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:
18
README.md
18
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
|
`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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
27
src/Nonemm.App/Dialogs/ThemeDialog.axaml
Normal file
27
src/Nonemm.App/Dialogs/ThemeDialog.axaml
Normal 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>
|
||||||
65
src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs
Normal file
65
src/Nonemm.App/Dialogs/ThemeDialog.axaml.cs
Normal 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);
|
||||||
|
}
|
||||||
132
src/Nonemm.App/Theming/Theme.cs
Normal file
132
src/Nonemm.App/Theming/Theme.cs
Normal 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
src/Nonemm.App/Theming/Themes.cs
Normal file
46
src/Nonemm.App/Theming/Themes.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
30
tests/Nonemm.App.Tests/ThemesTests.cs
Normal file
30
tests/Nonemm.App.Tests/ThemesTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user