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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/SharpMUTerm.Avalonia/TerminalView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}

Expand Down
10 changes: 10 additions & 0 deletions src/SharpMUTerm.Avalonia/WindowPointer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand Down
22 changes: 13 additions & 9 deletions src/SharpMUTerm.Tui/InlineImageHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ public bool Load(
int availableColumns,
Func<IGraphicsProtocol?> kitty,
Action<int, IReadOnlyList<string>> deliver,
int maxRows = InlineImageLayout.MaxRows)
int maxRows = InlineImageLayout.MaxRows,
ImageCellSize? cellSize = null)
{
ArgumentNullException.ThrowIfNull(requests);
ArgumentNullException.ThrowIfNull(kitty);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -191,16 +192,17 @@ presentation is not (InlineImagePresentation.Kitty or InlineImagePresentation.Ha
InlineImagePresentation presentation,
int availableColumns,
int maxRows,
Func<IGraphicsProtocol?> kitty)
Func<IGraphicsProtocol?> 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;
Expand Down Expand Up @@ -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
Expand All @@ -370,17 +373,18 @@ internal int SourcesHeld
return null;
}

return Fit(source, request, availableColumns, maxRows);
return Fit(source, request, availableColumns, maxRows, cellSize);
}

/// <summary>Sizes a decoded picture to its cell box, keeping the pixels either presentation needs.</summary>
internal static DecodedPicture? Fit(
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;
Expand Down
33 changes: 20 additions & 13 deletions src/SharpMUTerm.Tui/InlineImageLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ namespace SharpMUTerm.Tui;
/// the world's <c>W=</c>/<c>H=</c> hints, then the room there is. Pure, so the sizing is testable
/// without a terminal.
/// <para>
/// A cell is taken to be <see cref="CellPixelWidth"/> × <see cref="CellPixelHeight"/> pixels. The
/// Desktop hosts supply measured logical-pixel cells. Without those, a cell is taken to be
/// <see cref="CellPixelWidth"/> × <see cref="CellPixelHeight"/> pixels. The
/// client cannot ask the terminal (the framework owns the input stream a <c>CSI 16 t</c> 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.
/// </para>
/// </summary>
internal static class InlineImageLayout
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
}

/// <summary>One display cell in logical pixels; null metrics use the terminal fallback.</summary>
internal sealed record ImageCellSize(double Width, double Height);
3 changes: 3 additions & 0 deletions src/SharpMUTerm.Tui/Pointer/PointerTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ public bool SameAs(PaneScrollbar other) =>
/// </summary>
internal interface IPointerPresenter
{
/// <summary>Measured cell size in logical pixels, or null for terminal hosts with unknown metrics.</summary>
ImageCellSize? ImageCellSize => null;

/// <summary>The pointer moved onto something else. <see cref="PointerTarget.Nothing"/> when it left everything.</summary>
void Hover(PointerTarget target);

Expand Down
6 changes: 4 additions & 2 deletions src/SharpMUTerm.Tui/SharpMUTermApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
}
}

Expand Down
20 changes: 20 additions & 0 deletions tests/SharpMUTerm.Avalonia.Tests/BundledFontTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using SharpMUTerm.Tui;
using SkiaSharp;

namespace SharpMUTerm.Avalonia.Tests;

/// <summary>
Expand All @@ -6,6 +9,23 @@ namespace SharpMUTerm.Avalonia.Tests;
/// </summary>
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()
{
Expand Down
17 changes: 17 additions & 0 deletions tests/SharpMUTerm.Tui.Tests/InlineImageCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ private static async Task<List<IReadOnlyList<string>>> Show(InlineImageHost host
return drawn;
}

[Test]
public async Task NewCellMetricsResizeRowsWithoutRefetching()
{
var count = new Counting();
using var host = new InlineImageHost(count.Fetch(Task.FromResult<byte[]?>(TestImages.RgbaPng(240, 240))), run => run());
var request = Room with { Width = new ImageExtent(24, ImageExtentUnit.Characters) };
var drawn = new List<IReadOnlyList<string>>();
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()
{
Expand Down
31 changes: 31 additions & 0 deletions tests/SharpMUTerm.Tui.Tests/MxpImageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---------------------------------------------------------------------------------

/// <summary>
Expand Down
Loading