diff --git a/Examples/UICatalog/Scenarios/Links.cs b/Examples/UICatalog/Scenarios/Links.cs
new file mode 100644
index 0000000000..64acd13fd3
--- /dev/null
+++ b/Examples/UICatalog/Scenarios/Links.cs
@@ -0,0 +1,87 @@
+#nullable enable
+
+namespace UICatalog.Scenarios;
+
+[ScenarioMetadata ("Links", "Demonstrates how Links work.")]
+[ScenarioCategory ("Controls")]
+[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 ();
+ _app = app;
+
+ _appWindow = new Window { Title = GetName (), BorderStyle = LineStyle.None };
+
+ 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 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 (titleTextField) + 1 };
+ _appWindow.Add (urlLabel);
+
+ 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 (urlTextField) + 2 };
+ _appWindow.Add (simpleUrlLabel);
+
+ FrameView linkFrame = new ()
+ {
+ Title = "_Link Demo",
+ X = 0,
+ Y = Pos.Bottom (simpleUrlLabel) + 2,
+ Width = Dim.Fill(),
+ Height = Dim.Auto (),
+ AssignHotKeys = true,
+ TabStop = TabBehavior.TabStop
+ };
+
+ _link = new Link { X = 1, Y = 1, BorderStyle = LineStyle.Dotted };
+
+ _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);
+
+ 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.AnchorEnd () };
+ copyButton.Accepting += (s, e) => _link.Copy ();
+
+ linkFrame.Add (copyButton);
+
+ _appWindow.Add (linkFrame);
+
+ // StatusBar
+ Shortcut urlIndicator = new (Key.Empty, "", null);
+
+ 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);
+
+ _app.Run (_appWindow);
+ _appWindow.Dispose ();
+ }
+
+ 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/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs
index ca46daf3a7..7bedccce15 100644
--- a/Terminal.Gui/Drivers/DriverImpl.cs
+++ b/Terminal.Gui/Drivers/DriverImpl.cs
@@ -229,11 +229,15 @@ 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 ()
{
_outputBuffer.ClearContents ();
ClearedContents?.Invoke (this, EventArgs.Empty);
+ CurrentUrl = null;
}
///
diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs
index f2115f0468..0107230935 100644
--- a/Terminal.Gui/Drivers/IDriver.cs
+++ b/Terminal.Gui/Drivers/IDriver.cs
@@ -187,6 +187,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..c34ffe3a4b 100644
--- a/Terminal.Gui/Drivers/Output/IOutputBuffer.cs
+++ b/Terminal.Gui/Drivers/Output/IOutputBuffer.cs
@@ -115,6 +115,21 @@ 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; }
+
+ ///
+ /// 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 0d42a9ace1..bcac7f9759 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 ();
private bool _clearLastOutputPending;
@@ -77,6 +80,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++)
@@ -115,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;
@@ -140,6 +167,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);
diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
index ce4f63748f..9fe6d12eb8 100644
--- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
+++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
@@ -25,6 +25,41 @@ 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; }
+
+ ///
+ /// 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;
@@ -214,6 +249,12 @@ private void SetAttributeAndDirty (int col, int row)
{
Contents! [row, col].Attribute = CurrentAttribute;
Contents [row, col].IsDirty = true;
+
+ // If CurrentUrl is set, store it in the URL map
+ if (!string.IsNullOrEmpty (CurrentUrl))
+ {
+ SetCellUrl (col, row, CurrentUrl);
+ }
}
///
@@ -321,6 +362,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
new file mode 100644
index 0000000000..5a83c61d37
--- /dev/null
+++ b/Terminal.Gui/Views/Link.cs
@@ -0,0 +1,371 @@
+using System.Diagnostics;
+
+namespace Terminal.Gui.Views;
+
+///
+/// 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
+{
+ ///
+ 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!);
+
+ 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
+ if (!CanFocus)
+ {
+ return InvokeCommand (Command.HotKey, args.Context) == true;
+ }
+
+ return base.OnActivating (args);
+ }
+
+ ///
+ /// Called when the link is accepted (e.g., clicked or is invoked).
+ /// Opens in the default browser via .
+ ///
+ protected override void OnAccepted (ICommandContext? ctx)
+ {
+ base.OnAccepted (ctx);
+
+ OpenUrl (Url);
+ }
+
+ ///
+ /// 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 ())
+ {
+ 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 based on the current values of
+ /// and .
+ ///
+ ///
+ ///
+ /// 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 ()
+ {
+ if (string.IsNullOrEmpty (base.Text))
+ {
+ TextFormatter.Text = Url;
+ TextFormatter.ConstrainToWidth = null;
+ TextFormatter.ConstrainToHeight = null;
+ }
+ else
+ {
+ base.UpdateTextFormatterText ();
+ }
+ }
+
+ ///
+ /// The default value for — an empty string indicating no URL is associated with the link.
+ ///
+ public const string DEFAULT_URL = "";
+
+ private string _url = DEFAULT_URL;
+
+ ///
+ /// 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 and keep the current value.
+ ///
+ public event EventHandler>? UrlChanging;
+
+ ///
+ /// Raised after has changed. The and
+ /// properties contain the previous and current values.
+ ///
+ public event EventHandler>? UrlChanged;
+
+ ///
+ /// 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. 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)
+ {
+ 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;
+ }
+
+ ///
+ bool IDesignable.EnableForDesign ()
+ {
+ Title = "_Link";
+ Url = "https://github.com/gui-cs";
+
+ return true;
+ }
+
+ ///
+ /// Copies the current to the system clipboard.
+ ///
+ /// if the copy operation was initiated.
+ public bool Copy ()
+ {
+ SetClipboard (Url);
+
+ return true;
+ }
+
+ private void SetClipboard (string text) => App?.Clipboard?.SetClipboardData (text);
+
+ ///
+ /// 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)
+ {
+ return base.OnDrawingText (context);
+ }
+
+ Rectangle drawRect = new (ContentToScreen (Point.Empty), GetContentSize ());
+
+ Region textDrawRegion = TextFormatter.GetDrawRegion (drawRect);
+
+ // Report the drawn area to the context
+ context?.AddDrawnRegion (textDrawRegion);
+
+ Attribute normalAttr = HasFocus ? GetAttributeForRole (VisualRole.Focus) : GetAttributeForRole (VisualRole.Normal);
+ Attribute hotAttr = HasFocus ? GetAttributeForRole (VisualRole.HotFocus) : GetAttributeForRole (VisualRole.HotNormal);
+
+ string? url = Url;
+
+ // 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;
+
+ 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;
+ }
+
+ // 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)
+ {
+ if (_url == value)
+ {
+ return;
+ }
+
+ 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);
+
+ // 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
new file mode 100644
index 0000000000..b70ebea6ae
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Views/LinkTests.cs
@@ -0,0 +1,542 @@
+using JetBrains.Annotations;
+using UnitTests;
+
+namespace ViewsTests;
+
+///
+/// Unit tests for that don't require static Application dependencies.
+/// These tests can run in parallel without interference.
+///
+[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 (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_And_Title_Are_Independent ()
+ {
+ Link link = new () { Text = "Click here", Title = "My Link" };
+
+ Assert.Equal ("Click here", link.Text);
+ Assert.Equal ("My Link", link.Title);
+ }
+
+ [Fact]
+ 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 (string.Empty, link.Text);
+ Assert.Equal ("https://github.com", link.Url);
+ }
+
+ [Fact]
+ 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 ("not a valid url", link.Url);
+
+ link.Url = "";
+ 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]
+ public void Url_Set_Fires_UrlChanged_Event ()
+ {
+ var oldUrl = "http://oldvalue.io";
+ var newUrl = "http://newvalue.io";
+
+ Link link = new () { Url = oldUrl };
+ var eventFired = false;
+ var eventArgsValid = false;
+
+ link.UrlChanged += (_, e) =>
+ {
+ eventFired = true;
+ eventArgsValid = e.OldValue == oldUrl && e.NewValue == newUrl;
+ };
+ link.Url = newUrl;
+
+ Assert.True (eventFired);
+ Assert.True (eventArgsValid);
+ Assert.Equal (newUrl, link.Url);
+ }
+
+ [Fact]
+ public void Url_Set_Fires_UrlChanging_Event ()
+ {
+ var oldUrl = "http://oldvalue.io";
+ var newUrl = "http://newvalue.io";
+
+ Link link = new () { Url = oldUrl };
+ var eventFired = false;
+ var eventArgsValid = false;
+ var valueChanged = false;
+
+ link.UrlChanging += (_, 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.True (eventArgsValid);
+ Assert.False (valueChanged);
+ 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 ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Clipboard = new FakeClipboard ();
+
+ Link link = new () { App = app, Url = "https://github.com" };
+
+ bool 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);
+
+ 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" };
+ window.Add (link);
+
+ 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 present
+ string expectedStart = EscSeqUtils.OSC_StartHyperlink ("https://github.com/gui-cs/Terminal.Gui");
+ string expectedEnd = EscSeqUtils.OSC_EndHyperlink ();
+
+ Assert.Contains (expectedStart, look);
+ Assert.Contains (expectedEnd, look);
+ Assert.Contains ("Terminal.Gui", look);
+ Assert.Contains ("Terminal.Gui", ansi);
+
+ window.Dispose ();
+ }
+
+ [Fact]
+ 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 };
+
+ Link link = new () { App = app, Text = "Not a link" };
+ window.Add (link);
+
+ 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), look);
+ Assert.Contains ("Not a link", look);
+ Assert.Contains ("Not a link", ansi);
+
+ 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 () { Title = "_Link", CanFocus = false };
+ View nextView = new () { 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.Driver!.SetScreenSize (40, 2);
+
+ using Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None };
+
+ Link link1 = new ()
+ {
+ App = app,
+ X = 0,
+ Y = 0,
+ Url = "https://github.com",
+ Text = "GitHub"
+ };
+
+ Link link2 = new ()
+ {
+ App = app,
+ X = 0,
+ Y = 1,
+ Url = "https://microsoft.com",
+ Text = "Microsoft"
+ };
+
+ window.Add (link1, link2);
+
+ app.Begin (window);
+ app.LayoutAndDraw ();
+ app.Driver.Refresh ();
+
+ string? look = app.Driver.GetOutput ().GetLastOutput ();
+ string ansi = app.Driver.ToAnsi ();
+
+ // 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);
+
+ window.Dispose ();
+ }
+
+ [Fact]
+ 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 };
+
+ Link link = new () { App = app, Url = "https://example.com", Text = "Example" };
+ window.Add (link);
+
+ app.Begin (window);
+ app.LayoutAndDraw ();
+ app.Driver!.Refresh ();
+
+ 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";
+ app.LayoutAndDraw ();
+ app.Driver!.Refresh ();
+
+ string? look2 = app.Driver.GetOutput ().GetLastOutput ();
+ Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://newurl.com"), look2);
+
+ window.Dispose ();
+ }
+
+ [Fact]
+ public void Link_With_Focus_Draws_With_Focus_Colors ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+
+ 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 };
+ window.Add (dummyView);
+
+ Link link = new ()
+ {
+ App = app,
+ Url = "https://github.com",
+ Text = "GitHub",
+ CanFocus = true,
+ Y = 1 // Place it below dummyView
+ };
+ window.Add (link);
+
+ app.Begin (window);
+ app.LayoutAndDraw ();
+ app.Driver!.Refresh ();
+
+ // Without focus - dummyView has focus instead
+ Assert.False (link.HasFocus);
+ Assert.True (dummyView.HasFocus);
+
+ // 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? look = app.Driver.GetOutput ().GetLastOutput ();
+ Assert.Contains (EscSeqUtils.OSC_StartHyperlink ("https://github.com"), look);
+
+ window.Dispose ();
+ }
+
+ [Fact]
+ public void IDesignable_EnableForDesign_Sets_Title_And_Url ()
+ {
+ Link link = new ();
+ IDesignable designable = link;
+
+ bool result = designable.EnableForDesign ();
+
+ Assert.True (result);
+ Assert.Equal ("_Link", link.Title);
+ Assert.Equal ("https://github.com/gui-cs", link.Url);
+
+ link.Dispose ();
+ }
+
+ [Fact]
+ 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 ();
+
+ Assert.False (link.HasFocus);
+ Assert.False (nextView.HasFocus);
+
+ // Click on the link
+ link.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonReleased });
+
+ Assert.False (link.HasFocus);
+ Assert.True (nextView.HasFocus);
+
+ superView.Dispose ();
+ }
+
+ [Fact]
+ public void Link_Osc8_Emits_StartTextEnd_And_Outputs_Correctly ()
+ {
+ var text = "GitHub";
+ var 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 };
+ window.SetScheme (new Scheme (new Attribute (Color.Black, Color.White)));
+
+ 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[97m\x1b[40mGitHub\x1b]8;;\x1b\\\x1b[30m\x1b[107m
+ """,
+ output,
+ app.Driver);
+ }
+}
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