From 51ddae164a3b6e7a20bccb826ff761106af6e9f6 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Sun, 8 Nov 2026 17:53:32 -0600 Subject: [PATCH 1/2] Add live terminal zoom controls to the desktop window --- CHANGELOG.md | 5 + src/SharpMUTerm.Avalonia/FloatingPanels.cs | 1 + src/SharpMUTerm.Avalonia/MainWindow.cs | 2 + src/SharpMUTerm.Avalonia/TerminalView.cs | 117 ++++++++++-- src/SharpMUTerm.Avalonia/WindowHost.cs | 13 +- .../NativeComposerTests.cs | 26 ++- .../TerminalViewTests.cs | 2 +- .../TerminalZoomTests.cs | 169 ++++++++++++++++++ 8 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9eb93d..4e55233d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ 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. + - 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 the same two on its launcher's right-click menu once your desktop integrates it, and opens a terminal for the native diff --git a/src/SharpMUTerm.Avalonia/FloatingPanels.cs b/src/SharpMUTerm.Avalonia/FloatingPanels.cs index b054d47a..edcce23a 100644 --- a/src/SharpMUTerm.Avalonia/FloatingPanels.cs +++ b/src/SharpMUTerm.Avalonia/FloatingPanels.cs @@ -195,6 +195,7 @@ internal sealed class FloatingPanelWindow : Window public FloatingPanelWindow(FloatingPanel panel, TerminalView view) { _view = view; + view.AttachZoomKeys(this); Panel = panel; NativeTokens.ApplyTextRendering(this); AppIcon.Apply(this); diff --git a/src/SharpMUTerm.Avalonia/MainWindow.cs b/src/SharpMUTerm.Avalonia/MainWindow.cs index 07b6c72f..bfa75baa 100644 --- a/src/SharpMUTerm.Avalonia/MainWindow.cs +++ b/src/SharpMUTerm.Avalonia/MainWindow.cs @@ -51,6 +51,8 @@ public MainWindow(WindowOptions options, IClassicDesktopStyleApplicationLifetime _driver = new AvaloniaConsoleDriver(options.Columns, options.Rows); _view = new TerminalView(_driver, foreground, background, options.FontPoints * 96f / 72f, options.FontFamily, options.LineHeight); + _view.AttachZoomKeys(this); + Title = SharpMUTerm.Client.Chrome.WindowTitle.Product; AppIcon.Apply(this); diff --git a/src/SharpMUTerm.Avalonia/TerminalView.cs b/src/SharpMUTerm.Avalonia/TerminalView.cs index 273e3cd6..958f2ad9 100644 --- a/src/SharpMUTerm.Avalonia/TerminalView.cs +++ b/src/SharpMUTerm.Avalonia/TerminalView.cs @@ -35,6 +35,31 @@ 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. + private readonly Dictionary<(double Scale, int Zoom), CellRenderer> _zoomRenderers = []; + + internal int ZoomPercent => _zoomPercent; + + internal void SetZoom(int percent) + { + percent = Math.Clamp(percent, 50, 200); + if (percent == _zoomPercent) + { + return; + } + + _zoomPercent = percent; + 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), + ]; private int _invalidatePending; private char? _swallowText; private double _wheelRemainder; @@ -58,6 +83,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; _driver.Pointer.CellHeightMeasured = CellSize.Height; Focusable = true; ClipToBounds = true; @@ -225,11 +251,16 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnSizeChanged(SizeChangedEventArgs e) { base.OnSizeChanged(e); - var (columns, rows) = GridFor(e.NewSize); + ResizeGrid(e.NewSize); + } + + private void ResizeGrid(Size size) + { + var (columns, rows) = GridFor(size); // The width a terminal this window's size would have, sidebar and all, said before the grid moves: the // client's narrow layout reads it with the resize, and it does not change when the sidebar folds. - _driver.Pointer.WindowColumnsMeasured = Math.Max(columns, (int)((e.NewSize.Width + Margin.Left + Margin.Right) / CellSize.Width)); + _driver.Pointer.WindowColumnsMeasured = Math.Max(columns, (int)((size.Width + Margin.Left + Margin.Right) / CellSize.Width)); _driver.Resize(columns, rows); Repainted?.Invoke(); } @@ -294,14 +325,26 @@ internal void PrepareScale(double scale) private void Rescale(double scale) { - // The old fonts are not disposed: a frame already queued on the render thread may still be drawing - // with them. A change of display scale is rare enough that one set of leaked fonts is the cheaper fix. _scale = scale; - _renderer = new CellRenderer( - new CellFonts((float)(_fontPoints * scale), _family, _lineHeight), - _renderer.DefaultForeground, - _renderer.DefaultBackground); + if (!_zoomRenderers.TryGetValue((scale, _zoomPercent), out var renderer)) + { + renderer = new CellRenderer( + new CellFonts((float)(_fontPoints * scale * _zoomPercent / 100), _family, _lineHeight), + _renderer.DefaultForeground, + _renderer.DefaultBackground); + _zoomRenderers[(scale, _zoomPercent)] = renderer; + } + + _renderer = renderer; _driver.Pointer.CellHeightMeasured = CellSize.Height; + // Font changes leave the control's bounds alone, so OnSizeChanged will not do this for us. + // Going through the driver resize also recomputes pane geometry and schedules per-pane NAWS. + if (Bounds.Width > 0 && Bounds.Height > 0) + { + ResizeGrid(Bounds.Size); + } + + InvalidateVisual(); Repainted?.Invoke(); } @@ -319,6 +362,41 @@ private void OnFrameReady() } } + /// Keep zoom available while the native command editor owns keyboard focus. + internal void AttachZoomKeys(InputElement root) + { + char? swallowed = null; + root.AddHandler(KeyDownEvent, (_, e) => + { + swallowed = null; + if (!e.Handled && ZoomFor(e.Key, e.KeyModifiers) is not null) + { + HandleKeyDown(e); + swallowed = _swallowText; + } + }, global::Avalonia.Interactivity.RoutingStrategies.Tunnel); + root.AddHandler(TextInputEvent, (_, e) => + { + if (swallowed is { } character && e.Text == character.ToString()) + { + e.Handled = true; + _swallowText = null; + } + + swallowed = null; + }, global::Avalonia.Interactivity.RoutingStrategies.Tunnel); + root.AddHandler(LostFocusEvent, (_, _) => swallowed = null); + } + + 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.D0 or Key.NumPad0, KeyModifiers.Control) => 0, + _ => null, + }; + // ── Keys ──────────────────────────────────────────────────────────────────────────────────── protected override void OnKeyDown(KeyEventArgs e) @@ -334,6 +412,20 @@ protected override void OnKeyDown(KeyEventArgs e) internal void HandleKeyDown(KeyEventArgs e) { _swallowText = null; + if (ZoomFor(e.Key, e.KeyModifiers) is { } zoom) + { + SetZoom(zoom == 0 ? 100 : _zoomPercent + zoom); + _swallowText = e.Key switch + { + Key.D0 or Key.NumPad0 => '0', + Key.OemMinus or Key.Subtract => '-', + Key.Add => '+', + _ => e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? '+' : '=', + }; + e.Handled = true; + return; + } + var mapped = KeyMapper.FromKeyDown(e.Key, e.KeyModifiers); // The window's own chords (WindowHost): paste, and ⌥⏎ to maximise and restore. Read from the list the @@ -729,7 +821,7 @@ private void CloseTip() } /// The client's right-click menu, as a native one. Each entry runs on the client's thread by itself. - private void OpenMenu(IReadOnlyList items) => OpenMenu(NativeMenu.Build(items, PlacementMode.Pointer, this)); + private void OpenMenu(IReadOnlyList items) => OpenMenu(NativeMenu.Build([.. items, PointerMenuItem.Separator, .. ZoomItems], PlacementMode.Pointer, this)); private void OpenMenu(ContextMenu menu) { @@ -917,9 +1009,14 @@ private sealed class FrameDraw( public bool HitTest(Point p) => Bounds.Contains(p); public bool Equals(ICustomDrawOperation? other) => - other is FrameDraw draw && ReferenceEquals(draw.Frame, Frame) && draw.Bounds == Bounds && draw.Plane == Plane + other is FrameDraw draw && ReferenceEquals(draw.Renderer, Renderer) && draw.Scale == Scale + && ReferenceEquals(draw.Frame, Frame) && draw.Bounds == Bounds && draw.Plane == Plane && draw.Rule == Rule && draw.Alpha == Alpha && draw.Covered.SequenceEqual(Covered); + private CellRenderer Renderer => renderer; + + private double Scale => scale; + private byte Alpha => alpha; private IReadOnlyList Covered => covered; diff --git a/src/SharpMUTerm.Avalonia/WindowHost.cs b/src/SharpMUTerm.Avalonia/WindowHost.cs index 7d960d82..4b1c2b44 100644 --- a/src/SharpMUTerm.Avalonia/WindowHost.cs +++ b/src/SharpMUTerm.Avalonia/WindowHost.cs @@ -22,6 +22,17 @@ internal static class WindowHost public static ReservedChord WindowedFullScreen { get; } = new( new HostChord(ConsoleKey.F11, ConsoleModifiers.Shift, "Shift+F11", "Shift+F11"), "goes in and out of windowed full screen"); + public static IReadOnlyList Zoom { get; } = + [ + new(new HostChord(ConsoleKey.OemPlus, ConsoleModifiers.Control, "Ctrl+=", "Ctrl+="), "zooms in"), + new(new HostChord(ConsoleKey.OemPlus, ConsoleModifiers.Control | ConsoleModifiers.Shift, "Ctrl++", "Ctrl++"), "zooms in"), + new(new HostChord(ConsoleKey.Add, ConsoleModifiers.Control, "Ctrl+Add", "Ctrl+Add"), "zooms in"), + new(new HostChord(ConsoleKey.OemMinus, ConsoleModifiers.Control, "Ctrl+-", "Ctrl+-"), "zooms out"), + new(new HostChord(ConsoleKey.Subtract, ConsoleModifiers.Control, "Ctrl+Subtract", "Ctrl+Subtract"), "zooms out"), + new(new HostChord(ConsoleKey.D0, ConsoleModifiers.Control, "Ctrl+0", "Ctrl+0"), "resets terminal zoom"), + new(new HostChord(ConsoleKey.NumPad0, ConsoleModifiers.Control, "Ctrl+NumPad0", "Ctrl+NumPad0"), "resets terminal zoom"), + ]; + /// Paste is the window's, on the terminal conventions, so a bare ⌃V still reaches the client as a key. public static IReadOnlyList Paste { get; } = [ @@ -36,7 +47,7 @@ internal static class WindowHost public static HostKeys Keys { get; } = new() { Newline = new HostChord(ConsoleKey.Enter, ConsoleModifiers.Control, "Ctrl+⏎", "Ctrl+Enter"), - Reserved = [Maximise, FullScreen, WindowedFullScreen, .. Paste], + Reserved = [Maximise, FullScreen, WindowedFullScreen, .. Paste, .. Zoom], // Avalonia reports the key and its modifiers: ⌃digit, ⌃⇧letter, ⌃Tab and ⌥O arrive as themselves. ReportsChordsWhole = true, diff --git a/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs b/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs index 6e7e51f1..565e6268 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs @@ -32,11 +32,12 @@ private sealed record Rig(Window Window, TerminalView View, DesktopLayer Layer, private static SpellChecker Dictionary() => SpellChecker.FromStreams(new MemoryStream("5\nhello\nthe\nworld\nwaves\nsits\n"u8.ToArray()), new MemoryStream("SET UTF-8\n"u8.ToArray()), "en_GB"); - private static Task Run(Func body, bool spelling = false) => + private static Task Run(Func body, bool spelling = false, int zoom = 100) => Session.Dispatch(() => { var driver = new AvaloniaConsoleDriver(120, 34); var view = new TerminalView(driver, SKColors.White, new SKColor(0x1e, 0x1e, 0x24), 14, null); + view.SetZoom(zoom); var root = new Panel(); root.Children.Add(view); var layer = new DesktopLayer(view, HostChrome.HeaderAndRail); @@ -110,6 +111,29 @@ private static void Press(ComposerEditor editor, Key key, KeyModifiers modifiers Drain(); } + [Test] + [Arguments(50)] + [Arguments(200)] + public async Task TerminalZoomKeepsNativeTabsAndComposerUsable(int zoom) + { + var result = await Run(rig => + { + rig.App.RenderNextFrame(); + Drain(); + rig.Window.UpdateLayout(); + var stripsFit = rig.Layer.Strips.Values.All(strip => strip.Bounds.Height > 0 && strip.Bounds.Width > 0); + var overlay = OpenF1(rig); + Type(overlay.Composer.Editor, "Zoom stays usable"); + using var composerFrame = rig.Window.CaptureRenderedFrame(); + return (stripsFit, text: overlay.Composer.Editor.Text, height: overlay.Composer.Editor.Bounds.Height, + rendered: composerFrame is not null); + }, zoom: zoom); + await Assert.That(result.stripsFit).IsTrue(); + await Assert.That(result.text).IsEqualTo("Zoom stays usable"); + await Assert.That(result.height).IsGreaterThan(0); + await Assert.That(result.rendered).IsTrue(); + } + // ── F1 ─────────────────────────────────────────────────────────────────────────────────── [Test] diff --git a/tests/SharpMUTerm.Avalonia.Tests/TerminalViewTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TerminalViewTests.cs index 02494c17..999e54a5 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/TerminalViewTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/TerminalViewTests.cs @@ -237,7 +237,7 @@ public async Task TheClientsMenuOpensAndAnEntryRunsItsAction() return (labels, ran); }); - await Assert.That(labels).IsEquivalentTo(new[] { "True", "-", "False" }); + await Assert.That(labels).IsEquivalentTo(new[] { "True", "-", "False", "-", "True", "True", "False" }); await Assert.That(ran).IsEquivalentTo(new[] { "split" }); } diff --git a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs new file mode 100644 index 00000000..8decee81 --- /dev/null +++ b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs @@ -0,0 +1,169 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Headless; +using SkiaSharp; + +namespace SharpMUTerm.Avalonia.Tests; + +public class TerminalZoomTests +{ + [Test] + [Arguments(1.0)] + [Arguments(1.25)] + [Arguments(1.5)] + public async Task ZoomResizesTheGridWithoutResizingTheWindowAndResetReusesFonts(double scale) + { + 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 size = view.SizeFor(80, 24); + var window = new Window { Content = view, Width = size.Width, Height = size.Height }; + window.Show(); + view.PrepareScale(scale); + try + { + var original = driver.ScreenSize; + var renderer = view.Renderer; + var resized = 0; + driver.ScreenResized += (_, _) => resized++; + view.SetZoom(150); + var zoomed = driver.ScreenSize; + var measured = driver.Pointer.CellHeightMeasured; + var height = view.CellExtent.Height; + view.SetZoom(100); + return (original, zoomed, reset: driver.ScreenSize, sameFonts: ReferenceEquals(renderer, view.Renderer), resized, measured, height); + } + finally + { + window.Close(); + } + }, CancellationToken.None); + await Assert.That(result.zoomed.Width).IsLessThan(result.original.Width); + await Assert.That(result.zoomed.Height).IsLessThan(result.original.Height); + await Assert.That(result.reset.Width).IsEqualTo(result.original.Width); + await Assert.That(result.reset.Height).IsEqualTo(result.original.Height); + await Assert.That(result.sameFonts).IsTrue(); + await Assert.That(result.resized).IsEqualTo(2); + await Assert.That(result.measured).IsEqualTo(result.height); + } + + [Test] + public async Task ZoomRepaintsEvenWhenTheMinimumGridAndPublishedFrameDoNotChange() + { + var result = await TerminalViewTests.Session.Dispatch(() => + { + using var driver = new AvaloniaConsoleDriver(20, 8); + var view = new TerminalView(driver, SKColors.White, SKColors.Black, 16, null); + var window = new Window { Content = view, Width = 80, Height = 60 }; + window.Show(); + try + { + driver.FillCells(1, 0, 1, ' ', SharpConsoleUI.Color.Default, new SharpConsoleUI.Color(255, 0, 0)); + driver.Flush(); + DesktopLayerTests.Drain(); + byte[] Capture() + { + using var bitmap = window.CaptureRenderedFrame()!; + using var pixels = bitmap.Lock(); + var bytes = new byte[pixels.RowBytes * pixels.Size.Height]; + System.Runtime.InteropServices.Marshal.Copy(pixels.Address, bytes, 0, bytes.Length); + return bytes; + } + + var before = Capture(); + var frame = driver.Frame; + view.SetZoom(110); + DesktopLayerTests.Drain(); + return (sameFrame: ReferenceEquals(frame, driver.Frame), changedPixels: !before.SequenceEqual(Capture())); + } + finally + { + window.Close(); + } + }, CancellationToken.None); + await Assert.That(result.sameFrame).IsTrue(); + await Assert.That(result.changedPixels).IsTrue(); + } + + [Test] + public async Task ZoomWorksWhileANativeEditorOwnsFocus() + { + 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 editor = new TextBox(); + var window = new Window { Content = editor, Width = 300, Height = 200 }; + view.AttachZoomKeys(window); + window.Show(); + editor.Focus(); + try + { + window.KeyPressQwerty(PhysicalKey.Equal, RawInputModifiers.Control); + window.KeyTextInput("="); + var noZoomText = string.IsNullOrEmpty(editor.Text); + window.KeyPressQwerty(PhysicalKey.Equal, RawInputModifiers.None); + window.KeyTextInput("="); + return (view.ZoomPercent, noZoomText, editor.Text); + } + finally + { + window.Close(); + } + }, CancellationToken.None); + await Assert.That(result.ZoomPercent).IsEqualTo(110); + await Assert.That(result.noZoomText).IsTrue(); + await Assert.That(result.Text).IsEqualTo("="); + } + + [Test] + public async Task ZoomIsBoundedAndMenuActionsResetToLaunchSize() + { + 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 max = view.ZoomPercent; + var disabledIn = !view.ZoomItems[0].Enabled; + view.SetZoom(-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); + }, CancellationToken.None); + await Assert.That(result.max).IsEqualTo(200); + 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 ZoomKeysAreLocalAndOrdinaryTypingStillReachesTheClient() + { + 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 sent = new List(); + driver.KeyPressed += (_, key) => sent.Add(key); + var zoom = new KeyEventArgs { Key = Key.OemPlus, KeyModifiers = KeyModifiers.Control | KeyModifiers.Shift }; + view.HandleKeyDown(zoom); + var percent = view.ZoomPercent; + view.HandleKeyDown(new KeyEventArgs { Key = Key.NumPad0, KeyModifiers = KeyModifiers.Control }); + view.HandleTextInput(new TextInputEventArgs { Text = "0" }); + view.HandleKeyDown(new KeyEventArgs { Key = Key.A, KeyModifiers = KeyModifiers.Control }); + return (percent, zoom.Handled, sent.Count, key: sent.Single().Key); + }, CancellationToken.None); + await Assert.That(result.percent).IsEqualTo(110); + await Assert.That(result.Handled).IsTrue(); + await Assert.That(result.Count).IsEqualTo(1); + 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.NumPad0, KeyModifiers.Control)).IsEqualTo(0); + } +} From e151c38025e02dca69520f3ca48b551488a95ff5 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Sun, 8 Nov 2026 18:36:45 -0600 Subject: [PATCH 2/2] Refresh open composer metrics on zoom and cover review scenarios --- src/SharpMUTerm.Avalonia/ComposerView.cs | 14 ++++++++++ .../FloatingPanelTests.cs | 20 ++++++++++++++ .../NativeComposerTests.cs | 27 +++++++++++++++---- .../TerminalZoomTests.cs | 14 ++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/SharpMUTerm.Avalonia/ComposerView.cs b/src/SharpMUTerm.Avalonia/ComposerView.cs index 973b8707..1a328880 100644 --- a/src/SharpMUTerm.Avalonia/ComposerView.cs +++ b/src/SharpMUTerm.Avalonia/ComposerView.cs @@ -43,6 +43,7 @@ public IEnumerable EdgeRules(Size size) } private readonly TerminalView _view; + private (double Height, double TextSize) _fontMetrics; private readonly bool _fullScreen; private readonly ComposerEditor _editor; private readonly MarkLayer _marks; @@ -317,6 +318,8 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); FaceChanged += ApplyFont; + _view.Repainted += RefreshFontMetrics; + RefreshFontMetrics(); if (_model is { Armed: true }) { Dispatcher.UIThread.Post(() => _editor.Focus(), DispatcherPriority.Input); @@ -326,6 +329,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { FaceChanged -= ApplyFont; + _view.Repainted -= RefreshFontMetrics; base.OnDetachedFromVisualTree(e); } @@ -519,8 +523,18 @@ private void Paint() _marks.InvalidateVisual(); } + /// Refreshes an open composer after zoom without changing its selected face. + private void RefreshFontMetrics() + { + if (_fontMetrics != (_view.CellExtent.Height, _view.CellTextSize)) + { + ApplyFont(); + } + } + private void ApplyFont() { + _fontMetrics = (_view.CellExtent.Height, _view.CellTextSize); var cell = _view.CellExtent.Height > 0 ? _view.CellExtent.Height : 17; var ui = Math.Clamp(cell * 0.72, 11, 15); _title.FontSize = _fullScreen ? ui : PanelText.Size; diff --git a/tests/SharpMUTerm.Avalonia.Tests/FloatingPanelTests.cs b/tests/SharpMUTerm.Avalonia.Tests/FloatingPanelTests.cs index f64ec633..380a8b0b 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/FloatingPanelTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/FloatingPanelTests.cs @@ -24,6 +24,26 @@ public class FloatingPanelTests { private static readonly SKColor Back = new(0x10, 0x20, 0x30); + [Test] + public async Task OneFloatingWindowZoomKeyIsAppliedOnce() + { + var zoom = await Session.Dispatch(() => + { + using var driver = new AvaloniaConsoleDriver(80, 24); + var view = new TerminalView(driver, SKColors.White, Back, 16, null); + var panel = Floating(PanelIds.Vitals, new FloatingBounds(0, 0, 400, 300), _ => { }, () => { }); + var window = new FloatingPanelWindow(panel, view); + window.Show(); + try + { + window.KeyPressQwerty(global::Avalonia.Input.PhysicalKey.Equal, global::Avalonia.Input.RawInputModifiers.Control); + return view.ZoomPercent; + } + finally { window.Close(); } + }, CancellationToken.None); + await Assert.That(zoom).IsEqualTo(110); + } + // ── The client's half ──────────────────────────────────────────────────────────────────────── [Test] diff --git a/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs b/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs index 565e6268..86a86633 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs @@ -112,26 +112,43 @@ private static void Press(ComposerEditor editor, Key key, KeyModifiers modifiers } [Test] - [Arguments(50)] - [Arguments(200)] - public async Task TerminalZoomKeepsNativeTabsAndComposerUsable(int zoom) + [Arguments(50, false)] + [Arguments(200, false)] + [Arguments(50, true)] + [Arguments(200, true)] + public async Task TerminalZoomKeepsNativeTabsAndComposerUsable(int zoom, bool proportional) { var result = await Run(rig => { rig.App.RenderNextFrame(); Drain(); rig.Window.UpdateLayout(); - var stripsFit = rig.Layer.Strips.Values.All(strip => strip.Bounds.Height > 0 && strip.Bounds.Width > 0); var overlay = OpenF1(rig); + overlay.Composer.ChooseFace(proportional); + rig.View.SetZoom(zoom == 50 ? 60 : 190); + Drain(); + var changedHeight = overlay.Composer.Editor.LineHeight; + var expectedChangedHeight = proportional ? double.NaN : rig.View.CellExtent.Height; + rig.View.SetZoom(zoom); + Drain(); + rig.Window.UpdateLayout(); + var stripsFit = rig.Layer.Strips.Values.All(strip => strip.Bounds.Height > 0 && strip.Bounds.Width > 0); Type(overlay.Composer.Editor, "Zoom stays usable"); using var composerFrame = rig.Window.CaptureRenderedFrame(); return (stripsFit, text: overlay.Composer.Editor.Text, height: overlay.Composer.Editor.Bounds.Height, - rendered: composerFrame is not null); + rendered: composerFrame is not null, changedHeight, expectedChangedHeight, + lineHeight: overlay.Composer.Editor.LineHeight, expectedHeight: proportional ? double.NaN : rig.View.CellExtent.Height, + composerOpen: ReferenceEquals(rig.Driver.Composer.Overlay, overlay), + proportional: overlay.Composer.Proportional); }, zoom: zoom); await Assert.That(result.stripsFit).IsTrue(); await Assert.That(result.text).IsEqualTo("Zoom stays usable"); await Assert.That(result.height).IsGreaterThan(0); await Assert.That(result.rendered).IsTrue(); + await Assert.That(result.changedHeight).IsEqualTo(result.expectedChangedHeight); + await Assert.That(result.lineHeight).IsEqualTo(result.expectedHeight); + await Assert.That(result.composerOpen).IsTrue(); + await Assert.That(result.proportional).IsEqualTo(proportional); } // ── F1 ─────────────────────────────────────────────────────────────────────────────────── diff --git a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs index 12364af9..57db70e8 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/TerminalZoomTests.cs @@ -8,6 +8,20 @@ namespace SharpMUTerm.Avalonia.Tests; public class TerminalZoomTests { + [Test] + public async Task ReservedZoomChordsMatchTheirActions() + { + await Assert.That(WindowHost.Zoom.Count).IsEqualTo(7); + foreach (var reserved in WindowHost.Zoom) + { + 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 }; + await Assert.That(TerminalView.ZoomFor(key, modifiers)).IsEqualTo(expected); + } + } + [Test] [Arguments(1.0)] [Arguments(1.25)]