From 0335960b80c6cde32a4738536222cc4a8ae1fc4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 22:00:03 +0000 Subject: [PATCH] Text size and zoom for the window The window zooms as a browser does (50-300%; Ctrl+= / Ctrl+- / Ctrl+0, Ctrl+wheel, the right-click menu and the command palette), saved in the configuration. A Text size & zoom settings page sets the output's point size and four areas: composer text, interface, panels, menus & dialogs. The cells keep a real font size, so glyphs stay crisp and NAWS follows; native views are scaled and the chrome insets grow on the 4 px grid. The terminal lists none of it: its text is the emulator's. Help says to use the terminal's own zoom. --emulated keeps the zoom, with no page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TCdBPKDhhi63cHux3 --- CHANGELOG.md | 10 +- CLAUDE.md | 27 +++ .../AvaloniaConsoleDriver.cs | 61 +++++++ src/SharpMUTerm.Avalonia/ComposerPresenter.cs | 4 + src/SharpMUTerm.Avalonia/ComposerView.cs | 37 +++- src/SharpMUTerm.Avalonia/DesktopLayer.cs | 126 ++++++++----- src/SharpMUTerm.Avalonia/FloatingPanels.cs | 10 +- src/SharpMUTerm.Avalonia/MainWindow.cs | 41 ++++- src/SharpMUTerm.Avalonia/MapCanvas.cs | 15 +- src/SharpMUTerm.Avalonia/ModalOverlay.cs | 16 +- src/SharpMUTerm.Avalonia/ModalPresenter.cs | 5 +- src/SharpMUTerm.Avalonia/NativeMenu.cs | 12 +- src/SharpMUTerm.Avalonia/NativeScale.cs | 115 ++++++++++++ .../NativeTokens.Spacing.cs | 8 +- src/SharpMUTerm.Avalonia/PaletteOverlay.cs | 17 +- src/SharpMUTerm.Avalonia/PalettePresenter.cs | 5 +- src/SharpMUTerm.Avalonia/SettingsPresenter.cs | 7 +- src/SharpMUTerm.Avalonia/SettingsWindow.cs | 18 +- src/SharpMUTerm.Avalonia/TerminalView.cs | 140 +++++++++++--- src/SharpMUTerm.Avalonia/VitalsGauges.cs | 8 +- src/SharpMUTerm.Avalonia/WindowHost.cs | 4 + src/SharpMUTerm.Avalonia/WindowOptions.cs | 9 +- src/SharpMUTerm.Avalonia/WindowPointer.cs | 8 +- .../Commands/BuiltInCommands.cs | 24 +++ src/SharpMUTerm.Client/Help/HelpTopics.cs | 10 + .../Settings/BuiltInSettings.TextSize.cs | 85 +++++++++ .../Commands/CommandCatalog.cs | 9 +- .../Configuration/AppConfiguration.cs | 6 + .../Configuration/DisplaySettings.cs | 130 +++++++++++++ src/SharpMUTerm.Tui/Hosting/ClientHost.cs | 10 + .../Hosting/IWindowTextSize.cs | 30 +++ .../SharpMUTermApp.TextSize.cs | 122 +++++++++++++ src/SharpMUTerm.Tui/SharpMUTermApp.cs | 15 ++ .../HostParityTests.cs | 4 +- .../TerminalZoomTests.cs | 59 +++++- .../TextScaleTests.cs | 139 ++++++++++++++ .../Configuration/DisplaySettingsTests.cs | 55 ++++++ tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs | 172 ++++++++++++++++++ 38 files changed, 1449 insertions(+), 124 deletions(-) create mode 100644 src/SharpMUTerm.Avalonia/NativeScale.cs create mode 100644 src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs create mode 100644 src/SharpMUTerm.Core/Configuration/DisplaySettings.cs create mode 100644 src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs create mode 100644 src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs create mode 100644 tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs create mode 100644 tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c94538..77bb268f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Added -- The desktop window now supports live terminal zoom: Ctrl++ (or Ctrl+=), Ctrl+-, and Ctrl+0, plus - Zoom in/out/reset in the output pane's right-click menu. Zoom ranges from 50% to 200% and resets to - the launch font size. It resizes the real terminal grid, including per-pane NAWS, rather than stretching - a screenshot. Zoom lasts for this window; `--font-size` still chooses the startup size. +- Text size and zoom in the desktop window. Ctrl+= and Ctrl+- zoom the whole window in and out, as a browser does + (50% to 300%), Ctrl+0 goes back to 100%, and so does Ctrl with the mouse wheel or the output's right-click menu. The + zoom is kept between runs. *Text size & zoom* in the settings window (or the command palette) sets the game text's + size in points and, each on its own, the composer, the header and sidebar, the panels, and menus and dialogs. The + panes are re-measured and each game is told its new window size. In a terminal the text size is the terminal's own; + help says how to change it there. - Three ways to start SharpMUTerm, side by side. The Windows install adds *SharpMUTerm (Emulated)* and *SharpMUTerm (Native)* to the Start menu beside *SharpMUTerm*, and a SharpMUTerm profile to Windows Terminal. The AppImage offers diff --git a/CLAUDE.md b/CLAUDE.md index 70ccad62..53b93128 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2033,6 +2033,33 @@ draws ⌃P natively (`INativePalette`, null for the cell popup), whether it draw cell short; each view says its rules (`EdgeRules(size)`) and an unhittable `SeamRules` layer draws the half cell to `TerminalView.VerticalHairlines()`. `DrawDividers` runs a horizontal divider and the field strip's top rule on to a vertical hairline the same way. Presses there are the cells' (grab area unchanged); no cell or NAWS moves. `DividerSeamTests`. +- **Zoom and text sizes are client settings the window draws at** (Core `DisplaySettings`/`ZoomLevels`, + `AppConfiguration.Display`; Client `BuiltInSettings.TextSize`, page *Text size & zoom*; Tui `SharpMUTermApp.TextSize.cs`, + `ClientHost.TextSize` → `IWindowTextSize`, null in the terminal, headless and the parity harness; Avalonia `WindowTextSize`, + `NativeScale`/`NativeScales`, `MainWindow.ApplyTextSizes`). Decisions not to relitigate: + - **The zoom is the whole window's, as a browser's** (steps 50–300%, `ZoomLevels.Steps`), saved through + `PersistConfiguration` (a zoom is pressed in runs; the funnel re-periodises timers). The window's Ctrl+=/−/0, Ctrl+wheel + (`TerminalView.ZoomForWheel`, exactly Control) and its right-click menu *ask* (`TerminalView.ZoomRequested` → + `WindowTextSize.RequestZoom` → `StepZoom`); the client saves and says the sizes back (`SyncTextSizes`, on change only, from + `SaveConfiguration` and `PostBufferPaint`). ⌃P has zoom in/out/reset and *Text size & zoom…* (`CommandContext.TextZoom`, + `TextSizePage`). + - **The cells keep a real font size** (`TerminalView.SetCellZoom`: output points ÷ launch points × zoom; `--font-size` given + for a run wins over the saved points), so glyphs stay crisp and NAWS follows. **Every native view is scaled, not + re-sized**: `NativeScale.Cover` lays a view out at its area ÷ scale with a `ScaleTransform` (no layout rounding, or it + draws half a pixel past its area) — the header, sidebar, footer, strips, bands, toasts and buttons at *interface* × zoom, + panels and pop-out buttons at *panels* × zoom; dialogs (`ModalOverlay`, `PaletteOverlay`, the settings window, floating + panel windows) through `NativeScale.Wrap`, a `LayoutTransformControl`, read when each opens (presenters' `Scales`); + `NativeMenu.Scale` sizes menus. Chrome insets are `NativeLayout.Scaled` (a multiple of 4; exact at 1), so at 100% nothing + moves. Hairlines stay one device pixel (`NativeScale.DeviceScale` multiplies every ancestor's transform). The composer's + text is the cells' size × *composer* (`ComposerView.TextScale`, over the panel scale when docked). + - **A terminal lists none of it**: its text is its emulator's, and nothing a program writes resizes it. `/help` ▸ Text size + says to use the terminal's zoom. `--emulated` keeps the zoom and has no page (no native settings; output size by + `--font-size`), and its native views are none, so `SizesAreas` is false. + - **A panel laid out at the panel scale is told the cell height ÷ that scale** (`DesktopLayer` → gauges, who-here, quick + bar), and the vitals card's rows are the gauges' height at it (`VitalsGauges.CardRows(…, scale)`, `WindowPointer.PanelScale`, + read from the sizes last said); `SyncTextSizes` re-checks every card (`ResizeVitalsCard`) after saying new sizes. A + larger panel wraps the map's look buttons and drops the docked composer's Preview (F1 keeps it) rather than clip them. + - Not done: a dialog or floating window open during a zoom change keeps its scale until reopened. - **The window bundles its faces** (`BundledFonts`, Avalonia; `Fonts/README.md`). Inter 4.1 (four static weights, 1.67 MB) and JetBrains Mono are registered as `fonts:SharpMUTerm`, Inter is Avalonia's default family and the bare names map onto the bundle, so every view resolves them from the file on every OS; installed faces are only the fallback for glyphs the diff --git a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs index 6969b6eb..37a1b0ec 100644 --- a/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs +++ b/src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs @@ -1,3 +1,4 @@ +using SharpMUTerm.Core.Configuration; using Color = SharpConsoleUI.Color; using Point = System.Drawing.Point; using SharpConsoleUI; @@ -96,6 +97,9 @@ public AvaloniaConsoleDriver(int columns, int rows) /// F7's background transparency, which the main window draws (). public WindowTranslucency Translucency { get; } = new(); + /// The Text size page's sizes and the zoom, which the main window draws at (). + public WindowTextSize TextSize { get; } = new(); + /// The window's alerts while it is behind another (). public WindowAttention Attention { get; } = new(); @@ -233,6 +237,63 @@ public static byte PlaneAlpha(int percent, global::Avalonia.Controls.WindowTrans : (byte)Math.Round(255 * (100 - Math.Min(percent, 100)) / 100.0); } +/// +/// The window's half of : the client says the sizes on its own thread, and the main window, +/// which subscribes, redraws at them on Avalonia's; the window's zoom keys and Ctrl+wheel ask the client to step the zoom +/// through . With no client listening (a test of the view alone) a request steps +/// here, so the keys still zoom. +/// +internal sealed class WindowTextSize : IWindowTextSize +{ + private DisplaySettings _current = new(); + + /// The sizes in force: the configuration's until the client says otherwise. + public DisplaySettings Current => Volatile.Read(ref _current); + + /// Whether the window draws native views whose sizes the page's areas set; false in --emulated. + public bool SizesAreas { get; set; } = true; + + /// Raised with each new set of sizes, on whichever thread said them. + public event Action? Changed; + + /// + public event Action? ZoomRequested; + + /// Starts from the configuration's sizes, before the client exists, so the first frame is drawn at them. + public void Start(DisplaySettings sizes) => Volatile.Write(ref _current, sizes.Clamped()); + + public void Show(DisplaySettings sizes) + { + Volatile.Write(ref _current, sizes.Clamped()); + Changed?.Invoke(Current); + } + + /// + /// The window's own zoom keys or wheel: in for a positive , out for a negative one, 100% for + /// zero. The client steps, saves and says the result; with none, the step is taken here. + /// + public void RequestZoom(int step) + { + if (ZoomRequested is { } client) + { + client(step); + return; + } + + var current = Current; + var next = new DisplaySettings + { + Zoom = ZoomLevels.Stepped(current.Zoom, step), + OutputPoints = current.OutputPoints, + ComposerText = current.ComposerText, + InterfaceText = current.InterfaceText, + PanelText = current.PanelText, + DialogText = current.DialogText, + }; + Show(next); + } +} + /// /// The window's half of : the client hands it Character@World — SharpMUTerm on its own /// thread, and the main window, which subscribes, puts it on the title bar and the taskbar entry on Avalonia's. diff --git a/src/SharpMUTerm.Avalonia/ComposerPresenter.cs b/src/SharpMUTerm.Avalonia/ComposerPresenter.cs index a1f7a80c..bc0e9d1e 100644 --- a/src/SharpMUTerm.Avalonia/ComposerPresenter.cs +++ b/src/SharpMUTerm.Avalonia/ComposerPresenter.cs @@ -38,6 +38,9 @@ public void Attach(Panel root, Control below, TerminalView view, Action refocus) _attached = true; } + /// The window's text sizes, read when F1 opens (MainWindow sets it; designed sizes until then). + public Func Scales { get; set; } = () => NativeScales.One; + public void Open(ComposerSession session, PanelInk ink) => Dispatcher.UIThread.Post(() => OpenNow(session, ink)); public void Close() => Dispatcher.UIThread.Post(CloseNow); @@ -56,6 +59,7 @@ public void Attach(Panel root, Control below, TerminalView view, Action refocus) } var overlay = new ComposerOverlay(view, session, ink); + overlay.Composer.TextScale = Scales().Composer; _overlay = overlay; var at = _below is not null ? root.Children.IndexOf(_below) : -1; root.Children.Insert(at < 0 ? root.Children.Count : at, overlay); diff --git a/src/SharpMUTerm.Avalonia/ComposerView.cs b/src/SharpMUTerm.Avalonia/ComposerView.cs index 1a328880..9aa4a83b 100644 --- a/src/SharpMUTerm.Avalonia/ComposerView.cs +++ b/src/SharpMUTerm.Avalonia/ComposerView.cs @@ -523,6 +523,25 @@ private void Paint() _marks.InvalidateVisual(); } + /// + /// How much larger than the cells' text the game text is drawn: the Text size page's composer size, over whatever + /// zoom the view is already drawn at (a docked composer sits in a panel drawn at the panels' scale). + /// + internal double TextScale + { + get => _textScale; + set + { + if (Math.Abs(value - _textScale) > 0.0005) + { + _textScale = value; + ApplyFont(); + } + } + } + + private double _textScale = 1; + /// Refreshes an open composer after zoom without changing its selected face. private void RefreshFontMetrics() { @@ -545,8 +564,11 @@ private void ApplyFont() _textLabel.FontSize = PanelText.Size - 1; // Game text in the cells' own face and size (the mockups' 13 px JetBrains Mono beside the output's), not a share // of the row: a row carries the line height's leading, which made the text a size larger than the output's. - var text = _view.CellTextSize > 0 ? _view.CellTextSize : cell * 0.8; - _command.FontSize = Math.Clamp(text, 11, 18); + // The upper bounds keep a large --font-size from swamping the panel; a size the player chose on the Text size + // page or by zooming lifts them with it. + var text = (_view.CellTextSize > 0 ? _view.CellTextSize : cell * 0.8) * _textScale; + var most = 18 * Math.Max(1, _textScale * ZoomOf(_view)); + _command.FontSize = Math.Clamp(text, 11, most); _command.FontFamily = NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName); foreach (var button in new[] { _previewButton, _sendButton }) { @@ -558,7 +580,7 @@ private void ApplyFont() _previewHeading.FontSize = ui - 1.5; _previewText.FontSize = Math.Clamp(cell * 0.72, 11, 16); _previewText.FontFamily = NativeTokens.CellFace(_view.Renderer.Fonts.FamilyName); - _editor.FontSize = Proportional ? Math.Clamp(text * 1.08, 12, 19) : Math.Clamp(text, 11, 18); + _editor.FontSize = Proportional ? Math.Clamp(text * 1.08, 12, most * 19 / 18) : Math.Clamp(text, 11, most); // Rows as far apart as the output's (the mockups' 20 px line), so a post reads at the pitch it will be read at. _editor.LineHeight = Proportional ? double.NaN : cell; @@ -571,6 +593,8 @@ private void ApplyFont() } } + private static double ZoomOf(TerminalView view) => view.ZoomPercent / 100.0; + /// Where line (from 1) of starts; the end when it has fewer. internal static int LineStart(string text, int line) { @@ -675,6 +699,13 @@ private void Label(ComposerModel model) // A docked composer too short for its footer keeps the editor and drops the footer: the heading, the footer // and an editor of two lines need about 136 px, and below that the footer would squeeze the well shut. _footer.IsVisible = Bounds.Height is 0 or >= 136; + + // A panel too narrow for both buttons (a narrow column, or its text drawn larger) keeps Send and drops Preview, + // which F1 still offers (Alt+P), rather than squeezing it to a sliver. + _sendButton.Measure(Size.Infinity); + _previewButton.Measure(Size.Infinity); + var both = (2 * PanelText.Pad) + _sendButton.DesiredSize.Width + _previewButton.DesiredSize.Width + NativeTokens.Space8; + _previewButton.IsVisible = Bounds.Width is 0 || Bounds.Width >= both; ShowPlaceholder(!model.Armed); return; } diff --git a/src/SharpMUTerm.Avalonia/DesktopLayer.cs b/src/SharpMUTerm.Avalonia/DesktopLayer.cs index d9af6f4b..23a9a1c1 100644 --- a/src/SharpMUTerm.Avalonia/DesktopLayer.cs +++ b/src/SharpMUTerm.Avalonia/DesktopLayer.cs @@ -104,6 +104,28 @@ public DesktopLayer(TerminalView view, HostChrome chrome) Sync(); } + /// + /// How much larger than designed each native area is drawn (the Text size page and the zoom). Setting it moves the + /// cells out of the way of a header and sidebar of the new size and places every view again. + /// + internal NativeScales Scales + { + get => _scales; + set + { + if (_scales == value) + { + return; + } + + _scales = value; + SyncInsets(_pointer.Chrome?.Sidebar.Collapsed ?? false); + Sync(); + } + } + + private NativeScales _scales = NativeScales.One; + /// Which chrome this layer draws. Internal for tests. internal HostChrome Chrome => _chrome; @@ -119,7 +141,7 @@ public DesktopLayer(TerminalView view, HostChrome chrome) /// private void SyncInsets(bool collapsed) { - var insets = NativeLayout.Insets(_chrome, collapsed); + var insets = NativeLayout.Insets(_chrome, collapsed, _scales.Interface); if (_view.Margin != insets) { _view.Margin = insets; @@ -237,7 +259,7 @@ private void SyncCovered() .Concat(_popOuts.Values.Where(b => b.Reserved)); _view.Covered = views .Where(v => v.IsVisible && v.Width > 0 && v.Height > 0) - .Select(v => new Rect(GetLeft(v) - origin.X, GetTop(v) - origin.Y, v.Width, v.Height)) + .Select(v => new Rect(new Point(GetLeft(v) - origin.X, GetTop(v) - origin.Y), NativeScale.Extent(v))) .ToList(); } @@ -260,7 +282,8 @@ private void SyncSeams(Size cell) var views = _strips.Values.Cast().Concat(_bands.Values).Concat(_gauges.Values).Concat(_maps.Values) .Concat(_whoHere.Values).Concat(_quickBars.Values).Concat(_collapsedBars.Values).Concat(_composers.Values) .Where(v => v.IsVisible && v.Width > 0 && v.Height > 0 && v is IEdgeRules) - .Select(v => (new Rect(GetLeft(v), GetTop(v), v.Width, v.Height), ((IEdgeRules)v).EdgeRules(new Size(v.Width, v.Height)))); + .Select(v => (new Rect(new Point(GetLeft(v), GetTop(v)), NativeScale.Extent(v)), + ((IEdgeRules)v).EdgeRules(new Size(v.Width, v.Height)).Select(r => r with { Rect = NativeScale.Out(r.Rect, NativeScale.Of(v)) }))); _seams.Show(SeamRules.Between(views, hairlines, cell.Width)); } @@ -327,8 +350,11 @@ private void Sync(Dictionary controls, IEnumerable<(string Key, Pa SetLeft(control, origin.X + (row.X * cell.Width)); SetTop(control, origin.Y + (row.Y * cell.Height)); - control.Width = (row.Width * cell.Width) + RightOfGrid(row.X, row.Width, _view.Driver.Frame.Width, cell); - control.Height = row.Height * cell.Height; + NativeScale.Cover( + control, + (row.Width * cell.Width) + RightOfGrid(row.X, row.Width, _view.Driver.Frame.Width, cell), + row.Height * cell.Height, + _scales.Interface); } } @@ -348,10 +374,10 @@ private void SyncToasts(ScreenFrame frame, Size cell) var origin = CellOrigin; SetLeft(_toasts, origin.X); SetTop(_toasts, origin.Y); - _toasts.Width = Math.Max(0, frame.Width * cell.Width); - _toasts.Height = Math.Max(0, frame.Height * cell.Height); + var toastScale = _scales.Interface; + NativeScale.Cover(_toasts, Math.Max(0, frame.Width * cell.Width), Math.Max(0, frame.Height * cell.Height), toastScale); var above = _noticeAboveRow > 0 && _noticeAboveRow < frame.Height ? _noticeAboveRow : Math.Max(0, frame.Height - 2); - _toasts.Bottom = above * cell.Height; + _toasts.Bottom = above * cell.Height / toastScale; // The native maps' legends, in the toasts' coordinates: a toast is stacked clear of one rather than over it. var avoid = new List(); @@ -359,7 +385,7 @@ private void SyncToasts(ScreenFrame frame, Size cell) { if (map.LegendRect(new Size(map.Width, map.Height)) is { } legend) { - avoid.Add(legend.Translate(new Vector(GetLeft(map) - origin.X, GetTop(map) - origin.Y))); + avoid.Add(NativeScale.Out(legend, NativeScale.Of(map)).Translate(new Vector(GetLeft(map) - origin.X, GetTop(map) - origin.Y))); } } @@ -369,11 +395,12 @@ private void SyncToasts(ScreenFrame frame, Size cell) { if (view.IsVisible && view.Width > 0 && view.Height > 0) { - avoid.Add(new Rect(GetLeft(view) - origin.X, GetTop(view) - origin.Y, view.Width, view.Height)); + avoid.Add(new Rect(new Point(GetLeft(view) - origin.X, GetTop(view) - origin.Y), NativeScale.Extent(view))); } } - _toasts.Avoid = avoid; + // In the toasts' own units: they are drawn at the interface's scale. + _toasts.Avoid = avoid.Select(a => new Rect(a.X / toastScale, a.Y / toastScale, a.Width / toastScale, a.Height / toastScale)).ToList(); // Hidden, not stacked under it, while something modal is in front: a settings window is a window of its own and a // toast under it showed as a clipped fragment past its edge, and ⌃P and the modals have a scrim the toast would sit @@ -399,20 +426,22 @@ private void SyncChrome() SyncInsets(chrome?.Sidebar.Collapsed ?? false); var width = Bounds.Width; var height = Bounds.Height; - var top = _chrome.Header ? NativeLayout.HeaderHeight : 0; - var bottom = _chrome.Status ? NativeLayout.StatusHeight : 0; + var scale = _scales.Interface; + var top = _chrome.Header ? NativeLayout.Scaled(NativeLayout.HeaderHeight, scale) : 0; + var bottom = _chrome.Status ? NativeLayout.Scaled(NativeLayout.StatusHeight, scale) : 0; - // The header buttons' slots, plus the resize band, stay clear for the buttons drawn above the header. + // The header buttons' slots, plus the resize band, stay clear for the buttons drawn above the header (in the + // header's own units, which the interface's scale draws larger). _header.ReserveRight = _chrome.Header - ? (_pointer.Actions.Count * (NativeLayout.ButtonSize + NativeLayout.ButtonGap)) + WindowChrome.EdgeBand + 4 + ? (_pointer.Actions.Count * (NativeLayout.ButtonSize + NativeLayout.ButtonGap)) + ((WindowChrome.EdgeBand + 4) / scale) : 0; // Before the client has said anything the bands are drawn empty, on the view's own colours, so a window never // opens with a hole where its header will be. var ink = chrome is null ? Colour(_view.Renderer.DefaultForeground) : NativeChromeView.Parse(chrome.Palette.Ink); - Place(_header, _chrome.Header, new Rect(0, 0, width, NativeLayout.HeaderHeight), chrome, + Place(_header, _chrome.Header, new Rect(0, 0, width, top), scale, chrome, Translucent(chrome is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(chrome.Palette.HeaderPlane)), ink); - Place(_sidebar, _chrome.Rail, new Rect(0, top, _view.Margin.Left, Math.Max(0, height - top - bottom)), chrome, + Place(_sidebar, _chrome.Rail, new Rect(0, top, _view.Margin.Left, Math.Max(0, height - top - bottom)), scale, chrome, Translucent(chrome is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(chrome.Palette.RailPlane)), ink); // The footer across the whole width under the cells and the sidebar, and the field's strip: the header's plane, as @@ -428,8 +457,7 @@ private void SyncChrome() { SetLeft(_footer, 0); SetTop(_footer, height - bottom); - _footer.Width = width; - _footer.Height = bottom; + NativeScale.Cover(_footer, width, bottom, scale); _footer.Show(_pointer.Status); _footer.IsVisible = true; } @@ -439,7 +467,7 @@ private void SyncChrome() } } - private static void Place(NativeChromeView view, bool drawn, Rect area, ChromeOverlay? chrome, Color plane, Color ink) + private static void Place(NativeChromeView view, bool drawn, Rect area, double scale, ChromeOverlay? chrome, Color plane, Color ink) { if (!drawn || area.Width <= 0 || area.Height <= 0) { @@ -449,8 +477,7 @@ private static void Place(NativeChromeView view, bool drawn, Rect area, ChromeOv SetLeft(view, area.X); SetTop(view, area.Y); - view.Width = area.Width; - view.Height = area.Height; + NativeScale.Cover(view, area.Width, area.Height, scale); view.Show(chrome, plane, ink); view.IsVisible = true; } @@ -481,11 +508,11 @@ private void SyncButtons(ScreenFrame frame, Size cell) if (_chrome.Header) { // In the window's own header: right to left from its end, clear of the resize band, on its plane. - var side = NativeLayout.ButtonSize; - button.Width = side; - button.Height = side; - SetLeft(button, Bounds.Width - WindowChrome.EdgeBand - 4 - ((i + 1) * (side + NativeLayout.ButtonGap)) + NativeLayout.ButtonGap); - SetTop(button, Math.Round((NativeLayout.HeaderHeight - side) / 2)); + var zoom = _scales.Interface; + var side = NativeLayout.ButtonSize * zoom; + NativeScale.Cover(button, side, side, zoom); + SetLeft(button, Bounds.Width - WindowChrome.EdgeBand - 4 - ((i + 1) * (side + (NativeLayout.ButtonGap * zoom))) + (NativeLayout.ButtonGap * zoom)); + SetTop(button, Math.Round((NativeLayout.Scaled(NativeLayout.HeaderHeight, zoom) - side) / 2)); var said = _pointer.Chrome; button.Ink = said is null ? Colour(_view.Renderer.DefaultForeground) : NativeChromeView.Parse(said.Palette.Ink); button.Plane = said is null ? Colour(_view.Renderer.DefaultBackground) : NativeChromeView.Parse(said.Palette.HeaderPlane); @@ -531,7 +558,7 @@ private void SyncPanels(ScreenFrame frame, Size cell) (gauges, panel, plane) => { gauges.Seams = (panel.RuleAbove, panel.RuleBelow); - gauges.Show(panel.Vitals!, panel.Ink, plane, text, cell.Height); + gauges.Show(panel.Vitals!, panel.Ink, plane, text, cell.Height / _scales.Panels); }); Place( _maps, @@ -546,7 +573,7 @@ private void SyncPanels(ScreenFrame frame, Size cell) () => new WhoHereList(_view), frame, cell, - (list, panel, plane) => list.Show(panel.WhoHere!, panel.Ink, plane, text, cell.Height, panel.Pick)); + (list, panel, plane) => list.Show(panel.WhoHere!, panel.Ink, plane, text, cell.Height / _scales.Panels, panel.Pick)); Place( _quickBars, _pointer.Panels.Where(p => p.PanelId == PanelIds.QuickBar && p.QuickBar is not null), @@ -556,7 +583,7 @@ private void SyncPanels(ScreenFrame frame, Size cell) (bar, panel, plane) => { bar.Seams = (panel.RuleAbove, panel.RuleBelow); - bar.Show(panel.QuickBar!, panel.Ink, plane, text, cell.Height, panel.Pick, panel.Dispatch); + bar.Show(panel.QuickBar!, panel.Ink, plane, text, cell.Height / _scales.Panels, panel.Pick, panel.Dispatch); }); // A collapsed panel's bar, at the foot of its column (HostChrome.Strips). @@ -575,7 +602,13 @@ private void SyncPanels(ScreenFrame frame, Size cell) () => new ComposerView(_view, fullScreen: false), frame, cell, - (composer, panel, plane) => composer.Show(panel.Composer!, panel.ComposerSession!, panel.Ink, plane, text)); + (composer, panel, plane) => + { + // The editor's text is the cells' size at the composer's own scale, inside a panel already drawn at the + // panels' scale. + composer.TextScale = _scales.Composer / _scales.Panels; + composer.Show(panel.Composer!, panel.ComposerSession!, panel.Ink, plane, text); + }); SyncPopOuts(frame, cell); // A pane a native view covers draws no overlay scrollbar of its own: its cells are only the view's backing (the @@ -702,8 +735,7 @@ private void SyncPopOuts(ScreenFrame frame, Size cell) { // The strip's reserved end: the whole row's height, on the pane's own plane. var reserve = PopOutReserve(cell); - button.Width = reserve; - button.Height = rect.Height * cell.Height; + NativeScale.Cover(button, reserve, rect.Height * cell.Height, _scales.Panels); SetLeft(button, right - reserve); SetTop(button, origin.Y + (rect.Y * cell.Height)); var inside = rect.X >= 0 && rect.Y >= 0 && rect.X < frame.Width && rect.Y < frame.Height; @@ -711,8 +743,7 @@ private void SyncPopOuts(ScreenFrame frame, Size cell) } else { - button.Width = size; - button.Height = size; + NativeScale.Cover(button, size, size, _scales.Panels); SetLeft(button, right - size - 3); SetTop(button, origin.Y + (rect.Y * cell.Height) + 2); button.Plane = null; @@ -761,9 +792,12 @@ private void Place( SetTop(control, origin.Y + (rect.Y * cell.Height)); // A one-row strip that can pop out leaves its right end to the button (ReservesPopOut), so the button never // lies over its text (`88 / 100`, `F4 edits keys`); the button paints the pane's plane over the cells there. - control.Width = Math.Max(0, (rect.Width * cell.Width) + RightOfGrid(rect.X, rect.Width, frame.Width, cell) - - (ReservesPopOut(panel) ? PopOutReserve(cell) : 0)); - control.Height = (rect.Height * cell.Height) + BelowGrid(frame, rect, cell); + NativeScale.Cover( + control, + Math.Max(0, (rect.Width * cell.Width) + RightOfGrid(rect.X, rect.Width, frame.Width, cell) + - (ReservesPopOut(panel) ? PopOutReserve(cell) : 0)), + (rect.Height * cell.Height) + BelowGrid(frame, rect, cell), + _scales.Panels); // The pane's own plane, read off the frame, so the native view sits on whatever the pane wears — its // character's tint, the focus lift — without the client having to say it twice. @@ -963,9 +997,17 @@ internal static class NativeLayout public const double ButtonGap = NativeTokens.Space8; /// The cells' margin in the window: the space the native header and sidebar take. - public static Thickness Insets(HostChrome chrome, bool collapsed) => new( - chrome.Rail ? (collapsed ? SidebarSlim : SidebarWidth) + SidebarGutter : 0, - chrome.Header ? HeaderHeight : 0, + public static Thickness Insets(HostChrome chrome, bool collapsed, double scale = 1) => new( + chrome.Rail ? Scaled((collapsed ? SidebarSlim : SidebarWidth) + SidebarGutter, scale) : 0, + chrome.Header ? Scaled(HeaderHeight, scale) : 0, 0, - chrome.Status ? StatusHeight : 0); + chrome.Status ? Scaled(StatusHeight, scale) : 0); + + /// + /// A chrome size at the interface's , rounded to a multiple of 4 + /// as every chrome size is, so the cells below and beside it still start on a whole device pixel at 125% and 150%. + /// Exactly the designed size at 1. + /// + public static double Scaled(double designed, double scale) => + Math.Abs(scale - 1) < 0.0005 ? designed : Math.Max(4, Math.Round(designed * scale / 4) * 4); } diff --git a/src/SharpMUTerm.Avalonia/FloatingPanels.cs b/src/SharpMUTerm.Avalonia/FloatingPanels.cs index edcce23a..29e31501 100644 --- a/src/SharpMUTerm.Avalonia/FloatingPanels.cs +++ b/src/SharpMUTerm.Avalonia/FloatingPanels.cs @@ -27,6 +27,9 @@ internal sealed class FloatingPanelPresenter : IFloatingPanels private TerminalView? _view; private bool _attached; + /// The window's text sizes, read when a panel's window opens (MainWindow sets it; designed sizes until then). + public Func Scales { get; set; } = () => NativeScales.One; + /// What the client last said is floating; any thread. public IReadOnlyList Panels => Volatile.Read(ref _panels); @@ -101,7 +104,7 @@ internal void Sync() continue; } - var window = new FloatingPanelWindow(panel, _view); + var window = new FloatingPanelWindow(panel, _view, Scales().Panels); window.Update(panel, _view.CellExtent.Height); var bounds = panel.Model.Bounds.IsPlaced ? panel.Model.Bounds : BesideOwner(panel.Model.PanelId); window.Place(Clamp(bounds), Scaling()); @@ -192,7 +195,8 @@ internal sealed class FloatingPanelWindow : Window private bool _closingForClient; private bool _placing; - public FloatingPanelWindow(FloatingPanel panel, TerminalView view) + /// How much larger than designed the panel is drawn: the window's panel text size and zoom. + public FloatingPanelWindow(FloatingPanel panel, TerminalView view, double scale = 1) { _view = view; view.AttachZoomKeys(this); @@ -232,7 +236,7 @@ public FloatingPanelWindow(FloatingPanel panel, TerminalView view) DockPanel.SetDock(_bar, Dock.Top); root.Children.Add(_bar); root.Children.Add(_content); - Content = root; + Content = NativeScale.Wrap(root, scale); _report = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _report.Tick += (_, _) => diff --git a/src/SharpMUTerm.Avalonia/MainWindow.cs b/src/SharpMUTerm.Avalonia/MainWindow.cs index bfa75baa..b6add253 100644 --- a/src/SharpMUTerm.Avalonia/MainWindow.cs +++ b/src/SharpMUTerm.Avalonia/MainWindow.cs @@ -53,6 +53,13 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime _view.AttachZoomKeys(this); + // Zoom and text sizes are the client's settings (F7 ▸ Text size & zoom): the window starts from the saved ones, + // asks the client for each zoom step, and redraws whenever the client says the sizes changed. + _driver.TextSize.Start(_config.Display); + _view.SetCellZoom(CellZoom(_driver.TextSize.Current)); + _view.ZoomRequested += _driver.TextSize.RequestZoom; + _view.ZoomShown = () => _driver.TextSize.Current.Zoom; + Title = SharpMUTerm.Client.Chrome.WindowTitle.Product; AppIcon.Apply(this); @@ -106,7 +113,20 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime // view out of their way, so no cell lies under either), the Layouts button, and native panels over their panes. var layer = new DesktopLayer(_view, options.Chrome); _layer = layer; + layer.Scales = Scales(_driver.TextSize.Current); + _view.InterfaceScale = layer.Scales.Interface; + NativeMenu.Scale = layer.Scales.Dialogs; root.Children.Add(layer); + _driver.TextSize.Changed += sizes => Dispatcher.UIThread.Post(() => ApplyTextSizes(sizes)); + + // Dialogs, F1, the settings window and floating panels read the sizes when they open. + Func scales = () => Scales(_driver.TextSize.Current); + _driver.Modals.Scales = scales; + _driver.Palette.Scales = scales; + _driver.Composer.Scales = scales; + _driver.Settings.Scales = scales; + _driver.Floating.Scales = scales; + _driver.Pointer.PanelScale = () => scales().Panels; // The edge Avalonia would have drawn round a BorderOnly window on X11, drawn here (DecorationsFor): a hairline, // only while the window is an ordinary window. @@ -159,7 +179,7 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime // --size names the grid, so the window is that grid plus the native chrome around it — measured at the scale // of the screen it opens on, and shrunk by whole cells to that screen's working area (WindowFit): at 150% on a // 1080p screen the default grid is taller than the screen, and its command line opened below the edge. - var insets = NativeLayout.Insets(options.Chrome, collapsed: false); + var insets = NativeLayout.Insets(options.Chrome, collapsed: false, layer.Scales.Interface); var screen = Screens is { } screens ? screens.Primary ?? screens.All.FirstOrDefault() : null; if (screen is not null) { @@ -233,6 +253,25 @@ private void SyncTranslucency() _layer.PlaneAlpha = alpha; } + // The cells' size: the output's points (a --font-size given for this run wins over the saved one), at the zoom. + private double CellZoom(DisplaySettings sizes) + { + var points = _options.FontSizeGiven ? _options.FontPoints : sizes.OutputPoints; + return points / _options.FontPoints * sizes.Zoom / 100.0; + } + + // The native views' scales; in Emulated mode there are none to scale, so only the cells follow the zoom. + private NativeScales Scales(DisplaySettings sizes) => + _driver.TextSize.SizesAreas ? NativeScales.From(sizes) : NativeScales.One; + + private void ApplyTextSizes(DisplaySettings sizes) + { + _view.SetCellZoom(CellZoom(sizes)); + _layer.Scales = Scales(sizes); + _view.InterfaceScale = _layer.Scales.Interface; + NativeMenu.Scale = _layer.Scales.Dialogs; + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); diff --git a/src/SharpMUTerm.Avalonia/MapCanvas.cs b/src/SharpMUTerm.Avalonia/MapCanvas.cs index d6f79fe6..a3d5607f 100644 --- a/src/SharpMUTerm.Avalonia/MapCanvas.cs +++ b/src/SharpMUTerm.Avalonia/MapCanvas.cs @@ -515,7 +515,8 @@ internal sealed record LookButton(string Id, Rect Rect, string Label, bool Enabl /// /// The look buttons the view offers, left to right under the corner label: ▲ ▼ while the area has more than one level, - /// Areas ▾ while the map has more than one area, and Back to where I am while the view is elsewhere. Internal for tests. + /// Areas ▾ while the map has more than one area, and Back to where I am while the view is elsewhere, wrapping onto a + /// second row where the panel is too narrow for them. Internal for tests. /// internal IReadOnlyList LookButtons() { @@ -530,6 +531,14 @@ internal IReadOnlyList LookButtons() void Add(string id, string label, bool enabled) { var width = PanelText.Measure(label, ButtonSize, bold: false) + (2 * NativeTokens.Space10); + + // A button the row has no room for starts the next (a narrow column, or the panels drawn larger). + if (Bounds.Width > 0 && x > PanelText.Pad && x + width > Bounds.Width - PanelText.Pad) + { + x = PanelText.Pad; + y += ButtonHeight + NativeTokens.Space6; + } + buttons.Add(new LookButton(id, new Rect(x, y, width, ButtonHeight), label, enabled)); x += width + NativeTokens.Space6; } @@ -563,8 +572,8 @@ private double UnderHeading() => /// What is drawn above the rooms: the heading, a walk line, and the look buttons while there are any. private double TopReserve() { - var top = UnderHeading(); - return LookButtons().Count > 0 ? top + ButtonHeight + NativeTokens.Space8 : top; + var buttons = LookButtons(); + return buttons.Count > 0 ? buttons.Max(b => b.Rect.Bottom) + NativeTokens.Space8 : UnderHeading(); } /// What is drawn below the rooms: the legend and the hint, with a gap above them. diff --git a/src/SharpMUTerm.Avalonia/ModalOverlay.cs b/src/SharpMUTerm.Avalonia/ModalOverlay.cs index 9aff559e..082aa454 100644 --- a/src/SharpMUTerm.Avalonia/ModalOverlay.cs +++ b/src/SharpMUTerm.Avalonia/ModalOverlay.cs @@ -26,12 +26,16 @@ internal sealed class ModalOverlay : Panel private readonly Action _remove; private readonly IModalView _view; private readonly Border _card; + private readonly double _scale; /// What to draw, and where its gestures go. /// Takes the overlay out of the window. /// Hands a key to the client, as the cells would receive it. - public ModalOverlay(ModalSession session, Action remove, Action forward) + /// The window's text sizes: the card is drawn at the dialogs' scale, below the header's height. + public ModalOverlay(ModalSession session, Action remove, Action forward, NativeScales? scales = null) { + var sizes = scales ?? NativeScales.One; + _scale = sizes.Dialogs; Session = session; _remove = remove; Colours = NativeTokens.For(session.Theme, session.IsDark); @@ -82,13 +86,13 @@ public ModalOverlay(ModalSession session, Action remove, Action { @@ -191,8 +195,10 @@ private void Fit() return; } - _card.Width = Math.Max(260, Math.Min(_view.PreferredWidth, Bounds.Width - 32)); - _card.MaxHeight = Math.Max(180, Bounds.Height - (_view.Centred ? 32 : 88)); + // The room the card is laid out in is the overlay's at the dialogs' scale. + var room = Bounds.Size / _scale; + _card.Width = Math.Max(260, Math.Min(_view.PreferredWidth, room.Width - 32)); + _card.MaxHeight = Math.Max(180, room.Height - (_view.Centred ? 32 : 88)); } } diff --git a/src/SharpMUTerm.Avalonia/ModalPresenter.cs b/src/SharpMUTerm.Avalonia/ModalPresenter.cs index 2fa3ee86..89a0b668 100644 --- a/src/SharpMUTerm.Avalonia/ModalPresenter.cs +++ b/src/SharpMUTerm.Avalonia/ModalPresenter.cs @@ -59,6 +59,9 @@ public void Attach(Window? owner, Panel root, Action refocus, ActionThe window's text sizes, read when a surface opens (MainWindow sets it; designed sizes until then). + public Func Scales { get; set; } = () => NativeScales.One; + public void Show(ModalSession session) => Dispatcher.UIThread.Post(() => ShowNow(session)); public void Close(ModalSession session) => Dispatcher.UIThread.Post(() => @@ -83,7 +86,7 @@ public void Close(ModalSession session) => Dispatcher.UIThread.Post(() => } var previousFocus = TopLevel.GetTopLevel(root)?.FocusManager?.GetFocusedElement(); - var overlay = new ModalOverlay(session, Remove, Forward); + var overlay = new ModalOverlay(session, Remove, Forward, Scales()); if (session.AlwaysInFront) { if (_quit is { } old) diff --git a/src/SharpMUTerm.Avalonia/NativeMenu.cs b/src/SharpMUTerm.Avalonia/NativeMenu.cs index 58ea76e8..6843bd58 100644 --- a/src/SharpMUTerm.Avalonia/NativeMenu.cs +++ b/src/SharpMUTerm.Avalonia/NativeMenu.cs @@ -38,6 +38,12 @@ public static ContextMenu Build(MenuModel model, Action dispatch, Placem placement, target); + /// + /// How much larger than designed menus are drawn: the window's dialog text size and zoom (MainWindow sets it). A + /// menu is a popup window of its own, so it is sized rather than transformed. + /// + internal static double Scale { get; set; } = 1; + private static ContextMenu Build( IEnumerable<(string Label, string? Hint, bool Enabled, bool Checked, Action Run)?> items, PlacementMode placement, Control target) { @@ -53,7 +59,7 @@ private static ContextMenu Build( BorderThickness = new Thickness(NativeTokens.Hairline), CornerRadius = new CornerRadius(NativeTokens.RadiusM), Padding = new Thickness(NativeTokens.Space6), - FontSize = NativeTokens.Body, + FontSize = NativeTokens.Body * Scale, }; tokens.ApplySimple(menu.Resources); @@ -86,7 +92,7 @@ private static ContextMenu Build( { Header = Header(entry.Label, entry.Hint, tokens), IsEnabled = entry.Enabled, - MinHeight = NativeTokens.ControlHeightSmall, + MinHeight = NativeTokens.ControlHeightSmall * Scale, }; if (entry.Checked) { @@ -146,7 +152,7 @@ private static Control Header(string label, string? hint, NativeTokens tokens) { Text = NativeKeys.Words(key), FontFamily = NativeTokens.KeyFace, - FontSize = NativeTokens.Small, + FontSize = NativeTokens.Small * Scale, Foreground = tokens.Brush(tokens.Dim), Margin = new Thickness(NativeTokens.Space32, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, diff --git a/src/SharpMUTerm.Avalonia/NativeScale.cs b/src/SharpMUTerm.Avalonia/NativeScale.cs new file mode 100644 index 00000000..4f6a5a60 --- /dev/null +++ b/src/SharpMUTerm.Avalonia/NativeScale.cs @@ -0,0 +1,115 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Avalonia; + +/// +/// How much larger than designed each native area of the window is drawn: the Text size page's sizes with the zoom +/// multiplied in (). is against the cells' own text size, which +/// already carries the zoom; the rest are against the design's own sizes. +/// +internal readonly record struct NativeScales(double Interface, double Panels, double Dialogs, double Composer) +{ + /// Everything as designed. + public static NativeScales One { get; } = new(1, 1, 1, 1); + + /// The scales the page's ask for. + public static NativeScales From(DisplaySettings sizes) + { + var held = sizes.Clamped(); + var zoom = held.Zoom / 100.0; + return new(zoom * held.InterfaceText / 100.0, zoom * held.PanelText / 100.0, zoom * held.DialogText / 100.0, held.ComposerText / 100.0); + } +} + +/// +/// Draws a native view larger or smaller than designed, as a browser's zoom does: the view is laid out in a box +/// 1 / scale the size of the area it covers and drawn scale times larger, so its text, padding, rules and +/// hit areas grow together and it still covers exactly its area. Every view keeps its own layout rules; nothing in them +/// knows the scale. At 1 nothing is set, so a window at 100% is laid out exactly as it was. +/// +internal static class NativeScale +{ + /// Sizes to cover × at . + public static void Cover(Control control, double width, double height, double scale) + { + if (Math.Abs(scale - 1) < 0.0005) + { + if (control.RenderTransform is ScaleTransform) + { + control.RenderTransform = null; + } + + control.UseLayoutRounding = true; + control.Width = width; + control.Height = height; + return; + } + + // Not rounded: a box 1/scale the area's size is seldom whole pixels, and rounding it would draw the view up to half + // a pixel past its area (64 px at 1.5 is 42.67, which rounds to 43 and draws at 64.5). + control.UseLayoutRounding = false; + control.Width = width / scale; + control.Height = height / scale; + control.RenderTransformOrigin = RelativePoint.TopLeft; + if (control.RenderTransform is not ScaleTransform current || Math.Abs(current.ScaleX - scale) > 0.0005) + { + control.RenderTransform = new ScaleTransform(scale, scale); + } + } + + /// The scale is drawn at (1 when it is drawn as laid out). + public static double Of(Visual control) => control.RenderTransform is ScaleTransform scale ? scale.ScaleX : 1; + + /// The area covers on screen, in its parent's units. + public static Size Extent(Control control) + { + var scale = Of(control); + return new Size(control.Width * scale, control.Height * scale); + } + + /// , in a scaled view's own units, in the units of the area it covers. + public static Rect Out(Rect rect, double scale) => + Math.Abs(scale - 1) < 0.0005 ? rect : new Rect(rect.X * scale, rect.Y * scale, rect.Width * scale, rect.Height * scale); + + /// + /// How many device pixels one of 's own units is: the screen's scaling times every zoom applied + /// to it and its ancestors, so a hairline under a zoomed view is still one device pixel. + /// + public static double DeviceScale(Visual visual) + { + var top = TopLevel.GetTopLevel(visual); + var scale = top?.RenderScaling ?? 1; + if (scale <= 0) + { + scale = 1; + } + + for (Visual? at = visual; at is not null && at != top; at = at.GetVisualParent()) + { + // A ScaleTransform of ours (Cover) or the matrix a LayoutTransformControl lays on its child (Wrap). + if (at.RenderTransform is { } transform) + { + scale *= Math.Abs(transform.Value.M11); + } + } + + return scale; + } + + /// + /// laid out and drawn at inside whatever holds the result: for a + /// dialog over the whole window, which centres itself in the room it is given rather than covering a fixed area. + /// + public static LayoutTransformControl Wrap(Control child, double scale) => new() + { + LayoutTransform = Math.Abs(scale - 1) < 0.0005 ? null : new ScaleTransform(scale, scale), + Child = child, + }; + + /// The scale a lays its child out at. + public static double WrapScale(LayoutTransformControl wrap) => wrap.LayoutTransform is { } transform ? Math.Abs(transform.Value.M11) : 1; +} diff --git a/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs b/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs index 5f19c26e..9e9427b5 100644 --- a/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs +++ b/src/SharpMUTerm.Avalonia/NativeTokens.Spacing.cs @@ -166,9 +166,7 @@ public static double LineCentre(Visual visual, double centre, double width) /// public static void ApplyTextRendering(Visual window) => TextOptions.SetTextHintingMode(window, TextHintingMode.Light); - private static double Scaling(Visual visual) - { - var scale = TopLevel.GetTopLevel(visual)?.RenderScaling ?? 1; - return scale > 0 ? scale : 1; - } + // The screen's scaling times any zoom the view is drawn at (NativeScale), so a hairline in a zoomed panel is still one + // device pixel rather than the zoom's share of one. + private static double Scaling(Visual visual) => NativeScale.DeviceScale(visual); } diff --git a/src/SharpMUTerm.Avalonia/PaletteOverlay.cs b/src/SharpMUTerm.Avalonia/PaletteOverlay.cs index fc62a2af..0e7792fa 100644 --- a/src/SharpMUTerm.Avalonia/PaletteOverlay.cs +++ b/src/SharpMUTerm.Avalonia/PaletteOverlay.cs @@ -46,6 +46,8 @@ internal sealed class PaletteOverlay : Panel private readonly List _keyTexts = []; private PaletteView _view; private int _selected = -1; + private readonly double _scale; + private readonly double _top; /// What to list, and where a choice goes. /// Takes the overlay out of the window and gives the keyboard back to the cells. @@ -55,8 +57,12 @@ internal sealed class PaletteOverlay : Panel /// internal const string Placeholder = NativeHeader.SearchText + " — type a command, a character, a window or a setting"; - public PaletteOverlay(PaletteSession session, Action remove) + /// The window's text sizes: the card is drawn at the dialogs' scale, below the header's height. + public PaletteOverlay(PaletteSession session, Action remove, NativeScales? scales = null) { + var sizes = scales ?? NativeScales.One; + _scale = sizes.Dialogs; + _top = (NativeLayout.Scaled(NativeLayout.HeaderHeight, sizes.Interface) + CardTop - NativeLayout.HeaderHeight) / _scale; _session = session; _remove = remove; _colours = NativeTokens.For(session.Theme, session.IsDark); @@ -150,13 +156,13 @@ public PaletteOverlay(PaletteSession session, Action remove) RequestedThemeVariant = session.IsDark ? ThemeVariant.Dark : ThemeVariant.Light, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Top, - Margin = new Thickness(NativeTokens.Space16, CardTop, NativeTokens.Space16, NativeTokens.Space16), + Margin = new Thickness(NativeTokens.Space16, _top, NativeTokens.Space16, NativeTokens.Space16), Child = _card, }; scope.Styles.Add(new FluentTheme()); _colours.Apply(scope.Resources); _colours.ApplyFocusRing(scope); - Children.Add(scope); + Children.Add(NativeScale.Wrap(scope, _scale)); AddHandler(KeyDownEvent, OnKey, RoutingStrategies.Tunnel); PointerPressed += OnScrimPressed; @@ -241,9 +247,10 @@ public void Move(int delta) private void Fit() { - var width = Math.Max(240, Math.Min(CardWidth, Bounds.Width - 32)); + var room = Bounds.Size / _scale; + var width = Math.Max(240, Math.Min(CardWidth, room.Width - 32)); _card.Width = width; - _card.MaxHeight = Math.Max(160, Bounds.Height - CardTop - 32); + _card.MaxHeight = Math.Max(160, room.Height - _top - 32); } private void Refresh(bool resetSelection) diff --git a/src/SharpMUTerm.Avalonia/PalettePresenter.cs b/src/SharpMUTerm.Avalonia/PalettePresenter.cs index a3aa6ae0..6cde8cb2 100644 --- a/src/SharpMUTerm.Avalonia/PalettePresenter.cs +++ b/src/SharpMUTerm.Avalonia/PalettePresenter.cs @@ -30,6 +30,9 @@ public void Attach(Panel host, Action refocus) _refocus = refocus; } + /// The window's text sizes, read when a surface opens (MainWindow sets it; designed sizes until then). + public Func Scales { get; set; } = () => NativeScales.One; + public void Show(PaletteSession session) => Dispatcher.UIThread.Post(() => ShowNow(session)); public void Close() => Dispatcher.UIThread.Post(() => @@ -56,7 +59,7 @@ public void Close() => Dispatcher.UIThread.Post(() => return null; } - var overlay = new PaletteOverlay(session, Remove); + var overlay = new PaletteOverlay(session, Remove, Scales()); _overlay = overlay; host.Children.Add(overlay); overlay.SearchBox.Focus(); diff --git a/src/SharpMUTerm.Avalonia/SettingsPresenter.cs b/src/SharpMUTerm.Avalonia/SettingsPresenter.cs index 1c0fb66a..a5d29fb9 100644 --- a/src/SharpMUTerm.Avalonia/SettingsPresenter.cs +++ b/src/SharpMUTerm.Avalonia/SettingsPresenter.cs @@ -30,6 +30,9 @@ internal sealed class SettingsPresenter : INativeSettings internal Size? RememberedSize { get; private set; } /// Called on the window's thread once the settings window has opened or closed, so the main window can re-hide or show what sits under it. + /// The window's text sizes, read when the settings window opens (MainWindow sets it; designed sizes until then). + public Func Scales { get; set; } = () => NativeScales.One; + public Action? OpenedOrClosed { get; set; } /// The window the settings window belongs to, set once the main window exists. @@ -56,7 +59,7 @@ internal SettingsWindow ShowNow(SettingsForm form) return open; } - var window = new SettingsWindow(form); + var window = new SettingsWindow(form, Scales().Dialogs); if (RememberedSize is { } remembered) { window.Width = remembered.Width; @@ -135,7 +138,7 @@ private void Place(SettingsWindow window, SettingsForm form) { var wasDocked = _docked; _docked = false; - var size = RememberedSize ?? new Size(SettingsWindow.DefaultWidth, SettingsWindow.DefaultHeight); + var size = RememberedSize ?? new Size(SettingsWindow.DefaultWidth * window.Scale, SettingsWindow.DefaultHeight * window.Scale); // Inside the screen the main window is on (WindowFit): the default 1120 × 760 is 1,140 device rows at 150%. if ((owner.Screens.ScreenFromWindow(owner) ?? owner.Screens.Primary ?? owner.Screens.All.FirstOrDefault()) is { } screen) diff --git a/src/SharpMUTerm.Avalonia/SettingsWindow.cs b/src/SharpMUTerm.Avalonia/SettingsWindow.cs index 161ac805..cb694241 100644 --- a/src/SharpMUTerm.Avalonia/SettingsWindow.cs +++ b/src/SharpMUTerm.Avalonia/SettingsWindow.cs @@ -95,17 +95,23 @@ internal sealed partial class SettingsWindow : Window /// The size the window first opens at, before the player resizes it. internal const double DefaultWidth = 1120, DefaultHeight = 760; - public SettingsWindow(SettingsForm form) + /// The scale the page is drawn at (the dialogs' text size and the zoom). + internal double Scale { get; } + + /// The settings drawn and edited. + /// How much larger than designed the page is drawn: the window's dialog text size and zoom. + public SettingsWindow(SettingsForm form, double scale = 1) { _form = form; + Scale = scale; NativeTokens.ApplyTextRendering(this); AppIcon.Apply(this); _tokens = TokensFor(form); Title = "Settings"; - Width = DefaultWidth; - Height = DefaultHeight; - MinWidth = 640; - MinHeight = 440; + Width = DefaultWidth * scale; + Height = DefaultHeight * scale; + MinWidth = 640 * Math.Min(scale, 1.5); + MinHeight = 440 * Math.Min(scale, 1.5); WindowStartupLocation = WindowStartupLocation.CenterOwner; // The palette's frame, not the platform's: the window draws its own title, close button and edge, so it reads @@ -274,7 +280,7 @@ public SettingsWindow(SettingsForm form) // The frame is ours, so the resize border is too: a band along each edge, as the borderless main window has. CanResize = true; - _layers = WindowChrome.WithResizeBands(this, _frame); + _layers = WindowChrome.WithResizeBands(this, NativeScale.Wrap(_frame, scale)); Content = _layers; _clock = new DispatcherTimer(TimeSpan.FromSeconds(1), DispatcherPriority.Background, (_, _) => Tick()); diff --git a/src/SharpMUTerm.Avalonia/TerminalView.cs b/src/SharpMUTerm.Avalonia/TerminalView.cs index 8cc9cb86..9420aadd 100644 --- a/src/SharpMUTerm.Avalonia/TerminalView.cs +++ b/src/SharpMUTerm.Avalonia/TerminalView.cs @@ -35,31 +35,90 @@ internal sealed class TerminalView : Control private readonly float _lineHeight; private CellRenderer _renderer; private double _scale = 1; - private int _zoomPercent = 100; - // Render operations can still hold the old fonts. Reuse each zoom level instead of leaking a new - // font set on every repeated +/- or reset, and never dispose fonts underneath the render thread. + // How much larger than the launch size the cells are drawn: the Text size page's output size against the launch's, + // times the zoom (MainWindow.ApplyTextSizes). In thousandths, so a renderer can be found again for a size. + private int _cellZoom = 1000; + // Render operations can still hold the old fonts. Reuse each size instead of leaking a new font set on every + // repeated +/- or reset, and never dispose fonts underneath the render thread. private readonly Dictionary<(double Scale, int Zoom), CellRenderer> _zoomRenderers = []; - internal int ZoomPercent => _zoomPercent; + /// + /// How much larger than designed the window's own words over the cells are drawn (the command line's hint, tooltips): + /// the Text size page's interface size and the zoom. + /// + internal double InterfaceScale + { + get => _interfaceScale; + set + { + if (Math.Abs(value - _interfaceScale) > 0.0005) + { + _interfaceScale = value; + _tipText.FontSize = NativeTokens.Body * value; + InvalidateVisual(); + } + } + } + + private double _interfaceScale = 1; + + /// The cells' size against the launch size, as a percentage. + internal int ZoomPercent => (int)Math.Round(_cellZoom / 10.0); + + /// Draws the cells at of the launch size. + internal void SetZoom(int percent) => SetCellZoom(percent / 100.0); - internal void SetZoom(int percent) + /// + /// Draws the cells times the launch size, re-measuring the grid (so every pane's NAWS is + /// told its new size) without resizing the window. + /// + internal void SetCellZoom(double factor) { - percent = Math.Clamp(percent, 50, 200); - if (percent == _zoomPercent) + var thousandths = (int)Math.Round(Math.Clamp(factor, 0.25, 8) * 1000); + if (thousandths == _cellZoom) { return; } - _zoomPercent = percent; + _cellZoom = thousandths; Rescale(_scale); } - internal IReadOnlyList ZoomItems => - [ - new("Zoom in", "Ctrl++", () => SetZoom(_zoomPercent + 10), _zoomPercent < 200), - new("Zoom out", "Ctrl+-", () => SetZoom(_zoomPercent - 10), _zoomPercent > 50), - new($"Reset zoom ({_zoomPercent}%)", "Ctrl+0", () => SetZoom(100), _zoomPercent != 100), - ]; + /// + /// Raised when the player zooms with the keys, Ctrl+wheel or the right-click menu: in for a positive step, out for a + /// negative one, back to 100% for zero. The window passes it to the client, which saves it and says the new sizes; + /// with nothing listening (a test of the view alone) the cells step here. + /// + internal event Action? ZoomRequested; + + /// The window's zoom as the right-click menu names it; the cells' own size until the window says. + internal Func? ZoomShown { get; set; } + + /// Asks for a zoom step (see ). + internal void RequestZoom(int step) + { + if (ZoomRequested is { } listener) + { + listener(step); + return; + } + + SetZoom(SharpMUTerm.Core.Configuration.ZoomLevels.Stepped(ZoomPercent, step)); + } + + internal IReadOnlyList ZoomItems + { + get + { + var zoom = ZoomShown?.Invoke() ?? ZoomPercent; + return + [ + new($"Zoom in ({SharpMUTerm.Core.Configuration.ZoomLevels.Label(SharpMUTerm.Core.Configuration.ZoomLevels.Next(zoom))})", "Ctrl+=", () => RequestZoom(1), zoom < SharpMUTerm.Core.Configuration.ZoomLevels.Maximum), + new($"Zoom out ({SharpMUTerm.Core.Configuration.ZoomLevels.Label(SharpMUTerm.Core.Configuration.ZoomLevels.Previous(zoom))})", "Ctrl+-", () => RequestZoom(-1), zoom > SharpMUTerm.Core.Configuration.ZoomLevels.Minimum), + new($"Reset zoom ({SharpMUTerm.Core.Configuration.ZoomLevels.Label(zoom)})", "Ctrl+0", () => RequestZoom(0), zoom != 100), + ]; + } + } private int _invalidatePending; private char? _swallowText; private double _wheelRemainder; @@ -83,7 +142,7 @@ public TerminalView(AvaloniaConsoleDriver driver, SKColor foreground, SKColor ba _family = family; _lineHeight = lineHeight; _renderer = new CellRenderer(new CellFonts(fontPoints, family, lineHeight), foreground, background); - _zoomRenderers[(1, 100)] = _renderer; + _zoomRenderers[(1, 1000)] = _renderer; _driver.Pointer.CellHeightMeasured = CellSize.Height; _driver.CellMetrics.Current = new SharpMUTerm.Core.Telnet.TerminalCellSize(_renderer.Fonts.CellWidth, _renderer.Fonts.CellHeight); _driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, CellSize.Height); @@ -351,13 +410,13 @@ internal void PrepareScale(double scale) private void Rescale(double scale) { _scale = scale; - if (!_zoomRenderers.TryGetValue((scale, _zoomPercent), out var renderer)) + if (!_zoomRenderers.TryGetValue((scale, _cellZoom), out var renderer)) { renderer = new CellRenderer( - new CellFonts((float)(_fontPoints * scale * _zoomPercent / 100), _family, _lineHeight), + new CellFonts((float)(_fontPoints * scale * _cellZoom / 1000), _family, _lineHeight), _renderer.DefaultForeground, _renderer.DefaultBackground); - _zoomRenderers[(scale, _zoomPercent)] = renderer; + _zoomRenderers[(scale, _cellZoom)] = renderer; } _renderer = renderer; @@ -389,9 +448,26 @@ private void OnFrameReady() } } - /// Keep zoom available while the native command editor owns keyboard focus. + /// + /// Keeps the zoom keys and Ctrl+wheel working wherever the pointer or the keyboard is in : over + /// a native panel, the header or the sidebar, and while the native composer owns the keyboard. + /// internal void AttachZoomKeys(InputElement root) { + double remainder = 0; + root.AddHandler(PointerWheelChangedEvent, (_, e) => + { + if (!e.Handled && ZoomForWheel(ref remainder, e.KeyModifiers, e.Delta.Y) is { } steps) + { + for (var i = 0; i < Math.Abs(steps); i++) + { + RequestZoom(Math.Sign(steps)); + } + + e.Handled = true; + } + }, global::Avalonia.Interactivity.RoutingStrategies.Tunnel); + char? swallowed = null; root.AddHandler(KeyDownEvent, (_, e) => { @@ -415,15 +491,31 @@ internal void AttachZoomKeys(InputElement root) root.AddHandler(LostFocusEvent, (_, _) => swallowed = null); } + /// The zoom step a key asks for: 1 in, -1 out, 0 back to 100%; null for any other key. internal static int? ZoomFor(Key key, KeyModifiers modifiers) => (key, modifiers) switch { - (Key.OemPlus, KeyModifiers.Control or (KeyModifiers.Control | KeyModifiers.Shift)) => 10, - (Key.Add, KeyModifiers.Control) => 10, - (Key.OemMinus or Key.Subtract, KeyModifiers.Control) => -10, + (Key.OemPlus, KeyModifiers.Control or (KeyModifiers.Control | KeyModifiers.Shift)) => 1, + (Key.Add, KeyModifiers.Control) => 1, + (Key.OemMinus or Key.Subtract, KeyModifiers.Control) => -1, (Key.D0 or Key.NumPad0, KeyModifiers.Control) => 0, _ => null, }; + /// + /// The zoom steps a Ctrl+wheel movement asks for, one per notch (a browser's), a touchpad's fractions carried in + /// ; null when Ctrl alone is not held, so the wheel scrolls as it always did. + /// + internal static int? ZoomForWheel(ref double remainder, KeyModifiers modifiers, double delta) + { + if (modifiers != KeyModifiers.Control) + { + remainder = 0; + return null; + } + + return MouseMapper.WheelNotches(ref remainder, delta); + } + // ── Keys ──────────────────────────────────────────────────────────────────────────────────── protected override void OnKeyDown(KeyEventArgs e) @@ -441,7 +533,7 @@ internal void HandleKeyDown(KeyEventArgs e) _swallowText = null; if (ZoomFor(e.Key, e.KeyModifiers) is { } zoom) { - SetZoom(zoom == 0 ? 100 : _zoomPercent + zoom); + RequestZoom(zoom); _swallowText = e.Key switch { Key.D0 or Key.NumPad0 => '0', @@ -1466,7 +1558,7 @@ private void DrawFieldText(DrawingContext context, ScreenFrame frame, InputBand var hint = new FormattedText( NativeKeys.Words(band.Hint), System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, - new Typeface(NativeTokens.UiFont), NativeTokens.Small, Brush(SKColors.White)); + new Typeface(NativeTokens.UiFont), NativeTokens.Small * InterfaceScale, Brush(SKColors.White)); // Moved with the field's edge where the field runs on past the grid's last column (FieldRect). var right = ((end - band.Pad) * cell.Width) + (end >= frame.Width ? Math.Clamp(Bounds.Width - (frame.Width * cell.Width), 0, cell.Width) : 0); diff --git a/src/SharpMUTerm.Avalonia/VitalsGauges.cs b/src/SharpMUTerm.Avalonia/VitalsGauges.cs index 1c0f6354..977e6230 100644 --- a/src/SharpMUTerm.Avalonia/VitalsGauges.cs +++ b/src/SharpMUTerm.Avalonia/VitalsGauges.cs @@ -161,8 +161,12 @@ internal static double NeededHeight(int meters, bool target, double cellHeight) /// gaps between the blocks (), so the last bar still ends above the /// map. What the window answers for . /// - internal static int CardRows(int meters, bool target, double cellHeight) => - (int)Math.Ceiling((NeededHeight(meters, target, cellHeight) / cellHeight) - 0.001); + /// + /// The gauges are drawn at the Text size page's panel : laid out on cells of + /// cellHeight / scale and magnified, so their height on screen is that layout's times the scale. + /// + internal static int CardRows(int meters, bool target, double cellHeight, double scale = 1) => + (int)Math.Ceiling((NeededHeight(meters, target, cellHeight / scale) * scale / cellHeight) - 0.001); private bool StackedFor(double height, VitalsModel model) { diff --git a/src/SharpMUTerm.Avalonia/WindowHost.cs b/src/SharpMUTerm.Avalonia/WindowHost.cs index 0284dbd8..cb14c109 100644 --- a/src/SharpMUTerm.Avalonia/WindowHost.cs +++ b/src/SharpMUTerm.Avalonia/WindowHost.cs @@ -70,6 +70,7 @@ public static ClientHost Create(AvaloniaConsoleDriver driver, Action? cl { var host = Build(driver, clipboard, emulated ? HostChrome.None : chrome); driver.Pointer.CellsOnly = emulated; + driver.TextSize.SizesAreas = !emulated; return emulated ? host with { @@ -144,6 +145,9 @@ public static ClientHost Create(AvaloniaConsoleDriver driver, Action? cl // F7's background transparency is drawn here: every plane at the level, over a translucent desktop window. Translucency = driver.Translucency, + // The Text size page and the zoom: the window draws its cells and its native views at the sizes the client says. + TextSize = driver.TextSize, + // Set by the live launch alone (ClientLaunch.Run), so the demo and every test ask GitHub nothing. Updates = null, }; diff --git a/src/SharpMUTerm.Avalonia/WindowOptions.cs b/src/SharpMUTerm.Avalonia/WindowOptions.cs index a1f41c1e..fe40de83 100644 --- a/src/SharpMUTerm.Avalonia/WindowOptions.cs +++ b/src/SharpMUTerm.Avalonia/WindowOptions.cs @@ -27,7 +27,8 @@ internal sealed record WindowOptions( bool CellChrome = false, float LineHeight = WindowOptions.DefaultLineHeight, int? Transparency = null, - bool Emulated = false) + bool Emulated = false, + bool FontSizeGiven = false) { /// /// --emulated: the terminal in a window. The cells draw everything, as in a terminal: no native header, @@ -69,6 +70,7 @@ public static WindowOptions Parse(string[] args) var rest = new List(); string? font = null, view = null, theme = null, png = null; var points = DefaultPoints; + var fontSizeGiven = false; var lineHeight = DefaultLineHeight; var demo = false; int? transparency = null; @@ -86,6 +88,7 @@ public static WindowOptions Parse(string[] args) if (float.TryParse(Next(), NumberStyles.Float, CultureInfo.InvariantCulture, out var p)) { points = Math.Clamp(p, 6, 48); + fontSizeGiven = true; } break; @@ -123,7 +126,7 @@ public static WindowOptions Parse(string[] args) } } - return new WindowOptions([.. rest], font, points, demo, view, theme, png, columns, rows, titleBar, cellChrome, lineHeight, transparency, emulated); + return new WindowOptions([.. rest], font, points, demo, view, theme, png, columns, rows, titleBar, cellChrome, lineHeight, transparency, emulated, fontSizeGiven); } /// Applies --theme, the way the snapshot does: name and inline theme both; and --transparency. @@ -174,7 +177,7 @@ public static string Usage ("Window:", [ new("--font ", "An installed monospace font (default: the bundled JetBrains Mono)."), - new("--font-size ", "Font size (default 10)."), + new("--font-size ", "Output font size for this run, over the Text size page's (default 10)."), new("--line-height ", "Row height as a multiple of the font's (1 to 2; default 1.12)."), new("--size ", "The window's first size in cells, and the grid for --render-png (default 120x36)."), new("--title-bar", "Keep the system title bar. Without it, drag the blank part of the header", diff --git a/src/SharpMUTerm.Avalonia/WindowPointer.cs b/src/SharpMUTerm.Avalonia/WindowPointer.cs index 9f046a8a..555d2089 100644 --- a/src/SharpMUTerm.Avalonia/WindowPointer.cs +++ b/src/SharpMUTerm.Avalonia/WindowPointer.cs @@ -113,8 +113,14 @@ internal double CellHeightMeasured set => Volatile.Write(ref _cellHeight, value); } + /// + /// The Text size page's panel scale the gauges are drawn at, read when the client asks; 1 until the main window says + /// how to read it. It reads the sizes the client last said, so a change is seen as soon as it is said. + /// + internal Func PanelScale { get; set; } = () => 1; + int? IPointerPresenter.VitalsCardRows(int meters, bool target) => - CellsOnly ? null : CellHeightMeasured is > 0 and var cell ? VitalsGauges.CardRows(meters, target, cell) : null; + CellsOnly ? null : CellHeightMeasured is > 0 and var cell ? VitalsGauges.CardRows(meters, target, cell, PanelScale()) : null; public void Scrollbars(IReadOnlyList bars) { diff --git a/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs b/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs index b431f0e5..77facdb3 100644 --- a/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs +++ b/src/SharpMUTerm.Client/Commands/BuiltInCommands.cs @@ -104,6 +104,18 @@ public static class BuiltInCommands /// Windowed full screen, and back (⇧F11 in the window): a borderless window the size of the screen. public const string WindowedFullScreenCommand = "window:windowed-full-screen"; + /// Zooms the window in a step (Ctrl+= in the window). Offered only by a host that draws its own text. + public const string ZoomInCommand = "window:zoom-in"; + + /// Zooms the window out a step (Ctrl+- in the window). + public const string ZoomOutCommand = "window:zoom-out"; + + /// Puts the window's zoom back to 100% (Ctrl+0 in the window). + public const string ZoomResetCommand = "window:zoom-reset"; + + /// Opens the Text size & zoom page (the window only). + public const string TextSizeCommand = "window:text-size"; + /// Asks the release feed for a newer SharpMUTerm now, whatever F7's check for updates says. public const string CheckUpdatesCommand = "app:check-updates"; @@ -273,6 +285,18 @@ void Family(string id, string prefix, Func c.State.WindowMode is not null, titleFor: c => c.State.WindowMode == Core.Configuration.WindowMode.WindowedFullScreen ? "Leave windowed full screen" : "Windowed full screen"); + // Zoom, where the host draws its own text (TextZoom is null in a terminal, whose emulator zooms its own font). + Add(ZoomInCommand, CommandGroup.Layout, "Zoom in", "⌃=", "make everything in the window bigger", + when: c => c.State.TextZoom is { } zoom && zoom < Core.Configuration.ZoomLevels.Maximum, + titleFor: c => "Zoom in to " + Core.Configuration.ZoomLevels.Label(Core.Configuration.ZoomLevels.Next(c.State.TextZoom ?? 100))); + Add(ZoomOutCommand, CommandGroup.Layout, "Zoom out", "⌃-", "make everything in the window smaller", + when: c => c.State.TextZoom is { } zoom && zoom > Core.Configuration.ZoomLevels.Minimum, + titleFor: c => "Zoom out to " + Core.Configuration.ZoomLevels.Label(Core.Configuration.ZoomLevels.Previous(c.State.TextZoom ?? 100))); + Add(ZoomResetCommand, CommandGroup.Layout, "Reset zoom", "⌃0", "back to 100% · sizes by area are on the Text size page", + when: c => c.State.TextZoom is { } zoom && zoom != 100, + titleFor: c => "Reset zoom (" + Core.Configuration.ZoomLevels.Label(c.State.TextZoom ?? 100) + ")"); + Add(TextSizeCommand, CommandGroup.Settings, "Text size & zoom…", detail: "font sizes for the output, composer, panels and menus", + when: c => c.State.TextSizePage); Add("term:walk-stop", CommandGroup.Map, "Stop walking", detail: "ends the speedwalk", when: c => c.State.Walking); // Game sound, for this run. The levels and the off switch are F7's; this is the quick one. diff --git a/src/SharpMUTerm.Client/Help/HelpTopics.cs b/src/SharpMUTerm.Client/Help/HelpTopics.cs index 359830f1..4a4d8eec 100644 --- a/src/SharpMUTerm.Client/Help/HelpTopics.cs +++ b/src/SharpMUTerm.Client/Help/HelpTopics.cs @@ -43,6 +43,16 @@ public static IReadOnlyList For(string newline) => "", "Copy: drag across output, then ⌃C.", ]), + new("text-size", "Text size", "make the text bigger or smaller", [ + "In the window: ⌃= zooms in, ⌃- zooms out and ⌃0 goes back to 100%, as in a browser; so does ⌃ with", + "the wheel, and the output's right-click menu. The zoom covers the whole window and is kept between runs.", + "⌃P ▸ Text size & zoom sets the output's size in points, and the composer, the header and sidebar,", + "the panels, and menus and dialogs each on their own.", + "", + "In a terminal the text is the terminal's own, and nothing a program sends can change its size: use", + "the terminal's zoom (kitty ⌃⇧= and ⌃⇧-; WezTerm, Ghostty and Windows Terminal ⌃= and ⌃-).", + "The panes are re-measured and each game is told its pane's new size either way.", + ]), new("layouts", "Layouts", "panes, panels, and saving an arrangement", [ "⌥M lists the layouts: Roleplay (who is here, a composer), Combat (vitals, a map, a quick bar)", "and your own. ⏎ applies one; n saves the panes as they are now as a new layout.", diff --git a/src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs b/src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs new file mode 100644 index 00000000..721620c2 --- /dev/null +++ b/src/SharpMUTerm.Client/Settings/BuiltInSettings.TextSize.cs @@ -0,0 +1,85 @@ +using SharpMUTerm.Client.Contributions; +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Client.Settings; + +public static partial class BuiltInSettings +{ + /// The Text size page's first group: the zoom over everything, and the output's own size. + public const string ZoomGroup = "Zoom"; + + /// The Text size page's second group: each area's size against its designed one. + public const string AreasGroup = "Text size by area"; + + /// The id of the Text size page, which ⌃P's zoom entries' detail and the help topic name. + public const string TextSizeSectionId = "text-size"; + + /// The zoom levels as the page lists them: 50% … 300%. + public static IReadOnlyList ZoomChoices { get; } = [.. ZoomLevels.Steps.Select(ZoomLevels.Label)]; + + /// An area's sizes as the page lists them: 75% … 200%. + public static IReadOnlyList AreaChoices { get; } = [.. ZoomLevels.AreaSteps.Select(ZoomLevels.Label)]; + + /// + /// The desktop window's text sizes: a zoom over the whole window (Ctrl+= / Ctrl+- / Ctrl+0, Ctrl+wheel), the + /// output's size in points, and the composer, interface, panels and dialogs each as a percentage. Not in + /// and on no F-key, like : a host registers it only when it draws its own text + /// (the window), so a terminal, whose font size is its emulator's, never lists a choice that does nothing there. + /// + public static SettingsSectionContribution TextSize { get; } = new() + { + Id = TextSizeSectionId, + Title = "Text size & zoom", + Order = 65, + Fields = + [ + new ChoiceField("zoom", "zoom", ZoomChoices, + "everything in the window, as a browser zooms; Ctrl+= and Ctrl+- step it, Ctrl+0 resets, Ctrl+wheel too") + { + Group = ZoomGroup, + Keywords = "zoom scale magnify bigger smaller font size text size accessibility dpi", + Binding = Percent(c => c.Display.Zoom, (c, v) => c.Display.Zoom = ZoomLevels.Clamp(v)), + }, + Number("output-points", "output text (pt)", DisplaySettings.MinOutputPoints, DisplaySettings.MaxOutputPoints, + ZoomGroup, + c => c.Display.OutputPoints, (c, v) => c.Display.OutputPoints = v, + "the game's text and the command line, before the zoom; the panes are re-measured and the game told their new size", + "font size points output console monospace game text naws"), + + Area("composer-text", "composer text", c => c.Display.ComposerText, (c, v) => c.Display.ComposerText = v, + "what you write in F1 and the docked composer, against the output's size", "compose editor post font"), + Area("interface-text", "interface", c => c.Display.InterfaceText, (c, v) => c.Display.InterfaceText = v, + "the header, sidebar, tabs, status row and notices", "chrome sidebar header tabs footer status toast font"), + Area("panel-text", "panels", c => c.Display.PanelText, (c, v) => c.Display.PanelText = v, + "vitals, who is here, the quick bar and the map", "vitals map who here quick bar gauges font"), + Area("dialog-text", "menus & dialogs", c => c.Display.DialogText, (c, v) => c.Display.DialogText = v, + "the command palette, dialogs, right-click menus and this settings window", "palette modal menu dialog settings font"), + ], + }; + + private static ChoiceField Area( + string id, + string label, + Func get, + Action set, + string help, + string keywords) => + new(id, label, AreaChoices, help) + { + Group = AreasGroup, + Keywords = "font size text size bigger smaller " + keywords, + Binding = Percent(get, (c, v) => set(c, ZoomLevels.ClampArea(v))), + }; + + /// A percentage shown and chosen as 125%; a word that names none leaves the value as it was. + private static SettingBinding Percent(Func get, Action set) => + new( + c => ZoomLevels.Label(get(c)), + (c, v) => + { + if (ZoomLevels.Parse(v) is { } percent) + { + set(c, percent); + } + }); +} diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 03e8ee33..4380c39b 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -81,6 +81,11 @@ public sealed record CharacterRef( /// in a host with no window to size (the terminal). /// /// Where this copy's update stands; null in a host that cannot update itself. +/// Whether the host has the Text size & zoom page (the window, not --emulated). +/// +/// The desktop window's zoom, a percentage, so ⌃P offers zooming in, out and back; null in a host that draws no text of +/// its own (the terminal, whose font size is its emulator's). +/// public sealed record CommandContext( bool LoggingOn = false, bool Zoomed = false, @@ -110,7 +115,9 @@ public sealed record CommandContext( int RecentAlerts = 0, IReadOnlyList? Alerts = null, Configuration.WindowMode? WindowMode = null, - UpdateState? Update = null); + UpdateState? Update = null, + int? TextZoom = null, + bool TextSizePage = false); /// /// Where this copy's update stands, for ⌃P's update entries: null when the host cannot update itself at all (a build diff --git a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs index f20f0ffd..8badbf8d 100644 --- a/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs +++ b/src/SharpMUTerm.Core/Configuration/AppConfiguration.cs @@ -142,6 +142,12 @@ public sealed class AppConfiguration /// public WindowMode WindowStart { get; set; } + /// + /// The desktop window's text sizes and zoom (the Text size settings page, Ctrl+= / Ctrl+- / Ctrl+0). Read by the + /// window alone; a terminal's font size is its emulator's. + /// + public DisplaySettings Display { get; set; } = new(); + /// The package index Browse packages reads: an https link to an index.json. public string PackageIndexUrl { get; set; } = DefaultPackageIndexUrl; diff --git a/src/SharpMUTerm.Core/Configuration/DisplaySettings.cs b/src/SharpMUTerm.Core/Configuration/DisplaySettings.cs new file mode 100644 index 00000000..3502033d --- /dev/null +++ b/src/SharpMUTerm.Core/Configuration/DisplaySettings.cs @@ -0,0 +1,130 @@ +namespace SharpMUTerm.Core.Configuration; + +/// +/// How big the desktop window draws its text, area by area, and the zoom over all of them. Read by the window alone: a +/// terminal draws its cells in the font size its emulator was told, and nothing the client writes can change that, so +/// the terminal lists none of these (its help says to use the terminal's own zoom). +/// +/// The output is in points, because a monospace console's size is a font size (Mudlet, Windows Terminal and kitty all +/// say it that way). Every other area is a percentage of its designed size, because each is a ramp of sizes (a heading, +/// a label, a caption) that has to move together. multiplies all of them, the way a browser's does, +/// and is what Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel change. +/// +/// +public sealed class DisplaySettings +{ + /// The zoom over the whole window, a percentage on . + public int Zoom { get; set; } = 100; + + /// The game's text, the command line and every cell panel, in points, before . + public int OutputPoints { get; set; } = DefaultOutputPoints; + + /// The composer's text (F1 and the docked panel), as a percentage of the output's. + public int ComposerText { get; set; } = 100; + + /// The header, sidebar, tab strips, footer, scrolled-back band and notices. + public int InterfaceText { get; set; } = 100; + + /// The panels a layout places: vitals, who is here, the quick bar, the map, the docked composer's frame. + public int PanelText { get; set; } = 100; + + /// The command palette, the dialogs, the right-click menus and the settings window. + public int DialogText { get; set; } = 100; + + /// The output's size with nothing chosen: 10 pt, the mockups' 13 px game text. + public const int DefaultOutputPoints = 10; + + /// The smallest output size a player can choose. + public const int MinOutputPoints = 6; + + /// The largest output size a player can choose. + public const int MaxOutputPoints = 48; + + /// A copy held to every range, so a hand-edited file cannot ask for a 2,000% sidebar. + public DisplaySettings Clamped() => new() + { + Zoom = ZoomLevels.Clamp(Zoom), + OutputPoints = Math.Clamp(OutputPoints, MinOutputPoints, MaxOutputPoints), + ComposerText = ZoomLevels.ClampArea(ComposerText), + InterfaceText = ZoomLevels.ClampArea(InterfaceText), + PanelText = ZoomLevels.ClampArea(PanelText), + DialogText = ZoomLevels.ClampArea(DialogText), + }; +} + +/// +/// The zoom levels the window steps through, and the percentages an area's text size is chosen from. The steps are the +/// browsers' (Chrome and Firefox step 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300): small near 100%, +/// where a reader is fine-tuning, and large past 200%, where they need it big. +/// +public static class ZoomLevels +{ + /// Every zoom level, smallest first. + public static IReadOnlyList Steps { get; } = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300]; + + /// The percentages an area's text can be set to: the zoom steps an area is useful at. + public static IReadOnlyList AreaSteps { get; } = [75, 80, 90, 100, 110, 125, 150, 175, 200]; + + /// The smallest zoom. + public static int Minimum => Steps[0]; + + /// The largest zoom. + public static int Maximum => Steps[^1]; + + /// The level after , or the largest; a percentage between steps goes to the next one up. + public static int Next(int percent) + { + foreach (var step in Steps) + { + if (step > percent) + { + return step; + } + } + + return Maximum; + } + + /// The level before , or the smallest. + public static int Previous(int percent) + { + for (var i = Steps.Count - 1; i >= 0; i--) + { + if (Steps[i] < percent) + { + return Steps[i]; + } + } + + return Minimum; + } + + /// + /// The level moves to from : a positive step zooms in, a negative + /// one out, zero resets to 100%. + /// + public static int Stepped(int percent, int step) => step switch + { + > 0 => Next(percent), + < 0 => Previous(percent), + _ => 100, + }; + + /// held to the zoom's range (a value between steps is kept, as a file may say one). + public static int Clamp(int percent) => Math.Clamp(percent, Minimum, Maximum); + + /// held to an area's range. + public static int ClampArea(int percent) => Math.Clamp(percent, AreaSteps[0], AreaSteps[^1]); + + /// How a percentage is written on every surface: 125%. + public static string Label(int percent) => percent.ToString(System.Globalization.CultureInfo.InvariantCulture) + "%"; + + /// The percentage names (125% or 125), or null. + public static int? Parse(string? words) + { + var text = words?.Trim().TrimEnd('%').Trim(); + return int.TryParse(text, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var value) + ? value + : null; + } +} diff --git a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs index 13fe73f3..f8c92c99 100644 --- a/src/SharpMUTerm.Tui/Hosting/ClientHost.cs +++ b/src/SharpMUTerm.Tui/Hosting/ClientHost.cs @@ -185,6 +185,14 @@ internal sealed record ClientHost /// public required IWindowTranslucency? Translucency { get; init; } + /// + /// Draws its own text and so can size it (): the window, which is told the Text size + /// page's sizes and the zoom, and asks the client to step the zoom on Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel. Null in + /// the terminal (its font size is its emulator's), the snapshot, the tests and --plain, where the client lists + /// none of it. + /// + public required IWindowTextSize? TextSize { get; init; } + /// /// Finding, downloading and applying a newer release (): only an installed copy of /// the terminal or the window has one. Null for a build run from a folder, the snapshot, the tests, --plain @@ -242,6 +250,7 @@ public static ClientHost Terminal(TerminalCapabilities capabilities) FilePicker = null, WindowModes = null, Translucency = null, + TextSize = null, Updates = VelopackUpdates.Detect(window: false), }; @@ -278,6 +287,7 @@ public static ClientHost Terminal(TerminalCapabilities capabilities) FilePicker = null, WindowModes = null, Translucency = null, + TextSize = null, Updates = null, }; diff --git a/src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs b/src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs new file mode 100644 index 00000000..27fc92b4 --- /dev/null +++ b/src/SharpMUTerm.Tui/Hosting/IWindowTextSize.cs @@ -0,0 +1,30 @@ +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Tui; + +/// +/// A host that draws its own text and so can size it: the desktop window. The client owns the sizes (they are the +/// configuration's , saved like any setting) and tells the host each time they +/// change; the host owns the keys and the wheel that zoom, and asks the client to step, so a zoom is saved and offered on +/// ⌃P exactly as a change on the settings page is. +/// +internal interface IWindowTextSize +{ + /// + /// Says the sizes to draw at, already held to their ranges. Called on the client's thread, once a frame has been + /// painted, and only when they changed. + /// + void Show(DisplaySettings sizes); + + /// + /// Whether the host draws native views the page's areas (composer, interface, panels, dialogs) size. False where + /// everything is cells (the window's --emulated), where only the zoom and the output's size apply. + /// + bool SizesAreas { get; } + + /// + /// Raised on the host's thread when the player zooms with the host's own keys or wheel: a positive step zooms in, a + /// negative one out, zero resets to 100%. + /// + event Action? ZoomRequested; +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs new file mode 100644 index 00000000..820e1cd1 --- /dev/null +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.TextSize.cs @@ -0,0 +1,122 @@ +using SharpMUTerm.Client.Settings; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Diagnostics; + +namespace SharpMUTerm.Tui; + +/// +/// Text size and zoom, where the host draws its own text (: the desktop window). The +/// sizes are the configuration's : the Text size page edits them through the +/// settings funnel like any setting, the window's Ctrl+= / Ctrl+- / Ctrl+0 and Ctrl+wheel and ⌃P's zoom entries step the +/// zoom here, and the host is told the result. A terminal has none of it: its font size is its emulator's, and nothing an +/// application writes changes it (xterm's OSC 50 picks a font, not a size, and is off in every terminal this client +/// targets), so the client lists nothing that would do nothing and the help topic says to use the terminal's own zoom. +/// +internal sealed partial class SharpMUTermApp +{ + /// Whether the host draws its own text and so can size it. + internal bool CanSizeText => Host.TextSize is not null; + + /// The sizes last said to the host; null before the first, so the host is always told once. + private DisplaySettings? _textSizesShown; + + /// The sizes in force, held to their ranges. + internal DisplaySettings TextSizes => _config.Display.Clamped(); + + /// + /// Whether the Text size & zoom page is offered: the host sizes its own text and draws the page natively. The + /// window's --emulated mode draws everything in cells and has no native settings window, so it keeps the zoom + /// (keys, wheel, right-click menu, ⌃P) and takes the output's size from --font-size, with no page. + /// + internal bool HasTextSizePage => Host.TextSize is { SizesAreas: true } && Host.NativeSettings is not null; + + /// Registers the Text size page and listens for the host's zoom keys, where the host can size text. + private void RegisterTextSize() + { + if (Host.TextSize is not { } host) + { + return; + } + + if (HasTextSizePage) + { + _contributions.SettingsSections.Add(BuiltInSettings.TextSize); + } + + host.ZoomRequested += step => OnUiThread(() => StepZoom(step)); + } + + /// Opens the Text size & zoom page in the host's settings window (⌃P's entry). + private bool OpenTextSizePage() + { + if (!HasTextSizePage) + { + Notice("text size is your terminal's own here: use its zoom (often Ctrl+= and Ctrl+-)", toast: false); + return false; + } + + return TryOpenNativeSettings(Section(BuiltInSettings.TextSizeSectionId)); + } + + /// + /// Steps the zoom (in for a positive , out for a negative one, back to 100% for zero), saves it + /// and tells the host. Saved through , the view-preference path, because the settings + /// funnel re-periodises every timer and a zoom is pressed in runs. Refused out loud where the host draws no text. + /// + internal bool StepZoom(int step) + { + if (Host.TextSize is null) + { + Notice("text size is your terminal's own here: use its zoom (often Ctrl+= and Ctrl+-)", toast: false); + return false; + } + + var before = ZoomLevels.Clamp(_config.Display.Zoom); + var after = ZoomLevels.Stepped(before, step); + if (after == before && _config.Display.Zoom == before) + { + Notice(step > 0 ? $"zoom is at its largest, {ZoomLevels.Label(after)}" + : step < 0 ? $"zoom is at its smallest, {ZoomLevels.Label(after)}" + : "zoom is already 100%", MessageSeverity.Info, toast: false); + SyncTextSizes(); + return true; + } + + _config.Display.Zoom = after; + PersistConfiguration(); + SyncTextSizes(); + Notice($"zoom {ZoomLevels.Label(after)}", MessageSeverity.Info, key: step == 0 ? "⌃0" : step > 0 ? "⌃=" : "⌃-", toast: false); + return true; + } + + /// + /// Tells the host the sizes when they differ from what it was last told: after every settings commit, every zoom step + /// and every frame (a frame is cheap to check and covers a change made any other way, such as an undo). + /// + private void SyncTextSizes() + { + if (Host.TextSize is not { } host) + { + return; + } + + var sizes = TextSizes; + if (_textSizesShown is { } shown && Same(shown, sizes)) + { + return; + } + + _textSizesShown = sizes; + host.Show(sizes); + + // A vitals card is as tall as its gauges, which are drawn at the panel size: a change there can need other rows. + foreach (var windowId in _vitalsCardRows.Keys.ToArray()) + { + ResizeVitalsCard(windowId); + } + } + + private static bool Same(DisplaySettings a, DisplaySettings b) => + a.Zoom == b.Zoom && a.OutputPoints == b.OutputPoints && a.ComposerText == b.ComposerText + && a.InterfaceText == b.InterfaceText && a.PanelText == b.PanelText && a.DialogText == b.DialogText; +} diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index 03bef7c3..e54743f5 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -979,6 +979,7 @@ public SharpMUTermApp( RegisterBuiltInContributions(); RegisterBuiltInSettings(); + RegisterTextSize(); _palette = new CommandPalette( _system, BuildCatalog, @@ -1089,6 +1090,7 @@ public SharpMUTermApp( ReportPaneSizes(); SyncTabStripWidths(); SyncTransparentColours(); + SyncTextSizes(); MeasureHeadingRuns(buffer); PaintHeaderDividers(buffer); PaintTabOverflow(buffer); @@ -8016,6 +8018,9 @@ internal void SaveConfiguration() // repainted below is painted in it. See SharpMUTermApp.Theme.cs. ApplyThemeIfChanged(); + // The Text size page: the window redraws at the new sizes now, not at the next frame the cells happen to paint. + SyncTextSizes(); + // F8 edits the live InputSettings, so a committed height applies to the bars immediately rather // than at the next launch. SyncInputBars(); @@ -8995,6 +9000,8 @@ private CommandContext CommandState() RecentAlerts: _recentAlerts.Count, Alerts: _alertsListed ? AlertRefs() : null, WindowMode: CanSetWindowMode ? Host.WindowModes!.Mode : null, + TextZoom: CanSizeText ? TextSizes.Zoom : null, + TextSizePage: HasTextSizePage, Update: UpdateCommandState(), ScriptFiles: !_scriptsListed || (_scriptsRoot is null && !DemoScriptsShown) ? null : ScriptFileRefs()); } @@ -9486,6 +9493,14 @@ private bool RunBuiltInCommand(string id) return true; case SharpMUTerm.Client.Commands.BuiltInCommands.FullScreenCommand: return ToggleWindowMode(WindowMode.FullScreen); + case SharpMUTerm.Client.Commands.BuiltInCommands.ZoomInCommand: + return StepZoom(1); + case SharpMUTerm.Client.Commands.BuiltInCommands.ZoomOutCommand: + return StepZoom(-1); + case SharpMUTerm.Client.Commands.BuiltInCommands.ZoomResetCommand: + return StepZoom(0); + case SharpMUTerm.Client.Commands.BuiltInCommands.TextSizeCommand: + return OpenTextSizePage(); case SharpMUTerm.Client.Commands.BuiltInCommands.WindowedFullScreenCommand: return ToggleWindowMode(WindowMode.WindowedFullScreen); case "term:mute": diff --git a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs index 98421342..d6532147 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/HostParityTests.cs @@ -68,7 +68,7 @@ public async Task EveryViewDrawsTheSameCellsInTheWindowAsInTheTerminal(string na using var driver = new AvaloniaConsoleDriver(Width, Height); await using (var window = new SharpMUTermApp( DemoScene.Build(), - WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null, RunsInline = true })) + WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null, TextSize = null, RunsInline = true })) { window.RenderSnapshot(view); } @@ -107,7 +107,7 @@ public async Task EveryViewDrawsTheSameCellsWhenTheWindowsLoopIsRunning(string n using var driver = new AvaloniaConsoleDriver(Width, Height); await using var window = new SharpMUTermApp( DemoScene.Build(), - WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null }); + WindowHost.Create(driver, clipboard: null, chrome) with { Capabilities = Caps, Keys = HostKeys.Terminal, Audio = null, Presence = null, Announcer = null, NativeSettings = null, NativePalette = null, FloatingPanels = null, WindowModes = null, TextSize = null }); window.OnUiThread(() => _ = window.RenderSnapshot(view)); var loop = new Thread(() => window.Run([])) { IsBackground = true }; loop.Start(); diff --git a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs index 57db70e8..147f35ee 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs @@ -17,7 +17,7 @@ public async Task ReservedZoomChordsMatchTheirActions() var key = Enum.Parse(reserved.Chord.Key.ToString()); var modifiers = KeyModifiers.Control; if (reserved.Chord.Modifiers.HasFlag(ConsoleModifiers.Shift)) modifiers |= KeyModifiers.Shift; - var expected = reserved.Action switch { "zooms in" => 10, "zooms out" => -10, "resets terminal zoom" => 0, _ => int.MinValue }; + var expected = reserved.Action switch { "zooms in" => 1, "zooms out" => -1, "resets terminal zoom" => 0, _ => int.MinValue }; await Assert.That(TerminalView.ZoomFor(key, modifiers)).IsEqualTo(expected); } } @@ -139,28 +139,73 @@ public async Task ZoomWorksWhileANativeEditorOwnsFocus() } [Test] - public async Task ZoomIsBoundedAndMenuActionsResetToLaunchSize() + public async Task ZoomStepsThroughTheLevelsIsBoundedAndTheMenuResets() { var result = await TerminalViewTests.Session.Dispatch(() => { using var driver = new AvaloniaConsoleDriver(80, 24); var view = new TerminalView(driver, SKColors.White, SKColors.Black, 20, null); - view.SetZoom(999); + var seen = new List(); + for (var i = 0; i < 20; i++) + { + view.RequestZoom(1); + seen.Add(view.ZoomPercent); + } + var max = view.ZoomPercent; var disabledIn = !view.ZoomItems[0].Enabled; - view.SetZoom(-1); + for (var i = 0; i < 20; i++) + { + view.RequestZoom(-1); + } + var min = view.ZoomPercent; var disabledOut = !view.ZoomItems[1].Enabled; view.ZoomItems[2].Run(); - return (max, min, disabledIn, disabledOut, reset: view.ZoomPercent, text: view.CellTextSize); + return (seen, max, min, disabledIn, disabledOut, reset: view.ZoomPercent, text: view.CellTextSize); }, CancellationToken.None); - await Assert.That(result.max).IsEqualTo(200); + await Assert.That(result.seen.Take(4)).IsEquivalentTo(new[] { 110, 125, 150, 175 }); + await Assert.That(result.max).IsEqualTo(300); await Assert.That(result.min).IsEqualTo(50); await Assert.That(result.disabledIn && result.disabledOut).IsTrue(); await Assert.That(result.reset).IsEqualTo(100); await Assert.That(result.text).IsEqualTo(20); } + [Test] + public async Task WithAListenerTheViewAsksAndDoesNotZoomItself() + { + var result = await TerminalViewTests.Session.Dispatch(() => + { + using var driver = new AvaloniaConsoleDriver(80, 24); + var view = new TerminalView(driver, SKColors.White, SKColors.Black, 16, null); + var asked = new List(); + view.ZoomRequested += asked.Add; + view.ZoomShown = () => 125; + view.HandleKeyDown(new KeyEventArgs { Key = Key.OemMinus, KeyModifiers = KeyModifiers.Control }); + return (asked, view.ZoomPercent, menu: view.ZoomItems.Select(i => i.Label).ToArray()); + }, CancellationToken.None); + await Assert.That(result.asked).IsEquivalentTo(new[] { -1 }); + await Assert.That(result.ZoomPercent).IsEqualTo(100); + await Assert.That(result.menu[0]).IsEqualTo("Zoom in (150%)"); + await Assert.That(result.menu[2]).IsEqualTo("Reset zoom (125%)"); + } + + [Test] + public async Task CtrlAndTheWheelZoomANotchAtATime() + { + var remainder = 0.0; + await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, 1)).IsEqualTo(1); + await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, -1)).IsEqualTo(-1); + await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.None, 1)).IsNull(); + await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control | KeyModifiers.Shift, 1)).IsNull(); + + // A touchpad's small deltas add up to a notch before they zoom. + remainder = 0; + await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, 0.4)).IsEqualTo(0); + await Assert.That(TerminalView.ZoomForWheel(ref remainder, KeyModifiers.Control, 0.7)).IsEqualTo(1); + } + [Test] public async Task ZoomKeysAreLocalAndOrdinaryTypingStillReachesTheClient() { @@ -184,7 +229,7 @@ public async Task ZoomKeysAreLocalAndOrdinaryTypingStillReachesTheClient() await Assert.That(result.key).IsEqualTo(ConsoleKey.A); await Assert.That(TerminalView.ZoomFor(Key.OemPlus, KeyModifiers.None)).IsNull(); await Assert.That(TerminalView.ZoomFor(Key.D0, KeyModifiers.Control | KeyModifiers.Alt)).IsNull(); - await Assert.That(TerminalView.ZoomFor(Key.Subtract, KeyModifiers.Control)).IsEqualTo(-10); + await Assert.That(TerminalView.ZoomFor(Key.Subtract, KeyModifiers.Control)).IsEqualTo(-1); await Assert.That(TerminalView.ZoomFor(Key.NumPad0, KeyModifiers.Control)).IsEqualTo(0); } } diff --git a/tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs new file mode 100644 index 00000000..0bda70b5 --- /dev/null +++ b/tests/SharpMUTerm.Avalonia.Tests/TextScaleTests.cs @@ -0,0 +1,139 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Tui; +using SkiaSharp; +using Window = Avalonia.Controls.Window; + +namespace SharpMUTerm.Avalonia.Tests; + +/// +/// The Text size page's areas in the window: each native view is drawn at its area's scale and still covers exactly the +/// rectangle it covered at 100%, the chrome beside the cells grows on the 4 px grid, and the cells move out of its way. +/// +[NotInParallel] +public class TextScaleTests +{ + [Test] + public async Task AreasMultiplyTheZoomAndTheComposerFollowsTheCellsOwnZoom() + { + var scales = NativeScales.From(new DisplaySettings { Zoom = 125, InterfaceText = 110, PanelText = 150, DialogText = 90, ComposerText = 120 }); + + await Assert.That(scales.Interface).IsEqualTo(1.25 * 1.1).Within(1e-9); + await Assert.That(scales.Panels).IsEqualTo(1.25 * 1.5).Within(1e-9); + await Assert.That(scales.Dialogs).IsEqualTo(1.25 * 0.9).Within(1e-9); + await Assert.That(scales.Composer).IsEqualTo(1.2).Within(1e-9); + await Assert.That(NativeScales.From(new DisplaySettings())).IsEqualTo(NativeScales.One); + } + + [Test] + public async Task ChromeSizesStayOnTheFourPixelGridAndAreUnchangedAt100() + { + var at100 = NativeLayout.Insets(HostChrome.Desktop, collapsed: false); + var at150 = NativeLayout.Insets(HostChrome.Desktop, collapsed: false, 1.5); + + await Assert.That(at100.Top).IsEqualTo(NativeLayout.HeaderHeight); + await Assert.That(at150.Top).IsEqualTo(NativeLayout.Scaled(NativeLayout.HeaderHeight, 1.5)); + foreach (var size in new[] { at150.Left, at150.Top, at150.Bottom, NativeLayout.Scaled(NativeLayout.HeaderHeight, 1.1) }) + { + await Assert.That(size % 4).IsEqualTo(0); + } + + await Assert.That(at150.Top).IsGreaterThan(at100.Top); + await Assert.That(at150.Left).IsGreaterThan(at100.Left); + } + + [Test] + public async Task AScaledViewCoversItsAreaAndIsLaidOutSmaller() + { + var border = new Border(); + NativeScale.Cover(border, 300, 120, 1.5); + + await Assert.That(border.Width).IsEqualTo(200); + await Assert.That(border.Height).IsEqualTo(80); + await Assert.That(NativeScale.Of(border)).IsEqualTo(1.5); + await Assert.That(NativeScale.Extent(border)).IsEqualTo(new Size(300, 120)); + + NativeScale.Cover(border, 300, 120, 1); + await Assert.That(border.RenderTransform).IsNull(); + await Assert.That(border.Width).IsEqualTo(300); + } + + [Test] + public async Task TheLayerMovesTheCellsForAScaledHeaderAndSidebar_AndTheHeaderIsDrawnLarger() + { + var result = await TerminalViewTests.Session.Dispatch(() => + { + var driver = new AvaloniaConsoleDriver(140, 40); + var terminal = new TerminalView(driver, SKColors.White, new SKColor(0x1e, 0x1e, 0x24), 10f, null, 1.12f); + var root = new Panel(); + root.Children.Add(terminal); + var layer = new DesktopLayer(terminal, HostChrome.Desktop); + root.Children.Add(layer); + var window = new Window { Content = root, Width = 1400, Height = 900 }; + window.Show(); + var app = new SharpMUTermApp(DemoScene.Build(), WindowHost.Create(driver, clipboard: null) with + { + Capabilities = WindowOptions.Capabilities, + Audio = null, + Presence = null, + Announcer = null, + RunsInline = true, + }); + try + { + app.RenderSnapshot("layout-combat"); + app.RenderNextFrame(); + DesktopLayerTests.Drain(); + window.UpdateLayout(); + var before = (margin: terminal.Margin, header: layer.Header.Bounds.Size, scale: NativeScale.Of(layer.Header)); + var gaugesBefore = layer.Gauges.Values.Single(); + var gaugeArea = NativeScale.Extent(gaugesBefore); + + layer.Scales = new NativeScales(1.5, 1.5, 1, 1); + app.RenderNextFrame(); + DesktopLayerTests.Drain(); + window.UpdateLayout(); + var gauges = layer.Gauges.Values.Single(); + return (before, after: (margin: terminal.Margin, header: layer.Header.Bounds.Size, scale: NativeScale.Of(layer.Header)), + gaugeScale: NativeScale.Of(gauges), gaugeArea, gaugeAreaAfter: NativeScale.Extent(gauges), + headerOnScreen: layer.Header.Bounds.Height * NativeScale.Of(layer.Header)); + } + finally + { + app.DisposeAsync().AsTask().GetAwaiter().GetResult(); + window.Close(); + driver.Dispose(); + } + }, CancellationToken.None); + + await Assert.That(result.before.scale).IsEqualTo(1); + await Assert.That(result.before.margin.Top).IsEqualTo(NativeLayout.HeaderHeight); + await Assert.That(result.after.margin.Top).IsEqualTo(NativeLayout.Scaled(NativeLayout.HeaderHeight, 1.5)); + await Assert.That(result.after.margin.Left).IsGreaterThan(result.before.margin.Left); + await Assert.That(result.after.scale).IsEqualTo(1.5); + await Assert.That(result.headerOnScreen).IsEqualTo(result.after.margin.Top).Within(0.01); + await Assert.That(result.gaugeScale).IsEqualTo(1.5); + } + + [Test] + public async Task WithNoClientTheWindowStepsTheZoomItself_AndAClientIsAskedInstead() + { + var alone = new WindowTextSize(); + var said = new List(); + alone.Changed += s => said.Add(s.Zoom); + alone.RequestZoom(1); + + var asked = new List(); + var withClient = new WindowTextSize(); + withClient.Changed += s => said.Add(-s.Zoom); + ((IWindowTextSize)withClient).ZoomRequested += asked.Add; + withClient.RequestZoom(-1); + + await Assert.That(alone.Current.Zoom).IsEqualTo(110); + await Assert.That(said).IsEquivalentTo(new[] { 110 }); + await Assert.That(asked).IsEquivalentTo(new[] { -1 }); + await Assert.That(withClient.Current.Zoom).IsEqualTo(100); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs b/tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs new file mode 100644 index 00000000..d2b63c86 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Configuration/DisplaySettingsTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using SharpMUTerm.Core.Configuration; + +namespace SharpMUTerm.Core.Tests.Configuration; + +/// The zoom's levels and the text sizes the configuration keeps. +public class DisplaySettingsTests +{ + [Test] + public async Task TheZoomStepsThroughTheBrowsersLevelsAndStopsAtTheEnds() + { + await Assert.That(ZoomLevels.Next(100)).IsEqualTo(110); + await Assert.That(ZoomLevels.Previous(100)).IsEqualTo(90); + await Assert.That(ZoomLevels.Next(ZoomLevels.Maximum)).IsEqualTo(ZoomLevels.Maximum); + await Assert.That(ZoomLevels.Previous(ZoomLevels.Minimum)).IsEqualTo(ZoomLevels.Minimum); + await Assert.That(ZoomLevels.Stepped(175, 0)).IsEqualTo(100); + } + + [Test] + public async Task ALevelBetweenStepsStepsToTheNextOneAcross() + { + await Assert.That(ZoomLevels.Next(117)).IsEqualTo(125); + await Assert.That(ZoomLevels.Previous(117)).IsEqualTo(110); + } + + [Test] + public async Task AHandEditedFileIsHeldToTheRanges() + { + var held = new DisplaySettings { Zoom = 5000, OutputPoints = 1, ComposerText = 10, PanelText = 999 }.Clamped(); + + await Assert.That(held.Zoom).IsEqualTo(ZoomLevels.Maximum); + await Assert.That(held.OutputPoints).IsEqualTo(DisplaySettings.MinOutputPoints); + await Assert.That(held.ComposerText).IsEqualTo(ZoomLevels.AreaSteps[0]); + await Assert.That(held.PanelText).IsEqualTo(ZoomLevels.AreaSteps[^1]); + } + + [Test] + public async Task APercentReadsWithOrWithoutItsSign() + { + await Assert.That(ZoomLevels.Parse("125%")).IsEqualTo(125); + await Assert.That(ZoomLevels.Parse(" 90 ")).IsEqualTo(90); + await Assert.That(ZoomLevels.Parse("big")).IsNull(); + await Assert.That(ZoomLevels.Label(125)).IsEqualTo("125%"); + } + + [Test] + public async Task AFileFromBeforeTheSettingReadsAsEverythingAtItsDesignedSize() + { + var config = JsonSerializer.Deserialize("{}", ConfigurationStore.SerializerOptions)!; + + await Assert.That(config.Display.Zoom).IsEqualTo(100); + await Assert.That(config.Display.OutputPoints).IsEqualTo(DisplaySettings.DefaultOutputPoints); + await Assert.That(config.Display.DialogText).IsEqualTo(100); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs b/tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs new file mode 100644 index 00000000..cc804057 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/TextSizeTests.cs @@ -0,0 +1,172 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Client.Commands; +using SharpMUTerm.Client.Contributions; +using SharpMUTerm.Client.Help; +using SharpMUTerm.Client.Settings; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// Zoom and per-area text sizes are the client's settings, offered only where the host draws its own text: the window +/// lists the Text size page and ⌃P's zoom entries, steps and saves the zoom, and is told every change; a terminal lists +/// none of it and says to use the terminal's own zoom. +/// +[NotInParallel] +public class TextSizeTests +{ + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private sealed class FakeTextSize(bool areas = true) : IWindowTextSize + { + public List Shown { get; } = []; + + public bool SizesAreas => areas; + + public event Action? ZoomRequested; + + public void Show(DisplaySettings sizes) => Shown.Add(sizes); + + public void Press(int step) => ZoomRequested?.Invoke(step); + } + + private sealed class Settings : INativeSettings + { + public List Shown { get; } = []; + + public void Show(SettingsForm form) => Shown.Add(form); + + public void Close() + { + } + } + + private static SharpMUTermApp App(IWindowTextSize? sizes, INativeSettings? settings = null, AppConfiguration? config = null, List? saves = null) + { + Console.SetIn(TextReader.Null); + var host = ClientHost.ForHarness(Headless, new HeadlessConsoleDriver(120, 40), null, null, null) + with { TextSize = sizes, NativeSettings = settings }; + return new SharpMUTermApp(config ?? new AppConfiguration(), host, save: saves is null ? null : saves.Add); + } + + [Test] + public async Task ATerminalListsNoTextSizePageAndNoZoom_AndSaysToUseItsOwn() + { + var app = App(null); + var ids = app.BuildCatalog().Select(c => c.Id).ToArray(); + + await Assert.That(app.Contributions.SettingsSections.Find(BuiltInSettings.TextSizeSectionId)).IsNull(); + await Assert.That(ids).DoesNotContain(BuiltInCommands.ZoomInCommand); + await Assert.That(ids).DoesNotContain(BuiltInCommands.ZoomOutCommand); + await Assert.That(ids).DoesNotContain(BuiltInCommands.ZoomResetCommand); + await Assert.That(ids).DoesNotContain(BuiltInCommands.TextSizeCommand); + + await Assert.That(app.StepZoom(1)).IsFalse(); + await Assert.That(app.LastNoticeModel?.Text ?? "").Contains("terminal's own"); + await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(100); + } + + [Test] + public async Task TheWindowListsThePageAndZoomEntries_AndIsToldTheSizesOnce() + { + var sizes = new FakeTextSize(); + var app = App(sizes, new Settings()); + + await Assert.That(app.Contributions.SettingsSections.Find(BuiltInSettings.TextSizeSectionId)).IsNotNull(); + var catalog = app.BuildCatalog(); + await Assert.That(catalog.Single(c => c.Id == BuiltInCommands.ZoomInCommand).Title).IsEqualTo("Zoom in to 110%"); + await Assert.That(catalog.Single(c => c.Id == BuiltInCommands.ZoomOutCommand).Title).IsEqualTo("Zoom out to 90%"); + await Assert.That(catalog.Select(c => c.Id)).DoesNotContain(BuiltInCommands.ZoomResetCommand); + await Assert.That(catalog.Select(c => c.Id)).Contains(BuiltInCommands.TextSizeCommand); + + app.RenderWholeFrame(); + app.RenderWholeFrame(); + await Assert.That(sizes.Shown.Count).IsEqualTo(1); + await Assert.That(sizes.Shown[0].Zoom).IsEqualTo(100); + } + + [Test] + public async Task TheWindowsZoomKeysStepTheZoom_SaveIt_AndTellTheWindow() + { + var sizes = new FakeTextSize(); + var saves = new List(); + var app = App(sizes, new Settings(), saves: saves); + + sizes.Press(1); + sizes.Press(1); + + await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(125); + await Assert.That(saves.Count).IsGreaterThanOrEqualTo(2); + await Assert.That(sizes.Shown[^1].Zoom).IsEqualTo(125); + await Assert.That(app.LastNoticeModel?.Text ?? "").Contains("125%"); + + await Assert.That(app.BuildCatalog().Single(c => c.Id == BuiltInCommands.ZoomResetCommand).Title).IsEqualTo("Reset zoom (125%)"); + await Assert.That(app.DispatchCommand(BuiltInCommands.ZoomResetCommand)).IsTrue(); + await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(100); + await Assert.That(sizes.Shown[^1].Zoom).IsEqualTo(100); + } + + [Test] + public async Task ZoomStopsAtItsEnds() + { + var config = new AppConfiguration(); + config.Display.Zoom = ZoomLevels.Maximum; + var app = App(new FakeTextSize(), new Settings(), config); + + await Assert.That(app.BuildCatalog().Select(c => c.Id)).DoesNotContain(BuiltInCommands.ZoomInCommand); + await Assert.That(app.StepZoom(1)).IsTrue(); + await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(ZoomLevels.Maximum); + await Assert.That(app.LastNoticeModel?.Text ?? "").Contains("largest"); + } + + [Test] + public async Task AnEditOnThePageReachesTheWindowThroughTheSaveFunnel() + { + var sizes = new FakeTextSize(); + var app = App(sizes, new Settings()); + app.RenderWholeFrame(); + var field = (ChoiceField)BuiltInSettings.TextSize.Fields.Single(f => f.Id == "panel-text"); + + ((SettingBinding)field.Binding!).Set(app.Configuration, "150%"); + app.SaveConfiguration(); + + await Assert.That(sizes.Shown[^1].PanelText).IsEqualTo(150); + } + + [Test] + public async Task ThePalettesEntryOpensThePageInTheWindowsSettings() + { + var settings = new Settings(); + var app = App(new FakeTextSize(), settings); + + await Assert.That(app.DispatchCommand(BuiltInCommands.TextSizeCommand)).IsTrue(); + await Assert.That(settings.Shown.Count).IsEqualTo(1); + await Assert.That(settings.Shown[0].Initial).IsEqualTo(BuiltInSettings.TextSizeSectionId); + } + + [Test] + public async Task EmulatedModeKeepsTheZoomButHasNoPage() + { + var sizes = new FakeTextSize(areas: false); + var app = App(sizes, null); + + await Assert.That(app.Contributions.SettingsSections.Find(BuiltInSettings.TextSizeSectionId)).IsNull(); + var ids = app.BuildCatalog().Select(c => c.Id).ToArray(); + await Assert.That(ids).Contains(BuiltInCommands.ZoomInCommand); + await Assert.That(ids).DoesNotContain(BuiltInCommands.TextSizeCommand); + await Assert.That(app.StepZoom(-1)).IsTrue(); + await Assert.That(app.Configuration.Display.Zoom).IsEqualTo(90); + } + + [Test] + public async Task HelpSaysTheTerminalsTextIsItsOwn() + { + var topic = HelpTopics.For("⌥⏎").Single(t => t.Id == "text-size"); + var text = string.Join(" ", topic.Lines); + + await Assert.That(text).Contains("terminal's own"); + await Assert.That(text).Contains("⌃="); + } +}