Lay the digital window out the way N1MM does
The window now carries what N1MM's does. Across the top: the TX indicator, the Letters/Figs box showing the word under the pointer in the other Baudot case, and the MouseOver box showing the word itself. Down the left of the receive pane is the pause bar, green while text prints and yellow while it is held. Under the panes are the message buttons and the row of engine controls, in N1MM's order. Both of N1MM's window scroll types are here. The scrolling pane adds lines at the bottom; the non-scrolling pane keeps text where it was printed, underlines the line being printed to and walks it down the window, so a call does not move as the pointer reaches it. A callsign is checked before it is coloured: anything shaped like a call under the generic routine, only calls in the files that are ticked under the resources routine, and under the combination everything, with a call the files do not hold marked in yellow. The garbage text search finds a Master.scp call inside a run of noise. The mouse and the keys are N1MM's. Shift+click converts the case before the word is transferred, Ctrl+click puts it in the box the cursor is in, Alt+click puts a call on the stack, a double click replaces the call in the box. The receive pane and the grab list have their right click menus. Alt+T and Ctrl+K turn the transmitter on with the cursor in the transmit pane, Alt+G grabs, Esc aborts. New from the menus: hover mode, the callsign pause routines, sending a text file, a date-stamped copy of the receive pane in ExportFiles, clearing the grab list on a CQ or a QSY, the ham default and AFC off on a run to S&P change, AFC on with CQ, and raising the window with the entry window it belongs to. The grab list holds ten calls, as N1MM's does. The setup window is N1MM's dialog: a General / MMTTY Setup tab, an Engine tab for the Wine and bridge paths, and a Message Setup tab with the buttons laid out as they sit in the window, a caption and text box for the one being edited, the macro list, the count of buttons to show, and import and save of a .mc file. Right-clicking a message button opens that tab, so the free-form macro dialog is gone. The title carries the dial frequency with the mark tone added to it, or the dial frequency alone when the radio already reads out what it transmits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.DigitalDialog"
|
||||
Title="Digital engine" Width="620" SizeToContent="Height"
|
||||
Title="Digital Setup" Width="820" Height="640"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<Window.Styles>
|
||||
<Style Selector="TextBlock.label">
|
||||
@@ -9,51 +9,184 @@
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="Margin" Value="0,8,0,1" />
|
||||
</Style>
|
||||
<Style Selector="HeaderedContentControl">
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style Selector="CheckBox">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="22" />
|
||||
</Style>
|
||||
<Style Selector="RadioButton">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="22" />
|
||||
</Style>
|
||||
<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" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
<DockPanel Margin="14">
|
||||
<StackPanel DockPanel.Dock="Top">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="MMTTY and 2Tone are Windows programs. They run under Wine, driven through the same XMMT.ocx control N1MM uses, with a small bridge program in between. See docs/digital-bridge.md for setting the Wine prefix up." />
|
||||
<TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Name="EngineBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseEngine" />
|
||||
</Grid>
|
||||
<TextBlock Classes="label" Text="Bridge program (nonemm-mmtty-bridge.exe)" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Name="BridgeBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,8,160" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Classes="label" Text="Wine prefix (empty for Wine's own)" />
|
||||
<TextBox Name="PrefixBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="Wine command" />
|
||||
<TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<TextBlock Classes="label" Text="Control (XMMT.ocx), registered in the prefix above" />
|
||||
<Grid ColumnDefinitions="*,6,Auto,6,Auto">
|
||||
<TextBox Name="ControlBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" />
|
||||
<Button Grid.Column="4" Name="RegisterButton" Content="Register" Click="OnRegister" />
|
||||
</Grid>
|
||||
<TextBlock Name="RegisterText" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Margin="0,4,0,0" IsVisible="False" />
|
||||
<Grid ColumnDefinitions="160,8,160,8,*" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Classes="label" Text="Engine window" />
|
||||
<ComboBox Name="WindowBox" Grid.Row="1" HorizontalAlignment="Stretch" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="PTT port (empty reads Mmtty.INI)" />
|
||||
<TextBox Name="PttBox" Grid.Row="1" Grid.Column="2" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="Mark tone (Hz)" />
|
||||
<TextBox Name="MarkBox" Grid.Row="1" Grid.Column="4" />
|
||||
</Grid>
|
||||
<CheckBox Name="OnTopBox" Content="Keep the engine window on top" Margin="0,10,0,0" />
|
||||
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||
Spacing="6" Margin="0,10,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save Settings" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
|
||||
Spacing="6" Margin="0,12,0,0">
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<Panel />
|
||||
<TabControl Name="Tabs">
|
||||
<TabItem Header="General / MMTTY Setup">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="4,8,10,4">
|
||||
<HeaderedContentControl Header="General Settings">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080" Padding="8">
|
||||
<StackPanel>
|
||||
<CheckBox Name="RadioFrequencyBox"
|
||||
Content="Display radio freq and not exact freq in DI caption" />
|
||||
<CheckBox Name="ClickSpaceBox" Content="Send space on callsign mouse click" />
|
||||
<CheckBox Name="GrabSpaceBox" Content="Send space on using grab" />
|
||||
<CheckBox Name="HamOnSearchBox"
|
||||
Content="(MMTTY) Send ham default on run to S&P change" />
|
||||
<CheckBox Name="AfcOnSearchBox" Content="(MMTTY) Turn AFC off on run to S&P change" />
|
||||
<CheckBox Name="AfcOnCqBox" Content="(MMTTY) Turn AFC on with CQ" />
|
||||
<CheckBox Name="NoDupesBox" Content="Do not add dupes to grab window" />
|
||||
<CheckBox Name="KnownOnlyBox"
|
||||
Content="Only grab Master.scp calls and previously worked calls" />
|
||||
<CheckBox Name="ClearOnCqBox" Content="Clear grab window on CQ" />
|
||||
<CheckBox Name="ClearOnQsyBox" Content="QSY will clear grab window" />
|
||||
<CheckBox Name="ForegroundBox" Content="Bring to foreground when made active" />
|
||||
<CheckBox Name="TextOutputBox" Content="Output RX window to text file" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<Grid ColumnDefinitions="*,10,*">
|
||||
<HeaderedContentControl Header="Window Scroll">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080" Padding="8">
|
||||
<StackPanel>
|
||||
<RadioButton Name="ScrollingBox" GroupName="scroll" Content="Scrolling" />
|
||||
<RadioButton Name="NonScrollingBox" GroupName="scroll" Content="Non scrolling" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<HeaderedContentControl Grid.Column="2" Header="MMTTY Window Layout">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080" Padding="8">
|
||||
<StackPanel>
|
||||
<ComboBox Name="WindowBox" HorizontalAlignment="Stretch" />
|
||||
<CheckBox Name="OnTopBox" Content="MMTTY always on top" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</HeaderedContentControl>
|
||||
</Grid>
|
||||
|
||||
<HeaderedContentControl Header="Callsign Validity and Highlight Routines">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080" Padding="8">
|
||||
<StackPanel>
|
||||
<RadioButton Name="GenericBox" GroupName="routine" Content="Use generic routines" />
|
||||
<RadioButton Name="ResourcesBox" GroupName="routine" Content="Use these resources:" />
|
||||
<RadioButton Name="BothBox" GroupName="routine" Content="Use combination of both" />
|
||||
<WrapPanel Margin="18,2,0,4">
|
||||
<CheckBox Name="MasterScpBox" Content="Master.scp" Margin="0,0,12,0" />
|
||||
<CheckBox Name="CallHistoryBox" Content="Call history" Margin="0,0,12,0" />
|
||||
<CheckBox Name="TelnetBox" Content="Telnet calls" Margin="0,0,12,0" />
|
||||
<CheckBox Name="LoggedBox" Content="Logged calls" />
|
||||
</WrapPanel>
|
||||
<CheckBox Name="GarbageBox"
|
||||
Content="Use search routine to find Master.scp calls in garbage text" />
|
||||
<RadioButton Name="ForegroundHighlightBox" GroupName="highlight"
|
||||
Content="Highlight foreground text" />
|
||||
<RadioButton Name="BackgroundHighlightBox" GroupName="highlight"
|
||||
Content="Highlight background of text" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</HeaderedContentControl>
|
||||
|
||||
<HeaderedContentControl Header="Alignment Frequency">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080" Padding="8">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="MMTTY mark frequency (Hz)" Margin="0,0,0,1" />
|
||||
<TextBox Name="MarkBox" Width="120" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="label" TextWrapping="Wrap"
|
||||
Text="The Align button retunes the radio so the signal that was clicked in the waterfall sits on this tone." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</HeaderedContentControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Engine">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="4,8,10,4">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="MMTTY and 2Tone are Windows programs. They run under Wine, driven through the same XMMT.ocx control N1MM uses, with a small bridge program in between. See docs/digital-bridge.md for setting the Wine prefix up." />
|
||||
<TextBlock Classes="label" Text="Engine program (mmtty.exe or 2Tone.exe)" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Name="EngineBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseEngine" />
|
||||
</Grid>
|
||||
<TextBlock Classes="label" Text="Bridge program (nonemm-mmtty-bridge.exe)" />
|
||||
<Grid ColumnDefinitions="*,6,Auto">
|
||||
<TextBox Name="BridgeBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseBridge" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,8,160" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Classes="label" Text="Wine prefix (empty for Wine's own)" />
|
||||
<TextBox Name="PrefixBox" Grid.Row="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="Wine command" />
|
||||
<TextBox Name="WineBox" Grid.Row="1" Grid.Column="2" />
|
||||
</Grid>
|
||||
<TextBlock Classes="label" Text="Control (XMMT.ocx), registered in the prefix above" />
|
||||
<Grid ColumnDefinitions="*,6,Auto,6,Auto">
|
||||
<TextBox Name="ControlBox" />
|
||||
<Button Grid.Column="2" Content="Browse…" Click="OnBrowseControl" />
|
||||
<Button Grid.Column="4" Name="RegisterButton" Content="Register" Click="OnRegister" />
|
||||
</Grid>
|
||||
<TextBlock Name="RegisterText" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Margin="0,4,0,0" IsVisible="False" />
|
||||
<TextBlock Classes="label" Text="PTT port (empty reads Mmtty.INI)" />
|
||||
<TextBox Name="PttBox" Width="160" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Message Setup">
|
||||
<DockPanel Margin="4,8,4,4">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<TextBlock Classes="label" Text="Number of message buttons to use" Margin="0,0,6,0"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Name="ButtonCountBox" Grid.Column="1" Width="80" />
|
||||
<Button Grid.Column="3" Content="Import…" Click="OnImportMessages" Margin="4,0" />
|
||||
<Button Grid.Column="4" Content="Save…" Click="OnExportMessages" />
|
||||
</Grid>
|
||||
<TextBlock DockPanel.Dock="Top" Classes="label"
|
||||
Text="Select a macro button to edit, or use Import to load messages from a .mc file." />
|
||||
|
||||
<Grid DockPanel.Dock="Top" Name="MacroRows" RowDefinitions="Auto,Auto,Auto" Margin="0,4" />
|
||||
|
||||
<Grid ColumnDefinitions="*,10,220" Margin="0,8,0,0">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Message Caption" />
|
||||
<TextBox Name="CaptionBox" />
|
||||
<TextBlock Classes="label" Text="Message Text" />
|
||||
<TextBox Name="MessageBox" AcceptsReturn="True" TextWrapping="Wrap" Height="120" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Content="Save Message" Click="OnSaveMessage" />
|
||||
<Button Content="Back to the defaults" Click="OnDefaults" />
|
||||
</StackPanel>
|
||||
<TextBlock Name="MessageStatus" Classes="label" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Classes="label" Text="Available Macros" />
|
||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||
<ListBox Name="MacroList" Height="200" SelectionChanged="OnMacroPicked" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@@ -1,35 +1,237 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Windows;
|
||||
using Nonemm.Digital;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// Where the digital engine is and how it is started. It closes with the
|
||||
/// settings, or with null when nothing is to be changed.
|
||||
/// The tabs of the digital setup window, for the callers that open it on one.
|
||||
public static class DigitalDialogTabs
|
||||
{
|
||||
public const int General = 0;
|
||||
|
||||
public const int Engine = 1;
|
||||
|
||||
public const int Messages = 2;
|
||||
}
|
||||
|
||||
/// N1MM's digital setup window: how the receive pane and the grab list behave,
|
||||
/// where the engine is and how it is started, and the twenty-four message
|
||||
/// buttons. It closes with the settings, or with null when nothing is to be
|
||||
/// changed.
|
||||
public sealed partial class DigitalDialog : Window
|
||||
{
|
||||
private const int MacroColumns = 8;
|
||||
|
||||
private static readonly string[] WindowSizes = ["Normal", "Small", "Medium1", "Medium2"];
|
||||
|
||||
private readonly Settings settings;
|
||||
/// The macros the message buttons take, for the list beside the message
|
||||
/// text. They are the ones the entry window's function keys expand.
|
||||
private static readonly string[] Macros =
|
||||
[
|
||||
"{TX}", "{RX}", "{ENTER}", "{MYCALL}", "{CALL}", "{EXCH}", "{SENTRST}", "{SENTRSTCUT}",
|
||||
"{SERIAL}", "{NAME}", "{NAMEANDSPACE}", "{GRID}", "{GRIDSQUARE}", "{OPERATOR}",
|
||||
"{LASTCALL}", "{PREVNR}", "{FREQ}", "{FREQROUND}", "{TIME2}", "{TIMESTAMP}",
|
||||
"{GRAB}", "{LOG}", "{LOGTHENPOP}", "{WIPE}", "{RUN}", "{S&P}", "{SPACE}", "{SPOTME}",
|
||||
"{EXCHSENT}", "{STOPTX}", "{END}",
|
||||
];
|
||||
|
||||
public DigitalDialog(Settings settings)
|
||||
private readonly Settings settings;
|
||||
private readonly List<Button> macroButtons = [];
|
||||
private readonly List<FunctionKey> messages;
|
||||
|
||||
private int editing;
|
||||
|
||||
public DigitalDialog(Settings settings, int tab = DigitalDialogTabs.General)
|
||||
{
|
||||
this.settings = settings;
|
||||
InitializeComponent();
|
||||
messages = Messages.Digital(settings.DigitalMessageFile).Buttons.ToList();
|
||||
ShowGeneral();
|
||||
ShowEngine();
|
||||
BuildMacroButtons();
|
||||
MacroList.ItemsSource = Macros;
|
||||
Tabs.SelectedIndex = tab;
|
||||
}
|
||||
|
||||
private void ShowGeneral()
|
||||
{
|
||||
RadioFrequencyBox.IsChecked = settings.DigitalRadioFrequencyInTitle;
|
||||
ClickSpaceBox.IsChecked = settings.DigitalClickSendsSpace;
|
||||
GrabSpaceBox.IsChecked = settings.DigitalGrabSendsSpace;
|
||||
HamOnSearchBox.IsChecked = settings.DigitalHamDefaultOnSearch;
|
||||
AfcOnSearchBox.IsChecked = settings.DigitalAfcOffOnSearch;
|
||||
AfcOnCqBox.IsChecked = settings.DigitalAfcOnCq;
|
||||
NoDupesBox.IsChecked = settings.DigitalGrabFilter.ToLowerInvariant() == "notdupes";
|
||||
KnownOnlyBox.IsChecked = settings.DigitalGrabFilter.ToLowerInvariant() == "knowncalls";
|
||||
ClearOnCqBox.IsChecked = settings.DigitalClearGrabOnCq;
|
||||
ClearOnQsyBox.IsChecked = settings.DigitalClearGrabOnQsy;
|
||||
ForegroundBox.IsChecked = settings.DigitalBringToForeground;
|
||||
TextOutputBox.IsChecked = settings.DigitalOutputToTextFile;
|
||||
ScrollingBox.IsChecked = settings.DigitalScrolling;
|
||||
NonScrollingBox.IsChecked = !settings.DigitalScrolling;
|
||||
string routine = settings.DigitalHighlightRoutine.ToLowerInvariant();
|
||||
GenericBox.IsChecked = routine is not ("resources" or "both");
|
||||
ResourcesBox.IsChecked = routine == "resources";
|
||||
BothBox.IsChecked = routine == "both";
|
||||
MasterScpBox.IsChecked = settings.DigitalUseMasterScp;
|
||||
CallHistoryBox.IsChecked = settings.DigitalUseCallHistory;
|
||||
TelnetBox.IsChecked = settings.DigitalUseTelnetCalls;
|
||||
LoggedBox.IsChecked = settings.DigitalUseLoggedCalls;
|
||||
GarbageBox.IsChecked = settings.DigitalSearchGarbage;
|
||||
ForegroundHighlightBox.IsChecked = !settings.DigitalHighlightBackground;
|
||||
BackgroundHighlightBox.IsChecked = settings.DigitalHighlightBackground;
|
||||
MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture);
|
||||
WindowBox.ItemsSource = WindowSizes;
|
||||
WindowBox.SelectedItem =
|
||||
WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0];
|
||||
OnTopBox.IsChecked = settings.DigitalEngineOnTop;
|
||||
ButtonCountBox.ItemsSource = DigitalWindow.MacroCounts;
|
||||
ButtonCountBox.SelectedItem = DigitalWindow.MacroCounts.Contains(settings.DigitalMessageButtons)
|
||||
? settings.DigitalMessageButtons
|
||||
: DigitalMacros.ButtonCount;
|
||||
}
|
||||
|
||||
private void ShowEngine()
|
||||
{
|
||||
EngineBox.Text = settings.DigitalEnginePath;
|
||||
BridgeBox.Text = settings.DigitalBridgePath;
|
||||
ControlBox.Text = settings.DigitalControlPath;
|
||||
PrefixBox.Text = settings.DigitalWinePrefix;
|
||||
WineBox.Text = settings.DigitalWineCommand;
|
||||
PttBox.Text = settings.DigitalPttPort;
|
||||
MarkBox.Text = settings.DigitalMarkHertz.ToString(CultureInfo.InvariantCulture);
|
||||
WindowBox.ItemsSource = WindowSizes;
|
||||
WindowBox.SelectedItem =
|
||||
WindowSizes.FirstOrDefault(s => s == settings.DigitalEngineWindow) ?? WindowSizes[0];
|
||||
OnTopBox.IsChecked = settings.DigitalEngineOnTop;
|
||||
}
|
||||
|
||||
/// The twenty-four buttons as they sit in the digital window. Clicking one
|
||||
/// brings its caption and its text down into the boxes below.
|
||||
private void BuildMacroButtons()
|
||||
{
|
||||
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" } };
|
||||
button.Click += (_, _) => Edit(index);
|
||||
macroButtons.Add(button);
|
||||
grid.Children.Add(button);
|
||||
}
|
||||
MacroRows.Children.Add(grid);
|
||||
}
|
||||
LabelMacroButtons();
|
||||
Edit(0);
|
||||
}
|
||||
|
||||
private void LabelMacroButtons()
|
||||
{
|
||||
for (int index = 0; index < macroButtons.Count; index++)
|
||||
{
|
||||
macroButtons[index].Content = messages[index].Label.Length > 0
|
||||
? messages[index].Label
|
||||
: $"{index + 1}";
|
||||
}
|
||||
}
|
||||
|
||||
private void Edit(int index)
|
||||
{
|
||||
editing = index;
|
||||
CaptionBox.Text = messages[index].Label;
|
||||
MessageBox.Text = messages[index].Message;
|
||||
MessageStatus.Text = $"button {index + 1}";
|
||||
}
|
||||
|
||||
private void OnSaveMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
messages[editing] = new FunctionKey(CaptionBox.Text?.Trim() ?? "", MessageBox.Text?.Trim() ?? "");
|
||||
LabelMacroButtons();
|
||||
MessageStatus.Text = $"button {editing + 1} saved";
|
||||
}
|
||||
|
||||
private void OnDefaults(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
messages.Clear();
|
||||
messages.AddRange(DigitalMacros.Parse(Messages.DefaultDigital).Buttons);
|
||||
LabelMacroButtons();
|
||||
Edit(editing);
|
||||
}
|
||||
|
||||
/// A macro picked from the list is typed into the message at the cursor,
|
||||
/// which is what N1MM's list does.
|
||||
private void OnMacroPicked(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (MacroList.SelectedItem is not string macro)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string text = MessageBox.Text ?? "";
|
||||
int at = Math.Clamp(MessageBox.CaretIndex, 0, text.Length);
|
||||
MessageBox.Text = text[..at] + macro + text[at..];
|
||||
MessageBox.CaretIndex = at + macro.Length;
|
||||
MacroList.SelectedItem = null;
|
||||
MessageBox.Focus();
|
||||
}
|
||||
|
||||
private async void OnImportMessages(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (await PickAsync("The message file", "Message files", "*.mc") is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
messages.Clear();
|
||||
messages.AddRange(DigitalMacros.Parse(await File.ReadAllTextAsync(path)).Buttons);
|
||||
LabelMacroButtons();
|
||||
Edit(editing);
|
||||
MessageStatus.Text = $"read {Path.GetFileName(path)}";
|
||||
}
|
||||
catch (IOException failure)
|
||||
{
|
||||
MessageStatus.Text = failure.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnExportMessages(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
IStorageFile? file = await StorageProvider.SaveFilePickerAsync(
|
||||
new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Save the messages",
|
||||
DefaultExtension = "mc",
|
||||
SuggestedFileName = "DigitalMessages.mc",
|
||||
});
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(file.Path.LocalPath, MessageLines());
|
||||
MessageStatus.Text = $"wrote {Path.GetFileName(file.Path.LocalPath)}";
|
||||
}
|
||||
catch (IOException failure)
|
||||
{
|
||||
MessageStatus.Text = failure.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// The buttons as the `label,message` lines the rest of the program reads.
|
||||
private string MessageLines()
|
||||
{
|
||||
StringBuilder lines = new();
|
||||
foreach (FunctionKey message in messages)
|
||||
{
|
||||
lines.Append(message.Label.Replace("&", "&&")).Append(',').AppendLine(message.Message);
|
||||
}
|
||||
return lines.ToString();
|
||||
}
|
||||
|
||||
private async void OnBrowseEngine(object? sender, RoutedEventArgs e) =>
|
||||
@@ -101,5 +303,30 @@ public sealed partial class DigitalDialog : Window
|
||||
DigitalEngineWindow = WindowBox.SelectedItem as string ?? "Normal",
|
||||
DigitalEngineOnTop = OnTopBox.IsChecked == true,
|
||||
DigitalEnabled = (EngineBox.Text?.Trim().Length ?? 0) > 0,
|
||||
DigitalRadioFrequencyInTitle = RadioFrequencyBox.IsChecked == true,
|
||||
DigitalClickSendsSpace = ClickSpaceBox.IsChecked == true,
|
||||
DigitalGrabSendsSpace = GrabSpaceBox.IsChecked == true,
|
||||
DigitalHamDefaultOnSearch = HamOnSearchBox.IsChecked == true,
|
||||
DigitalAfcOffOnSearch = AfcOnSearchBox.IsChecked == true,
|
||||
DigitalAfcOnCq = AfcOnCqBox.IsChecked == true,
|
||||
DigitalGrabFilter = KnownOnlyBox.IsChecked == true ? "knowncalls"
|
||||
: NoDupesBox.IsChecked == true ? "notdupes" : "everything",
|
||||
DigitalClearGrabOnCq = ClearOnCqBox.IsChecked == true,
|
||||
DigitalClearGrabOnQsy = ClearOnQsyBox.IsChecked == true,
|
||||
DigitalBringToForeground = ForegroundBox.IsChecked == true,
|
||||
DigitalOutputToTextFile = TextOutputBox.IsChecked == true,
|
||||
DigitalScrolling = NonScrollingBox.IsChecked != true,
|
||||
DigitalHighlightRoutine = ResourcesBox.IsChecked == true ? "resources"
|
||||
: BothBox.IsChecked == true ? "both" : "generic",
|
||||
DigitalUseMasterScp = MasterScpBox.IsChecked == true,
|
||||
DigitalUseCallHistory = CallHistoryBox.IsChecked == true,
|
||||
DigitalUseTelnetCalls = TelnetBox.IsChecked == true,
|
||||
DigitalUseLoggedCalls = LoggedBox.IsChecked == true,
|
||||
DigitalSearchGarbage = GarbageBox.IsChecked == true,
|
||||
DigitalHighlightBackground = BackgroundHighlightBox.IsChecked == true,
|
||||
DigitalMessageButtons = ButtonCountBox.SelectedItem is int count
|
||||
? count
|
||||
: settings.DigitalMessageButtons,
|
||||
DigitalMessageFile = MessageLines(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Nonemm.App.Dialogs.DigitalMacrosDialog"
|
||||
Title="Digital macros" Width="620" Height="520"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
<DockPanel Margin="14">
|
||||
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="One line per button: the label, a comma, then the message. Twenty-four lines fill the three rows of eight. {TX} starts the transmission and {RX} ends it; {ENTER} sends a carriage return. The text macros are the same ones the function keys use." />
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="6" Margin="0,10,0,0">
|
||||
<Button Content="Back to the defaults" Click="OnDefaults" />
|
||||
<Panel Width="180" />
|
||||
<Button Content="Cancel" Click="OnCancel" />
|
||||
<Button Content="Save" Click="OnSave" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<Border BorderThickness="1" BorderBrush="#40808080" CornerRadius="3" Margin="0,8,0,0">
|
||||
<TextBox Name="TextArea" AcceptsReturn="True" FontFamily="monospace" FontSize="12"
|
||||
BorderThickness="0" />
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -1,22 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace Nonemm.App.Dialogs;
|
||||
|
||||
/// The digital window's macro buttons, edited as their lines. It closes with
|
||||
/// the text, or with null when nothing is to be changed.
|
||||
public sealed partial class DigitalMacrosDialog : Window
|
||||
{
|
||||
public DigitalMacrosDialog(string text)
|
||||
{
|
||||
InitializeComponent();
|
||||
TextArea.Text = text.Trim().Length > 0 ? text : Messages.DefaultDigital;
|
||||
}
|
||||
|
||||
private void OnDefaults(object? sender, RoutedEventArgs e) =>
|
||||
TextArea.Text = Messages.DefaultDigital;
|
||||
|
||||
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
|
||||
|
||||
private void OnSave(object? sender, RoutedEventArgs e) => Close(TextArea.Text ?? "");
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.VisualTree;
|
||||
using Nonemm.Session;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -11,6 +12,10 @@ namespace Nonemm.App.Windows;
|
||||
/// transmit pane, and the clicking and hovering in the receive pane.
|
||||
public sealed partial class DigitalWindow
|
||||
{
|
||||
/// The call the pointer is resting on, which hover mode takes and the
|
||||
/// modifier keys act on.
|
||||
private string underPointer = "";
|
||||
|
||||
/// 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)
|
||||
@@ -21,7 +26,7 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
return;
|
||||
}
|
||||
grab.NewestFirst = session.Settings.DigitalGrabNewestFirst;
|
||||
grab.NewestFirst = Settings.DigitalGrabNewestFirst;
|
||||
if (grab.Add(call))
|
||||
{
|
||||
ShowGrabList();
|
||||
@@ -29,7 +34,7 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
|
||||
private bool PassesFilter(OperatingPosition position, string call) =>
|
||||
session.Settings.DigitalGrabFilter.ToLowerInvariant() switch
|
||||
Settings.DigitalGrabFilter.ToLowerInvariant() switch
|
||||
{
|
||||
"notdupes" => position.JudgeCall(call)?.IsDupe != true,
|
||||
"knowncalls" => session.Calls.Holds(call) || position.Log.WorkedBefore(call).Count > 0,
|
||||
@@ -55,7 +60,7 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
Text = call,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
FontSize = session.Settings.DigitalFontSize,
|
||||
FontSize = Settings.DigitalFontSize,
|
||||
Foreground = Verdicts.Colour(position.JudgeCall(call)),
|
||||
};
|
||||
Border row = new()
|
||||
@@ -65,29 +70,59 @@ public sealed partial class DigitalWindow
|
||||
// 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,
|
||||
Tag = call,
|
||||
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.
|
||||
/// Left click grabs the call; the right click menu is on the list itself.
|
||||
private void GrabFromList(string call, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
if (!e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
Grab(call);
|
||||
}
|
||||
}
|
||||
|
||||
/// N1MM's grab list menu: clear the whole list, clear the call under the
|
||||
/// pointer, or change the order calls come off it in.
|
||||
private void OnGrabListPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = (e.Source as Control)?.FindAncestorOfType<Border>()?.Tag as string ?? "";
|
||||
MenuFlyout menu = new();
|
||||
menu.Items.Add(Item("Clear list", () =>
|
||||
{
|
||||
grab.Clear();
|
||||
ShowGrabList();
|
||||
}));
|
||||
menu.Items.Add(Item($"Clear selected call{(call.Length > 0 ? $" ({call})" : "")}", () =>
|
||||
{
|
||||
grab.Remove(call);
|
||||
ShowGrabList();
|
||||
return;
|
||||
}
|
||||
Grab(call);
|
||||
}, call.Length > 0));
|
||||
menu.Items.Add(new Separator());
|
||||
menu.Items.Add(Item("Last in, first out", () => GrabOrder(newestFirst: true)));
|
||||
menu.Items.Add(Item("First in, first out", () => GrabOrder(newestFirst: false)));
|
||||
menu.ShowAt(GrabCalls, showAtPointer: true);
|
||||
}
|
||||
|
||||
private static MenuItem Item(string header, Action act, bool enabled = true)
|
||||
{
|
||||
MenuItem item = new() { Header = header, IsEnabled = enabled };
|
||||
item.Click += (_, _) => act();
|
||||
return item;
|
||||
}
|
||||
|
||||
/// 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)
|
||||
private void Grab(string call, bool space = true)
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
@@ -101,7 +136,7 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
grab.Remove(call);
|
||||
ShowGrabList();
|
||||
entry.GrabCall(call, session.Settings.DigitalGrabSendsSpace);
|
||||
entry.GrabCall(call, space && Settings.DigitalGrabSendsSpace);
|
||||
entry.Refresh();
|
||||
}
|
||||
|
||||
@@ -114,6 +149,9 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// What the pointer rests on: the Letters/Figs and MouseOver boxes follow
|
||||
/// it, and with hover mode on a callsign goes to the entry window without a
|
||||
/// click at all.
|
||||
private void OnReceivePointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (e.Source is not TextBlock block || block.Text is not { } text || text.Trim().Length == 0)
|
||||
@@ -128,7 +166,35 @@ public sealed partial class DigitalWindow
|
||||
Unhighlight();
|
||||
hovered = block;
|
||||
block.TextDecorations = TextDecorations.Underline;
|
||||
MouseOverText.Text = DigitalReceiver.Scrub(text);
|
||||
underPointer = DigitalReceiver.Scrub(text);
|
||||
MouseOverText.Text = underPointer;
|
||||
// the box is Baudot's, so it says nothing in a mode that is not RTTY,
|
||||
// which is what N1MM writes in it
|
||||
FigsText.Text = (Radio?.Mode ?? Nonemm.Core.Modes.Rtty) == Nonemm.Core.Modes.Rtty
|
||||
? Baudot.LettersFigs(underPointer)
|
||||
: "NOT USED";
|
||||
WhenPointerRestsOnCall(underPointer);
|
||||
}
|
||||
|
||||
/// N1MM's hover mode and its callsign pause routines, both worked off the
|
||||
/// call the pointer is resting on: the call goes into an empty callsign box
|
||||
/// on its own, and the pane stops so the text under the pointer stays where
|
||||
/// it is.
|
||||
private void WhenPointerRestsOnCall(string text)
|
||||
{
|
||||
if (Radio is not { } position || CallToTake(position, text) is not { Length: > 0 } call)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (Settings.DigitalPauseOnCallsign && position.Entry.Call.Trim().Length == 0)
|
||||
{
|
||||
paused = true;
|
||||
ShowPauseBar();
|
||||
}
|
||||
if (Settings.DigitalHoverMode && position.Entry.Call.Trim().Length == 0)
|
||||
{
|
||||
Grab(call, Settings.DigitalClickSendsSpace);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnReceivePointerExited(object? sender, PointerEventArgs e)
|
||||
@@ -144,49 +210,70 @@ public sealed partial class DigitalWindow
|
||||
hovered.TextDecorations = null;
|
||||
hovered = null;
|
||||
}
|
||||
underPointer = "";
|
||||
MouseOverText.Text = "";
|
||||
FigsText.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.
|
||||
/// contest keeps for it. Ctrl puts it in the box the cursor is in whatever
|
||||
/// it holds, shift converts it to the other Baudot case first, alt puts a
|
||||
/// call on the call stack, and a double click replaces a call already in
|
||||
/// the box. A right click presses Enter in the entry window when that
|
||||
/// option is on, and opens the menu when it is not.
|
||||
private void OnReceivePointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
RightClick();
|
||||
RightClick(e);
|
||||
return;
|
||||
}
|
||||
string clicked = e.Source is TextBlock block ? DigitalReceiver.Scrub(block.Text ?? "") : "";
|
||||
if (clicked.Length == 0)
|
||||
{
|
||||
paused = true;
|
||||
ShowPauseBar();
|
||||
return;
|
||||
}
|
||||
Resume();
|
||||
Copy(clicked);
|
||||
KeyModifiers keys = e.KeyModifiers;
|
||||
if (keys.HasFlag(KeyModifiers.Shift))
|
||||
{
|
||||
clicked = Baudot.LettersFigs(clicked);
|
||||
}
|
||||
if (keys.HasFlag(KeyModifiers.Control))
|
||||
{
|
||||
entry.GrabIntoFocused(DigitalElement.Trim(clicked));
|
||||
return;
|
||||
}
|
||||
Copy(clicked, keys.HasFlag(KeyModifiers.Alt), e.ClickCount > 1);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// call stack or on a double click, and everything else is an exchange
|
||||
/// element.
|
||||
private void Copy(string clicked, bool toStack, bool replaces)
|
||||
{
|
||||
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);
|
||||
string call = CallToTake(position, clicked);
|
||||
bool waiting = position.Entry.Call.Trim().Length == 0;
|
||||
if (isCall && (waiting || StackingOrder() != StackOrder.Disabled))
|
||||
if (call.Length > 0 && toStack)
|
||||
{
|
||||
Grab(call);
|
||||
position.Stack.Order = StackingOrder();
|
||||
position.Stack.Add(call, position.JudgeCall(call)?.IsNewMultiplier == true);
|
||||
grab.Remove(call);
|
||||
ShowGrabList();
|
||||
entry.Refresh();
|
||||
return;
|
||||
}
|
||||
if (call.Length > 0 && (waiting || replaces || StackingOrder() != StackOrder.Disabled))
|
||||
{
|
||||
Grab(call, Settings.DigitalClickSendsSpace);
|
||||
return;
|
||||
}
|
||||
if (!waiting)
|
||||
@@ -195,18 +282,66 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void RightClick()
|
||||
/// The callsign in a clicked or hovered word, or empty when it is not one
|
||||
/// this station would work. Our own call is never taken.
|
||||
private string CallToTake(OperatingPosition position, string clicked)
|
||||
{
|
||||
if (!session.Settings.DigitalRightClickSendsEnter)
|
||||
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);
|
||||
return isCall ? call : "";
|
||||
}
|
||||
|
||||
private void RightClick(PointerPressedEventArgs e)
|
||||
{
|
||||
if (Settings.DigitalRightClickSendsEnter)
|
||||
{
|
||||
entry.PressEnter();
|
||||
if (!Settings.DigitalRightClickKeepsCall && Radio is { } position)
|
||||
{
|
||||
position.Wipe();
|
||||
entry.Refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
entry.PressEnter();
|
||||
if (!session.Settings.DigitalRightClickKeepsCall && Radio is { } position)
|
||||
MenuFlyout menu = new();
|
||||
menu.Items.Add(Item("Clear RX", () => OnClearReceive(this, new RoutedEventArgs())));
|
||||
menu.Items.Add(Item("Output to text file", () =>
|
||||
Store(Settings with { DigitalOutputToTextFile = !Settings.DigitalOutputToTextFile })));
|
||||
menu.Items.Add(Item(paused ? "Let the pane run" : "Hold the pane still", () =>
|
||||
{
|
||||
position.Wipe();
|
||||
entry.Refresh();
|
||||
if (paused)
|
||||
{
|
||||
Resume();
|
||||
}
|
||||
else
|
||||
{
|
||||
paused = true;
|
||||
ShowPauseBar();
|
||||
}
|
||||
}));
|
||||
menu.ShowAt(ReceiveLines, showAtPointer: true);
|
||||
}
|
||||
|
||||
/// N1MM's pause bar: the strip beside the receive pane, green while text is
|
||||
/// printing and yellow while it is held.
|
||||
private void OnPauseBarPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (paused)
|
||||
{
|
||||
Resume();
|
||||
return;
|
||||
}
|
||||
paused = true;
|
||||
ShowPauseBar();
|
||||
}
|
||||
|
||||
private void ShowPauseBar()
|
||||
{
|
||||
PauseBar.Background = paused ? Brushes.Gold : new SolidColorBrush(Color.FromRgb(0x2E, 0x8B, 0x57));
|
||||
PauseText.Text = paused ? "the pane is held still" : "";
|
||||
}
|
||||
|
||||
/// Lets the pane scroll again and prints what arrived while it was stopped.
|
||||
@@ -217,6 +352,7 @@ public sealed partial class DigitalWindow
|
||||
return;
|
||||
}
|
||||
paused = false;
|
||||
ShowPauseBar();
|
||||
if (held.Length == 0)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Nonemm.App.Dialogs;
|
||||
using Nonemm.Digital;
|
||||
using Nonemm.Session;
|
||||
@@ -14,14 +15,23 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
private const int MacroColumns = 8;
|
||||
|
||||
/// What N1MM offers for the number of message buttons.
|
||||
public static readonly int[] MacroCounts = [0, 8, 16, 24];
|
||||
|
||||
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.
|
||||
/// Rows of eight, which is how N1MM lays its digital macros out. It shows
|
||||
/// none, one row, two or three, and the setting says which.
|
||||
private void BuildMacros()
|
||||
{
|
||||
for (int row = 0; row < DigitalMacros.ButtonCount / MacroColumns; row++)
|
||||
MacroRows.Children.Clear();
|
||||
macroButtons.Clear();
|
||||
int wanted = MacroCounts.Contains(session.Settings.DigitalMessageButtons)
|
||||
? session.Settings.DigitalMessageButtons
|
||||
: DigitalMacros.ButtonCount;
|
||||
for (int row = 0; row < wanted / MacroColumns; row++)
|
||||
{
|
||||
UniformGrid grid = new() { Columns = MacroColumns, Rows = 1 };
|
||||
Grid.SetRow(grid, row);
|
||||
@@ -49,14 +59,14 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-clicking a macro button opens the macros for editing, the way
|
||||
/// right-clicking a function key does in the entry window.
|
||||
/// Right-clicking a macro button opens the setup window on the message tab,
|
||||
/// the way right-clicking one does in N1MM.
|
||||
private void OnMacroPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsRightButtonPressed)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = EditMacrosAsync();
|
||||
_ = SetupAsync(DigitalDialogTabs.Messages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,15 +97,36 @@ public sealed partial class DigitalWindow
|
||||
entry.Refresh();
|
||||
}
|
||||
|
||||
private async Task EditMacrosAsync()
|
||||
/// N1MM's Send Text File: a file of prepared text goes out as though it had
|
||||
/// been typed into the transmit pane.
|
||||
private async void OnSendTextFile(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (await new DigitalMacrosDialog(session.Settings.DigitalMessageFile)
|
||||
.ShowDialog<string?>(this) is not { } edited)
|
||||
if (session.DigitalKeyer is not { IsReady: true } keyer)
|
||||
{
|
||||
StateText.Text = "the engine is not connected";
|
||||
return;
|
||||
}
|
||||
IReadOnlyList<IStorageFile> picked = await StorageProvider.OpenFilePickerAsync(
|
||||
new FilePickerOpenOptions
|
||||
{
|
||||
Title = "The text file to send",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = [new FilePickerFileType("Text files") { Patterns = ["*.txt"] }],
|
||||
});
|
||||
if (picked.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.Save(session.Settings with { DigitalMessageFile = edited });
|
||||
LabelMacros();
|
||||
try
|
||||
{
|
||||
string text = await File.ReadAllTextAsync(picked[0].Path.LocalPath);
|
||||
TransmitBox.Text += text;
|
||||
await keyer.SendAsync(text);
|
||||
}
|
||||
catch (Exception failure) when (failure is IOException or InvalidOperationException)
|
||||
{
|
||||
StateText.Text = failure.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// What is typed in the transmit pane goes out as it is typed, which is how
|
||||
@@ -122,7 +153,60 @@ public sealed partial class DigitalWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTransmit(object? sender, RoutedEventArgs e) => _ = Running()?.SetPttAsync(true);
|
||||
/// N1MM's keys for the digital window: Alt+T turns the transmitter on and
|
||||
/// puts the cursor where what is typed goes out, Ctrl+K does the same, and
|
||||
/// Alt+G takes the next call off the grab list.
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.T)
|
||||
{
|
||||
e.Handled = true;
|
||||
ToggleTransmit();
|
||||
return;
|
||||
}
|
||||
if (e.KeyModifiers == KeyModifiers.Control && e.Key == Key.K)
|
||||
{
|
||||
e.Handled = true;
|
||||
ToggleTransmit();
|
||||
return;
|
||||
}
|
||||
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.G)
|
||||
{
|
||||
e.Handled = true;
|
||||
OnGrabNext(this, new RoutedEventArgs());
|
||||
return;
|
||||
}
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = Running()?.AbortAsync();
|
||||
return;
|
||||
}
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
/// Alt+T: on to transmit with the cursor in the transmit pane, off back to
|
||||
/// receive.
|
||||
private void ToggleTransmit()
|
||||
{
|
||||
if (Running() is not { } running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (running.IsTransmitting)
|
||||
{
|
||||
_ = running.ReturnToReceiveAsync();
|
||||
return;
|
||||
}
|
||||
_ = running.SetPttAsync(true);
|
||||
TransmitBox.Focus();
|
||||
}
|
||||
|
||||
private void OnTransmit(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = Running()?.SetPttAsync(true);
|
||||
TransmitBox.Focus();
|
||||
}
|
||||
|
||||
private void OnReceive(object? sender, RoutedEventArgs e) => _ = Running()?.AbortAsync();
|
||||
|
||||
@@ -133,8 +217,10 @@ public sealed partial class DigitalWindow
|
||||
ReceiveLines.Children.Clear();
|
||||
line = null;
|
||||
word = null;
|
||||
lineAt = -1;
|
||||
held.Clear();
|
||||
paused = false;
|
||||
ShowPauseBar();
|
||||
receiver.Clear();
|
||||
}
|
||||
|
||||
@@ -150,7 +236,9 @@ public sealed partial class DigitalWindow
|
||||
private void OnEngineSetup(object? sender, RoutedEventArgs e) =>
|
||||
_ = Running()?.PostAsync(MmttyMessage.Setup, 0);
|
||||
|
||||
private void OnToggleLock(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.AfcBit);
|
||||
private void OnToggleAfc(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.AfcBit);
|
||||
|
||||
private void OnToggleNet(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.NetBit);
|
||||
|
||||
private void OnToggleReverse(object? sender, RoutedEventArgs e) => Toggle(MmttySettings.ReverseBit);
|
||||
|
||||
@@ -166,6 +254,19 @@ public sealed partial class DigitalWindow
|
||||
ShowState();
|
||||
}
|
||||
|
||||
/// Sets one switch rather than flipping it, for the options that turn AFC
|
||||
/// on and off by themselves. Nothing is sent while the engine is not
|
||||
/// running, and nothing is said about it: the operator did not ask.
|
||||
private void Switch(int bit, bool on)
|
||||
{
|
||||
if (engine is not { } running || ((running.Switches & bit) != 0) == on)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_ = running.SetSwitchesAsync(on ? running.Switches | bit : 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.
|
||||
@@ -173,7 +274,7 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
if (engine is null)
|
||||
{
|
||||
StateText.Text = "the engine is not running — Engine ▸ Start";
|
||||
StateText.Text = "the engine is not running — Setup ▸ Start the engine";
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Dialogs;
|
||||
@@ -16,11 +17,21 @@ public sealed partial class DigitalWindow
|
||||
private void ApplySettings()
|
||||
{
|
||||
Settings settings = session.Settings;
|
||||
HighlightBackgroundItem.IsChecked = settings.DigitalHighlightBackground;
|
||||
GrabSpaceItem.IsChecked = settings.DigitalGrabSendsSpace;
|
||||
ClickSpaceItem.IsChecked = settings.DigitalClickSendsSpace;
|
||||
RightClickEnterItem.IsChecked = settings.DigitalRightClickSendsEnter;
|
||||
RightClickKeepsCallItem.IsChecked = settings.DigitalRightClickKeepsCall;
|
||||
HoverModeItem.IsChecked = settings.DigitalHoverMode;
|
||||
PauseOnCallItem.IsChecked = settings.DigitalPauseOnCallsign;
|
||||
ForegroundItem.IsChecked = settings.DigitalBringToForeground;
|
||||
TextOutputItem.IsChecked = settings.DigitalOutputToTextFile;
|
||||
AfcWithCqItem.IsChecked = settings.DigitalAfcOnCq;
|
||||
HamOnSearchItem.IsChecked = settings.DigitalHamDefaultOnSearch;
|
||||
AfcOnSearchItem.IsChecked = settings.DigitalAfcOffOnSearch;
|
||||
ScrollingItem.IsChecked = settings.DigitalScrolling;
|
||||
NonScrollingItem.IsChecked = !settings.DigitalScrolling;
|
||||
GrabNewestFirstItem.IsChecked = settings.DigitalGrabNewestFirst;
|
||||
GrabOldestFirstItem.IsChecked = !settings.DigitalGrabNewestFirst;
|
||||
string filter = settings.DigitalGrabFilter.ToLowerInvariant();
|
||||
GrabEverythingItem.IsChecked = filter is not ("notdupes" or "knowncalls");
|
||||
GrabNotDupesItem.IsChecked = filter == "notdupes";
|
||||
@@ -49,18 +60,23 @@ public sealed partial class DigitalWindow
|
||||
ShowState();
|
||||
}
|
||||
|
||||
private async void OnEngineSettings(object? sender, RoutedEventArgs e)
|
||||
private void OnEngineSettings(object? sender, RoutedEventArgs e) => _ = SetupAsync(0);
|
||||
|
||||
/// N1MM's digital setup window. The tab it opens on is the one the operator
|
||||
/// asked for: the menu opens the first, a right click on a macro button
|
||||
/// opens the message tab.
|
||||
private async Task SetupAsync(int tab)
|
||||
{
|
||||
if (await new DigitalDialog(session.Settings).ShowDialog<Settings?>(this) is not { } edited)
|
||||
if (await new DigitalDialog(session.Settings, tab).ShowDialog<Settings?>(this) is not { } edited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Store(edited);
|
||||
BuildMacros();
|
||||
LabelMacros();
|
||||
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);
|
||||
@@ -69,27 +85,31 @@ public sealed partial class DigitalWindow
|
||||
{
|
||||
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 (Control child in ReceiveLines.Children)
|
||||
{
|
||||
foreach (var block in lineOfText.Children.OfType<Avalonia.Controls.TextBlock>())
|
||||
if (((Border)child).Child is not WrapPanel body)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (TextBlock block in body.Children.OfType<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 OnToggleClickSpace(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalClickSendsSpace = !session.Settings.DigitalClickSendsSpace,
|
||||
});
|
||||
|
||||
private void OnToggleRightClick(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
@@ -102,12 +122,61 @@ public sealed partial class DigitalWindow
|
||||
DigitalRightClickKeepsCall = !session.Settings.DigitalRightClickKeepsCall,
|
||||
});
|
||||
|
||||
private void OnToggleGrabOrder(object? sender, RoutedEventArgs e) =>
|
||||
private void OnToggleHoverMode(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with { DigitalHoverMode = !session.Settings.DigitalHoverMode });
|
||||
|
||||
private void OnTogglePauseOnCall(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalGrabNewestFirst = !session.Settings.DigitalGrabNewestFirst,
|
||||
DigitalPauseOnCallsign = !session.Settings.DigitalPauseOnCallsign,
|
||||
});
|
||||
|
||||
private void OnToggleForeground(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalBringToForeground = !session.Settings.DigitalBringToForeground,
|
||||
});
|
||||
|
||||
private void OnToggleTextOutput(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalOutputToTextFile = !session.Settings.DigitalOutputToTextFile,
|
||||
});
|
||||
|
||||
private void OnToggleAfcWithCq(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with { DigitalAfcOnCq = !session.Settings.DigitalAfcOnCq });
|
||||
|
||||
private void OnToggleHamOnSearch(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalHamDefaultOnSearch = !session.Settings.DigitalHamDefaultOnSearch,
|
||||
});
|
||||
|
||||
private void OnToggleAfcOnSearch(object? sender, RoutedEventArgs e) =>
|
||||
Store(session.Settings with
|
||||
{
|
||||
DigitalAfcOffOnSearch = !session.Settings.DigitalAfcOffOnSearch,
|
||||
});
|
||||
|
||||
private void OnScrolling(object? sender, RoutedEventArgs e) => Scrolling(true);
|
||||
|
||||
private void OnNonScrolling(object? sender, RoutedEventArgs e) => Scrolling(false);
|
||||
|
||||
/// The two window scroll types are drawn differently enough that the pane
|
||||
/// starts again empty when the operator changes between them.
|
||||
private void Scrolling(bool scrolling)
|
||||
{
|
||||
Store(session.Settings with { DigitalScrolling = scrolling });
|
||||
OnClearReceive(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
private void OnGrabNewestFirst(object? sender, RoutedEventArgs e) => GrabOrder(true);
|
||||
|
||||
private void OnGrabOldestFirst(object? sender, RoutedEventArgs e) => GrabOrder(false);
|
||||
|
||||
private void GrabOrder(bool newestFirst) =>
|
||||
Store(session.Settings with { DigitalGrabNewestFirst = newestFirst });
|
||||
|
||||
private void OnGrabEverything(object? sender, RoutedEventArgs e) => Filter("everything");
|
||||
|
||||
private void OnGrabNotDupes(object? sender, RoutedEventArgs e) => Filter("notdupes");
|
||||
|
||||
@@ -21,73 +21,116 @@
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="6,0,4,0" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.readout">
|
||||
<Setter Property="FontFamily" Value="monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="4,2" />
|
||||
</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" />
|
||||
<MenuItem Header="Settings…" Click="OnEngineSettings" />
|
||||
<MenuItem Name="ForegroundItem" Header="Bring to foreground when made active"
|
||||
ToggleType="CheckBox" Click="OnToggleForeground" />
|
||||
<Separator />
|
||||
<MenuItem Header="Start the engine" Click="OnStartEngine" />
|
||||
<MenuItem Header="Stop the engine" Click="OnStopEngine" />
|
||||
<MenuItem Header="Setup MMTTY" Click="OnEngineSetup" />
|
||||
<Separator />
|
||||
<MenuItem Name="AfcWithCqItem" Header="AFC on/off with CQ"
|
||||
ToggleType="CheckBox" Click="OnToggleAfcWithCq" />
|
||||
<MenuItem Name="HamOnSearchItem" Header="Send ham default on run to S&P change"
|
||||
ToggleType="CheckBox" Click="OnToggleHamOnSearch" />
|
||||
<MenuItem Name="AfcOnSearchItem" Header="Turn AFC off on run to S&P change"
|
||||
ToggleType="CheckBox" Click="OnToggleAfcOnSearch" />
|
||||
<Separator />
|
||||
<MenuItem Name="HoverModeItem" Header="Turn hover mode on/off"
|
||||
ToggleType="CheckBox" Click="OnToggleHoverMode" />
|
||||
<MenuItem Name="RightClickEnterItem" Header="Rt click = Return NOT menu"
|
||||
ToggleType="CheckBox" Click="OnToggleRightClick" />
|
||||
<MenuItem Name="RightClickKeepsCallItem"
|
||||
Header="With Rt click enabled don't drop on-deck call"
|
||||
ToggleType="CheckBox" Click="OnToggleRightClickKeepsCall" />
|
||||
<Separator />
|
||||
<MenuItem Header="Send text file…" Click="OnSendTextFile" />
|
||||
<MenuItem Name="TextOutputItem" Header="Output RX window to text file"
|
||||
ToggleType="CheckBox" Click="OnToggleTextOutput" />
|
||||
<Separator />
|
||||
<MenuItem Header="Digital call stacking">
|
||||
<MenuItem Name="StackFirstInItem" Header="Enable using first in first out"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackFirstIn" />
|
||||
<MenuItem Name="StackLastInItem" Header="Enable using last in first out"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackLastIn" />
|
||||
<MenuItem Name="StackMultipliersItem" Header="Enable using FIFO mults first"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackMultipliers" />
|
||||
<MenuItem Name="StackDisabledItem" Header="Disabled"
|
||||
ToggleType="Radio" GroupName="stack" Click="OnStackDisabled" />
|
||||
</MenuItem>
|
||||
<MenuItem Name="PauseOnCallItem" Header="Use RX window callsign pause routines"
|
||||
ToggleType="CheckBox" Click="OnTogglePauseOnCall" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="Clear RX" Click="OnClearReceive" />
|
||||
<MenuItem Header="Clear TX" Click="OnClearTransmit" />
|
||||
<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 Name="ScrollingItem" Header="Scrolling window"
|
||||
ToggleType="Radio" GroupName="scroll" Click="OnScrolling" />
|
||||
<MenuItem Name="NonScrollingItem" Header="Non-scrolling window"
|
||||
ToggleType="Radio" GroupName="scroll" Click="OnNonScrolling" />
|
||||
</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" />
|
||||
<MenuItem Header="_Grab">
|
||||
<MenuItem Name="GrabEverythingItem" Header="Everything heard"
|
||||
ToggleType="Radio" GroupName="grab" Click="OnGrabEverything" />
|
||||
<MenuItem Name="GrabNotDupesItem" Header="Do not add dupes"
|
||||
ToggleType="Radio" GroupName="grab" Click="OnGrabNotDupes" />
|
||||
<MenuItem Name="GrabKnownItem" Header="Only Master.scp and worked calls"
|
||||
ToggleType="Radio" GroupName="grab" Click="OnGrabKnown" />
|
||||
<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 Header="Sort order">
|
||||
<MenuItem Name="GrabNewestFirstItem" Header="Last in, first out"
|
||||
ToggleType="Radio" GroupName="grabsort" Click="OnGrabNewestFirst" />
|
||||
<MenuItem Name="GrabOldestFirstItem" Header="First in, first out"
|
||||
ToggleType="Radio" GroupName="grabsort" Click="OnGrabOldestFirst" />
|
||||
</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 Name="GrabSpaceItem" Header="Send space on using grab"
|
||||
ToggleType="CheckBox" Click="OnToggleGrabSpace" />
|
||||
<MenuItem Name="ClickSpaceItem" Header="Send space on callsign mouse click"
|
||||
ToggleType="CheckBox" Click="OnToggleClickSpace" />
|
||||
<Separator />
|
||||
<MenuItem Header="Clear list" Click="OnClearGrabList" />
|
||||
</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">
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,Auto,*,Auto,*" Margin="6,3,6,1">
|
||||
<Border Name="TransmitDot" CornerRadius="7" Width="30" Height="16"
|
||||
Background="Transparent" BorderThickness="1" BorderBrush="#40808080">
|
||||
<TextBlock Text="TX" FontSize="10" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Classes="caption" Text="Letters/Figs" />
|
||||
<Border Grid.Column="2" BorderThickness="1" BorderBrush="#40808080" Name="FigsBorder">
|
||||
<TextBlock Classes="readout" Name="FigsText" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="3" Classes="caption" Text="MouseOver" />
|
||||
<Border Grid.Column="4" BorderThickness="1" BorderBrush="#40808080" Name="MouseOverBorder">
|
||||
<TextBlock Classes="readout" Name="MouseOverText" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<Grid DockPanel.Dock="Bottom" ColumnDefinitions="*,Auto" Margin="6,2,6,4">
|
||||
<TextBlock Classes="status" Name="StateText" />
|
||||
<TextBlock Grid.Column="1" Classes="status" Name="PauseText" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="4,0,4,2">
|
||||
@@ -96,23 +139,27 @@
|
||||
<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" Name="ReceiveButton" Content="RX" Click="OnReceive" />
|
||||
<Button Classes="control" Content="Ham" Click="OnHamDefault" />
|
||||
<Button Classes="control" Name="LockButton" Content="Lock" Click="OnToggleLock" />
|
||||
<Button Classes="control" Name="AfcButton" Content="AFC" Click="OnToggleAfc" />
|
||||
<Button Classes="control" Name="NetButton" Content="Net" Click="OnToggleNet" />
|
||||
<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>
|
||||
<DockPanel>
|
||||
<Border Name="PauseBar" DockPanel.Dock="Left" Width="10" Background="#2E8B57"
|
||||
Cursor="Hand" PointerPressed="OnPauseBarPressed"
|
||||
ToolTip.Tip="Click to hold the pane still; click again to let it run" />
|
||||
<ScrollViewer Name="ReceiveScroller" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Name="ReceiveLines" Margin="4,2"
|
||||
PointerMoved="OnReceivePointerMoved"
|
||||
PointerPressed="OnReceivePointerPressed"
|
||||
PointerExited="OnReceivePointerExited" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Row="1" Height="4" ResizeDirection="Rows" Background="Transparent" />
|
||||
@@ -123,11 +170,17 @@
|
||||
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.Column="2" RowDefinitions="*,Auto">
|
||||
<Border BorderThickness="1" BorderBrush="#40808080">
|
||||
<ScrollViewer Name="GrabScroller">
|
||||
<StackPanel Name="GrabCalls" Margin="3,2" PointerPressed="OnGrabListPressed" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<WrapPanel Grid.Row="1">
|
||||
<Button Classes="control" Content="Grab" Click="OnGrabNext" />
|
||||
<Button Classes="control" Content="CLR" Click="OnClearGrabList" />
|
||||
</WrapPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
|
||||
@@ -6,7 +6,9 @@ using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Nonemm.App.Configuration;
|
||||
using Nonemm.App.Theming;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Digital;
|
||||
using Nonemm.Session;
|
||||
|
||||
@@ -18,28 +20,47 @@ namespace Nonemm.App.Windows;
|
||||
/// 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.
|
||||
/// How many lines of decoded text are kept in a scrolling pane. A contest
|
||||
/// weekend of RTTY is more than any window needs to hold.
|
||||
private const int KeptLines = 300;
|
||||
|
||||
/// The fewest lines a non-scrolling pane is divided into, for a window too
|
||||
/// short to fit more.
|
||||
private const int LeastLines = 4;
|
||||
|
||||
/// The longest run of noise that is looked at as a callsign.
|
||||
private const int LongestCall = 12;
|
||||
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(45);
|
||||
|
||||
/// What N1MM marks a call its resource files do not hold with.
|
||||
private static readonly IBrush Unknown = Brushes.Gold;
|
||||
|
||||
private readonly AppSession session;
|
||||
private readonly EntryWindow entry;
|
||||
private readonly int radioNumber;
|
||||
private readonly DigitalReceiver receiver = new();
|
||||
private readonly GrabList grab = new();
|
||||
private readonly GrabList grab = new(GrabList.N1mmDepth);
|
||||
|
||||
/// What arrived while the pane was paused, held until it starts again.
|
||||
private readonly StringBuilder held = new();
|
||||
|
||||
private WrapPanel? line;
|
||||
private Border? line;
|
||||
private TextBlock? word;
|
||||
private TextBlock? hovered;
|
||||
private MmttyEngine? engine;
|
||||
private bool paused;
|
||||
private bool lastWasReturn;
|
||||
|
||||
/// Where the next line goes in a non-scrolling pane. It walks down the
|
||||
/// window and starts again at the top; -1 is before the first line.
|
||||
private int lineAt = -1;
|
||||
|
||||
/// The run state and the frequency as they were at the last refresh, so a
|
||||
/// change to either can be acted on. Null until the first refresh.
|
||||
private bool? wasRunning;
|
||||
private double wasKilohertz;
|
||||
|
||||
public DigitalWindow(AppSession session, EntryWindow entry, int radioNumber = 1)
|
||||
{
|
||||
this.session = session;
|
||||
@@ -47,20 +68,30 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
this.radioNumber = radioNumber;
|
||||
InitializeComponent();
|
||||
receiver.CallHeard += (_, call) => Dispatcher.UIThread.Post(() => Heard(call));
|
||||
entry.CqSent += WhenCqSent;
|
||||
entry.Activated += WhenEntryActivated;
|
||||
BuildMacros();
|
||||
ApplySettings();
|
||||
Attach(session.Digital);
|
||||
Refresh();
|
||||
Closed += (_, _) => Detach();
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
entry.CqSent -= WhenCqSent;
|
||||
entry.Activated -= WhenEntryActivated;
|
||||
Detach();
|
||||
};
|
||||
}
|
||||
|
||||
private OperatingPosition? Radio =>
|
||||
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
|
||||
|
||||
private Settings Settings => session.Settings;
|
||||
|
||||
public override void Refresh()
|
||||
{
|
||||
ReceiveScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
GrabScroller.Background = Themes.Brush(Themes.Current.FieldBackground);
|
||||
FollowRunAndFrequency();
|
||||
ShowState();
|
||||
ShowGrabList();
|
||||
LabelMacros();
|
||||
@@ -69,10 +100,10 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
/// 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)
|
||||
if (Settings.DigitalEnginePath.Trim().Length == 0
|
||||
|| Settings.DigitalBridgePath.Trim().Length == 0)
|
||||
{
|
||||
StateText.Text = "no engine — Engine ▸ Settings";
|
||||
StateText.Text = "no engine — Setup ▸ Settings";
|
||||
return;
|
||||
}
|
||||
StateText.Text = "starting the engine…";
|
||||
@@ -119,6 +150,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
receiver.Receive(text);
|
||||
WriteTranscript(text);
|
||||
Print(text);
|
||||
});
|
||||
|
||||
@@ -136,33 +168,104 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
private void WhenReported(object? sender, string text) =>
|
||||
Dispatcher.UIThread.Post(() => StateText.Text = text);
|
||||
|
||||
/// N1MM's "clear grab window on CQ": a run that starts again is a new set
|
||||
/// of callers, and the calls from the last one are of no more use.
|
||||
private void WhenCqSent(object? sender, EventArgs e)
|
||||
{
|
||||
if (Settings.DigitalAfcOnCq)
|
||||
{
|
||||
Switch(MmttySettings.AfcBit, on: true);
|
||||
}
|
||||
if (!Settings.DigitalClearGrabOnCq)
|
||||
{
|
||||
return;
|
||||
}
|
||||
grab.Clear();
|
||||
ShowGrabList();
|
||||
}
|
||||
|
||||
/// N1MM raises the digital window with the entry window it belongs to, so
|
||||
/// the right one of two is in front.
|
||||
private void WhenEntryActivated(object? sender, EventArgs e)
|
||||
{
|
||||
if (Settings.DigitalBringToForeground)
|
||||
{
|
||||
Activate();
|
||||
}
|
||||
}
|
||||
|
||||
/// The two changes N1MM acts on: leaving run for search and pounce puts the
|
||||
/// engine back where it started, and a QSY empties the grab list.
|
||||
private void FollowRunAndFrequency()
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (wasRunning == true && !position.IsRunning)
|
||||
{
|
||||
if (Settings.DigitalHamDefaultOnSearch)
|
||||
{
|
||||
_ = engine?.PostAsync(MmttyMessage.DefaultProfile, 0);
|
||||
}
|
||||
if (Settings.DigitalAfcOffOnSearch)
|
||||
{
|
||||
Switch(MmttySettings.AfcBit, on: false);
|
||||
}
|
||||
}
|
||||
wasRunning = position.IsRunning;
|
||||
double now = position.Frequency.Kilohertz;
|
||||
if (Settings.DigitalClearGrabOnQsy && wasKilohertz != 0 && Math.Abs(now - wasKilohertz) > 0.05)
|
||||
{
|
||||
grab.Clear();
|
||||
}
|
||||
wasKilohertz = now;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// which is how an operator with two of them tells them apart. The
|
||||
/// frequency is the dial frequency with the audio tone added to it, unless
|
||||
/// the radio itself already reads out what it transmits.
|
||||
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)
|
||||
? TitleFrequency(position).ToString("0.00", CultureInfo.InvariantCulture)
|
||||
: "";
|
||||
string what = session.Settings.DigitalEnginePath.Trim().Length > 0
|
||||
? Path.GetFileNameWithoutExtension(session.Settings.DigitalEnginePath)
|
||||
string what = Settings.DigitalEnginePath.Trim().Length > 0
|
||||
? Path.GetFileNameWithoutExtension(Settings.DigitalEnginePath)
|
||||
: "no engine";
|
||||
Title = $"{frequency} {radio} {mode} Mode - {what}";
|
||||
}
|
||||
|
||||
private double TitleFrequency(OperatingPosition position)
|
||||
{
|
||||
double dial = position.Frequency.Kilohertz;
|
||||
if (Settings.DigitalRadioFrequencyInTitle || engine is not { MarkHertz: > 0 } running)
|
||||
{
|
||||
return dial;
|
||||
}
|
||||
// RTTY is worked on the lower sideband, so the tone is below the dial
|
||||
// frequency; the other digital modes are worked on the upper sideband
|
||||
double tone = running.MarkHertz / 1000.0;
|
||||
return position.Mode == Modes.Rtty ? dial - tone : dial + tone;
|
||||
}
|
||||
|
||||
private void ShowState()
|
||||
{
|
||||
ShowTitle();
|
||||
StateText.Text = engine switch
|
||||
{
|
||||
{ IsConnected: true } running =>
|
||||
$"{Path.GetFileName(session.Settings.DigitalEnginePath)} {running.Version}"
|
||||
$"{Path.GetFileName(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;
|
||||
PauseText.Text = paused ? "the pane is held still" : "";
|
||||
AfcButton.FontWeight = IsSwitchOn(MmttySettings.AfcBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
NetButton.FontWeight = IsSwitchOn(MmttySettings.NetBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
ReverseButton.FontWeight = IsSwitchOn(MmttySettings.ReverseBit) ? FontWeight.Bold : FontWeight.Normal;
|
||||
}
|
||||
|
||||
@@ -195,7 +298,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
case ' ':
|
||||
lastWasReturn = false;
|
||||
EndWord();
|
||||
Line().Children.Add(Space());
|
||||
Body(Line()).Children.Add(Space());
|
||||
continue;
|
||||
default:
|
||||
lastWasReturn = false;
|
||||
@@ -203,8 +306,11 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
continue;
|
||||
}
|
||||
}
|
||||
TrimLines();
|
||||
ReceiveScroller.ScrollToEnd();
|
||||
if (Settings.DigitalScrolling)
|
||||
{
|
||||
TrimLines();
|
||||
ReceiveScroller.ScrollToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
private void NewLine()
|
||||
@@ -213,22 +319,71 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
line = null;
|
||||
}
|
||||
|
||||
private WrapPanel Line()
|
||||
/// The line being printed to. A scrolling pane adds one at the bottom; a
|
||||
/// non-scrolling pane walks down the lines it already has, clearing each
|
||||
/// one as it reaches it, and starts again at the top.
|
||||
private Border Line()
|
||||
{
|
||||
if (line is null)
|
||||
if (line is not null)
|
||||
{
|
||||
line = new WrapPanel();
|
||||
ReceiveLines.Children.Add(line);
|
||||
return line;
|
||||
}
|
||||
if (Settings.DigitalScrolling)
|
||||
{
|
||||
line = NewLineBorder();
|
||||
ReceiveLines.Children.Add(line);
|
||||
return line;
|
||||
}
|
||||
FitLines();
|
||||
lineAt = ReceiveLines.Children.Count == 0 ? 0 : (lineAt + 1) % ReceiveLines.Children.Count;
|
||||
line = (Border)ReceiveLines.Children[lineAt];
|
||||
Body(line).Children.Clear();
|
||||
Underline();
|
||||
return line;
|
||||
}
|
||||
|
||||
private static Border NewLineBorder() => new() { Child = new WrapPanel() };
|
||||
|
||||
private static WrapPanel Body(Border border) => (WrapPanel)border.Child!;
|
||||
|
||||
/// Fills a non-scrolling pane with as many empty lines as the window holds,
|
||||
/// so text stays where it was printed rather than moving under the pointer.
|
||||
private void FitLines()
|
||||
{
|
||||
double height = session.Settings.DigitalFontSize * 1.6;
|
||||
int wanted = Math.Max(LeastLines, (int)(ReceiveScroller.Bounds.Height / height));
|
||||
while (ReceiveLines.Children.Count > wanted && ReceiveLines.Children.Count > LeastLines)
|
||||
{
|
||||
ReceiveLines.Children.RemoveAt(ReceiveLines.Children.Count - 1);
|
||||
}
|
||||
while (ReceiveLines.Children.Count < wanted)
|
||||
{
|
||||
ReceiveLines.Children.Add(NewLineBorder());
|
||||
}
|
||||
lineAt = Math.Min(lineAt, ReceiveLines.Children.Count - 1);
|
||||
}
|
||||
|
||||
/// N1MM underlines the line it is printing to in a non-scrolling pane,
|
||||
/// which is the only mark of where the text is arriving.
|
||||
private void Underline()
|
||||
{
|
||||
foreach (Control child in ReceiveLines.Children)
|
||||
{
|
||||
((Border)child).BorderThickness = new Thickness(0);
|
||||
}
|
||||
if (line is not null)
|
||||
{
|
||||
line.BorderThickness = new Thickness(0, 0, 0, 1);
|
||||
line.BorderBrush = Themes.Brush(Themes.Current.FormForeground);
|
||||
}
|
||||
}
|
||||
|
||||
private TextBlock Word()
|
||||
{
|
||||
if (word is null)
|
||||
{
|
||||
word = NewBlock("");
|
||||
Line().Children.Add(word);
|
||||
Body(Line()).Children.Add(word);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
@@ -239,7 +394,7 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
{
|
||||
Text = text,
|
||||
FontFamily = new FontFamily("monospace"),
|
||||
FontSize = session.Settings.DigitalFontSize,
|
||||
FontSize = Settings.DigitalFontSize,
|
||||
};
|
||||
|
||||
/// A finished word is judged: one shaped like a callsign is coloured the
|
||||
@@ -250,14 +405,63 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(word.Text ?? ""));
|
||||
if (DigitalReceiver.IsCallsign(call))
|
||||
if (CallIn(word.Text ?? "") is { Length: > 0 } call)
|
||||
{
|
||||
Colour(word, call);
|
||||
}
|
||||
word = null;
|
||||
}
|
||||
|
||||
/// The callsign in a word, or empty when there is none. N1MM checks between
|
||||
/// the spaces, and with the garbage search on it also looks for a known
|
||||
/// call inside a run of noise.
|
||||
private string CallIn(string text)
|
||||
{
|
||||
string call = DigitalReceiver.TrimToCall(DigitalReceiver.Scrub(text));
|
||||
if (DigitalReceiver.IsCallsign(call) && Wanted(call))
|
||||
{
|
||||
return call;
|
||||
}
|
||||
return Settings.DigitalSearchGarbage ? InGarbage(DigitalReceiver.Scrub(text)) : "";
|
||||
}
|
||||
|
||||
/// N1MM's search for a Master.scp call inside garbage text. The last call
|
||||
/// in the run wins, which is what N1MM's own routine does.
|
||||
private string InGarbage(string text)
|
||||
{
|
||||
string found = "";
|
||||
for (int start = 0; start < text.Length; start++)
|
||||
{
|
||||
for (int length = Math.Min(text.Length - start, LongestCall); length >= 3; length--)
|
||||
{
|
||||
string part = text.Substring(start, length);
|
||||
if (session.Calls.Holds(part))
|
||||
{
|
||||
found = part;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Whether a call passes the highlight routine: everything shaped like a
|
||||
/// call under the generic routine, only calls in the ticked files under the
|
||||
/// resources routine, and everything under the combination, where a call
|
||||
/// the files do not hold is marked instead of dropped.
|
||||
private bool Wanted(string call) =>
|
||||
Settings.DigitalHighlightRoutine.ToLowerInvariant() != "resources" || Known(call);
|
||||
|
||||
private bool Known(string call) =>
|
||||
(Settings.DigitalUseMasterScp && session.Calls.Holds(call))
|
||||
|| (Settings.DigitalUseCallHistory && session.History.Find(call) is not null)
|
||||
|| (Settings.DigitalUseTelnetCalls && Spotted(call))
|
||||
|| (Settings.DigitalUseLoggedCalls && Radio?.Log.WorkedBefore(call).Count > 0);
|
||||
|
||||
private bool Spotted(string call) =>
|
||||
session.Bandmap.All().Any(spot =>
|
||||
string.Equals(spot.Call.Text, call, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void Colour(TextBlock block, string call)
|
||||
{
|
||||
if (Radio is not { } position)
|
||||
@@ -270,13 +474,18 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
return;
|
||||
}
|
||||
Contests.Verdict? verdict = position.JudgeCall(call);
|
||||
if (session.Settings.DigitalHighlightBackground)
|
||||
bool mark = Settings.DigitalHighlightRoutine.ToLowerInvariant() == "both" && !Known(call);
|
||||
if (Settings.DigitalHighlightBackground)
|
||||
{
|
||||
block.Background = Verdicts.Background(verdict);
|
||||
block.Foreground = Themes.TextOn(Verdicts.BackgroundColour(verdict));
|
||||
block.Foreground = mark ? Unknown : Themes.TextOn(Verdicts.BackgroundColour(verdict));
|
||||
return;
|
||||
}
|
||||
block.Foreground = Verdicts.Colour(verdict);
|
||||
if (mark)
|
||||
{
|
||||
block.Background = Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimLines()
|
||||
@@ -287,8 +496,28 @@ public sealed partial class DigitalWindow : RefreshableWindow
|
||||
}
|
||||
}
|
||||
|
||||
/// N1MM keeps a copy of the receive pane in a date-stamped file, so what
|
||||
/// was on the screen can be read again after the program stops.
|
||||
private void WriteTranscript(string text)
|
||||
{
|
||||
if (!Settings.DigitalOutputToTextFile || text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string name = $"{DateTime.UtcNow:MMddyyyy}DigitalInterface{radioNumber}Output.txt";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(session.Paths.ExportFiles);
|
||||
File.AppendAllText(Path.Combine(session.Paths.ExportFiles, name), text);
|
||||
}
|
||||
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
StateText.Text = $"the transcript could not be written: {failure.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private StackOrder StackingOrder() =>
|
||||
session.Settings.DigitalCallStacking.ToLowerInvariant() switch
|
||||
Settings.DigitalCallStacking.ToLowerInvariant() switch
|
||||
{
|
||||
"multipliers" => StackOrder.MultipliersFirst,
|
||||
"firstin" => StackOrder.FirstIn,
|
||||
|
||||
Reference in New Issue
Block a user