Skip to content

fix: command-block gutter hover repaint over-firing (issue #459 item 2) - #461

Merged
fredclausen merged 1 commit into
mainfrom
task-121/gutter-hover-repaint-edge-fix
Jul 27, 2026
Merged

fix: command-block gutter hover repaint over-firing (issue #459 item 2)#461
fredclausen merged 1 commit into
mainfrom
task-121/gutter-hover-repaint-edge-fix

Conversation

@fredclausen

@fredclausen fredclausen commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the command-block gutter hover-tint's repaint request over-firing on every frame the pointer rests over the gutter, instead of only on the enter/leave transition. This is follow-up item 2 from #459's real-workload profiling investigation.

The bug

widget.rs:1780-1803's if hovered || cache.pointer_in_gutter_last_frame { ui.ctx().request_repaint(); } fires the bare, zero-delay request_repaint() continuously for the entire duration the pointer rests over the gutter strip, contradicting its own doc comment's stated intent ("force one repaint on the frame the pointer leaves"). A bare zero-delay request_repaint() bypasses issue #436's ChromeMode::Replay idle-repaint bookkeeping (shortest_repaint_delay/pending_terminal_requested_delay only track the app's own request_repaint_after calls), forcing decide_chrome_mode to Full on every such frame — which rebuilds the entire menu bar/tab bar. Confirmed via real perf/flamegraph profiling in #459: show_menu_bar_contents/MenuButton::ui showed up as a real on-CPU cost during an otherwise-idle capture.

Why the obvious fix (a naive edge check) doesn't work

An earlier attempt at this PR used if hovered != cache.pointer_in_gutter_last_frame. Adversarial review caught this as BLOCKING before it landed: Response::hovered() is keyed off egui's interact_pos(), which lags PointerState::latest_pos() by exactly one frame specifically when the pointer leaves the OS window (Event::PointerGone — this is egui's own documented behavior, latest_pos clears immediately, interact_pos doesn't until "next frame"). On the exact frame CursorLeft is processed, hovered() still reads stale-true, so the naive check evaluates true != true = false and doesn't fire — and since firing request_repaint() is what schedules the next frame to run at all, nothing then schedules the frame where hovered() finally reads false. Without another active repaint source (blink is suppressed by a hidden/DECTCEM cursor, a non-blink cursor style, or echo-off), this could leave the hover tint stuck indefinitely after the mouse leaves the window — a real regression, not a hypothetical.

The actual fix

Fold in ui.ctx().input(|i| i.pointer.latest_pos().is_some()) — cleared same-frame on window-exit, unlike interact_pos() — as an override signal, so a pointer that has left the window is treated as "not hovering" immediately rather than one frame late:

let effectively_hovered = hovered && pointer_in_window;
(effectively_hovered != was_hovering, effectively_hovered)

Extracted into a pure gutter_hover_repaint_decision() function (matching this file's existing bell_flash_outcome/cursor_blink_phase pattern) with 6 unit tests, including the specific window-exit regression case that the naive fix got wrong.

Validation

  • cargo test --all, cargo clippy --all-targets --all-features -- -D warnings, cargo machete — all green.
  • Adversarially reviewed twice: the first attempt's naive edge-check was caught as blocking; the redesign's second pass independently re-verified the egui 0.35.0 source claims (interact_pos/latest_pos lag, the entry-path non-lag, the outer event-loop repaint guarantee) and found no blocking or should-fix issues.
  • Before/after flamegraph validation deferred — a benchmark-sensitive process was running on the test machine at implementation time. Will follow up with the same real-workload perf --call-graph dwarf,65528 methodology used for item 1 once the machine is free.

Closes item 2 of #459.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed command-block gutter hover highlighting when the pointer leaves the application window.
    • Improved hover repaint behavior for smoother, more accurate visual updates.
  • Tests

    • Added coverage for hover transitions, steady-state behavior, and pointer-window exit scenarios.

The gutter hover-tint's repaint request fired the bare, zero-delay
ui.ctx().request_repaint() on every frame for the entire duration the
pointer rested over the gutter, not just on the enter/leave
transition, contradicting its own doc comment's stated intent ('force
one repaint on the frame the pointer leaves'). This bare zero-delay
request bypasses issue #436's ChromeMode::Replay idle-repaint
bookkeeping, forcing a full chrome (menu bar/tab bar) rebuild on
every frame while the mouse rests over the gutter strip -- confirmed
via real perf/flamegraph profiling in #459 (menu bar construction
showing up as a real idle-adjacent cost).

A naive hovered != was_hovering edge check is NOT sufficient here and
was caught by review before landing: Response::hovered() is keyed off
egui's interact_pos(), which lags PointerState::latest_pos() by one
frame specifically when the pointer leaves the OS window
(Event::PointerGone -- egui's own documented behavior). The naive
check would read stale-true on the exact frame CursorLeft is
processed, fail to fire, and since firing request_repaint() is what
schedules the next frame at all, nothing would then schedule the
frame where hovered() finally reads false -- risking an indefinitely
stuck hover tint when no other repaint source is active (hidden
cursor, non-blink style, echo-off).

Fix: fold in ctx().input(|i| i.pointer.latest_pos().is_some()) --
cleared same-frame on window-exit, unlike interact_pos() -- as an
override signal, so a pointer that has left the window is treated as
'not hovering' immediately rather than one frame late. Extracted the
decision into a pure gutter_hover_repaint_decision() function
(matching the existing bell_flash_outcome/cursor_blink_phase pattern
in this file) with 6 unit tests covering steady hover, both
transition directions, and the specific window-exit regression case.

Verification: cargo test --all, cargo clippy --all-targets
--all-features -- -D warnings, cargo machete all green. Adversarially
reviewed twice (first attempt's naive edge-check was caught as
BLOCKING before landing; the redesign's second review pass found no
blocking or should-fix items, independently re-verifying the egui
source claims and the outer event-loop scheduling guarantee).

Before/after flamegraph validation deferred -- a benchmark-sensitive
process was running on the test machine at implementation time.

See #459 for the full profiling investigation this follows up on.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The terminal widget now combines egui hover status with pointer presence inside the window when updating gutter hover state, cursor behavior, and repaint requests. A pure decision function and unit tests cover transitions and stale hover state.

Changes

Gutter hover repaint correction

Layer / File(s) Summary
Window-aware hover decision
freminal/src/gui/terminal/widget.rs
Adds a pure decision function that returns whether repainting is needed and the updated cached hover state.
Widget integration and validation
freminal/src/gui/terminal/widget.rs
Uses pointer.latest_pos().is_some() in gutter hover handling and tests steady states, transitions, and stale hover behavior.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing command-block gutter hover repaint over-firing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-121/gutter-hover-repaint-edge-fix

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@freminal/src/gui/terminal/widget.rs`:
- Around line 1840-1842: Move the gutter hover cursor assignment from the
current effectively_hovered block into the final cursor-selection logic at the
end of show(), or incorporate cache.pointer_in_gutter_last_frame into that final
choice, ensuring gutter hover takes precedence over URL/OSC-22 and placeholder
hover cursor overrides.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 40d5a28c-4aee-46a6-aa6f-041ee68ef781

📥 Commits

Reviewing files that changed from the base of the PR and between d1aeded and 555a8b5.

📒 Files selected for processing (1)
  • freminal/src/gui/terminal/widget.rs

Comment on lines +1840 to 1842
if effectively_hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}

@coderabbitai coderabbitai Bot Jul 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C 3 'set_cursor_icon|cursor_icon\s*=' freminal/src/gui/terminal/widget.rs

Repository: fredsystems/freminal

Length of output: 1583


🏁 Script executed:

#!/bin/bash
sed -n '1828,1848p' freminal/src/gui/terminal/widget.rs
printf '\n----\n'
sed -n '3380,3480p' freminal/src/gui/terminal/widget.rs

Repository: fredsystems/freminal

Length of output: 5868


Move the gutter cursor override to the final cursor-selection block.
set_cursor_icon(PointingHand) here can be replaced later by the URL/OSC-22 cursor write and the placeholder hover override, so gutter hover won’t reliably win. Apply it at the end of show(), or fold cache.pointer_in_gutter_last_frame into the final cursor choice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@freminal/src/gui/terminal/widget.rs` around lines 1840 - 1842, Move the
gutter hover cursor assignment from the current effectively_hovered block into
the final cursor-selection logic at the end of show(), or incorporate
cache.pointer_in_gutter_last_frame into that final choice, ensuring gutter hover
takes precedence over URL/OSC-22 and placeholder hover cursor overrides.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed valid, and confirmed pre-existing on main before this PR (the if hovered { set_cursor_icon(...) } line is unchanged in logic here, only the variable feeding it changed from hovered to effectively_hovered).

Verified the mechanism precisely: Context::set_cursor_icon (egui 0.35.0) is literally self.output_mut(|o| o.cursor_icon = cursor_icon) -- the exact same plain-write mechanism the later URL/OSC-22/placeholder-hover block (widget.rs:3323-3474) uses, which always executes one of its branches every frame and therefore always overwrites this earlier call. So the gutter's pointing-hand cursor currently has no visible effect, independent of anything in this PR.

Since it's pre-existing and unrelated to the repaint-firing fix this PR targets, filing it separately rather than scope-creeping this PR: #462

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@fredclausen, agreed. Thanks for verifying the exact egui write behavior and filing the focused follow-up in #462. This PR can remain scoped to the gutter repaint regression.


✏️ Learnings added
Learnt from: fredclausen
Repo: fredsystems/freminal PR: 461
File: freminal/src/gui/terminal/widget.rs:1840-1842
Timestamp: 2026-07-27T23:29:31.433Z
Learning: In `freminal/src/gui/terminal/widget.rs`, the command-block gutter’s early `Context::set_cursor_icon(CursorIcon::PointingHand)` is overwritten later in `FreminalTerminalWidget::show` by the URL/OSC-22/placeholder cursor-selection logic, because all paths perform plain writes to egui’s `output.cursor_icon`. This pre-existing cursor-priority bug is tracked separately in GitHub issue `#462` and is not part of PR `#461`’s gutter repaint fix.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.04878% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
freminal/src/gui/terminal/widget.rs 78.04% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@fredclausen

Copy link
Copy Markdown
Member Author

Before/after validation (real workload, same scenario: mouse resting statically over the command-block gutter, 40s each)

Captured with the same perf --call-graph dwarf,65528 --no-inline methodology used throughout #459, before-fix binary built from main (pre-#461) and after-fix binary built from this branch, both copied to stable paths to avoid the in-place-rebuild/build-id corruption issue documented in #459.

Metric Before After Change
Total on-CPU samples (identical 40s scenario) 914 171 -81%
freminal's own DSO share (children/self) 72.17%/52.43% 25.25%/25.25% dominant -> minority
Dominant cost Continuous GPU swap/draw-call machinery (SwapBuffers, tc_flush, BufferData, DrawElementsBaseVertex chains) Same baseline GPU-present cost seen in #459's true-idle capture (occasional eglSwapBuffers/blit), not continuous

Honesty note: the specific show_menu_bar_contents/MenuButton::ui symbols that originally motivated this fix (from #459's true-idle capture) did not show up prominently in either of these two new captures -- the observable manifestation here was continuous full-frame GPU render/present cycles rather than menu-bar reconstruction specifically. Plausibly a variant of the same root cause (continuous bare request_repaint() forcing continuous full frames, whichever downstream cost dominates a given capture) rather than a different bug, but I want to be precise that this test validates the aggregate cost reduction, not the narrower menu-bar-rebuild mechanism specifically. The 914->171 sample-count drop for the identical real-world scenario is the robust, headline result.

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.

1 participant