Skip to content

Hovered hyperlink state is not invalidated when buffer content or viewport mapping changes under a stationary cursor #20219

@harder

Description

@harder
Terminal does not refresh the hovered link when the content changes

Windows Terminal version

1.24.10921.0

Windows build number

10.0.26220.0

Other Software

Application: Terminal.Gui v2 (https://github.com/gui-cs/Terminal.Gui)
Sample app: UICatalog -> TextView -> Editor

Steps to reproduce

When the mouse is hovering a hyperlink, Windows Terminal caches the hovered hyperlink state in viewport coordinates and only refreshes it on mouse-move / mouse-leave paths.

If the buffer contents change underneath a stationary pointer, or if scrolling changes which logical cell lives under the same viewport coordinates, the cached hover state is not invalidated. The strongest user-visible result is that the hover underline stays active after mouse-wheel scrolling even though the hovered logical cell has changed.

In a real app (Terminal.Gui TextView -> Editor), I also see a related stale-hover symptom after Ctrl+A -> Delete: locations that used to contain a link can still surface stale hover feedback. Based on the source, that appears to be the same cache invalidation bug, but the most reliable repro is the scroll case below.

With Terminal.Gui (most reliable)

git clone https://github.com/gui-cs/Terminal.Gui.git
cd Terminal.Gui
dotnet run --project Examples/UICatalog/UICatalog.csproj

Then:

  1. Open TextView -> Editor.
  2. Hover a visible URL until Windows Terminal shows the hyperlink hover underline.
  3. Keep the pointer over that same screen cell.
  4. Use the mouse wheel to scroll the editor content.
  5. Observe that the hover underline is not invalidated when the viewport mapping changes under the pointer.

Plain Powershell (a bit harder to notice)

The script only stages the content; the scroll must be done by the user with the mouse wheel, because the source code path that exhibits the bug (ControlInteractivity::MouseWheel -> Terminal::UserScrollViewport -> TriggerScroll) is only reached on user-initiated viewport scroll. Application-emitted line writes that cause the viewport to advance go through a different path and do not reliably reproduce.

$ErrorActionPreference = 'Stop'
$ESC = [char]27
$BEL = [char]7

[Console]::Write("$ESC[2J$ESC[H")
[Console]::WriteLine("Windows Terminal OSC 8 stale-hover repro -- mouse wheel scroll case")
[Console]::WriteLine("")
[Console]::WriteLine("STEPS (read carefully -- the script only stages content):")
[Console]::WriteLine("  1. Wait for 'Ready' to appear at the bottom of the screen.")
[Console]::WriteLine("  2. Find the line marked '----> HOVER_ME_LINK <----' below.")
[Console]::WriteLine("  3. Move the mouse onto HOVER_ME_LINK -- the hover underline should appear.")
[Console]::WriteLine("  4. KEEP THE MOUSE PERFECTLY STILL on that same screen cell.")
[Console]::WriteLine("  5. Spin the mouse wheel one notch up, then one notch down.")
[Console]::WriteLine("")
[Console]::WriteLine("EXPECTED: after the wheel scroll, the cell under the mouse is different")
[Console]::WriteLine("content (a filler line), so the hover underline should be gone from that cell.")
[Console]::WriteLine("ACTUAL: the underline tracks the link's new screen position instead of being")
[Console]::WriteLine("invalidated -- it 'follows' the link as it scrolls.")
[Console]::WriteLine("")
Start-Sleep -Seconds 1

# Fill plenty of scrollback above the link so wheel-up has somewhere to go.
for ($i = 1; $i -le 60; $i++) {
    [Console]::WriteLine("above-link filler line {0,2}  (plain text, no hyperlink)" -f $i)
}

# Emit the link with obvious visual markers and padding so it is easy to hit.
[Console]::Write("                ---->  ")
[Console]::Write("${ESC}]8;;https://example.com/demo${BEL}HOVER_ME_LINK${ESC}]8;;${BEL}")
[Console]::WriteLine("  <----   (hover the underlined text)")

# A few lines below the link, so the wheel-down direction also has content.
for ($i = 1; $i -le 6; $i++) {
    [Console]::WriteLine("below-link filler line {0,2}  (plain text, no hyperlink)" -f $i)
}

[Console]::WriteLine("")
[Console]::WriteLine("Ready. Hover HOVER_ME_LINK above, then mouse-wheel one notch each way.")
[Console]::WriteLine("Press Enter when done observing.")
$null = Read-Host

Expected Behavior

When scrolling changes which logical cell is under the pointer, Windows Terminal should re-evaluate the hovered hyperlink at that viewport position. If the new cell is not part of a hyperlink, the hover underline should disappear immediately.

Actual Behavior

The hover underline remains driven by stale cached hover state until the mouse enters a different cell. In practice this looks like the underline continuing to appear on the scrolled hyperlink content instead of being invalidated when the hovered cell identity changes.

Secondary manifestation seen in the same app

In the same TextView -> Editor repro app:

  1. Hover a URL until the hover underline appears.
  2. Keep the pointer parked on that screen location.
  3. Press Ctrl+A, then Delete.
  4. Move the pointer back over the location that used to contain the link.

I can still get stale hover feedback there in the real app.

I am not using this as the primary repro because the minimized overwrite-only script I tried to build was not reliable enough. The scroll repro above is the clearest and is fully explained by the source.

Why I believe this is a Windows Terminal bug

1. Hover state is cached and skipped if the mouse has not moved

src/cascadia/TerminalControl/ControlCore.cpp in v1.24.10921.0:

void ControlCore::_updateHoveredCell(const std::optional<til::point> terminalPosition)
{
    if (terminalPosition == _lastHoveredCell)
    {
        return;
    }

    _lastHoveredCell = terminalPosition;
    uint16_t newId{ 0u };
    decltype(_terminal->GetHyperlinkIntervalFromViewportPosition({})) newInterval{ std::nullopt };
    if (terminalPosition.has_value())
    {
        const auto lock = _terminal->LockForReading();
        newId = _terminal->GetHyperlinkIdAtViewportPosition(*terminalPosition);
        newInterval = _terminal->GetHyperlinkIntervalFromViewportPosition(*terminalPosition);
    }

    if (newId != _lastHoveredId || (newInterval != _lastHoveredInterval))
    {
        const auto lock = _terminal->LockForWriting();
        _lastHoveredId = newId;
        _lastHoveredInterval = newInterval;
        _renderer->UpdateHyperlinkHoveredId(newId);
        _renderer->UpdateLastHoveredInterval(newInterval);
        _renderer->TriggerRedrawAll();
    }
}

Key point: if the mouse is still on the same viewport cell, Windows Terminal does not re-query the hyperlink id or interval for that cell.

2. That hover-refresh path is mouse-driven, not buffer-driven

SetHoveredCell calls _updateHoveredCell, and ControlInteractivity::PointerMoved calls SetHoveredCell.

The mouse-wheel scroll path goes through:

ControlInteractivity::MouseWheel
-> _mouseScrollHandler
-> Terminal::UserScrollViewport
-> TriggerScroll

That path does not call _updateHoveredCell.

3. The hovered lookup is viewport-based, so scroll changes the logical cell identity

Terminal::GetHyperlinkIdAtViewportPosition does:

return _activeBuffer().GetCellDataAt(_ConvertToBufferCell(viewportPos, false))->TextAttr().GetHyperlinkId();

So the same viewport coordinates can refer to a different logical buffer cell after scroll. If Windows Terminal does not re-query after the scroll, it keeps using stale hover state.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area-TerminalControlIssues pertaining to the terminal control (input, selection, keybindings, mouse interaction, etc.)Impact-VisualIt look bad.Issue-BugIt either shouldn't be doing this or needs an investigation.Priority-3A description (P3)Product-TerminalThe new Windows Terminal.

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions