diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index e465fb0..8893f84 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -1573,7 +1573,7 @@ impl Forth { } else { let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8; let velocity = - get_int("velocity").unwrap_or(100).clamp(0, 127) as u8; + (get_float("velocity").unwrap_or(0.8) * 127.0).clamp(0.0, 127.0) as u8; let dur = get_float("dur").unwrap_or(1.0); let dur_secs = dur * ctx.step_duration(); outputs.push(format!( diff --git a/crates/forth/src/words/effects.rs b/crates/forth/src/words/effects.rs index d885551..30bce0b 100644 --- a/crates/forth/src/words/effects.rs +++ b/crates/forth/src/words/effects.rs @@ -28,8 +28,8 @@ pub(super) const WORDS: &[Word] = &[ aliases: &[], category: "Envelope", stack: "(v.. --)", - desc: "Set velocity", - example: "100 velocity", + desc: "Set velocity (0-1)", + example: "0.8 velocity", compile: Param, varargs: true, }, diff --git a/crates/ratatui/src/sample_browser.rs b/crates/ratatui/src/sample_browser.rs index b0d2d66..e0b730a 100644 --- a/crates/ratatui/src/sample_browser.rs +++ b/crates/ratatui/src/sample_browser.rs @@ -137,10 +137,10 @@ impl<'a> SampleBrowser<'a> { let (icon, icon_color) = match entry.kind { TreeLineKind::Root { expanded: true } | TreeLineKind::Folder { expanded: true } => { - ("\u{25BC} ", colors.browser.folder_icon) + ("\u{2212} ", colors.browser.folder_icon) } TreeLineKind::Root { expanded: false } - | TreeLineKind::Folder { expanded: false } => ("\u{25B6} ", colors.browser.folder_icon), + | TreeLineKind::Folder { expanded: false } => ("+ ", colors.browser.folder_icon), TreeLineKind::File => ("\u{266A} ", colors.browser.file_icon), }; diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index d339d83..7440fd2 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -339,12 +339,6 @@ impl App { } AppCommand::ToggleLiveKeysFill => self.live_keys.flip_fill(), - // Panel - AppCommand::ClosePanel => { - self.panel.visible = false; - self.panel.focus = crate::state::PanelFocus::Main; - } - // Direct navigation (mouse) AppCommand::GoToStep(step) => { let len = self.current_edit_pattern().length; diff --git a/src/app/mod.rs b/src/app/mod.rs index 9f1a9e7..d2e2815 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -25,8 +25,8 @@ use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, Variables use crate::page::Page; use crate::state::{ undo::UndoHistory, AudioSettings, EditorContext, LiveKeyState, Metrics, Modal, - OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, - ProjectState, ScriptEditorState, UiState, + OptionsState, PatternField, PatternPropsField, PatternsNav, PlaybackState, + ProjectState, SampleBrowserState, ScriptEditorState, UiState, }; static COMPLETION_CANDIDATES: LazyLock> = LazyLock::new(|| { @@ -66,7 +66,7 @@ pub struct App { pub audio: AudioSettings, pub options: OptionsState, - pub panel: PanelState, + pub sample_browser: Option, pub midi: MidiState, pub plugin_mode: bool, } @@ -123,7 +123,7 @@ impl App { AudioSettings::default() }, options: OptionsState::default(), - panel: PanelState::default(), + sample_browser: None, midi: MidiState::new(), plugin_mode, } @@ -213,6 +213,9 @@ impl App { if self.ui.modal != Modal::None { return; } + if crate::model::onboarding::for_page(self.page).is_empty() { + return; + } let name = self.page.name(); if self.ui.onboarding_dismissed.iter().any(|d| d == name) { return; diff --git a/src/commands.rs b/src/commands.rs index 4e31f6b..37bcef3 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -232,9 +232,6 @@ pub enum AppCommand { // Live keys ToggleLiveKeysFill, - // Panel - ClosePanel, - // Direct navigation (mouse) GoToStep(usize), PatternsSelectBank(usize), diff --git a/src/input/main_page.rs b/src/input/main_page.rs index fea9eb1..c02089a 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -3,9 +3,10 @@ use std::sync::atomic::Ordering; use super::{InputContext, InputResult}; use crate::commands::AppCommand; +use crate::page::Page; use crate::state::{ - ConfirmAction, CyclicEnum, EuclideanField, Modal, PanelFocus, PatternField, RenameTarget, - SampleBrowserState, SidePanel, + ConfirmAction, CyclicEnum, EuclideanField, Modal, PatternField, RenameTarget, + SampleBrowserState, }; pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputResult { @@ -13,15 +14,11 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool match key.code { KeyCode::Tab => { - if ctx.app.panel.visible { - ctx.app.panel.visible = false; - ctx.app.panel.focus = PanelFocus::Main; - } else { - let state = SampleBrowserState::new(&ctx.app.audio.config.sample_paths); - ctx.app.panel.side = Some(SidePanel::SampleBrowser(state)); - ctx.app.panel.visible = true; - ctx.app.panel.focus = PanelFocus::Side; + if ctx.app.sample_browser.is_none() { + ctx.app.sample_browser = + Some(SampleBrowserState::new(&ctx.app.audio.config.sample_paths)); } + ctx.dispatch(AppCommand::GoToPage(Page::SampleExplorer)); } KeyCode::Char('q') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { diff --git a/src/input/mod.rs b/src/input/mod.rs index bff1113..0b8f2e8 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -6,8 +6,8 @@ mod main_page; mod modal; mod mouse; pub(crate) mod options_page; -mod panel; mod patterns_page; +mod sample_explorer; mod script_page; use arc_swap::ArcSwap; @@ -22,7 +22,7 @@ use crate::app::App; use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::page::Page; -use crate::state::{MinimapMode, Modal, PanelFocus}; +use crate::state::{MinimapMode, Modal}; pub enum InputResult { Continue, @@ -106,10 +106,6 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { return InputResult::Continue; } - if ctx.app.panel.visible && ctx.app.panel.focus == PanelFocus::Side { - return panel::handle_panel_input(ctx, key); - } - if alt { match key.code { KeyCode::Up => { @@ -200,6 +196,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { Page::Help => help_page::handle_help_page(ctx, key), Page::Dict => help_page::handle_dict_page(ctx, key), Page::Script => script_page::handle_script_page(ctx, key), + Page::SampleExplorer => sample_explorer::handle_sample_explorer(ctx, key), } } diff --git a/src/input/mouse.rs b/src/input/mouse.rs index db90d80..b2e73a1 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -211,22 +211,16 @@ 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 { + match ctx.app.page { + Page::SampleExplorer => { + if let Some(state) = &mut ctx.app.sample_browser { if up { state.move_up(); } else { state.move_down(); } } - return; } - } - - match ctx.app.page { Page::Main => { if up { ctx.dispatch(AppCommand::StepUp); @@ -362,6 +356,7 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect) Page::Help => " HELP ", Page::Dict => " DICT ", Page::Script => " SCRIPT ", + Page::SampleExplorer => " SAMPLES ", }; let badge_end = block_inner.x + badge_text.len() as u16; if col < badge_end { @@ -371,73 +366,20 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect) // --- Body --- -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 { - 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) { + if !contains(body, col, row) { return; } match ctx.app.page { - Page::Main => handle_main_click(ctx, col, row, page_area, kind), - Page::Patterns => handle_patterns_click(ctx, col, row, page_area, kind), - Page::Help => handle_help_click(ctx, col, row, page_area), - Page::Dict => handle_dict_click(ctx, col, row, page_area), - Page::Options => handle_options_click(ctx, col, row, page_area), - Page::Engine => handle_engine_click(ctx, col, row, page_area, kind), - Page::Script => handle_script_click(ctx, col, row, page_area), + Page::Main => handle_main_click(ctx, col, row, body, kind), + Page::Patterns => handle_patterns_click(ctx, col, row, body, kind), + Page::Help => handle_help_click(ctx, col, row, body), + Page::Dict => handle_dict_click(ctx, col, row, body), + Page::Options => handle_options_click(ctx, col, row, body), + Page::Engine => handle_engine_click(ctx, col, row, body, kind), + Page::Script => handle_script_click(ctx, col, row, body), + Page::SampleExplorer => {} } } @@ -923,20 +865,7 @@ fn handle_script_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: R if ctx.app.script_editor.mouse_selecting { let padded = padded(term); let (_header, body, _footer) = top_level_layout(padded); - 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; - Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)]) - .split(body)[0] - } else { - let panel_height = body.height * 40 / 100; - Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)]) - .split(body)[0] - } - } else { - body - }; - handle_script_editor_mouse(ctx, col, row, page_area, true); + handle_script_editor_mouse(ctx, col, row, body, true); } } diff --git a/src/input/panel.rs b/src/input/sample_explorer.rs similarity index 90% rename from src/input/panel.rs rename to src/input/sample_explorer.rs index c1c79e6..9ebcfa8 100644 --- a/src/input/panel.rs +++ b/src/input/sample_explorer.rs @@ -3,12 +3,12 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::{InputContext, InputResult}; use crate::commands::AppCommand; use crate::engine::AudioCommand; -use crate::state::SidePanel; +use crate::page::Page; use cagire_ratatui::TreeLineKind; -pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { - let state = match &mut ctx.app.panel.side { - Some(SidePanel::SampleBrowser(s)) => s, +pub(super) fn handle_sample_explorer(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + let state = match &mut ctx.app.sample_browser { + Some(s) => s, None => return InputResult::Continue, }; @@ -86,11 +86,11 @@ pub(super) fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> Input if state.has_filter() { state.clear_filter(); } else { - ctx.dispatch(AppCommand::ClosePanel); + ctx.dispatch(AppCommand::GoToPage(Page::Main)); } } KeyCode::Tab => { - ctx.dispatch(AppCommand::ClosePanel); + ctx.dispatch(AppCommand::GoToPage(Page::Main)); } _ => {} } diff --git a/src/model/onboarding.rs b/src/model/onboarding.rs index 780e794..b1c3fad 100644 --- a/src/model/onboarding.rs +++ b/src/model/onboarding.rs @@ -4,7 +4,7 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str, match page { Page::Main => &[ ( - "The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern. The left panel shows playing patterns, the right side shows VU meters.", + "The step sequencer grid. Each cell is a Forth script that produces sound when evaluated. During playback, active steps run left-to-right, top-to-bottom. Toggle steps on/off with t to build your pattern.", &[ ("Arrows", "navigate grid"), ("Space", "play / stop"), @@ -101,5 +101,6 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str, ("Ctrl+S", "toggle stack preview"), ], )], + Page::SampleExplorer => &[], } } diff --git a/src/page.rs b/src/page.rs index 480e1bd..cea7d9c 100644 --- a/src/page.rs +++ b/src/page.rs @@ -8,10 +8,11 @@ pub enum Page { Dict, Options, Script, + SampleExplorer, } impl Page { - /// All pages for iteration (grid pages only — Script excluded) + /// All pages for iteration (grid pages only — Script and SampleExplorer excluded) pub const ALL: &'static [Page] = &[ Page::Main, Page::Patterns, @@ -29,7 +30,7 @@ impl Page { /// col 0 col 1 col 2 /// row 0 Dict Patterns Options /// row 1 Help Sequencer Engine - /// Script lives outside the grid at (1, 2) + /// Script and SampleExplorer live outside the grid at (1, 2) pub const fn grid_pos(self) -> (i8, i8) { match self { Page::Dict => (0, 0), @@ -38,7 +39,7 @@ impl Page { Page::Main => (1, 1), Page::Options => (2, 0), Page::Engine => (2, 1), - Page::Script => (1, 2), + Page::Script | Page::SampleExplorer => (1, 2), } } @@ -57,11 +58,12 @@ impl Page { Page::Dict => "Dict", Page::Options => "Options", Page::Script => "Script", + Page::SampleExplorer => "Samples", } } pub fn left(&mut self) { - if *self == Page::Script { + if matches!(*self, Page::Script | Page::SampleExplorer) { *self = Page::Help; return; } @@ -76,7 +78,7 @@ impl Page { } pub fn right(&mut self) { - if *self == Page::Script { + if matches!(*self, Page::Script | Page::SampleExplorer) { *self = Page::Engine; return; } @@ -91,7 +93,7 @@ impl Page { } pub fn up(&mut self) { - if *self == Page::Script { + if matches!(*self, Page::Script | Page::SampleExplorer) { *self = Page::Main; return; } @@ -115,13 +117,12 @@ impl Page { Page::Engine => Some(14), // "Introduction" (Audio Engine) Page::Help => Some(0), // "Welcome" Page::Dict => Some(7), // "About Forth" - Page::Options => None, - Page::Script => None, + Page::Options | Page::Script | Page::SampleExplorer => None, } } /// Whether this page appears in the navigation minimap grid. pub const fn visible_in_minimap(self) -> bool { - !matches!(self, Page::Script) + !matches!(self, Page::Script | Page::SampleExplorer) } } diff --git a/src/state/mod.rs b/src/state/mod.rs index fff67f2..2e096e0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -21,7 +21,6 @@ pub mod file_browser; pub mod live_keys; pub mod modal; pub mod options; -pub mod panel; pub mod patterns_nav; pub mod playback; pub mod project; @@ -38,7 +37,6 @@ pub use editor::{ pub use live_keys::LiveKeyState; pub use modal::{ConfirmAction, Modal, RenameTarget}; pub use options::{OptionsFocus, OptionsState}; -pub use panel::{PanelFocus, PanelState, SidePanel}; pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange}; pub use project::ProjectState; diff --git a/src/state/panel.rs b/src/state/panel.rs deleted file mode 100644 index 32c9d1b..0000000 --- a/src/state/panel.rs +++ /dev/null @@ -1,28 +0,0 @@ -use super::sample_browser::SampleBrowserState; - -#[derive(Clone, Copy, PartialEq, Eq, Default)] -pub enum PanelFocus { - #[default] - Main, - Side, -} - -pub enum SidePanel { - SampleBrowser(SampleBrowserState), -} - -pub struct PanelState { - pub side: Option, - pub focus: PanelFocus, - pub visible: bool, -} - -impl Default for PanelState { - fn default() -> Self { - Self { - side: None, - focus: PanelFocus::Main, - visible: false, - } - } -} diff --git a/src/views/mod.rs b/src/views/mod.rs index 0b4e270..01420b6 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -6,6 +6,7 @@ pub mod main_view; pub mod options_view; pub mod patterns_view; mod render; +pub mod sample_explorer_view; pub mod script_view; pub mod title_view; diff --git a/src/views/render.rs b/src/views/render.rs index 5f14e0c..9924a66 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -14,19 +14,18 @@ use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::{ExecutionTrace, SourceSpan}; use crate::page::Page; use crate::state::{ - EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget, - SidePanel, + EditorTarget, EuclideanField, FlashKind, Modal, PatternField, RenameTarget, }; use crate::theme; use crate::views::highlight::{self, highlight_line_with_runtime}; use crate::widgets::{ hint_line, render_props_form, render_search_bar, ConfirmModal, ModalFrame, NavMinimap, NavTile, - SampleBrowser, TextInputModal, + TextInputModal, }; use super::{ - dict_view, engine_view, help_view, main_view, options_view, patterns_view, script_view, - title_view, + dict_view, engine_view, help_view, main_view, options_view, patterns_view, + sample_explorer_view, script_view, title_view, }; fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option { @@ -164,28 +163,15 @@ pub fn render( render_header(frame, app, link, snapshot, header_area); } - let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() { - let panel_width = body_area.width * 35 / 100; - let [main, side] = - Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)]) - .areas(body_area); - (main, Some(side)) - } else { - (body_area, None) - }; - match app.page { - Page::Main => main_view::render(frame, app, snapshot, page_area), - Page::Patterns => patterns_view::render(frame, app, snapshot, page_area), - Page::Engine => engine_view::render(frame, app, link, page_area), - Page::Options => options_view::render(frame, app, page_area), - Page::Help => help_view::render(frame, app, page_area), - Page::Dict => dict_view::render(frame, app, page_area), - Page::Script => script_view::render(frame, app, snapshot, page_area), - } - - if let Some(side_area) = panel_area { - render_side_panel(frame, app, side_area); + Page::Main => main_view::render(frame, app, snapshot, body_area), + Page::Patterns => patterns_view::render(frame, app, snapshot, body_area), + Page::Engine => engine_view::render(frame, app, link, body_area), + Page::Options => options_view::render(frame, app, body_area), + Page::Help => help_view::render(frame, app, body_area), + Page::Dict => dict_view::render(frame, app, body_area), + Page::Script => script_view::render(frame, app, snapshot, body_area), + Page::SampleExplorer => sample_explorer_view::render(frame, app, body_area), } if !perf { @@ -292,69 +278,6 @@ fn header_height(_width: u16) -> u16 { 3 } -fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) { - let focused = app.panel.focus == PanelFocus::Side; - match &app.panel.side { - Some(SidePanel::SampleBrowser(state)) => { - 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) - .search(&state.search_query, state.search_active) - .focused(focused) - .render(frame, tree_area); - - if let Some(sample) = state - .sample_key() - .and_then(|key| app.audio.sample_registry.as_ref()?.get(&key)) - .filter(|s| s.frame_count >= s.total_frames) - { - use crate::widgets::Waveform; - use std::cell::RefCell; - thread_local! { - static MONO_BUF: RefCell> = const { RefCell::new(Vec::new()) }; - } - - let [wave_area, info_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]) - .areas(preview_area); - - MONO_BUF.with(|buf| { - let mut buf = buf.borrow_mut(); - let channels = sample.channels as usize; - let frame_count = sample.frame_count as usize; - buf.clear(); - buf.reserve(frame_count); - for i in 0..frame_count { - buf.push(sample.frames[i * channels]); - } - frame.render_widget(Waveform::new(&buf), wave_area); - }); - - let duration = sample.total_frames as f32 / app.audio.config.sample_rate; - let ch_label = if sample.channels == 1 { - "mono" - } else { - "stereo" - }; - let info = Paragraph::new(format!(" {duration:.1}s · {ch_label}")) - .style(Style::new().fg(theme::get().ui.text_dim)); - frame.render_widget(info, info_area); - } - } - None => {} - } -} - fn render_header( frame: &mut Frame, app: &App, @@ -527,6 +450,7 @@ fn render_footer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, are Page::Help => " HELP ", Page::Dict => " DICT ", Page::Script => " SCRIPT ", + Page::SampleExplorer => " SAMPLES ", }; let content = if let Some(ref msg) = app.ui.status_message { @@ -549,10 +473,10 @@ 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"), + Page::SampleExplorer => vec![ + ("\u{2191}\u{2193}", "Navigate"), + ("\u{2192}", "Expand/Play"), + ("\u{2190}", "Collapse"), ("/", "Search"), ("Tab", "Close"), ], diff --git a/src/views/sample_explorer_view.rs b/src/views/sample_explorer_view.rs new file mode 100644 index 0000000..8e28022 --- /dev/null +++ b/src/views/sample_explorer_view.rs @@ -0,0 +1,87 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Style; +use ratatui::Frame; + +use crate::app::App; +use crate::theme; +use crate::widgets::{SampleBrowser, Waveform}; + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + render_browser(frame, app, area); +} + +fn render_browser(frame: &mut Frame, app: &App, area: Rect) { + let state = match &app.sample_browser { + Some(s) => s, + None => return, + }; + + let [tree_area, preview_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(6)]).areas(area); + + 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) + .search(&state.search_query, state.search_active) + .focused(true) + .render(frame, tree_area); + + render_waveform_preview(frame, app, state, preview_area); +} + +fn render_waveform_preview( + frame: &mut Frame, + app: &App, + state: &crate::state::SampleBrowserState, + area: Rect, +) { + use ratatui::text::{Line, Span}; + use ratatui::widgets::Paragraph; + use std::cell::RefCell; + + let sample = match state + .sample_key() + .and_then(|key| app.audio.sample_registry.as_ref()?.get(&key)) + .filter(|s| s.frame_count >= s.total_frames) + { + Some(s) => s, + None => return, + }; + + thread_local! { + static MONO_BUF: RefCell> = const { RefCell::new(Vec::new()) }; + } + + let [wave_area, info_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + MONO_BUF.with(|buf| { + let mut buf = buf.borrow_mut(); + let channels = sample.channels as usize; + let frame_count = sample.frame_count as usize; + buf.clear(); + buf.reserve(frame_count); + for i in 0..frame_count { + buf.push(sample.frames[i * channels]); + } + frame.render_widget(Waveform::new(&buf), wave_area); + }); + + let duration = sample.total_frames as f32 / app.audio.config.sample_rate; + let ch_label = if sample.channels == 1 { + "mono" + } else { + "stereo" + }; + let info = Paragraph::new(Line::from(Span::styled( + format!(" {duration:.1}s \u{00B7} {ch_label}"), + Style::new().fg(theme::get().ui.text_dim), + ))); + frame.render_widget(info, info_area); +} diff --git a/tests/forth/midi.rs b/tests/forth/midi.rs index 4a9fe99..eef2c80 100644 --- a/tests/forth/midi.rs +++ b/tests/forth/midi.rs @@ -8,14 +8,14 @@ use cagire::forth::Value; #[test] fn test_midi_channel_set() { - let outputs = expect_outputs("60 note 100 velocity 3 chan m.", 1); - assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/2/dur/")); + let outputs = expect_outputs("60 note 0.8 velocity 3 chan m.", 1); + assert!(outputs[0].starts_with("/midi/note/60/vel/101/chan/2/dur/")); } #[test] fn test_midi_note_default_channel() { - let outputs = expect_outputs("72 note 80 velocity m.", 1); - assert!(outputs[0].starts_with("/midi/note/72/vel/80/chan/0/dur/")); + let outputs = expect_outputs("72 note 0.6 velocity m.", 1); + assert!(outputs[0].starts_with("/midi/note/72/vel/76/chan/0/dur/")); } #[test] @@ -79,43 +79,43 @@ fn test_ccval_reads_from_cc_memory() { #[test] fn test_midi_channel_clamping() { // Channel should be clamped 1-16, then converted to 0-15 internally - let outputs = expect_outputs("60 note 100 velocity 0 chan m.", 1); + let outputs = expect_outputs("60 note 0.8 velocity 0 chan m.", 1); assert!(outputs[0].contains("/chan/0")); // 0 clamped to 1, then -1 = 0 - let outputs = expect_outputs("60 note 100 velocity 17 chan m.", 1); + let outputs = expect_outputs("60 note 0.8 velocity 17 chan m.", 1); assert!(outputs[0].contains("/chan/15")); // 17 clamped to 16, then -1 = 15 } #[test] fn test_midi_note_clamping() { - let outputs = expect_outputs("-1 note 100 velocity m.", 1); + let outputs = expect_outputs("-1 note 0.8 velocity m.", 1); assert!(outputs[0].contains("/note/0")); - let outputs = expect_outputs("200 note 100 velocity m.", 1); + let outputs = expect_outputs("200 note 0.8 velocity m.", 1); assert!(outputs[0].contains("/note/127")); } #[test] fn test_midi_velocity_clamping() { - let outputs = expect_outputs("60 note -10 velocity m.", 1); + let outputs = expect_outputs("60 note -0.1 velocity m.", 1); assert!(outputs[0].contains("/vel/0")); - let outputs = expect_outputs("60 note 200 velocity m.", 1); + let outputs = expect_outputs("60 note 2.0 velocity m.", 1); assert!(outputs[0].contains("/vel/127")); } #[test] fn test_midi_defaults() { - // With only note specified, velocity defaults to 100 and channel to 0 + // With only note specified, velocity defaults to 0.8 (101) and channel to 0 let outputs = expect_outputs("60 note m.", 1); - assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/")); + assert!(outputs[0].starts_with("/midi/note/60/vel/101/chan/0/dur/")); } #[test] fn test_midi_full_defaults() { - // With nothing specified, defaults to note=60, velocity=100, channel=0 + // With nothing specified, defaults to note=60, velocity=0.8 (101), channel=0 let outputs = expect_outputs("m.", 1); - assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/")); + assert!(outputs[0].starts_with("/midi/note/60/vel/101/chan/0/dur/")); } // Pitch bend tests @@ -344,10 +344,10 @@ fn test_midi_polyphonic_notes() { #[test] fn test_midi_polyphonic_notes_with_velocity() { - let outputs = expect_outputs("60 64 67 note 100 80 60 velocity m.", 3); - assert!(outputs[0].contains("/note/60/vel/100/")); - assert!(outputs[1].contains("/note/64/vel/80/")); - assert!(outputs[2].contains("/note/67/vel/60/")); + let outputs = expect_outputs("60 64 67 note 0.8 0.6 0.5 velocity m.", 3); + assert!(outputs[0].contains("/note/60/vel/101/")); + assert!(outputs[1].contains("/note/64/vel/76/")); + assert!(outputs[2].contains("/note/67/vel/63/")); } #[test]