From c4a3648df2d948899394df2def1bd894f57c4e87 Mon Sep 17 00:00:00 2001 From: Isaac Corbrey Date: Thu, 11 Jun 2026 21:28:58 -0500 Subject: [PATCH] filetree: Drag the panel border to resize Press-and-drag on the column adjacent to the panel border resizes the panel in real time, clamped between a configurable min/max and the `min_remaining_editor_width` budget so the editor can never be squeezed out. `compute_resize_width` lives as a pure helper so the boundary math (clamps, position-aware sign) is unit-tested without faking a Context. --- helix-term/src/ui/filetree.rs | 242 +++++++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 4 deletions(-) diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index 196fd165..7c946bba 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -32,6 +32,54 @@ use std::time::{Duration, Instant}; /// to make it user-tunable. const DOUBLE_CLICK_THRESHOLD: Duration = Duration::from_millis(500); +/// Minimum panel width during a drag-resize. Tight enough to be useful for +/// users who want the panel out of the way, wide enough that the indicator +/// + 1ch margin (which need 3 cols total) and at least one character of an +/// entry name still fit. +const MIN_DRAG_WIDTH: u16 = 4; + +/// In-flight mouse-down/drag state. Lives on the component so the various +/// `handle_mouse` branches can coordinate — a Down can plant a drag-source, +/// a subsequent Drag can act on it, and the eventual Up tears it down. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DragState { + None, + /// A mouse-down landed on the panel's resize handle (the column flush + /// against the seam with the editor). `start_col` is the absolute screen + /// column the press hit, `start_width` is what the panel was before the + /// drag began. The delta between current event column and `start_col` + /// translates straight into a width change. + ResizingBorder { + start_col: u16, + start_width: u16, + }, +} + +/// Pure resize-math, factored out for testing the boundary cases (delta +/// direction by docked side, underflow, MIN_DRAG_WIDTH clamp). Saturates at +/// `MIN_DRAG_WIDTH` on the low end; the high end is left to render-time +/// `panel_rect.width.min(vstrip.width)` so an over-dragged panel just visually +/// clamps to the terminal without state.width having to know terminal width. +fn compute_resize_width( + start_width: u16, + start_col: u16, + current_col: u16, + position: FiletreePosition, +) -> u16 { + let raw_delta = current_col as i32 - start_col as i32; + let signed_delta = match position { + FiletreePosition::Left => raw_delta, + // A right-docked panel grows when the user drags left; flip the sign + // so a more-negative `raw_delta` increases the panel width. + FiletreePosition::Right => -raw_delta, + }; + let new_width = (start_width as i32).saturating_add(signed_delta); + // Clamp to [MIN_DRAG_WIDTH, u16::MAX] *before* the cast so a huge positive + // delta saturates at u16::MAX rather than wrapping back into a tiny value + // via the truncating `as u16`. + new_width.clamp(MIN_DRAG_WIDTH as i32, u16::MAX as i32) as u16 +} + /// What a click (mouse press-then-release) should do, given where the press /// landed, where the release landed, and whether the last completed click was /// recent enough on the same row to promote this one to a double-click. @@ -158,6 +206,10 @@ pub struct Filetree { /// double-click and activates the entry. Cleared after each promotion /// so a triple-click doesn't fire two activations. last_click: Option<(usize, Instant)>, + /// In-flight drag, or `None` between gestures. Currently only used by + /// border drag-resize; drag-and-drop file moves will add a variant in + /// a later phase. + drag_state: DragState, } impl Default for Filetree { @@ -171,6 +223,7 @@ impl Default for Filetree { last_active_doc_path: None, pending_click_row: None, last_click: None, + drag_state: DragState::None, } } } @@ -477,16 +530,70 @@ impl Filetree { } } + /// Is `(col, row)` on the resize handle? The handle is the single column + /// of the panel flush against the seam with the editor — last column for + /// a left-docked panel, first column for a right-docked one. Any row + /// inside the panel's vertical extent counts. + /// + /// Zero-width panels (e.g. auto-hidden state where `self.area` was reset + /// to default) never have a handle. + fn is_resize_handle(&self, col: u16, row: u16, position: FiletreePosition) -> bool { + if self.area.width == 0 + || row < self.area.y + || row >= self.area.y.saturating_add(self.area.height) + { + return false; + } + let handle_col = match position { + FiletreePosition::Left => self.area.x.saturating_add(self.area.width - 1), + FiletreePosition::Right => self.area.x, + }; + col == handle_col + } + fn handle_mouse(&mut self, event: &MouseEvent, ctx: &mut Context) -> EventResult { + // A resize in progress claims every subsequent mouse event until Up, + // even if the cursor drifts outside the panel. Branching here first + // means `within` checks below don't accidentally drop drag events. + if let DragState::ResizingBorder { + start_col, + start_width, + } = self.drag_state + { + let position = ctx.editor.config().filetree.position; + match event.kind { + MouseEventKind::Drag(MouseButton::Left) => { + let new_width = + compute_resize_width(start_width, start_col, event.column, position); + ctx.editor.filetree.width = new_width; + // A drag means the gesture is definitely a resize, not a + // click — cancel any pending click/double-click pairing + // that the original Down might have planted. + self.pending_click_row = None; + self.last_click = None; + return EventResult::Consumed(None); + } + MouseEventKind::Up(MouseButton::Left) => { + self.drag_state = DragState::None; + self.pending_click_row = None; + return EventResult::Consumed(None); + } + // Swallow other events (Moved, Drag(Right), etc.) while we + // own the drag — letting them through could let the editor + // misinterpret them. + _ => return EventResult::Consumed(None), + } + } + let within = self.contains(event.column, event.row); match event.kind { // Mouse-down inside the panel: focus the tree, move the cursor // to the clicked row, and remember the row index for the Up. - // We deliberately *don't* activate on Down — it leaves Down/Drag - // free for the future drag-and-drop work, and double-click - // detection lives on Up where both clicks of a pair are - // observable. + // If the press also landed on the resize handle, plant a drag + // source — the gesture is *potentially* a resize, and a + // subsequent Drag event confirms it. Without a Drag, the + // eventual Up runs the normal click logic. MouseEventKind::Down(MouseButton::Left) => { if !within { self.pending_click_row = None; @@ -499,6 +606,13 @@ impl Filetree { } else { self.pending_click_row = None; } + let position = ctx.editor.config().filetree.position; + if self.is_resize_handle(event.column, event.row, position) { + self.drag_state = DragState::ResizingBorder { + start_col: event.column, + start_width: ctx.editor.filetree.width, + }; + } EventResult::Consumed(None) } // Mouse-up inside the panel. @@ -1007,6 +1121,126 @@ mod tests { assert_eq!(tree.row_to_entry_idx(15, &state), None); } + /// [`Filetree::is_resize_handle`] picks out the single column flush + /// against the seam with the editor. Left-docked → last column of the + /// panel; right-docked → first column. Outside the panel's vertical + /// extent never qualifies. Zero-width panels (auto-hidden state) have + /// no handle. + #[test] + fn is_resize_handle_table() { + let mut tree = Filetree::new(); + tree.area = Rect { + x: 0, + y: 0, + width: 30, + height: 10, + }; + + // Left-docked: handle is the rightmost column. + let pos = FiletreePosition::Left; + assert!(tree.is_resize_handle(29, 0, pos), "rightmost col, top row"); + assert!( + tree.is_resize_handle(29, 9, pos), + "rightmost col, bottom row" + ); + assert!( + tree.is_resize_handle(29, 5, pos), + "rightmost col, middle row" + ); + assert!(!tree.is_resize_handle(28, 5, pos), "second-rightmost col"); + assert!( + !tree.is_resize_handle(0, 5, pos), + "leftmost col (would be right-dock handle)" + ); + assert!( + !tree.is_resize_handle(29, 10, pos), + "rightmost col but row past bottom" + ); + assert!(!tree.is_resize_handle(30, 5, pos), "one col past panel"); + + // Right-docked: handle is the leftmost column. + let mut tree = Filetree::new(); + tree.area = Rect { + x: 50, + y: 0, + width: 30, + height: 10, + }; + let pos = FiletreePosition::Right; + assert!(tree.is_resize_handle(50, 0, pos), "leftmost col, top row"); + assert!( + tree.is_resize_handle(50, 9, pos), + "leftmost col, bottom row" + ); + assert!(!tree.is_resize_handle(51, 5, pos), "second-leftmost col"); + assert!( + !tree.is_resize_handle(79, 5, pos), + "rightmost col (would be left-dock handle)" + ); + assert!(!tree.is_resize_handle(49, 5, pos), "one col before panel"); + + // Zero-width panel (e.g. auto-hidden, area reset to default): no + // column qualifies. + let mut tree = Filetree::new(); + tree.area = Rect::default(); + assert!(!tree.is_resize_handle(0, 0, FiletreePosition::Left)); + assert!(!tree.is_resize_handle(0, 0, FiletreePosition::Right)); + } + + /// [`compute_resize_width`] does the absolute-column → new-width math. + /// Closed domain: docked side × delta sign × clamp boundary. + #[test] + fn compute_resize_width_table() { + use FiletreePosition::*; + + // Left-docked panel: dragging the handle right grows the panel. + assert_eq!( + compute_resize_width(30, 29, 35, Left), + 36, + "left-dock + drag right = grow" + ); + assert_eq!( + compute_resize_width(30, 29, 20, Left), + 21, + "left-dock + drag left = shrink" + ); + + // Right-docked: signs flip. Dragging left grows the panel. + assert_eq!( + compute_resize_width(30, 50, 40, Right), + 40, + "right-dock + drag left = grow" + ); + assert_eq!( + compute_resize_width(30, 50, 60, Right), + 20, + "right-dock + drag right = shrink" + ); + + // No movement: width unchanged. + assert_eq!(compute_resize_width(30, 29, 29, Left), 30); + assert_eq!(compute_resize_width(30, 50, 50, Right), 30); + + // MIN_DRAG_WIDTH clamp. Dragging way below 4 saturates at 4. + assert_eq!( + compute_resize_width(30, 29, 0, Left), + MIN_DRAG_WIDTH, + "clamp to MIN_DRAG_WIDTH" + ); + assert_eq!( + compute_resize_width(5, 29, 25, Left), + MIN_DRAG_WIDTH, + "would-be 1 clamps to MIN_DRAG_WIDTH" + ); + + // Saturating add: huge positive delta doesn't wrap. + assert_eq!( + compute_resize_width(30, 0, u16::MAX, Left), + u16::MAX, + "huge delta saturates at u16::MAX, not wraps to 0" + ); + } + /// [`classify_click`] is the small state machine that decides /// `Cancel`/`Select`/`Activate` from press, release, and history. Closed /// input domain: every combination of (pending, released, last_click, -- 2.51.2