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 @@ + + + + + + + + + + + + + + + + + + + +