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