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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/SharpMUTerm.Avalonia/ComposerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public IEnumerable<EdgeRule> 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;
Expand Down Expand Up @@ -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);
Expand All @@ -326,6 +329,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
FaceChanged -= ApplyFont;
_view.Repainted -= RefreshFontMetrics;
base.OnDetachedFromVisualTree(e);
}

Expand Down Expand Up @@ -519,8 +523,18 @@ private void Paint()
_marks.InvalidateVisual();
}

/// <summary>Refreshes an open composer after zoom without changing its selected face.</summary>
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;
Expand Down
1 change: 1 addition & 0 deletions src/SharpMUTerm.Avalonia/FloatingPanels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/SharpMUTerm.Avalonia/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
117 changes: 107 additions & 10 deletions src/SharpMUTerm.Avalonia/TerminalView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PointerMenuItem> 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;
Expand All @@ -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;
_driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, CellSize.Height);
Focusable = true;
Expand Down Expand Up @@ -226,11 +252,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();
}
Expand Down Expand Up @@ -295,15 +326,27 @@ 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;
_driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, 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();
}

Expand All @@ -321,6 +364,41 @@ private void OnFrameReady()
}
}

/// <summary>Keep zoom available while the native command editor owns keyboard focus.</summary>
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);
}
Comment thread
HarryCordewener marked this conversation as resolved.
Resolved

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)
Expand All @@ -336,6 +414,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
Expand Down Expand Up @@ -731,7 +823,7 @@ private void CloseTip()
}

/// <summary>The client's right-click menu, as a native one. Each entry runs on the client's thread by itself.</summary>
private void OpenMenu(IReadOnlyList<PointerMenuItem> items) => OpenMenu(NativeMenu.Build(items, PlacementMode.Pointer, this));
private void OpenMenu(IReadOnlyList<PointerMenuItem> items) => OpenMenu(NativeMenu.Build([.. items, PointerMenuItem.Separator, .. ZoomItems], PlacementMode.Pointer, this));

private void OpenMenu(ContextMenu menu)
{
Expand Down Expand Up @@ -919,9 +1011,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<Rect> Covered => covered;
Expand Down
13 changes: 12 additions & 1 deletion src/SharpMUTerm.Avalonia/WindowHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReservedChord> 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"),
];
Comment thread
HarryCordewener marked this conversation as resolved.
Resolved

/// <summary>Paste is the window's, on the terminal conventions, so a bare ⌃V still reaches the client as a key.</summary>
public static IReadOnlyList<ReservedChord> Paste { get; } =
[
Expand All @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions tests/SharpMUTerm.Avalonia.Tests/FloatingPanelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
43 changes: 42 additions & 1 deletion tests/SharpMUTerm.Avalonia.Tests/NativeComposerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> Run<T>(Func<Rig, T> body, bool spelling = false) =>
private static Task<T> Run<T>(Func<Rig, T> 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);
Comment thread
HarryCordewener marked this conversation as resolved.
Resolved
var root = new Panel();
root.Children.Add(view);
var layer = new DesktopLayer(view, HostChrome.HeaderAndRail);
Expand Down Expand Up @@ -110,6 +111,46 @@ private static void Press(ComposerEditor editor, Key key, KeyModifiers modifiers
Drain();
}

[Test]
[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 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, 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 ───────────────────────────────────────────────────────────────────────────────────

[Test]
Expand Down
2 changes: 1 addition & 1 deletion tests/SharpMUTerm.Avalonia.Tests/TerminalViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}

Expand Down
Loading
Loading