Two themes, and one place that decides what colour a station is

Colours came from static brushes scattered over the windows. They now come
from a Theme, and everything that paints a station, a spot or a log row
asks Themes.Current as it paints, so changing the theme changes every
window without reopening it.

light is N1MM's own palette, read out of ApplicationStyles: the pale blue
behind a window, ivory on a button, white boxes, and the four colours a
station is painted in. dark is the same states in colours for a dark
screen, which is roughly what the program looked like before.

Config > Manage Skins, Colors and Fonts picks one, under N1MM's name for
the same dialog, and shows what each theme paints a callsign. The choice
is kept in settings.json as Theme, and light is the default.

The window chrome follows through resources the Fluent controls read, so a
text box and a button take the theme's colours in every state. Fonts and
skins are not part of it.

Looked at under Xvfb in both themes: the entry window, the theme dialog,
and the log window with its rows still coloured by what each contact was
worth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 13:06:32 +00:00
parent e4b3dc9572
commit da04764756
16 changed files with 452 additions and 49 deletions

View File

@@ -234,6 +234,24 @@ sitting on. How close the radio has to be to a spot is one number per mode in
`DigitalTuningToleranceHertz` — 300 Hz each out of the box, which is how N1MM `DigitalTuningToleranceHertz` — 300 Hz each out of the box, which is how N1MM
keeps them and what it defaults them to. keeps them and what it defaults them to.
### Colours
The callsign is coloured as it is typed, in N1MM's own colours: blue for a
station worth working, red when it is new in one multiplier, green when it is
new in more than one, grey for a dupe. A call under three characters stays blue.
The same four colours paint a row in the log, a spot on the bandmap and a line
in the check window, so the three windows cannot disagree.
At the right-hand end of the callsign box is N1MM's mark: a tick when the call
is in `MASTER.SCP`, a question mark while it is not. As in N1MM it only appears
while the check window is open, for three characters or more.
Two themes ship with the program. `light` is N1MM's own palette, down to the
pale blue behind a window and the ivory on a button; `dark` is the same states
in colours for a dark screen. Config ▸ Manage Skins, Colors and Fonts picks one,
and the choice is kept in `settings.json` as `Theme`. Fonts and skins are not
part of it: the name is N1MM's, the dialog only changes colours.
### Where the other station is ### Where the other station is
Under the entry window is the line N1MM writes there: the beam heading, the Under the entry window is the line N1MM writes there: the beam heading, the

View File

@@ -1,11 +1,27 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.App" x:Class="Nonemm.App.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Light">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. --> <!-- the colours behind these keys are filled in from the theme in use, in
App.ApplyTheme -->
<Application.Resources>
<SolidColorBrush x:Key="FormBackground">#CDDAEB</SolidColorBrush>
<SolidColorBrush x:Key="FormForeground">#000000</SolidColorBrush>
<SolidColorBrush x:Key="ButtonBackground">#FFFAF0</SolidColorBrush>
<SolidColorBrush x:Key="FieldBackground">#FFFFFF</SolidColorBrush>
<SolidColorBrush x:Key="FieldForeground">#000000</SolidColorBrush>
</Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" /> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource FormBackground}" />
<Setter Property="Foreground" Value="{DynamicResource FormForeground}" />
</Style>
<Style Selector="Menu">
<Setter Property="Background" Value="{DynamicResource FormBackground}" />
</Style>
</Application.Styles> </Application.Styles>
</Application> </Application>

View File

@@ -1,7 +1,9 @@
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.App.Theming;
using Nonemm.App.Windows; using Nonemm.App.Windows;
namespace Nonemm.App; namespace Nonemm.App;
@@ -16,10 +18,62 @@ public partial class App : Application
{ {
UserPaths paths = UserPaths.Default(); UserPaths paths = UserPaths.Default();
paths.CreateFolders(); paths.CreateFolders();
AppSession session = new(paths, Settings.Load(paths.SettingsFile)); Settings settings = Settings.Load(paths.SettingsFile);
Themes.Use(settings.Theme);
Themes.Changed += (_, _) => ApplyTheme();
ApplyTheme();
AppSession session = new(paths, settings);
desktop.MainWindow = new EntryWindow(session); desktop.MainWindow = new EntryWindow(session);
desktop.ShutdownRequested += (_, _) => session.Dispose(); desktop.ShutdownRequested += (_, _) => session.Dispose();
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
/// The window chrome follows the theme through these resources; everything
/// that draws a station or a spot asks `Themes.Current` as it paints.
private void ApplyTheme()
{
Theme theme = Themes.Current;
RequestedThemeVariant = theme.Variant;
Set("FormBackground", theme.FormBackground);
Set("FormForeground", theme.FormForeground);
Set("FieldBackground", theme.FieldBackground);
Set("FieldForeground", theme.FieldForeground);
// the keys the Fluent controls read, so a text box and a button take
// the theme's colours in every state rather than only where a style of
// ours reaches them
foreach (string key in new[]
{
"TextControlBackground", "TextControlBackgroundPointerOver",
"TextControlBackgroundFocused", "TextControlBackgroundDisabled",
"ComboBoxBackground", "ComboBoxBackgroundPointerOver", "ComboBoxBackgroundPressed",
"DataGridRowBackgroundBrush",
})
{
Set(key, theme.FieldBackground);
}
foreach (string key in new[]
{
"TextControlForeground", "TextControlForegroundPointerOver",
"TextControlForegroundFocused", "ComboBoxForeground", "ButtonForeground",
"ButtonForegroundPointerOver", "ButtonForegroundPressed",
})
{
Set(key, theme.FieldForeground);
}
Set("ButtonBackground", theme.ButtonBackground);
Set("ButtonBackgroundPointerOver", Lighter(theme.ButtonBackground));
Set("ButtonBackgroundPressed", Darker(theme.ButtonBackground));
}
private void Set(string key, Color colour) => Resources[key] = new SolidColorBrush(colour);
private static Color Lighter(Color colour) => Shifted(colour, 12);
private static Color Darker(Color colour) => Shifted(colour, -18);
private static Color Shifted(Color colour, int by) => Color.FromRgb(
(byte)Math.Clamp(colour.R + by, 0, 255),
(byte)Math.Clamp(colour.G + by, 0, 255),
(byte)Math.Clamp(colour.B + by, 0, 255));
} }

View File

@@ -9,6 +9,10 @@ public sealed record Settings
{ {
public string DatabasePath { get; init; } = ""; public string DatabasePath { get; init; } = "";
/// `light` is N1MM's own palette, `dark` the same states in colours for a
/// dark screen. Config > Manage Skins, Colors and Fonts picks one.
public string Theme { get; init; } = "light";
public int ContestNumber { get; init; } public int ContestNumber { get; init; }
public StoredStation Station { get; init; } = new(); public StoredStation Station { get; init; } = new();

View File

@@ -3,6 +3,7 @@ using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Nonemm.App.Theming;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
@@ -22,13 +23,13 @@ public sealed partial class MessagesDialog : Window
/// The three banks of function keys, coloured so the editor shows which /// The three banks of function keys, coloured so the editor shows which
/// line is which key. /// line is which key.
private static readonly IBrush Blue = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9)); private static IBrush Blue => Themes.Brush(Themes.Current.Worth);
private static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60)); private static IBrush Green => Themes.Brush(Themes.Current.Multipliers);
private static readonly IBrush Red = new SolidColorBrush(Color.FromRgb(0xC0, 0x39, 0x2B)); private static IBrush Red => Themes.Brush(Themes.Current.Multiplier);
private static readonly IBrush Grey = new SolidColorBrush(Color.FromRgb(0x7F, 0x8C, 0x8D)); private static IBrush Grey => Themes.Brush(Themes.Current.Dupe);
private readonly ModeCategory mode; private readonly ModeCategory mode;

View File

@@ -0,0 +1,27 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.ThemeDialog"
Title="Colors" Width="360" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Center"
Spacing="30" Margin="0,14,0,0">
<Button Content="Ok" Width="85" HorizontalContentAlignment="Center"
Click="OnOk" IsDefault="True" />
<Button Content="Cancel" Width="85" HorizontalContentAlignment="Center"
Click="OnCancel" IsCancel="True" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Colors" FontSize="11" />
<ComboBox Name="ThemeBox" HorizontalAlignment="Stretch" SelectionChanged="OnPicked" />
<TextBlock Name="AboutText" FontSize="11" TextWrapping="Wrap" />
<ItemsControl Name="Swatches" Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,65 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Nonemm.App.Theming;
namespace Nonemm.App.Dialogs;
/// Which colours the program paints with. N1MM keeps skins, colours and fonts
/// in one place; this picks between the themes that ship with the program and
/// shows what each one paints a station.
public sealed partial class ThemeDialog : Window
{
public ThemeDialog(string current)
{
InitializeComponent();
ThemeBox.ItemsSource = Themes.All.Select(Label).ToList();
ThemeBox.SelectedIndex = Math.Max(0, Themes.All.ToList().FindIndex(t => t.Name == Themes.Find(current).Name));
}
private Theme Picked => Themes.All[Math.Max(0, ThemeBox.SelectedIndex)];
private static string Label(Theme theme) => theme.Name switch
{
"light" => "Light — N1MM's own colours",
"dark" => "Dark — for operating at night",
_ => theme.Name,
};
private void OnPicked(object? sender, SelectionChangedEventArgs e) => ShowSwatches();
private void ShowSwatches()
{
Theme theme = Picked;
AboutText.Text = "What a station is painted in the callsign box, left to right: worth " +
"working, one new multiplier, more than one, a dupe.";
Swatches.ItemsSource = new[]
{
Swatch(theme.Worth, theme.FieldBackground),
Swatch(theme.Multiplier, theme.FieldBackground),
Swatch(theme.Multipliers, theme.FieldBackground),
Swatch(theme.Dupe, theme.FieldBackground),
};
}
private static Border Swatch(Color text, Color background) => new()
{
Background = new SolidColorBrush(background),
Width = 66,
Height = 26,
Margin = new Avalonia.Thickness(0, 0, 6, 6),
Child = new TextBlock
{
Text = "OM3KFF",
FontSize = 11,
Foreground = new SolidColorBrush(text),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
private void OnOk(object? sender, RoutedEventArgs e) => Close(Picked.Name);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}

View File

@@ -0,0 +1,132 @@
using Avalonia.Media;
using Avalonia.Styling;
namespace Nonemm.App.Theming;
/// The colours one theme paints with. The names follow N1MM's
/// `ApplicationStyles`, because the light theme is N1MM's own palette and the
/// windows are laid out the same way.
public sealed record Theme
{
public required string Name { get; init; }
/// Which set of control colours the theme sits on, so a text box or a
/// scroll bar looks right under the colours above.
public required ThemeVariant Variant { get; init; }
/// Behind a window, and the text on it.
public required Color FormBackground { get; init; }
public required Color FormForeground { get; init; }
public required Color ButtonBackground { get; init; }
/// Inside an entry box or a dialog box, and the text in it.
public required Color FieldBackground { get; init; }
public required Color FieldForeground { get; init; }
/// A log row, and every second one.
public required Color GridBackground { get; init; }
public required Color GridAlternateBackground { get; init; }
/// Worth a contact, no new multiplier.
public required Color Worth { get; init; }
public required Color WorthBackground { get; init; }
/// New in one multiplier.
public required Color Multiplier { get; init; }
public required Color MultiplierBackground { get; init; }
/// New in more than one.
public required Color Multipliers { get; init; }
public required Color MultipliersBackground { get; init; }
public required Color Dupe { get; init; }
public required Color DupeBackground { get; init; }
/// The frequency we are calling CQ on.
public required Color Cq { get; init; }
/// Behind a line that is in the log, one that is not filled in yet, and one
/// that cannot be read.
public required Color GoodBackground { get; init; }
public required Color EmptyBackground { get; init; }
public required Color BadBackground { get; init; }
/// The boxes while quick editing a logged contact.
public required Color QuickEdit { get; init; }
/// The light that says a radio is answering, and the one that says
/// something is going out on the air.
public required Color RadioLight { get; init; }
public required Color TransmitLight { get; init; }
/// N1MM's own defaults, from `ApplicationStyles`: the program as it looks
/// on a fresh install.
public static readonly Theme Light = new()
{
Name = "light",
Variant = ThemeVariant.Light,
FormBackground = Color.FromRgb(0xCD, 0xDA, 0xEB),
FormForeground = Color.FromRgb(0x00, 0x00, 0x00),
ButtonBackground = Color.FromRgb(0xFF, 0xFA, 0xF0),
FieldBackground = Color.FromRgb(0xFF, 0xFF, 0xFF),
FieldForeground = Color.FromRgb(0x00, 0x00, 0x00),
GridBackground = Color.FromRgb(0xF2, 0xFC, 0xFF),
GridAlternateBackground = Color.FromRgb(0xAB, 0xC0, 0xDC),
Worth = Color.FromRgb(0x00, 0x00, 0xFF),
WorthBackground = Color.FromRgb(0x51, 0xA8, 0xFF),
Multiplier = Color.FromRgb(0xE8, 0x00, 0x00),
MultiplierBackground = Color.FromRgb(0xFF, 0x37, 0x37),
Multipliers = Color.FromRgb(0x0D, 0x9D, 0x0D),
MultipliersBackground = Color.FromRgb(0x5F, 0xF1, 0x5F),
Dupe = Color.FromRgb(0x80, 0x80, 0x80),
DupeBackground = Color.FromRgb(0xD4, 0xD4, 0xD4),
Cq = Color.FromRgb(0xFF, 0x00, 0x00),
GoodBackground = Color.FromRgb(0xD4, 0xF5, 0xD4),
EmptyBackground = Color.FromRgb(0xFF, 0xE4, 0xE1),
BadBackground = Color.FromRgb(0xFF, 0xF8, 0xC4),
QuickEdit = Color.FromRgb(0xFF, 0xFF, 0xC0),
RadioLight = Color.FromRgb(0x0D, 0x9D, 0x0D),
TransmitLight = Color.FromRgb(0xFF, 0x00, 0x00),
};
/// For operating at night: the same states, in colours that carry on a dark
/// background.
public static readonly Theme Dark = new()
{
Name = "dark",
Variant = ThemeVariant.Dark,
FormBackground = Color.FromRgb(0x20, 0x22, 0x25),
FormForeground = Color.FromRgb(0xE8, 0xE8, 0xE8),
ButtonBackground = Color.FromRgb(0x2E, 0x31, 0x35),
FieldBackground = Color.FromRgb(0x17, 0x19, 0x1B),
FieldForeground = Color.FromRgb(0xE8, 0xE8, 0xE8),
GridBackground = Color.FromRgb(0x1B, 0x1D, 0x20),
GridAlternateBackground = Color.FromRgb(0x23, 0x26, 0x2A),
Worth = Color.FromRgb(0x29, 0x80, 0xB9),
WorthBackground = Color.FromRgb(0x1F, 0x4E, 0x6E),
Multiplier = Color.FromRgb(0xC0, 0x39, 0x2B),
MultiplierBackground = Color.FromRgb(0x6E, 0x24, 0x1C),
Multipliers = Color.FromRgb(0x27, 0xAE, 0x60),
MultipliersBackground = Color.FromRgb(0x1B, 0x5E, 0x36),
Dupe = Color.FromRgb(0x7F, 0x8C, 0x8D),
DupeBackground = Color.FromRgb(0x3A, 0x40, 0x40),
Cq = Color.FromRgb(0xE7, 0x4C, 0x3C),
GoodBackground = Color.FromRgb(0x1E, 0x38, 0x24),
EmptyBackground = Color.FromRgb(0x3A, 0x24, 0x24),
BadBackground = Color.FromRgb(0x3C, 0x36, 0x1C),
QuickEdit = Color.FromRgb(0x4A, 0x44, 0x1E),
RadioLight = Color.FromRgb(0x27, 0xAE, 0x60),
TransmitLight = Color.FromRgb(0xC0, 0x39, 0x2B),
};
}

View File

@@ -0,0 +1,46 @@
using Avalonia.Media;
namespace Nonemm.App.Theming;
/// Which theme the program is painting with. Everything that draws a colour
/// asks here rather than holding a brush of its own, so changing the theme
/// changes every window.
public static class Themes
{
public static readonly IReadOnlyList<Theme> All = [Theme.Light, Theme.Dark];
private static readonly Dictionary<Color, IBrush> brushes = [];
public static Theme Current { get; private set; } = Theme.Light;
/// Raised after the theme changes, for the windows that paint what they
/// hold rather than rebuilding it.
public static event EventHandler? Changed;
public static Theme Find(string name) =>
All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase))
?? Theme.Light;
public static void Use(string name)
{
Theme wanted = Find(name);
if (wanted == Current)
{
return;
}
Current = wanted;
Changed?.Invoke(null, EventArgs.Empty);
}
/// One brush per colour, because a brush is created for every row of every
/// window that is painted.
public static IBrush Brush(Color colour)
{
if (!brushes.TryGetValue(colour, out IBrush? brush))
{
brush = new SolidColorBrush(colour);
brushes[colour] = brush;
}
return brush;
}
}

View File

@@ -1,31 +1,28 @@
using Avalonia.Media; using Avalonia.Media;
using Nonemm.App.Theming;
using Nonemm.Contests; using Nonemm.Contests;
namespace Nonemm.App; namespace Nonemm.App;
/// One place decides what colour a station is, so the callsign box, the frame /// One place decides what colour a station is, so the callsign box, the frame
/// above it, a row in the check window and a spot on the bandmap cannot /// above it, a row in the check window and a spot on the bandmap cannot
/// disagree. The colours are N1MM's own defaults, from ApplicationStyles: /// disagree. The colours come from the theme in use.
/// QColor, MultColor, MultiMultColor and DupeColor.
public static class Verdicts public static class Verdicts
{ {
/// Worth a contact but no new multiplier. public static IBrush Worth => Themes.Brush(Themes.Current.Worth);
public static readonly IBrush Worth = new SolidColorBrush(Color.FromRgb(0x00, 0x00, 0xFF));
/// New in one multiplier. public static IBrush NewMultiplier => Themes.Brush(Themes.Current.Multiplier);
public static readonly IBrush NewMultiplier = new SolidColorBrush(Color.FromRgb(0xE8, 0x00, 0x00));
/// New in more than one, which is worth turning the radio for. public static IBrush NewMultipliers => Themes.Brush(Themes.Current.Multipliers);
public static readonly IBrush NewMultipliers = new SolidColorBrush(Color.FromRgb(0x0D, 0x9D, 0x0D));
public static readonly IBrush Dupe = new SolidColorBrush(Color.FromRgb(0x80, 0x80, 0x80)); public static IBrush Dupe => Themes.Brush(Themes.Current.Dupe);
public static readonly IBrush Nothing = Dupe; public static IBrush Nothing => Dupe;
/// The frequency we are calling CQ on. /// The frequency we are calling CQ on.
public static readonly IBrush Cq = new SolidColorBrush(Color.FromRgb(0xFF, 0x00, 0x00)); public static IBrush Cq => Themes.Brush(Themes.Current.Cq);
/// Nothing typed yet is the same blue as a station worth working: that is /// Nothing typed yet is the same colour as a station worth working: that is
/// what N1MM leaves the callsign box. /// what N1MM leaves the callsign box.
public static IBrush Colour(Verdict? verdict) => public static IBrush Colour(Verdict? verdict) =>
verdict is null ? Worth verdict is null ? Worth
@@ -35,18 +32,13 @@ public static class Verdicts
: Worth; : Worth;
/// The same four states as backgrounds, which is what N1MM paints behind a /// The same four states as backgrounds, which is what N1MM paints behind a
/// spot: QBackColor, MultBackColor and DupeBackColor. /// spot.
public static IBrush Background(Verdict? verdict) => public static IBrush Background(Verdict? verdict) =>
verdict is null ? WorthBack verdict is null ? Themes.Brush(Themes.Current.WorthBackground)
: verdict.IsDupe ? DupeBack : verdict.IsDupe ? Themes.Brush(Themes.Current.DupeBackground)
: verdict.NewMultipliers.Count > 0 ? MultiplierBack : verdict.NewMultipliers.Count > 1 ? Themes.Brush(Themes.Current.MultipliersBackground)
: WorthBack; : verdict.NewMultipliers.Count == 1 ? Themes.Brush(Themes.Current.MultiplierBackground)
: Themes.Brush(Themes.Current.WorthBackground);
private static readonly IBrush WorthBack = new SolidColorBrush(Color.FromRgb(0x51, 0xA8, 0xFF));
private static readonly IBrush MultiplierBack = new SolidColorBrush(Color.FromRgb(0xFF, 0x37, 0x37));
private static readonly IBrush DupeBack = new SolidColorBrush(Color.FromRgb(0xD4, 0xD4, 0xD4));
public static string Describe(Verdict? verdict) public static string Describe(Verdict? verdict)
{ {

View File

@@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.App.Dialogs; using Nonemm.App.Dialogs;
using Nonemm.App.Theming;
using Nonemm.Contests; using Nonemm.Contests;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Core.Country; using Nonemm.Core.Country;
@@ -469,6 +470,18 @@ public sealed partial class EntryWindow
private void OnEditPhoneMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Phone); private void OnEditPhoneMessages(object? sender, RoutedEventArgs e) => _ = EditMessages(ModeCategory.Phone);
private async void OnThemeSettings(object? sender, RoutedEventArgs e)
{
ThemeDialog dialog = new(session.Settings.Theme);
if (await dialog.ShowDialog<string?>(this) is not { } picked)
{
return;
}
session.Save(session.Settings with { Theme = picked });
Themes.Use(picked);
Status($"{picked} colours");
}
private async void OnQtcSetup(object? sender, RoutedEventArgs e) private async void OnQtcSetup(object? sender, RoutedEventArgs e)
{ {
QtcSetupDialog dialog = new(session.Settings); QtcSetupDialog dialog = new(session.Settings);

View File

@@ -118,6 +118,7 @@
<MenuItem Header="Change _SSB Function Key Definitions" Click="OnEditPhoneMessages" /> <MenuItem Header="Change _SSB Function Key Definitions" Click="OnEditPhoneMessages" />
</MenuItem> </MenuItem>
<MenuItem Header="Change Band Plan…" Click="OnSubBandSettings" /> <MenuItem Header="Change Band Plan…" Click="OnSubBandSettings" />
<MenuItem Header="Manage Skins, Colors and Fonts…" Click="OnThemeSettings" />
<MenuItem Header="Edit Networked-Computer Names…" Click="OnNetworkSettings" /> <MenuItem Header="Edit Networked-Computer Names…" Click="OnNetworkSettings" />
<MenuItem Header="WAE"> <MenuItem Header="WAE">
<MenuItem Header="Open QTC Window setup area" Click="OnQtcSetup" /> <MenuItem Header="Open QTC Window setup area" Click="OnQtcSetup" />

View File

@@ -7,6 +7,7 @@ using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Nonemm.Contests; using Nonemm.Contests;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.App.Theming;
using Nonemm.Session; using Nonemm.Session;
namespace Nonemm.App.Windows; namespace Nonemm.App.Windows;
@@ -24,13 +25,6 @@ public sealed partial class EntryWindow : Window
private static readonly IBrush Dark = new SolidColorBrush(Color.FromArgb(0x33, 0x80, 0x80, 0x80)); private static readonly IBrush Dark = new SolidColorBrush(Color.FromArgb(0x33, 0x80, 0x80, 0x80));
private static readonly IBrush GreenLight = new SolidColorBrush(Color.FromRgb(0x0D, 0x9D, 0x0D));
private static readonly IBrush RedLight = new SolidColorBrush(Color.FromRgb(0xFF, 0x00, 0x00));
/// The pale yellow N1MM paints the entry boxes while quick editing.
private static readonly IBrush QuickEditBackground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xC0));
private bool sending; private bool sending;
private TextBlock? callMark; private TextBlock? callMark;
private TextBox? nameBox; private TextBox? nameBox;
@@ -52,6 +46,8 @@ public sealed partial class EntryWindow : Window
BuildEntryBoxes(); BuildEntryBoxes();
ShowSecondRadio(); ShowSecondRadio();
}); });
Themes.Changed += OnThemeChanged;
Closed += (_, _) => Themes.Changed -= OnThemeChanged;
clock.Tick += (_, _) => UpdateClock(); clock.Tick += (_, _) => UpdateClock();
clock.Start(); clock.Start();
session.ActiveRadioChanged += (_, number) => session.ActiveRadioChanged += (_, number) =>
@@ -67,6 +63,8 @@ public sealed partial class EntryWindow : Window
} }
private void OnThemeChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Refresh);
/// Resolved every time rather than held, because opening a contest builds /// Resolved every time rather than held, because opening a contest builds
/// new positions. /// new positions.
private OperatingPosition? Logging => private OperatingPosition? Logging =>
@@ -697,8 +695,8 @@ public sealed partial class EntryWindow : Window
} }
else else
{ {
box.Background = QuickEditBackground; box.Background = Themes.Brush(Themes.Current.QuickEdit);
box.Foreground = Brushes.Black; box.Foreground = Themes.Brush(Themes.Current.FormForeground);
} }
} }
FrequencyText.Text = Logging.TransmitFrequency.Hertz > 0 FrequencyText.Text = Logging.TransmitFrequency.Hertz > 0
@@ -719,7 +717,7 @@ public sealed partial class EntryWindow : Window
ShowCallColour(verdict); ShowCallColour(verdict);
VerdictBorder.Background = Verdicts.Background(verdict); VerdictBorder.Background = Verdicts.Background(verdict);
VerdictText.Text = VerdictLine(verdict); VerdictText.Text = VerdictLine(verdict);
VerdictText.Foreground = Brushes.Black; VerdictText.Foreground = Themes.Brush(Themes.Current.FormForeground);
foreach (Window window in openWindows.Values) foreach (Window window in openWindows.Values)
{ {
(window as RefreshableWindow)?.Refresh(); (window as RefreshableWindow)?.Refresh();
@@ -781,8 +779,8 @@ public sealed partial class EntryWindow : Window
private void ShowLights() private void ShowLights()
{ {
bool radio = session.Radios.Any(r => r.Number == radioNumber && r.IsConnected); bool radio = session.Radios.Any(r => r.Number == radioNumber && r.IsConnected);
RadioLight.Fill = radio ? GreenLight : Dark; RadioLight.Fill = radio ? Themes.Brush(Themes.Current.RadioLight) : Dark;
TransmitLight.Fill = sending ? RedLight : Dark; TransmitLight.Fill = sending ? Themes.Brush(Themes.Current.TransmitLight) : Dark;
} }
/// What N1MM writes in its title bar: the frequency, the mode, whether the /// What N1MM writes in its title bar: the frequency, the mode, whether the

View File

@@ -5,6 +5,7 @@ using Avalonia.Media;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.App.Dialogs; using Nonemm.App.Dialogs;
using Nonemm.Contests.Rules; using Nonemm.Contests.Rules;
using Nonemm.App.Theming;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
@@ -20,9 +21,11 @@ namespace Nonemm.App.Windows;
/// SSB by voice, on RTTY from the digital window there is not yet. /// SSB by voice, on RTTY from the digital window there is not yet.
public sealed partial class QtcWindow : Window public sealed partial class QtcWindow : Window
{ {
private static readonly IBrush Saved = new SolidColorBrush(Color.FromRgb(0xD4, 0xF5, 0xD4)); private static IBrush Saved => Themes.Brush(Themes.Current.GoodBackground);
private static readonly IBrush Unsaved = new SolidColorBrush(Color.FromRgb(0xFF, 0xE4, 0xE1));
private static readonly IBrush Malformed = new SolidColorBrush(Color.FromRgb(0xFF, 0xF8, 0xC4)); private static IBrush Unsaved => Themes.Brush(Themes.Current.EmptyBackground);
private static IBrush Malformed => Themes.Brush(Themes.Current.BadBackground);
private readonly AppSession session; private readonly AppSession session;
private readonly OperatingPosition position; private readonly OperatingPosition position;

View File

@@ -5,6 +5,7 @@ using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Nonemm.App.Configuration; using Nonemm.App.Configuration;
using Nonemm.App.Theming;
using Nonemm.Core; using Nonemm.Core;
using Nonemm.Session; using Nonemm.Session;
using Nonemm.Spotting; using Nonemm.Spotting;
@@ -18,9 +19,11 @@ public sealed partial class TelnetWindow : RefreshableWindow
{ {
private const int LinesKept = 500; private const int LinesKept = 500;
private static readonly IBrush SpotColour = new SolidColorBrush(Color.FromRgb(0x27, 0xAE, 0x60)); private static IBrush SpotColour => Themes.Brush(Themes.Current.Multipliers);
private static readonly IBrush SentColour = new SolidColorBrush(Color.FromRgb(0x29, 0x80, 0xB9));
private static readonly IBrush NoticeColour = new SolidColorBrush(Color.FromRgb(0x7F, 0x8C, 0x8D)); private static IBrush SentColour => Themes.Brush(Themes.Current.Worth);
private static IBrush NoticeColour => Themes.Brush(Themes.Current.Dupe);
private readonly AppSession session; private readonly AppSession session;
private readonly Action<Frequency, string> tune; private readonly Action<Frequency, string> tune;

View File

@@ -0,0 +1,30 @@
using Nonemm.App.Theming;
namespace Nonemm.App.Tests;
public class ThemesTests
{
[Fact]
public void TheThemesAreFoundByName()
{
Assert.Equal("light", Themes.Find("light").Name);
Assert.Equal("dark", Themes.Find("DARK").Name);
}
[Fact]
public void AThemeNobodyShipsFallsBackToTheN1mmColours() =>
Assert.Equal(Theme.Light, Themes.Find("solarized"));
[Fact]
public void OneBrushIsHandedOutPerColour() =>
Assert.Same(Themes.Brush(Theme.Light.Worth), Themes.Brush(Theme.Light.Worth));
[Fact]
public void EveryThemePaintsTheFourStatesApart()
{
foreach (Theme theme in Themes.All)
{
Assert.Equal(4, new[] { theme.Worth, theme.Multiplier, theme.Multipliers, theme.Dupe }.Distinct().Count());
}
}
}