diff --git a/Examples/UICatalog/Scenarios/Keys.cs b/Examples/UICatalog/Scenarios/Keys.cs index 8f9c2139ff..e8d721fc89 100644 --- a/Examples/UICatalog/Scenarios/Keys.cs +++ b/Examples/UICatalog/Scenarios/Keys.cs @@ -15,87 +15,70 @@ public override void Main () ObservableCollection keyDownNotHandledList = []; ObservableCollection swallowedList = []; - using Window win = new () { Title = GetQuitKeyAndName () }; + using Window win = new (); + win.Title = GetQuitKeyAndName (); + win.BorderStyle = LineStyle.None; - Label label = new () + Shortcut quitShortcut = new () { - X = 0, - Y = 0, - Text = "_Type text here:" + // ReSharper disable once AccessToDisposedClosure + Title = "Quit", Key = Application.GetDefaultKey (Command.Quit), BindKeyToApplication = true, Action = app.RequestStop }; + + StatusBar statusBar = new ([quitShortcut, new Shortcut { Title = "Disable QuitKey", Action = () => { quitShortcut.Key = Key.Empty; } }]); + + app.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + // When the App is initialized, kitty detection is async, so we + // create the shortcut in a timeout. + statusBar.Add (new Shortcut + { + CommandView = new CheckBox + { + Title = "Kitty Keyboard Protocol Enabled", + + // ReSharper disable once AccessToDisposedClosure + Value = app.Driver?.KittyKeyboardCapabilities?.IsSupported is true ? CheckState.Checked : CheckState.UnChecked + }, + Enabled = false + }); + + return false; + }); + + Label label = new () { X = 0, Y = 0, Text = "_Type text here:" }; win.Add (label); - TextField edit = new () - { - X = Pos.Right (label) + 1, - Y = Pos.Top (label), - Width = Dim.Fill (2), - Height = 1, - }; + TextField edit = new () { X = Pos.Right (label) + 1, Y = Pos.Top (label), Width = Dim.Fill (2), Height = 1 }; win.Add (edit); - label = new () - { - X = 0, - Y = Pos.Bottom (label), - Text = "Last _app.Keyboard.KeyDown:" - }; + label = new Label { X = 0, Y = Pos.Bottom (label), Text = "Last _app.Keyboard.KeyDown:" }; win.Add (label); - Label labelAppKeypress = new () - { - X = Pos.Right (label) + 1, - Y = Pos.Top (label), - Width = Dim.Fill (2) - }; + Label labelAppKeypress = new () { X = Pos.Right (label) + 1, Y = Pos.Top (label), Width = Dim.Fill (2) }; win.Add (labelAppKeypress); app.Keyboard.KeyDown += (_, e) => labelAppKeypress.Text = FormatKeyEvent (e); - label = new () - { - X = 0, - Y = Pos.Bottom (label), - Text = "Last app.Keyboard._KeyUp:" - }; + label = new Label { X = 0, Y = Pos.Bottom (label), Text = "Last app.Keyboard._KeyUp:" }; win.Add (label); - Label labelAppKeyUp = new () - { - X = Pos.Right (label) + 1, - Y = Pos.Top (label), - Width = Dim.Fill (2) - }; + Label labelAppKeyUp = new () { X = Pos.Right (label) + 1, Y = Pos.Top (label), Width = Dim.Fill (2) }; win.Add (labelAppKeyUp); app.Keyboard.KeyUp += (_, e) => labelAppKeyUp.Text = FormatKeyEvent (e); - label = new () - { - X = 0, - Y = Pos.Bottom (label), - Text = "_Last TextField.KeyDown:" - }; + label = new Label { X = 0, Y = Pos.Bottom (label), Text = "_Last TextField.KeyDown:" }; win.Add (label); - Label lastTextFieldKeyDownLabel = new () - { - X = Pos.Right (label) + 1, - Y = Pos.Top (label), - Height = 1, - Width = Dim.Fill (2) - }; + Label lastTextFieldKeyDownLabel = new () { X = Pos.Right (label) + 1, Y = Pos.Top (label), Height = 1, Width = Dim.Fill (2) }; win.Add (lastTextFieldKeyDownLabel); edit.KeyDown += (_, e) => lastTextFieldKeyDownLabel.Text = FormatKeyEvent (e); // Application key event log: - label = new () - { - X = 0, - Y = Pos.Bottom (label) + 1, - Text = "Application Key Events:" - }; + label = new Label { X = 0, Y = Pos.Bottom (label) + 1, Text = "Application Key Events:" }; win.Add (label); int maxKeyString = Key.CursorRight.WithAlt.WithCtrl.WithShift.ToString ().Length; int colWidth = maxKeyString + 12; // room for event type + modifier info @@ -107,7 +90,7 @@ public override void Main () X = 0, Y = Pos.Bottom (label), Width = colWidth, - Height = Dim.Fill (), + Height = Dim.Fill (statusBar), Source = new ListWrapper (keyList) }; appKeyListView.SchemeName = "Runnable"; @@ -116,18 +99,10 @@ public override void Main () // View key events... edit.KeyDown += (_, a) => { keyDownList.Add (a.ToString ()); }; - edit.KeyDownNotHandled += (_, a) => - { - keyDownNotHandledList.Add ($"{a}"); - }; + edit.KeyDownNotHandled += (_, a) => { keyDownNotHandledList.Add ($"{a}"); }; // KeyDown - label = new () - { - X = Pos.Right (appKeyListView) + 1, - Y = Pos.Top (label), - Text = "TextView Key Down:" - }; + label = new Label { X = Pos.Right (appKeyListView) + 1, Y = Pos.Top (label), Text = "TextView Key Down:" }; win.Add (label); ListView onKeyDownListView = new () @@ -135,19 +110,14 @@ public override void Main () X = Pos.Left (label), Y = Pos.Bottom (label), Width = maxKeyString, - Height = Dim.Fill (), + Height = Dim.Fill (statusBar), Source = new ListWrapper (keyDownList) }; appKeyListView.SchemeName = "Runnable"; win.Add (onKeyDownListView); // KeyDownNotHandled - label = new () - { - X = Pos.Right (onKeyDownListView) + 1, - Y = Pos.Top (label), - Text = "TextView KeyDownNotHandled:" - }; + label = new Label { X = Pos.Right (onKeyDownListView) + 1, Y = Pos.Top (label), Text = "TextView KeyDownNotHandled:" }; win.Add (label); ListView onKeyDownNotHandledListView = new () @@ -155,20 +125,14 @@ public override void Main () X = Pos.Left (label), Y = Pos.Bottom (label), Width = maxKeyString, - Height = Dim.Fill (), + Height = Dim.Fill (statusBar), Source = new ListWrapper (keyDownNotHandledList) }; appKeyListView.SchemeName = "Runnable"; win.Add (onKeyDownNotHandledListView); - // Swallowed - label = new () - { - X = Pos.Right (onKeyDownNotHandledListView) + 1, - Y = Pos.Top (label), - Text = "Swallowed:" - }; + label = new Label { X = Pos.Right (onKeyDownNotHandledListView) + 1, Y = Pos.Top (label), Text = "Swallowed:" }; win.Add (label); ListView onSwallowedListView = new () @@ -176,7 +140,7 @@ public override void Main () X = Pos.Left (label), Y = Pos.Bottom (label), Width = maxKeyString, - Height = Dim.Fill (), + Height = Dim.Fill (statusBar), Source = new ListWrapper (swallowedList) }; appKeyListView.SchemeName = "Runnable"; @@ -187,15 +151,18 @@ public override void Main () app.Keyboard.KeyDown += (_, a) => KeyDownPressUp (a, "Down"); app.Keyboard.KeyUp += (_, a) => KeyDownPressUp (a, "Up"); + win.Add (statusBar); + app.Run (win); + + return; + void KeyDownPressUp (Key args, string upDown) { - string msg = $"Key{upDown,-7}: {FormatKeyEvent (args)}"; + var msg = $"Key{upDown,-7}: {FormatKeyEvent (args)}"; keyList.Add (msg); appKeyListView.MoveDown (); onKeyDownNotHandledListView.MoveDown (); } - - app.Run (win); } /// @@ -204,14 +171,14 @@ void KeyDownPressUp (Key args, string upDown) private static string FormatKeyEvent (Key key) { string eventType = key.EventType switch - { - KeyEventType.Press => "press", - KeyEventType.Repeat => "repeat", - KeyEventType.Release => "release", - _ => "?" - }; - - string text = $"{key} ({eventType})"; + { + KeyEventType.Press => "press", + KeyEventType.Repeat => "repeat", + KeyEventType.Release => "release", + _ => "?" + }; + + var text = $"{key} ({eventType})"; if (key.IsModifierOnly) { diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index 0fb8a60f06..9a09e27665 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -63,7 +63,23 @@ protected override void OnIsModalChanged (bool newIsModal) _disableMouseCb?.Value = App.Mouse.IsMouseDisabled ? CheckState.Checked : CheckState.UnChecked; - _shVersion?.Title = $"{RuntimeEnvironment.OperatingSystem} {RuntimeEnvironment.OperatingSystemVersion}, {App?.Driver?.GetVersionInfo ()}"; + string? driverName = App.Driver?.GetName (); + + _shVersion?.Title = string.IsNullOrEmpty (driverName) + ? $"{RuntimeEnvironment.OperatingSystem} {RuntimeEnvironment.OperatingSystemVersion}" + : $"{RuntimeEnvironment.OperatingSystem} {RuntimeEnvironment.OperatingSystemVersion}, {driverName}"; + + App.AddTimeout (TimeSpan.FromMilliseconds (100), + () => + { + // Kitty detection is async, so we update the shortcut in a timeout. + if (App.Driver?.KittyKeyboardCapabilities?.IsSupported is true) + { + _shVersion?.Title += " (kitty)"; + } + + return false; + }); if (string.IsNullOrEmpty ((string?)Result)) { diff --git a/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs b/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs index 7c68e99287..8d77657414 100644 --- a/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs +++ b/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs @@ -20,22 +20,11 @@ namespace Terminal.Gui.App; /// Type of raw input events, e.g. for .NET driver public class ApplicationMainLoop : IApplicationMainLoop where TInputRecord : struct { - private ITimedEvents? _timedEvents; - private ConcurrentQueue? _inputQueue; - private IInputProcessor? _inputProcessor; - private IOutput? _output; - private AnsiRequestScheduler? _ansiRequestScheduler; - private ISizeMonitor? _sizeMonitor; - /// public IApplication? App { get; private set; } /// - public ITimedEvents TimedEvents - { - get => _timedEvents ?? throw new NotInitializedException (nameof (TimedEvents)); - private set => _timedEvents = value; - } + public ITimedEvents TimedEvents { get => field ?? throw new NotInitializedException (nameof (TimedEvents)); private set; } // TODO: follow above pattern for others too @@ -44,42 +33,22 @@ public ITimedEvents TimedEvents /// thread by a . Is drained as part of each /// on the main loop thread. /// - public ConcurrentQueue InputQueue - { - get => _inputQueue ?? throw new NotInitializedException (nameof (InputQueue)); - private set => _inputQueue = value; - } + public ConcurrentQueue InputQueue { get => field ?? throw new NotInitializedException (nameof (InputQueue)); private set; } /// - public IInputProcessor InputProcessor - { - get => _inputProcessor ?? throw new NotInitializedException (nameof (InputProcessor)); - private set => _inputProcessor = value; - } + public IInputProcessor InputProcessor { get => field ?? throw new NotInitializedException (nameof (InputProcessor)); private set; } /// public IOutputBuffer OutputBuffer { get; } = new OutputBufferImpl (); /// - public IOutput Output - { - get => _output ?? throw new NotInitializedException (nameof (Output)); - private set => _output = value; - } + public IOutput Output { get => field ?? throw new NotInitializedException (nameof (Output)); private set; } /// - public AnsiRequestScheduler AnsiRequestScheduler - { - get => _ansiRequestScheduler ?? throw new NotInitializedException (nameof (AnsiRequestScheduler)); - private set => _ansiRequestScheduler = value; - } + public AnsiRequestScheduler AnsiRequestScheduler { get => field ?? throw new NotInitializedException (nameof (AnsiRequestScheduler)); private set; } /// - public ISizeMonitor SizeMonitor - { - get => _sizeMonitor ?? throw new NotInitializedException (nameof (SizeMonitor)); - private set => _sizeMonitor = value; - } + public ISizeMonitor SizeMonitor { get => field ?? throw new NotInitializedException (nameof (SizeMonitor)); private set; } /// /// Initializes the class with the provided subcomponents @@ -90,14 +59,12 @@ public ISizeMonitor SizeMonitor /// /// /// - public void Initialize ( - ITimedEvents timedEvents, - ConcurrentQueue inputBuffer, - IInputProcessor inputProcessor, - IOutput consoleOutput, - IComponentFactory componentFactory, - IApplication? app - ) + public void Initialize (ITimedEvents timedEvents, + ConcurrentQueue inputBuffer, + IInputProcessor inputProcessor, + IOutput consoleOutput, + IComponentFactory componentFactory, + IApplication? app) { App = app; InputQueue = inputBuffer; @@ -137,11 +104,15 @@ internal void IterationImpl () // Pull any input events from the input queue and process them InputProcessor.ProcessQueue (); + // Run any queued ANSI requests that previously could not be sent + // (e.g. throttled duplicate request sent too soon after an earlier one). + AnsiRequestScheduler.RunSchedule (App?.Driver); + // Check for any size changes; this will cause SizeChanged events SizeMonitor.Poll (); // Layout and draw any views that need it - App?.LayoutAndDraw (false); + App?.LayoutAndDraw (); // Update the cursor App?.Navigation?.UpdateCursor (); diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs index e3d7c11f6e..a5bec93217 100644 --- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs +++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs @@ -108,6 +108,21 @@ public void Stop () _stopCalled = true; + // Restore terminal kitty keyboard mode before shutting down output resources. + + try + { + if (_driver is { KittyKeyboardCapabilities.IsSupported: true }) + { + KittyKeyboardProtocolDetector kittyKeyboardDetector = new (_driver); + kittyKeyboardDetector.Disable (); + } + } + catch (Exception ex) + { + Logging.Warning ($"Kitty keyboard protocol disable failed: {ex.Message}"); + } + _runCancellationTokenSource.Cancel (); _output?.Dispose (); @@ -169,9 +184,10 @@ private void BuildDriverIfPossible (IApplication? app) return; } - Logging.Trace ($"app: SetDefaultAttribute ({new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0))})"); + Attribute attribute = new (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0)); + Logging.Trace ($"app: SetDefaultAttribute ({attribute})"); - _driver.SetDefaultAttribute (new Attribute (fg ?? new Color (255, 255, 255), bg ?? new Color (0, 0))); + _driver.SetDefaultAttribute (attribute); }); } catch (Exception ex) @@ -182,26 +198,29 @@ private void BuildDriverIfPossible (IApplication? app) try { + // Detect Kitty support. The async response we get back only indicates whether + // kitty is supported or not. KittyKeyboardProtocolDetector kittyKeyboardDetector = new (_driver); + kittyKeyboardDetector.Detect (result => - { - _driver.SetKittyKeyboardProtocol (result); - Trace.Lifecycle (app?.MainThreadId?.ToString (), - "KittyKeyboard", - $"Probe complete: Supported={result.IsSupported}, SupportedFlags={result.SupportedFlags}, EnabledFlags={result.EnabledFlags}"); - - if (!result.IsSupported || result.EnabledFlags <= 0 || _output is not AnsiOutput ansiOutput) - { - Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); - return; - } - - ansiOutput.EnableKittyKeyboard (result.EnabledFlags); - _driver.SetKittyKeyboardEnabledFlags (ansiOutput.KittyKeyboardEnabledFlags); - Trace.Lifecycle (app?.MainThreadId?.ToString (), - "KittyKeyboard", - $"Enabled kitty keyboard flags {ansiOutput.KittyKeyboardEnabledFlags}"); - }); + { + if (!result.IsSupported) + { + Trace.Lifecycle (app?.MainThreadId?.ToString (), "KittyKeyboard", "Kitty keyboard mode not enabled"); + + return; + } + + // Kitty is supported. Store the capabilities and set the flags we care about. + _driver?.SetKittyKeyboardCapabilities (result); + kittyKeyboardDetector.Enable (EscSeqUtils.KittyKeyboardRequestedFlags); + + Trace.Lifecycle (app?.MainThreadId?.ToString (), + "KittyKeyboard", + $"Requested kitty keyboard flags { + EscSeqUtils.KittyKeyboardRequestedFlags + }; awaiting confirmation"); + }); } catch (Exception ex) { @@ -209,7 +228,7 @@ private void BuildDriverIfPossible (IApplication? app) } _startupSemaphore.Release (); - Logging.Trace ($"app: {app?.MainThreadId} Driver: _input: {_input}, _output: {_output}"); + Trace.Lifecycle (app?.MainThreadId.ToString (), "Driver", $"_input: {_input}, _output: {_output}"); } /// @@ -255,7 +274,7 @@ private void RunInput (IApplication? app) if (_stopCalled) { - Trace.Lifecycle (app?.MainThreadId.ToString (), "Init", $"Input loop exited cleanly"); + Trace.Lifecycle (app?.MainThreadId.ToString (), "Init", "Input loop exited cleanly"); } else { diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiInputProcessor.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiInputProcessor.cs index 43dc807a87..c97e61915a 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiInputProcessor.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiInputProcessor.cs @@ -39,6 +39,8 @@ namespace Terminal.Gui.Drivers; /// public class AnsiInputProcessor : InputProcessorImpl { + private string _pendingPrintableSuppression = string.Empty; + /// /// The input buffer to process. /// Time provider for timestamps and timing control. @@ -56,6 +58,41 @@ protected override void Process (char input) } } + /// + protected override Key OnKeyboardEventParsed (Key keyEvent) + { + _pendingPrintableSuppression = string.Empty; + + if (keyEvent.EventType != KeyEventType.Press || keyEvent.IsAlt || keyEvent.IsCtrl || keyEvent.IsModifierOnly) + { + return keyEvent; + } + + string printableText = keyEvent.GetPrintableText (); + + if (!string.IsNullOrEmpty (printableText)) + { + _pendingPrintableSuppression = printableText; + } + + return keyEvent; + } + + /// + protected override bool ShouldSuppressFallbackKeyDown (Key key) + { + if (string.IsNullOrEmpty (_pendingPrintableSuppression)) + { + return false; + } + + string printableText = key.GetPrintableText (); + bool suppress = string.Equals (printableText, _pendingPrintableSuppression, StringComparison.Ordinal); + _pendingPrintableSuppression = string.Empty; + + return suppress; + } + /// public override void InjectKeyDownEvent (Key key) { diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs index a90ee85366..cec1b5a4ca 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs @@ -235,42 +235,6 @@ public void Write (ReadOnlySpan text) } } - /// - /// Gets the kitty keyboard flags currently enabled on the terminal. - /// - internal KittyKeyboardFlags KittyKeyboardEnabledFlags { get; private set; } - - /// - /// Enables kitty keyboard progressive enhancement flags for the active terminal. - /// - /// The kitty keyboard flags to enable. - internal void EnableKittyKeyboard (KittyKeyboardFlags flags) - { - if (flags == KittyKeyboardFlags.None || _platform == AnsiPlatform.Degraded) - { - return; - } - - Trace.Lifecycle (nameof (AnsiOutput), "KittyKeyboard", $"Writing enable sequence for flags {flags}"); - Write (EscSeqUtils.CSI_EnableKittyKeyboardFlags (flags)); - KittyKeyboardEnabledFlags = flags; - } - - /// - /// Restores the previous kitty keyboard flag state if kitty mode was enabled. - /// - internal void DisableKittyKeyboard () - { - if (KittyKeyboardEnabledFlags == KittyKeyboardFlags.None) - { - return; - } - - Trace.Lifecycle (nameof (AnsiOutput), "KittyKeyboard", $"Writing disable sequence for flags {KittyKeyboardEnabledFlags}"); - Write (EscSeqUtils.CSI_DisableKittyKeyboardFlags); - KittyKeyboardEnabledFlags = KittyKeyboardFlags.None; - } - /// public override void Write (IOutputBuffer buffer) { @@ -375,8 +339,6 @@ public void Dispose () { try { - DisableKittyKeyboard (); - if (_platform == AnsiPlatform.Degraded) { return; diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs index 1f65377ecc..f29c4b78c9 100644 --- a/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs +++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiSizeMonitor.cs @@ -74,7 +74,7 @@ private void SendSizeQuery () _expectingResponse = true; _lastQuery = DateTime.Now; - Trace.Lifecycle (nameof (AnsiSizeMonitor), "SendSizeQuery", "Queuing CSI 18t size query"); + //Trace.Lifecycle (nameof (AnsiSizeMonitor), "SendSizeQuery", "Queuing CSI 18t size query"); AnsiEscapeSequenceRequest request = new () { @@ -121,7 +121,7 @@ private bool CheckSizeChanged () return false; } - Trace.Lifecycle (nameof (AnsiSizeMonitor), "SizeChanged", $"{_lastSize} → {currentSize}"); + //Trace.Lifecycle (nameof (AnsiSizeMonitor), "SizeChanged", $"{_lastSize} → {currentSize}"); _lastSize = currentSize; SizeChanged?.Invoke (this, new SizeChangedEventArgs (currentSize)); @@ -130,7 +130,7 @@ private bool CheckSizeChanged () private void HandleSizeResponse (string? response) { - Trace.Lifecycle (nameof (AnsiSizeMonitor), "HandleSizeResponse", $"Response: '{response ?? ""}'"); + //Trace.Lifecycle (nameof (AnsiSizeMonitor), "HandleSizeResponse", $"Response: '{response ?? ""}'"); _expectingResponse = false; if (string.IsNullOrEmpty (response)) diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardCapabilities.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardCapabilities.cs new file mode 100644 index 0000000000..a418505fe5 --- /dev/null +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardCapabilities.cs @@ -0,0 +1,20 @@ +namespace Terminal.Gui.Drivers; + +/// +/// Describes the kitty keyboard protocol capabilities discovered from the terminal. +/// +public class KittyKeyboardCapabilities +{ + /// + /// Gets or sets whether the terminal responded to the kitty keyboard protocol query. If + /// the terminal supports the kitty keyboard protocol. This does not necessarily indicate that any particular flags are supported, + /// or that the support has been enabled, only that the terminal responded to the query and is therefore capable of supporting the protocol. + /// + public bool IsSupported { get; set; } + + /// + /// Gets or sets the kitty keyboard flags reported by the terminal as enabled. + /// is a valid value indicating that no kitty keyboard flags were reported as enabled. + /// + public KittyKeyboardFlags Flags { get; set; } +} diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs index f53d4a04e7..a54ba71539 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolDetector.cs @@ -20,18 +20,78 @@ public KittyKeyboardProtocolDetector (IDriver? driver) _driver = driver; } + /// + /// Enables kitty keyboard progressive enhancement flags for the active terminal, + /// then performs a follow-up detect request to confirm and store the flags that were actually enabled. + /// + /// The kitty keyboard flags to enable. + internal void Enable (KittyKeyboardFlags flags) + { + if (_driver is { IsLegacyConsole: true }) + { + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Enable", "Skipping kitty keyboard probe for legacy console"); + + return; + } + + if (flags == KittyKeyboardFlags.None) + { + return; + } + + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Enable", $"Writing enable sequence for flags {flags}"); + _driver?.GetOutput ().Write (EscSeqUtils.CSI_EnableKittyKeyboardFlags (flags)); + + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Enable", "Running Detector again, to get reported flags..."); + + Detect (result => + { + if (!result.IsSupported || _driver?.KittyKeyboardCapabilities is null) + { + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), + "Enable", + $"Post-enable detect did not update flags. IsSupported={ + result.IsSupported + }, HasCapabilities={ + _driver?.KittyKeyboardCapabilities is { } + }"); + + return; + } + + _driver.KittyKeyboardCapabilities.Flags = result.Flags; + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Enable", $"Post-enable detect confirmed kitty flags {result.Flags}"); + }); + } + + /// + /// Sends the kitty keyboard disable sequence to restore the terminal keyboard protocol mode. + /// + internal void Disable () + { + if (_driver is { IsLegacyConsole: true }) + { + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Disable", "Skipping kitty keyboard probe for legacy console"); + + return; + } + + Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Disable", "Writing disable sequence"); + _driver?.GetOutput ().Write (EscSeqUtils.CSI_DisableKittyKeyboardFlags); + } + /// /// Detects kitty keyboard protocol support asynchronously through the ANSI request scheduler. /// /// Called when detection completes. - public void Detect (Action resultCallback) + public void Detect (Action resultCallback) { ArgumentNullException.ThrowIfNull (resultCallback); if (_driver is { IsLegacyConsole: true }) { Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Detect", "Skipping kitty keyboard probe for legacy console"); - resultCallback (new KittyKeyboardProtocolResult ()); + resultCallback (new KittyKeyboardCapabilities ()); return; } @@ -43,26 +103,18 @@ public void Detect (Action resultCallback) QueueRequest (EscSeqUtils.CSI_QueryKittyKeyboardFlags, response => { - KittyKeyboardProtocolResult result = ParseResponse (response); - result.EnabledFlags = result.IsSupported ? EscSeqUtils.KittyKeyboardRequestedFlags : KittyKeyboardFlags.None; + KittyKeyboardCapabilities result = ParseResponse (response); Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Detect", - $"Kitty keyboard response '{ - response - }' => Supported={ - result.IsSupported - }, SupportedFlags={ - result.SupportedFlags - }, EnabledFlags={ - result.EnabledFlags - }"); + $"Kitty keyboard response '{response}' => IsSupported={result.IsSupported}, Flags={result.Flags}"); + resultCallback (result); }, () => { Trace.Lifecycle (nameof (KittyKeyboardProtocolDetector), "Detect", "Kitty keyboard probe abandoned"); - resultCallback (new KittyKeyboardProtocolResult ()); + resultCallback (new KittyKeyboardCapabilities ()); }); } @@ -80,20 +132,39 @@ private void QueueRequest (AnsiEscapeSequence req, Action responseCallba _driver?.QueueAnsiRequest (request); } - internal static KittyKeyboardProtocolResult ParseResponse (string? response) + /// + /// Parses a response, returning + /// the parsed result. If the response indicates Kitty support, + /// will be . If has been sent, + /// enabling Kitty support will indicate + /// which flags have been enabled. + /// + /// + /// The terminal response to parse. Supported formats are the Kitty keyboard flags reply + /// ESC[?<flags>u and the same sequence without the leading escape character + /// ([?<flags>u). + /// + /// + /// A value whose + /// property is when is a valid Kitty keyboard + /// protocol reply, and whose property contains the + /// parsed bit field. If parsing fails, returns a default + /// capabilities value. + /// + internal static KittyKeyboardCapabilities ParseResponse (string? response) { if (string.IsNullOrWhiteSpace (response)) { - return new KittyKeyboardProtocolResult (); + return new KittyKeyboardCapabilities (); } Match match = Regex.Match (response, @"(?:\x1B)?\[\?(\d+)u$"); if (!match.Success || !int.TryParse (match.Groups [1].Value, out int supportedFlags)) { - return new KittyKeyboardProtocolResult (); + return new KittyKeyboardCapabilities (); } - return new KittyKeyboardProtocolResult { IsSupported = true, SupportedFlags = (KittyKeyboardFlags)supportedFlags }; + return new KittyKeyboardCapabilities { IsSupported = true, Flags = (KittyKeyboardFlags)supportedFlags }; } } diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolResult.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolResult.cs deleted file mode 100644 index 90729a333d..0000000000 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardProtocolResult.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Terminal.Gui.Drivers; - -/// -/// Describes the kitty keyboard protocol state discovered from the active terminal. -/// -public class KittyKeyboardProtocolResult -{ - /// - /// Gets or sets whether the active terminal responded to the kitty keyboard protocol query. - /// - public bool IsSupported { get; set; } - - /// - /// Gets or sets the kitty keyboard flags reported by the terminal. - /// - public KittyKeyboardFlags SupportedFlags { get; set; } - - /// - /// Gets or sets the kitty keyboard flags Terminal.Gui intends to enable. - /// - public KittyKeyboardFlags EnabledFlags { get; set; } -} diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 3edbf2ec30..c902ad6985 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -87,14 +87,6 @@ public void Refresh () /// public string? GetName () => _componentFactory.GetDriverName (); - /// - public virtual string GetVersionInfo () - { - string? driverName = GetName (); - - return $"{driverName} driver"; - } - /// public void Suspend () { @@ -182,7 +174,7 @@ public virtual void SetScreenSize (int width, int height) private void OnSizeMonitorOnSizeChanged (object? _, SizeChangedEventArgs e) { - Trace.Lifecycle (nameof (DriverImpl), "OnSizeMonitorOnSizeChanged", $"{e.Size?.Width}×{e.Size?.Height}"); + // Trace.Lifecycle (nameof (DriverImpl), "OnSizeMonitorOnSizeChanged", $"{e.Size?.Width}×{e.Size?.Height}"); SetScreenSize (e.Size!.Value.Width, e.Size.Value.Height); } @@ -364,25 +356,14 @@ public Attribute SetAttribute (Attribute newAttribute) #region Input Events - /// - /// Gets the detected kitty keyboard protocol state for the current driver instance. - /// - internal KittyKeyboardProtocolResult KittyKeyboardProtocol { get; private set; } = new (); - - /// - /// Stores the latest kitty keyboard protocol detection result. - /// - /// The detected kitty keyboard protocol result. - internal void SetKittyKeyboardProtocol (KittyKeyboardProtocolResult result) => KittyKeyboardProtocol = result; + /// + public KittyKeyboardCapabilities? KittyKeyboardCapabilities { get; private set; } /// - /// Stores the kitty keyboard flags currently enabled on the terminal. + /// Stores the detected kitty keyboard protocol capabilities. /// - /// The kitty keyboard flags currently enabled. - internal void SetKittyKeyboardEnabledFlags (KittyKeyboardFlags enabledFlags) - { - KittyKeyboardProtocol.EnabledFlags = enabledFlags; - } + /// The detected kitty keyboard capabilities. + internal void SetKittyKeyboardCapabilities (KittyKeyboardCapabilities capabilities) => KittyKeyboardCapabilities = capabilities; /// Event fired when a key is pressed down. public event EventHandler? KeyDown; diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index 54673b2763..c19fcff3ee 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -21,10 +21,6 @@ public interface IDriver : IDisposable /// string? GetName (); - /// Returns the name of the driver and relevant library version information. - /// - string GetVersionInfo (); - /// Suspends the application (e.g. on Linux via SIGTSTP) and upon resume, resets the console driver. /// This is only implemented in UnixDriver. void Suspend (); @@ -356,6 +352,15 @@ public interface IDriver : IDisposable #region Input Events + /// + /// Gets the terminal kitty keyboard protocol capabilities detected at startup. + /// if the terminal was not queried, detection has not completed, or the terminal did not + /// respond and kitty keyboard protocol support could not be confirmed. + /// When non-, use to determine whether the + /// terminal supports the protocol. + /// + KittyKeyboardCapabilities? KittyKeyboardCapabilities { get; } + /// Event fired when a key is pressed down. event EventHandler? KeyDown; diff --git a/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs b/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs index c75eb51252..d72831ee74 100644 --- a/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs +++ b/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs @@ -69,13 +69,15 @@ protected InputProcessorImpl (ConcurrentQueue inputBuffer, IKeyCon Parser.Keyboard += (_, keyEvent) => { - if (keyEvent.EventType == KeyEventType.Release) + Key normalizedKeyEvent = OnKeyboardEventParsed (keyEvent); + + if (normalizedKeyEvent.EventType == KeyEventType.Release) { - RaiseKeyUpEvent (keyEvent); + RaiseKeyUpEvent (normalizedKeyEvent); } else { - RaiseKeyDownEvent (keyEvent); + RaiseKeyDownEvent (normalizedKeyEvent); } }; @@ -85,7 +87,6 @@ protected InputProcessorImpl (ConcurrentQueue inputBuffer, IKeyCon var cur = new string (str.Select (k => k.Item1).ToArray ()); // Check if this is an incomplete mouse sequence (timing issue when Run() blocks) - var mouseParser = new AnsiMouseParser (); Logging.Warning ($"{ nameof (InputProcessorImpl) @@ -169,15 +170,38 @@ private IEnumerable ReleaseParserHeldKeysIfStale () /// The input record to process. protected virtual void ProcessAfterParsing (TInputRecord input) { - var key = KeyConverter.ToKey (input); + Key key = KeyConverter.ToKey (input); // If the key is not valid, we don't want to raise any events. - if (IsValidInput (key, out key)) + if (!IsValidInput (key, out key)) + { + return; + } + + if (ShouldSuppressFallbackKeyDown (key)) { - RaiseKeyDownEvent (key); + return; } + + RaiseKeyDownEvent (key); } + /// + /// Called when the ANSI parser raises a keyboard event before it is forwarded to or + /// subscribers. + /// + /// The parsed key event. + /// The key event to forward. + protected virtual Key OnKeyboardEventParsed (Key keyEvent) => keyEvent; + + /// + /// Gives derived processors a chance to suppress keydown events that come from fallback stream processing + /// after ANSI parsing. + /// + /// The key produced by fallback processing. + /// when the keydown should be ignored; otherwise . + protected virtual bool ShouldSuppressFallbackKeyDown (Key key) => false; + private char _highSurrogate = '\0'; /// diff --git a/Terminal.sln.DotSettings b/Terminal.sln.DotSettings index ddb5e2e6b8..43cbc8351f 100644 --- a/Terminal.sln.DotSettings +++ b/Terminal.sln.DotSettings @@ -510,6 +510,8 @@ <Policy><Descriptor Staticness="Static" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Static fields (not private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb_AaBb" /></Policy></Policy> <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Local constants"><ElementKinds><Kind Name="LOCAL_CONSTANT" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></Policy> + No + True True ..\Terminal.sln.ToDo.DotSettings diff --git a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs index f3f1360e5a..ff1eac2329 100644 --- a/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs +++ b/Tests/UnitTestsParallelizable/Application/MainLoopCoordinatorTests.cs @@ -237,15 +237,14 @@ public async Task StartInputTaskAsync_DetectsKittyKeyboard_WhenTerminalResponds Assert.True (SpinWait.SpinUntil (() => input.ResponseSent, TimeSpan.FromSeconds (1))); loop.InputProcessor.ProcessQueue (); - var driver = Assert.IsType (appMock.Object.Driver); - Assert.True (driver.KittyKeyboardProtocol.IsSupported); - Assert.Equal ((KittyKeyboardFlags)31, driver.KittyKeyboardProtocol.SupportedFlags); + DriverImpl driver = Assert.IsType (appMock.Object.Driver); + Assert.True (driver.KittyKeyboardCapabilities?.IsSupported); - // In degraded mode (no real terminal), enable/disable are no-ops, - // but detection still succeeds via injected response. - Assert.Equal (KittyKeyboardFlags.None, driver.KittyKeyboardProtocol.EnabledFlags); + Assert.Equal (EscSeqUtils.KittyKeyboardRequestedFlags, driver.KittyKeyboardCapabilities?.Flags); coordinator.Stop (); + + Assert.Contains (EscSeqUtils.CSI_DisableKittyKeyboardFlags, output.GetLastOutput (), StringComparison.Ordinal); } } @@ -266,14 +265,16 @@ public async Task StartInputTaskAsync_DoesNotEnableKittyKeyboard_ForLegacyConsol await coordinator.StartInputTaskAsync (appMock.Object); - var driver = Assert.IsType (appMock.Object.Driver); - Assert.False (driver.KittyKeyboardProtocol.IsSupported); + DriverImpl driver = Assert.IsType (appMock.Object.Driver); + Assert.Null (driver.KittyKeyboardCapabilities); Assert.DoesNotContain (EscSeqUtils.CSI_EnableKittyKeyboardFlags (EscSeqUtils.KittyKeyboardRequestedFlags), output.GetLastOutput (), StringComparison.Ordinal); coordinator.Stop (); + + Assert.DoesNotContain (EscSeqUtils.CSI_DisableKittyKeyboardFlags, output.GetLastOutput (), StringComparison.Ordinal); } [Fact] diff --git a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs index 9500e6a1a4..edc16f3fad 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Ansi/AnsiInputOutputTests.cs @@ -69,45 +69,6 @@ public void AnsiOutput_Suspend_DoesNotThrow_WhenNoTerminalAvailable () Assert.Null (exception); } - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void AnsiOutput_EnableKittyKeyboard_DoesNotWriteEnableSequence_WhenNoTerminalAvailable () - { - using AnsiOutput output = new (); - - output.EnableKittyKeyboard (EscSeqUtils.KittyKeyboardRequestedFlags); - - Assert.Equal (KittyKeyboardFlags.None, output.KittyKeyboardEnabledFlags); - - Assert.DoesNotContain (EscSeqUtils.CSI_EnableKittyKeyboardFlags (EscSeqUtils.KittyKeyboardRequestedFlags), - output.GetLastOutput (), - StringComparison.Ordinal); - } - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void AnsiOutput_Dispose_DoesNotWriteDisableSequence_WhenNoTerminalAvailable () - { - AnsiOutput output = new (); - output.EnableKittyKeyboard (EscSeqUtils.KittyKeyboardRequestedFlags); - - output.Dispose (); - - Assert.Equal (KittyKeyboardFlags.None, output.KittyKeyboardEnabledFlags); - Assert.DoesNotContain (EscSeqUtils.CSI_DisableKittyKeyboardFlags, output.GetLastOutput (), StringComparison.Ordinal); - } - - [Fact] - [Trait ("Category", "LowLevelDriver")] - public void AnsiOutput_Dispose_DoesNotWriteDisableSequence_WhenKittyKeyboardWasNotEnabled () - { - using AnsiOutput output = new (); - - output.DisableKittyKeyboard (); - - Assert.DoesNotContain (EscSeqUtils.CSI_DisableKittyKeyboardFlags, output.GetLastOutput (), StringComparison.Ordinal); - } - [Fact] [Trait ("Category", "LowLevelDriver")] public void AnsiComponentFactory_CreateInput_DoesNotThrow () diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs index fb240123dc..20cda01bbf 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiDriver/AnsiSizeMonitorTests.cs @@ -171,38 +171,4 @@ public void SizeChange_PropagatesThrough_MonitorEvent () Assert.Single (sizes); Assert.Equal (new Size (120, 40), sizes [0]); } - -#if DEBUG - - /// - /// With a and enabled, - /// a size change must emit trace entries covering at minimum the response handling - /// and the size-change notification. - /// - [Fact] - public void SizeChange_EmitsLifecycleTraces () - { - AnsiOutput output = new (); - output.SetSize (80, 25); - - AnsiEscapeSequenceRequest? captured = null; - AnsiSizeMonitor monitor = new (output, req => captured = req); - - ListBackend backend = new (); - - using (Trace.PushScope (TraceCategory.Lifecycle, backend)) - { - monitor.Poll (); - captured!.ResponseReceived! ("[8;30;100t"); - } - - Assert.NotEmpty (backend.Entries); - - // At minimum: SendSizeQuery + HandleSizeResponse + SizeChanged. - Assert.Contains (backend.Entries, e => e.Phase == "SendSizeQuery"); - Assert.Contains (backend.Entries, e => e.Phase == "HandleSizeResponse"); - Assert.Contains (backend.Entries, e => e.Phase == "SizeChanged"); - } - -#endif } diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs index bd3a2c1b76..23328efdfc 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardPipelineTests.cs @@ -191,6 +191,55 @@ public void Pipeline_F12_CtrlShift_Press () #endregion + #region Mixed Kitty + Legacy Duplicate Input + + // Copilot + [Fact] + public void Pipeline_MixedKittyAndLegacyPrintable_DoesNotRaiseDuplicateKeyDown () + { + // Reproduces terminals that emit kitty CSI-u and a legacy printable char for the same keypress. + // Expected behavior: a single logical key event should be raised. + (List down, List up) = InjectRawSequence ("\x1b[97u", "a"); + + Assert.Single (down); + Assert.Equal (Key.A, down [0]); + Assert.Empty (up); + } + + // Copilot + [Theory] + [InlineData ("«", 171)] + [InlineData ("»", 187)] + [InlineData ("ç", 231)] + [InlineData ("Ç", 199)] + [InlineData ("º", 186)] + [InlineData ("ª", 170)] + public void Pipeline_MixedKittyAndLegacyPrintable_PortugueseKeys_DoesNotRaiseDuplicateKeyDown (string printable, int kittyCode) + { + // Issue #4918 (PT keyboard): kitty CSI-u plus legacy printable input for the same key should produce one KeyDown. + var kittySequence = $"\x1b[{kittyCode}u"; + (List down, List up) = InjectRawSequence (kittySequence, printable); + + Assert.Single (down); + Assert.Equal (printable, down [0].GetPrintableText ()); + Assert.Empty (up); + } + + // Copilot + [Fact] + public void Pipeline_MixedKittyAssociatedTextAndLegacyPrintable_DoesNotRaiseDuplicateKeyDown () + { + // Reproduces terminals that emit a kitty key event with associated text plus a legacy char. + // ESC[49;2;33u = shifted '1' producing associated text '!' + (List down, List up) = InjectRawSequence ("\x1b[49;2;33u", "!"); + + Assert.Single (down); + Assert.Equal ("!", down [0].GetPrintableText ()); + Assert.Empty (up); + } + + #endregion + #region Standalone Modifier Key Events // Copilot - Opus 4.6 diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs index 2d0bcc7ac4..1757bfa8d7 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardProtocolDetectorTests.cs @@ -4,6 +4,31 @@ namespace DriverTests.AnsiHandling; public class KittyKeyboardProtocolDetectorTests { + // Copilot + [Fact] + public void Enable_QueuesDetect_AndUpdatesDriverFlags_FromDetectionResponse () + { + Mock driverMock = new (MockBehavior.Strict); + using AnsiOutput output = new (); + KittyKeyboardCapabilities existingCapabilities = new () { IsSupported = true, Flags = KittyKeyboardFlags.None }; + driverMock.Setup (d => d.IsLegacyConsole).Returns (false); + driverMock.Setup (d => d.GetOutput ()).Returns (output); + driverMock.Setup (d => d.KittyKeyboardCapabilities).Returns (existingCapabilities); + + driverMock.Setup (d => d.QueueAnsiRequest (It.IsAny ())) + .Callback (request => request.ResponseReceived ("\u001B[?31u")); + + KittyKeyboardProtocolDetector detector = new (driverMock.Object); + + detector.Enable (EscSeqUtils.KittyKeyboardRequestedFlags); + + Assert.NotNull (existingCapabilities); + Assert.True (existingCapabilities.IsSupported); + Assert.Equal (EscSeqUtils.KittyKeyboardRequestedFlags, existingCapabilities.Flags); + driverMock.Verify (d => d.GetOutput (), Times.Once); + driverMock.Verify (d => d.QueueAnsiRequest (It.IsAny ()), Times.Once); + } + [Fact] public void Detect_QueuesKittyQuery_AndReturnsSupportedResult_WhenTerminalResponds () { @@ -21,14 +46,13 @@ public void Detect_QueuesKittyQuery_AndReturnsSupportedResult_WhenTerminalRespon KittyKeyboardProtocolDetector detector = new (driverMock.Object); - KittyKeyboardProtocolResult? result = null; + KittyKeyboardCapabilities? result = null; detector.Detect (r => result = r); Assert.NotNull (result); Assert.True (result.IsSupported); - Assert.Equal (31, (int)result.SupportedFlags); - Assert.Equal (EscSeqUtils.KittyKeyboardRequestedFlags, result.EnabledFlags); + Assert.Equal (EscSeqUtils.KittyKeyboardRequestedFlags, result.Flags); driverMock.Verify (d => d.QueueAnsiRequest (It.IsAny ()), Times.Once); } @@ -43,14 +67,13 @@ public void Detect_ReturnsUnsupportedResult_WhenTerminalDoesNotRespond () KittyKeyboardProtocolDetector detector = new (driverMock.Object); - KittyKeyboardProtocolResult? result = null; + KittyKeyboardCapabilities? result = null; detector.Detect (r => result = r); Assert.NotNull (result); Assert.False (result.IsSupported); - Assert.Equal (0, (int)result.SupportedFlags); - Assert.Equal (0, (int)result.EnabledFlags); + Assert.Equal (0, (int)result.Flags); } [Fact] @@ -61,7 +84,7 @@ public void Detect_SkipsLegacyConsole () KittyKeyboardProtocolDetector detector = new (driverMock.Object); - KittyKeyboardProtocolResult? result = null; + KittyKeyboardCapabilities? result = null; detector.Detect (r => result = r); @@ -78,9 +101,9 @@ public void Detect_SkipsLegacyConsole () [InlineData ("", false, 0)] public void ParseResponse_ReturnsExpectedResult (string response, bool isSupported, int supportedFlags) { - KittyKeyboardProtocolResult result = KittyKeyboardProtocolDetector.ParseResponse (response); + KittyKeyboardCapabilities result = KittyKeyboardProtocolDetector.ParseResponse (response); Assert.Equal (isSupported, result.IsSupported); - Assert.Equal ((KittyKeyboardFlags)supportedFlags, result.SupportedFlags); + Assert.Equal ((KittyKeyboardFlags)supportedFlags, result.Flags); } } diff --git a/docfx/docs/application.md b/docfx/docs/application.md index c40cee0bda..c1210666c6 100644 --- a/docfx/docs/application.md +++ b/docfx/docs/application.md @@ -112,7 +112,7 @@ top.Dispose (); Application.Shutdown (); // Obsolete - use Dispose() instead ``` -**Note:** The static class delegates to a singleton instance accessible via `Application.Instance`. [Application.Create()](xref:Terminal.Gui.App.Application.Create*) creates a **new** application instance, enabling multiple application contexts and better testability. +**Note:** The static class delegates to an internal singleton backend instance. [Application.Create()](xref:Terminal.Gui.App.Application.Create*) creates a **new** application instance, enabling multiple application contexts and better testability. ### View.App Property @@ -212,6 +212,8 @@ if (result is { }) **"Whoever creates it, owns it":** +During disposal, `MainLoopCoordinator.Stop()` performs terminal-side cleanup (including kitty keyboard disable when it was enabled) before disposing output resources. + | Method | Creator | Owner | Disposal | |--------|---------|-------|----------| | [Run()](xref:Terminal.Gui.App.IApplication.Run*) | Framework | Framework | Automatic when [Run()](xref:Terminal.Gui.App.IApplication.Run*) returns | diff --git a/docfx/docs/drivers.md b/docfx/docs/drivers.md index 00b39f242e..62c3d441ef 100644 --- a/docfx/docs/drivers.md +++ b/docfx/docs/drivers.md @@ -255,11 +255,11 @@ Manages the screen buffer and drawing operations: - Handles clipping regions - Tracks dirty regions for efficient rendering -#### IWindowSizeMonitor +#### ISizeMonitor Detects terminal size changes and raises `SizeChanged` events when the terminal is resized. -#### DriverFacade<T> -A unified facade that implements `IDriver` and coordinates all the components. This is what gets assigned to 's `Driver`. +#### DriverImpl +The concrete implementation that implements `IDriver` and coordinates all the components. This is what gets assigned to 's `Driver`. ### Threading Model @@ -288,9 +288,11 @@ The driver architecture employs a **multi-threaded design** for optimal responsi - **Main UI Thread**: Runs `ApplicationMainLoop.Iteration()` which: 1. Processes input from the queue via `IInputProcessor` - 2. Executes timeout callbacks - 3. Checks for UI changes (layout/drawing) - 4. Renders updates via `IOutput` + 2. Pumps queued ANSI requests via `AnsiRequestScheduler.RunSchedule(...)` + 3. Executes size polling / monitor checks + 4. Checks for UI changes (layout/drawing) + 5. Renders updates via `IOutput` + 6. Executes timeout callbacks This separation ensures that input is never lost and the UI remains responsive during intensive operations. @@ -300,10 +302,10 @@ When 's `Init()` is called: 1. **IApplication.Init()** is invoked 2. Creates a `MainLoopCoordinator` with the appropriate `ComponentFactory` -3. **MainLoopCoordinator.StartAsync()** begins: +3. **MainLoopCoordinator.StartInputTaskAsync()** begins: - Starts the input thread which creates `IInput` - Initializes the main UI loop which creates `IOutput` - - Creates `DriverFacade` and assigns to 's `Driver` + - Creates `DriverImpl` and assigns to 's `Driver` - Waits for both threads to be ready 4. Returns control to the application @@ -312,10 +314,11 @@ When 's `Init()` is called: When 's `Shutdown()` is called: 1. Cancellation token is triggered -2. Input thread exits its read loop -3. `IOutput` is disposed -4. Main thread waits for input thread to complete -5. All resources are cleaned up +2. If kitty keyboard mode was enabled, `KittyKeyboardProtocolDetector.Disable()` is sent to restore terminal keyboard mode +3. Input thread exits its read loop +4. `IOutput` is disposed +5. Main thread waits for input thread to complete +6. All resources are cleaned up ## Component Interfaces diff --git a/docfx/docs/keyboard.md b/docfx/docs/keyboard.md index 0a3acd7755..92c04f4142 100644 --- a/docfx/docs/keyboard.md +++ b/docfx/docs/keyboard.md @@ -142,7 +142,16 @@ The protocol defines progressive enhancement flags, represented by the 1 u` form via `EscSeqUtils.CSI_EnableKittyKeyboardFlags`). +3. Query again (`CSI ? u`) to confirm which flags the terminal actually enabled. +4. Store the confirmed flags in `IDriver.KittyKeyboardCapabilities.Flags`. + +On application shutdown, kitty mode is disabled by the main loop coordinator before output disposal. ### Alternate Key Reporting (Flag 4) diff --git a/plans/fix-border-subview-linecanvas-clipping.md b/plans/fix-border-subview-linecanvas-clipping.md deleted file mode 100644 index dc73ab5f24..0000000000 --- a/plans/fix-border-subview-linecanvas-clipping.md +++ /dev/null @@ -1,239 +0,0 @@ -# Fix: Border SubView LineCanvas Lines Not Clipped at Parent Bounds - -## Bug Summary - -When a SubView of a Border has `SuperViewRendersLineCanvas = true` and its own border -(`BorderStyle != None`), and the SubView's frame extends past the parent Border's bounds, -the SubView's border lines bleed into the parent's border columns. For example, a `║` -becomes `╫` because the SubView's `─` merges unclipped into the parent's LineCanvas. - -**Failing test:** `AdornmentSubViewLineCanvasTests.BorderSubView_WithBorder_ClippedWhenExceedingParentBounds` - -## Root Cause - -The merge at `View.Drawing.Adornments.cs:50` is unclipped: - -```csharp -// Line 43: clip set to border's frame (only affects raster drawing via Driver.Clip) -Region? saved = borderView.AddFrameToClip (); -// Line 44: subviews are drawn (their LineCanvas lines are generated) -borderView.DoDrawSubViews (); - -// Line 50: ALL lines from borderView's LineCanvas are merged — NO BOUNDS CHECK -LineCanvas.Merge (borderView.LineCanvas); -``` - -`LineCanvas.Merge()` (LineCanvas.cs:510-524) copies every `StraightLine` unconditionally. -`Driver.Clip` (set by `AddFrameToClip`) only restricts raster output (`AddStr`, `Move`), -not LineCanvas data. The merged lines participate in intersection resolution and produce -corrupted junction glyphs where they cross the parent's border lines. - -The class docs (LineCanvas.cs:44-48) describe a `Merge(LineCanvas, Region?)` overload for -clipped merging, but **this overload does not exist**. - -## Draw Pipeline Context - -``` -View.Draw(): - 1. DoDrawAdornments() — Parent's border adds lines to this.LineCanvas - 2. AddViewportToClip() — Clip to viewport (raster only) - 3. DoDrawSubViews() — Content subviews drawn - 4. SetClip → AddFrameToClip() — Clip to frame (raster only) - 5. DoDrawAdornmentsSubViews() — Border subview lines merged into this.LineCanvas ← BUG - 6. DoRenderLineCanvas() — Resolves all lines and renders to screen -``` - -The merge in step 5 must restrict lines to the border's content area before they enter -the parent's LineCanvas in step 6. - -## Fix Options - -### Option A: Clipped `Merge` overload on LineCanvas - -Implement the documented but missing `Merge(LineCanvas, Rectangle clipBounds)` overload. -It would trim or discard each incoming `StraightLine` to fit within `clipBounds` before -adding it. - -**Where to change:** -- `LineCanvas.cs` — Add `Merge(LineCanvas, Rectangle)` that clips each line using - `StraightLineExtensions`-style logic (trim Start/Length to stay within bounds). -- `View.Drawing.Adornments.cs:50` — Pass the border view's frame rect: - ```csharp - Rectangle borderBounds = borderView.FrameToScreen (); - LineCanvas.Merge (borderView.LineCanvas, borderBounds); - ``` -- Same pattern for Padding merge at line 84. - -**Pros:** -- Clean, self-contained — clipping logic lives in LineCanvas where it belongs. -- The documentation already describes this overload; just implement it. -- Lines are trimmed *before* intersection resolution, so no corrupted junctions. -- `StraightLineExtensions.Exclude` already has line-splitting logic that can be reused - to clip lines against a rectangle boundary. - -**Cons:** -- Trimming lines can produce different junction types at the clip boundary (the docs - warn about this). A line that was `PassOverHorizontal` may become `StartRight` after - clipping, which could change the resolved glyph. This is acceptable — the clipped - edge is at the parent's border, which already has its own lines providing the correct - junction context. -- Must handle both horizontal and vertical lines, and both positive/negative lengths. - -**Complexity:** Medium. The line-trimming math is straightforward (clamp start/end to -bounds, recompute length). `StraightLineExtensions` already demonstrates the pattern. - ---- - -### Option B: Exclude-based approach — add exclusion region to parent LineCanvas - -Instead of clipping lines before merge, merge everything, then exclude the out-of-bounds -cells from the parent's LineCanvas output. - -**Where to change:** -- `View.Drawing.Adornments.cs:50` — After merge, compute the region outside the border - view's frame and call `LineCanvas.Exclude()` on those areas. - -**Pros:** -- Simpler implementation — no line-splitting math. -- Uses existing `Exclude` API. - -**Cons:** -- **Does not fix the bug.** `Exclude` hides cells from `GetCellMap` output but lines - still participate in intersection resolution. The out-of-bounds `─` still crosses the - parent's `║` during resolution, producing `╫` — even though the `╫` cell at the - parent's border column would be excluded, the parent's own `║` line at that position - would ALSO be excluded because exclusion is position-based, not line-based. -- Would need careful region math to only exclude the *SubView's* cells outside bounds - without excluding the parent's own border cells at those positions. -- Fragile and semantically wrong — the problem is that lines exist where they shouldn't, - not that their output needs hiding. - -**Verdict: Not viable** without significant additional work to make exclusion line-aware. - ---- - -### Option C: Clip in `DoDrawSubViews` — restrict the SubView's own LineCanvas generation - -Prevent the SubView from generating LineCanvas lines outside the border's frame in the -first place, by clipping the SubView's layout/frame before it draws. - -**Where to change:** -- `View.Drawing.Adornments.cs:44` or the SubView's own `Draw()` — Constrain the - SubView's effective frame to the intersection of its frame and the border view's frame - before drawing. - -**Pros:** -- Fixes the problem at the source — lines are never generated outside bounds. -- No post-hoc filtering or trimming needed. - -**Cons:** -- Changing the SubView's frame/layout is invasive and could have side effects on hit - testing, mouse events, and other layout-dependent behavior. -- The SubView's `BorderView.OnDrawingContent` adds lines based on the SubView's - `FrameToScreen()`. Changing the frame changes the border geometry, not just clips it. -- Would need to be undone after drawing, adding complexity. -- Conceptually wrong — layout shouldn't change during draw. - -**Verdict: Too invasive.** Mixing layout mutation with draw is a design smell. - ---- - -### Option D: Filter during `RenderLineCanvas` — clip at output time - -Instead of clipping during merge, filter the resolved `cellMap` in `RenderLineCanvas` -to only include cells within the view's frame. - -**Where to change:** -- `View.Drawing.LineCanvas.cs:48-60` — Skip cells outside `FrameToScreen()`. - -**Pros:** -- Simple one-line check in the render loop. -- No changes to LineCanvas data structure. - -**Cons:** -- **Does not fix junction corruption.** The out-of-bounds lines still participate in - intersection resolution. Even if the corrupted `╫` cell is not rendered, the parent's - `║` line at that position may resolve differently because of the intersecting `─`. - The resolved glyph at the parent's border column would be wrong even if we skip - rendering out-of-bounds cells. -- Only addresses the symptom (rendering) not the cause (unclipped lines in the canvas). - -**Verdict: Insufficient.** Junction corruption happens during resolution, not rendering. - -## Recommendation - -**Option A** is the correct fix. It addresses the root cause (unclipped lines entering -the parent's LineCanvas), uses the existing documented API contract, and produces correct -junction glyphs because the parent's own border lines are the only lines at the boundary -during intersection resolution. - -### Implementation sketch - -```csharp -// LineCanvas.cs — new overload -public void Merge (LineCanvas lineCanvas, Rectangle clipBounds) -{ - foreach (StraightLine line in lineCanvas._lines) - { - // Clip the line to clipBounds; may produce 0 or 1 clipped line - StraightLine? clipped = ClipLine (line, clipBounds); - - if (clipped is { }) - { - AddLine (clipped); - } - } - - // Exclusion regions are position-based — intersect with clipBounds - if (lineCanvas._exclusionRegion is { }) - { - Region clippedExclusion = lineCanvas._exclusionRegion.Clone (); - clippedExclusion.Intersect (clipBounds); - _exclusionRegion ??= new Region (); - _exclusionRegion.Union (clippedExclusion); - } -} - -private static StraightLine? ClipLine (StraightLine line, Rectangle bounds) -{ - Rectangle lineBounds = line.Bounds; - Rectangle clipped = Rectangle.Intersect (lineBounds, bounds); - - if (clipped.IsEmpty) - { - return null; - } - - // Recompute Start and Length from the clipped rectangle - Point newStart = line.Orientation == Orientation.Horizontal - ? new Point (clipped.X, clipped.Y) - : new Point (clipped.X, clipped.Y); - - int newLength = line.Orientation == Orientation.Horizontal - ? clipped.Width - : clipped.Height; - - // Preserve direction (sign of Length) - if (line.Length < 0) - { - newLength = -newLength; - // Adjust start for negative-direction lines - // ... (handle negative length start offset) - } - - return new StraightLine (newStart, newLength, line.Orientation, line.Style, line.Attribute); -} -``` - -Call site in `View.Drawing.Adornments.cs`: - -```csharp -if (borderView.LineCanvas.Bounds != Rectangle.Empty) -{ - Rectangle clipBounds = borderView.FrameToScreen (); - LineCanvas.Merge (borderView.LineCanvas, clipBounds); - borderView.LineCanvas.Clear (); -} -``` - -Same for the Padding merge at line 82-86. diff --git a/plans/refactor-border-tab-to-borderview.md b/plans/refactor-border-tab-to-borderview.md deleted file mode 100644 index c3b84e3250..0000000000 --- a/plans/refactor-border-tab-to-borderview.md +++ /dev/null @@ -1,359 +0,0 @@ -# Plan: Move Tab-Related Functionality from Border to BorderView - -## Problem - -`Border` is instantiated on **every** `View` — it should be as lightweight as possible. Currently it carries tab-related members (`TabSide`, `TabOffset`, `TabLength`, `TabEnd`, `EffectiveTabLength`, `SettingsChanged`) that are only meaningful when `BorderSettings.Tab` is active. This functionality belongs on `BorderView`, which is lazily created only when needed. - -## Goals - -1. **Minimize Border's footprint** — Border stores only `Thickness`, `LineStyle`, and `Settings`. -2. **Move tab configuration to BorderView** — `TabSide`, `TabOffset`, `TabLength`, `EffectiveTabLength` become properties on `BorderView`. `TabEnd` is deleted (zero consumers). -3. **Update all consumers** — backwards compatibility is not a concern. All call sites change from `view.Border.TabSide` to `((BorderView)view.Border.View!).TabSide` (or use a helper/local). -4. **No behavioral changes** — rendering, tests, and UICatalog scenarios produce identical output. - -## Approach - -**Option B: Move completely, remove from Border.** All tab properties are deleted from `Border` and added to `BorderView`. Every consumer is updated. This gives the cleanest separation. - ---- - -## Current State Inventory - -### Members on `Border` today - -| Member | Kind | Tab-Only? | Consumers | -|--------|------|-----------|-----------| -| `Thickness` | inherited | No | Everywhere — **stays** | -| `LineStyle` | property | No | Everywhere — **stays** | -| `Settings` | property | No (but triggers tab setup) | Everywhere — **stays** | -| `SettingsChanged` | event | Yes (only subscriber: BorderView) | 1 internal — **remove** | -| `TabSide` | property | **Yes** | ~48 locations — **move** | -| `TabOffset` | property | **Yes** | ~80 locations — **move** | -| `TabLength` | property | **Yes** | ~13 locations — **move** | -| `TabEnd` | computed property | **Yes** | 0 consumers — **delete** | -| `EffectiveTabLength` | internal property | **Yes** | ~11 locations — **move** | - -### Members on `BorderView` today - -BorderView already has all the tab **rendering** logic. It reads tab configuration from `Border` via its `Adornment` reference. After this refactor, it owns the configuration too. - ---- - -## Execution Order - -Work in three phases, building green after each. - -### Phase 1: `EffectiveTabLength` and `TabEnd` - -Low-risk warm-up. `EffectiveTabLength` is `internal` and `TabEnd` is dead code. - -### Phase 2: `TabSide`, `TabOffset`, `TabLength` - -The bulk of the work — these are public properties with many consumers. - -### Phase 3: `SettingsChanged` event - -Cleanup — replace the event with a direct call. - ---- - -## Phase 1: Move `EffectiveTabLength`, Delete `TabEnd` - -### Step 1.1: Add `EffectiveTabLength` to `BorderView` - -Add to `BorderView.cs` (tab support region): - -```csharp -internal int EffectiveTabLength -{ - get - { - if (TabLength is { } explicitLength) - { - return explicitLength; - } - - if (TitleView is not (ITitleView itv and View tv)) - { - return 0; - } - - if (itv.MeasuredTabLength > 0) - { - return itv.MeasuredTabLength; - } - - // TitleView hasn't been laid out yet — set text and orientation, then measure. - tv.Text = Adornment?.Parent?.Title ?? string.Empty; - itv.Orientation = TabSide is Side.Left or Side.Right ? Orientation.Vertical : Orientation.Horizontal; - - int measured = TabSide is Side.Top or Side.Bottom ? tv.GetAutoWidth () : tv.GetAutoHeight (); - itv.MeasuredTabLength = measured; - - return measured; - } -} -``` - -Note: This initially reads `TabSide` and `TabLength` from `Border` (via `Adornment`). After Phase 2, these become local properties and the reads simplify. - -### Step 1.2: Delete `EffectiveTabLength` from `Border` - -Remove the full `EffectiveTabLength` property from `Border.cs`. - -### Step 1.3: Update consumers of `EffectiveTabLength` - -All consumers currently access `border.EffectiveTabLength` or `tab.Border.EffectiveTabLength`. - -**Library code (`Tabs.cs`)** — 4 reads. Pattern: `tab.Border.EffectiveTabLength`. Change to: - -```csharp -((BorderView)tab.Border.View!).EffectiveTabLength -``` - -Or introduce a local helper in `Tabs.cs`: - -```csharp -private static BorderView GetBorderView (View tab) => (BorderView)tab.Border.View!; -``` - -Then: `GetBorderView (tab).EffectiveTabLength` - -**Library code (`BorderView.cs`)** — 1 read in `DrawTabBorder`. Change `border.EffectiveTabLength` → `EffectiveTabLength` (now local). - -**Tests** — ~5 assertions. Change `view.Border.EffectiveTabLength` → cast and access. - -### Step 1.4: Delete `TabEnd` from `Border` - -Remove the `TabEnd` computed property entirely. It has **zero consumers**. - -### Step 1.5: Build and test - -```bash -dotnet build --no-restore -dotnet test --project Tests/UnitTestsParallelizable --no-build -``` - ---- - -## Phase 2: Move `TabSide`, `TabOffset`, `TabLength` - -### Step 2.1: Add properties to `BorderView` - -Add to `BorderView.cs` (tab support region): - -```csharp -public Side TabSide -{ - get; - set - { - if (field == value) - { - return; - } - - field = value; - Adornment?.Parent?.SetNeedsLayout (); - } -} = Side.Top; - -public int TabOffset -{ - get; - set - { - if (field == value) - { - return; - } - - field = value; - Adornment?.Parent?.SetNeedsLayout (); - } -} - -public int? TabLength -{ - get; - set - { - if (field == value) - { - return; - } - - field = value; - Adornment?.Parent?.SetNeedsLayout (); - } -} -``` - -### Step 2.2: Delete properties from `Border` - -Remove `TabSide`, `TabOffset`, `TabLength` (including backing fields, setters, and XML docs) from `Border.cs`. - -### Step 2.3: Update `BorderView` internal reads - -All reads in `BorderView.cs` that go through `border.TabSide`, `border.TabOffset`, `border.TabLength` change to `TabSide`, `TabOffset`, `TabLength` (now `this`). Affected methods: - -| Method | Properties read | -|--------|----------------| -| `ConfigureForTabMode()` | `border.TabSide` → `TabSide` | -| `UpdateTitleViewLayout()` | `border.TabSide`, `border.TabOffset`, `border.TabLength` → local | -| `GetTabBorderBounds()` | `border.TabSide` → `TabSide` | -| `DrawTabBorder()` | `border.TabSide`, `border.TabOffset`, `border.EffectiveTabLength` → local | -| `GetTabDepth()` | Uses `Adornment.Thickness` only — **no change** | -| `IsFocusedOrLastTab()` | No tab config reads — **no change** | - -Also update `EffectiveTabLength` getter (from Phase 1) to read `TabSide`/`TabLength` from `this` instead of from Border. - -### Step 2.4: Update `Tabs.cs` - -This is the primary external consumer. All patterns are `view.Border.TabSide`, `tab.Border.TabOffset`, etc. - -Add a static helper (or extension) to reduce cast noise: - -```csharp -// In Tabs.cs (private helper) -private static BorderView GetBorderView (View tab) => (BorderView)tab.Border.View!; -``` - -Then update all call sites: - -| Old | New | -|-----|-----| -| `view.Border.TabSide = _tabSide` | `GetBorderView (view).TabSide = _tabSide` | -| `tab.Border.TabOffset = offset` | `GetBorderView (tab).TabOffset = offset` | -| `tab.Border.TabLength = null` | `GetBorderView (tab).TabLength = null` | -| `tab.Border.EffectiveTabLength` | `GetBorderView (tab).EffectiveTabLength` | - -Approximate count: ~15 sites in `Tabs.cs`. - -### Step 2.5: Update `BorderEditor.cs` - -`BorderEditor.cs` in `Examples/UICatalog/Scenarios/EditorsAndHelpers/` casts to `Border` and reads/writes `TabSide`, `TabOffset`. Change to cast to `BorderView` via `AdornmentToEdit.View`: - -```csharp -// Old: -((Border)AdornmentToEdit).TabSide -// New: -((BorderView)AdornmentToEdit.View!).TabSide -``` - -Approximate count: ~5 sites. - -### Step 2.6: Update `Adornments.cs` scenario - -`Examples/UICatalog/Scenarios/Adornments.cs` reads `window.Border.TabSide`, `window.Border.TabOffset`, `window.Border.TabLength`. Change to access via `BorderView`: - -```csharp -BorderView bv = (BorderView)window.Border.View!; -bv.TabSide ... -bv.TabOffset ... -``` - -Approximate count: ~5 sites. - -### Step 2.7: Update `UICatalogRunnable.cs` - -Has 2 commented-out references to `Border.TabSide`. Update or remove the comments. - -### Step 2.8: Update tests - -Tests that access `view.Border.TabSide`, `view.Border.TabOffset`, `view.Border.TabLength` need updating: - -| Test file | Approx sites | -|-----------|-------------| -| `TabsTests.cs` | ~10 | -| `TabsScrollingTests.cs` | ~55 | -| `BorderViewTests.cs` | ~30 | -| `TitleViewTests.cs` | ~5 | -| `TabCompositionTests.cs` | ~3 | -| `AdornmentSubViewLineCanvasTests.cs` | ~2 | - -Pattern: add a local helper or inline cast. For test files with many accesses, a helper at the top of the class: - -```csharp -private static BorderView Bv (View v) => (BorderView)v.Border.View!; -``` - -### Step 2.9: Update docs - -- `docfx/docs/borders.md` — update "Key Properties" table and code examples to use `BorderView` access pattern. -- `Border.cs` XML docs — remove tab-related examples and references. -- `BorderView.cs` XML docs — add docs for the new properties. -- `BorderSettings.cs` — update `Tab` doc to reference `BorderView.TabSide` etc. instead of `Border.TabSide`. - -### Step 2.10: Build and test - -```bash -dotnet build --no-restore -dotnet test --project Tests/UnitTestsParallelizable --no-build -dotnet test --project Tests/UnitTests --no-build -``` - ---- - -## Phase 3: Remove `SettingsChanged` Event - -### Step 3.1: Replace event with direct call - -In `Border.Settings` setter, replace: - -```csharp -SettingsChanged?.Invoke (this, EventArgs.Empty); -``` - -with: - -```csharp -(View as BorderView)?.ConfigureForTabMode (); -``` - -Make `ConfigureForTabMode` `internal` (currently `private`). - -### Step 3.2: Remove event + subscription - -- Delete `public event EventHandler? SettingsChanged;` from `Border.cs`. -- Delete `border.SettingsChanged += OnSettingsChanged;` from `BorderView` constructor. -- Delete the `OnSettingsChanged` bridge method from `BorderView`. - -### Step 3.3: Build and test - -```bash -dotnet build --no-restore -dotnet test --project Tests/UnitTestsParallelizable --no-build -``` - ---- - -## Files Changed - -| File | Phase | Change | -|------|-------|--------| -| `Terminal.Gui/ViewBase/Adornment/Border.cs` | 1,2,3 | Remove `TabSide`, `TabOffset`, `TabLength`, `TabEnd`, `EffectiveTabLength`, `SettingsChanged`; update `Settings` setter | -| `Terminal.Gui/ViewBase/Adornment/BorderView.cs` | 1,2 | Add `TabSide`, `TabOffset`, `TabLength`, `EffectiveTabLength`; update all internal reads to use local props; make `ConfigureForTabMode` internal | -| `Terminal.Gui/ViewBase/Adornment/BorderSettings.cs` | 2 | Update XML doc for `Tab` to reference `BorderView` | -| `Terminal.Gui/Views/Tabs.cs` | 1,2 | Add helper; update ~15 call sites | -| `Examples/UICatalog/Scenarios/EditorsAndHelpers/BorderEditor.cs` | 2 | Update ~5 call sites | -| `Examples/UICatalog/Scenarios/Adornments.cs` | 2 | Update ~5 call sites | -| `Examples/UICatalog/UICatalogRunnable.cs` | 2 | Update 2 commented-out references | -| `Tests/.../TabsTests.cs` | 2 | Update ~10 sites | -| `Tests/.../TabsScrollingTests.cs` | 2 | Update ~55 sites | -| `Tests/.../BorderViewTests.cs` | 1,2 | Update ~30 sites | -| `Tests/.../TitleViewTests.cs` | 2 | Update ~5 sites | -| `Tests/.../TabCompositionTests.cs` | 2 | Update ~3 sites | -| `Tests/.../AdornmentSubViewLineCanvasTests.cs` | 2 | Update ~2 sites | -| `docfx/docs/borders.md` | 2 | Update property table and examples | - ---- - -## Risk Assessment - -| Risk | Mitigation | -|------|------------| -| `Tabs.cs` accesses tab properties before `BorderView` exists | `Tabs.cs` always sets `Border.Settings = Tab \| Title` first, which triggers `GetOrCreateView()` — `BorderView` exists before any tab property access | -| Tests that set `Border.TabOffset` without first enabling `BorderSettings.Tab` | These tests must also set `Border.Settings` to include `Tab` (which creates `BorderView`) before accessing tab properties. Fix in test updates. | -| Forgot a consumer — compile error | Good: compile errors are easy to find and fix. No silent runtime breakage. | -| `ConfigureForTabMode` visibility change | Making it `internal` is safe — it's only called from within the assembly |