lightweight variant (#1)
Reviewed-on: #1 Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Settings.Interop;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Placing the settings window: the centre maths and bringing the window back
|
||||
/// into the work area.
|
||||
/// </summary>
|
||||
public sealed class MainWindowPlacementTests
|
||||
{
|
||||
private static readonly PopupWindowNative.Rect Work = new()
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Right = 1000,
|
||||
Bottom = 800,
|
||||
};
|
||||
|
||||
private static readonly PopupWindowNative.Rect Bounds = new()
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Right = 400,
|
||||
Bottom = 300,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void The_centre_follows_the_window_size_and_the_work_area()
|
||||
{
|
||||
PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, Work);
|
||||
|
||||
Assert.Equal((1000 - 400) / 2, point.X);
|
||||
Assert.Equal((800 - 300) / 2, point.Y);
|
||||
}
|
||||
|
||||
// The work area of a second monitor does not start at zero
|
||||
[Fact]
|
||||
public void The_centre_of_a_neighbouring_monitor_is_measured_from_its_left_edge()
|
||||
{
|
||||
var work = new PopupWindowNative.Rect { Left = 1920, Top = 100, Right = 3520, Bottom = 1000 };
|
||||
|
||||
PopupWindowNative.Point point = MainWindowPlacement.Center(Bounds, work);
|
||||
|
||||
Assert.Equal(1920 + ((1600 - 400) / 2), point.X);
|
||||
Assert.Equal(100 + ((900 - 300) / 2), point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_inside_the_work_area_stays_where_it_is()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = 120, Y = 90 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(120, clamped.X);
|
||||
Assert.Equal(90, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_past_the_right_edge_is_pulled_back_in()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = 900, Y = 700 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(1000 - 400, clamped.X);
|
||||
Assert.Equal(800 - 300, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_past_the_left_and_top_edges_is_pulled_back_in()
|
||||
{
|
||||
var position = new PopupWindowNative.Point { X = -500, Y = -400 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, Bounds, Work);
|
||||
|
||||
Assert.Equal(Work.Left, clamped.X);
|
||||
Assert.Equal(Work.Top, clamped.Y);
|
||||
}
|
||||
|
||||
// The window height matches its content and on a short monitor exceeds the
|
||||
// work area. The title bar matters more than the bottom of the window
|
||||
[Fact]
|
||||
public void A_window_taller_than_the_work_area_is_pinned_to_its_top()
|
||||
{
|
||||
var tall = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 400, Bottom = 900 };
|
||||
var position = new PopupWindowNative.Point { X = 0, Y = 300 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, tall, Work);
|
||||
|
||||
Assert.Equal(Work.Top, clamped.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_wider_than_the_work_area_is_pinned_to_its_left_edge()
|
||||
{
|
||||
var wide = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = 1200, Bottom = 300 };
|
||||
var position = new PopupWindowNative.Point { X = 400, Y = 0 };
|
||||
|
||||
PopupWindowNative.Point clamped = MainWindowPlacement.Clamp(position, wide, Work);
|
||||
|
||||
Assert.Equal(Work.Left, clamped.X);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0, 0, true)]
|
||||
[InlineData(0, 0, 100, 0, true)]
|
||||
[InlineData(0, 0, 0, 100, true)]
|
||||
[InlineData(100, 100, 100, 200, true)]
|
||||
[InlineData(0, 0, 1, 1, false)]
|
||||
[InlineData(-100, -100, 100, 100, false)]
|
||||
public void An_area_without_width_or_height_counts_as_empty(
|
||||
int left, int top, int right, int bottom, bool expected)
|
||||
{
|
||||
var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = right, Bottom = bottom };
|
||||
|
||||
Assert.Equal(expected, MainWindowPlacement.IsEmpty(rect));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_time_in_a_session_the_window_lands_centred_on_the_active_monitor()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
(PopupWindowNative.Rect before, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
placement.Apply(window);
|
||||
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
|
||||
if (!before.Equals(work))
|
||||
{
|
||||
// The user moved to another monitor right during the check
|
||||
Assert.Skip("The active monitor changed while the check was running");
|
||||
}
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
// Pixel precision: the window goes exactly where it was computed to go
|
||||
PopupWindowNative.Point expected = MainWindowPlacement.Clamp(
|
||||
MainWindowPlacement.Center(bounds, work), bounds, work);
|
||||
|
||||
Assert.Equal(expected.X, bounds.Left);
|
||||
Assert.Equal(expected.Y, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_returns_where_the_user_moved_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
placement.Attach(window);
|
||||
placement.Apply(window);
|
||||
|
||||
// Move the window the way the user does it with the mouse
|
||||
PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
PopupWindowNative.MoveTo(window.Handle, centered.Left + 40, centered.Top + 30);
|
||||
window.RaiseLocationChanged();
|
||||
|
||||
// Showing the window again — it has to stay where it was left
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect after = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(centered.Left + 40, after.Left);
|
||||
Assert.Equal(centered.Top + 30, after.Top);
|
||||
});
|
||||
}
|
||||
|
||||
// While we move the window ourselves its position must not drift from repeats
|
||||
[Fact]
|
||||
public void Placing_again_does_not_move_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
placement.Attach(window);
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect first = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
placement.Apply(window);
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect third = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(first.Left, third.Left);
|
||||
Assert.Equal(first.Top, third.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_minimised_window_is_not_placed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
using var window = new TestWindow();
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, 7, 9);
|
||||
window.WindowState = WindowState.Minimized;
|
||||
|
||||
placement.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(7, bounds.Left);
|
||||
Assert.Equal(9, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_a_handle_is_not_placed()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var placement = new MainWindowPlacement();
|
||||
var window = new Window { Width = 200, Height = 150 };
|
||||
|
||||
// There must be no exception: the window does not exist yet,
|
||||
// so there is nothing to place
|
||||
placement.Apply(window);
|
||||
|
||||
Assert.Equal(IntPtr.Zero, new WindowInteropHelper(window).Handle);
|
||||
});
|
||||
}
|
||||
|
||||
// The place of the window lives in memory only: the set of monitors may be
|
||||
// different by the next run
|
||||
[Fact]
|
||||
public void Every_placement_starts_its_session_afresh()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var window = new TestWindow();
|
||||
|
||||
var first = new MainWindowPlacement();
|
||||
first.Attach(window);
|
||||
first.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect centered = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
|
||||
PopupWindowNative.MoveTo(window.Handle, centered.Left + 60, centered.Top + 60);
|
||||
window.RaiseLocationChanged();
|
||||
|
||||
// A new placement knows nothing of the earlier move and centres the window again
|
||||
var second = new MainWindowPlacement();
|
||||
second.Apply(window);
|
||||
|
||||
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(window.Handle)!.Value;
|
||||
Assert.Equal(centered.Left, bounds.Left);
|
||||
Assert.Equal(centered.Top, bounds.Top);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A window with a ready handle that never appears on screen: placement
|
||||
/// works with the system bounds, and a created window is enough for those.
|
||||
/// </summary>
|
||||
private sealed class TestWindow : Window, IDisposable
|
||||
{
|
||||
internal TestWindow()
|
||||
{
|
||||
Width = 400;
|
||||
Height = 300;
|
||||
ShowInTaskbar = false;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
|
||||
Handle = new WindowInteropHelper(this).EnsureHandle();
|
||||
}
|
||||
|
||||
internal IntPtr Handle { get; }
|
||||
|
||||
/// <summary>Reports a move the way WPF does after the user acts.</summary>
|
||||
internal void RaiseLocationChanged() => OnLocationChanged(EventArgs.Empty);
|
||||
|
||||
public void Dispose() => Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.Tests.Infrastructure;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Settings.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The look of the windows. The palette lives in the application resources,
|
||||
/// so everything happens on the interface thread.
|
||||
/// </summary>
|
||||
public sealed class ThemeServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void A_chosen_theme_is_applied_as_is(AppTheme theme)
|
||||
{
|
||||
var settings = new AppSettings { Theme = theme };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
Assert.Equal(theme, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void The_system_theme_is_taken_from_Windows(AppTheme system)
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, () => system);
|
||||
|
||||
Assert.Equal(system, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changing_the_theme_in_the_settings_repaints_the_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
Color light = WindowBackground();
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Dark, service.CurrentTheme);
|
||||
Assert.NotEqual(light, WindowBackground());
|
||||
});
|
||||
}
|
||||
|
||||
// The palette is replaced rather than piled up: otherwise the light one
|
||||
// would still sit under the dark one
|
||||
[Fact]
|
||||
public void The_palette_does_not_pile_up_in_the_resources()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
int before = Application.Current.Resources.MergedDictionaries.Count;
|
||||
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
settings.Theme = AppTheme.Light;
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(before + 1, Application.Current.Resources.MergedDictionaries.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_the_same_theme_again_leaves_the_resources_alone()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
int count = Application.Current.Resources.MergedDictionaries.Count;
|
||||
ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
|
||||
Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
|
||||
});
|
||||
}
|
||||
|
||||
// The other settings have nothing to do with the look
|
||||
[Fact]
|
||||
public void Other_settings_do_not_change_the_theme()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
settings.FontSize = 40;
|
||||
settings.Language = "ru";
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_that_already_exists_is_attached_at_once()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window();
|
||||
|
||||
try
|
||||
{
|
||||
_ = new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
// The title bar is painted by Windows, and the only way to check
|
||||
// this is that the call goes through without an error
|
||||
service.Register(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_without_a_handle_is_attached_once_it_appears()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Dark };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
|
||||
|
||||
try
|
||||
{
|
||||
service.Register(window);
|
||||
|
||||
// The window is created on show — and the look comes with it
|
||||
window.Show();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The ordinary service takes the theme from the Windows settings
|
||||
[Fact]
|
||||
public void The_service_can_work_with_the_real_Windows_theme()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(new AppSettings { Theme = AppTheme.System });
|
||||
|
||||
Assert.True(service.CurrentTheme is AppTheme.Light or AppTheme.Dark);
|
||||
Assert.Equal(ThemeService.DetectSystemTheme(), service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change from a thread other than the interface one
|
||||
[Fact]
|
||||
public void A_look_change_in_Windows_repaints_the_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
AppTheme system = AppTheme.Light;
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, () => system);
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
|
||||
system = AppTheme.Dark;
|
||||
RaiseUserPreferenceChanged(service);
|
||||
|
||||
Sta.WaitFor(() => service.CurrentTheme == AppTheme.Dark, "the theme was recomputed at the request of Windows");
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change even when nothing changed for the app:
|
||||
// in that case there is nothing to repaint
|
||||
[Fact]
|
||||
public void The_same_theme_does_not_replace_the_resources()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Dark);
|
||||
|
||||
ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1];
|
||||
int count = Application.Current.Resources.MergedDictionaries.Count;
|
||||
|
||||
RaiseUserPreferenceChanged(service);
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(30));
|
||||
|
||||
Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]);
|
||||
Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_closed_window_is_forgotten()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window();
|
||||
_ = new WindowInteropHelper(window).EnsureHandle();
|
||||
|
||||
service.Register(window);
|
||||
window.Close();
|
||||
|
||||
// A theme change must no longer concern the closed window
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Dark, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_service_lets_go_of_the_attached_windows()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false };
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
service.Register(window);
|
||||
|
||||
service.Dispose();
|
||||
|
||||
// The window now closes on its own, with no regard for the theme
|
||||
window.Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_the_setting_no_longer_changes_the_theme()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
service.Dispose();
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_the_service_is_closed_requests_from_Windows_go_unanswered()
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.System };
|
||||
AppTheme system = AppTheme.Light;
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var service = new ThemeService(settings, () => system);
|
||||
service.Dispose();
|
||||
|
||||
system = AppTheme.Dark;
|
||||
Sta.Pause(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.Equal(AppTheme.Light, service.CurrentTheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AppTheme.Light)]
|
||||
[InlineData(AppTheme.Dark)]
|
||||
public void The_palette_of_each_theme_lives_in_the_application_assembly(AppTheme theme)
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var palette = new ResourceDictionary { Source = ThemeService.PaletteUri(theme) };
|
||||
|
||||
Assert.NotEmpty(palette.Keys);
|
||||
Assert.True(palette.Contains("Theme.WindowBackground"));
|
||||
});
|
||||
}
|
||||
|
||||
// The palette address names the application assembly rather than the one
|
||||
// the process started from
|
||||
[Fact]
|
||||
public void The_palette_address_names_the_application_assembly()
|
||||
{
|
||||
// The palettes live with the settings window, not with the agent next to it
|
||||
Assert.Contains(
|
||||
"CursorLang.Settings;component",
|
||||
ThemeService.PaletteUri(AppTheme.Dark).ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_light_and_dark_palettes_share_one_set_of_keys()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var light = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Light) };
|
||||
var dark = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Dark) };
|
||||
|
||||
Assert.Equal(light.Keys.Cast<object>().OrderBy(key => key.ToString()),
|
||||
dark.Keys.Cast<object>().OrderBy(key => key.ToString()));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_Windows_theme_is_read_without_errors()
|
||||
{
|
||||
AppTheme theme = ThemeService.DetectSystemTheme();
|
||||
|
||||
// The "follow the system" setting has to yield something definite
|
||||
Assert.True(theme is AppTheme.Light or AppTheme.Dark);
|
||||
}
|
||||
|
||||
// A control the shared dictionary says nothing about keeps the look Windows
|
||||
// gives it and stays light in the dark theme
|
||||
[Theory]
|
||||
[InlineData(typeof(Button))]
|
||||
[InlineData(typeof(ComboBox))]
|
||||
[InlineData(typeof(CheckBox))]
|
||||
[InlineData(typeof(GroupBox))]
|
||||
[InlineData(typeof(ProgressBar))]
|
||||
[InlineData(typeof(Slider))]
|
||||
public void A_control_of_the_window_is_repainted_together_with_the_theme(Type control)
|
||||
{
|
||||
var settings = new AppSettings { Theme = AppTheme.Light };
|
||||
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var service = new ThemeService(settings, static () => AppTheme.Light);
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
Width = 200,
|
||||
Height = 100,
|
||||
Content = (Control)Activator.CreateInstance(control)!,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
IReadOnlyList<Color> light = PaintOf(window);
|
||||
Assert.NotEmpty(light);
|
||||
|
||||
settings.Theme = AppTheme.Dark;
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.NotEqual(light, PaintOf(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Windows reports a look change through an event that cannot be synthesised:
|
||||
// the test goes straight to the handler that event arrives at
|
||||
private static void RaiseUserPreferenceChanged(ThemeService service)
|
||||
{
|
||||
System.Reflection.MethodInfo handler = typeof(ThemeService)
|
||||
.GetMethod("OnUserPreferenceChanged", System.Reflection.BindingFlags.Instance
|
||||
| System.Reflection.BindingFlags.NonPublic)!;
|
||||
|
||||
handler.Invoke(service, [null, new UserPreferenceChangedEventArgs(UserPreferenceCategory.General)]);
|
||||
}
|
||||
|
||||
private static Color WindowBackground() =>
|
||||
((SolidColorBrush)Application.Current.Resources["Theme.WindowBackground"]).Color;
|
||||
|
||||
/// <summary>
|
||||
/// Every colour the element tree is painted with. What is compared is the
|
||||
/// whole set: which part of a control the palette reaches is its own business.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<Color> PaintOf(DependencyObject root)
|
||||
{
|
||||
var colours = new List<Color>();
|
||||
Collect(root, colours);
|
||||
|
||||
return colours;
|
||||
}
|
||||
|
||||
private static void Collect(DependencyObject node, List<Color> colours)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case Control control:
|
||||
Add(colours, control.Background, control.BorderBrush, control.Foreground);
|
||||
break;
|
||||
case Border border:
|
||||
Add(colours, border.Background, border.BorderBrush);
|
||||
break;
|
||||
case Shape shape:
|
||||
Add(colours, shape.Fill, shape.Stroke);
|
||||
break;
|
||||
case TextBlock text:
|
||||
Add(colours, text.Background, text.Foreground);
|
||||
break;
|
||||
}
|
||||
|
||||
int count = VisualTreeHelper.GetChildrenCount(node);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Collect(VisualTreeHelper.GetChild(node, i), colours);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(List<Color> colours, params Brush?[] brushes) =>
|
||||
colours.AddRange(brushes.OfType<SolidColorBrush>().Select(brush => brush.Color));
|
||||
}
|
||||
Reference in New Issue
Block a user