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,12 @@
|
||||
<Application x:Class="CursorLang.Settings.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Controls.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
using CursorLang.Settings.Views;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace CursorLang.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window as a process of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is started by the agent — from the tray menu, or straight away when the user
|
||||
/// launches the application themselves — and it ends when the window is closed. That
|
||||
/// is the whole point of the split: WPF costs around a hundred megabytes, and this way
|
||||
/// the system gets all of it back the moment the user is done, instead of the
|
||||
/// background process carrying it until sign-out.
|
||||
///
|
||||
/// Nothing here talks to the agent directly. Every edit goes into settings.json, and
|
||||
/// <see cref="SettingsService"/> nudges the agent to re-read it once the file is
|
||||
/// written. An agent that is not running is a normal case: the window works the same.
|
||||
/// </remarks>
|
||||
// ReSharper disable once RedundantExtendsListEntry
|
||||
public partial class App : Application
|
||||
{
|
||||
private ServiceProvider? _services;
|
||||
private SingleInstanceGate? _instanceGate;
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
var gate = new SingleInstanceGate(SingleInstanceGate.SettingsName);
|
||||
if (!gate.TryAcquire(showRunningInstance: true))
|
||||
{
|
||||
gate.Dispose();
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
_instanceGate = gate;
|
||||
_instanceGate.ActivationRequested += OnActivationRequested;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
ConfigureServices(services);
|
||||
_services = services.BuildServiceProvider();
|
||||
|
||||
_services.GetRequiredService<SettingsService>().TrackChanges();
|
||||
_services.GetRequiredService<ThemeService>();
|
||||
|
||||
MainWindow = _services.GetRequiredService<MainWindow>();
|
||||
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
MainWindow.Show();
|
||||
|
||||
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
|
||||
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_services?.Dispose();
|
||||
|
||||
if (_instanceGate is not null)
|
||||
{
|
||||
_instanceGate.ActivationRequested -= OnActivationRequested;
|
||||
_instanceGate.Dispose();
|
||||
}
|
||||
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
internal static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(new UpdateOptions());
|
||||
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton(provider => provider.GetRequiredService<SettingsService>().Load());
|
||||
|
||||
services.AddSingleton<ThemeService>();
|
||||
services.AddSingleton<IThemeService>(provider => provider.GetRequiredService<ThemeService>());
|
||||
|
||||
services.AddSingleton<MainWindowPlacement>();
|
||||
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<IStartupService, StartupService>();
|
||||
services.AddSingleton<IUpdateService, UpdateService>();
|
||||
|
||||
services.AddSingleton<UpdateViewModel>();
|
||||
services.AddSingleton<SettingsViewModel>();
|
||||
|
||||
services.AddSingleton<MainWindow>();
|
||||
}
|
||||
|
||||
// A second launch — another click on "Settings" in the tray menu, say. The gate
|
||||
// answers on a thread pool thread, and a window obeys only its own
|
||||
private void OnActivationRequested(object? sender, EventArgs e) =>
|
||||
Dispatcher.BeginInvoke(ShowMainWindow);
|
||||
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
if (MainWindow is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows;
|
||||
|
||||
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
|
||||
[assembly: InternalsVisibleTo("CursorLang.Settings.Tests")]
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>CursorLang.Settings</RootNamespace>
|
||||
<AssemblyName>CursorLang.Settings</AssemblyName>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
|
||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Product>CursorLang</Product>
|
||||
<Company>Aleksandr Neychev</Company>
|
||||
<Description>Settings window of CursorLang</Description>
|
||||
<Copyright>Copyright (c) 2026</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2"/>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Core.Interop;
|
||||
|
||||
namespace CursorLang.Settings.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Win32 API for placing the settings window: its own bounds and the work area
|
||||
/// of the monitor the window is asked to be put on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bounds are taken from the system rather than from <c>Window.Left/Top/Width/Height</c>:
|
||||
/// the window height adapts to its content, and WPF converts those properties using the
|
||||
/// monitor DPI, while the monitor work area comes in pixels. Computing the centre in a
|
||||
/// single unit is simpler than converting back and forth.
|
||||
/// </remarks>
|
||||
internal static class WindowPlacementNative
|
||||
{
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromRect(ref PopupWindowNative.Rect lprc, uint dwFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MonitorInfo
|
||||
{
|
||||
public int cbSize;
|
||||
public PopupWindowNative.Rect rcMonitor;
|
||||
public PopupWindowNative.Rect rcWork;
|
||||
public uint dwFlags;
|
||||
}
|
||||
|
||||
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||
|
||||
/// <summary>The window bounds in screen pixels — including the frame and the title bar.</summary>
|
||||
internal static PopupWindowNative.Rect? TryGetBounds(IntPtr hWnd) =>
|
||||
GetWindowRect(hWnd, out PopupWindowNative.Rect bounds) ? bounds : null;
|
||||
|
||||
/// <summary>
|
||||
/// The work area — without the taskbar — of the monitor that holds the
|
||||
/// rectangle entirely, or at least most of it.
|
||||
/// </summary>
|
||||
internal static PopupWindowNative.Rect? TryGetWorkAreaNear(PopupWindowNative.Rect rect)
|
||||
{
|
||||
IntPtr monitor = MonitorFromRect(ref rect, MONITOR_DEFAULTTONEAREST);
|
||||
|
||||
var info = new MonitorInfo { cbSize = Marshal.SizeOf<MonitorInfo>() };
|
||||
return GetMonitorInfo(monitor, ref info) ? info.rcWork : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace CursorLang.Settings.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Window frame styling by the system: the title bar is drawn by Windows,
|
||||
/// and in the dark theme it has to be switched separately from the window content.
|
||||
/// </summary>
|
||||
internal static class WindowThemeNative
|
||||
{
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr hWnd, int attribute, ref int value, int size);
|
||||
|
||||
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Recolours the window title bar. On Windows 10 builds before 2004 the attribute
|
||||
/// is not supported — the title bar simply stays light.
|
||||
/// </summary>
|
||||
internal static void SetDarkTitleBar(IntPtr hWnd, bool isDark)
|
||||
{
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int value = isDark ? 1 : 0;
|
||||
DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Settings": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ======================= GroupBox ======================= -->
|
||||
|
||||
<Style TargetType="GroupBox">
|
||||
<Setter Property="Padding" Value="12" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GroupBox">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ContentPresenter ContentSource="Header"
|
||||
Margin="2,0,0,6"
|
||||
TextBlock.FontWeight="SemiBold" />
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource Theme.Surface}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Button ======================= -->
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Padding" Value="12,5" />
|
||||
<Setter Property="MinHeight" Value="28" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Border"
|
||||
Background="{DynamicResource Theme.ControlBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.ControlHoverBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Border" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
<Setter TargetName="Border" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ComboBox ======================= -->
|
||||
|
||||
<Style x:Key="ComboBoxToggleStyle" TargetType="ToggleButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="ClickMode" Value="Press" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="Border"
|
||||
Background="{DynamicResource Theme.ControlBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
SnapsToDevicePixels="True">
|
||||
<Path x:Name="Arrow"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"
|
||||
Data="M0,0 L4,4 L8,0"
|
||||
Stroke="{DynamicResource Theme.SecondaryForeground}"
|
||||
StrokeThickness="1.4" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.ControlHoverBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Border" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter TargetName="Arrow" Property="Stroke"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Border" Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBoxItem">
|
||||
<Border x:Name="Border"
|
||||
Background="Transparent"
|
||||
CornerRadius="3"
|
||||
Margin="3,1"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<!-- Right padding leaves space for the arrow -->
|
||||
<Setter Property="Padding" Value="9,5,28,5" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<ToggleButton Style="{StaticResource ComboBoxToggleStyle}"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay,
|
||||
RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
<ContentPresenter Margin="{TemplateBinding Padding}"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
IsHitTestVisible="False" />
|
||||
<Popup x:Name="PART_Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True"
|
||||
Focusable="False"
|
||||
PopupAnimation="Fade">
|
||||
<Border Background="{DynamicResource Theme.PopupBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Margin="0,2,0,0"
|
||||
MinWidth="{Binding ActualWidth,
|
||||
RelativeSource={RelativeSource TemplatedParent}}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<ScrollViewer>
|
||||
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= CheckBox ======================= -->
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="MinHeight" Value="24" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<!-- Transparent background makes clicking on the label register as a click on the checkbox.
|
||||
The label lies in a column of limited width: long translations wrap by words
|
||||
instead of being cut off -->
|
||||
<Grid Background="Transparent">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="Box"
|
||||
Width="18" Height="18"
|
||||
CornerRadius="4"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource Theme.ControlBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
SnapsToDevicePixels="True">
|
||||
<Path x:Name="Check"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Data="M0,4 L3.5,7.5 L9.5,0.5"
|
||||
Stroke="White"
|
||||
StrokeThickness="1.8"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
|
||||
Visibility="Collapsed" />
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
</ContentPresenter.Resources>
|
||||
</ContentPresenter>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Box" Property="Background"
|
||||
Value="{DynamicResource Theme.ControlHoverBackground}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter TargetName="Box" Property="BorderBrush"
|
||||
Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter TargetName="Check" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Slider ======================= -->
|
||||
|
||||
<Style x:Key="SliderThumbStyle" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Width" Value="14" />
|
||||
<Setter Property="Height" Value="14" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Ellipse x:Name="Circle"
|
||||
Fill="{DynamicResource Theme.Accent}"
|
||||
Stroke="{DynamicResource Theme.ControlBackground}"
|
||||
StrokeThickness="2" />
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Circle" Property="StrokeThickness" Value="1" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Completed part of the scale: filled with accent color -->
|
||||
<Style x:Key="SliderDecreaseStyle" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Height="4" CornerRadius="2"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource Theme.Accent}" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="TransparentRepeatStyle" TargetType="RepeatButton">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="RepeatButton">
|
||||
<Border Background="Transparent" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="IsSnapToTickEnabled" Value="True" />
|
||||
<Setter Property="MinHeight" Value="24" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Slider">
|
||||
<Grid Background="Transparent">
|
||||
<Border Height="4" CornerRadius="2"
|
||||
VerticalAlignment="Center"
|
||||
Background="{DynamicResource Theme.SliderTrack}" />
|
||||
<Track x:Name="PART_Track">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.DecreaseLarge"
|
||||
Style="{StaticResource SliderDecreaseStyle}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource SliderThumbStyle}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="Slider.IncreaseLarge"
|
||||
Style="{StaticResource TransparentRepeatStyle}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ProgressBar ======================= -->
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ProgressBar">
|
||||
<Border Background="{DynamicResource Theme.SliderTrack}"
|
||||
CornerRadius="2"
|
||||
SnapsToDevicePixels="True">
|
||||
<Grid x:Name="PART_Track" ClipToBounds="True">
|
||||
<Border x:Name="PART_Indicator"
|
||||
HorizontalAlignment="Left"
|
||||
Background="{DynamicResource Theme.Accent}"
|
||||
CornerRadius="2"
|
||||
RenderTransformOrigin="0,0">
|
||||
<Border.RenderTransform>
|
||||
<ScaleTransform ScaleX="1" />
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<!-- The share downloaded is unknown: the bar fills over and over instead -->
|
||||
<Trigger Property="IsIndeterminate" Value="True">
|
||||
<Trigger.EnterActions>
|
||||
<BeginStoryboard x:Name="Running">
|
||||
<Storyboard>
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetName="PART_Indicator"
|
||||
Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
|
||||
From="0" To="1"
|
||||
Duration="0:0:1.2"
|
||||
RepeatBehavior="Forever" />
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.EnterActions>
|
||||
<Trigger.ExitActions>
|
||||
<StopStoryboard BeginStoryboardName="Running" />
|
||||
</Trigger.ExitActions>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Hyperlink ======================= -->
|
||||
|
||||
<!-- The underline is left for the pointer: a line under every link is noise -->
|
||||
<Style TargetType="Hyperlink">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Accent}" />
|
||||
<Setter Property="TextDecorations" Value="{x:Null}" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="TextDecorations" Value="Underline" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ScrollBar ======================= -->
|
||||
|
||||
<Style x:Key="ScrollBarThumbStyle" TargetType="Thumb">
|
||||
<Setter Property="OverridesDefaultStyle" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Thumb">
|
||||
<Border Background="{DynamicResource Theme.ScrollBarThumb}"
|
||||
CornerRadius="3" Margin="3" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ScrollBar">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="MinWidth" Value="12" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ScrollBar">
|
||||
<Grid Background="{TemplateBinding Background}">
|
||||
<Track x:Name="PART_Track"
|
||||
Orientation="{TemplateBinding Orientation}"
|
||||
IsDirectionReversed="True">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton Command="ScrollBar.PageUpCommand"
|
||||
Style="{StaticResource TransparentRepeatStyle}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb Style="{StaticResource ScrollBarThumbStyle}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton Command="ScrollBar.PageDownCommand"
|
||||
Style="{StaticResource TransparentRepeatStyle}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<!-- For the horizontal bar, the track direction is normal -->
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Horizontal">
|
||||
<Setter Property="Width" Value="Auto" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Height" Value="12" />
|
||||
<Setter Property="MinHeight" Value="12" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= Tray menu ======================= -->
|
||||
|
||||
<Style TargetType="ContextMenu">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="HasDropShadow" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ContextMenu">
|
||||
<Border Background="{DynamicResource Theme.PopupBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="3"
|
||||
SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="Padding" Value="12,6" />
|
||||
<Setter Property="MinWidth" Value="160" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="MenuItem">
|
||||
<Border x:Name="Border"
|
||||
Background="Transparent"
|
||||
CornerRadius="3"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter ContentSource="Header" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background"
|
||||
Value="{DynamicResource Theme.SelectionBackground}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="{x:Static MenuItem.SeparatorStyleKey}" TargetType="Separator">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="8,4" />
|
||||
<Setter Property="Background" Value="{DynamicResource Theme.ControlBorder}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Separator">
|
||||
<Border Background="{TemplateBinding Background}"
|
||||
HorizontalAlignment="Stretch"
|
||||
SnapsToDevicePixels="True" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ======================= ToolTip ======================= -->
|
||||
|
||||
<Style TargetType="ToolTip">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
|
||||
<Setter Property="MaxWidth" Value="360" />
|
||||
<Setter Property="HasDropShadow" Value="False" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToolTip">
|
||||
<Border Background="{DynamicResource Theme.PopupBackground}"
|
||||
BorderBrush="{DynamicResource Theme.ControlBorder}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="4"
|
||||
Padding="10,6"
|
||||
SnapsToDevicePixels="True">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,21 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<SolidColorBrush x:Key="Theme.WindowBackground" Color="#1E1E21" />
|
||||
<SolidColorBrush x:Key="Theme.Foreground" Color="#E8E8EA" />
|
||||
<SolidColorBrush x:Key="Theme.SecondaryForeground" Color="#9A9AA2" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Surface" Color="#26262A" />
|
||||
<SolidColorBrush x:Key="Theme.SurfaceStrong" Color="#303036" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.ControlBackground" Color="#2C2C31" />
|
||||
<SolidColorBrush x:Key="Theme.ControlHoverBackground" Color="#38383E" />
|
||||
<SolidColorBrush x:Key="Theme.ControlBorder" Color="#46464C" />
|
||||
<SolidColorBrush x:Key="Theme.PopupBackground" Color="#2A2A2F" />
|
||||
<SolidColorBrush x:Key="Theme.SelectionBackground" Color="#3A4665" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Accent" Color="#5B8CF7" />
|
||||
<SolidColorBrush x:Key="Theme.SliderTrack" Color="#46464C" />
|
||||
<SolidColorBrush x:Key="Theme.ScrollBarThumb" Color="#55555C" />
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,21 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<SolidColorBrush x:Key="Theme.WindowBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Theme.Foreground" Color="#1B1B1F" />
|
||||
<SolidColorBrush x:Key="Theme.SecondaryForeground" Color="#6B6B70" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Surface" Color="#FAFAFB" />
|
||||
<SolidColorBrush x:Key="Theme.SurfaceStrong" Color="#EFEFF1" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.ControlBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Theme.ControlHoverBackground" Color="#F2F2F4" />
|
||||
<SolidColorBrush x:Key="Theme.ControlBorder" Color="#D0D0D4" />
|
||||
<SolidColorBrush x:Key="Theme.PopupBackground" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="Theme.SelectionBackground" Color="#E4EBFB" />
|
||||
|
||||
<SolidColorBrush x:Key="Theme.Accent" Color="#2563EB" />
|
||||
<SolidColorBrush x:Key="Theme.SliderTrack" Color="#DCDCE0" />
|
||||
<SolidColorBrush x:Key="Theme.ScrollBarThumb" Color="#C2C2C7" />
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Windows.Data;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Settings.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// An option in a list. The caption changes together with the language, while the
|
||||
/// object itself lives for as long as the window does: recreating the list items
|
||||
/// makes the ComboBox drop the selected value.
|
||||
/// </summary>
|
||||
public sealed class EnumOption<T> : ObservableObject where T : struct, Enum
|
||||
{
|
||||
private string _display;
|
||||
|
||||
public EnumOption(T value, string display)
|
||||
{
|
||||
Value = value;
|
||||
_display = display;
|
||||
}
|
||||
|
||||
public T Value { get; }
|
||||
|
||||
public string Display
|
||||
{
|
||||
get => _display;
|
||||
set => SetProperty(ref _display, value);
|
||||
}
|
||||
|
||||
// Accessibility tools take the name of the list item from here
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The settings window. The values are edited right in <see cref="AppSettings"/>,
|
||||
/// so the popup picks them up at once, without an "Apply" button.
|
||||
/// </summary>
|
||||
public sealed partial class SettingsViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private static readonly Color[] Palette =
|
||||
[
|
||||
Color.FromArgb(0x20, 0x20, 0x20),
|
||||
Color.FromArgb(0x00, 0x00, 0x00),
|
||||
Color.FromArgb(0x1E, 0x3A, 0x8A),
|
||||
Color.FromArgb(0x0F, 0x76, 0x6E),
|
||||
Color.FromArgb(0x7C, 0x2D, 0x12),
|
||||
Color.FromArgb(0x86, 0x19, 0x8F),
|
||||
Color.FromArgb(0xB9, 0x1C, 0x1C),
|
||||
Color.FromArgb(0xF5, 0xF5, 0xF5),
|
||||
];
|
||||
|
||||
private static readonly Color[] ForegroundPalette =
|
||||
[
|
||||
Color.FromArgb(0xFF, 0xFF, 0xFF),
|
||||
Color.FromArgb(0xD4, 0xD4, 0xD4),
|
||||
Color.FromArgb(0x00, 0x00, 0x00),
|
||||
Color.FromArgb(0xFA, 0xCC, 0x15),
|
||||
Color.FromArgb(0x4A, 0xDE, 0x80),
|
||||
Color.FromArgb(0x60, 0xA5, 0xFA),
|
||||
Color.FromArgb(0xF9, 0x73, 0x16),
|
||||
Color.FromArgb(0xF8, 0x71, 0x71),
|
||||
];
|
||||
|
||||
private readonly IStartupService _startup;
|
||||
|
||||
private StartupState _startupState = StartupState.Unavailable;
|
||||
|
||||
public SettingsViewModel(
|
||||
AppSettings settings,
|
||||
ILocalizationService localization,
|
||||
IStartupService startup,
|
||||
UpdateViewModel updates)
|
||||
{
|
||||
Settings = settings;
|
||||
Localization = localization;
|
||||
Updates = updates;
|
||||
_startup = startup;
|
||||
|
||||
// The interface language is a setting like any other and is stored in the same place
|
||||
Localization.CurrentLanguage = settings.Language;
|
||||
Settings.PropertyChanged += OnSettingsChanged;
|
||||
Localization.PropertyChanged += OnLocalizationChanged;
|
||||
|
||||
Themes = CreateOptions<AppTheme>();
|
||||
PlacementModes = CreateOptions<PopupPlacementMode>();
|
||||
AnchorSides = CreateOptions<AnchorSide>();
|
||||
ScreenPositions = CreateOptions<ScreenPosition>();
|
||||
}
|
||||
|
||||
public AppSettings Settings { get; }
|
||||
|
||||
public ILocalizationService Localization { get; }
|
||||
|
||||
/// <summary>The updates section: it has a state and commands of its own.</summary>
|
||||
public UpdateViewModel Updates { get; }
|
||||
|
||||
public IReadOnlyList<Color> BackgroundPalette { get; } = Palette;
|
||||
|
||||
public IReadOnlyList<Color> TextPalette { get; } = ForegroundPalette;
|
||||
|
||||
public IReadOnlyList<EnumOption<AppTheme>> Themes { get; }
|
||||
|
||||
public IReadOnlyList<EnumOption<PopupPlacementMode>> PlacementModes { get; }
|
||||
|
||||
public IReadOnlyList<EnumOption<AnchorSide>> AnchorSides { get; }
|
||||
|
||||
public IReadOnlyList<EnumOption<ScreenPosition>> ScreenPositions { get; }
|
||||
|
||||
/// <summary>Whether to show the startup setting.</summary>
|
||||
public bool IsStartupAvailable => _startupState != StartupState.Unavailable;
|
||||
|
||||
/// <summary>Startup is up to the application rather than to Windows.</summary>
|
||||
public bool CanChangeStartup => _startupState is StartupState.Enabled or StartupState.Disabled;
|
||||
|
||||
/// <summary>It has to be explained why the setting does not give in.</summary>
|
||||
public bool IsStartupLocked => IsStartupAvailable && !CanChangeStartup;
|
||||
|
||||
/// <summary>
|
||||
/// Start the application together with Windows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The value is not stored in the application settings: Windows knows it, and it
|
||||
/// may be changed behind our back — in the "Startup apps" section. So the property
|
||||
/// answers from the last known state of the task every time.
|
||||
/// </remarks>
|
||||
public bool RunAtStartup
|
||||
{
|
||||
get => _startupState is StartupState.Enabled or StartupState.EnabledByPolicy;
|
||||
set
|
||||
{
|
||||
if (value == RunAtStartup)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ApplyStartupAsync(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks Windows about the state of startup. Called after the window is shown: the
|
||||
/// answer has to be waited for, while the settings must open right away.
|
||||
/// </summary>
|
||||
public async Task InitializeAsync() => UpdateStartupState(await _startup.GetStateAsync());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Settings.PropertyChanged -= OnSettingsChanged;
|
||||
Localization.PropertyChanged -= OnLocalizationChanged;
|
||||
}
|
||||
|
||||
private async Task ApplyStartupAsync(bool enabled) =>
|
||||
UpdateStartupState(await _startup.SetEnabledAsync(enabled));
|
||||
|
||||
private void UpdateStartupState(StartupState state)
|
||||
{
|
||||
_startupState = state;
|
||||
|
||||
// The change is reported even when the state is the same: the checkbox has
|
||||
// already been toggled, and only a re-read value can put it back
|
||||
OnPropertyChanged(nameof(RunAtStartup));
|
||||
OnPropertyChanged(nameof(IsStartupAvailable));
|
||||
OnPropertyChanged(nameof(CanChangeStartup));
|
||||
OnPropertyChanged(nameof(IsStartupLocked));
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(AppSettings.Language))
|
||||
{
|
||||
Localization.CurrentLanguage = Settings.Language;
|
||||
}
|
||||
}
|
||||
|
||||
// The option captions come from the resources, so on a language change we update
|
||||
// only the text — the list items themselves stay the same
|
||||
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != Binding.IndexerName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Translate(Themes);
|
||||
Translate(PlacementModes);
|
||||
Translate(AnchorSides);
|
||||
Translate(ScreenPositions);
|
||||
}
|
||||
|
||||
private EnumOption<T>[] CreateOptions<T>() where T : struct, Enum =>
|
||||
[.. Enum.GetValues<T>().Select(value => new EnumOption<T>(value, GetDisplayName(value)))];
|
||||
|
||||
private void Translate<T>(IReadOnlyList<EnumOption<T>> options) where T : struct, Enum
|
||||
{
|
||||
foreach (EnumOption<T> option in options)
|
||||
{
|
||||
option.Display = GetDisplayName(option.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// The resource key is built from the type name and the value: PopupPlacementMode_AtCursor
|
||||
private string GetDisplayName<T>(T value) where T : struct, Enum =>
|
||||
Localization[$"{typeof(T).Name}_{value}"];
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Data;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Settings.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The updates section of the settings window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A check made on opening the window passes its failures in silence: the machine does
|
||||
/// not always have a live network, and there is no point complaining about it to a user
|
||||
/// who came to change the popup colour. A check started by the button does report a
|
||||
/// failure — it is awaited and watched.
|
||||
/// </remarks>
|
||||
public sealed partial class UpdateViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IUpdateService _updates;
|
||||
private readonly ILocalizationService _localization;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly UpdateOptions _options;
|
||||
|
||||
private CancellationTokenSource? _work;
|
||||
private ReleaseInfo? _release;
|
||||
private string? _packagePath;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusText))]
|
||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||
[NotifyPropertyChangedFor(nameof(CanCheck))]
|
||||
[NotifyPropertyChangedFor(nameof(HasStatus))]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDownloadOffered))]
|
||||
[NotifyPropertyChangedFor(nameof(IsInstallOffered))]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressShown))]
|
||||
[NotifyPropertyChangedFor(nameof(IsReleaseLinkShown))]
|
||||
private UpdateStatus _status = UpdateStatus.Idle;
|
||||
|
||||
/// <summary>The downloaded fraction: from 0 to 1.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressUnknown))]
|
||||
private double _progress;
|
||||
|
||||
public UpdateViewModel(
|
||||
IUpdateService updates,
|
||||
ILocalizationService localization,
|
||||
AppSettings settings,
|
||||
UpdateOptions options)
|
||||
{
|
||||
_updates = updates;
|
||||
_localization = localization;
|
||||
_settings = settings;
|
||||
_options = options;
|
||||
|
||||
_localization.PropertyChanged += OnLocalizationChanged;
|
||||
}
|
||||
|
||||
/// <summary>Whether to show the updates section at all.</summary>
|
||||
public bool IsSupported => _updates.IsSupported;
|
||||
|
||||
public string CurrentVersion => _updates.CurrentVersion.ToString();
|
||||
|
||||
/// <summary>Check for new versions at startup.</summary>
|
||||
public bool CheckAutomatically
|
||||
{
|
||||
get => _settings.CheckForUpdates;
|
||||
set
|
||||
{
|
||||
if (value == _settings.CheckForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.CheckForUpdates = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A request or a download is in flight — the buttons freeze for that time.</summary>
|
||||
public bool IsBusy => Status is UpdateStatus.Checking or UpdateStatus.Downloading;
|
||||
|
||||
public bool CanCheck => !IsBusy;
|
||||
|
||||
public bool HasStatus => Status != UpdateStatus.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// The package size is unknown, and the bar shows only the fact of the download.
|
||||
/// Its start looks the same until the first report arrives.
|
||||
/// </summary>
|
||||
public bool IsProgressUnknown => Status == UpdateStatus.Downloading && Progress <= 0;
|
||||
|
||||
public bool IsDownloadOffered => Status == UpdateStatus.Available;
|
||||
|
||||
public bool IsInstallOffered => Status == UpdateStatus.Ready;
|
||||
|
||||
public bool IsProgressShown => Status == UpdateStatus.Downloading;
|
||||
|
||||
public bool IsReleaseLinkShown => _release?.PageUrl is not null && Status is not UpdateStatus.Checking;
|
||||
|
||||
/// <summary>The release page: the release notes live there too.</summary>
|
||||
public Uri? ReleaseUrl => _release?.PageUrl;
|
||||
|
||||
public string StatusText => Status switch
|
||||
{
|
||||
UpdateStatus.Checking => _localization["UpdateChecking"],
|
||||
UpdateStatus.UpToDate => _localization["UpdateUpToDate"],
|
||||
UpdateStatus.Available => Format("UpdateAvailable", _release?.Tag),
|
||||
UpdateStatus.Downloading => _localization["UpdateDownloading"],
|
||||
UpdateStatus.Ready => _localization["UpdateReady"],
|
||||
UpdateStatus.Failed => _localization["UpdateFailed"],
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Checks for updates when the user has not forbidden it and enough time has
|
||||
/// passed since the previous check. Called once when the window opens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It used to be called when the application started, which back then meant when
|
||||
/// the machine was switched on. The background half is a separate process now and
|
||||
/// does not go to the network at all — nothing in it could show the answer — so the
|
||||
/// question is asked when there is a window to answer into.
|
||||
/// </remarks>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (!IsSupported || !_settings.CheckForUpdates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_settings.LastUpdateCheck is { } last && DateTimeOffset.UtcNow - last < _options.CheckInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunCheckAsync(quiet: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_localization.PropertyChanged -= OnLocalizationChanged;
|
||||
|
||||
_work?.Cancel();
|
||||
_work?.Dispose();
|
||||
_work = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private Task CheckAsync() => RunCheckAsync(quiet: false);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DownloadAsync()
|
||||
{
|
||||
if (_release is not { } release)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token = StartWork();
|
||||
Progress = 0;
|
||||
Status = UpdateStatus.Downloading;
|
||||
|
||||
try
|
||||
{
|
||||
var progress = new Progress<double>(value => Progress = value);
|
||||
_packagePath = await _updates.DownloadAsync(release, progress, token);
|
||||
Status = UpdateStatus.Ready;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// The download was interrupted by the next piece of work: it has already set its own state
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = UpdateStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Install()
|
||||
{
|
||||
if (_packagePath is null || !File.Exists(_packagePath))
|
||||
{
|
||||
// The file was removed by the temp folder cleanup — downloading it again is what is left
|
||||
Status = _release is null ? UpdateStatus.Idle : UpdateStatus.Available;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_updates.Install(_packagePath);
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = UpdateStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The errors that simply leave the update undone: an unreachable network, an
|
||||
/// unexpected response, a file in use. Everything else is a reason to crash.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>OperationCanceledException</c> means an expired request deadline here:
|
||||
/// cancellation by the application itself is caught by a separate handler above.
|
||||
/// </remarks>
|
||||
private static bool IsExpected(Exception e) =>
|
||||
e is HttpRequestException or JsonException or IOException or UnauthorizedAccessException
|
||||
or NotSupportedException or InvalidOperationException or Win32Exception
|
||||
or OperationCanceledException;
|
||||
|
||||
private async Task RunCheckAsync(bool quiet)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token = StartWork();
|
||||
Status = UpdateStatus.Checking;
|
||||
|
||||
try
|
||||
{
|
||||
_release = await _updates.CheckAsync(token);
|
||||
_packagePath = null;
|
||||
|
||||
_settings.LastUpdateCheck = DateTimeOffset.UtcNow;
|
||||
Status = _release is null ? UpdateStatus.UpToDate : UpdateStatus.Available;
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// The check was cancelled by the next piece of work: it has already set its own state
|
||||
return;
|
||||
}
|
||||
catch (Exception e) when (IsExpected(e))
|
||||
{
|
||||
Status = quiet ? UpdateStatus.Idle : UpdateStatus.Failed;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ReleaseUrl));
|
||||
OnPropertyChanged(nameof(IsReleaseLinkShown));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new piece of work, cancelling the previous one: the user may have
|
||||
/// pressed "Check" in the middle of a download.
|
||||
/// </summary>
|
||||
private CancellationToken StartWork()
|
||||
{
|
||||
_work?.Cancel();
|
||||
_work?.Dispose();
|
||||
_work = new CancellationTokenSource();
|
||||
return _work.Token;
|
||||
}
|
||||
|
||||
private string Format(string key, string? argument) =>
|
||||
string.Format(CultureInfo.CurrentCulture, _localization[key], argument);
|
||||
|
||||
private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == Binding.IndexerName)
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using DrawingColor = System.Drawing.Color;
|
||||
|
||||
namespace CursorLang.Settings.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Shows an element when the value matches one of those listed in the parameter,
|
||||
/// separated by commas. Needed so that the anchor point settings and the screen
|
||||
/// settings are not shown at the same time.
|
||||
/// </summary>
|
||||
public sealed class EnumToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
string? current = value?.ToString();
|
||||
bool matches = parameter?.ToString()?
|
||||
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(expected => expected == current) ?? false;
|
||||
|
||||
return matches ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A colour into a "#RRGGBB" notation: the alpha is not shown, because
|
||||
/// transparency is a separate setting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The settings hold their colours as <see cref="System.Drawing.Color"/> — the agent
|
||||
/// reads the same file and must not be made to load WindowsBase for a colour. Turning
|
||||
/// them into something WPF can paint with is the job of this file and of
|
||||
/// <see cref="ColorToBrushConverter"/>, and of nothing else.
|
||||
/// </remarks>
|
||||
public sealed class ColorToHexConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DrawingColor color ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" : string.Empty;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A colour into a brush — for the palette swatches in the list.
|
||||
/// </summary>
|
||||
public sealed class ColorToBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DrawingColor color
|
||||
? new SolidColorBrush(Color.FromArgb(color.A, color.R, color.G, color.B))
|
||||
: Brushes.Transparent;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is SolidColorBrush brush
|
||||
? DrawingColor.FromArgb(brush.Color.A, brush.Color.R, brush.Color.G, brush.Color.B)
|
||||
: Binding.DoNothing;
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
<Window x:Class="CursorLang.Settings.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:CursorLang.Settings.Views"
|
||||
xmlns:vm="clr-namespace:CursorLang.Settings.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
|
||||
Title="{Binding Localization[SettingsTitle]}"
|
||||
Width="1000" SizeToContent="Height" MaxHeight="900"
|
||||
ResizeMode="CanMinimize"
|
||||
Background="{DynamicResource Theme.WindowBackground}"
|
||||
Foreground="{DynamicResource Theme.Foreground}">
|
||||
<Window.Resources>
|
||||
<local:EnumToVisibilityConverter x:Key="EnumToVisibility" />
|
||||
<local:ColorToBrushConverter x:Key="ColorToBrush" />
|
||||
<local:ColorToHexConverter x:Key="ColorToHex" />
|
||||
<BooleanToVisibilityConverter x:Key="BooleanToVisibility" />
|
||||
|
||||
<DataTemplate x:Key="EnumOptionTemplate">
|
||||
<TextBlock Text="{Binding Display}" />
|
||||
</DataTemplate>
|
||||
|
||||
<!-- A colour swatch with a caption: the same for the background and for the text -->
|
||||
<DataTemplate x:Key="ColorSwatchTemplate">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border Width="32" Height="16" CornerRadius="2"
|
||||
BorderBrush="#888" BorderThickness="1"
|
||||
Background="{Binding Converter={StaticResource ColorToBrush}}" />
|
||||
<TextBlock Margin="8,0,0,0"
|
||||
Text="{Binding Converter={StaticResource ColorToHex}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<Style TargetType="TextBlock" x:Key="FieldLabel">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0,0,12,0" />
|
||||
<!-- The caption column is narrow, and a translation may not fit into it:
|
||||
such a caption is better wrapped onto a second line than cut off -->
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style TargetType="TextBlock" x:Key="FieldValue">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="12,0,0,0" />
|
||||
<Setter Property="MinWidth" Value="48" />
|
||||
<Setter Property="TextAlignment" Value="Right" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource Theme.SecondaryForeground}" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<!-- The settings are laid out in two columns: this way the window fits on the
|
||||
screen entirely and does without scrolling. Scrolling is kept for the case of
|
||||
a large system font, with which the content is taller than the monitor after all -->
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel>
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionInterface]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[LanguageLabel]}" />
|
||||
<ComboBox Grid.Column="1"
|
||||
ItemsSource="{Binding Localization.AvailableLanguages}"
|
||||
DisplayMemberPath="DisplayName"
|
||||
SelectedValuePath="Code"
|
||||
SelectedValue="{Binding Settings.Language}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ThemeLabel]}" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding Themes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.Theme}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionPlacement]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PlacementModeLabel]}" />
|
||||
<ComboBox Grid.Column="1" Grid.ColumnSpan="2"
|
||||
ItemsSource="{Binding PlacementModes}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.PlacementMode}" />
|
||||
|
||||
<!-- The "at cursor" mode: a side and an offset of its own -->
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorCornerLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CursorSide}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorOffsetLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CursorOffset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCursor}" />
|
||||
|
||||
<!-- The "at caret" mode: a side and an offset of its own -->
|
||||
<TextBlock Grid.Row="3" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorCornerLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding AnchorSides}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.CaretSide}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CursorOffsetLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="80" TickFrequency="1"
|
||||
Value="{Binding Settings.CaretOffset}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CaretOffset, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=AtCaret}" />
|
||||
|
||||
<!-- The "fixed point" mode -->
|
||||
<TextBlock Grid.Row="5" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenPositionLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<ComboBox Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding ScreenPositions}"
|
||||
ItemTemplate="{StaticResource EnumOptionTemplate}"
|
||||
SelectedValuePath="Value"
|
||||
SelectedValue="{Binding Settings.ScreenPosition}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
|
||||
<TextBlock Grid.Row="6" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[ScreenMarginLabel]}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<Slider Grid.Row="6" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0" Maximum="200" TickFrequency="1"
|
||||
Value="{Binding Settings.ScreenMargin}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
<TextBlock Grid.Row="6" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.ScreenMargin, StringFormat={}{0:F0}}"
|
||||
Visibility="{Binding Settings.PlacementMode,
|
||||
Converter={StaticResource EnumToVisibility},
|
||||
ConverterParameter=FixedPoint}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionBehavior]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[DurationLabel]}" />
|
||||
<Slider Grid.Column="1" Minimum="200" Maximum="5000" TickFrequency="100"
|
||||
Value="{Binding Settings.DurationMilliseconds}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.DurationMilliseconds, StringFormat={}{0:F0}}" />
|
||||
<TextBlock Margin="4,0,0,0"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[MillisecondsSuffix]}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CapsLockLabel]}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
IsChecked="{Binding Settings.UseCapsLockHotkey}"
|
||||
Content="{Binding Localization[CapsLockHotkeyCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CapsLockHoldLabel]}" />
|
||||
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="150" Maximum="1500" TickFrequency="50"
|
||||
Value="{Binding Settings.CapsLockHoldMilliseconds}"
|
||||
IsEnabled="{Binding Settings.UseCapsLockHotkey}" />
|
||||
<StackPanel Grid.Row="2" Grid.Column="2" Margin="0,12,0,0"
|
||||
Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.CapsLockHoldMilliseconds, StringFormat={}{0:F0}}" />
|
||||
<TextBlock Margin="4,0,0,0"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Text="{Binding Localization[MillisecondsSuffix]}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}">
|
||||
<Run Text="{Binding Localization[CapsLockHoldHint], Mode=OneWay}" />
|
||||
<InlineUIContainer BaselineAlignment="Baseline">
|
||||
<!-- The elevation caveat lives in the tooltip to keep the section compact -->
|
||||
<TextBlock Text="{Binding Localization[MoreInfoLink]}"
|
||||
Foreground="{DynamicResource Theme.Accent}"
|
||||
TextDecorations="Underline"
|
||||
Cursor="Help"
|
||||
ToolTipService.InitialShowDelay="200"
|
||||
ToolTipService.ShowDuration="60000"
|
||||
Visibility="{Binding Settings.UseCapsLockHotkey, Converter={StaticResource BooleanToVisibility}}">
|
||||
<TextBlock.ToolTip>
|
||||
<ToolTip>
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Text="{Binding Localization[CapsLockElevationHint]}" />
|
||||
</ToolTip>
|
||||
</TextBlock.ToolTip>
|
||||
</TextBlock>
|
||||
</InlineUIContainer>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Localization[StartupLabel]}" />
|
||||
<CheckBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
Visibility="{Binding IsStartupAvailable, Converter={StaticResource BooleanToVisibility}}"
|
||||
IsEnabled="{Binding CanChangeStartup}"
|
||||
IsChecked="{Binding RunAtStartup}"
|
||||
Content="{Binding Localization[StartupCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Visibility="{Binding IsStartupLocked, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Localization[StartupLockedHint]}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0">
|
||||
|
||||
<GroupBox Header="{Binding Localization[SectionAppearance]}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[FontSizeLabel]}" />
|
||||
<Slider Grid.Column="1" Minimum="10" Maximum="72" TickFrequency="1"
|
||||
Value="{Binding Settings.FontSize}" />
|
||||
<TextBlock Grid.Column="2" Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.FontSize, StringFormat={}{0:F0}}" />
|
||||
|
||||
<TextBlock Grid.Row="1" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[OpacityLabel]}" />
|
||||
<Slider Grid.Row="1" Grid.Column="1" Margin="0,12,0,0"
|
||||
Minimum="0.1" Maximum="1" TickFrequency="0.05"
|
||||
Value="{Binding Settings.Opacity}" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Margin="12,12,0,0"
|
||||
Style="{StaticResource FieldValue}"
|
||||
Text="{Binding Settings.Opacity, StringFormat={}{0:P0}}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[BackgroundColorLabel]}" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding BackgroundPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.BackgroundColor}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[TextColorLabel]}" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
ItemsSource="{Binding TextPalette}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}"
|
||||
SelectedItem="{Binding Settings.ForegroundColor}" />
|
||||
|
||||
<TextBlock Grid.Row="4" Margin="0,12,12,0"
|
||||
Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[PreviewLabel]}" />
|
||||
<!-- The height of the area is sized for the largest font
|
||||
available: the sample text is fully visible at any
|
||||
position of the slider -->
|
||||
<Border Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" Padding="16"
|
||||
Height="152" ClipToBounds="True"
|
||||
Background="{DynamicResource Theme.SurfaceStrong}" CornerRadius="4"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Border CornerRadius="4" Padding="10,4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Opacity="{Binding Settings.Opacity}"
|
||||
Background="{Binding Settings.BackgroundColor,
|
||||
Converter={StaticResource ColorToBrush}}">
|
||||
<TextBlock Text="RU" FontWeight="SemiBold"
|
||||
FontSize="{Binding Settings.FontSize}"
|
||||
Foreground="{Binding Settings.ForegroundColor,
|
||||
Converter={StaticResource ColorToBrush}}" />
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- The section is absent for an app from the Store: the Store updates it itself -->
|
||||
<GroupBox Header="{Binding Localization[SectionUpdates]}"
|
||||
Visibility="{Binding Updates.IsSupported, Converter={StaticResource BooleanToVisibility}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="164" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Style="{StaticResource FieldLabel}"
|
||||
Text="{Binding Localization[CurrentVersionLabel]}" />
|
||||
<TextBlock Grid.Column="1" VerticalAlignment="Center"
|
||||
Text="{Binding Updates.CurrentVersion}" />
|
||||
<Button Grid.Column="2" Padding="12,4"
|
||||
Command="{Binding Updates.CheckCommand}"
|
||||
IsEnabled="{Binding Updates.CanCheck}"
|
||||
Content="{Binding Localization[CheckUpdatesButton]}" />
|
||||
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
|
||||
IsChecked="{Binding Updates.CheckAutomatically}"
|
||||
Content="{Binding Localization[UpdateAutoCheck]}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" TextWrapping="Wrap"
|
||||
Visibility="{Binding Updates.HasStatus, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Updates.StatusText}" />
|
||||
|
||||
<!-- The downloaded fraction is not always known: not every hosting reports the file size -->
|
||||
<ProgressBar Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,8,0,0" Height="4" Maximum="1"
|
||||
Value="{Binding Updates.Progress, Mode=OneWay}"
|
||||
IsIndeterminate="{Binding Updates.IsProgressUnknown}"
|
||||
Visibility="{Binding Updates.IsProgressShown, Converter={StaticResource BooleanToVisibility}}" />
|
||||
|
||||
<StackPanel Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,12,0,0" Orientation="Horizontal">
|
||||
<Button Padding="12,4"
|
||||
Command="{Binding Updates.DownloadCommand}"
|
||||
Visibility="{Binding Updates.IsDownloadOffered, Converter={StaticResource BooleanToVisibility}}"
|
||||
Content="{Binding Localization[DownloadUpdateButton]}" />
|
||||
<Button Padding="12,4"
|
||||
Command="{Binding Updates.InstallCommand}"
|
||||
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
|
||||
Content="{Binding Localization[InstallUpdateButton]}" />
|
||||
<TextBlock Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Visibility="{Binding Updates.IsReleaseLinkShown, Converter={StaticResource BooleanToVisibility}}">
|
||||
<Hyperlink NavigateUri="{Binding Updates.ReleaseUrl}"
|
||||
RequestNavigate="OnReleaseLinkNavigate">
|
||||
<Run Text="{Binding Localization[ReleasePageLink], Mode=OneWay}" />
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
|
||||
Margin="0,6,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource Theme.SecondaryForeground}"
|
||||
Visibility="{Binding Updates.IsInstallOffered, Converter={StaticResource BooleanToVisibility}}"
|
||||
Text="{Binding Localization[UpdateInstallHint]}" />
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Window>
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Navigation;
|
||||
using CursorLang.Settings.Services;
|
||||
using CursorLang.Settings.ViewModels;
|
||||
|
||||
namespace CursorLang.Settings.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The settings window of the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both buttons in the title bar mean what they say now: the window used to hide into
|
||||
/// the tray, because closing it would have thrown away the visual tree the background
|
||||
/// half was still using. The background half is a separate process and no longer cares,
|
||||
/// so closing closes and the process ends with it.
|
||||
/// </remarks>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow(
|
||||
SettingsViewModel viewModel,
|
||||
IThemeService theme,
|
||||
MainWindowPlacement placement)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
theme.Register(this);
|
||||
placement.Attach(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the release page in a browser. A link in WPF leads nowhere on its own:
|
||||
/// where to hand it over is up to the application.
|
||||
/// </summary>
|
||||
private void OnReleaseLinkNavigate(object sender, RequestNavigateEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true })?.Dispose();
|
||||
}
|
||||
catch (Exception exception) when (exception is Win32Exception or InvalidOperationException)
|
||||
{
|
||||
// There is no browser in the system — that does not get in the way of the
|
||||
// update, which is downloaded by the button next to it anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="CursorLang.Settings.app" />
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
Reference in New Issue
Block a user