From a753d0cbb1d5aa178ed62aa14dd828444f2b56d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 02:34:25 +0100 Subject: [PATCH 01/16] links with tests --- Examples/UICatalog/Scenarios/Links.cs | 102 +++++ Terminal.Gui/Drawing/Cell.cs | 8 +- Terminal.Gui/Drivers/DriverImpl.cs | 3 + Terminal.Gui/Drivers/IDriver.cs | 7 + Terminal.Gui/Drivers/Output/IOutputBuffer.cs | 6 + Terminal.Gui/Drivers/Output/OutputBase.cs | 29 ++ .../Drivers/Output/OutputBufferImpl.cs | 7 + Terminal.Gui/Views/Link.cs | 172 +++++++++ .../Views/LinkTests.cs | 354 ++++++++++++++++++ docfx/apispec/namespace-views.md | 2 +- 10 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 Examples/UICatalog/Scenarios/Links.cs create mode 100644 Terminal.Gui/Views/Link.cs create mode 100644 Tests/UnitTestsParallelizable/Views/LinkTests.cs diff --git a/Examples/UICatalog/Scenarios/Links.cs b/Examples/UICatalog/Scenarios/Links.cs new file mode 100644 index 0000000000..3b92e6c9f4 --- /dev/null +++ b/Examples/UICatalog/Scenarios/Links.cs @@ -0,0 +1,102 @@ +#nullable enable + +namespace UICatalog.Scenarios; + +[ScenarioMetadata ("Links", "Demonstrates how Links work.")] +[ScenarioCategory ("Controls")] +[ScenarioCategory ("Mouse and Keyboard")] +public class Links : Scenario +{ + public override void Main () + { + ConfigurationManager.Enable (ConfigLocations.All); + using IApplication app = Application.Create (); + app.Init (); + + using Window mainWindow = new () + { + Title = GetQuitKeyAndName () + }; + + Label textLabel = new () + { + Text = "_Text:", + X = 1, + Y = 1 + }; + mainWindow.Add (textLabel); + + TextField textField = new () + { + X = Pos.Right (textLabel) + 2, + Y = 1, + Width = 20 + }; + mainWindow.Add (textField); + + Label urlLabel = new () + { + Text = "_Url:", + X = 1, + Y = Pos.Bottom (textField) + 1 + }; + mainWindow.Add (urlLabel); + + TextField urlField = new () + { + X = Pos.Right (urlLabel) + 2, + Y = Pos.Bottom (textField) + 1, + Width = 64 + }; + mainWindow.Add (urlField); + + Label simpleUrlLabel = new () + { + X = 1, + Y = Pos.Bottom (urlField) + 2 + }; + mainWindow.Add (simpleUrlLabel); + + FrameView linkFrame = new () + { + Title = "_Link rendering", + X = 0, + Y = Pos.Bottom (simpleUrlLabel) + 2, + Width = 64, + Height = 8, + AssignHotKeys = true + }; + + Link link = new () + { + X = 1, + Y = 1, + Height = 1, + Width = 64 + }; + + link.UrlChanged += (s, e) => simpleUrlLabel.Text = link.Url; + textField.ValueChanged += (s, e) => link.Text = e.NewValue ?? link.Url; + urlField.ValueChanged += (s, e) => link.Url = e.NewValue ?? Link.DEFAULT_URL; + linkFrame.Add (link); + + textField.Text = "GitHub repo"; + urlField.Text = "https://github.com/gui-cs/Terminal.Gui"; + + Button copyButton = new () + { + Title = "_Copy", + X = Pos.Center (), + Y = Pos.Bottom (link) + 2, + + }; + copyButton.Accepting += (s, e) => link.Copy (); + + linkFrame.Add (copyButton); + + mainWindow.Add (linkFrame); + + app.Run (mainWindow); + } + +} diff --git a/Terminal.Gui/Drawing/Cell.cs b/Terminal.Gui/Drawing/Cell.cs index 6e9aff593a..4ac1a001c0 100644 --- a/Terminal.Gui/Drawing/Cell.cs +++ b/Terminal.Gui/Drawing/Cell.cs @@ -5,7 +5,7 @@ namespace Terminal.Gui.Drawing; /// Represents a single row/column in a Terminal.Gui rendering surface (e.g. and /// ). /// -public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, string Grapheme = "") +public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, string Grapheme = "", string? Url = null) { /// The attributes to use when drawing the Glyph. public Attribute? Attribute { get; set; } = Attribute; @@ -16,6 +16,12 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, st /// public bool IsDirty { get; set; } = IsDirty; + /// + /// Gets or sets the URL associated with this cell for OSC 8 hyperlink support. + /// When set, the cell will be rendered as a clickable hyperlink in terminals that support OSC 8. + /// + public string? Url { get; set; } = Url; + private string _grapheme = Grapheme; /// diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 265aa87cf0..9d1f9a9cf1 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -246,6 +246,9 @@ public virtual void SetScreenSize (int width, int height) /// public Region? Clip { get => _outputBuffer.Clip; set => _outputBuffer.Clip = value; } + /// + public string? CurrentUrl { get => _outputBuffer.CurrentUrl; set => _outputBuffer.CurrentUrl = value; } + /// Clears the of the driver. public void ClearContents () { diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs index de1f7b6191..21aa329640 100644 --- a/Terminal.Gui/Drivers/IDriver.cs +++ b/Terminal.Gui/Drivers/IDriver.cs @@ -174,6 +174,13 @@ public interface IDriver : IDisposable /// Attribute CurrentAttribute { get; set; } + /// + /// Gets or sets the URL that will be associated with cells added via or . + /// When set, subsequent cells will include this URL for OSC 8 hyperlink rendering. + /// Set to to stop associating URLs with cells. + /// + string? CurrentUrl { get; set; } + /// /// Updates and to the specified column and row in /// . diff --git a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs index cf096b8340..e70cc0cf1f 100644 --- a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs +++ b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs @@ -115,6 +115,12 @@ public interface IOutputBuffer /// The number of rows visible in the terminal. int Rows { get; set; } + /// + /// Gets or sets the URL that will be associated with cells added via or . + /// When set, subsequent cells will include this URL for OSC 8 hyperlink rendering. + /// + string? CurrentUrl { get; set; } + /// /// Changes the size of the buffer to the given size /// diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 863557ed48..8fbbef0135 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -49,6 +49,9 @@ public bool IsLegacyConsole // Last text style used, for updating style with EscSeqUtils.CSI_AppendTextStyleChange(). private TextStyle _redrawTextStyle = TextStyle.None; + // Last URL used for tracking hyperlink state + private string? _lastUrl = null; + StringBuilder _lastOutputStringBuilder = new (); /// @@ -75,6 +78,7 @@ public virtual void Write (IOutputBuffer buffer) } outputStringBuilder.Clear (); + _lastUrl = null; // Reset URL state at the start of each row // Process columns in row for (int col = left; col < cols; col++) @@ -138,6 +142,13 @@ public virtual void Write (IOutputBuffer buffer) { SetCursorPositionImpl (lastCol, row); + // Close any open hyperlink before processing URLs + if (_lastUrl is { }) + { + outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + _lastUrl = null; + } + // Wrap URLs with OSC 8 hyperlink sequences StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder); Write (processed); @@ -258,6 +269,24 @@ protected void AppendCellAnsi (Cell cell, StringBuilder output, ref Attribute? l { Attribute? attribute = cell.Attribute; + // Handle URL hyperlink state changes + if (!IsLegacyConsole && cell.Url != _lastUrl) + { + // If we were in a hyperlink, end it + if (_lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + } + + // If starting a new hyperlink, begin it + if (!string.IsNullOrEmpty (cell.Url)) + { + output.Append (EscSeqUtils.OSC_StartHyperlink (cell.Url)); + } + + _lastUrl = cell.Url; + } + // Add ANSI escape sequence for attribute change if (attribute.HasValue && attribute.Value != lastAttr) { diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index 00592f0e94..ee3b69bc7c 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -25,6 +25,12 @@ public class OutputBufferImpl : IOutputBuffer /// public Attribute CurrentAttribute { get; set; } + /// + /// Gets or sets the URL that will be associated with cells added via or . + /// When set, subsequent cells will include this URL for OSC 8 hyperlink rendering. + /// + public string? CurrentUrl { get; set; } + /// The leftmost column in the terminal. public virtual int Left { get; set; } = 0; @@ -209,6 +215,7 @@ private void AddGrapheme (string grapheme) private void SetAttributeAndDirty (int col, int row) { Contents! [row, col].Attribute = CurrentAttribute; + Contents [row, col].Url = CurrentUrl; Contents [row, col].IsDirty = true; } diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs new file mode 100644 index 0000000000..0ba724e36c --- /dev/null +++ b/Terminal.Gui/Views/Link.cs @@ -0,0 +1,172 @@ +namespace Terminal.Gui.Views; + +/// +/// Displays a clickable link with text and url. +/// +public class Link : View, IDesignable +{ + /// + public Link () + { + Height = Dim.Auto (DimAutoStyle.Text); + Width = Dim.Auto (DimAutoStyle.Text); + + CanFocus = true; + + // On HotKey, pass it to the next view + AddCommand (Command.HotKey, InvokeHotKeyOnNextPeer!); + } + + /// + public override string Text + { + get => string.IsNullOrWhiteSpace (Title) ? Url : Title; + set + { + Title = value; + base.Text = string.IsNullOrWhiteSpace (value) ? Url : value; + } + } + + /// + /// Représente l'URL par défaut utilisée lorsque aucune URL spécifique n'est fournie. + /// + /// Cette constante peut être utilisée pour initialiser des navigateurs ou des composants Web à + /// une page vierge ou à un état neutre. + public const string DEFAULT_URL = "about:blank"; + + private string _url = DEFAULT_URL; + + /// + /// Gets or sets the URL associated with this instance. + /// + public string Url + { + get { return _url; } + set + { + // Will throw exception if not a valid URL + _ = new Uri (value); + + _url = value; + OnUrlChanged (); + } + } + + /// + /// Text changed event, raised when the text has changed. + /// + public event EventHandler? UrlChanged; + + /// + /// Called when the has changed. Fires the event. + /// + public void OnUrlChanged () => UrlChanged?.Invoke (this, EventArgs.Empty); + + private bool? InvokeHotKeyOnNextPeer (ICommandContext commandContext) + { + if (RaiseHandlingHotKey (commandContext) == true) + { + return true; + } + + if (CanFocus) + { + SetFocus (); + + // Always return true on hotkey, even if SetFocus fails because + // hotkeys are always handled by the View (unless RaiseHandlingHotKey cancels). + // This is the same behavior as the base (View). + return true; + } + + if (HotKey.IsValid) + { + // If the Link has a hotkey, we need to find the next view in the subview list + int me = SuperView?.SubViews.IndexOf (this) ?? -1; + + if (me != -1 && me < SuperView?.SubViews.Count - 1) + { + return SuperView?.SubViews.ElementAt (me + 1).InvokeCommand (Command.HotKey) == true; + } + } + + return false; + } + + /// + protected override bool OnActivating (CommandEventArgs args) + { + // If Link can't focus and is clicked, invoke HotKey on next peer + if (!CanFocus) + { + return InvokeCommand (Command.HotKey, args.Context) == true; + } + + return base.OnActivating (args); + } + + /// + bool IDesignable.EnableForDesign () + { + Text = "_Link"; + + return true; + } + + /// Copy the URL to the clipboard contents. + public bool Copy () + { + SetClipboard (Url); + + return true; + } + + private void SetClipboard (string text) => App?.Clipboard?.SetClipboardData (text); + + /// + protected override bool OnDrawingText (DrawContext? context) + { + // Set the URL for cells that will be drawn + if (!string.IsNullOrEmpty (Url) && Url != DEFAULT_URL && Driver is { }) + { + Rectangle drawRect = new Rectangle (ContentToScreen (Point.Empty), GetContentSize ()); + + // Use GetDrawRegion to get precise drawn areas + Region textRegion = TextFormatter.GetDrawRegion (drawRect); + + // Report the drawn area to the context + context?.AddDrawnRegion (textRegion); + + Attribute normalAttr = HasFocus ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal); + Attribute hotAttr = HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal); + + // Set the URL in the driver so all cells drawn will have this URL + Driver.CurrentUrl = Url; + + try + { + // Draw the text using TextFormatter - all cells will now have the URL + TextFormatter.Draw ( + Driver, + drawRect, + normalAttr, + hotAttr, + Rectangle.Empty); + } + finally + { + // Clear the URL after drawing + Driver.CurrentUrl = null; + } + + // We assume that the text has been drawn over the entire area; ensure that the SubViews are redrawn. + SetSubViewNeedsDrawDownHierarchy (); + + return true; // We handled the drawing + } + + return base.OnDrawingText (context); + } +} + diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs new file mode 100644 index 0000000000..887e0853b2 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -0,0 +1,354 @@ +#nullable enable +using UnitTests; +using Xunit.Abstractions; + +namespace ViewsTests; + +/// +/// Unit tests for that don't require static Application dependencies. +/// These tests can run in parallel without interference. +/// +public class LinkTests (ITestOutputHelper output) : TestDriverBase +{ + private readonly ITestOutputHelper _output = output; + + [Fact] + public void Constructor_Defaults () + { + var link = new Link (); + + Assert.Equal (Link.DEFAULT_URL, link.Url); + Assert.Equal (Link.DEFAULT_URL, link.Text); + Assert.Equal (Dim.Auto (DimAutoStyle.Text), link.Height); + Assert.Equal (Dim.Auto (DimAutoStyle.Text), link.Width); + Assert.True (link.CanFocus); + } + + [Fact] + public void Text_Set_Updates_Title () + { + var link = new Link { Text = "Click here" }; + + Assert.Equal ("Click here", link.Text); + Assert.Equal ("Click here", link.Title); + } + + [Fact] + public void Text_Returns_Url_When_Title_Is_Empty () + { + var link = new Link { Url = "https://github.com" }; + + Assert.Equal ("https://github.com", link.Text); + } + + [Fact] + public void Url_Set_Validates_Uri () + { + var link = new Link (); + + Assert.Throws (() => link.Url = "not a valid url"); + Assert.Throws (() => link.Url = ""); + } + + [Fact] + public void Url_Set_Fires_UrlChanged_Event () + { + var link = new Link (); + var eventFired = false; + + link.UrlChanged += (s, e) => eventFired = true; + link.Url = "https://github.com"; + + Assert.True (eventFired); + Assert.Equal ("https://github.com", link.Url); + } + + [Fact] + public void Copy_Copies_Url_To_Clipboard () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link = new Link { App = app, Url = "https://github.com" }; + + var copied = link.Copy (); + + Assert.True (copied); + Assert.Equal ("https://github.com", app.Clipboard?.GetClipboardData ()); + } + + [Fact] + public void Link_Renders_With_OSC8_Hyperlink () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link = new Link + { + App = app, + Url = "https://github.com/gui-cs/Terminal.Gui", + Text = "Terminal.Gui" + }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new (30, 3)); + link.Draw (); + + // Get the ANSI output + string ansi = app.Driver!.ToAnsi (); + + // Verify OSC 8 sequences are present + string expectedStart = EscSeqUtils.OSC_StartHyperlink ("https://github.com/gui-cs/Terminal.Gui"); + string expectedEnd = EscSeqUtils.OSC_EndHyperlink (); + + Assert.Contains (expectedStart, ansi); + Assert.Contains (expectedEnd, ansi); + Assert.Contains ("Terminal.Gui", ansi); + + link.Dispose (); + } + + [Fact] + public void Link_Renders_Without_OSC8_When_Url_Is_Default () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link = new Link + { + App = app, + Text = "Not a link" + }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new (30, 3)); + link.Draw (); + + // Get the ANSI output + string ansi = app.Driver!.ToAnsi (); + + // Verify OSC 8 sequences are NOT present for default URL + Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink (Link.DEFAULT_URL), ansi); + Assert.Contains ("Not a link", ansi); + + link.Dispose (); + } + + [Fact] + public void Link_With_HotKey_Passes_To_Next_View () + { + var superView = new View { CanFocus = true }; + var link = new Link { Text = "_Link", CanFocus = false }; + var nextView = new View { CanFocus = true }; + + superView.Add (link, nextView); + superView.BeginInit (); + superView.EndInit (); + + Assert.False (link.HasFocus); + Assert.False (nextView.HasFocus); + + // Invoke hotkey + link.InvokeCommand (Command.HotKey); + + // Next view should get focus since Link can't focus + Assert.True (nextView.HasFocus); + + superView.Dispose (); + } + + [Fact] + public void Link_Multiple_Links_Each_Get_Their_Own_Url () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link1 = new Link + { + App = app, + X = 0, + Y = 0, + Url = "https://github.com", + Text = "GitHub" + }; + + var link2 = new Link + { + App = app, + X = 0, + Y = 1, + Url = "https://microsoft.com", + Text = "Microsoft" + }; + + link1.BeginInit (); + link1.EndInit (); + link1.SetRelativeLayout (new (30, 10)); + link1.Draw (); + + link2.BeginInit (); + link2.EndInit (); + link2.SetRelativeLayout (new (30, 10)); + link2.Draw (); + + // Get the ANSI output + string ansi = app.Driver!.ToAnsi (); + + // Verify both URLs are present + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), ansi); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://microsoft.com"), ansi); + Assert.Contains ("GitHub", ansi); + Assert.Contains ("Microsoft", ansi); + + link1.Dispose (); + link2.Dispose (); + } + + [Fact] + public void Link_Url_Changes_Update_Hyperlink () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link = new Link + { + App = app, + Url = "https://example.com", + Text = "Example" + }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new (30, 3)); + link.Draw (); + + string ansi1 = app.Driver!.ToAnsi (); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://example.com"), ansi1); + + // Clear and change URL + app.Driver.ClearContents (); + link.Url = "https://newurl.com"; + link.SetNeedsDraw (); + link.Draw (); + + string ansi2 = app.Driver.ToAnsi (); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://newurl.com"), ansi2); + + link.Dispose (); + } + + [Fact] + public void Link_With_Focus_Draws_With_Focus_Colors () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link = new Link + { + App = app, + Url = "https://github.com", + Text = "GitHub", + CanFocus = true + }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new (30, 3)); + + // Without focus + link.Draw (); + Assert.False (link.HasFocus); + + // Set focus + app.Driver!.ClearContents (); + link.SetFocus (); + link.Draw (); + Assert.True (link.HasFocus); + + // The link should still have OSC 8 sequences + string ansi = app.Driver.ToAnsi (); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), ansi); + + link.Dispose (); + } + + [Fact] + public void IDesignable_EnableForDesign_Sets_Default_Text () + { + var link = new Link (); + var designable = link as IDesignable; + + var result = designable.EnableForDesign (); + + Assert.True (result); + Assert.Equal ("_Link", link.Text); + + link.Dispose (); + } + + [Fact] + public void Link_LeftButtonReleased_InvokesHotKey_OnNextView () + { + var superView = new View { CanFocus = true, Height = 1, Width = 15 }; + var link = new Link { X = 0, HotKey = Key.L.WithAlt, CanFocus = false }; + var nextView = new View { CanFocus = true, X = 10, Width = 4, Height = 1 }; + + superView.Add (link, nextView); + superView.BeginInit (); + superView.EndInit (); + + Assert.False (link.HasFocus); + Assert.False (nextView.HasFocus); + + // Click on the link + link.NewMouseEvent (new () { Position = new (0, 0), Flags = MouseFlags.LeftButtonReleased }); + + Assert.False (link.HasFocus); + Assert.True (nextView.HasFocus); + + superView.Dispose (); + } + + [Fact] + public void Link_Cell_Url_Is_Set_In_Buffer () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Clipboard = new FakeClipboard (); + + var link = new Link + { + App = app, + X = 0, + Y = 0, + Url = "https://github.com", + Text = "GitHub" + }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new (30, 3)); + link.Draw (); + + // Verify that cells in the buffer have the URL set + Cell [,] contents = app.Driver!.Contents!; + + // The first cell of "GitHub" should have the URL + Assert.Equal ("https://github.com", contents [0, 0].Url); + Assert.Equal ("G", contents [0, 0].Grapheme); + + // All cells in "GitHub" should have the URL + Assert.Equal ("https://github.com", contents [0, 1].Url); + Assert.Equal ("https://github.com", contents [0, 2].Url); + + link.Dispose (); + } +} diff --git a/docfx/apispec/namespace-views.md b/docfx/apispec/namespace-views.md index 333fdc3ff7..c3ffc6364c 100644 --- a/docfx/apispec/namespace-views.md +++ b/docfx/apispec/namespace-views.md @@ -23,7 +23,7 @@ The `Views` namespace contains the complete collection of built-in UI controls d - OptionSeletor, FlagSelector, ColorPicker, DatePicker, Slider, NumericUpDown **Menus & Navigation** -- MenuBar, ContextMenu, StatusBar, Shortcut +- MenuBar, ContextMenu, StatusBar, Shortcut, Link **Dialogs** - Prompt, MessageBox, FileDialog, OpenDialog, SaveDialog, Wizard From fac88539bbadd10c350dd5c6834d142ad6fd82f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 12:46:17 +0100 Subject: [PATCH 02/16] Update Terminal.Gui/Views/Link.cs en doc translation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Views/Link.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index 0ba724e36c..7ad59f85e4 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -29,10 +29,11 @@ public override string Text } /// - /// Représente l'URL par défaut utilisée lorsque aucune URL spécifique n'est fournie. + /// Represents the default URL used when no specific URL is provided. /// - /// Cette constante peut être utilisée pour initialiser des navigateurs ou des composants Web à - /// une page vierge ou à un état neutre. + /// + /// This constant can be used to initialize browsers or web components to a blank page or neutral state. + /// public const string DEFAULT_URL = "about:blank"; private string _url = DEFAULT_URL; From 78888608e444db645dac5885db0b188eae4ce961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 12:46:49 +0100 Subject: [PATCH 03/16] Update Terminal.Gui/Views/Link.cs url doc fix Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Views/Link.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index 7ad59f85e4..cd6cc35286 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -55,7 +55,7 @@ public string Url } /// - /// Text changed event, raised when the text has changed. + /// URL changed event, raised when the URL has changed. /// public event EventHandler? UrlChanged; From 7986d8bb0335d4e021ca7d9ac2821975666886cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 12:49:00 +0100 Subject: [PATCH 04/16] explicit type declarations in tests --- .../Views/LinkTests.cs | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 887e0853b2..a14710def2 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -15,7 +15,7 @@ public class LinkTests (ITestOutputHelper output) : TestDriverBase [Fact] public void Constructor_Defaults () { - var link = new Link (); + Link link = new(); Assert.Equal (Link.DEFAULT_URL, link.Url); Assert.Equal (Link.DEFAULT_URL, link.Text); @@ -27,7 +27,7 @@ public void Constructor_Defaults () [Fact] public void Text_Set_Updates_Title () { - var link = new Link { Text = "Click here" }; + Link link = new () { Text = "Click here" }; Assert.Equal ("Click here", link.Text); Assert.Equal ("Click here", link.Title); @@ -36,7 +36,7 @@ public void Text_Set_Updates_Title () [Fact] public void Text_Returns_Url_When_Title_Is_Empty () { - var link = new Link { Url = "https://github.com" }; + Link link = new () { Url = "https://github.com" }; Assert.Equal ("https://github.com", link.Text); } @@ -44,7 +44,7 @@ public void Text_Returns_Url_When_Title_Is_Empty () [Fact] public void Url_Set_Validates_Uri () { - var link = new Link (); + Link link = new(); Assert.Throws (() => link.Url = "not a valid url"); Assert.Throws (() => link.Url = ""); @@ -53,7 +53,7 @@ public void Url_Set_Validates_Uri () [Fact] public void Url_Set_Fires_UrlChanged_Event () { - var link = new Link (); + Link link = new(); var eventFired = false; link.UrlChanged += (s, e) => eventFired = true; @@ -70,7 +70,7 @@ public void Copy_Copies_Url_To_Clipboard () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link = new Link { App = app, Url = "https://github.com" }; + Link link = new () { App = app, Url = "https://github.com" }; var copied = link.Copy (); @@ -85,7 +85,7 @@ public void Link_Renders_With_OSC8_Hyperlink () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link = new Link + Link link = new () { App = app, Url = "https://github.com/gui-cs/Terminal.Gui", @@ -118,7 +118,7 @@ public void Link_Renders_Without_OSC8_When_Url_Is_Default () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link = new Link + Link link = new () { App = app, Text = "Not a link" @@ -142,9 +142,9 @@ public void Link_Renders_Without_OSC8_When_Url_Is_Default () [Fact] public void Link_With_HotKey_Passes_To_Next_View () { - var superView = new View { CanFocus = true }; - var link = new Link { Text = "_Link", CanFocus = false }; - var nextView = new View { CanFocus = true }; + View superView = new () { CanFocus = true }; + Link link = new () { Text = "_Link", CanFocus = false }; + View nextView = new () { CanFocus = true }; superView.Add (link, nextView); superView.BeginInit (); @@ -169,7 +169,7 @@ public void Link_Multiple_Links_Each_Get_Their_Own_Url () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link1 = new Link + Link link1 = new () { App = app, X = 0, @@ -178,7 +178,7 @@ public void Link_Multiple_Links_Each_Get_Their_Own_Url () Text = "GitHub" }; - var link2 = new Link + Link link2 = new () { App = app, X = 0, @@ -217,7 +217,7 @@ public void Link_Url_Changes_Update_Hyperlink () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link = new Link + Link link = new () { App = app, Url = "https://example.com", @@ -251,7 +251,7 @@ public void Link_With_Focus_Draws_With_Focus_Colors () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link = new Link + Link link = new () { App = app, Url = "https://github.com", @@ -283,8 +283,8 @@ public void Link_With_Focus_Draws_With_Focus_Colors () [Fact] public void IDesignable_EnableForDesign_Sets_Default_Text () { - var link = new Link (); - var designable = link as IDesignable; + Link link = new(); + IDesignable designable = link as IDesignable; var result = designable.EnableForDesign (); @@ -297,9 +297,9 @@ public void IDesignable_EnableForDesign_Sets_Default_Text () [Fact] public void Link_LeftButtonReleased_InvokesHotKey_OnNextView () { - var superView = new View { CanFocus = true, Height = 1, Width = 15 }; - var link = new Link { X = 0, HotKey = Key.L.WithAlt, CanFocus = false }; - var nextView = new View { CanFocus = true, X = 10, Width = 4, Height = 1 }; + View superView = new () { CanFocus = true, Height = 1, Width = 15 }; + Link link = new () { X = 0, HotKey = Key.L.WithAlt, CanFocus = false }; + View nextView = new () { CanFocus = true, X = 10, Width = 4, Height = 1 }; superView.Add (link, nextView); superView.BeginInit (); @@ -324,7 +324,7 @@ public void Link_Cell_Url_Is_Set_In_Buffer () app.Init (DriverRegistry.Names.ANSI); app.Clipboard = new FakeClipboard (); - var link = new Link + Link link = new () { App = app, X = 0, From 9977b43a9d3eba023f4dbb5bab073efe723a7af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 13:06:39 +0100 Subject: [PATCH 05/16] using Cancellable Work Pattern recommandations --- Terminal.Gui/Views/Link.cs | 64 ++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index cd6cc35286..f65ce66dd9 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -43,26 +43,30 @@ public override string Text /// public string Url { - get { return _url; } - set - { - // Will throw exception if not a valid URL - _ = new Uri (value); - - _url = value; - OnUrlChanged (); - } + get => _url; + set => SetUrl (value); } + /// + /// Raised when is about to change. + /// Set to to cancel the change. + /// + public event EventHandler>? UrlChanging; + /// /// URL changed event, raised when the URL has changed. /// - public event EventHandler? UrlChanged; + public event EventHandler>? UrlChanged; /// - /// Called when the has changed. Fires the event. + /// Called before changes. Return to cancel the change. /// - public void OnUrlChanged () => UrlChanged?.Invoke (this, EventArgs.Empty); + protected virtual bool OnUrlChanging (ValueChangingEventArgs args) => false; + + /// + /// Called after has changed. + /// + protected virtual void OnUrlChanged (ValueChangedEventArgs args) { } private bool? InvokeHotKeyOnNextPeer (ICommandContext commandContext) { @@ -131,7 +135,7 @@ protected override bool OnDrawingText (DrawContext? context) // Set the URL for cells that will be drawn if (!string.IsNullOrEmpty (Url) && Url != DEFAULT_URL && Driver is { }) { - Rectangle drawRect = new Rectangle (ContentToScreen (Point.Empty), GetContentSize ()); + Rectangle drawRect = new (ContentToScreen (Point.Empty), GetContentSize ()); // Use GetDrawRegion to get precise drawn areas Region textRegion = TextFormatter.GetDrawRegion (drawRect); @@ -169,5 +173,39 @@ protected override bool OnDrawingText (DrawContext? context) return base.OnDrawingText (context); } + + private void SetUrl(string value) + { + // Will throw exception if not a valid URL + _ = new Uri (value); + + if (_url != value) + { + string oldValue = _url; + + // CWP: Fire ValueChanging (allows cancellation) + ValueChangingEventArgs changingArgs = new (oldValue, value); + + if (OnUrlChanging (changingArgs) || changingArgs.Handled) + { + return; + } + + UrlChanging?.Invoke (this, changingArgs); + + if (changingArgs.Handled) + { + return; + } + + // Do the work + _url = value; + + // CWP: Fire ValueChanged + ValueChangedEventArgs changedArgs = new (oldValue, value); + OnUrlChanged (changedArgs); + UrlChanged?.Invoke (this, changedArgs); + } + } } From 7deb8ae249921fc0c1bd2416b7d54f5c8ce75e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 13:48:57 +0100 Subject: [PATCH 06/16] events testing --- .../Views/LinkTests.cs | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index a14710def2..810e7cf57b 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -1,6 +1,6 @@ #nullable enable +using JetBrains.Annotations; using UnitTests; -using Xunit.Abstractions; namespace ViewsTests; @@ -8,10 +8,9 @@ namespace ViewsTests; /// Unit tests for that don't require static Application dependencies. /// These tests can run in parallel without interference. /// -public class LinkTests (ITestOutputHelper output) : TestDriverBase +[TestSubject (typeof (Link))] +public class LinkTests : TestDriverBase { - private readonly ITestOutputHelper _output = output; - [Fact] public void Constructor_Defaults () { @@ -53,14 +52,49 @@ public void Url_Set_Validates_Uri () [Fact] public void Url_Set_Fires_UrlChanged_Event () { - Link link = new(); - var eventFired = false; + string oldUrl = "http://oldvalue.io"; + string newUrl = "http://newvalue.io"; + + Link link = new() { Url = oldUrl }; + bool eventFired = false; + bool eventArgsValid = false; + + link.UrlChanged += (s, e) => + { + eventFired = true; + eventArgsValid = e.OldValue == oldUrl && e.NewValue == newUrl; + }; + link.Url = newUrl; - link.UrlChanged += (s, e) => eventFired = true; - link.Url = "https://github.com"; + Assert.True (eventFired); + Assert.True (eventArgsValid); + Assert.Equal (newUrl, link.Url); + } + + [Fact] + public void Url_Set_Fires_UrlChanging_Event () + { + string oldUrl = "http://oldvalue.io"; + string newUrl = "http://newvalue.io"; + + Link link = new () { Url = oldUrl }; + bool eventFired = false; + bool eventArgsValid = false; + bool valueChanged = false; + + link.UrlChanging += (s, e) => + { + eventFired = true; + eventArgsValid = e.CurrentValue == oldUrl && e.NewValue == newUrl; + // Should be false since the change hasn't happened yet + valueChanged = e.CurrentValue == newUrl || link.Url == newUrl; + }; + link.Url = newUrl; Assert.True (eventFired); - Assert.Equal ("https://github.com", link.Url); + Assert.True (eventArgsValid); + Assert.False (valueChanged); + Assert.Equal (newUrl, link.Url); } [Fact] From 34224c2639c689bc98f14b16706fa192c47c8384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 21:41:42 +0100 Subject: [PATCH 07/16] uicatalog url indicator + no crash Uri --- Examples/UICatalog/Scenarios/Links.cs | 55 ++++++++++++++++++--------- Terminal.Gui/Views/Link.cs | 6 +-- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Links.cs b/Examples/UICatalog/Scenarios/Links.cs index 3b92e6c9f4..8d503ad15e 100644 --- a/Examples/UICatalog/Scenarios/Links.cs +++ b/Examples/UICatalog/Scenarios/Links.cs @@ -7,15 +7,21 @@ namespace UICatalog.Scenarios; [ScenarioCategory ("Mouse and Keyboard")] public class Links : Scenario { + private IApplication? _app; + private Window? _appWindow; + private Link? _link; + public override void Main () { ConfigurationManager.Enable (ConfigLocations.All); - using IApplication app = Application.Create (); - app.Init (); - using Window mainWindow = new () + _app = Application.Create (); + _app.Init (); + + _appWindow = new () { - Title = GetQuitKeyAndName () + Title = GetName (), + BorderStyle = LineStyle.None }; Label textLabel = new () @@ -24,7 +30,7 @@ public override void Main () X = 1, Y = 1 }; - mainWindow.Add (textLabel); + _appWindow.Add (textLabel); TextField textField = new () { @@ -32,7 +38,7 @@ public override void Main () Y = 1, Width = 20 }; - mainWindow.Add (textField); + _appWindow.Add (textField); Label urlLabel = new () { @@ -40,7 +46,7 @@ public override void Main () X = 1, Y = Pos.Bottom (textField) + 1 }; - mainWindow.Add (urlLabel); + _appWindow.Add (urlLabel); TextField urlField = new () { @@ -48,14 +54,14 @@ public override void Main () Y = Pos.Bottom (textField) + 1, Width = 64 }; - mainWindow.Add (urlField); + _appWindow.Add (urlField); Label simpleUrlLabel = new () { X = 1, Y = Pos.Bottom (urlField) + 2 }; - mainWindow.Add (simpleUrlLabel); + _appWindow.Add (simpleUrlLabel); FrameView linkFrame = new () { @@ -67,7 +73,7 @@ public override void Main () AssignHotKeys = true }; - Link link = new () + _link = new () { X = 1, Y = 1, @@ -75,10 +81,10 @@ public override void Main () Width = 64 }; - link.UrlChanged += (s, e) => simpleUrlLabel.Text = link.Url; - textField.ValueChanged += (s, e) => link.Text = e.NewValue ?? link.Url; - urlField.ValueChanged += (s, e) => link.Url = e.NewValue ?? Link.DEFAULT_URL; - linkFrame.Add (link); + _link.UrlChanged += (s, e) => simpleUrlLabel.Text = _link.Url; + textField.ValueChanged += (s, e) => _link.Text = e.NewValue ?? _link.Url; + urlField.ValueChanged += (s, e) => _link.Url = e.NewValue ?? Link.DEFAULT_URL; + linkFrame.Add (_link); textField.Text = "GitHub repo"; urlField.Text = "https://github.com/gui-cs/Terminal.Gui"; @@ -87,16 +93,29 @@ public override void Main () { Title = "_Copy", X = Pos.Center (), - Y = Pos.Bottom (link) + 2, + Y = Pos.Bottom (_link) + 2, }; - copyButton.Accepting += (s, e) => link.Copy (); + copyButton.Accepting += (s, e) => _link.Copy (); linkFrame.Add (copyButton); - mainWindow.Add (linkFrame); + _appWindow.Add (linkFrame); + + // StatusBar + Shortcut urlIndicator = new (Key.Empty, "", null); + + StatusBar statusBar = new ([ + new (Application.QuitKey, "Quit", Quit), + urlIndicator + ]); + _link.MouseEnter += (s, e) => urlIndicator.Title = _link.Url; + _link.MouseLeave += (s, e) => urlIndicator.Title = ""; + _appWindow.Add (statusBar); - app.Run (mainWindow); + _app.Run (_appWindow); + _appWindow.Dispose (); } + private void Quit () { _appWindow?.RequestStop (); } } diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index f65ce66dd9..048cf3e424 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -176,10 +176,8 @@ protected override bool OnDrawingText (DrawContext? context) private void SetUrl(string value) { - // Will throw exception if not a valid URL - _ = new Uri (value); - - if (_url != value) + // Do dot crach on invalid URLs + if (Uri.TryCreate(value, UriKind.Absolute, out _) && _url != value) { string oldValue = _url; From d45b0a6e8ef5ab6aa2415be2cae6759a3b032d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 22 Feb 2026 23:18:25 +0100 Subject: [PATCH 08/16] fix scenario test --- Examples/UICatalog/Scenarios/Links.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Links.cs b/Examples/UICatalog/Scenarios/Links.cs index 8d503ad15e..53cea2b3bf 100644 --- a/Examples/UICatalog/Scenarios/Links.cs +++ b/Examples/UICatalog/Scenarios/Links.cs @@ -14,9 +14,9 @@ public class Links : Scenario public override void Main () { ConfigurationManager.Enable (ConfigLocations.All); - - _app = Application.Create (); - _app.Init (); + using IApplication app = Application.Create (); + app.Init (); + _app = app; _appWindow = new () { From 2b074bdc1d8971770b691536f9788db3e08d8de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Tue, 24 Feb 2026 00:24:14 +0100 Subject: [PATCH 09/16] Link Osc8 test --- .../Views/LinkTests.cs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 810e7cf57b..cb23923987 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -1,6 +1,7 @@ #nullable enable using JetBrains.Annotations; using UnitTests; +using Xunit.Abstractions; namespace ViewsTests; @@ -9,8 +10,10 @@ namespace ViewsTests; /// These tests can run in parallel without interference. /// [TestSubject (typeof (Link))] -public class LinkTests : TestDriverBase +public class LinkTests (ITestOutputHelper output) : TestDriverBase { + private readonly ITestOutputHelper _output = output; + [Fact] public void Constructor_Defaults () { @@ -385,4 +388,43 @@ public void Link_Cell_Url_Is_Set_In_Buffer () link.Dispose (); } + + [Fact] + public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () + { + string text = "GitHub"; + string url = "https://github.com"; + + // Arrange + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + app.Driver?.SetScreenSize (40, 1); + app.Driver!.Force16Colors = true; + + using Runnable window = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None + }; + + Link link = new () + { + X = 0, + Y = 0, + Width = 60, + Height = 1, + Text = text, + Url = url, + }; + window.Add (link); + + app.Begin(window); + app.LayoutAndDraw (); + app.Driver.Refresh (); + + DriverAssert.AssertDriverOutputIs (""" + \x1b]8;;https://github.com\x1b\\\x1b[90m\x1b[47mGitHub\x1b]8;;\x1b\\\x1b[37m\x1b[100m + """, _output, app.Driver); + } } From 0ecfcdddeddd44270660143bc943f77b8e0844c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Tue, 24 Feb 2026 00:54:00 +0100 Subject: [PATCH 10/16] bad url is now has no effect and is o --- Tests/UnitTestsParallelizable/Views/LinkTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index cb23923987..29d821756c 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -49,7 +49,7 @@ public void Url_Set_Validates_Uri () Link link = new(); Assert.Throws (() => link.Url = "not a valid url"); - Assert.Throws (() => link.Url = ""); + // link.Url = ""; is now has no effect and is ok } [Fact] @@ -417,7 +417,7 @@ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () Text = text, Url = url, }; - window.Add (link); + //window.Add (link); app.Begin(window); app.LayoutAndDraw (); From 54814547690c37acae5b7bda162d6b3e37745e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Tue, 24 Feb 2026 11:25:13 +0100 Subject: [PATCH 11/16] Url validation in Link class Update Url property validation to retain previous value on invalid input. --- Tests/UnitTestsParallelizable/Views/LinkTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 29d821756c..7fdad90b37 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -46,12 +46,15 @@ public void Text_Returns_Url_When_Title_Is_Empty () [Fact] public void Url_Set_Validates_Uri () { - Link link = new(); + Link link = new() { Url = "https://github.com" }; - Assert.Throws (() => link.Url = "not a valid url"); - // link.Url = ""; is now has no effect and is ok - } + link.Url = "not a valid url"; + Assert.Equal ("https://github.com", link.Url); // Url should not change on invalid + link.Url = ""; + Assert.Equal ("https://github.com", link.Url); // Url should not change on invalid + } + [Fact] public void Url_Set_Fires_UrlChanged_Event () { From b8db53e51855a731ed790961f374d760e97667dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Thu, 26 Feb 2026 23:54:52 +0100 Subject: [PATCH 12/16] fix link test scheme color --- Tests/UnitTestsParallelizable/Views/LinkTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 7fdad90b37..650c5579fa 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -401,8 +401,8 @@ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () // Arrange using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - app.Driver?.SetScreenSize (40, 1); - app.Driver!.Force16Colors = true; + app.Driver!.SetScreenSize (40, 1); + app.Driver.Force16Colors = true; using Runnable window = new () { @@ -410,6 +410,7 @@ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () Height = Dim.Fill (), BorderStyle = LineStyle.None }; + window.SetScheme (new (new Attribute (Color.Black, Color.White))); Link link = new () { @@ -420,14 +421,14 @@ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () Text = text, Url = url, }; - //window.Add (link); + window.Add (link); app.Begin(window); app.LayoutAndDraw (); app.Driver.Refresh (); DriverAssert.AssertDriverOutputIs (""" - \x1b]8;;https://github.com\x1b\\\x1b[90m\x1b[47mGitHub\x1b]8;;\x1b\\\x1b[37m\x1b[100m + \x1b]8;;https://github.com\x1b\\\x1b[97m\x1b[40mGitHub\x1b]8;;\x1b\\\x1b[30m\x1b[107m """, _output, app.Driver); } } From 52463a6688a6e8f10d0ce9ebdc2d52efd2d2bbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Couliou?= Date: Sun, 1 Mar 2026 18:58:47 +0100 Subject: [PATCH 13/16] no url in cell + SetNeedsDraw on url change + empty default url + update tests --- Terminal.Gui/Drawing/Cell.cs | 8 +- Terminal.Gui/Drivers/DriverImpl.cs | 1 + Terminal.Gui/Drivers/Output/IOutputBuffer.cs | 9 + Terminal.Gui/Drivers/Output/OutputBase.cs | 41 ++-- .../Drivers/Output/OutputBufferImpl.cs | 39 +++- Terminal.Gui/Views/Link.cs | 20 +- .../Views/LinkTests.cs | 191 +++++++++--------- 7 files changed, 184 insertions(+), 125 deletions(-) diff --git a/Terminal.Gui/Drawing/Cell.cs b/Terminal.Gui/Drawing/Cell.cs index 4ac1a001c0..6e9aff593a 100644 --- a/Terminal.Gui/Drawing/Cell.cs +++ b/Terminal.Gui/Drawing/Cell.cs @@ -5,7 +5,7 @@ namespace Terminal.Gui.Drawing; /// Represents a single row/column in a Terminal.Gui rendering surface (e.g. and /// ). /// -public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, string Grapheme = "", string? Url = null) +public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, string Grapheme = "") { /// The attributes to use when drawing the Glyph. public Attribute? Attribute { get; set; } = Attribute; @@ -16,12 +16,6 @@ public record struct Cell (Attribute? Attribute = null, bool IsDirty = false, st /// public bool IsDirty { get; set; } = IsDirty; - /// - /// Gets or sets the URL associated with this cell for OSC 8 hyperlink support. - /// When set, the cell will be rendered as a clickable hyperlink in terminals that support OSC 8. - /// - public string? Url { get; set; } = Url; - private string _grapheme = Grapheme; /// diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs index 10f3d0feb9..47e76cf6ec 100644 --- a/Terminal.Gui/Drivers/DriverImpl.cs +++ b/Terminal.Gui/Drivers/DriverImpl.cs @@ -265,6 +265,7 @@ public void ClearContents () { _outputBuffer.ClearContents (); ClearedContents?.Invoke (this, EventArgs.Empty); + CurrentUrl = null; } /// diff --git a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs index e70cc0cf1f..c34ffe3a4b 100644 --- a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs +++ b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs @@ -121,6 +121,15 @@ public interface IOutputBuffer /// string? CurrentUrl { get; set; } + /// + /// Gets the URL associated with the cell at the specified position. + /// Returns if no URL is associated with the cell. + /// + /// The column position. + /// The row position. + /// The URL associated with the cell, or if none exists. + string? GetCellUrl (int col, int row); + /// /// Changes the size of the buffer to the given size /// diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 37c16ae602..bcac7f9759 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -119,6 +119,29 @@ public virtual void Write (IOutputBuffer buffer) lastCol = col; } + // Handle URL hyperlink state changes + if (!IsLegacyConsole) + { + string? cellUrl = buffer.GetCellUrl (col, row); + + if (cellUrl != _lastUrl) + { + // If we were in a hyperlink, end it + if (_lastUrl is { }) + { + outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ()); + } + + // If starting a new hyperlink, begin it + if (!string.IsNullOrEmpty (cellUrl)) + { + outputStringBuilder.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + } + + _lastUrl = cellUrl; + } + } + // Append dirty cell as ANSI and mark clean Cell cell = buffer.Contents [row, col]; buffer.Contents [row, col].IsDirty = false; @@ -311,24 +334,6 @@ protected void AppendCellAnsi (Cell cell, StringBuilder output, ref Attribute? l { Attribute? attribute = cell.Attribute; - // Handle URL hyperlink state changes - if (!IsLegacyConsole && cell.Url != _lastUrl) - { - // If we were in a hyperlink, end it - if (_lastUrl is { }) - { - output.Append (EscSeqUtils.OSC_EndHyperlink ()); - } - - // If starting a new hyperlink, begin it - if (!string.IsNullOrEmpty (cell.Url)) - { - output.Append (EscSeqUtils.OSC_StartHyperlink (cell.Url)); - } - - _lastUrl = cell.Url; - } - // Add ANSI escape sequence for attribute change if (attribute.HasValue && attribute.Value != lastAttr) { diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index ee3b69bc7c..1f2592298b 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -31,6 +31,35 @@ public class OutputBufferImpl : IOutputBuffer /// public string? CurrentUrl { get; set; } + /// + /// Maps cell positions to URLs for OSC 8 hyperlink support. + /// Only stores entries for cells that actually have URLs, minimizing memory overhead. + /// + private Dictionary? _urlMap; + + /// + /// Gets the URL associated with the cell at the specified position. + /// + /// The column. + /// The row. + /// The URL if one exists, otherwise null. + public string? GetCellUrl (int col, int row) + { + return _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; + } + + /// + /// Sets the URL for the cell at the specified position. + /// + /// The column. + /// The row. + /// The URL to associate with this cell. + private void SetCellUrl (int col, int row, string url) + { + _urlMap ??= []; + _urlMap [new Point (col, row)] = url; + } + /// The leftmost column in the terminal. public virtual int Left { get; set; } = 0; @@ -215,8 +244,13 @@ private void AddGrapheme (string grapheme) private void SetAttributeAndDirty (int col, int row) { Contents! [row, col].Attribute = CurrentAttribute; - Contents [row, col].Url = CurrentUrl; Contents [row, col].IsDirty = true; + + // If CurrentUrl is set, store it in the URL map + if (!string.IsNullOrEmpty (CurrentUrl)) + { + SetCellUrl (col, row, CurrentUrl); + } } /// @@ -324,6 +358,9 @@ public void ClearContents () DirtyLines = new bool [Rows]; + // Clear the URL map + _urlMap?.Clear (); + lock (Contents) { for (var row = 0; row < Rows; row++) diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index 048cf3e424..908272dccc 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -32,9 +32,9 @@ public override string Text /// Represents the default URL used when no specific URL is provided. /// /// - /// This constant can be used to initialize browsers or web components to a blank page or neutral state. + /// An empty string indicates that no URL is associated with the link. /// - public const string DEFAULT_URL = "about:blank"; + public const string DEFAULT_URL = ""; private string _url = DEFAULT_URL; @@ -132,8 +132,8 @@ public bool Copy () /// protected override bool OnDrawingText (DrawContext? context) { - // Set the URL for cells that will be drawn - if (!string.IsNullOrEmpty (Url) && Url != DEFAULT_URL && Driver is { }) + // Set the URL for cells that will be drawn (only if URL is not empty) + if (!string.IsNullOrEmpty (Url) && Driver is { }) { Rectangle drawRect = new (ContentToScreen (Point.Empty), GetContentSize ()); @@ -176,8 +176,13 @@ protected override bool OnDrawingText (DrawContext? context) private void SetUrl(string value) { - // Do dot crach on invalid URLs - if (Uri.TryCreate(value, UriKind.Absolute, out _) && _url != value) + // Do not crash on invalid URLs, instead default to a blank page + if (!Uri.TryCreate (value, UriKind.Absolute, out _)) + { + value = DEFAULT_URL; + } + + if (_url != value) { string oldValue = _url; @@ -203,6 +208,9 @@ private void SetUrl(string value) ValueChangedEventArgs changedArgs = new (oldValue, value); OnUrlChanged (changedArgs); UrlChanged?.Invoke (this, changedArgs); + + // Mark as needing redraw since URL changed + SetNeedsDraw (); } } } diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 650c5579fa..8fb4d04070 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -49,10 +49,10 @@ public void Url_Set_Validates_Uri () Link link = new() { Url = "https://github.com" }; link.Url = "not a valid url"; - Assert.Equal ("https://github.com", link.Url); // Url should not change on invalid + Assert.Equal (Link.DEFAULT_URL, link.Url); // Url should not change on invalid link.Url = ""; - Assert.Equal ("https://github.com", link.Url); // Url should not change on invalid + Assert.Equal (Link.DEFAULT_URL, link.Url); // Url should not change on invalid } [Fact] @@ -123,7 +123,13 @@ public void Link_Renders_With_OSC8_Hyperlink () { using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - app.Clipboard = new FakeClipboard (); + + using Runnable window = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None + }; Link link = new () { @@ -131,24 +137,26 @@ public void Link_Renders_With_OSC8_Hyperlink () Url = "https://github.com/gui-cs/Terminal.Gui", Text = "Terminal.Gui" }; + window.Add (link); - link.BeginInit (); - link.EndInit (); - link.SetRelativeLayout (new (30, 3)); - link.Draw (); + app.Begin (window); + app.LayoutAndDraw (); + app.Driver!.Refresh (); // Get the ANSI output - string ansi = app.Driver!.ToAnsi (); + string ansi = app.Driver.ToAnsi (); + string? look = app.Driver.GetOutput ().GetLastOutput (); // Verify OSC 8 sequences are present string expectedStart = EscSeqUtils.OSC_StartHyperlink ("https://github.com/gui-cs/Terminal.Gui"); string expectedEnd = EscSeqUtils.OSC_EndHyperlink (); - Assert.Contains (expectedStart, ansi); - Assert.Contains (expectedEnd, ansi); + Assert.Contains (expectedStart, look); + Assert.Contains (expectedEnd, look); + Assert.Contains ("Terminal.Gui", look); Assert.Contains ("Terminal.Gui", ansi); - link.Dispose (); + window.Dispose (); } [Fact] @@ -156,27 +164,35 @@ public void Link_Renders_Without_OSC8_When_Url_Is_Default () { using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - app.Clipboard = new FakeClipboard (); + + using Runnable window = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None + }; Link link = new () { App = app, Text = "Not a link" }; + window.Add (link); - link.BeginInit (); - link.EndInit (); - link.SetRelativeLayout (new (30, 3)); - link.Draw (); + app.Begin (window); + app.LayoutAndDraw (); + app.Driver!.Refresh (); // Get the ANSI output string ansi = app.Driver!.ToAnsi (); + string? look = app.Driver.GetOutput ().GetLastOutput (); // Verify OSC 8 sequences are NOT present for default URL - Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink (Link.DEFAULT_URL), ansi); + Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink (Link.DEFAULT_URL), look); + Assert.Contains ("Not a link", look); Assert.Contains ("Not a link", ansi); - link.Dispose (); + window.Dispose (); } [Fact] @@ -207,8 +223,16 @@ public void Link_Multiple_Links_Each_Get_Their_Own_Url () { using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - app.Clipboard = new FakeClipboard (); + app.Driver!.SetScreenSize (40, 2); + + using Runnable window = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None + }; + // ✅ Ajouter les DEUX liens dès le début Link link1 = new () { App = app, @@ -227,27 +251,24 @@ public void Link_Multiple_Links_Each_Get_Their_Own_Url () Text = "Microsoft" }; - link1.BeginInit (); - link1.EndInit (); - link1.SetRelativeLayout (new (30, 10)); - link1.Draw (); + window.Add (link1, link2); - link2.BeginInit (); - link2.EndInit (); - link2.SetRelativeLayout (new (30, 10)); - link2.Draw (); + app.Begin (window); + app.LayoutAndDraw (); + app.Driver.Refresh (); - // Get the ANSI output - string ansi = app.Driver!.ToAnsi (); + string? look = app.Driver.GetOutput ().GetLastOutput (); + string ansi = app.Driver.ToAnsi (); - // Verify both URLs are present - Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), ansi); - Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://microsoft.com"), ansi); + // Verify both URLs are present in the same output + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), look); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://microsoft.com"), look); + Assert.Contains ("GitHub", look); + Assert.Contains ("Microsoft", look); Assert.Contains ("GitHub", ansi); Assert.Contains ("Microsoft", ansi); - link1.Dispose (); - link2.Dispose (); + window.Dispose (); } [Fact] @@ -255,7 +276,13 @@ public void Link_Url_Changes_Update_Hyperlink () { using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - app.Clipboard = new FakeClipboard (); + + using Runnable window = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None + }; Link link = new () { @@ -263,25 +290,25 @@ public void Link_Url_Changes_Update_Hyperlink () Url = "https://example.com", Text = "Example" }; + window.Add (link); - link.BeginInit (); - link.EndInit (); - link.SetRelativeLayout (new (30, 3)); - link.Draw (); + app.Begin (window); + app.LayoutAndDraw (); + app.Driver!.Refresh (); - string ansi1 = app.Driver!.ToAnsi (); - Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://example.com"), ansi1); + string? look1 = app.Driver.GetOutput ().GetLastOutput (); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://example.com"), look1); // Clear and change URL app.Driver.ClearContents (); link.Url = "https://newurl.com"; - link.SetNeedsDraw (); - link.Draw (); + app.LayoutAndDraw (); + app.Driver!.Refresh (); - string ansi2 = app.Driver.ToAnsi (); - Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://newurl.com"), ansi2); + string? look2 = app.Driver.GetOutput ().GetLastOutput (); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://newurl.com"), look2); - link.Dispose (); + window.Dispose (); } [Fact] @@ -289,35 +316,48 @@ public void Link_With_Focus_Draws_With_Focus_Colors () { using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - app.Clipboard = new FakeClipboard (); + + using Runnable window = new () + { + Width = Dim.Fill (), + Height = Dim.Fill (), + BorderStyle = LineStyle.None + }; + + // ✅ Add a dummy view to take initial focus + View dummyView = new () { CanFocus = true, Width = 1, Height = 1 }; + window.Add (dummyView); Link link = new () { App = app, Url = "https://github.com", Text = "GitHub", - CanFocus = true + CanFocus = true, + Y = 1 // Place it below dummyView }; + window.Add (link); - link.BeginInit (); - link.EndInit (); - link.SetRelativeLayout (new (30, 3)); + app.Begin (window); + app.LayoutAndDraw (); + app.Driver!.Refresh (); - // Without focus - link.Draw (); + // ✅ Without focus - dummyView has focus instead Assert.False (link.HasFocus); + Assert.True (dummyView.HasFocus); - // Set focus - app.Driver!.ClearContents (); + // Set focus on link + app.Driver.ClearContents (); link.SetFocus (); link.Draw (); Assert.True (link.HasFocus); + Assert.False (dummyView.HasFocus); // The link should still have OSC 8 sequences - string ansi = app.Driver.ToAnsi (); - Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), ansi); + string? look = app.Driver.GetOutput ().GetLastOutput (); + Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), look); - link.Dispose (); + window.Dispose (); } [Fact] @@ -357,41 +397,6 @@ public void Link_LeftButtonReleased_InvokesHotKey_OnNextView () superView.Dispose (); } - [Fact] - public void Link_Cell_Url_Is_Set_In_Buffer () - { - using IApplication app = Application.Create (); - app.Init (DriverRegistry.Names.ANSI); - app.Clipboard = new FakeClipboard (); - - Link link = new () - { - App = app, - X = 0, - Y = 0, - Url = "https://github.com", - Text = "GitHub" - }; - - link.BeginInit (); - link.EndInit (); - link.SetRelativeLayout (new (30, 3)); - link.Draw (); - - // Verify that cells in the buffer have the URL set - Cell [,] contents = app.Driver!.Contents!; - - // The first cell of "GitHub" should have the URL - Assert.Equal ("https://github.com", contents [0, 0].Url); - Assert.Equal ("G", contents [0, 0].Grapheme); - - // All cells in "GitHub" should have the URL - Assert.Equal ("https://github.com", contents [0, 1].Url); - Assert.Equal ("https://github.com", contents [0, 2].Url); - - link.Dispose (); - } - [Fact] public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () { From db81f442f59542883c7e72ca83cb53c965e54fef Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 6 Mar 2026 13:39:17 -0700 Subject: [PATCH 14/16] Refactor Link control: API, UI, and platform integration - Added static Link.OpenUrl for platform-specific URL opening; removed OpenUrl from UICatalogRunnable. - Link now handles mouse clicks and keyboard activation, opening URLs directly. - Improved drawing: shows Url if Text is empty, disables link if Url is invalid, always clears Driver.CurrentUrl. - Copy() now copies Text, not Url. - SetUrl validates URLs; invalid URLs revert to DEFAULT_URL. - IDesignable.EnableForDesign sets default Title and Text. - Refactored Links scenario UI for clarity and usability; status bar now shows Link's Text. - Updated all tests for new Link API and behaviors; improved test clarity. - Minor code style improvements and use of System.Diagnostics for process launching. --- Examples/UICatalog/Scenarios/Links.cs | 96 +++---- Examples/UICatalog/UICatalogRunnable.cs | 34 +-- Terminal.Gui/Views/Link.cs | 234 +++++++++++------- .../Views/LinkTests.cs | 152 ++++-------- 4 files changed, 235 insertions(+), 281 deletions(-) diff --git a/Examples/UICatalog/Scenarios/Links.cs b/Examples/UICatalog/Scenarios/Links.cs index 53cea2b3bf..64acd13fd3 100644 --- a/Examples/UICatalog/Scenarios/Links.cs +++ b/Examples/UICatalog/Scenarios/Links.cs @@ -18,84 +18,53 @@ public override void Main () app.Init (); _app = app; - _appWindow = new () - { - Title = GetName (), - BorderStyle = LineStyle.None - }; + _appWindow = new Window { Title = GetName (), BorderStyle = LineStyle.None }; - Label textLabel = new () - { - Text = "_Text:", - X = 1, - Y = 1 - }; + Label titleLabel = new () { Text = "_Title:", X = 1, Y = 1 }; + _appWindow.Add (titleLabel); + + TextField titleTextField = new () { X = Pos.Right (titleLabel) + 1, Y = Pos.Top (titleLabel), Width = Dim.Fill () }; + _appWindow.Add (titleTextField); + + Label textLabel = new () { Text = " Te_xt:", X = Pos.Left (titleLabel), Y = Pos.Bottom(titleLabel) }; _appWindow.Add (textLabel); - TextField textField = new () - { - X = Pos.Right (textLabel) + 2, - Y = 1, - Width = 20 - }; - _appWindow.Add (textField); + TextField textTextField = new () { X = Pos.Right (textLabel) + 1, Y = Pos.Top(textLabel), Width = Dim.Fill () }; + _appWindow.Add (textTextField); - Label urlLabel = new () - { - Text = "_Url:", - X = 1, - Y = Pos.Bottom (textField) + 1 - }; + Label urlLabel = new () { Text = " _Url:", X = 1, Y = Pos.Bottom (titleTextField) + 1 }; _appWindow.Add (urlLabel); - TextField urlField = new () - { - X = Pos.Right (urlLabel) + 2, - Y = Pos.Bottom (textField) + 1, - Width = 64 - }; - _appWindow.Add (urlField); + TextField urlTextField = new () { X = Pos.Right (urlLabel) + 1, Y = Pos.Bottom (titleTextField) + 1, Width = Dim.Fill () }; + _appWindow.Add (urlTextField); - Label simpleUrlLabel = new () - { - X = 1, - Y = Pos.Bottom (urlField) + 2 - }; + Label simpleUrlLabel = new () { X = 1, Y = Pos.Bottom (urlTextField) + 2 }; _appWindow.Add (simpleUrlLabel); FrameView linkFrame = new () { - Title = "_Link rendering", + Title = "_Link Demo", X = 0, Y = Pos.Bottom (simpleUrlLabel) + 2, - Width = 64, - Height = 8, - AssignHotKeys = true + Width = Dim.Fill(), + Height = Dim.Auto (), + AssignHotKeys = true, + TabStop = TabBehavior.TabStop }; - _link = new () - { - X = 1, - Y = 1, - Height = 1, - Width = 64 - }; + _link = new Link { X = 1, Y = 1, BorderStyle = LineStyle.Dotted }; - _link.UrlChanged += (s, e) => simpleUrlLabel.Text = _link.Url; - textField.ValueChanged += (s, e) => _link.Text = e.NewValue ?? _link.Url; - urlField.ValueChanged += (s, e) => _link.Url = e.NewValue ?? Link.DEFAULT_URL; + _link.TextChanged += (s, e) => simpleUrlLabel.Text = $"This is just a Label with a URL in Text (WT automatically enables URLs) - {_link.Text}"; + titleTextField.ValueChanged += (s, e) => _link.Title = e.NewValue ?? string.Empty; + textTextField.ValueChanged += (s, e) => _link.Text = e.NewValue ?? string.Empty; + urlTextField.ValueChanged += (s, e) => _link.Url = e.NewValue ?? Link.DEFAULT_URL; linkFrame.Add (_link); - textField.Text = "GitHub repo"; - urlField.Text = "https://github.com/gui-cs/Terminal.Gui"; + titleTextField.Text = "Title"; + textTextField.Text = "GitHub repo"; + urlTextField.Text = "https://github.com/gui-cs/Terminal.Gui"; - Button copyButton = new () - { - Title = "_Copy", - X = Pos.Center (), - Y = Pos.Bottom (_link) + 2, - - }; + Button copyButton = new () { Title = "_Copy", X = Pos.Center (), Y = Pos.AnchorEnd () }; copyButton.Accepting += (s, e) => _link.Copy (); linkFrame.Add (copyButton); @@ -105,11 +74,8 @@ public override void Main () // StatusBar Shortcut urlIndicator = new (Key.Empty, "", null); - StatusBar statusBar = new ([ - new (Application.QuitKey, "Quit", Quit), - urlIndicator - ]); - _link.MouseEnter += (s, e) => urlIndicator.Title = _link.Url; + StatusBar statusBar = new ([new Shortcut (Application.QuitKey, "Quit", Quit), urlIndicator]); + _link.MouseEnter += (s, e) => urlIndicator.Title = _link.Text; _link.MouseLeave += (s, e) => urlIndicator.Title = ""; _appWindow.Add (statusBar); @@ -117,5 +83,5 @@ public override void Main () _appWindow.Dispose (); } - private void Quit () { _appWindow?.RequestStop (); } + private void Quit () => _appWindow?.RequestStop (); } diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs index bd8878070b..3f3a3b3c0e 100644 --- a/Examples/UICatalog/UICatalogRunnable.cs +++ b/Examples/UICatalog/UICatalogRunnable.cs @@ -148,11 +148,11 @@ private MenuBar CreateMenuBar () [ new MenuItem ("_Documentation", "API docs", - () => OpenUrl ("https://gui-cs.github.io/Terminal.Gui"), + () => Link.OpenUrl ("https://gui-cs.github.io/Terminal.Gui"), Key.F1), new MenuItem ("_README", "Project readme", - () => OpenUrl ("https://github.com/gui-cs/Terminal.Gui"), + () => Link.OpenUrl ("https://github.com/gui-cs/Terminal.Gui"), Key.F2), new MenuItem ("_About...", "About UI Catalog", @@ -401,7 +401,7 @@ View [] CreateLoggingMenuItems () // add a separator menuItems.Add (new Line ()); - menuItems.Add (new MenuItem ("_Open Log Folder", string.Empty, () => OpenUrl (UICatalog.LOGFILE_LOCATION))); + menuItems.Add (new MenuItem ("_Open Log Folder", string.Empty, () => Link.OpenUrl (UICatalog.LOGFILE_LOCATION))); return menuItems.ToArray ()!; @@ -747,34 +747,6 @@ _______ _ _ _____ _ return msg.ToString (); } - public static void OpenUrl (string url) - { - if (PlatformDetection.IsWindows ()) - { - url = url.Replace ("&", "^&"); - Process.Start (new ProcessStartInfo ("cmd", $"/c start {url}") { CreateNoWindow = true }); - } - else if (PlatformDetection.IsMac ()) - { - Process.Start ("open", url); - } - else if (PlatformDetection.IsLinux ()) - { - using Process process = new (); - - process.StartInfo = new ProcessStartInfo - { - FileName = "xdg-open", - Arguments = url, - RedirectStandardError = true, - RedirectStandardOutput = true, - CreateNoWindow = true, - UseShellExecute = false - }; - process.Start (); - } - } - /// /// Shows a dialog displaying error logs from a scenario run. /// diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index 908272dccc..84e5dcd00b 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -1,4 +1,6 @@ -namespace Terminal.Gui.Views; +using System.Diagnostics; + +namespace Terminal.Gui.Views; /// /// Displays a clickable link with text and url. @@ -15,37 +17,101 @@ public Link () // On HotKey, pass it to the next view AddCommand (Command.HotKey, InvokeHotKeyOnNextPeer!); + + MouseBindings.Add (MouseFlags.LeftButtonClicked, Command.Accept); } /// - public override string Text + protected override bool OnActivating (CommandEventArgs args) { - get => string.IsNullOrWhiteSpace (Title) ? Url : Title; - set + // If Link can't focus and is activated, invoke HotKey on next peer + if (!CanFocus) { - Title = value; - base.Text = string.IsNullOrWhiteSpace (value) ? Url : value; + return InvokeCommand (Command.HotKey, args.Context) == true; } + + return base.OnActivating (args); + } + + /// + /// Opens . + /// + protected override void OnAccepted (ICommandContext? ctx) + { + base.OnAccepted (ctx); + + OpenUrl (Url); } /// - /// Represents the default URL used when no specific URL is provided. + /// Opens the specified URL in the default web browser. The implementation is platform-specific: + /// + /// + public static void OpenUrl (string url) + { + if (PlatformDetection.IsWindows ()) + { + url = url.Replace ("&", "^&"); + Process.Start (new ProcessStartInfo ("cmd", $"/c start {url}") { CreateNoWindow = true }); + } + else if (PlatformDetection.IsMac ()) + { + Process.Start ("open", url); + } + else if (PlatformDetection.IsLinux ()) + { + using Process process = new (); + + process.StartInfo = new ProcessStartInfo + { + FileName = "xdg-open", + Arguments = url, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true, + UseShellExecute = false + }; + process.Start (); + } + } + + /// + /// Updates the text displayed by the text formatter based on the current value of the control's text property. /// /// - /// An empty string indicates that no URL is associated with the link. + /// If the base text is null or empty, the formatter displays the URL instead. Otherwise, the + /// base implementation is used. This method is used by the Layout engine to determine the text Size. + /// + protected override void UpdateTextFormatterText () + { + if (string.IsNullOrEmpty (base.Text)) + { + TextFormatter.Text = Url; + TextFormatter.ConstrainToWidth = null; + TextFormatter.ConstrainToHeight = null; + } + else + { + base.UpdateTextFormatterText (); + } + } + + /// + /// Represents the default URL used when no specific URL is provided. + /// + /// + /// An empty string indicates that no URL is associated with the link. /// public const string DEFAULT_URL = ""; private string _url = DEFAULT_URL; /// - /// Gets or sets the URL associated with this instance. + /// Gets or sets the URL associated with this instance. If is empty, the URL will be displayed as a + /// clickable link. If is set, + /// it will be displayed as a clickable link. /// - public string Url - { - get => _url; - set => SetUrl (value); - } + public string Url { get => _url; set => SetUrl (value); } /// /// Raised when is about to change. @@ -99,22 +165,11 @@ protected virtual void OnUrlChanged (ValueChangedEventArgs args) { } return false; } - /// - protected override bool OnActivating (CommandEventArgs args) - { - // If Link can't focus and is clicked, invoke HotKey on next peer - if (!CanFocus) - { - return InvokeCommand (Command.HotKey, args.Context) == true; - } - - return base.OnActivating (args); - } - /// bool IDesignable.EnableForDesign () { - Text = "_Link"; + Title = "_Link"; + Text = "https://github.com/gui-cs"; return true; } @@ -122,96 +177,99 @@ bool IDesignable.EnableForDesign () /// Copy the URL to the clipboard contents. public bool Copy () { - SetClipboard (Url); + SetClipboard (Text); return true; } private void SetClipboard (string text) => App?.Clipboard?.SetClipboardData (text); - /// + /// + /// Draws the Link. If is empty, the will be drawn; otherwise + /// will be drawn. + /// + /// + /// protected override bool OnDrawingText (DrawContext? context) { - // Set the URL for cells that will be drawn (only if URL is not empty) - if (!string.IsNullOrEmpty (Url) && Driver is { }) + if (Driver is null) { - Rectangle drawRect = new (ContentToScreen (Point.Empty), GetContentSize ()); + return base.OnDrawingText (context); + } - // Use GetDrawRegion to get precise drawn areas - Region textRegion = TextFormatter.GetDrawRegion (drawRect); + Rectangle drawRect = new (ContentToScreen (Point.Empty), GetContentSize ()); - // Report the drawn area to the context - context?.AddDrawnRegion (textRegion); + Region textDrawRegion = TextFormatter.GetDrawRegion (drawRect); - Attribute normalAttr = HasFocus ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal); - Attribute hotAttr = HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal); + // Report the drawn area to the context + context?.AddDrawnRegion (textDrawRegion); - // Set the URL in the driver so all cells drawn will have this URL - Driver.CurrentUrl = Url; + Attribute normalAttr = HasFocus ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal); + Attribute hotAttr = HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal); - try - { - // Draw the text using TextFormatter - all cells will now have the URL - TextFormatter.Draw ( - Driver, - drawRect, - normalAttr, - hotAttr, - Rectangle.Empty); - } - finally - { - // Clear the URL after drawing - Driver.CurrentUrl = null; - } + string? url = Url; - // We assume that the text has been drawn over the entire area; ensure that the SubViews are redrawn. - SetSubViewNeedsDrawDownHierarchy (); + // If the URL is not valid, don't set CurrentUrl, and adjust the attributes to indicate it's not well-formed + if (!Uri.IsWellFormedUriString (Url, UriKind.Absolute)) + { + normalAttr = GetAttributeForRole (VisualRole.Disabled); + normalAttr = normalAttr with { Background = HasFocus ? GetAttributeForRole (VisualRole.Focus).Background : normalAttr.Background }; + url = null; + } + + // Set the URL in the driver so all cells drawn will have this URL + Driver.CurrentUrl = url; - return true; // We handled the drawing + try + { + // Draw the Title using TextFormatter - all cells will now have the URL + TextFormatter.Draw (Driver, drawRect, normalAttr, hotAttr, Rectangle.Empty); + } + finally + { + // Always clear the URL after drawing + Driver.CurrentUrl = null; } - return base.OnDrawingText (context); + // We assume that the text has been drawn over the entire area; ensure that the SubViews are redrawn. + SetSubViewNeedsDrawDownHierarchy (); + + return true; // We handled the drawing } - private void SetUrl(string value) + private void SetUrl (string value) { - // Do not crash on invalid URLs, instead default to a blank page - if (!Uri.TryCreate (value, UriKind.Absolute, out _)) + if (_url == value) { - value = DEFAULT_URL; + return; } + string oldValue = _url; - if (_url != value) - { - string oldValue = _url; + // CWP: Fire ValueChanging (allows cancellation) + ValueChangingEventArgs changingArgs = new (oldValue, value); - // CWP: Fire ValueChanging (allows cancellation) - ValueChangingEventArgs changingArgs = new (oldValue, value); - - if (OnUrlChanging (changingArgs) || changingArgs.Handled) - { - return; - } + if (OnUrlChanging (changingArgs) || changingArgs.Handled) + { + return; + } - UrlChanging?.Invoke (this, changingArgs); + UrlChanging?.Invoke (this, changingArgs); - if (changingArgs.Handled) - { - return; - } + if (changingArgs.Handled) + { + return; + } - // Do the work - _url = value; + // Do the work + _url = value; - // CWP: Fire ValueChanged - ValueChangedEventArgs changedArgs = new (oldValue, value); - OnUrlChanged (changedArgs); - UrlChanged?.Invoke (this, changedArgs); + // CWP: Fire ValueChanged + ValueChangedEventArgs changedArgs = new (oldValue, value); + OnUrlChanged (changedArgs); + UrlChanged?.Invoke (this, changedArgs); - // Mark as needing redraw since URL changed - SetNeedsDraw (); - } + // Indicate the formatter needs formatting, which will cause UpdateTextFormatterText to be invoked + TextFormatter.NeedsFormat = true; + SetNeedsLayout (); } } - diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 8fb4d04070..9520935240 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -1,7 +1,5 @@ -#nullable enable using JetBrains.Annotations; using UnitTests; -using Xunit.Abstractions; namespace ViewsTests; @@ -12,12 +10,10 @@ namespace ViewsTests; [TestSubject (typeof (Link))] public class LinkTests (ITestOutputHelper output) : TestDriverBase { - private readonly ITestOutputHelper _output = output; - [Fact] public void Constructor_Defaults () { - Link link = new(); + Link link = new (); Assert.Equal (Link.DEFAULT_URL, link.Url); Assert.Equal (Link.DEFAULT_URL, link.Text); @@ -46,7 +42,7 @@ public void Text_Returns_Url_When_Title_Is_Empty () [Fact] public void Url_Set_Validates_Uri () { - Link link = new() { Url = "https://github.com" }; + Link link = new () { Url = "https://github.com" }; link.Url = "not a valid url"; Assert.Equal (Link.DEFAULT_URL, link.Url); // Url should not change on invalid @@ -54,22 +50,22 @@ public void Url_Set_Validates_Uri () link.Url = ""; Assert.Equal (Link.DEFAULT_URL, link.Url); // Url should not change on invalid } - + [Fact] public void Url_Set_Fires_UrlChanged_Event () { - string oldUrl = "http://oldvalue.io"; - string newUrl = "http://newvalue.io"; + var oldUrl = "http://oldvalue.io"; + var newUrl = "http://newvalue.io"; - Link link = new() { Url = oldUrl }; - bool eventFired = false; - bool eventArgsValid = false; + Link link = new () { Url = oldUrl }; + var eventFired = false; + var eventArgsValid = false; link.UrlChanged += (s, e) => - { - eventFired = true; - eventArgsValid = e.OldValue == oldUrl && e.NewValue == newUrl; - }; + { + eventFired = true; + eventArgsValid = e.OldValue == oldUrl && e.NewValue == newUrl; + }; link.Url = newUrl; Assert.True (eventFired); @@ -80,21 +76,22 @@ public void Url_Set_Fires_UrlChanged_Event () [Fact] public void Url_Set_Fires_UrlChanging_Event () { - string oldUrl = "http://oldvalue.io"; - string newUrl = "http://newvalue.io"; + var oldUrl = "http://oldvalue.io"; + var newUrl = "http://newvalue.io"; Link link = new () { Url = oldUrl }; - bool eventFired = false; - bool eventArgsValid = false; - bool valueChanged = false; + var eventFired = false; + var eventArgsValid = false; + var valueChanged = false; link.UrlChanging += (s, e) => - { - eventFired = true; - eventArgsValid = e.CurrentValue == oldUrl && e.NewValue == newUrl; - // Should be false since the change hasn't happened yet - valueChanged = e.CurrentValue == newUrl || link.Url == newUrl; - }; + { + eventFired = true; + eventArgsValid = e.CurrentValue == oldUrl && e.NewValue == newUrl; + + // Should be false since the change hasn't happened yet + valueChanged = e.CurrentValue == newUrl || link.Url == newUrl; + }; link.Url = newUrl; Assert.True (eventFired); @@ -111,8 +108,8 @@ public void Copy_Copies_Url_To_Clipboard () app.Clipboard = new FakeClipboard (); Link link = new () { App = app, Url = "https://github.com" }; - - var copied = link.Copy (); + + bool copied = link.Copy (); Assert.True (copied); Assert.Equal ("https://github.com", app.Clipboard?.GetClipboardData ()); @@ -124,19 +121,9 @@ public void Link_Renders_With_OSC8_Hyperlink () using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - using Runnable window = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - BorderStyle = LineStyle.None - }; + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; - Link link = new () - { - App = app, - Url = "https://github.com/gui-cs/Terminal.Gui", - Text = "Terminal.Gui" - }; + Link link = new () { App = app, Url = "https://github.com/gui-cs/Terminal.Gui", Text = "Terminal.Gui" }; window.Add (link); app.Begin (window); @@ -165,18 +152,9 @@ public void Link_Renders_Without_OSC8_When_Url_Is_Default () using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - using Runnable window = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - BorderStyle = LineStyle.None - }; + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; - Link link = new () - { - App = app, - Text = "Not a link" - }; + Link link = new () { App = app, Text = "Not a link" }; window.Add (link); app.Begin (window); @@ -201,7 +179,7 @@ public void Link_With_HotKey_Passes_To_Next_View () View superView = new () { CanFocus = true }; Link link = new () { Text = "_Link", CanFocus = false }; View nextView = new () { CanFocus = true }; - + superView.Add (link, nextView); superView.BeginInit (); superView.EndInit (); @@ -225,12 +203,7 @@ public void Link_Multiple_Links_Each_Get_Their_Own_Url () app.Init (DriverRegistry.Names.ANSI); app.Driver!.SetScreenSize (40, 2); - using Runnable window = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - BorderStyle = LineStyle.None - }; + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; // ✅ Ajouter les DEUX liens dès le début Link link1 = new () @@ -277,19 +250,9 @@ public void Link_Url_Changes_Update_Hyperlink () using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - using Runnable window = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - BorderStyle = LineStyle.None - }; + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; - Link link = new () - { - App = app, - Url = "https://example.com", - Text = "Example" - }; + Link link = new () { App = app, Url = "https://example.com", Text = "Example" }; window.Add (link); app.Begin (window); @@ -317,12 +280,10 @@ public void Link_With_Focus_Draws_With_Focus_Colors () using IApplication app = Application.Create (); app.Init (DriverRegistry.Names.ANSI); - using Runnable window = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - BorderStyle = LineStyle.None - }; + using Runnable window = new (); + window.Width = Dim.Fill (); + window.Height = Dim.Fill (); + window.BorderStyle = LineStyle.None; // ✅ Add a dummy view to take initial focus View dummyView = new () { CanFocus = true, Width = 1, Height = 1 }; @@ -334,7 +295,7 @@ public void Link_With_Focus_Draws_With_Focus_Colors () Url = "https://github.com", Text = "GitHub", CanFocus = true, - Y = 1 // Place it below dummyView + Y = 1 // Place it below dummyView }; window.Add (link); @@ -363,10 +324,10 @@ public void Link_With_Focus_Draws_With_Focus_Colors () [Fact] public void IDesignable_EnableForDesign_Sets_Default_Text () { - Link link = new(); - IDesignable designable = link as IDesignable; + Link link = new (); + var designable = link as IDesignable; - var result = designable.EnableForDesign (); + bool result = designable.EnableForDesign (); Assert.True (result); Assert.Equal ("_Link", link.Text); @@ -380,7 +341,7 @@ public void Link_LeftButtonReleased_InvokesHotKey_OnNextView () View superView = new () { CanFocus = true, Height = 1, Width = 15 }; Link link = new () { X = 0, HotKey = Key.L.WithAlt, CanFocus = false }; View nextView = new () { CanFocus = true, X = 10, Width = 4, Height = 1 }; - + superView.Add (link, nextView); superView.BeginInit (); superView.EndInit (); @@ -389,8 +350,8 @@ public void Link_LeftButtonReleased_InvokesHotKey_OnNextView () Assert.False (nextView.HasFocus); // Click on the link - link.NewMouseEvent (new () { Position = new (0, 0), Flags = MouseFlags.LeftButtonReleased }); - + link.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonReleased }); + Assert.False (link.HasFocus); Assert.True (nextView.HasFocus); @@ -400,8 +361,8 @@ public void Link_LeftButtonReleased_InvokesHotKey_OnNextView () [Fact] public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () { - string text = "GitHub"; - string url = "https://github.com"; + var text = "GitHub"; + var url = "https://github.com"; // Arrange using IApplication app = Application.Create (); @@ -409,13 +370,8 @@ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () app.Driver!.SetScreenSize (40, 1); app.Driver.Force16Colors = true; - using Runnable window = new () - { - Width = Dim.Fill (), - Height = Dim.Fill (), - BorderStyle = LineStyle.None - }; - window.SetScheme (new (new Attribute (Color.Black, Color.White))); + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + window.SetScheme (new Scheme (new Attribute (Color.Black, Color.White))); Link link = new () { @@ -424,16 +380,18 @@ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly () Width = 60, Height = 1, Text = text, - Url = url, + Url = url }; window.Add (link); - app.Begin(window); + app.Begin (window); app.LayoutAndDraw (); app.Driver.Refresh (); DriverAssert.AssertDriverOutputIs (""" - \x1b]8;;https://github.com\x1b\\\x1b[97m\x1b[40mGitHub\x1b]8;;\x1b\\\x1b[30m\x1b[107m - """, _output, app.Driver); + \x1b]8;;https://github.com\x1b\\\x1b[97m\x1b[40mGitHub\x1b]8;;\x1b\\\x1b[30m\x1b[107m + """, + output, + app.Driver); } } From f42e54875ed7f41841f2813e0037b0b64a0ba8ba Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 6 Mar 2026 13:51:32 -0700 Subject: [PATCH 15/16] Refactor Link: decouple Text/Title/Url, expand tests - IDesignable.EnableForDesign now sets Title and Url, not Text. - Copy() now copies Url to clipboard instead of Text. - Text, Title, and Url are now independent; setting one does not affect the others. - TextFormatter displays Url if Text is empty; otherwise uses Text. - DimAuto sizing uses Text width if set, otherwise Url, including wide chars. - Url property now accepts any string (no URI validation). - Setting the same Url does not fire change events. - UrlChanging event can cancel Url changes. - Rendering tests ensure invalid URLs do not produce OSC 8 hyperlinks and are styled as disabled. - Tests updated and expanded to cover all new behaviors and edge cases. - Minor test code cleanups and clarifications. --- Terminal.Gui/Views/Link.cs | 4 +- .../Views/LinkTests.cs | 181 ++++++++++++++++-- 2 files changed, 165 insertions(+), 20 deletions(-) diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index 84e5dcd00b..bf172d2870 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -169,7 +169,7 @@ protected virtual void OnUrlChanged (ValueChangedEventArgs args) { } bool IDesignable.EnableForDesign () { Title = "_Link"; - Text = "https://github.com/gui-cs"; + Url = "https://github.com/gui-cs"; return true; } @@ -177,7 +177,7 @@ bool IDesignable.EnableForDesign () /// Copy the URL to the clipboard contents. public bool Copy () { - SetClipboard (Text); + SetClipboard (Url); return true; } diff --git a/Tests/UnitTestsParallelizable/Views/LinkTests.cs b/Tests/UnitTestsParallelizable/Views/LinkTests.cs index 9520935240..b70ebea6ae 100644 --- a/Tests/UnitTestsParallelizable/Views/LinkTests.cs +++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs @@ -10,45 +10,154 @@ namespace ViewsTests; [TestSubject (typeof (Link))] public class LinkTests (ITestOutputHelper output) : TestDriverBase { + // Claude - Opus 4.6 + [Fact] public void Constructor_Defaults () { Link link = new (); Assert.Equal (Link.DEFAULT_URL, link.Url); - Assert.Equal (Link.DEFAULT_URL, link.Text); + Assert.Equal (string.Empty, link.Text); Assert.Equal (Dim.Auto (DimAutoStyle.Text), link.Height); Assert.Equal (Dim.Auto (DimAutoStyle.Text), link.Width); Assert.True (link.CanFocus); } [Fact] - public void Text_Set_Updates_Title () + public void Text_And_Title_Are_Independent () { - Link link = new () { Text = "Click here" }; + Link link = new () { Text = "Click here", Title = "My Link" }; Assert.Equal ("Click here", link.Text); - Assert.Equal ("Click here", link.Title); + Assert.Equal ("My Link", link.Title); } [Fact] - public void Text_Returns_Url_When_Title_Is_Empty () + public void Text_Set_Does_Not_Change_Url () + { + Link link = new () { Url = "https://github.com", Text = "Click here" }; + + Assert.Equal ("Click here", link.Text); + Assert.Equal ("https://github.com", link.Url); + } + + [Fact] + public void Url_Set_Does_Not_Change_Text () { Link link = new () { Url = "https://github.com" }; - Assert.Equal ("https://github.com", link.Text); + Assert.Equal (string.Empty, link.Text); + Assert.Equal ("https://github.com", link.Url); } [Fact] - public void Url_Set_Validates_Uri () + public void TextFormatter_Uses_Url_When_Text_Is_Empty () { Link link = new () { Url = "https://github.com" }; + // Trigger layout so UpdateTextFormatterText runs + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new Size (80, 25)); + + Assert.Equal ("https://github.com", link.TextFormatter.Text); + } + + [Fact] + public void TextFormatter_Uses_Text_When_Text_Is_Set () + { + Link link = new () { Url = "https://github.com", Text = "Click here" }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new Size (80, 25)); + + Assert.Equal ("Click here", link.TextFormatter.Text); + } + + [Fact] + public void DimAuto_Uses_Text_Width_When_Text_Is_Set () + { + Link link = new () { Text = "Click", Url = "https://github.com/gui-cs/Terminal.Gui" }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new Size (80, 25)); + + // Width should be based on Text ("Click" = 5), not Url + Assert.Equal (5, link.Frame.Width); + } + + [Fact] + public void DimAuto_Uses_Text_Width_When_Text_Has_Wide_Chars () + { + // "ターミナル" = 5 CJK chars × 2 columns each = 10 columns + Link link = new () { Text = "ターミナル", Url = "https://example.com" }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new Size (80, 25)); + + Assert.Equal (10, link.Frame.Width); + } + + [Fact] + public void DimAuto_Uses_Url_Width_When_Text_Is_Empty () + { + Link link = new () { Url = "https://github.com" }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new Size (80, 25)); + + // Width should be based on Url since Text is empty + Assert.Equal ("https://github.com".Length, link.Frame.Width); + } + + [Fact] + public void DimAuto_Uses_Url_Width_When_Url_Has_Wide_Chars () + { + // IRI with CJK path: "https://例え.jp/テスト" + // "https://" = 8, "例え" = 4, ".jp/" = 4, "テスト" = 6 → 22 columns + Link link = new () { Url = "https://例え.jp/テスト" }; + + link.BeginInit (); + link.EndInit (); + link.SetRelativeLayout (new Size (80, 25)); + + Assert.Equal ("https://例え.jp/テスト".GetColumns (), link.Frame.Width); + } + + [Fact] + public void Url_Accepts_Any_String () + { + Link link = new (); + link.Url = "not a valid url"; - Assert.Equal (Link.DEFAULT_URL, link.Url); // Url should not change on invalid + Assert.Equal ("not a valid url", link.Url); link.Url = ""; - Assert.Equal (Link.DEFAULT_URL, link.Url); // Url should not change on invalid + Assert.Equal ("", link.Url); + + link.Url = "https://github.com"; + Assert.Equal ("https://github.com", link.Url); + } + + [Fact] + public void Url_Set_Same_Value_Does_Not_Fire_Events () + { + Link link = new () { Url = "https://github.com" }; + var changingFired = false; + var changedFired = false; + + link.UrlChanging += (_, _) => changingFired = true; + link.UrlChanged += (_, _) => changedFired = true; + + link.Url = "https://github.com"; + + Assert.False (changingFired); + Assert.False (changedFired); } [Fact] @@ -61,7 +170,7 @@ public void Url_Set_Fires_UrlChanged_Event () var eventFired = false; var eventArgsValid = false; - link.UrlChanged += (s, e) => + link.UrlChanged += (_, e) => { eventFired = true; eventArgsValid = e.OldValue == oldUrl && e.NewValue == newUrl; @@ -84,7 +193,7 @@ public void Url_Set_Fires_UrlChanging_Event () var eventArgsValid = false; var valueChanged = false; - link.UrlChanging += (s, e) => + link.UrlChanging += (_, e) => { eventFired = true; eventArgsValid = e.CurrentValue == oldUrl && e.NewValue == newUrl; @@ -100,6 +209,18 @@ public void Url_Set_Fires_UrlChanging_Event () Assert.Equal (newUrl, link.Url); } + [Fact] + public void UrlChanging_Can_Cancel_Change () + { + Link link = new () { Url = "https://original.com" }; + + link.UrlChanging += (_, e) => e.Handled = true; + + link.Url = "https://newurl.com"; + + Assert.Equal ("https://original.com", link.Url); + } + [Fact] public void Copy_Copies_Url_To_Clipboard () { @@ -173,11 +294,35 @@ public void Link_Renders_Without_OSC8_When_Url_Is_Default () window.Dispose (); } + [Fact] + public void Link_Invalid_Url_Renders_With_Disabled_Style () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + + Link link = new () { App = app, Url = "not a valid url", Text = "Bad Link" }; + window.Add (link); + + app.Begin (window); + app.LayoutAndDraw (); + app.Driver!.Refresh (); + + string? look = app.Driver.GetOutput ().GetLastOutput (); + + // Invalid URL should NOT produce OSC 8 hyperlink sequences + Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink ("not a valid url"), look); + Assert.Contains ("Bad Link", look); + + window.Dispose (); + } + [Fact] public void Link_With_HotKey_Passes_To_Next_View () { View superView = new () { CanFocus = true }; - Link link = new () { Text = "_Link", CanFocus = false }; + Link link = new () { Title = "_Link", CanFocus = false }; View nextView = new () { CanFocus = true }; superView.Add (link, nextView); @@ -205,7 +350,6 @@ public void Link_Multiple_Links_Each_Get_Their_Own_Url () using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; - // ✅ Ajouter les DEUX liens dès le début Link link1 = new () { App = app, @@ -285,7 +429,7 @@ public void Link_With_Focus_Draws_With_Focus_Colors () window.Height = Dim.Fill (); window.BorderStyle = LineStyle.None; - // ✅ Add a dummy view to take initial focus + // Add a dummy view to take initial focus View dummyView = new () { CanFocus = true, Width = 1, Height = 1 }; window.Add (dummyView); @@ -303,7 +447,7 @@ public void Link_With_Focus_Draws_With_Focus_Colors () app.LayoutAndDraw (); app.Driver!.Refresh (); - // ✅ Without focus - dummyView has focus instead + // Without focus - dummyView has focus instead Assert.False (link.HasFocus); Assert.True (dummyView.HasFocus); @@ -322,15 +466,16 @@ public void Link_With_Focus_Draws_With_Focus_Colors () } [Fact] - public void IDesignable_EnableForDesign_Sets_Default_Text () + public void IDesignable_EnableForDesign_Sets_Title_And_Url () { Link link = new (); - var designable = link as IDesignable; + IDesignable designable = link; bool result = designable.EnableForDesign (); Assert.True (result); - Assert.Equal ("_Link", link.Text); + Assert.Equal ("_Link", link.Title); + Assert.Equal ("https://github.com/gui-cs", link.Url); link.Dispose (); } From abdb9930b48e1b3e11b31a8c2f1cb9859f0e81bd Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 6 Mar 2026 14:08:06 -0700 Subject: [PATCH 16/16] Improve Link API documentation Add comprehensive XML docs for the Link class covering the three independent text properties (Text, Title, Url), OSC 8 hyperlink rendering, draw-time URL validation, CWP event pattern, HotKey-to-next-peer behavior, and Dim.Auto sizing. Enhance docs for all public members. Co-Authored-By: Claude Opus 4.6 --- Terminal.Gui/Views/Link.cs | 148 ++++++++++++++++++++++++++++++------- 1 file changed, 122 insertions(+), 26 deletions(-) diff --git a/Terminal.Gui/Views/Link.cs b/Terminal.Gui/Views/Link.cs index bf172d2870..5a83c61d37 100644 --- a/Terminal.Gui/Views/Link.cs +++ b/Terminal.Gui/Views/Link.cs @@ -1,10 +1,57 @@ -using System.Diagnostics; +using System.Diagnostics; namespace Terminal.Gui.Views; /// -/// Displays a clickable link with text and url. +/// Displays a clickable hyperlink with optional display text and a target URL. /// +/// +/// +/// has three independent text-related properties: +/// +/// +/// +/// +/// — The display text shown to the user. When empty, is +/// displayed +/// instead. +/// +/// +/// +/// +/// — Controls the . Set this to include an underscore +/// prefix +/// (e.g., "_Link") to define a keyboard shortcut. +/// +/// +/// +/// +/// — The hyperlink target. When the link is accepted (clicked or +/// +/// is invoked), this URL is opened in the default browser via . +/// +/// +/// +/// +/// The link renders using OSC 8 hyperlink escape sequences when the terminal supports them, enabling +/// clickable URLs in modern terminal emulators. If is not a well-formed absolute URI, +/// the link renders with the style and OSC 8 sequences are suppressed. +/// +/// +/// changes follow the Cancellable Workflow Pattern (CWP): the event +/// fires before the change (and can cancel it by setting to +/// ), and the event fires after. +/// +/// +/// When is and the link has a valid , +/// pressing the HotKey passes focus to the next peer in the SuperView's SubView list. This +/// enables to act as a label-like hotkey proxy (similar to ). +/// +/// +/// Both and default to , +/// so the link auto-sizes to fit whichever text is displayed ( or ). +/// +/// public class Link : View, IDesignable { /// @@ -21,7 +68,11 @@ public Link () MouseBindings.Add (MouseFlags.LeftButtonClicked, Command.Accept); } - /// + /// + /// Handles activation. If is , delegates to the + /// command (which passes focus to the next peer view). Otherwise, uses the + /// default activation behavior. + /// protected override bool OnActivating (CommandEventArgs args) { // If Link can't focus and is activated, invoke HotKey on next peer @@ -34,7 +85,8 @@ protected override bool OnActivating (CommandEventArgs args) } /// - /// Opens . + /// Called when the link is accepted (e.g., clicked or is invoked). + /// Opens in the default browser via . /// protected override void OnAccepted (ICommandContext? ctx) { @@ -44,9 +96,17 @@ protected override void OnAccepted (ICommandContext? ctx) } /// - /// Opens the specified URL in the default web browser. The implementation is platform-specific: + /// Opens the specified URL in the default web browser using a platform-specific mechanism. /// - /// + /// + /// + /// On Windows, uses cmd /c start. On macOS, uses open. On Linux, uses xdg-open. + /// + /// + /// Ampersands in the URL are escaped on Windows to prevent shell interpretation. + /// + /// + /// The URL to open. Should be a well-formed absolute URI. public static void OpenUrl (string url) { if (PlatformDetection.IsWindows ()) @@ -76,11 +136,15 @@ public static void OpenUrl (string url) } /// - /// Updates the text displayed by the text formatter based on the current value of the control's text property. + /// Updates the text displayed by the based on the current values of + /// and . /// /// - /// If the base text is null or empty, the formatter displays the URL instead. Otherwise, the - /// base implementation is used. This method is used by the Layout engine to determine the text Size. + /// + /// If is or empty, the formatter displays + /// instead, ensuring the link auto-sizes correctly via . Otherwise, the + /// base implementation is used. + /// /// protected override void UpdateTextFormatterText () { @@ -97,41 +161,58 @@ protected override void UpdateTextFormatterText () } /// - /// Represents the default URL used when no specific URL is provided. + /// The default value for — an empty string indicating no URL is associated with the link. /// - /// - /// An empty string indicates that no URL is associated with the link. - /// public const string DEFAULT_URL = ""; private string _url = DEFAULT_URL; /// - /// Gets or sets the URL associated with this instance. If is empty, the URL will be displayed as a - /// clickable link. If is set, - /// it will be displayed as a clickable link. + /// Gets or sets the URL (hyperlink target) associated with this . /// + /// + /// + /// Any string value is accepted. URL validation occurs at draw time: if the value is not a well-formed + /// absolute URI (per ), the link renders with the + /// style and no OSC 8 hyperlink sequence is emitted. + /// + /// + /// When is empty, is used as the display text. + /// + /// + /// Setting this property follows the Cancellable Workflow Pattern: and + /// fire before the change and can cancel it; and + /// fire after. Setting the same value is a no-op. + /// + /// public string Url { get => _url; set => SetUrl (value); } /// - /// Raised when is about to change. - /// Set to to cancel the change. + /// Raised when is about to change. Set to + /// to cancel the change and keep the current value. /// public event EventHandler>? UrlChanging; /// - /// URL changed event, raised when the URL has changed. + /// Raised after has changed. The and + /// properties contain the previous and current values. /// public event EventHandler>? UrlChanged; /// - /// Called before changes. Return to cancel the change. + /// Called before changes. Override in subclasses to implement validation or cancel the change. /// + /// + /// Contains the current and proposed new values. Set to + /// to cancel the change. + /// + /// to cancel the change; to allow it. protected virtual bool OnUrlChanging (ValueChangingEventArgs args) => false; /// - /// Called after has changed. + /// Called after has changed. Override in subclasses to react to URL changes. /// + /// Contains the old and new URL values. protected virtual void OnUrlChanged (ValueChangedEventArgs args) { } private bool? InvokeHotKeyOnNextPeer (ICommandContext commandContext) @@ -174,7 +255,10 @@ bool IDesignable.EnableForDesign () return true; } - /// Copy the URL to the clipboard contents. + /// + /// Copies the current to the system clipboard. + /// + /// if the copy operation was initiated. public bool Copy () { SetClipboard (Url); @@ -185,11 +269,22 @@ public bool Copy () private void SetClipboard (string text) => App?.Clipboard?.SetClipboardData (text); /// - /// Draws the Link. If is empty, the will be drawn; otherwise - /// will be drawn. + /// Draws the link text with OSC 8 hyperlink sequences when the URL is valid. /// - /// - /// + /// + /// + /// If is empty, is drawn; otherwise is drawn. + /// The displayed text is determined by . + /// + /// + /// If is a well-formed absolute URI, the driver's CurrentUrl is set so that + /// all drawn cells carry the URL (enabling OSC 8 hyperlink output in supporting terminals). + /// If the URL is not well-formed, the text is rendered with the style + /// and no hyperlink sequence is emitted. + /// + /// + /// The draw context for tracking drawn regions. + /// — drawing is always handled by . protected override bool OnDrawingText (DrawContext? context) { if (Driver is null) @@ -243,6 +338,7 @@ private void SetUrl (string value) { return; } + string oldValue = _url; // CWP: Fire ValueChanging (allows cancellation)