diff --git a/src/Capacitor.App/App.axaml.cs b/src/Capacitor.App/App.axaml.cs
index 695376cb66..2c01040053 100644
--- a/src/Capacitor.App/App.axaml.cs
+++ b/src/Capacitor.App/App.axaml.cs
@@ -83,11 +83,14 @@ public partial class App : Application {
// two read-modify-write cycles interleave and drop an update.
readonly AppStateStore _appState;
+ readonly UiScaleSetting _uiScale;
+
public App() {
_foreignHttp = new ServiceCollection().AddAppForeignHttp(_config, _serverEnv).BuildValidated();
#pragma warning disable RS0030 // the one store the ban points every other site at
_appState = new AppStateStore(_config.Path("app-state.json"));
#pragma warning restore RS0030
+ _uiScale = UiScaleSetting.LoadAsync(_appState).GetAwaiter().GetResult();
}
/// The authenticated lanes. They cannot join : their handlers need a
@@ -751,7 +754,7 @@ void BuildDaemonGraph(
historyImport: _historyImport?.Run,
backgroundPriority: lifecycle.BackgroundPriority, reloadState: lifecycle.ReloadState,
isReloading: lifecycle.IsReloading, reloadDaemon: lifecycle.ReloadServiceAsync,
- localServer: localServer)),
+ localServer: localServer, uiScale: _uiScale)),
// Both close paths release the workspace: hide-to-tray keeps the window (and its
// attach) alive, a real close discards the window the next Show() would rebuild.
releaseWorkspace: window => (window.DataContext as MainWindowViewModel)?.CloseWorkspace());
@@ -876,7 +879,7 @@ void OpenSettings(IClassicDesktopStyleApplicationLifetime desktop, SettingsProfi
nameOverridden: Environment.GetEnvironmentVariable("KCAP_DAEMON_NAME") is { Length: > 0 },
needsAppRestart: lane.IsRetired(service.DaemonName), appLifetime: _shutdown.Token,
notificationSettings: _notificationSettings, notificationAccess: _notificationAccess,
- profiles: profilesVm);
+ profiles: profilesVm) { Appearance = new AppearanceSettingsViewModel(_uiScale) };
} catch (Exception ex) {
notifier.Notify($"Could not open settings: {ex.Message}");
return;
@@ -1095,7 +1098,7 @@ async Task RunWizardModeAsync(
_wizardAuth = graph.Auth;
_historyImport = graph.History;
- var window = ShowWizardWindow(desktop, graph.ViewModel, _appState, graph.Install, () => _shutdown.IsCancellationRequested);
+ var window = ShowWizardWindow(desktop, graph.ViewModel, _appState, graph.Install, () => _shutdown.IsCancellationRequested, _uiScale);
_wizardWindow = window;
// The SAME consumer function, over the wizard's own surface: outcome presentation stays
@@ -1169,8 +1172,8 @@ internal static IKcapCli NewWizardCli(
// exits the app.
internal static MainWindow ShowWizardWindow(
IClassicDesktopStyleApplicationLifetime desktop, OnboardingViewModel wizard, IAppStateStore? appState = null,
- LocalInstallController? install = null, Func? shuttingDown = null) {
- var window = new MainWindow { Onboarding = wizard };
+ LocalInstallController? install = null, Func? shuttingDown = null, UiScaleSetting? uiScale = null) {
+ var window = new MainWindow { Onboarding = wizard, ContentScale = uiScale };
if (appState is not null) {
WindowSizeMemory.Restore(window, appState);
WindowSizeMemory.Attach(window, appState);
@@ -1390,7 +1393,7 @@ internal static MainWindow BuildAndShowMainWindow(
Action? requestSetup = null, HistoryImportRun? historyImport = null,
IObservable? backgroundPriority = null, IObservable? reloadState = null,
IObservable? isReloading = null, Func? reloadDaemon = null,
- LocalServerRecoveryViewModel? localServer = null) {
+ LocalServerRecoveryViewModel? localServer = null, UiScaleSetting? uiScale = null) {
// Notifier is set on the WINDOW (the toast overlay), not the ViewModel — the toast
// is a View-level concern (WindowNotificationManager lives on MainWindow) independent of
// the VM's WhenActivated-scoped projections.
@@ -1445,6 +1448,7 @@ internal static MainWindow BuildAndShowMainWindow(
var window = adopt ?? new MainWindow();
window.DataContext = vm;
window.Notifier = notifier;
+ window.ContentScale = uiScale;
window.Onboarding = null;
if (adopt is null) {
WindowSizeMemory.Restore(window, appState);
diff --git a/src/Capacitor.App/Services/AppStateStore.cs b/src/Capacitor.App/Services/AppStateStore.cs
index c7418d4ab6..0e6c9f55b7 100644
--- a/src/Capacitor.App/Services/AppStateStore.cs
+++ b/src/Capacitor.App/Services/AppStateStore.cs
@@ -19,7 +19,8 @@ public sealed record AppState(
double? WindowHeight = null,
int? WindowX = null,
int? WindowY = null,
- bool WindowMaximized = false);
+ bool WindowMaximized = false,
+ double? UiScale = null);
public interface IAppStateStore {
Task LoadAsync();
diff --git a/src/Capacitor.App/Services/UiScale.cs b/src/Capacitor.App/Services/UiScale.cs
new file mode 100644
index 0000000000..c7d88c34c6
--- /dev/null
+++ b/src/Capacitor.App/Services/UiScale.cs
@@ -0,0 +1,33 @@
+using System.Globalization;
+
+namespace Capacitor.App.Services;
+
+/// How much the main window magnifies its content: text and the layout around it grow together,
+/// because control font sizes are fixed per control. Kept to whole steps so a value read back from
+/// app state lands on one the shortcuts can step from.
+public static class UiScale {
+ public const double Default = 1.0;
+ public const double Min = 0.8;
+ public const double Max = 1.5;
+ const double Step = 0.1;
+ const int StepsPerUnit = 10;
+
+ public static double Normalize(double? scale) {
+ if (scale is not { } value || !double.IsFinite(value)) return Default;
+
+ // Multiplied rather than divided by the step: 0.95 / 0.1 lands just below 9.5 and would round down.
+ return Math.Round(Math.Round(Math.Clamp(value, Min, Max) * StepsPerUnit, MidpointRounding.AwayFromZero) / StepsPerUnit, 2);
+ }
+
+ public static double Larger(double scale) => Normalize(Normalize(scale) + Step);
+
+ public static double Smaller(double scale) => Normalize(Normalize(scale) - Step);
+
+ /// A window minimum that leaves the magnified content the room it is laid out for, without
+ /// growing past what the screen offers. Never below the unscaled minimum.
+ public static double Minimum(double unscaled, double scale, double? available) =>
+ Math.Max(unscaled, Math.Min(unscaled * Normalize(scale), available ?? double.MaxValue));
+
+ public static string Label(double scale) =>
+ ((int)Math.Round(Normalize(scale) * 100)).ToString(CultureInfo.InvariantCulture) + "%";
+}
diff --git a/src/Capacitor.App/Services/UiScaleSetting.cs b/src/Capacitor.App/Services/UiScaleSetting.cs
new file mode 100644
index 0000000000..523135974a
--- /dev/null
+++ b/src/Capacitor.App/Services/UiScaleSetting.cs
@@ -0,0 +1,33 @@
+using System.Reactive.Subjects;
+
+namespace Capacitor.App.Services;
+
+/// The app's one UI scale, shared by the main window and Settings and kept in app state.
+public sealed class UiScaleSetting {
+ readonly IAppStateStore _store;
+ readonly BehaviorSubject _changes;
+
+ UiScaleSetting(IAppStateStore store, double initial) {
+ _store = store;
+ _changes = new BehaviorSubject(initial);
+ }
+
+ public static async Task LoadAsync(IAppStateStore store) =>
+ new(store, UiScale.Normalize((await store.LoadAsync().ConfigureAwait(false)).UiScale));
+
+ public double Current => _changes.Value;
+
+ /// The current scale, then each change.
+ public IObservable Changes => _changes;
+
+ /// A failed write keeps the scale for this run. Each write stores the scale current when it runs,
+ /// so overlapping changes cannot leave an older one on disk.
+ public Task SetAsync(double scale) {
+ var next = UiScale.Normalize(scale);
+ if (next == _changes.Value) return Task.CompletedTask;
+
+ _changes.OnNext(next);
+ // Off the caller's thread: an uncontended store reads and writes the file before its first await.
+ return Task.Run(() => _store.UpdateAsync(state => state with { UiScale = _changes.Value }));
+ }
+}
diff --git a/src/Capacitor.App/ViewModels/AppearanceSettingsViewModel.cs b/src/Capacitor.App/ViewModels/AppearanceSettingsViewModel.cs
new file mode 100644
index 0000000000..287a16d687
--- /dev/null
+++ b/src/Capacitor.App/ViewModels/AppearanceSettingsViewModel.cs
@@ -0,0 +1,47 @@
+using System.Reactive;
+using System.Reactive.Linq;
+using Capacitor.App.Services;
+using ReactiveUI.Reactive;
+
+namespace Capacitor.App.ViewModels;
+
+/// Settings' Appearance tab: the UI scale, which the main window's shortcuts can also change while
+/// the tab is open.
+public sealed class AppearanceSettingsViewModel : ReactiveObject, IDisposable {
+ readonly IDisposable _subscription;
+ double _scale;
+
+ public AppearanceSettingsViewModel(UiScaleSetting setting) {
+ _scale = setting.Current;
+ var scale = this.WhenAnyValue(x => x.Scale);
+ SmallerCommand = ReactiveCommand.CreateFromTask(() => setting.SetAsync(UiScale.Smaller(Scale)), scale.Select(s => s > UiScale.Min));
+ LargerCommand = ReactiveCommand.CreateFromTask(() => setting.SetAsync(UiScale.Larger(Scale)), scale.Select(s => s < UiScale.Max));
+ ResetCommand = ReactiveCommand.CreateFromTask(() => setting.SetAsync(UiScale.Default), scale.Select(s => s != UiScale.Default));
+ _subscription = setting.Changes.ObserveOn(RxSchedulers.MainThreadScheduler).Subscribe(s => Scale = s);
+ }
+
+ public double Scale {
+ get => _scale;
+ private set {
+ this.RaiseAndSetIfChanged(ref _scale, value);
+ this.RaisePropertyChanged(nameof(ScaleLabel));
+ }
+ }
+
+ public string ScaleLabel => UiScale.Label(Scale);
+
+ public string ShortcutHint { get; } = OperatingSystem.IsMacOS()
+ ? "Scales text and layout together. ⌘+ and ⌘− also change it."
+ : "Scales text and layout together. Ctrl++ and Ctrl+− also change it, and Ctrl+0 resets it.";
+
+ public ReactiveCommand SmallerCommand { get; }
+ public ReactiveCommand LargerCommand { get; }
+ public ReactiveCommand ResetCommand { get; }
+
+ public void Dispose() {
+ _subscription.Dispose();
+ SmallerCommand.Dispose();
+ LargerCommand.Dispose();
+ ResetCommand.Dispose();
+ }
+}
diff --git a/src/Capacitor.App/ViewModels/SettingsViewModel.cs b/src/Capacitor.App/ViewModels/SettingsViewModel.cs
index 17acff0d5c..b4663e48fd 100644
--- a/src/Capacitor.App/ViewModels/SettingsViewModel.cs
+++ b/src/Capacitor.App/ViewModels/SettingsViewModel.cs
@@ -164,6 +164,8 @@ public string? NotificationMessage {
public ProfilesSettingsViewModel? Profiles { get; }
+ public AppearanceSettingsViewModel? Appearance { get; init; }
+
/// What the OS calls the app that holds its notification switches.
internal static string SystemSettingsName { get; } = OperatingSystem.IsWindows() ? "Windows Settings" : "System Settings";
@@ -410,6 +412,7 @@ void Refresh() {
public void Dispose() {
_lifetime.Cancel();
_subscriptions.Dispose();
+ Appearance?.Dispose();
_lifetime.Dispose();
}
}
diff --git a/src/Capacitor.App/Views/MainWindow.axaml b/src/Capacitor.App/Views/MainWindow.axaml
index 90e4fb57e5..384d1e9d21 100644
--- a/src/Capacitor.App/Views/MainWindow.axaml
+++ b/src/Capacitor.App/Views/MainWindow.axaml
@@ -26,133 +26,139 @@
only receives presses where no surface content is hit-testable — rail and workspace
headers carry their own drag handlers. -->
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/src/Capacitor.App/Views/MainWindow.axaml.cs b/src/Capacitor.App/Views/MainWindow.axaml.cs
index 75128ea74c..d07dc49533 100644
--- a/src/Capacitor.App/Views/MainWindow.axaml.cs
+++ b/src/Capacitor.App/Views/MainWindow.axaml.cs
@@ -4,6 +4,7 @@
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Input;
+using Avalonia.Media;
using Capacitor.App.Services;
using Capacitor.App.ViewModels;
using Capacitor.App.ViewModels.Onboarding;
@@ -57,8 +58,66 @@ public IAppNotifier? Notifier {
}
}
+ UiScaleSetting? _contentScale;
+ IDisposable? _contentScaleSubscription;
+
+ /// Magnifies everything in the window. Assigned by App; left null on a plainly-constructed
+ /// window, whose zoom shortcuts then do nothing.
+ public UiScaleSetting? ContentScale {
+ get => _contentScale;
+ set {
+ _contentScale = value;
+ _contentScaleSubscription?.Dispose();
+ _contentScaleSubscription = value?.Changes
+ .ObserveOn(RxSchedulers.MainThreadScheduler)
+ .Subscribe(ApplyScale);
+ }
+ }
+
+ double _scale = UiScale.Default;
+ double _captionTop;
+
+ void ApplyScale(double scale) {
+ _scale = scale;
+ ScaleHost.LayoutTransform = scale == UiScale.Default ? null : new ScaleTransform(scale, scale);
+ ApplyMinimumSize();
+ ApplyCaptionInset();
+ }
+
+ // A closed window has no platform window to ask a screen about.
+ void RefreshMinimumSize() {
+ if (_scale != UiScale.Default && PlatformImpl is not null) ApplyMinimumSize();
+ }
+
+ // Bounded by the screen the window is on.
+ void ApplyMinimumSize() {
+ var area = Screens?.ScreenFromWindow(this) ?? Screens?.Primary;
+ var available = area is null ? (Size?)null : area.WorkingArea.Size.ToSize(area.Scaling);
+ MinWidth = UiScale.Minimum(WindowSizeMemory.MinWidth, _scale, available?.Width);
+ MinHeight = UiScale.Minimum(WindowSizeMemory.MinHeight, _scale, available?.Height);
+ }
+
+ // The inset is in window units and the pane sits inside the scale, so it is divided back out.
+ void ApplyCaptionInset() => ContentPane.Margin = new Thickness(0, _captionTop / _scale, 0, 0);
+
public MainWindow() {
InitializeComponent();
+ KeyBindings.AddRange(ZoomShortcut.For(this));
+ // A move, a display scaling change and a resized work area each change what the screen offers.
+ PositionChanged += (_, _) => RefreshMinimumSize();
+ ScalingChanged += (_, _) => RefreshMinimumSize();
+ Screens? watchedScreens = null;
+ void OnScreensChanged(object? sender, EventArgs e) => RefreshMinimumSize();
+ Opened += (_, _) => {
+ if (watchedScreens is not null || Screens is not { } screens) return;
+ watchedScreens = screens;
+ screens.Changed += OnScreensChanged;
+ };
+ // Hide-to-tray never closes the window, so only a real close lets the setting and the screens go.
+ Closed += (_, _) => {
+ _contentScaleSubscription?.Dispose();
+ if (watchedScreens is not null) watchedScreens.Changed -= OnScreensChanged;
+ };
BindRefreshShortcuts();
BindSettingsShortcut();
KeepContentClearOfCaptionButtons();
@@ -132,7 +191,10 @@ void Add(KeyGesture gesture) {
void KeepContentClearOfCaptionButtons() {
if (!OperatingSystem.IsWindows()) return;
this.GetObservable(WindowDecorationMarginProperty)
- .Subscribe(margin => ContentPane.Margin = new Thickness(0, margin.Top, 0, 0));
+ .Subscribe(margin => {
+ _captionTop = margin.Top;
+ ApplyCaptionInset();
+ });
}
// F11 is the Windows convention for full screen; Win+Up and Win+Down already maximize and
diff --git a/src/Capacitor.App/Views/RefreshUnlessTerminalFocused.cs b/src/Capacitor.App/Views/RefreshUnlessTerminalFocused.cs
index 9f88a9ade3..2077c96bf7 100644
--- a/src/Capacitor.App/Views/RefreshUnlessTerminalFocused.cs
+++ b/src/Capacitor.App/Views/RefreshUnlessTerminalFocused.cs
@@ -1,9 +1,6 @@
using System.Windows.Input;
-using Avalonia;
using Avalonia.Controls;
-using Avalonia.VisualTree;
using Capacitor.App.ViewModels;
-using SvcSystems.UI.Terminal;
namespace Capacitor.App.Views;
@@ -18,17 +15,9 @@ public void Execute(object? parameter) {
inner.Execute(parameter);
}
- // Read on the key press. Nothing can cache this: focus is not a command input.
public event EventHandler? CanExecuteChanged { add { } remove { } }
ICommand? Inner => (window.DataContext as MainWindowViewModel)?.RefreshWorkCommand;
- bool FocusedInTerminal() {
- var focused = window.FocusManager?.GetFocusedElement() as Visual;
- while (focused is not null) {
- if (focused is TerminalControl) return true;
- focused = focused.GetVisualParent();
- }
- return false;
- }
+ bool FocusedInTerminal() => TerminalFocus.Has(window);
}
diff --git a/src/Capacitor.App/Views/SettingsWindow.axaml b/src/Capacitor.App/Views/SettingsWindow.axaml
index 6c539f97b9..912920a311 100644
--- a/src/Capacitor.App/Views/SettingsWindow.axaml
+++ b/src/Capacitor.App/Views/SettingsWindow.axaml
@@ -183,6 +183,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
next) : ICommand {
+ static readonly KeyModifiers Modifier = RefreshShortcut.UsesControl ? KeyModifiers.Control : KeyModifiers.Meta;
+
+ /// Larger on = (also + with Shift) and keypad +; smaller on - and keypad -. Actual size is Ctrl+0
+ /// only: macOS gives ⌘0 to the Window menu, so it is Settings' Reset there.
+ public static IEnumerable For(MainWindow window) {
+ var larger = new ZoomShortcut(window, UiScale.Larger);
+ var smaller = new ZoomShortcut(window, UiScale.Smaller);
+ yield return Bind(Key.OemPlus, Modifier, larger);
+ yield return Bind(Key.OemPlus, Modifier | KeyModifiers.Shift, larger);
+ yield return Bind(Key.Add, Modifier, larger);
+ yield return Bind(Key.OemMinus, Modifier, smaller);
+ yield return Bind(Key.Subtract, Modifier, smaller);
+ if (RefreshShortcut.UsesControl) yield return Bind(Key.D0, Modifier, new ZoomShortcut(window, _ => UiScale.Default));
+ }
+
+ static KeyBinding Bind(Key key, KeyModifiers modifiers, ICommand command) =>
+ new() { Gesture = new KeyGesture(key, modifiers), Command = command };
+
+ public bool CanExecute(object? parameter) =>
+ window.ContentScale is not null && !(RefreshShortcut.UsesControl && TerminalFocus.Has(window));
+
+ public void Execute(object? parameter) {
+ if (!CanExecute(parameter) || window.ContentScale is not { } setting) return;
+ _ = setting.SetAsync(next(setting.Current));
+ }
+
+ public event EventHandler? CanExecuteChanged { add { } remove { } }
+}
diff --git a/test/Capacitor.App.Tests.Unit/UiScaleTests.cs b/test/Capacitor.App.Tests.Unit/UiScaleTests.cs
new file mode 100644
index 0000000000..c34ba4ef89
--- /dev/null
+++ b/test/Capacitor.App.Tests.Unit/UiScaleTests.cs
@@ -0,0 +1,69 @@
+using Capacitor.App.Services;
+
+namespace Capacitor.App.Tests.Unit;
+
+public class UiScaleTests {
+ [Test]
+ [Arguments(null, 1.0)]
+ [Arguments(double.NaN, 1.0)]
+ [Arguments(double.PositiveInfinity, 1.0)]
+ [Arguments(0.1, 0.8)]
+ [Arguments(9.0, 1.5)]
+ [Arguments(1.13, 1.1)]
+ [Arguments(1.16, 1.2)]
+ [Arguments(0.95, 1.0)]
+ [Arguments(1.05, 1.1)]
+ public async Task Normalize_falls_back_clamps_and_snaps_to_a_step(double? stored, double expected) =>
+ await Assert.That(UiScale.Normalize(stored)).IsEqualTo(expected);
+
+ [Test]
+ [Arguments(1.0, 1.1, 0.9)]
+ [Arguments(1.5, 1.5, 1.4)]
+ [Arguments(0.8, 0.9, 0.8)]
+ public async Task Larger_and_smaller_move_one_step_within_the_range(double from, double larger, double smaller) {
+ await Assert.That(UiScale.Larger(from)).IsEqualTo(larger);
+ await Assert.That(UiScale.Smaller(from)).IsEqualTo(smaller);
+ }
+
+ [Test]
+ [Arguments(1.0, null, 1200.0)]
+ [Arguments(0.8, null, 1200.0)]
+ [Arguments(1.5, null, 1800.0)]
+ [Arguments(1.5, 1470.0, 1470.0)]
+ [Arguments(1.5, 900.0, 1200.0)]
+ public async Task Minimum_grows_with_the_scale_up_to_the_screen_and_never_below_the_unscaled_one(
+ double scale, double? available, double expected) =>
+ await Assert.That(UiScale.Minimum(1200, scale, available)).IsEqualTo(expected);
+
+ [Test]
+ [Arguments(1.0, "100%")]
+ [Arguments(0.9, "90%")]
+ [Arguments(1.3, "130%")]
+ public async Task Label_is_a_whole_percentage(double scale, string label) =>
+ await Assert.That(UiScale.Label(scale)).IsEqualTo(label);
+
+ [Test]
+ public async Task A_change_is_published_and_kept_for_the_next_start() {
+ using var tmp = TempDir.WithPathTo("app-state.json", out var path);
+ var setting = await UiScaleSetting.LoadAsync(new AppStateStore(path));
+ var seen = new List();
+ using var subscription = setting.Changes.Subscribe(seen.Add);
+
+ await setting.SetAsync(1.2);
+ await setting.SetAsync(1.2);
+
+ await Assert.That(string.Join(",", seen)).IsEqualTo("1,1.2");
+ await Assert.That(setting.Current).IsEqualTo(1.2);
+ var reloaded = await UiScaleSetting.LoadAsync(new AppStateStore(path));
+ await Assert.That(reloaded.Current).IsEqualTo(1.2);
+ }
+
+ [Test]
+ public async Task A_stored_scale_out_of_range_loads_clamped() {
+ using var tmp = TempDir.WithPathTo("app-state.json", out var path);
+ var store = new AppStateStore(path);
+ await store.UpdateAsync(s => s with { UiScale = 4 });
+
+ await Assert.That((await UiScaleSetting.LoadAsync(store)).Current).IsEqualTo(1.5);
+ }
+}
diff --git a/test/Capacitor.App.Tests.Unit/UiScaleWindowTests.cs b/test/Capacitor.App.Tests.Unit/UiScaleWindowTests.cs
new file mode 100644
index 0000000000..0dff8237dd
--- /dev/null
+++ b/test/Capacitor.App.Tests.Unit/UiScaleWindowTests.cs
@@ -0,0 +1,141 @@
+using System.Reactive.Linq;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Threading;
+using Capacitor.App.Services;
+using Capacitor.App.Services.Mutation;
+using Capacitor.App.ViewModels;
+using Capacitor.App.Views;
+using Capacitor.Cli.Core.Config;
+using SvcSystems.UI.Terminal;
+
+namespace Capacitor.App.Tests.Unit;
+
+[NotInParallel("AvaloniaSession")]
+public class UiScaleWindowTests {
+ [TempConfigRoot] public required TempConfigRoot Config { get; init; }
+
+ static readonly RawInputModifiers Modifier =
+ RefreshShortcut.UsesControl ? RawInputModifiers.Control : RawInputModifiers.Meta;
+
+ Task NewSetting() => UiScaleSetting.LoadAsync(new AppStateStore(Config.Root.Path("app-state.json")));
+
+ static double? Applied(MainWindow window) =>
+ (window.FindControl("ScaleHost")!.LayoutTransform as ScaleTransform)?.ScaleX;
+
+ [Test]
+ public Task Main_window_shortcuts_step_the_scale_and_magnify_its_content() => AvaloniaSession.RunOnUiAsync(async () => {
+ var setting = await NewSetting();
+ var window = new MainWindow { ContentScale = setting };
+ try {
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(Applied(window)).IsNull();
+
+ window.KeyPressQwerty(PhysicalKey.Equal, Modifier);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(1.1);
+ await Assert.That(Applied(window)).IsEqualTo(1.1);
+
+ window.KeyPressQwerty(PhysicalKey.Minus, Modifier);
+ window.KeyPressQwerty(PhysicalKey.Minus, Modifier);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(0.9);
+ await Assert.That(Applied(window)).IsEqualTo(0.9);
+ } finally { window.Close(); }
+ });
+
+ [Test]
+ public Task Shift_equals_keypad_keys_and_ctrl_zero_reach_the_scale() => AvaloniaSession.RunOnUiAsync(async () => {
+ var setting = await NewSetting();
+ var window = new MainWindow { ContentScale = setting };
+ try {
+ window.Show();
+ window.KeyPressQwerty(PhysicalKey.Equal, Modifier | RawInputModifiers.Shift);
+ window.KeyPressQwerty(PhysicalKey.NumPadAdd, Modifier);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(1.2);
+
+ window.KeyPressQwerty(PhysicalKey.NumPadSubtract, Modifier);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(1.1);
+
+ // ⌘0 belongs to the Window menu on macOS, so only Ctrl+0 resets.
+ window.KeyPressQwerty(PhysicalKey.Digit0, Modifier);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(RefreshShortcut.UsesControl ? UiScale.Default : 1.1);
+ } finally { window.Close(); }
+ });
+
+ /// A Ctrl shortcut leaves a focused terminal its key; ⌘ never reaches a shell, so it always zooms.
+ [Test]
+ public Task A_focused_terminal_keeps_ctrl_but_not_cmd() => AvaloniaSession.RunOnUiAsync(async () => {
+ var setting = await NewSetting();
+ var window = new MainWindow { ContentScale = setting };
+ var terminal = new TerminalControl { Width = 200, Height = 100, Focusable = true };
+ try {
+ window.FindControl("ContentPane")!.Children.Add(terminal);
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ terminal.Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ window.KeyPressQwerty(PhysicalKey.Equal, Modifier);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(RefreshShortcut.UsesControl ? UiScale.Default : 1.1);
+ } finally { window.Close(); }
+ });
+
+ [Test]
+ public Task A_closed_window_stops_following_the_scale() => AvaloniaSession.RunOnUiAsync(async () => {
+ var setting = await NewSetting();
+ var window = new MainWindow { ContentScale = setting };
+ window.Show();
+ window.Close();
+ Dispatcher.UIThread.RunJobs();
+
+ await setting.SetAsync(1.3);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(Applied(window)).IsNull();
+ });
+
+ [Test]
+ public Task Appearance_tab_steps_resets_and_follows_a_change_made_elsewhere() => AvaloniaSession.RunOnUiAsync(async () => {
+ ConfigMutator.Mutate(Config.Root, c => c with { Profiles = new() { ["work"] = new Profile { ServerUrl = "https://work.example" } } });
+ var setting = await NewSetting();
+ using var vm = new SettingsViewModel(new SettingsProfileStore(Config.Root, "work", "https://work.example"),
+ new FakeDaemonClientService(), new ScriptedLocalControlOps(), (_, _) => Task.FromResult(false),
+ (_, _) => Task.FromResult(new MutationOutcome.Succeeded()),
+ (_, _) => Task.FromResult(false), _ => Task.FromResult(false), true, Task.CompletedTask, (_, _) => Task.FromResult(true)) {
+ Appearance = new AppearanceSettingsViewModel(setting),
+ };
+ var window = new SettingsWindow { DataContext = vm };
+ try {
+ window.Show();
+ window.FindControl("SettingsTabs")!.SelectedItem = window.FindControl("AppearanceTab");
+ Dispatcher.UIThread.RunJobs();
+ var label = window.FindControl("TextSizeText")!;
+ var larger = window.FindControl("TextLargerButton")!;
+ var reset = window.FindControl("TextResetButton")!;
+ await Assert.That(label.Text).IsEqualTo("100%");
+ await Assert.That(reset.IsEffectivelyEnabled).IsFalse();
+
+ await vm.Appearance.LargerCommand.Execute();
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(label.Text).IsEqualTo("110%");
+ await Assert.That(reset.IsEffectivelyEnabled).IsTrue();
+
+ await setting.SetAsync(UiScale.Max);
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(label.Text).IsEqualTo("150%");
+ await Assert.That(larger.IsEffectivelyEnabled).IsFalse();
+
+ await vm.Appearance.ResetCommand.Execute();
+ Dispatcher.UIThread.RunJobs();
+ await Assert.That(setting.Current).IsEqualTo(UiScale.Default);
+ await Assert.That(label.Text).IsEqualTo("100%");
+ } finally { window.Close(); }
+ });
+}