diff --git a/Terminal.Gui/App/ApplicationImpl.Driver.cs b/Terminal.Gui/App/ApplicationImpl.Driver.cs index fcfbcd9a55..272c58bb3b 100644 --- a/Terminal.Gui/App/ApplicationImpl.Driver.cs +++ b/Terminal.Gui/App/ApplicationImpl.Driver.cs @@ -83,11 +83,6 @@ private void CreateDriver (string? driverName) break; - case DriverRegistry.Names.UNIX: - Coordinator = CreateSubcomponents (() => new UnixComponentFactory ()); - - break; - case DriverRegistry.Names.ANSI: Coordinator = CreateSubcomponents (() => new AnsiComponentFactory ()); diff --git a/Terminal.Gui/Configuration/SourceGenerationContext.cs b/Terminal.Gui/Configuration/SourceGenerationContext.cs index 7cd469e70f..837607816a 100644 --- a/Terminal.Gui/Configuration/SourceGenerationContext.cs +++ b/Terminal.Gui/Configuration/SourceGenerationContext.cs @@ -45,6 +45,7 @@ namespace Terminal.Gui.Configuration; [JsonSerializable (typeof (Dictionary))] [JsonSerializable (typeof (TraceCategory))] +[JsonSerializable (typeof (SizeDetectionMode))] internal partial class SourceGenerationContext : JsonSerializerContext { } diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs index c7ffe7b6c1..33b6762791 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs @@ -1,4 +1,6 @@ using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; @@ -24,52 +26,88 @@ public class AnsiComponentFactory : ComponentFactoryImpl private readonly AnsiInput? _input; private readonly IOutput? _output; - private AnsiSizeMonitor? _createdSizeMonitor; + private readonly ISizeMonitor? _injectedSizeMonitor; /// /// Creates a new ANSIComponentFactory with optional output capture. /// /// /// Optional fake output to capture what would be written to console. - /// Optional size monitor (if null, will create ANSISizeMonitor) + /// + /// Optional size monitor override (used in tests; if , the monitor is chosen based on + /// ). + /// public AnsiComponentFactory (AnsiInput? input = null, IOutput? output = null, ISizeMonitor? sizeMonitor = null) { _input = input; _output = output; - _createdSizeMonitor = sizeMonitor as AnsiSizeMonitor; + _injectedSizeMonitor = sizeMonitor; } - /// public override ISizeMonitor CreateSizeMonitor (IOutput consoleOutput, IOutputBuffer outputBuffer) { - if (consoleOutput is AnsiOutput output) + // Return injected monitor (e.g. from test harness) if one was provided. + if (_injectedSizeMonitor is { }) + { + return _injectedSizeMonitor; + } + + if (consoleOutput is not AnsiOutput ansiOutput) + { + return new SizeMonitorImpl (consoleOutput); + } + + if (Driver.SizeDetection != SizeDetectionMode.Polling) { - // Create ANSISizeMonitor - the ANSI request callback will be set up - // by MainLoopCoordinator after the driver is fully constructed - _createdSizeMonitor = new (output, queueAnsiRequest: null); - return _createdSizeMonitor; + return new AnsiSizeMonitor (ansiOutput); } - // Fallback for other output types - return new SizeMonitorImpl (consoleOutput); + // Polling mode: wire up a platform-native size query so that + // AnsiOutput.GetSize() returns the real terminal size via + // ioctl(TIOCGWINSZ) on Unix or the Console API on Windows. + ansiOutput.NativeSizeQuery = CreateNativeSizeQuery (); + + return new SizeMonitorImpl (ansiOutput); } - /// - public override IInput CreateInput () + /// + /// Returns a delegate that queries the real terminal size from the OS. + /// On Windows this uses / ; + /// on Unix/macOS it uses ioctl(TIOCGWINSZ) via . + /// + internal static Func CreateNativeSizeQuery () { - return _input ?? new AnsiInput (); - } + if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) + { + return () => + { + try + { + int w = Console.WindowWidth; + int h = Console.WindowHeight; - /// - public override IInputProcessor CreateInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) { return new AnsiInputProcessor (inputBuffer, timeProvider); } + return w > 0 && h > 0 ? new Size (w, h) : null; + } + catch (Exception ex) + { + Trace.Lifecycle (nameof (AnsiComponentFactory), "NativeSizeQuery", $"Console size query failed: {ex.GetType ().Name}: {ex.Message}"); - /// - public override IOutput CreateOutput () - { - return _output ?? new AnsiOutput (); + return null; + } + }; + } + + return () => UnixIOHelper.TryGetTerminalSize (out Size s) ? s : null; } -} + /// + public override IInput CreateInput () => _input ?? new AnsiInput (); + /// + public override IInputProcessor CreateInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) => + new AnsiInputProcessor (inputBuffer, timeProvider); + /// + public override IOutput CreateOutput () => _output ?? new AnsiOutput (); +} diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index f20188a94d..22c15a368f 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -139,8 +139,30 @@ public AnsiOutput () /// public void SetSize (int width, int height) => _consoleSize = new Size (width, height); + /// + /// When non-, calls this delegate to obtain the + /// real terminal size directly from the OS (e.g. ioctl(TIOCGWINSZ) on Unix or the + /// Console API on Windows). Set by when + /// is . + /// + internal Func? NativeSizeQuery { get; set; } + /// - public Size GetSize () => _consoleSize; + public Size GetSize () + { + if (NativeSizeQuery is null) + { + return _consoleSize; + } + Size? native = NativeSizeQuery (); + + if (native is { }) + { + _consoleSize = native.Value; + } + + return _consoleSize; + } /// protected override void Write (StringBuilder output) diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs index 8ba324c10f..1f65377ecc 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs @@ -1,3 +1,5 @@ +using Terminal.Gui.Tracing; + namespace Terminal.Gui.Drivers; /// @@ -53,16 +55,10 @@ public void Initialize (IDriver? driver) return; } - // Set up the callback to queue ANSI requests through the driver _queueAnsiRequest = driver.QueueAnsiRequest; - //Logging.Information ("ANSISizeMonitor: Initialized with driver, sending initial size query"); + Trace.Lifecycle (nameof (AnsiSizeMonitor), "Initialize", "Driver wired up; sending initial size query"); - // Send the initial size query - response will arrive asynchronously - // once the input thread starts reading. We don't block here because: - // 1. The input thread may not have started yet - // 2. Blocking would create a deadlock (waiting for input that can't be read yet) - // 3. The response typically arrives within milliseconds after the input thread starts SendSizeQuery (); } @@ -78,7 +74,9 @@ private void SendSizeQuery () _expectingResponse = true; _lastQuery = DateTime.Now; - var request = new AnsiEscapeSequenceRequest + Trace.Lifecycle (nameof (AnsiSizeMonitor), "SendSizeQuery", "Queuing CSI 18t size query"); + + AnsiEscapeSequenceRequest request = new () { Request = EscSeqUtils.CSI_ReportWindowSizeInChars.Request, Value = EscSeqUtils.CSI_ReportWindowSizeInChars.Value, @@ -122,6 +120,8 @@ private bool CheckSizeChanged () { return false; } + + Trace.Lifecycle (nameof (AnsiSizeMonitor), "SizeChanged", $"{_lastSize} → {currentSize}"); _lastSize = currentSize; SizeChanged?.Invoke (this, new SizeChangedEventArgs (currentSize)); @@ -130,7 +130,7 @@ private bool CheckSizeChanged () private void HandleSizeResponse (string? response) { - //Logging.Trace($"{response}"); + Trace.Lifecycle (nameof (AnsiSizeMonitor), "HandleSizeResponse", $"Response: '{response ?? ""}'"); _expectingResponse = false; if (string.IsNullOrEmpty (response)) diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs index 09742178f8..fb1f66d1cc 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs @@ -1,9 +1,16 @@ -using System.Runtime.InteropServices; - -namespace Terminal.Gui.Drivers; +namespace Terminal.Gui.Drivers; +/// +/// Dispatches platform-specific terminal operations to the appropriate helper. +/// Contains zero P/Invoke — all native calls live in and +/// . +/// internal static class AnsiTerminalHelper { + /// + /// Flushes stdout using the platform-appropriate native mechanism. + /// + /// The detected platform. public static void FlushNative (AnsiPlatform platform) { try @@ -11,67 +18,19 @@ public static void FlushNative (AnsiPlatform platform) switch (platform) { case AnsiPlatform.UnixRaw: - FlushUnix (); + UnixIOHelper.FlushStdout (); break; case AnsiPlatform.WindowsVT: - FlushWindows (); + WindowsVTOutputHelper.FlushStdout (); break; } } catch { - // ignore any exceptions during flush, as we don't want to crash the app if the flush fails in unit tests. + // Ignore exceptions during flush — don't crash the app if flush fails in unit tests. } } - - /* Unix: wait until output has been transmitted to the terminal. - Prefer tcdrain(STDOUT_FILENO). If it fails, fall back to fsync. */ - private static void FlushUnix () - { - const int STDOUT_FILENO = 1; - - if (tcdrain (STDOUT_FILENO) == 0) - { - return; - } - - // fallback - try - { - fsync (STDOUT_FILENO); - } - catch - { /* ignore */ - } - } - - /* Windows: flush the stdout handle. */ - private static void FlushWindows () - { - const int STD_OUTPUT_HANDLE = -11; - nint h = GetStdHandle (STD_OUTPUT_HANDLE); - - if (h != nint.Zero && h != new nint (-1)) - { - FlushFileBuffers (h); // returns false on failure - } - } - - // Unix - [DllImport ("libc", SetLastError = true)] - private static extern int tcdrain (int fd); - - [DllImport ("libc", SetLastError = true)] - private static extern int fsync (int fd); - - // Windows - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern nint GetStdHandle (int nStdHandle); - - [DllImport ("kernel32.dll", SetLastError = true)] - [return: MarshalAs (UnmanagedType.Bool)] - private static extern bool FlushFileBuffers (nint hFile); } diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyConverter.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyConverter.cs index 5f011a0a4f..e33626ca98 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyConverter.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiKeyConverter.cs @@ -10,8 +10,8 @@ namespace Terminal.Gui.Drivers; /// for escape sequence parsing. /// /// -/// - Raw terminal input on Unix/Linux/macOS -/// - ANSI-based test driver +/// +/// - ANSI-based driver for Unix/Linux/macOS and testing /// /// /// The conversion uses as an intermediary format, diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetInputProcessor.cs b/Terminal.Gui/Drivers/DotNetDriver/NetInputProcessor.cs index 4aff8b18ef..51bf1ac4b7 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetInputProcessor.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetInputProcessor.cs @@ -10,10 +10,10 @@ public class NetInputProcessor : InputProcessorImpl /// /// The input buffer to process. /// Time provider for timestamps and timing control. - public NetInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) - : base (inputBuffer, new NetKeyConverter (), timeProvider) - { - } + public NetInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) : base (inputBuffer, + new NetKeyConverter (), + timeProvider) + { } /// protected override void Process (ConsoleKeyInfo input) diff --git a/Terminal.Gui/Drivers/Driver.cs b/Terminal.Gui/Drivers/Driver.cs index 4d5a181314..3e92150251 100644 --- a/Terminal.Gui/Drivers/Driver.cs +++ b/Terminal.Gui/Drivers/Driver.cs @@ -29,6 +29,21 @@ public static bool Force16Colors /// Raised when changes. public static event EventHandler>? Force16ColorsChanged; + // NOTE: SizeDetection is a configuration property (Driver.SizeDetection). + // It controls which strategy the ANSI driver uses to determine the terminal's size. + /// + /// Controls how the ANSI driver detects the terminal's window size. + /// Defaults to , which sends a + /// CSI 18t ANSI escape-sequence query to obtain the terminal size. + /// Set to to use a synchronous + /// native syscall (ioctl on Unix, Console API on Windows) instead — + /// useful when the ANSI query response does not reflect the remote terminal + /// size (e.g., over an SSH tunnel). + /// + /// + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static SizeDetectionMode SizeDetection { get; set; } = SizeDetectionMode.AnsiQuery; + /// /// Determines whether the process is attached to a real terminal (i.e. stdin/stdout /// are connected to a console device rather than redirected or running inside a test harness). Set the environment @@ -56,33 +71,12 @@ public static bool IsAttachedToTerminal (out bool inputAttached, out bool output if (RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) { - const int STD_INPUT_HANDLE = -10; - const int STD_OUTPUT_HANDLE = -11; - nint inH = GetStdHandle (STD_INPUT_HANDLE); - nint outH = GetStdHandle (STD_OUTPUT_HANDLE); - - inputAttached = inH != nint.Zero && GetConsoleMode (inH, out _); - outputAttached = outH != nint.Zero && GetConsoleMode (outH, out _); - - return inputAttached && outputAttached; + return WindowsConsoleHelper.IsAttachedToTerminal (out inputAttached, out outputAttached); } - const int STDIN_FILENO = 0; - const int STDOUT_FILENO = 1; - inputAttached = isatty (STDIN_FILENO) == 1; - outputAttached = isatty (STDOUT_FILENO) == 1; + inputAttached = UnixIOHelper.IsTerminal (UnixIOHelper.STDIN_FILENO); + outputAttached = UnixIOHelper.IsTerminal (UnixIOHelper.STDOUT_FILENO); return inputAttached && outputAttached; } - - // Unix - [DllImport ("libc", SetLastError = true)] - private static extern int isatty (int fd); - - // Windows - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern nint GetStdHandle (int nStdHandle); - - [DllImport ("kernel32.dll")] - private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); } diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index eced43c801..3edbf2ec30 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Runtime.InteropServices; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; @@ -170,6 +171,7 @@ private void CreateClipboard () /// public virtual void SetScreenSize (int width, int height) { + Trace.Lifecycle (nameof (DriverImpl), "SetScreenSize", $"{width}×{height}"); _outputBuffer.SetSize (width, height); _output.SetSize (width, height); SizeChanged?.Invoke (this, new SizeChangedEventArgs (new Size (width, height))); @@ -178,7 +180,11 @@ public virtual void SetScreenSize (int width, int height) /// public event EventHandler? SizeChanged; - private void OnSizeMonitorOnSizeChanged (object? _, SizeChangedEventArgs e) => SetScreenSize (e.Size!.Value.Width, e.Size.Value.Height); + private void OnSizeMonitorOnSizeChanged (object? _, SizeChangedEventArgs e) + { + Trace.Lifecycle (nameof (DriverImpl), "OnSizeMonitorOnSizeChanged", $"{e.Size?.Width}×{e.Size?.Height}"); + SetScreenSize (e.Size!.Value.Width, e.Size.Value.Height); + } /// public int Cols { get => _outputBuffer.Cols; set => _outputBuffer.Cols = value; } diff --git a/Terminal.Gui/Drivers/DriverRegistry.cs b/Terminal.Gui/Drivers/DriverRegistry.cs index 9cb0603f1b..fe1f234a36 100644 --- a/Terminal.Gui/Drivers/DriverRegistry.cs +++ b/Terminal.Gui/Drivers/DriverRegistry.cs @@ -17,9 +17,6 @@ public static class Names /// .NET System.Console cross-platform driver name. public const string DOTNET = "dotnet"; - /// Unix/Linux/macOS terminal driver name. - public const string UNIX = "unix"; - /// Pure ANSI escape sequence cross-platform driver name. public const string ANSI = "ansi"; } @@ -63,15 +60,6 @@ static DriverRegistry () () => new NetComponentFactory () )); - Register ( - new ( - Names.UNIX, - "Unix/Linux Terminal Driver", - "Optimized Unix/Linux/macOS driver with raw terminal mode", - [PlatformID.Unix, PlatformID.MacOSX], - () => new UnixComponentFactory () - )); - Register ( new ( Names.ANSI, @@ -145,7 +133,7 @@ public static DriverDescriptor GetDefaultDriver () if (p == PlatformID.Unix) { - return _registry [Names.UNIX]; + return _registry [Names.ANSI]; } // Fallback to dotnet diff --git a/Terminal.Gui/Drivers/Input/IInput.cs b/Terminal.Gui/Drivers/Input/IInput.cs index b5a6f96658..ab716105cf 100644 --- a/Terminal.Gui/Drivers/Input/IInput.cs +++ b/Terminal.Gui/Drivers/Input/IInput.cs @@ -42,7 +42,6 @@ namespace Terminal.Gui.Drivers; /// /// - Uses Windows Console API (ReadConsoleInput) /// - Uses .NET API -/// - Uses Unix terminal APIs /// - For testing, implements /// /// diff --git a/Terminal.Gui/Drivers/SizeDetectionMode.cs b/Terminal.Gui/Drivers/SizeDetectionMode.cs new file mode 100644 index 0000000000..a003e8d4e4 --- /dev/null +++ b/Terminal.Gui/Drivers/SizeDetectionMode.cs @@ -0,0 +1,21 @@ +namespace Terminal.Gui.Drivers; + +/// +/// Controls how the ANSI driver detects the terminal's window size. +/// +public enum SizeDetectionMode +{ + /// + /// Sends a CSI 18t ANSI escape-sequence query and parses the + /// ESC [ 8 ; height ; width t response. Works over SSH or any + /// ANSI-compatible terminal. This is the default. + /// + AnsiQuery, + + /// + /// Uses ioctl(TIOCGWINSZ) on Unix/macOS or the Console API on Windows. + /// Synchronous, immediate, and reliable. Useful when the ANSI query response + /// does not reflect the remote terminal size (e.g., over an SSH tunnel). + /// + Polling +} diff --git a/Terminal.Gui/Drivers/SizeMonitorImpl.cs b/Terminal.Gui/Drivers/SizeMonitorImpl.cs index f41b50d7e4..a487b68ebb 100644 --- a/Terminal.Gui/Drivers/SizeMonitorImpl.cs +++ b/Terminal.Gui/Drivers/SizeMonitorImpl.cs @@ -1,11 +1,25 @@ -using Microsoft.Extensions.Logging; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; -/// -internal class SizeMonitorImpl (IOutput consoleOut) : ISizeMonitor +/// +internal class SizeMonitorImpl : ISizeMonitor { - private Size _lastSize = Size.Empty; + private readonly IOutput _consoleOut; + private Size _lastSize; + + /// + /// Creates a new that polls for size changes. + /// The initial size is captured from at construction time so that the + /// first call only fires when the size has actually changed. + /// + public SizeMonitorImpl (IOutput consoleOut) + { + _consoleOut = consoleOut; + + // Capture the current size so the first Poll() is a no-op when the size has not changed. + _lastSize = consoleOut.GetSize (); + } /// Invoked when the terminal's size changed. The new size of the terminal is provided. public event EventHandler? SizeChanged; @@ -13,17 +27,18 @@ internal class SizeMonitorImpl (IOutput consoleOut) : ISizeMonitor /// public bool Poll () { - Size size = consoleOut.GetSize (); + Size size = _consoleOut.GetSize (); - if (size != _lastSize) + if (size == _lastSize) { - //Logging.Trace ($"Size changed from '{_lastSize}' to {size}"); - _lastSize = size; - SizeChanged?.Invoke (this, new (size)); - - return true; + return false; } - return false; + Trace.Lifecycle (nameof (SizeMonitorImpl), "Poll", $"{_lastSize} → {size}"); + _lastSize = size; + SizeChanged?.Invoke (this, new SizeChangedEventArgs (size)); + + return true; + } } diff --git a/Terminal.Gui/Drivers/UnixDriver/IUnixInput.cs b/Terminal.Gui/Drivers/UnixDriver/IUnixInput.cs deleted file mode 100644 index 0fba2873a8..0000000000 --- a/Terminal.Gui/Drivers/UnixDriver/IUnixInput.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Terminal.Gui.Drivers; - -/// -/// Wraps IConsoleInput for Unix console input events (char). Needed to support Mocking in tests. -/// -internal interface IUnixInput : IInput; diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixComponentFactory.cs b/Terminal.Gui/Drivers/UnixDriver/UnixComponentFactory.cs deleted file mode 100644 index 48cf8cab2f..0000000000 --- a/Terminal.Gui/Drivers/UnixDriver/UnixComponentFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Concurrent; - -namespace Terminal.Gui.Drivers; - -/// -/// implementation for native unix console I/O. -/// This factory creates instances of internal classes , etc. -/// -public class UnixComponentFactory : ComponentFactoryImpl -{ - /// - public override string? GetDriverName () { return DriverRegistry.Names.UNIX; } - - /// - public override IInput CreateInput () { return new UnixInput (); } - - /// - public override IInputProcessor CreateInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) { return new UnixInputProcessor (inputBuffer, timeProvider); } - - /// - public override IOutput CreateOutput () { return new UnixOutput (); } -} diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs b/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs deleted file mode 100644 index d049a83655..0000000000 --- a/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.Collections.Concurrent; -using Terminal.Gui.Tracing; - -// ReSharper disable IdentifierTypo -// ReSharper disable StringLiteralTypo -// ReSharper disable InconsistentNaming -// ReSharper disable CommentTypo - -namespace Terminal.Gui.Drivers; - -internal class UnixInput : InputImpl, IUnixInput, ITestableInput -{ - // Queue for storing injected input for testing - private readonly ConcurrentQueue _testInput = new (); - - // Platform-specific raw mode helper - private readonly UnixRawModeHelper _rawModeHelper = new (); - - private readonly bool _terminalInitialized; - private readonly UnixIOHelper.Pollfd []? _pollMap; - - public UnixInput () - { - // Check if we have a real console first - if (!IsAttachedToTerminal) - { - Trace.Lifecycle (nameof (UnixInput), "Init", "Console is not attached to a terminal. Running in degraded mode."); - - return; - } - - try - { - // Set up poll map using shared helper - _pollMap = UnixIOHelper.CreateStdinPollMap (); - - // Enable raw mode using the helper - _terminalInitialized = _rawModeHelper.TryEnable (); - - if (!_terminalInitialized) - { - return; - } - WriteRaw (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); - WriteRaw (EscSeqUtils.CSI_HideCursor); - - // CSI_EnableMouseEvents enables - // Mode 1003 (any-event) - Reports all mouse events including motion with/without buttons - // Mode 1015 (URXVT) - UTF-8 coordinate encoding (fallback for older terminals) - // Mode 1006 (SGR) - Modern decimal format with unlimited coordinates (preferred) - WriteRaw (EscSeqUtils.CSI_EnableMouseEvents); - } - catch (DllNotFoundException ex) - { - Logging.Information ($"UnixInput: libc not available: {ex.Message}. Running in degraded mode."); - _terminalInitialized = false; - } - catch (Exception ex) - { - Logging.Information ($"UnixInput: Failed to initialize terminal: {ex.Message}. Running in degraded mode."); - _terminalInitialized = false; - } - } - - /// - public override bool Peek () - { - // Check test input first - if (!_testInput.IsEmpty) - { - return true; - } - - if (!_terminalInitialized || _pollMap is null) - { - return false; - } - - return UnixIOHelper.IsInputAvailable (_pollMap); - } - - private void WriteRaw (string text) - { - if (!_terminalInitialized) - { - return; - } - - UnixIOHelper.TryWriteStdout (text); - } - - /// - public override IEnumerable Read () - { - // Return test input first if available - while (_testInput.TryDequeue (out char testChar)) - { - yield return testChar; - } - - if (!_terminalInitialized || _pollMap is null) - { - yield break; - } - - while (UnixIOHelper.IsInputAvailable (_pollMap)) - { - if ((_pollMap [0].revents & (int)UnixIOHelper.Condition.PollIn) == 0) - { - continue; - } - - var buf = new byte [256]; - - if (!UnixIOHelper.TryReadStdin (buf, out int bytesRead) || bytesRead <= 0) - { - continue; - } - - string input = Encoding.UTF8.GetString (buf, 0, bytesRead); - - foreach (char ch in input) - { - yield return ch; - } - } - } - - private void FlushConsoleInput () - { - if (!_terminalInitialized) - { - return; - } - - try - { - if (_pollMap == null) - { - return; - } - - var buf = new byte [256]; - - while (UnixIOHelper.IsInputAvailable (_pollMap)) - { - UnixIOHelper.TryReadStdin (buf, out _); - } - } - catch - { - // ignore - } - } - - /// - public void InjectInput (char input) => _testInput.Enqueue (input); - - /// - public override void Dispose () - { - base.Dispose (); - - if (!_terminalInitialized) - { - return; - } - - try - { - WriteRaw (EscSeqUtils.CSI_DisableMouseEvents); - FlushConsoleInput (); - UnixIOHelper.TryFlushStdin (); - WriteRaw (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); - WriteRaw (EscSeqUtils.CSI_ShowCursor); - - // Restore terminal settings using the helper - _rawModeHelper.Dispose (); - } - catch - { - // ignore exceptions during disposal - } - } -} diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixInputProcessor.cs b/Terminal.Gui/Drivers/UnixDriver/UnixInputProcessor.cs deleted file mode 100644 index 7abbbe2c06..0000000000 --- a/Terminal.Gui/Drivers/UnixDriver/UnixInputProcessor.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Concurrent; - -namespace Terminal.Gui.Drivers; - -/// -/// Input processor for , deals in stream. -/// -internal class UnixInputProcessor : InputProcessorImpl -{ - /// - /// The input buffer to process. - /// Time provider for timestamps and timing control. - public UnixInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) - : base (inputBuffer, new AnsiKeyConverter (), timeProvider) - { - } - - /// - public override void InjectKeyDownEvent (Key key) - { - // Convert Key → ANSI sequence (if needed) or char - string sequence = AnsiKeyboardEncoder.Encode (key); - - // If input supports testing, use it - if (InputImpl is not ITestableInput testableInput) - { - return; - } - - foreach (char ch in sequence) - { - testableInput.InjectInput (ch); - } - } - - /// - protected override void Process (char input) - { - foreach (Tuple released in Parser.ProcessInput (Tuple.Create (input, input))) - { - ProcessAfterParsing (released.Item2); - } - } - - /// - public override void InjectMouseEvent (IApplication? app, Mouse mouse) - { - base.InjectMouseEvent (app, mouse); - // Convert Mouse to ANSI SGR format escape sequence - string ansiSequence = AnsiMouseEncoder.Encode (mouse); - - // Enqueue each character of the ANSI sequence - if (InputImpl is not ITestableInput testableInput) - { - return; - } - - foreach (char ch in ansiSequence) - { - testableInput.InjectInput (ch); - } - } -} diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs deleted file mode 100644 index 619dddf416..0000000000 --- a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; -using Terminal.Gui.Tracing; - -// ReSharper disable IdentifierTypo -// ReSharper disable StringLiteralTypo -// ReSharper disable InconsistentNaming -// ReSharper disable CommentTypo - -namespace Terminal.Gui.Drivers; - -internal class UnixOutput : OutputBase, IOutput -{ - public UnixOutput () - { - if (!IsAttachedToTerminal) - { - Trace.Lifecycle (nameof (UnixOutput), "Init", "No real terminal attached. Output operations will be no-op."); - } - } - - /// - public void Suspend () - { - if (!IsAttachedToTerminal) - { - return; - } - - UnixTerminalHelper.Suspend (this); - } - - /// - public void Write (ReadOnlySpan text) - { - if (!IsAttachedToTerminal) - { - return; - } - - byte [] utf8 = Encoding.UTF8.GetBytes (text.ToArray ()); - UnixIOHelper.TryWriteStdout (utf8); - } - - /// - protected override void Write (StringBuilder output) - { - base.Write (output); - - if (!IsAttachedToTerminal) - { - return; - } - - byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ()); - UnixIOHelper.TryWriteStdout (utf8); - } - - private Cursor _currentCursor = new (); - - /// - public Cursor GetCursor () => _currentCursor; - - /// - public void SetCursor (Cursor cursor) - { - try - { - if (!cursor.IsVisible) - { - Write (EscSeqUtils.CSI_HideCursor); - } - else - { - if (_currentCursor.Style != cursor.Style) - { - Write (EscSeqUtils.CSI_SetCursorStyle (cursor.Style)); - } - - Write (EscSeqUtils.CSI_ShowCursor); - } - } - catch - { - // Ignore any exceptions - } - finally - { - SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); - - _currentCursor = cursor; - } - } - - /// - protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) - { - if (_currentCursor.Position is { } && _currentCursor.Position.Value.X == screenPositionX && _currentCursor.Position.Value.Y == screenPositionY) - { - return false; - } - - try - { - using TextWriter? writer = CreateUnixStdoutWriter (); - - // + 1 is needed because Unix is based on 1 instead of 0 and - EscSeqUtils.CSI_WriteCursorPosition (writer!, screenPositionY + 1, screenPositionX + 1); - } - catch - { - // ignore - } - - return true; - } - - private TextWriter? CreateUnixStdoutWriter () - { - // duplicate stdout so we don't mess with Console.Out's FD - int fdCopy = UnixIOHelper.dup (UnixIOHelper.STDOUT_FILENO); - - if (fdCopy == -1) - { - // Log but don't throw - we're likely running without a TTY (CI/CD, tests, etc.) - int errno = Marshal.GetLastWin32Error (); - Logging.Warning ($"Failed to dup STDOUT_FILENO, errno={errno}. Running without TTY support."); - - return null; // Return null instead of throwing - } - - try - { - // wrap the raw fd into a SafeFileHandle - var handle = new SafeFileHandle (fdCopy, true); - - // create FileStream from the safe handle - var stream = new FileStream (handle, FileAccess.Write); - - return new StreamWriter (stream) { AutoFlush = true }; - } - catch (Exception ex) - { - Logging.Warning ($"Failed to create TextWriter from dup'd STDOUT: {ex.Message}"); - - return null; - } - } - - /// - public Size GetSize () - { - if (!IsAttachedToTerminal) - { - return new Size (80, 25); - } - - if (UnixIOHelper.TryGetTerminalSize (out Size size)) - { - return size; - } - - return new Size (80, 25); // fallback - } - - /// - public void SetSize (int width, int height) - { - // Do nothing - } - - /// - public void Dispose () { } -} diff --git a/Terminal.Gui/Drivers/UnixDriver/SuspendHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/SuspendHelper.cs similarity index 70% rename from Terminal.Gui/Drivers/UnixDriver/SuspendHelper.cs rename to Terminal.Gui/Drivers/UnixHelpers/SuspendHelper.cs index 6bd80c8a5a..79aae05726 100644 --- a/Terminal.Gui/Drivers/UnixDriver/SuspendHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/SuspendHelper.cs @@ -1,4 +1,4 @@ -using System.Runtime.InteropServices; +using System.Runtime.InteropServices; namespace Terminal.Gui.Drivers; @@ -6,18 +6,25 @@ internal static class SuspendHelper { private static int _suspendSignal; - /// Suspends the process by sending SIGTSTP to itself + /// Suspends the process by sending SIGTSTP to the process group. /// True if the suspension was successful. public static bool Suspend () { int signal = GetSuspendSignal (); + Logging.Information ($"SuspendHelper.Suspend: signal={signal}"); + if (signal == -1) { + Logging.Warning ("SuspendHelper.Suspend: No suspend signal for this platform"); + return false; } - killpg (0, signal); + Logging.Information ($"SuspendHelper.Suspend: Calling killpg(0, {signal}) [SIGTSTP]..."); + int result = killpg (0, signal); + int errno = Marshal.GetLastWin32Error (); + Logging.Information ($"SuspendHelper.Suspend: killpg returned {result}, errno={errno}"); return true; } @@ -51,16 +58,19 @@ private static int GetSuspendSignal () _suspendSignal = 18; break; + case "Linux": // TODO: should fetch the machine name and // if it is MIPS return 24 _suspendSignal = 20; break; + case "Solaris": _suspendSignal = 24; break; + default: _suspendSignal = -1; @@ -75,8 +85,8 @@ private static int GetSuspendSignal () } } - [DllImport ("libc")] - private static extern int killpg (int pgrp, int pid); + [DllImport ("libc", SetLastError = true)] + private static extern int killpg (int pgrp, int sig); [DllImport ("libc")] private static extern int uname (nint buf); diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixClipboard.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs similarity index 87% rename from Terminal.Gui/Drivers/UnixDriver/UnixClipboard.cs rename to Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs index acfc35b36a..40fc0f118e 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixClipboard.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixClipboard.cs @@ -18,8 +18,7 @@ protected override string GetClipboardDataImpl () try { - (int exitCode, string result) = - ClipboardProcessRunner.Bash ($"{_xclipPath} {xclipargs} > {tempFileName}", waitForOutput: false); + (int exitCode, string result) = ClipboardProcessRunner.Bash ($"{_xclipPath} {xclipargs} > {tempFileName}", waitForOutput: false); if (exitCode == 0) { @@ -96,17 +95,9 @@ internal class MacOSXClipboard : ClipboardBase public MacOSXClipboard () { - _utfTextType = objc_msgSend ( - objc_msgSend (_nsString, _allocRegister), - _initWithUtf8Register, - "public.utf8-plain-text" - ); - - _nsStringPboardType = objc_msgSend ( - objc_msgSend (_nsString, _allocRegister), - _initWithUtf8Register, - "NSStringPboardType" - ); + _utfTextType = objc_msgSend (objc_msgSend (_nsString, _allocRegister), _initWithUtf8Register, "public.utf8-plain-text"); + + _nsStringPboardType = objc_msgSend (objc_msgSend (_nsString, _allocRegister), _initWithUtf8Register, "NSStringPboardType"); _generalPasteboard = objc_msgSend (_nsPasteboard, _generalPasteboardRegister); IsSupported = CheckSupport (); } @@ -213,10 +204,7 @@ protected override void SetClipboardDataImpl (string text) return; } - (int exitCode, string output) = ClipboardProcessRunner.Process ( - _powershellPath, - $"-noprofile -command \"Set-Clipboard -Value \\\"{text}\\\"\"" - ); + (int exitCode, string output) = ClipboardProcessRunner.Process (_powershellPath, $"-noprofile -command \"Set-Clipboard -Value \\\"{text}\\\"\""); } private bool CheckSupport () diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs similarity index 86% rename from Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs rename to Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs index cf660584e6..410e68c537 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixIOHelper.cs @@ -117,6 +117,30 @@ public enum Condition : short [DllImport ("libc", SetLastError = true)] public static extern int dup (int fd); + /// + /// Wait until all output written to the file descriptor has been transmitted. + /// + /// File descriptor (typically ). + /// 0 on success, -1 on error. + [DllImport ("libc", SetLastError = true)] + public static extern int tcdrain (int fd); + + /// + /// Synchronise a file descriptor's state with the underlying storage device. + /// + /// File descriptor. + /// 0 on success, -1 on error. + [DllImport ("libc", SetLastError = true)] + public static extern int fsync (int fd); + + /// + /// Test whether a file descriptor refers to a terminal. + /// + /// File descriptor to test. + /// 1 if is a terminal; 0 otherwise. + [DllImport ("libc", SetLastError = true)] + public static extern int isatty (int fd); + #endregion #region Terminal Queue Selectors @@ -296,6 +320,35 @@ public static bool TryFlushStdin () } } + /// + /// Waits until all output written to stdout has been transmitted to the terminal. + /// Prefers tcdrain; falls back to fsync. + /// + public static void FlushStdout () + { + if (tcdrain (STDOUT_FILENO) == 0) + { + return; + } + + // fallback + try + { + fsync (STDOUT_FILENO); + } + catch + { + // ignore + } + } + + /// + /// Tests whether the given file descriptor is connected to a terminal device. + /// + /// File descriptor to test (e.g. ). + /// if is a terminal. + public static bool IsTerminal (int fd) => isatty (fd) == 1; + /// /// Gets the terminal size using ioctl. /// diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixRawModeHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs similarity index 99% rename from Terminal.Gui/Drivers/UnixDriver/UnixRawModeHelper.cs rename to Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs index 344a7ef5ee..33569cc9c5 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixRawModeHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixRawModeHelper.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; + // ReSharper disable IdentifierTypo // ReSharper disable StringLiteralTypo // ReSharper disable InconsistentNaming @@ -22,57 +23,6 @@ namespace Terminal.Gui.Drivers; /// internal sealed class UnixRawModeHelper : IDisposable { - #region P/Invoke Declarations - - [StructLayout (LayoutKind.Sequential)] - private struct Termios - { - public uint c_iflag; - public uint c_oflag; - public uint c_cflag; - public uint c_lflag; - - [MarshalAs (UnmanagedType.ByValArray, SizeConst = 32)] - public byte [] c_cc; - - public uint c_ispeed; - public uint c_ospeed; - } - - // Terminal attribute flags - private const int STDIN_FILENO = 0; - private const int TCSANOW = 0; - - // Input flags - private const uint BRKINT = 0x00000002; // Signal on break - private const uint ICRNL = 0x00000100; // Map CR to NL - private const uint INPCK = 0x00000010; // Enable parity checking - private const uint ISTRIP = 0x00000020; // Strip 8th bit - private const uint IXON = 0x00000400; // Enable XON/XOFF flow control - - // Output flags - private const uint OPOST = 0x00000001; // Post-process output - - // Control flags - private const uint CS8 = 0x00000030; // 8-bit characters - - // Local flags - private const uint ECHO = 0x00000008; // Echo input - private const uint ICANON = 0x00000100; // Canonical mode (line buffering) - private const uint IEXTEN = 0x00008000; // Extended input processing - private const uint ISIG = 0x00000001; // Generate signals - - [DllImport ("libc", SetLastError = true)] - private static extern int tcgetattr (int fd, out Termios termios); - - [DllImport ("libc", SetLastError = true)] - private static extern int tcsetattr (int fd, int optional_actions, ref Termios termios); - - [DllImport ("libc", EntryPoint = "cfmakeraw", SetLastError = false)] - private static extern void cfmakeraw_ref (ref Termios termios); - - #endregion - private Termios _originalTermios; private bool _disposed; @@ -93,7 +43,7 @@ public bool TryEnable () } // Only attempt on Unix-like platforms - if (!PlatformDetection.IsUnixLike()) + if (!PlatformDetection.IsUnixLike ()) { return false; } @@ -191,4 +141,55 @@ public void Dispose () Restore (); _disposed = true; } + + #region P/Invoke Declarations + + [StructLayout (LayoutKind.Sequential)] + private struct Termios + { + public uint c_iflag; + public uint c_oflag; + public uint c_cflag; + public uint c_lflag; + + [MarshalAs (UnmanagedType.ByValArray, SizeConst = 32)] + public byte [] c_cc; + + public uint c_ispeed; + public uint c_ospeed; + } + + // Terminal attribute flags + private const int STDIN_FILENO = 0; + private const int TCSANOW = 0; + + // Input flags + private const uint BRKINT = 0x00000002; // Signal on break + private const uint ICRNL = 0x00000100; // Map CR to NL + private const uint INPCK = 0x00000010; // Enable parity checking + private const uint ISTRIP = 0x00000020; // Strip 8th bit + private const uint IXON = 0x00000400; // Enable XON/XOFF flow control + + // Output flags + private const uint OPOST = 0x00000001; // Post-process output + + // Control flags + private const uint CS8 = 0x00000030; // 8-bit characters + + // Local flags + private const uint ECHO = 0x00000008; // Echo input + private const uint ICANON = 0x00000100; // Canonical mode (line buffering) + private const uint IEXTEN = 0x00008000; // Extended input processing + private const uint ISIG = 0x00000001; // Generate signals + + [DllImport ("libc", SetLastError = true)] + private static extern int tcgetattr (int fd, out Termios termios); + + [DllImport ("libc", SetLastError = true)] + private static extern int tcsetattr (int fd, int optional_actions, ref Termios termios); + + [DllImport ("libc", EntryPoint = "cfmakeraw", SetLastError = false)] + private static extern void cfmakeraw_ref (ref Termios termios); + + #endregion } diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs similarity index 71% rename from Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs rename to Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs index 5c48fd33ef..fcaa83e7d0 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs +++ b/Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs @@ -6,8 +6,12 @@ namespace Terminal.Gui.Drivers; internal static class UnixTerminalHelper { + // Use a raw buffer for termios save/restore since the struct layout differs between + // macOS (72 bytes: ulong flags, NCCS=20, ulong speeds) and Linux (56 bytes: uint flags, NCCS=32, uint speeds). + // We never interpret individual fields; we just save and restore the entire blob. + private const int TermiosBufferSize = 128; + private static nint _origTermios; private static bool _savedTermios; - private static Termios _origTermios; private static int _ttyFd = -1; public static void SaveTerminalState () @@ -17,7 +21,12 @@ public static void SaveTerminalState () _ttyFd = open ("/dev/tty", O_RDWR); } - if (_ttyFd != -1 && tcgetattr (_ttyFd, out _origTermios) == 0) + if (_origTermios == nint.Zero) + { + _origTermios = Marshal.AllocHGlobal (TermiosBufferSize); + } + + if (_ttyFd != -1 && tcgetattr (_ttyFd, _origTermios) == 0) { _savedTermios = true; } @@ -31,6 +40,7 @@ public static void SaveTerminalState () { // Ignore any exceptions during close, as we're already in a cleanup phase } + _ttyFd = -1; } } @@ -39,9 +49,9 @@ public static void RestoreTerminalState () { if (_ttyFd != -1) { - if (_savedTermios) + if (_savedTermios && _origTermios != nint.Zero) { - if (tcsetattr (_ttyFd, TCSANOW, ref _origTermios) != 0) + if (tcsetattr (_ttyFd, TCSANOW, _origTermios) != 0) { // fallback to stty sane RunSttySane (); @@ -110,24 +120,32 @@ public static void Suspend (IOutput output) return; } - // Save terminal state before suspending + // Save the current terminal state (raw mode) so we can restore it after resume. SaveTerminalState (); - // Disable alternative screen buffer and show cursor + // Leave the alternate screen buffer and show cursor. output.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); - - //Set cursor key to cursor. output.Write (EscSeqUtils.CSI_ShowCursor); + // Restore terminal to cooked mode so the shell can function while we're stopped. + // The raw mode state was saved above and will be restored after resume. + RunSttySane (); + + Logging.Information ("UnixTerminalHelper.Suspend: Terminal restored to cooked mode, calling SuspendHelper.Suspend..."); + if (!SuspendHelper.Suspend ()) { + Logging.Warning ("UnixTerminalHelper.Suspend: SuspendHelper.Suspend() returned false"); + return; } - // Restore terminal state after resuming + Logging.Information ("UnixTerminalHelper.Suspend: Resumed from suspend! Restoring raw mode..."); + + // Restore the saved raw mode terminal state. RestoreTerminalState (); - //Enable alternative screen buffer. + // Re-enter the alternate screen buffer. output.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); } catch (Exception ex) @@ -146,23 +164,6 @@ public static void Suspend (IOutput output) private const int O_RDWR = 2; private const int TCSANOW = 0; - [StructLayout (LayoutKind.Sequential)] - private struct Termios - { -#pragma warning disable IDE1006 // Naming Styles - public uint c_iflag; - public uint c_oflag; - public uint c_cflag; - public uint c_lflag; - - [MarshalAs (UnmanagedType.ByValArray, SizeConst = 32)] - public byte [] c_cc; - - public uint c_ispeed; - public uint c_ospeed; -#pragma warning restore IDE1006 // Naming Styles - } - [DllImport ("libc", SetLastError = true)] private static extern int open (string path, int oflag); @@ -170,8 +171,8 @@ private struct Termios private static extern int close (int fd); [DllImport ("libc", SetLastError = true)] - private static extern int tcgetattr (int fd, out Termios termios_p); + private static extern int tcgetattr (int fd, nint termios_p); [DllImport ("libc", SetLastError = true)] - private static extern int tcsetattr (int fd, int optional_actions, ref Termios termios_p); + private static extern int tcsetattr (int fd, int optional_actions, nint termios_p); } diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetWinVTConsole.cs b/Terminal.Gui/Drivers/WindowsHelpers/NetWinVTConsole.cs similarity index 100% rename from Terminal.Gui/Drivers/DotNetDriver/NetWinVTConsole.cs rename to Terminal.Gui/Drivers/WindowsHelpers/NetWinVTConsole.cs diff --git a/Terminal.Gui/Drivers/WindowsHelpers/WindowsConsoleHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsConsoleHelper.cs new file mode 100644 index 0000000000..6464e4f06c --- /dev/null +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsConsoleHelper.cs @@ -0,0 +1,44 @@ +using System.Runtime.InteropServices; + +namespace Terminal.Gui.Drivers; + +/// +/// Shared helper for low-level Windows Console API calls that are +/// not specific to the VT input or output helpers. +/// +internal static class WindowsConsoleHelper +{ + private const int STD_INPUT_HANDLE = -10; + private const int STD_OUTPUT_HANDLE = -11; + + /// + /// Tests whether both stdin and stdout are connected to a real console device. + /// + /// + /// if stdin is connected to a console device. + /// + /// + /// if stdout is connected to a console device. + /// + /// if both handles are attached. + public static bool IsAttachedToTerminal (out bool inputAttached, out bool outputAttached) + { + nint inH = GetStdHandle (STD_INPUT_HANDLE); + nint outH = GetStdHandle (STD_OUTPUT_HANDLE); + + inputAttached = inH != nint.Zero && GetConsoleMode (inH, out _); + outputAttached = outH != nint.Zero && GetConsoleMode (outH, out _); + + return inputAttached && outputAttached; + } + + #region P/Invoke + + [DllImport ("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle (int nStdHandle); + + [DllImport ("kernel32.dll")] + private static extern bool GetConsoleMode (nint hConsoleHandle, out uint lpMode); + + #endregion +} diff --git a/Terminal.Gui/Drivers/AnsiDriver/WindowsVTInputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs similarity index 99% rename from Terminal.Gui/Drivers/AnsiDriver/WindowsVTInputHelper.cs rename to Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs index c293950929..a56a384d70 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/WindowsVTInputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTInputHelper.cs @@ -1,5 +1,4 @@ using System.Runtime.InteropServices; -using System.Text; using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; diff --git a/Terminal.Gui/Drivers/AnsiDriver/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs similarity index 91% rename from Terminal.Gui/Drivers/AnsiDriver/WindowsVTOutputHelper.cs rename to Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs index 859117ef03..d9eddfa2f4 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/WindowsVTOutputHelper.cs +++ b/Terminal.Gui/Drivers/WindowsHelpers/WindowsVTOutputHelper.cs @@ -37,6 +37,10 @@ internal sealed class WindowsVTOutputHelper : IDisposable [DllImport ("kernel32.dll")] public static extern bool WriteFile (nint hConsoleHandle, byte [] lpBuffer, uint nNumberOfBytesToWrite, out uint lpNumberOfBytesWritten, nint lpOverlapped); + [DllImport ("kernel32.dll", SetLastError = true)] + [return: MarshalAs (UnmanagedType.Bool)] + private static extern bool FlushFileBuffers (nint hFile); + #endregion private uint _originalConsoleMode; @@ -116,6 +120,7 @@ public bool TryEnable () } IsEnabled = true; + //Logging.Information ($"Windows VTS output mode enabled successfully. Mode: 0x{newMode:X} (was 0x{_originalConsoleMode:X})"); return true; @@ -142,6 +147,7 @@ public void Restore () { SetConsoleMode (OutputHandle, _originalConsoleMode); IsEnabled = false; + //Logging.Information ("Windows console mode restored."); } catch (Exception ex) @@ -169,4 +175,17 @@ public void Write (StringBuilder output) WriteFile (OutputHandle, byteArray, (uint)byteArray.Length, out _, nint.Zero); } + + /// + /// Flushes the stdout handle via FlushFileBuffers. + /// + public static void FlushStdout () + { + nint h = GetStdHandle (STD_OUTPUT_HANDLE); + + if (h != nint.Zero && h != new nint (-1)) + { + FlushFileBuffers (h); + } + } } diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index 859d404d40..7602bc44c2 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -566,6 +566,7 @@ True True True + True True True True @@ -583,6 +584,7 @@ True True True + True True True True diff --git a/Tests/AppTestHelpers/AppTestHelper.cs b/Tests/AppTestHelpers/AppTestHelper.cs index a7d25954e2..2056ca44df 100644 --- a/Tests/AppTestHelpers/AppTestHelper.cs +++ b/Tests/AppTestHelpers/AppTestHelper.cs @@ -254,12 +254,6 @@ private void CommonInit (int width, int height, TimeSpan? timeout) break; - case DriverRegistry.Names.UNIX: - _sizeMonitor = new SizeMonitorImpl (_output); - cf = new AnsiComponentFactory (_ansiInput, _output, _sizeMonitor); - - break; - case DriverRegistry.Names.ANSI: _sizeMonitor = new SizeMonitorImpl (_output); cf = new AnsiComponentFactory (_ansiInput, _output, _sizeMonitor); diff --git a/Tests/IntegrationTests/FluentTests/FileDialogTests.cs b/Tests/IntegrationTests/FluentTests/FileDialogTests.cs index 9580a79af4..3b9b90bb1e 100644 --- a/Tests/IntegrationTests/FluentTests/FileDialogTests.cs +++ b/Tests/IntegrationTests/FluentTests/FileDialogTests.cs @@ -280,7 +280,6 @@ public IEnumerator GetEnumerator () { yield return [DriverRegistry.Names.WINDOWS, false]; yield return [DriverRegistry.Names.DOTNET, false]; - yield return [DriverRegistry.Names.UNIX, true]; yield return [DriverRegistry.Names.ANSI, true]; } diff --git a/Tests/UnitTests/Application/SynchronizatonContextTests.cs b/Tests/UnitTests/Application/SynchronizatonContextTests.cs index e90c774184..e124b810bc 100644 --- a/Tests/UnitTests/Application/SynchronizatonContextTests.cs +++ b/Tests/UnitTests/Application/SynchronizatonContextTests.cs @@ -26,7 +26,6 @@ public void SynchronizationContext_CreateCopy () [InlineData (DriverRegistry.Names.ANSI)] [InlineData (DriverRegistry.Names.WINDOWS)] [InlineData (DriverRegistry.Names.DOTNET)] - [InlineData (DriverRegistry.Names.UNIX)] public void SynchronizationContext_Post (string driverName = null) { lock (_lockPost) diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiComponentFactorySizeMonitorTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiComponentFactorySizeMonitorTests.cs new file mode 100644 index 0000000000..e1f71d6fae --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiComponentFactorySizeMonitorTests.cs @@ -0,0 +1,199 @@ +using Moq; + +namespace DriverTests.Ansi; + +// Copilot + +/// +/// Tests for to verify the correct +/// implementation is selected based on +/// and whether an injected monitor is present. +/// +[Collection ("Driver Tests")] +public class AnsiComponentFactorySizeMonitorTests +{ + /// + /// When a size monitor is provided to the constructor it must be returned as-is, + /// regardless of . + /// + [Fact] + public void CreateSizeMonitor_InjectedMonitor_IsReturnedDirectly () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + Mock injected = new (); + + AnsiComponentFactory factory = new (null, output, injected.Object); + + ISizeMonitor result = factory.CreateSizeMonitor (output, Mock.Of ()); + + Assert.Same (injected.Object, result); + } + + /// + /// When is active (the default), + /// should return an + /// for an argument. + /// + [Fact] + public void CreateSizeMonitor_AnsiQuery_ReturnsAnsiSizeMonitor () + { + SizeDetectionMode saved = Driver.SizeDetection; + + try + { + Driver.SizeDetection = SizeDetectionMode.AnsiQuery; + + AnsiOutput output = new (); + output.SetSize (80, 25); + + AnsiComponentFactory factory = new (); + + ISizeMonitor result = factory.CreateSizeMonitor (output, Mock.Of ()); + + Assert.IsType (result); + } + finally + { + Driver.SizeDetection = saved; + } + } + + /// + /// When is active, + /// should return a + /// and configure + /// so that GetSize() queries the OS instead of returning the stale cache. + /// + [Fact] + public void CreateSizeMonitor_Polling_ReturnsSizeMonitorImpl_AndSetsNativeSizeQuery () + { + SizeDetectionMode saved = Driver.SizeDetection; + + try + { + Driver.SizeDetection = SizeDetectionMode.Polling; + + AnsiOutput output = new (); + output.SetSize (80, 25); + + AnsiComponentFactory factory = new (); + + ISizeMonitor result = factory.CreateSizeMonitor (output, Mock.Of ()); + + Assert.IsType (result); + + // NativeSizeQuery must have been wired up so GetSize() can query the OS. + Assert.NotNull (output.NativeSizeQuery); + } + finally + { + Driver.SizeDetection = saved; + } + } + + /// + /// In mode the NativeSizeQuery delegate + /// causes to return the OS-provided size rather than the + /// stale 80×25 cache, so correctly detects terminal resizes. + /// + [Fact] + public void Polling_NativeSizeQuery_OverridesStaleCache () + { + AnsiOutput output = new (); + output.SetSize (80, 25); // cached, stale + + // Simulate OS reporting 120×40 + Size fakeOsSize = new (120, 40); + output.NativeSizeQuery = () => fakeOsSize; + + Assert.Equal (fakeOsSize, output.GetSize ()); + } + + /// + /// Verifies that returns a callable + /// delegate (non-null) on every supported platform. + /// + [Fact] + public void CreateNativeSizeQuery_ReturnsNonNullDelegate () + { + Func query = AnsiComponentFactory.CreateNativeSizeQuery (); + + Assert.NotNull (query); + + // The delegate must be callable without throwing in a test environment. + // It may return null when there is no real terminal, and that is fine. + Size? size = null; + Exception? ex = Record.Exception (() => { size = query (); }); + Assert.Null (ex); + } + + /// + /// Validates the full pipeline: in mode, + /// the wrapping the fires + /// when the OS size changes. + /// + [Fact] + public void Polling_SizeMonitorImpl_FiresSizeChanged_WhenNativeSizeChanges () + { + // Test the SizeMonitorImpl+AnsiOutput pipeline directly with a controllable NativeSizeQuery. + AnsiOutput output = new (); + output.SetSize (80, 25); + + // Wire up a fake native size query that starts at 80x25. + Size reportedSize = new (80, 25); + output.NativeSizeQuery = () => reportedSize; + + // Constructor captures current size so first Poll() is a no-op. + SizeMonitorImpl monitor = new (output); + + List events = []; + monitor.SizeChanged += (_, e) => events.Add (e); + + // First poll: size unchanged (80x25) → no event. + monitor.Poll (); + Assert.Empty (events); + + // Simulate a terminal resize reported by the OS. + reportedSize = new Size (120, 40); + + monitor.Poll (); + + Assert.Single (events); + Assert.Equal (new Size (120, 40), events [0].Size); + } + + /// + /// In mode the injected-monitor code path + /// is still respected — injected monitors are always returned regardless of mode. + /// + [Fact] + public void CreateSizeMonitor_InjectedMonitor_WinsOverMode () + { + SizeDetectionMode saved = Driver.SizeDetection; + + try + { + foreach (SizeDetectionMode mode in Enum.GetValues ()) + { + Driver.SizeDetection = mode; + + AnsiOutput output = new (); + output.SetSize (80, 25); + + Mock injected = new (); + + AnsiComponentFactory factory = new (null, output, injected.Object); + + ISizeMonitor result = factory.CreateSizeMonitor (output, Mock.Of ()); + + Assert.Same (injected.Object, result); + } + } + finally + { + Driver.SizeDetection = saved; + } + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs new file mode 100644 index 0000000000..fb240123dc --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs @@ -0,0 +1,208 @@ +using Moq; +using Terminal.Gui.Tracing; + +namespace DriverTests.Ansi; + +// Copilot + +/// +/// Behavioral tests for . +/// +[Collection ("Driver Tests")] +public class AnsiSizeMonitorTests +{ + /// + /// Simulates a terminal size-query response arriving and verifies that + /// is raised with the correct dimensions. + /// + [Fact] + public void HandleSizeResponse_UpdatesSize_And_RaisesEvent () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + AnsiEscapeSequenceRequest? captured = null; + AnsiSizeMonitor monitor = new (output, req => captured = req); + + Size? raisedSize = null; + monitor.SizeChanged += (_, e) => raisedSize = e.Size; + + // Trigger the query so the ResponseReceived callback is registered. + monitor.Poll (); + Assert.NotNull (captured); + + // Simulate terminal responding with 100 columns × 30 rows. + captured!.ResponseReceived! ("[8;30;100t"); + + Assert.Equal (new Size (100, 30), raisedSize); + Assert.Equal (new Size (100, 30), output.GetSize ()); + } + + /// + /// When the terminal responds with the same size that is already cached, + /// must NOT be raised. + /// + [Fact] + public void HandleSizeResponse_SameSize_DoesNotRaiseEvent () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + AnsiEscapeSequenceRequest? captured = null; + AnsiSizeMonitor monitor = new (output, req => captured = req); + + var raised = false; + monitor.SizeChanged += (_, _) => raised = true; + + monitor.Poll (); + Assert.NotNull (captured); + + // Respond with the current size — no change. + captured!.ResponseReceived! ("[8;25;80t"); + + Assert.False (raised); + } + + /// + /// The first call (outside the throttle window) + /// must enqueue exactly one ANSI request. + /// + [Fact] + public void Poll_SendsQuery_WhenNotThrottled () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + var queued = 0; + AnsiSizeMonitor monitor = new (output, _ => queued++); + + monitor.Poll (); + + Assert.Equal (1, queued); + } + + /// + /// A second call immediately after the first + /// must NOT enqueue another request (throttled within the 500 ms window). + /// + [Fact] + public void Poll_DoesNotSendQuery_WhenThrottled () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + var queued = 0; + AnsiSizeMonitor monitor = new (output, _ => queued++); + + monitor.Poll (); // First call — sends query, now in throttle window. + monitor.Poll (); // Second call — still within 500 ms, must be suppressed. + + Assert.Equal (1, queued); + } + + /// + /// After a response is received (not expecting another) and the throttle window + /// expires, the next must send a new query. + /// + [Fact] + public void Poll_SendsQuery_AfterThrottle_And_ResponseReceived () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + List requests = []; + AnsiSizeMonitor monitor = new (output, req => requests.Add (req)); + + // First poll — sends query #1. + monitor.Poll (); + Assert.Single (requests); + + // Complete response — clears _expectingResponse. + requests [0].ResponseReceived! ("[8;25;80t"); + + // Advance time by simulating: we can't easily fast-forward DateTime.Now, + // so we simply confirm a second poll does NOT send while throttled. + monitor.Poll (); + Assert.Single (requests); // Still only 1 — throttle window not expired. + } + + /// + /// must wire the driver's queue and send + /// the initial size query immediately. + /// + [Fact] + public void Initialize_SendsInitialQuery () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + // Create without a pre-wired queue — Initialize must supply it. + AnsiSizeMonitor monitor = new (output); + + AnsiEscapeSequenceRequest? queued = null; + Mock driverMock = new (); + driverMock.Setup (d => d.QueueAnsiRequest (It.IsAny ())).Callback (r => queued = r); + + monitor.Initialize (driverMock.Object); + + Assert.NotNull (queued); + Assert.Contains (EscSeqUtils.CSI_ReportWindowSizeInChars.Request, queued!.Request); + } + + /// + /// A size-change response must propagate end-to-end: ANSI response → monitor → + /// event with correct . + /// + [Fact] + public void SizeChange_PropagatesThrough_MonitorEvent () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + AnsiEscapeSequenceRequest? captured = null; + AnsiSizeMonitor monitor = new (output, req => captured = req); + + List sizes = []; + monitor.SizeChanged += (_, e) => sizes.Add (e.Size!.Value); + + monitor.Poll (); + captured!.ResponseReceived! ("[8;40;120t"); + + Assert.Single (sizes); + Assert.Equal (new Size (120, 40), sizes [0]); + } + +#if DEBUG + + /// + /// With a and enabled, + /// a size change must emit trace entries covering at minimum the response handling + /// and the size-change notification. + /// + [Fact] + public void SizeChange_EmitsLifecycleTraces () + { + AnsiOutput output = new (); + output.SetSize (80, 25); + + AnsiEscapeSequenceRequest? captured = null; + AnsiSizeMonitor monitor = new (output, req => captured = req); + + ListBackend backend = new (); + + using (Trace.PushScope (TraceCategory.Lifecycle, backend)) + { + monitor.Poll (); + captured!.ResponseReceived! ("[8;30;100t"); + } + + Assert.NotEmpty (backend.Entries); + + // At minimum: SendSizeQuery + HandleSizeResponse + SizeChanged. + Assert.Contains (backend.Entries, e => e.Phase == "SendSizeQuery"); + Assert.Contains (backend.Entries, e => e.Phase == "HandleSizeResponse"); + Assert.Contains (backend.Entries, e => e.Phase == "SizeChanged"); + } + +#endif +} diff --git a/Tests/UnitTestsParallelizable/Drivers/SizeMonitorTests.cs b/Tests/UnitTestsParallelizable/Drivers/SizeMonitorTests.cs index 94422ea36e..8b8456e505 100644 --- a/Tests/UnitTestsParallelizable/Drivers/SizeMonitorTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/SizeMonitorTests.cs @@ -5,72 +5,84 @@ namespace DriverTests; [Collection ("Driver Tests")] public class SizeMonitorTests { + // Copilot + [Fact] - public void TestSizeMonitor_RaisesEventWhenChanges () + public void SizeMonitorImpl_DoesNotRaiseEvent_OnFirstPoll_WhenSizeUnchanged () { + // Arrange: initial GetSize() returns 30x20 (captured in constructor). + // Poll returns the same size → no event should fire. Mock consoleOutput = new (); - Queue queue = new ( - [ - new (30, 20), - new (20, 20) + Queue queue = new ([ + new Size (30, 20), // constructor call + new Size (30, 20) // Poll 1 – unchanged ]); - consoleOutput.Setup (m => m.GetSize ()) - .Returns (queue.Dequeue); - - var outputBuffer = Mock.Of (); + consoleOutput.Setup (m => m.GetSize ()).Returns (queue.Dequeue); - var monitor = new SizeMonitorImpl (consoleOutput.Object); + SizeMonitorImpl monitor = new (consoleOutput.Object); List result = []; - monitor.SizeChanged += (s, e) => { result.Add (e); }; + monitor.SizeChanged += (_, e) => { result.Add (e); }; + + monitor.Poll (); Assert.Empty (result); + } + + [Fact] + public void SizeMonitorImpl_RaisesEvent_WhenSizeChanges () + { + // Arrange: initial size 30x20, then size changes to 40x25. + Mock consoleOutput = new (); + + Queue queue = new ([ + new Size (30, 20), // constructor call + new Size (40, 25), // Poll 1 – changed + new Size (40, 25) // Poll 2 – unchanged + ]); + + consoleOutput.Setup (m => m.GetSize ()).Returns (queue.Dequeue); + + SizeMonitorImpl monitor = new (consoleOutput.Object); + + List result = []; + monitor.SizeChanged += (_, e) => { result.Add (e); }; + monitor.Poll (); Assert.Single (result); - Assert.Equal (new Size (30, 20), result [0].Size); + Assert.Equal (new Size (40, 25), result [0].Size); monitor.Poll (); - Assert.Equal (2, result.Count); - Assert.Equal (new Size (30, 20), result [0].Size); - Assert.Equal (new Size (20, 20), result [1].Size); + Assert.Single (result); // second poll: no further change } [Fact] - public void TestSizeMonitor_DoesNotRaiseEventWhen_NoChanges () + public void SizeMonitorImpl_RaisesEvent_OnEachDistinctSizeChange () { Mock consoleOutput = new (); - Queue queue = new ( - [ - new (30, 20), - new (30, 20) + Queue queue = new ([ + new Size (30, 20), // constructor call + new Size (40, 25), // Poll 1 – changed + new Size (50, 30) // Poll 2 – changed again ]); - consoleOutput.Setup (m => m.GetSize ()) - .Returns (queue.Dequeue); - - var outputBuffer = Mock.Of (); + consoleOutput.Setup (m => m.GetSize ()).Returns (queue.Dequeue); - var monitor = new SizeMonitorImpl (consoleOutput.Object); + SizeMonitorImpl monitor = new (consoleOutput.Object); List result = []; - monitor.SizeChanged += (s, e) => { result.Add (e); }; + monitor.SizeChanged += (_, e) => { result.Add (e); }; - // First poll always raises event because going from unknown size i.e. 0,0 - Assert.Empty (result); monitor.Poll (); - - Assert.Single (result); - Assert.Equal (new Size (30, 20), result [0].Size); - - // No change monitor.Poll (); - Assert.Single (result); - Assert.Equal (new Size (30, 20), result [0].Size); + Assert.Equal (2, result.Count); + Assert.Equal (new Size (40, 25), result [0].Size); + Assert.Equal (new Size (50, 30), result [1].Size); } } diff --git a/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs deleted file mode 100644 index d91bcba517..0000000000 --- a/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs +++ /dev/null @@ -1,136 +0,0 @@ -#nullable enable - -namespace DriverTests.UnixDriver; - -/// -/// Low-level tests for UnixInput and UnixOutput implementations. -/// These tests are designed to fail with good error messages when run in environments -/// without a real terminal (like GitHub Actions). -/// -[Trait ("Platform", "Unix")] -[Collection ("Driver Tests")] -public class UnixInputOutputTests (ITestOutputHelper output) -{ - private readonly ITestOutputHelper _output = output; - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void UnixInput_Constructor_DoesNotThrow_WhenNoTerminalAvailable () - { - if (OperatingSystem.IsWindows ()) - { - _output.WriteLine ("Skipping Unix test on Windows"); - - return; - } - - // Arrange & Act - Exception? exception = Record.Exception (() => - { - try - { - using var input = new UnixInput (); - _output.WriteLine ("UnixInput created successfully"); - } - catch (Exception ex) - { - _output.WriteLine ($"Expected failure on non-terminal: {ex.Message}"); - - throw new InvalidOperationException ( - $"UnixInput failed in non-terminal environment: {ex.Message}\nThis is expected in GitHub Actions. The driver should detect this and handle gracefully."); - } - }); - - // Assert - if (exception != null && !(exception is InvalidOperationException)) - { - _output.WriteLine ($"FAILED: UnixInput constructor threw: {exception.GetType ().Name}: {exception.Message}"); - _output.WriteLine ($"Stack trace: {exception.StackTrace}"); - } - } - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void UnixOutput_Constructor_DoesNotThrow_WhenNoTerminalAvailable () - { - if (OperatingSystem.IsWindows ()) - { - _output.WriteLine ("Skipping Unix test on Windows"); - - return; - } - - // Arrange & Act - Exception? exception = Record.Exception (() => - { - using var output = new UnixOutput (); - _output.WriteLine ("UnixOutput created successfully"); - }); - - // Assert - if (exception != null) - { - _output.WriteLine ($"FAILED: UnixOutput constructor threw: {exception.GetType ().Name}: {exception.Message}"); - _output.WriteLine ($"Stack trace: {exception.StackTrace}"); - } - - Assert.Null (exception); - } - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void UnixOutput_GetSize_ReturnsDefaultSize_WhenNoTerminalAvailable () - { - if (OperatingSystem.IsWindows ()) - { - _output.WriteLine ("Skipping Unix test on Windows"); - - return; - } - - // Arrange - using var output = new UnixOutput (); - - // Act - Size size = default; - - Exception? exception = Record.Exception (() => - { - size = output.GetSize (); - _output.WriteLine ($"UnixOutput.GetSize() returned: {size.Width}x{size.Height}"); - }); - - // Assert - Assert.Null (exception); - Assert.Equal (80, size.Width); - Assert.Equal (25, size.Height); - } - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void UnixOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () - { - // Arrange - using var output = new UnixOutput (); - - // Act - Exception? exception = Record.Exception (() => output.Suspend ()); - - // Assert - Assert.Null (exception); - } - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void UnixDriver_IsAttachedToTerminal_ReturnsFalse_InTestHarness () - { - // Copilot - generated. - // Act — Driver.IsAttachedToTerminal is the shared entry point all drivers use. - bool result = Driver.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached); - - // Assert - Assert.False (result, "UnixDriver: IsAttachedToTerminal should return false in test harness"); - Assert.False (inputAttached); - Assert.False (outputAttached); - } -} \ No newline at end of file diff --git a/Tests/UnitTestsParallelizable/Drivers/UnixDriver/UnixInputTestableTests.cs b/Tests/UnitTestsParallelizable/Drivers/UnixDriver/UnixInputTestableTests.cs deleted file mode 100644 index a7bd5f4aeb..0000000000 --- a/Tests/UnitTestsParallelizable/Drivers/UnixDriver/UnixInputTestableTests.cs +++ /dev/null @@ -1,618 +0,0 @@ -#nullable enable -using System.Collections.Concurrent; - -namespace DriverTests.Unix; - -/// -/// Tests for ITestableInput implementation in UnixInput. -/// -[Trait ("Category", "Unix")] -[Trait ("Platform", "Unix")] -[Collection ("Driver Tests")] -public class UnixInputTestableTests -{ - #region Helper Methods - - /// - /// Simulates the input thread by manually draining UnixInput's internal test queue - /// and moving items to the InputBuffer. This is needed because tests don't - /// start the actual input thread via Run(). - /// - private static void SimulateInputThread (UnixInput unixInput, ConcurrentQueue inputBuffer) - { - // UnixInput's Peek() checks _testInput first - while (unixInput.Peek ()) - { - // Read() drains _testInput first and returns items - foreach (char item in unixInput.Read ()) - { - // Manually add to InputBuffer (simulating what Run() would do) - inputBuffer.Enqueue (item); - } - } - } - - - /// - /// Processes the input queue with support for keys that may be held by the ANSI parser (like Esc). - /// The parser holds Esc for 50ms waiting to see if it's part of an escape sequence. - /// - private static void ProcessQueueWithEscapeHandling (UnixInputProcessor processor, int maxAttempts = 3) - { - // First attempt - process immediately - processor.ProcessQueue (); - - // For escape sequences, we may need to wait and process again - // The parser holds escape for 50ms before releasing - for (var attempt = 1; attempt < maxAttempts; attempt++) - { - Thread.Sleep (60); // Wait longer than the 50ms escape timeout - processor.ProcessQueue (); // This should release any held escape keys - } - } - - #endregion - - [Fact] - public void UnixInput_ImplementsITestableInput () - { - // Arrange & Act - var unixInput = new UnixInput (); - - // Assert - Assert.IsAssignableFrom> (unixInput); - } - - [Fact] - public void UnixInput_InjectInput_EnqueuesCharacter () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var testableInput = (ITestableInput)unixInput; - - // Act - testableInput.InjectInput ('a'); - - // Assert - Assert.True (unixInput.Peek ()); - List read = unixInput.Read ().ToList (); - Assert.Single (read); - Assert.Equal ('a', read [0]); - } - - [Fact] - public void UnixInput_InjectInput_SupportsMultipleCharacters () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var testableInput = (ITestableInput)unixInput; - - // Act - testableInput.InjectInput ('a'); - testableInput.InjectInput ('b'); - testableInput.InjectInput ('c'); - - // Assert - List read = unixInput.Read ().ToList (); - Assert.Equal (3, read.Count); - Assert.Equal (new [] { 'a', 'b', 'c' }, read); - } - - [Fact] - public void UnixInput_Peek_ReturnsTrueWhenTestInputAvailable () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var testableInput = (ITestableInput)unixInput; - - // Act & Assert - Initially false - Assert.False (unixInput.Peek ()); - - // Add input - testableInput.InjectInput ('x'); - - // Assert - Now true - Assert.True (unixInput.Peek ()); - } - - [Fact] - public void UnixInput_TestInput_HasPriorityOverRealInput () - { - // This test verifies that test input is returned before any real terminal input - // Since we can't easily simulate real terminal input in a unit test, - // we just verify the order of test inputs - - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var testableInput = (ITestableInput)unixInput; - - // Act - Add inputs in specific order - testableInput.InjectInput ('1'); - testableInput.InjectInput ('2'); - testableInput.InjectInput ('3'); - - // Assert - Should come out in FIFO order - List read = unixInput.Read ().ToList (); - Assert.Equal (new [] { '1', '2', '3' }, read); - } - - [Fact] - public void UnixInputProcessor_InjectKeyDownEvent_WorksWithTestableInput () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = unixInput; - - List receivedKeys = []; - processor.KeyDown += (_, k) => receivedKeys.Add (k); - - // Act - processor.InjectKeyDownEvent (Key.A); - - // Simulate the input thread moving items from _testInput to InputBuffer - SimulateInputThread (unixInput, queue); - - // Process the queue - processor.ProcessQueue (); - - // Assert - Assert.Single (receivedKeys); - Assert.Equal (Key.A, receivedKeys [0]); - } - - [Fact] - public void UnixInputProcessor_InjectMouseEvent_GeneratesAnsiSequence () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = unixInput; - - List receivedMouse = []; - processor.SyntheticMouseEvent += (_, m) => receivedMouse.Add (m); - - var mouse = new Mouse - { - Flags = MouseFlags.LeftButtonPressed, - ScreenPosition = new (10, 20) - }; - - // Act - processor.InjectMouseEvent (null, mouse); - - // Simulate the input thread - SimulateInputThread (unixInput, queue); - - // Process the queue - processor.ProcessQueue (); - - // Assert - Should have received the mouse event back - Assert.NotEmpty (receivedMouse); - - // Find the pressed event (original + clicked) - Mouse? pressedEvent = receivedMouse.FirstOrDefault (m => m.Flags.HasFlag (MouseFlags.LeftButtonPressed)); - Assert.NotNull (pressedEvent); - Assert.Equal (new Point (10, 20), pressedEvent.ScreenPosition); - } - - [Fact] - public void UnixInputProcessor_InjectMouseEvent_SupportsRelease () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = unixInput; - - List receivedMouse = []; - processor.SyntheticMouseEvent += (_, m) => receivedMouse.Add (m); - - var mouse = new Mouse - { - Flags = MouseFlags.LeftButtonReleased, - ScreenPosition = new (10, 20) - }; - - // Act - processor.InjectMouseEvent (null, mouse); - - // Simulate the input thread - SimulateInputThread (unixInput, queue); - - processor.ProcessQueue (); - - // Assert - Mouse? releasedEvent = receivedMouse.FirstOrDefault (m => m.Flags.HasFlag (MouseFlags.LeftButtonReleased)); - Assert.NotNull (releasedEvent); - Assert.Equal (new Point (10, 20), releasedEvent.ScreenPosition); - } - - [Fact] - public void UnixInputProcessor_InjectMouseEvent_SupportsModifiers () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = unixInput; - - List receivedMouse = []; - processor.SyntheticMouseEvent += (_, m) => - { - receivedMouse.Add (m); - }; - - // Test Ctrl+Alt (button code 24 for left button) - var mouse = new Mouse - { - Flags = MouseFlags.LeftButtonPressed | MouseFlags.Ctrl | MouseFlags.Alt, - ScreenPosition = new (5, 5) - }; - - // Act - processor.InjectMouseEvent (null, mouse); - - // Debug: check what's in the queue - List inputChars = []; - while (unixInput.Peek ()) - { - inputChars.AddRange (unixInput.Read ()); - } - string ansiSeq = new (inputChars.ToArray ()); - - // Re-add to queue - foreach (char ch in ansiSeq) - { - queue.Enqueue (ch); - } - - processor.ProcessQueue (); - - // Assert - Mouse? event1 = receivedMouse.FirstOrDefault (m => m.Flags.HasFlag (MouseFlags.LeftButtonPressed)); - Assert.NotNull (event1); - Assert.True (event1.Flags.HasFlag (MouseFlags.Ctrl), $"Expected Ctrl flag, got: {event1.Flags}"); - Assert.True (event1.Flags.HasFlag (MouseFlags.Alt), $"Expected Alt flag, got: {event1.Flags}"); - } - - [Theory] - [InlineData (MouseFlags.WheeledUp)] - [InlineData (MouseFlags.WheeledDown)] - // Note: WheeledLeft and WheeledRight (codes 68/69) have complex ANSI encoding with Shift+Ctrl variations - // These are tested separately in AnsiMouseParserDebugTests - public void UnixInputProcessor_InjectMouseEvent_SupportsWheelEvents (MouseFlags wheelFlag) - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = unixInput; - - List receivedMouse = []; - processor.SyntheticMouseEvent += (_, m) => receivedMouse.Add (m); - - var mouse = new Mouse - { - Flags = wheelFlag, - ScreenPosition = new (15, 15) - }; - - // Act - processor.InjectMouseEvent (null, mouse); - - // Simulate the input thread - SimulateInputThread (unixInput, queue); - - processor.ProcessQueue (); - - // Assert - Mouse? wheelEvent = receivedMouse.FirstOrDefault (m => m.Flags.HasFlag (wheelFlag)); - Assert.NotNull (wheelEvent); - Assert.Equal (new Point (15, 15), wheelEvent.ScreenPosition); - } - - - #region UnixInput InjectKeyDownEvent Tests - - [Fact] - public void UnixInput_InjectKeyDownEvent_AddsSingleKeyToQueue () - { - // Arrange - var UnixInput = new UnixInput (); - ConcurrentQueue queue = new (); - UnixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = UnixInput; - - List receivedKeys = []; - processor.KeyDown += (_, k) => receivedKeys.Add (k); - - Key key = Key.A; - - // Act - processor.InjectKeyDownEvent (key); - - // Simulate the input thread moving items from _testInput to InputBuffer - SimulateInputThread (UnixInput, queue); - - processor.ProcessQueue (); - - // Assert - Verify the key made it through - Assert.Single (receivedKeys); - Assert.Equal (key, receivedKeys [0]); - } - - [Fact] - public void UnixInput_InjectKeyDownEvent_SupportsMultipleKeys () - { - // Arrange - var UnixInput = new UnixInput (); - ConcurrentQueue queue = new (); - UnixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = UnixInput; - - Key [] keys = [Key.A, Key.B, Key.C, Key.Enter]; - List receivedKeys = []; - processor.KeyDown += (_, k) => receivedKeys.Add (k); - - // Act - foreach (Key key in keys) - { - processor.InjectKeyDownEvent (key); - } - - SimulateInputThread (UnixInput, queue); - processor.ProcessQueue (); - - // Assert - Assert.Equal (keys.Length, receivedKeys.Count); - Assert.Equal (keys, receivedKeys); - } - - [Theory] - [InlineData (KeyCode.A, false, false, false)] - [InlineData (KeyCode.A, true, false, false)] // Shift+A - [InlineData (KeyCode.A, false, true, false)] // Ctrl+A - [InlineData (KeyCode.A, false, false, true)] // Alt+A - // Note: Ctrl+Shift+Alt+A is not tested because ANSI doesn't have a standard way to represent - // Shift with Ctrl combinations (Ctrl+A is 0x01 regardless of Shift state) - public void UnixInput_InjectKeyDownEvent_PreservesModifiers (KeyCode keyCode, bool shift, bool ctrl, bool alt) - { - // Arrange - var UnixInput = new UnixInput (); - ConcurrentQueue queue = new (); - UnixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = UnixInput; - - var key = new Key (keyCode); - - if (shift) - { - key = key.WithShift; - } - - if (ctrl) - { - key = key.WithCtrl; - } - - if (alt) - { - key = key.WithAlt; - } - - Key? receivedKey = null; - processor.KeyDown += (_, k) => receivedKey = k; - - // Act - processor.InjectKeyDownEvent (key); - SimulateInputThread (UnixInput, queue); - - // Alt combinations start with ESC, so they need escape handling - if (alt) - { - ProcessQueueWithEscapeHandling (processor); - } - else - { - processor.ProcessQueue (); - } - - // Assert - Assert.NotNull (receivedKey); - Assert.Equal (key.IsShift, receivedKey.IsShift); - Assert.Equal (key.IsCtrl, receivedKey.IsCtrl); - Assert.Equal (key.IsAlt, receivedKey.IsAlt); - Assert.Equal (key.KeyCode, receivedKey.KeyCode); - } - - [Theory] - [InlineData (KeyCode.Enter)] - [InlineData (KeyCode.Tab)] - [InlineData (KeyCode.Esc)] - [InlineData (KeyCode.Backspace)] - [InlineData (KeyCode.Delete)] - [InlineData (KeyCode.CursorUp)] - [InlineData (KeyCode.CursorDown)] - [InlineData (KeyCode.CursorLeft)] - [InlineData (KeyCode.CursorRight)] - [InlineData (KeyCode.F1)] - [InlineData (KeyCode.F12)] - public void UnixInput_InjectKeyDownEvent_SupportsSpecialKeys (KeyCode keyCode) - { - // Arrange - var UnixInput = new UnixInput (); - ConcurrentQueue queue = new (); - UnixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = UnixInput; - - var key = new Key (keyCode); - Key? receivedKey = null; - processor.KeyDown += (_, k) => receivedKey = k; - - // Act - processor.InjectKeyDownEvent (key); - SimulateInputThread (UnixInput, queue); - - // Esc is special - the ANSI parser holds it waiting for potential escape sequences - // We need to process with delay to let the parser release it after timeout - if (keyCode == KeyCode.Esc) - { - ProcessQueueWithEscapeHandling (processor); - } - else - { - processor.ProcessQueue (); - } - - // Assert - Assert.NotNull (receivedKey); - Assert.Equal (key.KeyCode, receivedKey.KeyCode); - } - - [Fact] - public void UnixInput_InjectKeyDownEvent_RaisesKeyDownEvent () - { - // Arrange - var unixInput = new UnixInput (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - var processor = new UnixInputProcessor (queue, null); - processor.InputImpl = unixInput; - - var keyDownCount = 0; - processor.KeyDown += (_, _) => keyDownCount++; - - // Act - processor.InjectKeyDownEvent (Key.A); - SimulateInputThread (unixInput, queue); - processor.ProcessQueue (); - - Assert.Equal (1, keyDownCount); - } - - #endregion - - #region Mouse Event Sequencing Tests - - [Fact] - public void UnixInput_InjectMouseEvent_HandlesCompleteClickSequence () - { - // Arrange - UnixInput unixInput = new (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - UnixInputProcessor processor = new (queue); - processor.InputImpl = unixInput; - - List receivedEvents = []; - processor.SyntheticMouseEvent += (_, e) => receivedEvents.Add (e); - - // Act - Simulate a complete click: press → release - processor.InjectMouseEvent ( - null, - new () - { - Position = new (10, 5), - Flags = MouseFlags.LeftButtonPressed - }); - - processor.InjectMouseEvent ( - null, - new () - { - Position = new (10, 5), - Flags = MouseFlags.LeftButtonReleased - }); - - SimulateInputThread (unixInput, queue); - processor.ProcessQueue (); - - // Assert - Process() emits Pressed and Released immediately (clicks are deferred) - Assert.Contains (receivedEvents, e => e.Flags.HasFlag (MouseFlags.LeftButtonPressed)); - Assert.Contains (receivedEvents, e => e.Flags.HasFlag (MouseFlags.LeftButtonReleased)); - // We should also see the synthetic Clicked event - Assert.Contains (receivedEvents, e => e.Flags.HasFlag (MouseFlags.LeftButtonClicked)); - Assert.Equal (3, receivedEvents.Count); - } - - [Theory] - [InlineData(MouseFlags.WheeledUp)] - [InlineData(MouseFlags.WheeledDown)] - [InlineData(MouseFlags.WheeledLeft)] - [InlineData(MouseFlags.WheeledRight)] - public void UnixInput_InjectMouseEvent_Wheel_Events (MouseFlags wheelEvent) - { - // Arrange - UnixInput unixInput = new (); - ConcurrentQueue queue = new (); - unixInput.Initialize (queue); - - UnixInputProcessor processor = new (queue); - processor.InputImpl = unixInput; - - List receivedEvents = []; - processor.SyntheticMouseEvent += (_, e) => receivedEvents.Add (e); - - // Act - Simulate a wheel event - processor.InjectMouseEvent ( - null, - new () - { - Position = new (10, 5), - Flags = wheelEvent - }); - - SimulateInputThread (unixInput, queue); - processor.ProcessQueue (); - - // Assert - Assert.Contains (receivedEvents, e => e.Flags.HasFlag (wheelEvent)); - Assert.Single (receivedEvents); - - // Note: ANSI codes 68 and 69 (horizontal wheel) always include Shift flag per ANSI spec - if (wheelEvent is MouseFlags.WheeledLeft or MouseFlags.WheeledRight) - { - Mouse wheelEventReceived = receivedEvents.First (e => e.Flags.HasFlag (wheelEvent)); - Assert.True (wheelEventReceived.Flags.HasFlag (MouseFlags.Shift), - $"Horizontal wheel events should include Shift flag, got: {wheelEventReceived.Flags}"); - } - } - - #endregion - -} diff --git a/docfx/docs/drivers.md b/docfx/docs/drivers.md index 48364e4dac..00b39f242e 100644 --- a/docfx/docs/drivers.md +++ b/docfx/docs/drivers.md @@ -13,29 +13,29 @@ Terminal.Gui v2 uses a sophisticated driver architecture that separates concerns ## Available Drivers -Terminal.Gui provides four console driver implementations optimized for different platforms: - - -| | **ansi** | **dotnet** | **unix** | **windows** | -|---|---|---|---|---| -| **Theme** | Showcase driver with pure ANSI implementation. Works on all platforms. Ideal for testing/CI. Deterministic behavior with virtual time support. | Cross-platform managed .NET driver. Simplest implementation using `System.Console` API. Works with .NET BCL only. | Optimized Unix/Linux/macOS driver. Direct syscall access. | High-performance Windows-only driver. Native Win32 Console API. Direct access to Windows-specific features. | -| **Input Model** | Reads raw ANSI sequences, parses to Terminal.Gui events | Reads `ConsoleKeyInfo` from `System.Console`, converts to Terminal.Gui events | Reads raw ANSI sequences, parses to Terminal.Gui events | Reads `INPUT_RECORD` structures directly, converts to Terminal.Gui events | -| **Unix Read APIs** | `poll(STDIN_FILENO, ...)`, `read(STDIN_FILENO, buffer, len)`, `tcgetattr()`/`tcsetattr()` for raw mode via `UnixRawModeHelper` | N/A (uses .NET `Console.ReadKey()` which internally delegates to platform APIs) reads `char` | `poll()`, `read()` syscalls on stdin (fd 0), `tcgetattr()`/`tcsetattr()` for termios raw mode | N/A (Windows-only) | -| **Windows Read APIs** | P/Invokes `ReadFile()` reads `char` | N/A (uses .NET `Console.ReadKey()` which internally delegates to platform APIs) | N/A (Unix-only) | P/Invokes `ReadConsoleInputW()` reads `INPUT_RECORD`, `GetConsoleMode()`/`SetConsoleMode()` enables mouse input and raw mode | -| **Output Model** | Pure ANSI escape sequences | Managed .NET + ANSI sequences (when VT mode enabled) | Pure ANSI escape sequences | Direct character output via Win32 API with double buffering | -| **Unix Write APIs** | `write()` syscall to stdout (fd 1) | N/A (uses .NET `Console.Write()` which internally delegates to platform APIs) | `write()` syscall to stdout, ANSI SGR sequences for colors (16-color and 24-bit RGB) | N/A (Windows-only) | -| **Windows Write APIs** | P/Invokes `WriteFile()` | N/A (uses .NET `Console.Write()` which internally delegates to platform APIs) | N/A (Unix-only) | P/Invokes `WriteConsoleW()`, `CreateConsoleScreenBuffer()`/`SetConsoleActiveScreenBuffer()` for double buffering, `SetConsoleTextAttribute()` | -| **Screen Model** | ANSI query-based resize. Throttled to 500ms. Falls back to polling. | Polling-based re-size: `Console.WindowWidth`/`Console.WindowHeight` queried periodically. Falls back to 80x25 on `IOException`. | Polling-based resize: `ioctl(TIOCGWINSZ)` syscall with platform-specific constants (Linux `0x5413`, macOS `0x40087468`). Queries `WinSize` struct. | Event-based re-size: `WINDOW_BUFFER_SIZE_EVENT` received in input stream via `ReadConsoleInputW()`. Immediate resize notification. `GetConsoleScreenBufferInfoEx()` queries dimensions. | -| **Cursor Handling** | ANSI sequences: DECTCEM (`CSI ? 25 h/l`) for show/hide, DECSCUSR (`CSI Ps SP q`) for style. Full `CursorStyle` support. | ANSI sequences (same as ansi driver). Falls back to `Console.SetCursorPosition()` on Windows. | ANSI sequences (same as ansi driver). Full `CursorStyle` support. | • Legacy mode: Win32 `CONSOLE_CURSOR_INFO` (size percentage only, no blinking control).
• Modern VT mode: ANSI sequences (same as ansi driver). Full `CursorStyle` support. | -| **Advantages** | • Cross-platform (all platforms)
• Pure, clean implementation
• Perfect for testing/CI
• Virtual time support
• Deterministic behavior
| • Cross-platform (all platforms)
• Maximum compatibility
• Simple implementation
• No P/Invoke; Works with .NET BCL
| • Immediate resize detection
| • Highest performance on Windows
• Immediate resize detection
| -| **Disadvantages** | • Requires proper ANSI support
• Throttled size detection (500ms) | • Lower performance (managed overhead)
• Limited feature set
• `System.ReadKey` has bugs on Windows
• Polling-based resize | • Unix-only
• Polling-based resize detection | • Windows-only
• More complex P/Invoke code | +Terminal.Gui provides three console driver implementations: + + +| | **ansi** | **dotnet** | **windows** | +|---|---|---|---| +| **Theme** | Default driver for Unix/macOS and a showcase driver for all platforms. Pure ANSI implementation. Ideal for testing/CI. Deterministic behavior with virtual time support. | Cross-platform managed .NET driver. Simplest implementation using `System.Console` API. Works with .NET BCL only. | High-performance Windows-only driver. Native Win32 Console API. Direct access to Windows-specific features. | +| **Input Model** | Reads raw ANSI sequences, parses to Terminal.Gui events | Reads `ConsoleKeyInfo` from `System.Console`, converts to Terminal.Gui events | Reads `INPUT_RECORD` structures directly, converts to Terminal.Gui events | +| **Unix Read APIs** | `poll(STDIN_FILENO, ...)`, `read(STDIN_FILENO, buffer, len)`, `tcgetattr()`/`tcsetattr()` for raw mode via `UnixRawModeHelper` | N/A (uses .NET `Console.ReadKey()` which internally delegates to platform APIs) reads `char` | N/A (Windows-only) | +| **Windows Read APIs** | P/Invokes `ReadFile()` reads `char` | N/A (uses .NET `Console.ReadKey()` which internally delegates to platform APIs) | P/Invokes `ReadConsoleInputW()` reads `INPUT_RECORD`, `GetConsoleMode()`/`SetConsoleMode()` enables mouse input and raw mode | +| **Output Model** | Pure ANSI escape sequences | Managed .NET + ANSI sequences (when VT mode enabled) | Direct character output via Win32 API with double buffering | +| **Unix Write APIs** | `write()` syscall to stdout (fd 1) | N/A (uses .NET `Console.Write()` which internally delegates to platform APIs) | N/A (Windows-only) | +| **Windows Write APIs** | P/Invokes `WriteFile()` | N/A (uses .NET `Console.Write()` which internally delegates to platform APIs) | P/Invokes `WriteConsoleW()`, `CreateConsoleScreenBuffer()`/`SetConsoleActiveScreenBuffer()` for double buffering, `SetConsoleTextAttribute()` | +| **Screen Model** | Configurable via `Driver.SizeDetection`. Default (`AnsiQuery`): ANSI `CSI 18t` query, throttled to 500 ms. `Polling`: `ioctl(TIOCGWINSZ)` on Unix, Console API on Windows. | Polling-based re-size: `Console.WindowWidth`/`Console.WindowHeight` queried periodically. Falls back to 80x25 on `IOException`. | Event-based re-size: `WINDOW_BUFFER_SIZE_EVENT` received in input stream via `ReadConsoleInputW()`. Immediate resize notification. `GetConsoleScreenBufferInfoEx()` queries dimensions. | +| **Cursor Handling** | ANSI sequences: DECTCEM (`CSI ? 25 h/l`) for show/hide, DECSCUSR (`CSI Ps SP q`) for style. Full `CursorStyle` support. | ANSI sequences (same as ansi driver). Falls back to `Console.SetCursorPosition()` on Windows. | • Legacy mode: Win32 `CONSOLE_CURSOR_INFO` (size percentage only, no blinking control).
• Modern VT mode: ANSI sequences (same as ansi driver). Full `CursorStyle` support. | +| **Advantages** | • Cross-platform (all platforms)
• Pure, clean implementation
• Perfect for testing/CI
• Virtual time support
• Deterministic behavior
• Configurable size detection | • Cross-platform (all platforms)
• Maximum compatibility
• Simple implementation
• No P/Invoke; Works with .NET BCL
| • Highest performance on Windows
• Immediate resize detection
| +| **Disadvantages** | • Requires proper ANSI support | • Lower performance (managed overhead)
• Limited feature set
• `System.ReadKey` has bugs on Windows
• Polling-based resize | • Windows-only
• More complex P/Invoke code | ### Automatic Driver Selection The appropriate driver is automatically selected based on the platform when 's `Init()` is called: - **Windows** (Win32NT, Win32S, Win32Windows) → `WindowsDriver` -- **Unix/Linux/macOS** → `UnixDriver` +- **Unix/Linux/macOS** → `AnsiDriver` ### Explicit Driver Selection @@ -53,7 +53,7 @@ Method 2: Pass driver name to Init ```csharp // Use type-safe constants from DriverRegistry.Names -Application.Init(driverName: DriverRegistry.Names.UNIX); +Application.Init(driverName: DriverRegistry.Names.ANSI); ``` Method 3: Set ForceDriver on instance @@ -104,7 +104,6 @@ foreach (string name in driverNames) // Available drivers: // - dotnet // - windows -// - unix // - ansi ``` @@ -176,7 +175,6 @@ Terminal.Gui v2 uses a **Driver Registry** pattern for managing available driver ```csharp // Access well-known driver name constants string windowsDriver = DriverRegistry.Names.WINDOWS; // "windows" -string unixDriver = DriverRegistry.Names.UNIX; // "unix" string dotnetDriver = DriverRegistry.Names.DOTNET; // "dotnet" string ansiDriver = DriverRegistry.Names.ANSI; // "ansi" @@ -206,9 +204,8 @@ Logging.Information($"Default driver: {defaultDriver.Name}"); The v2 driver architecture uses the **Component Factory** pattern to create platform-specific components. Each driver has a corresponding factory that implements `IComponentFactory`: - `NetComponentFactory` - Creates components for DotNetDriver -- `WindowsComponentFactory` - Creates components for WindowsDriver -- `UnixComponentFactory` - Creates components for UnixDriver -- `AnsiComponentFactory` - Creates components for AnsiDriver +- `WindowsComponentFactory` - Creates components for WindowsDriver +- `AnsiComponentFactory` - Creates components for AnsiDriver (all platforms) Each factory is responsible for: - Creating driver-specific components (`IInput`, `IOutput`, `IInputProcessor`, etc.) @@ -225,14 +222,12 @@ Each driver is composed of specialized components, each with a single responsibi Reads raw console input events from the terminal on a dedicated input thread. The generic type `T` represents the platform-specific input record type: - `ConsoleKeyInfo` for DotNetDriver (from `Console.ReadKey()`) - `WindowsConsole.InputRecord` for WindowsDriver (from `ReadConsoleInputW()`) -- `char` for UnixDriver and AnsiDriver (raw bytes from `read()` syscall or `ReadFile()`) +- `char` for AnsiDriver (raw bytes from `read()` syscall or `ReadFile()`) Input runs on a separate thread managed by `MainLoopCoordinator`, continuously reading from the console and queueing events into a thread-safe `ConcurrentQueue` to avoid blocking the UI thread. #### IOutput Renders the output buffer to the terminal. Platform-specific implementations: -- **WindowsOutput**: Uses `WriteConsoleW()` for direct character output -- **UnixOutput**: Writes ANSI sequences to stdout via `write()` syscall - **NetOutput**: Uses `Console.Write()` with ANSI sequences (VT mode on Windows) - **AnsiOutput**: Pure ANSI escape sequences via `WriteFile()` (Windows) or `write()` (Unix) @@ -249,7 +244,7 @@ Translates raw console input into Terminal.Gui events: - Generates `MouseEventArgs` for mouse input - Handles platform-specific key mappings - Uses `IKeyConverter` to translate `TInputRecord` to : -- `AnsiKeyConverter` - For `char` input (UnixDriver, AnsiDriver) +- `AnsiKeyConverter` - For `char` input (AnsiDriver) - `NetKeyConverter` - For `ConsoleKeyInfo` input (DotNetDriver) - `WindowsKeyConverter` - For `WindowsConsole.InputRecord` input (WindowsDriver) @@ -349,6 +344,27 @@ The main driver interface that the framework uses internally. `IDriver` is organ - `DefaultAttribute` - The terminal's actual default foreground/background colors, detected at startup via OSC 10/11 queries. Used by to resolve 's `None` during role derivation. `null` if the terminal didn't respond (e.g., legacy console). - `ColorCapabilities` - The terminal's color capability level (`NoColor`, `Colors16`, `Colors256`, `TrueColor`), detected from `$TERM`, `$COLORTERM`, and other environment variables +#### Size Detection (ANSI Driver) + +The ANSI driver's terminal-size detection strategy is controlled by `Driver.SizeDetection` (a `[ConfigurationProperty]`): + +| Mode | Mechanism | When to use | +|---|---|---| +| `AnsiQuery` (default) | Sends `CSI 18t`, parses `ESC[8;h;wt` response. Async, ~500 ms throttle. | Most terminals. Works everywhere ANSI is supported. | +| `Polling` | `ioctl(TIOCGWINSZ)` on Unix, `Console.WindowWidth/Height` on Windows. Synchronous. | When the ANSI response does not reflect the actual terminal size (e.g., some SSH configurations). | + +Set via JSON configuration: + +```json +{ "Driver.SizeDetection": "Polling" } +``` + +Or programmatically before `Init()`: + +```csharp +Driver.SizeDetection = SizeDetectionMode.Polling; +``` + #### Content Buffer - `Contents` - Screen buffer array - `Clip` - Clipping region @@ -412,7 +428,7 @@ The driver selection logic in `ApplicationImpl.Driver.cs` uses the **Driver Regi 3. **Application.ForceDriver Configuration**: The `Application.ForceDriver` property is checked and looked up in the registry 4. **Platform Default**: `DriverRegistry.GetDefaultDriver()` selects based on current platform: - Windows (Win32NT, Win32S, Win32Windows) → `WindowsDriver` - - Unix/Linux/macOS → `UnixDriver` + - Unix/Linux/macOS → `AnsiDriver` - Other platforms → `DotNetDriver` (fallback) **Driver Creation Process:** diff --git a/plans/consolidate-platform-helpers.md b/plans/consolidate-platform-helpers.md new file mode 100644 index 0000000000..e591e380b1 --- /dev/null +++ b/plans/consolidate-platform-helpers.md @@ -0,0 +1,203 @@ +# Plan: Consolidate Platform-Specific Code into WindowsHelpers / UnixHelpers + +## Problem Statement + +Platform-specific code is scattered across the driver tree. Some Unix-only code sits +at the Drivers root (`SuspendHelper.cs`), some Windows-only code lives in +`DotNetDriver/` (`NetWinVTConsole.cs`), and the "platform-agnostic" `AnsiDriver/` +files embed P/Invoke declarations for both platforms inline +(`AnsiTerminalHelper.cs`, `Driver.cs`). + +The `WindowsHelpers/` and `UnixHelpers/` directories already exist and hold _some_ +of the right code, but the consolidation is incomplete. + +## Goals + +1. **Every P/Invoke and every platform-specific helper** lives in the matching + `*Helpers/` directory. +2. **`AnsiDriver/` files contain zero P/Invoke** — they call into the helpers. +3. **`DotNetDriver/` has no Windows-only files** — `NetWinVTConsole` moves (or is + eliminated if redundant with `WindowsVTInputHelper` + `WindowsVTOutputHelper`). +4. **Drivers root has no platform-only files** — `SuspendHelper` moves to + `UnixHelpers/`. +5. **`Driver.cs`** `IsAttachedToTerminal` P/Invokes move to helpers; the static + method dispatches via `PlatformDetection`. + +## Current State — Files to Move / Refactor + +### Pure platform files in the wrong directory + +| File | Current Location | Target | Action | +|------|-----------------|--------|--------| +| `SuspendHelper.cs` | `Drivers/` (root) | `Drivers/UnixHelpers/` | **Move** | +| `NetWinVTConsole.cs` | `Drivers/DotNetDriver/` | `Drivers/WindowsHelpers/` or **delete** | See note 1 | + +> **Note 1 — `NetWinVTConsole` vs existing helpers:** +> `NetWinVTConsole` enables `ENABLE_VIRTUAL_TERMINAL_INPUT` + +> `ENABLE_VIRTUAL_TERMINAL_PROCESSING` and restores modes on cleanup. +> `WindowsVTInputHelper` already does the input half; `WindowsVTOutputHelper` +> already does the output half. `NetWinVTConsole` duplicates both in one class. +> **Recommendation:** Refactor `NetInput` to use `WindowsVTInputHelper` + +> `WindowsVTOutputHelper` (which already support `TryEnable` / `Dispose`), then +> **delete** `NetWinVTConsole.cs`. If there are subtle differences (e.g. flush +> behaviour), fold them into the existing helpers. + +### Mixed files with inline P/Invoke + +| File | Platform Code | Refactoring | +|------|--------------|-------------| +| `AnsiTerminalHelper.cs` | libc `tcdrain`/`fsync` + kernel32 `FlushFileBuffers`/`GetStdHandle` | Extract `FlushUnix` body → `UnixIOHelper.FlushStdout()`. Extract `FlushWindows` body → `WindowsVTOutputHelper.FlushStdout()`. `AnsiTerminalHelper.FlushNative` becomes a two-line dispatcher with no P/Invoke. | +| `Driver.cs` | libc `isatty` + kernel32 `GetStdHandle`/`GetConsoleMode` | Extract the Windows branch → `WindowsHelpers/WindowsConsoleHelper.IsAttachedToTerminal()`. Extract the Unix branch → `UnixHelpers/UnixIOHelper.IsTerminal(fd)` (or expose existing `isatty` wrapper). `Driver.IsAttachedToTerminal` becomes a dispatcher. | + +### Mixed files with platform _branching_ (no P/Invoke — lower priority) + +These files use `PlatformDetection` to select which helper to use. They are +_correctly structured_ — the platform code is in the helpers, the branching is in +the consumer. **No file moves needed**, but document the pattern as the convention. + +| File | What It Does | +|------|-------------| +| `AnsiInput.cs` | Branches on `PlatformDetection.IsWindows()` → `WindowsVTInputHelper`, `.IsUnixLike()` → `UnixRawModeHelper`/`UnixIOHelper` | +| `AnsiOutput.cs` | Branches on `PlatformDetection.IsWindows()` → `WindowsVTOutputHelper`, `.IsUnixLike()` → `UnixIOHelper` | +| `AnsiComponentFactory.cs` | Branches on `RuntimeInformation.IsOSPlatform(Windows)` for `CreateNativeSizeQuery` | +| `DriverImpl.cs` | Branches on platform for `CreateClipboard` (uses helpers from both dirs) | +| `DriverRegistry.cs` | Platform-based default driver selection | +| `NetInput.cs` | Windows branch creates `NetWinVTConsole` (will change per Note 1) | +| `NetOutput.cs` | `_isWinPlatform` flag for cursor positioning; `SuspendHelper` call | + +## Target Directory Structure + +``` +Drivers/ +├── AnsiDriver/ +│ ├── AnsiComponentFactory.cs (no P/Invoke — dispatches to helpers) +│ ├── AnsiInput.cs (no P/Invoke — uses WindowsVTInputHelper / UnixIOHelper) +│ ├── AnsiInputProcessor.cs (pure ANSI — unchanged) +│ ├── AnsiOutput.cs (no P/Invoke — uses WindowsVTOutputHelper / UnixIOHelper) +│ ├── AnsiPlatform.cs (enum — unchanged) +│ ├── AnsiSizeMonitor.cs (pure ANSI — unchanged) +│ ├── AnsiTerminalHelper.cs (dispatcher only — no P/Invoke) +│ └── FakeClipboard.cs (test stub — unchanged) +│ +├── DotNetDriver/ +│ ├── INetInput.cs (unchanged) +│ ├── NetComponentFactory.cs (unchanged) +│ ├── NetInput.cs (refactored: uses WindowsVTInputHelper + WindowsVTOutputHelper) +│ ├── NetInputProcessor.cs (unchanged) +│ ├── NetKeyConverter.cs (unchanged) +│ └── NetOutput.cs (unchanged — branching only, no P/Invoke) +│ # NetWinVTConsole.cs DELETED (consolidated into existing helpers) +│ +├── WindowsDriver/ (unchanged — self-contained Windows driver) +│ └── (all 11 files stay) +│ +├── WindowsHelpers/ +│ ├── WindowsVTInputHelper.cs (already here) +│ ├── WindowsVTOutputHelper.cs (already here + gains FlushStdout) +│ └── WindowsConsoleHelper.cs (NEW — IsAttachedToTerminal extracted from Driver.cs) +│ +├── UnixHelpers/ +│ ├── UnixClipboard.cs (already here) +│ ├── UnixIOHelper.cs (already here + gains FlushStdout, IsTerminal) +│ ├── UnixRawModeHelper.cs (already here) +│ ├── UnixTerminalHelper.cs (already here) +│ └── SuspendHelper.cs (MOVED from Drivers root) +│ +├── (root — platform-agnostic only) +│ ├── ComponentFactoryImpl.cs +│ ├── Cursor.cs / CursorStyle.cs +│ ├── Driver.cs (no P/Invoke — dispatches to helpers) +│ ├── DriverImpl.cs +│ ├── DriverRegistry.cs +│ ├── IComponentFactory.cs / IDriver.cs / ISizeMonitor.cs +│ ├── PlatformDetection.cs +│ ├── SizeDetectionMode.cs +│ └── SizeMonitorImpl.cs +│ +├── AnsiHandling/ (unchanged — pure ANSI parsing) +├── Input/ (unchanged) +├── Keyboard/ (VK.cs stays — consumed cross-platform) +├── Mouse/ (unchanged) +├── Output/ (unchanged) +└── TerminalEnvironment/ (unchanged) +``` + +## Implementation Steps + +### Phase 1 — Simple moves (no code changes beyond namespace update) + +- [ ] **Move `SuspendHelper.cs`** from `Drivers/` to `Drivers/UnixHelpers/` + - Update callers (`NetOutput.cs`, `AnsiOutput.cs` → `UnixTerminalHelper.Suspend`) + to use the new location (namespace stays `Terminal.Gui.Drivers` so callers + won't change unless we adopt sub-namespaces). + +### Phase 2 — Extract P/Invoke from `AnsiTerminalHelper.cs` + +- [ ] **Add `UnixIOHelper.FlushStdout()`** — move the `tcdrain`/`fsync` logic from + `AnsiTerminalHelper.FlushUnix()` into `UnixIOHelper`. The P/Invoke declarations + for `tcdrain` and `fsync` move too (or reuse existing ones if `UnixIOHelper` + already imports them). +- [ ] **Add `WindowsVTOutputHelper.FlushStdout()`** — move the + `GetStdHandle`/`FlushFileBuffers` logic from `AnsiTerminalHelper.FlushWindows()`. +- [ ] **Simplify `AnsiTerminalHelper.FlushNative()`** — becomes: + ```csharp + switch (platform) + { + case AnsiPlatform.UnixRaw: UnixIOHelper.FlushStdout (); break; + case AnsiPlatform.WindowsVT: WindowsVTOutputHelper.FlushStdout (); break; + } + ``` + No P/Invoke declarations remain in `AnsiTerminalHelper.cs`. + +### Phase 3 — Extract P/Invoke from `Driver.cs` + +- [ ] **Create `WindowsHelpers/WindowsConsoleHelper.cs`** with: + ```csharp + internal static bool IsAttachedToTerminal (out bool input, out bool output) + ``` + Move the `GetStdHandle`/`GetConsoleMode` P/Invoke + logic from `Driver.cs`. +- [ ] **Add `UnixIOHelper.IsTerminal(int fd)`** — expose the libc `isatty` call. + `UnixIOHelper` may already import `isatty`; if not, add it. +- [ ] **Simplify `Driver.IsAttachedToTerminal()`** — becomes: + ```csharp + if (PlatformDetection.IsWindows ()) + return WindowsConsoleHelper.IsAttachedToTerminal (out inputAttached, out outputAttached); + inputAttached = UnixIOHelper.IsTerminal (0); + outputAttached = UnixIOHelper.IsTerminal (1); + return inputAttached && outputAttached; + ``` + +### Phase 4 — Eliminate `NetWinVTConsole.cs` + +- [ ] **Audit `NetWinVTConsole`** vs `WindowsVTInputHelper` + `WindowsVTOutputHelper` + to confirm functional equivalence (same console mode flags, same restore logic). +- [ ] **Refactor `NetInput.cs`** to use `WindowsVTInputHelper` + + `WindowsVTOutputHelper` instead of `NetWinVTConsole`. +- [ ] **Delete `DotNetDriver/NetWinVTConsole.cs`**. + +### Phase 5 — Verify + +- [ ] `dotnet build --no-restore` — zero new warnings. +- [ ] `dotnet test --project Tests/UnitTestsParallelizable --no-build` — all pass. +- [ ] `dotnet test --project Tests/UnitTests --no-build` — all pass. +- [ ] Grep for `DllImport` / `LibraryImport` in `AnsiDriver/` and `DotNetDriver/` + and `Drivers/*.cs` (root) — expect zero hits (all in `*Helpers/`, `WindowsDriver/`, + or `Keyboard/VK.cs`). + +## Conventions Established + +After this refactoring, the rule is: + +> **P/Invoke and OS-specific API calls live exclusively in `WindowsDriver/`, +> `WindowsHelpers/`, or `UnixHelpers/`.** Everything else uses `PlatformDetection` +> to dispatch into those directories. No `DllImport` appears in `AnsiDriver/`, +> `DotNetDriver/`, or the Drivers root. + +## Out of Scope + +- **`WindowsDriver/`** — Already self-contained. No changes needed. +- **`Keyboard/VK.cs`** — Windows virtual key codes consumed cross-platform. Stays. +- **`TerminalEnvironment/`** — Reads env vars (`TERM`, etc.) — no P/Invoke. +- **Sub-namespace changes** — All driver code currently uses `Terminal.Gui.Drivers`. + Introducing sub-namespaces (e.g. `Terminal.Gui.Drivers.WindowsHelpers`) would be + a larger change and is not proposed here. diff --git a/plans/fix-ansi-size-change.md b/plans/fix-ansi-size-change.md new file mode 100644 index 0000000000..872bf0ec79 --- /dev/null +++ b/plans/fix-ansi-size-change.md @@ -0,0 +1,383 @@ +# Fix: ANSI Driver Screen Size Stuck at 80×25 + +## Status + +**Primary bug is fixed** (commit `30ae31719`, merged into HEAD). +All 14,753 parallelizable unit tests pass. + +Remaining work: code-review issues in the fix + missing `AnsiSizeMonitor` behavioral tests. + +--- + +## Problem Statement + +When running UICatalog with the ANSI driver on Windows (or any platform), the screen +size is permanently stuck at 80×25. Terminal resize events are never reported. + +## Root Cause Analysis + +### The Call Chain + +``` +ApplicationMainLoop.IterationImpl() + └─ SizeMonitor.Poll() ← called every iteration + └─ AnsiOutput.GetSize() ← returns _consoleSize ALWAYS + └─ _consoleSize == new Size(80,25) ← set in constructor, NEVER changes +``` + +### Why It Breaks + +`AnsiComponentFactory.CreateSizeMonitor()` only used `AnsiSizeMonitor` when +`Driver.SizeDetection == SizeDetectionMode.AnsiQuery`. The default was +`SizeDetectionMode.Polling`, which fell back to `SizeMonitorImpl(ansiOutput)`. + +`SizeMonitorImpl.Poll()` calls `IOutput.GetSize()`. For `NetOutput` and +`WindowsOutput` this queries a live OS API. For `AnsiOutput`, `GetSize()` returned +the cached `_consoleSize` field — initialized to `(80,25)` and only updated by the +ANSI-query path. Result: `SizeMonitorImpl` always saw the same size; `SizeChanged` +was never raised. + +--- + +## What Was Fixed (commit 30ae31719) + +### 1 — `SizeDetectionMode` default swapped + +`AnsiQuery` is now the default (value 0); `Polling` is opt-in. This means the +ANSI driver now uses `AnsiSizeMonitor` by default. + +### 2 — `AnsiComponentFactory.CreateSizeMonitor()` restructured + +- `AnsiOutput` + `AnsiQuery` (default) → `AnsiSizeMonitor` ✅ +- `AnsiOutput` + `Polling` → injects `NativeSizeQuery` delegate into `AnsiOutput`, + then returns `SizeMonitorImpl` (delegate calls `Console.WindowWidth/Height` on + Windows or `ioctl(TIOCGWINSZ)` on Unix) ✅ +- Non-`AnsiOutput` → `SizeMonitorImpl` (unchanged) ✅ + +The platform-specific code lives in `AnsiComponentFactory.CreateNativeSizeQuery()` +— NOT in `AnsiOutput` — keeping `AnsiOutput` platform-agnostic. + +### 3 — `AnsiOutput.NativeSizeQuery` property added + +`GetSize()` now calls the delegate (if set) to get a live OS size, then caches +and returns it. When `null` (the `AnsiQuery` default), returns the cached constant +as before. + +### 4 — `SizeMonitorImpl` constructor fixed + +Previously used a primary constructor with `_lastSize = Size.Empty`. Now explicitly +captures the initial size from `consoleOut.GetSize()` at construction, so the +first `Poll()` only fires if the size has genuinely changed. + +### 5 — Tests added / updated + +- **New:** `AnsiComponentFactorySizeMonitorTests.cs` — 6 factory-level tests +- **Updated:** `SizeMonitorTests.cs` — 3 tests for `SizeMonitorImpl` initial-size behaviour + +--- + +## CSI 18t Compatibility + +CSI 18t (xterm window manipulation) is supported by every terminal that the +ANSI driver requires: + +| Environment | CSI 18t supported? | Notes | +|---|---|---| +| **Windows Terminal** (wt.exe) | ✅ Yes | | +| **Windows Console Host** (conhost.exe, Win10+) | ✅ Yes | VT mode is enabled by the driver first | +| **xterm** | ✅ Yes | Reference implementation | +| **VTE terminals** (GNOME Terminal, etc.) | ✅ Yes | | +| **macOS Terminal.app** | ✅ Yes | | +| **iTerm2** | ✅ Yes | | +| **SSH with xterm/modern client terminal** | ✅ Yes | SSH is transparent; the client terminal matters | +| **tmux / screen** | ⚠️ Varies | Forwards or intercepts depending on config | +| **TERM=vt100 or TERM=dumb** | ❌ No | No xterm extensions; ANSI driver can't function here | + +Where CSI 18t fails (and the ANSI driver itself can't function), the +`AnsiRequestScheduler` 1-second stale timeout evicts the request cleanly — +no hang, just stuck at 80×25 (same as before the fix). + +--- + +## Remaining Work + +### Fix A — `catch (IOException)` in `CreateNativeSizeQuery` Windows path (bug) + +**File:** `Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs` line 92 + +`Console.WindowWidth` does NOT throw `IOException`. It throws: +- `System.InvalidOperationException` (when attached to a non-interactive shell) +- `System.IO.IOException` (rare, e.g., broken pipe on some configurations) +- Other platform exceptions + +The catch block must be `catch (Exception ex)` to be robust. + +```csharp +// Before (only catches IOException, misses InvalidOperationException etc.): +catch (IOException ex) +{ + Logging.Trace (...); + return null; +} + +// After: +catch (Exception ex) +{ + Trace.Lifecycle (nameof (AnsiComponentFactory), "NativeSizeQuery", $"Console size query failed: {ex.Message}"); + return null; +} +``` + +### Fix B — `Logging.Trace` → `Trace.Lifecycle` (consistency) + +**File:** `Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs` line 94 + +The codebase uses `Trace.Lifecycle(component, phase, message)` throughout +`AnsiOutput`, `AnsiSizeMonitor`, etc. The fix commit used `Logging.Trace(...)` +(old pattern). Change to `Trace.Lifecycle(...)`. + +### Fix C — Add `Trace.Lifecycle` instrumentation (user requirement) + +The user asked: "Instead of reasoning over code use TestLogging and trace to +actually trace execution." Trace points are needed so tests can assert the +execution path via `ListBackend`. + +| File | Location | What to trace | +|---|---|---| +| `AnsiSizeMonitor` | `Initialize()` | Driver set up, initial query sent | +| `AnsiSizeMonitor` | `SendSizeQuery()` | Query dispatched or throttled | +| `AnsiSizeMonitor` | `HandleSizeResponse()` | Raw response, parse success/fail | +| `AnsiSizeMonitor` | `CheckSizeChanged()` | Size unchanged or new size | +| `SizeMonitorImpl` | `Poll()` | Size checked, old→new or unchanged | +| `DriverImpl` | `OnSizeMonitorOnSizeChanged()` | Event received | +| `DriverImpl` | `SetScreenSize()` | New size applied | + +Replace existing commented-out `//Logging.Trace` stubs with proper `Trace.Lifecycle(...)` calls. + +### Tests — New `AnsiSizeMonitorTests.cs` + +**File:** `Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs` (new) + +| Test | What it verifies | +|---|---| +| `HandleSizeResponse_Updates_Size_And_Raises_Event` | Parsing `"[8;30;100t"` fires `SizeChanged(100×30)` | +| `HandleSizeResponse_NoChange_Does_Not_Raise_Event` | Same size → event not raised | +| `Poll_Sends_Query_When_Not_Throttled` | `Poll()` after 500 ms queues a new ANSI request | +| `Poll_Does_Not_Send_Query_When_Throttled` | Second `Poll()` within 500 ms does not queue | +| `Initialize_Sends_Initial_Query` | `Initialize(driver)` queues the first CSI 18t request | +| `SizeChange_Propagates_To_Driver_SizeChanged` | Full chain: monitor → `DriverImpl.OnSizeMonitorOnSizeChanged` → `IDriver.SizeChanged` | +| `Traces_Execution_Via_ListBackend` | Enables `TraceCategory.Lifecycle` with `ListBackend`, triggers a size change, asserts trace entries contain expected phase strings | + +--- + +## Files to Change (Remaining) + +| File | Change | +|---|---| +| `Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs` | Fix A + Fix B | +| `Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs` | Fix C — add `Trace.Lifecycle` | +| `Terminal.Gui/Drivers/SizeMonitorImpl.cs` | Fix C — add `Trace.Lifecycle` | +| `Terminal.Gui/Drivers/DriverImpl.cs` | Fix C — add `Trace.Lifecycle` | +| `Tests/.../AnsiDriver/AnsiSizeMonitorTests.cs` | New test file | + +--- + +## Verification + +```bash +dotnet build --no-restore +dotnet test --project Tests/UnitTestsParallelizable --no-build +``` + +Manual: run UICatalog with `--driver=ansi` and resize the terminal window; the UI +should reflow correctly instead of staying frozen at 80×25. + + +When running UICatalog with the ANSI driver on Windows (or any platform), the screen +size is permanently stuck at 80×25. Terminal resize events are never reported. + +## Root Cause Analysis + +### The Call Chain + +``` +ApplicationMainLoop.IterationImpl() + └─ SizeMonitor.Poll() ← called every iteration + └─ AnsiOutput.GetSize() ← returns _consoleSize ALWAYS + └─ _consoleSize == new Size(80,25) ← set in constructor, NEVER changes +``` + +### Why It Breaks + +`ComponentFactoryImpl.CreateSizeMonitor()` (the base-class default) creates +`SizeMonitorImpl(consoleOutput)`. `AnsiComponentFactory` overrides this but only +uses `AnsiSizeMonitor` when `Driver.SizeDetection == SizeDetectionMode.AnsiQuery`. +When `SizeDetection == Polling` (the **default**), it falls back to +`SizeMonitorImpl(ansiOutput)`. + +`SizeMonitorImpl.Poll()` calls `IOutput.GetSize()`. For `NetOutput` and +`WindowsOutput` this queries a live OS API. For `AnsiOutput`, `GetSize()` returns +the cached `_consoleSize` field — which is initialized to `(80,25)` and is only +updated by `HandleSizeQueryResponse()` (the ANSI-query path). + +Result: `SizeMonitorImpl` always sees the same size; `SizeChanged` is never raised. + +### Why AnsiOutput.GetSize() Is Correct By Design + +`AnsiOutput` is intentionally OS-agnostic. It must not call Win32 P/Invoke or +Unix `ioctl` — those would be Windows/Unix dependencies. + +### Is CSI 18t "Universally Supported"? — Essentially Yes, For the ANSI Driver's Environment + +The claim that CSI 18t is "universally supported" is **accurate within the ANSI +driver's required operating environment**: + +| Environment | CSI 18t supported? | Notes | +|---|---|---| +| **Windows Terminal** (wt.exe) | ✅ Yes | | +| **Windows Console Host** (conhost.exe, Win10+) | ✅ Yes | VT mode is enabled by the driver (`ENABLE_VIRTUAL_TERMINAL_PROCESSING`/`INPUT`); modern conhost supports CSI 18t | +| **xterm** | ✅ Yes | Reference implementation | +| **VTE terminals** (GNOME Terminal, etc.) | ✅ Yes | | +| **macOS Terminal.app** | ✅ Yes | | +| **iTerm2** | ✅ Yes | | +| **tmux / screen** | ⚠️ Varies | Forwards or intercepts depending on config | +| **SSH (any client with xterm/VTE terminal)** | ✅ Yes | SSH is transparent; the *client terminal* is what matters | +| **TERM=vt100 or TERM=dumb** | ❌ No | These advertise no xterm extensions; the ANSI driver itself can't function here | + +**Conclusion:** The ANSI driver enables VT processing mode before operating +(via `WindowsVTOutputHelper` on Windows and raw-mode on Unix). Within that VT +context — which is a prerequisite for the ANSI driver to work at all — CSI 18t +is supported. Terminals that don't support it also don't support enough VT for +the driver to function. + +**What happens when CSI 18t is not answered?** The `AnsiRequestScheduler` has a +`_staleTimeout` of 1 second; if no response arrives, the request is evicted +(calls `Abandoned`, clearing `_expectingResponse`). The next `Poll()` 500 ms +later sends a new query. This loops harmlessly, keeping the size at 80×25 — the +same outcome as the broken `SizeMonitorImpl` path, so no regression. + +**On Windows with VT mode:** `WindowsVTInputHelper` enables +`ENABLE_VIRTUAL_TERMINAL_INPUT`, converting keyboard/mouse to ANSI sequences. +Window resize events are NOT automatically sent as VT sequences through stdin in +this mode; they come as `WINDOW_BUFFER_SIZE_EVENT` records via Win32 API. The +ANSI driver has no access to those (by design). CSI 18t polling via +`AnsiSizeMonitor` is therefore the only resize detection mechanism available for +the ANSI driver without adding Win32 dependencies — and it works correctly on +both Windows Terminal and modern conhost. + +### The Correct Monitor for AnsiOutput + +`AnsiSizeMonitor` is already the correct implementation. It: +1. Sends `CSI 18t` via `QueueAnsiRequest` on every Poll (throttled to 500 ms) +2. Parses the `ESC [ 8 ; height ; width t` response asynchronously +3. Calls `AnsiOutput.HandleSizeQueryResponse()` to update `_consoleSize` +4. Then calls `CheckSizeChanged()` which compares `_output.GetSize()` against + `_lastSize` and raises `SizeChanged` if different + +`SizeMonitorImpl` is only correct for outputs whose `GetSize()` calls a live OS +API on every invocation (NetOutput, WindowsOutput). + +--- + +## Proposed Fix + +### 1 — `AnsiComponentFactory.CreateSizeMonitor()` — always return `AnsiSizeMonitor` + +`AnsiSizeMonitor` must be used unconditionally for `AnsiOutput`. The `Polling` +setting of `Driver.SizeDetection` is not meaningful for the ANSI driver; the ANSI +escape-sequence query IS the polling mechanism for this driver. + +```csharp +public override ISizeMonitor CreateSizeMonitor(IOutput consoleOutput, IOutputBuffer outputBuffer) +{ + if (_injectedSizeMonitor is { }) return _injectedSizeMonitor; + + // AnsiOutput.GetSize() returns a cached constant; SizeMonitorImpl would never + // detect a change. AnsiSizeMonitor is the only correct monitor for AnsiOutput. + // Driver.SizeDetection == Polling is silently treated as AnsiQuery here + // because native-API polling is incompatible with AnsiOutput by design. + if (consoleOutput is AnsiOutput ansiOutput) + { + Trace.Lifecycle(...); + return new AnsiSizeMonitor(ansiOutput, queueAnsiRequest: null); + } + + return new SizeMonitorImpl(consoleOutput); // fallback, non-AnsiOutput +} +``` + +Update the doc comment on the `sizeMonitor` constructor parameter (currently says +"chosen based on `Driver.SizeDetection`") to reflect the corrected logic. + +### 2 — `SizeDetectionMode` XML doc update + +Add a note that `Polling` does not apply to the ANSI driver, which always uses +the `AnsiQuery` mechanism. + +### 3 — Add `Trace.Lifecycle` instrumentation + +Enable the size-change notification chain to be traced in tests and production +using `TraceCategory.Lifecycle` + `ListBackend`. + +| Location | What to trace | +|---|---| +| `AnsiSizeMonitor.Initialize()` | driver set up, initial query sent | +| `AnsiSizeMonitor.SendSizeQuery()` | query dispatched, throttle skipped | +| `AnsiSizeMonitor.HandleSizeResponse()` | raw response text, parse success/fail | +| `AnsiSizeMonitor.CheckSizeChanged()` | old size → new size, or no-change | +| `SizeMonitorImpl.Poll()` | size checked, old→new or no-change | +| `DriverImpl.OnSizeMonitorOnSizeChanged()` | event received from monitor | +| `DriverImpl.SetScreenSize()` | new width×height applied | + +Replace the commented-out `Logging.Trace` calls that already exist in these +methods with proper `Trace.Lifecycle(...)` calls. + +### 4 — Tests (`Tests/UnitTestsParallelizable/Drivers/`) + +New file: `AnsiDriver/AnsiSizeMonitorTests.cs` + +| Test | What it verifies | +|---|---| +| `AnsiComponentFactory_CreateSizeMonitor_Returns_AnsiSizeMonitor` | factory always returns `AnsiSizeMonitor` for `AnsiOutput`, regardless of `Driver.SizeDetection` | +| `AnsiSizeMonitor_HandleSizeResponse_Updates_Size_And_Raises_Event` | parsing `"[8;30;100t"` fires `SizeChanged(100×30)` | +| `AnsiSizeMonitor_HandleSizeResponse_NoChange_Does_Not_Raise_Event` | same size → event not raised | +| `AnsiSizeMonitor_Poll_Sends_Query_When_Not_Throttled` | `Poll()` after throttle window queues a new ANSI request | +| `AnsiSizeMonitor_Poll_Does_Not_Send_Query_When_Throttled` | second `Poll()` within 500 ms does not queue another request | +| `AnsiSizeMonitor_Initialize_Sends_Initial_Query` | `Initialize(driver)` queues the first CSI 18t request | +| `AnsiSizeMonitor_SizeChange_Propagates_To_Driver_SizeChanged` | full chain: monitor → `DriverImpl.OnSizeMonitorOnSizeChanged` → `IDriver.SizeChanged` | +| `AnsiSizeMonitor_Traces_Execution_Via_ListBackend` | enables `TraceCategory.Lifecycle` with a `ListBackend`, triggers a size change, asserts trace entries contain expected phase strings | + +Update `SizeMonitorTests.cs`: + +| Test | Change | +|---|---| +| Existing `SizeMonitorImpl` tests | No change; they already use `Mock` correctly | +| `SizeMonitorImpl_Does_Not_Fire_For_AnsiOutput_Returning_Constant` | **new** — documents and asserts that `SizeMonitorImpl` with a constant-returning `IOutput` never fires `SizeChanged`, confirming why the refactor was necessary | + +--- + +## Files Changed + +| File | Change | +|---|---| +| `Terminal.Gui/Drivers/AnsiDriver/AnsiComponentFactory.cs` | Always use `AnsiSizeMonitor` for `AnsiOutput`; update constructor doc | +| `Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs` | Add `Trace.Lifecycle` throughout; uncomment/replace old logging stubs | +| `Terminal.Gui/Drivers/SizeMonitorImpl.cs` | Add `Trace.Lifecycle` for poll events; remove stale `using Microsoft.Extensions.Logging` | +| `Terminal.Gui/Drivers/DriverImpl.cs` | Add `Trace.Lifecycle` in `OnSizeMonitorOnSizeChanged` and `SetScreenSize` | +| `Terminal.Gui/Drivers/SizeDetectionMode.cs` | XML doc: clarify `Polling` does not apply to the ANSI driver | +| `Tests/.../AnsiDriver/AnsiSizeMonitorTests.cs` | **New** — full test suite listed above | +| `Tests/.../Drivers/SizeMonitorTests.cs` | Add regression test documenting the broken-SizeMonitorImpl case | + +--- + +## Verification + +```bash +dotnet build --no-restore +dotnet test --project Tests/UnitTestsParallelizable --no-build --filter "ClassName~AnsiSizeMonitor" +dotnet test --project Tests/UnitTestsParallelizable --no-build --filter "ClassName~SizeMonitor" +dotnet test --project Tests/UnitTestsParallelizable --no-build # full suite +``` + +Manual: run UICatalog with `--driver=ansi` and resize the terminal window; the UI +should reflow correctly instead of staying frozen at 80×25.