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
9 changes: 9 additions & 0 deletions release-notes/v1.2.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## What's new

- **Light or dark window.** A new theme button at the bottom of the sidebar switches the window between following Windows, always light, and always dark. Your choice is kept for the next start. Thanks to @MarcelL94 for building it (#13).

## Fixes

- **Black text in dark mode.** Some text in the window, such as the sidebar links, the quality preset cards and the on/off switches, showed in black on the dark background and was hard to read. It now uses the theme's text colour in both light and dark mode. Thanks to @MarcelL94 (#13).

- **Photo scenery missing on the first flight after installing.** If you ticked **Launch OrthoStreamXP now** at the end of the installer and then pressed **Start XP**, X-Plane started without the photo scenery and removed the OrthoStream packages from its scenery list. Restarting OrthoStreamXP made it work. **Start XP** now starts X-Plane the same way a double-click in Windows does, so the first flight shows the photo scenery too. Your **X-Plane start options** are still passed to X-Plane as before. Thanks to @MarcelL94 for reporting it (#14).
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
130 changes: 130 additions & 0 deletions src/OrthoStreamXP/Setup/Installation/DesktopShell.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Runtime.InteropServices;
using Microsoft.CSharp.RuntimeBinder;

namespace OrthoStreamXP.Setup;

/// <summary>
/// Starts programs through the desktop's Explorer, as a double-click there would, so they get the person's own
/// environment instead of inheriting this program's. Started by the installer's "Launch" box, this program's
/// environment is the installer's, and an X-Plane started from it left the mounted packages out of its scenery.
/// </summary>
internal static class DesktopShell
{
private const int CsidlDesktop = 0, SwcDesktop = 8, SwfoNeedDispatch = 1, SvgioBackground = 0, SwShowNormal = 1;
private static readonly Guid ShellWindowsClass = new("9BA05972-F6A8-11CF-A442-00A0C90A8F39");
private static readonly Guid TopLevelBrowserService = new("4C96BE40-915C-11CF-99D3-00AA004AE837");
private static readonly Guid ShellBrowserInterface = typeof(IShellBrowser).GUID;
private static readonly Guid DispatchInterface = new("00020400-0000-0000-C000-000000000046");

/// <summary>
/// Asks the desktop's Explorer to start <paramref name="program"/>. Returns false, after logging why, when it could not
/// (no Explorer running as the desktop); call this from an STA thread such as the window's.
/// </summary>
public static bool TryStart(string program, string arguments, string workingDirectory)
{
var pending = new List<object>();
try
{
object shellWindows = Keep(pending, Activator.CreateInstance(Type.GetTypeFromCLSID(ShellWindowsClass, throwOnError: true)!)!);
object location = CsidlDesktop;
object? root = null; // an empty VARIANT
object? desktop = ((IShellWindows)shellWindows).FindWindowSW(ref location, ref root, SwcDesktop, out _, SwfoNeedDispatch);
if (desktop is null)
{
Log.Warning("The desktop's Explorer is not running; X-Plane is started directly");
return false;
}
Keep(pending, desktop);
Guid service = TopLevelBrowserService, shellBrowser = ShellBrowserInterface, dispatch = DispatchInterface;
var browser = (IShellBrowser)Keep(pending, ((IServiceProvider)desktop).QueryService(ref service, ref shellBrowser));
IShellView view = browser.QueryActiveShellView();
Keep(pending, view);
// The desktop view's Shell.Application lives in Explorer, so its ShellExecute starts the program from there.
dynamic folderView = Keep(pending, view.GetItemObject(SvgioBackground, ref dispatch));
dynamic application = Keep(pending, folderView.Application);
application.ShellExecute(program, arguments, workingDirectory, "", SwShowNormal);
return true;
}
catch (Exception ex) when (ex is COMException or InvalidCastException or RuntimeBinderException)
{
Log.Warning($"The desktop's Explorer could not start {program} ({ex.Message}); it is started directly");
return false;
}
finally
{
foreach (object comObject in pending)
if (Marshal.IsComObject(comObject)) Marshal.ReleaseComObject(comObject);
}
}

private static object Keep(List<object> pending, object comObject)
{
pending.Add(comObject);
return comObject;
}

// Only the methods used are called; the Slot methods keep the others' places in the interfaces' tables.
#pragma warning disable CA1707, IDE1006 // the slot names mark unused table entries
[ComImport, Guid("85CB6900-4D95-11CF-960C-0080C7F4EE85"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
private interface IShellWindows
{
void Slot_Count();
void Slot_Item();
void Slot_NewEnum();
void Slot_Register();
void Slot_RegisterPending();
void Slot_Revoke();
void Slot_OnNavigate();
void Slot_OnActivated();

[return: MarshalAs(UnmanagedType.IDispatch)]
object? FindWindowSW(ref object location, ref object? root, int windowClass, out int window, int options);
}

[ComImport, Guid("6D5140C1-7436-11CE-8034-00AA006009FA"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IServiceProvider
{
[return: MarshalAs(UnmanagedType.IUnknown)]
object QueryService(ref Guid service, ref Guid interfaceId);
}

[ComImport, Guid("000214E2-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellBrowser
{
void Slot_GetWindow();
void Slot_ContextSensitiveHelp();
void Slot_InsertMenusSB();
void Slot_SetMenuSB();
void Slot_RemoveMenusSB();
void Slot_SetStatusTextSB();
void Slot_EnableModelessSB();
void Slot_TranslateAcceleratorSB();
void Slot_BrowseObject();
void Slot_GetViewStateStream();
void Slot_GetControlWindow();
void Slot_SendControlMsg();

IShellView QueryActiveShellView();
}

[ComImport, Guid("000214E3-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellView
{
void Slot_GetWindow();
void Slot_ContextSensitiveHelp();
void Slot_TranslateAccelerator();
void Slot_EnableModeless();
void Slot_UIActivate();
void Slot_Refresh();
void Slot_CreateViewWindow();
void Slot_DestroyViewWindow();
void Slot_GetCurrentInfo();
void Slot_AddPropertySheetPages();
void Slot_SaveViewState();
void Slot_SelectItem();

[return: MarshalAs(UnmanagedType.IUnknown)]
object GetItemObject(int item, ref Guid interfaceId);
}
#pragma warning restore CA1707, IDE1006
}
13 changes: 10 additions & 3 deletions src/OrthoStreamXP/Setup/Installation/SystemCheck.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,17 @@ public static IReadOnlyList<string> TakenInCustomScenery(string? xplane, IEnumer
return File.Exists(exe) ? exe : null;
}

/// <summary>Starts X-Plane from its folder, as a double-click on X-Plane.exe would.</summary>
/// <summary>
/// Starts X-Plane from its folder, as a double-click on X-Plane.exe would: through the desktop's Explorer, so it does not
/// inherit this program's environment (directly only when Explorer cannot).
/// </summary>
/// <param name="arguments">Added to its command line, such as --allow_reshade; null or empty for none.</param>
public static void StartXPlane(string program, string? arguments = null) =>
Process.Start(new ProcessStartInfo(program, arguments?.Trim() ?? "") { WorkingDirectory = Path.GetDirectoryName(program)!, UseShellExecute = true })?.Dispose();
public static void StartXPlane(string program, string? arguments = null)
{
string folder = Path.GetDirectoryName(program)!, options = arguments?.Trim() ?? "";
if (DesktopShell.TryStart(program, options, folder)) return;
Process.Start(new ProcessStartInfo(program, options) { WorkingDirectory = folder, UseShellExecute = true })?.Dispose();
}

public static bool IsXPlaneRunning()
{
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