chain word and better save/load UI
This commit is contained in:
@@ -79,4 +79,5 @@ pub enum Op {
|
|||||||
Ramp,
|
Ramp,
|
||||||
Range,
|
Range,
|
||||||
Noise,
|
Noise,
|
||||||
|
Chain,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub struct ExecutionTrace {
|
|||||||
pub struct StepContext {
|
pub struct StepContext {
|
||||||
pub step: usize,
|
pub step: usize,
|
||||||
pub beat: f64,
|
pub beat: f64,
|
||||||
|
pub bank: usize,
|
||||||
pub pattern: usize,
|
pub pattern: usize,
|
||||||
pub tempo: f64,
|
pub tempo: f64,
|
||||||
pub phase: f64,
|
pub phase: f64,
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ impl Forth {
|
|||||||
let val = match name.as_str() {
|
let val = match name.as_str() {
|
||||||
"step" => Value::Int(ctx.step as i64, None),
|
"step" => Value::Int(ctx.step as i64, None),
|
||||||
"beat" => Value::Float(ctx.beat, None),
|
"beat" => Value::Float(ctx.beat, None),
|
||||||
|
"bank" => Value::Int(ctx.bank as i64, None),
|
||||||
"pattern" => Value::Int(ctx.pattern as i64, None),
|
"pattern" => Value::Int(ctx.pattern as i64, None),
|
||||||
"tempo" => Value::Float(ctx.tempo, None),
|
"tempo" => Value::Float(ctx.tempo, None),
|
||||||
"phase" => Value::Float(ctx.phase, None),
|
"phase" => Value::Float(ctx.phase, None),
|
||||||
@@ -628,6 +629,24 @@ impl Forth {
|
|||||||
.insert("__tempo__".to_string(), Value::Float(clamped, None));
|
.insert("__tempo__".to_string(), Value::Float(clamped, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Op::Chain => {
|
||||||
|
let pattern = stack.pop().ok_or("stack underflow")?.as_int()? - 1;
|
||||||
|
let bank = stack.pop().ok_or("stack underflow")?.as_int()? - 1;
|
||||||
|
if bank < 0 || pattern < 0 {
|
||||||
|
return Err("chain: bank and pattern must be >= 1".into());
|
||||||
|
}
|
||||||
|
if bank as usize == ctx.bank && pattern as usize == ctx.pattern {
|
||||||
|
// chaining to self is a no-op
|
||||||
|
} else {
|
||||||
|
let key = format!("__chain_{}_{}__", ctx.bank, ctx.pattern);
|
||||||
|
let val = format!("{bank}:{pattern}");
|
||||||
|
self.vars
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(key, Value::Str(val, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Op::ListStart => {
|
Op::ListStart => {
|
||||||
stack.push(Value::Marker);
|
stack.push(Value::Marker);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -428,6 +428,13 @@ pub const WORDS: &[Word] = &[
|
|||||||
example: "pattern => 0",
|
example: "pattern => 0",
|
||||||
compile: Context("pattern"),
|
compile: Context("pattern"),
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "pbank",
|
||||||
|
stack: "(-- n)",
|
||||||
|
desc: "Current pattern's bank index",
|
||||||
|
example: "pbank => 0",
|
||||||
|
compile: Context("bank"),
|
||||||
|
},
|
||||||
Word {
|
Word {
|
||||||
name: "tempo",
|
name: "tempo",
|
||||||
stack: "(-- f)",
|
stack: "(-- f)",
|
||||||
@@ -621,6 +628,13 @@ pub const WORDS: &[Word] = &[
|
|||||||
example: "140 tempo!",
|
example: "140 tempo!",
|
||||||
compile: Simple,
|
compile: Simple,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "chain",
|
||||||
|
stack: "(bank pattern --)",
|
||||||
|
desc: "Chain to bank/pattern (1-indexed) when current pattern ends",
|
||||||
|
example: "1 4 chain",
|
||||||
|
compile: Simple,
|
||||||
|
},
|
||||||
// Lists
|
// Lists
|
||||||
Word {
|
Word {
|
||||||
name: "[",
|
name: "[",
|
||||||
@@ -1550,6 +1564,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"ramp" => Op::Ramp,
|
"ramp" => Op::Ramp,
|
||||||
"range" => Op::Range,
|
"range" => Op::Range,
|
||||||
"noise" => Op::Noise,
|
"noise" => Op::Noise,
|
||||||
|
"chain" => Op::Chain,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
110
crates/ratatui/src/file_browser.rs
Normal file
110
crates/ratatui/src/file_browser.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use super::ModalFrame;
|
||||||
|
|
||||||
|
pub struct FileBrowserModal<'a> {
|
||||||
|
title: &'a str,
|
||||||
|
input: &'a str,
|
||||||
|
entries: &'a [(String, bool)],
|
||||||
|
selected: usize,
|
||||||
|
scroll_offset: usize,
|
||||||
|
border_color: Color,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FileBrowserModal<'a> {
|
||||||
|
pub fn new(title: &'a str, input: &'a str, entries: &'a [(String, bool)]) -> Self {
|
||||||
|
Self {
|
||||||
|
title,
|
||||||
|
input,
|
||||||
|
entries,
|
||||||
|
selected: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
border_color: Color::White,
|
||||||
|
width: 60,
|
||||||
|
height: 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(mut self, idx: usize) -> Self {
|
||||||
|
self.selected = idx;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_offset(mut self, offset: usize) -> Self {
|
||||||
|
self.scroll_offset = offset;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn border_color(mut self, c: Color) -> Self {
|
||||||
|
self.border_color = c;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(mut self, w: u16) -> Self {
|
||||||
|
self.width = w;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(mut self, h: u16) -> Self {
|
||||||
|
self.height = h;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
||||||
|
let inner = ModalFrame::new(self.title)
|
||||||
|
.width(self.width)
|
||||||
|
.height(self.height)
|
||||||
|
.border_color(self.border_color)
|
||||||
|
.render_centered(frame, term);
|
||||||
|
|
||||||
|
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||||
|
|
||||||
|
// Input line
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled(self.input, Style::new().fg(Color::Cyan)),
|
||||||
|
Span::styled("█", Style::new().fg(Color::White)),
|
||||||
|
])),
|
||||||
|
rows[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Entries list
|
||||||
|
let visible_height = rows[1].height as usize;
|
||||||
|
let visible_entries = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.skip(self.scroll_offset)
|
||||||
|
.take(visible_height);
|
||||||
|
|
||||||
|
let lines: Vec<Line> = visible_entries
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (name, is_dir))| {
|
||||||
|
let abs_idx = i + self.scroll_offset;
|
||||||
|
let is_selected = abs_idx == self.selected;
|
||||||
|
let prefix = if is_selected { "> " } else { " " };
|
||||||
|
let display = if *is_dir {
|
||||||
|
format!("{prefix}{name}/")
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{name}")
|
||||||
|
};
|
||||||
|
let color = if is_selected {
|
||||||
|
Color::Yellow
|
||||||
|
} else if *is_dir {
|
||||||
|
Color::Blue
|
||||||
|
} else {
|
||||||
|
Color::White
|
||||||
|
};
|
||||||
|
Line::from(Span::styled(display, Style::new().fg(color)))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(lines), rows[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
mod confirm;
|
mod confirm;
|
||||||
|
mod file_browser;
|
||||||
|
mod list_select;
|
||||||
mod modal;
|
mod modal;
|
||||||
mod scope;
|
mod scope;
|
||||||
mod spectrum;
|
mod spectrum;
|
||||||
@@ -6,6 +8,8 @@ mod text_input;
|
|||||||
mod vu_meter;
|
mod vu_meter;
|
||||||
|
|
||||||
pub use confirm::ConfirmModal;
|
pub use confirm::ConfirmModal;
|
||||||
|
pub use file_browser::FileBrowserModal;
|
||||||
|
pub use list_select::ListSelect;
|
||||||
pub use modal::ModalFrame;
|
pub use modal::ModalFrame;
|
||||||
pub use scope::{Orientation, Scope};
|
pub use scope::{Orientation, Scope};
|
||||||
pub use spectrum::Spectrum;
|
pub use spectrum::Spectrum;
|
||||||
|
|||||||
103
crates/ratatui/src/list_select.rs
Normal file
103
crates/ratatui/src/list_select.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
pub struct ListSelect<'a> {
|
||||||
|
items: &'a [String],
|
||||||
|
selected: usize,
|
||||||
|
cursor: usize,
|
||||||
|
focused: bool,
|
||||||
|
visible_count: usize,
|
||||||
|
scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ListSelect<'a> {
|
||||||
|
pub fn new(items: &'a [String], selected: usize, cursor: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
selected,
|
||||||
|
cursor,
|
||||||
|
focused: false,
|
||||||
|
visible_count: 5,
|
||||||
|
scroll_offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focused(mut self, focused: bool) -> Self {
|
||||||
|
self.focused = focused;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_offset(mut self, offset: usize) -> Self {
|
||||||
|
self.scroll_offset = offset;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_count(mut self, n: usize) -> Self {
|
||||||
|
self.visible_count = n;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> u16 {
|
||||||
|
let item_lines = self.items.len().min(self.visible_count) as u16;
|
||||||
|
if self.items.len() > self.visible_count {
|
||||||
|
item_lines + 1
|
||||||
|
} else {
|
||||||
|
item_lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(self, frame: &mut Frame, area: Rect) {
|
||||||
|
let cursor_style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
|
let selected_style = Style::new().fg(Color::Cyan);
|
||||||
|
let normal_style = Style::default();
|
||||||
|
let indicator_style = Style::new().fg(Color::DarkGray);
|
||||||
|
|
||||||
|
let visible_end = (self.scroll_offset + self.visible_count).min(self.items.len());
|
||||||
|
let has_above = self.scroll_offset > 0;
|
||||||
|
let has_below = visible_end < self.items.len();
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for i in self.scroll_offset..visible_end {
|
||||||
|
let name = &self.items[i];
|
||||||
|
let is_cursor = self.focused && i == self.cursor;
|
||||||
|
let is_selected = i == self.selected;
|
||||||
|
|
||||||
|
let style = if is_cursor {
|
||||||
|
cursor_style
|
||||||
|
} else if is_selected {
|
||||||
|
selected_style
|
||||||
|
} else {
|
||||||
|
normal_style
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = if is_selected { "● " } else { " " };
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(prefix.to_string(), style),
|
||||||
|
Span::styled(name.clone(), style),
|
||||||
|
];
|
||||||
|
|
||||||
|
if has_above && i == self.scroll_offset {
|
||||||
|
spans.push(Span::styled(" ▲", indicator_style));
|
||||||
|
} else if has_below && i == visible_end - 1 {
|
||||||
|
spans.push(Span::styled(" ▼", indicator_style));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.items.len() > self.visible_count {
|
||||||
|
let position = self.cursor + 1;
|
||||||
|
let total = self.items.len();
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" ({position}/{total})"),
|
||||||
|
indicator_style,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(lines), area);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,6 +279,7 @@ impl App {
|
|||||||
let ctx = StepContext {
|
let ctx = StepContext {
|
||||||
step: step_idx,
|
step: step_idx,
|
||||||
beat: link.beat(),
|
beat: link.beat(),
|
||||||
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
tempo: link.tempo(),
|
tempo: link.tempo(),
|
||||||
phase: link.phase(),
|
phase: link.phase(),
|
||||||
@@ -353,6 +354,7 @@ impl App {
|
|||||||
let ctx = StepContext {
|
let ctx = StepContext {
|
||||||
step: step_idx,
|
step: step_idx,
|
||||||
beat: 0.0,
|
beat: 0.0,
|
||||||
|
bank,
|
||||||
pattern,
|
pattern,
|
||||||
tempo: link.tempo(),
|
tempo: link.tempo(),
|
||||||
phase: 0.0,
|
phase: 0.0,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use super::LinkState;
|
use super::LinkState;
|
||||||
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
||||||
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Variables};
|
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
|
||||||
use crate::state::LiveKeyState;
|
use crate::state::LiveKeyState;
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
@@ -367,10 +367,13 @@ fn sequencer_loop(
|
|||||||
step_traces.retain(|&(bank, pattern, _), _| {
|
step_traces.retain(|&(bank, pattern, _), _| {
|
||||||
bank != id.bank || pattern != id.pattern
|
bank != id.bank || pattern != id.pattern
|
||||||
});
|
});
|
||||||
|
let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern);
|
||||||
|
variables.lock().unwrap().remove(&chain_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let prev_beat = audio_state.prev_beat;
|
let prev_beat = audio_state.prev_beat;
|
||||||
|
let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new();
|
||||||
|
|
||||||
for (_id, active) in audio_state.active_patterns.iter_mut() {
|
for (_id, active) in audio_state.active_patterns.iter_mut() {
|
||||||
let Some(pattern) = pattern_cache.get(active.bank, active.pattern) else {
|
let Some(pattern) = pattern_cache.get(active.bank, active.pattern) else {
|
||||||
@@ -397,6 +400,7 @@ fn sequencer_loop(
|
|||||||
let ctx = StepContext {
|
let ctx = StepContext {
|
||||||
step: step_idx,
|
step: step_idx,
|
||||||
beat,
|
beat,
|
||||||
|
bank: active.bank,
|
||||||
pattern: active.pattern,
|
pattern: active.pattern,
|
||||||
tempo,
|
tempo,
|
||||||
phase: beat % quantum,
|
phase: beat % quantum,
|
||||||
@@ -440,11 +444,44 @@ fn sequencer_loop(
|
|||||||
let next_step = active.step_index + 1;
|
let next_step = active.step_index + 1;
|
||||||
if next_step >= pattern.length {
|
if next_step >= pattern.length {
|
||||||
active.iter += 1;
|
active.iter += 1;
|
||||||
|
let chain_key = format!("__chain_{}_{}__", active.bank, active.pattern);
|
||||||
|
let chain_target = {
|
||||||
|
let vars = variables.lock().unwrap();
|
||||||
|
vars.get(&chain_key).and_then(|v| {
|
||||||
|
if let Value::Str(s, _) = v {
|
||||||
|
let parts: Vec<&str> = s.split(':').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let b = parts[0].parse::<usize>().ok()?;
|
||||||
|
let p = parts[1].parse::<usize>().ok()?;
|
||||||
|
Some(PatternId { bank: b, pattern: p })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(target) = chain_target {
|
||||||
|
let source = PatternId { bank: active.bank, pattern: active.pattern };
|
||||||
|
chain_transitions.push((source, target));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
active.step_index = next_step % pattern.length;
|
active.step_index = next_step % pattern.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (source, target) in chain_transitions {
|
||||||
|
if !audio_state.pending_stops.contains(&source) {
|
||||||
|
audio_state.pending_stops.push(source);
|
||||||
|
}
|
||||||
|
if !audio_state.pending_starts.contains(&target) {
|
||||||
|
audio_state.pending_starts.push(target);
|
||||||
|
}
|
||||||
|
let chain_key = format!("__chain_{}_{}__", source.bank, source.pattern);
|
||||||
|
variables.lock().unwrap().remove(&chain_key);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut state = shared_state.lock().unwrap();
|
let mut state = shared_state.lock().unwrap();
|
||||||
state.active_patterns = audio_state
|
state.active_patterns = audio_state
|
||||||
|
|||||||
129
src/input.rs
129
src/input.rs
@@ -190,31 +190,32 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Modal::SaveAs(path) => match key.code {
|
Modal::FileBrowser(state) => match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let save_path = PathBuf::from(path.as_str());
|
use crate::state::file_browser::FileBrowserMode;
|
||||||
ctx.dispatch(AppCommand::CloseModal);
|
let mode = state.mode.clone();
|
||||||
ctx.dispatch(AppCommand::Save(save_path));
|
if let Some(path) = state.confirm() {
|
||||||
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
|
match mode {
|
||||||
|
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
|
||||||
|
FileBrowserMode::Load => {
|
||||||
|
ctx.dispatch(AppCommand::Load(path));
|
||||||
|
load_project_samples(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||||
KeyCode::Backspace => {
|
KeyCode::Tab => state.autocomplete(),
|
||||||
path.pop();
|
KeyCode::Left => state.go_up(),
|
||||||
|
KeyCode::Right => state.enter_selected(),
|
||||||
|
KeyCode::Up => state.select_prev(12),
|
||||||
|
KeyCode::Down => state.select_next(12),
|
||||||
|
KeyCode::Backspace => state.backspace(),
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
state.input.push(c);
|
||||||
|
state.refresh_entries();
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => path.push(c),
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
Modal::LoadFrom(path) => match key.code {
|
|
||||||
KeyCode::Enter => {
|
|
||||||
let load_path = PathBuf::from(path.as_str());
|
|
||||||
ctx.dispatch(AppCommand::CloseModal);
|
|
||||||
ctx.dispatch(AppCommand::Load(load_path));
|
|
||||||
load_project_samples(ctx);
|
|
||||||
}
|
|
||||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
path.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => path.push(c),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Modal::RenameBank { bank, name } => match key.code {
|
Modal::RenameBank { bank, name } => match key.code {
|
||||||
@@ -438,13 +439,23 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
|||||||
KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)),
|
KeyCode::Enter => ctx.dispatch(AppCommand::OpenModal(Modal::Editor)),
|
||||||
KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep),
|
KeyCode::Char('t') => ctx.dispatch(AppCommand::ToggleStep),
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') => {
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::SaveAs(String::new())));
|
use crate::state::file_browser::FileBrowserState;
|
||||||
|
let initial = ctx
|
||||||
|
.app
|
||||||
|
.project_state
|
||||||
|
.file_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let state = FileBrowserState::new_save(initial);
|
||||||
|
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state)));
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep),
|
KeyCode::Char('c') if ctrl => ctx.dispatch(AppCommand::CopyStep),
|
||||||
KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep),
|
KeyCode::Char('v') if ctrl => ctx.dispatch(AppCommand::PasteStep),
|
||||||
KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep),
|
KeyCode::Char('b') if ctrl => ctx.dispatch(AppCommand::LinkPasteStep),
|
||||||
KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep),
|
KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenStep),
|
||||||
KeyCode::Char('l') => {
|
KeyCode::Char('l') => {
|
||||||
|
use crate::state::file_browser::FileBrowserState;
|
||||||
let default_dir = ctx
|
let default_dir = ctx
|
||||||
.app
|
.app
|
||||||
.project_state
|
.project_state
|
||||||
@@ -459,7 +470,8 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
|||||||
s
|
s
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::LoadFrom(default_dir)));
|
let state = FileBrowserState::new_load(default_dir);
|
||||||
|
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(state)));
|
||||||
}
|
}
|
||||||
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
|
KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp),
|
||||||
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
|
KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown),
|
||||||
@@ -589,12 +601,62 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
selected: false,
|
selected: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => ctx.app.audio.prev_focus(),
|
KeyCode::Tab => ctx.app.audio.next_focus(),
|
||||||
KeyCode::Down | KeyCode::Char('j') => ctx.app.audio.next_focus(),
|
KeyCode::BackTab => ctx.app.audio.prev_focus(),
|
||||||
|
KeyCode::Up => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => ctx.app.audio.output_list.move_up(),
|
||||||
|
AudioFocus::InputDevice => ctx.app.audio.input_list.move_up(),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::Down => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => {
|
||||||
|
let count = ctx.app.audio.output_devices.len();
|
||||||
|
ctx.app.audio.output_list.move_down(count);
|
||||||
|
}
|
||||||
|
AudioFocus::InputDevice => {
|
||||||
|
let count = ctx.app.audio.input_devices.len();
|
||||||
|
ctx.app.audio.input_list.move_down(count);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::PageUp => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => ctx.app.audio.output_list.page_up(),
|
||||||
|
AudioFocus::InputDevice => ctx.app.audio.input_list.page_up(),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::PageDown => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => {
|
||||||
|
let count = ctx.app.audio.output_devices.len();
|
||||||
|
ctx.app.audio.output_list.page_down(count);
|
||||||
|
}
|
||||||
|
AudioFocus::InputDevice => {
|
||||||
|
let count = ctx.app.audio.input_devices.len();
|
||||||
|
ctx.app.audio.input_list.page_down(count);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
KeyCode::Enter => match ctx.app.audio.focus {
|
||||||
|
AudioFocus::OutputDevice => {
|
||||||
|
let cursor = ctx.app.audio.output_list.cursor;
|
||||||
|
if cursor < ctx.app.audio.output_devices.len() {
|
||||||
|
ctx.app.audio.config.output_device =
|
||||||
|
Some(ctx.app.audio.output_devices[cursor].name.clone());
|
||||||
|
ctx.app.save_settings(ctx.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioFocus::InputDevice => {
|
||||||
|
let cursor = ctx.app.audio.input_list.cursor;
|
||||||
|
if cursor < ctx.app.audio.input_devices.len() {
|
||||||
|
ctx.app.audio.config.input_device =
|
||||||
|
Some(ctx.app.audio.input_devices[cursor].name.clone());
|
||||||
|
ctx.app.save_settings(ctx.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
match ctx.app.audio.focus {
|
match ctx.app.audio.focus {
|
||||||
AudioFocus::OutputDevice => ctx.app.audio.prev_output_device(),
|
AudioFocus::OutputDevice | AudioFocus::InputDevice => {}
|
||||||
AudioFocus::InputDevice => ctx.app.audio.prev_input_device(),
|
|
||||||
AudioFocus::Channels => ctx.app.audio.adjust_channels(-1),
|
AudioFocus::Channels => ctx.app.audio.adjust_channels(-1),
|
||||||
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
||||||
AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(),
|
AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(),
|
||||||
@@ -614,14 +676,16 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||||
AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0),
|
AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0),
|
||||||
}
|
}
|
||||||
if ctx.app.audio.focus != AudioFocus::SamplePaths {
|
if !matches!(
|
||||||
|
ctx.app.audio.focus,
|
||||||
|
AudioFocus::SamplePaths | AudioFocus::OutputDevice | AudioFocus::InputDevice
|
||||||
|
) {
|
||||||
ctx.app.save_settings(ctx.link);
|
ctx.app.save_settings(ctx.link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
match ctx.app.audio.focus {
|
match ctx.app.audio.focus {
|
||||||
AudioFocus::OutputDevice => ctx.app.audio.next_output_device(),
|
AudioFocus::OutputDevice | AudioFocus::InputDevice => {}
|
||||||
AudioFocus::InputDevice => ctx.app.audio.next_input_device(),
|
|
||||||
AudioFocus::Channels => ctx.app.audio.adjust_channels(1),
|
AudioFocus::Channels => ctx.app.audio.adjust_channels(1),
|
||||||
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
||||||
AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(),
|
AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(),
|
||||||
@@ -641,7 +705,10 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||||
AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0),
|
AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0),
|
||||||
}
|
}
|
||||||
if ctx.app.audio.focus != AudioFocus::SamplePaths {
|
if !matches!(
|
||||||
|
ctx.app.audio.focus,
|
||||||
|
AudioFocus::SamplePaths | AudioFocus::OutputDevice | AudioFocus::InputDevice
|
||||||
|
) {
|
||||||
ctx.app.save_settings(ctx.link);
|
ctx.app.save_settings(ctx.link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ mod script;
|
|||||||
|
|
||||||
pub use cagire_forth::{Word, WordCompile, WORDS};
|
pub use cagire_forth::{Word, WordCompile, WORDS};
|
||||||
pub use cagire_project::{load, save, Bank, Pattern, PatternSpeed, Project, MAX_BANKS, MAX_PATTERNS};
|
pub use cagire_project::{load, save, Bank, Pattern, PatternSpeed, Project, MAX_BANKS, MAX_PATTERNS};
|
||||||
pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Variables};
|
pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use cagire_forth::Forth;
|
use cagire_forth::Forth;
|
||||||
|
|
||||||
pub use cagire_forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Variables};
|
pub use cagire_forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
|
||||||
|
|
||||||
pub struct ScriptEngine {
|
pub struct ScriptEngine {
|
||||||
forth: Forth,
|
forth: Forth,
|
||||||
|
|||||||
@@ -77,6 +77,45 @@ impl Default for AudioConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ListSelectState {
|
||||||
|
pub cursor: usize,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSelectState {
|
||||||
|
pub fn move_up(&mut self) {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
self.cursor -= 1;
|
||||||
|
if self.cursor < self.scroll_offset {
|
||||||
|
self.scroll_offset = self.cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_down(&mut self, item_count: usize) {
|
||||||
|
if self.cursor + 1 < item_count {
|
||||||
|
self.cursor += 1;
|
||||||
|
if self.cursor >= self.scroll_offset + 5 {
|
||||||
|
self.scroll_offset = self.cursor - 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_up(&mut self) {
|
||||||
|
self.cursor = self.cursor.saturating_sub(5);
|
||||||
|
if self.cursor < self.scroll_offset {
|
||||||
|
self.scroll_offset = self.cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_down(&mut self, item_count: usize) {
|
||||||
|
self.cursor = (self.cursor + 5).min(item_count.saturating_sub(1));
|
||||||
|
if self.cursor >= self.scroll_offset + 5 {
|
||||||
|
self.scroll_offset = self.cursor.saturating_sub(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum AudioFocus {
|
pub enum AudioFocus {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -127,6 +166,8 @@ pub struct AudioSettings {
|
|||||||
pub focus: AudioFocus,
|
pub focus: AudioFocus,
|
||||||
pub output_devices: Vec<AudioDeviceInfo>,
|
pub output_devices: Vec<AudioDeviceInfo>,
|
||||||
pub input_devices: Vec<AudioDeviceInfo>,
|
pub input_devices: Vec<AudioDeviceInfo>,
|
||||||
|
pub output_list: ListSelectState,
|
||||||
|
pub input_list: ListSelectState,
|
||||||
pub restart_pending: bool,
|
pub restart_pending: bool,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -138,6 +179,8 @@ impl Default for AudioSettings {
|
|||||||
focus: AudioFocus::default(),
|
focus: AudioFocus::default(),
|
||||||
output_devices: doux::audio::list_output_devices(),
|
output_devices: doux::audio::list_output_devices(),
|
||||||
input_devices: doux::audio::list_input_devices(),
|
input_devices: doux::audio::list_input_devices(),
|
||||||
|
output_list: ListSelectState { cursor: 0, scroll_offset: 0 },
|
||||||
|
input_list: ListSelectState { cursor: 0, scroll_offset: 0 },
|
||||||
restart_pending: false,
|
restart_pending: false,
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
@@ -184,25 +227,7 @@ impl AudioSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_output_device(&mut self) {
|
pub fn current_output_device_index(&self) -> usize {
|
||||||
if self.output_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_output_device_index();
|
|
||||||
let next_idx = (current_idx + 1) % self.output_devices.len();
|
|
||||||
self.config.output_device = Some(self.output_devices[next_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_output_device(&mut self) {
|
|
||||||
if self.output_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_output_device_index();
|
|
||||||
let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len();
|
|
||||||
self.config.output_device = Some(self.output_devices[prev_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_output_device_index(&self) -> usize {
|
|
||||||
match &self.config.output_device {
|
match &self.config.output_device {
|
||||||
Some(name) => self
|
Some(name) => self
|
||||||
.output_devices
|
.output_devices
|
||||||
@@ -217,25 +242,7 @@ impl AudioSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_input_device(&mut self) {
|
pub fn current_input_device_index(&self) -> usize {
|
||||||
if self.input_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_input_device_index();
|
|
||||||
let next_idx = (current_idx + 1) % self.input_devices.len();
|
|
||||||
self.config.input_device = Some(self.input_devices[next_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev_input_device(&mut self) {
|
|
||||||
if self.input_devices.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let current_idx = self.current_input_device_index();
|
|
||||||
let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len();
|
|
||||||
self.config.input_device = Some(self.input_devices[prev_idx].name.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_input_device_index(&self) -> usize {
|
|
||||||
match &self.config.input_device {
|
match &self.config.input_device {
|
||||||
Some(name) => self
|
Some(name) => self
|
||||||
.input_devices
|
.input_devices
|
||||||
@@ -264,29 +271,6 @@ impl AudioSettings {
|
|||||||
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_output_device_name(&self) -> &str {
|
|
||||||
match &self.config.output_device {
|
|
||||||
Some(name) => name,
|
|
||||||
None => self
|
|
||||||
.output_devices
|
|
||||||
.iter()
|
|
||||||
.find(|d| d.is_default)
|
|
||||||
.map(|d| d.name.as_str())
|
|
||||||
.unwrap_or("Default"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn current_input_device_name(&self) -> &str {
|
|
||||||
match &self.config.input_device {
|
|
||||||
Some(name) => name,
|
|
||||||
None => self
|
|
||||||
.input_devices
|
|
||||||
.iter()
|
|
||||||
.find(|d| d.is_default)
|
|
||||||
.map(|d| d.name.as_str())
|
|
||||||
.unwrap_or("None"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_sample_path(&mut self, path: PathBuf) {
|
pub fn add_sample_path(&mut self, path: PathBuf) {
|
||||||
if !self.config.sample_paths.contains(&path) {
|
if !self.config.sample_paths.contains(&path) {
|
||||||
|
|||||||
266
src/state/file_browser.rs
Normal file
266
src/state/file_browser.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub enum FileBrowserMode {
|
||||||
|
Save,
|
||||||
|
Load,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct DirEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct FileBrowserState {
|
||||||
|
pub mode: FileBrowserMode,
|
||||||
|
pub input: String,
|
||||||
|
pub entries: Vec<DirEntry>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileBrowserState {
|
||||||
|
pub fn new_save(initial_path: String) -> Self {
|
||||||
|
let mut state = Self {
|
||||||
|
mode: FileBrowserMode::Save,
|
||||||
|
input: initial_path,
|
||||||
|
entries: Vec::new(),
|
||||||
|
selected: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
};
|
||||||
|
state.refresh_entries();
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_load(initial_path: String) -> Self {
|
||||||
|
let mut state = Self {
|
||||||
|
mode: FileBrowserMode::Load,
|
||||||
|
input: initial_path,
|
||||||
|
entries: Vec::new(),
|
||||||
|
selected: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
};
|
||||||
|
state.refresh_entries();
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_dir(&self) -> PathBuf {
|
||||||
|
if self.input.is_empty() {
|
||||||
|
return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||||
|
}
|
||||||
|
let path = Path::new(&self.input);
|
||||||
|
if self.input.ends_with('/') {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
match path.parent() {
|
||||||
|
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
|
||||||
|
_ => std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn partial_name(&self) -> &str {
|
||||||
|
if self.input.is_empty() || self.input.ends_with('/') {
|
||||||
|
""
|
||||||
|
} else if !self.input.contains('/') {
|
||||||
|
&self.input
|
||||||
|
} else {
|
||||||
|
Path::new(&self.input)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_entries(&mut self) {
|
||||||
|
let dir = self.current_dir();
|
||||||
|
let prefix = self.partial_name().to_lowercase();
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
if prefix.is_empty() && dir.parent().is_some() {
|
||||||
|
entries.push(DirEntry {
|
||||||
|
name: "..".to_string(),
|
||||||
|
is_dir: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(read_dir) = fs::read_dir(&dir) {
|
||||||
|
for entry in read_dir.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||||
|
if prefix.is_empty() || name.to_lowercase().starts_with(&prefix) {
|
||||||
|
entries.push(DirEntry { name, is_dir });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort_by(|a, b| match (a.name.as_str(), b.name.as_str()) {
|
||||||
|
("..", _) => Ordering::Less,
|
||||||
|
(_, "..") => Ordering::Greater,
|
||||||
|
_ => match (a.is_dir, b.is_dir) {
|
||||||
|
(true, false) => Ordering::Less,
|
||||||
|
(false, true) => Ordering::Greater,
|
||||||
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
self.entries = entries;
|
||||||
|
self.selected = 0;
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn autocomplete(&mut self) {
|
||||||
|
let real_entries: Vec<&DirEntry> =
|
||||||
|
self.entries.iter().filter(|e| e.name != "..").collect();
|
||||||
|
if real_entries.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lcp = longest_common_prefix(&real_entries);
|
||||||
|
if lcp.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = self.current_dir();
|
||||||
|
let mut new_input = dir.join(&lcp).display().to_string();
|
||||||
|
|
||||||
|
if real_entries.len() == 1 && real_entries[0].is_dir && !new_input.ends_with('/') {
|
||||||
|
new_input.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_input != self.input {
|
||||||
|
self.input = new_input;
|
||||||
|
self.refresh_entries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_next(&mut self, visible_height: usize) {
|
||||||
|
if self.selected + 1 < self.entries.len() {
|
||||||
|
self.selected += 1;
|
||||||
|
if self.selected >= self.scroll_offset + visible_height {
|
||||||
|
self.scroll_offset = self.selected + 1 - visible_height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_prev(&mut self, _visible_height: usize) {
|
||||||
|
if self.selected > 0 {
|
||||||
|
self.selected -= 1;
|
||||||
|
if self.selected < self.scroll_offset {
|
||||||
|
self.scroll_offset = self.selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn confirm(&mut self) -> Option<PathBuf> {
|
||||||
|
if !self.entries.is_empty() {
|
||||||
|
let entry = &self.entries[self.selected];
|
||||||
|
if entry.is_dir {
|
||||||
|
self.navigate_to_selected();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = self.current_dir().join(&entry.name);
|
||||||
|
if self.mode == FileBrowserMode::Save {
|
||||||
|
ensure_parent_dirs(&path);
|
||||||
|
}
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mode == FileBrowserMode::Save && !self.input.is_empty() {
|
||||||
|
let path = PathBuf::from(&self.input);
|
||||||
|
ensure_parent_dirs(&path);
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enter_selected(&mut self) {
|
||||||
|
if let Some(entry) = self.entries.get(self.selected) {
|
||||||
|
if entry.is_dir {
|
||||||
|
self.navigate_to_selected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_up(&mut self) {
|
||||||
|
let dir = self.current_dir();
|
||||||
|
if let Some(parent) = dir.parent() {
|
||||||
|
self.input = format_dir_path(parent);
|
||||||
|
self.refresh_entries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backspace(&mut self) {
|
||||||
|
if self.input.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.input.ends_with('/') && self.input.len() > 1 {
|
||||||
|
let trimmed = &self.input[..self.input.len() - 1];
|
||||||
|
match trimmed.rfind('/') {
|
||||||
|
Some(pos) => self.input = trimmed[..=pos].to_string(),
|
||||||
|
None => self.input.clear(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.input.pop();
|
||||||
|
}
|
||||||
|
self.refresh_entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_to_selected(&mut self) {
|
||||||
|
let entry = &self.entries[self.selected];
|
||||||
|
if entry.name == ".." {
|
||||||
|
self.go_up();
|
||||||
|
} else {
|
||||||
|
let dir = self.current_dir();
|
||||||
|
self.input = format_dir_path(&dir.join(&entry.name));
|
||||||
|
self.refresh_entries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_dir_path(path: &Path) -> String {
|
||||||
|
let mut s = path.display().to_string();
|
||||||
|
if !s.ends_with('/') {
|
||||||
|
s.push('/');
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_parent_dirs(path: &Path) {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.exists() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn longest_common_prefix(entries: &[&DirEntry]) -> String {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let first = &entries[0].name;
|
||||||
|
let mut len = first.len();
|
||||||
|
for entry in &entries[1..] {
|
||||||
|
len = first
|
||||||
|
.chars()
|
||||||
|
.zip(entry.name.chars())
|
||||||
|
.take_while(|(a, b)| a.to_lowercase().eq(b.to_lowercase()))
|
||||||
|
.count()
|
||||||
|
.min(len);
|
||||||
|
}
|
||||||
|
first[..first
|
||||||
|
.char_indices()
|
||||||
|
.nth(len)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(first.len())]
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod file_browser;
|
||||||
pub mod live_keys;
|
pub mod live_keys;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod patterns_nav;
|
pub mod patterns_nav;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::state::editor::PatternField;
|
use crate::state::editor::PatternField;
|
||||||
|
use crate::state::file_browser::FileBrowserState;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
pub enum Modal {
|
pub enum Modal {
|
||||||
@@ -21,8 +22,7 @@ pub enum Modal {
|
|||||||
bank: usize,
|
bank: usize,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
},
|
},
|
||||||
SaveAs(String),
|
FileBrowser(FileBrowserState),
|
||||||
LoadFrom(String),
|
|
||||||
RenameBank {
|
RenameBank {
|
||||||
bank: usize,
|
bank: usize,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use cagire_ratatui::ListSelect;
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -44,8 +45,10 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
height: inner.height.saturating_sub(1),
|
height: inner.height.saturating_sub(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let devices_height = devices_section_height(app);
|
||||||
|
|
||||||
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
||||||
Constraint::Length(4),
|
Constraint::Length(devices_height),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(8),
|
Constraint::Length(8),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -58,39 +61,57 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
render_samples(frame, app, samples_area);
|
render_samples(frame, app, samples_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_height(item_count: usize) -> u16 {
|
||||||
|
let visible = item_count.min(5) as u16;
|
||||||
|
if item_count > 5 { visible + 1 } else { visible }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn devices_section_height(app: &App) -> u16 {
|
||||||
|
// header(1) + "Output" label(1) + output list + "Input" label(1) + input list
|
||||||
|
1 + 1 + list_height(app.audio.output_devices.len())
|
||||||
|
+ 1 + list_height(app.audio.input_devices.len())
|
||||||
|
}
|
||||||
|
|
||||||
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let header_style = Style::new()
|
let header_style = Style::new()
|
||||||
.fg(Color::Rgb(100, 160, 180))
|
.fg(Color::Rgb(100, 160, 180))
|
||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
let [header_area, content_area] =
|
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
|
||||||
|
|
||||||
frame.render_widget(Paragraph::new("Devices").style(header_style), header_area);
|
|
||||||
|
|
||||||
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
|
||||||
let normal = Style::new().fg(Color::White);
|
|
||||||
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||||
|
|
||||||
let output_name = truncate_name(app.audio.current_output_device_name(), 35);
|
let output_h = list_height(app.audio.output_devices.len());
|
||||||
let input_name = truncate_name(app.audio.current_input_device_name(), 35);
|
let input_h = list_height(app.audio.input_devices.len());
|
||||||
|
|
||||||
let output_focused = app.audio.focus == AudioFocus::OutputDevice;
|
let [header_area, output_label_area, output_list_area, input_label_area, input_list_area] =
|
||||||
let input_focused = app.audio.focus == AudioFocus::InputDevice;
|
Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(output_h),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(input_h),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
let rows = vec![
|
frame.render_widget(Paragraph::new("Devices").style(header_style), header_area);
|
||||||
Row::new(vec![
|
frame.render_widget(Paragraph::new(Span::styled("Output", label_style)), output_label_area);
|
||||||
Span::styled("Output", label_style),
|
frame.render_widget(Paragraph::new(Span::styled("Input", label_style)), input_label_area);
|
||||||
render_selector(&output_name, output_focused, highlight, normal),
|
|
||||||
]),
|
|
||||||
Row::new(vec![
|
|
||||||
Span::styled("Input", label_style),
|
|
||||||
render_selector(&input_name, input_focused, highlight, normal),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]);
|
let output_items: Vec<String> = app.audio.output_devices.iter()
|
||||||
frame.render_widget(table, content_area);
|
.map(|d| truncate_name(&d.name, 35))
|
||||||
|
.collect();
|
||||||
|
let output_selected = app.audio.current_output_device_index();
|
||||||
|
ListSelect::new(&output_items, output_selected, app.audio.output_list.cursor)
|
||||||
|
.focused(app.audio.focus == AudioFocus::OutputDevice)
|
||||||
|
.scroll_offset(app.audio.output_list.scroll_offset)
|
||||||
|
.render(frame, output_list_area);
|
||||||
|
|
||||||
|
let input_items: Vec<String> = app.audio.input_devices.iter()
|
||||||
|
.map(|d| truncate_name(&d.name, 35))
|
||||||
|
.collect();
|
||||||
|
let input_selected = app.audio.current_input_device_index();
|
||||||
|
ListSelect::new(&input_items, input_selected, app.audio.input_list.cursor)
|
||||||
|
.focused(app.audio.focus == AudioFocus::InputDevice)
|
||||||
|
.scroll_offset(app.audio.input_list.scroll_offset)
|
||||||
|
.render(frame, input_list_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
|||||||
@@ -316,16 +316,24 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
|
ConfirmModal::new("Confirm", &format!("Reset bank {}?", bank + 1), *selected)
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term);
|
||||||
}
|
}
|
||||||
Modal::SaveAs(path) => {
|
Modal::FileBrowser(state) => {
|
||||||
TextInputModal::new("Save As (Enter to confirm, Esc to cancel)", path)
|
use crate::state::file_browser::FileBrowserMode;
|
||||||
|
use crate::widgets::FileBrowserModal;
|
||||||
|
let (title, border_color) = match state.mode {
|
||||||
|
FileBrowserMode::Save => ("Save As", Color::Green),
|
||||||
|
FileBrowserMode::Load => ("Load From", Color::Blue),
|
||||||
|
};
|
||||||
|
let entries: Vec<(String, bool)> = state
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.name.clone(), e.is_dir))
|
||||||
|
.collect();
|
||||||
|
FileBrowserModal::new(title, &state.input, &entries)
|
||||||
|
.selected(state.selected)
|
||||||
|
.scroll_offset(state.scroll_offset)
|
||||||
|
.border_color(border_color)
|
||||||
.width(60)
|
.width(60)
|
||||||
.border_color(Color::Green)
|
.height(18)
|
||||||
.render_centered(frame, term);
|
|
||||||
}
|
|
||||||
Modal::LoadFrom(path) => {
|
|
||||||
TextInputModal::new("Load From (Enter to confirm, Esc to cancel)", path)
|
|
||||||
.width(60)
|
|
||||||
.border_color(Color::Blue)
|
|
||||||
.render_centered(frame, term);
|
.render_centered(frame, term);
|
||||||
}
|
}
|
||||||
Modal::RenameBank { bank, name } => {
|
Modal::RenameBank { bank, name } => {
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
pub use cagire_ratatui::{ConfirmModal, ModalFrame, Orientation, Scope, Spectrum, TextInputModal, VuMeter};
|
pub use cagire_ratatui::{
|
||||||
|
ConfirmModal, FileBrowserModal, ModalFrame, Orientation, Scope, Spectrum, TextInputModal,
|
||||||
|
VuMeter,
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub fn default_ctx() -> StepContext {
|
|||||||
StepContext {
|
StepContext {
|
||||||
step: 0,
|
step: 0,
|
||||||
beat: 0.0,
|
beat: 0.0,
|
||||||
|
bank: 0,
|
||||||
pattern: 0,
|
pattern: 0,
|
||||||
tempo: 120.0,
|
tempo: 120.0,
|
||||||
phase: 0.0,
|
phase: 0.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user