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