Feat: UI/UX fixes + removing clones from places
This commit is contained in:
@@ -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(¬e, &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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
1320
src/model/palette.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
("F1–F7", "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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user