Feat: begin sample explorer overhaul
All checks were successful
Deploy Website / deploy (push) Has been skipped
All checks were successful
Deploy Website / deploy (push) Has been skipped
This commit is contained in:
@@ -23,6 +23,7 @@ pub struct TreeLine {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub folder: String,
|
pub folder: String,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
|
pub child_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tree-view browser for navigating sample folders.
|
/// Tree-view browser for navigating sample folders.
|
||||||
@@ -163,15 +164,43 @@ impl<'a> SampleBrowser<'a> {
|
|||||||
Style::new().fg(icon_color)
|
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<str> = 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![
|
let mut spans = vec![
|
||||||
Span::raw(indent),
|
Span::raw(indent),
|
||||||
Span::styled(icon, icon_style),
|
Span::styled(icon, icon_style),
|
||||||
Span::styled(&entry.label, label_style),
|
Span::styled(label, label_style),
|
||||||
];
|
];
|
||||||
|
|
||||||
if matches!(entry.kind, TreeLineKind::File) {
|
match entry.kind {
|
||||||
|
TreeLineKind::File => {
|
||||||
let idx_style = Style::new().fg(colors.browser.empty_text);
|
let idx_style = Style::new().fg(colors.browser.empty_text);
|
||||||
spans.push(Span::styled(format!(" {}", entry.index), idx_style));
|
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));
|
lines.push(Line::from(spans));
|
||||||
|
|||||||
@@ -211,6 +211,21 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
|||||||
return;
|
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 {
|
match ctx.app.page {
|
||||||
Page::Main => {
|
Page::Main => {
|
||||||
if up {
|
if up {
|
||||||
@@ -356,25 +371,60 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect)
|
|||||||
|
|
||||||
// --- Body ---
|
// --- Body ---
|
||||||
|
|
||||||
fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) {
|
fn panel_split(body: Rect) -> (Rect, Rect) {
|
||||||
// Account for side panel splitting
|
|
||||||
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
|
|
||||||
if body.width >= 120 {
|
if body.width >= 120 {
|
||||||
let panel_width = body.width * 35 / 100;
|
let panel_width = body.width * 35 / 100;
|
||||||
let [main, _side] =
|
let [main, side] =
|
||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
||||||
.areas(body);
|
.areas(body);
|
||||||
main
|
(main, side)
|
||||||
} else {
|
} else {
|
||||||
let panel_height = body.height * 40 / 100;
|
let panel_height = body.height * 40 / 100;
|
||||||
let [main, _side] =
|
let [main, side] =
|
||||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
||||||
.areas(body);
|
.areas(body);
|
||||||
main
|
(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 {
|
} else {
|
||||||
body
|
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) {
|
if !contains(page_area, col, row) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
for _ in 0..10 {
|
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 {
|
} else {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => state.move_up(),
|
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 => {
|
KeyCode::PageUp => {
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
state.move_up();
|
state.move_up();
|
||||||
@@ -57,7 +57,7 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
}
|
}
|
||||||
KeyCode::PageDown => {
|
KeyCode::PageDown => {
|
||||||
for _ in 0..20 {
|
for _ in 0..20 {
|
||||||
state.move_down(30);
|
state.move_down();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Right => {
|
KeyCode::Enter | KeyCode::Right => {
|
||||||
@@ -71,6 +71,10 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
.audio_tx
|
.audio_tx
|
||||||
.load()
|
.load()
|
||||||
.send(AudioCommand::Evaluate { cmd, time: None });
|
.send(AudioCommand::Evaluate { cmd, time: None });
|
||||||
|
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||||
|
"\u{25B8} {}/{}",
|
||||||
|
folder, entry.label
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
_ => state.toggle_expand(),
|
_ => state.toggle_expand(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::cell::Cell;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -62,12 +63,19 @@ impl SampleNode {
|
|||||||
SampleNode::Folder { expanded, .. } => TreeLineKind::Folder { expanded: *expanded },
|
SampleNode::Folder { expanded, .. } => TreeLineKind::Folder { expanded: *expanded },
|
||||||
SampleNode::File { .. } => TreeLineKind::File,
|
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 {
|
out.push(TreeLine {
|
||||||
depth,
|
depth,
|
||||||
kind,
|
kind,
|
||||||
label: self.label().to_string(),
|
label: self.label().to_string(),
|
||||||
folder: parent_folder.to_string(),
|
folder: parent_folder.to_string(),
|
||||||
index: file_index,
|
index: file_index,
|
||||||
|
child_count,
|
||||||
});
|
});
|
||||||
if self.expanded() {
|
if self.expanded() {
|
||||||
let folder_name = self.label();
|
let folder_name = self.label();
|
||||||
@@ -321,6 +329,7 @@ impl SampleTree {
|
|||||||
expanded,
|
expanded,
|
||||||
} if name == target_name => {
|
} if name == target_name => {
|
||||||
let show_children = !collapsed && *expanded;
|
let show_children = !collapsed && *expanded;
|
||||||
|
let file_count = children.iter().filter(|c| matches!(c, SampleNode::File { .. })).count();
|
||||||
out.push(TreeLine {
|
out.push(TreeLine {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
kind: TreeLineKind::Folder {
|
kind: TreeLineKind::Folder {
|
||||||
@@ -329,6 +338,7 @@ impl SampleTree {
|
|||||||
label: name.clone(),
|
label: name.clone(),
|
||||||
folder: String::new(),
|
folder: String::new(),
|
||||||
index: 0,
|
index: 0,
|
||||||
|
child_count: file_count,
|
||||||
});
|
});
|
||||||
if show_children {
|
if show_children {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
@@ -340,6 +350,7 @@ impl SampleTree {
|
|||||||
label: fname.clone(),
|
label: fname.clone(),
|
||||||
folder: name.clone(),
|
folder: name.clone(),
|
||||||
index: idx,
|
index: idx,
|
||||||
|
child_count: 0,
|
||||||
});
|
});
|
||||||
idx += 1;
|
idx += 1;
|
||||||
}
|
}
|
||||||
@@ -362,6 +373,7 @@ pub struct SampleBrowserState {
|
|||||||
pub scroll_offset: usize,
|
pub scroll_offset: usize,
|
||||||
pub search_query: String,
|
pub search_query: String,
|
||||||
pub search_active: bool,
|
pub search_active: bool,
|
||||||
|
pub visible_height: Cell<usize>,
|
||||||
filter: Option<Vec<String>>,
|
filter: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +385,7 @@ impl SampleBrowserState {
|
|||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
search_active: false,
|
search_active: false,
|
||||||
|
visible_height: Cell::new(20),
|
||||||
filter: None,
|
filter: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,6 +440,10 @@ impl SampleBrowserState {
|
|||||||
if self.scroll_offset > self.cursor {
|
if self.scroll_offset > self.cursor {
|
||||||
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) {
|
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();
|
let count = self.visible_count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.cursor + 1 < count {
|
if self.cursor + 1 < count {
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
if self.cursor >= self.scroll_offset + visible_height {
|
let vh = self.visible_height.get();
|
||||||
self.scroll_offset = self.cursor - visible_height + 1;
|
if vh > 0 && self.cursor >= self.scroll_offset + vh {
|
||||||
|
self.scroll_offset = self.cursor - vh + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,13 @@ fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let [tree_area, preview_area] =
|
let [tree_area, preview_area] =
|
||||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(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();
|
let entries = state.entries();
|
||||||
SampleBrowser::new(&entries, state.cursor)
|
SampleBrowser::new(&entries, state.cursor)
|
||||||
.scroll_offset(state.scroll_offset)
|
.scroll_offset(state.scroll_offset)
|
||||||
@@ -542,6 +549,13 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are
|
|||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
let bindings: Vec<(&str, &str)> = match app.page {
|
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![
|
Page::Main => vec![
|
||||||
("Space", "Play"),
|
("Space", "Play"),
|
||||||
("Enter", "Edit"),
|
("Enter", "Edit"),
|
||||||
|
|||||||
Reference in New Issue
Block a user