Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and exchanges window messages with the engine. A Linux process cannot load that control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes lines over its standard input and output. docs/digital-bridge.md states the protocol and what the control needs. The window is N1MM's: receive pane with coloured callsigns, grab list, call stacking, twenty-four macro buttons and the engine controls. One left click copies what is under it — a callsign to the callsign box, anything else to the exchange box the contest keeps for that kind of value. Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the prefix first if it is not there. The engine, the bridge and the control start at the copies shipped beside the program. The bridge reads the control's own events. OnTranslateMessage carries only the messages the control has no event for, so nothing was ever decoded through it. hamlib's data mode names read as digital modes now, and a mode typed into the callsign box changes mode the way a frequency changes band, so a station with no radio can reach RTTY at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The calls heard and what the pointer does to them: the grab list beside the
|
||||
/// transmit pane, and the clicking and hovering in the receive pane.
|
||||
public sealed partial class DigitalWindow
|
||||
{
|
||||
/// A callsign the modem decoded goes in the grab list, unless it is ours,
|
||||
/// the one already being typed, or the filter turns it away.
|
||||
private void Heard(string call)
|
||||
{
|
||||
if (Radio is not { } position
|
||||
|| !GrabList.Wanted(call, position.Me.Callsign, position.Entry.Call)
|
||||
|| !PassesFilter(position, call))
|
||||
{
|
||||
return;
|
||||
}
|
||||
grab.NewestFirst = session.Settings.DigitalGrabNewestFirst;
|
||||
if (grab.Add(call))
|
||||
{
|
||||
ShowGrabList();
|
||||
}
|
||||
}
|
||||
|
||||
private bool PassesFilter(OperatingPosition position, string call) =>
|
||||
session.Settings.DigitalGrabFilter.ToLowerInvariant() switch
|
||||
{
|
||||
"notdupes" => position.JudgeCall(call)?.IsDupe != true,
|
||||
"knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
private void ShowGrabList()
|
||||
{
|
||||
GrabCalls.Children.Clear();
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (string call in grab.Calls)
|
||||
{
|
||||
GrabCalls.Children.Add(GrabRow(position, call));
|
||||
}
|
||||
}
|
||||
|
||||
private Control GrabRow(OperatingPosition position, string call)
|
||||
{
|
||||
TextBlock text = new()
|
||||
{
|
||||
Text = call,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
FontSize = session.Settings.DigitalFontSize,
|
||||
Foreground = Verdicts.Colour(position.JudgeCall(call)),
|
||||
};
|
||||
Border row = new()
|
||||
{
|
||||
Padding = new Thickness(4, 1),
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
// a border with no background of its own is not clickable away from
|
||||
// the letters, and the whole row is the target
|
||||
Background = Brushes.Transparent,
|
||||
Child = text,
|
||||
};
|
||||
row.PointerPressed += (_, e) => GrabFromList(call, e);
|
||||
return row;
|
||||
}
|
||||
|
||||
/// Left click grabs the call, right click drops it from the list, which is
|
||||
/// what N1MM's grab window does.
|
||||
private void GrabFromList(string call, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
grab.Remove(call);
|
||||
ShowGrabList();
|
||||
return;
|
||||
}
|
||||
Grab(call);
|
||||
}
|
||||
|
||||
/// Puts a call in the entry window. With call stacking on and a call
|
||||
/// already being typed, the one being typed goes on the stack first, so
|
||||
/// neither is lost — N1MM's digital call stacking.
|
||||
private void Grab(string call)
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
position.Stack.Order = StackingOrder();
|
||||
if (position.Stack.Order != StackOrder.Disabled
|
||||
&& position.Entry.Call.Trim().Length > 0)
|
||||
{
|
||||
position.StackAnother();
|
||||
}
|
||||
grab.Remove(call);
|
||||
ShowGrabList();
|
||||
entry.GrabCall(call, session.Settings.DigitalGrabSendsSpace);
|
||||
entry.Refresh();
|
||||
}
|
||||
|
||||
/// N1MM's Grab button: the next call off the list.
|
||||
private void OnGrabNext(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (grab.Count > 0)
|
||||
{
|
||||
Grab(grab.Calls[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
|
||||
{
|
||||
Unhighlight();
|
||||
return;
|
||||
}
|
||||
if (ReferenceEquals(block, hovered))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Unhighlight();
|
||||
hovered = block;
|
||||
block.TextDecorations = TextDecorations.Underline;
|
||||
MouseOverText.Text = DigitalReceiver.Scrub(text);
|
||||
}
|
||||
|
||||
private void OnReceivePointerExited(object? sender, PointerEventArgs e)
|
||||
{
|
||||
Unhighlight();
|
||||
Resume();
|
||||
}
|
||||
|
||||
private void Unhighlight()
|
||||
{
|
||||
if (hovered is not null)
|
||||
{
|
||||
hovered.TextDecorations = null;
|
||||
hovered = null;
|
||||
}
|
||||
MouseOverText.Text = "";
|
||||
}
|
||||
|
||||
/// One left click copies the word under the pointer, the way N1MM does: a
|
||||
/// callsign goes in the callsign box, anything else in the exchange box the
|
||||
/// contest keeps for it. A click on nothing pauses the pane instead, so
|
||||
/// text can be read while more arrives. A right click presses Enter in the
|
||||
/// entry window when that option is on.
|
||||
private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
RightClick();
|
||||
return;
|
||||
}
|
||||
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
|
||||
if (clicked.Length == 0)
|
||||
{
|
||||
paused = true;
|
||||
return;
|
||||
}
|
||||
Resume();
|
||||
Copy(clicked);
|
||||
}
|
||||
|
||||
/// Where a clicked word goes. N1MM's order: an empty callsign box takes a
|
||||
/// call, a callsign box that already holds one takes another only onto the
|
||||
/// call stack, and everything else is an exchange element.
|
||||
private void Copy(string clicked)
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = DigitalReceiver.TrimToCall(clicked);
|
||||
bool isCall = call.Length is >= 3 and < 13
|
||||
&& !call.All(char.IsAsciiDigit)
|
||||
&& DigitalReceiver.IsCallsign(call)
|
||||
&& !string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase);
|
||||
bool waiting = position.Entry.Call.Trim().Length == 0;
|
||||
if (isCall && (waiting || StackingOrder() != StackOrder.Disabled))
|
||||
{
|
||||
Grab(call);
|
||||
return;
|
||||
}
|
||||
if (!waiting)
|
||||
{
|
||||
entry.GrabExchange(DigitalElement.Trim(clicked));
|
||||
}
|
||||
}
|
||||
|
||||
private void RightClick()
|
||||
{
|
||||
if (!session.Settings.DigitalRightClickSendsEnter)
|
||||
{
|
||||
return;
|
||||
}
|
||||
entry.PressEnter();
|
||||
if (!session.Settings.DigitalRightClickKeepsCall && Radio is { } position)
|
||||
{
|
||||
position.Wipe();
|
||||
entry.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the pane scroll again and prints what arrived while it was stopped.
|
||||
private void Resume()
|
||||
{
|
||||
if (!paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
paused = false;
|
||||
if (held.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string waiting = held.ToString();
|
||||
held.Clear();
|
||||
Print(waiting);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Dialogs;
|
||||
using Nonemm.Digital;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The macro buttons and the transmit pane: what goes out, and the engine
|
||||
/// controls beside them.
|
||||
public sealed partial class DigitalWindow
|
||||
{
|
||||
private const int MacroColumns = 8;
|
||||
|
||||
private readonly List<Button> macroButtons = [];
|
||||
|
||||
private DigitalMacros Macros => Messages.Digital(session.Settings.DigitalMessageFile);
|
||||
|
||||
/// Three rows of eight, which is how N1MM lays its digital macros out.
|
||||
private void BuildMacros()
|
||||
{
|
||||
for (int row = 0; row < DigitalMacros.ButtonCount / MacroColumns; row++)
|
||||
{
|
||||
UniformGrid grid = new() { Columns = MacroColumns, Rows = 1 };
|
||||
Grid.SetRow(grid, row);
|
||||
for (int column = 0; column < MacroColumns; column++)
|
||||
{
|
||||
int index = (row * MacroColumns) + column;
|
||||
Button button = new() { Classes = { "macro" }, Tag = index };
|
||||
button.Click += (_, _) => SendMacro(index);
|
||||
button.AddHandler(PointerPressedEvent, OnMacroPointerPressed, RoutingStrategies.Tunnel);
|
||||
macroButtons.Add(button);
|
||||
grid.Children.Add(button);
|
||||
}
|
||||
MacroRows.Children.Add(grid);
|
||||
}
|
||||
}
|
||||
|
||||
private void LabelMacros()
|
||||
{
|
||||
DigitalMacros macros = Macros;
|
||||
for (int index = 0; index < macroButtons.Count; index++)
|
||||
{
|
||||
FunctionKey macro = macros[index];
|
||||
macroButtons[index].Content = macro.Label.Length > 0 ? macro.Label : $"{index + 1}";
|
||||
macroButtons[index].IsEnabled = macro.Label.Length > 0 || macro.Message.Length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-clicking a macro button opens the macros for editing, the way
|
||||
/// right-clicking a function key does in the entry window.
|
||||
private void OnMacroPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = EditMacrosAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// A macro goes out through the entry window, so the action macros in it
|
||||
/// act on the contact being worked. The text goes to this window's own
|
||||
/// engine whatever mode the entry window is on: these buttons are the
|
||||
/// engine's, the way N1MM's digital window keys MMTTY from its own buttons.
|
||||
private async void SendMacro(int index)
|
||||
{
|
||||
string message = Macros[index].Message;
|
||||
if (message.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Running() is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (session.DigitalKeyer is not { IsReady: true } keyer)
|
||||
{
|
||||
StateText.Text = "the engine is not connected";
|
||||
return;
|
||||
}
|
||||
if (!await entry.SendTextAsync(message, keyer))
|
||||
{
|
||||
StateText.Text = "nothing was sent — see the entry window";
|
||||
}
|
||||
entry.Refresh();
|
||||
}
|
||||
|
||||
private async Task EditMacrosAsync()
|
||||
{
|
||||
if (await new DigitalMacrosDialog(session.Settings.DigitalMessageFile)
|
||||
.ShowDialog<string?>(this) is not { } edited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with { DigitalMessageFile = edited });
|
||||
LabelMacros();
|
||||
}
|
||||
|
||||
/// What is typed in the transmit pane goes out as it is typed, which is how
|
||||
/// N1MM's window works: the engine transmits each character as it arrives.
|
||||
private void OnTransmitKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (engine is not { IsConnected: true } running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = running.TypeAsync('\r');
|
||||
TransmitBox.Text += "\n";
|
||||
TransmitBox.CaretIndex = TransmitBox.Text.Length;
|
||||
return;
|
||||
}
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = running.AbortAsync();
|
||||
TransmitBox.Text = "";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTransmit(object? sender, RoutedEventArgs e) => _ = Running()?.SetPttAsync(true);
|
||||
|
||||
private void OnReceive(object? sender, RoutedEventArgs e) => _ = Running()?.AbortAsync();
|
||||
|
||||
private void OnClearTransmit(object? sender, RoutedEventArgs e) => TransmitBox.Text = "";
|
||||
|
||||
private void OnClearReceive(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
ReceiveLines.Children.Clear();
|
||||
line = null;
|
||||
word = null;
|
||||
held.Clear();
|
||||
paused = false;
|
||||
receiver.Clear();
|
||||
}
|
||||
|
||||
/// Puts the demodulator back on the tone pair the operator works on.
|
||||
private void OnAlign(object? sender, RoutedEventArgs e) =>
|
||||
_ = Running()?.TuneAsync(session.Settings.DigitalMarkHertz);
|
||||
|
||||
private void OnHamDefault(object? sender, RoutedEventArgs e) =>
|
||||
_ = Running()?.PostAsync(MmttyMessage.DefaultProfile, 0);
|
||||
|
||||
/// MMTTY's own setup dialog. The engine opens it beside its own window, so
|
||||
/// it comes up wherever the engine window is.
|
||||
private void OnEngineSetup(object? sender, RoutedEventArgs e) =>
|
||||
_ = Running()?.PostAsync(MmttyMessage.Setup, 0);
|
||||
|
||||
private void OnToggleLock(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.AfcBit);
|
||||
|
||||
private void OnToggleReverse(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.ReverseBit);
|
||||
|
||||
/// The engine takes its switches as one word, so a toggle sends the whole
|
||||
/// word back with one bit flipped.
|
||||
private void Toggle(int bit)
|
||||
{
|
||||
if (Running() is not { } running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_ = running.SetSwitchesAsync(running.Switches ^ bit);
|
||||
ShowState();
|
||||
}
|
||||
|
||||
/// The engine, or null with the reason in the status line. A menu item or
|
||||
/// button that needs the engine says why nothing happened rather than
|
||||
/// doing nothing at all.
|
||||
private MmttyEngine? Running()
|
||||
{
|
||||
if (engine is null)
|
||||
{
|
||||
StateText.Text = "the engine is not running — Engine ▸ Start";
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Dialogs;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// The window's menus: what the engine does, and N1MM's options for how the
|
||||
/// receive pane and the grab list behave.
|
||||
public sealed partial class DigitalWindow
|
||||
{
|
||||
/// N1MM caps the receive pane's font at fourteen points.
|
||||
private const int LargestFont = 14;
|
||||
|
||||
private const int SmallestFont = 8;
|
||||
|
||||
private void ApplySettings()
|
||||
{
|
||||
Settings settings = session.Settings;
|
||||
HighlightBackgroundItem.IsChecked = settings.DigitalHighlightBackground;
|
||||
GrabSpaceItem.IsChecked = settings.DigitalGrabSendsSpace;
|
||||
RightClickEnterItem.IsChecked = settings.DigitalRightClickSendsEnter;
|
||||
RightClickKeepsCallItem.IsChecked = settings.DigitalRightClickKeepsCall;
|
||||
GrabNewestFirstItem.IsChecked = settings.DigitalGrabNewestFirst;
|
||||
string filter = settings.DigitalGrabFilter.ToLowerInvariant();
|
||||
GrabEverythingItem.IsChecked = filter is not ("notdupes" or "knowncalls");
|
||||
GrabNotDupesItem.IsChecked = filter == "notdupes";
|
||||
GrabKnownItem.IsChecked = filter == "knowncalls";
|
||||
string stacking = settings.DigitalCallStacking.ToLowerInvariant();
|
||||
StackMultipliersItem.IsChecked = stacking == "multipliers";
|
||||
StackFirstInItem.IsChecked = stacking == "firstin";
|
||||
StackLastInItem.IsChecked = stacking == "lastin";
|
||||
StackDisabledItem.IsChecked = stacking is not ("multipliers" or "firstin" or "lastin");
|
||||
grab.NewestFirst = settings.DigitalGrabNewestFirst;
|
||||
}
|
||||
|
||||
private void Store(Settings settings)
|
||||
{
|
||||
session.Save(settings);
|
||||
ApplySettings();
|
||||
ShowGrabList();
|
||||
}
|
||||
|
||||
private void OnStartEngine(object? sender, RoutedEventArgs e) => _ = StartAsync();
|
||||
|
||||
private void OnStopEngine(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Detach();
|
||||
session.StopDigital();
|
||||
ShowState();
|
||||
}
|
||||
|
||||
private async void OnEngineSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (await new DigitalDialog(session.Settings).ShowDialog<Settings?>(this) is not { } edited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Store(edited);
|
||||
ShowState();
|
||||
}
|
||||
|
||||
private void OnEditMacros(object? sender, RoutedEventArgs e) => _ = EditMacrosAsync();
|
||||
|
||||
private void OnLargerFont(object? sender, RoutedEventArgs e) => Resize(1);
|
||||
|
||||
private void OnSmallerFont(object? sender, RoutedEventArgs e) => Resize(-1);
|
||||
|
||||
private void Resize(int step)
|
||||
{
|
||||
int wanted = Math.Clamp(session.Settings.DigitalFontSize + step, SmallestFont, LargestFont);
|
||||
Store(session.Settings with { DigitalFontSize = wanted });
|
||||
foreach (var lineOfText in ReceiveLines.Children.OfType<Avalonia.Controls.WrapPanel>())
|
||||
{
|
||||
foreach (var block in lineOfText.Children.OfType<Avalonia.Controls.TextBlock>())
|
||||
{
|
||||
block.FontSize = wanted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnToggleHighlight(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalHighlightBackground = !session.Settings.DigitalHighlightBackground,
|
||||
});
|
||||
|
||||
private void OnToggleGrabSpace(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalGrabSendsSpace = !session.Settings.DigitalGrabSendsSpace,
|
||||
});
|
||||
|
||||
private void OnToggleRightClick(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalRightClickSendsEnter = !session.Settings.DigitalRightClickSendsEnter,
|
||||
});
|
||||
|
||||
private void OnToggleRightClickKeepsCall(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalRightClickKeepsCall = !session.Settings.DigitalRightClickKeepsCall,
|
||||
});
|
||||
|
||||
private void OnToggleGrabOrder(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalGrabNewestFirst = !session.Settings.DigitalGrabNewestFirst,
|
||||
});
|
||||
|
||||
private void OnGrabEverything(object? sender, RoutedEventArgs e) => Filter("everything");
|
||||
|
||||
private void OnGrabNotDupes(object? sender, RoutedEventArgs e) => Filter("notdupes");
|
||||
|
||||
private void OnGrabKnown(object? sender, RoutedEventArgs e) => Filter("knowncalls");
|
||||
|
||||
private void Filter(string which) =>
|
||||
Store(session.Settings with { DigitalGrabFilter = which });
|
||||
|
||||
private void OnClearGrabList(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
grab.Clear();
|
||||
ShowGrabList();
|
||||
}
|
||||
|
||||
private void OnStackMultipliers(object? sender, RoutedEventArgs e) => Stacking("multipliers");
|
||||
|
||||
private void OnStackFirstIn(object? sender, RoutedEventArgs e) => Stacking("firstin");
|
||||
|
||||
private void OnStackLastIn(object? sender, RoutedEventArgs e) => Stacking("lastin");
|
||||
|
||||
private void OnStackDisabled(object? sender, RoutedEventArgs e) => Stacking("disabled");
|
||||
|
||||
private void Stacking(string which)
|
||||
{
|
||||
Store(session.Settings with { DigitalCallStacking = which });
|
||||
if (Radio is { } position)
|
||||
{
|
||||
position.Stack.Order = StackingOrder();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.DigitalWindow"
|
||||
Title="Digital Interface" Width="900" Height="640">
|
||||
<Window.Styles>
|
||||
<Style Selector="Button.macro">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Padding" Value="2,3" />
|
||||
<Setter Property="Margin" Value="1" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.control">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Padding" Value="8,3" />
|
||||
<Setter Property="Margin" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.status">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<DockPanel>
|
||||
<Menu DockPanel.Dock="Top" Name="MenuBar">
|
||||
<MenuItem Header="_Engine">
|
||||
<MenuItem Header="Start" Click="OnStartEngine" />
|
||||
<MenuItem Header="Stop" Click="OnStopEngine" />
|
||||
<Separator />
|
||||
<MenuItem Header="MMTTY setup window" Click="OnEngineSetup" />
|
||||
<MenuItem Header="Ham default profile" Click="OnHamDefault" />
|
||||
<MenuItem Header="Align to mark tone" Click="OnAlign" />
|
||||
<Separator />
|
||||
<MenuItem Header="Settings…" Click="OnEngineSettings" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Setup">
|
||||
<MenuItem Header="Macros…" Click="OnEditMacros" />
|
||||
<Separator />
|
||||
<MenuItem Header="Larger font" Click="OnLargerFont" />
|
||||
<MenuItem Header="Smaller font" Click="OnSmallerFont" />
|
||||
<Separator />
|
||||
<MenuItem Header="Clear receive pane" Click="OnClearReceive" />
|
||||
<MenuItem Header="Clear transmit pane" Click="OnClearTransmit" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Options">
|
||||
<MenuItem Name="HighlightBackgroundItem" Header="Highlight the background"
|
||||
ToggleType="CheckBox" Click="OnToggleHighlight" />
|
||||
<MenuItem Name="GrabSpaceItem" Header="A grabbed call is followed by a space"
|
||||
ToggleType="CheckBox" Click="OnToggleGrabSpace" />
|
||||
<Separator />
|
||||
<MenuItem Name="RightClickEnterItem" Header="Right click presses Enter"
|
||||
ToggleType="CheckBox" Click="OnToggleRightClick" />
|
||||
<MenuItem Name="RightClickKeepsCallItem" Header="Right click keeps the call"
|
||||
ToggleType="CheckBox" Click="OnToggleRightClickKeepsCall" />
|
||||
<Separator />
|
||||
<MenuItem Header="Grab list">
|
||||
<MenuItem Name="GrabEverythingItem" Header="Everything heard"
|
||||
ToggleType="Radio" GroupName="grab" Click="OnGrabEverything" />
|
||||
<MenuItem Name="GrabNotDupesItem" Header="Not dupes"
|
||||
ToggleType="Radio" GroupName="grab" Click="OnGrabNotDupes" />
|
||||
<MenuItem Name="GrabKnownItem" Header="Known calls only"
|
||||
ToggleType="Radio" GroupName="grab" Click="OnGrabKnown" />
|
||||
<Separator />
|
||||
<MenuItem Name="GrabNewestFirstItem" Header="Newest first"
|
||||
ToggleType="CheckBox" Click="OnToggleGrabOrder" />
|
||||
<MenuItem Header="Clear the list" Click="OnClearGrabList" />
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Call _stacking">
|
||||
<MenuItem Name="StackMultipliersItem" Header="Multipliers first"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackMultipliers" />
|
||||
<MenuItem Name="StackFirstInItem" Header="First in, first out"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackFirstIn" />
|
||||
<MenuItem Name="StackLastInItem" Header="Last in, first out"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackLastIn" />
|
||||
<MenuItem Name="StackDisabledItem" Header="Off"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackDisabled" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="Auto,*,Auto,Auto" Margin="6,2,6,4">
|
||||
<TextBlock Classes="status" Name="MouseOverText" MinWidth="120" />
|
||||
<TextBlock Grid.Column="1" Classes="status" Name="StateText" HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="status" Name="FigsText" Text="Letters" Margin="0,0,10,0" />
|
||||
<Border Grid.Column="3" Name="TransmitDot" CornerRadius="7" Width="30" Height="16"
|
||||
Background="Transparent" BorderThickness="1">
|
||||
<TextBlock Text="TX" FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="4,0,4,2">
|
||||
<Grid Name="MacroRows" RowDefinitions="Auto,Auto,Auto" />
|
||||
<WrapPanel Margin="0,3,0,0">
|
||||
<Button Classes="control" Content="Clr RX" Click="OnClearReceive" />
|
||||
<Button Classes="control" Content="Align" Click="OnAlign" />
|
||||
<Button Classes="control" Name="TransmitButton" Content="TX" Click="OnTransmit" />
|
||||
<Button Classes="control" Content="RX" Click="OnReceive" />
|
||||
<Button Classes="control" Content="Ham" Click="OnHamDefault" />
|
||||
<Button Classes="control" Name="LockButton" Content="Lock" Click="OnToggleLock" />
|
||||
<Button Classes="control" Name="ReverseButton" Content="Rev" Click="OnToggleReverse" />
|
||||
<Button Classes="control" Content="CLR" Click="OnClearTransmit" />
|
||||
<Button Classes="control" Content="Grab" Click="OnGrabNext" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid RowDefinitions="3*,Auto,*" Margin="4,2">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||
<ScrollViewer Name="ReceiveScroller" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Name="ReceiveLines" Margin="4,2"
|
||||
PointerMoved="OnReceivePointerMoved"
|
||||
PointerPressed="OnReceivePointerPressed"
|
||||
PointerExited="OnReceivePointerExited" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Row="1" Height="4" ResizeDirection="Rows" Background="Transparent" />
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto,150">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||
<TextBox Name="TransmitBox" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
FontFamily="monospace" BorderThickness="0"
|
||||
KeyDown="OnTransmitKeyDown" />
|
||||
</Border>
|
||||
<Border Grid.Column="2" BorderThickness="1" BorderBrush="#40808080">
|
||||
<ScrollViewer Name="GrabScroller">
|
||||
<StackPanel Name="GrabCalls" Margin="3,2" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</local:RefreshableWindow>
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.App.Theming;
|
||||
using Nonemm.Digital;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
|
||||
/// N1MM's digital interface window: what the modem decodes, what is about to go
|
||||
/// out, the calls heard, and the macro buttons. Every callsign in the receive
|
||||
/// pane is coloured by what working it would bring, and double-clicking one
|
||||
/// puts it in the entry window.
|
||||
public sealed partial class DigitalWindow : RefreshableWindow
|
||||
{
|
||||
/// How many lines of decoded text are kept in the pane. A contest weekend
|
||||
/// of RTTY is more than any window needs to hold.
|
||||
private const int KeptLines = 300;
|
||||
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(45);
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly EntryWindow entry;
|
||||
private readonly int radioNumber;
|
||||
private readonly DigitalReceiver receiver = new();
|
||||
private readonly GrabList grab = new();
|
||||
|
||||
/// What arrived while the pane was paused, held until it starts again.
|
||||
private readonly StringBuilder held = new();
|
||||
|
||||
private WrapPanel? line;
|
||||
private TextBlock? word;
|
||||
private TextBlock? hovered;
|
||||
private MmttyEngine? engine;
|
||||
private bool paused;
|
||||
private bool lastWasReturn;
|
||||
|
||||
public DigitalWindow(AppSession session, EntryWindow entry, int radioNumber = 1)
|
||||
{
|
||||
this.session = session;
|
||||
this.entry = entry;
|
||||
this.radioNumber = radioNumber;
|
||||
InitializeComponent();
|
||||
receiver.CallHeard += (_, call) => Dispatcher.UIThread.Post(() => Heard(call));
|
||||
BuildMacros();
|
||||
ApplySettings();
|
||||
Attach(session.Digital);
|
||||
Refresh();
|
||||
Closed += (_, _) => Detach();
|
||||
}
|
||||
|
||||
private OperatingPosition? Radio =>
|
||||
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
ShowState();
|
||||
ShowGrabList();
|
||||
LabelMacros();
|
||||
}
|
||||
|
||||
/// Starts the engine if it is not running, and connects this window to it.
|
||||
private async Task StartAsync()
|
||||
{
|
||||
if (session.Settings.DigitalEnginePath.Trim().Length == 0
|
||||
|| session.Settings.DigitalBridgePath.Trim().Length == 0)
|
||||
{
|
||||
StateText.Text = "no engine — Engine ▸ Settings";
|
||||
return;
|
||||
}
|
||||
StateText.Text = "starting the engine…";
|
||||
try
|
||||
{
|
||||
Attach(await session.StartDigitalAsync().WaitAsync(Patience));
|
||||
}
|
||||
catch (Exception failure) when (failure is InvalidOperationException or TimeoutException)
|
||||
{
|
||||
StateText.Text = failure.Message;
|
||||
return;
|
||||
}
|
||||
ShowState();
|
||||
}
|
||||
|
||||
private void Attach(MmttyEngine? started)
|
||||
{
|
||||
if (started is null || ReferenceEquals(started, engine))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Detach();
|
||||
engine = started;
|
||||
started.Received += WhenReceived;
|
||||
started.TransmitChanged += WhenTransmitChanged;
|
||||
started.ConnectionChanged += WhenConnectionChanged;
|
||||
started.Reported += WhenReported;
|
||||
}
|
||||
|
||||
private void Detach()
|
||||
{
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
engine.Received -= WhenReceived;
|
||||
engine.TransmitChanged -= WhenTransmitChanged;
|
||||
engine.ConnectionChanged -= WhenConnectionChanged;
|
||||
engine.Reported -= WhenReported;
|
||||
engine = null;
|
||||
}
|
||||
|
||||
private void WhenReceived(object? sender, string text) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
receiver.Receive(text);
|
||||
Print(text);
|
||||
});
|
||||
|
||||
private void WhenTransmitChanged(object? sender, bool transmitting) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
TransmitDot.Background = transmitting
|
||||
? Themes.Brush(Themes.Current.TransmitLight)
|
||||
: Brushes.Transparent;
|
||||
});
|
||||
|
||||
private void WhenConnectionChanged(object? sender, bool connected) =>
|
||||
Dispatcher.UIThread.Post(ShowState);
|
||||
|
||||
private void WhenReported(object? sender, string text) =>
|
||||
Dispatcher.UIThread.Post(() => StateText.Text = text);
|
||||
|
||||
/// N1MM titles the window with where the radio is and what is decoding,
|
||||
/// which is how an operator with two of them tells them apart.
|
||||
private void ShowTitle()
|
||||
{
|
||||
string radio = $"DI{radioNumber}";
|
||||
string mode = Radio?.Mode.Name ?? "RTTY";
|
||||
string frequency = Radio is { } position
|
||||
? position.Frequency.Kilohertz.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
: "";
|
||||
string what = session.Settings.DigitalEnginePath.Trim().Length > 0
|
||||
? Path.GetFileNameWithoutExtension(session.Settings.DigitalEnginePath)
|
||||
: "no engine";
|
||||
Title = $"{frequency} {radio} {mode} Mode - {what}";
|
||||
}
|
||||
|
||||
private void ShowState()
|
||||
{
|
||||
ShowTitle();
|
||||
StateText.Text = engine switch
|
||||
{
|
||||
{ IsConnected: true } running =>
|
||||
$"{Path.GetFileName(session.Settings.DigitalEnginePath)} {running.Version}"
|
||||
+ $" · mark {running.MarkHertz} Hz",
|
||||
not null => "the engine is not connected",
|
||||
_ => "the engine is not running",
|
||||
};
|
||||
LockButton.FontWeight = IsSwitchOn(MmttySettings.AfcBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
ReverseButton.FontWeight = IsSwitchOn(MmttySettings.ReverseBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
}
|
||||
|
||||
private bool IsSwitchOn(int bit) => engine is { } running && (running.Switches & bit) != 0;
|
||||
|
||||
/// Prints what the modem decoded, one word at a time so each word is its
|
||||
/// own control and can be coloured, pointed at and clicked.
|
||||
private void Print(string text)
|
||||
{
|
||||
if (paused)
|
||||
{
|
||||
held.Append(text);
|
||||
return;
|
||||
}
|
||||
foreach (char c in text)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '\r':
|
||||
NewLine();
|
||||
lastWasReturn = true;
|
||||
continue;
|
||||
// a modem that sends both ends one line, not two
|
||||
case '\n' when lastWasReturn:
|
||||
lastWasReturn = false;
|
||||
continue;
|
||||
case '\n':
|
||||
NewLine();
|
||||
continue;
|
||||
case ' ':
|
||||
lastWasReturn = false;
|
||||
EndWord();
|
||||
Line().Children.Add(Space());
|
||||
continue;
|
||||
default:
|
||||
lastWasReturn = false;
|
||||
Word().Text += c;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
TrimLines();
|
||||
ReceiveScroller.ScrollToEnd();
|
||||
}
|
||||
|
||||
private void NewLine()
|
||||
{
|
||||
EndWord();
|
||||
line = null;
|
||||
}
|
||||
|
||||
private WrapPanel Line()
|
||||
{
|
||||
if (line is null)
|
||||
{
|
||||
line = new WrapPanel();
|
||||
ReceiveLines.Children.Add(line);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private TextBlock Word()
|
||||
{
|
||||
if (word is null)
|
||||
{
|
||||
word = NewBlock("");
|
||||
Line().Children.Add(word);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
private TextBlock Space() => NewBlock(" ");
|
||||
|
||||
private TextBlock NewBlock(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
FontSize = session.Settings.DigitalFontSize,
|
||||
};
|
||||
|
||||
/// A finished word is judged: one shaped like a callsign is coloured the
|
||||
/// way every other window colours a station.
|
||||
private void EndWord()
|
||||
{
|
||||
if (word is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(word.Text ?? ""));
|
||||
if (DigitalReceiver.IsCallsign(call))
|
||||
{
|
||||
Colour(word, call);
|
||||
}
|
||||
word = null;
|
||||
}
|
||||
|
||||
private void Colour(TextBlock block, string call)
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (string.Equals(call, position.Me.Callsign, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
block.Foreground = Verdicts.MyCall;
|
||||
return;
|
||||
}
|
||||
Contests.Verdict? verdict = position.JudgeCall(call);
|
||||
if (session.Settings.DigitalHighlightBackground)
|
||||
{
|
||||
block.Background = Verdicts.Background(verdict);
|
||||
block.Foreground = Themes.TextOn(Verdicts.BackgroundColour(verdict));
|
||||
return;
|
||||
}
|
||||
block.Foreground = Verdicts.Colour(verdict);
|
||||
}
|
||||
|
||||
private void TrimLines()
|
||||
{
|
||||
while (ReceiveLines.Children.Count > KeptLines)
|
||||
{
|
||||
ReceiveLines.Children.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private StackOrder StackingOrder() =>
|
||||
session.Settings.DigitalCallStacking.ToLowerInvariant() switch
|
||||
{
|
||||
"multipliers" => StackOrder.MultipliersFirst,
|
||||
"firstin" => StackOrder.FirstIn,
|
||||
"lastin" => StackOrder.LastIn,
|
||||
_ => StackOrder.Disabled,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,23 @@ public sealed partial class EntryWindow
|
||||
/// actions after `{END}` still run.
|
||||
private static readonly TimeSpan SendingPatience = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// What sends this radio's messages: the digital engine on a digital mode,
|
||||
/// and the CW or voice keyer otherwise. A message therefore goes out the
|
||||
/// way the mode needs without the caller choosing.
|
||||
private MessageSender? Sender =>
|
||||
Logging?.Mode.Category == ModeCategory.Digital && session.DigitalKeyer is { IsReady: true } engine
|
||||
? engine
|
||||
: session.Keyer;
|
||||
|
||||
/// What to say when a message has nowhere to go. The mode decides which
|
||||
/// keyer a message uses, so the mode is part of the answer: an operator on
|
||||
/// a data mode who sees "no keyer for USB" knows to move the radio or the
|
||||
/// band panel rather than to look at the keyer settings.
|
||||
private string NoKeyer() =>
|
||||
Logging is { } logging
|
||||
? $"no keyer for {logging.Mode.Name} — Config ▸ Keyer"
|
||||
: "no keyer — Config ▸ Keyer";
|
||||
|
||||
/// The twelve keys for this radio, which are a different twelve while
|
||||
/// running and while searching, as N1MM's file holds them.
|
||||
private IReadOnlyList<FunctionKey> Keys() =>
|
||||
@@ -74,9 +91,9 @@ public sealed partial class EntryWindow
|
||||
// a key that only acts: {WIPE}, {RUN}, {TELNET sh/dx}
|
||||
return;
|
||||
}
|
||||
if (session.Keyer is null)
|
||||
if (Sender is null)
|
||||
{
|
||||
Status("no keyer — Config ▸ Keyer");
|
||||
Status(NoKeyer());
|
||||
return;
|
||||
}
|
||||
if (index == Esm.Exchange)
|
||||
@@ -97,18 +114,23 @@ public sealed partial class EntryWindow
|
||||
// the box has to point at this radio before the key does
|
||||
await session.PointTransmitAtAsync(radioNumber);
|
||||
Status($"sending {text}");
|
||||
await SendThenAsync(session.Keyer, text, plan.After);
|
||||
await SendThenAsync(Sender, text, plan.After);
|
||||
}
|
||||
|
||||
/// Sends a message that did not come from a function key. The QTC window
|
||||
/// hands its traffic over this way, as N1MM's does through its own entry
|
||||
/// window, so it goes out through the same keyer with the same macros
|
||||
/// expanded.
|
||||
private async Task SendTextAsync(string template)
|
||||
/// `through` is the keyer the caller insists on: the digital window keys
|
||||
/// its own engine whatever mode the entry window is on, which is what
|
||||
/// N1MM's digital window does. Everyone else passes null and gets the keyer
|
||||
/// the mode calls for. False when nothing went out, with the reason already
|
||||
/// in the status line.
|
||||
internal async Task<bool> SendTextAsync(string template, MessageSender? through = null)
|
||||
{
|
||||
if (Logging is null || template.Trim().Length == 0)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
MessagePlan plan = MessagePlan.Read(template, Logging, session.Other(Logging));
|
||||
foreach (MessageAction action in plan.Before)
|
||||
@@ -117,16 +139,18 @@ public sealed partial class EntryWindow
|
||||
}
|
||||
if (plan.Text.Length == 0)
|
||||
{
|
||||
return;
|
||||
// a button that only acts: {WIPE}, {LOG}, {RUN}
|
||||
return true;
|
||||
}
|
||||
if (session.Keyer is null)
|
||||
if ((through ?? Sender) is not { } keyer)
|
||||
{
|
||||
Status("no keyer — Config ▸ Keyer");
|
||||
return;
|
||||
Status(NoKeyer());
|
||||
return false;
|
||||
}
|
||||
await session.PointTransmitAtAsync(radioNumber);
|
||||
Status($"sending {plan.Text}");
|
||||
await SendThenAsync(session.Keyer, plan.Text, plan.After);
|
||||
await SendThenAsync(keyer, plan.Text, plan.After);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Hands the text to the keyer, and leaves what follows `{END}` to run on
|
||||
@@ -236,7 +260,7 @@ public sealed partial class EntryWindow
|
||||
break;
|
||||
case MessageCommand.StopSending:
|
||||
sending = false;
|
||||
_ = session.Keyer?.AbortAsync();
|
||||
_ = Sender?.AbortAsync();
|
||||
break;
|
||||
case MessageCommand.Telnet:
|
||||
SendToCluster(action.Argument);
|
||||
@@ -267,6 +291,15 @@ public sealed partial class EntryWindow
|
||||
Logging.Stack.Clear();
|
||||
ShowCallStack();
|
||||
break;
|
||||
// the digital engine keys itself while it has text to send, so
|
||||
// {TX} and {RX} only matter when a macro wants the transmitter
|
||||
// held open around what it sends
|
||||
case MessageCommand.StartTransmit:
|
||||
_ = session.Digital?.SetPttAsync(true);
|
||||
break;
|
||||
case MessageCommand.ReturnToReceive:
|
||||
_ = session.Digital?.ReturnToReceiveAsync();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +417,7 @@ public sealed partial class EntryWindow
|
||||
{
|
||||
if (Logging is null
|
||||
|| session.Other(Logging) is not { } other
|
||||
|| session.Keyer is null
|
||||
|| Sender is null
|
||||
|| !int.TryParse(key, out int number))
|
||||
{
|
||||
return;
|
||||
@@ -407,7 +440,7 @@ public sealed partial class EntryWindow
|
||||
await session.PointTransmitAtAsync(radio);
|
||||
try
|
||||
{
|
||||
await session.Keyer!.SendAsync(text);
|
||||
await Sender!.SendAsync(text);
|
||||
}
|
||||
catch (InvalidOperationException e)
|
||||
{
|
||||
|
||||
@@ -430,6 +430,10 @@ public sealed partial class EntryWindow
|
||||
|
||||
private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
|
||||
|
||||
/// N1MM's digital interface window, which is where RTTY is worked from.
|
||||
private void OnShowDigital(object? sender, RoutedEventArgs e) =>
|
||||
Show(() => new DigitalWindow(session, this, radioNumber));
|
||||
|
||||
private async void OnStationSettings(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
StationDialog dialog = new(session.Settings.Station);
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
<MenuItem Header="Log" Click="OnShowLog" InputGesture="Ctrl+L" />
|
||||
<MenuItem Header="Score Summary" Click="OnShowScore" />
|
||||
<MenuItem Header="Telnet" Click="OnShowTelnet" />
|
||||
<MenuItem Header="Digital Interface" Click="OnShowDigital" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
|
||||
@@ -355,7 +355,7 @@ public sealed partial class EntryWindow : Window
|
||||
// N1MM's Escape holds alternating CQ where it is rather than
|
||||
// switching it off; the next CQ carries it on
|
||||
session.Alternating?.Hold();
|
||||
_ = session.Keyer?.AbortAsync();
|
||||
_ = Sender?.AbortAsync();
|
||||
Logging.Wipe();
|
||||
ResetEsm();
|
||||
SyncBoxes();
|
||||
@@ -480,6 +480,18 @@ public sealed partial class EntryWindow : Window
|
||||
boxes[0].Focus();
|
||||
return;
|
||||
}
|
||||
if (Logging.PendingMode() is { } mode)
|
||||
{
|
||||
Logging.Tune(Logging.Frequency, mode);
|
||||
// a radio that is connected is moved as well, or its next answer
|
||||
// would put the mode back
|
||||
_ = session.Radio?.SetModeAsync(mode);
|
||||
Logging.Entry.Call = "";
|
||||
SyncBoxes();
|
||||
boxes[0].Focus();
|
||||
Status($"mode {mode.Name}");
|
||||
return;
|
||||
}
|
||||
if (IsEsmOn)
|
||||
{
|
||||
_ = RunEsmAsync();
|
||||
@@ -531,6 +543,49 @@ public sealed partial class EntryWindow : Window
|
||||
/// What space does: the callsign box and the exchange boxes, stepping over
|
||||
/// the reports and over a box this station is not asked for, and round to
|
||||
/// the callsign again. This is N1MM's NextTab.
|
||||
/// The digital window hands a callsign over: the boxes are wiped and the
|
||||
/// call typed in, which is what double-clicking one in N1MM's receive pane
|
||||
/// does. With `space` the focus moves on to the exchange as though the
|
||||
/// operator had pressed space, so the reports and the call history fill in.
|
||||
internal void GrabCall(string call, bool space)
|
||||
{
|
||||
if (Logging is null || call.Trim().Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Logging.Wipe();
|
||||
ResetEsm();
|
||||
Logging.Entry.Call = call.Trim().ToUpperInvariant();
|
||||
SyncBoxes();
|
||||
if (space)
|
||||
{
|
||||
MoveFocus(forward: true);
|
||||
}
|
||||
boxes[Logging.Entry.Focus].Focus();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// The digital window's click on something that is not a callsign: it goes
|
||||
/// in the exchange box the contest keeps for that kind of value, and the
|
||||
/// cursor follows it. False when the contest has no box that takes it.
|
||||
internal bool GrabExchange(string text)
|
||||
{
|
||||
if (Logging is null || DigitalElement.FieldFor(Logging.Entry, text) is not { } field)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Logging.Entry[field] = text;
|
||||
Logging.Entry.FocusOn(field);
|
||||
SyncBoxes();
|
||||
boxes[field].Focus();
|
||||
Refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The digital window's right click, which N1MM makes the same as pressing
|
||||
/// Enter here.
|
||||
internal void PressEnter() => OnEnter();
|
||||
|
||||
private void MoveFocus(bool forward)
|
||||
{
|
||||
if (Logging is null || boxes.Count == 0)
|
||||
@@ -621,7 +676,7 @@ public sealed partial class EntryWindow : Window
|
||||
{
|
||||
sending = false;
|
||||
session.Alternating?.Hold();
|
||||
_ = session.Keyer?.AbortAsync();
|
||||
_ = Sender?.AbortAsync();
|
||||
Status("stopped sending");
|
||||
}
|
||||
|
||||
@@ -708,7 +763,7 @@ public sealed partial class EntryWindow : Window
|
||||
updating = false;
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
internal void Refresh()
|
||||
{
|
||||
ShowFunctionKeys();
|
||||
ShowBandButtons();
|
||||
@@ -1042,7 +1097,7 @@ public sealed partial class EntryWindow : Window
|
||||
Status("nothing left to report to " + station.Text);
|
||||
return;
|
||||
}
|
||||
QtcWindow window = new(session, Logging, station, direction, SendTextAsync);
|
||||
QtcWindow window = new(session, Logging, station, direction, text => SendTextAsync(text));
|
||||
bool saved = await window.ShowDialog<bool>(this);
|
||||
Logging.Wipe();
|
||||
SyncBoxes();
|
||||
|
||||
Reference in New Issue
Block a user