diff --git a/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs b/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs index f1c39e193b..69119538cb 100644 --- a/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs +++ b/Terminal.Gui/App/Keyboard/ApplicationKeyboard.cs @@ -280,6 +280,9 @@ internal void AddKeyBindings () { App?.Driver?.Suspend (); + // When the app is resumed, we need to force a full redraw to clear out any artifacts from the suspended console. + App?.ClearScreenNextIteration = true; + return true; }); @@ -345,11 +348,8 @@ internal void AddKeyBindings () // TODO: Refresh Key should be configurable KeyBindings.ReplaceCommands (Key.F5, Command.Refresh); - // TODO: Suspend Key should be configurable - if (Environment.OSVersion.Platform == PlatformID.Unix) - { - KeyBindings.ReplaceCommands (Key.Z.WithCtrl, Command.Suspend); - } + // Each driver handles Suspend themselves + KeyBindings.ReplaceCommands (Key.Z.WithCtrl, Command.Suspend); } /// diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs index f3c107a318..6d8231d6b8 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInput.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Runtime.InteropServices; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; @@ -87,9 +88,9 @@ public AnsiInput () try { // Check if we have a real console first - if (Console.IsInputRedirected || Console.IsOutputRedirected) + if (!AnsiTerminalHelper.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) { - Logging.Information ($"Console redirected (Output: {Console.IsOutputRedirected}, Input: {Console.IsInputRedirected}). Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiInput), "Init", $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); return; } @@ -104,7 +105,7 @@ public AnsiInput () _windowsVTInput.Dispose (); _windowsVTInput = null; - Logging.Warning ("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; } @@ -120,7 +121,7 @@ public AnsiInput () if (!_unixRawMode.TryEnable ()) { - Logging.Warning ("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; @@ -131,32 +132,12 @@ public AnsiInput () } catch (DllNotFoundException ex) { - Logging.Warning ($"Failed to enable Unix raw input mode. libc not available: {ex.Message}. Running in degraded mode."); - - return; + Trace.Lifecycle (nameof (AnsiInput), "Init", $"Failed to enable Unix raw input mode. libc not available: {ex.Message}. Running in degraded mode."); } } else { - Logging.Warning ("Unknown OS platform. Terminal input will not work. Running in degraded mode."); - - return; - } - - // Try to disable Ctrl+C handling to allow raw input - try - { - // BUGBUG: This is not needed on Windows as we turn off ENABLE_PROCESSED_INPUT in _windowsVTInput.TryEnable () above - // BUGBUG: This does nothing if we're running Unix, because we are using raw mode - - // All TreatConsoleCAsInput does is un-set ENABLE_PROCESSED_INPUT on the input handle - Console.TreatControlCAsInput = true; - } - catch (Exception ex) - { - Logging.Warning ($"Failed to set TreatControlCAsInput: {ex.Message}"); - - // Not supported in all environments - continue anyway + Trace.Lifecycle (nameof (AnsiInput), "Init", "Unknown OS platform. Terminal input will not work. Running in degraded mode."); } // NOTE: Output operations (alternate buffer, cursor visibility, mouse events) @@ -166,8 +147,7 @@ public AnsiInput () } catch (Exception ex) { - Logging.Warning ($"Failed to initialize terminal: {ex.GetType ().Name}: {ex.Message}. Running in degraded mode."); - Logging.Warning ($"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; } } @@ -217,13 +197,9 @@ public override IEnumerable Read () yield break; } - // Convert UTF-8 bytes to characters - uint cp = WindowsVTInputHelper.GetConsoleCP (); - var enc = Encoding.GetEncoding ((int)cp); - - string text = enc.GetString (buffer, 0, bytesRead); + string text = Encoding.UTF8.GetString (buffer, 0, bytesRead); - //Logging.Trace ($"AnsiInput.Read: read {bytesRead} text: {text}"); + //Trace.Lifecycle (nameof (AnsiInput), "Read", $"Read {bytesRead} bytes from Windows VT Input: {text}"); foreach (char ch in text) { @@ -284,7 +260,7 @@ private IEnumerable ReadUnixInput (byte [] buffer) { // Error int errno = Marshal.GetLastWin32Error (); - Logging.Warning ($"Read: read() returned {readResult}, errno={errno}"); + Logging.Warning ($"{nameof (AnsiInput)}: read() returned {readResult}, errno={errno}"); yield break; } @@ -292,7 +268,7 @@ private IEnumerable ReadUnixInput (byte [] buffer) } else { - Logging.Error ("Read: read() failed"); + Logging.Warning ($"{nameof (AnsiInput)}: read() failed"); yield break; } @@ -364,7 +340,7 @@ private void FlushInput () if (flushCount > 0) { - Logging.Information ($"FlushInput: Flushed input buffer ({flushCount} read attempts)"); + Trace.Lifecycle (nameof (AnsiInput), "FlushInput", $"Flushed input buffer ({flushCount} read attempts)"); } break; @@ -376,7 +352,7 @@ private void FlushInput () } catch (Exception ex) { - Logging.Warning ($"Error flushing input: {ex.Message}"); + Logging.Warning ($"{nameof (AnsiInput)}: Error flushing input: {ex.Message}"); } } diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index 0a39454919..d7ceff9e0e 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; @@ -59,10 +60,10 @@ public AnsiOutput () try { - // Check if console is available (not redirected) - if (Console.IsOutputRedirected || Console.IsInputRedirected) + // Check if we have a real console first + if (!AnsiTerminalHelper.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) { - Logging.Information ($"Console redirected (Output: {Console.IsOutputRedirected}, Input: {Console.IsInputRedirected}). Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiOutput), "Init", $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); return; } @@ -77,7 +78,7 @@ public AnsiOutput () _windowsVTOutput.Dispose (); _windowsVTOutput = null; - Logging.Information ("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; } @@ -90,7 +91,7 @@ public AnsiOutput () if (fdCopy == -1) { - Logging.Information ("Console output stream is not writable. Running in degraded mode."); + Trace.Lifecycle (nameof (AnsiOutput), "Init", "Console output stream is not writable. Running in degraded mode."); return; } @@ -104,12 +105,12 @@ public AnsiOutput () Write (EscSeqUtils.CSI_ClearScreen (EscSeqUtils.ClearScreenOptions.EntireScreen)); Write (EscSeqUtils.CSI_SetCursorPosition (1, 1)); // Move to top-left Write (EscSeqUtils.CSI_HideCursor); + // TODO: Move Input related CSI sequences to AnsiInput Write (EscSeqUtils.CSI_EnableMouseEvents); // Flush to ensure all sequences are sent - // NOTE: Default implementation of Flush does nothing. - Console.Out.Flush (); + AnsiTerminalHelper.FlushNative (_platform); //Logging.Information ("ANSIOutput initialized successfully"); @@ -121,12 +122,14 @@ public AnsiOutput () } catch (Exception ex) { - Logging.Warning ($"Failed to initialize ANSIOutput: {ex.GetType ().Name}: {ex.Message}"); - Logging.Warning ($"Stack trace: {ex.StackTrace}"); + Trace.Lifecycle (nameof (AnsiOutput), "Init", $"Failed to initialize ANSIOutput: {ex.GetType ().Name}: {ex.Message}. Stack trace: {ex.StackTrace}"); _platform = AnsiPlatform.Degraded; } } + /// + public void Suspend () => UnixTerminalHelper.Suspend (this); + /// /// Gets or sets the last output buffer written. The contains /// a reference to the buffer last written with . @@ -228,7 +231,7 @@ public void SetCursor (Cursor cursor) } else { - if (_currentCursor!.Style != cursor.Style) + if (_currentCursor.Style != cursor.Style) { Write (EscSeqUtils.CSI_SetCursorStyle (cursor.Style)); } @@ -251,7 +254,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; } @@ -286,19 +289,21 @@ public void HandleSizeQueryResponse (string? response) // Example: "[8;25;80t" Match match = Regex.Match (response, @"\[(\d+);(\d+);(\d+)t$"); - if (match is { Success: true, Groups.Count: 4 }) + if (match is not { Success: true, Groups.Count: 4 }) { - // Group 1 should be "8" (the response value) - // Group 2 is height, Group 3 is width - if (int.TryParse (match.Groups [2].Value, out int height) && int.TryParse (match.Groups [3].Value, out int width)) - { - _consoleSize = new Size (width, height); - } + return; + } + + // Group 1 should be "8" (the response value) + // Group 2 is height, Group 3 is width + if (int.TryParse (match.Groups [2].Value, out int height) && int.TryParse (match.Groups [3].Value, out int width)) + { + _consoleSize = new Size (width, height); } } catch (Exception ex) { - Logging.Warning ($"Failed to parse size query response '{response}': {ex.Message}"); + Trace.Lifecycle (nameof (AnsiOutput), "SizeQuery", $"Failed to parse size query response '{response}': {ex.Message}"); } } diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs new file mode 100644 index 0000000000..34ab711bfd --- /dev/null +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiTerminalHelper.cs @@ -0,0 +1,107 @@ +using System.Runtime.InteropServices; + +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 + { + switch (platform) + { + case AnsiPlatform.UnixRaw: + FlushUnix (); + + break; + + case AnsiPlatform.WindowsVT: + FlushWindows (); + + break; + } + } + catch + { + // ignore any exceptions during flush, as we don't want to crash the app if the 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 isatty (int fd); + + [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")] + 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/WindowsDriver/WindowsVTInputHelper.cs b/Terminal.Gui/Drivers/AnsiDriver/WindowsVTInputHelper.cs similarity index 66% rename from Terminal.Gui/Drivers/WindowsDriver/WindowsVTInputHelper.cs rename to Terminal.Gui/Drivers/AnsiDriver/WindowsVTInputHelper.cs index d5ad93d271..eac440a91a 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsVTInputHelper.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/WindowsVTInputHelper.cs @@ -1,5 +1,5 @@ using System.Runtime.InteropServices; -using static Terminal.Gui.Drivers.WindowsConsole; +using Terminal.Gui.Tracing; namespace Terminal.Gui.Drivers; @@ -26,6 +26,11 @@ internal sealed class WindowsVTInputHelper : IDisposable { #region P/Invoke Declarations + // In ideal world, Windows Console would have a way of setting VTS mode without having to use SetConsoleMode. + // It would also provide a non-blocking API for reading input bytes directly as ANSI sequences without having + // to use GetNumberOfConsoleInputEvents to poll for availability. With such APIs, this helper class would be unnecessary. + // If this were the case, the only API the ANSI driver would require on Windows is GetStdHandle and ReadFile. + [DllImport ("kernel32.dll", SetLastError = true)] private static extern nint GetStdHandle (int nStdHandle); @@ -35,20 +40,15 @@ internal sealed class WindowsVTInputHelper : IDisposable [DllImport ("kernel32.dll")] private static extern bool SetConsoleMode (nint hConsoleHandle, uint dwMode); - [DllImport ("kernel32.dll", EntryPoint = "PeekConsoleInputW", CharSet = CharSet.Unicode)] - public static extern bool PeekConsoleInput (nint hConsoleInput, nint lpBuffer, uint nLength, out uint lpNumberOfEventsRead); - - [DllImport ("kernel32.dll", SetLastError = true)] - private static extern bool ReadFile (nint hFile, byte [] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, nint lpOverlapped); - + // Equivalent of poll() on Unix — needed because ReadFile blocks and the input loop + // requires a non-blocking availability check for throttling and cancellation. [DllImport ("kernel32.dll", SetLastError = true)] private static extern bool GetNumberOfConsoleInputEvents (nint hConsoleInput, out uint lpcNumberOfEvents); [DllImport ("kernel32.dll", SetLastError = true)] - private static extern bool FlushConsoleInputBuffer (nint hConsoleInput); + private static extern bool ReadFile (nint hFile, byte [] lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, nint lpOverlapped); - [DllImport ("kernel32.dll")] - internal static extern uint GetConsoleCP (); + #endregion // Console mode flags private const int STD_INPUT_HANDLE = -10; @@ -60,8 +60,6 @@ internal sealed class WindowsVTInputHelper : IDisposable private const uint ENABLE_QUICK_EDIT_MODE = 0x0040; private const uint ENABLE_EXTENDED_FLAGS = 0x0080; - #endregion - private uint _originalConsoleMode; private bool _disposed; @@ -98,14 +96,14 @@ public bool TryEnable () if (InputHandle == nint.Zero || InputHandle == new nint (-1)) { - Logging.Warning ("Failed to get Windows console input handle."); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: Failed to get Windows console input handle."); return false; } if (!GetConsoleMode (InputHandle, out _originalConsoleMode)) { - Logging.Warning ("Failed to get Windows console mode."); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: Failed to get Windows console mode."); return false; } @@ -120,7 +118,7 @@ public bool TryEnable () if (!SetConsoleMode (InputHandle, newMode)) { - Logging.Warning ("Failed to set Windows VTS console mode."); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: Failed to set Windows VTS console mode."); return false; } @@ -128,13 +126,16 @@ public bool TryEnable () Encoding.RegisterProvider (CodePagesEncodingProvider.Instance); IsEnabled = true; - //Logging.Information ($"Windows VTS input mode enabled successfully. Mode: 0x{newMode:X} (was 0x{_originalConsoleMode:X})"); + + Trace.Lifecycle (nameof (WindowsVTInputHelper), + "Init", + $"Windows VTS input mode enabled successfully. Mode: 0x{newMode:X} (was 0x{_originalConsoleMode:X})"); return true; } catch (Exception ex) { - Logging.Warning ($"Failed to enable Windows VTS mode: {ex.Message}"); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: Failed to enable Windows VTS mode: {ex.Message}"); return false; } @@ -150,35 +151,47 @@ public bool TryRead (byte [] buffer, out int bytesRead) { bytesRead = 0; - if (!IsEnabled || InputHandle == nint.Zero || !Console.KeyAvailable) + if (!IsEnabled || InputHandle == nint.Zero) { return false; } try { - //Logging.Trace ("ReadFile..."); + // Read the VT byte stream via ReadFile. With ENABLE_VIRTUAL_TERMINAL_INPUT + // enabled, the Windows console converts all input (keyboard, mouse, etc.) + // into ANSI escape sequences in this stream. bool success = ReadFile (InputHandle, buffer, (uint)buffer.Length, out uint numBytesRead, nint.Zero); -#pragma warning disable IL3050 - - //Logging.Trace ($"...{JsonSerializer.Serialize (Encoding.UTF8.GetString (buffer, 0, (int)numBytesRead))}"); -#pragma warning restore IL3050 if (!success) { int error = Marshal.GetLastWin32Error (); - Logging.Warning ($"ReadFile failed with error code: {error}"); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: ReadFile failed with error code: {error}"); return false; } + if (numBytesRead == 0) + { + // Workaround for Windows bug (since Win8, fix pending in microsoft/terminal#19940): + // ReadFile unconditionally treats Ctrl+Z as EOF and returns 0 bytes, even when + // ENABLE_PROCESSED_INPUT is disabled. Since we have a live console handle with + // processed input disabled, 0-byte success can only mean this bug. + // Synthesize the 0x1A (SUB) byte that ReadFile should have returned. + // See https://github.com/microsoft/terminal/issues/4958 + buffer [0] = 0x1A; + bytesRead = 1; + + return true; + } + bytesRead = (int)numBytesRead; return true; } catch (Exception ex) { - Logging.Warning ($"Error reading Windows console input: {ex.Message}"); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: Error reading Windows console input: {ex.Message}"); return false; } @@ -196,21 +209,12 @@ public void Restore () try { - // Flush the input buffer to clear any pending INPUT_RECORD structures - // This prevents residual ANSI responses from lingering in the OS buffer - if (!FlushConsoleInputBuffer (InputHandle)) - { - int error = Marshal.GetLastWin32Error (); - Logging.Warning ($"FlushConsoleInputBuffer failed with error: {error}"); - } - SetConsoleMode (InputHandle, _originalConsoleMode); IsEnabled = false; - //Logging.Information ("Windows console mode restored."); } catch (Exception ex) { - Logging.Warning ($"Failed to restore Windows console mode: {ex.Message}"); + Logging.Warning ($"{nameof (WindowsVTInputHelper)}: Failed to restore Windows console mode: {ex.Message}"); } } @@ -226,36 +230,9 @@ public void Dispose () _disposed = true; } - public bool Peek () - { - const int BUFFER_SIZE = 1; // We only need to check if there's at least one event - nint pRecord = Marshal.AllocHGlobal (Marshal.SizeOf () * BUFFER_SIZE); - - try - { - // Use PeekConsoleInput to inspect the input buffer without removing events - if (PeekConsoleInput (InputHandle, pRecord, BUFFER_SIZE, out uint numberOfEventsRead)) - { - // Return true if there's at least one event in the buffer - return numberOfEventsRead > 0; - } - else - { - // Handle the failure of PeekConsoleInput - throw new InvalidOperationException ("Failed to peek console input."); - } - } - catch (Exception ex) - { - // Optionally log the exception - Logging.Error (@$"Error in Peek: {ex.Message}"); - - return false; - } - finally - { - // Free the allocated memory - Marshal.FreeHGlobal (pRecord); - } - } + /// + /// Checks whether input is available without consuming it. + /// + /// true if there is at least one input event available. + public bool Peek () => GetNumberOfConsoleInputEvents (InputHandle, out uint count) && count > 0; } diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsVTOutputHelper.cs b/Terminal.Gui/Drivers/AnsiDriver/WindowsVTOutputHelper.cs similarity index 100% rename from Terminal.Gui/Drivers/WindowsDriver/WindowsVTOutputHelper.cs rename to Terminal.Gui/Drivers/AnsiDriver/WindowsVTOutputHelper.cs diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs index fe2c5d0e21..ae73fe2043 100644 --- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs +++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs @@ -160,4 +160,54 @@ protected override bool SetCursorPositionImpl (int col, int row) /// public void Dispose () { } + + /// + public void Suspend () + { + if (PlatformDetection.IsWindows ()) + { + return; + } + + // Best-effort: mirror behavior of ANSI/Unix outputs for consoles that accept CSI sequences. + try + { + // Disable mouse events to prevent mouse events from being sent to the application while it is suspended. + Write (EscSeqUtils.CSI_DisableMouseEvents); + + // 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."); + + return; + } + + Console.ResetColor (); + Console.Clear (); + + //Disable alternative screen buffer. + Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + + //Set cursor key to cursor. + Write (EscSeqUtils.CSI_ShowCursor); + + if (!SuspendHelper.Suspend ()) + { + return; + } + + //Enable alternative screen buffer. + Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); + } + catch (Exception ex) + { + Logging.Error ($"Error suspending terminal: {ex.Message}"); + } + finally + { + // Enable mouse events to allow mouse events to be sent to the application when it is resumed. + Write (EscSeqUtils.CSI_EnableMouseEvents); + } + } } diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index f75c1a4e11..ca46daf3a7 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -96,42 +96,14 @@ public virtual string GetVersionInfo () /// public void Suspend () { - if (PlatformDetection.IsWindows ()) - { - return; - } - - _output.Write (EscSeqUtils.CSI_DisableMouseEvents); - try { - // BUGBUG: We should NOT be calling Console. APIs here. We should use native - // BUGBUG: OS capabilities or ANSI sequences - Console.ResetColor (); - - // BUGBUG: We should NOT be calling Console. APIs here. We should use native - // BUGBUG: OS capabilities or ANSI sequences - Console.Clear (); - - //Disable alternative screen buffer. - _output.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); - - //Set cursor key to cursor. - _output.Write (EscSeqUtils.CSI_ShowCursor); - - // BUGBUG: This is unix-specific and should not be implemented here. - if (SuspendHelper.Suspend ()) - { - //Enable alternative screen buffer. - _output.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); - } + _output.Suspend (); } catch (Exception ex) { Logging.Error ($"Error suspending terminal: {ex.Message}"); } - - _output.Write (EscSeqUtils.CSI_EnableMouseEvents); } /// diff --git a/Terminal.Gui/Drivers/Output/IOutput.cs b/Terminal.Gui/Drivers/Output/IOutput.cs index 5cca27912f..df092d6f46 100644 --- a/Terminal.Gui/Drivers/Output/IOutput.cs +++ b/Terminal.Gui/Drivers/Output/IOutput.cs @@ -55,6 +55,13 @@ public interface IOutput : IDisposable /// void Write (ReadOnlySpan text); + /// + /// Suspend the application / terminal (e.g. SIGTSTP on Unix) and perform any + /// driver-specific state save/restore required across the suspend/resume cycle. + /// Implementations on platforms that do not support suspension may be a no-op. + /// + void Suspend (); + /// /// Write the contents of the to the console /// diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs index 7fe4278945..286ec59ec0 100644 --- a/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs +++ b/Terminal.Gui/Drivers/UnixDriver/UnixOutput.cs @@ -10,6 +10,9 @@ namespace Terminal.Gui.Drivers; internal class UnixOutput : OutputBase, IOutput { + /// + public void Suspend () => UnixTerminalHelper.Suspend (this); + /// public void Write (ReadOnlySpan text) { diff --git a/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs b/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs new file mode 100644 index 0000000000..a2d8f57cf0 --- /dev/null +++ b/Terminal.Gui/Drivers/UnixDriver/UnixTerminalHelper.cs @@ -0,0 +1,175 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Trace = Terminal.Gui.Tracing.Trace; + +namespace Terminal.Gui.Drivers; + +internal static class UnixTerminalHelper +{ + private static bool _savedTermios; + private static Termios _origTermios; + private static int _ttyFd = -1; + + public static void SaveTerminalState () + { + if (_ttyFd == -1) + { + _ttyFd = open ("/dev/tty", O_RDWR); + } + + if (_ttyFd != -1 && tcgetattr (_ttyFd, out _origTermios) == 0) + { + _savedTermios = true; + } + else + { + try + { + _ = close (_ttyFd); + } + catch + { + // Ignore any exceptions during close, as we're already in a cleanup phase + } + _ttyFd = -1; + } + } + + public static void RestoreTerminalState () + { + if (_ttyFd != -1) + { + if (_savedTermios) + { + if (tcsetattr (_ttyFd, TCSANOW, ref _origTermios) != 0) + { + // fallback to stty sane + RunSttySane (); + } + } + else + { + // fallback to stty sane + RunSttySane (); + } + + // close the fd we opened earlier + try + { + _ = close (_ttyFd); + } + catch + { + // Ignore any exceptions during close, as we're already in a cleanup phase + } + _ttyFd = -1; + _savedTermios = false; + } + else + { + // fallback to stty sane + RunSttySane (); + } + } + + private static void RunSttySane () + { + try + { + var psi = new ProcessStartInfo ("/bin/sh", "-c \"stty sane < /dev/tty\"") + { + RedirectStandardOutput = false, RedirectStandardError = false, UseShellExecute = false, CreateNoWindow = true + }; + Process.Start (psi)?.WaitForExit (); + } + catch + { + // Ignore any exceptions, as this is a best-effort attempt to restore terminal state + } + } + + public static void Suspend (IOutput output) + { + if (PlatformDetection.IsWindows ()) + { + return; + } + + try + { + // Disable mouse events to prevent mouse events from being sent to the application while it is suspended. + output.Write (EscSeqUtils.CSI_DisableMouseEvents); + + // Check if we have a real console first + if (!AnsiTerminalHelper.IsAttachedToTerminal (out bool inputAttached, out bool outputAttached)) + { + Trace.Lifecycle (nameof (UnixTerminalHelper), "Suspend", $"Console redirected (Output: {outputAttached}, Input: {inputAttached}). Running in degraded mode."); + + return; + } + + // Save terminal state before suspending + SaveTerminalState (); + + // Disable alternative screen buffer and show cursor + output.Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + + //Set cursor key to cursor. + output.Write (EscSeqUtils.CSI_ShowCursor); + + if (!SuspendHelper.Suspend ()) + { + return; + } + + // Restore terminal state after resuming + RestoreTerminalState (); + + //Enable alternative screen buffer. + output.Write (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); + } + catch (Exception ex) + { + Trace.Lifecycle (nameof (UnixTerminalHelper), "Suspend", $"Error suspending terminal: {ex.Message}"); + } + finally + { + // Enable mouse events to allow mouse events to be sent to the application when it is resumed. + output.Write (EscSeqUtils.CSI_EnableMouseEvents); + } + } + + // P/Invoke and types + // ReSharper disable IdentifierTypo + 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); + + [DllImport ("libc", SetLastError = true)] + private static extern int close (int fd); + + [DllImport ("libc", SetLastError = true)] + private static extern int tcgetattr (int fd, out Termios termios_p); + + [DllImport ("libc", SetLastError = true)] + private static extern int tcsetattr (int fd, int optional_actions, ref Termios termios_p); +} diff --git a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs index 90636b4601..cc5ab8218d 100644 --- a/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs +++ b/Terminal.Gui/Drivers/WindowsDriver/WindowsOutput.cs @@ -122,7 +122,9 @@ public WindowsOutput () throw new ApplicationException ($"Failed to get screenBuffer console mode, error code: {Marshal.GetLastWin32Error ()}."); } +#pragma warning disable IDE1006 // Naming Styles const uint ENABLE_WRAP_AT_EOL_OUTPUT = 0x0002; +#pragma warning restore IDE1006 // Naming Styles mode &= ~ENABLE_WRAP_AT_EOL_OUTPUT; // Disable wrap @@ -621,4 +623,11 @@ public void Dispose () _isDisposed = true; } + + /// + public void Suspend () + { + // Suspends are not supported on Windows consoles in this implementation. + // No-op to match prior behavior where DriverImpl skipped suspend on Windows. + } } diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index 9ef3f5fa90..d065a9ba65 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -555,6 +555,7 @@ True True True + True True True @@ -578,6 +579,7 @@ True True True + True True True True diff --git a/Tests/UnitTestsParallelizable/Application/Keyboard/KeyboardTests.cs b/Tests/UnitTestsParallelizable/Application/Keyboard/KeyboardTests.cs index 74c2e627c3..d721b5fcf6 100644 --- a/Tests/UnitTestsParallelizable/Application/Keyboard/KeyboardTests.cs +++ b/Tests/UnitTestsParallelizable/Application/Keyboard/KeyboardTests.cs @@ -1,5 +1,4 @@ #nullable enable -using Terminal.Gui.App; namespace ApplicationTests.Keyboard; @@ -10,7 +9,6 @@ namespace ApplicationTests.Keyboard; [Collection("Application Tests")] public class KeyboardTests { - [Fact] public void Init_CreatesKeybindings () { @@ -475,4 +473,18 @@ public void KeyBindings_Replace_PreservesCommandsForNewKey () Assert.True (keyboard.KeyBindings.TryGet (newKey, out KeyBinding newBinding)); Assert.Equal (oldCommands, newBinding.Commands); } + + [Fact] + public void InvokeCommandsBoundToKey_Suspend_ReturnsNotNull () + { + // Arrange + var keyboard = new ApplicationKeyboard (); + Key key = Key.Z.WithCtrl; + + // Act + bool? result = keyboard.InvokeCommandsBoundToKey (key); + + // Assert + Assert.True (result); + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/AllDriverTests.cs b/Tests/UnitTestsParallelizable/Drivers/AllDriverTests.cs index b20ec5d21e..147a9cb0c5 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AllDriverTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AllDriverTests.cs @@ -134,6 +134,17 @@ public void All_Drivers_When_Clipped_AddStr_Glyph_On_Second_Cell_Of_Wide_Glyph_O DriverAssert.AssertDriverOutputIs (@"\x1b[38;2;0;0;0m\x1b[48;2;0;0;0m①┌─┐🍎\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m", output, driver); } + + [Theory] + [MemberData (nameof (GetAllDriverNames))] + public void All_Drivers_Handles_Suspend_Themselves (string driverName) + { + // Arrange + using IApplication? app = Application.Create ().Init (driverName); + + // Act & Assert + Assert.True (app.Keyboard.RaiseKeyDownEvent (Key.Z.WithCtrl)); + } } public class TestTop : Runnable diff --git a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs index b011f01e6c..6b2882bf05 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs @@ -58,6 +58,20 @@ public void AnsiOutput_GetSize_ReturnsExpectedSize () Assert.True (size.Height > 0); } + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void AnsiOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () + { + // Arrange + using var output = new AnsiOutput (); + + // Act + Exception? exception = Record.Exception (() => output.Suspend ()); + + // Assert + Assert.Null (exception); + } + [Fact] [Trait ("Category", "LowLevelDriver")] public void AnsiComponentFactory_CreateInput_DoesNotThrow () diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputTestableTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputTestableTests.cs index 39e630bc87..ecc4207a95 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputTestableTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiInputTestableTests.cs @@ -315,6 +315,31 @@ public void AnsiInputProcessor_InjectMouseEvent_SupportsWheelEvents (MouseFlags Assert.Equal (new Point (15, 15), wheelEvent.ScreenPosition); } + [Fact] + public void Read_ReturnsInjectedTestInput () + { + var input = new AnsiInput (); + var queue = new ConcurrentQueue (); + input.Initialize (queue); + + ITestableInput testable = input; + + // Inject characters, including Ctrl+Z + testable.InjectInput ('a'); + testable.InjectInput ('\x1A'); + + // Peek should report available input + Assert.True (input.Peek ()); + + // Read should return injected characters in FIFO order + var read = input.Read ().ToList (); + Assert.Equal (2, read.Count); + Assert.Equal ('a', read [0]); + Assert.Equal ('\x1A', read [1]); + + input.Dispose (); + } + #region AnsiInput InjectKeyDownEvent Tests [Fact] diff --git a/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs index 248445cce9..9eaf9a4da8 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Dotnet/NetInputOutputTests.cs @@ -205,6 +205,20 @@ public void NetOutput_SetCursor_Position_DoesNotThrow_WhenNoTerminalAvailable () Assert.Null (exception); } + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void NetOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () + { + // Arrange + using var output = new NetOutput (); + + // Act + Exception? exception = Record.Exception (() => output.Suspend ()); + + // Assert + Assert.Null (exception); + } + [Fact] [Trait ("Category", "LowLevelDriver")] public void NetInputProcessor_Constructor_DoesNotThrow () diff --git a/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs index bf9eb50584..4ee3e0a749 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Unix/UnixInputOutputTests.cs @@ -106,4 +106,18 @@ public void UnixOutput_GetSize_ReturnsDefaultSize_WhenNoTerminalAvailable () 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); + } } diff --git a/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs index 2599acd30b..8192064493 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Windows/WindowsInputOutputTests.cs @@ -78,4 +78,18 @@ public void WindowsOutput_Constructor_DoesNotThrow_WhenNoTerminalAvailable () Assert.Null (exception); } + + [Fact] + [Trait ("Category", "LowLevelDriver")] + public void WindowsOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () + { + // Arrange + using var output = new WindowsOutput (); + + // Act + Exception? exception = Record.Exception (() => output.Suspend ()); + + // Assert + Assert.Null (exception); + } }