diff --git a/helix-term/src/ui/filetree.rs b/helix-term/src/ui/filetree.rs index ff2cfd77..4e901fbf 100644 --- a/helix-term/src/ui/filetree.rs +++ b/helix-term/src/ui/filetree.rs @@ -26,7 +26,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use helix_view::editor::{Action, FiletreeConfig, FiletreePosition, FiletreeState}; -use helix_view::graphics::{Rect, UnderlineStyle}; +use helix_view::graphics::Rect; use helix_view::input::KeyEvent; use helix_view::keyboard::{KeyCode, KeyModifiers}; @@ -208,16 +208,16 @@ impl Filetree { /// Activate the entry under the cursor. /// /// For a file: opens it in the focused view via [`Action::Replace`] and - /// transfers keyboard focus back to the editor. For a directory: toggles - /// its expansion. Either way, the entries list is marked dirty so the - /// next render reflects the change. + /// hands keyboard focus back to the editor. For a directory: toggles its + /// expansion. In both cases the entries list is marked dirty so the next + /// render reflects the change. /// - /// Returns whether an action was actually taken (the cursor pointed at a - /// real entry). When `false`, the caller may still treat the keystroke - /// as consumed; this is just whether `editor` was mutated. - fn activate_under_cursor(&mut self, ctx: &mut Context) -> bool { + /// In narrow mode, dropping focus is also what makes the panel + /// auto-hide — see [`FiletreeState::render_mode`] — so a "file opened on + /// overlay" implicitly dismisses the panel without any explicit close. + fn activate_under_cursor(&mut self, ctx: &mut Context) { let Some(entry) = self.entry_under_cursor(&ctx.editor.filetree) else { - return false; + return; }; let path = entry.path.clone(); @@ -226,19 +226,19 @@ impl Filetree { if let Err(e) = ctx.editor.open(&path, Action::Replace) { ctx.editor .set_error(format!("unable to open {}: {}", path.display(), e)); - } else { - // Hand focus back to the editor — opening a file is the - // "I want to edit this" intent, so further keystrokes - // should go to the buffer. - ctx.editor.filetree.focused = false; } + // Hand focus back to the editor — opening a file is the + // "I want to edit this" intent, so further keystrokes + // should go to the buffer. In overlay mode this also + // triggers the auto-hide rule and the panel disappears + // until re-focused or the terminal widens. + ctx.editor.filetree.focused = false; } EntryKind::Directory { .. } => { ctx.editor.filetree.toggle_expanded(&path); self.dirty = true; } } - true } /// Collapse the directory under the cursor; if it's already collapsed (or @@ -310,6 +310,12 @@ impl Filetree { } match key.code { + // Dismiss the focus. In docked mode the panel coexists with the + // editor and stays visible; in overlay mode dropping focus also + // triggers the auto-hide rule in `FiletreeState::render_mode`, + // so the panel quietly disappears until re-focused or the + // terminal widens. The intent flag (`visible`) stays set either + // way, so a re-widen brings the panel back without a re-summon. KeyCode::Esc | KeyCode::Tab => { ctx.editor.filetree.focused = false; EventResult::Consumed(None) @@ -322,7 +328,8 @@ impl Filetree { // leader state machine from here would mean tracking a // multi-key trie, and the modal "lose focus, regain with // e" pattern is both simpler and the convention - // pickers already follow. + // pickers already follow. In overlay mode the unfocus also + // auto-hides the panel via the same rule. KeyCode::Char(' ' | ':') if key.modifiers.is_empty() => { ctx.editor.filetree.focused = false; EventResult::Ignored(None) @@ -354,6 +361,11 @@ impl Filetree { EventResult::Consumed(None) } KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { + // Open the file or toggle the directory. If a file was + // opened, activate_under_cursor drops focus — and in overlay + // mode that's also what triggers the auto-hide, so the + // panel quietly gets out of the editor's way without any + // explicit dismissal here. self.activate_under_cursor(ctx); EventResult::Consumed(None) } @@ -388,8 +400,8 @@ impl Component for Filetree { .render_mode(vstrip.width, config.filetree.min_remaining_editor_width); if !mode.is_visible() { - // Not summoned. Reset cached area so stale mouse hit-testing - // can't fire while the panel is hidden. + // Not summoned, or narrow + unfocused (auto-hidden). Reset cached + // area so stale mouse hit-testing can't fire while we're invisible. self.area = Rect::default(); return; } @@ -453,18 +465,55 @@ impl Component for Filetree { let selected_style = theme .try_get("ui.filetree.selected") .unwrap_or_else(|| theme.get("ui.menu.selected")); - // `ui.filetree.selected.unfocused` is queried via `try_get_exact` so - // the dotted fallback doesn't slip the focused selected style back - // in — the whole point is for unfocused to look different. If the - // theme doesn't define it, we derive a subtle indicator below by - // adding a single-line underline to the entry's base file/dir style. - let unfocused_cursor_themed = theme.try_get_exact("ui.filetree.selected.unfocused"); + // Style chain for the unfocused cursor row: + // 1. exact `ui.filetree.selected.unfocused` if the theme set it, + // 2. else the bufferline-active style — the same visual language + // Helix uses for "this is the active tab", + // 3. else `ui.statusline.active`, + // 4. else fall back to the focused selected style so the cursor + // row stays visible even on minimally-themed setups. + // `try_get_exact` for the filetree key prevents the dotted fallback + // from quietly substituting the focused selected style. The + // bufferline/statusline lookups are also exact: a dotted fallback + // there would slip in the *inactive* bufferline style, which would + // make focused vs unfocused look indistinguishable. + let unfocused_cursor_style = theme + .try_get_exact("ui.filetree.selected.unfocused") + .or_else(|| theme.try_get_exact("ui.bufferline.active")) + .or_else(|| theme.try_get_exact("ui.statusline.active")) + .unwrap_or(selected_style); + + // Themable scope for the open-buffer indicator dot. `try_get_exact` + // again so non-themed renders use the row's own style rather than + // some inherited highlight. + let indicator_style = theme.try_get_exact("ui.filetree.indicator.open"); + + // 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. + let open_paths: HashSet = ctx + .editor + .documents + .values() + .filter_map(|doc| doc.path().map(|p| p.to_path_buf())) + .collect(); let visible_rows = panel_rect.height as usize; let scroll = ctx.editor.filetree.scroll; let cursor_idx = ctx.editor.filetree.cursor_idx; let focused = ctx.editor.filetree.focused; + // Reserve three columns on the right when the panel is wide enough: + // col (width - 3) is a 1ch text gutter before the indicator, + // col (width - 2) is the indicator glyph itself, + // col (width - 1) is a 1ch right margin so the glyph doesn't butt + // right up against the editor's gutter. + // Below 5 columns we skip the indicator entirely and reclaim the + // width for the entry name. + let marker_reserve: u16 = if panel_rect.width >= 5 { 3 } else { 0 }; + let marker_col_offset: u16 = 2; // distance from right edge + let text_width = panel_rect.width.saturating_sub(marker_reserve); + for row in 0..visible_rows { let entry_idx = scroll + row; let Some(entry) = self.entries.get(entry_idx) else { @@ -477,36 +526,66 @@ impl Component for Filetree { EntryKind::Directory { .. } => dir_style, }; - // Three visual states: - // - cursor row + focused : full bg highlight (selected_style) - // - cursor row + unfocused: subtle indicator (themed if defined, - // else base style with a single-line - // underline) - // - non-cursor row : just the entry's base style + // Three visual states for row text: + // - 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 let style = if is_cursor_row { if focused { selected_style - } else if let Some(themed) = unfocused_cursor_themed { - themed } else { - base.underline_style(UnderlineStyle::Line) + unfocused_cursor_style } } else { base }; - // Stretch the bg highlight across the full panel width only when - // focused — when unfocused the indicator should be quieter, so - // the row bg stays the panel background and only the entry's - // text picks up the underline (or themed style). - if is_cursor_row && focused { + // Stretch the bg highlight across the full panel width for *both* + // focused and unfocused cursor rows, so the highlight reads as a + // continuous band like the bufferline's active tab rather than a + // patch around just the entry text. Pre-fill before set_stringn + // so the text characters' style stays intact (set_stringn paints + // cells with the text style; pre-fill covers the rest of the + // row with the same style so bg/fg/mods are uniform). + if is_cursor_row { for x in panel_rect.x..panel_rect.x + panel_rect.width { - surface[(x, y)].set_style(selected_style); + surface[(x, y)].set_style(style); } } - let row_text = format_row(entry, panel_rect.width); - surface.set_stringn(panel_rect.x, y, &row_text, panel_rect.width as usize, style); + let row_text = format_row(entry, text_width); + surface.set_stringn(panel_rect.x, y, &row_text, text_width as usize, style); + + // Open-buffer indicator. A file matches if its path is in + // open_paths; a directory matches if any open buffer's path + // starts with the directory — so collapsed parent dirs reveal + // they contain something interesting. + if marker_reserve > 0 { + let has_open = match entry.kind { + EntryKind::File => open_paths.contains(&entry.path), + EntryKind::Directory { .. } => { + open_paths.iter().any(|p| p.starts_with(&entry.path)) + } + }; + if has_open { + // Use the row's style on cursor rows so the marker stays + // in the highlight; otherwise the themed indicator + // style if defined, falling back to the row's style so + // we don't introduce a third color out of nowhere. + let marker_style = if is_cursor_row { + style + } else { + indicator_style.unwrap_or(style) + }; + surface.set_string( + panel_rect.x + panel_rect.width - marker_col_offset, + y, + "●", + marker_style, + ); + } + } } } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 8d7117a5..334072e5 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1389,15 +1389,26 @@ impl FiletreeState { /// /// An un-summoned panel is always [`FiletreeRenderMode::Hidden`]. A /// summoned panel docks when there would be at least `min_remaining` - /// columns left for the editor, and overlays on top of the editor when - /// there wouldn't be. + /// columns left for the editor. When there wouldn't be: + /// + /// - if the panel currently has focus, it renders as an overlay on top of + /// the editor — the user is actively driving it, so covering the buffer + /// is acceptable; + /// - if it's *unfocused*, it auto-hides. The `visible` flag stays set so + /// widening the terminal brings the panel back without a re-summon, and + /// re-focusing it (via `e`) brings it back as an overlay even + /// while still narrow. The rule: a non-driven panel covering the editor + /// is worse than no panel. pub fn render_mode(&self, editor_area_width: u16, min_remaining: u16) -> FiletreeRenderMode { if !self.visible { - FiletreeRenderMode::Hidden - } else if editor_area_width.saturating_sub(self.width) >= min_remaining { + return FiletreeRenderMode::Hidden; + } + if editor_area_width.saturating_sub(self.width) >= min_remaining { FiletreeRenderMode::Docked - } else { + } else if self.focused { FiletreeRenderMode::Overlay + } else { + FiletreeRenderMode::Hidden } } @@ -2891,15 +2902,17 @@ mod tests { /// [`FiletreeState::render_mode`]. The interesting axes are: /// /// - `visible`: false always yields [`FiletreeRenderMode::Hidden`], - /// regardless of widths. + /// regardless of widths or focus. /// - The arithmetic boundary: when summoned, the panel docks iff /// `editor_area_width - width >= min_remaining`. The comparison is - /// `>=`, not `>` — landing exactly on the threshold docks. Below the - /// threshold the panel overlays. + /// `>=`, not `>` — landing exactly on the threshold docks. + /// - `focused` matters only below the docking threshold: focused → + /// overlay (the user is driving, so covering the buffer is OK); + /// unfocused → hidden (a non-driven panel covering the editor is + /// worse than no panel; `visible` stays set so widening or + /// re-focusing brings it back). /// - Saturating subtraction: when the panel is wider than the editor /// area, `editor_area_width - width` saturates at 0 instead of wrapping. - /// This is a degenerate-but-representable case the type system can't - /// prevent. /// /// Helper methods [`FiletreeRenderMode::is_visible`] and /// [`FiletreeRenderMode::should_clip_editor`] are also exercised through @@ -2910,31 +2923,46 @@ mod tests { struct Case { name: &'static str, visible: bool, + focused: bool, width: u16, editor_area_width: u16, min_remaining: u16, expected: FiletreeRenderMode, } let cases = [ + // Hidden axis: !visible wins regardless of width/focus. Case { - name: "hidden when not summoned, even on wide terminal", + name: "hidden when not summoned, even on wide terminal + focus", visible: false, + focused: true, width: 30, editor_area_width: 200, min_remaining: 80, expected: FiletreeRenderMode::Hidden, }, Case { - name: "hidden when not summoned on narrow terminal", + name: "hidden when not summoned, narrow terminal + no focus", visible: false, + focused: false, width: 30, editor_area_width: 50, min_remaining: 80, expected: FiletreeRenderMode::Hidden, }, + // Docked axis: enough room for editor leftover. Focus is irrelevant. + Case { + name: "docked with comfortable margin, focused", + visible: true, + focused: true, + width: 30, + editor_area_width: 200, + min_remaining: 80, + expected: FiletreeRenderMode::Docked, + }, Case { - name: "docked with comfortable margin", + name: "docked with comfortable margin, unfocused", visible: true, + focused: false, width: 30, editor_area_width: 200, min_remaining: 80, @@ -2943,56 +2971,104 @@ mod tests { Case { name: "docked exactly at threshold (>=, not >)", visible: true, + focused: true, width: 30, editor_area_width: 110, min_remaining: 80, expected: FiletreeRenderMode::Docked, }, + // Narrow + summoned + focused → overlay. Case { - name: "overlay one column below threshold", + name: "overlay: one column below threshold, focused", visible: true, + focused: true, width: 30, editor_area_width: 109, min_remaining: 80, expected: FiletreeRenderMode::Overlay, }, Case { - name: "overlay on small terminal", + name: "overlay: small terminal, focused", visible: true, + focused: true, width: 30, editor_area_width: 60, min_remaining: 80, expected: FiletreeRenderMode::Overlay, }, + // Narrow + summoned + UNfocused → hidden (auto-hide). + Case { + name: "auto-hide: one column below threshold, unfocused", + visible: true, + focused: false, + width: 30, + editor_area_width: 109, + min_remaining: 80, + expected: FiletreeRenderMode::Hidden, + }, + Case { + name: "auto-hide: small terminal, unfocused", + visible: true, + focused: false, + width: 30, + editor_area_width: 60, + min_remaining: 80, + expected: FiletreeRenderMode::Hidden, + }, + // Edge: min_remaining = 0 means docking always succeeds; focus + // never matters because we never even reach the narrow branch. + Case { + name: "min_remaining=0 always docks when summoned, focused", + visible: true, + focused: true, + width: 30, + editor_area_width: 30, + min_remaining: 0, + expected: FiletreeRenderMode::Docked, + }, Case { - name: "min_remaining=0 always docks when summoned", + name: "min_remaining=0 always docks when summoned, unfocused", visible: true, + focused: false, width: 30, editor_area_width: 30, min_remaining: 0, expected: FiletreeRenderMode::Docked, }, + // Saturating-sub edge: panel wider than area. Case { name: "saturating sub: panel wider than area + min_remaining=0 docks", visible: true, + focused: false, width: 100, editor_area_width: 50, min_remaining: 0, expected: FiletreeRenderMode::Docked, }, Case { - name: "saturating sub: panel wider than area + min_remaining>0 overlays", + name: "saturating sub: panel wider than area + min_remaining>0 + focused overlays", visible: true, + focused: true, width: 100, editor_area_width: 50, min_remaining: 1, expected: FiletreeRenderMode::Overlay, }, + Case { + name: "saturating sub: panel wider than area + min_remaining>0 + unfocused hides", + visible: true, + focused: false, + width: 100, + editor_area_width: 50, + min_remaining: 1, + expected: FiletreeRenderMode::Hidden, + }, ]; for case in cases { let state = FiletreeState { visible: case.visible, + focused: case.focused, width: case.width, ..FiletreeState::default() }; @@ -3001,11 +3077,12 @@ mod tests { actual, case.expected, "case `{}`: render_mode(area_width={}, min_remaining={}) \ - with visible={}, width={} = {:?}, expected {:?}", + with visible={}, focused={}, width={} = {:?}, expected {:?}", case.name, case.editor_area_width, case.min_remaining, case.visible, + case.focused, case.width, actual, case.expected,