This commit is contained in:
2026-03-05 18:24:09 +01:00
parent 4743c33916
commit 0097777449
18 changed files with 177 additions and 276 deletions

View File

@@ -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!(

View File

@@ -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,
},

View File

@@ -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),
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -232,9 +232,6 @@ pub enum AppCommand {
// Live keys
ToggleLiveKeysFill,
// Panel
ClosePanel,
// Direct navigation (mouse)
GoToStep(usize),
PatternsSelectBank(usize),

View File

@@ -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 {

View File

@@ -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),
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
_ => {}
}

View File

@@ -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 => &[],
}
}

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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,
}
}
}

View File

@@ -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;

View File

@@ -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"),
],

View 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);
}

View File

@@ -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]