Feat: UI/UX fixes + removing clones from places

This commit is contained in:
2026-03-05 00:15:51 +01:00
parent 35370a6f2c
commit 60fb62829f
17 changed files with 1817 additions and 290 deletions

View File

@@ -112,7 +112,7 @@ impl Forth {
let vars_snapshot = self.vars.load_full();
let mut var_writes: HashMap<String, Value> = HashMap::new();
cmd.set_global(self.global_params.lock().clone());
cmd.set_global(std::mem::take(&mut *self.global_params.lock()));
self.execute_ops(
ops,
@@ -459,7 +459,7 @@ impl Forth {
if b.as_float().map_or(true, |v| v == 0.0) {
return Err("division by zero".into());
}
stack.push(lift_binary(a, b, |x, y| x / y)?);
stack.push(lift_binary(&a, &b, |x, y| x / y)?);
}
Op::Mod => {
let b = pop(stack)?;
@@ -467,47 +467,47 @@ impl Forth {
if b.as_float().map_or(true, |v| v == 0.0) {
return Err("modulo by zero".into());
}
let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?;
let result = lift_binary(&a, &b, |x, y| (x as i64 % y as i64) as f64)?;
stack.push(result);
}
Op::Neg => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| -x)?);
stack.push(lift_unary(&v, |x| -x)?);
}
Op::Abs => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.abs())?);
stack.push(lift_unary(&v, |x| x.abs())?);
}
Op::Floor => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.floor())?);
stack.push(lift_unary(&v, |x| x.floor())?);
}
Op::Ceil => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.ceil())?);
stack.push(lift_unary(&v, |x| x.ceil())?);
}
Op::Round => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.round())?);
stack.push(lift_unary(&v, |x| x.round())?);
}
Op::Min => binary_op(stack, |a, b| a.min(b))?,
Op::Max => binary_op(stack, |a, b| a.max(b))?,
Op::Pow => binary_op(stack, |a, b| a.powf(b))?,
Op::Sqrt => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.sqrt())?);
stack.push(lift_unary(&v, |x| x.sqrt())?);
}
Op::Sin => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.sin())?);
stack.push(lift_unary(&v, |x| x.sin())?);
}
Op::Cos => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.cos())?);
stack.push(lift_unary(&v, |x| x.cos())?);
}
Op::Log => {
let v = pop(stack)?;
stack.push(lift_unary(v, |x| x.ln())?);
stack.push(lift_unary(&v, |x| x.ln())?);
}
Op::Eq => cmp_op(stack, |a, b| (a - b).abs() < f64::EPSILON)?,
@@ -1055,7 +1055,7 @@ impl Forth {
let key = read_key(&var_writes_cell, vars_snapshot);
let values = std::mem::take(stack);
for val in values {
let result = lift_unary_int(val, |degree| {
let result = lift_unary_int(&val, |degree| {
let octave_offset = degree.div_euclid(len);
let idx = degree.rem_euclid(len) as usize;
key + octave_offset * 12 + pattern[idx]
@@ -1155,7 +1155,7 @@ impl Forth {
Op::Oct => {
let shift = pop(stack)?;
let note = pop(stack)?;
let result = lift_binary(note, shift, |n, s| n + s * 12.0)?;
let result = lift_binary(&note, &shift, |n, s| n + s * 12.0)?;
stack.push(result);
}
@@ -1921,65 +1921,65 @@ fn float_to_value(result: f64) -> Value {
}
}
fn lift_unary<F>(val: Value, f: F) -> Result<Value, String>
fn lift_unary<F>(val: &Value, f: F) -> Result<Value, String>
where
F: Fn(f64) -> f64 + Copy,
{
match val {
Value::ArpList(items) => {
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect();
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x.clone(), f)).collect();
let mapped: Result<Vec<_>, _> = items.iter().map(|x| lift_unary(x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
v => Ok(float_to_value(f(v.as_float()?))),
}
}
fn lift_unary_int<F>(val: Value, f: F) -> Result<Value, String>
fn lift_unary_int<F>(val: &Value, f: F) -> Result<Value, String>
where
F: Fn(i64) -> i64 + Copy,
{
match val {
Value::ArpList(items) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x.clone(), f)).collect();
items.iter().map(|x| lift_unary_int(x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
Value::CycleList(items) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_unary_int(x.clone(), f)).collect();
items.iter().map(|x| lift_unary_int(x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
v => Ok(Value::Int(f(v.as_int()?), None)),
}
}
fn lift_binary<F>(a: Value, b: Value, f: F) -> Result<Value, String>
fn lift_binary<F>(a: &Value, b: &Value, f: F) -> Result<Value, String>
where
F: Fn(f64, f64) -> f64 + Copy,
{
match (a, b) {
(Value::ArpList(items), b) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect();
items.iter().map(|x| lift_binary(x, b, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(a, Value::ArpList(items)) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect();
items.iter().map(|x| lift_binary(a, x, f)).collect();
Ok(Value::ArpList(Arc::from(mapped?)))
}
(Value::CycleList(items), b) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(x.clone(), b.clone(), f)).collect();
items.iter().map(|x| lift_binary(x, b, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
(a, Value::CycleList(items)) => {
let mapped: Result<Vec<_>, _> =
items.iter().map(|x| lift_binary(a.clone(), x.clone(), f)).collect();
items.iter().map(|x| lift_binary(a, x, f)).collect();
Ok(Value::CycleList(Arc::from(mapped?)))
}
(a, b) => Ok(float_to_value(f(a.as_float()?, b.as_float()?))),
@@ -1992,7 +1992,7 @@ where
{
let b = pop(stack)?;
let a = pop(stack)?;
stack.push(lift_binary(a, b, f)?);
stack.push(lift_binary(&a, &b, f)?);
Ok(())
}

View File

@@ -170,6 +170,17 @@ impl LaunchQuantization {
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::Immediate => "Imm",
Self::Beat => "Bt",
Self::Bar => "1B",
Self::Bars2 => "2B",
Self::Bars4 => "4B",
Self::Bars8 => "8B",
}
}
/// Cycle to the next longer quantization, clamped at `Bars8`.
pub fn next(&self) -> Self {
match self {
@@ -212,6 +223,13 @@ impl SyncMode {
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::Reset => "Rst",
Self::PhaseLock => "Plk",
}
}
/// Toggle between Reset and PhaseLock.
pub fn toggle(&self) -> Self {
match self {

View File

@@ -1,6 +1,7 @@
//! Script editor widget with completion, search, and sample finder popups.
use std::cell::Cell;
use std::sync::Arc;
use crate::theme;
use ratatui::{
@@ -25,7 +26,7 @@ pub struct CompletionCandidate {
}
struct CompletionState {
candidates: Vec<CompletionCandidate>,
candidates: Arc<[CompletionCandidate]>,
matches: Vec<usize>,
cursor: usize,
prefix: String,
@@ -37,7 +38,7 @@ struct CompletionState {
impl CompletionState {
fn new() -> Self {
Self {
candidates: Vec::new(),
candidates: Arc::from([]),
matches: Vec::new(),
cursor: 0,
prefix: String::new(),
@@ -171,7 +172,7 @@ impl Editor {
self.scroll_offset.set(0);
}
pub fn set_candidates(&mut self, candidates: Vec<CompletionCandidate>) {
pub fn set_candidates(&mut self, candidates: Arc<[CompletionCandidate]>) {
self.completion.candidates = candidates;
}

View File

@@ -29,7 +29,7 @@ use crate::state::{
ProjectState, ScriptEditorState, UiState,
};
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
static COMPLETION_CANDIDATES: LazyLock<Arc<[CompletionCandidate]>> = LazyLock::new(|| {
model::WORDS
.iter()
.map(|w| CompletionCandidate {

View File

@@ -1,5 +1,7 @@
//! Forth script compilation, evaluation, and editor ↔ step synchronization.
use std::sync::Arc;
use crossbeam_channel::Sender;
use crate::engine::LinkState;
@@ -55,7 +57,7 @@ impl App {
script.lines().map(String::from).collect()
};
self.editor_ctx.editor.set_content(lines);
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
self.editor_ctx
.editor
.set_completion_enabled(self.ui.show_completion);
@@ -87,7 +89,7 @@ impl App {
prelude.lines().map(String::from).collect()
};
self.editor_ctx.editor.set_content(lines);
self.editor_ctx.editor.set_candidates(COMPLETION_CANDIDATES.clone());
self.editor_ctx.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
self.editor_ctx
.editor
.set_completion_enabled(self.ui.show_completion);
@@ -190,7 +192,7 @@ impl App {
script.lines().map(String::from).collect()
};
self.script_editor.editor.set_content(lines);
self.script_editor.editor.set_candidates(COMPLETION_CANDIDATES.clone());
self.script_editor.editor.set_candidates(Arc::clone(&COMPLETION_CANDIDATES));
self.script_editor
.editor
.set_completion_enabled(self.ui.show_completion);

View File

@@ -555,7 +555,7 @@ pub struct SequencerState {
pattern_cache: PatternCache,
pending_updates: HashMap<(usize, usize), PatternSnapshot>,
runs_counter: RunsCounter,
step_traces: StepTracesMap,
step_traces: Arc<StepTracesMap>,
event_count: usize,
script_engine: ScriptEngine,
variables: Variables,
@@ -593,7 +593,7 @@ impl SequencerState {
pattern_cache: PatternCache::new(),
pending_updates: HashMap::new(),
runs_counter: RunsCounter::new(),
step_traces: HashMap::new(),
step_traces: Arc::new(HashMap::new()),
event_count: 0,
script_engine,
variables,
@@ -713,7 +713,7 @@ impl SequencerState {
self.audio_state.active_patterns.clear();
self.audio_state.pending_starts.clear();
self.audio_state.pending_stops.clear();
self.step_traces.clear();
self.step_traces = Arc::new(HashMap::new());
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
}
@@ -731,7 +731,7 @@ impl SequencerState {
self.speed_overrides.clear();
self.script_engine.clear_global_params();
self.runs_counter.counts.clear();
self.step_traces.clear();
self.step_traces = Arc::new(HashMap::new());
self.audio_state.flush_midi_notes = true;
}
SeqCommand::ResetScriptState => {
@@ -811,7 +811,7 @@ impl SequencerState {
fn tick_paused(&mut self) -> TickOutput {
for pending in self.audio_state.pending_stops.drain(..) {
self.audio_state.active_patterns.remove(&pending.id);
self.step_traces.retain(|&(bank, pattern, _), _| {
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
let key = (pending.id.bank, pending.id.pattern);
@@ -894,7 +894,7 @@ impl SequencerState {
for pending in &self.audio_state.pending_stops {
if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) {
self.audio_state.active_patterns.remove(&pending.id);
self.step_traces.retain(|&(bank, pattern, _), _| {
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
// Flush pending update so cache stays current for future launches
@@ -1015,7 +1015,7 @@ impl SequencerState {
.script_engine
.evaluate_with_trace(script, &ctx, &mut trace)
{
self.step_traces.insert(
Arc::make_mut(&mut self.step_traces).insert(
(active.bank, active.pattern, source_idx),
std::mem::take(&mut trace),
);
@@ -1229,7 +1229,7 @@ impl SequencerState {
last_step_beat: a.last_step_beat,
})
.collect(),
step_traces: Arc::new(self.step_traces.clone()),
step_traces: Arc::clone(&self.step_traces),
event_count: self.event_count,
tempo: self.last_tempo,
beat: self.last_beat,

View File

@@ -79,9 +79,6 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
let current = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
}
KeyCode::Char(':') => {
ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(String::new())));
}
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),

View File

@@ -179,6 +179,19 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
return InputResult::Continue;
}
if key.code == KeyCode::Char(':') {
let in_search = ctx.app.ui.dict_search_active || ctx.app.ui.help_search_active;
let in_script = ctx.app.page == Page::Script && ctx.app.script_editor.focused;
if !in_search && !in_script {
ctx.dispatch(AppCommand::OpenModal(Modal::CommandPalette {
input: String::new(),
cursor: 0,
scroll: 0,
}));
return InputResult::Continue;
}
}
match ctx.app.page {
Page::Main => main_page::handle_main_page(ctx, key, ctrl),
Page::Patterns => patterns_page::handle_patterns_page(ctx, key),

View File

@@ -10,6 +10,49 @@ use crate::state::{
};
pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
// Handle CommandPalette before the main match to avoid borrow conflicts
// (Enter needs &App for palette_entries while the match borrows &mut modal)
if let Modal::CommandPalette { input, cursor, scroll } = &mut ctx.app.ui.modal {
match key.code {
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Char(c) => {
input.push(c);
*cursor = 0;
*scroll = 0;
}
KeyCode::Backspace => {
input.pop();
*cursor = 0;
*scroll = 0;
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
*cursor = cursor.saturating_sub(5);
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
*cursor += 5;
}
KeyCode::Up => {
*cursor = cursor.saturating_sub(1);
}
KeyCode::Down => {
*cursor += 1;
}
KeyCode::PageUp => {
*cursor = cursor.saturating_sub(10);
}
KeyCode::PageDown => {
*cursor += 10;
}
KeyCode::Enter => {
let query = input.clone();
let cursor_val = *cursor;
handle_palette_enter(ctx, &query, cursor_val);
}
_ => {}
}
return InputResult::Continue;
}
match &mut ctx.app.ui.modal {
Modal::Confirm { action, selected } => {
let confirmed = *selected;
@@ -179,22 +222,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char(c) => input.push(c),
_ => {}
},
Modal::JumpToStep(input) => match key.code {
KeyCode::Enter => {
if let Ok(step) = input.parse::<usize>() {
if step > 0 {
ctx.dispatch(AppCommand::GoToStep(step - 1));
}
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => input.push(c),
_ => {}
},
Modal::SetTempo(input) => match key.code {
KeyCode::Enter => {
if let Ok(tempo) = input.parse::<f64>() {
@@ -514,7 +541,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
}
}
Modal::KeybindingsHelp { scroll } => {
let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page, ctx.app.plugin_mode).len();
let bindings_count = crate::model::palette::bindings_for(ctx.app.page, ctx.app.plugin_mode).len();
match key.code {
KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Up | KeyCode::Char('k') => {
@@ -636,6 +663,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
_ => ctx.dispatch(AppCommand::CloseModal),
}
}
Modal::CommandPalette { .. } => unreachable!(),
Modal::None => unreachable!(),
}
InputResult::Continue
@@ -670,6 +698,97 @@ fn execute_confirm(ctx: &mut InputContext, action: &ConfirmAction) -> InputResul
InputResult::Continue
}
fn handle_palette_enter(ctx: &mut InputContext, query: &str, cursor: usize) {
let page = ctx.app.page;
let plugin_mode = ctx.app.plugin_mode;
// Numeric input on Main page → jump to step
if page == crate::page::Page::Main
&& !query.is_empty()
&& query.chars().all(|c| c.is_ascii_digit())
{
if let Ok(step) = query.parse::<usize>() {
if step > 0 {
ctx.dispatch(AppCommand::GoToStep(step - 1));
}
}
ctx.dispatch(AppCommand::CloseModal);
return;
}
let entries = crate::model::palette::palette_entries(query, plugin_mode, ctx.app);
if let Some(entry) = entries.get(cursor) {
ctx.dispatch(AppCommand::CloseModal);
execute_palette_entry(ctx, entry);
} else {
ctx.dispatch(AppCommand::CloseModal);
}
}
fn execute_palette_entry(
ctx: &mut InputContext,
entry: &crate::model::palette::CommandEntry,
) {
use crate::model::palette::PaletteAction;
use std::sync::atomic::Ordering;
match &entry.action {
Some(PaletteAction::Resolve(f)) => {
if let Some(cmd) = f(ctx.app) {
ctx.dispatch(cmd);
}
}
Some(PaletteAction::Save) => super::open_save(ctx),
Some(PaletteAction::Load) => super::open_load(ctx),
Some(PaletteAction::TogglePlaying) => {
ctx.dispatch(AppCommand::TogglePlaying);
ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed);
}
Some(PaletteAction::MuteToggle) => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.app.playback.toggle_mute(bank, pattern);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::SoloToggle) => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.app.playback.toggle_solo(bank, pattern);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::ClearMutes) => {
ctx.dispatch(AppCommand::ClearMutes);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::ClearSolos) => {
ctx.dispatch(AppCommand::ClearSolos);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
}
Some(PaletteAction::Hush) => {
let _ = ctx
.audio_tx
.load()
.send(crate::engine::AudioCommand::Hush);
}
Some(PaletteAction::Panic) => {
let _ = ctx
.audio_tx
.load()
.send(crate::engine::AudioCommand::Panic);
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
}
Some(PaletteAction::TestTone) => {
let _ = ctx
.audio_tx
.load()
.send(crate::engine::AudioCommand::Evaluate {
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
time: None,
});
}
None => {}
}
}
fn rename_command(target: &RenameTarget, name: Option<String>) -> AppCommand {
match target {
RenameTarget::Bank { bank } => AppCommand::RenameBank { bank: *bank, name },

View File

@@ -986,7 +986,8 @@ fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
Modal::FileBrowser(_) | Modal::AddSamplePath(_) => (60, 18),
Modal::Rename { .. } => (40, 5),
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
Modal::SetTempo(_) | Modal::JumpToStep(_) => (30, 5),
Modal::SetTempo(_) => (30, 5),
Modal::CommandPalette { .. } => (55, 20),
_ => return,
};
let modal_area = centered_rect(term, w, h);

View File

@@ -2,6 +2,7 @@ pub mod categories;
pub mod demos;
pub mod docs;
pub mod onboarding;
pub mod palette;
mod script;
pub use cagire_forth::{

1320
src/model/palette.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,6 @@ pub enum Modal {
input: String,
},
SetTempo(String),
JumpToStep(String),
AddSamplePath(Box<FileBrowserState>),
Editor,
PatternProps {
@@ -102,4 +101,9 @@ pub enum Modal {
rotation: String,
},
Onboarding { page: usize },
CommandPalette {
input: String,
cursor: usize,
scroll: usize,
},
}

View File

@@ -1,144 +0,0 @@
use crate::page::Page;
pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'static str, &'static str)> {
let mut bindings = vec![
("F1F7", "Go to view", "Dict/Patterns/Options/Help/Sequencer/Engine/Script"),
("Ctrl+←→↑↓", "Navigate", "Switch between adjacent views"),
];
if !plugin_mode {
bindings.push(("q", "Quit", "Quit application"));
}
bindings.extend([
("s", "Save", "Save project"),
("l", "Load", "Load project"),
("?", "Keybindings", "Show this help"),
("F12", "Restart", "Full restart from step 0"),
]);
// Page-specific bindings
match page {
Page::Main => {
if !plugin_mode {
bindings.push(("Space", "Play/Stop", "Toggle playback"));
}
bindings.push(("Alt+↑↓", "Pattern", "Previous/next pattern"));
bindings.push(("Alt+←→", "Bank", "Previous/next bank"));
bindings.push(("←→↑↓", "Navigate", "Move cursor between steps"));
bindings.push(("Shift+←→↑↓", "Select", "Extend selection"));
bindings.push(("Esc", "Clear", "Clear selection"));
bindings.push(("Enter", "Edit", "Open step editor"));
bindings.push(("t", "Toggle", "Toggle selected steps"));
bindings.push(("p", "Prelude", "Edit prelude script"));
bindings.push(("Tab", "Samples", "Toggle sample browser"));
bindings.push(("Ctrl+C", "Copy", "Copy selected steps"));
bindings.push(("Ctrl+V", "Paste", "Paste steps"));
bindings.push(("Ctrl+B", "Link", "Paste as linked steps"));
bindings.push(("Ctrl+D", "Duplicate", "Duplicate selection"));
bindings.push(("Ctrl+H", "Harden", "Convert links to copies"));
bindings.push(("Del", "Delete", "Delete step(s)"));
bindings.push(("< >", "Length", "Decrease/increase pattern length"));
bindings.push(("[ ]", "Speed", "Decrease/increase pattern speed"));
if !plugin_mode {
bindings.push(("+ -", "Tempo", "Increase/decrease tempo"));
bindings.push(("T", "Set tempo", "Open tempo input"));
}
bindings.push(("L", "Set length", "Open length input"));
bindings.push(("S", "Set speed", "Open speed input"));
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
bindings.push(("r", "Rename", "Rename current step"));
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
bindings.push((":", "Jump", "Jump to step number"));
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
bindings.push(("m", "Mute", "Arm mute for current pattern"));
bindings.push(("x", "Solo", "Arm solo for current pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("d", "Eval prelude", "Re-evaluate prelude without editing"));
bindings.push(("g", "Share", "Export pattern to clipboard"));
bindings.push(("G", "Import", "Import pattern from clipboard"));
}
Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
bindings.push(("Shift+↑↓", "Select", "Extend selection"));
bindings.push(("Alt+↑↓", "Shift", "Move patterns up/down"));
bindings.push(("Enter", "Select", "Select pattern for editing"));
if !plugin_mode {
bindings.push(("Space", "Play", "Toggle pattern playback"));
}
bindings.push(("Esc", "Back", "Clear armed or go back"));
bindings.push(("c", "Launch", "Launch armed changes"));
bindings.push(("p", "Arm play", "Arm pattern play toggle"));
bindings.push(("r", "Rename", "Rename bank/pattern"));
bindings.push(("d", "Describe", "Add description to pattern"));
bindings.push(("e", "Properties", "Edit pattern properties"));
bindings.push(("m", "Mute", "Arm mute for pattern"));
bindings.push(("x", "Solo", "Arm solo for pattern"));
bindings.push(("M", "Clear mutes", "Clear all mutes"));
bindings.push(("X", "Clear solos", "Clear all solos"));
bindings.push(("g", "Share", "Export bank or pattern to clipboard"));
bindings.push(("G", "Import", "Import bank or pattern from clipboard"));
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
bindings.push(("Del", "Reset", "Reset bank/pattern"));
bindings.push(("Ctrl+Z", "Undo", "Undo last action"));
bindings.push(("Ctrl+Shift+Z", "Redo", "Redo last action"));
}
Page::Engine => {
bindings.push(("Tab", "Section", "Next section"));
bindings.push(("Shift+Tab", "Section", "Previous section"));
bindings.push(("←→", "Switch", "Switch device type or adjust setting"));
bindings.push(("↑↓", "Navigate", "Navigate list items"));
bindings.push(("PgUp/Dn", "Page", "Page through device list"));
bindings.push(("Enter", "Select", "Select device"));
if !plugin_mode {
bindings.push(("R", "Restart", "Restart audio engine"));
}
bindings.push(("A", "Add path", "Add sample path"));
bindings.push(("D", "Refresh/Del", "Refresh devices or delete path"));
bindings.push(("h", "Hush", "Stop all sounds gracefully"));
bindings.push(("p", "Panic", "Stop all sounds immediately"));
bindings.push(("r", "Reset", "Reset peak voice counter"));
if !plugin_mode {
bindings.push(("t", "Test", "Play test tone"));
}
}
Page::Options => {
bindings.push(("Tab", "Next", "Move to next option"));
bindings.push(("Shift+Tab", "Previous", "Move to previous option"));
bindings.push(("↑↓", "Navigate", "Navigate options"));
bindings.push(("←→", "Toggle", "Toggle or adjust option"));
if !plugin_mode {
bindings.push(("Space", "Play/Stop", "Toggle playback"));
}
}
Page::Help => {
bindings.push(("↑↓ j/k", "Scroll", "Scroll content"));
bindings.push(("Tab", "Topic", "Next topic"));
bindings.push(("Shift+Tab", "Topic", "Previous topic"));
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("n", "Next code", "Jump to next code block"));
bindings.push(("p", "Prev code", "Jump to previous code block"));
bindings.push(("Enter", "Run code", "Execute focused code block"));
bindings.push(("/", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search / deselect block"));
}
Page::Dict => {
bindings.push(("Tab", "Focus", "Toggle category/words focus"));
bindings.push(("↑↓ j/k", "Navigate", "Navigate items"));
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("/", "Search", "Activate search"));
bindings.push(("Ctrl+F", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search"));
}
Page::Script => {
bindings.push(("Enter", "Focus", "Focus editor for typing"));
bindings.push(("Esc", "Unfocus", "Unfocus editor to use page keybindings"));
bindings.push(("Ctrl+E", "Evaluate", "Compile and check for errors (focused)"));
bindings.push(("S", "Set Speed", "Set script speed via text input (unfocused)"));
bindings.push(("L", "Set Length", "Set script length via text input (unfocused)"));
bindings.push(("Ctrl+S", "Stack", "Toggle stack preview (focused)"));
}
}
bindings
}

View File

@@ -2,7 +2,6 @@ pub mod dict_view;
pub mod engine_view;
pub mod help_view;
pub mod highlight;
pub mod keybindings;
pub mod main_view;
pub mod options_view;
pub mod patterns_view;

View File

@@ -1,7 +1,7 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
@@ -359,8 +359,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let cursor = app.patterns_nav.pattern_cursor;
let available = inner.height as usize;
// Cursor row takes 2 lines (main + detail); account for 1 extra
let max_visible = available.saturating_sub(1).max(1);
let max_visible = available.max(1);
let scroll_offset = if MAX_PATTERNS <= max_visible {
0
@@ -375,8 +374,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let mut y = inner.y;
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let is_expanded = idx == cursor;
let row_h = if is_expanded { 2u16 } else { 1u16 };
if y >= inner.y + inner.height {
break;
}
@@ -385,7 +382,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
x: inner.x,
y,
width: inner.width,
height: row_h.min(inner.y + inner.height - y),
height: 1u16.min(inner.y + inner.height - y),
};
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
@@ -471,21 +468,9 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
let base_style = Style::new().bg(bg).fg(fg);
let bold_style = base_style.add_modifier(Modifier::BOLD);
let content_area = if is_expanded {
let border_color = if is_focused { theme.selection.cursor } else { theme.ui.unfocused };
let block = Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::new().fg(border_color).bg(bg))
.style(Style::new().bg(bg));
let content = block.inner(row_area);
frame.render_widget(block, row_area);
content
} else {
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
row_area
};
let bg_block = Block::default().style(Style::new().bg(bg));
frame.render_widget(bg_block, row_area);
let content_area = row_area;
let text_area = Rect {
x: content_area.x,
@@ -521,16 +506,38 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
String::new()
};
let props_indicator = if has_staged_props { "~" } else { "" };
let right_info = if content_count > 0 {
format!("{props_indicator}{content_count}/{length}{speed_str}")
let quant_sync = if is_selected {
format!(
"{}:{} ",
pattern.quantization.short_label(),
pattern.sync_mode.short_label()
)
} else {
format!("{props_indicator} {length}{speed_str}")
String::new()
};
let right_info = if content_count > 0 {
format!("{quant_sync}{props_indicator}{content_count}/{length}{speed_str}")
} else {
format!("{quant_sync}{props_indicator} {length}{speed_str}")
};
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let right_width = right_info.chars().count();
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
let gap = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
spans.push(Span::styled(" ".repeat(padding), dim_style));
if let Some(desc) = pattern.description.as_deref().filter(|d| !d.is_empty() && gap > 4) {
let budget = gap - 2;
let char_count = desc.chars().count();
if char_count <= budget {
spans.push(Span::styled(format!(" {desc}"), dim_style));
spans.push(Span::styled(" ".repeat(gap - char_count - 1), dim_style));
} else {
let truncated: String = desc.chars().take(budget - 1).collect();
spans.push(Span::styled(format!(" {truncated}\u{2026}"), dim_style));
spans.push(Span::styled(" ", dim_style));
}
} else {
spans.push(Span::styled(" ".repeat(gap), dim_style));
}
spans.push(Span::styled(right_info, dim_style));
let spans = if is_playing && !is_cursor && !is_in_range {
@@ -543,52 +550,6 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
if is_expanded && content_area.height >= 2 {
let detail_area = Rect {
x: content_area.x,
y: content_area.y + 1,
width: content_area.width,
height: 1,
};
let right_label = format!(
"{} · {}",
pattern.quantization.label(),
pattern.sync_mode.label()
);
let w = detail_area.width as usize;
let label = if let Some(desc) = &pattern.description {
let right_len = right_label.chars().count();
let max_desc = w.saturating_sub(right_len + 1);
let truncated: String = desc.chars().take(max_desc).collect();
let pad = w.saturating_sub(truncated.chars().count() + right_len);
format!("{truncated}{}{right_label}", " ".repeat(pad))
} else {
format!("{right_label:>w$}")
};
let padded_label = label;
let filled_width = if is_playing {
let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0);
(ratio * detail_area.width as f64).min(detail_area.width as f64) as usize
} else {
0
};
let dim_fg = theme.ui.text_muted;
let progress_bg = theme.list.playing_bg;
let byte_offset = padded_label
.char_indices()
.nth(filled_width)
.map_or(padded_label.len(), |(i, _)| i);
let (left, right) = padded_label.split_at(byte_offset);
let detail_spans = vec![
Span::styled(left.to_string(), Style::new().bg(progress_bg).fg(dim_fg)),
Span::styled(right.to_string(), Style::new().bg(theme.ui.bg).fg(dim_fg)),
];
frame.render_widget(Paragraph::new(Line::from(detail_spans)), detail_area);
}
y += row_area.height;
}

View File

@@ -707,15 +707,6 @@ fn render_modal(
.border_color(theme.modal.confirm)
.render_centered(frame, term)
}
Modal::JumpToStep(input) => {
let pattern_len = app.current_edit_pattern().length;
let title = format!("Jump to Step (1-{})", pattern_len);
TextInputModal::new(&title, input)
.hint("Enter step number")
.width(30)
.border_color(theme.modal.confirm)
.render_centered(frame, term)
}
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
.hint("Enter BPM")
.width(30)
@@ -883,6 +874,9 @@ fn render_modal(
inner
}
Modal::CommandPalette { input, cursor, scroll } => {
render_command_palette(frame, app, input, *cursor, *scroll, term)
}
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
Modal::EuclideanDistribution {
source_step,
@@ -1086,6 +1080,247 @@ fn render_modal_editor(
inner
}
fn render_command_palette(
frame: &mut Frame,
app: &App,
query: &str,
cursor: usize,
scroll: usize,
term: Rect,
) -> Rect {
use crate::model::palette::{palette_entries, CommandEntry};
let theme = theme::get();
let entries = palette_entries(query, app.plugin_mode, app);
// On Main page, numeric input prepends a synthetic "Jump to Step" entry
let jump_step: Option<usize> = if app.page == Page::Main
&& !query.is_empty()
&& query.chars().all(|c| c.is_ascii_digit())
{
query.parse().ok()
} else {
None
};
// Build display rows: each is either a separator header or a command entry
struct DisplayRow<'a> {
entry: Option<&'a CommandEntry>,
separator: Option<&'static str>,
is_jump: bool,
jump_label: String,
}
let mut rows: Vec<DisplayRow> = Vec::new();
if let Some(n) = jump_step {
rows.push(DisplayRow {
entry: None,
separator: None,
is_jump: true,
jump_label: format!("Jump to Step {n}"),
});
}
if query.is_empty() {
// Grouped by category with separators
let mut last_category = "";
for e in &entries {
if e.category != last_category {
rows.push(DisplayRow {
entry: None,
separator: Some(e.category),
is_jump: false,
jump_label: String::new(),
});
last_category = e.category;
}
rows.push(DisplayRow {
entry: Some(e),
separator: None,
is_jump: false,
jump_label: String::new(),
});
}
} else {
for e in &entries {
rows.push(DisplayRow {
entry: Some(e),
separator: None,
is_jump: false,
jump_label: String::new(),
});
}
}
// Count selectable items (non-separator)
let selectable_count = rows.iter().filter(|r| r.separator.is_none()).count();
let cursor = cursor.min(selectable_count.saturating_sub(1));
let width: u16 = 55;
let max_height = (term.height as usize * 60 / 100).max(8);
let content_height = rows.len() + 4; // input + gap + hint + border padding
let height = content_height.min(max_height) as u16;
let inner = ModalFrame::new(": Command Palette")
.width(width)
.height(height)
.border_color(theme.modal.confirm)
.render_centered(frame, term);
let mut y = inner.y;
let content_width = inner.width;
// Input line
let input_line = Line::from(vec![
Span::styled("> ", Style::default().fg(theme.modal.confirm)),
Span::styled(query, Style::default().fg(theme.ui.text_primary)),
Span::styled("\u{2588}", Style::default().fg(theme.modal.confirm)),
]);
frame.render_widget(
Paragraph::new(input_line),
Rect::new(inner.x, y, content_width, 1),
);
y += 1;
// Visible area for entries
let visible_height = inner.height.saturating_sub(2) as usize; // minus input line and hint line
// Auto-scroll
let scroll = {
let mut s = scroll;
// Map cursor (selectable index) to row index for scrolling
let mut selectable_idx = 0;
let mut cursor_row = 0;
for (i, row) in rows.iter().enumerate() {
if row.separator.is_some() {
continue;
}
if selectable_idx == cursor {
cursor_row = i;
break;
}
selectable_idx += 1;
}
if cursor_row >= s + visible_height {
s = cursor_row + 1 - visible_height;
}
if cursor_row < s {
s = cursor_row;
}
s
};
// Render visible rows
let mut selectable_idx = rows.iter().take(scroll).filter(|r| r.separator.is_none()).count();
for row in rows.iter().skip(scroll).take(visible_height) {
if y >= inner.y + inner.height - 1 {
break;
}
if let Some(cat) = row.separator {
// Category header
let pad = content_width.saturating_sub(cat.len() as u16 + 4) / 2;
let sep_left = "\u{2500}".repeat(pad as usize);
let sep_right =
"\u{2500}".repeat(content_width.saturating_sub(pad + cat.len() as u16 + 4) as usize);
let line = Line::from(vec![
Span::styled(
format!("{sep_left} "),
Style::default().fg(theme.ui.text_muted),
),
Span::styled(cat, Style::default().fg(theme.ui.text_dim)),
Span::styled(
format!(" {sep_right}"),
Style::default().fg(theme.ui.text_muted),
),
]);
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1));
y += 1;
continue;
}
let is_selected = selectable_idx == cursor;
let (bg, fg) = if is_selected {
(theme.selection.cursor_bg, theme.selection.cursor_fg)
} else if selectable_idx.is_multiple_of(2) {
(theme.table.row_even, theme.ui.text_primary)
} else {
(theme.table.row_odd, theme.ui.text_primary)
};
let (name, keybinding) = if row.is_jump {
(row.jump_label.as_str(), "")
} else if let Some(e) = row.entry {
(e.name, e.keybinding)
} else {
selectable_idx += 1;
y += 1;
continue;
};
let key_len = keybinding.len() as u16;
let name_width = content_width.saturating_sub(key_len + 2);
let truncated_name: String = name.chars().take(name_width as usize).collect();
let padding = name_width.saturating_sub(truncated_name.len() as u16);
let key_fg = if is_selected {
theme.selection.cursor_fg
} else {
theme.ui.text_dim
};
let line = Line::from(vec![
Span::styled(format!(" {truncated_name}"), Style::default().bg(bg).fg(fg)),
Span::styled(
" ".repeat(padding as usize),
Style::default().bg(bg),
),
Span::styled(
format!("{keybinding} "),
Style::default().bg(bg).fg(key_fg),
),
]);
frame.render_widget(Paragraph::new(line), Rect::new(inner.x, y, content_width, 1));
selectable_idx += 1;
y += 1;
}
// Empty state
if selectable_count == 0 {
let msg = "No matching commands";
let empty_y = inner.y + inner.height / 2;
if empty_y < inner.y + inner.height - 1 {
frame.render_widget(
Paragraph::new(msg)
.style(Style::default().fg(theme.ui.text_muted))
.alignment(Alignment::Center),
Rect::new(inner.x, empty_y, content_width, 1),
);
}
}
// Hint bar
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let hints = if jump_step.is_some() && cursor == 0 {
hint_line(&[
("\u{2191}\u{2193}", "navigate"),
("Enter", "jump to step"),
("Esc", "close"),
])
} else {
hint_line(&[
("\u{2191}\u{2193}", "navigate"),
("Enter", "run"),
("Esc", "close"),
])
};
frame.render_widget(Paragraph::new(hints), hint_area);
inner
}
fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
let theme = theme::get();
let width = (term.width * 80 / 100).clamp(60, 100);
@@ -1098,7 +1333,7 @@ fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: R
.border_color(theme.modal.editor)
.render_centered(frame, term);
let bindings = super::keybindings::bindings_for(app.page, app.plugin_mode);
let bindings = crate::model::palette::bindings_for(app.page, app.plugin_mode);
let visible_rows = inner.height.saturating_sub(2) as usize;
let rows: Vec<Row> = bindings