Skip to content

Commit e21615e

Browse files
authored
Merge pull request #5276 from harder/harder/issue-5275-textview-osc8
Fixes #5275. Clear stale TextView OSC 8 hyperlinks
2 parents f07ff65 + 7d30ac7 commit e21615e

4 files changed

Lines changed: 493 additions & 30 deletions

File tree

Terminal.Gui/Drivers/AnsiHandling/Osc8UrlLinker.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ namespace Terminal.Gui.Drivers;
33

44
internal static class Osc8UrlLinker
55
{
6+
internal readonly record struct UrlRange (int Start, int Length, string Url);
7+
68
internal readonly struct Options
79
{
810
internal readonly string [] _allowedSchemes;
@@ -30,6 +32,11 @@ internal static StringBuilder WrapOsc8 (StringBuilder input)
3032
return WrapOsc8 (input, _defaultOptions);
3133
}
3234

35+
internal static List<UrlRange> FindUrls (string input)
36+
{
37+
return FindUrls (input, _defaultOptions);
38+
}
39+
3340
internal static StringBuilder WrapOsc8 (StringBuilder input, Options options)
3441
{
3542
if (input.Length == 0)
@@ -103,6 +110,76 @@ internal static StringBuilder WrapOsc8 (StringBuilder input, Options options)
103110
return result;
104111
}
105112

113+
private static List<UrlRange> FindUrls (string input, Options options)
114+
{
115+
List<UrlRange> ranges = [];
116+
ReadOnlySpan<char> span = input.AsSpan ();
117+
ReadOnlySpan<char> delimiter = "://".AsSpan ();
118+
int i = 0;
119+
120+
while (i < span.Length)
121+
{
122+
int rel = span.Slice (i).IndexOf (delimiter, StringComparison.Ordinal);
123+
if (rel < 0)
124+
{
125+
break;
126+
}
127+
128+
int delimAt = i + rel;
129+
int schemeEnd = delimAt;
130+
int schemeStart = schemeEnd - 1;
131+
132+
while (schemeStart >= 0 && char.IsLetter (span [schemeStart]))
133+
{
134+
schemeStart--;
135+
}
136+
137+
schemeStart++;
138+
139+
if (schemeStart < 0 || schemeStart >= schemeEnd)
140+
{
141+
i = delimAt + delimiter.Length;
142+
continue;
143+
}
144+
145+
ReadOnlySpan<char> scheme = span.Slice (schemeStart, schemeEnd - schemeStart);
146+
if (!IsAllowedScheme (scheme, options))
147+
{
148+
i = delimAt + delimiter.Length;
149+
continue;
150+
}
151+
152+
int urlStart = schemeStart;
153+
int j = delimAt + delimiter.Length;
154+
155+
while (j < span.Length && !IsUrlTerminator (span [j]))
156+
{
157+
j++;
158+
}
159+
160+
int urlEnd = TrimTrailingPunctuation (span, urlStart, j);
161+
if (urlEnd <= (delimAt + delimiter.Length))
162+
{
163+
i = j;
164+
continue;
165+
}
166+
167+
string candidate = span.Slice (urlStart, urlEnd - urlStart).ToString ();
168+
169+
Uri? _;
170+
if (options._validateWithUri && !IsValidUrl (candidate, options, out _))
171+
{
172+
i = j;
173+
continue;
174+
}
175+
176+
ranges.Add (new (urlStart, urlEnd - urlStart, candidate));
177+
i = j;
178+
}
179+
180+
return ranges;
181+
}
182+
106183
private static int ParseEscapeSequence (string text, int start, int len)
107184
{
108185
int i = start;
@@ -341,4 +418,4 @@ private static bool IsValidUrl (string candidate, Options options, out Uri? uri)
341418
uri = null;
342419
return false;
343420
}
344-
}
421+
}

Terminal.Gui/Drivers/Output/OutputBase.cs

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,27 @@ public bool IsLegacyConsole
5858
// Last URL used for tracking hyperlink state
5959
private string? _lastUrl = null;
6060

61+
// Rows that contained URLs in the last rendered frame; used to emit OSC 8 close
62+
// before re-rendering a row that has since lost all URL cells, so terminals don't
63+
// keep stale hyperlink metadata.
64+
private readonly HashSet<int> _rowsWithUrls = [];
65+
66+
// Identifies the buffer state we last synced _rowsWithUrls against. When the buffer
67+
// is replaced, resized, or its URL maps are wiped, this stops matching and we drop
68+
// the stale tracking before reading it.
69+
private IOutputBuffer? _lastTrackedBuffer;
70+
private int _lastTrackedRows;
71+
private int _lastTrackedCols;
72+
private int _lastTrackedUrlVersion;
73+
6174
private readonly StringBuilder _lastOutputStringBuilder = new ();
6275
private bool _clearLastOutputPending;
6376

6477
/// <summary>
65-
/// Writes dirty cells from the buffer to the console. Hides cursor, iterates rows/cols,
66-
/// skips clean cells, batches dirty cells into ANSI sequences, wraps URLs with OSC 8,
67-
/// then renders sixel images. Cursor visibility is managed by <c>ApplicationMainLoop.SetCursor()</c>.
78+
/// Writes dirty cells from the buffer to the console. Iterates rows/cols, skips clean cells,
79+
/// batches dirty cells into ANSI sequences, emits OSC 8 hyperlink start/close around URL cells,
80+
/// and finally renders queued sixel images. Cursor visibility is managed by
81+
/// <c>ApplicationMainLoop.SetCursor()</c>.
6882
/// </summary>
6983
public virtual void Write (IOutputBuffer buffer)
7084
{
@@ -77,6 +91,8 @@ public virtual void Write (IOutputBuffer buffer)
7791
Attribute? redrawAttr = null;
7892
int lastCol = -1;
7993

94+
InvalidateRowsWithUrlsIfStale (buffer, rows, cols);
95+
8096
// Process each row
8197
for (int row = top; row < rows; row++)
8298
{
@@ -93,9 +109,22 @@ public virtual void Write (IOutputBuffer buffer)
93109
return;
94110
}
95111

112+
if (!IsLegacyConsole && buffer is OutputBufferImpl outputBuffer)
113+
{
114+
outputBuffer.SyncAutoUrlsForRow (row);
115+
}
116+
117+
bool rowHadUrlsPreviously = _rowsWithUrls.Contains (row);
118+
bool rowHasUrlsNow = !IsLegacyConsole && RowContainsUrls (buffer, row, cols);
119+
96120
outputStringBuilder.Clear ();
97121
_lastUrl = null; // Reset URL state at the start of each row
98122

123+
if (!IsLegacyConsole && rowHadUrlsPreviously && !rowHasUrlsNow)
124+
{
125+
outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ());
126+
}
127+
99128
// Process columns in row
100129
for (int col = left; col < cols; col++)
101130
{
@@ -170,6 +199,21 @@ public virtual void Write (IOutputBuffer buffer)
170199
}
171200
}
172201

202+
// Track row's URL status BEFORE the early-exit so _rowsWithUrls stays consistent
203+
// with the buffer state — even for rows whose cells were all flushed via WriteToConsole
204+
// during the inner loop (leaving outputStringBuilder empty at this point).
205+
if (!IsLegacyConsole)
206+
{
207+
if (rowHasUrlsNow)
208+
{
209+
_rowsWithUrls.Add (row);
210+
}
211+
else
212+
{
213+
_rowsWithUrls.Remove (row);
214+
}
215+
}
216+
173217
// Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink
174218
// may still be open in the terminal because it was started in a prior batch flushed by
175219
// WriteToConsole and the row ended (or only clean cells followed) before any cell with
@@ -197,9 +241,7 @@ public virtual void Write (IOutputBuffer buffer)
197241

198242
SetCursorPositionImpl (lastCol, row);
199243

200-
// Wrap URLs with OSC 8 hyperlink sequences
201-
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder);
202-
Write (processed);
244+
Write (outputStringBuilder);
203245
}
204246

205247
if (IsLegacyConsole)
@@ -442,6 +484,11 @@ public string ToAnsi (IOutputBuffer buffer)
442484
return output.ToString ();
443485
}
444486

487+
if (buffer is OutputBufferImpl outputBuffer)
488+
{
489+
outputBuffer.SyncAutoUrlsForAllRows ();
490+
}
491+
445492
StringBuilder ansiOutput = new ();
446493
Attribute? lastAttr = null;
447494

@@ -451,24 +498,45 @@ public string ToAnsi (IOutputBuffer buffer)
451498
}
452499

453500
/// <summary>
454-
/// Writes buffered output to console, wrapping URLs with OSC 8 hyperlinks (non-legacy only),
455-
/// then clears the buffer and advances <paramref name="lastCol"/> by <paramref name="outputWidth"/>.
501+
/// Writes buffered output to console, then clears the buffer and advances
502+
/// <paramref name="lastCol"/> by <paramref name="outputWidth"/>.
456503
/// </summary>
457504
private void WriteToConsole (StringBuilder output, ref int lastCol, ref int outputWidth)
458505
{
459-
if (IsLegacyConsole)
460-
{
461-
Write (output);
462-
}
463-
else
464-
{
465-
// Wrap URLs with OSC 8 hyperlink sequences
466-
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (output);
467-
Write (processed);
468-
}
506+
Write (output);
469507

470508
output.Clear ();
471509
lastCol += outputWidth;
472510
outputWidth = 0;
473511
}
512+
513+
private static bool RowContainsUrls (IOutputBuffer buffer, int row, int cols)
514+
{
515+
for (int col = 0; col < cols; col++)
516+
{
517+
if (!string.IsNullOrEmpty (buffer.GetCellUrl (col, row)))
518+
{
519+
return true;
520+
}
521+
}
522+
523+
return false;
524+
}
525+
526+
private void InvalidateRowsWithUrlsIfStale (IOutputBuffer buffer, int rows, int cols)
527+
{
528+
int urlVersion = buffer is OutputBufferImpl outputBuffer ? outputBuffer.UrlStateVersion : 0;
529+
530+
if (!ReferenceEquals (_lastTrackedBuffer, buffer)
531+
|| _lastTrackedRows != rows
532+
|| _lastTrackedCols != cols
533+
|| _lastTrackedUrlVersion != urlVersion)
534+
{
535+
_rowsWithUrls.Clear ();
536+
_lastTrackedBuffer = buffer;
537+
_lastTrackedRows = rows;
538+
_lastTrackedCols = cols;
539+
_lastTrackedUrlVersion = urlVersion;
540+
}
541+
}
474542
}

0 commit comments

Comments
 (0)