diff --git a/README.md b/README.md
index d195a19..0d48bae 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Most AV kit ships set to DHCP, and on a bench or a fresh site there's usually no
- **Several devices at once** behind a small switch, each with its own lease.
- **Spots a USB adapter that has stopped receiving.** Some USB Ethernet adapters keep showing "connected" but quietly stop receiving after a few seconds, which looks exactly like a silent device. If nothing has come in for 20 seconds while the laptop keeps sending, the cable/link step says so and tells you to replug the adapter. Realtek RTL8152/8153 adapters on a driver older than 2019 (including Windows' own 2015 driver) are flagged as soon as you pick them, with where to get the newer driver.
- **Wi-Fi and VPN overlap warnings**, with a one-click option to turn Wi-Fi off until you press Stop.
-- **Dark and light themes**, with large status text you can read from arm's length.
+- **Dark and light themes** and a choice of looks (rack faceplate, Frutiger Aero, Metal Heart), with large status text you can read from arm's length.
**Phase 2: visibility**
@@ -104,7 +104,7 @@ Each phase ends with a hardware test pass on real AV equipment before the next o
| What | Where |
|---|---|
-| Your settings (last adapter, profile in use, theme) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
+| Your settings (last adapter, profile in use, look and theme) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
| Your profiles | `%LOCALAPPDATA%\ProDHCPServer\profiles.json` |
| Adapter backup (only while a session runs, or after a crash) | `%ProgramData%\ProDHCPServer\adapter-backup.json` |
diff --git a/src/ProDHCPServer.App/App.xaml b/src/ProDHCPServer.App/App.xaml
index 27e628e..456c969 100644
--- a/src/ProDHCPServer.App/App.xaml
+++ b/src/ProDHCPServer.App/App.xaml
@@ -11,184 +11,15 @@
+
- pack://application:,,,/Assets/Fonts/#Figtree
- pack://application:,,,/Assets/Fonts/#League Spartan
- pack://application:,,,/Assets/Fonts/#Figtree
- Cascadia Mono, Consolas, Courier New
- Segoe Fluent Icons, Segoe MDL2 Assets
-
-
- 13
- 16
- 20
- 40
- 64
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/src/ProDHCPServer.App/App.xaml.cs b/src/ProDHCPServer.App/App.xaml.cs
index a6d0f7d..7a3dbce 100644
--- a/src/ProDHCPServer.App/App.xaml.cs
+++ b/src/ProDHCPServer.App/App.xaml.cs
@@ -27,7 +27,8 @@ protected override void OnStartup(StartupEventArgs e)
base.OnStartup(e);
var userFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ProDHCPServer");
var settingsStore = new SettingsStore(Path.Combine(userFolder, "settings.json"));
- Theme.Apply(settingsStore.Load().Theme);
+ var startSettings = settingsStore.Load();
+ Theme.Apply(startSettings.Theme, startSettings.Look);
if (!Elevation.IsAdministrator())
{
diff --git a/src/ProDHCPServer.App/Assets/Fonts/OFL-Orbitron.txt b/src/ProDHCPServer.App/Assets/Fonts/OFL-Orbitron.txt
new file mode 100644
index 0000000..91ede45
--- /dev/null
+++ b/src/ProDHCPServer.App/Assets/Fonts/OFL-Orbitron.txt
@@ -0,0 +1,93 @@
+Copyright 2018 The Orbitron Project Authors (https://github.com/theleagueof/orbitron), with Reserved Font Name: "Orbitron"
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/ProDHCPServer.App/Assets/Fonts/Orbitron-Bold.ttf b/src/ProDHCPServer.App/Assets/Fonts/Orbitron-Bold.ttf
new file mode 100644
index 0000000..3252adc
Binary files /dev/null and b/src/ProDHCPServer.App/Assets/Fonts/Orbitron-Bold.ttf differ
diff --git a/src/ProDHCPServer.App/Converters.cs b/src/ProDHCPServer.App/Converters.cs
index ca50b70..7532a7a 100644
--- a/src/ProDHCPServer.App/Converters.cs
+++ b/src/ProDHCPServer.App/Converters.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
+using System.Windows.Markup;
using ProDHCPServer.Core;
using ProDHCPServer.Core.Status;
@@ -36,6 +37,24 @@ public sealed class StateBrushConverter : IValueConverter
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException();
}
+///
+/// IsRunning → the Start/Stop button's Background or Foreground (the ConverterParameter): solid ink to start, a quiet
+/// button to stop, since the status field already says the server is live. Looked up again after a look change.
+///
+public sealed class RunningBrushConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ bool running = value is true;
+ var key = parameter as string == "Foreground"
+ ? (running ? "TextPrimary" : "AccentText")
+ : (running ? "RaisedBg" : "AccentBrush");
+ return Application.Current.TryFindResource(key);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException();
+}
+
/// StepState → a short word for screen readers and the colour-blind.
public sealed class StateWordConverter : IValueConverter
{
@@ -100,13 +119,29 @@ public object Convert(object? value, Type targetType, object? parameter, Culture
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException();
}
-/// ActualHeight → a corner radius of half the height, so a Border is a true pill or circle at any size.
-public sealed class PillRadiusConverter : IValueConverter
+///
+/// A corner radius of a Border's height times the look's ButtonCornerScale, which the Border carries in its Tag
+/// (0.5 makes a true pill or circle at any size, 0 a square). A fixed large radius would draw ovals in WPF, and the Tag
+/// lets the radius follow a live look change. Used as CornerRadius="{local:CornerScale}".
+///
+public sealed class CornerScaleExtension : MarkupExtension, IMultiValueConverter
{
- public static readonly PillRadiusConverter Instance = new();
+ public override object ProvideValue(IServiceProvider serviceProvider) => new MultiBinding
+ {
+ Converter = this,
+ Bindings =
+ {
+ new Binding(nameof(FrameworkElement.ActualHeight)) { RelativeSource = RelativeSource.Self },
+ new Binding(nameof(FrameworkElement.Tag)) { RelativeSource = RelativeSource.Self },
+ },
+ }.ProvideValue(serviceProvider);
- public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- new CornerRadius(value is double h && h > 0 ? h / 2 : 0);
+ public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
+ {
+ var height = values.Length > 0 && values[0] is double h ? h : 0;
+ var scale = values.Length > 1 && values[1] is double d ? d : 0.5;
+ return new CornerRadius(height > 0 ? height * scale : 0);
+ }
- public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => throw new NotSupportedException();
+ public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture) => throw new NotSupportedException();
}
diff --git a/src/ProDHCPServer.App/MainWindow.xaml b/src/ProDHCPServer.App/MainWindow.xaml
index c47d087..34fd4f9 100644
--- a/src/ProDHCPServer.App/MainWindow.xaml
+++ b/src/ProDHCPServer.App/MainWindow.xaml
@@ -12,7 +12,7 @@
Title="ProDHCPServer" Width="1320" Height="860" MinWidth="900" MinHeight="600"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource WindowBg}"
- FontFamily="{StaticResource TextFont}"
+ FontFamily="{DynamicResource TextFont}"
Closing="OnClosing"
SourceInitialized="OnSourceInitialized">
@@ -40,7 +40,8 @@
-
+
+
@@ -87,12 +88,15 @@
ItemsSource="{Binding Settings.Profiles}" SelectedItem="{Binding Settings.SelectedProfile}" DisplayMemberPath="Display"
IsEnabled="{Binding SettingsEditable}" ToolTip="The saved settings Start uses: subnet, pool, lease time and DHCP options" />
-
-
-
+
-
+
-
+
+
-
-
+
-
-
-
+
+
-
@@ -171,8 +167,8 @@
-
-
+
+
-
@@ -191,11 +187,11 @@
-
-
-
+
-
-
@@ -227,7 +223,7 @@
-
+
@@ -257,6 +253,17 @@
+
+
+
+
+
@@ -277,10 +284,10 @@
-
+
-
+
@@ -298,7 +305,7 @@
-
@@ -309,9 +316,9 @@
-
-
@@ -340,7 +347,7 @@
-
@@ -350,10 +357,10 @@
-
-
-
-
+
+
+
+
@@ -413,7 +420,7 @@
-
@@ -444,7 +451,7 @@
-
+
@@ -459,17 +466,17 @@
Foreground="{Binding StateColour, Converter={StaticResource StateBrush}}" />
-
-
-
@@ -481,11 +488,11 @@
-
+
-
+
@@ -520,8 +527,8 @@
-
-
+
@@ -552,13 +559,14 @@
Text="{Binding SelectedItem.Entry, ElementName=LogList, Mode=OneWay, Converter={x:Static local:LogDetailConverter.Instance}, TargetNullValue='Select a log line to see its details.', FallbackValue='Select a log line to see its details.'}" />
-
+
-
@@ -567,9 +575,9 @@
-
-
@@ -577,15 +585,15 @@
-
-
+
-
@@ -596,20 +604,20 @@
IsEnabled="{Binding CanManage}" FontSize="{StaticResource TextBody}" HorizontalAlignment="Stretch" />
-
-
-
-
-
+
+
+
+
-
-
@@ -659,7 +667,7 @@
-
@@ -718,28 +726,34 @@
FontSize="{StaticResource TextSmall}" AutomationProperties.Name="Value type" />
-
-
+
-
-
+
+
+
+
+
+
-
+
diff --git a/src/ProDHCPServer.App/Theme.cs b/src/ProDHCPServer.App/Theme.cs
index d8c3e71..4cb0164 100644
--- a/src/ProDHCPServer.App/Theme.cs
+++ b/src/ProDHCPServer.App/Theme.cs
@@ -1,46 +1,46 @@
using System.Windows;
-using System.Windows.Media;
using Microsoft.Win32;
namespace ProDHCPServer.App;
///
-/// The app's own colour tokens, swapped together with WPF's Fluent ThemeMode so custom panels and
-/// standard controls always match. Status colours are tuned for readability at arm's length.
+/// Picks the app's look (resource dictionaries under Themes/) and swaps it together with WPF's Fluent ThemeMode so
+/// custom panels and standard controls always match. Every colour, font and shape the XAML reads with DynamicResource
+/// comes from the current look, so switching looks repaints the window live.
+///
+/// A look is Themes/Name.Dark.xaml and Themes/Name.Light.xaml (colours, fonts, backdrop), each merging Shared.xaml
+/// (type scale, text styles, default shapes) and optionally a Name.Shapes.xaml that replaces the panel, status field,
+/// top bar, banner or button styles with its own templates. To add one: copy Faceplate's two files, change the values,
+/// and add one line to .
///
public static class Theme
{
- private static readonly Dictionary Tokens = new()
+ /// One selectable look. is the file name prefix and what settings.json stores.
+ public sealed record Look(string Id, string Name, string Blurb)
{
- // A rack faceplate: one ground, a surface one tone step up, a raised step for the slide-over and selected rows.
- // Surfaces are separated by tone, never by lines or shadows.
- ["WindowBg"] = ("#0B0D10", "#E6E8EB"),
- ["PanelBg"] = ("#15181C", "#F4F5F7"),
- ["RaisedBg"] = ("#1E2227", "#FFFFFF"),
- ["RowHover"] = ("#252A30", "#EBEDF0"),
- ["Scrim"] = ("#A6000000", "#66000000"),
- ["TextPrimary"] = ("#F2F4F6", "#0E1114"),
- ["TextSecondary"] = ("#B3BAC3", "#474F59"),
- ["TextMuted"] = ("#8A929C", "#5D6570"),
- // Status LED colours, tuned for readability at arm's length. These are the only hues in the app.
- ["GoodBrush"] = ("#3DD068", "#1A7F37"),
- ["WarnBrush"] = ("#F2A928", "#B26B00"),
- ["BadBrush"] = ("#FF5A52", "#CF222E"),
- ["IdleBrush"] = ("#4A515A", "#A3AAB3"),
- ["GoodSoft"] = ("#10291A", "#D9F2E0"),
- ["WarnSoft"] = ("#2E2309", "#FBEACB"),
- ["BadSoft"] = ("#361515", "#F9DADC"),
- ["IdleSoft"] = ("#15181C", "#F4F5F7"),
- // The action colour is the ink itself: primary buttons are solid ink with ground-coloured text.
- ["AccentBrush"] = ("#F2F4F6", "#0E1114"),
- ["AccentText"] = ("#0B0D10", "#FFFFFF"),
- ["MonoBg"] = ("#0B0D10", "#ECEEF1"),
- };
+ public override string ToString() => Name;
+ }
+
+ public static IReadOnlyList Looks { get; } =
+ [
+ new("Faceplate", "Rack faceplate", "The default: a flat front panel, one status light at every scale."),
+ new("FrutigerAero", "Frutiger Aero", "Glass widgets and orb lights over a sky full of bubbles, 2000s desktop style."),
+ new("MetalHeart", "Metal Heart", "Riveted steel plates over shattered chrome, black and white with a red accent."),
+ ];
+
+ public static Look DefaultLook => Looks[0];
public static bool IsDark { get; private set; }
- /// Applies "System", "Dark" or "Light".
- public static void Apply(string mode)
+ public static Look CurrentLook { get; private set; } = DefaultLook;
+
+ private static ResourceDictionary? _current;
+
+ /// Finds a look by its id, falling back to the default for unknown or old values in settings.json.
+ public static Look Find(string? id) => Looks.FirstOrDefault(l => l.Id == id) ?? DefaultLook;
+
+ /// Applies a light-or-dark mode ("System", "Dark" or "Light") and a look id.
+ public static void Apply(string mode, string? lookId)
{
bool dark = mode switch
{
@@ -48,15 +48,20 @@ public static void Apply(string mode)
"Light" => false,
_ => SystemPrefersDark(),
};
+ var look = Find(lookId);
IsDark = dark;
+ CurrentLook = look;
var app = Application.Current;
app.ThemeMode = dark ? ThemeMode.Dark : ThemeMode.Light;
- foreach (var (key, (d, l)) in Tokens)
+ var next = new ResourceDictionary
{
- var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(dark ? d : l));
- brush.Freeze();
- app.Resources[key] = brush;
- }
+ Source = new Uri($"pack://application:,,,/Themes/{look.Id}.{(dark ? "Dark" : "Light")}.xaml"),
+ };
+ // The look goes last so its keys win over Fluent's own dictionaries (added by ThemeMode), e.g. the control font.
+ var merged = app.Resources.MergedDictionaries;
+ if (_current is not null) merged.Remove(_current);
+ merged.Add(next);
+ _current = next;
}
private static bool SystemPrefersDark()
diff --git a/src/ProDHCPServer.App/Themes/Faceplate.Dark.xaml b/src/ProDHCPServer.App/Themes/Faceplate.Dark.xaml
new file mode 100644
index 0000000..f433789
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/Faceplate.Dark.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pack://application:,,,/Assets/Fonts/#Figtree
+ pack://application:,,,/Assets/Fonts/#League Spartan
+ pack://application:,,,/Assets/Fonts/#Figtree
+
diff --git a/src/ProDHCPServer.App/Themes/Faceplate.Light.xaml b/src/ProDHCPServer.App/Themes/Faceplate.Light.xaml
new file mode 100644
index 0000000..716eede
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/Faceplate.Light.xaml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pack://application:,,,/Assets/Fonts/#Figtree
+ pack://application:,,,/Assets/Fonts/#League Spartan
+ pack://application:,,,/Assets/Fonts/#Figtree
+
diff --git a/src/ProDHCPServer.App/Themes/FrutigerAero.Dark.xaml b/src/ProDHCPServer.App/Themes/FrutigerAero.Dark.xaml
new file mode 100644
index 0000000..4084748
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/FrutigerAero.Dark.xaml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Segoe UI
+ Segoe UI
+ Segoe UI
+
diff --git a/src/ProDHCPServer.App/Themes/FrutigerAero.Light.xaml b/src/ProDHCPServer.App/Themes/FrutigerAero.Light.xaml
new file mode 100644
index 0000000..96b5975
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/FrutigerAero.Light.xaml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Segoe UI
+ Segoe UI
+ Segoe UI
+
diff --git a/src/ProDHCPServer.App/Themes/FrutigerAero.Shapes.xaml b/src/ProDHCPServer.App/Themes/FrutigerAero.Shapes.xaml
new file mode 100644
index 0000000..6c35dbb
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/FrutigerAero.Shapes.xaml
@@ -0,0 +1,75 @@
+
+
+
+ 18
+ 10
+ 1
+ 1,0,0,0
+ 12,8
+
+
+ 0.5
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProDHCPServer.App/Themes/MetalHeart.Dark.xaml b/src/ProDHCPServer.App/Themes/MetalHeart.Dark.xaml
new file mode 100644
index 0000000..ae1ea80
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/MetalHeart.Dark.xaml
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pack://application:,,,/Assets/Fonts/#Figtree
+ pack://application:,,,/Assets/Fonts/#Orbitron
+ pack://application:,,,/Assets/Fonts/#Figtree
+
diff --git a/src/ProDHCPServer.App/Themes/MetalHeart.Light.xaml b/src/ProDHCPServer.App/Themes/MetalHeart.Light.xaml
new file mode 100644
index 0000000..241b99e
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/MetalHeart.Light.xaml
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pack://application:,,,/Assets/Fonts/#Figtree
+ pack://application:,,,/Assets/Fonts/#Orbitron
+ pack://application:,,,/Assets/Fonts/#Figtree
+
diff --git a/src/ProDHCPServer.App/Themes/MetalHeart.Shapes.xaml b/src/ProDHCPServer.App/Themes/MetalHeart.Shapes.xaml
new file mode 100644
index 0000000..f2dec11
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/MetalHeart.Shapes.xaml
@@ -0,0 +1,127 @@
+
+
+
+ 6
+ 3
+ 1
+ 2,0,0,0
+ 12,8
+
+
+ 0.18
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProDHCPServer.App/Themes/Shared.xaml b/src/ProDHCPServer.App/Themes/Shared.xaml
new file mode 100644
index 0000000..1025258
--- /dev/null
+++ b/src/ProDHCPServer.App/Themes/Shared.xaml
@@ -0,0 +1,242 @@
+
+
+ Cascadia Mono, Consolas, Courier New
+ Segoe Fluent Icons, Segoe MDL2 Assets
+
+
+ 13
+ 16
+ 20
+ 40
+ 64
+
+
+
+ 0
+ 0
+ 0
+
+ 0
+
+ 0.5
+
+
+
+
+ 0
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
index 3393094..ac05f6c 100644
--- a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
+++ b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
@@ -45,7 +45,7 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
_settings = settingsStore.Load();
_monitor = new DeviceMonitor(log);
if (profiles.Load(_settings) is { } profileProblem) log.Add(LogSeverity.Warning, LogCategory.App, profileProblem);
- Settings = new SettingsViewModel(profiles, _settings.ActiveProfile, _settings.Theme);
+ Settings = new SettingsViewModel(profiles, _settings.ActiveProfile, _settings.Theme, Theme.Find(_settings.Look));
Settings.ActiveProfileChanged += profile =>
{
if (_settings.ActiveProfile != profile.Name)
@@ -61,6 +61,12 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
SaveSettings();
ApplyTheme();
};
+ Settings.LookChanged += look =>
+ {
+ _settings = _settings with { Look = look.Id };
+ SaveSettings();
+ ApplyTheme();
+ };
Steps = new ObservableCollection(Enum.GetValues().Select(id => new StepViewModel(id)));
RebuildLog();
@@ -806,9 +812,10 @@ private void DismissError()
public void ApplyTheme()
{
- Theme.Apply(_settings.Theme);
+ Theme.Apply(_settings.Theme, _settings.Look);
foreach (var s in Steps) s.Repaint();
OnPropertyChanged(nameof(HeadlineState));
+ OnPropertyChanged(nameof(IsRunning));
}
private void OpenUrl(string url)
diff --git a/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs b/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs
index 0d1dcd8..b1311be 100644
--- a/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs
+++ b/src/ProDHCPServer.App/ViewModels/SettingsViewModel.cs
@@ -7,6 +7,7 @@
using ProDHCPServer.Core.Dhcp;
using ProDHCPServer.Core.Net;
using ProDHCPServer.Core.Profiles;
+using LookInfo = ProDHCPServer.App.Theme.Look;
namespace ProDHCPServer.App.ViewModels;
@@ -51,10 +52,11 @@ public sealed partial class SettingsViewModel : ObservableObject
private ISettingsDialogs? _dialogs;
private bool _loading;
- public SettingsViewModel(ProfileLibrary library, string? activeProfile, string theme)
+ public SettingsViewModel(ProfileLibrary library, string? activeProfile, string theme, LookInfo look)
{
_library = library;
_theme = theme;
+ _look = look;
ReloadList();
var active = _library.Find(activeProfile) ?? ProfilePresets.Default;
_selectedProfile = Profiles.First(p => p.Name == active.Name);
@@ -68,6 +70,7 @@ public SettingsViewModel(ProfileLibrary library, string? activeProfile, string t
Enumerable.Range(8, 23).Reverse().Select(p => new PrefixOption(p)).ToList();
public static IReadOnlyList Themes { get; } = ["System", "Dark", "Light"];
+ public static IReadOnlyList Looks => global::ProDHCPServer.App.Theme.Looks;
public static IReadOnlyList Formats { get; } = Enum.GetValues();
public ObservableCollection Profiles { get; } = [];
@@ -79,6 +82,7 @@ public SettingsViewModel(ProfileLibrary library, string? activeProfile, string t
/// Raised when a different profile is picked, or the active one is saved or renamed.
public event Action? ActiveProfileChanged;
public event Action? ThemeChanged;
+ public event Action? LookChanged;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPreset), nameof(CanEdit), nameof(CanDelete))]
@@ -111,6 +115,7 @@ public SettingsViewModel(ProfileLibrary library, string? activeProfile, string t
[ObservableProperty] private string _option43 = "";
[ObservableProperty] private bool _alwaysBroadcast;
[ObservableProperty] private string _theme;
+ [ObservableProperty] private LookInfo _look;
[ObservableProperty] private string _errors = "";
/// A one-off message after import/export/delete.
[ObservableProperty] private string _notice = "";
@@ -135,6 +140,8 @@ protected override void OnPropertyChanged(PropertyChangedEventArgs e)
partial void OnThemeChanged(string value) => ThemeChanged?.Invoke(value);
+ partial void OnLookChanged(LookInfo value) => LookChanged?.Invoke(value);
+
partial void OnSelectedProfileChanged(ProfileItem? oldValue, ProfileItem newValue)
{
if (_loading || newValue is null) return;
diff --git a/src/ProDHCPServer.Core/AppSettings.cs b/src/ProDHCPServer.Core/AppSettings.cs
index e27bd3c..ca7b932 100644
--- a/src/ProDHCPServer.Core/AppSettings.cs
+++ b/src/ProDHCPServer.Core/AppSettings.cs
@@ -21,6 +21,8 @@ public sealed record AppSettings
public bool AlwaysBroadcast { get; init; }
/// "System", "Dark" or "Light".
public string Theme { get; init; } = "System";
+ /// Which look the window uses (a name from the App's theme list); unknown values fall back to the default.
+ public string Look { get; init; } = "Faceplate";
}
public sealed class SettingsStore(string path)
diff --git a/tools/smoke-test.ps1 b/tools/smoke-test.ps1
index 900b744..6a30ac4 100644
--- a/tools/smoke-test.ps1
+++ b/tools/smoke-test.ps1
@@ -1,4 +1,4 @@
-# Launches the published exe in each theme, checks it stays up, and saves a screenshot.
+# Launches the published exe in each look (dark and light), checks it stays up, and saves a screenshot.
# Runs on the CI runner, which is already elevated, so no UAC prompt appears.
param(
[Parameter(Mandatory)] [string] $Exe,
@@ -19,15 +19,21 @@ function Save-Screen([string] $path) {
$g.Dispose(); $bmp.Dispose()
}
-# Each shot: theme, file name, extra arguments. The last one opens the settings slide-over.
+# Each shot: dark or light, look, file name, extra arguments. "--open-settings" opens the settings slide-over.
+# The first three keep their names: README screenshots are taken from them.
$shots = @(
- @{ Theme = "Dark"; Name = "main-dark"; Args = @() },
- @{ Theme = "Light"; Name = "main-light"; Args = @() },
- @{ Theme = "Dark"; Name = "settings-dark"; Args = @("--open-settings") }
+ @{ Theme = "Dark"; Look = "Faceplate"; Name = "main-dark"; Args = @() },
+ @{ Theme = "Light"; Look = "Faceplate"; Name = "main-light"; Args = @() },
+ @{ Theme = "Dark"; Look = "Faceplate"; Name = "settings-dark"; Args = @("--open-settings") },
+ @{ Theme = "Light"; Look = "FrutigerAero"; Name = "aero-light"; Args = @() },
+ @{ Theme = "Dark"; Look = "FrutigerAero"; Name = "aero-dark"; Args = @() },
+ @{ Theme = "Light"; Look = "FrutigerAero"; Name = "aero-settings-light"; Args = @("--open-settings") },
+ @{ Theme = "Dark"; Look = "MetalHeart"; Name = "metal-dark"; Args = @() },
+ @{ Theme = "Light"; Look = "MetalHeart"; Name = "metal-light"; Args = @() },
+ @{ Theme = "Dark"; Look = "MetalHeart"; Name = "metal-settings-dark"; Args = @("--open-settings") }
)
foreach ($shot in $shots) {
- $theme = $shot.Theme
- @{ Theme = $theme } | ConvertTo-Json | Set-Content -Path (Join-Path $settingsDir "settings.json") -Encoding utf8
+ @{ Theme = $shot.Theme; Look = $shot.Look } | ConvertTo-Json | Set-Content -Path (Join-Path $settingsDir "settings.json") -Encoding utf8
if ($shot.Args.Count -gt 0) { $p = Start-Process -FilePath $Exe -ArgumentList $shot.Args -PassThru }
else { $p = Start-Process -FilePath $Exe -PassThru }
Start-Sleep -Seconds 15