Skip to content

feat: clickable links, hover cursor, and mouse capture toggle#17

Merged
bahdotsh merged 4 commits into
mainfrom
feat/clickable-links-mouse-toggle
Mar 18, 2026
Merged

feat: clickable links, hover cursor, and mouse capture toggle#17
bahdotsh merged 4 commits into
mainfrom
feat/clickable-links-mouse-toggle

Conversation

@bahdotsh
Copy link
Copy Markdown
Owner

@bahdotsh bahdotsh commented Mar 18, 2026

Summary

Fixes #13 — mouse capture was grabbing all events, preventing text selection and link clicks.

  • Click to open links: Left-clicking a link in the rendered markdown opens it in the default browser (uses the same open::that() as the link picker)
  • Hover cursor: Mouse pointer changes to a hand cursor (OSC 22) when hovering over links, resets when moving off. Terminals that don't support OSC 22 silently ignore it
  • Mouse capture toggle (m): Press m to disable mouse capture entirely so the terminal can handle text selection natively. Press again to re-enable scroll wheel support. Status message confirms the state

Test plan

  • cargo clippy — no warnings
  • cargo test — 102 passed, 0 failed
  • Open a markdown file with links, verify left-click opens them in browser
  • Hover over a link and verify cursor changes to pointer (in supported terminals: kitty, foot, xterm)
  • Press m to toggle mouse capture off, verify text selection works
  • Press m again, verify scroll wheel works
  • Verify F1 help screen shows the new m keybinding

EnableMouseCapture grabs *all* mouse events, which means the
terminal can't do its own text selection or handle OSC 8 hyperlink
clicks. The links were already being emitted with proper OSC 8
sequences, but nobody could actually click them. Not great.

Three things here:

Add a link_at_position() helper that maps terminal (row, col)
coordinates back to the underlying span and checks for a link_url.
Left-clicking a link now opens it via open::that(), same as the
link picker but without the extra ceremony.

Mouse hover over links sends OSC 22 to switch the pointer to a
hand cursor, and resets it when you move off. Not every terminal
supports OSC 22, but those that don't will silently ignore it,
so this is harmless.

Press 'm' to toggle mouse capture on/off entirely. When off, the
terminal regains control and you can select text like a normal
human being. When on, scroll wheel works as before. Status message
confirms the state.
The clickable links feature had three problems that a code review
turned up, ranging from "silently wrong" to "actively dangerous."

First, open::that() was being called on *any* URL the user clicked,
with zero scheme validation. The existing link picker correctly
restricts to http://, https://, and mailto: — but the new click
handler just blindly opened whatever was in the href. A malicious
markdown file with file:/// or other scheme URLs would happily get
passed to the OS handler. Not great.

Second, anchor link clicks used a naive to_lowercase().replace(' ',
"-") slug instead of the proper heading_to_slug() function that
strips punctuation, collapses consecutive hyphens, and handles
Unicode. So clicking an anchor to "What's new?" would try to match
"what's new?" instead of "whats-new", silently fail, and give the
user zero feedback. While at it, added a "Heading not found" status
message for the miss case.

Third, link_at_position() used chars().count() for column tracking,
but terminal coordinates are in display-width columns. CJK and emoji
characters occupy two columns but count as one char, so the hit-test
was wrong for any line containing wide characters. Switched to
UnicodeWidthStr::width().

Also reset the OSC 22 pointer cursor when toggling mouse capture
off, so you don't get a stuck hand cursor.
- Reset cursor shape (OSC 22) in TerminalGuard::drop to prevent
  leaked pointer cursor on exit
- Fix renderer column tracking to use UnicodeWidthStr::width() instead
  of chars().count(), aligning with link_at_position hit-testing for
  wide characters (CJK, emoji)
- Extract left gutter width into GUTTER_COLS constant
- Add 7 unit tests for link_at_position covering hit/miss, gutter,
  title bar, past-end-of-line, past-last-line, and multiple links
The URL dispatch logic — scheme validation, open::that, anchor
navigation — was copy-pasted between the mouse click handler and
the link picker. Two copies of the same logic, each with slightly
different ordering of the checks. This is not great.

It turns out open::that errors were also being silently swallowed
with `let _ = ...`, while the status bar cheerfully reported
"Opened: ..." regardless. So if your browser wasn't configured or
the open failed, you'd never know. Let's fix that.

Extract a shared dispatch_link() that both code paths now call.
The function actually checks the Result from open::that and shows
the error in the status bar on failure.

While at it, add reset_cursor_shape() calls at every mode
transition away from Normal (search, TOC, link picker, fuzzy
heading, help). Previously, if you hovered a link and then pressed
'/' to search, the cursor stayed as a hand pointer until you
happened to move the mouse back in Normal mode.
@bahdotsh bahdotsh merged commit cc00ad0 into main Mar 18, 2026
5 checks passed
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.

Cannot select text with mouse

1 participant