Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 122 additions & 3 deletions freminal/src/gui/terminal/widget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,44 @@ pub(super) fn handle_scrollbar(
new_offset
}

/// Decide whether the command-block gutter's hover-tint state changed
/// enough to need a repaint, and what the new cached hover state should be.
///
/// This is the pure decision logic factored out of the gutter hover block
/// so it is unit-testable without a live `egui::Ui`/`Context`.
///
/// `hovered` is the raw `Response::hovered()` for this frame's gutter
/// interact region. `pointer_in_window` is whether the pointer currently
/// has *any* position in this window
/// (`PointerState::latest_pos().is_some()`). `was_hovering` is the cached
/// state from the previous frame (`ViewState::pointer_in_gutter_last_frame`
/// or equivalent).
///
/// `hovered()` is keyed off egui's `interact_pos()`, which lags
/// `latest_pos()` by one frame specifically when the pointer leaves the OS
/// window (egui's own documented behavior around `Event::PointerGone`:
/// `latest_pos` clears to `None` immediately, but `interact_pos` is not
/// cleared until the next frame). A naive `hovered != was_hovering` edge
/// check would therefore read stale-`true` on the exact frame the pointer
/// leaves the window, evaluate `true != true` = no change, and fail to
/// schedule the very repaint needed to observe the real (cleared) state on
/// a later frame -- since nothing else is guaranteed to wake a frame,
/// the hover tint could get stuck indefinitely. Folding in
/// `pointer_in_window` (cleared same-frame on window-exit) closes that gap:
/// a pointer that has left the window is treated as "not hovering"
/// immediately, without waiting for `interact_pos()`'s one-frame lag to
/// catch up.
///
/// Returns `(needs_repaint, new_cached_state)`.
const fn gutter_hover_repaint_decision(
hovered: bool,
pointer_in_window: bool,
was_hovering: bool,
) -> (bool, bool) {
let effectively_hovered = hovered && pointer_in_window;
(effectively_hovered != was_hovering, effectively_hovered)
}

/// Duration of the visual bell flash overlay.
const BELL_FLASH_DURATION: Duration = Duration::from_millis(150);

Expand Down Expand Up @@ -1788,13 +1826,24 @@ impl FreminalTerminalWidget {
egui::Sense::click(),
);
let hovered = gutter_response.hovered();
if hovered {
// See `gutter_hover_repaint_decision` for why `latest_pos()` is
// folded in alongside `hovered()` here: it detects a
// pointer-left-the-window exit in the same frame it happens,
// instead of one frame late (egui's documented `interact_pos()`
// lag around `Event::PointerGone`).
let pointer_in_window = ui.ctx().input(|i| i.pointer.latest_pos().is_some());
let (needs_repaint, effectively_hovered) = gutter_hover_repaint_decision(
hovered,
pointer_in_window,
cache.pointer_in_gutter_last_frame,
);
if effectively_hovered {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
Comment on lines +1840 to 1842

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

if hovered || cache.pointer_in_gutter_last_frame {
if needs_repaint {
ui.ctx().request_repaint();
}
cache.pointer_in_gutter_last_frame = hovered;
cache.pointer_in_gutter_last_frame = effectively_hovered;
} else if cache.pointer_in_gutter_last_frame {
// Feature toggled off / alt-screen entered while we were hovering:
// draw one clearing frame.
Expand Down Expand Up @@ -3670,6 +3719,76 @@ fn handle_file_drop(ui: &Ui, terminal_rect: Rect, input_tx: &Sender<InputEvent>)
}
}

#[cfg(test)]
mod gutter_hover_repaint_decision_tests {
//! Tests for [`gutter_hover_repaint_decision`], the pure decision
//! function behind the command-block gutter's hover-tint repaint
//! logic. Covers the steady-state / enter / leave-within-window cases
//! plus the critical regression case: a same-frame pointer-left-window
//! exit must be detected immediately via `pointer_in_window`, without
//! waiting for `hovered()`'s one-frame `interact_pos()` lag to catch up
//! (see the function doc comment for the full egui behavior citation).

use super::gutter_hover_repaint_decision;

#[test]
fn steady_hover_no_change() {
assert_eq!(
gutter_hover_repaint_decision(true, true, true),
(false, true)
);
}

#[test]
fn steady_not_hovering_no_change() {
assert_eq!(
gutter_hover_repaint_decision(false, true, false),
(false, false)
);
}

#[test]
fn enter_transition_fires_and_now_hovering() {
assert_eq!(
gutter_hover_repaint_decision(true, true, false),
(true, true)
);
}

#[test]
fn leave_within_window_transition_fires_and_now_not_hovering() {
// Pointer is still inside the window, but has moved off the
// gutter's interact rect.
assert_eq!(
gutter_hover_repaint_decision(false, true, true),
(true, false)
);
}

#[test]
fn pointer_left_window_fires_immediately_despite_stale_hovered() {
// Regression guard: `hovered` is stale-`true` here (simulating
// egui's one-frame `interact_pos()` lag on `Event::PointerGone`),
// but `pointer_in_window` correctly reports `false` in the same
// frame the cursor left the OS window. The decision must fire the
// repaint immediately and clear the cached hover state -- it must
// NOT require a follow-up frame to observe the real state, since
// nothing else is guaranteed to wake one.
assert_eq!(
gutter_hover_repaint_decision(true, false, true),
(true, false)
);
}

#[test]
fn steady_state_after_window_exit_settled_no_more_repaints() {
assert_eq!(
gutter_hover_repaint_decision(false, false, false),
(false, false)
);
}
}

#[cfg(test)]
mod bell_flash_tests {
//! Tests for [`bell_flash_outcome`], the pure decision function behind
Expand Down