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
co-authored by Claude Opus 5
parent e4b3dc9572
commit da04764756
16 changed files with 452 additions and 49 deletions
+132
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),
};
}
+46
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;
}
}