From 80c392c24bc755ea152edd9ce15d41fa97f1d40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Mon, 9 Feb 2026 21:12:49 +0100 Subject: [PATCH] Feat: entretien de la codebase --- CHANGELOG.md | 2 + crates/forth/src/ops.rs | 1 + crates/forth/src/types.rs | 8 +- crates/forth/src/vm.rs | 107 +++++- crates/forth/src/words/compile.rs | 1 + crates/forth/src/words/sound.rs | 10 + crates/ratatui/src/category_list.rs | 145 ++++++++ crates/ratatui/src/hint_bar.rs | 27 ++ crates/ratatui/src/lib.rs | 12 + crates/ratatui/src/props_form.rs | 42 +++ crates/ratatui/src/scroll_indicators.rs | 53 +++ crates/ratatui/src/search_bar.rs | 20 ++ crates/ratatui/src/section_header.rs | 30 ++ src/input.rs | 427 ++++++------------------ src/services/stack_preview.rs | 2 +- src/state/mod.rs | 2 +- src/state/modal.rs | 93 +++--- src/views/dict_view.rs | 104 ++---- src/views/engine_view.rs | 58 +--- src/views/help_view.rs | 101 ++---- src/views/options_view.rs | 28 +- src/views/patterns_view.rs | 49 ++- src/views/render.rs | 276 +++------------ src/widgets/mod.rs | 6 +- tests/forth/temporal.rs | 121 +++++++ 25 files changed, 847 insertions(+), 878 deletions(-) create mode 100644 crates/ratatui/src/category_list.rs create mode 100644 crates/ratatui/src/hint_bar.rs create mode 100644 crates/ratatui/src/props_form.rs create mode 100644 crates/ratatui/src/scroll_indicators.rs create mode 100644 crates/ratatui/src/search_bar.rs create mode 100644 crates/ratatui/src/section_header.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 99bb118..25d556a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Version number displayed in subtitle, read automatically from `Cargo.toml` at build time. ### Added +- `arp` word for arpeggios: wraps stack values into an arpeggio list that spreads notes across time positions instead of playing them all simultaneously. With explicit `at` deltas, arp items zip with deltas (cycling the shorter list); without `at`, the step is auto-subdivided evenly. Example: `sine s c4 e4 g4 b4 arp note .` plays a 4-note arpeggio across the step. - Resolved value annotations: nondeterministic words (`rand`, `choose`, `cycle`, `bounce`, `wchoose`, `coin`, `chance`, `prob`, `exprand`, `logrand`) now display their resolved value inline (e.g., `choose [sine]`, `rand [7]`, `chance [yes]`) during playback in both Preview and Editor modals. - Inline sample finder in the editor: press `Ctrl+B` to open a fuzzy-search popup of all sample folder names. Type to filter, `Ctrl+N`/`Ctrl+P` to navigate, `Tab`/`Enter` to insert the folder name at cursor, `Esc` to dismiss. Mutually exclusive with word completion. - Sample browser now displays the 0-based file index next to each sample name, making it easy to reference samples by index in Forth scripts (e.g., `"drums" bank 0 n`). @@ -16,6 +17,7 @@ All notable changes to this project will be documented in this file. ### Improved - Header bar stats block (CPU/voices/Link peers) is now centered like all other header sections. - CPU percentage changes color when load is high: accent color at 50%+, error color at 80%+. +- Extracted 6 reusable TUI components into `cagire-ratatui`: `CategoryList`, `render_scroll_indicators`, `render_search_bar`, `render_section_header`, `render_props_form`, `hint_line`. Reduces duplication across views. ### Fixed - Soundless emits (e.g., `1 gain .`) no longer stack infinite voices. All emitted commands now receive a default duration of one beat unless the user explicitly sets `dur`. Use `0 dur` for intentionally infinite voices. diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 121dfc6..669f3ff 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -98,6 +98,7 @@ pub enum Op { ClearCmd, SetSpeed, At, + Arp, IntRange, StepRange, Generate, diff --git a/crates/forth/src/types.rs b/crates/forth/src/types.rs index 814bfc5..f6b8131 100644 --- a/crates/forth/src/types.rs +++ b/crates/forth/src/types.rs @@ -88,6 +88,7 @@ pub enum Value { Str(Arc, Option), Quotation(Arc<[Op]>, Option), CycleList(Arc<[Value]>), + ArpList(Arc<[Value]>), } impl PartialEq for Value { @@ -98,6 +99,7 @@ impl PartialEq for Value { (Value::Str(a, _), Value::Str(b, _)) => a == b, (Value::Quotation(a, _), Value::Quotation(b, _)) => a == b, (Value::CycleList(a), Value::CycleList(b)) => a == b, + (Value::ArpList(a), Value::ArpList(b)) => a == b, _ => false, } } @@ -133,7 +135,7 @@ impl Value { Value::Float(f, _) => *f != 0.0, Value::Str(s, _) => !s.is_empty(), Value::Quotation(..) => true, - Value::CycleList(items) => !items.is_empty(), + Value::CycleList(items) | Value::ArpList(items) => !items.is_empty(), } } @@ -143,14 +145,14 @@ impl Value { Value::Float(f, _) => f.to_string(), Value::Str(s, _) => s.to_string(), Value::Quotation(..) => String::new(), - Value::CycleList(_) => String::new(), + Value::CycleList(_) | Value::ArpList(_) => String::new(), } } pub(super) fn span(&self) -> Option { match self { Value::Int(_, s) | Value::Float(_, s) | Value::Str(_, s) | Value::Quotation(_, s) => *s, - Value::CycleList(_) => None, + Value::CycleList(_) | Value::ArpList(_) => None, } } } diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 793fb20..0debfa6 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -216,6 +216,28 @@ impl Forth { sound_len.max(param_max) }; + let has_arp_list = |cmd: &CmdRegister| -> bool { + matches!(cmd.sound(), Some(Value::ArpList(_))) + || cmd.params().iter().any(|(_, v)| matches!(v, Value::ArpList(_))) + }; + + let compute_arp_count = |cmd: &CmdRegister| -> usize { + let sound_len = match cmd.sound() { + Some(Value::ArpList(items)) => items.len(), + _ => 0, + }; + let param_max = cmd + .params() + .iter() + .map(|(_, v)| match v { + Value::ArpList(items) => items.len(), + _ => 0, + }) + .max() + .unwrap_or(0); + sound_len.max(param_max).max(1) + }; + let emit_with_cycling = |cmd: &CmdRegister, emit_idx: usize, delta_secs: f64, @@ -231,7 +253,7 @@ impl Forth { .iter() .map(|(k, v)| { let resolved = resolve_cycling(v, emit_idx); - if let Value::CycleList(_) = v { + if let Value::CycleList(_) | Value::ArpList(_) = v { if let Some(span) = resolved.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { trace.selected_spans.push(span); @@ -559,24 +581,36 @@ impl Forth { } Op::Emit => { - let poly_count = compute_poly_count(cmd); - let deltas = if cmd.deltas().is_empty() { - vec![Value::Float(0.0, None)] - } else { - cmd.deltas().to_vec() - }; + if has_arp_list(cmd) { + let arp_count = compute_arp_count(cmd); + let explicit_deltas = !cmd.deltas().is_empty(); + let delta_list: Vec = if explicit_deltas { + cmd.deltas().to_vec() + } else { + Vec::new() + }; + let count = if explicit_deltas { + arp_count.max(delta_list.len()) + } else { + arp_count + }; - for poly_idx in 0..poly_count { - for delta_val in deltas.iter() { - let delta_frac = delta_val.as_float()?; - let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration(); - if let Some(span) = delta_val.span() { - if let Some(trace) = trace_cell.borrow_mut().as_mut() { - trace.selected_spans.push(span); + for i in 0..count { + let delta_secs = if explicit_deltas { + let dv = &delta_list[i % delta_list.len()]; + let frac = dv.as_float()?; + if let Some(span) = dv.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } } - } + ctx.nudge_secs + frac * ctx.step_duration() + } else { + ctx.nudge_secs + + (i as f64 / count as f64) * ctx.step_duration() + }; if let Some(sound_val) = - emit_with_cycling(cmd, poly_idx, delta_secs, outputs)? + emit_with_cycling(cmd, i, delta_secs, outputs)? { if let Some(span) = sound_val.span() { if let Some(trace) = trace_cell.borrow_mut().as_mut() { @@ -585,6 +619,37 @@ impl Forth { } } } + } else { + let poly_count = compute_poly_count(cmd); + let deltas = if cmd.deltas().is_empty() { + vec![Value::Float(0.0, None)] + } else { + cmd.deltas().to_vec() + }; + + for poly_idx in 0..poly_count { + for delta_val in deltas.iter() { + let delta_frac = delta_val.as_float()?; + let delta_secs = + ctx.nudge_secs + delta_frac * ctx.step_duration(); + if let Some(span) = delta_val.span() { + if let Some(trace) = trace_cell.borrow_mut().as_mut() { + trace.selected_spans.push(span); + } + } + if let Some(sound_val) = + emit_with_cycling(cmd, poly_idx, delta_secs, outputs)? + { + if let Some(span) = sound_val.span() { + if let Some(trace) = + trace_cell.borrow_mut().as_mut() + { + trace.selected_spans.push(span); + } + } + } + } + } } } @@ -976,6 +1041,14 @@ impl Forth { cmd.set_deltas(deltas); } + Op::Arp => { + if stack.is_empty() { + return Err("stack underflow".into()); + } + let values = std::mem::take(stack); + stack.push(Value::ArpList(Arc::from(values))); + } + Op::Adsr => { let r = stack.pop().ok_or("stack underflow")?; let s = stack.pop().ok_or("stack underflow")?; @@ -1528,7 +1601,7 @@ where fn resolve_cycling(val: &Value, emit_idx: usize) -> Cow<'_, Value> { match val { - Value::CycleList(items) if !items.is_empty() => { + Value::CycleList(items) | Value::ArpList(items) if !items.is_empty() => { Cow::Owned(items[emit_idx % items.len()].clone()) } other => Cow::Borrowed(other), diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 479d148..4c31839 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -79,6 +79,7 @@ pub(super) fn simple_op(name: &str) -> Option { "tempo!" => Op::SetTempo, "speed!" => Op::SetSpeed, "at" => Op::At, + "arp" => Op::Arp, "adsr" => Op::Adsr, "ad" => Op::Ad, "apply" => Op::Apply, diff --git a/crates/forth/src/words/sound.rs b/crates/forth/src/words/sound.rs index dcec3e3..a6fb033 100644 --- a/crates/forth/src/words/sound.rs +++ b/crates/forth/src/words/sound.rs @@ -23,6 +23,16 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: "arp", + aliases: &[], + category: "Sound", + stack: "(v1..vn -- arplist)", + desc: "Wrap stack values as arpeggio list for spreading across deltas", + example: "c4 e4 g4 b4 arp note => arpeggio", + compile: Simple, + varargs: true, + }, Word { name: "clear", aliases: &[], diff --git a/crates/ratatui/src/category_list.rs b/crates/ratatui/src/category_list.rs new file mode 100644 index 0000000..00187bc --- /dev/null +++ b/crates/ratatui/src/category_list.rs @@ -0,0 +1,145 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders, List, ListItem}; +use ratatui::Frame; + +use crate::theme; + +pub struct CategoryItem<'a> { + pub label: &'a str, + pub is_section: bool, +} + +pub struct CategoryList<'a> { + items: &'a [CategoryItem<'a>], + selected: usize, + focused: bool, + title: &'a str, + section_color: Color, + focused_color: Color, + selected_color: Color, + normal_color: Color, + dimmed_color: Option, +} + +impl<'a> CategoryList<'a> { + pub fn new(items: &'a [CategoryItem<'a>], selected: usize) -> Self { + let theme = theme::get(); + Self { + items, + selected, + focused: false, + title: "", + section_color: theme.ui.text_dim, + focused_color: theme.dict.category_focused, + selected_color: theme.dict.category_selected, + normal_color: theme.dict.category_normal, + dimmed_color: None, + } + } + + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + pub fn title(mut self, title: &'a str) -> Self { + self.title = title; + self + } + + pub fn selected_color(mut self, color: Color) -> Self { + self.selected_color = color; + self + } + + pub fn normal_color(mut self, color: Color) -> Self { + self.normal_color = color; + self + } + + pub fn dimmed(mut self, color: Color) -> Self { + self.dimmed_color = Some(color); + self + } + + pub fn render(self, frame: &mut Frame, area: Rect) { + let theme = theme::get(); + + let visible_height = area.height.saturating_sub(2) as usize; + let total_items = self.items.len(); + + let selected_visual_idx = { + let mut visual = 0; + let mut selectable_count = 0; + for item in self.items.iter() { + if !item.is_section { + if selectable_count == self.selected { + break; + } + selectable_count += 1; + } + visual += 1; + } + visual + }; + + let scroll = if selected_visual_idx < visible_height / 2 { + 0 + } else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) { + total_items.saturating_sub(visible_height) + } else { + selected_visual_idx.saturating_sub(visible_height / 2) + }; + + let mut selectable_idx = self.items + .iter() + .take(scroll) + .filter(|e| !e.is_section) + .count(); + + let is_dimmed = self.dimmed_color.is_some(); + + let items: Vec = self.items + .iter() + .skip(scroll) + .take(visible_height) + .map(|item| { + if item.is_section { + let style = Style::new().fg(self.section_color); + ListItem::new(format!("─ {} ─", item.label)).style(style) + } else { + let is_selected = selectable_idx == self.selected; + let style = if let Some(dim_color) = self.dimmed_color { + Style::new().fg(dim_color) + } else if is_selected && self.focused { + Style::new() + .fg(self.focused_color) + .add_modifier(Modifier::BOLD) + } else if is_selected { + Style::new() + .fg(self.selected_color) + .add_modifier(Modifier::BOLD) + } else { + Style::new().fg(self.normal_color) + }; + let prefix = if is_selected && !is_dimmed { "> " } else { " " }; + selectable_idx += 1; + ListItem::new(format!("{prefix}{}", item.label)).style(style) + } + }) + .collect(); + + let border_color = if self.focused { + theme.dict.border_focused + } else { + theme.dict.border_normal + }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(border_color)) + .title(self.title); + let list = List::new(items).block(block); + frame.render_widget(list, area); + } +} diff --git a/crates/ratatui/src/hint_bar.rs b/crates/ratatui/src/hint_bar.rs new file mode 100644 index 0000000..340cab2 --- /dev/null +++ b/crates/ratatui/src/hint_bar.rs @@ -0,0 +1,27 @@ +use ratatui::text::{Line, Span}; +use ratatui::style::Style; + +use crate::theme; + +pub fn hint_line(pairs: &[(&str, &str)]) -> Line<'static> { + let theme = theme::get(); + let key_style = Style::default().fg(theme.hint.key); + let text_style = Style::default().fg(theme.hint.text); + + let spans: Vec = pairs + .iter() + .enumerate() + .flat_map(|(i, (key, action))| { + let mut s = vec![ + Span::styled(key.to_string(), key_style), + Span::styled(format!(" {action}"), text_style), + ]; + if i + 1 < pairs.len() { + s.push(Span::styled(" ", text_style)); + } + s + }) + .collect(); + + Line::from(spans) +} diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 1638f0f..04c2f0c 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -1,12 +1,18 @@ mod active_patterns; +mod category_list; mod confirm; mod editor; mod file_browser; +mod hint_bar; mod list_select; mod modal; mod nav_minimap; +mod props_form; mod sample_browser; mod scope; +mod scroll_indicators; +mod search_bar; +mod section_header; mod sparkles; mod spectrum; mod text_input; @@ -15,14 +21,20 @@ mod vu_meter; mod waveform; pub use active_patterns::{ActivePatterns, MuteStatus}; +pub use category_list::{CategoryItem, CategoryList}; pub use confirm::ConfirmModal; pub use editor::{fuzzy_match, CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; +pub use hint_bar::hint_line; pub use list_select::ListSelect; pub use modal::ModalFrame; pub use nav_minimap::{NavMinimap, NavTile}; +pub use props_form::render_props_form; pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind}; pub use scope::{Orientation, Scope}; +pub use scroll_indicators::{render_scroll_indicators, IndicatorAlign}; +pub use search_bar::render_search_bar; +pub use section_header::render_section_header; pub use sparkles::Sparkles; pub use spectrum::Spectrum; pub use text_input::TextInputModal; diff --git a/crates/ratatui/src/props_form.rs b/crates/ratatui/src/props_form.rs new file mode 100644 index 0000000..1314f78 --- /dev/null +++ b/crates/ratatui/src/props_form.rs @@ -0,0 +1,42 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::theme; + +pub fn render_props_form(frame: &mut Frame, area: Rect, fields: &[(&str, &str, bool)]) { + let theme = theme::get(); + + for (i, (label, value, selected)) in fields.iter().enumerate() { + let y = area.y + i as u16; + if y >= area.y + area.height { + break; + } + + let (label_style, value_style) = if *selected { + ( + Style::default() + .fg(theme.hint.key) + .add_modifier(Modifier::BOLD), + Style::default() + .fg(theme.ui.text_primary) + .bg(theme.ui.surface), + ) + } else { + ( + Style::default().fg(theme.ui.text_muted), + Style::default().fg(theme.ui.text_primary), + ) + }; + + let label_area = Rect::new(area.x + 1, y, 14, 1); + let value_area = Rect::new(area.x + 16, y, area.width.saturating_sub(18), 1); + + frame.render_widget( + Paragraph::new(format!("{label}:")).style(label_style), + label_area, + ); + frame.render_widget(Paragraph::new(*value).style(value_style), value_area); + } +} diff --git a/crates/ratatui/src/scroll_indicators.rs b/crates/ratatui/src/scroll_indicators.rs new file mode 100644 index 0000000..565c78b --- /dev/null +++ b/crates/ratatui/src/scroll_indicators.rs @@ -0,0 +1,53 @@ +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub enum IndicatorAlign { + Center, + Right, +} + +pub fn render_scroll_indicators( + frame: &mut Frame, + area: Rect, + offset: usize, + visible: usize, + total: usize, + color: Color, + align: IndicatorAlign, +) { + let style = Style::new().fg(color); + + match align { + IndicatorAlign::Center => { + if offset > 0 { + let indicator = Paragraph::new("▲") + .style(style) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(indicator, Rect { height: 1, ..area }); + } + if offset + visible < total { + let y = area.y + area.height.saturating_sub(1); + let indicator = Paragraph::new("▼") + .style(style) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(indicator, Rect { y, height: 1, ..area }); + } + } + IndicatorAlign::Right => { + let x = area.x + area.width.saturating_sub(1); + if offset > 0 { + let indicator = Paragraph::new("▲").style(style); + frame.render_widget(indicator, Rect::new(x, area.y, 1, 1)); + } + if offset + visible < total { + let indicator = Paragraph::new("▼").style(style); + frame.render_widget( + indicator, + Rect::new(x, area.y + area.height.saturating_sub(1), 1, 1), + ); + } + } + } +} diff --git a/crates/ratatui/src/search_bar.rs b/crates/ratatui/src/search_bar.rs new file mode 100644 index 0000000..be3e527 --- /dev/null +++ b/crates/ratatui/src/search_bar.rs @@ -0,0 +1,20 @@ +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::theme; + +pub fn render_search_bar(frame: &mut Frame, area: Rect, query: &str, active: bool) { + let theme = theme::get(); + let style = if active { + Style::new().fg(theme.search.active) + } else { + Style::new().fg(theme.search.inactive) + }; + let cursor = if active { "_" } else { "" }; + let text = format!(" /{query}{cursor}"); + let line = Line::from(Span::styled(text, style)); + frame.render_widget(Paragraph::new(vec![line]), area); +} diff --git a/crates/ratatui/src/section_header.rs b/crates/ratatui/src/section_header.rs new file mode 100644 index 0000000..f6eae07 --- /dev/null +++ b/crates/ratatui/src/section_header.rs @@ -0,0 +1,30 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::theme; + +pub fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) { + let theme = theme::get(); + let [header_area, divider_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); + + let header_style = if focused { + Style::new() + .fg(theme.engine.header_focused) + .add_modifier(Modifier::BOLD) + } else { + Style::new() + .fg(theme.engine.header) + .add_modifier(Modifier::BOLD) + }; + + frame.render_widget(Paragraph::new(title).style(header_style), header_area); + + let divider = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(divider).style(Style::new().fg(theme.engine.divider)), + divider_area, + ); +} diff --git a/src/input.rs b/src/input.rs index 725f16d..197411b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -11,8 +11,9 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; use crate::state::{ - CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal, OptionsFocus, - PanelFocus, PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel, + ConfirmAction, CyclicEnum, DeviceKind, EditorTarget, EngineSection, EuclideanField, Modal, + OptionsFocus, PanelFocus, PatternField, PatternPropsField, RenameTarget, SampleBrowserState, + SettingKind, SidePanel, }; pub enum InputResult { @@ -79,169 +80,61 @@ fn handle_live_keys(ctx: &mut InputContext, key: &KeyEvent) -> bool { } } +fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResult { + match action { + ConfirmAction::Quit => return InputResult::Quit, + ConfirmAction::DeleteStep { bank, pattern, step } => { + ctx.dispatch(AppCommand::DeleteStep { bank: *bank, pattern: *pattern, step: *step }); + } + ConfirmAction::DeleteSteps { bank, pattern, steps } => { + ctx.dispatch(AppCommand::DeleteSteps { bank: *bank, pattern: *pattern, steps: steps.clone() }); + } + ConfirmAction::ResetPattern { bank, pattern } => { + ctx.dispatch(AppCommand::ResetPattern { bank: *bank, pattern: *pattern }); + } + ConfirmAction::ResetBank { bank } => { + ctx.dispatch(AppCommand::ResetBank { bank: *bank }); + } + ConfirmAction::ResetPatterns { bank, patterns } => { + ctx.dispatch(AppCommand::ResetPatterns { bank: *bank, patterns: patterns.clone() }); + } + ConfirmAction::ResetBanks { banks } => { + ctx.dispatch(AppCommand::ResetBanks { banks: banks.clone() }); + } + } + ctx.dispatch(AppCommand::CloseModal); + InputResult::Continue +} + +fn rename_command(target: &RenameTarget, name: Option) -> AppCommand { + match target { + RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name }, + RenameTarget::Pattern { bank, pattern } => AppCommand::RenamePattern { + bank: *bank, pattern: *pattern, name, + }, + RenameTarget::Step { bank, pattern, step } => AppCommand::RenameStep { + bank: *bank, pattern: *pattern, step: *step, name, + }, + } +} + fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match &mut ctx.app.ui.modal { - Modal::ConfirmQuit { selected } => match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => return InputResult::Quit, - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Left | KeyCode::Right => { - *selected = !*selected; - } - KeyCode::Enter => { - if *selected { - return InputResult::Quit; - } else { - ctx.dispatch(AppCommand::CloseModal); - } - } - _ => {} - }, - Modal::ConfirmDeleteStep { - bank, - pattern, - step, - selected: _, - } => { - let (bank, pattern, step) = (*bank, *pattern, *step); + Modal::Confirm { action, selected } => { + let (action, confirmed) = (action.clone(), *selected); match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - ctx.dispatch(AppCommand::DeleteStep { - bank, - pattern, - step, - }); - ctx.dispatch(AppCommand::CloseModal); - } + KeyCode::Char('y') | KeyCode::Char('Y') => return execute_confirm(ctx, &action), KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { ctx.dispatch(AppCommand::CloseModal); } KeyCode::Left | KeyCode::Right => { - if let Modal::ConfirmDeleteStep { selected, .. } = &mut ctx.app.ui.modal { + if let Modal::Confirm { selected, .. } = &mut ctx.app.ui.modal { *selected = !*selected; } } KeyCode::Enter => { - let do_delete = - if let Modal::ConfirmDeleteStep { selected, .. } = &ctx.app.ui.modal { - *selected - } else { - false - }; - if do_delete { - ctx.dispatch(AppCommand::DeleteStep { - bank, - pattern, - step, - }); - } - ctx.dispatch(AppCommand::CloseModal); - } - _ => {} - } - } - Modal::ConfirmDeleteSteps { - bank, - pattern, - steps, - selected: _, - } => { - let (bank, pattern, steps) = (*bank, *pattern, steps.clone()); - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - ctx.dispatch(AppCommand::DeleteSteps { - bank, - pattern, - steps, - }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Left | KeyCode::Right => { - if let Modal::ConfirmDeleteSteps { selected, .. } = &mut ctx.app.ui.modal { - *selected = !*selected; - } - } - KeyCode::Enter => { - let do_delete = - if let Modal::ConfirmDeleteSteps { selected, .. } = &ctx.app.ui.modal { - *selected - } else { - false - }; - if do_delete { - ctx.dispatch(AppCommand::DeleteSteps { - bank, - pattern, - steps, - }); - } - ctx.dispatch(AppCommand::CloseModal); - } - _ => {} - } - } - Modal::ConfirmResetPattern { - bank, - pattern, - selected: _, - } => { - let (bank, pattern) = (*bank, *pattern); - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - ctx.dispatch(AppCommand::ResetPattern { bank, pattern }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Left | KeyCode::Right => { - if let Modal::ConfirmResetPattern { selected, .. } = &mut ctx.app.ui.modal { - *selected = !*selected; - } - } - KeyCode::Enter => { - let do_reset = - if let Modal::ConfirmResetPattern { selected, .. } = &ctx.app.ui.modal { - *selected - } else { - false - }; - if do_reset { - ctx.dispatch(AppCommand::ResetPattern { bank, pattern }); - } - ctx.dispatch(AppCommand::CloseModal); - } - _ => {} - } - } - Modal::ConfirmResetBank { bank, selected: _ } => { - let bank = *bank; - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - ctx.dispatch(AppCommand::ResetBank { bank }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Left | KeyCode::Right => { - if let Modal::ConfirmResetBank { selected, .. } = &mut ctx.app.ui.modal { - *selected = !*selected; - } - } - KeyCode::Enter => { - let do_reset = - if let Modal::ConfirmResetBank { selected, .. } = &ctx.app.ui.modal { - *selected - } else { - false - }; - if do_reset { - ctx.dispatch(AppCommand::ResetBank { bank }); + if confirmed { + return execute_confirm(ctx, &action); } ctx.dispatch(AppCommand::CloseModal); } @@ -278,81 +171,32 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } _ => {} }, - Modal::RenameBank { bank, name } => match key.code { - KeyCode::Enter => { - let bank_idx = *bank; - let new_name = if name.trim().is_empty() { - None - } else { - Some(name.clone()) - }; - ctx.dispatch(AppCommand::RenameBank { - bank: bank_idx, - name: new_name, - }); - ctx.dispatch(AppCommand::CloseModal); + Modal::Rename { target, name } => { + let target = target.clone(); + match key.code { + KeyCode::Enter => { + let new_name = if name.trim().is_empty() { + None + } else { + Some(name.clone()) + }; + ctx.dispatch(rename_command(&target, new_name)); + ctx.dispatch(AppCommand::CloseModal); + } + KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), + KeyCode::Backspace => { + if let Modal::Rename { name, .. } = &mut ctx.app.ui.modal { + name.pop(); + } + } + KeyCode::Char(c) => { + if let Modal::Rename { name, .. } = &mut ctx.app.ui.modal { + name.push(c); + } + } + _ => {} } - KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Backspace => { - name.pop(); - } - KeyCode::Char(c) => name.push(c), - _ => {} - }, - Modal::RenamePattern { - bank, - pattern, - name, - } => match key.code { - KeyCode::Enter => { - let (bank_idx, pattern_idx) = (*bank, *pattern); - let new_name = if name.trim().is_empty() { - None - } else { - Some(name.clone()) - }; - ctx.dispatch(AppCommand::RenamePattern { - bank: bank_idx, - pattern: pattern_idx, - name: new_name, - }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Backspace => { - name.pop(); - } - KeyCode::Char(c) => name.push(c), - _ => {} - }, - Modal::RenameStep { - bank, - pattern, - step, - name, - } => match key.code { - KeyCode::Enter => { - let (bank_idx, pattern_idx, step_idx) = (*bank, *pattern, *step); - let new_name = if name.trim().is_empty() { - None - } else { - Some(name.clone()) - }; - ctx.dispatch(AppCommand::RenameStep { - bank: bank_idx, - pattern: pattern_idx, - step: step_idx, - name: new_name, - }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal), - KeyCode::Backspace => { - name.pop(); - } - KeyCode::Char(c) => name.push(c), - _ => {} - }, + } Modal::SetPattern { field, input } => match key.code { KeyCode::Enter => { let field = *field; @@ -774,70 +618,6 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { _ => {} } } - Modal::ConfirmResetPatterns { - bank, - patterns, - selected: _, - } => { - let (bank, patterns) = (*bank, patterns.clone()); - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - ctx.dispatch(AppCommand::ResetPatterns { bank, patterns }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Left | KeyCode::Right => { - if let Modal::ConfirmResetPatterns { selected, .. } = &mut ctx.app.ui.modal { - *selected = !*selected; - } - } - KeyCode::Enter => { - let do_reset = - if let Modal::ConfirmResetPatterns { selected, .. } = &ctx.app.ui.modal { - *selected - } else { - false - }; - if do_reset { - ctx.dispatch(AppCommand::ResetPatterns { bank, patterns }); - } - ctx.dispatch(AppCommand::CloseModal); - } - _ => {} - } - } - Modal::ConfirmResetBanks { banks, selected: _ } => { - let banks = banks.clone(); - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - ctx.dispatch(AppCommand::ResetBanks { banks }); - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - ctx.dispatch(AppCommand::CloseModal); - } - KeyCode::Left | KeyCode::Right => { - if let Modal::ConfirmResetBanks { selected, .. } = &mut ctx.app.ui.modal { - *selected = !*selected; - } - } - KeyCode::Enter => { - let do_reset = - if let Modal::ConfirmResetBanks { selected, .. } = &ctx.app.ui.modal { - *selected - } else { - false - }; - if do_reset { - ctx.dispatch(AppCommand::ResetBanks { banks }); - } - ctx.dispatch(AppCommand::CloseModal); - } - _ => {} - } - } Modal::None => unreachable!(), } InputResult::Continue @@ -996,7 +776,8 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR } } KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::Quit, selected: false, })); } @@ -1114,18 +895,14 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); if let Some(range) = ctx.app.editor_ctx.selection_range() { let steps: Vec = range.collect(); - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteSteps { - bank, - pattern, - steps, + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::DeleteSteps { bank, pattern, steps }, selected: false, })); } else { let step = ctx.app.editor_ctx.step; - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmDeleteStep { - bank, - pattern, - step, + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::DeleteStep { bank, pattern, step }, selected: false, })); } @@ -1163,10 +940,8 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR .step(step) .and_then(|s| s.name.clone()) .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenameStep { - bank, - pattern, - step, + ctx.dispatch(AppCommand::OpenModal(Modal::Rename { + target: RenameTarget::Step { bank, pattern, step }, name: current_name, })); } @@ -1308,7 +1083,8 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } } KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::Quit, selected: false, })); } @@ -1368,13 +1144,13 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { PatternsColumn::Banks => { let banks = ctx.app.patterns_nav.selected_banks(); if banks.len() > 1 { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBanks { - banks, + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::ResetBanks { banks }, selected: false, })); } else { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetBank { - bank, + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::ResetBank { bank }, selected: false, })); } @@ -1382,16 +1158,14 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { PatternsColumn::Patterns => { let patterns = ctx.app.patterns_nav.selected_patterns(); if patterns.len() > 1 { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPatterns { - bank, - patterns, + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::ResetPatterns { bank, patterns }, selected: false, })); } else { let pattern = ctx.app.patterns_nav.pattern_cursor; - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmResetPattern { - bank, - pattern, + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::ResetPattern { bank, pattern }, selected: false, })); } @@ -1407,8 +1181,8 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { .name .clone() .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenameBank { - bank, + ctx.dispatch(AppCommand::OpenModal(Modal::Rename { + target: RenameTarget::Bank { bank }, name: current_name, })); } @@ -1419,9 +1193,8 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { .name .clone() .unwrap_or_default(); - ctx.dispatch(AppCommand::OpenModal(Modal::RenamePattern { - bank, - pattern, + ctx.dispatch(AppCommand::OpenModal(Modal::Rename { + target: RenameTarget::Pattern { bank, pattern }, name: current_name, })); } @@ -1468,7 +1241,8 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::Quit, selected: false, })); } @@ -1649,7 +1423,8 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::Quit, selected: false, })); } @@ -1858,7 +1633,8 @@ fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)), KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::Quit, selected: false, })); } @@ -1911,7 +1687,8 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)), KeyCode::Char('q') => { - ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + ctx.dispatch(AppCommand::OpenModal(Modal::Confirm { + action: ConfirmAction::Quit, selected: false, })); } diff --git a/src/services/stack_preview.rs b/src/services/stack_preview.rs index 35b2e30..79cc561 100644 --- a/src/services/stack_preview.rs +++ b/src/services/stack_preview.rs @@ -98,7 +98,7 @@ fn format_value(v: &Value) -> String { } Value::Str(s, _) => format!("\"{s}\""), Value::Quotation(..) => "[...]".to_string(), - Value::CycleList(items) => { + Value::CycleList(items) | Value::ArpList(items) => { let inner: Vec = items.iter().map(format_value).collect(); format!("({})", inner.join(" ")) } diff --git a/src/state/mod.rs b/src/state/mod.rs index 440c7a2..13c2cfc 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -36,7 +36,7 @@ pub use editor::{ PatternPropsField, StackCache, }; pub use live_keys::LiveKeyState; -pub use modal::Modal; +pub use modal::{ConfirmAction, Modal, RenameTarget}; pub use options::{OptionsFocus, OptionsState}; pub use panel::{PanelFocus, PanelState, SidePanel}; pub use patterns_nav::{PatternsColumn, PatternsNav}; diff --git a/src/state/modal.rs b/src/state/modal.rs index 1a8eac9..f134b75 100644 --- a/src/state/modal.rs +++ b/src/state/modal.rs @@ -2,56 +2,61 @@ use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::state::editor::{EuclideanField, PatternField, PatternPropsField}; use crate::state::file_browser::FileBrowserState; +#[derive(Clone, PartialEq, Eq)] +pub enum ConfirmAction { + Quit, + DeleteStep { bank: usize, pattern: usize, step: usize }, + DeleteSteps { bank: usize, pattern: usize, steps: Vec }, + ResetPattern { bank: usize, pattern: usize }, + ResetBank { bank: usize }, + ResetPatterns { bank: usize, patterns: Vec }, + ResetBanks { banks: Vec }, +} + +impl ConfirmAction { + pub fn message(&self) -> String { + match self { + Self::Quit => "Quit?".into(), + Self::DeleteStep { step, .. } => format!("Delete step {}?", step + 1), + Self::DeleteSteps { steps, .. } => { + let nums: Vec = steps.iter().map(|s| format!("{:02}", s + 1)).collect(); + format!("Delete steps {}?", nums.join(", ")) + } + Self::ResetPattern { pattern, .. } => format!("Reset pattern {}?", pattern + 1), + Self::ResetBank { bank } => format!("Reset bank {}?", bank + 1), + Self::ResetPatterns { patterns, .. } => format!("Reset {} patterns?", patterns.len()), + Self::ResetBanks { banks } => format!("Reset {} banks?", banks.len()), + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub enum RenameTarget { + Bank { bank: usize }, + Pattern { bank: usize, pattern: usize }, + Step { bank: usize, pattern: usize, step: usize }, +} + +impl RenameTarget { + pub fn title(&self) -> String { + match self { + Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1), + Self::Pattern { bank, pattern } => format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), + Self::Step { step, .. } => format!("Name Step {:02}", step + 1), + } + } +} + #[derive(Clone, PartialEq, Eq)] pub enum Modal { None, - ConfirmQuit { - selected: bool, - }, - ConfirmDeleteStep { - bank: usize, - pattern: usize, - step: usize, - selected: bool, - }, - ConfirmDeleteSteps { - bank: usize, - pattern: usize, - steps: Vec, - selected: bool, - }, - ConfirmResetPattern { - bank: usize, - pattern: usize, - selected: bool, - }, - ConfirmResetBank { - bank: usize, - selected: bool, - }, - ConfirmResetPatterns { - bank: usize, - patterns: Vec, - selected: bool, - }, - ConfirmResetBanks { - banks: Vec, + Confirm { + action: ConfirmAction, selected: bool, }, FileBrowser(Box), - RenameBank { - bank: usize, - name: String, - }, - RenamePattern { - bank: usize, - pattern: usize, - name: String, - }, - RenameStep { - bank: usize, - pattern: usize, - step: usize, + Rename { + target: RenameTarget, name: String, }, SetPattern { diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs index 21f1672..4062757 100644 --- a/src/views/dict_view.rs +++ b/src/views/dict_view.rs @@ -1,7 +1,7 @@ use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::App; @@ -9,6 +9,7 @@ use crate::model::categories::{get_category_name, CatEntry, CATEGORIES}; use crate::model::{Word, WORDS}; use crate::state::DictFocus; use crate::theme; +use crate::widgets::{render_search_bar, CategoryItem, CategoryList}; use CatEntry::{Category, Section}; @@ -47,84 +48,35 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) { let theme = theme::get(); let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed; - let visible_height = area.height.saturating_sub(2) as usize; - let total_items = CATEGORIES.len(); - - // Find the visual index of the selected category (including sections) - let selected_visual_idx = { - let mut visual = 0; - let mut cat_count = 0; - for entry in CATEGORIES.iter() { - if let Category(_) = entry { - if cat_count == app.ui.dict_category { - break; - } - cat_count += 1; - } - visual += 1; - } - visual - }; - - // Calculate scroll to keep selection visible (centered when possible) - let scroll = if selected_visual_idx < visible_height / 2 { - 0 - } else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) { - total_items.saturating_sub(visible_height) - } else { - selected_visual_idx.saturating_sub(visible_height / 2) - }; - - // Count categories before the scroll offset to track cat_idx correctly - let mut cat_idx = CATEGORIES + let items: Vec = CATEGORIES .iter() - .take(scroll) - .filter(|e| matches!(e, Category(_))) - .count(); - - let items: Vec = CATEGORIES - .iter() - .skip(scroll) - .take(visible_height) .map(|entry| match entry { - Section(name) => { - let style = Style::new().fg(theme.ui.text_dim); - ListItem::new(format!("─ {name} ─")).style(style) - } - Category(name) => { - let is_selected = cat_idx == app.ui.dict_category; - let style = if dimmed { - Style::new().fg(theme.dict.category_dimmed) - } else if is_selected && focused { - Style::new() - .fg(theme.dict.category_focused) - .add_modifier(Modifier::BOLD) - } else if is_selected { - Style::new().fg(theme.dict.category_selected) - } else { - Style::new().fg(theme.dict.category_normal) - }; - let prefix = if is_selected && !dimmed { "> " } else { " " }; - cat_idx += 1; - ListItem::new(format!("{prefix}{name}")).style(style) - } + Section(name) => CategoryItem { + label: name, + is_section: true, + }, + Category(name) => CategoryItem { + label: name, + is_section: false, + }, }) .collect(); - let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::new().fg(border_color)) + let mut list = CategoryList::new(&items, app.ui.dict_category) + .focused(focused) .title("Categories"); - let list = List::new(items).block(block); - frame.render_widget(list, area); + + if dimmed { + list = list.dimmed(theme.dict.category_dimmed); + } + + list.render(frame, area); } fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { let theme = theme::get(); let focused = app.ui.dict_focus == DictFocus::Words; - // Filter words by search query or category let words: Vec<&Word> = if is_searching { let query = app.ui.dict_search_query.to_lowercase(); WORDS @@ -142,7 +94,6 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { .collect() }; - // Split area for search bar when search is active or has query let show_search = app.ui.dict_search_active || is_searching; let (search_area, content_area) = if show_search { let [s, c] = @@ -152,9 +103,8 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { (None, area) }; - // Render search bar if let Some(sa) = search_area { - render_search_bar(frame, app, sa); + render_search_bar(frame, sa, &app.ui.dict_search_query, app.ui.dict_search_active); } let content_width = content_area.width.saturating_sub(2) as usize; @@ -229,17 +179,3 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { .block(block); frame.render_widget(para, content_area); } - -fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { - let theme = theme::get(); - let style = if app.ui.dict_search_active { - Style::new().fg(theme.search.active) - } else { - Style::new().fg(theme.search.inactive) - }; - let cursor = if app.ui.dict_search_active { "_" } else { "" }; - let text = format!(" /{}{}", app.ui.dict_search_query, cursor); - let line = RLine::from(Span::styled(text, style)); - frame.render_widget(Paragraph::new(vec![line]), area); -} - diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 163e34e..d1f9a62 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -8,7 +8,9 @@ use ratatui::Frame; use crate::app::App; use crate::state::{DeviceKind, EngineSection, SettingKind}; use crate::theme; -use crate::widgets::{Orientation, Scope, Spectrum}; +use crate::widgets::{ + render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum, +}; pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [left_col, _, right_col] = Layout::horizontal([ @@ -122,27 +124,15 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { render_samples(frame, app, samples_area); } - // Scroll indicators - let indicator_style = Style::new().fg(theme.engine.scroll_indicator); - let indicator_x = padded.x + padded.width.saturating_sub(1); - - if scroll_offset > 0 { - let up_indicator = Paragraph::new("▲").style(indicator_style); - frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1)); - } - - if scroll_offset + max_visible < total_lines { - let down_indicator = Paragraph::new("▼").style(indicator_style); - frame.render_widget( - down_indicator, - Rect::new( - indicator_x, - padded.y + padded.height.saturating_sub(1), - 1, - 1, - ), - ); - } + render_scroll_indicators( + frame, + padded, + scroll_offset, + max_visible, + total_lines, + theme.engine.scroll_indicator, + IndicatorAlign::Right, + ); } fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { @@ -210,30 +200,6 @@ fn devices_section_height(app: &App) -> u16 { 3 + output_h.max(input_h) } -fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) { - let theme = theme::get(); - let [header_area, divider_area] = - Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); - - let header_style = if focused { - Style::new() - .fg(theme.engine.header_focused) - .add_modifier(Modifier::BOLD) - } else { - Style::new() - .fg(theme.engine.header) - .add_modifier(Modifier::BOLD) - }; - - frame.render_widget(Paragraph::new(title).style(header_style), header_area); - - let divider = "─".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(divider).style(Style::new().fg(theme.engine.divider)), - divider_area, - ); -} - fn render_devices(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let section_focused = app.audio.section == EngineSection::Devices; diff --git a/src/views/help_view.rs b/src/views/help_view.rs index d77624d..2549dac 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -2,7 +2,7 @@ use cagire_markdown::{CodeHighlighter, MarkdownTheme}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line as RLine, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; use ratatui::Frame; #[cfg(not(feature = "desktop"))] use tui_big_text::{BigText, PixelSize}; @@ -12,6 +12,7 @@ use crate::model::docs::{get_topic, DocEntry, DOCS}; use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; +use crate::widgets::{render_search_bar, CategoryItem, CategoryList}; use DocEntry::{Section, Topic}; @@ -100,80 +101,28 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { fn render_topics(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); + let focused = app.ui.help_focus == HelpFocus::Topics; - let visible_height = area.height.saturating_sub(2) as usize; - let total_items = DOCS.len(); - - // Find the visual index of the selected topic (including sections) - let selected_visual_idx = { - let mut visual = 0; - let mut topic_count = 0; - for entry in DOCS.iter() { - if let Topic(_, _) = entry { - if topic_count == app.ui.help_topic { - break; - } - topic_count += 1; - } - visual += 1; - } - visual - }; - - // Calculate scroll to keep selection visible (centered when possible) - let scroll = if selected_visual_idx < visible_height / 2 { - 0 - } else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) { - total_items.saturating_sub(visible_height) - } else { - selected_visual_idx.saturating_sub(visible_height / 2) - }; - - // Count topics before the scroll offset to track topic_idx correctly - let mut topic_idx = DOCS + let items: Vec = DOCS .iter() - .take(scroll) - .filter(|e| matches!(e, Topic(_, _))) - .count(); - - let items: Vec = DOCS - .iter() - .skip(scroll) - .take(visible_height) .map(|entry| match entry { - Section(name) => { - let style = Style::new().fg(theme.ui.text_dim); - ListItem::new(format!("─ {name} ─")).style(style) - } - Topic(name, _) => { - let selected = topic_idx == app.ui.help_topic; - let style = if selected { - Style::new() - .fg(theme.dict.category_selected) - .add_modifier(Modifier::BOLD) - } else { - Style::new().fg(theme.ui.text_primary) - }; - let prefix = if selected { "> " } else { " " }; - topic_idx += 1; - ListItem::new(format!("{prefix}{name}")).style(style) - } + Section(name) => CategoryItem { + label: name, + is_section: true, + }, + Topic(name, _) => CategoryItem { + label: name, + is_section: false, + }, }) .collect(); - let focused = app.ui.help_focus == HelpFocus::Topics; - let border_color = if focused { - theme.dict.border_focused - } else { - theme.dict.border_normal - }; - let list = List::new(items).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::new().fg(border_color)) - .title("Topics"), - ); - frame.render_widget(list, area); + CategoryList::new(&items, app.ui.help_topic) + .focused(focused) + .title("Topics") + .selected_color(theme.dict.category_selected) + .normal_color(theme.ui.text_primary) + .render(frame, area); } const WELCOME_TOPIC: usize = 0; @@ -237,7 +186,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { let content_area = if has_search_bar { let [content, search] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area); - render_search_bar(frame, app, search); + render_search_bar(frame, search, &app.ui.help_search_query, app.ui.help_search_active); content } else { md_area @@ -292,18 +241,6 @@ fn wrapped_line_count(line: &RLine, width: usize) -> usize { } } -fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { - let theme = theme::get(); - let style = if app.ui.help_search_active { - Style::new().fg(theme.search.active) - } else { - Style::new().fg(theme.search.inactive) - }; - let cursor = if app.ui.help_search_active { "█" } else { "" }; - let text = format!(" /{}{cursor}", app.ui.help_search_query); - frame.render_widget(Paragraph::new(text).style(style), area); -} - fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> { let theme = theme::get(); let mut result: Vec> = Vec::new(); diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 522a32c..707a67d 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -9,6 +9,7 @@ use crate::engine::LinkState; use crate::midi; use crate::state::OptionsFocus; use crate::theme; +use crate::widgets::{render_scroll_indicators, IndicatorAlign}; pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let theme = theme::get(); @@ -243,24 +244,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { frame.render_widget(Paragraph::new(visible_lines), padded); - let indicator_style = Style::new().fg(theme.ui.text_dim); - let indicator_x = padded.x + padded.width.saturating_sub(1); - - if scroll_offset > 0 { - let up_indicator = Paragraph::new("▲").style(indicator_style); - frame.render_widget( - up_indicator, - Rect::new(indicator_x, padded.y, 1, 1), - ); - } - - if visible_end < total_lines { - let down_indicator = Paragraph::new("▼").style(indicator_style); - frame.render_widget( - down_indicator, - Rect::new(indicator_x, padded.y + padded.height.saturating_sub(1), 1, 1), - ); - } + render_scroll_indicators( + frame, + padded, + scroll_offset, + visible_end - scroll_offset, + total_lines, + theme.ui.text_dim, + IndicatorAlign::Right, + ); } fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'static> { diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 6288581..941df7a 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -9,6 +9,7 @@ use crate::engine::SequencerSnapshot; use crate::model::{MAX_BANKS, MAX_PATTERNS}; use crate::state::PatternsColumn; use crate::theme; +use crate::widgets::{render_scroll_indicators, IndicatorAlign}; const MIN_ROW_HEIGHT: u16 = 1; @@ -171,21 +172,15 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area frame.render_widget(para, text_area); } - // Scroll indicators - let indicator_style = Style::new().fg(theme.ui.text_muted); - if scroll_offset > 0 { - let indicator = Paragraph::new("▲") - .style(indicator_style) - .alignment(ratatui::layout::Alignment::Center); - frame.render_widget(indicator, Rect { height: 1, ..inner }); - } - if scroll_offset + visible_count < MAX_BANKS { - let y = inner.y + inner.height.saturating_sub(1); - let indicator = Paragraph::new("▼") - .style(indicator_style) - .alignment(ratatui::layout::Alignment::Center); - frame.render_widget(indicator, Rect { y, height: 1, ..inner }); - } + render_scroll_indicators( + frame, + inner, + scroll_offset, + visible_count, + MAX_BANKS, + theme.ui.text_muted, + IndicatorAlign::Center, + ); } fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { @@ -419,19 +414,13 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a frame.render_widget(Paragraph::new(Line::from(spans)), text_area); } - // Scroll indicators - let indicator_style = Style::new().fg(theme.ui.text_muted); - if scroll_offset > 0 { - let indicator = Paragraph::new("▲") - .style(indicator_style) - .alignment(ratatui::layout::Alignment::Center); - frame.render_widget(indicator, Rect { height: 1, ..inner }); - } - if scroll_offset + visible_count < MAX_PATTERNS { - let y = inner.y + inner.height.saturating_sub(1); - let indicator = Paragraph::new("▼") - .style(indicator_style) - .alignment(ratatui::layout::Alignment::Center); - frame.render_widget(indicator, Rect { y, height: 1, ..inner }); - } + render_scroll_indicators( + frame, + inner, + scroll_offset, + visible_count, + MAX_PATTERNS, + theme.ui.text_muted, + IndicatorAlign::Center, + ); } diff --git a/src/views/render.rs b/src/views/render.rs index 4e36eb5..0814a8a 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -12,12 +12,14 @@ use crate::engine::{LinkState, SequencerSnapshot}; use crate::model::SourceSpan; use crate::page::Page; use crate::state::{ - EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, + EditorTarget, EuclideanField, FlashKind, Modal, PanelFocus, PatternField, RenameTarget, + SidePanel, }; use crate::theme; use crate::views::highlight::{self, highlight_line_with_runtime}; use crate::widgets::{ - ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal, + hint_line, render_props_form, render_search_bar, ConfirmModal, ModalFrame, NavMinimap, NavTile, + SampleBrowser, TextInputModal, }; use super::{ @@ -497,42 +499,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let user_words: HashSet = app.dict.lock().keys().cloned().collect(); let inner = match &app.ui.modal { Modal::None => return None, - Modal::ConfirmQuit { selected } => { - ConfirmModal::new("Confirm", "Quit?", *selected).render_centered(frame, term) - } - Modal::ConfirmDeleteStep { step, selected, .. } => { - ConfirmModal::new("Confirm", &format!("Delete step {}?", step + 1), *selected) + Modal::Confirm { action, selected } => { + ConfirmModal::new("Confirm", &action.message(), *selected) .render_centered(frame, term) } - Modal::ConfirmDeleteSteps { - steps, selected, .. - } => { - let nums: Vec = steps.iter().map(|s| format!("{:02}", s + 1)).collect(); - let label = format!("Delete steps {}?", nums.join(", ")); - ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term) - } - Modal::ConfirmResetPattern { - pattern, selected, .. - } => ConfirmModal::new( - "Confirm", - &format!("Reset pattern {}?", pattern + 1), - *selected, - ) - .render_centered(frame, term), - Modal::ConfirmResetBank { bank, selected } => { - ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected) - .render_centered(frame, term) - } - Modal::ConfirmResetPatterns { - patterns, selected, .. - } => { - let label = format!("Reset {} patterns?", patterns.len()); - ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term) - } - Modal::ConfirmResetBanks { banks, selected } => { - let label = format!("Reset {} banks?", banks.len()); - ConfirmModal::new("Confirm", &label, *selected).render_centered(frame, term) - } Modal::FileBrowser(state) => { use crate::state::file_browser::FileBrowserMode; use crate::widgets::FileBrowserModal; @@ -553,27 +523,14 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .height(18) .render_centered(frame, term) } - Modal::RenameBank { bank, name } => { - TextInputModal::new(&format!("Rename Bank {:02}", bank + 1), name) + Modal::Rename { target, name } => { + let border_color = match target { + RenameTarget::Step { .. } => theme.modal.input, + _ => theme.modal.rename, + }; + TextInputModal::new(&target.title(), name) .width(40) - .border_color(theme.modal.rename) - .render_centered(frame, term) - } - Modal::RenamePattern { - bank, - pattern, - name, - } => TextInputModal::new( - &format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1), - name, - ) - .width(40) - .border_color(theme.modal.rename) - .render_centered(frame, term), - Modal::RenameStep { step, name, .. } => { - TextInputModal::new(&format!("Name Step {:02}", step + 1), name) - .width(40) - .border_color(theme.modal.input) + .border_color(border_color) .render_centered(frame, term) } Modal::SetPattern { field, input } => { @@ -794,18 +751,12 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term let hint_area = Rect::new(inner.x, y, inner.width, 1); if let Some(sa) = search_area { - let style = if app.editor_ctx.editor.search_active() { - Style::default().fg(theme.search.active) - } else { - Style::default().fg(theme.search.inactive) - }; - let cursor = if app.editor_ctx.editor.search_active() { - "_" - } else { - "" - }; - let text = format!("/{}{}", app.editor_ctx.editor.search_query(), cursor); - frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), sa); + render_search_bar( + frame, + sa, + app.editor_ctx.editor.search_query(), + app.editor_ctx.editor.search_active(), + ); } if let Some(kind) = flash_kind { @@ -821,17 +772,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .editor .render(frame, editor_area, &highlighter); - let dim = Style::default().fg(theme.hint.text); - let key = Style::default().fg(theme.hint.key); - if app.editor_ctx.editor.search_active() { - let hint = Line::from(vec![ - Span::styled("Enter", key), - Span::styled(" confirm ", dim), - Span::styled("Esc", key), - Span::styled(" cancel", dim), - ]); - frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]); + frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); } else if app.editor_ctx.show_stack { let stack_text = app .editor_ctx @@ -840,42 +783,29 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .as_ref() .map(|c| c.result.clone()) .unwrap_or_else(|| "Stack: []".to_string()); - let hint = Line::from(vec![ - Span::styled("Esc", key), - Span::styled(" save ", dim), - Span::styled("C-e", key), - Span::styled(" eval ", dim), - Span::styled("C-s", key), - Span::styled(" hide", dim), - ]); + let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]); let [hint_left, stack_right] = Layout::horizontal([ - Constraint::Length(hint.width() as u16), + Constraint::Length(hints.width() as u16), Constraint::Fill(1), ]) .areas(hint_area); - frame.render_widget(Paragraph::new(hint), hint_left); + frame.render_widget(Paragraph::new(hints), hint_left); + let dim = Style::default().fg(theme.hint.text); frame.render_widget( Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right), stack_right, ); } else { - let hint = Line::from(vec![ - Span::styled("Esc", key), - Span::styled(" save ", dim), - Span::styled("C-e", key), - Span::styled(" eval ", dim), - Span::styled("C-f", key), - Span::styled(" find ", dim), - Span::styled("C-b", key), - Span::styled(" samples ", dim), - Span::styled("C-s", key), - Span::styled(" stack ", dim), - Span::styled("C-u", key), - Span::styled("/", dim), - Span::styled("C-r", key), - Span::styled(" undo/redo", dim), + let hints = hint_line(&[ + ("Esc", "save"), + ("C-e", "eval"), + ("C-f", "find"), + ("C-b", "samples"), + ("C-s", "stack"), + ("C-u", "/"), + ("C-r", "undo/redo"), ]); - frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area); + frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area); } inner @@ -899,70 +829,20 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(theme.modal.input) .render_centered(frame, term); - let fields = [ + let speed_label = speed.label(); + let fields: Vec<(&str, &str, bool)> = vec![ ("Name", name.as_str(), *field == PatternPropsField::Name), - ( - "Length", - length.as_str(), - *field == PatternPropsField::Length, - ), - ("Speed", &speed.label(), *field == PatternPropsField::Speed), - ( - "Quantization", - quantization.label(), - *field == PatternPropsField::Quantization, - ), - ( - "Sync Mode", - sync_mode.label(), - *field == PatternPropsField::SyncMode, - ), + ("Length", length.as_str(), *field == PatternPropsField::Length), + ("Speed", &speed_label, *field == PatternPropsField::Speed), + ("Quantization", quantization.label(), *field == PatternPropsField::Quantization), + ("Sync Mode", sync_mode.label(), *field == PatternPropsField::SyncMode), ]; - for (i, (label, value, selected)) in fields.iter().enumerate() { - let y = inner.y + i as u16; - if y >= inner.y + inner.height { - break; - } - - let (label_style, value_style) = if *selected { - ( - Style::default() - .fg(theme.hint.key) - .add_modifier(Modifier::BOLD), - Style::default() - .fg(theme.ui.text_primary) - .bg(theme.ui.surface), - ) - } else { - ( - Style::default().fg(theme.ui.text_muted), - Style::default().fg(theme.ui.text_primary), - ) - }; - - let label_area = Rect::new(inner.x + 1, y, 14, 1); - let value_area = Rect::new(inner.x + 16, y, inner.width.saturating_sub(18), 1); - - frame.render_widget( - Paragraph::new(format!("{label}:")).style(label_style), - label_area, - ); - frame.render_widget(Paragraph::new(*value).style(value_style), value_area); - } + render_props_form(frame, inner, &fields); let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); - let hint_line = Line::from(vec![ - Span::styled("↑↓", Style::default().fg(theme.hint.key)), - Span::styled(" nav ", Style::default().fg(theme.hint.text)), - Span::styled("←→", Style::default().fg(theme.hint.key)), - Span::styled(" change ", Style::default().fg(theme.hint.text)), - Span::styled("Enter", Style::default().fg(theme.hint.key)), - Span::styled(" save ", Style::default().fg(theme.hint.text)), - Span::styled("Esc", Style::default().fg(theme.hint.key)), - Span::styled(" cancel", Style::default().fg(theme.hint.text)), - ]); - frame.render_widget(Paragraph::new(hint_line), hint_area); + let hints = hint_line(&[("↑↓", "nav"), ("←→", "change"), ("Enter", "save"), ("Esc", "cancel")]); + frame.render_widget(Paragraph::new(hints), hint_area); inner } @@ -1024,16 +904,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term width: inner.width, height: 1, }; - let keybind_hint = Line::from(vec![ - Span::styled("↑↓", Style::default().fg(theme.hint.key)), - Span::styled(" scroll ", Style::default().fg(theme.hint.text)), - Span::styled("PgUp/Dn", Style::default().fg(theme.hint.key)), - Span::styled(" page ", Style::default().fg(theme.hint.text)), - Span::styled("Esc/?", Style::default().fg(theme.hint.key)), - Span::styled(" close", Style::default().fg(theme.hint.text)), - ]); + let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]); frame.render_widget( - Paragraph::new(keybind_hint).alignment(Alignment::Right), + Paragraph::new(hints).alignment(Alignment::Right), hint_area, ); @@ -1056,51 +929,13 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term .border_color(theme.modal.input) .render_centered(frame, term); - let fields = [ - ( - "Pulses", - pulses.as_str(), - *field == EuclideanField::Pulses, - ), + let fields: Vec<(&str, &str, bool)> = vec![ + ("Pulses", pulses.as_str(), *field == EuclideanField::Pulses), ("Steps", steps.as_str(), *field == EuclideanField::Steps), - ( - "Rotation", - rotation.as_str(), - *field == EuclideanField::Rotation, - ), + ("Rotation", rotation.as_str(), *field == EuclideanField::Rotation), ]; - for (i, (label, value, selected)) in fields.iter().enumerate() { - let row_y = inner.y + i as u16; - if row_y >= inner.y + inner.height { - break; - } - - let (label_style, value_style) = if *selected { - ( - Style::default() - .fg(theme.hint.key) - .add_modifier(Modifier::BOLD), - Style::default() - .fg(theme.ui.text_primary) - .bg(theme.ui.surface), - ) - } else { - ( - Style::default().fg(theme.ui.text_muted), - Style::default().fg(theme.ui.text_primary), - ) - }; - - let label_area = Rect::new(inner.x + 1, row_y, 14, 1); - let value_area = Rect::new(inner.x + 16, row_y, inner.width.saturating_sub(18), 1); - - frame.render_widget( - Paragraph::new(format!("{label}:")).style(label_style), - label_area, - ); - frame.render_widget(Paragraph::new(*value).style(value_style), value_area); - } + render_props_form(frame, inner, &fields); let preview_y = inner.y + 4; if preview_y < inner.y + inner.height { @@ -1118,17 +953,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term } let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); - let hint_line = Line::from(vec![ - Span::styled("↑↓", Style::default().fg(theme.hint.key)), - Span::styled(" nav ", Style::default().fg(theme.hint.text)), - Span::styled("←→", Style::default().fg(theme.hint.key)), - Span::styled(" adjust ", Style::default().fg(theme.hint.text)), - Span::styled("Enter", Style::default().fg(theme.hint.key)), - Span::styled(" apply ", Style::default().fg(theme.hint.text)), - Span::styled("Esc", Style::default().fg(theme.hint.key)), - Span::styled(" cancel", Style::default().fg(theme.hint.text)), - ]); - frame.render_widget(Paragraph::new(hint_line), hint_area); + let hints = hint_line(&[("↑↓", "nav"), ("←→", "adjust"), ("Enter", "apply"), ("Esc", "cancel")]); + frame.render_widget(Paragraph::new(hints), hint_area); inner } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 226ded7..5fa4575 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,4 +1,6 @@ pub use cagire_ratatui::{ - ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile, - Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform, + hint_line, render_props_form, render_scroll_indicators, render_search_bar, + render_section_header, ActivePatterns, CategoryItem, CategoryList, ConfirmModal, + FileBrowserModal, IndicatorAlign, ModalFrame, MuteStatus, NavMinimap, NavTile, Orientation, + SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter, Waveform, }; diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index 9728d4c..a41f104 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -207,3 +207,124 @@ fn at_records_selected_spans() { assert_eq!(&script[trace.selected_spans[2].start as usize..trace.selected_spans[2].end as usize], "0.5"); assert_eq!(&script[trace.selected_spans[4].start as usize..trace.selected_spans[4].end as usize], "0.75"); } + +// --- arp tests --- + +fn get_notes(outputs: &[String]) -> Vec { + outputs + .iter() + .map(|o| parse_params(o).get("note").copied().unwrap_or(0.0)) + .collect() +} + +fn get_gains(outputs: &[String]) -> Vec { + outputs + .iter() + .map(|o| parse_params(o).get("gain").copied().unwrap_or(f64::NAN)) + .collect() +} + +#[test] +fn arp_auto_subdivide() { + let outputs = expect_outputs(r#"sine s c4 e4 g4 b4 arp note ."#, 4); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 64.0)); + assert!(approx_eq(notes[2], 67.0)); + assert!(approx_eq(notes[3], 71.0)); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.25 * step_dur)); + assert!(approx_eq(deltas[2], 0.5 * step_dur)); + assert!(approx_eq(deltas[3], 0.75 * step_dur)); +} + +#[test] +fn arp_with_explicit_at() { + let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine s c4 e4 g4 b4 arp note ."#, 4); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 64.0)); + assert!(approx_eq(notes[2], 67.0)); + assert!(approx_eq(notes[3], 71.0)); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.25 * step_dur)); + assert!(approx_eq(deltas[2], 0.5 * step_dur)); + assert!(approx_eq(deltas[3], 0.75 * step_dur)); +} + +#[test] +fn arp_single_note() { + let outputs = expect_outputs(r#"sine s c4 arp note ."#, 1); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); +} + +#[test] +fn arp_fewer_deltas_than_notes() { + let outputs = expect_outputs(r#"0 0.5 at sine s c4 e4 g4 b4 arp note ."#, 4); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 64.0)); + assert!(approx_eq(notes[2], 67.0)); + assert!(approx_eq(notes[3], 71.0)); + let deltas = get_deltas(&outputs); + let step_dur = 0.125; + assert!(approx_eq(deltas[0], 0.0)); + assert!(approx_eq(deltas[1], 0.5 * step_dur)); + assert!(approx_eq(deltas[2], 0.0)); // wraps: 2 % 2 = 0 + assert!(approx_eq(deltas[3], 0.5 * step_dur)); // wraps: 3 % 2 = 1 +} + +#[test] +fn arp_fewer_notes_than_deltas() { + let outputs = expect_outputs(r#"0 0.25 0.5 0.75 at sine s c4 e4 arp note ."#, 4); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 64.0)); + assert!(approx_eq(notes[2], 60.0)); // wraps + assert!(approx_eq(notes[3], 64.0)); // wraps +} + +#[test] +fn arp_multiple_params() { + let outputs = expect_outputs(r#"sine s c4 e4 g4 arp note 0.5 0.7 0.9 arp gain ."#, 3); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 64.0)); + assert!(approx_eq(notes[2], 67.0)); + let gains = get_gains(&outputs); + assert!(approx_eq(gains[0], 0.5)); + assert!(approx_eq(gains[1], 0.7)); + assert!(approx_eq(gains[2], 0.9)); +} + +#[test] +fn arp_no_arp_unchanged() { + // Standard CycleList without arp → cross-product (backward compat) + let outputs = expect_outputs(r#"0 0.5 at sine s c4 e4 note ."#, 4); + let notes = get_notes(&outputs); + // Cross-product: each note at each delta + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 60.0)); + assert!(approx_eq(notes[2], 64.0)); + assert!(approx_eq(notes[3], 64.0)); +} + +#[test] +fn arp_mixed_cycle_and_arp() { + // CycleList sound + ArpList note → flat loop, sound cycles + let outputs = expect_outputs(r#"sine saw s c4 e4 g4 arp note ."#, 3); + let sounds = get_sounds(&outputs); + // Sound is CycleList, cycles across the 3 arp emissions + assert_eq!(sounds[0], "sine"); + assert_eq!(sounds[1], "saw"); + assert_eq!(sounds[2], "sine"); + let notes = get_notes(&outputs); + assert!(approx_eq(notes[0], 60.0)); + assert!(approx_eq(notes[1], 64.0)); + assert!(approx_eq(notes[2], 67.0)); +}