Repository navigation
Fix hyperlinks being split into multiple links when text is highlighted - #4110
filbranden wants to merge 7 commits into
Conversation
|
@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! |
|
@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:
To:
So I was hoping we could get this merged into Thank you! |
|
@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
left a comment
There was a problem hiding this comment.
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 4The 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.
|
Thanks for the review and for bringing up the I'll take a look into an emitted output test as well, I think that would probably make sense. |
c48c4d3 to
004d573
Compare
|
@MohammediYunus I pushed a fix for the |
MohammediYunus
left a comment
There was a problem hiding this comment.
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.
|
Interactive terminal hover behavior (on iTerm2 on MacOS) displayed here: textualize-rich-link-highlight.mov |
|
Just wanted to add, this would also close the dup issue #3526 from the looks of it |
|
Yes @KaiErikNiermann, this fixes #3526. I updated the commits to add a regression test |
…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.
004d573 to
b37efa3
Compare
|
@willmcgugan Friendly ping? |
Wkhanex
left a comment
There was a problem hiding this comment.
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 conflatedI 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>


Type of changes
AI?
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:
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.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
Description
Style.__add__was callingcopy()on every combined style that contained alink, 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
_addtake the link_id as an argument so that the lru_cachewill 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.