Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="_foreignHttp"/>: their handlers need a
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1095,7 +1098,7 @@ async Task<bool> 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
Expand Down Expand Up @@ -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<bool>? shuttingDown = null) {
var window = new MainWindow { Onboarding = wizard };
LocalInstallController? install = null, Func<bool>? 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);
Expand Down Expand Up @@ -1390,7 +1393,7 @@ internal static MainWindow BuildAndShowMainWindow(
Action? requestSetup = null, HistoryImportRun? historyImport = null,
IObservable<bool>? backgroundPriority = null, IObservable<ReloadState?>? reloadState = null,
IObservable<bool>? isReloading = null, Func<CancellationToken, Task>? 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.
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/Capacitor.App/Services/AppStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState> LoadAsync();
Expand Down
33 changes: 33 additions & 0 deletions src/Capacitor.App/Services/UiScale.cs
Original file line number Diff line number Diff line change
@@ -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);

/// <summary>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.</summary>
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) + "%";
}
33 changes: 33 additions & 0 deletions src/Capacitor.App/Services/UiScaleSetting.cs
Original file line number Diff line number Diff line change
@@ -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<double> _changes;

UiScaleSetting(IAppStateStore store, double initial) {
_store = store;
_changes = new BehaviorSubject<double>(initial);
}

public static async Task<UiScaleSetting> 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<double> 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 }));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
47 changes: 47 additions & 0 deletions src/Capacitor.App/ViewModels/AppearanceSettingsViewModel.cs
Original file line number Diff line number Diff line change
@@ -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<Unit, Unit> SmallerCommand { get; }
public ReactiveCommand<Unit, Unit> LargerCommand { get; }
public ReactiveCommand<Unit, Unit> ResetCommand { get; }

public void Dispose() {
_subscription.Dispose();
SmallerCommand.Dispose();
LargerCommand.Dispose();
ResetCommand.Dispose();
}
}
3 changes: 3 additions & 0 deletions src/Capacitor.App/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -410,6 +412,7 @@ void Refresh() {
public void Dispose() {
_lifetime.Cancel();
_subscriptions.Dispose();
Appearance?.Dispose();
_lifetime.Dispose();
}
}
Loading
Loading