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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]

### Fixed

- Desktop overlays follow modal visibility changes on the client UI thread, avoiding background-thread reads of changing pane layouts.

- The desktop window answers terminal cell-pixel queries with its actual measured pixels, allowing
servers to size images correctly. Unix terminal mode also reports cells when the terminal publishes
an exact pixel extent; unavailable dimensions stay unknown. Servers that asked receive refreshed
dimensions after zoom or display-scaling changes, even when the character grid is unchanged.
Replies use the opted-in TNC terminal-query plugin and never forward server escapes to the local terminal.

- 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.
Expand Down
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<PackageVersion Include="SharpConsoleUI" Version="2.7.8" />

<!-- Telnet negotiation: TELOPT/NAWS/MTTS/EOR/CHARSET/MSSP/GMCP/MSDP/MCCP/MXP negotiation. -->
<PackageVersion Include="TelnetNegotiationCore" Version="4.5.3" />
<PackageVersion Include="TelnetNegotiationCore" Version="4.9.0" />

<!-- The version, from the git tag (see Directory.Build.props). Build-time only. -->
<PackageVersion Include="MinVer" Version="8.0.0" />
Expand Down Expand Up @@ -49,7 +49,7 @@
<PackageVersion Include="AngleSharp" Version="1.7.1" />

<!-- Logging abstractions (TelnetNegotiationCore takes an ILogger). -->
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.13" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.14" />

<!--
Client diagnostics: Serilog behind Microsoft.Extensions.Logging. Only the Tui's composition root
Expand Down
2 changes: 2 additions & 0 deletions src/SharpMUTerm.Avalonia/AvaloniaConsoleDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ public AvaloniaConsoleDriver(int columns, int rows)
/// <summary>What the client says is under the pointer, for the window to show (<see cref="WindowPointer"/>).</summary>
public WindowPointer Pointer { get; } = new();

public CellMetrics CellMetrics { get; } = new();

/// <summary>The window's screen-reader regions; the window adds them beside the view.</summary>
public WindowAnnouncer Announcer { get; } = new();

Expand Down
29 changes: 27 additions & 2 deletions src/SharpMUTerm.Avalonia/TerminalView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public TerminalView(AvaloniaConsoleDriver driver, SKColor foreground, SKColor ba
_renderer = new CellRenderer(new CellFonts(fontPoints, family, lineHeight), foreground, background);
_zoomRenderers[(1, 100)] = _renderer;
_driver.Pointer.CellHeightMeasured = CellSize.Height;
_driver.CellMetrics.Current = new SharpMUTerm.Core.Telnet.TerminalCellSize(_renderer.Fonts.CellWidth, _renderer.Fonts.CellHeight);
_driver.Pointer.ImageCellSizeMeasured = new ImageCellSize(CellSize.Width, CellSize.Height);
Focusable = true;
ClipToBounds = true;
Expand Down Expand Up @@ -240,12 +241,35 @@ internal double RowTop(int row, int column)
/// <summary>The window size that holds a grid of <paramref name="columns"/> × <paramref name="rows"/>.</summary>
public Size SizeFor(int columns, int rows) => new(columns * CellSize.Width, (rows * CellSize.Height) + FieldReserve);

private TopLevel? _scalingOwner;

protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (TopLevel.GetTopLevel(this) is { } top && Math.Abs(top.RenderScaling - _scale) > 0.001)
_scalingOwner = TopLevel.GetTopLevel(this);
if (_scalingOwner is { } top)
{
top.ScalingChanged += OnDisplayScalingChanged;
PrepareScale(top.RenderScaling);
}
}

protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (_scalingOwner is { } top)
{
top.ScalingChanged -= OnDisplayScalingChanged;
_scalingOwner = null;
}

base.OnDetachedFromVisualTree(e);
}

private void OnDisplayScalingChanged(object? sender, EventArgs e)
{
if (_scalingOwner is { } top)
{
Rescale(top.RenderScaling);
PrepareScale(top.RenderScaling);
}
}

Expand Down Expand Up @@ -338,6 +362,7 @@ private void Rescale(double scale)

_renderer = renderer;
_driver.Pointer.CellHeightMeasured = CellSize.Height;
_driver.CellMetrics.Current = new SharpMUTerm.Core.Telnet.TerminalCellSize(_renderer.Fonts.CellWidth, _renderer.Fonts.CellHeight);
_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.
Expand Down
1 change: 1 addition & 0 deletions src/SharpMUTerm.Avalonia/WindowHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public static ClientHost Create(AvaloniaConsoleDriver driver, Action<string>? cl
Keys = Keys,
RunsInline = false,
Pointer = driver.Pointer,
CellMetrics = driver.CellMetrics,
SixelCell = SharpMUTermApp.SixelCellSize(null),

// The same desktop device as the terminal: both are desktop processes on the same machine.
Expand Down
7 changes: 7 additions & 0 deletions src/SharpMUTerm.Core/Session/WorldSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)

var telnet = _sessionFactory(World.ToConnectionOptions());
_telnet = telnet;
telnet.TerminalCellSizeProvider = () => TerminalCellSizeProvider?.Invoke();
telnet.OutputReceived += OnOutputReceived;
GameData.Clear();
Occupants.Clear();
Expand Down Expand Up @@ -1519,6 +1520,12 @@ public async Task SendRawAsync(string command, CancellationToken cancellationTok
return macro.Command;
}

/// <summary>The host's current physical cell dimensions; null for hosts without measurements.</summary>
public Func<TerminalCellSize?>? TerminalCellSizeProvider { get; set; }

public ValueTask RefreshTerminalCellSizeAsync() =>
_telnet?.RefreshTerminalCellSizeAsync() ?? ValueTask.CompletedTask;

public ValueTask SetWindowSizeAsync(int width, int height) =>
_telnet?.SetWindowSizeAsync(width, height) ?? ValueTask.CompletedTask;

Expand Down
6 changes: 6 additions & 0 deletions src/SharpMUTerm.Core/Telnet/ITelnetSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ ValueTask SendMooEditAsync(MooEdit edit, IReadOnlyList<string> lines) =>
/// <summary>Connects the transport and begins negotiation and the receive loop.</summary>
Task ConnectAsync(CancellationToken cancellationToken = default);

/// <summary>Measured physical cell pixels, if this host knows them. Read at query time.</summary>
Func<TerminalCellSize?>? TerminalCellSizeProvider { get => null; set { } }

/// <summary>Refresh a previously requested terminal cell report after a zoom or display change.</summary>
ValueTask RefreshTerminalCellSizeAsync() => ValueTask.CompletedTask;

/// <summary>Sends a command line to the server (a trailing CR/LF is appended).</summary>
ValueTask SendLineAsync(string text, CancellationToken cancellationToken = default);

Expand Down
18 changes: 18 additions & 0 deletions src/SharpMUTerm.Core/Telnet/TelnetSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,12 @@ private Encoding? Negotiated
private Encoding Fallback =>
_options.CharsetOrder is [var first, ..] ? first : Encoding.UTF8;

public Func<TerminalCellSize?>? TerminalCellSizeProvider { get; set; }
private TerminalQueryProtocol? _cellQueries;

public ValueTask RefreshTerminalCellSizeAsync() =>
IsConnected ? _cellQueries?.ReportCellSizeAsync() ?? ValueTask.CompletedTask : ValueTask.CompletedTask;

public event EventHandler<TelnetOutputEventArgs>? OutputReceived;

/// <summary>Raised when <see cref="CurrentEncoding"/> changes — in practice, when CHARSET settles.</summary>
Expand Down Expand Up @@ -439,10 +445,13 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
// Building/wiring the interpreter failed after the socket opened; close it so we
// don't leak a half-open connection, and stay in the not-connected state.
var stopping = _cellQueries?.OnDisabledAsync() ?? ValueTask.CompletedTask;
_cellQueries = null;
_interpreter = null;
_seeded = null;
_negotiated = null;
await _transport.CloseAsync().ConfigureAwait(false);
await stopping.ConfigureAwait(false);
throw;
}

Expand All @@ -457,11 +466,16 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)

private Task<TelnetInterpreter> BuildInterpreterAsync()
{
_cellQueries = new TerminalQueryProtocol().WithCellSizeProvider(() =>
TerminalCellSizeProvider?.Invoke() is { IsValid: true } size
? new TelnetNegotiationCore.Models.TerminalCellSize(size.Width, size.Height)
: null);
TelnetInterpreterBuilder builder = new TelnetInterpreterBuilder()
.UseMode(TelnetInterpreter.TelnetMode.Client)
.UseLogger(_logger)
.OnNegotiation(WriteToTransportAsync)
.OnSubmit(OnSubmitAsync)
.AddPlugin(_cellQueries)
// Who this client is, for every protocol in the library that names it.
.WithClientIdentity(Identity())
// Explicit defaults let NAWS observe negotiation completion, so an early pane report
Expand Down Expand Up @@ -1023,12 +1037,16 @@ private static async ValueTask ReportBothAsync(WindowSizeProtocol protocol, MudC

public async Task DisconnectAsync()
{
// Disable queued replies immediately, but close transport before awaiting an active socket write.
var stopping = _cellQueries?.OnDisabledAsync() ?? ValueTask.CompletedTask;
_cellQueries = null;
if (_loopCts is not null)
{
await _loopCts.CancelAsync().ConfigureAwait(false);
}

await _transport.CloseAsync().ConfigureAwait(false);
await stopping.ConfigureAwait(false);

if (_readLoop is not null)
{
Expand Down
7 changes: 7 additions & 0 deletions src/SharpMUTerm.Core/Telnet/TerminalCellSize.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SharpMUTerm.Core.Telnet;

/// <summary>One terminal character cell in physical pixels, not display-independent units.</summary>
public sealed record TerminalCellSize(int Width, int Height)
{
public bool IsValid => Width is > 0 and <= 65535 && Height is > 0 and <= 65535;
}
27 changes: 27 additions & 0 deletions src/SharpMUTerm.Tui/Hosting/CellMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using SharpMUTerm.Core.Telnet;

namespace SharpMUTerm.Tui;

/// <summary>A host-owned, thread-safe snapshot of actual physical cell pixels. Unknown is never guessed.</summary>
internal class CellMetrics : IDisposable
{
private TerminalCellSize? _current;

public TerminalCellSize? Current
{
get => Volatile.Read(ref _current);
set
{
if (Interlocked.Exchange(ref _current, value) != value)
{
Changed?.Invoke();
}
}
}

public event Action? Changed;

public virtual void Start() { }

public virtual void Dispose() { }
}
5 changes: 5 additions & 0 deletions src/SharpMUTerm.Tui/Hosting/ClientHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ internal sealed record ClientHost
/// </summary>
public required IPointerPresenter? Pointer { get; init; }

/// <summary>Measured physical cell pixels, independent of pointer presentation; null when unavailable.</summary>
public required CellMetrics? CellMetrics { get; init; }

/// <summary>The keys this host delivers differently from the others, or keeps for itself.</summary>
public required HostKeys Keys { get; init; }

Expand Down Expand Up @@ -221,6 +224,7 @@ public static ClientHost Terminal(TerminalCapabilities capabilities)
FocusReporting = PlatformDecodesFocusReports,
Keys = HostKeys.Terminal,
Pointer = TerminalPointer.Create(driver),
CellMetrics = new TerminalCellMetrics(),
RunsInline = false,
SixelCell = SharpMUTermApp.SixelCellSize(Environment.GetEnvironmentVariable(SharpMUTermApp.SixelCellVariable)),
Audio = () => DesktopAudio.TryCreate(),
Expand Down Expand Up @@ -256,6 +260,7 @@ public static ClientHost Terminal(TerminalCapabilities capabilities)
FocusReporting = false,
Keys = HostKeys.Terminal,
Pointer = null,
CellMetrics = null,
RunsInline = true,
SixelCell = SharpMUTermApp.SixelCellSize(null),
Audio = null,
Expand Down
91 changes: 91 additions & 0 deletions src/SharpMUTerm.Tui/Hosting/TerminalCellMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Runtime.InteropServices;
using SharpMUTerm.Core.Telnet;

namespace SharpMUTerm.Tui;

/// <summary>
/// Reads the Unix terminal's published pixel extent without touching its input stream. Some emulators
/// publish no pixels; Windows pseudoconsoles do not expose reliable renderer metrics. Those stay unknown.
/// </summary>
internal sealed class TerminalCellMetrics : CellMetrics
{
private readonly Func<TerminalCellSize?> _read;
private Timer? _timer;
private readonly object _gate = new();
private bool _disposed;

public TerminalCellMetrics() : this(ReadTerminal) { }

internal TerminalCellMetrics(Func<TerminalCellSize?> read) => _read = read;

public override void Start()
{
lock (_gate)
{
if (_disposed || _timer is not null) { return; }
Sample();
// Pixel-only zoom need not resize the character grid.
_timer = new Timer(_ => Sample(), null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500));
}
}

internal void Sample()
{
lock (_gate)
{
if (!_disposed) { Current = _read(); }
}
}

public override void Dispose()
{
lock (_gate)
{
_disposed = true;
_timer?.Dispose();
_timer = null;
}
}

internal static TerminalCellSize? FromWindowSize(int columns, int rows, int width, int height)
{
if (columns <= 0 || rows <= 0 || width <= 0 || height <= 0 || width % columns != 0 || height % rows != 0)
{
return null;
}

var cell = new TerminalCellSize(width / columns, height / rows);
return cell.IsValid ? cell : null;
}

private static TerminalCellSize? ReadTerminal()
{
if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) { return null; }
// Query stdout only: a redirected stream must not silently borrow a different terminal's dimensions.
var request = OperatingSystem.IsMacOS() ? 0x40087468UL : 0x5413UL;
WindowSize size;
// macOS ARM64 passes variadic ioctl arguments on the stack, not in x2.
var result = OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? IoctlArm64(1, (nuint)request, 0, 0, 0, 0, 0, 0, out size)
: Ioctl(1, (nuint)request, out size);
return result == 0
? FromWindowSize(size.Columns, size.Rows, size.Width, size.Height)
: null;
}

[StructLayout(LayoutKind.Sequential)]
private struct WindowSize
{
public ushort Rows;
public ushort Columns;
public ushort Width;
public ushort Height;
}

[DllImport("libc", EntryPoint = "ioctl", SetLastError = true)]
private static extern int IoctlArm64(int descriptor, nuint request,
ulong x2, ulong x3, ulong x4, ulong x5, ulong x6, ulong x7, out WindowSize size);

[DllImport("libc", EntryPoint = "ioctl", SetLastError = true)]
private static extern int Ioctl(int descriptor, nuint request, out WindowSize size);
}
Loading
Loading