Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/OrthoStreamXP/Gui/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ protected override void OnStartup(StartupEventArgs e)
Log.Configure(LogOutput.None, null);
DispatcherUnhandledException += OnUnhandledException;
_main = new MainViewModel();
_main.Preferences.Theme.Apply();
var window = new MainWindow(_main);
MainWindow = window;
window.Show();
Expand Down
71 changes: 71 additions & 0 deletions src/OrthoStreamXP/Gui/AppTheme.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Windows;
using Microsoft.Win32;

namespace OrthoStreamXP.Gui;

/// <summary>The window's colours: the Windows theme (follows Windows), or always light or dark.</summary>
internal enum AppTheme
{
System,
Light,
Dark,
}

/// <summary>The sidebar's theme button: what it shows for a theme, and the theme a click switches to.</summary>
internal sealed record ThemeChoice(string Label, string Glyph, AppTheme Theme)
{
public static ThemeChoice Windows { get; } = new("Windows theme", "", AppTheme.System);
public static ThemeChoice Light { get; } = new("Light theme", "", AppTheme.Light);
public static ThemeChoice Dark { get; } = new("Dark theme", "", AppTheme.Dark);

public static ThemeChoice Of(AppTheme theme) => theme switch
{
AppTheme.Light => Light,
AppTheme.Dark => Dark,
_ => Windows,
};

/// <summary>
/// The Windows theme (follows Windows), then the other look, then Windows' look kept fixed, then the Windows theme again:
/// the first click always changes the colours.
/// </summary>
public ThemeChoice Next(Func<bool> windowsUsesLight)
{
ThemeChoice same = windowsUsesLight() ? Light : Dark;
ThemeChoice other = same == Light ? Dark : Light;
if (this == Windows) return other;
return this == other ? same : Windows;
}
}

internal static class AppThemeExtensions
{
#pragma warning disable WPF0001 // WPF's Fluent theme is marked experimental.
/// <summary>The WPF Fluent theme mode for the choice.</summary>
public static ThemeMode ToThemeMode(this AppTheme theme) => theme switch
{
AppTheme.Light => ThemeMode.Light,
AppTheme.Dark => ThemeMode.Dark,
_ => ThemeMode.System,
};

/// <summary>Switches the running application to the theme; the window's DynamicResource brushes follow.</summary>
public static void Apply(this AppTheme theme)
{
if (Application.Current is { } app) app.ThemeMode = theme.ToThemeMode();
}
#pragma warning restore WPF0001
}

/// <summary>Whether Windows shows apps light or dark (Settings > Personalisation > Colours).</summary>
internal static class WindowsTheme
{
private const string Personalize = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";

/// <summary>True for light apps, also when Windows does not say (older versions default to light).</summary>
public static bool AppsUseLight()
{
using RegistryKey? key = Registry.CurrentUser.OpenSubKey(Personalize);
return key?.GetValue("AppsUseLightTheme") is not int value || value != 0;
}
}
10 changes: 10 additions & 0 deletions src/OrthoStreamXP/Gui/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@
</StackPanel>
</Button>

<!-- Theme: Windows (follows Windows), then the other look, then Windows' look kept fixed, then Windows again -->
<Button DockPanel.Dock="Bottom" Style="{StaticResource SidebarLink}" Margin="4,0,4,0"
Command="{Binding ChangeTheme}" AutomationProperties.Name="{Binding Theme.Label, StringFormat='{}{0} (click to change)'}"
ToolTip="Switch the window's colours: same as Windows, light or dark.">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource Icon}" Text="{Binding Theme.Glyph}" Width="20" />
<TextBlock Text="{Binding Theme.Label}" Margin="12,0,0,0" VerticalAlignment="Center" />
</StackPanel>
</Button>

<ListBox ItemsSource="{Binding Navigation}" SelectedItem="{Binding SelectedNav}"
ItemContainerStyle="{StaticResource NavItem}" Background="Transparent" BorderThickness="0"
AutomationProperties.Name="Pages" ScrollViewer.HorizontalScrollBarVisibility="Disabled" />
Expand Down
8 changes: 8 additions & 0 deletions src/OrthoStreamXP/Gui/Theme.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
<gui:VisibleWhen x:Key="Visible" />
<gui:EqualsParameter x:Key="Is" />

<!--
A Style set on a control replaces Fluent's implicit one, so the classic theme's black text would show through:
the styles below set the Foreground themselves.
-->

<!-- Text -->
<Style x:Key="PageTitle" TargetType="TextBlock">
<Setter Property="FontSize" Value="28" />
Expand Down Expand Up @@ -116,6 +121,7 @@

<!-- A sidebar link: looks like a navigation item, but opens a web page -->
<Style x:Key="SidebarLink" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Setter Property="Margin" Value="4,2" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{DynamicResource DefaultControlFocusVisualStyle}" />
Expand All @@ -140,6 +146,7 @@

<!-- A choice shown as a card (quality presets, detected folders): an accent border when chosen -->
<Style x:Key="ChoiceCard" TargetType="RadioButton">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{DynamicResource DefaultControlFocusVisualStyle}" />
<Setter Property="Margin" Value="0,0,0,6" />
Expand Down Expand Up @@ -196,6 +203,7 @@

<!-- An on/off setting as a Windows 11 toggle switch: its text ("On", "Off") on the left, the switch on the right -->
<Style x:Key="Switch" TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalAlignment" Value="Center" />
Expand Down
23 changes: 23 additions & 0 deletions src/OrthoStreamXP/Gui/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Input;
using OrthoStreamXP.Setup;

Expand All @@ -25,6 +26,10 @@ internal sealed class WindowPreferences
/// <summary>Download a mesh region's roads, forests and buildings (its overlays) with it.</summary>
public bool DownloadOverlays { get; set; } = true;

/// <summary>Light, dark, or the same as Windows.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<AppTheme>))]
public AppTheme Theme { get; set; }

public static WindowPreferences Load()
{
try
Expand Down Expand Up @@ -79,6 +84,7 @@ public MainViewModel()
_selected = Navigation[0];
HandleProblem = new RelayCommand<ProblemAction>(Handle);
Donate = new RelayCommand(() => Shell.Open(AboutViewModel.DonationPage));
ChangeTheme = new RelayCommand(() => Theme = Theme.Next(WindowsTheme.AppsUseLight));
Mount.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(MountController.State)) Raise(nameof(SidebarStatus));
Expand Down Expand Up @@ -140,6 +146,23 @@ private set
/// <summary>Opens the donation page, from the sidebar.</summary>
public ICommand Donate { get; }

/// <summary>The sidebar's theme button: Windows, the other look than Windows', Windows' look kept fixed, back to Windows.</summary>
public ICommand ChangeTheme { get; }

/// <summary>The window's theme (the Windows one follows Windows): applied at once and remembered for the next start.</summary>
public ThemeChoice Theme
{
get => ThemeChoice.Of(Preferences.Theme);
private set
{
if (value.Theme == Preferences.Theme) return;
Preferences.Theme = value.Theme;
Preferences.Save();
value.Theme.Apply();
Raise();
}
}

public void Show(IPage page) => SelectedNav = Navigation.First(n => n.Page == page);

/// <summary>Called when the window has opened: setup on the first run, otherwise the auto-start.</summary>
Expand Down
51 changes: 51 additions & 0 deletions tests/OrthoStreamXP.Tests/AppThemeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Text.Json;
using System.Windows;
using OrthoStreamXP.Gui;

namespace OrthoStreamXP.Tests;

/// <summary>The window's theme choice: what each entry switches WPF to, and how window.json keeps it.</summary>
public sealed class AppThemeTests
{
#pragma warning disable WPF0001
[Fact]
public void Each_choice_maps_to_its_wpf_theme_mode()
{
Assert.Equal(ThemeMode.System, AppTheme.System.ToThemeMode());
Assert.Equal(ThemeMode.Light, AppTheme.Light.ToThemeMode());
Assert.Equal(ThemeMode.Dark, AppTheme.Dark.ToThemeMode());
}
#pragma warning restore WPF0001

[Theory]
[InlineData(true)]
[InlineData(false)]
public void Clicks_go_windows_then_the_other_look_then_the_windows_look_then_windows(bool windowsUsesLight)
{
AppTheme same = windowsUsesLight ? AppTheme.Light : AppTheme.Dark;
AppTheme other = windowsUsesLight ? AppTheme.Dark : AppTheme.Light;
ThemeChoice choice = ThemeChoice.Of(AppTheme.System);
var seen = new List<AppTheme>();
for (int i = 0; i < 4; i++, choice = choice.Next(() => windowsUsesLight)) seen.Add(choice.Theme);

Assert.Equal([AppTheme.System, other, same, AppTheme.System], seen);
}

[Fact]
public void Each_saved_theme_shows_as_its_own_choice()
{
Assert.Same(ThemeChoice.Windows, ThemeChoice.Of(AppTheme.System));
Assert.Same(ThemeChoice.Light, ThemeChoice.Of(AppTheme.Light));
Assert.Same(ThemeChoice.Dark, ThemeChoice.Of(AppTheme.Dark));
}

[Fact]
public void The_theme_is_kept_by_name_and_older_files_without_it_follow_windows()
{
string json = JsonSerializer.Serialize(new WindowPreferences { Theme = AppTheme.Dark });

Assert.Contains("\"Theme\":\"Dark\"", json, StringComparison.Ordinal);
Assert.Equal(AppTheme.Dark, JsonSerializer.Deserialize<WindowPreferences>(json)!.Theme);
Assert.Equal(AppTheme.System, JsonSerializer.Deserialize<WindowPreferences>("{\"AutoStart\":true}")!.Theme);
}
}
Loading