diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d41181adfe..b78293b9ca 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -18,6 +18,8 @@ jobs: matrix: os: [ ubuntu-latest, windows-latest, macos-latest ] timeout-minutes: 15 + env: + DisableRealDriverIO: "1" steps: - name: Checkout code diff --git a/.github/workflows/stress-tests.yml b/.github/workflows/stress-tests.yml index ffbb23e566..033f5c43d5 100644 --- a/.github/workflows/stress-tests.yml +++ b/.github/workflows/stress-tests.yml @@ -17,6 +17,8 @@ jobs: os: [ ubuntu-latest ] timeout-minutes: 70 # Allow some buffer time beyond the 1-hour test duration + env: + DisableRealDriverIO: "1" steps: - name: Checkout code uses: actions/checkout@v4 @@ -58,6 +60,8 @@ jobs: os: [ ubuntu-latest, windows-latest, macos-latest ] timeout-minutes: 90 + env: + DisableRealDriverIO: "1" steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 181333709a..9cd63dfb0c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,6 +20,8 @@ jobs: os: [ ubuntu-latest, windows-latest, macos-latest ] timeout-minutes: 15 + env: + DisableRealDriverIO: "1" steps: - name: Checkout code @@ -76,6 +78,8 @@ jobs: os: [ ubuntu-latest, windows-latest, macos-latest ] timeout-minutes: 60 + env: + DisableRealDriverIO: "1" steps: - name: Checkout code diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index 6d8231d6b8..5064c488bd 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -88,9 +88,9 @@ public AnsiInput () try { // Check if we have a real console first - if (!AnsiTerminalHelper.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) + if (!IsAttachedToTerminal) { - Trace.Lifecycle (nameof (AnsiInput), "Init", $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiInput), "Init", "Console is not attached to a terminal. Running in degraded mode."); return; } @@ -105,7 +105,9 @@ public AnsiInput () _windowsVTInput.Dispose (); _windowsVTInput = null; - Trace.Lifecycle (nameof (AnsiInput), "Init", "Failed to enable Windows VT Input mode. Terminal input will not work. Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiInput), + "Init", + "Failed to enable Windows VT Input mode. Terminal input will not work. Running in degraded mode."); return; } @@ -121,7 +123,9 @@ public AnsiInput () if (!_unixRawMode.TryEnable ()) { - Trace.Lifecycle (nameof (AnsiInput), "Init", "Failed to enable Unix raw input mode. Terminal input will not work. Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiInput), + "Init", + "Failed to enable Unix raw input mode. Terminal input will not work. Running in degraded mode."); _pollMap = null; _unixRawMode?.Dispose (); _unixRawMode = null; @@ -132,7 +136,9 @@ public AnsiInput () } catch (DllNotFoundException ex) { - Trace.Lifecycle (nameof (AnsiInput), "Init", $"Failed to enable Unix raw input mode. libc not available: {ex.Message}. Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiInput), + "Init", + $"Failed to enable Unix raw input mode. libc not available: {ex.Message}. Running in degraded mode."); } } else @@ -147,7 +153,9 @@ public AnsiInput () } catch (Exception ex) { - Trace.Lifecycle (nameof (AnsiInput), "Init", $"Failed to initialize terminal: {ex.GetType ().Name}: {ex.Message}. Running in degraded mode. Stack trace: {ex.StackTrace}"); + Trace.Lifecycle (nameof (AnsiInput), + "Init", + $"Failed to initialize terminal: {ex.GetType ().Name}: {ex.Message}. Running in degraded mode. Stack trace: {ex.StackTrace}"); _platform = AnsiPlatform.Degraded; } } diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index d7ceff9e0e..f3ef57d450 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -61,9 +61,9 @@ public AnsiOutput () try { // Check if we have a real console first - if (!AnsiTerminalHelper.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) + if (!IsAttachedToTerminal) { - Trace.Lifecycle (nameof (AnsiOutput), "Init", $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiOutput), "Init", "No real terminal attached. Running in degraded mode."); return; } @@ -78,7 +78,9 @@ public AnsiOutput () _windowsVTOutput.Dispose (); _windowsVTOutput = null; - Trace.Lifecycle (nameof (AnsiOutput), "Init", "Failed to enable Windows VT Input mode. Terminal input will not work. Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiOutput), + "Init", + "Failed to enable Windows VT Input mode. Terminal input will not work. Running in degraded mode."); return; } @@ -178,14 +180,16 @@ protected override void Write (StringBuilder output) /// public void Write (ReadOnlySpan text) { + StringBuilder capturedOutput = new (); + capturedOutput.Append (text); + base.Write (capturedOutput); + try { switch (_platform) { case AnsiPlatform.WindowsVT: - StringBuilder sb = new (); - sb.Append (text); - _windowsVTOutput!.Write (sb); + _windowsVTOutput!.Write (capturedOutput); break; @@ -310,13 +314,13 @@ public void HandleSizeQueryResponse (string? response) /// public void Dispose () { - if (_platform == AnsiPlatform.Degraded) - { - return; - } - try { + if (_platform == AnsiPlatform.Degraded) + { + return; + } + // Restore terminal state: disable mouse, restore buffer, show cursor // TODO: Move Input related CSI sequences to AnsiInput Write (EscSeqUtils.CSI_DisableMouseEvents); diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs index 34ab711bfd..09742178f8 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs @@ -4,30 +4,6 @@ namespace Terminal.Gui.Drivers; internal static class AnsiTerminalHelper { - public static bool IsAttachedToTerminal (out bool inputAttached, out bool outputAttached) - { - inputAttached = outputAttached = false; - - 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; - } - const int STDIN_FILENO = 0; - const int STDOUT_FILENO = 1; - inputAttached = isatty (STDIN_FILENO) == 1; - outputAttached = isatty (STDOUT_FILENO) == 1; - - return inputAttached && outputAttached; - } - public static void FlushNative (AnsiPlatform platform) { try @@ -85,9 +61,6 @@ private static void FlushWindows () } // Unix - [DllImport ("libc", SetLastError = true)] - private static extern int isatty (int fd); - [DllImport ("libc", SetLastError = true)] private static extern int tcdrain (int fd); @@ -98,9 +71,6 @@ private static void FlushWindows () [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); - [DllImport ("kernel32.dll", SetLastError = true)] [return: MarshalAs (UnmanagedType.Bool)] private static extern bool FlushFileBuffers (nint hFile); diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs index b45996378e..0c9a04fb59 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs @@ -1,4 +1,6 @@ #nullable disable +using Terminal.Gui.Tracing; + namespace Terminal.Gui.Drivers; /// @@ -11,19 +13,25 @@ public class NetInput : InputImpl, ITestableInput /// Creates a new instance of the class. Implicitly sends /// console mode settings that enable virtual input (mouse - /// reporting etc). + /// reporting etc.). /// public NetInput () { - //Logging.Information ($"Creating {nameof (NetInput)}"); + // Check if we have a real console first + if (!IsAttachedToTerminal) + { + Trace.Lifecycle (nameof (NetInput), "Init", "Console is not attached to a terminal. Running in degraded mode."); + + return; + } PlatformID p = Environment.OSVersion.Platform; - if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) + if (p is PlatformID.Win32NT or PlatformID.Win32S or PlatformID.Win32Windows) { try { - _adjustConsole = new (); + _adjustConsole = new NetWinVTConsole (); } catch (ApplicationException ex) { @@ -84,8 +92,8 @@ public override void Dispose () } } - /// - public void InjectInput (ConsoleKeyInfo input) { throw new NotImplementedException (); } + /// + public void InjectInput (ConsoleKeyInfo input) => throw new NotImplementedException (); /// public override bool Peek () diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index ae73fe2043..0474809d34 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -1,3 +1,5 @@ +using Terminal.Gui.Tracing; + namespace Terminal.Gui.Drivers; /// @@ -15,6 +17,13 @@ public NetOutput () { // Logging.Information ($"Creating {nameof (NetOutput)}"); + if (!IsAttachedToTerminal) + { + Trace.Lifecycle (nameof (NetOutput), "Init", "No real terminal attached. Output operations will be no-op."); + + return; + } + try { Console.OutputEncoding = Encoding.UTF8; @@ -26,7 +35,7 @@ public NetOutput () PlatformID p = Environment.OSVersion.Platform; - if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) + if (p is PlatformID.Win32NT or PlatformID.Win32S or PlatformID.Win32Windows) { _isWinPlatform = true; } @@ -35,6 +44,11 @@ public NetOutput () /// public Size GetSize () { + if (!IsAttachedToTerminal) + { + return new Size (80, 25); + } + try { if (Console.IsInputRedirected || Console.IsOutputRedirected) @@ -58,10 +72,14 @@ public void SetSize (int width, int height) // Do Nothing. } - /// public void Write (ReadOnlySpan text) { + if (!IsAttachedToTerminal) + { + return; + } + try { Console.Out.Write (text); @@ -77,6 +95,11 @@ protected override void Write (StringBuilder output) { base.Write (output); + if (!IsAttachedToTerminal) + { + return; + } + try { Console.Out.Write (output); @@ -89,14 +112,10 @@ protected override void Write (StringBuilder output) private Cursor _currentCursor = new (); - /// - public Cursor GetCursor () - { - return _currentCursor; - } - + /// + public Cursor GetCursor () => _currentCursor; - /// + /// public void SetCursor (Cursor cursor) { try @@ -107,7 +126,7 @@ public void SetCursor (Cursor cursor) } else { - if (_currentCursor!.Style != cursor.Style) + if (_currentCursor.Style != cursor.Style) { Write (EscSeqUtils.CSI_SetCursorStyle (cursor.Style)); } @@ -121,10 +140,7 @@ public void SetCursor (Cursor cursor) } finally { - SetCursorPositionImpl ( - cursor.Position?.X ?? 0, - cursor.Position?.Y ?? 0 - ); + SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); _currentCursor = cursor; } @@ -133,7 +149,7 @@ public void SetCursor (Cursor cursor) /// protected override bool SetCursorPositionImpl (int col, int row) { - if (_currentCursor!.Position is { } && _currentCursor.Position.Value.X == col && _currentCursor.Position.Value.Y == row) + if (_currentCursor.Position is { } && _currentCursor.Position.Value.X == col && _currentCursor.Position.Value.Y == row) { return false; } @@ -148,6 +164,7 @@ protected override bool SetCursorPositionImpl (int col, int row) { // Could happen that the windows is still resizing and the col is bigger than Console.WindowWidth. } + return true; } @@ -164,7 +181,7 @@ public void Dispose () { } /// public void Suspend () { - if (PlatformDetection.IsWindows ()) + if (PlatformDetection.IsWindows () && !IsAttachedToTerminal) { return; } @@ -178,7 +195,11 @@ public void Suspend () // Check if we have a real console first if (Console.IsInputRedirected || Console.IsOutputRedirected) { - Logging.Information ($"Console redirected (Output: {Console.IsOutputRedirected}, Input: {Console.IsInputRedirected}). Running in degraded mode."); + Logging.Information ($"Console redirected (Output: { + Console.IsOutputRedirected + }, Input: { + Console.IsInputRedirected + }). Running in degraded mode."); return; } diff --git a/Terminal.Gui/Drivers/Driver.cs b/Terminal.Gui/Drivers/Driver.cs index 02bc73a640..4d5a181314 100644 --- a/Terminal.Gui/Drivers/Driver.cs +++ b/Terminal.Gui/Drivers/Driver.cs @@ -1,12 +1,12 @@ -namespace Terminal.Gui.Drivers; +using System.Runtime.InteropServices; + +namespace Terminal.Gui.Drivers; /// -/// Holds global driver settings. +/// Holds global driver settings and cross-driver utility methods. /// public sealed class Driver { - private static bool _force16Colors = false; // Resources/config.json overrides - // NOTE: Force16Colors is a configuration property (Driver.Force16Colors). // NOTE: IDriver also has a Force16Colors property, which is an instance property // NOTE: set whenever this static property is set. @@ -17,15 +17,72 @@ public sealed class Driver [ConfigurationProperty (Scope = typeof (SettingsScope))] public static bool Force16Colors { - get => _force16Colors; + get; set { - bool oldValue = _force16Colors; - _force16Colors = value; - Force16ColorsChanged?.Invoke (null, new ValueChangedEventArgs (oldValue, _force16Colors)); + bool oldValue = field; + field = value; + Force16ColorsChanged?.Invoke (null, new ValueChangedEventArgs (oldValue, field)); } } /// Raised when changes. public static event EventHandler>? Force16ColorsChanged; + + /// + /// 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 + /// variable "DisableRealDriverIO=1" to skip real terminal detection and force this method to return false, which is + /// required for running in test harnesses that do not have a real terminal attached. + /// + /// + /// When this method returns, if standard input is connected to a console device; + /// otherwise . + /// + /// + /// When this method returns, if standard output is connected to a console device; + /// otherwise . + /// + /// if both input and output are attached to a terminal; otherwise . + public static bool IsAttachedToTerminal (out bool inputAttached, out bool outputAttached) + { + inputAttached = outputAttached = false; + + // When the test harness sets DisableRealDriverIO, skip real terminal detection entirely. + if (string.Equals (Environment.GetEnvironmentVariable ("DisableRealDriverIO"), "1", StringComparison.Ordinal)) + { + return false; + } + + 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; + } + + const int STDIN_FILENO = 0; + const int STDOUT_FILENO = 1; + inputAttached = isatty (STDIN_FILENO) == 1; + outputAttached = isatty (STDOUT_FILENO) == 1; + + 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/Input/InputImpl.cs b/Terminal.Gui/Drivers/Input/InputImpl.cs index 6aa69cfc5b..d72af4a465 100644 --- a/Terminal.Gui/Drivers/Input/InputImpl.cs +++ b/Terminal.Gui/Drivers/Input/InputImpl.cs @@ -10,6 +10,17 @@ namespace Terminal.Gui.Drivers; /// public abstract class InputImpl : IInput { + /// + /// Initializes a new instance of the class and detects if we are attached to a + /// real terminal device. If not, the input implementation will run in a degraded mode where all operations are no-op. + /// + protected InputImpl () => IsAttachedToTerminal = Driver.IsAttachedToTerminal (out _, out _); + + /// + /// Gets whether this input instance is attached to a real terminal device. + /// + protected bool IsAttachedToTerminal { get; } + private ConcurrentQueue? _inputQueue; /// diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 0d42a9ace1..01e9b09653 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -7,12 +7,20 @@ namespace Terminal.Gui.Drivers; /// public abstract class OutputBase { - private bool _force16Colors; + /// + /// Initializes a new instance of the class and detects whether the output is attached to a real terminal device. + /// + protected OutputBase () => IsAttachedToTerminal = Driver.IsAttachedToTerminal (out _, out _); + + /// + /// Gets whether this output instance is attached to a real terminal device. + /// + protected bool IsAttachedToTerminal { get; } /// public bool Force16Colors { - get => _force16Colors; + get; set { if (IsLegacyConsole && !value) @@ -20,19 +28,17 @@ public bool Force16Colors return; } - _force16Colors = value; + field = value; } } - private bool _isLegacyConsole; - /// public bool IsLegacyConsole { - get => _isLegacyConsole; + get; set { - _isLegacyConsole = value; + field = value; if (value) // If legacy console (true), force 16 colors { @@ -43,13 +49,13 @@ public bool IsLegacyConsole private readonly ConcurrentQueue _sixels = []; - /// > + /// public ConcurrentQueue GetSixels () => _sixels; // Last text style used, for updating style with EscSeqUtils.CSI_AppendTextStyleChange(). private TextStyle _redrawTextStyle = TextStyle.None; - StringBuilder _lastOutputStringBuilder = new (); + private readonly StringBuilder _lastOutputStringBuilder = new (); private bool _clearLastOutputPending; /// @@ -61,8 +67,8 @@ public virtual void Write (IOutputBuffer buffer) { _clearLastOutputPending = true; StringBuilder outputStringBuilder = new (); - int top = 0; - int left = 0; + var top = 0; + var left = 0; int rows = buffer.Rows; int cols = buffer.Cols; Attribute? redrawAttr = null; @@ -130,20 +136,22 @@ public virtual void Write (IOutputBuffer buffer) } // Flush buffered output for row - if (outputStringBuilder.Length > 0) + if (outputStringBuilder.Length <= 0) { - if (IsLegacyConsole) - { - Write (outputStringBuilder); - } - else - { - SetCursorPositionImpl (lastCol, row); + continue; + } - // Wrap URLs with OSC 8 hyperlink sequences - StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder); - Write (processed); - } + if (IsLegacyConsole) + { + Write (outputStringBuilder); + } + else + { + SetCursorPositionImpl (lastCol, row); + + // Wrap URLs with OSC 8 hyperlink sequences + StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder); + Write (processed); } } @@ -165,7 +173,7 @@ public virtual void Write (IOutputBuffer buffer) } } - /// + /// public virtual string GetLastOutput () => _lastOutputStringBuilder.ToString (); /// @@ -250,19 +258,17 @@ protected virtual void Write (StringBuilder output) /// The last attribute used, for optimization. /// Predicate to determine which cells to include. If null, includes all cells. /// Whether to add newlines between rows. - protected void BuildAnsiForRegion ( - IOutputBuffer buffer, - int startRow, - int endRow, - int startCol, - int endCol, - StringBuilder output, - ref Attribute? lastAttr, - Func? includeCellPredicate = null, - bool addNewlines = true - ) + protected void BuildAnsiForRegion (IOutputBuffer buffer, + int startRow, + int endRow, + int startCol, + int endCol, + StringBuilder output, + ref Attribute? lastAttr, + Func? includeCellPredicate = null, + bool addNewlines = true) { - TextStyle redrawTextStyle = TextStyle.None; + var redrawTextStyle = TextStyle.None; for (int row = startRow; row < endRow; row++) { @@ -296,7 +302,13 @@ protected void BuildAnsiForRegion ( /// The maximum column, used for wide character handling. /// The current column, updated for wide characters. /// The current output width, updated for wide characters. - protected void AppendCellAnsi (Cell cell, StringBuilder output, ref Attribute? lastAttr, ref TextStyle redrawTextStyle, int maxCol, ref int currentCol, ref int outputWidth) + protected void AppendCellAnsi (Cell cell, + StringBuilder output, + ref Attribute? lastAttr, + ref TextStyle redrawTextStyle, + int maxCol, + ref int currentCol, + ref int outputWidth) { Attribute? attribute = cell.Attribute; @@ -314,11 +326,12 @@ protected void AppendCellAnsi (Cell cell, StringBuilder output, ref Attribute? l outputWidth++; // Handle wide grapheme - if (grapheme.GetColumns () > 1 && currentCol + 1 < maxCol) + if (grapheme.GetColumns () <= 1 || currentCol + 1 >= maxCol) { - currentCol++; // Skip next cell for wide character - outputWidth++; + return; } + currentCol++; // Skip next cell for wide character + outputWidth++; } /// @@ -335,9 +348,9 @@ public string ToAnsi (IOutputBuffer buffer) { StringBuilder output = new (); - for (int row = 0; row < buffer.Rows; row++) + for (var row = 0; row < buffer.Rows; row++) { - for (int col = 0; col < buffer.Cols; col++) + for (var col = 0; col < buffer.Cols; col++) { Cell cell = buffer.Contents! [row, col]; string grapheme = cell.Grapheme; diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs b/Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs index b2e9bb2e96..cf660584e6 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs +++ b/Terminal.Gui/Drivers/UnixDriver/UnixIOHelper.cs @@ -78,7 +78,7 @@ public enum Condition : short /// Timeout in milliseconds (0 = non-blocking, -1 = infinite) /// Number of file descriptors with events, or -1 on error [DllImport ("libc", SetLastError = true)] - public static extern int poll ([In][Out] Pollfd [] ufds, uint nfds, int timeout); + public static extern int poll ([In] [Out] Pollfd [] ufds, uint nfds, int timeout); /// /// Read bytes from a file descriptor. @@ -157,10 +157,9 @@ public struct WinSize /// Get window/terminal size using ioctl. /// Platform-specific constant (different on Darwin/BSD vs Linux). /// - public static readonly uint TIOCGWINSZ = - RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD) - ? 0x40087468u // Darwin/BSD - : 0x5413u; // Linux + public static readonly uint TIOCGWINSZ = RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD) + ? 0x40087468u // Darwin/BSD + : 0x5413u; // Linux /// /// I/O control operations on file descriptors. @@ -173,8 +172,8 @@ public struct WinSize public static extern int ioctl (int fd, uint request, out WinSize ws); /// - /// ioctl definition for Darwin/FreeBSD on ARM64. - /// See https://github.com/dotnet/runtime/issues/48796#issuecomment-3695794860. + /// ioctl definition for Darwin/FreeBSD on ARM64. + /// See https://github.com/dotnet/runtime/issues/48796#issuecomment-3695794860. /// /// File descriptor /// Request code (e.g., TIOCGWINSZ) @@ -187,7 +186,15 @@ public struct WinSize /// Window size structure (output) /// 0 on success, -1 on error [DllImport ("libc", EntryPoint = "ioctl", SetLastError = true)] - public static extern int ioctl_arm64 (int fd, ulong request, nint r3, nint r4, nint r5, nint r6, nint r7, nint r8, out WinSize ws); + public static extern int ioctl_arm64 (int fd, + ulong request, + nint r3, + nint r4, + nint r5, + nint r6, + nint r7, + nint r8, + out WinSize ws); #endregion @@ -298,12 +305,21 @@ public static bool TryGetTerminalSize (out Size size) { try { - int ioctlResult = 0; + var ioctlResult = 0; WinSize ws; - if (RuntimeInformation.OSArchitecture == Architecture.Arm64 && - (RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD))) + + if (RuntimeInformation.OSArchitecture == Architecture.Arm64 + && (RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD))) { - ioctlResult = ioctl_arm64 (STDOUT_FILENO, TIOCGWINSZ, 0, 0, 0, 0, 0, 0, out ws); + ioctlResult = ioctl_arm64 (STDOUT_FILENO, + TIOCGWINSZ, + 0, + 0, + 0, + 0, + 0, + 0, + out ws); } else { @@ -312,9 +328,9 @@ public static bool TryGetTerminalSize (out Size size) if (ioctlResult == 0) { - if (ws.ws_col > 0 && ws.ws_row > 0) + if (ws is { ws_col: > 0, ws_row: > 0 }) { - size = new (ws.ws_col, ws.ws_row); + size = new Size (ws.ws_col, ws.ws_row); return true; } @@ -325,7 +341,7 @@ public static bool TryGetTerminalSize (out Size size) // ignore } - size = new (80, 25); // fallback + size = new Size (80, 25); // fallback return false; } diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs b/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs index d2726bc83f..d049a83655 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs +++ b/Terminal.Gui/Drivers/UnixDriver/UnixInput.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Terminal.Gui.Tracing; // ReSharper disable IdentifierTypo // ReSharper disable StringLiteralTypo @@ -20,7 +21,13 @@ internal class UnixInput : InputImpl, IUnixInput, ITestableInput public UnixInput () { - //Logging.Information ($"Creating {nameof (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 { @@ -30,17 +37,18 @@ public UnixInput () // Enable raw mode using the helper _terminalInitialized = _rawModeHelper.TryEnable (); - if (_terminalInitialized) + if (!_terminalInitialized) { - 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); + 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) { @@ -102,7 +110,7 @@ public override IEnumerable Read () continue; } - byte [] buf = new byte [256]; + var buf = new byte [256]; if (!UnixIOHelper.TryReadStdin (buf, out int bytesRead) || bytesRead <= 0) { @@ -146,7 +154,7 @@ private void FlushConsoleInput () } /// - public void InjectInput (char input) { _testInput.Enqueue (input); } + public void InjectInput (char input) => _testInput.Enqueue (input); /// public override void Dispose () diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs index 286ec59ec0..619dddf416 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs +++ b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; +using Terminal.Gui.Tracing; // ReSharper disable IdentifierTypo // ReSharper disable StringLiteralTypo @@ -10,12 +11,33 @@ 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 () => UnixTerminalHelper.Suspend (this); + 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); } @@ -25,19 +47,21 @@ 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 () - { - return _currentCursor; - } + /// + public Cursor GetCursor () => _currentCursor; - /// + /// public void SetCursor (Cursor cursor) { try @@ -48,7 +72,7 @@ public void SetCursor (Cursor cursor) } else { - if (_currentCursor!.Style != cursor.Style) + if (_currentCursor.Style != cursor.Style) { Write (EscSeqUtils.CSI_SetCursorStyle (cursor.Style)); } @@ -62,10 +86,7 @@ public void SetCursor (Cursor cursor) } finally { - SetCursorPositionImpl ( - cursor.Position?.X ?? 0, - cursor.Position?.Y ?? 0 - ); + SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); _currentCursor = cursor; } @@ -74,7 +95,7 @@ public void SetCursor (Cursor cursor) /// protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) { - if (_currentCursor!.Position is { } && _currentCursor.Position.Value.X == screenPositionX && _currentCursor.Position.Value.Y == screenPositionY) + if (_currentCursor.Position is { } && _currentCursor.Position.Value.X == screenPositionX && _currentCursor.Position.Value.Y == screenPositionY) { return false; } @@ -116,10 +137,7 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo // create FileStream from the safe handle var stream = new FileStream (handle, FileAccess.Write); - return new StreamWriter (stream) - { - AutoFlush = true - }; + return new StreamWriter (stream) { AutoFlush = true }; } catch (Exception ex) { @@ -132,12 +150,17 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo /// public Size GetSize () { + if (!IsAttachedToTerminal) + { + return new Size (80, 25); + } + if (UnixIOHelper.TryGetTerminalSize (out Size size)) { return size; } - return new (80, 25); // fallback + return new Size (80, 25); // fallback } /// diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs b/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs index a2d8f57cf0..5c48fd33ef 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs +++ b/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs @@ -101,9 +101,11 @@ public static void Suspend (IOutput output) output.Write (EscSeqUtils.CSI_DisableMouseEvents); // Check if we have a real console first - if (!AnsiTerminalHelper.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) + if (!Driver.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) { - Trace.Lifecycle (nameof (UnixTerminalHelper), "Suspend", $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); + Trace.Lifecycle (nameof (UnixTerminalHelper), + "Suspend", + $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); return; } diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsInput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsInput.cs index 7b4e4f6603..16bb3c0a53 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsInput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsInput.cs @@ -1,5 +1,5 @@ using System.Runtime.InteropServices; -using Microsoft.Extensions.Logging; +using Terminal.Gui.Tracing; using static Terminal.Gui.Drivers.WindowsConsole; namespace Terminal.Gui.Drivers; @@ -9,20 +9,10 @@ internal class WindowsInput : InputImpl, IWindowsInput private readonly nint _inputHandle; [DllImport ("kernel32.dll", EntryPoint = "ReadConsoleInputW", CharSet = CharSet.Unicode)] - public static extern bool ReadConsoleInput ( - nint hConsoleInput, - nint lpBuffer, - uint nLength, - out uint lpNumberOfEventsRead - ); + public static extern bool ReadConsoleInput (nint hConsoleInput, nint lpBuffer, uint nLength, out uint lpNumberOfEventsRead); [DllImport ("kernel32.dll", EntryPoint = "PeekConsoleInputW", CharSet = CharSet.Unicode)] - public static extern bool PeekConsoleInput ( - nint hConsoleInput, - nint lpBuffer, - uint nLength, - out uint lpNumberOfEventsRead - ); + public static extern bool PeekConsoleInput (nint hConsoleInput, nint lpBuffer, uint nLength, out uint lpNumberOfEventsRead); [DllImport ("kernel32.dll", SetLastError = true)] private static extern nint GetStdHandle (int nStdHandle); @@ -40,7 +30,13 @@ out uint lpNumberOfEventsRead public WindowsInput () { - //Logging.Information ($"Creating {nameof (WindowsInput)}"); + // Check if we have a real console first + if (!IsAttachedToTerminal) + { + Trace.Lifecycle (nameof (WindowsInput), "Init", "Console is not attached to a terminal. Running in degraded mode."); + + return; + } try { @@ -101,19 +97,14 @@ public override IEnumerable Read () try { - ReadConsoleInput ( - _inputHandle, - pRecord, - BUFFER_SIZE, - out uint numberEventsRead); - - return numberEventsRead == 0 - ? [] - : new [] { Marshal.PtrToStructure (pRecord) }; + ReadConsoleInput (_inputHandle, pRecord, BUFFER_SIZE, out uint numberEventsRead); + + return numberEventsRead == 0 ? [] : new [] { Marshal.PtrToStructure (pRecord) }; } catch (Exception) { Logging.Error ($"Error reading console input, error code: {Marshal.GetLastWin32Error ()}."); + return []; } finally diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs index cc5ab8218d..c6f3d818c6 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Runtime.InteropServices; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; @@ -7,13 +8,11 @@ internal partial class WindowsOutput : OutputBase, IOutput { [LibraryImport ("kernel32.dll", EntryPoint = "WriteConsoleW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] [return: MarshalAs (UnmanagedType.Bool)] - private static partial bool WriteConsole ( - nint hConsoleOutput, - ReadOnlySpan lpBuffer, - uint numberOfCharsToWrite, - out uint lpNumberOfCharsWritten, - nint lpReserved - ); + private static partial bool WriteConsole (nint hConsoleOutput, + ReadOnlySpan lpBuffer, + uint numberOfCharsToWrite, + out uint lpNumberOfCharsWritten, + nint lpReserved); [LibraryImport ("kernel32.dll", SetLastError = true)] private static partial nint GetStdHandle (int nStdHandle); @@ -23,13 +22,11 @@ nint lpReserved private static partial bool CloseHandle (nint handle); [LibraryImport ("kernel32.dll", SetLastError = true)] - private static partial nint CreateConsoleScreenBuffer ( - DesiredAccess dwDesiredAccess, - ShareMode dwShareMode, - nint securityAttributes, - uint flags, - nint screenBufferData - ); + private static partial nint CreateConsoleScreenBuffer (DesiredAccess dwDesiredAccess, + ShareMode dwShareMode, + nint securityAttributes, + uint flags, + nint screenBufferData); [DllImport ("kernel32.dll", SetLastError = true)] [return: MarshalAs (UnmanagedType.Bool)] @@ -76,9 +73,7 @@ private enum DesiredAccess : uint private static partial bool SetConsoleMode (nint hConsoleHandle, uint dwMode); [LibraryImport ("kernel32.dll", SetLastError = true)] - private static partial WindowsConsole.Coord GetLargestConsoleWindowSize ( - nint hConsoleOutput - ); + private static partial WindowsConsole.Coord GetLargestConsoleWindowSize (nint hConsoleOutput); [DllImport ("kernel32.dll", SetLastError = true)] [return: MarshalAs (UnmanagedType.Bool)] @@ -86,11 +81,7 @@ nint hConsoleOutput [DllImport ("kernel32.dll", SetLastError = true)] [return: MarshalAs (UnmanagedType.Bool)] - private static extern bool SetConsoleWindowInfo ( - nint hConsoleOutput, - bool bAbsolute, - [In] ref WindowsConsole.SmallRect lpConsoleWindow - ); + private static extern bool SetConsoleWindowInfo (nint hConsoleOutput, bool bAbsolute, [In] ref WindowsConsole.SmallRect lpConsoleWindow); private const int STD_OUTPUT_HANDLE = -11; private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004; @@ -103,6 +94,13 @@ public WindowsOutput () { //Logging.Information ($"Creating {nameof (WindowsOutput)}"); + if (!IsAttachedToTerminal) + { + Trace.Lifecycle (nameof (WindowsOutput), "Init", "No real terminal attached. Output operations will be no-op."); + + return; + } + if (!RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) { return; @@ -152,11 +150,8 @@ public WindowsOutput () private Cursor _currentCursor = new (); - /// - public Cursor GetCursor () - { - return _currentCursor; - } + /// + public Cursor GetCursor () => _currentCursor; // public void SetCursor (Cursor cursor) @@ -177,15 +172,15 @@ public void SetCursor (Cursor cursor) cursorInfo.bVisible = true; cursorInfo.dwSize = cursor.Style switch - { - CursorStyle.BlinkingBlock => 100, - CursorStyle.SteadyBlock => 100, - CursorStyle.BlinkingUnderline => 15, - CursorStyle.SteadyUnderline => 15, - CursorStyle.BlinkingBar => 15, - CursorStyle.SteadyBar => 15, - _ => 100 - }; + { + CursorStyle.BlinkingBlock => 100, + CursorStyle.SteadyBlock => 100, + CursorStyle.BlinkingUnderline => 15, + CursorStyle.SteadyUnderline => 15, + CursorStyle.BlinkingBar => 15, + CursorStyle.SteadyBar => 15, + _ => 100 + }; } SetConsoleCursorInfo (!IsLegacyConsole ? _outputHandle : _screenBuffer, ref cursorInfo); @@ -198,7 +193,7 @@ public void SetCursor (Cursor cursor) } else { - if (_currentCursor!.Style != cursor.Style) + if (_currentCursor.Style != cursor.Style) { Write (EscSeqUtils.CSI_SetCursorStyle (cursor.Style)); } @@ -213,10 +208,7 @@ public void SetCursor (Cursor cursor) } finally { - SetCursorPositionImpl ( - cursor.Position?.X ?? 0, - cursor.Position?.Y ?? 0 - ); + SetCursorPositionImpl (cursor.Position?.X ?? 0, cursor.Position?.Y ?? 0); _currentCursor = cursor; } } @@ -226,7 +218,7 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo { if (Force16Colors && IsLegacyConsole) { - SetConsoleCursorPosition (_screenBuffer, new ((short)screenPositionX, (short)screenPositionY)); + SetConsoleCursorPosition (_screenBuffer, new WindowsConsole.Coord ((short)screenPositionX, (short)screenPositionY)); } else { @@ -240,13 +232,11 @@ protected override bool SetCursorPositionImpl (int screenPositionX, int screenPo private void CreateScreenBuffer () { - _screenBuffer = CreateConsoleScreenBuffer ( - DesiredAccess.GenericRead | DesiredAccess.GenericWrite, + _screenBuffer = CreateConsoleScreenBuffer (DesiredAccess.GenericRead | DesiredAccess.GenericWrite, ShareMode.FileShareRead | ShareMode.FileShareWrite, nint.Zero, 1, - nint.Zero - ); + nint.Zero); if (_screenBuffer == INVALID_HANDLE_VALUE) { @@ -266,6 +256,11 @@ private void CreateScreenBuffer () public void Write (ReadOnlySpan str) { + if (!IsAttachedToTerminal) + { + return; + } + if (!RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) { return; @@ -296,9 +291,9 @@ public Size ResizeBuffer (Size size) internal Size SetConsoleWindow (short cols, short rows) { - if (!RuntimeInformation.IsOSPlatform (OSPlatform.Windows)) + if (!RuntimeInformation.IsOSPlatform (OSPlatform.Windows) || !IsAttachedToTerminal) { - return new (cols, rows); + return new Size (cols, rows); } var csbi = new WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX (); @@ -312,36 +307,31 @@ internal Size SetConsoleWindow (short cols, short rows) // Use the requested size directly. GetLargestConsoleWindowSize can underreport // in modern terminals with non-default font sizes, causing the buffer to be // clamped smaller than the actual window (visible as a gap at the bottom/right). - short newCols = cols; - short newRows = rows; - csbi.dwSize = new (newCols, Math.Max (newRows, (short)1)); - csbi.srWindow = new (0, 0, newCols, newRows); - csbi.dwMaximumWindowSize = new (newCols, newRows); + csbi.dwSize = new WindowsConsole.Coord (cols, Math.Max (rows, (short)1)); + csbi.srWindow = new WindowsConsole.SmallRect (0, 0, cols, rows); + csbi.dwMaximumWindowSize = new WindowsConsole.Coord (cols, rows); if (!SetConsoleScreenBufferInfoEx (!IsLegacyConsole ? _outputHandle : _screenBuffer, ref csbi)) { throw new Win32Exception (Marshal.GetLastWin32Error ()); } - var winRect = new WindowsConsole.SmallRect (0, 0, (short)(newCols - 1), (short)Math.Max (newRows - 1, 0)); + var winRect = new WindowsConsole.SmallRect (0, 0, (short)(cols - 1), (short)Math.Max (rows - 1, 0)); if (!SetConsoleWindowInfo (_outputHandle, true, ref winRect)) { //throw new System.ComponentModel.Win32Exception (Marshal.GetLastWin32Error ()); - return new (cols, rows); + return new Size (cols, rows); } SetConsoleOutputWindow (csbi); - return new (winRect.Right + 1, newRows - 1 < 0 ? 0 : winRect.Bottom + 1); + return new Size (winRect.Right + 1, rows - 1 < 0 ? 0 : winRect.Bottom + 1); } private void SetConsoleOutputWindow (WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX csbi) { - if ((!IsLegacyConsole - ? _outputHandle - : _screenBuffer) - != nint.Zero + if ((!IsLegacyConsole ? _outputHandle : _screenBuffer) != nint.Zero && !SetConsoleScreenBufferInfoEx (!IsLegacyConsole ? _outputHandle : _screenBuffer, ref csbi)) { throw new Win32Exception (Marshal.GetLastWin32Error ()); @@ -368,25 +358,31 @@ public override void Write (IOutputBuffer outputBuffer) { base.Write (outputBuffer); + if (!IsAttachedToTerminal) + { + return; + } + ReadOnlySpan span = _everythingStringBuilder.ToString ().AsSpan (); // still allocates the string bool result = WriteConsole (_consoleBuffer, span, (uint)span.Length, out _, nint.Zero); - if (!result) + if (result) { - int err = Marshal.GetLastWin32Error (); + return; + } + int err = Marshal.GetLastWin32Error (); - if (err == 1) - { - Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); + if (err == 1) + { + Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); - return; - } + return; + } - if (err != 0) - { - throw new Win32Exception (err); - } + if (err != 0) + { + throw new Win32Exception (err); } } catch (DllNotFoundException) @@ -414,6 +410,11 @@ protected override void Write (StringBuilder output) base.Write (output); + if (!IsAttachedToTerminal) + { + return; + } + var str = output.ToString (); if (Force16Colors && IsLegacyConsole) @@ -429,21 +430,22 @@ protected override void Write (StringBuilder output) bool result = WriteConsole (_outputHandle, span, (uint)span.Length, out _, nint.Zero); - if (!result) + if (result) { - int err = Marshal.GetLastWin32Error (); + return; + } + int err = Marshal.GetLastWin32Error (); - if (err == 1) - { - Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); + if (err == 1) + { + Logging.Error ($"Error: {Marshal.GetLastWin32Error ()} in {nameof (WindowsOutput)}"); - return; - } + return; + } - if (err != 0) - { - throw new Win32Exception (err); - } + if (err != 0) + { + throw new Win32Exception (err); } } catch (DllNotFoundException) @@ -507,31 +509,40 @@ public Size GetSize () _lastWindowSizeBeforeMaximized = null; } - if (_lastSize == null || _lastSize != newSize) + if (_lastSize == newSize) { - // User is resizing the screen, they can only ever resize the active - // buffer since. We now however have issue because background offscreen - // buffer will be wrong size, recreate it to ensure it doesn't result in - // differing active and back buffer sizes (which causes flickering of window size) - Size? bufSize = null; - int retries = 0; + return newSize; + } - while (bufSize != newSize && retries < 5) - { - _lockResize = true; - bufSize = ResizeBuffer (newSize); - retries++; - } + // User is resizing the screen, they can only ever resize the active + // buffer since. We now however have issue because background offscreen + // buffer will be wrong size, recreate it to ensure it doesn't result in + // differing active and back buffer sizes (which causes flickering of window size) + Size? bufSize = null; + var retries = 0; - _lockResize = false; - _lastSize = newSize; + while (bufSize != newSize && retries < 5) + { + _lockResize = true; + bufSize = ResizeBuffer (newSize); + retries++; } + _lockResize = false; + _lastSize = newSize; + return newSize; } public Size GetWindowSize (out WindowsConsole.Coord cursorPosition) { + if (!IsAttachedToTerminal) + { + cursorPosition = default (WindowsConsole.Coord); + + return new Size (80, 25); + } + try { var csbi = new WindowsConsole.CONSOLE_SCREEN_BUFFER_INFOEX (); @@ -545,9 +556,7 @@ public Size GetWindowSize (out WindowsConsole.Coord cursorPosition) return Size.Empty; } - Size sz = new ( - csbi.srWindow.Right - csbi.srWindow.Left + 1, - csbi.srWindow.Bottom - csbi.srWindow.Top + 1); + Size sz = new (csbi.srWindow.Right - csbi.srWindow.Left + 1, csbi.srWindow.Bottom - csbi.srWindow.Top + 1); cursorPosition = csbi.dwCursorPosition; @@ -558,11 +567,16 @@ public Size GetWindowSize (out WindowsConsole.Coord cursorPosition) cursorPosition = default (WindowsConsole.Coord); } - return new (80, 25); + return new Size (80, 25); } private Size GetLargestConsoleWindowSize () { + if (!IsAttachedToTerminal) + { + return new Size (80, 25); + } + WindowsConsole.Coord maxWinSize; try @@ -571,13 +585,12 @@ private Size GetLargestConsoleWindowSize () } catch { - maxWinSize = new (80, 25); + maxWinSize = new WindowsConsole.Coord (80, 25); } - return new (maxWinSize.X, maxWinSize.Y); + return new Size (maxWinSize.X, maxWinSize.Y); } - /// public void SetSize (int width, int height) { @@ -596,6 +609,13 @@ public void Dispose () return; } + if (!IsAttachedToTerminal) + { + _isDisposed = true; + + return; + } + if (IsLegacyConsole) { if (_screenBuffer != nint.Zero) diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index d065a9ba65..c7fcaeef2e 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -543,6 +543,7 @@ True True True + True True True True diff --git a/Tests/Directory.Build.props b/Tests/Directory.Build.props new file mode 100644 index 0000000000..9318d68b87 --- /dev/null +++ b/Tests/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + $(MSBuildThisFileDirectory)test.runsettings + + diff --git a/Tests/IntegrationTests/FluentTests/TestContextTests.cs b/Tests/IntegrationTests/FluentTests/TestContextTests.cs index 980acb760a..881efabcba 100644 --- a/Tests/IntegrationTests/FluentTests/TestContextTests.cs +++ b/Tests/IntegrationTests/FluentTests/TestContextTests.cs @@ -11,6 +11,34 @@ public class TestContextTests (ITestOutputHelper outputHelper) : TestsAllDrivers { private readonly TextWriter _out = new TestOutputWriter (outputHelper); + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void DisableRealDriverIO_EnvironmentVariable_IsSet () + { + // Diagnostic: verify the env var actually reaches the test process. + string? value = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + outputHelper.WriteLine ($"DisableRealDriverIO = '{value ?? "(null)"}'"); + + Assert.Equal ("1", value); + } + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void IsAttachedToTerminal_ReturnsFalse_WhenDisableRealDriverIO_IsSet () + { + // Arrange – the env var should already be "1" via the test harness. + string? value = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + outputHelper.WriteLine ($"DisableRealDriverIO = '{value ?? "(null)"}'"); + + // Act + bool result = Driver.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached); + + // Assert + Assert.False (result, "IsAttachedToTerminal should return false when DisableRealDriverIO=1"); + Assert.False (inputAttached, "inputAttached should be false when DisableRealDriverIO=1"); + Assert.False (outputAttached, "outputAttached should be false when DisableRealDriverIO=1"); + } + [Theory] [MemberData (nameof (GetAllDriverNames))] public void Constructor_Sets_Application_Screen (string d) diff --git a/Tests/UnitTests/Application/MainLoopCoordinatorTests.cs b/Tests/UnitTests/Application/MainLoopCoordinatorTests.cs deleted file mode 100644 index 1b88fcb296..0000000000 --- a/Tests/UnitTests/Application/MainLoopCoordinatorTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Collections.Concurrent; -using Microsoft.Extensions.Logging; -using Moq; - -namespace UnitTests.ApplicationTests; - -public class MainLoopCoordinatorTests -{ - [Fact] - public async Task TestMainLoopCoordinator_InputCrashes_ExceptionSurfacesMainThread () - { - Mock mockLogger = new (); - - ILogger beforeLogger = Logging.Logger; - Logging.Logger = mockLogger.Object; - - Mock> m = new (); - - // Runs on a separate thread (input thread) - m.Setup (f => f.CreateInput ()).Throws (new Exception ("Crash on boot")); - - MainLoopCoordinator c = new (new TimedEvents (), - - // Rest runs on main thread - new ConcurrentQueue (), - Mock.Of> (), - m.Object); - - // StartAsync boots the main loop and the input thread. But if the input class bombs - // on startup it is important that the exception surface at the call site and not lost - var ex = await Assert.ThrowsAsync (() => c.StartInputTaskAsync (null)); - Assert.Equal ("Crash on boot", ex.InnerExceptions [0].Message); - - // Restore the original null logger to be polite to other tests - Logging.Logger = beforeLogger; - - // Logs should explicitly call out that input loop crashed. - mockLogger.Verify (l => l.Log (LogLevel.Critical, - It.IsAny (), - It.Is ((v, t) => v.ToString ().Contains ("Input loop crashed")), - It.IsAny (), - It.IsAny> ()), - Times.Once); - } - /* - [Fact] - public void TestMainLoopCoordinator_InputExitsImmediately_ExceptionRaisedInMainThread () - { - - // Runs on a separate thread (input thread) - // But because it's just a mock it immediately exists - var mockInputFactoryMethod = () => Mock.Of> (); - - - var mockOutput = Mock.Of (); - var mockInputProcessor = Mock.Of (); - var inputQueue = new ConcurrentQueue (); - var timedEvents = new TimedEvents (); - - var mainLoop = new MainLoop (); - mainLoop.Initialize (timedEvents, - inputQueue, - mockInputProcessor, - mockOutput - ); - - var c = new MainLoopCoordinator (timedEvents, - mockInputFactoryMethod, - inputQueue, - mockInputProcessor, - ()=>mockOutput, - mainLoop - ); - - // TODO: This test has race condition - // - // * When the input loop exits it can happen - // * - During boot - // * - After boot - // * - // * If it happens in boot you get input exited - // * If it happens after you get "Input loop exited early (stop not called)" - // - - // Because the console input class does not block - i.e. breaks contract - // We need to let the user know input has silently exited and all has gone bad. - var ex = Assert.ThrowsAsync (c.StartAsync).Result; - Assert.Equal ("Input loop exited during startup instead of entering read loop properly (i.e. and blocking)", ex.Message); - }*/ -} diff --git a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs index c59dfe0ccd..ae52bb071f 100644 --- a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs +++ b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs @@ -1,6 +1,9 @@ -#nullable enable using System.Collections.Concurrent; using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Moq; +using Terminal.Gui.Tests; + // ReSharper disable AccessToDisposedClosure #pragma warning disable xUnit1031 @@ -11,10 +14,10 @@ namespace ApplicationTests; /// These tests ensure that the input thread starts, runs, and stops correctly when applications /// are created, initialized, and disposed. /// -[Collection("Application Tests")] -public class MainLoopCoordinatorTests : IDisposable +[Collection ("Application Tests")] +public class MainLoopCoordinatorTests (ITestOutputHelper outputHelper) : IDisposable { - private readonly List _createdApps = new (); + private readonly List _createdApps = []; public void Dispose () { @@ -139,11 +142,11 @@ public void Multiple_Applications_Dispose_Without_Thread_Leaks () public void InputLoop_Throttle_Limits_Poll_Rate () { // Arrange - Create a ANSIInput and manually run it with throttling - AnsiInput input = new AnsiInput (); - ConcurrentQueue queue = new ConcurrentQueue (); + var input = new AnsiInput (); + ConcurrentQueue queue = new (); input.Initialize (queue); - CancellationTokenSource cts = new CancellationTokenSource (); + var cts = new CancellationTokenSource (); // Act - Run the input loop for 500ms // Short duration reduces test time while still proving throttle exists @@ -210,4 +213,63 @@ public void Throttle_Prevents_CPU_Saturation_With_Leaked_Apps () // With the throttle, each thread does Task.Delay(20ms) and exits within ~20-40ms Assert.True (sw.ElapsedMilliseconds < 2000, $"Disposing {COUNT} apps took {sw.ElapsedMilliseconds}ms - CPU may be saturated"); } + + [Fact] + public async Task TestMainLoopCoordinator_InputCrashes_ExceptionSurfacesMainThread () + { + using IDisposable logScope = TestLogging.BindTo (outputHelper, LogLevel.Critical); + + Mock> m = new (); + + m.Setup (f => f.CreateInput ()).Throws (new Exception ("Crash on boot")); + + MainLoopCoordinator c = new (new TimedEvents (), new ConcurrentQueue (), Mock.Of> (), m.Object); + + AggregateException ex = await Assert.ThrowsAsync (() => c.StartInputTaskAsync (null)); + Assert.Equal ("Crash on boot", ex.InnerExceptions [0].Message); + } + + private sealed class TestAnsiComponentFactory (TestAnsiInput input, AnsiOutput output) : ComponentFactoryImpl + { + public override string GetDriverName () => DriverRegistry.Names.ANSI; + + public override IInput CreateInput () => input; + + public override IInputProcessor CreateInputProcessor (ConcurrentQueue inputBuffer, ITimeProvider? timeProvider = null) => + new AnsiInputProcessor (inputBuffer, timeProvider); + + public override IOutput CreateOutput () => output; + + public override ISizeMonitor CreateSizeMonitor (IOutput consoleOutput, IOutputBuffer outputBuffer) => new SizeMonitorImpl (consoleOutput); + } + + private sealed class TestAnsiInput (string? response) : IInput + { + private ConcurrentQueue? _inputQueue; + + public CancellationTokenSource? ExternalCancellationTokenSource { get; set; } + + public bool ResponseSent { get; private set; } + + public void Initialize (ConcurrentQueue inputQueue) => _inputQueue = inputQueue; + + public void Run (CancellationToken runCancellationToken) + { + if (!ResponseSent && !string.IsNullOrEmpty (response) && _inputQueue is { } inputQueue) + { + foreach (char ch in response) + { + inputQueue.Enqueue (ch); + } + + ResponseSent = true; + } + + WaitHandle.WaitAny ([runCancellationToken.WaitHandle]); + + throw new OperationCanceledException (runCancellationToken); + } + + public void Dispose () { } + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs index 164993dc4b..916fbf272e 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs @@ -106,4 +106,18 @@ public void AnsiComponentFactory_CreateOutput_DoesNotThrow () // Assert Assert.Null (exception); } + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void AnsiDriver_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, "AnsiDriver: IsAttachedToTerminal should return false in test harness"); + Assert.False (inputAttached); + Assert.False (outputAttached); + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiTerminalHelperTests.cs b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiTerminalHelperTests.cs new file mode 100644 index 0000000000..ff4bf4d1b5 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiTerminalHelperTests.cs @@ -0,0 +1,38 @@ +#nullable enable + +namespace DriverTests; + +/// +/// Tests for environment-variable gating. +/// Copilot - generated. +/// +public class DriverIsAttachedToTerminalTests (ITestOutputHelper output) +{ + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void DisableRealDriverIO_EnvironmentVariable_IsSet () + { + // Diagnostic: verify the env var actually reaches the test process. + string? value = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + output.WriteLine ($"DisableRealDriverIO = '{value ?? "(null)"}'"); + + Assert.Equal ("1", value); + } + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void IsAttachedToTerminal_ReturnsFalse_WhenDisableRealDriverIO_IsSet () + { + // Arrange – the env var should already be "1" via the test harness. + string? value = Environment.GetEnvironmentVariable ("DisableRealDriverIO"); + output.WriteLine ($"DisableRealDriverIO = '{value ?? "(null)"}'"); + + // Act + bool result = Driver.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached); + + // Assert + Assert.False (result, "IsAttachedToTerminal should return false when DisableRealDriverIO=1"); + Assert.False (inputAttached, "inputAttached should be false when DisableRealDriverIO=1"); + Assert.False (outputAttached, "outputAttached should be false when DisableRealDriverIO=1"); + } +} diff --git a/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs index 8c8c74a887..1d94ded003 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs @@ -287,4 +287,18 @@ public void NetComponentFactory_CreateOutput_DoesNotThrow () Assert.Null (exception); } -} + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void NetDriver_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, "NetDriver: 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/Unix/UnixInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs index f3de24895d..d91bcba517 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs @@ -119,4 +119,18 @@ public void UnixOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () // 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/Windows/WindowsInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs index 6fb72c464b..c11de00789 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs @@ -91,4 +91,18 @@ public void WindowsOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () // Assert Assert.Null (exception); } -} + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void WindowsDriver_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, "WindowsDriver: IsAttachedToTerminal should return false in test harness"); + Assert.False (inputAttached); + Assert.False (outputAttached); + } +} \ No newline at end of file diff --git a/Tests/test.runsettings b/Tests/test.runsettings new file mode 100644 index 0000000000..0deb342f93 --- /dev/null +++ b/Tests/test.runsettings @@ -0,0 +1,8 @@ + + + + + 1 + + +