Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Terminal.Gui/Drivers/Output/OutputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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++)
{
Expand All @@ -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;
}
Comment thread
tig marked this conversation as resolved.

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)
{
Expand Down
6 changes: 6 additions & 0 deletions Terminal.Gui/Drivers/Output/OutputBufferImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ public class OutputBufferImpl : IOutputBuffer
/// <returns>The URL if one exists, otherwise null.</returns>
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;
Expand Down
151 changes: 151 additions & 0 deletions Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Comment thread
tig marked this conversation as resolved.

// 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)]
Expand Down
57 changes: 57 additions & 0 deletions plans/osc8-toansi-hyperlinks.md
Original file line number Diff line number Diff line change
@@ -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
Loading