diff --git a/README.md b/README.md
index d41433d..64e30c0 100644
--- a/README.md
+++ b/README.md
@@ -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
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
Under the entry window is the line N1MM writes there: the beam heading, the
diff --git a/src/Nonemm.App/App.axaml b/src/Nonemm.App/App.axaml
index 76dc11b..4eb7fe3 100644
--- a/src/Nonemm.App/App.axaml
+++ b/src/Nonemm.App/App.axaml
@@ -1,11 +1,27 @@
-
+ RequestedThemeVariant="Light">
+
+
+ #CDDAEB
+ #000000
+ #FFFAF0
+ #FFFFFF
+ #000000
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/Nonemm.App/App.axaml.cs b/src/Nonemm.App/App.axaml.cs
index 45db542..d1c5a40 100644
--- a/src/Nonemm.App/App.axaml.cs
+++ b/src/Nonemm.App/App.axaml.cs
@@ -1,7 +1,9 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using Avalonia.Media;
using Nonemm.App.Configuration;
+using Nonemm.App.Theming;
using Nonemm.App.Windows;
namespace Nonemm.App;
@@ -16,10 +18,62 @@ public partial class App : Application
{
UserPaths paths = UserPaths.Default();
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.ShutdownRequested += (_, _) => session.Dispose();
}
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));
}
diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs
index 0072257..dfbd63d 100644
--- a/src/Nonemm.App/Configuration/Settings.cs
+++ b/src/Nonemm.App/Configuration/Settings.cs
@@ -9,6 +9,10 @@ public sealed record Settings
{
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 StoredStation Station { get; init; } = new();
diff --git a/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs b/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs
index fde043c..a5ecc59 100644
--- a/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs
+++ b/src/Nonemm.App/Dialogs/MessagesDialog.axaml.cs
@@ -3,6 +3,7 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
+using Nonemm.App.Theming;
using Nonemm.Core;
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
/// 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;
diff --git a/src/Nonemm.App/Dialogs/ThemeDialog.axaml b/src/Nonemm.App/Dialogs/ThemeDialog.axaml
new file mode 100644
index 0000000..fa0ec62
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ThemeDialog.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs b/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs
new file mode 100644
index 0000000..26d6ddc
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs
@@ -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);
+}
diff --git a/src/Nonemm.App/Theming/Theme.cs b/src/Nonemm.App/Theming/Theme.cs
new file mode 100644
index 0000000..95b787c
--- /dev/null
+++ b/src/Nonemm.App/Theming/Theme.cs
@@ -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),
+ };
+}
diff --git a/src/Nonemm.App/Theming/Themes.cs b/src/Nonemm.App/Theming/Themes.cs
new file mode 100644
index 0000000..8b314c2
--- /dev/null
+++ b/src/Nonemm.App/Theming/Themes.cs
@@ -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 All = [Theme.Light, Theme.Dark];
+
+ private static readonly Dictionary 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;
+ }
+}
diff --git a/src/Nonemm.App/Verdicts.cs b/src/Nonemm.App/Verdicts.cs
index d6b062e..cd6e978 100644
--- a/src/Nonemm.App/Verdicts.cs
+++ b/src/Nonemm.App/Verdicts.cs
@@ -1,31 +1,28 @@
using Avalonia.Media;
+using Nonemm.App.Theming;
using Nonemm.Contests;
namespace Nonemm.App;
/// 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
-/// disagree. The colours are N1MM's own defaults, from ApplicationStyles:
-/// QColor, MultColor, MultiMultColor and DupeColor.
+/// disagree. The colours come from the theme in use.
public static class Verdicts
{
- /// Worth a contact but no new multiplier.
- public static readonly IBrush Worth = new SolidColorBrush(Color.FromRgb(0x00, 0x00, 0xFF));
+ public static IBrush Worth => Themes.Brush(Themes.Current.Worth);
- /// New in one multiplier.
- public static readonly IBrush NewMultiplier = new SolidColorBrush(Color.FromRgb(0xE8, 0x00, 0x00));
+ public static IBrush NewMultiplier => Themes.Brush(Themes.Current.Multiplier);
- /// New in more than one, which is worth turning the radio for.
- public static readonly IBrush NewMultipliers = new SolidColorBrush(Color.FromRgb(0x0D, 0x9D, 0x0D));
+ public static IBrush NewMultipliers => Themes.Brush(Themes.Current.Multipliers);
- 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.
- 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.
public static IBrush Colour(Verdict? verdict) =>
verdict is null ? Worth
@@ -35,18 +32,13 @@ public static class Verdicts
: Worth;
/// 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) =>
- verdict is null ? WorthBack
- : verdict.IsDupe ? DupeBack
- : verdict.NewMultipliers.Count > 0 ? MultiplierBack
- : WorthBack;
-
- 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));
+ verdict is null ? Themes.Brush(Themes.Current.WorthBackground)
+ : verdict.IsDupe ? Themes.Brush(Themes.Current.DupeBackground)
+ : verdict.NewMultipliers.Count > 1 ? Themes.Brush(Themes.Current.MultipliersBackground)
+ : verdict.NewMultipliers.Count == 1 ? Themes.Brush(Themes.Current.MultiplierBackground)
+ : Themes.Brush(Themes.Current.WorthBackground);
public static string Describe(Verdict? verdict)
{
diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
index ef8cbdc..27d0845 100644
--- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
@@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Nonemm.App.Configuration;
using Nonemm.App.Dialogs;
+using Nonemm.App.Theming;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
@@ -469,6 +470,18 @@ public sealed partial class EntryWindow
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(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)
{
QtcSetupDialog dialog = new(session.Settings);
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
index 4ea1e8a..a6e9a08 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -118,6 +118,7 @@
+