From aebb9f3c5ef4ef4dd28d871407aa8233b68c09fe Mon Sep 17 00:00:00 2001 From: Isaac Corbrey Date: Thu, 11 Jun 2026 21:30:58 -0500 Subject: [PATCH] filetree: Hover indicators for rows and the resize handle Tracks the most recent mouse position so the row under the cursor and the resize-handle column get a subdued highlight (themed via `ui.filetree.hover` and `ui.filetree.resize-handle.hover`), giving mouse users an unobtrusive "this is what I'm about to act on" cue before they click. --- helix-term/src/ui/filetree.rs | 130 +++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index 7c946bba..9e0926c9 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -210,6 +210,16 @@ pub struct Filetree { /// border drag-resize; drag-and-drop file moves will add a variant in /// a later phase. drag_state: DragState, + /// Entry index the mouse is currently hovering over, or `None` if the + /// cursor isn't over any panel row. Updated by `MouseEventKind::Moved` + /// events; the renderer paints this row with a subtle highlight so the + /// user knows where the next click will land before they click. + hovered_row: Option, + /// `true` when the mouse cursor is sitting on the resize handle column + /// (with no button held). Triggers the `│` glyph in the render so the + /// handle becomes visually present and the drag-to-resize affordance is + /// discoverable. + hovered_handle: bool, } impl Default for Filetree { @@ -224,6 +234,8 @@ impl Default for Filetree { pending_click_row: None, last_click: None, drag_state: DragState::None, + hovered_row: None, + hovered_handle: false, } } } @@ -576,6 +588,23 @@ impl Filetree { MouseEventKind::Up(MouseButton::Left) => { self.drag_state = DragState::None; self.pending_click_row = None; + // Re-evaluate hover state at the release position. + // Without this, `hovered_handle` is still `false` from + // when the drag started (we explicitly cleared it in + // the Down arm to suppress the bar mid-drag), and the + // `│` wouldn't reappear until the user wiggled the + // mouse to trigger a Moved event — which is exactly + // the niggle this fixes. We seed both hover fields + // from the release position so both indicators come + // back in sync. + let within = self.contains(event.column, event.row); + self.hovered_handle = + within && self.is_resize_handle(event.column, event.row, position); + self.hovered_row = if within { + self.row_to_entry_idx(event.row, &ctx.editor.filetree) + } else { + None + }; return EventResult::Consumed(None); } // Swallow other events (Moved, Drag(Right), etc.) while we @@ -612,6 +641,10 @@ impl Filetree { start_col: event.column, start_width: ctx.editor.filetree.width, }; + // Clear handle hover the moment the drag starts — + // there's no point showing a "this is a handle" hint + // when the user is already using it. + self.hovered_handle = false; } EventResult::Consumed(None) } @@ -654,6 +687,37 @@ impl Filetree { } EventResult::Consumed(None) } + // Bare mouse movement (no button held) — update hover state so + // the renderer can paint "this is what you'd click" feedback. + // `Moved` events arrive constantly while the mouse drifts over + // the terminal because Helix enables AnyEventMouse tracking + // (CSI ?1003). + // + // We must return Consumed when hover state actually changes: + // `Application::handle_terminal_events` only schedules a render + // when `compositor.handle_event` returns `true`, which only + // happens if at least one layer Consumed the event. Without + // this, the hover state would update but never get redrawn + // until some unrelated event fired. Returning Ignored for + // no-op moves keeps the redraw frequency proportional to + // actual hover transitions, not mouse-pixel motion. + MouseEventKind::Moved => { + let position = ctx.editor.config().filetree.position; + let new_handle = within && self.is_resize_handle(event.column, event.row, position); + let new_row = if within { + self.row_to_entry_idx(event.row, &ctx.editor.filetree) + } else { + None + }; + let changed = self.hovered_row != new_row || self.hovered_handle != new_handle; + self.hovered_row = new_row; + self.hovered_handle = new_handle; + if changed { + EventResult::Consumed(None) + } else { + EventResult::Ignored(None) + } + } // Scroll-wheel: pan the viewport, cursor stays anchored on // its entry — buffer-scroll semantics. The cursor only // moves if it would drift outside the scrolloff comfort @@ -799,6 +863,32 @@ impl Component for Filetree { // some inherited highlight. let indicator_style = theme.try_get_exact("ui.filetree.indicator.open"); + // Hover indicator styles. Like the unfocused-cursor chain, these use + // `try_get_exact` to avoid the dotted fallback substituting the + // base text style (which would make hover invisible). + // + // For the row hover, the default fallback is a single-line + // underline. Underline beats BOLD here for two reasons: it renders + // consistently across terminals and font weights (BOLD can get + // lost in dense lists when the font's regular and bold weights + // are too close), and it doesn't compete with bg highlights — + // the cursor row's bg fill and an underlined hover read as two + // distinct affordances rather than two competing emphases. + let hover_row_style = theme + .try_get_exact("ui.filetree.row.hover") + .unwrap_or_else(|| { + helix_view::graphics::Style::default() + .underline_style(helix_view::graphics::UnderlineStyle::Line) + }); + // For the handle hover, we want a visibly *present* column — the + // glyph itself (`│`) carries the affordance, but the style decides + // how loud it reads. Fall back to the unfocused cursor style so it + // matches the panel's other "this is the active interactive thing" + // language. + let hover_handle_style = theme + .try_get_exact("ui.filetree.resize-handle.hover") + .unwrap_or(unfocused_cursor_style); + // Set of every path currently open as a buffer. Directories show the // indicator when *any* of their descendants is open, so callers will // also iterate this set for `starts_with` checks below. @@ -813,6 +903,14 @@ impl Component for Filetree { let scroll = ctx.editor.filetree.scroll; let cursor_idx = ctx.editor.filetree.cursor_idx; let focused = ctx.editor.filetree.focused; + // While a drag is in progress, suppress hover affordances — the + // user is already committed, and showing a "you'd grab here" hint + // would just be visual noise. Drag events arrive instead of Moved + // events anyway, so `hovered_*` stays at whatever it was when the + // drag started. + let in_drag = !matches!(self.drag_state, DragState::None); + let hovered_row = if in_drag { None } else { self.hovered_row }; + let hovered_handle = self.hovered_handle && !in_drag; // Reserve three columns on the right when the panel is wide enough: // col (width - 3) is a 1ch text gutter before the indicator, @@ -837,17 +935,26 @@ impl Component for Filetree { EntryKind::Directory { .. } => dir_style, }; - // Three visual states for row text: + let is_hovered = hovered_row == Some(entry_idx); + // Row text style, in priority order: // - cursor row + focused : focused selected style // - cursor row + unfocused: bufferline-active style (themed // chain above) - // - non-cursor row : the entry's base file/dir style + // - hovered row : hover row style (typically BOLD) + // - everything else : the entry's base file/dir style + // Cursor wins over hover because the cursor is sticky state, + // hover is mouse-position state. Selecting a row by clicking + // implicitly makes it the cursor, so no row should ever be + // both cursor and hover at once — but the priority ordering + // makes that explicit anyway. let style = if is_cursor_row { if focused { selected_style } else { unfocused_cursor_style } + } else if is_hovered { + base.patch(hover_row_style) } else { base }; @@ -898,6 +1005,25 @@ impl Component for Filetree { } } } + + // Resize-handle hover indicator. The handle column is the one + // immediately against the seam with the editor — last for left- + // docked, first for right-docked. While the mouse hovers it (and + // we're not already mid-drag), paint a vertical bar down the full + // height so the affordance becomes visually present. The bar + // covers any open-buffer dot or row text in that column, but the + // dot lives at width-2 (not width-1) precisely so it doesn't + // collide. + if hovered_handle { + let handle_col = match config.filetree.position { + FiletreePosition::Left => panel_rect.x + panel_rect.width - 1, + FiletreePosition::Right => panel_rect.x, + }; + for row in 0..visible_rows { + let y = panel_rect.y + row as u16; + surface.set_string(handle_col, y, "│", hover_handle_style); + } + } } fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { -- 2.51.2