diff --git a/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence/AnsiEscapeSequenceRequest.cs b/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence/AnsiEscapeSequenceRequest.cs new file mode 100644 index 0000000000..d8dc22ad30 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence/AnsiEscapeSequenceRequest.cs @@ -0,0 +1,197 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Describes an ongoing ANSI request sent to the console. +/// Use to handle the response +/// when console answers the request. +/// +public class AnsiEscapeSequenceRequest +{ + /// + /// Request to send e.g. see + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// + public required string Request { get; init; } + + /// + /// Invoked when the console responds with an ANSI response code that matches the + /// + /// + public event EventHandler? ResponseReceived; + + /// + /// + /// The terminator that uniquely identifies the type of response as responded + /// by the console. e.g. for + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// the terminator is + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Terminator + /// + /// . + /// + /// + /// After sending a request, the first response with matching terminator will be matched + /// to the oldest outstanding request. + /// + /// + public required string Terminator { get; init; } + + /// + /// Execute an ANSI escape sequence escape which may return a response or error. + /// + /// The ANSI escape sequence to request. + /// + /// When this method returns , an object containing the response with an empty + /// error. + /// + /// A with the response, error, terminator and value. + public static bool TryExecuteAnsiRequest (AnsiEscapeSequenceRequest ansiRequest, out AnsiEscapeSequenceResponse result) + { + var response = new StringBuilder (); + var error = new StringBuilder (); + var savedIsReportingMouseMoves = false; + NetDriver? netDriver = null; + var values = new string? [] { null }; + + try + { + switch (Application.Driver) + { + case NetDriver: + netDriver = Application.Driver as NetDriver; + savedIsReportingMouseMoves = netDriver!.IsReportingMouseMoves; + + if (savedIsReportingMouseMoves) + { + netDriver.StopReportingMouseMoves (); + } + + while (Console.KeyAvailable) + { + netDriver._mainLoopDriver._netEvents._waitForStart.Set (); + netDriver._mainLoopDriver._netEvents._waitForStart.Reset (); + + netDriver._mainLoopDriver._netEvents._forceRead = true; + } + + netDriver._mainLoopDriver._netEvents._forceRead = false; + + break; + case CursesDriver cursesDriver: + savedIsReportingMouseMoves = cursesDriver.IsReportingMouseMoves; + + if (savedIsReportingMouseMoves) + { + cursesDriver.StopReportingMouseMoves (); + } + + break; + } + + if (netDriver is { }) + { + NetEvents._suspendRead = true; + } + else + { + Thread.Sleep (100); // Allow time for mouse stopping and to flush the input buffer + + // Flush the input buffer to avoid reading stale input + while (Console.KeyAvailable) + { + Console.ReadKey (true); + } + } + + // Send the ANSI escape sequence + Console.Write (ansiRequest.Request); + Console.Out.Flush (); // Ensure the request is sent + + // Read the response from stdin (response should come back as input) + Thread.Sleep (100); // Allow time for the terminal to respond + + // Read input until no more characters are available or the terminator is encountered + while (Console.KeyAvailable) + { + // Peek the next key + ConsoleKeyInfo keyInfo = Console.ReadKey (true); // true to not display on the console + + // Append the current key to the response + response.Append (keyInfo.KeyChar); + + // Read until no key is available if no terminator was specified or + // check if the key is terminator (ANSI escape sequence ends) + if (!string.IsNullOrEmpty (ansiRequest.Terminator) && keyInfo.KeyChar == ansiRequest.Terminator [^1]) + { + // Break out of the loop when terminator is found + break; + } + } + + if (string.IsNullOrEmpty (ansiRequest.Terminator)) + { + error.AppendLine ("Terminator request is empty."); + } + else if (!response.ToString ().EndsWith (ansiRequest.Terminator [^1])) + { + throw new InvalidOperationException ($"Terminator doesn't ends with: '{ansiRequest.Terminator [^1]}'"); + } + } + catch (Exception ex) + { + error.AppendLine ($"Error executing ANSI request: {ex.Message}"); + } + finally + { + if (string.IsNullOrEmpty (error.ToString ())) + { + (string? c1Control, string? code, values, string? terminator) = EscSeqUtils.GetEscapeResult (response.ToString ().ToCharArray ()); + } + + if (savedIsReportingMouseMoves) + { + switch (Application.Driver) + { + case NetDriver: + NetEvents._suspendRead = false; + netDriver!.StartReportingMouseMoves (); + + break; + case CursesDriver cursesDriver: + cursesDriver.StartReportingMouseMoves (); + + break; + } + } + } + + AnsiEscapeSequenceResponse ansiResponse = new () + { + Response = response.ToString (), Error = error.ToString (), + Terminator = string.IsNullOrEmpty (response.ToString ()) ? "" : response.ToString () [^1].ToString (), Value = values [0] + }; + + // Invoke the event if it's subscribed + ansiRequest.ResponseReceived?.Invoke (ansiRequest, ansiResponse); + + result = ansiResponse; + + return string.IsNullOrWhiteSpace (result.Error) && !string.IsNullOrWhiteSpace (result.Response); + } + + /// + /// The value expected in the response e.g. + /// + /// EscSeqUtils.CSI_ReportTerminalSizeInChars.Value + /// + /// which will have a 't' as terminator but also other different request may return the same terminator with a + /// different value. + /// + public string? Value { get; init; } +} diff --git a/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence/AnsiEscapeSequenceResponse.cs b/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence/AnsiEscapeSequenceResponse.cs new file mode 100644 index 0000000000..df98511558 --- /dev/null +++ b/Terminal.Gui/ConsoleDrivers/AnsiEscapeSequence/AnsiEscapeSequenceResponse.cs @@ -0,0 +1,53 @@ +#nullable enable +namespace Terminal.Gui; + +/// +/// Describes a finished ANSI received from the console. +/// +public class AnsiEscapeSequenceResponse +{ + /// + /// Error received from e.g. see + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// + public required string Error { get; init; } + + /// + /// Response received from e.g. see + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// . + /// + public required string Response { get; init; } + + /// + /// + /// The terminator that uniquely identifies the type of response as responded + /// by the console. e.g. for + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Request + /// + /// the terminator is + /// + /// EscSeqUtils.CSI_SendDeviceAttributes.Terminator + /// + /// + /// + /// The received terminator must match to the terminator sent by the request. + /// + /// + public required string Terminator { get; init; } + + /// + /// The value expected in the response e.g. + /// + /// EscSeqUtils.CSI_ReportTerminalSizeInChars.Value + /// + /// which will have a 't' as terminator but also other different request may return the same terminator with a + /// different value. + /// + public string? Value { get; init; } +} diff --git a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs index 99e560044d..a48881d4b1 100644 --- a/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/CursesDriver/CursesDriver.cs @@ -177,11 +177,15 @@ public override bool SetCursorVisibility (CursorVisibility visibility) return true; } + public bool IsReportingMouseMoves { get; private set; } + public void StartReportingMouseMoves () { if (!RunningUnitTests) { Console.Out.Write (EscSeqUtils.CSI_EnableMouseEvents); + + IsReportingMouseMoves = true; } } @@ -190,6 +194,8 @@ public void StopReportingMouseMoves () if (!RunningUnitTests) { Console.Out.Write (EscSeqUtils.CSI_DisableMouseEvents); + + IsReportingMouseMoves = false; } } diff --git a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs index 1eb63e34aa..5f87b1e264 100644 --- a/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs +++ b/Terminal.Gui/ConsoleDrivers/EscSeqUtils/EscSeqUtils.cs @@ -1316,13 +1316,9 @@ public enum DECSCUSR_Style /// /// ESC [ ? 6 n - Request Cursor Position Report (?) (DECXCPR) /// https://terminalguide.namepad.de/seq/csi_sn__p-6/ + /// The terminal reply to . ESC [ ? (y) ; (x) ; 1 R /// - public static readonly string CSI_RequestCursorPositionReport = CSI + "?6n"; - - /// - /// The terminal reply to . ESC [ ? (y) ; (x) R - /// - public const string CSI_RequestCursorPositionReport_Terminator = "R"; + public static readonly AnsiEscapeSequenceRequest CSI_RequestCursorPositionReport = new () { Request = CSI + "?6n", Terminator = "R" }; /// /// ESC [ 0 c - Send Device Attributes (Primary DA) @@ -1341,37 +1337,35 @@ public enum DECSCUSR_Style /// 28 = Rectangular area operations /// 32 = Text macros /// 42 = ISO Latin-2 character set + /// The terminator indicating a reply to or + /// /// - public static readonly string CSI_SendDeviceAttributes = CSI + "0c"; + public static readonly AnsiEscapeSequenceRequest CSI_SendDeviceAttributes = new () { Request = CSI + "0c", Terminator = "c" }; /// /// ESC [ > 0 c - Send Device Attributes (Secondary DA) /// Windows Terminal v1.18+ emits: "\x1b[>0;10;1c" (vt100, firmware version 1.0, vt220) - /// - public static readonly string CSI_SendDeviceAttributes2 = CSI + ">0c"; - - /// /// The terminator indicating a reply to or /// /// - public const string CSI_ReportDeviceAttributes_Terminator = "c"; + public static readonly AnsiEscapeSequenceRequest CSI_SendDeviceAttributes2 = new () { Request = CSI + ">0c", Terminator = "c" }; /// - /// CSI 1 8 t | yes | yes | yes | report window size in chars - /// https://terminalguide.namepad.de/seq/csi_st-18/ + /// CSI 16 t - Request sixel resolution (width and height in pixels) /// - public static readonly string CSI_ReportTerminalSizeInChars = CSI + "18t"; + public static readonly AnsiEscapeSequenceRequest CSI_RequestSixelResolution = new () { Request = CSI + "16t", Terminator = "t" }; /// - /// The terminator indicating a reply to : ESC [ 8 ; height ; width t + /// CSI 14 t - Request window size in pixels (width x height) /// - public const string CSI_ReportTerminalSizeInChars_Terminator = "t"; + public static readonly AnsiEscapeSequenceRequest CSI_RequestWindowSizeInPixels = new () { Request = CSI + "14t", Terminator = "t" }; /// - /// The value of the response to indicating value 1 and 2 are the terminal - /// size in chars. + /// CSI 1 8 t | yes | yes | yes | report window size in chars + /// https://terminalguide.namepad.de/seq/csi_st-18/ + /// The terminator indicating a reply to : ESC [ 8 ; height ; width t /// - public const string CSI_ReportTerminalSizeInChars_ResponseValue = "8"; + public static readonly AnsiEscapeSequenceRequest CSI_ReportTerminalSizeInChars = new () { Request = CSI + "18t", Terminator = "t", Value = "8" }; #endregion } diff --git a/Terminal.Gui/ConsoleDrivers/NetDriver.cs b/Terminal.Gui/ConsoleDrivers/NetDriver.cs index b5729406ce..9097b37309 100644 --- a/Terminal.Gui/ConsoleDrivers/NetDriver.cs +++ b/Terminal.Gui/ConsoleDrivers/NetDriver.cs @@ -136,7 +136,7 @@ internal class NetEvents : IDisposable { private readonly ManualResetEventSlim _inputReady = new (false); private CancellationTokenSource _inputReadyCancellationTokenSource; - private readonly ManualResetEventSlim _waitForStart = new (false); + internal readonly ManualResetEventSlim _waitForStart = new (false); //CancellationTokenSource _waitForStartCancellationTokenSource; private readonly ManualResetEventSlim _winChange = new (false); @@ -202,7 +202,7 @@ private static ConsoleKeyInfo ReadConsoleKeyInfo (CancellationToken cancellation { // if there is a key available, return it without waiting // (or dispatching work to the thread queue) - if (Console.KeyAvailable) + if (Console.KeyAvailable && !_suspendRead) { return Console.ReadKey (intercept); } @@ -211,7 +211,7 @@ private static ConsoleKeyInfo ReadConsoleKeyInfo (CancellationToken cancellation { Task.Delay (100, cancellationToken).Wait (cancellationToken); - if (Console.KeyAvailable) + if (Console.KeyAvailable && !_suspendRead) { return Console.ReadKey (intercept); } @@ -222,6 +222,9 @@ private static ConsoleKeyInfo ReadConsoleKeyInfo (CancellationToken cancellation return default (ConsoleKeyInfo); } + internal bool _forceRead; + internal static bool _suspendRead; + private void ProcessInputQueue () { while (_inputReadyCancellationTokenSource is { IsCancellationRequested: false }) @@ -237,7 +240,7 @@ private void ProcessInputQueue () _waitForStart.Reset (); - if (_inputQueue.Count == 0) + if (_inputQueue.Count == 0 || _forceRead) { ConsoleKey key = 0; ConsoleModifiers mod = 0; @@ -591,55 +594,48 @@ private MouseButtonState MapMouseFlags (MouseFlags mouseFlags) private void HandleRequestResponseEvent (string c1Control, string code, string [] values, string terminating) { - switch (terminating) - { - // BUGBUG: I can't find where we send a request for cursor position (ESC[?6n), so I'm not sure if this is needed. - case EscSeqUtils.CSI_RequestCursorPositionReport_Terminator: - var point = new Point { X = int.Parse (values [1]) - 1, Y = int.Parse (values [0]) - 1 }; - - if (_lastCursorPosition.Y != point.Y) - { - _lastCursorPosition = point; - var eventType = EventType.WindowPosition; - var winPositionEv = new WindowPositionEvent { CursorPosition = point }; - - _inputQueue.Enqueue ( - new InputResult { EventType = eventType, WindowPositionEvent = winPositionEv } - ); - } - else - { - return; - } - - break; + if (terminating == - case EscSeqUtils.CSI_ReportTerminalSizeInChars_Terminator: - switch (values [0]) - { - case EscSeqUtils.CSI_ReportTerminalSizeInChars_ResponseValue: - EnqueueWindowSizeEvent ( - Math.Max (int.Parse (values [1]), 0), - Math.Max (int.Parse (values [2]), 0), - Math.Max (int.Parse (values [1]), 0), - Math.Max (int.Parse (values [2]), 0) - ); - - break; - default: - EnqueueRequestResponseEvent (c1Control, code, values, terminating); + // BUGBUG: I can't find where we send a request for cursor position (ESC[?6n), so I'm not sure if this is needed. + // The observation is correct because the response isn't immediate and this is useless + EscSeqUtils.CSI_RequestCursorPositionReport.Terminator) + { + var point = new Point { X = int.Parse (values [1]) - 1, Y = int.Parse (values [0]) - 1 }; - break; - } + if (_lastCursorPosition.Y != point.Y) + { + _lastCursorPosition = point; + var eventType = EventType.WindowPosition; + var winPositionEv = new WindowPositionEvent { CursorPosition = point }; - break; - case EscSeqUtils.CSI_ReportDeviceAttributes_Terminator: - ConsoleDriver.SupportsSixel = values.Any (v => v == "4"); - break; - default: + _inputQueue.Enqueue ( + new InputResult { EventType = eventType, WindowPositionEvent = winPositionEv } + ); + } + else + { + return; + } + } + else if (terminating == EscSeqUtils.CSI_ReportTerminalSizeInChars.Terminator) + { + if (values [0] == EscSeqUtils.CSI_ReportTerminalSizeInChars.Value) + { + EnqueueWindowSizeEvent ( + Math.Max (int.Parse (values [1]), 0), + Math.Max (int.Parse (values [2]), 0), + Math.Max (int.Parse (values [1]), 0), + Math.Max (int.Parse (values [2]), 0) + ); + } + else + { EnqueueRequestResponseEvent (c1Control, code, values, terminating); - - break; + } + } + else + { + EnqueueRequestResponseEvent (c1Control, code, values, terminating); } _inputReady.Set (); @@ -819,7 +815,7 @@ internal class NetDriver : ConsoleDriver private const int COLOR_RED = 31; private const int COLOR_WHITE = 37; private const int COLOR_YELLOW = 33; - private NetMainLoop _mainLoopDriver; + internal NetMainLoop _mainLoopDriver; public bool IsWinPlatform { get; private set; } public NetWinVTConsole NetWinConsole { get; private set; } @@ -1141,13 +1137,9 @@ internal override MainLoop Init () _mainLoopDriver = new NetMainLoop (this); _mainLoopDriver.ProcessInput = ProcessInput; - _mainLoopDriver._netEvents.EscSeqRequests.Add ("c"); - // Determine if sixel is supported - Console.Out.Write (EscSeqUtils.CSI_SendDeviceAttributes); - return new MainLoop (_mainLoopDriver); } - + private void ProcessInput (InputResult inputEvent) { switch (inputEvent.EventType) @@ -1348,7 +1340,6 @@ private bool SetCursorPosition (int col, int row) } private CursorVisibility? _cachedCursorVisibility; - private static bool _supportsSixel; public override void UpdateCursor () { @@ -1397,11 +1388,15 @@ public override bool EnsureCursorVisibility () #region Mouse Handling + public bool IsReportingMouseMoves { get; private set; } + public void StartReportingMouseMoves () { if (!RunningUnitTests) { Console.Out.Write (EscSeqUtils.CSI_EnableMouseEvents); + + IsReportingMouseMoves = true; } } @@ -1410,6 +1405,8 @@ public void StopReportingMouseMoves () if (!RunningUnitTests) { Console.Out.Write (EscSeqUtils.CSI_DisableMouseEvents); + + IsReportingMouseMoves = false; } } @@ -1769,7 +1766,13 @@ void IMainLoopDriver.Iteration () { while (_resultQueue.Count > 0) { - ProcessInput?.Invoke (_resultQueue.Dequeue ().Value); + // Always dequeue even if it's null and invoke if isn't null + InputResult? dequeueResult = _resultQueue.Dequeue (); + + if (dequeueResult is { }) + { + ProcessInput?.Invoke (dequeueResult.Value); + } } } @@ -1825,10 +1828,16 @@ private void NetInputHandler () _resultQueue.Enqueue (_netEvents.DequeueInput ()); } - while (_resultQueue.Count > 0 && _resultQueue.Peek () is null) + try { - _resultQueue.Dequeue (); + while (_resultQueue.Count > 0 && _resultQueue.Peek () is null) + { + // Dequeue null values + _resultQueue.Dequeue (); + } } + catch (InvalidOperationException) // Peek can raise an exception + { } if (_resultQueue.Count > 0) { diff --git a/Terminal.Gui/Drawing/SixelSupportDetector.cs b/Terminal.Gui/Drawing/SixelSupportDetector.cs new file mode 100644 index 0000000000..4713d5e259 --- /dev/null +++ b/Terminal.Gui/Drawing/SixelSupportDetector.cs @@ -0,0 +1,133 @@ +using System.Text.RegularExpressions; + +namespace Terminal.Gui; + +/// +/// Uses Ansi escape sequences to detect whether sixel is supported +/// by the terminal. +/// +public class SixelSupportDetector +{ + /// + /// Sends Ansi escape sequences to the console to determine whether + /// sixel is supported (and + /// etc). + /// + /// Description of sixel support, may include assumptions where + /// expected response codes are not returned by console. + public SixelSupportResult Detect () + { + var result = new SixelSupportResult (); + + result.IsSupported = IsSixelSupportedByDar (); + + if (result.IsSupported) + { + if (TryGetResolutionDirectly (out var res)) + { + result.Resolution = res; + } + else if(TryComputeResolution(out res)) + { + result.Resolution = res; + } + + result.SupportsTransparency = IsWindowsTerminal () || IsXtermWithTransparency (); + } + + return result; + } + + + private bool TryGetResolutionDirectly (out Size resolution) + { + // Expect something like: + //[6;20;10t + + if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestSixelResolution, out var response)) + { + // Terminal supports directly responding with resolution + var match = Regex.Match (response.Response, @"\[\d+;(\d+);(\d+)t$"); + + if (match.Success) + { + if (int.TryParse (match.Groups [1].Value, out var ry) && + int.TryParse (match.Groups [2].Value, out var rx)) + { + resolution = new Size (rx, ry); + + return true; + } + } + } + + resolution = default; + return false; + } + + + private bool TryComputeResolution (out Size resolution) + { + // Fallback to window size in pixels and characters + if (AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_RequestWindowSizeInPixels, out var pixelSizeResponse) + && AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_ReportTerminalSizeInChars, out var charSizeResponse)) + { + // Example [4;600;1200t + var pixelMatch = Regex.Match (pixelSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + + // Example [8;30;120t + var charMatch = Regex.Match (charSizeResponse.Response, @"\[\d+;(\d+);(\d+)t$"); + + if (pixelMatch.Success && charMatch.Success) + { + // Extract pixel dimensions + if (int.TryParse (pixelMatch.Groups [1].Value, out var pixelHeight) + && int.TryParse (pixelMatch.Groups [2].Value, out var pixelWidth) + && + + // Extract character dimensions + int.TryParse (charMatch.Groups [1].Value, out var charHeight) + && int.TryParse (charMatch.Groups [2].Value, out var charWidth) + && charWidth != 0 + && charHeight != 0) // Avoid divide by zero + { + // Calculate the character cell size in pixels + var cellWidth = (int)Math.Round ((double)pixelWidth / charWidth); + var cellHeight = (int)Math.Round ((double)pixelHeight / charHeight); + + // Set the resolution based on the character cell size + resolution = new Size (cellWidth, cellHeight); + + return true; + } + } + } + + resolution = default; + return false; + } + private bool IsSixelSupportedByDar () + { + return AnsiEscapeSequenceRequest.TryExecuteAnsiRequest (EscSeqUtils.CSI_SendDeviceAttributes, out AnsiEscapeSequenceResponse darResponse) + ? darResponse.Response.Split (';').Contains ("4") + : false; + } + + private bool IsWindowsTerminal () + { + return !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable ("WT_SESSION"));; + } + private bool IsXtermWithTransparency () + { + // Check if running in real xterm (XTERM_VERSION is more reliable than TERM) + var xtermVersionStr = Environment.GetEnvironmentVariable ("XTERM_VERSION"); + + // If XTERM_VERSION exists, we are in a real xterm + if (!string.IsNullOrWhiteSpace (xtermVersionStr) && int.TryParse (xtermVersionStr, out var xtermVersion) && xtermVersion >= 370) + { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/Terminal.Gui/Drawing/SixelSupportResult.cs b/Terminal.Gui/Drawing/SixelSupportResult.cs new file mode 100644 index 0000000000..db42ad2370 --- /dev/null +++ b/Terminal.Gui/Drawing/SixelSupportResult.cs @@ -0,0 +1,33 @@ +namespace Terminal.Gui; + +/// +/// Describes the discovered state of sixel support and ancillary information +/// e.g. . You can use +/// to discover this information. +/// +public class SixelSupportResult +{ + /// + /// Whether the terminal supports sixel graphic format. + /// Defaults to false. + /// + public bool IsSupported { get; set; } + + /// + /// The number of pixels of sixel that corresponds to each Col () + /// and each Row (. Defaults to 10x20. + /// + public Size Resolution { get; set; } = new (10, 20); + + /// + /// The maximum number of colors that can be included in a sixel image. Defaults + /// to 256. + /// + public int MaxPaletteColors { get; set; } = 256; + + /// + /// Whether the terminal supports transparent background sixels. + /// Defaults to false + /// + public bool SupportsTransparency { get; set; } +} diff --git a/Terminal.Gui/Views/AutocompleteFilepathContext.cs b/Terminal.Gui/Views/AutocompleteFilepathContext.cs index f577e554fe..96ce1dfaef 100644 --- a/Terminal.Gui/Views/AutocompleteFilepathContext.cs +++ b/Terminal.Gui/Views/AutocompleteFilepathContext.cs @@ -6,7 +6,7 @@ namespace Terminal.Gui; internal class AutocompleteFilepathContext : AutocompleteContext { public AutocompleteFilepathContext (string currentLine, int cursorPosition, FileDialogState state) - : base (TextModel.ToRuneCellList (currentLine), cursorPosition) + : base (RuneCell.ToRuneCellList (currentLine), cursorPosition) { State = state; } diff --git a/Terminal.Gui/Views/TextField.cs b/Terminal.Gui/Views/TextField.cs index e5bd503630..2a8ecd7f5b 100644 --- a/Terminal.Gui/Views/TextField.cs +++ b/Terminal.Gui/Views/TextField.cs @@ -944,7 +944,7 @@ public override void OnDrawContent (Rectangle viewport) int p = ScrollOffset; var col = 0; - int width = Frame.Width + OffSetBackground (); + int width = Viewport.Width + OffSetBackground (); int tcount = _text.Count; Attribute roc = GetReadOnlyColor (); @@ -1130,10 +1130,10 @@ public virtual void Paste () } int cols = _text [idx].GetColumns (); - TextModel.SetCol (ref col, Frame.Width - 1, cols); + TextModel.SetCol (ref col, Viewport.Width - 1, cols); } - int pos = _cursorPosition - ScrollOffset + Math.Min (Frame.X, 0); + int pos = _cursorPosition - ScrollOffset + Math.Min (Viewport.X, 0); Move (pos, 0); return new Point (pos, 0); @@ -1216,16 +1216,16 @@ private void Adjust () ScrollOffset = _cursorPosition; need = true; } - else if (Frame.Width > 0 - && (ScrollOffset + _cursorPosition - (Frame.Width + offB) == 0 - || TextModel.DisplaySize (_text, ScrollOffset, _cursorPosition).size >= Frame.Width + offB)) + else if (Viewport.Width > 0 + && (ScrollOffset + _cursorPosition - (Viewport.Width + offB) == 0 + || TextModel.DisplaySize (_text, ScrollOffset, _cursorPosition).size >= Viewport.Width + offB)) { ScrollOffset = Math.Max ( TextModel.CalculateLeftColumn ( _text, ScrollOffset, _cursorPosition, - Frame.Width + offB + Viewport.Width + offB ), 0 ); @@ -1330,7 +1330,7 @@ private List DeleteSelectedText () private void GenerateSuggestions () { - List currentLine = TextModel.ToRuneCellList (Text); + List currentLine = RuneCell.ToRuneCellList (Text); int cursorPosition = Math.Min (CursorPosition, currentLine.Count); Autocomplete.Context = new ( diff --git a/Terminal.Gui/Views/TextView.cs b/Terminal.Gui/Views/TextView.cs index 680a29c205..eaf54eefc6 100644 --- a/Terminal.Gui/Views/TextView.cs +++ b/Terminal.Gui/Views/TextView.cs @@ -42,6 +42,22 @@ public override string ToString () return $"U+{Rune.Value:X4} '{Rune.ToString ()}'; {colorSchemeStr}"; } + + /// Converts the string into a . + /// The string to convert. + /// The to use. + /// + public static List ToRuneCellList (string str, ColorScheme? colorScheme = null) + { + List cells = new (); + + foreach (Rune rune in str.EnumerateRunes ()) + { + cells.Add (new () { Rune = rune, ColorScheme = colorScheme }); + } + + return cells; + } } internal class TextModel @@ -233,22 +249,6 @@ public static List> StringToLinesOfRuneCells (string content, Col return SplitNewLines (cells); } - /// Converts the string into a . - /// The string to convert. - /// The to use. - /// - public static List ToRuneCellList (string str, ColorScheme? colorScheme = null) - { - List cells = new (); - - foreach (Rune rune in str.EnumerateRunes ()) - { - cells.Add (new () { Rune = rune, ColorScheme = colorScheme }); - } - - return cells; - } - public override string ToString () { var sb = new StringBuilder (); @@ -855,7 +855,7 @@ internal static int GetColFromX (List t, int start, int x, int tabWidth = found = true; } - _lines [i] = ToRuneCellList (ReplaceText (x, textToReplace!, matchText, col)); + _lines [i] = RuneCell.ToRuneCellList (ReplaceText (x, textToReplace!, matchText, col)); x = _lines [i]; txt = GetText (x); pos = new (col, i); @@ -1706,7 +1706,7 @@ public List> ToListRune (List textList) foreach (string text in textList) { - runesList.Add (TextModel.ToRuneCellList (text)); + runesList.Add (RuneCell.ToRuneCellList (text)); } return runesList; @@ -3601,6 +3601,8 @@ public override void OnDrawContent (Rectangle viewport) else { AddRune (col, row, rune); + // Ensures that cols less than 0 to be 1 because it will be converted to a printable rune + cols = Math.Max (cols, 1); } if (!TextModel.SetCol (ref col, viewport.Right, cols)) @@ -3723,7 +3725,7 @@ public void Paste () if (_copyWithoutSelection && contents.FirstOrDefault (x => x == '\n' || x == '\r') == 0) { - List runeList = contents is null ? new () : TextModel.ToRuneCellList (contents); + List runeList = contents is null ? new () : RuneCell.ToRuneCellList (contents); List currentLine = GetCurrentLine (); _historyText.Add (new () { new (currentLine) }, CursorPosition); @@ -3812,6 +3814,11 @@ public void Paste () { cols += TabWidth + 1; } + else + { + // Ensures that cols less than 0 to be 1 because it will be converted to a printable rune + cols = Math.Max (cols, 1); + } if (!TextModel.SetCol (ref col, Viewport.Width, cols)) { diff --git a/UICatalog/Scenarios/AnsiEscapeSequenceRequests.cs b/UICatalog/Scenarios/AnsiEscapeSequenceRequests.cs new file mode 100644 index 0000000000..eae5085bb3 --- /dev/null +++ b/UICatalog/Scenarios/AnsiEscapeSequenceRequests.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using Terminal.Gui; + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("AnsiEscapeSequenceRequest", "Ansi Escape Sequence Request")] +[ScenarioCategory ("Ansi Escape Sequence")] +public sealed class AnsiEscapeSequenceRequests : Scenario +{ + public override void Main () + { + // Init + Application.Init (); + + // Setup - Create a top-level application window and configure it. + Window appWindow = new () + { + Title = GetQuitKeyAndName (), + }; + appWindow.Padding.Thickness = new (1); + + var scrRequests = new List + { + "CSI_SendDeviceAttributes", + "CSI_ReportTerminalSizeInChars", + "CSI_RequestCursorPositionReport", + "CSI_SendDeviceAttributes2" + }; + + var cbRequests = new ComboBox () { Width = 40, Height = 5, ReadOnly = true, Source = new ListWrapper (new (scrRequests)) }; + appWindow.Add (cbRequests); + + var label = new Label { Y = Pos.Bottom (cbRequests) + 1, Text = "Request:" }; + var tfRequest = new TextField { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 20 }; + appWindow.Add (label, tfRequest); + + label = new Label { X = Pos.Right (tfRequest) + 1, Y = Pos.Top (tfRequest) - 1, Text = "Value:" }; + var tfValue = new TextField { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 6 }; + appWindow.Add (label, tfValue); + + label = new Label { X = Pos.Right (tfValue) + 1, Y = Pos.Top (tfValue) - 1, Text = "Terminator:" }; + var tfTerminator = new TextField { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 4 }; + appWindow.Add (label, tfTerminator); + + cbRequests.SelectedItemChanged += (s, e) => + { + var selAnsiEscapeSequenceRequestName = scrRequests [cbRequests.SelectedItem]; + AnsiEscapeSequenceRequest selAnsiEscapeSequenceRequest = null; + switch (selAnsiEscapeSequenceRequestName) + { + case "CSI_SendDeviceAttributes": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_SendDeviceAttributes; + break; + case "CSI_ReportTerminalSizeInChars": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_ReportTerminalSizeInChars; + break; + case "CSI_RequestCursorPositionReport": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_RequestCursorPositionReport; + break; + case "CSI_SendDeviceAttributes2": + selAnsiEscapeSequenceRequest = EscSeqUtils.CSI_SendDeviceAttributes2; + break; + } + + tfRequest.Text = selAnsiEscapeSequenceRequest is { } ? selAnsiEscapeSequenceRequest.Request : ""; + tfValue.Text = selAnsiEscapeSequenceRequest is { } ? selAnsiEscapeSequenceRequest.Value ?? "" : ""; + tfTerminator.Text = selAnsiEscapeSequenceRequest is { } ? selAnsiEscapeSequenceRequest.Terminator : ""; + }; + // Forces raise cbRequests.SelectedItemChanged to update TextFields + cbRequests.SelectedItem = 0; + + label = new Label { Y = Pos.Bottom (tfRequest) + 2, Text = "Response:" }; + var tvResponse = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 40, Height = 4, ReadOnly = true }; + appWindow.Add (label, tvResponse); + + label = new Label { X = Pos.Right (tvResponse) + 1, Y = Pos.Top (tvResponse) - 1, Text = "Error:" }; + var tvError = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 40, Height = 4, ReadOnly = true }; + appWindow.Add (label, tvError); + + label = new Label { X = Pos.Right (tvError) + 1, Y = Pos.Top (tvError) - 1, Text = "Value:" }; + var tvValue = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 6, Height = 4, ReadOnly = true }; + appWindow.Add (label, tvValue); + + label = new Label { X = Pos.Right (tvValue) + 1, Y = Pos.Top (tvValue) - 1, Text = "Terminator:" }; + var tvTerminator = new TextView { X = Pos.Left (label), Y = Pos.Bottom (label), Width = 4, Height = 4, ReadOnly = true }; + appWindow.Add (label, tvTerminator); + + var btnResponse = new Button { X = Pos.Center (), Y = Pos.Bottom (tvResponse) + 2, Text = "Send Request", IsDefault = true }; + + var lblSuccess = new Label { X = Pos.Center (), Y = Pos.Bottom (btnResponse) + 1 }; + appWindow.Add (lblSuccess); + + btnResponse.Accept += (s, e) => + { + var ansiEscapeSequenceRequest = new AnsiEscapeSequenceRequest + { + Request = tfRequest.Text, + Terminator = tfTerminator.Text, + Value = string.IsNullOrEmpty (tfValue.Text) ? null : tfValue.Text + }; + + var success = AnsiEscapeSequenceRequest.TryExecuteAnsiRequest ( + ansiEscapeSequenceRequest, + out AnsiEscapeSequenceResponse ansiEscapeSequenceResponse + ); + + tvResponse.Text = ansiEscapeSequenceResponse.Response; + tvError.Text = ansiEscapeSequenceResponse.Error; + tvValue.Text = ansiEscapeSequenceResponse.Value ?? ""; + tvTerminator.Text = ansiEscapeSequenceResponse.Terminator; + + if (success) + { + lblSuccess.ColorScheme = Colors.ColorSchemes ["Base"]; + lblSuccess.Text = "Successful"; + } + else + { + lblSuccess.ColorScheme = Colors.ColorSchemes ["Error"]; + lblSuccess.Text = "Error"; + } + }; + appWindow.Add (btnResponse); + + appWindow.Add (new Label { Y = Pos.Bottom (lblSuccess) + 2, Text = "You can send other requests by editing the TextFields." }); + + // Run - Start the application. + Application.Run (appWindow); + appWindow.Dispose (); + + // Shutdown - Calling Application.Shutdown is required. + Application.Shutdown (); + } +} diff --git a/UICatalog/Scenarios/Images.cs b/UICatalog/Scenarios/Images.cs index 6e319c8521..9fb4666daf 100644 --- a/UICatalog/Scenarios/Images.cs +++ b/UICatalog/Scenarios/Images.cs @@ -61,9 +61,15 @@ public class Images : Scenario private RadioGroup _rgDistanceAlgorithm; private NumericUpDown _popularityThreshold; private SixelToRender _sixelImage; + private SixelSupportResult _sixelSupportResult; public override void Main () { + var sixelSupportDetector = new SixelSupportDetector (); + _sixelSupportResult = sixelSupportDetector.Detect (); + + ConsoleDriver.SupportsSixel = _sixelSupportResult.IsSupported; + Application.Init (); _win = new() { Title = $"{Application.QuitKey} to Quit - Scenario: {GetName ()}" }; @@ -158,8 +164,23 @@ private void BtnStartFireOnAccept (object sender, HandledEventArgs e) return; } + if (!_sixelSupportResult.SupportsTransparency) + { + if (MessageBox.Query ( + "Transparency Not Supported", + "It looks like your terminal does not support transparent sixel backgrounds. Do you want to try anyway?", + "Yes", + "No") + != 0) + { + return; + } + } + + _fire = new DoomFire (_win.Frame.Width * _pxX.Value, _win.Frame.Height * _pxY.Value); _fireEncoder = new SixelEncoder (); + _fireEncoder.Quantizer.MaxColors = Math.Min (_fireEncoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); _fireEncoder.Quantizer.PaletteBuildingAlgorithm = new ConstPalette (_fire.Palette); _fireFrameCounter = 0; @@ -337,7 +358,7 @@ private void BuildSixelTab () { X = Pos.Right (lblPxX), Y = Pos.Bottom (btnStartFire) + 1, - Value = 10 + Value = _sixelSupportResult.Resolution.Width }; var lblPxY = new Label @@ -351,7 +372,7 @@ private void BuildSixelTab () { X = Pos.Right (lblPxY), Y = Pos.Bottom (_pxX), - Value = 20 + Value = _sixelSupportResult.Resolution.Height }; var l1 = new Label () @@ -500,6 +521,7 @@ int pixelsPerCellY ) { var encoder = new SixelEncoder (); + encoder.Quantizer.MaxColors = Math.Min (encoder.Quantizer.MaxColors, _sixelSupportResult.MaxPaletteColors); encoder.Quantizer.PaletteBuildingAlgorithm = GetPaletteBuilder (); encoder.Quantizer.DistanceAlgorithm = GetDistanceAlgorithm (); diff --git a/UnitTests/Text/AutocompleteTests.cs b/UnitTests/Text/AutocompleteTests.cs index c7d907e1a9..1689c70c8d 100644 --- a/UnitTests/Text/AutocompleteTests.cs +++ b/UnitTests/Text/AutocompleteTests.cs @@ -254,7 +254,7 @@ public void Test_GenerateSuggestions_Simple () ac.GenerateSuggestions ( new ( - TextModel.ToRuneCellList (tv.Text), + RuneCell.ToRuneCellList (tv.Text), 2 ) ); diff --git a/UnitTests/View/Mouse/MouseTests.cs b/UnitTests/View/Mouse/MouseTests.cs index e365f98a8d..8c021e3c4c 100644 --- a/UnitTests/View/Mouse/MouseTests.cs +++ b/UnitTests/View/Mouse/MouseTests.cs @@ -404,6 +404,7 @@ public void WantContinuousButtonPressed_True_And_WantMousePositionReports_True_M me.Handled = false; view.Dispose (); + Application.ResetState (ignoreDisposed: true); } [Theory] diff --git a/UnitTests/Views/TextFieldTests.cs b/UnitTests/Views/TextFieldTests.cs index 5c81dffcad..4e1fec138b 100644 --- a/UnitTests/Views/TextFieldTests.cs +++ b/UnitTests/Views/TextFieldTests.cs @@ -2108,4 +2108,18 @@ public void Autocomplete_Visible_False_By_Default () Assert.True (t.Visible); Assert.False (t.Autocomplete.Visible); } + + [Fact] + [AutoInitShutdown] + public void Draw_Esc_Rune () + { + var tf = new TextField { Width = 5, Text = "\u001b" }; + tf.BeginInit (); + tf.EndInit (); + tf.Draw (); + + TestHelpers.AssertDriverContentsWithFrameAre ("\u241b", output); + + tf.Dispose (); + } } diff --git a/UnitTests/Views/TextViewTests.cs b/UnitTests/Views/TextViewTests.cs index 0c2bbe12f2..b69d3108fe 100644 --- a/UnitTests/Views/TextViewTests.cs +++ b/UnitTests/Views/TextViewTests.cs @@ -8699,4 +8699,18 @@ public void Autocomplete_Visible_False_By_Default () Assert.True (t.Visible); Assert.False (t.Autocomplete.Visible); } + + [Fact] + [AutoInitShutdown] + public void Draw_Esc_Rune () + { + var tv = new TextView { Width = 5, Height = 1, Text = "\u001b" }; + tv.BeginInit (); + tv.EndInit (); + tv.Draw (); + + TestHelpers.AssertDriverContentsWithFrameAre ("\u241b", _output); + + tv.Dispose (); + } }