diff --git a/src/Devolutions.Terminal.Control/PACKAGE.md b/src/Devolutions.Terminal.Control/PACKAGE.md index fea5732..a9fda0c 100644 --- a/src/Devolutions.Terminal.Control/PACKAGE.md +++ b/src/Devolutions.Terminal.Control/PACKAGE.md @@ -29,6 +29,13 @@ Content = terminal; await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); ``` +The `columns` and `rows` passed to `StartAsync` set both the engine and PTY +initially, even if the control was arranged at a different size before startup. +Subsequent layout passes resize both to fit the available space. You can use +`TermControl.MeasureCell(profile, displayScale)` to calculate an initial grid +for the profile and display; pending layout changes made while the connection +starts are applied once it is ready. + See the [`samples/Devolutions.Terminal.Control.Sample`](https://github.com/Devolutions/devolutions-terminal/tree/main/samples/Devolutions.Terminal.Control.Sample) project in the [Devolutions Terminal repository](https://github.com/Devolutions/devolutions-terminal) for a full working app, plus the full source and documentation. diff --git a/src/Devolutions.Terminal.Control/TermControl.cs b/src/Devolutions.Terminal.Control/TermControl.cs index 547e6c7..233bdf4 100644 --- a/src/Devolutions.Terminal.Control/TermControl.cs +++ b/src/Devolutions.Terminal.Control/TermControl.cs @@ -84,6 +84,8 @@ public sealed class TermControl : Avalonia.Controls.Control private int _pendingPtyRows; private int _pendingPtyPixelWidth; private int _pendingPtyPixelHeight; + private bool _hasPendingPtyResize; + private bool _connectionStarting; // Throughput-harness diagnostics (Devolutions.Terminal.Bench): posts requested by // the engine-invalidated handler vs UI drains actually executed. @@ -275,6 +277,7 @@ public static CellSize MeasureCell(ProfileSettings profile, double scale = 1) public async Task StartAsync(ProfileSettings profile, int columns, int rows) { + ClearPendingPtyResize(); Profile = profile; _padding = ParsePadding(profile.Padding); _shaderEffectsEnabled = true; @@ -291,7 +294,22 @@ public async Task StartAsync(ProfileSettings profile, int columns, int rows) profile.AllowKittyKeyboardMode); ResizeEngine(columns, rows); - await StartConnectionAsync(profile, columns, rows).ConfigureAwait(true); + _connectionStarting = true; + try + { + await StartConnectionAsync(profile, columns, rows).ConfigureAwait(true); + } + catch + { + ClearPendingPtyResize(); + throw; + } + finally + { + _connectionStarting = false; + } + + FlushPtyResize(); _blinkTimer.Start(); InvalidateVisual(); } @@ -378,15 +396,40 @@ public async Task RestartAsync(CancellationToken cancellationToken = default) { var connection = _connection ?? throw new InvalidOperationException("The terminal connection has not been started."); - await connection.CloseAsync(cancellationToken).ConfigureAwait(true); - ResetTerminal(); - await connection.RestartAsync(cancellationToken: cancellationToken).ConfigureAwait(true); + _connectionStarting = true; + try + { + await connection.CloseAsync(cancellationToken).ConfigureAwait(true); + ResetTerminal(); + await connection.RestartAsync(cancellationToken: cancellationToken).ConfigureAwait(true); + } + catch + { + ClearPendingPtyResize(); + throw; + } + finally + { + _connectionStarting = false; + } + + if (!_hasPendingPtyResize) + { + SchedulePtyResize( + Engine.Columns, + Engine.Rows, + checked((int)Math.Max(1, Engine.Columns * _engineCellWidthPixels)), + checked((int)Math.Max(1, Engine.Rows * _engineCellHeightPixels))); + } + + FlushPtyResize(); _blinkTimer.Start(); } public async Task CloseAsync() { _blinkTimer.Stop(); + ClearPendingPtyResize(); if (_connection is not null) { var connection = _connection; @@ -1007,28 +1050,41 @@ private void SchedulePtyResize(int columns, int rows, int pixelWidth, int pixelH _pendingPtyRows = rows; _pendingPtyPixelWidth = pixelWidth; _pendingPtyPixelHeight = pixelHeight; + _hasPendingPtyResize = true; _ptyResizeTimer.Stop(); _ptyResizeTimer.Start(); } + private void ClearPendingPtyResize() + { + _ptyResizeTimer.Stop(); + _hasPendingPtyResize = false; + } + private void FlushPtyResize() { _ptyResizeTimer.Stop(); + if (!_hasPendingPtyResize || _connectionStarting || _connection?.IsRunning != true) + { + return; + } + try { - if (_connection?.IsRunning == true) - { - _connection.Resize( - _pendingPtyColumns, - _pendingPtyRows, - _pendingPtyPixelWidth, - _pendingPtyPixelHeight); - } + _connection.Resize( + _pendingPtyColumns, + _pendingPtyRows, + _pendingPtyPixelWidth, + _pendingPtyPixelHeight); } catch (Exception ex) when (ex is IOException or InvalidOperationException or OverflowException) { ReportInteractionError("Terminal resize was not accepted; resize again after input drains", ex); } + finally + { + _hasPendingPtyResize = false; + } } private void ResizeEngine(int columns, int rows) diff --git a/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs b/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs new file mode 100644 index 0000000..4852f19 --- /dev/null +++ b/tests/Devolutions.Terminal.Control.Tests/TermControlResizeTests.cs @@ -0,0 +1,347 @@ +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Devolutions.Terminal.Connection; +using Devolutions.Terminal.Settings; +using Xunit; + +namespace Devolutions.Terminal.Control.Tests; + +public sealed class TermControlResizeTests +{ + [AvaloniaFact] + public async Task StartAsyncDiscardsResizeFromBeforeStart() + { + var connection = new FakePtyConnection(); + var control = CreateControl(connection); + try + { + ArrangeControl(control, 800, 600); + Assert.True(control.Bounds.Width > 0); + var arrangedColumns = control.Engine.Columns; + var arrangedRows = control.Engine.Rows; + + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + Assert.True(arrangedColumns != 62 || arrangedRows != 19); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(62, control.Engine.Columns); + Assert.Equal(19, control.Engine.Rows); + Assert.Equal(62, connection.Columns); + Assert.Equal(19, connection.Rows); + Assert.Equal(0, connection.ResizeCount); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task LayoutDuringStartFlushesLatestResizeAfterConnectionStarts() + { + var connection = new FakePtyConnection { StartGate = NewGate() }; + var control = CreateControl(connection); + try + { + ArrangeControl(control, 800, 600); + var start = control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + await connection.StartEntered.Task; + + ArrangeControl(control, 920, 600); + var firstColumns = control.Engine.Columns; + ArrangeControl(control, 1040, 700); + var expectedColumns = control.Engine.Columns; + var expectedRows = control.Engine.Rows; + Assert.NotEqual(firstColumns, expectedColumns); + + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(0, connection.ResizeCount); + + connection.StartGate.SetResult(); + await start; + + Assert.Equal(expectedColumns, control.Engine.Columns); + Assert.Equal(expectedRows, control.Engine.Rows); + Assert.Equal(expectedColumns, connection.Columns); + Assert.Equal(expectedRows, connection.Rows); + Assert.Equal(1, connection.ResizeCount); + Assert.Equal(expectedColumns * CellPixelWidth(control), connection.PixelWidth); + Assert.Equal(expectedRows * CellPixelHeight(control), connection.PixelHeight); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task LayoutDuringRestartFlushesLatestResizeAfterConnectionRestarts() + { + var connection = new FakePtyConnection { RestartGate = NewGate() }; + var control = CreateControl(connection); + try + { + ArrangeControl(control, 800, 600); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + var restart = control.RestartAsync(); + await connection.RestartEntered.Task; + + ArrangeControl(control, 1040, 700); + var expectedColumns = control.Engine.Columns; + var expectedRows = control.Engine.Rows; + Assert.True(expectedColumns != 62 || expectedRows != 19); + + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(0, connection.ResizeCount); + + connection.RestartGate.SetResult(); + await restart; + + Assert.Equal(expectedColumns, connection.Columns); + Assert.Equal(expectedRows, connection.Rows); + Assert.Equal(1, connection.ResizeCount); + Assert.Equal(expectedColumns * CellPixelWidth(control), connection.PixelWidth); + Assert.Equal(expectedRows * CellPixelHeight(control), connection.PixelHeight); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task RestartRestoresCurrentGridAfterConnectionReusesLaunchSize() + { + var connection = new FakePtyConnection(); + var control = CreateControl(connection); + try + { + ArrangeControl(control, 800, 600); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + + ArrangeControl(control, 1040, 600); + var expectedColumns = control.Engine.Columns; + Assert.NotEqual(62, expectedColumns); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(expectedColumns, connection.Columns); + var resizeCount = connection.ResizeCount; + + await control.RestartAsync(); + + Assert.Equal(expectedColumns, control.Engine.Columns); + Assert.Equal(expectedColumns, connection.Columns); + Assert.Equal(control.Engine.Rows, connection.Rows); + Assert.Equal(resizeCount + 1, connection.ResizeCount); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task FailedStartDiscardsPendingResize() + { + var connection = new FakePtyConnection + { + StartGate = NewGate(), + StartFailure = new InvalidOperationException("start failed"), + }; + var control = CreateControl(connection); + try + { + ArrangeControl(control, 800, 600); + var start = control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + await connection.StartEntered.Task; + + ArrangeControl(control, 1040, 600); + Assert.NotEqual(62, control.Engine.Columns); + + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + connection.StartGate.SetResult(); + await Assert.ThrowsAsync(() => start); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, connection.ResizeCount); + Assert.True(connection.Disposed); + } + finally + { + await control.CloseAsync(); + } + } + + [AvaloniaFact] + public async Task CloseCancelsPendingResize() + { + var connection = new FakePtyConnection(); + var control = CreateControl(connection); + try + { + ArrangeControl(control, 800, 600); + await control.StartAsync(new ProfileSettings { Commandline = "cmd.exe" }, 62, 19); + ArrangeControl(control, 1040, 600); + Assert.NotEqual(62, control.Engine.Columns); + + await control.CloseAsync(); + await Task.Delay(80); + Dispatcher.UIThread.RunJobs(); + + Assert.Equal(0, connection.ResizeCount); + Assert.True(connection.Disposed); + } + finally + { + if (!connection.Disposed) + { + await control.CloseAsync(); + } + } + } + + private static TaskCompletionSource NewGate() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static int CellPixelWidth(TermControl control) => + checked((int)Math.Max(1, Math.Round(control.CellSize.Width))); + + private static int CellPixelHeight(TermControl control) => + checked((int)Math.Max(1, Math.Round(control.CellSize.Height))); + + private static TermControl CreateControl(FakePtyConnection connection) => + new() { ConnectionFactory = _ => connection }; + + private static void ArrangeControl(TermControl control, double width, double height) + { + control.Measure(new Size(width, height)); + control.Arrange(new Rect(0, 0, width, height)); + } + + private sealed class FakePtyConnection : IRestartableTerminalConnection + { +#pragma warning disable CS0067 + public event EventHandler>? OutputReceived; + public event EventHandler? Exited; + public event EventHandler? Faulted; + public event EventHandler? SessionExited; +#pragma warning restore CS0067 + + private TerminalLaunchOptions? _launchOptions; + + public TaskCompletionSource? StartGate { get; init; } + public TaskCompletionSource? RestartGate { get; init; } + public TaskCompletionSource StartEntered { get; } = NewGate(); + public TaskCompletionSource RestartEntered { get; } = NewGate(); + public Exception? StartFailure { get; init; } + public bool Disposed { get; private set; } + public bool IsRunning { get; private set; } + public int Columns { get; private set; } + public int Rows { get; private set; } + public int PixelWidth { get; private set; } + public int PixelHeight { get; private set; } + public int ResizeCount { get; private set; } + public TerminalConnectionCapabilities Capabilities => + TerminalConnectionCapabilities.Resize | TerminalConnectionCapabilities.Restart; + public TerminalConnectionState State { get; private set; } + public TerminalProcessMetadata? ProcessMetadata => null; + public TerminalExitInfo? LastExitInfo => null; + + public async Task StartAsync( + TerminalLaunchOptions options, + CancellationToken cancellationToken = default) + { + StartEntered.TrySetResult(); + if (StartGate is not null) + { + await StartGate.Task; + } + + if (StartFailure is not null) + { + throw StartFailure; + } + + _launchOptions = options; + Columns = options.Columns; + Rows = options.Rows; + IsRunning = true; + State = TerminalConnectionState.Connected; + } + + public Task StartAsync( + string commandLine, + string? workingDirectory, + int columns, + int rows, + CancellationToken cancellationToken = default) => + StartAsync( + new TerminalLaunchOptions + { + CommandLine = commandLine, + WorkingDirectory = workingDirectory, + Columns = columns, + Rows = rows, + }, + cancellationToken); + + public void Write(ReadOnlySpan data) { } + public void Write(string text) { } + public ValueTask WriteAsync( + ReadOnlyMemory data, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + + public void Resize(int columns, int rows) => Resize(columns, rows, 0, 0); + + public void Resize(int columns, int rows, int pixelWidth, int pixelHeight) + { + Assert.True(IsRunning); + Columns = columns; + Rows = rows; + PixelWidth = pixelWidth; + PixelHeight = pixelHeight; + ResizeCount++; + } + + public async Task RestartAsync( + TerminalLaunchOptions? options = null, + CancellationToken cancellationToken = default) + { + RestartEntered.TrySetResult(); + if (RestartGate is not null) + { + await RestartGate.Task; + } + + var launch = options ?? _launchOptions + ?? throw new InvalidOperationException("No prior launch."); + Columns = launch.Columns; + Rows = launch.Rows; + PixelWidth = 0; + PixelHeight = 0; + IsRunning = true; + State = TerminalConnectionState.Connected; + } + + public Task CloseAsync(CancellationToken cancellationToken = default) + { + IsRunning = false; + State = TerminalConnectionState.Closed; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + Disposed = true; + IsRunning = false; + State = TerminalConnectionState.Disposed; + return ValueTask.CompletedTask; + } + } +}