diff --git a/Terminal.Gui/App/Application.Initialization.cs b/Terminal.Gui/App/Application.Initialization.cs index c688783e4..87b202612 100644 --- a/Terminal.Gui/App/Application.Initialization.cs +++ b/Terminal.Gui/App/Application.Initialization.cs @@ -227,7 +227,7 @@ public static partial class Application // Initialization (Init/Shutdown) List driverTypeNames = driverTypes .Where (d => !typeof (IConsoleDriverFacade).IsAssignableFrom (d)) .Select (d => d!.Name) - .Union (["v2", "v2win", "v2net"]) + .Union (["v2", "v2win", "v2net", "v2unix"]) .ToList ()!; return (driverTypes, driverTypeNames); diff --git a/Terminal.Gui/Drivers/V2/ApplicationV2.cs b/Terminal.Gui/Drivers/V2/ApplicationV2.cs index a3964328f..f2297e2d3 100644 --- a/Terminal.Gui/Drivers/V2/ApplicationV2.cs +++ b/Terminal.Gui/Drivers/V2/ApplicationV2.cs @@ -83,6 +83,7 @@ public class ApplicationV2 : ApplicationImpl bool definetlyWin = (driverName?.Contains ("win") ?? false )|| _componentFactory is IComponentFactory; bool definetlyNet = (driverName?.Contains ("net") ?? false ) || _componentFactory is IComponentFactory; + bool definetlyUnix = (driverName?.Contains ("unix") ?? false ) || _componentFactory is IComponentFactory; if (definetlyWin) { @@ -92,13 +93,17 @@ public class ApplicationV2 : ApplicationImpl { _coordinator = CreateNetSubcomponents (); } + else if (definetlyUnix) + { + _coordinator = CreateUnixSubcomponents (); + } else if (p == PlatformID.Win32NT || p == PlatformID.Win32S || p == PlatformID.Win32Windows) { _coordinator = CreateWindowsSubcomponents (); } else { - _coordinator = CreateNetSubcomponents (); + _coordinator = CreateUnixSubcomponents (); } _coordinator.StartAsync ().Wait (); @@ -154,6 +159,29 @@ public class ApplicationV2 : ApplicationImpl cf); } + private IMainLoopCoordinator CreateUnixSubcomponents () + { + ConcurrentQueue inputBuffer = new (); + MainLoop loop = new (); + + IComponentFactory cf; + + if (_componentFactory != null) + { + cf = (IComponentFactory)_componentFactory; + } + else + { + cf = new UnixComponentFactory (); + } + + return new MainLoopCoordinator ( + _timedEvents, + inputBuffer, + loop, + cf); + } + /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] diff --git a/Terminal.Gui/Drivers/V2/IUnixInput.cs b/Terminal.Gui/Drivers/V2/IUnixInput.cs new file mode 100644 index 000000000..23755f0c2 --- /dev/null +++ b/Terminal.Gui/Drivers/V2/IUnixInput.cs @@ -0,0 +1,3 @@ +namespace Terminal.Gui.Drivers; + +internal interface IUnixInput : IConsoleInput; diff --git a/Terminal.Gui/Drivers/V2/UnixComponentFactory.cs b/Terminal.Gui/Drivers/V2/UnixComponentFactory.cs new file mode 100644 index 000000000..c2de42696 --- /dev/null +++ b/Terminal.Gui/Drivers/V2/UnixComponentFactory.cs @@ -0,0 +1,29 @@ +#nullable enable +using System.Collections.Concurrent; + +namespace Terminal.Gui.Drivers; + +/// +/// implementation for native unix console I/O i.e. v2unix. +/// This factory creates instances of internal classes , etc. +/// +public class UnixComponentFactory : ComponentFactory +{ + /// + public override IConsoleInput CreateInput () + { + return new UnixInput (); + } + + /// + public override IInputProcessor CreateInputProcessor (ConcurrentQueue inputBuffer) + { + return new UnixInputProcessor (inputBuffer); + } + + /// + public override IConsoleOutput CreateOutput () + { + return new UnixOutput (); + } +} diff --git a/Terminal.Gui/Drivers/V2/UnixInput.cs b/Terminal.Gui/Drivers/V2/UnixInput.cs new file mode 100644 index 000000000..7dc8023f9 --- /dev/null +++ b/Terminal.Gui/Drivers/V2/UnixInput.cs @@ -0,0 +1,260 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Terminal.Gui.Drivers; + +internal class UnixInput : ConsoleInput, IUnixInput +{ + private const int STDIN_FILENO = 0; + + [StructLayout (LayoutKind.Sequential)] + private struct Termios + { + public uint c_iflag; + public uint c_oflag; + public uint c_cflag; + public uint c_lflag; + + [MarshalAs (UnmanagedType.ByValArray, SizeConst = 32)] + public byte [] c_cc; + + public uint c_ispeed; + public uint c_ospeed; + } + + [DllImport ("libc", SetLastError = true)] + private static extern int tcgetattr (int fd, out Termios termios); + + [DllImport ("libc", SetLastError = true)] + private static extern int tcsetattr (int fd, int optional_actions, ref Termios termios); + + // try cfmakeraw (glibc and macOS usually export it) + [DllImport ("libc", EntryPoint = "cfmakeraw", SetLastError = false)] + private static extern void cfmakeraw_ref (ref Termios termios); + + [DllImport ("libc", SetLastError = true)] + private static extern nint strerror (int err); + + private const int TCSANOW = 0; + + private const ulong BRKINT = 0x00000002; + private const ulong ICRNL = 0x00000100; + private const ulong INPCK = 0x00000010; + private const ulong ISTRIP = 0x00000020; + private const ulong IXON = 0x00000400; + + private const ulong OPOST = 0x00000001; + + private const ulong ECHO = 0x00000008; + private const ulong ICANON = 0x00000100; + private const ulong IEXTEN = 0x00008000; + private const ulong ISIG = 0x00000001; + + private const ulong CS8 = 0x00000030; + + private Termios _original; + + [StructLayout (LayoutKind.Sequential)] + private struct Pollfd + { + public int fd; + public short events; + public readonly short revents; // readonly signals "don't touch this in managed code" + } + + /// Condition on which to wake up from file descriptor activity. These match the Linux/BSD poll definitions. + [Flags] + private enum Condition : short + { + /// There is data to read + PollIn = 1, + + /// There is urgent data to read + PollPri = 2, + + /// Writing to the specified descriptor will not block + PollOut = 4, + + /// Error condition on output + PollErr = 8, + + /// Hang-up on output + PollHup = 16, + + /// File descriptor is not open. + PollNval = 32 + } + + [DllImport ("libc", SetLastError = true)] + private static extern int poll ([In][Out] Pollfd [] ufds, uint nfds, int timeout); + + [DllImport ("libc", SetLastError = true)] + private static extern int read (int fd, byte [] buf, int count); + + // File descriptor for stdout + private const int STDOUT_FILENO = 1; + + [DllImport ("libc", SetLastError = true)] + private static extern int write (int fd, byte [] buf, int count); + + [DllImport ("libc", SetLastError = true)] + private static extern int tcflush (int fd, int queueSelector); + + private const int TCIFLUSH = 0; // flush data received but not read + + private Pollfd [] _pollMap; + + public UnixInput () + { + Logging.Logger.LogInformation ($"Creating {nameof (UnixInput)}"); + + if (ConsoleDriver.RunningUnitTests) + { + return; + } + + _pollMap = new Pollfd [1]; + _pollMap [0].fd = STDIN_FILENO; // stdin + _pollMap [0].events = (short)Condition.PollIn; + + EnableRawModeAndTreatControlCAsInput (); + + //Enable alternative screen buffer. + WriteRaw (EscSeqUtils.CSI_SaveCursorAndActivateAltBufferNoBackscroll); + + //Set cursor key to application. + WriteRaw (EscSeqUtils.CSI_HideCursor); + + WriteRaw (EscSeqUtils.CSI_EnableMouseEvents); + } + + private void EnableRawModeAndTreatControlCAsInput () + { + if (tcgetattr (STDIN_FILENO, out _original) != 0) + { + var e = Marshal.GetLastWin32Error (); + throw new InvalidOperationException ($"tcgetattr failed errno={e} ({StrError (e)})"); + } + + var raw = _original; + + // Prefer cfmakeraw if available + try + { + cfmakeraw_ref (ref raw); + } + catch (EntryPointNotFoundException) + { + // fallback: roughly cfmakeraw equivalent + raw.c_iflag &= ~((uint)BRKINT | (uint)ICRNL | (uint)INPCK | (uint)ISTRIP | (uint)IXON); + raw.c_oflag &= ~(uint)OPOST; + raw.c_cflag |= (uint)CS8; + raw.c_lflag &= ~((uint)ECHO | (uint)ICANON | (uint)IEXTEN | (uint)ISIG); + } + + if (tcsetattr (STDIN_FILENO, TCSANOW, ref raw) != 0) + { + var e = Marshal.GetLastWin32Error (); + throw new InvalidOperationException ($"tcsetattr failed errno={e} ({StrError (e)})"); + } + } + + private string StrError (int err) + { + var p = strerror (err); + return p == nint.Zero ? $"errno={err}" : Marshal.PtrToStringAnsi (p) ?? $"errno={err}"; + } + + /// + protected override bool Peek () + { + try + { + if (ConsoleDriver.RunningUnitTests) + { + return false; + } + + int n = poll (_pollMap!, (uint)_pollMap!.Length, 0); + + if (n != 0) + { + return true; + } + + return false; + } + catch (Exception ex) + { + // Optionally log the exception + Logging.Logger.LogError ($"Error in Peek: {ex.Message}"); + + return false; + } + } + private void WriteRaw (string text) + { + byte [] utf8 = Encoding.UTF8.GetBytes (text); + // Write to stdout (fd 1) + write (STDOUT_FILENO, utf8, utf8.Length); + } + + /// + protected override IEnumerable Read () + { + while (poll (_pollMap!, (uint)_pollMap!.Length, 0) != 0) + { + // Check if stdin has data + if ((_pollMap [0].revents & (int)Condition.PollIn) != 0) + { + var buf = new byte [256]; + int bytesRead = read (0, buf, buf.Length); // Read from stdin + string input = Encoding.UTF8.GetString (buf, 0, bytesRead); + + foreach (char ch in input) + { + yield return ch; + } + } + } + } + + private void FlushConsoleInput () + { + if (!ConsoleDriver.RunningUnitTests) + { + var fds = new Pollfd [1]; + fds [0].fd = STDIN_FILENO; + fds [0].events = (short)Condition.PollIn; + var buf = new byte [256]; + while (poll (fds, 1, 0) > 0) + { + read (STDIN_FILENO, buf, buf.Length); + } + } + } + + /// + public override void Dispose () + { + base.Dispose (); + + // Disable mouse events first + WriteRaw (EscSeqUtils.CSI_DisableMouseEvents); + + // Drain any pending input already queued by the terminal + FlushConsoleInput (); + + // Flush kernel input buffer + tcflush (STDIN_FILENO, TCIFLUSH); + + //Disable alternative screen buffer. + WriteRaw (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll); + + //Set cursor key to cursor. + WriteRaw (EscSeqUtils.CSI_ShowCursor); + + // Restore terminal to original state + tcsetattr (STDIN_FILENO, TCSANOW, ref _original); + } +} diff --git a/Terminal.Gui/Drivers/V2/UnixInputProcessor.cs b/Terminal.Gui/Drivers/V2/UnixInputProcessor.cs new file mode 100644 index 000000000..8187702d6 --- /dev/null +++ b/Terminal.Gui/Drivers/V2/UnixInputProcessor.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; + +namespace Terminal.Gui.Drivers; + +/// +/// Input processor for , deals in stream. +/// +internal class UnixInputProcessor : InputProcessor +{ + /// + public UnixInputProcessor (ConcurrentQueue inputBuffer) : base (inputBuffer, new UnixKeyConverter ()) + { + DriverName = "unix"; + } + + /// + protected override void Process (char input) + { + foreach (Tuple released in Parser.ProcessInput (Tuple.Create (input, input))) + { + ProcessAfterParsing (released.Item2); + } + + } + + /// + protected override void ProcessAfterParsing (char input) + { + var key = KeyConverter.ToKey (input); + + // If the key is not valid, we don't want to raise any events. + if (IsValidInput (key, out key)) + { + OnKeyDown (key); + OnKeyUp (key); + } + } +} diff --git a/Terminal.Gui/Drivers/V2/UnixKeyConverter.cs b/Terminal.Gui/Drivers/V2/UnixKeyConverter.cs new file mode 100644 index 000000000..cdaa38537 --- /dev/null +++ b/Terminal.Gui/Drivers/V2/UnixKeyConverter.cs @@ -0,0 +1,20 @@ +#nullable enable + +namespace Terminal.Gui.Drivers; + +/// +/// capable of converting the +/// unix native class +/// into Terminal.Gui shared representation +/// (used by etc). +/// +internal class UnixKeyConverter : IKeyConverter +{ + /// + public Key ToKey (char value) + { + ConsoleKeyInfo adjustedInput = EscSeqUtils.MapChar (value); + + return EscSeqUtils.MapKey (adjustedInput); + } +} diff --git a/Terminal.Gui/Drivers/V2/UnixOutput.cs b/Terminal.Gui/Drivers/V2/UnixOutput.cs new file mode 100644 index 000000000..cc0fffb0e --- /dev/null +++ b/Terminal.Gui/Drivers/V2/UnixOutput.cs @@ -0,0 +1,156 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Terminal.Gui.Drivers; + +internal class UnixOutput : OutputBase, IConsoleOutput +{ + [StructLayout (LayoutKind.Sequential)] + private struct WinSize + { + public ushort ws_row; + public ushort ws_col; + public ushort ws_xpixel; + public ushort ws_ypixel; + } + + private static readonly uint TIOCGWINSZ = + RuntimeInformation.IsOSPlatform (OSPlatform.OSX) || + RuntimeInformation.IsOSPlatform (OSPlatform.FreeBSD) + ? 0x40087468u // Darwin/BSD + : 0x5413u; // Linux + + [DllImport ("libc", SetLastError = true)] + private static extern int ioctl (int fd, uint request, out WinSize ws); + + // File descriptor for stdout + private const int STDOUT_FILENO = 1; + + [DllImport ("libc")] + private static extern int write (int fd, byte [] buf, int n); + + [DllImport ("libc", SetLastError = true)] + private static extern int dup (int fd); + + /// + protected override void AppendOrWriteAttribute (StringBuilder output, Attribute attr, TextStyle redrawTextStyle) + { + if (Application.Force16Colors) + { + output.Append (EscSeqUtils.CSI_SetForegroundColor (attr.Foreground.GetAnsiColorCode ())); + output.Append (EscSeqUtils.CSI_SetBackgroundColor (attr.Background.GetAnsiColorCode ())); + } + else + { + EscSeqUtils.CSI_AppendForegroundColorRGB ( + output, + attr.Foreground.R, + attr.Foreground.G, + attr.Foreground.B + ); + + EscSeqUtils.CSI_AppendBackgroundColorRGB ( + output, + attr.Background.R, + attr.Background.G, + attr.Background.B + ); + EscSeqUtils.CSI_AppendTextStyleChange (output, redrawTextStyle, attr.Style); + } + } + + /// + protected override void Write (StringBuilder output) + { + byte [] utf8 = Encoding.UTF8.GetBytes (output.ToString ()); + // Write to stdout (fd 1) + write (STDOUT_FILENO, utf8, utf8.Length); + } + + private Point? _lastCursorPosition; + + /// + protected override bool SetCursorPositionImpl (int screenPositionX, int screenPositionY) + { + if (_lastCursorPosition is { } && _lastCursorPosition.Value.X == screenPositionX && _lastCursorPosition.Value.Y == screenPositionY) + { + return true; + } + + _lastCursorPosition = new (screenPositionX, screenPositionY); + + using var writer = CreateUnixStdoutWriter (); + + // + 1 is needed because Unix is based on 1 instead of 0 and + EscSeqUtils.CSI_WriteCursorPosition (writer, screenPositionY + 1, screenPositionX + 1); + + return true; + } + + private TextWriter CreateUnixStdoutWriter () + { + // duplicate stdout so we don’t mess with Console.Out’s FD + int fdCopy = dup (STDOUT_FILENO); + + if (fdCopy == -1) + { + throw new IOException ("Failed to dup STDOUT_FILENO"); + } + + // wrap the raw fd into a SafeFileHandle + var handle = new SafeFileHandle (fdCopy, ownsHandle: true); + + // create FileStream from the safe handle + var stream = new FileStream (handle, FileAccess.Write); + + return new StreamWriter (stream) + { + AutoFlush = true + }; + } + + /// + public void Write (ReadOnlySpan text) + { + byte [] utf8 = Encoding.UTF8.GetBytes (text.ToArray ()); + // Write to stdout (fd 1) + write (STDOUT_FILENO, utf8, utf8.Length); + } + + /// + public Size GetWindowSize () + { + if (ConsoleDriver.RunningUnitTests) + { + // For unit tests, we return a default size. + return Size.Empty; + } + + if (ioctl (1, TIOCGWINSZ, out WinSize ws) == 0) + { + if (ws.ws_col > 0 && ws.ws_row > 0) + { + return new (ws.ws_col, ws.ws_row); + } + } + + return Size.Empty; // fallback + } + + /// + public override void SetCursorVisibility (CursorVisibility visibility) + { + Write (visibility == CursorVisibility.Default ? EscSeqUtils.CSI_ShowCursor : EscSeqUtils.CSI_HideCursor); + } + + /// + public void SetCursorPosition (int col, int row) + { + SetCursorPositionImpl (col, row); + } + + /// + public void Dispose () + { + } +} diff --git a/Tests/UnitTests/Application/SynchronizatonContextTests.cs b/Tests/UnitTests/Application/SynchronizatonContextTests.cs index 86f138027..0a3c1120f 100644 --- a/Tests/UnitTests/Application/SynchronizatonContextTests.cs +++ b/Tests/UnitTests/Application/SynchronizatonContextTests.cs @@ -30,6 +30,7 @@ public class SyncrhonizationContextTests [InlineData (typeof (CursesDriver))] [InlineData (typeof (ConsoleDriverFacade), "v2win")] [InlineData (typeof (ConsoleDriverFacade), "v2net")] + [InlineData (typeof (ConsoleDriverFacade), "v2unix")] public void SynchronizationContext_Post (Type driverType, string driverName = null) { lock (_lockPost)