3 Commits

Author SHA1 Message Date
2be15d11f4 Mixed bag of things 2026-01-28 17:39:41 +01:00
5952807240 wip 2026-01-28 13:54:29 +01:00
0beed16c31 Help modal 2026-01-28 13:22:51 +01:00
17 changed files with 569 additions and 83 deletions

View File

@@ -1,80 +1,138 @@
use std::path::PathBuf; use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS}; use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum PatternSpeed { pub struct PatternSpeed {
Eighth, // 1/8x pub num: u8,
Quarter, // 1/4x pub denom: u8,
Half, // 1/2x
#[default]
Normal, // 1x
Double, // 2x
Quad, // 4x
Octo, // 8x
} }
impl PatternSpeed { impl PatternSpeed {
pub const EIGHTH: Self = Self { num: 1, denom: 8 };
pub const FIFTH: Self = Self { num: 1, denom: 5 };
pub const QUARTER: Self = Self { num: 1, denom: 4 };
pub const THIRD: Self = Self { num: 1, denom: 3 };
pub const HALF: Self = Self { num: 1, denom: 2 };
pub const TWO_THIRDS: Self = Self { num: 2, denom: 3 };
pub const NORMAL: Self = Self { num: 1, denom: 1 };
pub const DOUBLE: Self = Self { num: 2, denom: 1 };
pub const QUAD: Self = Self { num: 4, denom: 1 };
pub const OCTO: Self = Self { num: 8, denom: 1 };
const PRESETS: &[Self] = &[
Self::EIGHTH,
Self::FIFTH,
Self::QUARTER,
Self::THIRD,
Self::HALF,
Self::TWO_THIRDS,
Self::NORMAL,
Self::DOUBLE,
Self::QUAD,
Self::OCTO,
];
pub fn multiplier(&self) -> f64 { pub fn multiplier(&self) -> f64 {
match self { self.num as f64 / self.denom as f64
Self::Eighth => 0.125,
Self::Quarter => 0.25,
Self::Half => 0.5,
Self::Normal => 1.0,
Self::Double => 2.0,
Self::Quad => 4.0,
Self::Octo => 8.0,
}
} }
pub fn label(&self) -> &'static str { pub fn label(&self) -> String {
match self { if self.denom == 1 {
Self::Eighth => "1/8x", format!("{}x", self.num)
Self::Quarter => "1/4x", } else {
Self::Half => "1/2x", format!("{}/{}x", self.num, self.denom)
Self::Normal => "1x",
Self::Double => "2x",
Self::Quad => "4x",
Self::Octo => "8x",
} }
} }
pub fn next(&self) -> Self { pub fn next(&self) -> Self {
match self { let current = self.multiplier();
Self::Eighth => Self::Quarter, Self::PRESETS
Self::Quarter => Self::Half, .iter()
Self::Half => Self::Normal, .find(|p| p.multiplier() > current + 0.0001)
Self::Normal => Self::Double, .copied()
Self::Double => Self::Quad, .unwrap_or(*self)
Self::Quad => Self::Octo,
Self::Octo => Self::Octo,
}
} }
pub fn prev(&self) -> Self { pub fn prev(&self) -> Self {
match self { let current = self.multiplier();
Self::Eighth => Self::Eighth, Self::PRESETS
Self::Quarter => Self::Eighth, .iter()
Self::Half => Self::Quarter, .rev()
Self::Normal => Self::Half, .find(|p| p.multiplier() < current - 0.0001)
Self::Double => Self::Normal, .copied()
Self::Quad => Self::Double, .unwrap_or(*self)
Self::Octo => Self::Quad,
}
} }
pub fn from_label(s: &str) -> Option<Self> { pub fn from_label(s: &str) -> Option<Self> {
match s.trim() { let s = s.trim().trim_end_matches('x');
"1/8x" | "1/8" | "0.125x" => Some(Self::Eighth), if let Some((num, denom)) = s.split_once('/') {
"1/4x" | "1/4" | "0.25x" => Some(Self::Quarter), let num: u8 = num.parse().ok()?;
"1/2x" | "1/2" | "0.5x" => Some(Self::Half), let denom: u8 = denom.parse().ok()?;
"1x" | "1" => Some(Self::Normal), if denom == 0 {
"2x" | "2" => Some(Self::Double), return None;
"4x" | "4" => Some(Self::Quad), }
"8x" | "8" => Some(Self::Octo), return Some(Self { num, denom });
_ => None, }
if let Ok(val) = s.parse::<f64>() {
if val <= 0.0 || val > 255.0 {
return None;
}
if (val - val.round()).abs() < 0.0001 {
return Some(Self {
num: val.round() as u8,
denom: 1,
});
}
for denom in 1..=16u8 {
let num = val * denom as f64;
if (num - num.round()).abs() < 0.0001 && (1.0..=255.0).contains(&num) {
return Some(Self {
num: num.round() as u8,
denom,
});
}
}
}
None
}
}
impl Default for PatternSpeed {
fn default() -> Self {
Self::NORMAL
}
}
impl Serialize for PatternSpeed {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
(self.num, self.denom).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for PatternSpeed {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum SpeedFormat {
Tuple((u8, u8)),
Legacy(String),
}
match SpeedFormat::deserialize(deserializer)? {
SpeedFormat::Tuple((num, denom)) => Ok(Self { num, denom }),
SpeedFormat::Legacy(s) => Ok(match s.as_str() {
"Eighth" => Self::EIGHTH,
"Quarter" => Self::QUARTER,
"Half" => Self::HALF,
"Normal" => Self::NORMAL,
"Double" => Self::DOUBLE,
"Quad" => Self::QUAD,
"Octo" => Self::OCTO,
_ => Self::NORMAL,
}),
} }
} }
} }

View File

@@ -98,6 +98,7 @@ impl App {
show_scope: self.audio.config.show_scope, show_scope: self.audio.config.show_scope,
show_spectrum: self.audio.config.show_spectrum, show_spectrum: self.audio.config.show_spectrum,
show_completion: self.ui.show_completion, show_completion: self.ui.show_completion,
flash_brightness: self.ui.flash_brightness,
}, },
link: crate::settings::LinkSettings { link: crate::settings::LinkSettings {
enabled: link.is_enabled(), enabled: link.is_enabled(),

View File

@@ -1095,7 +1095,7 @@ mod tests {
let mut state = make_state(); let mut state = make_state();
let mut pat = simple_pattern(8); let mut pat = simple_pattern(8);
pat.speed = crate::model::PatternSpeed::Double; pat.speed = crate::model::PatternSpeed::DOUBLE;
state.tick(tick_with( state.tick(tick_with(
vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pat }], vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pat }],

View File

@@ -359,7 +359,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
))); )));
} else { } else {
ctx.dispatch(AppCommand::SetStatus( ctx.dispatch(AppCommand::SetStatus(
"Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string(), "Invalid speed (try 1/3, 2/5, 1x, 2x)".to_string(),
)); ));
} }
} }
@@ -572,6 +572,25 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
_ => {} _ => {}
} }
} }
Modal::KeybindingsHelp { scroll } => {
let bindings_count = crate::views::keybindings::bindings_for(ctx.app.page).len();
match key.code {
KeyCode::Esc | KeyCode::Char('?') => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
*scroll = (*scroll + 1).min(bindings_count.saturating_sub(1));
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(10);
}
KeyCode::PageDown => {
*scroll = (*scroll + 10).min(bindings_count.saturating_sub(1));
}
_ => {}
}
}
Modal::None => unreachable!(), Modal::None => unreachable!(),
} }
InputResult::Continue InputResult::Continue
@@ -845,6 +864,9 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
})); }));
} }
} }
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue
@@ -952,6 +974,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
} }
} }
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue
@@ -1106,6 +1131,9 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
"/sound/sine/dur/0.5/decay/0.2".into(), "/sound/sine/dur/0.5/decay/0.2".into(),
)); ));
} }
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue
@@ -1135,6 +1163,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
OptionsFocus::ShowCompletion => { OptionsFocus::ShowCompletion => {
ctx.app.ui.show_completion = !ctx.app.ui.show_completion ctx.app.ui.show_completion = !ctx.app.ui.show_completion
} }
OptionsFocus::FlashBrightness => {
let delta = if key.code == KeyCode::Left { -0.1 } else { 0.1 };
ctx.app.ui.flash_brightness =
(ctx.app.ui.flash_brightness + delta).clamp(0.0, 1.0);
}
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
OptionsFocus::StartStopSync => ctx OptionsFocus::StartStopSync => ctx
.link .link
@@ -1151,6 +1184,9 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
ctx.playing ctx.playing
.store(ctx.app.playback.playing, Ordering::Relaxed); .store(ctx.app.playback.playing, Ordering::Relaxed);
} }
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue
@@ -1184,6 +1220,9 @@ fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
selected: false, selected: false,
})); }));
} }
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue
@@ -1229,6 +1268,9 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
selected: false, selected: false,
})); }));
} }
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
_ => {} _ => {}
} }
InputResult::Continue InputResult::Continue

View File

@@ -94,6 +94,7 @@ fn main() -> io::Result<()> {
app.audio.config.show_scope = settings.display.show_scope; app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum; app.audio.config.show_spectrum = settings.display.show_spectrum;
app.ui.show_completion = settings.display.show_completion; app.ui.show_completion = settings.display.show_completion;
app.ui.flash_brightness = settings.display.flash_brightness;
let metrics = Arc::new(EngineMetrics::default()); let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new()); let scope_buffer = Arc::new(ScopeBuffer::new());
@@ -212,6 +213,13 @@ fn main() -> io::Result<()> {
app.metrics.event_count = seq_snapshot.event_count; app.metrics.event_count = seq_snapshot.event_count;
app.metrics.dropped_events = seq_snapshot.dropped_events; app.metrics.dropped_events = seq_snapshot.dropped_events;
app.ui.event_flash = (app.ui.event_flash - 0.1).max(0.0);
let new_events = app.metrics.event_count.saturating_sub(app.ui.last_event_count);
if new_events > 0 {
app.ui.event_flash = (new_events as f32 * 0.4).min(1.0);
}
app.ui.last_event_count = app.metrics.event_count;
app.flush_queued_changes(&sequencer.cmd_tx); app.flush_queued_changes(&sequencer.cmd_tx);
app.flush_dirty_patterns(&sequencer.cmd_tx); app.flush_dirty_patterns(&sequencer.cmd_tx);

View File

@@ -29,8 +29,12 @@ pub struct DisplaySettings {
pub show_spectrum: bool, pub show_spectrum: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub show_completion: bool, pub show_completion: bool,
#[serde(default = "default_flash_brightness")]
pub flash_brightness: f32,
} }
fn default_flash_brightness() -> f32 { 1.0 }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LinkSettings { pub struct LinkSettings {
pub enabled: bool, pub enabled: bool,
@@ -60,6 +64,7 @@ impl Default for DisplaySettings {
show_scope: true, show_scope: true,
show_spectrum: true, show_spectrum: true,
show_completion: true, show_completion: true,
flash_brightness: 1.0,
} }
} }
} }

View File

@@ -57,4 +57,7 @@ pub enum Modal {
quantization: LaunchQuantization, quantization: LaunchQuantization,
sync_mode: SyncMode, sync_mode: SyncMode,
}, },
KeybindingsHelp {
scroll: usize,
},
} }

View File

@@ -6,6 +6,7 @@ pub enum OptionsFocus {
ShowScope, ShowScope,
ShowSpectrum, ShowSpectrum,
ShowCompletion, ShowCompletion,
FlashBrightness,
LinkEnabled, LinkEnabled,
StartStopSync, StartStopSync,
Quantum, Quantum,
@@ -23,7 +24,8 @@ impl OptionsState {
OptionsFocus::RuntimeHighlight => OptionsFocus::ShowScope, OptionsFocus::RuntimeHighlight => OptionsFocus::ShowScope,
OptionsFocus::ShowScope => OptionsFocus::ShowSpectrum, OptionsFocus::ShowScope => OptionsFocus::ShowSpectrum,
OptionsFocus::ShowSpectrum => OptionsFocus::ShowCompletion, OptionsFocus::ShowSpectrum => OptionsFocus::ShowCompletion,
OptionsFocus::ShowCompletion => OptionsFocus::LinkEnabled, OptionsFocus::ShowCompletion => OptionsFocus::FlashBrightness,
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync, OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
OptionsFocus::StartStopSync => OptionsFocus::Quantum, OptionsFocus::StartStopSync => OptionsFocus::Quantum,
OptionsFocus::Quantum => OptionsFocus::RefreshRate, OptionsFocus::Quantum => OptionsFocus::RefreshRate,
@@ -37,7 +39,8 @@ impl OptionsState {
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight, OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
OptionsFocus::ShowSpectrum => OptionsFocus::ShowScope, OptionsFocus::ShowSpectrum => OptionsFocus::ShowScope,
OptionsFocus::ShowCompletion => OptionsFocus::ShowSpectrum, OptionsFocus::ShowCompletion => OptionsFocus::ShowSpectrum,
OptionsFocus::LinkEnabled => OptionsFocus::ShowCompletion, OptionsFocus::FlashBrightness => OptionsFocus::ShowCompletion,
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled, OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
OptionsFocus::Quantum => OptionsFocus::StartStopSync, OptionsFocus::Quantum => OptionsFocus::StartStopSync,
}; };

View File

@@ -38,6 +38,9 @@ pub struct UiState {
pub runtime_highlight: bool, pub runtime_highlight: bool,
pub show_completion: bool, pub show_completion: bool,
pub minimap_until: Option<Instant>, pub minimap_until: Option<Instant>,
pub last_event_count: usize,
pub event_flash: f32,
pub flash_brightness: f32,
} }
impl Default for UiState { impl Default for UiState {
@@ -61,6 +64,9 @@ impl Default for UiState {
runtime_highlight: false, runtime_highlight: false,
show_completion: true, show_completion: true,
minimap_until: None, minimap_until: None,
last_event_count: 0,
event_flash: 0.0,
flash_brightness: 1.0,
} }
} }
} }

91
src/views/keybindings.rs Normal file
View File

@@ -0,0 +1,91 @@
use crate::page::Page;
pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str)> {
let mut bindings = Vec::new();
// Global bindings
bindings.push(("Ctrl+←→↑↓", "Navigate", "Switch between views"));
bindings.push(("q", "Quit", "Quit application"));
bindings.push(("?", "Keybindings", "Show this help"));
// Page-specific bindings
match page {
Page::Main => {
bindings.push(("Space", "Play/Stop", "Toggle playback"));
bindings.push(("←→↑↓", "Navigate", "Move cursor between steps"));
bindings.push(("Shift+←→↑↓", "Select", "Extend selection"));
bindings.push(("Esc", "Clear", "Clear selection"));
bindings.push(("Enter", "Edit", "Open step editor"));
bindings.push(("t", "Toggle", "Toggle selected steps"));
bindings.push(("p", "Preview", "Preview step script"));
bindings.push(("Tab", "Samples", "Toggle sample browser"));
bindings.push(("Ctrl+C", "Copy", "Copy selected steps"));
bindings.push(("Ctrl+V", "Paste", "Paste steps"));
bindings.push(("Ctrl+B", "Link", "Paste as linked steps"));
bindings.push(("Ctrl+D", "Duplicate", "Duplicate selection"));
bindings.push(("Ctrl+H", "Harden", "Convert links to copies"));
bindings.push(("Del", "Delete", "Delete step(s)"));
bindings.push(("< >", "Length", "Decrease/increase pattern length"));
bindings.push(("[ ]", "Speed", "Decrease/increase pattern speed"));
bindings.push(("+ -", "Tempo", "Increase/decrease tempo"));
bindings.push(("T", "Set tempo", "Open tempo input"));
bindings.push(("L", "Set length", "Open length input"));
bindings.push(("S", "Set speed", "Open speed input"));
bindings.push(("s", "Save", "Save project"));
bindings.push(("l", "Load", "Load project"));
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
}
Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
bindings.push(("Enter", "Select", "Select pattern for editing"));
bindings.push(("Space", "Play", "Toggle pattern playback"));
bindings.push(("Esc", "Back", "Clear staged or go back"));
bindings.push(("c", "Commit", "Commit staged changes"));
bindings.push(("r", "Rename", "Rename bank/pattern"));
bindings.push(("e", "Properties", "Edit pattern properties"));
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
bindings.push(("Del", "Reset", "Reset bank/pattern"));
}
Page::Engine => {
bindings.push(("Tab", "Section", "Next section"));
bindings.push(("Shift+Tab", "Section", "Previous section"));
bindings.push(("←→", "Switch", "Switch device type or adjust setting"));
bindings.push(("↑↓", "Navigate", "Navigate list items"));
bindings.push(("PgUp/Dn", "Page", "Page through device list"));
bindings.push(("Enter", "Select", "Select device"));
bindings.push(("R", "Restart", "Restart audio engine"));
bindings.push(("A", "Add path", "Add sample path"));
bindings.push(("D", "Refresh/Del", "Refresh devices or delete path"));
bindings.push(("h", "Hush", "Stop all sounds gracefully"));
bindings.push(("p", "Panic", "Stop all sounds immediately"));
bindings.push(("r", "Reset", "Reset peak voice counter"));
bindings.push(("t", "Test", "Play test tone"));
}
Page::Options => {
bindings.push(("Tab", "Next", "Move to next option"));
bindings.push(("Shift+Tab", "Previous", "Move to previous option"));
bindings.push(("↑↓", "Navigate", "Navigate options"));
bindings.push(("←→", "Toggle", "Toggle or adjust option"));
bindings.push(("Space", "Play/Stop", "Toggle playback"));
}
Page::Help => {
bindings.push(("↑↓ j/k", "Scroll", "Scroll content"));
bindings.push(("Tab", "Topic", "Next topic"));
bindings.push(("Shift+Tab", "Topic", "Previous topic"));
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("/", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search"));
}
Page::Dict => {
bindings.push(("Tab", "Focus", "Toggle category/words focus"));
bindings.push(("↑↓ j/k", "Navigate", "Navigate items"));
bindings.push(("PgUp/Dn", "Page", "Page scroll"));
bindings.push(("/", "Search", "Activate search"));
bindings.push(("Ctrl+F", "Search", "Activate search"));
bindings.push(("Esc", "Clear", "Clear search"));
}
}
bindings
}

View File

@@ -41,7 +41,24 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
} }
render_sequencer(frame, app, snapshot, sequencer_area); render_sequencer(frame, app, snapshot, sequencer_area);
render_vu_meter(frame, app, vu_area);
// Calculate actual grid height to align VU meter
let pattern = app.current_edit_pattern();
let page = app.editor_ctx.step / STEPS_PER_PAGE;
let page_start = page * STEPS_PER_PAGE;
let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern.length) - page_start;
let num_rows = steps_on_page.div_ceil(8);
let spacing = num_rows.saturating_sub(1) as u16;
let row_height = sequencer_area.height.saturating_sub(spacing) / num_rows as u16;
let actual_grid_height = row_height * num_rows as u16 + spacing;
let aligned_vu_area = Rect {
y: sequencer_area.y,
height: actual_grid_height,
..vu_area
};
render_vu_meter(frame, app, aligned_vu_area);
} }
const STEPS_PER_PAGE: usize = 32; const STEPS_PER_PAGE: usize = 32;

View File

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

View File

@@ -29,7 +29,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
}; };
let [display_area, _, link_area, _, session_area] = Layout::vertical([ let [display_area, _, link_area, _, session_area] = Layout::vertical([
Constraint::Length(7), Constraint::Length(8),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(5), Constraint::Length(5),
Constraint::Length(1), Constraint::Length(1),
@@ -63,6 +63,7 @@ fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
); );
let focus = app.options.focus; let focus = app.options.focus;
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
let lines = vec![ let lines = vec![
render_option_line( render_option_line(
"Refresh rate", "Refresh rate",
@@ -93,6 +94,11 @@ fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
if app.ui.show_completion { "On" } else { "Off" }, if app.ui.show_completion { "On" } else { "Off" },
focus == OptionsFocus::ShowCompletion, focus == OptionsFocus::ShowCompletion,
), ),
render_option_line(
"Flash brightness",
&flash_str,
focus == OptionsFocus::FlashBrightness,
),
]; ];
frame.render_widget(Paragraph::new(lines), content_area); frame.render_widget(Paragraph::new(lines), content_area);

View File

@@ -287,7 +287,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
frame.render_widget(Paragraph::new(length_line), length_area); frame.render_widget(Paragraph::new(length_line), length_area);
// Column 3: speed (only if non-default) // Column 3: speed (only if non-default)
if speed != PatternSpeed::Normal { if speed != PatternSpeed::NORMAL {
let speed_line = Line::from(vec![ let speed_line = Line::from(vec![
Span::styled("Speed: ", bold_style), Span::styled("Speed: ", bold_style),
Span::styled(speed.label(), base_style), Span::styled(speed.label(), base_style),

View File

@@ -3,7 +3,7 @@ use std::time::Instant;
use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
@@ -41,9 +41,20 @@ fn adjust_spans_for_line(
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) { pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
let term = frame.area(); let term = frame.area();
let bg_color = if app.ui.event_flash > 0.0 {
let i = (app.ui.event_flash * app.ui.flash_brightness * 60.0) as u8;
Color::Rgb(i, i, i)
} else {
Color::Reset
};
let blank = " ".repeat(term.width as usize); let blank = " ".repeat(term.width as usize);
let lines: Vec<Line> = (0..term.height).map(|_| Line::raw(&blank)).collect(); let lines: Vec<Line> = (0..term.height).map(|_| Line::raw(&blank)).collect();
frame.render_widget(Paragraph::new(lines), term); frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(bg_color)),
term,
);
if app.ui.show_title { if app.ui.show_title {
title_view::render(frame, term, &app.ui); title_view::render(frame, term, &app.ui);
@@ -225,7 +236,7 @@ fn render_header(
// Pattern block (name + length + speed + page + iter) // Pattern block (name + length + speed + page + iter)
let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1); let default_pattern_name = format!("Pattern {:02}", app.editor_ctx.pattern + 1);
let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name); let pattern_name = pattern.name.as_deref().unwrap_or(&default_pattern_name);
let speed_info = if pattern.speed != PatternSpeed::Normal { let speed_info = if pattern.speed != PatternSpeed::NORMAL {
format!(" · {}", pattern.speed.label()) format!(" · {}", pattern.speed.label())
} else { } else {
String::new() String::new()
@@ -295,20 +306,11 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
} else { } else {
let bindings: Vec<(&str, &str)> = match app.page { let bindings: Vec<(&str, &str)> = match app.page {
Page::Main => vec![ Page::Main => vec![
("←→↑↓", "Nav"),
("Shift+↑↓", "Select"),
("t", "Toggle"),
("Enter", "Edit"),
("Space", "Play"), ("Space", "Play"),
("^C", "Copy"), ("Enter", "Edit"),
("^V", "Paste"), ("t", "Toggle"),
("^B", "Link"), ("Tab", "Samples"),
("^D", "Dup"), ("?", "Keys"),
("^H", "Harden"),
("Del", "Delete"),
("<>", "Len"),
("[]", "Spd"),
("+-", "Tempo"),
], ],
Page::Patterns => vec![ Page::Patterns => vec![
("←→↑↓", "Navigate"), ("←→↑↓", "Navigate"),
@@ -317,6 +319,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("Esc", "Back"), ("Esc", "Back"),
("r", "Rename"), ("r", "Rename"),
("Del", "Reset"), ("Del", "Reset"),
("?", "Keys"),
], ],
Page::Engine => vec![ Page::Engine => vec![
("Tab", "Section"), ("Tab", "Section"),
@@ -324,15 +327,27 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("↑↓", "Navigate"), ("↑↓", "Navigate"),
("Enter", "Select"), ("Enter", "Select"),
("A", "Add path"), ("A", "Add path"),
("?", "Keys"),
],
Page::Options => vec![
("Tab", "Next"),
("←→", "Toggle"),
("Space", "Play"),
("?", "Keys"),
], ],
Page::Options => vec![("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play")],
Page::Help => vec![ Page::Help => vec![
("↑↓", "Scroll"), ("↑↓", "Scroll"),
("Tab", "Topic"), ("Tab", "Topic"),
("PgUp/Dn", "Page"), ("PgUp/Dn", "Page"),
("/", "Search"), ("/", "Search"),
("?", "Keys"),
],
Page::Dict => vec![
("Tab", "Focus"),
("↑↓", "Navigate"),
("/", "Search"),
("?", "Keys"),
], ],
Page::Dict => vec![("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search")],
}; };
let page_width = page_indicator.chars().count(); let page_width = page_indicator.chars().count();
@@ -451,7 +466,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
Modal::SetPattern { field, input } => { Modal::SetPattern { field, input } => {
let (title, hint) = match field { let (title, hint) = match field {
PatternField::Length => ("Set Length (1-128)", "Enter number"), PatternField::Length => ("Set Length (1-128)", "Enter number"),
PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"), PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"),
}; };
TextInputModal::new(title, input) TextInputModal::new(title, input)
.hint(hint) .hint(hint)
@@ -716,7 +731,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
length.as_str(), length.as_str(),
*field == PatternPropsField::Length, *field == PatternPropsField::Length,
), ),
("Speed", speed.label(), *field == PatternPropsField::Speed), ("Speed", &speed.label(), *field == PatternPropsField::Speed),
( (
"Quantization", "Quantization",
quantization.label(), quantization.label(),
@@ -772,5 +787,73 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
]); ]);
frame.render_widget(Paragraph::new(hint), hint_area); frame.render_widget(Paragraph::new(hint), hint_area);
} }
Modal::KeybindingsHelp { scroll } => {
let width = (term.width * 80 / 100).clamp(60, 100);
let height = (term.height * 80 / 100).max(15);
let title = format!("Keybindings — {}", app.page.name());
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(Color::Rgb(100, 160, 180))
.render_centered(frame, term);
let bindings = super::keybindings::bindings_for(app.page);
let visible_rows = inner.height.saturating_sub(2) as usize;
let rows: Vec<Row> = bindings
.iter()
.enumerate()
.skip(*scroll)
.take(visible_rows)
.map(|(i, (key, name, desc))| {
let bg = if i % 2 == 0 {
Color::Rgb(25, 25, 30)
} else {
Color::Rgb(35, 35, 42)
};
Row::new(vec![
Cell::from(*key).style(Style::default().fg(Color::Yellow)),
Cell::from(*name).style(Style::default().fg(Color::Cyan)),
Cell::from(*desc).style(Style::default().fg(Color::White)),
])
.style(Style::default().bg(bg))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(14),
Constraint::Length(12),
Constraint::Fill(1),
],
)
.column_spacing(2);
let table_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
};
frame.render_widget(table, table_area);
let hint_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
let hint = Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Yellow)),
Span::styled(" scroll ", Style::default().fg(Color::DarkGray)),
Span::styled("PgUp/Dn", Style::default().fg(Color::Yellow)),
Span::styled(" page ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc/?", Style::default().fg(Color::Yellow)),
Span::styled(" close", Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
}
} }
} }

BIN
website/cagire.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

162
website/index.html Normal file
View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cagire</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
padding: 2rem;
line-height: 1.5;
}
.container {
max-width: 70ch;
margin: 0 auto;
}
.header {
border-bottom: 1px solid #333;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.ascii {
color: #64a0b4;
font-size: 0.7rem;
line-height: 1.1;
white-space: pre;
}
nav {
margin-top: 1rem;
}
nav a {
color: #cccc44;
text-decoration: none;
margin-right: 1.5rem;
}
nav a:hover {
text-decoration: underline;
}
section {
margin-bottom: 2rem;
}
h2 {
color: #888;
font-size: 0.9rem;
font-weight: bold;
margin-bottom: 0.75rem;
border-bottom: 1px solid #333;
padding-bottom: 0.25rem;
}
.buttons {
display: flex;
gap: 1rem;
}
.btn {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #333;
color: #e0e0e0;
text-decoration: none;
font-size: 0.9rem;
text-align: center;
}
.btn:hover {
border-color: #64a0b4;
color: #64a0b4;
}
.screenshot img {
width: 100%;
border: 1px solid #333;
margin-bottom: 0.75rem;
}
.screenshot p {
color: #888;
font-size: 0.9rem;
}
p {
color: #888;
margin-bottom: 0.5rem;
}
section a {
color: #cccc44;
text-decoration: none;
}
section a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<pre class="ascii">
██████╗ █████╗ ██████╗ ██╗██████╗ ███████╗
██╔════╝██╔══██╗██╔════╝ ██║██╔══██╗██╔════╝
██║ ███████║██║ ███╗██║██████╔╝█████╗
██║ ██╔══██║██║ ██║██║██╔══██╗██╔══╝
╚██████╗██║ ██║╚██████╔╝██║██║ ██║███████╗
╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚══════╝
</pre>
<nav>
<a href="#description">Description</a>
<a href="#releases">Releases</a>
<a href="#credits">Credits</a>
<a href="#support">Support</a>
</nav>
</div>
<section id="description" class="screenshot">
<img src="cagire.png" alt="Cagire screenshot">
<p>Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a Forth script that produces sound and creates events. Synchronize with other musicians using Ableton Link. Cagire uses its own audio engine for audio synthesis and sampling!</p>
</section>
<section id="releases">
<h2>Releases</h2>
<div class="buttons">
<a href="#" class="btn">macOS</a>
<a href="#" class="btn">Windows</a>
<a href="#" class="btn">Linux</a>
</div>
</section>
<section id="credits">
<h2>Credits</h2>
<p>Cagire is built by BuboBubo (Raphael Maurice Forment).</p>
<p>Doux (audio engine) is a Rust port of Dough, originally written in C by Felix Roos.</p>
<p>mi-plaits-dsp-rs by Oliver Rockstedt, based on Mutable Instruments Plaits by Emilie Gillet.</p>
</section>
<section id="support">
<h2>Support</h2>
<p>Report issues and contribute on <a href="https://github.com/bubo/cagire">GitHub</a>.</p>
<p>Support the project on <a href="https://ko-fi.com/raphaelbubo">Ko-fi</a>.</p>
</section>
</div>
</body>
</html>