Skip to content

Fix hyperlinks being split into multiple links when text is highlighted - #4110

Open
filbranden wants to merge 7 commits into
Textualize:mainfrom
filbranden:linkhighlight1
Open

filbranden wants to merge 7 commits into
Textualize:mainfrom
filbranden:linkhighlight1

Conversation

@filbranden

@filbranden filbranden commented May 1, 2026 •

Copy link
Copy Markdown

Type of changes

  • Bug fix
  • New feature
  • Documentation / docstrings
  • Tests
  • Other

AI?

  • AI was used to generate this PR

I used Claude Sonnet 4.6 to work on this fix, it was fully reviewed by a human though. The process went roughly as follows:

  • Claude initially suggested just dropping the copy() call, I told it there was probably a reason why it was there, so I asked it to figure that out and add a test that would ensure that behavior was preserved.
  • Claude then suggested a lighter approach to creating the copied style, which ended in an uncached refactor of _add that would get called in that specific situation. This looked acceptable. I asked Claude to add appropriate tests.
  • Then I realized if the issue was the lru_cache, we could fix it by passing the expected link_id as an argument, which would turn into a key for the lru_cache. In a sense, this seems more efficient because we don't need to copy or recreate or discard a style object. On the other hand, it might have side effects on the lru_cache itself, so I count on code review to confirm this is an appropriate approach.
  • I also asked Claude to add a specific test involving the highlighter, since that's the specific issue I was trying to fix.

I understand there wasn't discussion in the issue itself, I hope this PR with the code might serve as a starting point for discussion with a proposed code change that I'm willing to iterate on. Thanks!

Checklist

  • I've run the latest black with default args on new code.
  • I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate (see note about typos above).
  • I've added tests for new code.
  • I accept that @willmcgugan may be pedantic in the code review. YES PLEASE!

Description

Style.__add__ was calling copy() on every combined style that contained a
link, which generated a fresh link_id for each highlighted sub-span within
a single link markup region. Terminals use link_id to identify hyperlink
boundaries, so each sub-span appeared as a separate clickable link instead
of one continuous one.

The underlying root cause is that _add's lru_cache keys on __hash__/__eq__,
which intentionally excludes link_id. Two Style objects with the same visual
content but different link_ids therefore collide in the cache; copy() was
a blunt workaround that overcorrected by regenerating the id on every hit.

Fix this by having _add take the link_id as an argument so that the lru_cache
will now also key by link_id, preventing the issue that required the copy()
workaround altogether.

Add test cases to confirm the issue is fixed and include regression tests to prevent it from getting broken again in the future.

Fixes #4109.
Fixes #3526.

@filbranden

Copy link
Copy Markdown
Author

@willmcgugan Ping? Would you mind taking a look? This is a small visual annoyance but I'd still like to have it fixed, if possible, thanks!

@filbranden

Copy link
Copy Markdown
Author

@willmcgugan Would you mind taking a look? I think there's a considerable improvement in the way hyperlinks with uuids or paths look and behave, from:

Image

To:

Image

So I was hoping we could get this merged into rich.

Thank you!

@filbranden

Copy link
Copy Markdown
Author

@willmcgugan Would you please take a look? I posted some screenshots that illustrate the change in behavior. The change itself is also fairly small, basically just adding to the arguments that get seen by lru_cache. Thank you!

@MohammediYunus MohammediYunus left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this fix. I checked the emitted OSC 8 output at c48c4d3: the highlighted UUID/path example now shares one link ID with colors enabled, compared with four on current main.

One remaining case is no_color=True. The rendered segments share one ID, but the final output still contains four:

import io
import re
from rich.console import Console

markup = (
    "Error in job [link=https://example.com/issues/42]"
    "78351748-9b32-4e08-ad3e-7e9ff124d541: see /var/log/myapp.log"
    "[/link] for details"
)
for no_color in (False, True):
    out = io.StringIO()
    console = Console(
        file=out, force_terminal=True, color_system="truecolor",
        legacy_windows=False, width=120, no_color=no_color,
    )
    console.print(markup, highlight=True)
    ids = {m[0] for m in re.findall(r'\x1b\]8;id=([^;]*);([^\x1b]*)\x1b\\',out.getvalue())}
    print(no_color, len(ids))
# False 1
# True 4

The remaining split happens in the final color-removal path: Segment.remove_color uses Style.without_color, which creates a new link ID for each distinct style. This also happens on main, so it is an existing uncovered case rather than a regression from this PR.

Would an emitted-output test for this path make sense here, or as a follow-up? The 327 style, highlighter, text and console tests pass on this head. This check inspects output bytes; I have not tested interactive terminal hover behavior.

@filbranden

Copy link
Copy Markdown
Author

Thanks for the review and for bringing up the no_color situation, which I indeed missed! Let me take a look at that and try to address it.

I'll take a look into an emitted output test as well, I think that would probably make sense.

@filbranden

Copy link
Copy Markdown
Author

@MohammediYunus I pushed a fix for the no_color case and also emitted-output tests for no_color being either True or False. Please take another look when you have some time. Thanks!

@MohammediYunus MohammediYunus left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing that! I reran the original UUID/path example at 004d573. It now emits one link ID with no_color=False and no_color=True. Separate links to the same URL also retain their own IDs after color removal.

All 391 style, segment, highlighter, text and console tests pass. I also ran the three new regression cases against the previous head: the no_color=True output case and distinct-link color-removal case fail there and pass with this update.

This resolves the case I reported. These checks inspect emitted output bytes; I haven’t tested interactive terminal hover behavior.

@filbranden

Copy link
Copy Markdown
Author

Interactive terminal hover behavior (on iTerm2 on MacOS) displayed here:

textualize-rich-link-highlight.mov

@KaiErikNiermann

KaiErikNiermann commented Oct 10, 2026 •

Copy link
Copy Markdown

Just wanted to add, this would also close the dup issue #3526 from the looks of it

@filbranden

Copy link
Copy Markdown
Author

Yes @KaiErikNiermann, this fixes #3526. I updated the commits to add a regression test test_highlight_words_preserves_link_in_output to confirm it does.

…k styles

Two Style(link=...) objects with the same URL are equal by hash and comparison,
but carry different _link_id values. Combining each with a base style must
yield combined styles with distinct _link_ids; the lru_cache must not cause the
second result to inherit the first's _link_id.
Style.__add__ was calling copy() on every combined style that contained a
link, which generated a fresh link_id for each highlighted sub-span within
a single link markup region. Terminals use link_id to identify hyperlink
boundaries, so each sub-span appeared as a separate clickable link instead
of one continuous one.

The underlying root cause is that _add's lru_cache keys on __hash__/__eq__,
which intentionally excludes link_id. Two Style objects with the same visual
content but different link_ids therefore collide in the cache; copy() was
a blunt workaround that overcorrected by regenerating the id on every hit.

Fix this by having _add take the link_id as an argument so that the lru_cache
will now also key by link_id, preventing the issue that required the copy()
workaround altogether.
The existing test checks the link_id of rendered segments. Also check
the OSC 8 escape sequences in the console output. The terminal uses
these to find the link boundaries.

Add a test for Text.highlight_words on a link, from issue Textualize#3526.
With no_color=True, Segment.remove_color calls Style.without_color.
without_color made a new link_id for each style. Thus one link with
highlighted text became many links in the output.

Keep the link_id in without_color. Add link_id to the remove_color
cache key, because Style equality ignores link_id. This keeps
separate links with the same style apart.
@filbranden

Copy link
Copy Markdown
Author

@willmcgugan Friendly ping?

@Wkhanex Wkhanex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one remaining cache boundary worth tracking separately from the no_color case already discussed. At b37efa3, two distinct Style objects for the same URL still become one rendered link ID when used in separate spans of the same Text:

from rich.console import Console
from rich.style import Style
from rich.text import Text

a = Style(link="https://example.org")
b = Style(link="https://example.org")
t = Text()
t.append("first", a)
t.append(" gap ")
t.append("last", b)
ids = [s.style.link_id for s in t.render(Console()) if s.style and s.style.link]
assert a.link_id != b.link_id
assert ids[0] == ids[1]  # distinct input regions are conflated

I reproduced this on the PR's base as well, so it is an existing uncovered case, not a regression introduced here. Text.render has its own style_cache keyed by tuples of Style objects, whose equality ignores link_id; it can return the first cached combined style before this PR's corrected _add key is reached. A follow-up test at the Text/rendered-output level would complement the direct Segment.remove_color test. I also confirmed the conflation in emitted OSC8 bytes with width 8/80 and no_color True/False. The five relevant upstream modules passed 393 tests on Linux/Python 3.12.14; interactive hover was not tested.

AI-assisted review using OpenAI Codex; reproductions and test runs were executed locally.

Text.render caches combined styles in a dict keyed by a tuple of
Style objects. Style equality ignores link_id, so two spans with
equal styles but different links shared one cache entry. The second
span then got the first span's link_id, and the terminal showed two
separate links as one.

Add the link_ids to the cache key, like Segment.remove_color does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@filbranden

Copy link
Copy Markdown
Author

@Wkhanex The fix is similar to the one for the other issues, it involves including the link_id as a key to the caches. I added a commit to the top of my stack that addresses that. See 4d11647. Thanks!

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.

[BUG] Hyperlinks are split into multiple links when text is highlighted [BUG] Highlighting part of a link breaks it into two

4 participants