Two entry windows and an SO2R box
LoggingSession mixed two things: the contest, which one station has one of, and what the operator is typing, which belongs to a radio. It is now ContestSession and RadioPosition. Two positions share one log, one score and one run of serial numbers, so a station worked on radio 1 is a dupe on radio 2. Frequency, mode, split, what is typed and whether you are running belong to each radio on its own. A second radio opens a second entry window. It has no menu of its own: it is another view of the same contest, not another program. Only the window for radio 1 opens the database and the connections. Ctrl+Tab moves the operator to the other radio and the keyboard follows. Ctrl+Shift+Tab puts both radios in the headphones. OtrspBox speaks OTRSP to an SO2R box over a serial port: TX1/TX2 for the key, RX1/RX2 and RX1S for the headphones, AUXnnn for an output line. It follows the active radio, and every message points the box at this radio before keying, so a message cannot go out of the radio the operator has just left. A command that would change nothing is not sent, because the box works relays; N1MM does the same. The box takes a Stream, so what it sends is tested without a serial port. Still missing: alternating CQ, and voice keying on the second radio. Looked at under Xvfb with two fake radios, one of them split: both windows up, radio 1 showing 14008.00 into 14020.0, radio 2 on 14030.00, Ctrl+Tab moving the keyboard between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,10 +19,12 @@ public sealed class AppSession : IDisposable
|
||||
{
|
||||
private LogStore? store;
|
||||
private readonly List<RigctldRadio> radios = [];
|
||||
private readonly List<RadioPosition> positions = [];
|
||||
private int activeRadio;
|
||||
private ClusterClient? cluster;
|
||||
private StationNetwork? network;
|
||||
private MessageSender? keyer;
|
||||
private So2rBox? box;
|
||||
|
||||
public AppSession(UserPaths paths, Settings settings)
|
||||
{
|
||||
@@ -49,7 +51,16 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public Bandmap Bandmap { get; } = new();
|
||||
|
||||
public LoggingSession? Logging { get; private set; }
|
||||
/// The contest in progress: one log and one score, however many radios.
|
||||
public ContestSession? Logging { get; private set; }
|
||||
|
||||
/// One per radio, in radio-number order. There is always at least one, so
|
||||
/// the program works with no radio connected.
|
||||
public IReadOnlyList<RadioPosition> Positions => positions;
|
||||
|
||||
/// The radio the operator is on.
|
||||
public RadioPosition? Position =>
|
||||
activeRadio < positions.Count ? positions[activeRadio] : null;
|
||||
|
||||
public CheckWindowSources? Check { get; private set; }
|
||||
|
||||
@@ -60,7 +71,7 @@ public sealed class AppSession : IDisposable
|
||||
/// frequency and mode stay wherever they were last typed.
|
||||
public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null;
|
||||
|
||||
public int ActiveRadioNumber => Radio?.Number ?? 1;
|
||||
public int ActiveRadioNumber => Position?.RadioNumber ?? 1;
|
||||
|
||||
public ClusterClient? Cluster => cluster;
|
||||
|
||||
@@ -68,6 +79,13 @@ public sealed class AppSession : IDisposable
|
||||
|
||||
public MessageSender? Keyer => keyer;
|
||||
|
||||
/// The SO2R box, or null when there is none and the operator switches the
|
||||
/// transmitter and the headphones by hand.
|
||||
public So2rBox? Box => box;
|
||||
|
||||
/// True while both radios are in the headphones.
|
||||
public bool IsListeningToBoth { get; private set; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public event EventHandler? ContestChanged;
|
||||
@@ -115,10 +133,13 @@ public sealed class AppSession : IDisposable
|
||||
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
||||
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
||||
SaveDefinition(contest);
|
||||
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries)
|
||||
Logging = new ContestSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
|
||||
positions.Clear();
|
||||
for (int number = 1; number <= PositionCount; number++)
|
||||
{
|
||||
RadioNumber = ActiveRadioNumber,
|
||||
};
|
||||
positions.Add(new RadioPosition(Logging, number));
|
||||
}
|
||||
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
|
||||
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
||||
@@ -126,11 +147,14 @@ public sealed class AppSession : IDisposable
|
||||
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
||||
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
||||
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
|
||||
Check = new CheckWindowSources(Logging, Calls, Bandmap);
|
||||
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
|
||||
Save(Settings with { ContestNumber = contestNumber });
|
||||
ContestChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// One entry position per radio, and one when there is no radio at all.
|
||||
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
|
||||
|
||||
/// Starts, restarts or stops the link to the other stations of a
|
||||
/// multi-operator entry, following what the settings now say.
|
||||
public void ApplyNetworkSettings()
|
||||
@@ -240,7 +264,12 @@ public sealed class AppSession : IDisposable
|
||||
radios.Add(opened);
|
||||
opened.Start();
|
||||
}
|
||||
activeRadio = Math.Min(activeRadio, Math.Max(0, radios.Count - 1));
|
||||
if (Logging is not null && positions.Count != PositionCount)
|
||||
{
|
||||
OpenContest(Logging.Instance.ContestNumber);
|
||||
return;
|
||||
}
|
||||
activeRadio = Math.Min(activeRadio, Math.Max(0, positions.Count - 1));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -254,34 +283,71 @@ public sealed class AppSession : IDisposable
|
||||
/// that radio is sitting, and contacts are logged against its number.
|
||||
public void SwapRadio()
|
||||
{
|
||||
if (radios.Count < 2)
|
||||
if (positions.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
activeRadio = (activeRadio + 1) % radios.Count;
|
||||
if (Logging is not null)
|
||||
{
|
||||
Logging.RadioNumber = ActiveRadioNumber;
|
||||
if (Radio?.State is { } state)
|
||||
{
|
||||
Logging.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
||||
}
|
||||
}
|
||||
activeRadio = (activeRadio + 1) % positions.Count;
|
||||
_ = FollowActiveRadioAsync();
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
|
||||
}
|
||||
|
||||
/// Puts both radios in the headphones, or goes back to one. An operator
|
||||
/// listens to the second radio while the first is sending.
|
||||
public void ToggleListenToBoth()
|
||||
{
|
||||
IsListeningToBoth = !IsListeningToBoth;
|
||||
_ = FollowActiveRadioAsync();
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Points the box at the radio about to transmit. Called before keying, so
|
||||
/// a message never goes out of the radio the operator has just left.
|
||||
public Task PointTransmitAtAsync(int radioNumber) =>
|
||||
box?.SetTransmitAsync(radioNumber) ?? Task.CompletedTask;
|
||||
|
||||
/// Starts, restarts or stops the SO2R box, following what the settings say.
|
||||
public void ApplySo2rSettings()
|
||||
{
|
||||
box?.Dispose();
|
||||
box = Settings.So2rBoxPort.Length > 0 ? OtrspBox.Open(Settings.So2rBoxPort) : null;
|
||||
_ = FollowActiveRadioAsync();
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private async Task FollowActiveRadioAsync()
|
||||
{
|
||||
if (box is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await box.SetTransmitAsync(ActiveRadioNumber).ConfigureAwait(false);
|
||||
await box.SetReceiveAsync(ActiveRadioNumber, IsListeningToBoth).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or InvalidOperationException)
|
||||
{
|
||||
box.Dispose();
|
||||
box = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Raised when the operator moves to the other radio, so the entry windows
|
||||
/// can hand the keyboard over and the SO2R box can follow.
|
||||
public event EventHandler<int>? ActiveRadioChanged;
|
||||
|
||||
/// The radio the operator is not on still moves, and the bandmap shows it,
|
||||
/// but it must not drag the entry window off the contact being worked.
|
||||
private void RadioMoved(Radio moved, RadioState state)
|
||||
{
|
||||
if (moved.Number == ActiveRadioNumber)
|
||||
RadioPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number);
|
||||
if (position is null)
|
||||
{
|
||||
Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
||||
}
|
||||
else
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
|
||||
}
|
||||
|
||||
private void DisposeRadios()
|
||||
@@ -332,6 +398,7 @@ public sealed class AppSession : IDisposable
|
||||
cluster?.Dispose();
|
||||
network?.Dispose();
|
||||
keyer?.Dispose();
|
||||
box?.Dispose();
|
||||
store?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ public sealed record Settings
|
||||
|
||||
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
||||
|
||||
/// The serial port of an SO2R box speaking OTRSP, or empty for none. The
|
||||
/// box routes the transmitter and the headphones between the two radios.
|
||||
public string So2rBoxPort { get; init; } = "";
|
||||
|
||||
/// `none`, `cwdaemon` or `winkeyer`.
|
||||
public string KeyerKind { get; init; } = "none";
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ public sealed partial class EditContactDialog : Window
|
||||
QsoField.CountryPrefix, QsoField.WpxPrefix,
|
||||
];
|
||||
|
||||
private readonly LoggingSession logging;
|
||||
private readonly ContestSession logging;
|
||||
private readonly Dictionary<QsoField, TextBox> boxes = [];
|
||||
private int at;
|
||||
|
||||
public EditContactDialog(LoggingSession logging, string contactId)
|
||||
public EditContactDialog(ContestSession logging, string contactId)
|
||||
{
|
||||
this.logging = logging;
|
||||
InitializeComponent();
|
||||
|
||||
@@ -23,8 +23,11 @@
|
||||
<CheckBox Grid.Row="4" Grid.Column="1" Name="SecondEnabledBox" Content="Follow this radio"
|
||||
Margin="0,2,0,0" />
|
||||
</Grid>
|
||||
<TextBlock Text="SO2R box serial port, or empty for none" FontSize="11" Opacity="0.7"
|
||||
Margin="0,10,0,1" />
|
||||
<TextBox Name="BoxPortBox" />
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" Margin="0,8,0,0"
|
||||
Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other." />
|
||||
Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other, and Ctrl+Shift+Tab puts both in the headphones. An OTRSP box follows, routing the key and the headphones." />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed partial class RadioDialog : Window
|
||||
InitializeComponent();
|
||||
Show(0, HostBox, PortBox, EnabledBox);
|
||||
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
|
||||
BoxPortBox.Text = settings.So2rBoxPort;
|
||||
}
|
||||
|
||||
private void Show(int at, TextBox host, TextBox port, CheckBox enabled)
|
||||
@@ -36,6 +37,7 @@ public sealed partial class RadioDialog : Window
|
||||
Read(HostBox, PortBox, EnabledBox),
|
||||
Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
|
||||
],
|
||||
So2rBoxPort = (BoxPortBox.Text ?? "").Trim(),
|
||||
});
|
||||
|
||||
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()
|
||||
|
||||
@@ -43,18 +43,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
public override void Refresh()
|
||||
{
|
||||
session.Bandmap.DropOlderThan(DateTime.UtcNow);
|
||||
Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
|
||||
Band? band = session.Position is null ? null : Bands.ForFrequency(session.Position.Frequency);
|
||||
BandText.Text = band?.Name ?? "no band";
|
||||
if (session.Logging is null || band is null)
|
||||
if (session.Position is null || band is null)
|
||||
{
|
||||
View.Spots = [];
|
||||
View.Vfos = [];
|
||||
StatusText.Text = session.Logging is null ? "no contest is open" : "off band";
|
||||
StatusText.Text = session.Position is null ? "no contest is open" : "off band";
|
||||
View.InvalidateVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
(Frequency low, Frequency high) = Window(band, session.Logging.Frequency);
|
||||
(Frequency low, Frequency high) = Window(band, session.Position.Frequency);
|
||||
View.Low = low;
|
||||
View.High = high;
|
||||
View.TickStep = StepFor(high.Hertz - low.Hertz);
|
||||
@@ -69,19 +69,19 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
/// station.
|
||||
private IReadOnlyList<VfoMarker> Vfos()
|
||||
{
|
||||
if (session.Logging is null)
|
||||
if (session.Position is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
Frequency width = Modes.Bandwidth(session.Logging.Mode);
|
||||
Frequency width = Modes.Bandwidth(session.Position.Mode);
|
||||
List<VfoMarker> markers =
|
||||
[
|
||||
VfoMarker.Centred(session.Logging.Frequency, width, VfoRole.Receive),
|
||||
VfoMarker.Centred(session.Position.Frequency, width, VfoRole.Receive),
|
||||
];
|
||||
if (session.Logging.TransmitFrequency.Hertz > 0)
|
||||
if (session.Position.TransmitFrequency.Hertz > 0)
|
||||
{
|
||||
markers.Add(VfoMarker.Centred(
|
||||
session.Logging.TransmitFrequency, width, VfoRole.Transmit));
|
||||
session.Position.TransmitFrequency, width, VfoRole.Transmit));
|
||||
}
|
||||
foreach (Radio other in session.Radios.Where(r => r.Number != session.ActiveRadioNumber))
|
||||
{
|
||||
@@ -119,18 +119,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
||||
|
||||
private Verdict? VerdictFor(Spot spot)
|
||||
{
|
||||
if (session.Logging is null)
|
||||
if (session.Position is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return session.Logging.Log.Judge(new Qso
|
||||
return session.Position.Log.Judge(new Qso
|
||||
{
|
||||
Id = "",
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Call = spot.Call,
|
||||
Frequency = spot.Frequency,
|
||||
Mode = session.Logging.Mode,
|
||||
ContestName = session.Logging.Contest.Name,
|
||||
Mode = session.Position.Mode,
|
||||
ContestName = session.Position.Contest.Name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ public sealed partial class EntryWindow
|
||||
Status("there is nothing in the log yet");
|
||||
return;
|
||||
}
|
||||
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this);
|
||||
await new EditContactDialog(Logging.Session, Logging.Log.Qsos[^1].Id).ShowDialog(this);
|
||||
}
|
||||
|
||||
/// Puts the call being typed on the cluster, or the last one logged when
|
||||
@@ -258,6 +258,15 @@ public sealed partial class EntryWindow
|
||||
}
|
||||
session.Save(updated);
|
||||
session.ConnectRadios();
|
||||
try
|
||||
{
|
||||
session.ApplySo2rSettings();
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
|
||||
{
|
||||
Status($"could not open the SO2R box: {error.Message}");
|
||||
return;
|
||||
}
|
||||
Status(session.Radios.Count switch
|
||||
{
|
||||
0 => "no radio",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</Window.Styles>
|
||||
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top">
|
||||
<Menu Name="MainMenu" DockPanel.Dock="Top">
|
||||
<MenuItem Header="_File">
|
||||
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
|
||||
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />
|
||||
|
||||
@@ -15,30 +15,54 @@ namespace Nonemm.App.Windows;
|
||||
public sealed partial class EntryWindow : Window
|
||||
{
|
||||
private readonly AppSession session;
|
||||
private readonly int radioNumber;
|
||||
private EntryWindow? secondRadio;
|
||||
private readonly List<TextBox> boxes = [];
|
||||
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly Dictionary<Type, Window> openWindows = [];
|
||||
private bool updating;
|
||||
|
||||
public EntryWindow(AppSession session)
|
||||
public EntryWindow(AppSession session, int radioNumber = 1)
|
||||
{
|
||||
this.session = session;
|
||||
this.radioNumber = radioNumber;
|
||||
InitializeComponent();
|
||||
BuildFunctionKeys();
|
||||
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
|
||||
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
BuildEntryBoxes();
|
||||
ShowSecondRadio();
|
||||
});
|
||||
clock.Tick += (_, _) => UpdateClock();
|
||||
clock.Start();
|
||||
session.ActiveRadioChanged += (_, number) =>
|
||||
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
|
||||
// only the window for radio 1 opens the log and the connections; the
|
||||
// second window is another view of the same session
|
||||
Opened += (_, _) => Reopen();
|
||||
if (radioNumber > 1)
|
||||
{
|
||||
Title = "Nonemm — radio 2";
|
||||
MainMenu.IsVisible = false;
|
||||
}
|
||||
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
|
||||
}
|
||||
|
||||
|
||||
private LoggingSession? Logging => session.Logging;
|
||||
/// Resolved every time rather than held, because opening a contest builds
|
||||
/// new positions.
|
||||
private RadioPosition? Logging =>
|
||||
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
|
||||
|
||||
/// Reopens the database and contest the operator was last in.
|
||||
private void Reopen()
|
||||
{
|
||||
if (radioNumber > 1)
|
||||
{
|
||||
BuildEntryBoxes();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
int contestNumber = session.Settings.ContestNumber;
|
||||
@@ -57,6 +81,42 @@ public sealed partial class EntryWindow : Window
|
||||
}
|
||||
StartConnections();
|
||||
BuildEntryBoxes();
|
||||
ShowSecondRadio();
|
||||
}
|
||||
|
||||
/// A second radio gets its own entry window: the two share one log and one
|
||||
/// score, but what is typed on each belongs to that radio.
|
||||
private void ShowSecondRadio()
|
||||
{
|
||||
if (radioNumber > 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool wanted = session.Positions.Count > 1;
|
||||
if (wanted && secondRadio is null)
|
||||
{
|
||||
Title = "Nonemm — radio 1";
|
||||
secondRadio = new EntryWindow(session, 2);
|
||||
secondRadio.Closed += (_, _) => secondRadio = null;
|
||||
secondRadio.Show(this);
|
||||
}
|
||||
else if (!wanted && secondRadio is not null)
|
||||
{
|
||||
Title = "Nonemm";
|
||||
secondRadio.Close();
|
||||
secondRadio = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard follows the operator to the other radio.
|
||||
private void FollowActiveRadio(int number)
|
||||
{
|
||||
if (number != radioNumber || Logging is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Activate();
|
||||
boxes.ElementAtOrDefault(Logging.Entry.Focus)?.Focus();
|
||||
}
|
||||
|
||||
/// Brings up whatever the operator had connected last time. Each is
|
||||
@@ -94,6 +154,10 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
yield return ("keyer", session.ApplyKeyerSettings);
|
||||
}
|
||||
if (session.Settings.So2rBoxPort.Length > 0)
|
||||
{
|
||||
yield return ("SO2R box", session.ApplySo2rSettings);
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildEntryBoxes()
|
||||
@@ -175,10 +239,15 @@ public sealed partial class EntryWindow : Window
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
break;
|
||||
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control)
|
||||
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift):
|
||||
e.Handled = true;
|
||||
session.ToggleListenToBoth();
|
||||
Status(session.IsListeningToBoth ? "both radios" : $"radio {session.ActiveRadioNumber}");
|
||||
break;
|
||||
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
session.SwapRadio();
|
||||
Status($"radio {session.ActiveRadioNumber}");
|
||||
break;
|
||||
case Key.Tab when FocusedIsEntryBox():
|
||||
e.Handled = true;
|
||||
@@ -392,6 +461,8 @@ public sealed partial class EntryWindow : Window
|
||||
return;
|
||||
}
|
||||
string text = MessageExpander.Expand(template, Logging);
|
||||
// the box has to point at this radio before the key does
|
||||
_ = session.PointTransmitAtAsync(radioNumber);
|
||||
Status($"sending {text}");
|
||||
_ = SendAsync(text);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed partial class LogWindow : RefreshableWindow
|
||||
message = "";
|
||||
}
|
||||
|
||||
private static string Summary(LoggingSession logging) =>
|
||||
private static string Summary(ContestSession logging) =>
|
||||
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
|
||||
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user