From 69b0526020c5706a1e6eda78db7362d8c7198bee Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Sun, 8 Nov 2026 17:52:14 -0600 Subject: [PATCH 1/2] Size desktop MXP images using measured display cells --- src/SharpMUTerm.Avalonia/TerminalView.cs | 2 ++ src/SharpMUTerm.Avalonia/WindowPointer.cs | 10 ++++++ src/SharpMUTerm.Tui/InlineImageHost.cs | 22 ++++++++----- src/SharpMUTerm.Tui/InlineImageLayout.cs | 33 +++++++++++-------- src/SharpMUTerm.Tui/Pointer/PointerTarget.cs | 3 ++ src/SharpMUTerm.Tui/SharpMUTermApp.cs | 6 ++-- .../BundledFontTests.cs | 20 +++++++++++ .../InlineImageCacheTests.cs | 17 ++++++++++ tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs | 31 +++++++++++++++++ 9 files changed, 120 insertions(+), 24 deletions(-) diff --git a/src/SharpMUTerm.Avalonia/TerminalView.cs b/src/SharpMUTerm.Avalonia/TerminalView.cs index 273e3cd6..4ec7c6b4 100644 --- a/src/SharpMUTerm.Avalonia/TerminalView.cs +++ b/src/SharpMUTerm.Avalonia/TerminalView.cs @@ -59,6 +59,7 @@ public TerminalView(AvaloniaConsoleDriver driver, SKColor foreground, SKColor ba _lineHeight = lineHeight; _renderer = new CellRenderer(new CellFonts(fontPoints, family, lineHeight), foreground, background); _driver.Pointer.CellHeightMeasured = CellSize.Height; + _driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, CellSize.Height); Focusable = true; ClipToBounds = true; Cursor = new Cursor(StandardCursorType.Ibeam); @@ -302,6 +303,7 @@ private void Rescale(double scale) _renderer.DefaultForeground, _renderer.DefaultBackground); _driver.Pointer.CellHeightMeasured = CellSize.Height; + _driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, CellSize.Height); Repainted?.Invoke(); } diff --git a/src/SharpMUTerm.Avalonia/WindowPointer.cs b/src/SharpMUTerm.Avalonia/WindowPointer.cs index aaffa921..9f046a8a 100644 --- a/src/SharpMUTerm.Avalonia/WindowPointer.cs +++ b/src/SharpMUTerm.Avalonia/WindowPointer.cs @@ -91,6 +91,16 @@ internal int WindowColumnsMeasured int? IPointerPresenter.WindowColumns => WindowColumnsMeasured is > 0 and var columns ? columns : null; + private ImageCellSize? _imageCellSize; + + internal ImageCellSize? ImageCellSizeMeasured + { + get => Volatile.Read(ref _imageCellSize); + set => Volatile.Write(ref _imageCellSize, value); + } + + ImageCellSize? IPointerPresenter.ImageCellSize => ImageCellSizeMeasured; + private double _cellHeight; /// diff --git a/src/SharpMUTerm.Tui/InlineImageHost.cs b/src/SharpMUTerm.Tui/InlineImageHost.cs index bdfae50c..4e90b56d 100644 --- a/src/SharpMUTerm.Tui/InlineImageHost.cs +++ b/src/SharpMUTerm.Tui/InlineImageHost.cs @@ -125,7 +125,8 @@ public bool Load( int availableColumns, Func kitty, Action> deliver, - int maxRows = InlineImageLayout.MaxRows) + int maxRows = InlineImageLayout.MaxRows, + ImageCellSize? cellSize = null) { ArgumentNullException.ThrowIfNull(requests); ArgumentNullException.ThrowIfNull(kitty); @@ -156,7 +157,7 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha for (var i = 0; i < requests.Count; i++) { var index = i; - var rows = await RowsForAsync(requests[index], presentation, availableColumns, maxRows, kitty).ConfigureAwait(false); + var rows = await RowsForAsync(requests[index], presentation, availableColumns, maxRows, kitty, cellSize).ConfigureAwait(false); if (rows is not null && !_cts.IsCancellationRequested) { _onUi(() => deliver(index, rows)); @@ -191,16 +192,17 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha InlineImagePresentation presentation, int availableColumns, int maxRows, - Func kitty) + Func kitty, + ImageCellSize? cellSize) { - var key = $"{presentation}|{availableColumns}|{maxRows}|{request.Width}|{request.Height}|{request.Url}"; + var key = $"{presentation}|{availableColumns}|{maxRows}|{cellSize}|{request.Width}|{request.Height}|{request.Url}"; if (Cached(key) is { } hit) { return hit; } var source = await SourceFor(request.Url).ConfigureAwait(false); - var picture = source is null ? null : Fit(source, request, availableColumns, maxRows); + var picture = source is null ? null : Fit(source, request, availableColumns, maxRows, cellSize); if (picture is null) { return null; @@ -358,7 +360,8 @@ internal int SourcesHeld byte[] bytes, InlineImageRequest request, int availableColumns, - int maxRows = InlineImageLayout.MaxRows) + int maxRows = InlineImageLayout.MaxRows, + ImageCellSize? cellSize = null) { PixelBuffer source; try @@ -370,7 +373,7 @@ internal int SourcesHeld return null; } - return Fit(source, request, availableColumns, maxRows); + return Fit(source, request, availableColumns, maxRows, cellSize); } /// Sizes a decoded picture to its cell box, keeping the pixels either presentation needs. @@ -378,9 +381,10 @@ internal int SourcesHeld PixelBuffer source, InlineImageRequest request, int availableColumns, - int maxRows = InlineImageLayout.MaxRows) + int maxRows = InlineImageLayout.MaxRows, + ImageCellSize? cellSize = null) { - var box = InlineImageLayout.Fit(source.Width, source.Height, request.Width, request.Height, availableColumns, maxRows); + var box = InlineImageLayout.Fit(source.Width, source.Height, request.Width, request.Height, availableColumns, maxRows, cellSize); if (box.Columns <= 0 || box.Rows <= 0) { return null; diff --git a/src/SharpMUTerm.Tui/InlineImageLayout.cs b/src/SharpMUTerm.Tui/InlineImageLayout.cs index bc3ea455..4016a5f8 100644 --- a/src/SharpMUTerm.Tui/InlineImageLayout.cs +++ b/src/SharpMUTerm.Tui/InlineImageLayout.cs @@ -7,10 +7,11 @@ namespace SharpMUTerm.Tui; /// the world's W=/H= hints, then the room there is. Pure, so the sizing is testable /// without a terminal. /// -/// A cell is taken to be × pixels. The +/// Desktop hosts supply measured logical-pixel cells. Without those, a cell is taken to be +/// × pixels. The /// client cannot ask the terminal (the framework owns the input stream a CSI 16 t reply would -/// arrive on), and the 1:2 ratio is the one thing that matters: it is what keeps a square picture -/// square under both Kitty and half-blocks, which carry one pixel across and two down per cell. +/// arrive on), so those hosts retain the established 1:2 fallback. Desktop sizing uses the measured +/// aspect ratio instead; the final box is rounded to whole cells. /// /// internal static class InlineImageLayout @@ -38,19 +39,22 @@ public static WebImageLayout.CellBox Fit( ImageExtent? width, ImageExtent? height, int availableColumns, - int maxRows = MaxRows) + int maxRows = MaxRows, + ImageCellSize? cellSize = null) { if (pixelWidth <= 0 || pixelHeight <= 0 || availableColumns <= 0 || maxRows <= 0) { return new WebImageLayout.CellBox(0, 0); } - double columns = Math.Ceiling(pixelWidth / (double)CellPixelWidth); - double rows = Math.Ceiling(pixelHeight / (double)CellPixelHeight); - var aspect = rows / columns; + var cw = cellSize?.Width is > 0 and var measuredWidth && double.IsFinite(measuredWidth) ? measuredWidth : CellPixelWidth; + var ch = cellSize?.Height is > 0 and var measuredHeight && double.IsFinite(measuredHeight) ? measuredHeight : CellPixelHeight; + double columns = Math.Ceiling(pixelWidth / cw); + double rows = Math.Ceiling(pixelHeight / ch); + var aspect = cellSize is null ? rows / columns : (pixelHeight / ch) / (pixelWidth / cw); - var hintedColumns = Columns(width, availableColumns); - var hintedRows = Rows(height, maxRows); + var hintedColumns = Columns(width, availableColumns, cw); + var hintedRows = Rows(height, maxRows, ch); if (hintedColumns is { } c && hintedRows is { } r) { columns = c; @@ -73,19 +77,22 @@ public static WebImageLayout.CellBox Fit( Math.Clamp((int)Math.Round(rows * scale, MidpointRounding.AwayFromZero), 1, maxRows)); } - private static double? Columns(ImageExtent? extent, int available) => extent switch + private static double? Columns(ImageExtent? extent, int available, double cellWidth) => extent switch { - { Unit: ImageExtentUnit.Pixels } e => Math.Ceiling(e.Value / (double)CellPixelWidth), + { Unit: ImageExtentUnit.Pixels } e => Math.Ceiling(e.Value / cellWidth), { Unit: ImageExtentUnit.Characters } e => e.Value, { Unit: ImageExtentUnit.Percent } e => Math.Max(1, available * e.Value / 100.0), _ => null, }; - private static double? Rows(ImageExtent? extent, int maxRows) => extent switch + private static double? Rows(ImageExtent? extent, int maxRows, double cellHeight) => extent switch { - { Unit: ImageExtentUnit.Pixels } e => Math.Ceiling(e.Value / (double)CellPixelHeight), + { Unit: ImageExtentUnit.Pixels } e => Math.Ceiling(e.Value / cellHeight), { Unit: ImageExtentUnit.Characters } e => e.Value, { Unit: ImageExtentUnit.Percent } e => Math.Max(1, maxRows * e.Value / 100.0), _ => null, }; } + +/// One display cell in logical pixels; null metrics use the terminal fallback. +internal sealed record ImageCellSize(double Width, double Height); diff --git a/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs b/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs index 38b076c5..50589abb 100644 --- a/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs +++ b/src/SharpMUTerm.Tui/Pointer/PointerTarget.cs @@ -109,6 +109,9 @@ public bool SameAs(PaneScrollbar other) => /// internal interface IPointerPresenter { + /// Measured cell size in logical pixels, or null for terminal hosts with unknown metrics. + ImageCellSize? ImageCellSize => null; + /// The pointer moved onto something else. when it left everything. void Hover(PointerTarget target); diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index aa335b9d..d9bc9865 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -4703,7 +4703,8 @@ private void AppendSessionLine(string windowId, StyledLine line, string stamp) presentation, Math.Min(InlineImageColumns(windowId), edges?.Columns ?? int.MaxValue), Kitty, - (index, rows) => PlaceInlinePicture(windowId, anchor, shown, index, rows)); + (index, rows) => PlaceInlinePicture(windowId, anchor, shown, index, rows), + cellSize: _pointer?.ImageCellSize); } foreach (var (picture, request, interaction, columns, rows) in reserved) @@ -4714,7 +4715,8 @@ private void AppendSessionLine(string windowId, StyledLine line, string stamp) columns, Kitty, (_, drawn) => DrawReservedPicture(windowId, picture, Linked(drawn, interaction)), - rows); + rows, + _pointer?.ImageCellSize); } } diff --git a/tests/SharpMUTerm.Avalonia.Tests/BundledFontTests.cs b/tests/SharpMUTerm.Avalonia.Tests/BundledFontTests.cs index b99f75e2..99e54f11 100644 --- a/tests/SharpMUTerm.Avalonia.Tests/BundledFontTests.cs +++ b/tests/SharpMUTerm.Avalonia.Tests/BundledFontTests.cs @@ -1,3 +1,6 @@ +using SharpMUTerm.Tui; +using SkiaSharp; + namespace SharpMUTerm.Avalonia.Tests; /// @@ -6,6 +9,23 @@ namespace SharpMUTerm.Avalonia.Tests; /// public class BundledFontTests { + [Test] + public async Task ImageSizingUsesTheViewsMeasuredCellsAfterScaling() + { + var measurements = await TerminalViewTests.Session.Dispatch(() => + { + var driver = new AvaloniaConsoleDriver(80, 24); + var view = new TerminalView(driver, SKColors.White, SKColors.Black, 16, null, WindowOptions.DefaultLineHeight); + var first = ((IPointerPresenter)driver.Pointer).ImageCellSize; + var initial = new ImageCellSize(view.CellExtent.Width, view.CellExtent.Height); + view.PrepareScale(1.5); + return (first, initial, current: ((IPointerPresenter)driver.Pointer).ImageCellSize, + expected: new ImageCellSize(view.CellExtent.Width, view.CellExtent.Height)); + }, CancellationToken.None); + await Assert.That(measurements.first).IsEqualTo(measurements.initial); + await Assert.That(measurements.current).IsEqualTo(measurements.expected); + } + [Test] public async Task TheBundledFacesAreInTheBuild() { diff --git a/tests/SharpMUTerm.Tui.Tests/InlineImageCacheTests.cs b/tests/SharpMUTerm.Tui.Tests/InlineImageCacheTests.cs index 28526f93..91aaf439 100644 --- a/tests/SharpMUTerm.Tui.Tests/InlineImageCacheTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/InlineImageCacheTests.cs @@ -36,6 +36,23 @@ private static async Task>> Show(InlineImageHost host return drawn; } + [Test] + public async Task NewCellMetricsResizeRowsWithoutRefetching() + { + var count = new Counting(); + using var host = new InlineImageHost(count.Fetch(Task.FromResult(TestImages.RgbaPng(240, 240))), run => run()); + var request = Room with { Width = new ImageExtent(24, ImageExtentUnit.Characters) }; + var drawn = new List>(); + foreach (var cell in new[] { new ImageCellSize(10, 20), new ImageCellSize(10, 24) }) + { + host.Load([request], InlineImagePresentation.HalfBlock, 100, () => null, (_, rows) => drawn.Add(rows), cellSize: cell); + await host.Pending; + } + await Assert.That(count.Fetches).IsEqualTo(1); + await Assert.That(drawn[0].Count).IsEqualTo(12); + await Assert.That(drawn[1].Count).IsEqualTo(10); + } + [Test] public async Task APictureShownAtAnotherWidthIsNotFetchedAgain() { diff --git a/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs b/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs index 03959034..20686d7b 100644 --- a/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs @@ -66,6 +66,37 @@ public async Task BothHintsStretch() await Assert.That(box).IsEqualTo(new WebImageLayout.CellBox(40, 2)); } + [Test] + public async Task MeasuredCellsKeepOneHintPhysicallySquare() + { + var box = InlineImageLayout.Fit(160, 160, new ImageExtent(24, ImageExtentUnit.Characters), null, 100, + cellSize: new ImageCellSize(10, 24)); + await Assert.That(box).IsEqualTo(new WebImageLayout.CellBox(24, 10)); + } + + [Test] + public async Task MeasuredPixelHintsUseActualCells() + { + var box = InlineImageLayout.Fit(160, 160, new ImageExtent(120, ImageExtentUnit.Pixels), + new ImageExtent(120, ImageExtentUnit.Pixels), 100, cellSize: new ImageCellSize(10, 24)); + await Assert.That(box).IsEqualTo(new WebImageLayout.CellBox(12, 5)); + } + + [Test] + public async Task ExplicitCellBoxRemainsTheServersGeometry() + { + var box = InlineImageLayout.Fit(160, 160, new ImageExtent(24, ImageExtentUnit.Characters), + new ImageExtent(12, ImageExtentUnit.Characters), 100, cellSize: new ImageCellSize(10, 24)); + await Assert.That(box).IsEqualTo(new WebImageLayout.CellBox(24, 12)); + } + + [Test] + public async Task InvalidMeasuredCellsKeepTerminalFallback() + { + var box = InlineImageLayout.Fit(160, 160, null, null, 100, cellSize: new ImageCellSize(double.NaN, 0)); + await Assert.That(box).IsEqualTo(new WebImageLayout.CellBox(20, 10)); + } + // ---- Rows --------------------------------------------------------------------------------- /// From 2c2bf71519b1660298fa102b2134b614a6d6a4f9 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Sun, 8 Nov 2026 18:12:23 -0600 Subject: [PATCH 2/2] Document measured desktop image sizing --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9eb93d..cfc84849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,10 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Fixed +- Desktop MXP images use the measured font cell dimensions when sizing pictures, instead of assuming an 8×16 cell. + This avoids stretching pictures when the selected font has a different shape. Explicit server-specified cell boxes + stay unchanged, and terminal hosts without measured cells keep their existing sizing. + - Quitting the terminal client turns off the terminal's focus reporting again, so the shell no longer receives `ESC [ I` / `ESC [ O` when you switch windows afterwards. - Servers now hear this client's name. Over TTYPE (MTTS) it introduced itself as `UNKNOWN`, because its own