Skip to content

Commit 1f9bfa9

Browse files
committed
Added light mode and option to follow system theme
1 parent 1d16a01 commit 1f9bfa9

31 files changed

Lines changed: 651 additions & 219 deletions

src/EventLogExpert.UI.Tests/Services/SettingsServiceTests.cs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,141 @@ public void ShowDisplayPaneOnSelectionChange_WhenSetToSameValue_ShouldNotUpdateP
488488
mockPreferences.DidNotReceive().DisplayPaneSelectionPreference = Arg.Any<bool>();
489489
}
490490

491+
[Fact]
492+
public void Theme_WhenAccessedMultipleTimes_ShouldCacheValue()
493+
{
494+
// Arrange
495+
var mockPreferences = Substitute.For<IPreferencesProvider>();
496+
mockPreferences.ThemePreference.Returns(Theme.Light);
497+
498+
var settingsService = CreateSettingsService(mockPreferences);
499+
500+
// Act
501+
_ = settingsService.Theme;
502+
_ = settingsService.Theme;
503+
_ = settingsService.Theme;
504+
505+
// Assert
506+
_ = mockPreferences.Received(1).ThemePreference;
507+
}
508+
509+
[Fact]
510+
public void Theme_WhenFirstAccessed_ShouldReturnFromPreferences()
511+
{
512+
// Arrange
513+
var mockPreferences = Substitute.For<IPreferencesProvider>();
514+
mockPreferences.ThemePreference.Returns(Theme.Light);
515+
516+
var settingsService = CreateSettingsService(mockPreferences);
517+
518+
// Act
519+
var result = settingsService.Theme;
520+
521+
// Assert
522+
Assert.Equal(Theme.Light, result);
523+
}
524+
525+
[Fact]
526+
public void Theme_WhenPreferenceIsDefault_ShouldReturnSystem()
527+
{
528+
// Arrange
529+
var mockPreferences = Substitute.For<IPreferencesProvider>();
530+
531+
var settingsService = CreateSettingsService(mockPreferences);
532+
533+
// Act
534+
var result = settingsService.Theme;
535+
536+
// Assert
537+
Assert.Equal(Theme.System, result);
538+
}
539+
540+
[Fact]
541+
public void Theme_WhenSet_ShouldUpdatePreferences()
542+
{
543+
// Arrange
544+
var mockPreferences = Substitute.For<IPreferencesProvider>();
545+
var settingsService = CreateSettingsService(mockPreferences);
546+
547+
// Act
548+
settingsService.Theme = Theme.Dark;
549+
550+
// Assert
551+
mockPreferences.Received(1).ThemePreference = Theme.Dark;
552+
}
553+
554+
[Fact]
555+
public void Theme_WhenSetToDifferentValue_ShouldInvokeChangedEvent()
556+
{
557+
// Arrange
558+
var mockPreferences = Substitute.For<IPreferencesProvider>();
559+
var settingsService = CreateSettingsService(mockPreferences);
560+
561+
var eventInvoked = false;
562+
settingsService.ThemeChanged = () => eventInvoked = true;
563+
564+
// Act
565+
settingsService.Theme = Theme.Light;
566+
567+
// Assert
568+
Assert.True(eventInvoked);
569+
}
570+
571+
[Theory]
572+
[InlineData(Theme.System)]
573+
[InlineData(Theme.Light)]
574+
[InlineData(Theme.Dark)]
575+
public void Theme_WhenSetToEachValue_ShouldPersistCorrectly(Theme theme)
576+
{
577+
// Arrange
578+
var mockPreferences = Substitute.For<IPreferencesProvider>();
579+
var settingsService = CreateSettingsService(mockPreferences);
580+
581+
// Act
582+
settingsService.Theme = theme;
583+
584+
// Assert
585+
mockPreferences.Received(1).ThemePreference = theme;
586+
}
587+
588+
[Fact]
589+
public void Theme_WhenSetToSameValue_ShouldNotInvokeChangedEvent()
590+
{
591+
// Arrange
592+
var mockPreferences = Substitute.For<IPreferencesProvider>();
593+
mockPreferences.ThemePreference.Returns(Theme.Light);
594+
595+
var settingsService = CreateSettingsService(mockPreferences);
596+
_ = settingsService.Theme; // Cache the value
597+
598+
var eventInvoked = false;
599+
settingsService.ThemeChanged = () => eventInvoked = true;
600+
601+
// Act
602+
settingsService.Theme = Theme.Light;
603+
604+
// Assert
605+
Assert.False(eventInvoked);
606+
}
607+
608+
[Fact]
609+
public void Theme_WhenSetToSameValue_ShouldNotUpdatePreferences()
610+
{
611+
// Arrange
612+
var mockPreferences = Substitute.For<IPreferencesProvider>();
613+
mockPreferences.ThemePreference.Returns(Theme.Dark);
614+
615+
var settingsService = CreateSettingsService(mockPreferences);
616+
_ = settingsService.Theme; // Cache the value
617+
mockPreferences.ClearReceivedCalls();
618+
619+
// Act
620+
settingsService.Theme = Theme.Dark;
621+
622+
// Assert
623+
mockPreferences.DidNotReceive().ThemePreference = Arg.Any<Theme>();
624+
}
625+
491626
[Fact]
492627
public void TimeZoneId_WhenAccessedMultipleTimes_ShouldCacheValue()
493628
{

src/EventLogExpert.UI/Enums.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,10 @@ public enum FilterType
9797
Advanced,
9898
Cached
9999
}
100+
101+
public enum Theme
102+
{
103+
System,
104+
Light,
105+
Dark
106+
}

src/EventLogExpert.UI/Interfaces/IPreferencesProvider.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,7 @@ public interface IPreferencesProvider
3232

3333
IEnumerable<FilterGroupModel> SavedFiltersPreference { get; set; }
3434

35+
Theme ThemePreference { get; set; }
36+
3537
string TimeZonePreference { get; set; }
3638
}

src/EventLogExpert.UI/Interfaces/ISettingsService.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public interface ISettingsService
2121

2222
bool ShowDisplayPaneOnSelectionChange { get; set; }
2323

24+
Theme Theme { get; set; }
25+
26+
Action? ThemeChanged { get; set; }
27+
2428
EventHandler<TimeZoneInfo>? TimeZoneChanged { get; set; }
2529

2630
string TimeZoneId { get; set; }

src/EventLogExpert.UI/Services/SettingsService.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public sealed class SettingsService(IPreferencesProvider preferences) : ISetting
1414
private bool? _isPreReleaseEnabled;
1515
private LogLevel? _logLevel;
1616
private bool? _showDisplayPaneOnSelectionChange;
17+
private Theme? _theme;
1718
private string? _timeZoneId;
1819

1920
public CopyType CopyType
@@ -92,6 +93,26 @@ public bool ShowDisplayPaneOnSelectionChange
9293
}
9394
}
9495

96+
public Theme Theme
97+
{
98+
get
99+
{
100+
_theme ??= _preferences.ThemePreference;
101+
102+
return _theme ?? Theme.System;
103+
}
104+
set
105+
{
106+
if (_theme == value) { return; }
107+
108+
_theme = value;
109+
_preferences.ThemePreference = value;
110+
ThemeChanged?.Invoke();
111+
}
112+
}
113+
114+
public Action? ThemeChanged { get; set; }
115+
95116
public EventHandler<TimeZoneInfo>? TimeZoneChanged { get; set; }
96117

97118
public string TimeZoneId

src/EventLogExpert/App.xaml.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using EventLogExpert.Eventing.EventResolvers;
55
using EventLogExpert.Eventing.Helpers;
66
using EventLogExpert.Services;
7+
using EventLogExpert.UI;
78
using EventLogExpert.UI.Interfaces;
89
using EventLogExpert.UI.Models;
910
using EventLogExpert.UI.Options;
@@ -19,6 +20,7 @@ namespace EventLogExpert;
1920
public sealed partial class App : Application
2021
{
2122
private readonly MainPage _mainPage;
23+
private readonly ISettingsService _settings;
2224

2325
public App(
2426
IDispatcher fluxorDispatcher,
@@ -40,6 +42,13 @@ public App(
4042
{
4143
InitializeComponent();
4244

45+
_settings = settings;
46+
47+
// Apply native (XAML) theme before constructing MainPage so the initial
48+
// MenuFlyout / native control tree is created under the correct theme.
49+
ApplyNativeTheme(_settings.Theme);
50+
_settings.ThemeChanged += OnThemeChanged;
51+
4352
_mainPage = new MainPage(
4453
fluxorDispatcher,
4554
databaseCollectionProvider,
@@ -73,6 +82,91 @@ protected override Window CreateWindow(IActivationState? activationState)
7382
window.Width = 2000;
7483
}
7584

85+
// The WinUI MenuBar (rendered for ContentPage.MenuBarItems) is a
86+
// native control whose theme is driven by the WinUI window root's
87+
// FrameworkElement.RequestedTheme - MAUI's UserAppTheme alone does
88+
// not propagate to it. Apply once the platform handler is attached.
89+
window.HandlerChanged += (_, _) => ApplyPlatformWindowTheme();
90+
7691
return window;
7792
}
93+
94+
private void ApplyNativeTheme(Theme theme)
95+
{
96+
UserAppTheme = theme switch
97+
{
98+
Theme.Light => AppTheme.Light,
99+
Theme.Dark => AppTheme.Dark,
100+
_ => AppTheme.Unspecified,
101+
};
102+
103+
ApplyPlatformWindowTheme();
104+
}
105+
106+
private void OnThemeChanged() =>
107+
// ThemeChanged may be raised from non-UI threads (Blazor JSInterop /
108+
// Fluxor effects). UserAppTheme must be set on the MAUI UI thread.
109+
MainThread.BeginInvokeOnMainThread(() => ApplyNativeTheme(_settings.Theme));
110+
111+
private void ApplyPlatformWindowTheme()
112+
{
113+
// The WinUI window root hosts the native title bar AND the MAUI
114+
// AppTitleBar (which contains the MenuBar). Both follow the root's
115+
// RequestedTheme. Force Dark so the entire top chrome stays dark
116+
// regardless of the user's selected app theme. The Blazor page is in
117+
// a WebView and doesn't inherit RequestedTheme - it follows the user
118+
// theme independently via CSS data-theme.
119+
foreach (var window in Windows)
120+
{
121+
if (window.Handler?.PlatformView is not Microsoft.UI.Xaml.Window winUiWindow)
122+
{
123+
continue;
124+
}
125+
126+
if (winUiWindow.Content is Microsoft.UI.Xaml.FrameworkElement root)
127+
{
128+
root.RequestedTheme = Microsoft.UI.Xaml.ElementTheme.Dark;
129+
}
130+
131+
ForceDarkTitleBar(winUiWindow);
132+
}
133+
}
134+
135+
private static void ForceDarkTitleBar(Microsoft.UI.Xaml.Window winUiWindow)
136+
{
137+
try
138+
{
139+
var titleBar = winUiWindow.AppWindow?.TitleBar;
140+
141+
if (titleBar is null) { return; }
142+
143+
if (!Microsoft.UI.Windowing.AppWindowTitleBar.IsCustomizationSupported()) { return; }
144+
145+
var background = global::Windows.UI.Color.FromArgb(0xFF, 0x22, 0x22, 0x22);
146+
var foreground = global::Windows.UI.Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF);
147+
var inactiveBackground = global::Windows.UI.Color.FromArgb(0xFF, 0x2D, 0x2D, 0x2D);
148+
var inactiveForeground = global::Windows.UI.Color.FromArgb(0xFF, 0x99, 0x99, 0x99);
149+
var hoverBackground = global::Windows.UI.Color.FromArgb(0xFF, 0x35, 0x35, 0x35);
150+
var pressedBackground = global::Windows.UI.Color.FromArgb(0xFF, 0x44, 0x44, 0x44);
151+
152+
titleBar.BackgroundColor = background;
153+
titleBar.ForegroundColor = foreground;
154+
titleBar.InactiveBackgroundColor = inactiveBackground;
155+
titleBar.InactiveForegroundColor = inactiveForeground;
156+
157+
titleBar.ButtonBackgroundColor = background;
158+
titleBar.ButtonForegroundColor = foreground;
159+
titleBar.ButtonHoverBackgroundColor = hoverBackground;
160+
titleBar.ButtonHoverForegroundColor = foreground;
161+
titleBar.ButtonPressedBackgroundColor = pressedBackground;
162+
titleBar.ButtonPressedForegroundColor = foreground;
163+
titleBar.ButtonInactiveBackgroundColor = inactiveBackground;
164+
titleBar.ButtonInactiveForegroundColor = inactiveForeground;
165+
}
166+
catch (System.Runtime.InteropServices.COMException)
167+
{
168+
// Window has been closed/disposed between the event firing and
169+
// here. Safe to ignore.
170+
}
171+
}
78172
}

src/EventLogExpert/Components/EventTable.razor

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
var width = GetColumnWidth(columnHeader);
1616

1717
<th @key="columnHeader" aria-colindex="@(columnIndex + 1)" aria-sort="@(_eventTableState.OrderBy == columnHeader ? (_eventTableState.IsDescending ? "descending" : "ascending") : "none")"
18-
class="@columnHeader.ToString().ToLower()" data-column="@columnHeader" role="columnheader" style="width: @(width)px">
18+
class="@columnHeader.ToString().ToLowerInvariant()" data-column="@columnHeader" role="columnheader" style="width: @(width)px">
1919
@if (columnHeader == ColumnName.DateAndTime)
2020
{
2121
<text>@GetDateColumnHeader()</text>
@@ -40,8 +40,8 @@
4040
@if (_currentTable is not null)
4141
{
4242
<Virtualize Context="evt" Items="@((_currentTable.DisplayedEvents as ICollection<DisplayEventModel>) ?? [])">
43-
<tr aria-rowindex="@GetRowIndex(evt)" aria-selected="@(_selectedEventState.Contains(evt).ToString().ToLower())"
44-
class="@GetCss(evt)" @key="@($"{evt.OwningLog}_{evt.RecordId}_{evt.TimeCreated:O}")" @onmousedown="args => SelectEvent(args, evt)" role="row" tabindex="0">
43+
<tr aria-rowindex="@GetRowIndex(evt)" aria-selected="@(_selectedSet.Contains(evt).ToString().ToLower())"
44+
class="@GetCss(evt)" data-highlight="@GetHighlight(evt)" @key="@($"{evt.OwningLog}_{evt.RecordId}_{evt.TimeCreated:O}")" @onmousedown="args => SelectEvent(args, evt)" role="row" tabindex="0">
4545
@for (int i = 0; i < _enabledColumns.Length; i++)
4646
{
4747
<td aria-colindex="@(i + 1)" role="gridcell">

0 commit comments

Comments
 (0)