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,19 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Core.Models;
|
||||
|
||||
namespace CursorLang.Settings.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the light or the dark look to the windows of the application.
|
||||
/// </summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
/// <summary>The theme in effect right now.</summary>
|
||||
AppTheme CurrentTheme { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Hooks a window up to theme changes: the window title bar is drawn by Windows,
|
||||
/// and its colour has to be switched for each window separately.
|
||||
/// </summary>
|
||||
void Register(Window window);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Settings.Interop;
|
||||
|
||||
namespace CursorLang.Settings.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Decides where the settings window shows up: for the first time in a session — in
|
||||
/// the centre of the monitor the user is working on, and after that — where they
|
||||
/// left that window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The position lives in memory only and is not kept between launches: the set of
|
||||
/// monitors may be different by the next launch, while "in the centre of the active
|
||||
/// one" is always right.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowPlacement
|
||||
{
|
||||
private PopupWindowNative.Point? _position;
|
||||
|
||||
// The window reports a move when we move it ourselves as well;
|
||||
// what has to be remembered is only what the user chose
|
||||
private bool _isPlacing;
|
||||
|
||||
/// <summary>
|
||||
/// Takes over the placement of the window: puts it in place by the first show
|
||||
/// and follows where the user moves it.
|
||||
/// </summary>
|
||||
public void Attach(Window window)
|
||||
{
|
||||
window.SourceInitialized += OnSourceInitialized;
|
||||
window.LocationChanged += OnLocationChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the window to the remembered place, and when it has not been shown
|
||||
/// yet during this session — puts it in the centre of the active monitor.
|
||||
/// </summary>
|
||||
public void Apply(Window window)
|
||||
{
|
||||
// A minimized window has no meaningful bounds: it is restored in its former
|
||||
// place, and it can be positioned only after that
|
||||
if (window.WindowState != WindowState.Normal)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr handle = new WindowInteropHelper(window).Handle;
|
||||
if (handle == IntPtr.Zero || WindowPlacementNative.TryGetBounds(handle) is not { } bounds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PopupWindowNative.Point? wanted = _position ?? CenterOnActiveMonitor(bounds);
|
||||
if (wanted is null || KeepOnScreen(wanted.Value, bounds) is not { } target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isPlacing = true;
|
||||
try
|
||||
{
|
||||
PopupWindowNative.MoveTo(handle, target.X, target.Y);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isPlacing = false;
|
||||
}
|
||||
|
||||
_position = target;
|
||||
}
|
||||
|
||||
// The window height adapts to its content and is unknown until the first layout
|
||||
// pass — an empty window frame would end up in the centre. So we ask for the
|
||||
// layout to be computed right away: by that moment the window is not shown yet,
|
||||
// so it will not flash in its former place
|
||||
private void OnSourceInitialized(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is not Window window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
window.SourceInitialized -= OnSourceInitialized;
|
||||
window.UpdateLayout();
|
||||
Apply(window);
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_isPlacing || sender is not Window { WindowState: WindowState.Normal } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IntPtr handle = new WindowInteropHelper(window).Handle;
|
||||
if (handle != IntPtr.Zero && WindowPlacementNative.TryGetBounds(handle) is { } bounds)
|
||||
{
|
||||
_position = new PopupWindowNative.Point { X = bounds.Left, Y = bounds.Top };
|
||||
}
|
||||
}
|
||||
|
||||
private static PopupWindowNative.Point? CenterOnActiveMonitor(PopupWindowNative.Rect bounds)
|
||||
{
|
||||
(PopupWindowNative.Rect work, _) = PopupWindowNative.GetActiveMonitorWorkArea();
|
||||
return IsEmpty(work) ? null : Center(bounds, work);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The point at which a window with the given bounds ends up in the centre of the work area.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point Center(
|
||||
PopupWindowNative.Rect bounds, PopupWindowNative.Rect work) => new()
|
||||
{
|
||||
X = work.Left + (((work.Right - work.Left) - Width(bounds)) / 2),
|
||||
Y = work.Top + (((work.Bottom - work.Top) - Height(bounds)) / 2),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the window into the work area of the nearest monitor.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Needed in two cases. The monitor the user put the window on may be disconnected
|
||||
/// during the session — returning the window to its place would then leave the
|
||||
/// user without a window, so the remembered position is a wish here rather than an
|
||||
/// order. And the window height equals the height of its content and may exceed
|
||||
/// the work area on a short monitor — then the title bar of a window placed in the
|
||||
/// centre would go past the top edge.
|
||||
/// </remarks>
|
||||
private static PopupWindowNative.Point? KeepOnScreen(
|
||||
PopupWindowNative.Point position, PopupWindowNative.Rect bounds)
|
||||
{
|
||||
int width = Width(bounds);
|
||||
int height = Height(bounds);
|
||||
|
||||
var wanted = new PopupWindowNative.Rect
|
||||
{
|
||||
Left = position.X,
|
||||
Top = position.Y,
|
||||
Right = position.X + width,
|
||||
Bottom = position.Y + height,
|
||||
};
|
||||
|
||||
if (WindowPlacementNative.TryGetWorkAreaNear(wanted) is not { } work || IsEmpty(work))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Clamp(position, bounds, work);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the point so that a window with the given bounds fits into the work area
|
||||
/// entirely. A window taller than the work area gets its top edge: the title bar
|
||||
/// is needed more than the lower part of the window.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Point Clamp(
|
||||
PopupWindowNative.Point position,
|
||||
PopupWindowNative.Rect bounds,
|
||||
PopupWindowNative.Rect work)
|
||||
{
|
||||
int width = Width(bounds);
|
||||
int height = Height(bounds);
|
||||
|
||||
return new PopupWindowNative.Point
|
||||
{
|
||||
X = Math.Clamp(position.X, work.Left, Math.Max(work.Left, work.Right - width)),
|
||||
Y = Math.Clamp(position.Y, work.Top, Math.Max(work.Top, work.Bottom - height)),
|
||||
};
|
||||
}
|
||||
|
||||
private static int Width(PopupWindowNative.Rect rect) => rect.Right - rect.Left;
|
||||
|
||||
private static int Height(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top;
|
||||
|
||||
internal static bool IsEmpty(PopupWindowNative.Rect rect) =>
|
||||
rect.Right <= rect.Left || rect.Bottom <= rect.Top;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Settings.Interop;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace CursorLang.Settings.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the palette of the chosen theme in the application resources and swaps it
|
||||
/// when the setting changes — the windows are recoloured without a restart.
|
||||
/// </summary>
|
||||
public sealed class ThemeService : IThemeService, IDisposable
|
||||
{
|
||||
private const string PersonalizeKey =
|
||||
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
|
||||
|
||||
private readonly AppSettings _settings;
|
||||
private readonly Func<AppTheme> _detectSystemTheme;
|
||||
private readonly List<Window> _windows = [];
|
||||
private ResourceDictionary? _palette;
|
||||
private AppTheme _current;
|
||||
|
||||
public ThemeService(AppSettings settings)
|
||||
: this(settings, DetectSystemTheme)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the source of the system theme explicitly: in tests it is not the registry
|
||||
/// that provides it.
|
||||
/// </summary>
|
||||
internal ThemeService(AppSettings settings, Func<AppTheme> detectSystemTheme)
|
||||
{
|
||||
_settings = settings;
|
||||
_detectSystemTheme = detectSystemTheme;
|
||||
_settings.PropertyChanged += OnSettingsChanged;
|
||||
SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>The theme the user sees: <c>System</c> is already resolved here.</summary>
|
||||
public AppTheme CurrentTheme => _current;
|
||||
|
||||
public void Register(Window window)
|
||||
{
|
||||
_windows.Add(window);
|
||||
window.Closed += OnWindowClosed;
|
||||
|
||||
if (new WindowInteropHelper(window).Handle == IntPtr.Zero)
|
||||
{
|
||||
window.SourceInitialized += OnWindowSourceInitialized;
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyTitleBar(window);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_settings.PropertyChanged -= OnSettingsChanged;
|
||||
SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
|
||||
|
||||
foreach (Window window in _windows)
|
||||
{
|
||||
window.Closed -= OnWindowClosed;
|
||||
window.SourceInitialized -= OnWindowSourceInitialized;
|
||||
}
|
||||
|
||||
_windows.Clear();
|
||||
}
|
||||
|
||||
/// <summary>The app theme from the Windows settings.</summary>
|
||||
internal static AppTheme DetectSystemTheme()
|
||||
{
|
||||
try
|
||||
{
|
||||
using RegistryKey? key = Registry.CurrentUser.OpenSubKey(PersonalizeKey);
|
||||
return key?.GetValue("AppsUseLightTheme") is int light && light == 0
|
||||
? AppTheme.Dark
|
||||
: AppTheme.Light;
|
||||
}
|
||||
catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
return AppTheme.Light;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AppSettings.Theme))
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
|
||||
// Windows reports a theme change from outside the interface thread
|
||||
private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) =>
|
||||
Application.Current?.Dispatcher.InvokeAsync(Apply);
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
AppTheme theme = _settings.Theme == AppTheme.System ? _detectSystemTheme() : _settings.Theme;
|
||||
if (_palette is not null && theme == _current)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_current = theme;
|
||||
|
||||
var next = new ResourceDictionary
|
||||
{
|
||||
Source = PaletteUri(theme),
|
||||
};
|
||||
|
||||
ICollection<ResourceDictionary> dictionaries = Application.Current.Resources.MergedDictionaries;
|
||||
dictionaries.Add(next);
|
||||
|
||||
if (_palette is not null)
|
||||
{
|
||||
dictionaries.Remove(_palette);
|
||||
}
|
||||
|
||||
_palette = next;
|
||||
|
||||
foreach (Window window in _windows)
|
||||
{
|
||||
ApplyTitleBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
// The assembly name in the address is there on purpose: without it the dictionary
|
||||
// is looked up in the assembly the process started from, which is not always the
|
||||
// one holding the palettes. It is taken from the type rather than spelt out —
|
||||
// the name of this assembly has changed once already, and a wrong one here is a
|
||||
// crash on startup rather than a build error
|
||||
internal static Uri PaletteUri(AppTheme theme) => new(
|
||||
$"pack://application:,,,/{typeof(ThemeService).Assembly.GetName().Name};component/Themes/{theme}.xaml",
|
||||
UriKind.Absolute);
|
||||
|
||||
private void ApplyTitleBar(Window window) =>
|
||||
WindowThemeNative.SetDarkTitleBar(
|
||||
new WindowInteropHelper(window).Handle,
|
||||
_current == AppTheme.Dark);
|
||||
|
||||
private void OnWindowSourceInitialized(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is Window window)
|
||||
{
|
||||
window.SourceInitialized -= OnWindowSourceInitialized;
|
||||
ApplyTitleBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowClosed(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is Window window)
|
||||
{
|
||||
window.Closed -= OnWindowClosed;
|
||||
_windows.Remove(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user