diff --git a/Terminal.Gui/Drivers/Output/OutputBase.cs b/Terminal.Gui/Drivers/Output/OutputBase.cs index 87e0f7eee1..7458b83f6d 100644 --- a/Terminal.Gui/Drivers/Output/OutputBase.cs +++ b/Terminal.Gui/Drivers/Output/OutputBase.cs @@ -311,6 +311,7 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, bool addNewlines = true) { var redrawTextStyle = TextStyle.None; + string? lastUrl = null; for (int row = startRow; row < endRow; row++) { @@ -321,11 +322,36 @@ protected void BuildAnsiForRegion (IOutputBuffer buffer, continue; } + // Handle OSC 8 hyperlink state transitions + string? cellUrl = buffer.GetCellUrl (col, row); + + if (cellUrl != lastUrl) + { + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + } + + if (!string.IsNullOrEmpty (cellUrl)) + { + output.Append (EscSeqUtils.OSC_StartHyperlink (cellUrl)); + } + + lastUrl = cellUrl; + } + Cell cell = buffer.Contents! [row, col]; int outputWidth = -1; AppendCellAnsi (cell, output, ref lastAttr, ref redrawTextStyle, endCol, ref col, ref outputWidth); } + // Close any open hyperlink at end of row + if (lastUrl is { }) + { + output.Append (EscSeqUtils.OSC_EndHyperlink ()); + lastUrl = null; + } + // Add newline at end of row if requested if (addNewlines) { diff --git a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs index 9a40d2153d..d01321c1d9 100644 --- a/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs +++ b/Terminal.Gui/Drivers/Output/OutputBufferImpl.cs @@ -58,6 +58,12 @@ public class OutputBufferImpl : IOutputBuffer /// The URL if one exists, otherwise null. public string? GetCellUrl (int col, int row) { + // Fast-path: skip locking when no URLs have been set + if (_urlMap is null) + { + return null; + } + lock (_contentsLock) { return _urlMap?.TryGetValue (new Point (col, row), out string? url) == true ? url : null; diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs index b27ff18b5d..d809665635 100644 --- a/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs @@ -231,6 +231,157 @@ public void Write_Virtual_Or_NonVirtual_Uses_WriteToConsole_And_Clears_Dirty_Fla Assert.False (buffer.Contents! [0, 2].IsDirty); } + // Copilot + [Fact] + public void ToAnsi_CellsWithUrl_EmitsOsc8Sequences () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (5, 1); + + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Hello"); + buffer.CurrentUrl = null; + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert + string expectedStart = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + string expectedEnd = EscSeqUtils.OSC_EndHyperlink (); + + Assert.Contains (expectedStart, ansi); + Assert.Contains (expectedEnd, ansi); + Assert.Contains ("Hello", ansi); + } + + // Copilot + [Fact] + public void ToAnsi_CellsWithDifferentUrls_EmitsCorrectTransitions () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (6, 1); + + buffer.CurrentUrl = "https://one.com"; + buffer.AddStr ("AB"); + buffer.CurrentUrl = "https://two.com"; + buffer.AddStr ("CD"); + buffer.CurrentUrl = null; + buffer.AddStr ("EF"); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: verify hyperlink transitions are emitted in order + string startOne = EscSeqUtils.OSC_StartHyperlink ("https://one.com"); + string startTwo = EscSeqUtils.OSC_StartHyperlink ("https://two.com"); + string end = EscSeqUtils.OSC_EndHyperlink (); + + int startOneIdx = ansi.IndexOf (startOne, StringComparison.Ordinal); + int endOneIdx = ansi.IndexOf (end, startOneIdx + startOne.Length, StringComparison.Ordinal); + int startTwoIdx = ansi.IndexOf (startTwo, endOneIdx + end.Length, StringComparison.Ordinal); + int endTwoIdx = ansi.IndexOf (end, startTwoIdx + startTwo.Length, StringComparison.Ordinal); + int nonUrlTextIdx = ansi.IndexOf ("EF", endTwoIdx + end.Length, StringComparison.Ordinal); + + Assert.True (startOneIdx >= 0, "First OSC 8 start not found"); + Assert.True (endOneIdx > startOneIdx, "First OSC 8 end not found after first start"); + Assert.True (startTwoIdx > endOneIdx, "Second OSC 8 start should appear after first OSC 8 end"); + Assert.True (endTwoIdx > startTwoIdx, "Second OSC 8 end not found after second start"); + Assert.True (nonUrlTextIdx > endTwoIdx, "Non-URL text should appear after second OSC 8 end"); + } + + // Copilot + [Fact] + public void ToAnsi_CellsWithUrl_ThenNoUrl_ClosesHyperlink () + { + // Arrange + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (6, 1); + + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Link"); + buffer.CurrentUrl = null; + buffer.AddStr (" "); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: hyperlink is opened and closed + string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + string end = EscSeqUtils.OSC_EndHyperlink (); + + int startIdx = ansi.IndexOf (start, StringComparison.Ordinal); + int endIdx = ansi.IndexOf (end, startIdx + start.Length, StringComparison.Ordinal); + + Assert.True (startIdx >= 0, "OSC 8 start not found"); + Assert.True (endIdx > startIdx, "OSC 8 end not found after start"); + + // "Link" text should be between start and end + int textIdx = ansi.IndexOf ("Link", startIdx, StringComparison.Ordinal); + Assert.True (textIdx > startIdx && textIdx < endIdx, "Link text should be between OSC 8 sequences"); + } + + // Copilot + [Fact] + public void ToAnsi_UrlAtEndOfRow_ClosedBeforeNewline () + { + // Arrange: 2-row buffer, URL at end of row 0, plain text on row 1 + AnsiOutput output = new (); + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (4, 2); + + // Row 0: "Link" with URL + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Link"); + buffer.CurrentUrl = null; + + // Row 1: "Text" without URL + buffer.Move (0, 1); + buffer.AddStr ("Text"); + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: OSC 8 end appears before the newline that precedes row 1 text + string end = EscSeqUtils.OSC_EndHyperlink (); + int endIdx = ansi.IndexOf (end, StringComparison.Ordinal); + int textIdx = ansi.IndexOf ("Text", StringComparison.Ordinal); + + Assert.True (endIdx >= 0, "OSC 8 end not found"); + Assert.True (textIdx > endIdx, "Row 1 text should appear after OSC 8 end (hyperlink closed at row boundary)"); + + // Verify no OSC 8 start appears on row 1 + string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com"); + int secondStart = ansi.IndexOf (start, endIdx + end.Length, StringComparison.Ordinal); + Assert.True (secondStart < 0, "No OSC 8 start should appear on row 1"); + } + + // Copilot + [Fact] + public void ToAnsi_LegacyConsole_NoOsc8 () + { + // Arrange + AnsiOutput output = new () { IsLegacyConsole = true }; + IOutputBuffer buffer = output.GetLastBuffer ()!; + buffer.SetSize (5, 1); + + buffer.CurrentUrl = "https://example.com"; + buffer.AddStr ("Hello"); + buffer.CurrentUrl = null; + + // Act + string ansi = output.ToAnsi (buffer); + + // Assert: legacy console should NOT contain OSC 8 sequences + Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink ("https://example.com"), ansi); + Assert.DoesNotContain (EscSeqUtils.OSC_EndHyperlink (), ansi); + Assert.Contains ("Hello", ansi); + } + [Theory] [InlineData (true)] [InlineData (false)] diff --git a/plans/osc8-toansi-hyperlinks.md b/plans/osc8-toansi-hyperlinks.md new file mode 100644 index 0000000000..fb2420a4b9 --- /dev/null +++ b/plans/osc8-toansi-hyperlinks.md @@ -0,0 +1,57 @@ +# Plan: Emit OSC 8 Hyperlink Sequences in `ToAnsi()` + +## Problem Statement + +When `Driver.ToAnsi()` is called (e.g., for print mode in `clet --help` or `mdv --print`), the resulting ANSI string includes SGR styling (underline, color) for links but does NOT include [OSC 8 hyperlink sequences](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda). This means links are visually styled but not clickable in terminals that support OSC 8. + +## Existing Infrastructure + +The codebase already has all the pieces: + +1. **Cell-level URL storage**: `IOutputBuffer.GetCellUrl(col, row)` returns the URL associated with a cell. `OutputBufferImpl` stores these in a `_urlMap` dictionary keyed by `Point(col, row)`. + +2. **URL assignment during draw**: Both `Link` view and `Markdown` view set `Driver.CurrentUrl` before drawing cells. The `OutputBufferImpl.AddStr`/`AddRune` methods call `SetCellUrl` when `CurrentUrl` is non-null. + +3. **OSC 8 utilities**: `EscSeqUtils.OSC_StartHyperlink(url)` and `EscSeqUtils.OSC_EndHyperlink()` already exist. + +4. **Real-time rendering already works**: `OutputBase.Write(IOutputBuffer)` already queries `buffer.GetCellUrl(col, row)` and emits OSC 8 sequences during live terminal output. + +5. **`ToAnsi()` does NOT emit OSC 8**: `BuildAnsiForRegion` (called by `ToAnsi`) only handles SGR attribute changes and graphemes — it has no URL tracking. + +## Design + +### Approach + +Modify `BuildAnsiForRegion` in `OutputBase.cs` to track the current URL state and emit OSC 8 open/close sequences when the URL changes between cells. + +### Implementation + +In `BuildAnsiForRegion`, add a `string? lastUrl = null` tracker. For each cell: +1. Query `buffer.GetCellUrl(col, row)` to get the cell's URL +2. If the URL differs from `lastUrl`: + - If `lastUrl` was non-null, emit `EscSeqUtils.OSC_EndHyperlink()` to close the previous link + - If the new URL is non-null, emit `EscSeqUtils.OSC_StartHyperlink(url)` to open the new link + - Update `lastUrl` +3. After the loop completes (or at end of each row), if `lastUrl` is non-null, emit `EscSeqUtils.OSC_EndHyperlink()` + +### Files Changed + +- `Terminal.Gui/Drivers/Output/OutputBase.cs` — `BuildAnsiForRegion` method + +## Unit Tests + +Tests in `Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs`: + +1. **`ToAnsi_CellsWithUrl_EmitsOsc8Sequences`**: Create a buffer, set `CurrentUrl`, write text, verify `ToAnsi()` output contains OSC 8 start/end sequences. + +2. **`ToAnsi_CellsWithDifferentUrls_EmitsCorrectTransitions`**: Verify that transitioning between different URLs properly closes the first and opens the second. + +3. **`ToAnsi_CellsWithUrl_ThenNoUrl_ClosesHyperlink`**: Verify that when URL cells are followed by non-URL cells, the hyperlink is properly closed. + +4. **`ToAnsi_LegacyConsole_NoOsc8`**: Verify that legacy console mode does not emit OSC 8. + +## Verification + +- Existing `LinkTests.Link_Renders_With_OSC8_Hyperlink` test already verifies OSC 8 in live rendering +- New tests verify OSC 8 in `ToAnsi()` output specifically +- Run `MarkdownViewTests` to ensure no regressions