From 5a37a82b40862cd1a48452c92b2244cc2cf7ee72 Mon Sep 17 00:00:00 2001 From: flexeykinDev Date: Thu, 22 Oct 2026 14:04:29 +0200 Subject: [PATCH 1/3] Window zoom: Ctrl + wheel, Ctrl + plus / minus and Ctrl + 0 make every window larger or smaller, remembered --- src/Filee.App/App.axaml.cs | 14 +++ src/Filee.App/Services/UiZoom.cs | 100 ++++++++++++++++++ .../Views/EngineSetupWindow.axaml.cs | 1 + .../Views/GroupEditorWindow.axaml.cs | 1 + .../Views/LibreOfficeOptionsWindow.axaml.cs | 1 + src/Filee.App/Views/MainWindow.axaml.cs | 1 + src/Filee.App/Views/MergeOrderWindow.axaml.cs | 1 + .../Views/PresetEditorWindow.axaml.cs | 1 + src/Filee.App/Views/RenameToolWindow.axaml.cs | 1 + src/Filee.Core/Settings/AppSettings.cs | 3 + tests/Filee.App.Tests/UiZoomTests.cs | 43 ++++++++ 11 files changed, 167 insertions(+) create mode 100644 src/Filee.App/Services/UiZoom.cs create mode 100644 tests/Filee.App.Tests/UiZoomTests.cs diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 618e0d55..d6d79a73 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -257,6 +257,20 @@ private static void ApplySettings(AppSettings settings) var custom = CustomTheme.Load(AppHost.Get().Directory, AppHost.Get>()); AppHost.Get().Apply(settings.Theme, loc.Language, custom); + // Ctrl + wheel / Ctrl +/- / Ctrl+0: everything larger or smaller, remembered. + UiZoom.Configure(settings.UiScale, scale => + { + var store = AppHost.Get(); + store.Settings.UiScale = scale; + try + { + store.SaveSettings(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + AppHost.Get>().LogWarning(ex, "Could not remember the window zoom"); + } + }); AppHost.Get().Apply(settings); AppHost.Get().Priority = settings.EnginePriority; if (ownCopiesChanged || routesChanged) diff --git a/src/Filee.App/Services/UiZoom.cs b/src/Filee.App/Services/UiZoom.cs new file mode 100644 index 00000000..20c7eb72 --- /dev/null +++ b/src/Filee.App/Services/UiZoom.cs @@ -0,0 +1,100 @@ +// Everything in Filee's windows larger or smaller, as a browser zooms a page: Ctrl + mouse wheel, Ctrl + plus / minus, +// Ctrl + 0 back to normal. Each tracked window's content is scaled by a layout transform (so layouts that adapt to their +// width see the room they really have), every window at once, and the size is remembered. + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; + +namespace Filee.App.Services; + +public static class UiZoom +{ + /// The smallest and largest size, and one step. + public const double Min = 0.7, Max = 1.8, Step = 0.1; + + private static readonly List> Windows = []; + private static Action? _save; + + /// How large everything is drawn: 1 as designed. + public static double Scale { get; private set; } = 1; + + /// The size remembered and how to remember a new one. + public static void Configure(double scale, Action save) + { + _save = save; + Set(scale, remember: false); + } + + /// Scales 's content and gives it the zoom keys and wheel. + public static void Track(Window window) + { + lock (Windows) + { + Windows.RemoveAll(w => !w.TryGetTarget(out _)); + Windows.Add(new WeakReference(window)); + } + window.AddHandler(InputElement.PointerWheelChangedEvent, OnWheel, RoutingStrategies.Tunnel); + window.AddHandler(InputElement.KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + Apply(window); + } + + /// Sets the size (kept between and ) in every window. + public static void Set(double scale, bool remember = true) + { + var value = Math.Round(Math.Clamp(double.IsFinite(scale) ? scale : 1, Min, Max), 2); + var changed = Math.Abs(value - Scale) > 1e-9; + Scale = value; + List windows; + lock (Windows) + windows = Windows.Select(w => w.TryGetTarget(out var window) ? window : null).OfType().ToList(); + foreach (var window in windows) + Apply(window); + if (changed && remember) + _save?.Invoke(value); + } + + private static void Apply(Window window) + { + if (window.Content is not Control content) + return; + if (content is not LayoutTransformControl { Tag: "ui-zoom" } scaled) + { + window.Content = null; + scaled = new LayoutTransformControl { Tag = "ui-zoom", Child = content }; + window.Content = scaled; + } + scaled.LayoutTransform = Math.Abs(Scale - 1) < 1e-9 ? null : new ScaleTransform(Scale, Scale); + } + + private static void OnWheel(object? sender, PointerWheelEventArgs e) + { + // Ctrl, or Cmd on a Mac. + if (!(e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Meta)) || e.Delta.Y == 0) + return; + Set(Scale + Math.Sign(e.Delta.Y) * Step); + e.Handled = true; + } + + private static void OnKey(object? sender, KeyEventArgs e) + { + if (e.KeyModifiers is not (KeyModifiers.Control or KeyModifiers.Meta)) + return; + switch (e.Key) + { + case Key.OemPlus or Key.Add: + Set(Scale + Step); + e.Handled = true; + break; + case Key.OemMinus or Key.Subtract: + Set(Scale - Step); + e.Handled = true; + break; + case Key.D0 or Key.NumPad0: + Set(1); + e.Handled = true; + break; + } + } +} diff --git a/src/Filee.App/Views/EngineSetupWindow.axaml.cs b/src/Filee.App/Views/EngineSetupWindow.axaml.cs index 23f5e020..eb43d323 100644 --- a/src/Filee.App/Views/EngineSetupWindow.axaml.cs +++ b/src/Filee.App/Views/EngineSetupWindow.axaml.cs @@ -10,6 +10,7 @@ public EngineSetupWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); DataContextChanged += (_, _) => { if (DataContext is EngineSetupViewModel vm) diff --git a/src/Filee.App/Views/GroupEditorWindow.axaml.cs b/src/Filee.App/Views/GroupEditorWindow.axaml.cs index 5b981e4b..b6e28279 100644 --- a/src/Filee.App/Views/GroupEditorWindow.axaml.cs +++ b/src/Filee.App/Views/GroupEditorWindow.axaml.cs @@ -13,6 +13,7 @@ public GroupEditorWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); Opened += (_, _) => NameBox.Focus(); // Before the ComboBox, which would take Enter to open its list again. AddBox.AddHandler(KeyDownEvent, OnAddKeyDown, RoutingStrategies.Tunnel); diff --git a/src/Filee.App/Views/LibreOfficeOptionsWindow.axaml.cs b/src/Filee.App/Views/LibreOfficeOptionsWindow.axaml.cs index 37ed5140..8411f5e5 100644 --- a/src/Filee.App/Views/LibreOfficeOptionsWindow.axaml.cs +++ b/src/Filee.App/Views/LibreOfficeOptionsWindow.axaml.cs @@ -11,6 +11,7 @@ public LibreOfficeOptionsWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); } private void OnClose(object? sender, RoutedEventArgs e) => Close(); diff --git a/src/Filee.App/Views/MainWindow.axaml.cs b/src/Filee.App/Views/MainWindow.axaml.cs index f5d743a5..e0ecab17 100644 --- a/src/Filee.App/Views/MainWindow.axaml.cs +++ b/src/Filee.App/Views/MainWindow.axaml.cs @@ -20,6 +20,7 @@ public MainWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); } private async void OnOpenGitHub(object? sender, RoutedEventArgs e) diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml.cs b/src/Filee.App/Views/MergeOrderWindow.axaml.cs index 46aa011e..430c21c4 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml.cs +++ b/src/Filee.App/Views/MergeOrderWindow.axaml.cs @@ -11,6 +11,7 @@ public MergeOrderWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); } public IReadOnlyList? Result { get; private set; } diff --git a/src/Filee.App/Views/PresetEditorWindow.axaml.cs b/src/Filee.App/Views/PresetEditorWindow.axaml.cs index cc0d0b38..92f93dcd 100644 --- a/src/Filee.App/Views/PresetEditorWindow.axaml.cs +++ b/src/Filee.App/Views/PresetEditorWindow.axaml.cs @@ -12,6 +12,7 @@ public PresetEditorWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); } /// True after the user saved (also available when the window was not shown as a dialog). diff --git a/src/Filee.App/Views/RenameToolWindow.axaml.cs b/src/Filee.App/Views/RenameToolWindow.axaml.cs index 05c123cc..fe5a8567 100644 --- a/src/Filee.App/Views/RenameToolWindow.axaml.cs +++ b/src/Filee.App/Views/RenameToolWindow.axaml.cs @@ -13,6 +13,7 @@ public RenameToolWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); } /// Double-clicking a ready-made replacement adds it as a step, like its + button. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 4f488dcb..b51a9747 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -241,6 +241,9 @@ public sealed class AppSettings /// Every geospatial conversion is saved as a ZIP, whatever the preset says (see ). public bool ZipGeoResults { get; set; } + /// How large Filee's windows draw everything (Ctrl + mouse wheel, Ctrl +/-, Ctrl+0): 1 as designed. + public double UiScale { get; set; } = 1; + /// /// Returns itself, or a copy with the app-wide output settings applied: it saves to /// when the preset's location is , and keeps the diff --git a/tests/Filee.App.Tests/UiZoomTests.cs b/tests/Filee.App.Tests/UiZoomTests.cs new file mode 100644 index 00000000..aab6b741 --- /dev/null +++ b/tests/Filee.App.Tests/UiZoomTests.cs @@ -0,0 +1,43 @@ +// Ctrl + wheel, Ctrl + plus / minus and Ctrl + 0 zoom every window. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Media; +using Filee.App.Services; + +namespace Filee.App.Tests; + +public class UiZoomTests +{ + [AvaloniaFact] + public void Ctrl_and_the_wheel_or_plus_minus_zoom_every_window() + { + TestServices.EnsureInitialized("en"); + var window = new Window { Content = new Border { Width = 100, Height = 100 }, Width = 300, Height = 300 }; + UiZoom.Track(window); + window.Show(); + try + { + UiZoom.Set(1, remember: false); + var scaled = Assert.IsType(window.Content); + window.KeyPress(Key.OemPlus, RawInputModifiers.Control, PhysicalKey.None, null); + Assert.Equal(1.1, UiZoom.Scale, 6); + Assert.Equal(1.1, Assert.IsType(scaled.LayoutTransform).ScaleX, 6); + window.MouseWheel(new Point(50, 50), new Vector(0, 1), RawInputModifiers.Control); + Assert.Equal(1.2, UiZoom.Scale, 6); + window.KeyPress(Key.D0, RawInputModifiers.Control, PhysicalKey.None, null); + Assert.Equal(1, UiZoom.Scale, 6); + Assert.Null(scaled.LayoutTransform); + UiZoom.Set(99, remember: false); + Assert.Equal(UiZoom.Max, UiZoom.Scale); + } + finally + { + UiZoom.Set(1, remember: false); + window.Close(); + } + } +} From 2b606f706002a2ce376d37345bb996bf5101ab51 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 21:49:24 +0900 Subject: [PATCH 2/3] Window zoom: Theme page setting with Reset, touchpad-safe wheel, quiet debounced save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the review of #440: - Theme → Window size: − / + and Reset, with a hint naming the shortcuts (Ctrl, or Cmd on a Mac); all 14 languages. - Small wheel deltas (precision touchpads, smooth wheels, pinch on a Windows touchpad) add up to one step per whole notch; turning the other way or a pause starts the count again. - The size is written by UserDataStore.SaveUiScale 0.6 s after the last change (and on exit) without raising SettingsChanged, so zooming no longer re-applies every setting. UiZoom is configured once at start. - Keys use the platform's command modifier; Shift is allowed (Ctrl + Shift + = is "Ctrl +"), Alt is not (AltGr); they are handled after the controls inside, so the Triggers page's shortcut recorder still gets them. - Windows that can't be resized grow with the zoom up to their screen and scroll; the engine setup keeps itself on screen and scrolls its list. Close, sponsor, what's new, update progress and the message dialog are tracked too; the donut, toast, corner notices and crop overlay stay at 100 %. - docs/ARCHITECTURE.md describes UiZoom, what it covers and UiScale in settings.json. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 14 + src/Filee.App/App.axaml.cs | 28 +- src/Filee.App/Assets/i18n/ar.json | 5 + src/Filee.App/Assets/i18n/en.json | 5 + src/Filee.App/Assets/i18n/es.json | 5 + src/Filee.App/Assets/i18n/fr.json | 5 + src/Filee.App/Assets/i18n/hi.json | 5 + src/Filee.App/Assets/i18n/ja.json | 5 + src/Filee.App/Assets/i18n/ko.json | 5 + src/Filee.App/Assets/i18n/ne.json | 5 + src/Filee.App/Assets/i18n/pl.json | 5 + src/Filee.App/Assets/i18n/pt-BR.json | 5 + src/Filee.App/Assets/i18n/ru.json | 5 + src/Filee.App/Assets/i18n/th.json | 5 + src/Filee.App/Assets/i18n/zh-CN.json | 5 + src/Filee.App/Assets/i18n/zh-TW.json | 5 + src/Filee.App/Services/UiZoom.cs | 196 +++++++++-- src/Filee.App/Services/WindowService.cs | 1 + src/Filee.App/Styles/Icons.axaml | 1 + .../ViewModels/Pages/ThemePageViewModel.cs | 46 ++- src/Filee.App/Views/CloseAskWindow.axaml.cs | 1 + .../Views/EngineSetupWindow.axaml.cs | 3 +- src/Filee.App/Views/Pages/ThemePage.axaml | 24 ++ src/Filee.App/Views/SponsorWindow.axaml.cs | 1 + .../Views/UpdateProgressWindow.axaml.cs | 1 + src/Filee.App/Views/WhatsNewWindow.axaml.cs | 1 + src/Filee.Core/Settings/AppSettings.cs | 5 +- src/Filee.Core/Settings/UserDataStore.cs | 10 + tests/Filee.App.Tests/UiZoomTests.cs | 314 +++++++++++++++++- 29 files changed, 666 insertions(+), 50 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cd601a2..ee151c3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -171,6 +171,20 @@ Bump `AppSettings.CurrentSchemaVersion` and add a step to `SettingsMigrations` w stay left to right. Light/dark surface colours are theme dictionaries in `Styles/Soft.axaml`; a user's `theme.json` (`CustomTheme`, see [CUSTOM-THEMES.md](CUSTOM-THEMES.md)) overrides them with theme dictionaries in application resources, which are searched first. +- Window zoom (`Services/UiZoom`): Theme → *Window size* (− / + and *Reset*), and in any tracked window the + platform's command key (`HotkeyConfiguration.CommandModifiers`: Ctrl, Cmd on macOS) with the mouse wheel, plus / + minus (Shift allowed, Alt not: AltGr is Ctrl + Alt) or 0 for 100 %. 70–180 % in 10 % steps, every window at once. + `UiZoom.Track(window)` wraps the window's content in a `LayoutTransformControl`, so layouts see the room they really + have. Small wheel deltas (touchpads, smooth wheels, pinching on a Windows touchpad) add up to one step per whole notch. + Windows that can't be resized grow with the zoom (their designed width × the zoom) up to their screen, cap their + height to it and scroll; the engine setup keeps itself on screen and scrolls its own list (`fitsItself`). + Tracked: the main window, the preset, group, rename and merge-order editors, the engine setup, LibreOffice options, + close, sponsor, what's new and update-progress windows and `WindowService`'s message dialog. Not tracked, so at + 100 %: the donut (it has its own size), the toast and the corner notices (update, engine update, feedback, format + request, elevation), which are placed in a screen corner like system notifications, and the crop overlay, whose + wheel zooms the crop. Drop-down lists, context menus and tooltips open in their own popup windows outside the + transform and stay at 100 % too. The size is `UiScale` in settings.json (1 when missing, so no schema step); it is + written by `UserDataStore.SaveUiScale` 0.6 s after the last change (and on exit), without raising `SettingsChanged`. ## "What's new" slides diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index c57eff46..8c38d32d 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -68,6 +68,19 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) var loc = AppHost.Get(); loc.Attach(this); ApplySettings(store.Settings); + // Window zoom: set once here, not with the other settings, since the zoom saves itself (UiZoom) without + // raising SettingsChanged. + UiZoom.Configure(store.Settings.UiScale, scale => + { + try + { + store.SaveUiScale(scale); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log.LogWarning(ex, "Could not remember the window zoom"); + } + }); // Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts // every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile. _ = AppHost.Get().CheckInBackgroundAsync(); @@ -150,6 +163,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) desktop.Exit += (_, _) => { + UiZoom.Flush(); watchdog.Dispose(); AppHost.Get().Dispose(); AppHost.Get().Dispose(); @@ -260,20 +274,6 @@ private static void ApplySettings(AppSettings settings) var custom = CustomTheme.Load(AppHost.Get().Directory, AppHost.Get>()); AppHost.Get().Apply(settings.Theme, loc.Language, custom); - // Ctrl + wheel / Ctrl +/- / Ctrl+0: everything larger or smaller, remembered. - UiZoom.Configure(settings.UiScale, scale => - { - var store = AppHost.Get(); - store.Settings.UiScale = scale; - try - { - store.SaveSettings(); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - AppHost.Get>().LogWarning(ex, "Could not remember the window zoom"); - } - }); AppHost.Get().Apply(settings); AppHost.Get().Priority = settings.EnginePriority; if (ownCopiesChanged || routesChanged) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index d5f34485..9d7f0395 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -636,6 +636,11 @@ "theme.donut_opacity": "شفافية شريط الأدوات الدائري", "theme.reduce_motion": "تقليل الرسوم المتحركة", "theme.reduce_motion_hint": "موصى به للحواسيب القديمة. يعطّل الانتقالات وحركة ارتداد الشريط الدائري.", + "theme.window_size": "حجم النوافذ", + "theme.window_size_smaller": "أصغر", + "theme.window_size_larger": "أكبر", + "theme.window_size_reset": "إعادة تعيين", + "theme.window_size_hint": "يكبّر كل ما في نوافذ Filee أو يصغّره. في أي نافذة، يؤدي {0} + عجلة الفأرة و{0} + زائد / ناقص الأمر نفسه، ويعيد {0} + 0 الحجم إلى 100%.", "theme.preview": "معاينة", "engines.title": "محركات التحويل", "engines.description": "يختار Filee أفضل محرك متاح لكل تحويل.\nإذا أمكن لعدة محركات تنفيذ المهمة نفسها، تكون الأولوية للأعلى في القائمة.", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 08c33f55..db0cec46 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "Donut toolbar opacity", "theme.reduce_motion": "Reduce animations", "theme.reduce_motion_hint": "Recommended for older PCs. Turns off transitions and the donut's bounce.", + "theme.window_size": "Window size", + "theme.window_size_smaller": "Smaller", + "theme.window_size_larger": "Larger", + "theme.window_size_reset": "Reset", + "theme.window_size_hint": "Makes everything in Filee's windows larger or smaller. In any window, {0} + mouse wheel and {0} + plus / minus do the same, and {0} + 0 goes back to 100%.", "theme.preview": "Preview", "engines.title": "Conversion engines", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index fbc629f4..36e21e2e 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -636,6 +636,11 @@ "theme.donut_opacity": "Opacidad del menú circular", "theme.reduce_motion": "Reducir las animaciones", "theme.reduce_motion_hint": "Recomendado para PC antiguos. Desactiva las transiciones y el rebote del menú circular.", + "theme.window_size": "Tamaño de las ventanas", + "theme.window_size_smaller": "Más pequeño", + "theme.window_size_larger": "Más grande", + "theme.window_size_reset": "Restablecer", + "theme.window_size_hint": "Agranda o reduce todo en las ventanas de Filee. En cualquier ventana, {0} + rueda del ratón y {0} + más / menos hacen lo mismo, y {0} + 0 vuelve al 100 %.", "theme.preview": "Vista previa", "engines.title": "Motores de conversión", "engines.description": "Filee elige el mejor motor disponible para cada conversión.\nCuando varios pueden hacer el mismo trabajo, gana el que está más arriba.", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index fbc0a30b..1b1bad17 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "Opacité du menu circulaire", "theme.reduce_motion": "Réduire les animations", "theme.reduce_motion_hint": "Recommandé pour les PC anciens. Désactive les transitions et le rebond du menu circulaire.", + "theme.window_size": "Taille des fenêtres", + "theme.window_size_smaller": "Plus petit", + "theme.window_size_larger": "Plus grand", + "theme.window_size_reset": "Réinitialiser", + "theme.window_size_hint": "Agrandit ou réduit tout le contenu des fenêtres de Filee. Dans n'importe quelle fenêtre, {0} + molette de la souris et {0} + plus / moins font de même, et {0} + 0 revient à 100 %.", "theme.preview": "Aperçu", "engines.title": "Moteurs de conversion", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index aa2b97dd..7a9ba00a 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -636,6 +636,11 @@ "theme.donut_opacity": "डोनट टूलबार की अपारदर्शिता", "theme.reduce_motion": "एनिमेशन कम करें", "theme.reduce_motion_hint": "पुराने PC के लिए अनुशंसित। ट्रांज़िशन और डोनट की उछाल बंद कर देता है।", + "theme.window_size": "विंडो का आकार", + "theme.window_size_smaller": "छोटा", + "theme.window_size_larger": "बड़ा", + "theme.window_size_reset": "रीसेट करें", + "theme.window_size_hint": "Filee की विंडो में सब कुछ बड़ा या छोटा दिखाता है। किसी भी विंडो में {0} + माउस व्हील और {0} + प्लस / माइनस से भी यही होता है, और {0} + 0 से वापस 100% हो जाता है।", "theme.preview": "प्रीव्यू", "engines.title": "कन्वर्ज़न इंजन", "engines.description": "Filee हर कन्वर्ज़न के लिए उपलब्ध सबसे अच्छा इंजन चुनता है।\nजब कई इंजन एक ही काम कर सकते हैं, तो ऊपर वाला चुना जाता है।", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index ede66293..d534835b 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -636,6 +636,11 @@ "theme.donut_opacity": "ドーナツメニューの不透明度", "theme.reduce_motion": "アニメーションを減らす", "theme.reduce_motion_hint": "古いPCにおすすめです。画面切り替え時のアニメーションと、ドーナツメニューの弾む動きを無効にします。", + "theme.window_size": "ウィンドウのサイズ", + "theme.window_size_smaller": "小さく", + "theme.window_size_larger": "大きく", + "theme.window_size_reset": "リセット", + "theme.window_size_hint": "Filee のウィンドウ内のすべてを大きく、または小さく表示します。どのウィンドウでも {0} + マウスホイール、{0} + プラス / マイナスで同じ操作ができ、{0} + 0 で 100% に戻ります。", "theme.preview": "プレビュー", "engines.title": "変換エンジン", "engines.description": "Fileeは変換ごとに、利用可能な最適なエンジンを選びます。\n同じ変換ができるエンジンが複数ある場合は、一覧で上にあるものが優先されます。", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 91d11c7e..c53d5905 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "원형 툴바 불투명도", "theme.reduce_motion": "애니메이션 줄이기", "theme.reduce_motion_hint": "저사양 PC에 권장해요. 전환 효과와 도넛의 튕김 효과를 꺼요.", + "theme.window_size": "창 크기", + "theme.window_size_smaller": "작게", + "theme.window_size_larger": "크게", + "theme.window_size_reset": "초기화", + "theme.window_size_hint": "Filee 창의 모든 것을 크게 또는 작게 보여 줘요. 어느 창에서든 {0} + 마우스 휠, {0} + 더하기 / 빼기로도 바꿀 수 있고, {0} + 0을 누르면 100%로 돌아가요.", "theme.preview": "미리보기", "engines.title": "변환 엔진", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 17e5fd99..57fda904 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -636,6 +636,11 @@ "theme.donut_opacity": "डोनट टुलबार अपारदर्शिता", "theme.reduce_motion": "एनिमेसन कम गर्नुहोस्", "theme.reduce_motion_hint": "पुराना कम्प्युटरहरूका लागि सिफारिस गरिन्छ। ट्रान्जिसन र डोनटको बाउन्स बन्द गर्दछ।", + "theme.window_size": "विन्डोको आकार", + "theme.window_size_smaller": "सानो", + "theme.window_size_larger": "ठूलो", + "theme.window_size_reset": "रिसेट गर्नुहोस्", + "theme.window_size_hint": "Filee का विन्डोहरूमा सबै कुरा ठूलो वा सानो देखाउँछ। जुनसुकै विन्डोमा {0} + माउस ह्वील र {0} + प्लस / माइनसले पनि यही गर्छ, र {0} + 0 ले 100% मा फर्काउँछ।", "theme.preview": "पूर्वावलोकन", "engines.title": "रूपान्तरण इन्जिनहरू", "engines.description": "Filee ले प्रत्येक रूपान्तरणका लागि उपलब्ध उत्तम इन्जिन छान्दछ।\nजब धेरैले उही काम गर्न सक्छन्, माथि भएको इन्जिनले प्राथमिकता पाउँछ।", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 9f88452c..9367a23a 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "Krycie paska-pierścienia", "theme.reduce_motion": "Ogranicz animacje", "theme.reduce_motion_hint": "Zalecane na starszych komputerach. Wyłącza przejścia i odbijanie pierścienia.", + "theme.window_size": "Rozmiar okien", + "theme.window_size_smaller": "Mniejsze", + "theme.window_size_larger": "Większe", + "theme.window_size_reset": "Resetuj", + "theme.window_size_hint": "Powiększa lub pomniejsza całą zawartość okien Filee. W każdym oknie to samo robią {0} + kółko myszy i {0} + plus / minus, a {0} + 0 przywraca 100%.", "theme.preview": "Podgląd", "engines.title": "Silniki konwersji", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index ab9da6f1..cbe798c6 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "Opacidade do menu circular", "theme.reduce_motion": "Reduzir animações", "theme.reduce_motion_hint": "Recomendado para PCs mais antigos. Desliga as transições e o salto do menu.", + "theme.window_size": "Tamanho das janelas", + "theme.window_size_smaller": "Menor", + "theme.window_size_larger": "Maior", + "theme.window_size_reset": "Redefinir", + "theme.window_size_hint": "Deixa tudo nas janelas do Filee maior ou menor. Em qualquer janela, {0} + roda do mouse e {0} + mais / menos fazem o mesmo, e {0} + 0 volta para 100%.", "theme.preview": "Prévia", "engines.title": "Motores de conversão", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 8aacb6aa..46670b2c 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "Непрозрачность кругового меню", "theme.reduce_motion": "Меньше анимации", "theme.reduce_motion_hint": "Рекомендуется для старых компьютеров. Отключает переходы и подпрыгивание кругового меню.", + "theme.window_size": "Размер окон", + "theme.window_size_smaller": "Меньше", + "theme.window_size_larger": "Больше", + "theme.window_size_reset": "Сбросить", + "theme.window_size_hint": "Увеличивает или уменьшает всё в окнах Filee. В любом окне то же делают {0} + колесо мыши и {0} + плюс / минус, а {0} + 0 возвращает 100%.", "theme.preview": "Предпросмотр", "engines.title": "Движки конвертации", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 013e31ff..6fa505fa 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -636,6 +636,11 @@ "theme.donut_opacity": "ความทึบแสงของแถบโดนัท", "theme.reduce_motion": "ลดการเคลื่อนไหว (แอนิเมชัน)", "theme.reduce_motion_hint": "แนะนำสำหรับคอมพิวเตอร์รุ่นเก่า จะปิดเอฟเฟกต์การเปลี่ยนผ่านและการเด้งของโดนัท", + "theme.window_size": "ขนาดหน้าต่าง", + "theme.window_size_smaller": "เล็กลง", + "theme.window_size_larger": "ใหญ่ขึ้น", + "theme.window_size_reset": "รีเซ็ต", + "theme.window_size_hint": "ขยายหรือย่อทุกอย่างในหน้าต่างของ Filee ในหน้าต่างใดก็ได้ {0} + ล้อเมาส์ และ {0} + บวก / ลบ ก็ทำได้เหมือนกัน ส่วน {0} + 0 จะกลับไปที่ 100%", "theme.preview": "ตัวอย่าง", "engines.title": "เครื่องมือแปลงไฟล์", "engines.description": "Filee จะเลือกเครื่องมือที่ดีที่สุดที่พร้อมใช้งานสำหรับการแปลงแต่ละครั้ง\nหากมีหลายเครื่องมือที่ทำหน้าที่เดียวกันได้ เครื่องมือที่อยู่อันดับสูงกว่าจะถูกนำมาใช้", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index a880dc4c..55a8d220 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "环形工具栏不透明度", "theme.reduce_motion": "减少动画", "theme.reduce_motion_hint": "适合配置较低的电脑。关闭过渡效果和圆环的弹跳效果。", + "theme.window_size": "窗口大小", + "theme.window_size_smaller": "缩小", + "theme.window_size_larger": "放大", + "theme.window_size_reset": "重置", + "theme.window_size_hint": "放大或缩小 Filee 窗口中的所有内容。在任意窗口中,{0} + 鼠标滚轮和 {0} + 加号 / 减号也能调整,{0} + 0 恢复为 100%。", "theme.preview": "预览", "engines.title": "转换引擎", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2e36d0d1..e5bcb90c 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -652,6 +652,11 @@ "theme.donut_opacity": "環形工具列不透明度", "theme.reduce_motion": "減少動畫", "theme.reduce_motion_hint": "適合效能較低的電腦。關閉轉場效果和圓環的彈跳效果。", + "theme.window_size": "視窗大小", + "theme.window_size_smaller": "縮小", + "theme.window_size_larger": "放大", + "theme.window_size_reset": "重設", + "theme.window_size_hint": "放大或縮小 Filee 視窗中的所有內容。在任何視窗中,{0} + 滑鼠滾輪和 {0} + 加號 / 減號也能調整,{0} + 0 會恢復為 100%。", "theme.preview": "預覽", "engines.title": "轉換引擎", diff --git a/src/Filee.App/Services/UiZoom.cs b/src/Filee.App/Services/UiZoom.cs index 20c7eb72..8c8fcfa1 100644 --- a/src/Filee.App/Services/UiZoom.cs +++ b/src/Filee.App/Services/UiZoom.cs @@ -1,11 +1,20 @@ // Everything in Filee's windows larger or smaller, as a browser zooms a page: Ctrl + mouse wheel, Ctrl + plus / minus, -// Ctrl + 0 back to normal. Each tracked window's content is scaled by a layout transform (so layouts that adapt to their -// width see the room they really have), every window at once, and the size is remembered. +// Ctrl + 0 back to normal (Cmd on a Mac), and Theme → Window size. Each tracked window's content is scaled by a layout +// transform (so layouts that adapt to their width see the room they really have), every window at once, and the size +// is remembered (written a moment after the last change, without re-applying the other settings). +// Windows that can't be resized grow with the zoom instead, as far as their screen allows, and scroll when it doesn't. +// The donut, the toast, the corner notices and the crop overlay aren't tracked; popups (drop-down lists, menus, +// tooltips) open in their own windows and stay at 100 %. See docs/ARCHITECTURE.md. +using System.Runtime.CompilerServices; +using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; namespace Filee.App.Services; @@ -14,29 +23,60 @@ public static class UiZoom /// The smallest and largest size, and one step. public const double Min = 0.7, Max = 1.8, Step = 0.1; + /// How long the size has to stay put before it is written to settings.json. + internal static readonly TimeSpan SaveDelay = TimeSpan.FromMilliseconds(600); + + /// A pause in the wheel (ms) after which a part of a notch left over no longer counts. + private const ulong WheelPause = 1000; + + /// Room left between a window that can't be resized and the edges of its screen. + private const double ScreenMargin = 12; + + private const string Marker = "ui-zoom"; + private static readonly List> Windows = []; + private static readonly ConditionalWeakTable Sizes = []; private static Action? _save; + private static DispatcherTimer? _saveTimer; + private static double _wheel; + private static ulong _wheelAt; /// How large everything is drawn: 1 as designed. public static double Scale { get; private set; } = 1; - /// The size remembered and how to remember a new one. - public static void Configure(double scale, Action save) + /// Raised after changed. + public static event EventHandler? Changed; + + /// A new size is waiting to be written ( writes it now). + internal static bool SavePending => _saveTimer?.IsEnabled == true; + + /// The size remembered and how to remember a new one (called once the size has stayed put a moment). + public static void Configure(double scale, Action? save) { + _saveTimer?.Stop(); _save = save; Set(scale, remember: false); } - /// Scales 's content and gives it the zoom keys and wheel. - public static void Track(Window window) + /// + /// Scales 's content (call it once the content is in place) and gives it the zoom keys and + /// wheel. A window that can't be resized also grows with the zoom and scrolls when its screen is too small, unless + /// : then it keeps itself on screen and scrolls its own content. + /// + public static void Track(Window window, bool fitsItself = false) { lock (Windows) { Windows.RemoveAll(w => !w.TryGetTarget(out _)); Windows.Add(new WeakReference(window)); } + Sizes.AddOrUpdate(window, new Designed(window.Width, window.MaxHeight, fitsItself)); + // The wheel before anything inside (a list would scroll); the keys after, so a control that uses the + // combination itself (recording a shortcut on the Triggers page) gets it first. window.AddHandler(InputElement.PointerWheelChangedEvent, OnWheel, RoutingStrategies.Tunnel); - window.AddHandler(InputElement.KeyDownEvent, OnKey, RoutingStrategies.Tunnel); + window.AddHandler(InputElement.KeyDownEvent, OnKey, RoutingStrategies.Bubble); + window.Opened += (_, _) => Apply(window); + window.SizeChanged += (_, _) => KeepOnScreen(window); Apply(window); } @@ -51,50 +91,160 @@ public static void Set(double scale, bool remember = true) windows = Windows.Select(w => w.TryGetTarget(out var window) ? window : null).OfType().ToList(); foreach (var window in windows) Apply(window); - if (changed && remember) - _save?.Invoke(value); + if (!changed) + return; + if (remember) + ScheduleSave(); + Changed?.Invoke(null, EventArgs.Empty); + } + + public static void ZoomIn() => Set(Scale + Step); + + public static void ZoomOut() => Set(Scale - Step); + + public static void Reset() => Set(1); + + /// Writes a size still waiting for its moment (when Filee exits). + public static void Flush() + { + if (_saveTimer is not { IsEnabled: true } timer) + return; + timer.Stop(); + _save?.Invoke(Scale); + } + + // Turning the wheel a few notches writes settings.json once. + private static void ScheduleSave() + { + if (_save is null) + return; + if (_saveTimer is null) + { + _saveTimer = new DispatcherTimer { Interval = SaveDelay }; + _saveTimer.Tick += (_, _) => Flush(); + } + _saveTimer.Stop(); + _saveTimer.Start(); } private static void Apply(Window window) { - if (window.Content is not Control content) + if (window.Content is not Control content || !Sizes.TryGetValue(window, out var designed)) return; - if (content is not LayoutTransformControl { Tag: "ui-zoom" } scaled) + var growsWithZoom = !window.CanResize && double.IsFinite(designed.Width); + var scaled = content switch + { + LayoutTransformControl { Tag: Marker } existing => existing, + ScrollViewer { Tag: Marker, Content: LayoutTransformControl inner } => inner, + _ => null, + }; + if (scaled is null) { window.Content = null; - scaled = new LayoutTransformControl { Tag = "ui-zoom", Child = content }; - window.Content = scaled; + scaled = new LayoutTransformControl { Tag = Marker, Child = content }; + window.Content = growsWithZoom && !designed.FitsItself + ? new ScrollViewer + { + Tag = Marker, + Content = scaled, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + } + : scaled; } scaled.LayoutTransform = Math.Abs(Scale - 1) < 1e-9 ? null : new ScaleTransform(Scale, Scale); + if (!growsWithZoom) + return; + + // As wide as designed times the zoom and, unless it fits itself, no taller than its screen: what doesn't fit + // scrolls. Without a screen (headless), only the width. + var area = WorkingArea(window); + var width = designed.Width * Scale; + if (area is { } room) + width = Math.Min(width, Math.Max(designed.Width, room.Width - 2 * ScreenMargin)); + window.Width = width; + if (!designed.FitsItself && area is { } screen) + { + var frame = window.FrameSize is { } size ? Math.Max(0, size.Height - window.ClientSize.Height) : 0; + window.MaxHeight = Math.Min(designed.MaxHeight, Math.Max(window.MinHeight, screen.Height - frame - 2 * ScreenMargin)); + } + } + + /// The working area of the window's screen in device-independent pixels, or null when unknown. + private static Rect? WorkingArea(Window window) + { + var screens = window.Screens; + var screen = (window.IsVisible ? screens.ScreenFromWindow(window) : null) ?? screens.Primary; + if (screen is null || screen.Scaling <= 0) + return null; + var area = screen.WorkingArea; + return new Rect(area.X / screen.Scaling, area.Y / screen.Scaling, area.Width / screen.Scaling, area.Height / screen.Scaling); } + /// A window that can't be resized and grew past the edge of its screen moves back onto it. + private static void KeepOnScreen(Window window) + { + if (window.CanResize || !window.IsVisible || window.WindowState != WindowState.Normal) + return; + var screen = window.Screens.ScreenFromWindow(window); + if (screen is null) + return; + var area = screen.WorkingArea; + var size = window.FrameSize ?? window.ClientSize; + var width = (int)Math.Ceiling(size.Width * screen.Scaling); + var height = (int)Math.Ceiling(size.Height * screen.Scaling); + var margin = (int)Math.Round(ScreenMargin * screen.Scaling); + var x = Math.Max(area.X, Math.Min(window.Position.X, area.Right - margin - width)); + var y = Math.Max(area.Y, Math.Min(window.Position.Y, area.Bottom - margin - height)); + if (x != window.Position.X || y != window.Position.Y) + window.Position = new PixelPoint(x, y); + } + + /// Ctrl on Windows and Linux, Cmd on a Mac. + private static KeyModifiers Command(object? sender) => + (sender as Visual)?.GetPlatformSettings()?.HotkeyConfiguration.CommandModifiers ?? KeyModifiers.Control; + private static void OnWheel(object? sender, PointerWheelEventArgs e) { - // Ctrl, or Cmd on a Mac. - if (!(e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Meta)) || e.Delta.Y == 0) + if (e.KeyModifiers != Command(sender) || e.Delta.Y == 0) return; - Set(Scale + Math.Sign(e.Delta.Y) * Step); e.Handled = true; + // Touchpads (and pinching on one, which Windows sends as Ctrl + wheel) and smooth wheels send many small + // deltas: they add up to one step per whole notch. Turning the other way, or a pause, starts the count again. + if (Math.Sign(e.Delta.Y) != Math.Sign(_wheel) || e.Timestamp - _wheelAt > WheelPause) + _wheel = 0; + _wheelAt = e.Timestamp; + _wheel += e.Delta.Y; + var notches = (int)(_wheel + Math.Sign(_wheel) * 1e-6); // three thirds make a notch + if (notches == 0) + return; + _wheel -= notches; + Set(Scale + notches * Step); } + // Ctrl (Cmd) with plus, minus or 0, Shift allowed: Ctrl + Shift + = is "Ctrl +" on a US keyboard and the only way + // to type + on some layouts. Alt is not: AltGr is Ctrl + Alt on Windows, and AltGr + 0 types "}" on German keyboards. private static void OnKey(object? sender, KeyEventArgs e) { - if (e.KeyModifiers is not (KeyModifiers.Control or KeyModifiers.Meta)) + if (e.Handled || (e.KeyModifiers & ~KeyModifiers.Shift) != Command(sender)) return; switch (e.Key) { case Key.OemPlus or Key.Add: - Set(Scale + Step); - e.Handled = true; + ZoomIn(); break; case Key.OemMinus or Key.Subtract: - Set(Scale - Step); - e.Handled = true; + ZoomOut(); break; case Key.D0 or Key.NumPad0: - Set(1); - e.Handled = true; + Reset(); break; + default: + return; } + e.Handled = true; } + + /// A window's width and maximum height at 100 %. + private sealed record Designed(double Width, double MaxHeight, bool FitsItself); } diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 3445db6d..4e9076ff 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -454,6 +454,7 @@ private async Task ShowMessageAsync(Window? owner, string message, bool de }, }, }; + UiZoom.Track(dialog); if (owner is { IsVisible: true }) return await dialog.ShowDialog(owner); diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml index 527cac49..2de50bf5 100644 --- a/src/Filee.App/Styles/Icons.axaml +++ b/src/Filee.App/Styles/Icons.axaml @@ -15,6 +15,7 @@ M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z + M19,13H5V11H19V13Z M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z diff --git a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs index 63001a05..f5b1b574 100644 --- a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs @@ -1,8 +1,10 @@ -// Theme: light/dark, accent colour, softness (corner radius), the donut's opacity, reduce animations. Changes apply live. +// Theme: light/dark, accent colour, softness (corner radius), the donut's opacity, reduce animations, and the window +// size (UiZoom). Changes apply live. using Avalonia; using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using Filee.App.Controls; using Filee.App.Services; using Filee.Core.Localization; @@ -11,14 +13,16 @@ namespace Filee.App.ViewModels.Pages; -public sealed partial class ThemePageViewModel : ObservableObject +public sealed partial class ThemePageViewModel : ObservableObject, IDisposable { private readonly UserDataStore _store; + private readonly ILocalizer _loc; private readonly bool _loading; public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform) { _store = store; + _loc = loc; _loading = true; var theme = store.Settings.Theme; Modes = Enum.GetValues().Select(m => new Choice(m, loc[$"theme.mode.{m}"])).ToList(); @@ -38,6 +42,8 @@ public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices new DonutItem { Id = "f", Label = "TIFF" }, ]; _loading = false; + UiZoom.Changed += OnZoomChanged; + loc.LanguageChanged += OnLanguageChanged; } public IReadOnlyList> Modes { get; } @@ -74,6 +80,42 @@ public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices /// [ObservableProperty] private double _donutOpacity; + /// The window size in percent (): the same for every window, saved by UiZoom. + public double WindowSize => Math.Round(UiZoom.Scale * 100); + + /// Where the shortcuts do the same, with the platform's key: Ctrl, or Cmd on a Mac. + public string WindowSizeHint => _loc.Format("theme.window_size_hint", OperatingSystem.IsMacOS() ? "Cmd" : "Ctrl"); + + [RelayCommand(CanExecute = nameof(CanZoomOut))] + private void ZoomOut() => UiZoom.ZoomOut(); + + [RelayCommand(CanExecute = nameof(CanZoomIn))] + private void ZoomIn() => UiZoom.ZoomIn(); + + [RelayCommand(CanExecute = nameof(CanResetZoom))] + private void ResetZoom() => UiZoom.Reset(); + + private static bool CanZoomOut() => UiZoom.Scale > UiZoom.Min + 1e-9; + private static bool CanZoomIn() => UiZoom.Scale < UiZoom.Max - 1e-9; + private static bool CanResetZoom() => Math.Abs(UiZoom.Scale - 1) > 1e-9; + + // Ctrl + wheel in any window changes it too. + private void OnZoomChanged(object? sender, EventArgs e) + { + OnPropertyChanged(nameof(WindowSize)); + ZoomOutCommand.NotifyCanExecuteChanged(); + ZoomInCommand.NotifyCanExecuteChanged(); + ResetZoomCommand.NotifyCanExecuteChanged(); + } + + private void OnLanguageChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(WindowSizeHint)); + + public void Dispose() + { + UiZoom.Changed -= OnZoomChanged; + _loc.LanguageChanged -= OnLanguageChanged; + } + partial void OnModeChanged(Choice? oldValue, Choice newValue) { if (!Choices.Restore(oldValue, newValue, v => Mode = v)) diff --git a/src/Filee.App/Views/CloseAskWindow.axaml.cs b/src/Filee.App/Views/CloseAskWindow.axaml.cs index da81937c..38128298 100644 --- a/src/Filee.App/Views/CloseAskWindow.axaml.cs +++ b/src/Filee.App/Views/CloseAskWindow.axaml.cs @@ -15,6 +15,7 @@ public CloseAskWindow() { InitializeComponent(); Motion.Track(this); + UiZoom.Track(this); } /// "Don't ask again" was ticked: the answer becomes the setting. diff --git a/src/Filee.App/Views/EngineSetupWindow.axaml.cs b/src/Filee.App/Views/EngineSetupWindow.axaml.cs index bd42befd..96ea33d6 100644 --- a/src/Filee.App/Views/EngineSetupWindow.axaml.cs +++ b/src/Filee.App/Views/EngineSetupWindow.axaml.cs @@ -15,7 +15,8 @@ public EngineSetupWindow() { InitializeComponent(); Motion.Track(this); - UiZoom.Track(this); + // Keeps itself inside the screen (FitTo), its engine list scrolls. + UiZoom.Track(this, fitsItself: true); DataContextChanged += (_, _) => { if (DataContext is EngineSetupViewModel vm) diff --git a/src/Filee.App/Views/Pages/ThemePage.axaml b/src/Filee.App/Views/Pages/ThemePage.axaml index bdb8574d..28f1d468 100644 --- a/src/Filee.App/Views/Pages/ThemePage.axaml +++ b/src/Filee.App/Views/Pages/ThemePage.axaml @@ -99,6 +99,30 @@ + + + + + + + + + +