feat(terminal): an OSC 8 link rests under a faint dotted underline (#1078)

* feat(terminal): an OSC 8 link rests under a faint dotted underline

A hyperlink a program emits (OSC 8, which panes ask for via
FORCE_HYPERLINK) looked like plain text until the pointer found it.
It now carries iTerm2's faint dotted underline, drawn by the SGR 4:4
dotted painter, and swaps to the hovered link's solid line under the
pointer. A cell the program underlined or gave an underline colour
keeps its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(terminal): an OSC 8 link's dots stay faint under the block cursor

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Adam Hitchcock
2026-10-03 20:36:45 +08:00
committed by GitHub
co-authored by Claude Opus 5.5 l0ng-ai
parent 02620cfa48
commit 85b739650c
2 changed files with 135 additions and 2 deletions
+10 -2
View File
@@ -19,6 +19,8 @@ use gpui_component::ActiveTheme as _;
use super::view::{TerminalView, should_show_context_menu};
use crate::core::config::Config;
mod osc8_underline;
const DIM_OPACITY: f32 = 0.66;
/// How much of the text's own colour a link's underline keeps before the
@@ -54,6 +56,7 @@ pub(super) struct RenderCell {
match_hit: bool,
match_current: bool,
link_hover: bool,
osc8_dots: bool,
}
impl Default for RenderCell {
@@ -74,6 +77,7 @@ impl Default for RenderCell {
match_hit: false,
match_current: false,
link_hover: false,
osc8_dots: false,
}
}
}
@@ -252,6 +256,7 @@ fn snapshot_cell(
if flags.contains(Flags::DIM) {
rc.fg = dim_fg(rc.fg, bgc, colors.legible_dim);
}
osc8_underline::mark(&mut rc, cell);
rc
}
@@ -1708,8 +1713,10 @@ fn invert_cursor_cell(
cell.bg = colors.caret;
cell.draw_bg = true;
cell.fg = ink;
// Keep the underline's own alpha: an OSC 8 link's held-back dots stay
// faint under the caret rather than turning into a full-strength line.
if let Some(under) = cell.underline_color.as_mut() {
*under = ink;
*under = ink.opacity(under.a);
}
// A wide character's trailing spacer is absorbed into the lead cell's
// background run by `paint_backgrounds`, so the fill already covers both
@@ -2004,6 +2011,7 @@ impl TerminalElement {
let row = grid_row as usize * cols;
for col in start.column.0..=end.column.0.min(cols.saturating_sub(1)) {
let cell = &mut buf[row + col];
osc8_underline::unmark(cell);
cell.link_hover = true;
cell.underline_color = link_underline_color(cell, link.armed);
}
@@ -3882,7 +3890,7 @@ mod tests {
assert_eq!(a.as_ref(), "界");
}
fn test_colors() -> PaintColors {
pub(super) fn test_colors() -> PaintColors {
let fg = Rgb {
r: 10,
g: 10,
+125
View File
@@ -0,0 +1,125 @@
//! An OSC 8 link's resting decoration: iTerm2's faint dotted underline, so a
//! `#1234` that is a link reads as one before the pointer finds it. It rides
//! the SGR 4:4 dotted painter, so it costs what an app's own dotted run does.
use alacritty_terminal::term::cell::Cell;
use super::{HELD_BACK_LINK_ALPHA, RenderCell, UnderlineKind};
/// A cell the application underlined, or gave an underline colour, keeps
/// its own decoration.
pub(super) fn mark(rc: &mut RenderCell, cell: &Cell) {
if rc.underline == UnderlineKind::None
&& rc.underline_color.is_none()
&& cell.hyperlink().is_some()
{
rc.underline = UnderlineKind::Dotted;
rc.underline_color = Some(rc.fg.opacity(HELD_BACK_LINK_ALPHA));
rc.osc8_dots = true;
}
}
/// Under the pointer the dots give way to the hovered link's solid line.
pub(super) fn unmark(rc: &mut RenderCell) {
if std::mem::take(&mut rc.osc8_dots) {
rc.underline = UnderlineKind::None;
rc.underline_color = None;
}
}
#[cfg(test)]
mod tests {
use alacritty_terminal::index::{Column, Line, Point};
use alacritty_terminal::term::cell::{Flags, Hyperlink};
use alacritty_terminal::vte::ansi::Rgb;
use super::super::{
GlyphStyle, PaintColors, invert_cursor_cell, snapshot_cell, tests::test_colors,
};
use super::*;
fn linked(flags: Flags) -> Cell {
let mut cell = Cell {
c: '#',
flags,
..Cell::default()
};
cell.set_hyperlink(Some(Hyperlink::new(
None::<&str>,
"https://example.com/3328".to_string(),
)));
cell
}
fn paint_fixture() -> ([Rgb; 256], PaintColors) {
([Rgb { r: 0, g: 0, b: 0 }; 256], test_colors())
}
#[test]
fn a_hyperlink_rests_under_faint_dots() {
let (palette, colors) = paint_fixture();
let rc = snapshot_cell(
&linked(Flags::empty()),
Point::new(Line(0), Column(0)),
&palette,
&colors,
None,
);
assert_eq!(rc.underline, UnderlineKind::Dotted);
assert_eq!(
rc.underline_color,
Some(rc.fg.opacity(HELD_BACK_LINK_ALPHA))
);
}
#[test]
fn an_sgr_underlined_link_keeps_its_own_line_only() {
let (palette, colors) = paint_fixture();
let rc = snapshot_cell(
&linked(Flags::UNDERLINE),
Point::new(Line(0), Column(0)),
&palette,
&colors,
None,
);
assert_eq!(rc.underline, UnderlineKind::Single);
assert_eq!(rc.underline_color, None);
assert!(!rc.osc8_dots);
}
#[test]
fn hovering_swaps_the_dots_for_a_solid_line() {
let (palette, colors) = paint_fixture();
let mut rc = snapshot_cell(
&linked(Flags::empty()),
Point::new(Line(0), Column(0)),
&palette,
&colors,
None,
);
unmark(&mut rc);
rc.link_hover = true;
assert_eq!(rc.underline, UnderlineKind::None);
let solid = GlyphStyle::of(&rc)
.underline_style()
.expect("a solid underline");
assert!(!solid.wavy);
}
#[test]
fn the_dots_stay_faint_under_a_block_cursor() {
let (palette, colors) = paint_fixture();
let rc = snapshot_cell(
&linked(Flags::empty()),
Point::new(Line(0), Column(0)),
&palette,
&colors,
None,
);
let mut buf = vec![rc];
invert_cursor_cell(&mut buf, 1, 0, 0, &colors);
let under = buf[0].underline_color.expect("the dots keep a colour");
assert_eq!(under.a, buf[0].fg.a * HELD_BACK_LINK_ALPHA);
}
}