diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a9eb93..cfc8484 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
diff --git a/src/SharpMUTerm.Avalonia/TerminalView.cs b/src/SharpMUTerm.Avalonia/TerminalView.cs
index 273e3cd..4ec7c6b 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 aaffa92..9f046a8 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 bdfae50..4e90b56 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 bc3ea45..4016a5f 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 38b076c..50589ab 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 aa335b9..d9bc986 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 b99f75e..99e54f1 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 28526f9..91aaf43 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 0395903..20686d7 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 ---------------------------------------------------------------------------------
///