diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index 9e0926c9..ebdb0ef2 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -22,6 +22,8 @@ //! routing, VCS coloring, file operations, and the filesystem watcher //! aren't wired up here. +mod context_menu; + use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -260,6 +262,14 @@ impl Filetree { self.dirty = true; } + /// Force the entries list to rebuild on the next render. Used by + /// [`context_menu::ContextMenu`]'s `ToggleExpand` action to react to a + /// mutation of `editor.filetree.expanded` it performed from outside the + /// component. + pub fn mark_dirty(&mut self) { + self.dirty = true; + } + /// Expand the directories between the root and `path`, then queue a /// reveal of `path` itself for the next render. Returns whether the /// reveal was set up — `false` means the path isn't under the root and @@ -648,6 +658,40 @@ impl Filetree { } EventResult::Consumed(None) } + // Right-click inside the panel: focus the tree, move the cursor + // to the clicked row, and push the context menu as a new + // compositor layer anchored at the click position. The menu + // owns the dispatch from there until it's dismissed. + // + // We do the cursor move *and* the menu push in one gesture + // because right-clicking a different row than where the cursor + // currently sits and then having the menu act on the *cursor* + // (not the right-clicked row) would be surprising. Operating + // on the click target is also what makes the menu's "Toggle + // expand" agree with the user's pointer-finger intent. + MouseEventKind::Down(MouseButton::Right) => { + if !within { + return EventResult::Ignored(None); + } + ctx.editor.filetree.focused = true; + let Some(idx) = self.row_to_entry_idx(event.row, &ctx.editor.filetree) else { + return EventResult::Consumed(None); + }; + ctx.editor.filetree.cursor_idx = idx; + let entry = &self.entries[idx]; + let target = entry.path.clone(); + let is_dir = entry.kind.is_dir(); + let anchor = (event.column, event.row); + EventResult::Consumed(Some(Box::new(move |compositor, _cx| { + // `replace_or_push` keeps the menu single-instance — + // a second right-click while a menu is already open + // re-anchors and re-targets rather than stacking. + compositor.replace_or_push( + context_menu::ID, + context_menu::ContextMenu::for_entry(anchor, target, is_dir), + ); + }))) + } // Mouse-up inside the panel. // // Single-click semantics: when the press and release land on the diff --git a/helix-term/src/ui/filetree/context_menu.rs b/helix-term/src/ui/filetree/context_menu.rs new file mode 100644 index 00000000..6b5892e2 --- /dev/null +++ b/helix-term/src/ui/filetree/context_menu.rs @@ -0,0 +1,542 @@ +//! Right-click context menu for the filetree. +//! +//! Pushed as its own compositor layer on top of [`super::Filetree`] when the +//! user right-clicks an entry. Renders a small bordered popup at (or near) +//! the click position with a list of actions appropriate for the clicked +//! entry's kind: +//! +//! - File: `Open`, `Open in horizontal split`, `Open in vertical split`. +//! - Directory: `Toggle expand`. +//! +//! Dismiss-then-fall-through on click-outside, dismiss-only on ``, +//! activate-then-dismiss on `` or click-inside. Mouse hover inside +//! the menu moves the selection (so single-click-to-activate works +//! naturally even without keyboard navigation). +//! +//! File operations beyond opening (`new`, `rename`, `delete`) are not here +//! yet — they live in the same conceptual surface but need the +//! [`crate::ui::Prompt`] flow for confirmation, which is a separate phase. + +use std::path::{Path, PathBuf}; + +use helix_view::editor::Action; +use helix_view::graphics::Rect; +use helix_view::input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use helix_view::keyboard::KeyCode; + +use tui::buffer::Buffer as Surface; + +use crate::compositor::{self, Component, Context, Event, EventResult}; + +/// One row in the context menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuItem { + /// Open the target file in the focused view, replacing the current buffer. + Open, + /// Open the target file in a horizontal split below the focused view. + OpenHorizontalSplit, + /// Open the target file in a vertical split next to the focused view. + OpenVerticalSplit, + /// Expand a collapsed directory or collapse an expanded one. The label + /// is "Toggle expand" rather than "Expand"/"Collapse" because the + /// dynamic label change would require the menu to know the directory's + /// current expanded state at render time, which costs more than it's + /// worth for a one-word affordance. + ToggleExpand, +} + +impl ContextMenuItem { + /// Visible label rendered into the menu cell. + pub fn label(self) -> &'static str { + match self { + Self::Open => "Open", + Self::OpenHorizontalSplit => "Open in horizontal split", + Self::OpenVerticalSplit => "Open in vertical split", + Self::ToggleExpand => "Toggle expand", + } + } +} + +/// Build the item list for an entry. Pure function so the per-kind decision +/// is testable in isolation — and so adding a kind later (symlinks?) only +/// touches this site. +pub fn items_for(is_dir: bool) -> Vec { + if is_dir { + vec![ContextMenuItem::ToggleExpand] + } else { + vec![ + ContextMenuItem::Open, + ContextMenuItem::OpenHorizontalSplit, + ContextMenuItem::OpenVerticalSplit, + ] + } +} + +/// The compositor layer ID. Held as a constant so [`Self::activate`] and +/// [`Self::dismiss_callback`] can both refer to the same name when scheduling +/// removal from the compositor. +pub const ID: &str = "filetree-context-menu"; + +/// Menu chrome — 1 cell of horizontal padding on each side, no vertical +/// padding, no border. So a 5-character label needs `5 + 2 = 7` columns, +/// and `n` items need exactly `n` rows. Visual separation from the +/// filetree panel underneath comes from the dedicated +/// `ui.filetree.context-menu` theme scope (set below), so vertical +/// padding rows would just read as wasted space rather than as the +/// "contained block" cue padding usually provides. +const HORIZONTAL_PADDING: u16 = 1; +const VERTICAL_PADDING: u16 = 0; + +/// Right-click context menu for the filetree. +pub struct ContextMenu { + /// Where the right-click landed. Top-left of the menu will sit *at* + /// this position unless clamping shifts it (see [`Self::compute_rect`]). + anchor: (u16, u16), + /// Cached rect from the most recent render. Updated on each `render` + /// pass and used by `handle_mouse` for hit-testing. Default `Rect` until + /// the first render, in which case `contains()` will always return false + /// — i.e. all clicks fall through, dismissing the menu. + area: Rect, + /// Target entry path. Cloned into the activation callbacks so they can + /// outlive the component (the callback fires after dispatch, when the + /// menu has already been removed from the compositor). + target: PathBuf, + /// Item list, computed once at construction. Not regenerated since + /// the entry kind it was built for can't change while the menu's open + /// (the panel doesn't re-walk while the menu is up — the menu owns the + /// dispatch). + items: Vec, + /// Currently-selected item index. Kept in `[0, items.len())` by + /// [`Self::navigate`] and the click-to-select path. + selected: usize, +} + +impl ContextMenu { + /// Build a menu for the given entry. `anchor` is where the menu's + /// top-left corner *prefers* to sit; [`Self::compute_rect`] clamps it + /// onto the visible screen at render time. + pub fn for_entry(anchor: (u16, u16), target: PathBuf, is_dir: bool) -> Self { + Self { + anchor, + area: Rect::default(), + target, + items: items_for(is_dir), + selected: 0, + } + } + + /// Width of the widest item label, in cells. Used to size the menu. + fn widest_label(&self) -> u16 { + self.items + .iter() + .map(|i| i.label().chars().count() as u16) + .max() + .unwrap_or(0) + } + + /// Compute the menu rect, clamped to the screen `area`. The top-left + /// of the menu will be the anchor unless that would push the menu off + /// the right or bottom edge, in which case we shift it left / up just + /// far enough to fit. + fn compute_rect(&self, area: Rect) -> Rect { + let menu_width = self.widest_label() + 2 * HORIZONTAL_PADDING; + let menu_height = self.items.len() as u16 + 2 * VERTICAL_PADDING; + // Saturate the clamp so a tiny terminal that can't fit the menu at + // all still produces a valid (clamped-to-the-corner) rect rather + // than panicking. The menu won't *look* right, but render is + // defensive against clipped sub-areas. + let x = self.anchor.0.min(area.width.saturating_sub(menu_width)); + let y = self.anchor.1.min(area.height.saturating_sub(menu_height)); + Rect { + x, + y, + width: menu_width.min(area.width), + height: menu_height.min(area.height), + } + } + + /// Hit-test a screen coordinate against the menu rect. + fn contains(&self, col: u16, row: u16) -> bool { + col >= self.area.x + && col < self.area.x.saturating_add(self.area.width) + && row >= self.area.y + && row < self.area.y.saturating_add(self.area.height) + } + + /// Translate a screen row to an item index. Returns `None` for rows + /// outside the menu's vertical extent *and* for rows that land on the + /// top/bottom padding rows (which carry no item). + fn row_to_item_idx(&self, screen_row: u16) -> Option { + let content_top = self.area.y.saturating_add(VERTICAL_PADDING); + let row_offset = screen_row.checked_sub(content_top)? as usize; + if row_offset < self.items.len() { + Some(row_offset) + } else { + None + } + } + + /// Bump the selection by `delta`, clamping to the item range. + fn navigate(&mut self, delta: isize) { + if self.items.is_empty() { + return; + } + let max = self.items.len() - 1; + let new = (self.selected as isize + delta).clamp(0, max as isize) as usize; + self.selected = new; + } + + /// Compositor callback that just removes the menu layer. Used for Esc + /// and for click-outside (where the same callback also lets the click + /// fall through to the underlying filetree). + fn dismiss_callback() -> compositor::Callback { + Box::new(|compositor, _cx| { + compositor.remove(ID); + }) + } + + /// Compositor callback that performs the currently-selected item's + /// action *and* removes the menu. Cloned `target` is captured by move + /// because the closure outlives the menu component (we're being torn + /// down by the same callback). + fn activate(&self) -> Option { + let item = *self.items.get(self.selected)?; + let target = self.target.clone(); + Some(Box::new(move |compositor, cx| { + apply_action(item, &target, compositor, cx); + compositor.remove(ID); + })) + } + + fn handle_key(&mut self, key: KeyEvent) -> EventResult { + match key.code { + KeyCode::Esc => EventResult::Consumed(Some(Self::dismiss_callback())), + KeyCode::Up | KeyCode::Char('k') => { + self.navigate(-1); + EventResult::Consumed(None) + } + KeyCode::Down | KeyCode::Char('j') => { + self.navigate(1); + EventResult::Consumed(None) + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected = 0; + EventResult::Consumed(None) + } + KeyCode::End | KeyCode::Char('G') => { + self.selected = self.items.len().saturating_sub(1); + EventResult::Consumed(None) + } + KeyCode::Enter => { + if let Some(cb) = self.activate() { + EventResult::Consumed(Some(cb)) + } else { + EventResult::Consumed(Some(Self::dismiss_callback())) + } + } + // Anything else while the menu is up is intentionally swallowed + // — leaking through would let typed keystrokes hit the editor + // through what the user perceives as a modal popup, which is + // surprising and lossy. + _ => EventResult::Consumed(None), + } + } + + fn handle_mouse(&mut self, event: &MouseEvent) -> EventResult { + let within = self.contains(event.column, event.row); + match event.kind { + // Click outside dismisses the menu *and* bubbles, so the same + // click that closes the menu can land on whatever was under it + // (e.g. a row in the filetree). Standard popup convention. + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Down(MouseButton::Right) => { + if !within { + return EventResult::Ignored(Some(Self::dismiss_callback())); + } + if let Some(idx) = self.row_to_item_idx(event.row) { + self.selected = idx; + } + EventResult::Consumed(None) + } + // Mouse-up inside the menu on an item activates. Outside, we + // let the up bubble so the underlying layer can match Down/Up + // pairs cleanly. + MouseEventKind::Up(MouseButton::Left) => { + if !within { + return EventResult::Ignored(None); + } + if self.row_to_item_idx(event.row).is_some() { + if let Some(cb) = self.activate() { + return EventResult::Consumed(Some(cb)); + } + } + EventResult::Consumed(None) + } + // Hover inside the menu moves the selection so single-click + // works naturally. Either way — inside or outside, with or + // without a selection change — we *always* Consume so the + // underlying filetree never sees the Moved event and never + // paints its row-hover underline through the open menu. + // The menu is the topmost interactive surface for its + // lifetime; mouse motion belongs to it exclusively. + MouseEventKind::Moved => { + if within { + if let Some(idx) = self.row_to_item_idx(event.row) { + if self.selected != idx { + self.selected = idx; + } + } + } + EventResult::Consumed(None) + } + // Swallow scroll-wheel events that land inside the menu so they + // don't scroll the underlying filetree out from under the + // user. Outside the menu, let them through. + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if within => { + EventResult::Consumed(None) + } + _ => EventResult::Ignored(None), + } + } +} + +/// Run the action a menu item represents. Separated out so the dispatch +/// closure (`activate`) stays tiny. +fn apply_action( + item: ContextMenuItem, + target: &Path, + compositor: &mut crate::compositor::Compositor, + cx: &mut Context, +) { + match item { + ContextMenuItem::Open => open_with(cx, target, Action::Replace), + ContextMenuItem::OpenHorizontalSplit => open_with(cx, target, Action::HorizontalSplit), + ContextMenuItem::OpenVerticalSplit => open_with(cx, target, Action::VerticalSplit), + ContextMenuItem::ToggleExpand => { + cx.editor.filetree.toggle_expanded(target); + // The filetree's flat entries list is component-local and only + // rebuilt when `dirty` is set. Reach into the layer stack and + // flip the flag so the next render reflects the new expansion + // state — without this the menu would dismiss cleanly but the + // panel below would still show the old shape. + if let Some(filetree) = compositor.find_id::(super::ID) { + filetree.mark_dirty(); + } + } + } +} + +fn open_with(cx: &mut Context, path: &Path, action: Action) { + if let Err(e) = cx.editor.open(path, action) { + cx.editor + .set_error(format!("unable to open {}: {}", path.display(), e)); + } + // Drop focus from the tree — same as the keyboard-Enter / click-open + // path. The user just picked a file to edit; the next keystroke should + // go to the buffer. + cx.editor.filetree.focused = false; +} + +impl Component for ContextMenu { + fn render(&mut self, area: Rect, surface: &mut Surface, ctx: &mut Context) { + let rect = self.compute_rect(area); + self.area = rect; + + if rect.width == 0 || rect.height == 0 { + // Terminal too small to fit any of the menu; render nothing. + // The menu will still consume input until dismissed. + return; + } + + let theme = &ctx.editor.theme; + // Background chain. The dedicated `ui.filetree.context-menu` scope + // takes priority so themes that ship a distinct color for the + // panel's popup can express that intent directly. Beyond that, we + // fall back through the standard chain: `ui.popup` (themes use it + // for floating surfaces; gruvbox/catppuccin tend to give it its + // own bg), then `ui.menu`, then `ui.window`. Falling all the way + // back keeps minimally-themed setups working — they just won't + // get the visual separation a richer theme provides. + // + // The filetree panel underneath ALSO uses `ui.menu` as its + // fallback bg, so without the explicit `ui.filetree.context-menu` + // scope minimalist themes (e.g. darcula-solid, which doesn't + // override `ui.menu`) end up with the panel and the menu at the + // same color. The scope is the right hook for them to fix that. + let bg = theme + .try_get_exact("ui.filetree.context-menu") + .or_else(|| theme.try_get_exact("ui.popup")) + .or_else(|| theme.try_get_exact("ui.menu")) + .unwrap_or_else(|| theme.get("ui.window")); + let selected_style = theme + .try_get_exact("ui.filetree.context-menu.selected") + .or_else(|| theme.try_get("ui.menu.selected")) + .unwrap_or_else(|| theme.get("ui.cursor")); + + // Solid bg fill — the only visual chrome. A box-drawing border at + // this scale reads as busy ASCII art; the fill alone (plus the + // top/bottom vertical padding) separates the menu from whatever's + // underneath, and the selected highlight does the work of + // communicating "this is the next click target". + surface.clear_with(rect, bg); + + let label_x = rect.x + HORIZONTAL_PADDING; + let content_top = rect.y + VERTICAL_PADDING; + let content_bottom = rect.y + rect.height - VERTICAL_PADDING; + for (i, item) in self.items.iter().enumerate() { + let y = content_top + i as u16; + if y >= content_bottom { + break; + } + let style = if i == self.selected { + selected_style + } else { + bg + }; + // Stretch the selected style across the full row width so the + // highlight reads as a row, not just text. For non-selected + // rows this is a no-op (set_style with the same style is a + // patch with the same values). + for x_off in 0..rect.width { + surface[(rect.x + x_off, y)].set_style(style); + } + surface.set_string(label_x, y, item.label(), style); + } + } + + fn handle_event(&mut self, event: &Event, _ctx: &mut Context) -> EventResult { + match event { + Event::Key(key) => self.handle_key(*key), + Event::Mouse(mouse) => self.handle_mouse(mouse), + _ => EventResult::Ignored(None), + } + } + + fn id(&self) -> Option<&'static str> { + Some(ID) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn items_for_file_and_dir() { + // Files get the three "Open …" actions; directories get only + // toggle-expand. Closed domain: enumerate. + let file_items = items_for(false); + assert_eq!( + file_items, + vec![ + ContextMenuItem::Open, + ContextMenuItem::OpenHorizontalSplit, + ContextMenuItem::OpenVerticalSplit, + ], + ); + let dir_items = items_for(true); + assert_eq!(dir_items, vec![ContextMenuItem::ToggleExpand]); + } + + #[test] + fn navigate_clamps_to_range() { + let mut menu = ContextMenu::for_entry((0, 0), PathBuf::from("/x"), false); + // Items at this point: [Open, OpenHorizontalSplit, OpenVerticalSplit] + assert_eq!(menu.selected, 0); + + menu.navigate(1); + assert_eq!(menu.selected, 1); + menu.navigate(1); + assert_eq!(menu.selected, 2); + // Past the end clamps. + menu.navigate(1); + assert_eq!(menu.selected, 2); + + menu.navigate(-2); + assert_eq!(menu.selected, 0); + // Past the start clamps. + menu.navigate(-1); + assert_eq!(menu.selected, 0); + + // Big jumps are clamped, not wrapped. + menu.navigate(99); + assert_eq!(menu.selected, 2); + menu.navigate(-99); + assert_eq!(menu.selected, 0); + } + + #[test] + fn compute_rect_clamps_to_screen() { + // Anchor near the bottom-right of the screen should slide the menu + // up/left to stay on screen. + let menu = ContextMenu::for_entry((100, 100), PathBuf::from("/x"), false); + let screen = Rect { + x: 0, + y: 0, + width: 80, + height: 24, + }; + let rect = menu.compute_rect(screen); + assert!( + rect.x + rect.width <= screen.width, + "menu fits horizontally: x={} width={} screen_width={}", + rect.x, + rect.width, + screen.width, + ); + assert!( + rect.y + rect.height <= screen.height, + "menu fits vertically: y={} height={} screen_height={}", + rect.y, + rect.height, + screen.height, + ); + + // An anchor that fits naturally stays put. + let menu = ContextMenu::for_entry((10, 5), PathBuf::from("/x"), false); + let rect = menu.compute_rect(screen); + assert_eq!((rect.x, rect.y), (10, 5)); + + // A tiny screen smaller than the menu still produces a valid rect + // (might render clipped, but no panic). + let tiny = Rect { + x: 0, + y: 0, + width: 5, + height: 2, + }; + let rect = menu.compute_rect(tiny); + assert!(rect.width <= tiny.width); + assert!(rect.height <= tiny.height); + } + + #[test] + fn row_to_item_idx_maps_to_entries() { + let mut menu = ContextMenu::for_entry((0, 10), PathBuf::from("/x"), false); + // Pretend a render happened, putting area at (0, 10) with the menu's + // computed size. + menu.area = menu.compute_rect(Rect { + x: 0, + y: 0, + width: 80, + height: 24, + }); + // With no vertical padding, row 0 of the menu *is* the first item. + assert_eq!(menu.row_to_item_idx(menu.area.y), Some(0)); + assert_eq!(menu.row_to_item_idx(menu.area.y + 1), Some(1)); + // Last content row → last item. + let last_content = menu.area.y + menu.items.len() as u16 - 1; + assert_eq!( + menu.row_to_item_idx(last_content), + Some(menu.items.len() - 1) + ); + // One past the bottom → None. + assert_eq!(menu.row_to_item_idx(menu.area.y + menu.area.height), None); + // Far below the menu → None. + assert_eq!( + menu.row_to_item_idx(menu.area.y + menu.area.height + 5), + None + ); + // Above the menu → None (saturating sub). + assert_eq!(menu.row_to_item_idx(0), None); + } +} diff --git a/runtime/themes/darcula-solid.toml b/runtime/themes/darcula-solid.toml index c867583d..e042cf9d 100644 --- a/runtime/themes/darcula-solid.toml +++ b/runtime/themes/darcula-solid.toml @@ -8,6 +8,20 @@ inherits = "darcula" "ui.window" = { bg = "grey00" } "ui.cursorline.secondary" = { bg = "grey03" } +# Filetree context menu lives at grey02 — one shade lighter than the panel +# (which uses ui.menu / grey00) so the popup reads as floating above it +# rather than blending in. Selected row gets grey04 with bold dark text +# for a bright, unambiguous "this is the focused command" indicator. +"ui.filetree.context-menu" = { fg = "grey05", bg = "grey02" } +"ui.filetree.context-menu.selected" = { fg = "grey00", bg = "grey04", modifiers = ["bold"] } + +# Filetree cursor row gets its own scope at grey03 (one shade darker than +# the context menu's selected row at grey04) so when the context menu is +# open over a selected entry the two highlights read as distinct bands — +# without this override, the filetree row falls back through ui.menu.selected +# to grey04 and visually merges with the context menu's highlight. +"ui.filetree.selected" = { bg = "grey03" } + [palette] grey00 = "#101010" grey01 = "#1f1f1f"