Skip to content

🔗 feat(link-styling): add per-profile underline style and hover/active colours for OSC 8 hyperlinks - #650

Draft
kud wants to merge 2 commits into
gnachman:masterfrom
kud:feat/link-underline-style-picker
Draft

🔗 feat(link-styling): add per-profile underline style and hover/active colours for OSC 8 hyperlinks#650
kud wants to merge 2 commits into
gnachman:masterfrom
kud:feat/link-underline-style-picker

Conversation

@kud

@kud kud commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Note

Rebased onto current master and reworked in response to the review below. Still draft: I haven't rebuilt it since the rebase, and the PreferencePanel.xib merge wants a pass in Interface Builder before this is ready.

📄 Motivation

The OSC 8 hyperlink underline is currently hardcoded to a dashed single underline. With a handful of links that's fine. With output that is mostly links it turns into visual noise — this is my own terminal, running a tool that emits OSC 8 links in almost every line:

I'd like to be able to make that solid, or turn it off, per profile — and to give hovered and active links their own colours so the affordance survives without leaning on the underline.

🔍 Scope, and the ambiguity question

From the review:

There are a number of underlines. In addition to the ones set by SGR control sequence, they are also used for annotations, semantic history, and OSC 8 hyperlinks. There could be others I'm forgetting. Customizing colors is simple enough but customizing the shapes is riskier - you could easily end up with ambiguous cases. I believe there's a special code path for semantic history on already-underlined text that shows a mixed sold/dash underline.

That's fair, and it's the reason this PR touches exactly one branch. The style setting is applied only where the OSC 8 style is currently hardcoded:

} else if (url != nil && underlineHyperlinks) {
-   attributes[visualX].underlineStyle = iTermMetalGlyphAttributesUnderlineDashedSingle;
+   attributes[visualX].underlineStyle = validLinkStyle ? (iTermMetalGlyphAttributesUnderline)linkStyle
                                                        : iTermMetalGlyphAttributesUnderlineDashedSingle;
Underline source Touched by this PR?
OSC 8 hyperlinks Yes — the hardcoded DashedSingle becomes the profile setting
SGR underline (SGR 4 and friends) No — separate branch, untouched
Annotations No
Semantic history, including the mixed solid/dash path on already-underlined text No

Two things that follow from keeping it that narrow:

  • The default is unchanged. DashedSingle remains the default and the fallback when the stored value isn't a valid style, so an existing profile renders exactly as it does today. Nothing changes unless someone opts in.
  • The ambiguity that remains is opt-in and self-inflicted. Someone who deliberately sets OSC 8 links to a plain solid underline can make them harder to tell from SGR-underlined text. That's their choice, made in a preference pane, and it's the same class of trade-off as setting the link colour to match the foreground. I'd rather expose it than hardcode a style that some of us find noisy.

If you'd still rather not have shape customisation at all, I'm happy to cut the popup and land only the hover/active colours — say the word and I'll strip it.

📦 What's in it

  • KEY_LINK_HOVER_COLOR, KEY_LINK_ACTIVE_COLOR and their "use custom colour" toggles, plus KEY_LINK_UNDERLINE_STYLE, wired through iTermProfilePreferences and the colour presets
  • kColorMapLinkHover / kColorMapLinkActive in iTermColorMap, resolved in PTYSession's colour key map and included in the light/dark key list
  • An activeLinkRange on iTermTextDrawingHelper, driven from the Cmd-hover handler in PTYTextView+ARC.m and cleared on mouse-down and selection change
  • The same state carried through the Metal path so both renderers agree
  • Colour wells, checkboxes and the style popup in Profiles → Colors
  • ModernTests/LinkColourMapAndActiveRangeTests.swift — coverage for the colour-map resolution and the range maths

🧹 Since the last review

  • Fixed a nib-load crash that was in my earlier version of this branch. Five of the new outlets had been attached to the <connections> of the "Border around active pane" checkbox rather than to the view controller. NSButton isn't KVC-compliant for _useLinkHoverColor and friends, so opening Profiles → Colors would have raised NSUnknownKeyException. They now bind to p8i-eM-oBc, alongside the existing _linkColor outlet. The three new rows were also parented to the terminal-mode enclosure instead of the Regular pane — they lined up by coordinate coincidence — and are now siblings of the other colour controls, where they belong.
  • Rebased onto current master (the fork point was 1044 commits back)
  • Squashed the WIP commit away; this is a single commit now
  • Dropped the accidental submodules/iTerm2-shell-integration pointer bump
  • Dropped a local Makefile change that resigned frameworks on my machine and had no business being here

✅ How to Validate

  1. Profiles → Colors — the link hover and active colour wells and the underline style popup should be there, with the toggles off by default
  2. With everything default, OSC 8 links should render exactly as before (dashed single)
  3. Set the underline style to solid, or to none, and confirm OSC 8 links follow it
  4. Confirm SGR-underlined text, annotations and semantic history underlines are unaffected by that setting
  5. Enable the hover colour, Cmd-hover a link, and confirm the colour applies and reverts on mouse-out
  6. Repeat under the Metal renderer and confirm both paths agree
  7. Check a link that wraps across a line boundary, and one inside a column window (split pane with a narrow window)

🛠️ Developer Checklist

  • Code is readable and maintainable
  • Single atomic commit
  • Tests included (LinkColourMapAndActiveRangeTests.swift) — not re-run since the rebase
  • Commits follow Conventional Commits
  • Rebuilt and manually verified after the rebase — pending

@kud

kud commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

Still working on it.

Draft: not ready to present it to reviewers, but gives information that I'm working on a feature.

@gnachman

Copy link
Copy Markdown
Owner

There are a number of underlines. In addition to the ones set by SGR control sequence, they are also used for annotations, semantic history, and OSC 8 hyperlinks. There could be others I'm forgetting. Customizing colors is simple enough but customizing the shapes is riskier - you could easily end up with ambiguous cases. I believe there's a special code path for semantic history on already-underlined text that shows a mixed sold/dash underline.

@kud
kud force-pushed the feat/link-underline-style-picker branch from 6dc7d16 to 127ce9f Compare August 16, 2026 12:00
- Add KEY_LINK_HOVER_COLOR, KEY_LINK_ACTIVE_COLOR, and their "use custom colour" toggles plus KEY_LINK_UNDERLINE_STYLE to ITAddressBookMgr.h and wire them through iTermProfilePreferences.m (defaults, descriptions, presets) and iTermColorPresets.m
- Introduce kColorMapLinkHover/kColorMapLinkActive in iTermColorMap.h/.m and resolve them in PTYSession.m's colour keyMap (honouring the use-custom toggles) and PTYSession.swift's light/dark-mode key list
- Track an activeLinkRange in iTermTextDrawingHelper (new property, activeLinkRangeOnLine: accessor) driven from PTYTextView+ARC.m's Cmd+hover handler and reset on mouse-down/selection changes in PTYTextView.m; iTermAttributedStringBuilder.m now prefers the hover colour when drawing underlined link text
- Extend the Metal rendering path (iTermMetalPerFrameState/Row/Configuration) to carry inActiveLinkRange through glyph colour keys and apply the configurable link underline style
- Add IBOutlets and preference bindings for the new colour wells/checkboxes/popup in ProfilesColorsPreferencesViewController.m and the corresponding PreferencePanel.xib layout, plus regression tests in ModernTests/LinkColourMapAndActiveRangeTests.swift
@kud
kud force-pushed the feat/link-underline-style-picker branch from 127ce9f to b767337 Compare August 16, 2026 13:54
- Document new “Link hover” and “Link active” profile colors (Settings > Profiles > Colors), each with its own checkbox, off by default
- Document new “Link style” setting for the underline drawn under OSC 8 hyperlinks: single, double, curly, dotted, or dashed
@gnachman

Copy link
Copy Markdown
Owner

Thanks for this. The wiring through the color map, profile prefs, and presets looks clean, and keeping the default at dashed-single with the toggles off is the right call. A few things to flag before this is ready, the first of which is load-bearing:

1. The legacy (Core Text) renderer is missing two of the three features

iTerm2 treats the GPU renderer and the legacy renderer as pixel-for-pixel equal. That is a hard invariant, not a best-effort target, so any divergence between them is a bug rather than a polish item. Right now only the Metal path implements the underline style and the active-link color:

  • Underline style: sources/Drawing/iTermAttributedStringBuilder.m:895 still hardcodes NSUnderlinePatternDash for isURL. Setting Link style to Solid/Double/Curly/Dotted only changes the GPU path; on the legacy path links stay dashed.
  • Active-link color: the legacy path has no activeLinkRange / kColorMapLinkActive awareness at all (grep finds nothing). Only the hover color was added there, at iTermAttributedStringBuilder.m:190. Cmd-clicking a link shows the active color under Metal only.

The legacy path is reachable in normal use (Metal disabled, and the transparency/low-power fallbacks), so this is a visible divergence. Both properties need to be threaded into the Core Text attribute build so the two renderers match.

2. _linkUnderlineStyle is not part of the config generation, so the row cache can go stale

sources/MetalRenderer/Glue/iTermMetalPerFrameStateConfiguration.m sets _linkUnderlineStyle as a plain ivar, but it is not a field of iTermRowRenderInputs (sources/MetalRenderer/Glue/iTermRowRenderInputs.h), which is what feeds metalConfigGenerationForRenderInputs. The hover/active toggles are covered indirectly because they change the color map (which bumps colorMapGeneration), but the underline style changes only the profile and nothing bumps the generation.

The per-row output cache is keyed on configGeneration (iTermMetalPerFrameState.m:1903). With that cache enabled, changing the Link style popup leaves already-rendered links drawn with the old style until their content changes or scrolls. The struct comment spells out the invariant ("each independently changes the built blob, so each must be compared"). Adding a linkUnderlineStyle field to iTermRowRenderInputs and populating it alongside the others fixes it. (The row output cache is off by default, so this is latent for most users, but it is the kind of thing that bites later.)

3. The Link style popup has no "None" / off item

The motivation here is to be able to turn the underline off on link-heavy output, and the validation steps say to set the style "to none," but the menu only offers tags 1/2/3/4/6. Value 0 (None) is treated as invalid and falls back to dashed, so the underline can never actually be suppressed from the UI. Worth adding a None item (and letting 0 through the validity check) if turning it off is a goal.

Happy to look again once the legacy path is at parity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants