From 4743c339163f7ff2b435fd45a15a4b4299698dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Thu, 5 Mar 2026 00:42:39 +0100 Subject: [PATCH] Feat: begin sample explorer overhaul --- crates/ratatui/src/sample_browser.rs | 37 ++++++++++-- src/input/mouse.rs | 86 ++++++++++++++++++++++------ src/input/panel.rs | 10 +++- src/state/sample_browser.rs | 24 +++++++- src/views/render.rs | 14 +++++ 5 files changed, 143 insertions(+), 28 deletions(-) diff --git a/crates/ratatui/src/sample_browser.rs b/crates/ratatui/src/sample_browser.rs index 5594e97..b0d2d66 100644 --- a/crates/ratatui/src/sample_browser.rs +++ b/crates/ratatui/src/sample_browser.rs @@ -23,6 +23,7 @@ pub struct TreeLine { pub label: String, pub folder: String, pub index: usize, + pub child_count: usize, } /// Tree-view browser for navigating sample folders. @@ -163,15 +164,43 @@ impl<'a> SampleBrowser<'a> { Style::new().fg(icon_color) }; + let prefix_width = indent.len() + 2; // indent + icon + let suffix = match entry.kind { + TreeLineKind::File => format!(" {}", entry.index), + TreeLineKind::Root { expanded: false } + | TreeLineKind::Folder { expanded: false } + if entry.child_count > 0 => + { + format!(" ({})", entry.child_count) + } + _ => String::new(), + }; + let max_label = (area.width as usize) + .saturating_sub(prefix_width) + .saturating_sub(suffix.len()); + let label: std::borrow::Cow = if entry.label.len() > max_label && max_label > 1 { + let truncated: String = entry.label.chars().take(max_label - 1).collect(); + format!("{}\u{2026}", truncated).into() + } else { + (&entry.label).into() + }; + let mut spans = vec![ Span::raw(indent), Span::styled(icon, icon_style), - Span::styled(&entry.label, label_style), + Span::styled(label, label_style), ]; - if matches!(entry.kind, TreeLineKind::File) { - let idx_style = Style::new().fg(colors.browser.empty_text); - spans.push(Span::styled(format!(" {}", entry.index), idx_style)); + match entry.kind { + TreeLineKind::File => { + let idx_style = Style::new().fg(colors.browser.empty_text); + spans.push(Span::styled(suffix, idx_style)); + } + _ if !suffix.is_empty() => { + let dim_style = Style::new().fg(colors.browser.empty_text); + spans.push(Span::styled(suffix, dim_style)); + } + _ => {} } lines.push(Line::from(spans)); diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 5087d2a..db90d80 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -211,6 +211,21 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo return; } + // Scroll over side panel area + if ctx.app.panel.visible && ctx.app.panel.side.is_some() { + let (_main_area, side_area) = panel_split(body); + if contains(side_area, col, row) { + if let Some(crate::state::SidePanel::SampleBrowser(state)) = &mut ctx.app.panel.side { + if up { + state.move_up(); + } else { + state.move_down(); + } + } + return; + } + } + match ctx.app.page { Page::Main => { if up { @@ -356,25 +371,60 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect) // --- Body --- -fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) { - // Account for side panel splitting - let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() { - if body.width >= 120 { - let panel_width = body.width * 35 / 100; - let [main, _side] = - Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)]) - .areas(body); - main - } else { - let panel_height = body.height * 40 / 100; - let [main, _side] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)]) - .areas(body); - main - } +fn panel_split(body: Rect) -> (Rect, Rect) { + if body.width >= 120 { + let panel_width = body.width * 35 / 100; + let [main, side] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)]) + .areas(body); + (main, side) } else { - body - }; + let panel_height = body.height * 40 / 100; + let [main, side] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)]) + .areas(body); + (main, side) + } +} + +fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) { + use crate::state::PanelFocus; + + if ctx.app.panel.visible && ctx.app.panel.side.is_some() { + let (main_area, side_area) = panel_split(body); + + if contains(side_area, col, row) { + ctx.app.panel.focus = PanelFocus::Side; + return; + } + + // Click on main area: defocus panel + if contains(main_area, col, row) { + if kind == ClickKind::Double { + ctx.dispatch(AppCommand::ClosePanel); + } else { + ctx.app.panel.focus = PanelFocus::Main; + } + } + + // Fall through to page-specific handler with main_area + if !contains(main_area, col, row) { + return; + } + + match ctx.app.page { + Page::Main => handle_main_click(ctx, col, row, main_area, kind), + Page::Patterns => handle_patterns_click(ctx, col, row, main_area, kind), + Page::Help => handle_help_click(ctx, col, row, main_area), + Page::Dict => handle_dict_click(ctx, col, row, main_area), + Page::Options => handle_options_click(ctx, col, row, main_area), + Page::Engine => handle_engine_click(ctx, col, row, main_area, kind), + Page::Script => handle_script_click(ctx, col, row, main_area), + } + return; + } + + let page_area = body; if !contains(page_area, col, row) { return; diff --git a/src/input/panel.rs b/src/input/panel.rs index e2c4095..c1c79e6 100644 --- a/src/input/panel.rs +++ b/src/input/panel.rs @@ -41,7 +41,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input } KeyCode::Down => { for _ in 0..10 { - state.move_down(30); + state.move_down(); } } _ => {} @@ -49,7 +49,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input } else { match key.code { KeyCode::Up | KeyCode::Char('k') => state.move_up(), - KeyCode::Down | KeyCode::Char('j') => state.move_down(30), + KeyCode::Down | KeyCode::Char('j') => state.move_down(), KeyCode::PageUp => { for _ in 0..20 { state.move_up(); @@ -57,7 +57,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input } KeyCode::PageDown => { for _ in 0..20 { - state.move_down(30); + state.move_down(); } } KeyCode::Enter | KeyCode::Right => { @@ -71,6 +71,10 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input .audio_tx .load() .send(AudioCommand::Evaluate { cmd, time: None }); + ctx.dispatch(AppCommand::SetStatus(format!( + "\u{25B8} {}/{}", + folder, entry.label + ))); } _ => state.toggle_expand(), } diff --git a/src/state/sample_browser.rs b/src/state/sample_browser.rs index 672991b..9f0d347 100644 --- a/src/state/sample_browser.rs +++ b/src/state/sample_browser.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::fs; use std::path::{Path, PathBuf}; @@ -62,12 +63,19 @@ impl SampleNode { SampleNode::Folder { expanded, .. } => TreeLineKind::Folder { expanded: *expanded }, SampleNode::File { .. } => TreeLineKind::File, }; + let child_count = match self { + SampleNode::Root { children, .. } | SampleNode::Folder { children, .. } => { + children.iter().filter(|c| matches!(c, SampleNode::File { .. })).count() + } + SampleNode::File { .. } => 0, + }; out.push(TreeLine { depth, kind, label: self.label().to_string(), folder: parent_folder.to_string(), index: file_index, + child_count, }); if self.expanded() { let folder_name = self.label(); @@ -321,6 +329,7 @@ impl SampleTree { expanded, } if name == target_name => { let show_children = !collapsed && *expanded; + let file_count = children.iter().filter(|c| matches!(c, SampleNode::File { .. })).count(); out.push(TreeLine { depth: 0, kind: TreeLineKind::Folder { @@ -329,6 +338,7 @@ impl SampleTree { label: name.clone(), folder: String::new(), index: 0, + child_count: file_count, }); if show_children { let mut idx = 0; @@ -340,6 +350,7 @@ impl SampleTree { label: fname.clone(), folder: name.clone(), index: idx, + child_count: 0, }); idx += 1; } @@ -362,6 +373,7 @@ pub struct SampleBrowserState { pub scroll_offset: usize, pub search_query: String, pub search_active: bool, + pub visible_height: Cell, filter: Option>, } @@ -373,6 +385,7 @@ impl SampleBrowserState { scroll_offset: 0, search_query: String::new(), search_active: false, + visible_height: Cell::new(20), filter: None, } } @@ -427,6 +440,10 @@ impl SampleBrowserState { if self.scroll_offset > self.cursor { self.scroll_offset = self.cursor; } + let vh = self.visible_height.get(); + if vh > 0 && self.cursor >= self.scroll_offset + vh { + self.scroll_offset = self.cursor - vh + 1; + } } pub fn move_up(&mut self) { @@ -438,15 +455,16 @@ impl SampleBrowserState { } } - pub fn move_down(&mut self, visible_height: usize) { + pub fn move_down(&mut self) { let count = self.visible_count(); if count == 0 { return; } if self.cursor + 1 < count { self.cursor += 1; - if self.cursor >= self.scroll_offset + visible_height { - self.scroll_offset = self.cursor - visible_height + 1; + let vh = self.visible_height.get(); + if vh > 0 && self.cursor >= self.scroll_offset + vh { + self.scroll_offset = self.cursor - vh + 1; } } } diff --git a/src/views/render.rs b/src/views/render.rs index d6563d6..5f14e0c 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -299,6 +299,13 @@ fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) { let [tree_area, preview_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area); + // Compute visible height: tree_area minus borders (2), minus search bar (1) if shown + let mut vh = tree_area.height.saturating_sub(2) as usize; + if state.search_active || !state.search_query.is_empty() { + vh = vh.saturating_sub(1); + } + state.visible_height.set(vh); + let entries = state.entries(); SampleBrowser::new(&entries, state.cursor) .scroll_offset(state.scroll_offset) @@ -542,6 +549,13 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are ]) } else { let bindings: Vec<(&str, &str)> = match app.page { + Page::Main if app.panel.visible && app.panel.focus == PanelFocus::Side => vec![ + ("↑↓", "Navigate"), + ("→", "Expand/Play"), + ("←", "Collapse"), + ("/", "Search"), + ("Tab", "Close"), + ], Page::Main => vec![ ("Space", "Play"), ("Enter", "Edit"),