Skip to content

Commit c74d1e6

Browse files
committed
Fix OSC 8 print-time hyperlink attribution (closes #635)
1 parent 1052996 commit c74d1e6

3 files changed

Lines changed: 156 additions & 39 deletions

File tree

Sources/SwiftTerm/Buffer.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,11 +1577,17 @@ public final class Buffer {
15771577

15781578
/// Bulk-inserts ASCII characters (all width-1, non-combining).
15791579
/// Returns number of bytes consumed. Returns 0 if insert mode is active.
1580-
func insertAsciiRun(_ bytes: ArraySlice<UInt8>, attribute: Attribute) -> Int {
1580+
func insertAsciiRun(
1581+
_ bytes: ArraySlice<UInt8>,
1582+
attribute: Attribute,
1583+
resolvePayload: () -> TinyAtom?
1584+
) -> Int {
15811585
guard !insertMode else { return 0 }
15821586
let right = marginMode ? _marginRight : _cols - 1
15831587
var consumed = 0
15841588
var idx = bytes.startIndex
1589+
var payload: TinyAtom?
1590+
var didResolvePayload = false
15851591

15861592
while idx < bytes.endIndex {
15871593
if _x > right {
@@ -1599,9 +1605,16 @@ public final class Buffer {
15991605
let available = right - _x + 1
16001606
let runLen = min(available, bytes.endIndex - idx)
16011607
let row = _lines[_y + _yBase]
1608+
if !didResolvePayload {
1609+
payload = resolvePayload()
1610+
didResolvePayload = true
1611+
}
16021612
for i in 0..<runLen {
16031613
var cell = CharData(attribute: attribute, code: Int32(bytes[idx + i]), size: 1)
16041614
cell.setSemanticContent(semanticContent) // buffer's own classification (D.1)
1615+
if let payload {
1616+
cell.setPayload(atom: payload)
1617+
}
16051618
row[_x + i] = cell
16061619
}
16071620
_x += runLen
@@ -1611,7 +1624,7 @@ public final class Buffer {
16111624
return consumed
16121625
}
16131626

1614-
func insertCharacter(_ charData: CharData) {
1627+
func insertCharacter(_ charData: CharData, resolvePayload: () -> TinyAtom?) {
16151628
// D.1: stamp the OSC 133 role once, here at the insertion funnel, from
16161629
// the buffer's own classification. No caller can forget to stamp, and
16171630
// during ordinary output `semanticContent` is `.none` (a no-op).
@@ -1673,6 +1686,7 @@ public final class Buffer {
16731686
if _x >= _cols {
16741687
_x = _cols-1
16751688
}
1689+
charData.setPayload(atom: resolvePayload() ?? TinyAtom.empty)
16761690
bufferRow[_x] = charData
16771691
_x += 1
16781692

@@ -1682,6 +1696,7 @@ public final class Buffer {
16821696
if chWidth > 1 {
16831697
var wideEmpty = CharData(attribute: curAttr, scalar: UnicodeScalar(0)!, size: 0)
16841698
wideEmpty.setSemanticContent(charData.semanticContent)
1699+
wideEmpty.setPayload(atom: charData.payload)
16851700
chWidth -= 1
16861701
while chWidth != 0 && _x < _cols {
16871702
bufferRow [_x] = wideEmpty

Sources/SwiftTerm/Terminal.swift

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,7 @@ open class Terminal {
984984
xtermTitleSetHex = false
985985
xtermTitleQueryHex = false
986986

987-
hyperLinkTracking = nil
987+
activeHyperlink = nil
988988
cursorBlink = false
989989
hostCurrentDirectory = nil
990990
lineFeedMode = options.convertEol
@@ -1309,7 +1309,11 @@ open class Terminal {
13091309
}
13101310
if allAscii {
13111311
updateRange(borrowing: buffer, buffer.y)
1312-
let consumed = buffer.insertAsciiRun(data, attribute: curAttr)
1312+
let consumed = buffer.insertAsciiRun(
1313+
data,
1314+
attribute: curAttr,
1315+
resolvePayload: { self.resolveActiveHyperlink() }
1316+
)
13131317
if consumed == data.count {
13141318
updateRange(borrowing: buffer, buffer.y)
13151319
return
@@ -1345,7 +1349,7 @@ open class Terminal {
13451349
// Every single mapping in the charset only takes one slot
13461350
chWidth = 1
13471351
let charData = makeCharData (attribute: curAttr, char: ch, size: Int8 (chWidth))
1348-
buffer.insertCharacter(charData)
1352+
insertCharacter(charData)
13491353
continue
13501354
}
13511355
}
@@ -1354,7 +1358,7 @@ open class Terminal {
13541358
chWidth = UnicodeUtil.columnWidth(rune: rune)
13551359
if chWidth > 0 {
13561360
let charData = makeCharData (attribute: curAttr, scalar: rune, size: Int8 (chWidth))
1357-
buffer.insertCharacter(charData)
1361+
insertCharacter(charData)
13581362
}
13591363
continue
13601364
} else if readingBuffer.bytesLeft() >= (n-1) {
@@ -1383,7 +1387,7 @@ open class Terminal {
13831387
chWidth = UnicodeUtil.columnWidth(rune: rune)
13841388
if chWidth > 0 {
13851389
let charData = makeCharData (attribute: curAttr, scalar: rune, size: Int8 (chWidth))
1386-
buffer.insertCharacter(charData)
1390+
insertCharacter(charData)
13871391
}
13881392
continue
13891393
}
@@ -1498,6 +1502,7 @@ open class Terminal {
14981502
let nextX = lastx + 1
14991503
var empty = makeCharData (attribute: cd.attribute, code: 0, size: 0)
15001504
empty.setSemanticContent(cd.semanticContent)
1505+
empty.setPayload(atom: cd.payload)
15011506
existingLine [nextX] = empty
15021507
buffer.x += 1
15031508
} else {
@@ -1513,6 +1518,7 @@ open class Terminal {
15131518
updateCharData(&cd, char: newCh, size: 2)
15141519
var empty = makeCharData(attribute: cd.attribute, code: 0, size: 0)
15151520
empty.setSemanticContent(cd.semanticContent)
1521+
empty.setPayload(atom: cd.payload)
15161522
existingLine [lastx + 1] = empty
15171523
buffer.x += 1
15181524
} else {
@@ -1537,7 +1543,7 @@ open class Terminal {
15371543
// emitChar (ch)
15381544
//}
15391545
let charData = makeCharData (attribute: curAttr, char: ch, size: Int8 (chWidth))
1540-
buffer.insertCharacter(charData)
1546+
insertCharacter(charData)
15411547
}
15421548
updateRange(borrowing: buffer, buffer.y)
15431549
readingBuffer.done ()
@@ -1612,9 +1618,10 @@ open class Terminal {
16121618
// Inserts the specified character with the computed width into the next cell, following
16131619
// the rules for wrapping around, scrolling and overflow expected in the terminal.
16141620
func insertCharacter (_ charData: CharData) {
1615-
// TODO, make this a direct call. no need to pproxy here
16161621
buffer.insertCharacter(
1617-
charData)
1622+
charData,
1623+
resolvePayload: { self.resolveActiveHyperlink() }
1624+
)
16181625
}
16191626

16201627
// func insertCharacter2(_ charData: CharData) {
@@ -2601,9 +2608,35 @@ open class Terminal {
26012608
}
26022609
}
26032610

2604-
var hyperLinkTracking: (start: Position, payload: String)? = nil
2611+
private enum ActiveHyperlink {
2612+
case pending(String)
2613+
case resolved(TinyAtom)
2614+
case unavailable
2615+
}
2616+
2617+
private var activeHyperlink: ActiveHyperlink? = nil
26052618
private var payloadCodes = Set<UInt16>()
26062619

2620+
private func resolveActiveHyperlink() -> TinyAtom? {
2621+
guard let activeHyperlink else {
2622+
return nil
2623+
}
2624+
2625+
switch activeHyperlink {
2626+
case .pending(let payload):
2627+
guard let atom = makePayload(value: payload) else {
2628+
self.activeHyperlink = .unavailable
2629+
return nil
2630+
}
2631+
self.activeHyperlink = .resolved(atom)
2632+
return atom
2633+
case .resolved(let atom):
2634+
return atom
2635+
case .unavailable:
2636+
return nil
2637+
}
2638+
}
2639+
26072640
/// Creates a payload atom whose lifetime is managed by this terminal.
26082641
///
26092642
/// ``garbageCollectPayload()`` releases the atom after it is no longer present in
@@ -2619,35 +2652,11 @@ open class Terminal {
26192652

26202653
func oscHyperlink (_ data: ArraySlice<UInt8>)
26212654
{
2622-
let buffer = self.buffer
26232655
if data.count == 1 && data [data.startIndex] == UInt8 (ascii: ";") {
2624-
// We only had the terminator, so we can close ";"
2625-
if let hlt = hyperLinkTracking {
2626-
let str = hlt.payload
2627-
if let urlToken = makePayload(value: str) {
2628-
//print ("Setting the text from \(hlt.start) to \(buffer.x) on line \(buffer.y+buffer.yBase) to \(str)")
2629-
2630-
// Between the time the flag was set, and now `y` might have changed negatively,
2631-
// in that case, we do not flag any sequence as a hyperlink
2632-
if hlt.start.row <= buffer.y+buffer.yBase {
2633-
for y in hlt.start.row...(buffer.y+buffer.yBase) {
2634-
let line = buffer.lines [y]
2635-
let startCol = y == hlt.start.row ? min (hlt.start.col, cols-1) : 0
2636-
let endCol = y == buffer.y ? min (buffer.x, cols-1) : (marginMode ? buffer.marginRight : cols-1)
2637-
if endCol > startCol {
2638-
for x in startCol...endCol {
2639-
var cd = line [x]
2640-
cd.setPayload(atom: urlToken)
2641-
line [x] = cd
2642-
}
2643-
}
2644-
}
2645-
}
2646-
}
2647-
}
2648-
hyperLinkTracking = nil
2656+
activeHyperlink = nil
26492657
} else {
2650-
hyperLinkTracking = (start: Position(col: buffer.x, row: buffer.y+buffer.yBase), payload: String (bytes:data, encoding: .ascii) ?? "")
2658+
let payload = String(bytes: data, encoding: .ascii) ?? ""
2659+
activeHyperlink = .pending(payload)
26512660
}
26522661
}
26532662

@@ -4592,7 +4601,7 @@ open class Terminal {
45924601
charset = nil
45934602
setgLevel (0)
45944603
conformance = .vt500
4595-
hyperLinkTracking = nil
4604+
activeHyperlink = nil
45964605
lineFeedMode = options.convertEol
45974606
resetAllColors()
45984607
tdel?.showCursor(source: self)
@@ -6281,6 +6290,9 @@ open class Terminal {
62816290

62826291
// check all atoms used in both buffers
62836292
var used = Set<UInt16>()
6293+
if let activeHyperlink, case .resolved(let atom) = activeHyperlink {
6294+
used.insert(atom.code)
6295+
}
62846296
for buffer in [normalBuffer, altBuffer] {
62856297
// TODO use a better system than this ugly nest
62866298
for line in buffer.lines.getArray() {

Tests/SwiftTermTests/OscTests.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ import Testing
1111
@testable import SwiftTerm
1212

1313
final class SwiftTermOsc {
14+
private func explicitLink(_ terminal: Terminal, row: Int, col: Int) -> String? {
15+
terminal.link(
16+
at: .buffer(Position(col: col, row: row)),
17+
mode: .explicitOnly
18+
)
19+
}
20+
21+
private func payload(_ terminal: Terminal, row: Int, col: Int) -> String? {
22+
terminal.getCharData(col: col, row: row)?.getPayload() as? String
23+
}
24+
1425
private func promptKinds(_ terminal: Terminal, at row: Int) -> [SemanticPromptKind] {
1526
terminal.semanticPromptMarks(at: row).map(\.kind)
1627
}
@@ -191,6 +202,85 @@ final class SwiftTermOsc {
191202
// Verification depends on how SwiftTerm exposes hyperlink data
192203
}
193204

205+
@Test func testOscHyperlinkMarksOnlyPrintedCellsAcrossCursorJump() {
206+
let terminal = Terminal(
207+
delegate: TitleDelegate(),
208+
options: TerminalOptions(cols: 8, rows: 4, scrollback: 0)
209+
)
210+
let escape = "\u{1b}"
211+
let stringTerminator = "\(escape)\\"
212+
213+
terminal.feed(text: "\(escape)[H")
214+
terminal.feed(text: "\(escape)]8;;https://example.com\(stringTerminator)")
215+
terminal.feed(text: "AB")
216+
217+
#expect(explicitLink(terminal, row: 0, col: 0) == "https://example.com")
218+
#expect(explicitLink(terminal, row: 0, col: 1) == "https://example.com")
219+
220+
terminal.feed(text: "\(escape)[3;1H")
221+
terminal.feed(text: "\(escape)]8;;\(stringTerminator)")
222+
223+
for col in 2..<terminal.cols {
224+
#expect(explicitLink(terminal, row: 0, col: col) == nil)
225+
}
226+
for col in 0..<terminal.cols {
227+
#expect(explicitLink(terminal, row: 1, col: col) == nil)
228+
}
229+
#expect(explicitLink(terminal, row: 2, col: 0) == nil)
230+
231+
terminal.feed(text: "C")
232+
#expect(explicitLink(terminal, row: 2, col: 0) == nil)
233+
}
234+
235+
@Test func testOscHyperlinkMarksWideCharacterContinuationCell() {
236+
let terminal = Terminal(
237+
delegate: TitleDelegate(),
238+
options: TerminalOptions(cols: 4, rows: 2, scrollback: 0)
239+
)
240+
let escape = "\u{1b}"
241+
let stringTerminator = "\(escape)\\"
242+
243+
terminal.feed(text: "\(escape)]8;;https://example.com\(stringTerminator)")
244+
terminal.feed(text: "")
245+
terminal.feed(text: "\(escape)]8;;\(stringTerminator)")
246+
247+
#expect(payload(terminal, row: 0, col: 0) == ";https://example.com")
248+
#expect(payload(terminal, row: 0, col: 1) == ";https://example.com")
249+
#expect(payload(terminal, row: 0, col: 2) == nil)
250+
}
251+
252+
@Test func testOscPendingHyperlinkSurvivesPayloadCollection() {
253+
let terminal = Terminal(
254+
delegate: TitleDelegate(),
255+
options: TerminalOptions(cols: 4, rows: 2, scrollback: 0)
256+
)
257+
let escape = "\u{1b}"
258+
let stringTerminator = "\(escape)\\"
259+
260+
terminal.feed(text: "\(escape)]8;;https://example.com\(stringTerminator)")
261+
terminal.garbageCollectPayload()
262+
terminal.feed(text: "A")
263+
terminal.feed(text: "\(escape)]8;;\(stringTerminator)")
264+
265+
#expect(explicitLink(terminal, row: 0, col: 0) == "https://example.com")
266+
}
267+
268+
@Test func testOscHyperlinkUsesLastPendingPayload() {
269+
let terminal = Terminal(
270+
delegate: TitleDelegate(),
271+
options: TerminalOptions(cols: 4, rows: 2, scrollback: 0)
272+
)
273+
let escape = "\u{1b}"
274+
let stringTerminator = "\(escape)\\"
275+
276+
terminal.feed(text: "\(escape)]8;;https://first.example\(stringTerminator)")
277+
terminal.feed(text: "\(escape)]8;;https://second.example\(stringTerminator)")
278+
terminal.feed(text: "A")
279+
terminal.feed(text: "\(escape)]8;;\(stringTerminator)")
280+
281+
#expect(explicitLink(terminal, row: 0, col: 0) == "https://second.example")
282+
}
283+
194284
/// Test OSC 8 hyperlinks with ID parameter
195285
/// From Ghostty: hyperlink with id
196286
@Test func testOscHyperlinkWithId() {

0 commit comments

Comments
 (0)