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:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 deletions
+12
View File
@@ -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>
+116
View File
@@ -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();
}
}
+5
View File
@@ -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);
}
}
}
+540
View File
@@ -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>
+21
View File
@@ -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>
+21
View File
@@ -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));
}
}
}
+63
View File
@@ -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;
}
+468
View File
@@ -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
}
}
}
+19
View File
@@ -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>