Compare commits
3 Commits
c6860105a6
...
2be15d11f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2be15d11f4 | |||
| 5952807240 | |||
| 0beed16c31 |
@@ -1,80 +1,138 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub enum PatternSpeed {
|
||||
Eighth, // 1/8x
|
||||
Quarter, // 1/4x
|
||||
Half, // 1/2x
|
||||
#[default]
|
||||
Normal, // 1x
|
||||
Double, // 2x
|
||||
Quad, // 4x
|
||||
Octo, // 8x
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PatternSpeed {
|
||||
pub num: u8,
|
||||
pub denom: u8,
|
||||
}
|
||||
|
||||
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 {
|
||||
match self {
|
||||
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,
|
||||
}
|
||||
self.num as f64 / self.denom as f64
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Eighth => "1/8x",
|
||||
Self::Quarter => "1/4x",
|
||||
Self::Half => "1/2x",
|
||||
Self::Normal => "1x",
|
||||
Self::Double => "2x",
|
||||
Self::Quad => "4x",
|
||||
Self::Octo => "8x",
|
||||
pub fn label(&self) -> String {
|
||||
if self.denom == 1 {
|
||||
format!("{}x", self.num)
|
||||
} else {
|
||||
format!("{}/{}x", self.num, self.denom)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::Eighth => Self::Quarter,
|
||||
Self::Quarter => Self::Half,
|
||||
Self::Half => Self::Normal,
|
||||
Self::Normal => Self::Double,
|
||||
Self::Double => Self::Quad,
|
||||
Self::Quad => Self::Octo,
|
||||
Self::Octo => Self::Octo,
|
||||
}
|
||||
let current = self.multiplier();
|
||||
Self::PRESETS
|
||||
.iter()
|
||||
.find(|p| p.multiplier() > current + 0.0001)
|
||||
.copied()
|
||||
.unwrap_or(*self)
|
||||
}
|
||||
|
||||
pub fn prev(&self) -> Self {
|
||||
match self {
|
||||
Self::Eighth => Self::Eighth,
|
||||
Self::Quarter => Self::Eighth,
|
||||
Self::Half => Self::Quarter,
|
||||
Self::Normal => Self::Half,
|
||||
Self::Double => Self::Normal,
|
||||
Self::Quad => Self::Double,
|
||||
Self::Octo => Self::Quad,
|
||||
}
|
||||
let current = self.multiplier();
|
||||
Self::PRESETS
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|p| p.multiplier() < current - 0.0001)
|
||||
.copied()
|
||||
.unwrap_or(*self)
|
||||
}
|
||||
|
||||
pub fn from_label(s: &str) -> Option<Self> {
|
||||
match s.trim() {
|
||||
"1/8x" | "1/8" | "0.125x" => Some(Self::Eighth),
|
||||
"1/4x" | "1/4" | "0.25x" => Some(Self::Quarter),
|
||||
"1/2x" | "1/2" | "0.5x" => Some(Self::Half),
|
||||
"1x" | "1" => Some(Self::Normal),
|
||||
"2x" | "2" => Some(Self::Double),
|
||||
"4x" | "4" => Some(Self::Quad),
|
||||
"8x" | "8" => Some(Self::Octo),
|
||||
_ => None,
|
||||
let s = s.trim().trim_end_matches('x');
|
||||
if let Some((num, denom)) = s.split_once('/') {
|
||||
let num: u8 = num.parse().ok()?;
|
||||
let denom: u8 = denom.parse().ok()?;
|
||||
if denom == 0 {
|
||||
return None;
|
||||
}
|
||||
return Some(Self { num, denom });
|
||||
}
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ impl App {
|
||||
show_scope: self.audio.config.show_scope,
|
||||
show_spectrum: self.audio.config.show_spectrum,
|
||||
show_completion: self.ui.show_completion,
|
||||
flash_brightness: self.ui.flash_brightness,
|
||||
},
|
||||
link: crate::settings::LinkSettings {
|
||||
enabled: link.is_enabled(),
|
||||
|
||||
@@ -1095,7 +1095,7 @@ mod tests {
|
||||
let mut state = make_state();
|
||||
|
||||
let mut pat = simple_pattern(8);
|
||||
pat.speed = crate::model::PatternSpeed::Double;
|
||||
pat.speed = crate::model::PatternSpeed::DOUBLE;
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternUpdate { bank: 0, pattern: 0, data: pat }],
|
||||
|
||||
44
src/input.rs
44
src/input.rs
@@ -359,7 +359,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
)));
|
||||
} else {
|
||||
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!(),
|
||||
}
|
||||
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
|
||||
@@ -952,6 +974,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
|
||||
}
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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(),
|
||||
));
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -1135,6 +1163,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
OptionsFocus::ShowCompletion => {
|
||||
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::StartStopSync => ctx
|
||||
.link
|
||||
@@ -1151,6 +1184,9 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.playing
|
||||
.store(ctx.app.playback.playing, Ordering::Relaxed);
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -1184,6 +1220,9 @@ fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -1229,6 +1268,9 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
|
||||
@@ -94,6 +94,7 @@ fn main() -> io::Result<()> {
|
||||
app.audio.config.show_scope = settings.display.show_scope;
|
||||
app.audio.config.show_spectrum = settings.display.show_spectrum;
|
||||
app.ui.show_completion = settings.display.show_completion;
|
||||
app.ui.flash_brightness = settings.display.flash_brightness;
|
||||
|
||||
let metrics = Arc::new(EngineMetrics::default());
|
||||
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.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_dirty_patterns(&sequencer.cmd_tx);
|
||||
|
||||
|
||||
@@ -29,8 +29,12 @@ pub struct DisplaySettings {
|
||||
pub show_spectrum: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_completion: bool,
|
||||
#[serde(default = "default_flash_brightness")]
|
||||
pub flash_brightness: f32,
|
||||
}
|
||||
|
||||
fn default_flash_brightness() -> f32 { 1.0 }
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LinkSettings {
|
||||
pub enabled: bool,
|
||||
@@ -60,6 +64,7 @@ impl Default for DisplaySettings {
|
||||
show_scope: true,
|
||||
show_spectrum: true,
|
||||
show_completion: true,
|
||||
flash_brightness: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +57,7 @@ pub enum Modal {
|
||||
quantization: LaunchQuantization,
|
||||
sync_mode: SyncMode,
|
||||
},
|
||||
KeybindingsHelp {
|
||||
scroll: usize,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub enum OptionsFocus {
|
||||
ShowScope,
|
||||
ShowSpectrum,
|
||||
ShowCompletion,
|
||||
FlashBrightness,
|
||||
LinkEnabled,
|
||||
StartStopSync,
|
||||
Quantum,
|
||||
@@ -23,7 +24,8 @@ impl OptionsState {
|
||||
OptionsFocus::RuntimeHighlight => OptionsFocus::ShowScope,
|
||||
OptionsFocus::ShowScope => OptionsFocus::ShowSpectrum,
|
||||
OptionsFocus::ShowSpectrum => OptionsFocus::ShowCompletion,
|
||||
OptionsFocus::ShowCompletion => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::ShowCompletion => OptionsFocus::FlashBrightness,
|
||||
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
|
||||
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
|
||||
OptionsFocus::Quantum => OptionsFocus::RefreshRate,
|
||||
@@ -37,7 +39,8 @@ impl OptionsState {
|
||||
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
|
||||
OptionsFocus::ShowSpectrum => OptionsFocus::ShowScope,
|
||||
OptionsFocus::ShowCompletion => OptionsFocus::ShowSpectrum,
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::ShowCompletion,
|
||||
OptionsFocus::FlashBrightness => OptionsFocus::ShowCompletion,
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
|
||||
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct UiState {
|
||||
pub runtime_highlight: bool,
|
||||
pub show_completion: bool,
|
||||
pub minimap_until: Option<Instant>,
|
||||
pub last_event_count: usize,
|
||||
pub event_flash: f32,
|
||||
pub flash_brightness: f32,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -61,6 +64,9 @@ impl Default for UiState {
|
||||
runtime_highlight: false,
|
||||
show_completion: true,
|
||||
minimap_until: None,
|
||||
last_event_count: 0,
|
||||
event_flash: 0.0,
|
||||
flash_brightness: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
91
src/views/keybindings.rs
Normal file
91
src/views/keybindings.rs
Normal 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
|
||||
}
|
||||
@@ -41,7 +41,24 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, 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;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod dict_view;
|
||||
pub mod engine_view;
|
||||
pub mod help_view;
|
||||
pub mod highlight;
|
||||
pub mod keybindings;
|
||||
pub mod main_view;
|
||||
pub mod options_view;
|
||||
pub mod patterns_view;
|
||||
|
||||
@@ -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([
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(1),
|
||||
@@ -63,6 +63,7 @@ fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
);
|
||||
|
||||
let focus = app.options.focus;
|
||||
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
|
||||
let lines = vec![
|
||||
render_option_line(
|
||||
"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" },
|
||||
focus == OptionsFocus::ShowCompletion,
|
||||
),
|
||||
render_option_line(
|
||||
"Flash brightness",
|
||||
&flash_str,
|
||||
focus == OptionsFocus::FlashBrightness,
|
||||
),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines), content_area);
|
||||
|
||||
@@ -287,7 +287,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
frame.render_widget(Paragraph::new(length_line), length_area);
|
||||
|
||||
// Column 3: speed (only if non-default)
|
||||
if speed != PatternSpeed::Normal {
|
||||
if speed != PatternSpeed::NORMAL {
|
||||
let speed_line = Line::from(vec![
|
||||
Span::styled("Speed: ", bold_style),
|
||||
Span::styled(speed.label(), base_style),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::time::Instant;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
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 crate::app::App;
|
||||
@@ -41,9 +41,20 @@ fn adjust_spans_for_line(
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
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 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 {
|
||||
title_view::render(frame, term, &app.ui);
|
||||
@@ -225,7 +236,7 @@ fn render_header(
|
||||
// Pattern block (name + length + speed + page + iter)
|
||||
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 speed_info = if pattern.speed != PatternSpeed::Normal {
|
||||
let speed_info = if pattern.speed != PatternSpeed::NORMAL {
|
||||
format!(" · {}", pattern.speed.label())
|
||||
} else {
|
||||
String::new()
|
||||
@@ -295,20 +306,11 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
} else {
|
||||
let bindings: Vec<(&str, &str)> = match app.page {
|
||||
Page::Main => vec![
|
||||
("←→↑↓", "Nav"),
|
||||
("Shift+↑↓", "Select"),
|
||||
("t", "Toggle"),
|
||||
("Enter", "Edit"),
|
||||
("Space", "Play"),
|
||||
("^C", "Copy"),
|
||||
("^V", "Paste"),
|
||||
("^B", "Link"),
|
||||
("^D", "Dup"),
|
||||
("^H", "Harden"),
|
||||
("Del", "Delete"),
|
||||
("<>", "Len"),
|
||||
("[]", "Spd"),
|
||||
("+-", "Tempo"),
|
||||
("Enter", "Edit"),
|
||||
("t", "Toggle"),
|
||||
("Tab", "Samples"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Patterns => vec![
|
||||
("←→↑↓", "Navigate"),
|
||||
@@ -317,6 +319,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("Esc", "Back"),
|
||||
("r", "Rename"),
|
||||
("Del", "Reset"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Engine => vec![
|
||||
("Tab", "Section"),
|
||||
@@ -324,15 +327,27 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("↑↓", "Navigate"),
|
||||
("Enter", "Select"),
|
||||
("A", "Add path"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Options => vec![
|
||||
("Tab", "Next"),
|
||||
("←→", "Toggle"),
|
||||
("Space", "Play"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Options => vec![("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play")],
|
||||
Page::Help => vec![
|
||||
("↑↓", "Scroll"),
|
||||
("Tab", "Topic"),
|
||||
("PgUp/Dn", "Page"),
|
||||
("/", "Search"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Dict => vec![
|
||||
("Tab", "Focus"),
|
||||
("↑↓", "Navigate"),
|
||||
("/", "Search"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Dict => vec![("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search")],
|
||||
};
|
||||
|
||||
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 } => {
|
||||
let (title, hint) = match field {
|
||||
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)
|
||||
.hint(hint)
|
||||
@@ -716,7 +731,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
length.as_str(),
|
||||
*field == PatternPropsField::Length,
|
||||
),
|
||||
("Speed", speed.label(), *field == PatternPropsField::Speed),
|
||||
("Speed", &speed.label(), *field == PatternPropsField::Speed),
|
||||
(
|
||||
"Quantization",
|
||||
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);
|
||||
}
|
||||
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
BIN
website/cagire.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
162
website/index.html
Normal file
162
website/index.html
Normal 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>
|
||||
Reference in New Issue
Block a user