Fixes
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Arc<[CompletionCandidate]>> = LazyLock::new(|| {
|
||||
@@ -66,7 +66,7 @@ pub struct App {
|
||||
|
||||
pub audio: AudioSettings,
|
||||
pub options: OptionsState,
|
||||
pub panel: PanelState,
|
||||
pub sample_browser: Option<SampleBrowserState>,
|
||||
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;
|
||||
|
||||
@@ -232,9 +232,6 @@ pub enum AppCommand {
|
||||
// Live keys
|
||||
ToggleLiveKeysFill,
|
||||
|
||||
// Panel
|
||||
ClosePanel,
|
||||
|
||||
// Direct navigation (mouse)
|
||||
GoToStep(usize),
|
||||
PatternsSelectBank(usize),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if !contains(body, 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -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 => &[],
|
||||
}
|
||||
}
|
||||
|
||||
19
src/page.rs
19
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SidePanel>,
|
||||
pub focus: PanelFocus,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
impl Default for PanelState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
side: None,
|
||||
focus: PanelFocus::Main,
|
||||
visible: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<SourceSpan> {
|
||||
@@ -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<Vec<f32>> = 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"),
|
||||
],
|
||||
|
||||
87
src/views/sample_explorer_view.rs
Normal file
87
src/views/sample_explorer_view.rs
Normal file
@@ -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<Vec<f32>> = 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);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user