Compare commits
13 Commits
v0.0.3
...
194030d953
| Author | SHA1 | Date | |
|---|---|---|---|
| 194030d953 | |||
| e4799c1f42 | |||
| 636129688d | |||
| a2ee0e5a50 | |||
| 96ed74c6fe | |||
| a67d982fcd | |||
| c9ab7a4f0b | |||
| 772d21a8ed | |||
| 4396147a8b | |||
| c396c39b6b | |||
| f6b43cb021 | |||
| 60d1d7ca74 | |||
| 9864cc6d61 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -2,7 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [0.0.5] - Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
|
||||||
|
- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load.
|
||||||
|
- Realtime thread scheduling (`SCHED_FIFO`) for sequencer thread on Unix systems, improving timing reliability.
|
||||||
|
|
||||||
|
## [0.0.4] - 2026-02-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Double-stack words: `2dup`, `2drop`, `2swap`, `2over`.
|
||||||
|
- `forget` word to remove user-defined words from the dictionary.
|
||||||
|
- Active patterns panel showing playing patterns with bank, pattern, iteration count, and step position.
|
||||||
|
- Configurable visualization layout (Top/Bottom/Left/Right) for scope and spectrum placement.
|
||||||
|
- Euclidean distribution modal to spread a step's script across the pattern using Euclidean rhythms.
|
||||||
|
- Fairyfloss theme (pastel candy colors by sailorhg).
|
||||||
|
- Hot Dog Stand theme (classic Windows 3.1 red/yellow).
|
||||||
|
- Hue rotation option in Options menu to shift all theme colors (0-360°).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Title view now adapts to smaller terminal sizes gracefully.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
|
||||||
|
- Updated `cpal` dependency from 0.15 to 0.17 to fix type mismatch with `doux` audio backend.
|
||||||
|
- Copy/paste (Ctrl+C/V/X) not working in desktop version due to egui intercepting clipboard shortcuts.
|
||||||
|
|
||||||
## [0.0.3] - 2026-02-02
|
## [0.0.3] - 2026-02-02
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.3"
|
version = "0.0.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
@@ -53,7 +53,7 @@ doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
|
|||||||
rusty_link = "0.4"
|
rusty_link = "0.4"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
cpal = "0.15"
|
cpal = "0.17"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
||||||
</p>
|
</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 create events. It is made by BuboBubo (Raphaël Maurice Forment): [https://raphaelforment.fr](https://raphaelforment.fr). Cagire is open-source (AGPL-3.0 licensed) and available on GitHub : [https://github.com/BuboBubo/cagire](https://github.com/BuboBubo/cagire).
|
Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ pub enum Op {
|
|||||||
Rot,
|
Rot,
|
||||||
Nip,
|
Nip,
|
||||||
Tuck,
|
Tuck,
|
||||||
|
Dup2,
|
||||||
|
Drop2,
|
||||||
|
Swap2,
|
||||||
|
Over2,
|
||||||
|
Forget,
|
||||||
Add,
|
Add,
|
||||||
Sub,
|
Sub,
|
||||||
Mul,
|
Mul,
|
||||||
|
|||||||
@@ -262,6 +262,42 @@ impl Forth {
|
|||||||
let v = stack[len - 1].clone();
|
let v = stack[len - 1].clone();
|
||||||
stack.insert(len - 2, v);
|
stack.insert(len - 2, v);
|
||||||
}
|
}
|
||||||
|
Op::Dup2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 2 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
let a = stack[len - 2].clone();
|
||||||
|
let b = stack[len - 1].clone();
|
||||||
|
stack.push(a);
|
||||||
|
stack.push(b);
|
||||||
|
}
|
||||||
|
Op::Drop2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 2 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
stack.pop();
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
Op::Swap2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 4 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
stack.swap(len - 4, len - 2);
|
||||||
|
stack.swap(len - 3, len - 1);
|
||||||
|
}
|
||||||
|
Op::Over2 => {
|
||||||
|
let len = stack.len();
|
||||||
|
if len < 4 {
|
||||||
|
return Err("stack underflow".into());
|
||||||
|
}
|
||||||
|
let a = stack[len - 4].clone();
|
||||||
|
let b = stack[len - 3].clone();
|
||||||
|
stack.push(a);
|
||||||
|
stack.push(b);
|
||||||
|
}
|
||||||
|
|
||||||
Op::Add => binary_op(stack, |a, b| a + b)?,
|
Op::Add => binary_op(stack, |a, b| a + b)?,
|
||||||
Op::Sub => binary_op(stack, |a, b| a - b)?,
|
Op::Sub => binary_op(stack, |a, b| a - b)?,
|
||||||
@@ -915,6 +951,10 @@ impl Forth {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
stack.push(Value::Int(val as i64, None));
|
stack.push(Value::Int(val as i64, None));
|
||||||
}
|
}
|
||||||
|
Op::Forget => {
|
||||||
|
let name = stack.pop().ok_or("stack underflow")?.as_str()?.to_string();
|
||||||
|
self.dict.lock().unwrap().remove(&name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pc += 1;
|
pc += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,46 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
varargs: false,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "2dup",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b -- a b a b)",
|
||||||
|
desc: "Duplicate top two values",
|
||||||
|
example: "1 2 2dup => 1 2 1 2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "2drop",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b --)",
|
||||||
|
desc: "Drop top two values",
|
||||||
|
example: "1 2 3 2drop => 1",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "2swap",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b c d -- c d a b)",
|
||||||
|
desc: "Swap top two pairs",
|
||||||
|
example: "1 2 3 4 2swap => 3 4 1 2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
|
Word {
|
||||||
|
name: "2over",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Stack",
|
||||||
|
stack: "(a b c d -- a b c d a b)",
|
||||||
|
desc: "Copy second pair to top",
|
||||||
|
example: "1 2 3 4 2over => 1 2 3 4 1 2",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
// Arithmetic
|
// Arithmetic
|
||||||
Word {
|
Word {
|
||||||
name: "+",
|
name: "+",
|
||||||
@@ -2571,6 +2611,16 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
varargs: false,
|
||||||
},
|
},
|
||||||
|
Word {
|
||||||
|
name: "forget",
|
||||||
|
aliases: &[],
|
||||||
|
category: "Definitions",
|
||||||
|
stack: "(name --)",
|
||||||
|
desc: "Remove user-defined word from dictionary",
|
||||||
|
example: "\"double\" forget",
|
||||||
|
compile: Simple,
|
||||||
|
varargs: false,
|
||||||
|
},
|
||||||
// Generator
|
// Generator
|
||||||
Word {
|
Word {
|
||||||
name: "..",
|
name: "..",
|
||||||
@@ -2770,6 +2820,10 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"rot" => Op::Rot,
|
"rot" => Op::Rot,
|
||||||
"nip" => Op::Nip,
|
"nip" => Op::Nip,
|
||||||
"tuck" => Op::Tuck,
|
"tuck" => Op::Tuck,
|
||||||
|
"2dup" => Op::Dup2,
|
||||||
|
"2drop" => Op::Drop2,
|
||||||
|
"2swap" => Op::Swap2,
|
||||||
|
"2over" => Op::Over2,
|
||||||
"+" => Op::Add,
|
"+" => Op::Add,
|
||||||
"-" => Op::Sub,
|
"-" => Op::Sub,
|
||||||
"*" => Op::Mul,
|
"*" => Op::Mul,
|
||||||
@@ -2842,6 +2896,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"mstart" => Op::MidiStart,
|
"mstart" => Op::MidiStart,
|
||||||
"mstop" => Op::MidiStop,
|
"mstop" => Op::MidiStop,
|
||||||
"mcont" => Op::MidiContinue,
|
"mcont" => Op::MidiContinue,
|
||||||
|
"forget" => Op::Forget,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
104
crates/ratatui/src/active_patterns.rs
Normal file
104
crates/ratatui/src/active_patterns.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use crate::theme;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::widgets::Widget;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MuteStatus {
|
||||||
|
Normal,
|
||||||
|
Muted,
|
||||||
|
Soloed,
|
||||||
|
EffectivelyMuted, // Solo active on another pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ActivePatterns<'a> {
|
||||||
|
patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter)
|
||||||
|
mute_status: Option<&'a [MuteStatus]>,
|
||||||
|
current_step: Option<(usize, usize)>, // (current_step, total_steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ActivePatterns<'a> {
|
||||||
|
pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self {
|
||||||
|
Self {
|
||||||
|
patterns,
|
||||||
|
mute_status: None,
|
||||||
|
current_step: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_step(mut self, current: usize, total: usize) -> Self {
|
||||||
|
self.current_step = Some((current, total));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_mute_status(mut self, status: &'a [MuteStatus]) -> Self {
|
||||||
|
self.mute_status = Some(status);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ActivePatterns<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
if area.width < 10 || area.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let theme = theme::get();
|
||||||
|
|
||||||
|
let max_pattern_rows = if self.current_step.is_some() {
|
||||||
|
area.height.saturating_sub(1) as usize
|
||||||
|
} else {
|
||||||
|
area.height as usize
|
||||||
|
};
|
||||||
|
|
||||||
|
for (row, &(bank, pattern, iter)) in self.patterns.iter().enumerate() {
|
||||||
|
if row >= max_pattern_rows {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mute_status = self
|
||||||
|
.mute_status
|
||||||
|
.and_then(|s| s.get(row))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(MuteStatus::Normal);
|
||||||
|
|
||||||
|
let (prefix, fg, bg) = match mute_status {
|
||||||
|
MuteStatus::Soloed => ("S", theme.list.soloed_fg, theme.list.soloed_bg),
|
||||||
|
MuteStatus::Muted => ("M", theme.list.muted_fg, theme.list.muted_bg),
|
||||||
|
MuteStatus::EffectivelyMuted => (" ", theme.list.muted_fg, theme.list.muted_bg),
|
||||||
|
MuteStatus::Normal => {
|
||||||
|
let bg = if row % 2 == 0 {
|
||||||
|
theme.table.row_even
|
||||||
|
} else {
|
||||||
|
theme.table.row_odd
|
||||||
|
};
|
||||||
|
(" ", theme.ui.text_primary, bg)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = format!("{}B{:02}:{:02}({:02})", prefix, bank + 1, pattern + 1, iter.min(99));
|
||||||
|
let y = area.y + row as u16;
|
||||||
|
|
||||||
|
let mut chars = text.chars();
|
||||||
|
for col in 0..area.width as usize {
|
||||||
|
let ch = chars.next().unwrap_or(' ');
|
||||||
|
buf[(area.x + col as u16, y)]
|
||||||
|
.set_char(ch)
|
||||||
|
.set_fg(fg)
|
||||||
|
.set_bg(bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((current, total)) = self.current_step {
|
||||||
|
let text = format!("{:02}/{:02}", current + 1, total);
|
||||||
|
let y = area.y + area.height.saturating_sub(1);
|
||||||
|
let mut chars = text.chars();
|
||||||
|
for col in 0..area.width as usize {
|
||||||
|
let ch = chars.next().unwrap_or(' ');
|
||||||
|
buf[(area.x + col as u16, y)]
|
||||||
|
.set_char(ch)
|
||||||
|
.set_fg(theme.ui.text_primary)
|
||||||
|
.set_bg(theme.table.row_even);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod active_patterns;
|
||||||
mod confirm;
|
mod confirm;
|
||||||
mod editor;
|
mod editor;
|
||||||
mod file_browser;
|
mod file_browser;
|
||||||
@@ -12,6 +13,7 @@ mod text_input;
|
|||||||
pub mod theme;
|
pub mod theme;
|
||||||
mod vu_meter;
|
mod vu_meter;
|
||||||
|
|
||||||
|
pub use active_patterns::{ActivePatterns, MuteStatus};
|
||||||
pub use confirm::ConfirmModal;
|
pub use confirm::ConfirmModal;
|
||||||
pub use editor::{CompletionCandidate, Editor};
|
pub use editor::{CompletionCandidate, Editor};
|
||||||
pub use file_browser::FileBrowserModal;
|
pub use file_browser::FileBrowserModal;
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: surface0,
|
info_bg: surface0,
|
||||||
info_fg: text,
|
info_fg: text,
|
||||||
event_rgb: (225, 215, 240),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(210, 235, 220),
|
playing_bg: Color::Rgb(210, 235, 220),
|
||||||
@@ -123,6 +122,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: teal,
|
edit_fg: teal,
|
||||||
hover_bg: surface1,
|
hover_bg: surface1,
|
||||||
hover_fg: text,
|
hover_fg: text,
|
||||||
|
muted_bg: Color::Rgb(215, 215, 225),
|
||||||
|
muted_fg: overlay0,
|
||||||
|
soloed_bg: Color::Rgb(250, 235, 200),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: red,
|
disabled: red,
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: surface0,
|
info_bg: surface0,
|
||||||
info_fg: text,
|
info_fg: text,
|
||||||
event_rgb: (55, 45, 70),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(35, 55, 45),
|
playing_bg: Color::Rgb(35, 55, 45),
|
||||||
@@ -123,6 +122,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: teal,
|
edit_fg: teal,
|
||||||
hover_bg: surface1,
|
hover_bg: surface1,
|
||||||
hover_fg: text,
|
hover_fg: text,
|
||||||
|
muted_bg: Color::Rgb(40, 40, 50),
|
||||||
|
muted_fg: overlay0,
|
||||||
|
soloed_bg: Color::Rgb(60, 55, 35),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: red,
|
disabled: red,
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: current_line,
|
info_bg: current_line,
|
||||||
info_fg: foreground,
|
info_fg: foreground,
|
||||||
event_rgb: (70, 55, 85),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(40, 65, 50),
|
playing_bg: Color::Rgb(40, 65, 50),
|
||||||
@@ -117,6 +116,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: cyan,
|
edit_fg: cyan,
|
||||||
hover_bg: lighter_bg,
|
hover_bg: lighter_bg,
|
||||||
hover_fg: foreground,
|
hover_fg: foreground,
|
||||||
|
muted_bg: Color::Rgb(50, 52, 65),
|
||||||
|
muted_fg: comment,
|
||||||
|
soloed_bg: Color::Rgb(70, 70, 50),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: red,
|
disabled: red,
|
||||||
|
|||||||
280
crates/ratatui/src/theme/fairyfloss.rs
Normal file
280
crates/ratatui/src/theme/fairyfloss.rs
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
use super::*;
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
pub fn theme() -> ThemeColors {
|
||||||
|
let bg = Color::Rgb(90, 84, 117);
|
||||||
|
let bg_light = Color::Rgb(113, 103, 153);
|
||||||
|
let bg_lighter = Color::Rgb(130, 120, 165);
|
||||||
|
let fg = Color::Rgb(248, 248, 240);
|
||||||
|
let fg_dim = Color::Rgb(197, 163, 255);
|
||||||
|
let muted = Color::Rgb(168, 164, 177);
|
||||||
|
let dark = Color::Rgb(55, 51, 72);
|
||||||
|
|
||||||
|
let purple = Color::Rgb(174, 129, 255);
|
||||||
|
let pink = Color::Rgb(255, 184, 209);
|
||||||
|
let coral = Color::Rgb(255, 133, 127);
|
||||||
|
let yellow = Color::Rgb(255, 243, 82);
|
||||||
|
let gold = Color::Rgb(230, 192, 0);
|
||||||
|
let mint = Color::Rgb(194, 255, 223);
|
||||||
|
let lavender = Color::Rgb(197, 163, 255);
|
||||||
|
|
||||||
|
ThemeColors {
|
||||||
|
ui: UiColors {
|
||||||
|
bg,
|
||||||
|
bg_rgb: (90, 84, 117),
|
||||||
|
text_primary: fg,
|
||||||
|
text_muted: fg_dim,
|
||||||
|
text_dim: muted,
|
||||||
|
border: bg_lighter,
|
||||||
|
header: mint,
|
||||||
|
unfocused: muted,
|
||||||
|
accent: pink,
|
||||||
|
surface: bg_light,
|
||||||
|
},
|
||||||
|
status: StatusColors {
|
||||||
|
playing_bg: Color::Rgb(70, 95, 85),
|
||||||
|
playing_fg: mint,
|
||||||
|
stopped_bg: Color::Rgb(100, 70, 85),
|
||||||
|
stopped_fg: coral,
|
||||||
|
fill_on: mint,
|
||||||
|
fill_off: muted,
|
||||||
|
fill_bg: bg_light,
|
||||||
|
},
|
||||||
|
selection: SelectionColors {
|
||||||
|
cursor_bg: pink,
|
||||||
|
cursor_fg: dark,
|
||||||
|
selected_bg: Color::Rgb(120, 90, 130),
|
||||||
|
selected_fg: pink,
|
||||||
|
in_range_bg: Color::Rgb(100, 95, 125),
|
||||||
|
in_range_fg: fg,
|
||||||
|
cursor: pink,
|
||||||
|
selected: Color::Rgb(120, 90, 130),
|
||||||
|
in_range: Color::Rgb(100, 95, 125),
|
||||||
|
},
|
||||||
|
tile: TileColors {
|
||||||
|
playing_active_bg: Color::Rgb(100, 85, 60),
|
||||||
|
playing_active_fg: gold,
|
||||||
|
playing_inactive_bg: Color::Rgb(95, 90, 70),
|
||||||
|
playing_inactive_fg: yellow,
|
||||||
|
active_bg: Color::Rgb(70, 100, 100),
|
||||||
|
active_fg: mint,
|
||||||
|
inactive_bg: bg_light,
|
||||||
|
inactive_fg: fg_dim,
|
||||||
|
active_selected_bg: Color::Rgb(120, 90, 130),
|
||||||
|
active_in_range_bg: Color::Rgb(100, 95, 125),
|
||||||
|
link_bright: [
|
||||||
|
(255, 184, 209),
|
||||||
|
(174, 129, 255),
|
||||||
|
(255, 133, 127),
|
||||||
|
(194, 255, 223),
|
||||||
|
(255, 243, 82),
|
||||||
|
],
|
||||||
|
link_dim: [
|
||||||
|
(100, 75, 90),
|
||||||
|
(85, 70, 105),
|
||||||
|
(100, 65, 65),
|
||||||
|
(75, 100, 95),
|
||||||
|
(100, 95, 55),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
header: HeaderColors {
|
||||||
|
tempo_bg: Color::Rgb(100, 75, 95),
|
||||||
|
tempo_fg: pink,
|
||||||
|
bank_bg: Color::Rgb(70, 95, 95),
|
||||||
|
bank_fg: mint,
|
||||||
|
pattern_bg: Color::Rgb(85, 75, 110),
|
||||||
|
pattern_fg: purple,
|
||||||
|
stats_bg: bg_light,
|
||||||
|
stats_fg: fg_dim,
|
||||||
|
},
|
||||||
|
modal: ModalColors {
|
||||||
|
border: mint,
|
||||||
|
border_accent: pink,
|
||||||
|
border_warn: coral,
|
||||||
|
border_dim: muted,
|
||||||
|
confirm: coral,
|
||||||
|
rename: purple,
|
||||||
|
input: mint,
|
||||||
|
editor: mint,
|
||||||
|
preview: muted,
|
||||||
|
},
|
||||||
|
flash: FlashColors {
|
||||||
|
error_bg: Color::Rgb(100, 65, 70),
|
||||||
|
error_fg: coral,
|
||||||
|
success_bg: Color::Rgb(65, 95, 85),
|
||||||
|
success_fg: mint,
|
||||||
|
info_bg: bg_light,
|
||||||
|
info_fg: fg,
|
||||||
|
},
|
||||||
|
list: ListColors {
|
||||||
|
playing_bg: Color::Rgb(65, 95, 85),
|
||||||
|
playing_fg: mint,
|
||||||
|
staged_play_bg: Color::Rgb(95, 80, 120),
|
||||||
|
staged_play_fg: purple,
|
||||||
|
staged_stop_bg: Color::Rgb(105, 70, 85),
|
||||||
|
staged_stop_fg: pink,
|
||||||
|
edit_bg: Color::Rgb(70, 95, 100),
|
||||||
|
edit_fg: mint,
|
||||||
|
hover_bg: bg_lighter,
|
||||||
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(75, 70, 95),
|
||||||
|
muted_fg: muted,
|
||||||
|
soloed_bg: Color::Rgb(100, 95, 65),
|
||||||
|
soloed_fg: yellow,
|
||||||
|
},
|
||||||
|
link_status: LinkStatusColors {
|
||||||
|
disabled: coral,
|
||||||
|
connected: mint,
|
||||||
|
listening: yellow,
|
||||||
|
},
|
||||||
|
syntax: SyntaxColors {
|
||||||
|
gap_bg: dark,
|
||||||
|
executed_bg: Color::Rgb(80, 75, 100),
|
||||||
|
selected_bg: Color::Rgb(110, 100, 70),
|
||||||
|
emit: (fg, Color::Rgb(110, 80, 100)),
|
||||||
|
number: (purple, Color::Rgb(85, 75, 110)),
|
||||||
|
string: (yellow, Color::Rgb(100, 95, 60)),
|
||||||
|
comment: (muted, dark),
|
||||||
|
keyword: (pink, Color::Rgb(105, 75, 90)),
|
||||||
|
stack_op: (mint, Color::Rgb(70, 100, 95)),
|
||||||
|
operator: (pink, Color::Rgb(105, 75, 90)),
|
||||||
|
sound: (mint, Color::Rgb(70, 100, 95)),
|
||||||
|
param: (coral, Color::Rgb(105, 70, 70)),
|
||||||
|
context: (coral, Color::Rgb(105, 70, 70)),
|
||||||
|
note: (lavender, Color::Rgb(85, 75, 110)),
|
||||||
|
interval: (Color::Rgb(220, 190, 255), Color::Rgb(85, 75, 100)),
|
||||||
|
variable: (lavender, Color::Rgb(85, 75, 110)),
|
||||||
|
vary: (yellow, Color::Rgb(100, 95, 60)),
|
||||||
|
generator: (mint, Color::Rgb(70, 95, 95)),
|
||||||
|
default: (fg_dim, dark),
|
||||||
|
},
|
||||||
|
table: TableColors {
|
||||||
|
row_even: dark,
|
||||||
|
row_odd: bg,
|
||||||
|
},
|
||||||
|
values: ValuesColors {
|
||||||
|
tempo: coral,
|
||||||
|
value: fg_dim,
|
||||||
|
},
|
||||||
|
hint: HintColors {
|
||||||
|
key: coral,
|
||||||
|
text: muted,
|
||||||
|
},
|
||||||
|
view_badge: ViewBadgeColors { bg: fg, fg: bg },
|
||||||
|
nav: NavColors {
|
||||||
|
selected_bg: Color::Rgb(110, 85, 120),
|
||||||
|
selected_fg: fg,
|
||||||
|
unselected_bg: bg_light,
|
||||||
|
unselected_fg: muted,
|
||||||
|
},
|
||||||
|
editor_widget: EditorWidgetColors {
|
||||||
|
cursor_bg: fg,
|
||||||
|
cursor_fg: bg,
|
||||||
|
selection_bg: Color::Rgb(105, 95, 125),
|
||||||
|
completion_bg: bg_light,
|
||||||
|
completion_fg: fg,
|
||||||
|
completion_selected: coral,
|
||||||
|
completion_example: mint,
|
||||||
|
},
|
||||||
|
browser: BrowserColors {
|
||||||
|
directory: mint,
|
||||||
|
project_file: purple,
|
||||||
|
selected: coral,
|
||||||
|
file: fg,
|
||||||
|
focused_border: coral,
|
||||||
|
unfocused_border: muted,
|
||||||
|
root: fg,
|
||||||
|
file_icon: muted,
|
||||||
|
folder_icon: mint,
|
||||||
|
empty_text: muted,
|
||||||
|
},
|
||||||
|
input: InputColors {
|
||||||
|
text: mint,
|
||||||
|
cursor: fg,
|
||||||
|
hint: muted,
|
||||||
|
},
|
||||||
|
search: SearchColors {
|
||||||
|
active: coral,
|
||||||
|
inactive: muted,
|
||||||
|
match_bg: yellow,
|
||||||
|
match_fg: dark,
|
||||||
|
},
|
||||||
|
markdown: MarkdownColors {
|
||||||
|
h1: mint,
|
||||||
|
h2: coral,
|
||||||
|
h3: purple,
|
||||||
|
code: lavender,
|
||||||
|
code_border: Color::Rgb(120, 115, 140),
|
||||||
|
link: pink,
|
||||||
|
link_url: Color::Rgb(150, 145, 165),
|
||||||
|
quote: muted,
|
||||||
|
text: fg,
|
||||||
|
list: fg,
|
||||||
|
},
|
||||||
|
engine: EngineColors {
|
||||||
|
header: mint,
|
||||||
|
header_focused: yellow,
|
||||||
|
divider: Color::Rgb(110, 105, 130),
|
||||||
|
scroll_indicator: Color::Rgb(125, 120, 145),
|
||||||
|
label: Color::Rgb(175, 170, 190),
|
||||||
|
label_focused: Color::Rgb(210, 205, 225),
|
||||||
|
label_dim: Color::Rgb(145, 140, 160),
|
||||||
|
value: Color::Rgb(230, 225, 240),
|
||||||
|
focused: yellow,
|
||||||
|
normal: fg,
|
||||||
|
dim: Color::Rgb(125, 120, 145),
|
||||||
|
path: Color::Rgb(175, 170, 190),
|
||||||
|
border_magenta: pink,
|
||||||
|
border_green: mint,
|
||||||
|
border_cyan: lavender,
|
||||||
|
separator: Color::Rgb(110, 105, 130),
|
||||||
|
hint_active: Color::Rgb(240, 230, 120),
|
||||||
|
hint_inactive: Color::Rgb(110, 105, 130),
|
||||||
|
},
|
||||||
|
dict: DictColors {
|
||||||
|
word_name: lavender,
|
||||||
|
word_bg: Color::Rgb(75, 85, 105),
|
||||||
|
alias: muted,
|
||||||
|
stack_sig: purple,
|
||||||
|
description: fg,
|
||||||
|
example: Color::Rgb(175, 170, 190),
|
||||||
|
category_focused: yellow,
|
||||||
|
category_selected: mint,
|
||||||
|
category_normal: fg,
|
||||||
|
category_dimmed: Color::Rgb(125, 120, 145),
|
||||||
|
border_focused: yellow,
|
||||||
|
border_normal: Color::Rgb(110, 105, 130),
|
||||||
|
header_desc: Color::Rgb(195, 190, 210),
|
||||||
|
},
|
||||||
|
title: TitleColors {
|
||||||
|
big_title: pink,
|
||||||
|
author: mint,
|
||||||
|
link: lavender,
|
||||||
|
license: coral,
|
||||||
|
prompt: Color::Rgb(195, 190, 210),
|
||||||
|
subtitle: fg,
|
||||||
|
},
|
||||||
|
meter: MeterColors {
|
||||||
|
low: mint,
|
||||||
|
mid: yellow,
|
||||||
|
high: coral,
|
||||||
|
low_rgb: (194, 255, 223),
|
||||||
|
mid_rgb: (255, 243, 82),
|
||||||
|
high_rgb: (255, 133, 127),
|
||||||
|
},
|
||||||
|
sparkle: SparkleColors {
|
||||||
|
colors: [
|
||||||
|
(194, 255, 223),
|
||||||
|
(255, 133, 127),
|
||||||
|
(255, 243, 82),
|
||||||
|
(255, 184, 209),
|
||||||
|
(174, 129, 255),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: ConfirmColors {
|
||||||
|
border: coral,
|
||||||
|
button_selected_bg: coral,
|
||||||
|
button_selected_fg: dark,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,7 +106,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: bg1,
|
info_bg: bg1,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (70, 55, 45),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(50, 65, 45),
|
playing_bg: Color::Rgb(50, 65, 45),
|
||||||
@@ -119,6 +118,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: aqua,
|
edit_fg: aqua,
|
||||||
hover_bg: bg2,
|
hover_bg: bg2,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(50, 50, 55),
|
||||||
|
muted_fg: fg4,
|
||||||
|
soloed_bg: Color::Rgb(70, 65, 40),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: red,
|
disabled: red,
|
||||||
|
|||||||
276
crates/ratatui/src/theme/hot_dog_stand.rs
Normal file
276
crates/ratatui/src/theme/hot_dog_stand.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
use super::*;
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
pub fn theme() -> ThemeColors {
|
||||||
|
let red = Color::Rgb(255, 0, 0);
|
||||||
|
let dark_red = Color::Rgb(215, 0, 0);
|
||||||
|
let darker_red = Color::Rgb(175, 0, 0);
|
||||||
|
let yellow = Color::Rgb(255, 255, 0);
|
||||||
|
let light_yellow = Color::Rgb(255, 255, 95);
|
||||||
|
let gold = Color::Rgb(255, 215, 0);
|
||||||
|
let black = Color::Rgb(0, 0, 0);
|
||||||
|
let white = Color::Rgb(255, 255, 255);
|
||||||
|
|
||||||
|
let dim_yellow = Color::Rgb(180, 180, 0);
|
||||||
|
let muted_red = Color::Rgb(140, 40, 40);
|
||||||
|
|
||||||
|
ThemeColors {
|
||||||
|
ui: UiColors {
|
||||||
|
bg: red,
|
||||||
|
bg_rgb: (255, 0, 0),
|
||||||
|
text_primary: yellow,
|
||||||
|
text_muted: light_yellow,
|
||||||
|
text_dim: gold,
|
||||||
|
border: yellow,
|
||||||
|
header: yellow,
|
||||||
|
unfocused: gold,
|
||||||
|
accent: yellow,
|
||||||
|
surface: dark_red,
|
||||||
|
},
|
||||||
|
status: StatusColors {
|
||||||
|
playing_bg: Color::Rgb(180, 180, 0),
|
||||||
|
playing_fg: black,
|
||||||
|
stopped_bg: darker_red,
|
||||||
|
stopped_fg: yellow,
|
||||||
|
fill_on: yellow,
|
||||||
|
fill_off: gold,
|
||||||
|
fill_bg: dark_red,
|
||||||
|
},
|
||||||
|
selection: SelectionColors {
|
||||||
|
cursor_bg: yellow,
|
||||||
|
cursor_fg: red,
|
||||||
|
selected_bg: Color::Rgb(200, 200, 0),
|
||||||
|
selected_fg: black,
|
||||||
|
in_range_bg: Color::Rgb(170, 100, 0),
|
||||||
|
in_range_fg: yellow,
|
||||||
|
cursor: yellow,
|
||||||
|
selected: Color::Rgb(200, 200, 0),
|
||||||
|
in_range: Color::Rgb(170, 100, 0),
|
||||||
|
},
|
||||||
|
tile: TileColors {
|
||||||
|
playing_active_bg: Color::Rgb(200, 200, 0),
|
||||||
|
playing_active_fg: black,
|
||||||
|
playing_inactive_bg: Color::Rgb(180, 180, 0),
|
||||||
|
playing_inactive_fg: black,
|
||||||
|
active_bg: Color::Rgb(200, 50, 50),
|
||||||
|
active_fg: yellow,
|
||||||
|
inactive_bg: dark_red,
|
||||||
|
inactive_fg: gold,
|
||||||
|
active_selected_bg: Color::Rgb(200, 200, 0),
|
||||||
|
active_in_range_bg: Color::Rgb(170, 100, 0),
|
||||||
|
link_bright: [
|
||||||
|
(255, 255, 0),
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 215, 0),
|
||||||
|
(255, 255, 95),
|
||||||
|
(255, 255, 0),
|
||||||
|
],
|
||||||
|
link_dim: [
|
||||||
|
(140, 140, 0),
|
||||||
|
(140, 140, 140),
|
||||||
|
(140, 120, 0),
|
||||||
|
(140, 140, 60),
|
||||||
|
(140, 140, 0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
header: HeaderColors {
|
||||||
|
tempo_bg: Color::Rgb(180, 180, 0),
|
||||||
|
tempo_fg: black,
|
||||||
|
bank_bg: darker_red,
|
||||||
|
bank_fg: yellow,
|
||||||
|
pattern_bg: Color::Rgb(200, 200, 0),
|
||||||
|
pattern_fg: black,
|
||||||
|
stats_bg: dark_red,
|
||||||
|
stats_fg: yellow,
|
||||||
|
},
|
||||||
|
modal: ModalColors {
|
||||||
|
border: yellow,
|
||||||
|
border_accent: white,
|
||||||
|
border_warn: gold,
|
||||||
|
border_dim: dim_yellow,
|
||||||
|
confirm: gold,
|
||||||
|
rename: light_yellow,
|
||||||
|
input: yellow,
|
||||||
|
editor: yellow,
|
||||||
|
preview: gold,
|
||||||
|
},
|
||||||
|
flash: FlashColors {
|
||||||
|
error_bg: black,
|
||||||
|
error_fg: yellow,
|
||||||
|
success_bg: Color::Rgb(180, 180, 0),
|
||||||
|
success_fg: black,
|
||||||
|
info_bg: dark_red,
|
||||||
|
info_fg: yellow,
|
||||||
|
},
|
||||||
|
list: ListColors {
|
||||||
|
playing_bg: Color::Rgb(180, 180, 0),
|
||||||
|
playing_fg: black,
|
||||||
|
staged_play_bg: Color::Rgb(200, 200, 0),
|
||||||
|
staged_play_fg: black,
|
||||||
|
staged_stop_bg: darker_red,
|
||||||
|
staged_stop_fg: yellow,
|
||||||
|
edit_bg: Color::Rgb(200, 50, 50),
|
||||||
|
edit_fg: yellow,
|
||||||
|
hover_bg: Color::Rgb(230, 50, 50),
|
||||||
|
hover_fg: yellow,
|
||||||
|
muted_bg: darker_red,
|
||||||
|
muted_fg: dim_yellow,
|
||||||
|
soloed_bg: Color::Rgb(200, 200, 0),
|
||||||
|
soloed_fg: black,
|
||||||
|
},
|
||||||
|
link_status: LinkStatusColors {
|
||||||
|
disabled: white,
|
||||||
|
connected: yellow,
|
||||||
|
listening: gold,
|
||||||
|
},
|
||||||
|
syntax: SyntaxColors {
|
||||||
|
gap_bg: darker_red,
|
||||||
|
executed_bg: Color::Rgb(200, 50, 50),
|
||||||
|
selected_bg: Color::Rgb(180, 180, 0),
|
||||||
|
emit: (yellow, muted_red),
|
||||||
|
number: (white, muted_red),
|
||||||
|
string: (gold, muted_red),
|
||||||
|
comment: (dim_yellow, darker_red),
|
||||||
|
keyword: (light_yellow, muted_red),
|
||||||
|
stack_op: (yellow, muted_red),
|
||||||
|
operator: (light_yellow, muted_red),
|
||||||
|
sound: (yellow, muted_red),
|
||||||
|
param: (gold, muted_red),
|
||||||
|
context: (gold, muted_red),
|
||||||
|
note: (white, muted_red),
|
||||||
|
interval: (Color::Rgb(255, 240, 150), muted_red),
|
||||||
|
variable: (white, muted_red),
|
||||||
|
vary: (gold, muted_red),
|
||||||
|
generator: (yellow, muted_red),
|
||||||
|
default: (light_yellow, darker_red),
|
||||||
|
},
|
||||||
|
table: TableColors {
|
||||||
|
row_even: darker_red,
|
||||||
|
row_odd: red,
|
||||||
|
},
|
||||||
|
values: ValuesColors {
|
||||||
|
tempo: gold,
|
||||||
|
value: light_yellow,
|
||||||
|
},
|
||||||
|
hint: HintColors {
|
||||||
|
key: white,
|
||||||
|
text: gold,
|
||||||
|
},
|
||||||
|
view_badge: ViewBadgeColors { bg: yellow, fg: red },
|
||||||
|
nav: NavColors {
|
||||||
|
selected_bg: Color::Rgb(200, 200, 0),
|
||||||
|
selected_fg: black,
|
||||||
|
unselected_bg: dark_red,
|
||||||
|
unselected_fg: gold,
|
||||||
|
},
|
||||||
|
editor_widget: EditorWidgetColors {
|
||||||
|
cursor_bg: yellow,
|
||||||
|
cursor_fg: red,
|
||||||
|
selection_bg: Color::Rgb(180, 180, 0),
|
||||||
|
completion_bg: dark_red,
|
||||||
|
completion_fg: yellow,
|
||||||
|
completion_selected: white,
|
||||||
|
completion_example: gold,
|
||||||
|
},
|
||||||
|
browser: BrowserColors {
|
||||||
|
directory: yellow,
|
||||||
|
project_file: white,
|
||||||
|
selected: gold,
|
||||||
|
file: light_yellow,
|
||||||
|
focused_border: white,
|
||||||
|
unfocused_border: gold,
|
||||||
|
root: yellow,
|
||||||
|
file_icon: gold,
|
||||||
|
folder_icon: yellow,
|
||||||
|
empty_text: gold,
|
||||||
|
},
|
||||||
|
input: InputColors {
|
||||||
|
text: yellow,
|
||||||
|
cursor: white,
|
||||||
|
hint: gold,
|
||||||
|
},
|
||||||
|
search: SearchColors {
|
||||||
|
active: white,
|
||||||
|
inactive: gold,
|
||||||
|
match_bg: yellow,
|
||||||
|
match_fg: red,
|
||||||
|
},
|
||||||
|
markdown: MarkdownColors {
|
||||||
|
h1: yellow,
|
||||||
|
h2: white,
|
||||||
|
h3: gold,
|
||||||
|
code: light_yellow,
|
||||||
|
code_border: dim_yellow,
|
||||||
|
link: white,
|
||||||
|
link_url: gold,
|
||||||
|
quote: dim_yellow,
|
||||||
|
text: yellow,
|
||||||
|
list: yellow,
|
||||||
|
},
|
||||||
|
engine: EngineColors {
|
||||||
|
header: yellow,
|
||||||
|
header_focused: white,
|
||||||
|
divider: dim_yellow,
|
||||||
|
scroll_indicator: gold,
|
||||||
|
label: light_yellow,
|
||||||
|
label_focused: white,
|
||||||
|
label_dim: dim_yellow,
|
||||||
|
value: yellow,
|
||||||
|
focused: white,
|
||||||
|
normal: yellow,
|
||||||
|
dim: dim_yellow,
|
||||||
|
path: gold,
|
||||||
|
border_magenta: gold,
|
||||||
|
border_green: yellow,
|
||||||
|
border_cyan: white,
|
||||||
|
separator: dim_yellow,
|
||||||
|
hint_active: white,
|
||||||
|
hint_inactive: dim_yellow,
|
||||||
|
},
|
||||||
|
dict: DictColors {
|
||||||
|
word_name: yellow,
|
||||||
|
word_bg: darker_red,
|
||||||
|
alias: gold,
|
||||||
|
stack_sig: white,
|
||||||
|
description: yellow,
|
||||||
|
example: gold,
|
||||||
|
category_focused: white,
|
||||||
|
category_selected: yellow,
|
||||||
|
category_normal: light_yellow,
|
||||||
|
category_dimmed: dim_yellow,
|
||||||
|
border_focused: white,
|
||||||
|
border_normal: dim_yellow,
|
||||||
|
header_desc: gold,
|
||||||
|
},
|
||||||
|
title: TitleColors {
|
||||||
|
big_title: yellow,
|
||||||
|
author: white,
|
||||||
|
link: gold,
|
||||||
|
license: light_yellow,
|
||||||
|
prompt: gold,
|
||||||
|
subtitle: yellow,
|
||||||
|
},
|
||||||
|
meter: MeterColors {
|
||||||
|
low: yellow,
|
||||||
|
mid: gold,
|
||||||
|
high: white,
|
||||||
|
low_rgb: (255, 255, 0),
|
||||||
|
mid_rgb: (255, 215, 0),
|
||||||
|
high_rgb: (255, 255, 255),
|
||||||
|
},
|
||||||
|
sparkle: SparkleColors {
|
||||||
|
colors: [
|
||||||
|
(255, 255, 0),
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 215, 0),
|
||||||
|
(255, 255, 95),
|
||||||
|
(255, 255, 0),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: ConfirmColors {
|
||||||
|
border: white,
|
||||||
|
button_selected_bg: yellow,
|
||||||
|
button_selected_fg: red,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ pub fn theme() -> ThemeColors {
|
|||||||
let autumn_red = Color::Rgb(195, 64, 67);
|
let autumn_red = Color::Rgb(195, 64, 67);
|
||||||
let carp_yellow = Color::Rgb(230, 195, 132);
|
let carp_yellow = Color::Rgb(230, 195, 132);
|
||||||
let spring_blue = Color::Rgb(127, 180, 202);
|
let spring_blue = Color::Rgb(127, 180, 202);
|
||||||
let wave_red = Color::Rgb(226, 109, 115);
|
let wave_red = Color::Rgb(228, 104, 118);
|
||||||
let sakura_pink = Color::Rgb(212, 140, 149);
|
let sakura_pink = Color::Rgb(210, 126, 153);
|
||||||
|
|
||||||
let darker_bg = Color::Rgb(26, 26, 34);
|
let darker_bg = Color::Rgb(26, 26, 34);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ pub fn theme() -> ThemeColors {
|
|||||||
active_selected_bg: Color::Rgb(65, 55, 70),
|
active_selected_bg: Color::Rgb(65, 55, 70),
|
||||||
active_in_range_bg: Color::Rgb(50, 50, 60),
|
active_in_range_bg: Color::Rgb(50, 50, 60),
|
||||||
link_bright: [
|
link_bright: [
|
||||||
(226, 109, 115),
|
(228, 104, 118),
|
||||||
(149, 127, 184),
|
(149, 127, 184),
|
||||||
(230, 195, 132),
|
(230, 195, 132),
|
||||||
(127, 180, 202),
|
(127, 180, 202),
|
||||||
@@ -106,7 +106,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: autumn_green,
|
success_fg: autumn_green,
|
||||||
info_bg: bg_light,
|
info_bg: bg_light,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (50, 50, 60),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(40, 55, 45),
|
playing_bg: Color::Rgb(40, 55, 45),
|
||||||
@@ -119,6 +118,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: crystal_blue,
|
edit_fg: crystal_blue,
|
||||||
hover_bg: bg_lighter,
|
hover_bg: bg_lighter,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(38, 38, 48),
|
||||||
|
muted_fg: comment,
|
||||||
|
soloed_bg: Color::Rgb(60, 55, 45),
|
||||||
|
soloed_fg: carp_yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: autumn_red,
|
disabled: autumn_red,
|
||||||
@@ -258,14 +261,14 @@ pub fn theme() -> ThemeColors {
|
|||||||
high: wave_red,
|
high: wave_red,
|
||||||
low_rgb: (118, 148, 106),
|
low_rgb: (118, 148, 106),
|
||||||
mid_rgb: (230, 195, 132),
|
mid_rgb: (230, 195, 132),
|
||||||
high_rgb: (226, 109, 115),
|
high_rgb: (228, 104, 118),
|
||||||
},
|
},
|
||||||
sparkle: SparkleColors {
|
sparkle: SparkleColors {
|
||||||
colors: [
|
colors: [
|
||||||
(127, 180, 202),
|
(127, 180, 202),
|
||||||
(230, 195, 132),
|
(230, 195, 132),
|
||||||
(118, 148, 106),
|
(118, 148, 106),
|
||||||
(226, 109, 115),
|
(228, 104, 118),
|
||||||
(149, 127, 184),
|
(149, 127, 184),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
mod catppuccin_latte;
|
mod catppuccin_latte;
|
||||||
mod catppuccin_mocha;
|
mod catppuccin_mocha;
|
||||||
mod dracula;
|
mod dracula;
|
||||||
|
mod fairyfloss;
|
||||||
mod gruvbox_dark;
|
mod gruvbox_dark;
|
||||||
|
mod hot_dog_stand;
|
||||||
mod kanagawa;
|
mod kanagawa;
|
||||||
mod monochrome_black;
|
mod monochrome_black;
|
||||||
mod monochrome_white;
|
mod monochrome_white;
|
||||||
@@ -13,6 +15,7 @@ mod nord;
|
|||||||
mod pitch_black;
|
mod pitch_black;
|
||||||
mod rose_pine;
|
mod rose_pine;
|
||||||
mod tokyo_night;
|
mod tokyo_night;
|
||||||
|
pub mod transform;
|
||||||
|
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -36,6 +39,8 @@ pub const THEMES: &[ThemeEntry] = &[
|
|||||||
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
|
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
|
||||||
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
|
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
|
||||||
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
|
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
|
||||||
|
ThemeEntry { id: "Fairyfloss", label: "Fairyfloss", colors: fairyfloss::theme },
|
||||||
|
ThemeEntry { id: "HotDogStand", label: "Hot Dog Stand", colors: hot_dog_stand::theme },
|
||||||
];
|
];
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -167,7 +172,6 @@ pub struct FlashColors {
|
|||||||
pub success_fg: Color,
|
pub success_fg: Color,
|
||||||
pub info_bg: Color,
|
pub info_bg: Color,
|
||||||
pub info_fg: Color,
|
pub info_fg: Color,
|
||||||
pub event_rgb: (u8, u8, u8),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -182,6 +186,10 @@ pub struct ListColors {
|
|||||||
pub edit_fg: Color,
|
pub edit_fg: Color,
|
||||||
pub hover_bg: Color,
|
pub hover_bg: Color,
|
||||||
pub hover_fg: Color,
|
pub hover_fg: Color,
|
||||||
|
pub muted_bg: Color,
|
||||||
|
pub muted_fg: Color,
|
||||||
|
pub soloed_bg: Color,
|
||||||
|
pub soloed_fg: Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: bright,
|
success_fg: bright,
|
||||||
info_bg: surface,
|
info_bg: surface,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (40, 40, 40),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(50, 50, 50),
|
playing_bg: Color::Rgb(50, 50, 50),
|
||||||
@@ -116,6 +115,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: bright,
|
edit_fg: bright,
|
||||||
hover_bg: surface2,
|
hover_bg: surface2,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(22, 22, 22),
|
||||||
|
muted_fg: dark,
|
||||||
|
soloed_bg: Color::Rgb(60, 60, 60),
|
||||||
|
soloed_fg: bright,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: dim,
|
disabled: dim,
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: dark,
|
success_fg: dark,
|
||||||
info_bg: surface,
|
info_bg: surface,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (220, 220, 220),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(200, 200, 200),
|
playing_bg: Color::Rgb(200, 200, 200),
|
||||||
@@ -116,6 +115,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: dark,
|
edit_fg: dark,
|
||||||
hover_bg: surface2,
|
hover_bg: surface2,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(235, 235, 235),
|
||||||
|
muted_fg: light,
|
||||||
|
soloed_bg: Color::Rgb(190, 190, 190),
|
||||||
|
soloed_fg: dark,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: dim,
|
disabled: dim,
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: bg_light,
|
info_bg: bg_light,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (70, 55, 70),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(50, 70, 45),
|
playing_bg: Color::Rgb(50, 70, 45),
|
||||||
@@ -117,6 +116,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: blue,
|
edit_fg: blue,
|
||||||
hover_bg: bg_lighter,
|
hover_bg: bg_lighter,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(48, 50, 45),
|
||||||
|
muted_fg: comment,
|
||||||
|
soloed_bg: Color::Rgb(70, 65, 45),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: pink,
|
disabled: pink,
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: aurora_green,
|
success_fg: aurora_green,
|
||||||
info_bg: polar_night1,
|
info_bg: polar_night1,
|
||||||
info_fg: snow_storm2,
|
info_fg: snow_storm2,
|
||||||
event_rgb: (60, 55, 75),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(50, 65, 55),
|
playing_bg: Color::Rgb(50, 65, 55),
|
||||||
@@ -117,6 +116,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: frost0,
|
edit_fg: frost0,
|
||||||
hover_bg: polar_night2,
|
hover_bg: polar_night2,
|
||||||
hover_fg: snow_storm2,
|
hover_fg: snow_storm2,
|
||||||
|
muted_bg: Color::Rgb(55, 60, 70),
|
||||||
|
muted_fg: polar_night3,
|
||||||
|
soloed_bg: Color::Rgb(70, 65, 50),
|
||||||
|
soloed_fg: aurora_yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: aurora_red,
|
disabled: aurora_red,
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: surface,
|
info_bg: surface,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (40, 30, 50),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(15, 45, 25),
|
playing_bg: Color::Rgb(15, 45, 25),
|
||||||
@@ -118,6 +117,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: cyan,
|
edit_fg: cyan,
|
||||||
hover_bg: surface2,
|
hover_bg: surface2,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(15, 15, 15),
|
||||||
|
muted_fg: fg_muted,
|
||||||
|
soloed_bg: Color::Rgb(45, 40, 15),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: red,
|
disabled: red,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub fn theme() -> ThemeColors {
|
|||||||
let fg = Color::Rgb(224, 222, 244);
|
let fg = Color::Rgb(224, 222, 244);
|
||||||
let fg_dim = Color::Rgb(144, 140, 170);
|
let fg_dim = Color::Rgb(144, 140, 170);
|
||||||
let muted = Color::Rgb(110, 106, 134);
|
let muted = Color::Rgb(110, 106, 134);
|
||||||
let rose = Color::Rgb(235, 111, 146);
|
let rose = Color::Rgb(235, 188, 186);
|
||||||
let gold = Color::Rgb(246, 193, 119);
|
let gold = Color::Rgb(246, 193, 119);
|
||||||
let foam = Color::Rgb(156, 207, 216);
|
let foam = Color::Rgb(156, 207, 216);
|
||||||
let iris = Color::Rgb(196, 167, 231);
|
let iris = Color::Rgb(196, 167, 231);
|
||||||
@@ -105,7 +105,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: foam,
|
success_fg: foam,
|
||||||
info_bg: bg_light,
|
info_bg: bg_light,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (50, 45, 60),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(35, 55, 55),
|
playing_bg: Color::Rgb(35, 55, 55),
|
||||||
@@ -118,6 +117,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: foam,
|
edit_fg: foam,
|
||||||
hover_bg: bg_lighter,
|
hover_bg: bg_lighter,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(32, 30, 42),
|
||||||
|
muted_fg: muted,
|
||||||
|
soloed_bg: Color::Rgb(60, 50, 40),
|
||||||
|
soloed_fg: gold,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: love,
|
disabled: love,
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ pub fn theme() -> ThemeColors {
|
|||||||
success_fg: green,
|
success_fg: green,
|
||||||
info_bg: bg_light,
|
info_bg: bg_light,
|
||||||
info_fg: fg,
|
info_fg: fg,
|
||||||
event_rgb: (55, 50, 70),
|
|
||||||
},
|
},
|
||||||
list: ListColors {
|
list: ListColors {
|
||||||
playing_bg: Color::Rgb(45, 60, 45),
|
playing_bg: Color::Rgb(45, 60, 45),
|
||||||
@@ -118,6 +117,10 @@ pub fn theme() -> ThemeColors {
|
|||||||
edit_fg: blue,
|
edit_fg: blue,
|
||||||
hover_bg: bg_lighter,
|
hover_bg: bg_lighter,
|
||||||
hover_fg: fg,
|
hover_fg: fg,
|
||||||
|
muted_bg: Color::Rgb(35, 38, 50),
|
||||||
|
muted_fg: comment,
|
||||||
|
soloed_bg: Color::Rgb(60, 55, 40),
|
||||||
|
soloed_fg: yellow,
|
||||||
},
|
},
|
||||||
link_status: LinkStatusColors {
|
link_status: LinkStatusColors {
|
||||||
disabled: red,
|
disabled: red,
|
||||||
|
|||||||
348
crates/ratatui/src/theme/transform.rs
Normal file
348
crates/ratatui/src/theme/transform.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
use ratatui::style::Color;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||||
|
let r = r as f32 / 255.0;
|
||||||
|
let g = g as f32 / 255.0;
|
||||||
|
let b = b as f32 / 255.0;
|
||||||
|
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let delta = max - min;
|
||||||
|
|
||||||
|
let h = if delta == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if max == r {
|
||||||
|
60.0 * (((g - b) / delta) % 6.0)
|
||||||
|
} else if max == g {
|
||||||
|
60.0 * (((b - r) / delta) + 2.0)
|
||||||
|
} else {
|
||||||
|
60.0 * (((r - g) / delta) + 4.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let h = if h < 0.0 { h + 360.0 } else { h };
|
||||||
|
let s = if max == 0.0 { 0.0 } else { delta / max };
|
||||||
|
let v = max;
|
||||||
|
|
||||||
|
(h, s, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
|
||||||
|
let c = v * s;
|
||||||
|
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
||||||
|
let m = v - c;
|
||||||
|
|
||||||
|
let (r, g, b) = if h < 60.0 {
|
||||||
|
(c, x, 0.0)
|
||||||
|
} else if h < 120.0 {
|
||||||
|
(x, c, 0.0)
|
||||||
|
} else if h < 180.0 {
|
||||||
|
(0.0, c, x)
|
||||||
|
} else if h < 240.0 {
|
||||||
|
(0.0, x, c)
|
||||||
|
} else if h < 300.0 {
|
||||||
|
(x, 0.0, c)
|
||||||
|
} else {
|
||||||
|
(c, 0.0, x)
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
((r + m) * 255.0) as u8,
|
||||||
|
((g + m) * 255.0) as u8,
|
||||||
|
((b + m) * 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_hue_rgb(r: u8, g: u8, b: u8, degrees: f32) -> (u8, u8, u8) {
|
||||||
|
let (h, s, v) = rgb_to_hsv(r, g, b);
|
||||||
|
let new_h = (h + degrees) % 360.0;
|
||||||
|
let new_h = if new_h < 0.0 { new_h + 360.0 } else { new_h };
|
||||||
|
hsv_to_rgb(new_h, s, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_color(color: Color, degrees: f32) -> Color {
|
||||||
|
match color {
|
||||||
|
Color::Rgb(r, g, b) => {
|
||||||
|
let (nr, ng, nb) = rotate_hue_rgb(r, g, b, degrees);
|
||||||
|
Color::Rgb(nr, ng, nb)
|
||||||
|
}
|
||||||
|
_ => color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_tuple(tuple: (u8, u8, u8), degrees: f32) -> (u8, u8, u8) {
|
||||||
|
rotate_hue_rgb(tuple.0, tuple.1, tuple.2, degrees)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rotate_color_pair(pair: (Color, Color), degrees: f32) -> (Color, Color) {
|
||||||
|
(rotate_color(pair.0, degrees), rotate_color(pair.1, degrees))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
|
||||||
|
if degrees == 0.0 {
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeColors {
|
||||||
|
ui: UiColors {
|
||||||
|
bg: rotate_color(theme.ui.bg, degrees),
|
||||||
|
bg_rgb: rotate_tuple(theme.ui.bg_rgb, degrees),
|
||||||
|
text_primary: rotate_color(theme.ui.text_primary, degrees),
|
||||||
|
text_muted: rotate_color(theme.ui.text_muted, degrees),
|
||||||
|
text_dim: rotate_color(theme.ui.text_dim, degrees),
|
||||||
|
border: rotate_color(theme.ui.border, degrees),
|
||||||
|
header: rotate_color(theme.ui.header, degrees),
|
||||||
|
unfocused: rotate_color(theme.ui.unfocused, degrees),
|
||||||
|
accent: rotate_color(theme.ui.accent, degrees),
|
||||||
|
surface: rotate_color(theme.ui.surface, degrees),
|
||||||
|
},
|
||||||
|
status: StatusColors {
|
||||||
|
playing_bg: rotate_color(theme.status.playing_bg, degrees),
|
||||||
|
playing_fg: rotate_color(theme.status.playing_fg, degrees),
|
||||||
|
stopped_bg: rotate_color(theme.status.stopped_bg, degrees),
|
||||||
|
stopped_fg: rotate_color(theme.status.stopped_fg, degrees),
|
||||||
|
fill_on: rotate_color(theme.status.fill_on, degrees),
|
||||||
|
fill_off: rotate_color(theme.status.fill_off, degrees),
|
||||||
|
fill_bg: rotate_color(theme.status.fill_bg, degrees),
|
||||||
|
},
|
||||||
|
selection: SelectionColors {
|
||||||
|
cursor_bg: rotate_color(theme.selection.cursor_bg, degrees),
|
||||||
|
cursor_fg: rotate_color(theme.selection.cursor_fg, degrees),
|
||||||
|
selected_bg: rotate_color(theme.selection.selected_bg, degrees),
|
||||||
|
selected_fg: rotate_color(theme.selection.selected_fg, degrees),
|
||||||
|
in_range_bg: rotate_color(theme.selection.in_range_bg, degrees),
|
||||||
|
in_range_fg: rotate_color(theme.selection.in_range_fg, degrees),
|
||||||
|
cursor: rotate_color(theme.selection.cursor, degrees),
|
||||||
|
selected: rotate_color(theme.selection.selected, degrees),
|
||||||
|
in_range: rotate_color(theme.selection.in_range, degrees),
|
||||||
|
},
|
||||||
|
tile: TileColors {
|
||||||
|
playing_active_bg: rotate_color(theme.tile.playing_active_bg, degrees),
|
||||||
|
playing_active_fg: rotate_color(theme.tile.playing_active_fg, degrees),
|
||||||
|
playing_inactive_bg: rotate_color(theme.tile.playing_inactive_bg, degrees),
|
||||||
|
playing_inactive_fg: rotate_color(theme.tile.playing_inactive_fg, degrees),
|
||||||
|
active_bg: rotate_color(theme.tile.active_bg, degrees),
|
||||||
|
active_fg: rotate_color(theme.tile.active_fg, degrees),
|
||||||
|
inactive_bg: rotate_color(theme.tile.inactive_bg, degrees),
|
||||||
|
inactive_fg: rotate_color(theme.tile.inactive_fg, degrees),
|
||||||
|
active_selected_bg: rotate_color(theme.tile.active_selected_bg, degrees),
|
||||||
|
active_in_range_bg: rotate_color(theme.tile.active_in_range_bg, degrees),
|
||||||
|
link_bright: [
|
||||||
|
rotate_tuple(theme.tile.link_bright[0], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[1], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[2], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[3], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_bright[4], degrees),
|
||||||
|
],
|
||||||
|
link_dim: [
|
||||||
|
rotate_tuple(theme.tile.link_dim[0], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[1], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[2], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[3], degrees),
|
||||||
|
rotate_tuple(theme.tile.link_dim[4], degrees),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
header: HeaderColors {
|
||||||
|
tempo_bg: rotate_color(theme.header.tempo_bg, degrees),
|
||||||
|
tempo_fg: rotate_color(theme.header.tempo_fg, degrees),
|
||||||
|
bank_bg: rotate_color(theme.header.bank_bg, degrees),
|
||||||
|
bank_fg: rotate_color(theme.header.bank_fg, degrees),
|
||||||
|
pattern_bg: rotate_color(theme.header.pattern_bg, degrees),
|
||||||
|
pattern_fg: rotate_color(theme.header.pattern_fg, degrees),
|
||||||
|
stats_bg: rotate_color(theme.header.stats_bg, degrees),
|
||||||
|
stats_fg: rotate_color(theme.header.stats_fg, degrees),
|
||||||
|
},
|
||||||
|
modal: ModalColors {
|
||||||
|
border: rotate_color(theme.modal.border, degrees),
|
||||||
|
border_accent: rotate_color(theme.modal.border_accent, degrees),
|
||||||
|
border_warn: rotate_color(theme.modal.border_warn, degrees),
|
||||||
|
border_dim: rotate_color(theme.modal.border_dim, degrees),
|
||||||
|
confirm: rotate_color(theme.modal.confirm, degrees),
|
||||||
|
rename: rotate_color(theme.modal.rename, degrees),
|
||||||
|
input: rotate_color(theme.modal.input, degrees),
|
||||||
|
editor: rotate_color(theme.modal.editor, degrees),
|
||||||
|
preview: rotate_color(theme.modal.preview, degrees),
|
||||||
|
},
|
||||||
|
flash: FlashColors {
|
||||||
|
error_bg: rotate_color(theme.flash.error_bg, degrees),
|
||||||
|
error_fg: rotate_color(theme.flash.error_fg, degrees),
|
||||||
|
success_bg: rotate_color(theme.flash.success_bg, degrees),
|
||||||
|
success_fg: rotate_color(theme.flash.success_fg, degrees),
|
||||||
|
info_bg: rotate_color(theme.flash.info_bg, degrees),
|
||||||
|
info_fg: rotate_color(theme.flash.info_fg, degrees),
|
||||||
|
},
|
||||||
|
list: ListColors {
|
||||||
|
playing_bg: rotate_color(theme.list.playing_bg, degrees),
|
||||||
|
playing_fg: rotate_color(theme.list.playing_fg, degrees),
|
||||||
|
staged_play_bg: rotate_color(theme.list.staged_play_bg, degrees),
|
||||||
|
staged_play_fg: rotate_color(theme.list.staged_play_fg, degrees),
|
||||||
|
staged_stop_bg: rotate_color(theme.list.staged_stop_bg, degrees),
|
||||||
|
staged_stop_fg: rotate_color(theme.list.staged_stop_fg, degrees),
|
||||||
|
edit_bg: rotate_color(theme.list.edit_bg, degrees),
|
||||||
|
edit_fg: rotate_color(theme.list.edit_fg, degrees),
|
||||||
|
hover_bg: rotate_color(theme.list.hover_bg, degrees),
|
||||||
|
hover_fg: rotate_color(theme.list.hover_fg, degrees),
|
||||||
|
muted_bg: rotate_color(theme.list.muted_bg, degrees),
|
||||||
|
muted_fg: rotate_color(theme.list.muted_fg, degrees),
|
||||||
|
soloed_bg: rotate_color(theme.list.soloed_bg, degrees),
|
||||||
|
soloed_fg: rotate_color(theme.list.soloed_fg, degrees),
|
||||||
|
},
|
||||||
|
link_status: LinkStatusColors {
|
||||||
|
disabled: rotate_color(theme.link_status.disabled, degrees),
|
||||||
|
connected: rotate_color(theme.link_status.connected, degrees),
|
||||||
|
listening: rotate_color(theme.link_status.listening, degrees),
|
||||||
|
},
|
||||||
|
syntax: SyntaxColors {
|
||||||
|
gap_bg: rotate_color(theme.syntax.gap_bg, degrees),
|
||||||
|
executed_bg: rotate_color(theme.syntax.executed_bg, degrees),
|
||||||
|
selected_bg: rotate_color(theme.syntax.selected_bg, degrees),
|
||||||
|
emit: rotate_color_pair(theme.syntax.emit, degrees),
|
||||||
|
number: rotate_color_pair(theme.syntax.number, degrees),
|
||||||
|
string: rotate_color_pair(theme.syntax.string, degrees),
|
||||||
|
comment: rotate_color_pair(theme.syntax.comment, degrees),
|
||||||
|
keyword: rotate_color_pair(theme.syntax.keyword, degrees),
|
||||||
|
stack_op: rotate_color_pair(theme.syntax.stack_op, degrees),
|
||||||
|
operator: rotate_color_pair(theme.syntax.operator, degrees),
|
||||||
|
sound: rotate_color_pair(theme.syntax.sound, degrees),
|
||||||
|
param: rotate_color_pair(theme.syntax.param, degrees),
|
||||||
|
context: rotate_color_pair(theme.syntax.context, degrees),
|
||||||
|
note: rotate_color_pair(theme.syntax.note, degrees),
|
||||||
|
interval: rotate_color_pair(theme.syntax.interval, degrees),
|
||||||
|
variable: rotate_color_pair(theme.syntax.variable, degrees),
|
||||||
|
vary: rotate_color_pair(theme.syntax.vary, degrees),
|
||||||
|
generator: rotate_color_pair(theme.syntax.generator, degrees),
|
||||||
|
default: rotate_color_pair(theme.syntax.default, degrees),
|
||||||
|
},
|
||||||
|
table: TableColors {
|
||||||
|
row_even: rotate_color(theme.table.row_even, degrees),
|
||||||
|
row_odd: rotate_color(theme.table.row_odd, degrees),
|
||||||
|
},
|
||||||
|
values: ValuesColors {
|
||||||
|
tempo: rotate_color(theme.values.tempo, degrees),
|
||||||
|
value: rotate_color(theme.values.value, degrees),
|
||||||
|
},
|
||||||
|
hint: HintColors {
|
||||||
|
key: rotate_color(theme.hint.key, degrees),
|
||||||
|
text: rotate_color(theme.hint.text, degrees),
|
||||||
|
},
|
||||||
|
view_badge: ViewBadgeColors {
|
||||||
|
bg: rotate_color(theme.view_badge.bg, degrees),
|
||||||
|
fg: rotate_color(theme.view_badge.fg, degrees),
|
||||||
|
},
|
||||||
|
nav: NavColors {
|
||||||
|
selected_bg: rotate_color(theme.nav.selected_bg, degrees),
|
||||||
|
selected_fg: rotate_color(theme.nav.selected_fg, degrees),
|
||||||
|
unselected_bg: rotate_color(theme.nav.unselected_bg, degrees),
|
||||||
|
unselected_fg: rotate_color(theme.nav.unselected_fg, degrees),
|
||||||
|
},
|
||||||
|
editor_widget: EditorWidgetColors {
|
||||||
|
cursor_bg: rotate_color(theme.editor_widget.cursor_bg, degrees),
|
||||||
|
cursor_fg: rotate_color(theme.editor_widget.cursor_fg, degrees),
|
||||||
|
selection_bg: rotate_color(theme.editor_widget.selection_bg, degrees),
|
||||||
|
completion_bg: rotate_color(theme.editor_widget.completion_bg, degrees),
|
||||||
|
completion_fg: rotate_color(theme.editor_widget.completion_fg, degrees),
|
||||||
|
completion_selected: rotate_color(theme.editor_widget.completion_selected, degrees),
|
||||||
|
completion_example: rotate_color(theme.editor_widget.completion_example, degrees),
|
||||||
|
},
|
||||||
|
browser: BrowserColors {
|
||||||
|
directory: rotate_color(theme.browser.directory, degrees),
|
||||||
|
project_file: rotate_color(theme.browser.project_file, degrees),
|
||||||
|
selected: rotate_color(theme.browser.selected, degrees),
|
||||||
|
file: rotate_color(theme.browser.file, degrees),
|
||||||
|
focused_border: rotate_color(theme.browser.focused_border, degrees),
|
||||||
|
unfocused_border: rotate_color(theme.browser.unfocused_border, degrees),
|
||||||
|
root: rotate_color(theme.browser.root, degrees),
|
||||||
|
file_icon: rotate_color(theme.browser.file_icon, degrees),
|
||||||
|
folder_icon: rotate_color(theme.browser.folder_icon, degrees),
|
||||||
|
empty_text: rotate_color(theme.browser.empty_text, degrees),
|
||||||
|
},
|
||||||
|
input: InputColors {
|
||||||
|
text: rotate_color(theme.input.text, degrees),
|
||||||
|
cursor: rotate_color(theme.input.cursor, degrees),
|
||||||
|
hint: rotate_color(theme.input.hint, degrees),
|
||||||
|
},
|
||||||
|
search: SearchColors {
|
||||||
|
active: rotate_color(theme.search.active, degrees),
|
||||||
|
inactive: rotate_color(theme.search.inactive, degrees),
|
||||||
|
match_bg: rotate_color(theme.search.match_bg, degrees),
|
||||||
|
match_fg: rotate_color(theme.search.match_fg, degrees),
|
||||||
|
},
|
||||||
|
markdown: MarkdownColors {
|
||||||
|
h1: rotate_color(theme.markdown.h1, degrees),
|
||||||
|
h2: rotate_color(theme.markdown.h2, degrees),
|
||||||
|
h3: rotate_color(theme.markdown.h3, degrees),
|
||||||
|
code: rotate_color(theme.markdown.code, degrees),
|
||||||
|
code_border: rotate_color(theme.markdown.code_border, degrees),
|
||||||
|
link: rotate_color(theme.markdown.link, degrees),
|
||||||
|
link_url: rotate_color(theme.markdown.link_url, degrees),
|
||||||
|
quote: rotate_color(theme.markdown.quote, degrees),
|
||||||
|
text: rotate_color(theme.markdown.text, degrees),
|
||||||
|
list: rotate_color(theme.markdown.list, degrees),
|
||||||
|
},
|
||||||
|
engine: EngineColors {
|
||||||
|
header: rotate_color(theme.engine.header, degrees),
|
||||||
|
header_focused: rotate_color(theme.engine.header_focused, degrees),
|
||||||
|
divider: rotate_color(theme.engine.divider, degrees),
|
||||||
|
scroll_indicator: rotate_color(theme.engine.scroll_indicator, degrees),
|
||||||
|
label: rotate_color(theme.engine.label, degrees),
|
||||||
|
label_focused: rotate_color(theme.engine.label_focused, degrees),
|
||||||
|
label_dim: rotate_color(theme.engine.label_dim, degrees),
|
||||||
|
value: rotate_color(theme.engine.value, degrees),
|
||||||
|
focused: rotate_color(theme.engine.focused, degrees),
|
||||||
|
normal: rotate_color(theme.engine.normal, degrees),
|
||||||
|
dim: rotate_color(theme.engine.dim, degrees),
|
||||||
|
path: rotate_color(theme.engine.path, degrees),
|
||||||
|
border_magenta: rotate_color(theme.engine.border_magenta, degrees),
|
||||||
|
border_green: rotate_color(theme.engine.border_green, degrees),
|
||||||
|
border_cyan: rotate_color(theme.engine.border_cyan, degrees),
|
||||||
|
separator: rotate_color(theme.engine.separator, degrees),
|
||||||
|
hint_active: rotate_color(theme.engine.hint_active, degrees),
|
||||||
|
hint_inactive: rotate_color(theme.engine.hint_inactive, degrees),
|
||||||
|
},
|
||||||
|
dict: DictColors {
|
||||||
|
word_name: rotate_color(theme.dict.word_name, degrees),
|
||||||
|
word_bg: rotate_color(theme.dict.word_bg, degrees),
|
||||||
|
alias: rotate_color(theme.dict.alias, degrees),
|
||||||
|
stack_sig: rotate_color(theme.dict.stack_sig, degrees),
|
||||||
|
description: rotate_color(theme.dict.description, degrees),
|
||||||
|
example: rotate_color(theme.dict.example, degrees),
|
||||||
|
category_focused: rotate_color(theme.dict.category_focused, degrees),
|
||||||
|
category_selected: rotate_color(theme.dict.category_selected, degrees),
|
||||||
|
category_normal: rotate_color(theme.dict.category_normal, degrees),
|
||||||
|
category_dimmed: rotate_color(theme.dict.category_dimmed, degrees),
|
||||||
|
border_focused: rotate_color(theme.dict.border_focused, degrees),
|
||||||
|
border_normal: rotate_color(theme.dict.border_normal, degrees),
|
||||||
|
header_desc: rotate_color(theme.dict.header_desc, degrees),
|
||||||
|
},
|
||||||
|
title: TitleColors {
|
||||||
|
big_title: rotate_color(theme.title.big_title, degrees),
|
||||||
|
author: rotate_color(theme.title.author, degrees),
|
||||||
|
link: rotate_color(theme.title.link, degrees),
|
||||||
|
license: rotate_color(theme.title.license, degrees),
|
||||||
|
prompt: rotate_color(theme.title.prompt, degrees),
|
||||||
|
subtitle: rotate_color(theme.title.subtitle, degrees),
|
||||||
|
},
|
||||||
|
meter: MeterColors {
|
||||||
|
low: rotate_color(theme.meter.low, degrees),
|
||||||
|
mid: rotate_color(theme.meter.mid, degrees),
|
||||||
|
high: rotate_color(theme.meter.high, degrees),
|
||||||
|
low_rgb: rotate_tuple(theme.meter.low_rgb, degrees),
|
||||||
|
mid_rgb: rotate_tuple(theme.meter.mid_rgb, degrees),
|
||||||
|
high_rgb: rotate_tuple(theme.meter.high_rgb, degrees),
|
||||||
|
},
|
||||||
|
sparkle: SparkleColors {
|
||||||
|
colors: [
|
||||||
|
rotate_tuple(theme.sparkle.colors[0], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[1], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[2], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[3], degrees),
|
||||||
|
rotate_tuple(theme.sparkle.colors[4], degrees),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: ConfirmColors {
|
||||||
|
border: rotate_color(theme.confirm.border, degrees),
|
||||||
|
button_selected_bg: rotate_color(theme.confirm.button_selected_bg, degrees),
|
||||||
|
button_selected_fg: rotate_color(theme.confirm.button_selected_fg, degrees),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@ allow-branch = ["main"]
|
|||||||
sign-commit = false
|
sign-commit = false
|
||||||
sign-tag = false
|
sign-tag = false
|
||||||
push = true
|
push = true
|
||||||
|
push-remote = "github"
|
||||||
publish = false
|
publish = false
|
||||||
tag-name = "v{{version}}"
|
tag-name = "v{{version}}"
|
||||||
|
|||||||
188
src/app.rs
188
src/app.rs
@@ -17,8 +17,8 @@ use crate::services::pattern_editor;
|
|||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
||||||
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
|
||||||
ProjectState, StagedChange, UiState,
|
PlaybackState, ProjectState, StagedChange, UiState,
|
||||||
};
|
};
|
||||||
use crate::views::{dict_view, help_view};
|
use crate::views::{dict_view, help_view};
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ pub struct App {
|
|||||||
pub project_state: ProjectState,
|
pub project_state: ProjectState,
|
||||||
pub ui: UiState,
|
pub ui: UiState,
|
||||||
pub playback: PlaybackState,
|
pub playback: PlaybackState,
|
||||||
|
pub mute: MuteState,
|
||||||
|
|
||||||
pub page: Page,
|
pub page: Page,
|
||||||
pub editor_ctx: EditorContext,
|
pub editor_ctx: EditorContext,
|
||||||
@@ -69,6 +70,7 @@ impl App {
|
|||||||
project_state: ProjectState::default(),
|
project_state: ProjectState::default(),
|
||||||
ui: UiState::default(),
|
ui: UiState::default(),
|
||||||
playback: PlaybackState::default(),
|
playback: PlaybackState::default(),
|
||||||
|
mute: MuteState::default(),
|
||||||
|
|
||||||
page: Page::default(),
|
page: Page::default(),
|
||||||
editor_ctx: EditorContext::default(),
|
editor_ctx: EditorContext::default(),
|
||||||
@@ -108,8 +110,9 @@ 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,
|
|
||||||
color_scheme: self.ui.color_scheme,
|
color_scheme: self.ui.color_scheme,
|
||||||
|
layout: self.audio.config.layout,
|
||||||
|
hue_rotation: self.ui.hue_rotation,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
link: crate::settings::LinkSettings {
|
link: crate::settings::LinkSettings {
|
||||||
@@ -492,26 +495,64 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit_staged_changes(&mut self) {
|
/// Commits staged pattern and mute/solo changes.
|
||||||
if self.playback.staged_changes.is_empty() {
|
/// Returns true if mute state changed (caller should send to sequencer).
|
||||||
|
pub fn commit_staged_changes(&mut self) -> bool {
|
||||||
|
let pattern_count = self.playback.staged_changes.len();
|
||||||
|
let mute_count = self.playback.staged_mute_changes.len();
|
||||||
|
|
||||||
|
if pattern_count == 0 && mute_count == 0 {
|
||||||
self.ui.set_status("No changes to commit".to_string());
|
self.ui.set_status("No changes to commit".to_string());
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let count = self.playback.staged_changes.len();
|
|
||||||
self.playback
|
// Commit pattern changes (queued for quantization)
|
||||||
.queued_changes
|
if pattern_count > 0 {
|
||||||
.append(&mut self.playback.staged_changes);
|
self.playback
|
||||||
self.ui.set_status(format!("Committed {count} changes"));
|
.queued_changes
|
||||||
|
.append(&mut self.playback.staged_changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mute/solo changes immediately
|
||||||
|
let mute_changed = mute_count > 0;
|
||||||
|
for change in self.playback.staged_mute_changes.drain() {
|
||||||
|
match change {
|
||||||
|
crate::state::StagedMuteChange::ToggleMute { bank, pattern } => {
|
||||||
|
self.mute.toggle_mute(bank, pattern);
|
||||||
|
}
|
||||||
|
crate::state::StagedMuteChange::ToggleSolo { bank, pattern } => {
|
||||||
|
self.mute.toggle_solo(bank, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = match (pattern_count, mute_count) {
|
||||||
|
(0, m) => format!("Applied {m} mute/solo changes"),
|
||||||
|
(p, 0) => format!("Committed {p} pattern changes"),
|
||||||
|
(p, m) => format!("Committed {p} pattern + {m} mute/solo changes"),
|
||||||
|
};
|
||||||
|
self.ui.set_status(status);
|
||||||
|
|
||||||
|
mute_changed
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_staged_changes(&mut self) {
|
pub fn clear_staged_changes(&mut self) {
|
||||||
if self.playback.staged_changes.is_empty() {
|
let pattern_count = self.playback.staged_changes.len();
|
||||||
|
let mute_count = self.playback.staged_mute_changes.len();
|
||||||
|
|
||||||
|
if pattern_count == 0 && mute_count == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let count = self.playback.staged_changes.len();
|
|
||||||
self.playback.staged_changes.clear();
|
self.playback.staged_changes.clear();
|
||||||
self.ui
|
self.playback.staged_mute_changes.clear();
|
||||||
.set_status(format!("Cleared {count} staged changes"));
|
|
||||||
|
let status = match (pattern_count, mute_count) {
|
||||||
|
(0, m) => format!("Cleared {m} staged mute/solo"),
|
||||||
|
(p, 0) => format!("Cleared {p} staged patterns"),
|
||||||
|
(p, m) => format!("Cleared {p} patterns + {m} mute/solo"),
|
||||||
|
};
|
||||||
|
self.ui.set_status(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select_edit_pattern(&mut self, pattern: usize) {
|
pub fn select_edit_pattern(&mut self, pattern: usize) {
|
||||||
@@ -1092,9 +1133,6 @@ impl App {
|
|||||||
AppCommand::DuplicateSteps => self.duplicate_steps(link),
|
AppCommand::DuplicateSteps => self.duplicate_steps(link),
|
||||||
|
|
||||||
// Pattern playback (staging)
|
// Pattern playback (staging)
|
||||||
AppCommand::CommitStagedChanges => {
|
|
||||||
self.commit_staged_changes();
|
|
||||||
}
|
|
||||||
AppCommand::ClearStagedChanges => {
|
AppCommand::ClearStagedChanges => {
|
||||||
self.clear_staged_changes();
|
self.clear_staged_changes();
|
||||||
}
|
}
|
||||||
@@ -1298,6 +1336,22 @@ impl App {
|
|||||||
self.stage_pattern_toggle(bank, pattern, snapshot);
|
self.stage_pattern_toggle(bank, pattern, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mute/Solo (staged)
|
||||||
|
AppCommand::StageMute { bank, pattern } => {
|
||||||
|
self.playback.stage_mute(bank, pattern);
|
||||||
|
}
|
||||||
|
AppCommand::StageSolo { bank, pattern } => {
|
||||||
|
self.playback.stage_solo(bank, pattern);
|
||||||
|
}
|
||||||
|
AppCommand::ClearMutes => {
|
||||||
|
self.playback.clear_staged_mutes();
|
||||||
|
self.mute.clear_mute();
|
||||||
|
}
|
||||||
|
AppCommand::ClearSolos => {
|
||||||
|
self.playback.clear_staged_solos();
|
||||||
|
self.mute.clear_solo();
|
||||||
|
}
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
AppCommand::ClearMinimap => {
|
AppCommand::ClearMinimap => {
|
||||||
self.ui.minimap_until = None;
|
self.ui.minimap_until = None;
|
||||||
@@ -1310,7 +1364,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
AppCommand::SetColorScheme(scheme) => {
|
AppCommand::SetColorScheme(scheme) => {
|
||||||
self.ui.color_scheme = scheme;
|
self.ui.color_scheme = scheme;
|
||||||
crate::theme::set(scheme.to_theme());
|
let base_theme = scheme.to_theme();
|
||||||
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, self.ui.hue_rotation);
|
||||||
|
crate::theme::set(rotated);
|
||||||
|
}
|
||||||
|
AppCommand::SetHueRotation(degrees) => {
|
||||||
|
self.ui.hue_rotation = degrees;
|
||||||
|
let base_theme = self.ui.color_scheme.to_theme();
|
||||||
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, degrees);
|
||||||
|
crate::theme::set(rotated);
|
||||||
}
|
}
|
||||||
AppCommand::ToggleRuntimeHighlight => {
|
AppCommand::ToggleRuntimeHighlight => {
|
||||||
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
||||||
@@ -1321,10 +1383,6 @@ impl App {
|
|||||||
.editor
|
.editor
|
||||||
.set_completion_enabled(self.ui.show_completion);
|
.set_completion_enabled(self.ui.show_completion);
|
||||||
}
|
}
|
||||||
AppCommand::AdjustFlashBrightness(delta) => {
|
|
||||||
self.ui.flash_brightness = (self.ui.flash_brightness + delta).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live keys
|
// Live keys
|
||||||
AppCommand::ToggleLiveKeysFill => {
|
AppCommand::ToggleLiveKeysFill => {
|
||||||
self.live_keys.flip_fill();
|
self.live_keys.flip_fill();
|
||||||
@@ -1428,6 +1486,65 @@ impl App {
|
|||||||
AppCommand::ResetPeakVoices => {
|
AppCommand::ResetPeakVoices => {
|
||||||
self.metrics.peak_voices = 0;
|
self.metrics.peak_voices = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Euclidean distribution
|
||||||
|
AppCommand::ApplyEuclideanDistribution {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
source_step,
|
||||||
|
pulses,
|
||||||
|
steps,
|
||||||
|
rotation,
|
||||||
|
} => {
|
||||||
|
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
|
||||||
|
let rhythm = euclidean_rhythm(pulses, steps, rotation);
|
||||||
|
|
||||||
|
let mut created_count = 0;
|
||||||
|
for (i, &is_hit) in rhythm.iter().enumerate() {
|
||||||
|
if !is_hit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = (source_step + i) % pat_len;
|
||||||
|
|
||||||
|
if target == source_step {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(step) = self
|
||||||
|
.project_state
|
||||||
|
.project
|
||||||
|
.pattern_at_mut(bank, pattern)
|
||||||
|
.step_mut(target)
|
||||||
|
{
|
||||||
|
step.source = Some(source_step);
|
||||||
|
step.script.clear();
|
||||||
|
step.command = None;
|
||||||
|
step.active = true;
|
||||||
|
}
|
||||||
|
created_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.project_state.mark_dirty(bank, pattern);
|
||||||
|
|
||||||
|
for (i, &is_hit) in rhythm.iter().enumerate() {
|
||||||
|
if !is_hit || i == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = (source_step + i) % pat_len;
|
||||||
|
let saved = self.editor_ctx.step;
|
||||||
|
self.editor_ctx.step = target;
|
||||||
|
self.compile_current_step(link);
|
||||||
|
self.editor_ctx.step = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.load_step_to_editor();
|
||||||
|
self.ui.flash(
|
||||||
|
&format!("Created {} linked steps (E({pulses},{steps}))", created_count),
|
||||||
|
200,
|
||||||
|
FlashKind::Success,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1453,6 +1570,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn send_mute_state(&self, cmd_tx: &Sender<SeqCommand>) {
|
||||||
|
let _ = cmd_tx.send(SeqCommand::SetMuteState {
|
||||||
|
muted: self.mute.muted.clone(),
|
||||||
|
soloed: self.mute.soloed.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
||||||
for (bank, pattern) in self.project_state.take_dirty() {
|
for (bank, pattern) in self.project_state.take_dirty() {
|
||||||
let pat = self.project_state.project.pattern_at(bank, pattern);
|
let pat = self.project_state.project.pattern_at(bank, pattern);
|
||||||
@@ -1480,3 +1604,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec<bool> {
|
||||||
|
if pulses == 0 || steps == 0 || pulses > steps {
|
||||||
|
return vec![false; steps];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pattern = vec![false; steps];
|
||||||
|
for i in 0..pulses {
|
||||||
|
let pos = (i * steps) / pulses;
|
||||||
|
pattern[pos] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rotation > 0 {
|
||||||
|
pattern.rotate_left(rotation % steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ impl CagireDesktop {
|
|||||||
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());
|
||||||
@@ -419,17 +418,6 @@ impl eframe::App for CagireDesktop {
|
|||||||
self.app.metrics.event_count = seq_snapshot.event_count;
|
self.app.metrics.event_count = seq_snapshot.event_count;
|
||||||
self.app.metrics.dropped_events = seq_snapshot.dropped_events;
|
self.app.metrics.dropped_events = seq_snapshot.dropped_events;
|
||||||
|
|
||||||
self.app.ui.event_flash = (self.app.ui.event_flash - 0.1).max(0.0);
|
|
||||||
let new_events = self
|
|
||||||
.app
|
|
||||||
.metrics
|
|
||||||
.event_count
|
|
||||||
.saturating_sub(self.app.ui.last_event_count);
|
|
||||||
if new_events > 0 {
|
|
||||||
self.app.ui.event_flash = (new_events as f32 * 0.4).min(1.0);
|
|
||||||
}
|
|
||||||
self.app.ui.last_event_count = self.app.metrics.event_count;
|
|
||||||
|
|
||||||
self.app.flush_queued_changes(&sequencer.cmd_tx);
|
self.app.flush_queued_changes(&sequencer.cmd_tx);
|
||||||
self.app.flush_dirty_patterns(&sequencer.cmd_tx);
|
self.app.flush_dirty_patterns(&sequencer.cmd_tx);
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ pub enum AppCommand {
|
|||||||
DuplicateSteps,
|
DuplicateSteps,
|
||||||
|
|
||||||
// Pattern playback (staging)
|
// Pattern playback (staging)
|
||||||
CommitStagedChanges,
|
|
||||||
ClearStagedChanges,
|
ClearStagedChanges,
|
||||||
|
|
||||||
// Project
|
// Project
|
||||||
@@ -156,14 +155,20 @@ pub enum AppCommand {
|
|||||||
PatternsBack,
|
PatternsBack,
|
||||||
PatternsTogglePlay,
|
PatternsTogglePlay,
|
||||||
|
|
||||||
|
// Mute/Solo (staged)
|
||||||
|
StageMute { bank: usize, pattern: usize },
|
||||||
|
StageSolo { bank: usize, pattern: usize },
|
||||||
|
ClearMutes, // Clears both staged and applied mutes
|
||||||
|
ClearSolos, // Clears both staged and applied solos
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
ClearMinimap,
|
ClearMinimap,
|
||||||
HideTitle,
|
HideTitle,
|
||||||
ToggleEditorStack,
|
ToggleEditorStack,
|
||||||
SetColorScheme(ColorScheme),
|
SetColorScheme(ColorScheme),
|
||||||
|
SetHueRotation(f32),
|
||||||
ToggleRuntimeHighlight,
|
ToggleRuntimeHighlight,
|
||||||
ToggleCompletion,
|
ToggleCompletion,
|
||||||
AdjustFlashBrightness(f32),
|
|
||||||
|
|
||||||
// Live keys
|
// Live keys
|
||||||
ToggleLiveKeysFill,
|
ToggleLiveKeysFill,
|
||||||
@@ -207,4 +212,13 @@ pub enum AppCommand {
|
|||||||
// Metrics
|
// Metrics
|
||||||
ResetPeakVoices,
|
ResetPeakVoices,
|
||||||
|
|
||||||
|
// Euclidean distribution
|
||||||
|
ApplyEuclideanDistribution {
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
source_step: usize,
|
||||||
|
pulses: usize,
|
||||||
|
steps: usize,
|
||||||
|
rotation: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ pub fn build_stream(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
|
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
|
||||||
let sample_rate = default_config.sample_rate().0 as f32;
|
let sample_rate = default_config.sample_rate() as f32;
|
||||||
|
|
||||||
let buffer_size = if config.buffer_size > 0 {
|
let buffer_size = if config.buffer_size > 0 {
|
||||||
cpal::BufferSize::Fixed(config.buffer_size)
|
cpal::BufferSize::Fixed(config.buffer_size)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use std::sync::atomic::{AtomicI64, AtomicU64};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thread_priority::{set_current_thread_priority, ThreadPriority};
|
use thread_priority::ThreadPriority;
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
use thread_priority::set_current_thread_priority;
|
||||||
|
|
||||||
use super::LinkState;
|
use super::LinkState;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
@@ -115,6 +117,10 @@ pub enum SeqCommand {
|
|||||||
pattern: usize,
|
pattern: usize,
|
||||||
quantization: LaunchQuantization,
|
quantization: LaunchQuantization,
|
||||||
},
|
},
|
||||||
|
SetMuteState {
|
||||||
|
muted: std::collections::HashSet<(usize, usize)>,
|
||||||
|
soloed: std::collections::HashSet<(usize, usize)>,
|
||||||
|
},
|
||||||
StopAll,
|
StopAll,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
@@ -432,6 +438,25 @@ fn check_quantization_boundary(
|
|||||||
|
|
||||||
type StepKey = (usize, usize, usize);
|
type StepKey = (usize, usize, usize);
|
||||||
|
|
||||||
|
/// Tracks a step that has been pre-evaluated via lookahead scheduling.
|
||||||
|
/// Used to prevent duplicate evaluation when the step's actual fire time arrives.
|
||||||
|
struct ScheduledStep {
|
||||||
|
target_beat: f64,
|
||||||
|
tempo_at_schedule: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An audio command scheduled for future emission.
|
||||||
|
/// Commands are held here until their target_beat passes, then emitted to the audio engine.
|
||||||
|
struct PendingCommand {
|
||||||
|
cmd: TimestampedCommand,
|
||||||
|
target_beat: f64,
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key for tracking scheduled steps: (bank, pattern, step_index, beat_int)
|
||||||
|
type ScheduledStepKey = (usize, usize, usize, i64);
|
||||||
|
|
||||||
struct RunsCounter {
|
struct RunsCounter {
|
||||||
counts: HashMap<StepKey, usize>,
|
counts: HashMap<StepKey, usize>,
|
||||||
}
|
}
|
||||||
@@ -551,6 +576,12 @@ pub(crate) struct SequencerState {
|
|||||||
buf_audio_commands: Vec<TimestampedCommand>,
|
buf_audio_commands: Vec<TimestampedCommand>,
|
||||||
cc_access: Option<Arc<dyn CcAccess>>,
|
cc_access: Option<Arc<dyn CcAccess>>,
|
||||||
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
||||||
|
muted: std::collections::HashSet<(usize, usize)>,
|
||||||
|
soloed: std::collections::HashSet<(usize, usize)>,
|
||||||
|
// Lookahead scheduling state
|
||||||
|
scheduled_steps: HashMap<(usize, usize, usize, i64), ScheduledStep>,
|
||||||
|
pending_commands: Vec<PendingCommand>,
|
||||||
|
last_tempo: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SequencerState {
|
impl SequencerState {
|
||||||
@@ -575,9 +606,25 @@ impl SequencerState {
|
|||||||
buf_audio_commands: Vec::new(),
|
buf_audio_commands: Vec::new(),
|
||||||
cc_access,
|
cc_access,
|
||||||
active_notes: HashMap::new(),
|
active_notes: HashMap::new(),
|
||||||
|
muted: std::collections::HashSet::new(),
|
||||||
|
soloed: std::collections::HashSet::new(),
|
||||||
|
scheduled_steps: HashMap::new(),
|
||||||
|
pending_commands: Vec::new(),
|
||||||
|
last_tempo: 120.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
let key = (bank, pattern);
|
||||||
|
if self.muted.contains(&key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !self.soloed.is_empty() && !self.soloed.contains(&key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn process_commands(&mut self, commands: Vec<SeqCommand>) {
|
fn process_commands(&mut self, commands: Vec<SeqCommand>) {
|
||||||
for cmd in commands {
|
for cmd in commands {
|
||||||
match cmd {
|
match cmd {
|
||||||
@@ -619,6 +666,28 @@ impl SequencerState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SeqCommand::SetMuteState { muted, soloed } => {
|
||||||
|
let newly_muted: Vec<(usize, usize)> = self
|
||||||
|
.audio_state
|
||||||
|
.active_patterns
|
||||||
|
.keys()
|
||||||
|
.filter(|id| {
|
||||||
|
let key = (id.bank, id.pattern);
|
||||||
|
let was_muted = self.is_effectively_muted(id.bank, id.pattern);
|
||||||
|
let now_muted = muted.contains(&key)
|
||||||
|
|| (!soloed.is_empty() && !soloed.contains(&key));
|
||||||
|
!was_muted && now_muted
|
||||||
|
})
|
||||||
|
.map(|id| (id.bank, id.pattern))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.muted = muted;
|
||||||
|
self.soloed = soloed;
|
||||||
|
|
||||||
|
if !newly_muted.is_empty() {
|
||||||
|
self.audio_state.flush_midi_notes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
SeqCommand::StopAll => {
|
SeqCommand::StopAll => {
|
||||||
self.audio_state.active_patterns.clear();
|
self.audio_state.active_patterns.clear();
|
||||||
self.audio_state.pending_starts.clear();
|
self.audio_state.pending_starts.clear();
|
||||||
@@ -626,6 +695,8 @@ impl SequencerState {
|
|||||||
Arc::make_mut(&mut self.step_traces).clear();
|
Arc::make_mut(&mut self.step_traces).clear();
|
||||||
self.runs_counter.counts.clear();
|
self.runs_counter.counts.clear();
|
||||||
self.audio_state.flush_midi_notes = true;
|
self.audio_state.flush_midi_notes = true;
|
||||||
|
self.scheduled_steps.clear();
|
||||||
|
self.pending_commands.clear();
|
||||||
}
|
}
|
||||||
SeqCommand::Shutdown => {}
|
SeqCommand::Shutdown => {}
|
||||||
}
|
}
|
||||||
@@ -744,12 +815,65 @@ impl SequencerState {
|
|||||||
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||||
bank != pending.id.bank || pattern != pending.id.pattern
|
bank != pending.id.bank || pattern != pending.id.pattern
|
||||||
});
|
});
|
||||||
|
// Clear scheduled steps and pending commands for this pattern
|
||||||
|
let (b, p) = (pending.id.bank, pending.id.pattern);
|
||||||
|
self.scheduled_steps
|
||||||
|
.retain(|&(bank, pattern, _, _), _| bank != b || pattern != p);
|
||||||
|
self.pending_commands
|
||||||
|
.retain(|cmd| cmd.bank != b || cmd.pattern != p);
|
||||||
stopped.push(pending.id);
|
stopped.push(pending.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stopped
|
stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a logical beat position to engine time for audio scheduling.
|
||||||
|
fn beat_to_engine_time(
|
||||||
|
target_beat: f64,
|
||||||
|
current_beat: f64,
|
||||||
|
engine_time: f64,
|
||||||
|
tempo: f64,
|
||||||
|
) -> f64 {
|
||||||
|
let beats_ahead = target_beat - current_beat;
|
||||||
|
let secs_ahead = beats_ahead * 60.0 / tempo;
|
||||||
|
engine_time + secs_ahead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reschedule all pending commands when tempo changes.
|
||||||
|
fn reschedule_for_tempo_change(
|
||||||
|
&mut self,
|
||||||
|
new_tempo: f64,
|
||||||
|
current_beat: f64,
|
||||||
|
engine_time: f64,
|
||||||
|
) {
|
||||||
|
for pending in &mut self.pending_commands {
|
||||||
|
if pending.cmd.time.is_some() {
|
||||||
|
pending.cmd.time = Some(Self::beat_to_engine_time(
|
||||||
|
pending.target_beat,
|
||||||
|
current_beat,
|
||||||
|
engine_time,
|
||||||
|
new_tempo,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for step in self.scheduled_steps.values_mut() {
|
||||||
|
step.tempo_at_schedule = new_tempo;
|
||||||
|
}
|
||||||
|
self.last_tempo = new_tempo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main step execution with lookahead scheduling support.
|
||||||
|
///
|
||||||
|
/// This function handles two timing modes:
|
||||||
|
/// 1. **Immediate firing**: When a beat boundary is crossed (`beat_int != prev_beat_int`),
|
||||||
|
/// the current step fires. If already pre-evaluated via lookahead, we skip evaluation.
|
||||||
|
/// 2. **Lookahead pre-evaluation**: When `lookahead_secs > 0`, we pre-evaluate future steps
|
||||||
|
/// and queue their commands with precise timestamps for later emission.
|
||||||
|
///
|
||||||
|
/// The lookahead scheduling improves timing accuracy by:
|
||||||
|
/// - Evaluating scripts BEFORE their logical fire time
|
||||||
|
/// - Scheduling audio commands at exact beat positions using engine time
|
||||||
|
/// - Allowing the audio engine to play sounds at the precise moment
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn execute_steps(
|
fn execute_steps(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -772,6 +896,12 @@ impl SequencerState {
|
|||||||
any_step_fired: false,
|
any_step_fired: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reschedule pending commands if tempo changed
|
||||||
|
if (tempo - self.last_tempo).abs() > 0.001 {
|
||||||
|
self.reschedule_for_tempo_change(tempo, beat, engine_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load speed overrides from variables
|
||||||
self.speed_overrides.clear();
|
self.speed_overrides.clear();
|
||||||
{
|
{
|
||||||
let vars = self.variables.lock().unwrap();
|
let vars = self.variables.lock().unwrap();
|
||||||
@@ -783,6 +913,15 @@ impl SequencerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let muted_snapshot = self.muted.clone();
|
||||||
|
let soloed_snapshot = self.soloed.clone();
|
||||||
|
let lookahead_beats = if tempo > 0.0 {
|
||||||
|
lookahead_secs * tempo / 60.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each active pattern
|
||||||
for (_id, active) in self.audio_state.active_patterns.iter_mut() {
|
for (_id, active) in self.audio_state.active_patterns.iter_mut() {
|
||||||
let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else {
|
let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -793,76 +932,94 @@ impl SequencerState {
|
|||||||
.get(&(active.bank, active.pattern))
|
.get(&(active.bank, active.pattern))
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or_else(|| pattern.speed.multiplier());
|
.unwrap_or_else(|| pattern.speed.multiplier());
|
||||||
|
|
||||||
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
|
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
|
||||||
let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64;
|
let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64;
|
||||||
|
let step_fires = beat_int != prev_beat_int && prev_beat >= 0.0;
|
||||||
|
|
||||||
if beat_int != prev_beat_int && prev_beat >= 0.0 {
|
// === IMMEDIATE STEP EXECUTION ===
|
||||||
|
// Fire the current step if a beat boundary was crossed
|
||||||
|
if step_fires {
|
||||||
result.any_step_fired = true;
|
result.any_step_fired = true;
|
||||||
let step_idx = active.step_index % pattern.length;
|
let step_idx = active.step_index % pattern.length;
|
||||||
|
let sched_key: ScheduledStepKey =
|
||||||
|
(active.bank, active.pattern, step_idx, beat_int);
|
||||||
|
|
||||||
if let Some(step) = pattern.steps.get(step_idx) {
|
// Skip evaluation if already done via lookahead
|
||||||
let resolved_script = pattern.resolve_script(step_idx);
|
if !self.scheduled_steps.contains_key(&sched_key) {
|
||||||
let has_script = resolved_script
|
if let Some(step) = pattern.steps.get(step_idx) {
|
||||||
.map(|s| !s.trim().is_empty())
|
let resolved_script = pattern.resolve_script(step_idx);
|
||||||
.unwrap_or(false);
|
let has_script = resolved_script
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
if step.active && has_script {
|
if step.active && has_script {
|
||||||
let source_idx = pattern.resolve_source(step_idx);
|
let pattern_key = (active.bank, active.pattern);
|
||||||
let runs = self.runs_counter.get_and_increment(
|
let is_muted = muted_snapshot.contains(&pattern_key)
|
||||||
active.bank,
|
|| (!soloed_snapshot.is_empty()
|
||||||
active.pattern,
|
&& !soloed_snapshot.contains(&pattern_key));
|
||||||
source_idx,
|
|
||||||
);
|
if !is_muted {
|
||||||
let ctx = StepContext {
|
let source_idx = pattern.resolve_source(step_idx);
|
||||||
step: step_idx,
|
let runs = self.runs_counter.get_and_increment(
|
||||||
beat,
|
active.bank,
|
||||||
bank: active.bank,
|
active.pattern,
|
||||||
pattern: active.pattern,
|
source_idx,
|
||||||
tempo,
|
|
||||||
phase: beat % quantum,
|
|
||||||
slot: 0,
|
|
||||||
runs,
|
|
||||||
iter: active.iter,
|
|
||||||
speed: speed_mult,
|
|
||||||
fill,
|
|
||||||
nudge_secs,
|
|
||||||
cc_access: self.cc_access.clone(),
|
|
||||||
#[cfg(feature = "desktop")]
|
|
||||||
mouse_x,
|
|
||||||
#[cfg(feature = "desktop")]
|
|
||||||
mouse_y,
|
|
||||||
#[cfg(feature = "desktop")]
|
|
||||||
mouse_down,
|
|
||||||
};
|
|
||||||
if let Some(script) = resolved_script {
|
|
||||||
let mut trace = ExecutionTrace::default();
|
|
||||||
if let Ok(cmds) = self
|
|
||||||
.script_engine
|
|
||||||
.evaluate_with_trace(script, &ctx, &mut trace)
|
|
||||||
{
|
|
||||||
Arc::make_mut(&mut self.step_traces).insert(
|
|
||||||
(active.bank, active.pattern, source_idx),
|
|
||||||
std::mem::take(&mut trace),
|
|
||||||
);
|
);
|
||||||
|
let ctx = StepContext {
|
||||||
let event_time = if lookahead_secs > 0.0 {
|
step: step_idx,
|
||||||
Some(engine_time + lookahead_secs)
|
beat,
|
||||||
} else {
|
bank: active.bank,
|
||||||
None
|
pattern: active.pattern,
|
||||||
|
tempo,
|
||||||
|
phase: beat % quantum,
|
||||||
|
slot: 0,
|
||||||
|
runs,
|
||||||
|
iter: active.iter,
|
||||||
|
speed: speed_mult,
|
||||||
|
fill,
|
||||||
|
nudge_secs,
|
||||||
|
cc_access: self.cc_access.clone(),
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_x,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_y,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_down,
|
||||||
};
|
};
|
||||||
|
|
||||||
for cmd in cmds {
|
if let Some(script) = resolved_script {
|
||||||
self.event_count += 1;
|
let mut trace = ExecutionTrace::default();
|
||||||
self.buf_audio_commands.push(TimestampedCommand {
|
if let Ok(cmds) = self
|
||||||
cmd,
|
.script_engine
|
||||||
time: event_time,
|
.evaluate_with_trace(script, &ctx, &mut trace)
|
||||||
});
|
{
|
||||||
|
Arc::make_mut(&mut self.step_traces).insert(
|
||||||
|
(active.bank, active.pattern, source_idx),
|
||||||
|
std::mem::take(&mut trace),
|
||||||
|
);
|
||||||
|
|
||||||
|
let event_time = if lookahead_secs > 0.0 {
|
||||||
|
Some(engine_time + lookahead_secs)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
for cmd in cmds {
|
||||||
|
self.event_count += 1;
|
||||||
|
self.buf_audio_commands.push(TimestampedCommand {
|
||||||
|
cmd,
|
||||||
|
time: event_time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advance step index
|
||||||
let next_step = active.step_index + 1;
|
let next_step = active.step_index + 1;
|
||||||
if next_step >= pattern.length {
|
if next_step >= pattern.length {
|
||||||
active.iter += 1;
|
active.iter += 1;
|
||||||
@@ -873,8 +1030,144 @@ impl SequencerState {
|
|||||||
}
|
}
|
||||||
active.step_index = next_step % pattern.length;
|
active.step_index = next_step % pattern.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === LOOKAHEAD PRE-EVALUATION ===
|
||||||
|
// Pre-evaluate future steps within the lookahead window
|
||||||
|
if lookahead_secs > 0.0 {
|
||||||
|
let future_beat = beat + lookahead_beats;
|
||||||
|
let future_beat_int = (future_beat * 4.0 * speed_mult).floor() as i64;
|
||||||
|
let start_beat_int = beat_int + 1;
|
||||||
|
|
||||||
|
let mut lookahead_step = active.step_index;
|
||||||
|
let mut lookahead_iter = active.iter;
|
||||||
|
|
||||||
|
for target_beat_int in start_beat_int..=future_beat_int {
|
||||||
|
let step_idx = lookahead_step % pattern.length;
|
||||||
|
let sched_key: ScheduledStepKey =
|
||||||
|
(active.bank, active.pattern, step_idx, target_beat_int);
|
||||||
|
|
||||||
|
// Skip if already scheduled
|
||||||
|
if self.scheduled_steps.contains_key(&sched_key) {
|
||||||
|
let next = lookahead_step + 1;
|
||||||
|
if next >= pattern.length {
|
||||||
|
lookahead_iter += 1;
|
||||||
|
}
|
||||||
|
lookahead_step = next % pattern.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the logical beat time for this step
|
||||||
|
let target_beat = target_beat_int as f64 / (4.0 * speed_mult);
|
||||||
|
|
||||||
|
if let Some(step) = pattern.steps.get(step_idx) {
|
||||||
|
let resolved_script = pattern.resolve_script(step_idx);
|
||||||
|
let has_script = resolved_script
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if step.active && has_script {
|
||||||
|
let pattern_key = (active.bank, active.pattern);
|
||||||
|
let is_muted = muted_snapshot.contains(&pattern_key)
|
||||||
|
|| (!soloed_snapshot.is_empty()
|
||||||
|
&& !soloed_snapshot.contains(&pattern_key));
|
||||||
|
|
||||||
|
if !is_muted {
|
||||||
|
let source_idx = pattern.resolve_source(step_idx);
|
||||||
|
let runs = self.runs_counter.get_and_increment(
|
||||||
|
active.bank,
|
||||||
|
active.pattern,
|
||||||
|
source_idx,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ctx = StepContext {
|
||||||
|
step: step_idx,
|
||||||
|
beat: target_beat,
|
||||||
|
bank: active.bank,
|
||||||
|
pattern: active.pattern,
|
||||||
|
tempo,
|
||||||
|
phase: target_beat % quantum,
|
||||||
|
slot: 0,
|
||||||
|
runs,
|
||||||
|
iter: lookahead_iter,
|
||||||
|
speed: speed_mult,
|
||||||
|
fill,
|
||||||
|
nudge_secs,
|
||||||
|
cc_access: self.cc_access.clone(),
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_x,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_y,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_down,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(script) = resolved_script {
|
||||||
|
let mut trace = ExecutionTrace::default();
|
||||||
|
if let Ok(cmds) = self
|
||||||
|
.script_engine
|
||||||
|
.evaluate_with_trace(script, &ctx, &mut trace)
|
||||||
|
{
|
||||||
|
Arc::make_mut(&mut self.step_traces).insert(
|
||||||
|
(active.bank, active.pattern, source_idx),
|
||||||
|
std::mem::take(&mut trace),
|
||||||
|
);
|
||||||
|
|
||||||
|
let event_time = Some(Self::beat_to_engine_time(
|
||||||
|
target_beat,
|
||||||
|
beat,
|
||||||
|
engine_time,
|
||||||
|
tempo,
|
||||||
|
));
|
||||||
|
|
||||||
|
for cmd in cmds {
|
||||||
|
self.event_count += 1;
|
||||||
|
self.pending_commands.push(PendingCommand {
|
||||||
|
cmd: TimestampedCommand { cmd, time: event_time },
|
||||||
|
target_beat,
|
||||||
|
bank: active.bank,
|
||||||
|
pattern: active.pattern,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark step as scheduled
|
||||||
|
self.scheduled_steps.insert(
|
||||||
|
sched_key,
|
||||||
|
ScheduledStep {
|
||||||
|
target_beat,
|
||||||
|
tempo_at_schedule: tempo,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance for next iteration
|
||||||
|
let next = lookahead_step + 1;
|
||||||
|
if next >= pattern.length {
|
||||||
|
lookahead_iter += 1;
|
||||||
|
}
|
||||||
|
lookahead_step = next % pattern.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === EMIT READY COMMANDS ===
|
||||||
|
// Move commands whose target_beat has passed from pending to output
|
||||||
|
let (ready, still_pending): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending_commands)
|
||||||
|
.into_iter()
|
||||||
|
.partition(|p| p.target_beat <= beat);
|
||||||
|
self.pending_commands = still_pending;
|
||||||
|
|
||||||
|
for pending in ready {
|
||||||
|
self.buf_audio_commands.push(pending.cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup stale scheduled_steps (more than 1 beat in the past)
|
||||||
|
self.scheduled_steps
|
||||||
|
.retain(|_, s| s.target_beat > beat - 1.0);
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,7 +1288,25 @@ fn sequencer_loop(
|
|||||||
) {
|
) {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let _ = set_current_thread_priority(ThreadPriority::Max);
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use thread_priority::unix::{
|
||||||
|
set_thread_priority_and_policy, thread_native_id, RealtimeThreadSchedulePolicy,
|
||||||
|
ThreadSchedulePolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
let policy = ThreadSchedulePolicy::Realtime(RealtimeThreadSchedulePolicy::Fifo);
|
||||||
|
if let Err(e) =
|
||||||
|
set_thread_priority_and_policy(thread_native_id(), ThreadPriority::Max, policy)
|
||||||
|
{
|
||||||
|
eprintln!("Warning: Could not set SCHED_FIFO: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = set_current_thread_priority(ThreadPriority::Max);
|
||||||
|
}
|
||||||
|
|
||||||
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
|
let mut seq_state = SequencerState::new(variables, dict, rng, cc_access);
|
||||||
|
|
||||||
@@ -1905,4 +2216,178 @@ mod tests {
|
|||||||
let output = state.tick(tick_at(1.0, true));
|
let output = state.tick(tick_at(1.0, true));
|
||||||
assert_eq!(output.new_tempo, Some(140.0));
|
assert_eq!(output.new_tempo, Some(140.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tick_with_lookahead(beat: f64, lookahead_secs: f64) -> TickInput {
|
||||||
|
TickInput {
|
||||||
|
commands: Vec::new(),
|
||||||
|
playing: true,
|
||||||
|
beat,
|
||||||
|
tempo: 120.0,
|
||||||
|
quantum: 4.0,
|
||||||
|
fill: false,
|
||||||
|
nudge_secs: 0.0,
|
||||||
|
current_time_us: 0,
|
||||||
|
engine_time: beat * 0.5, // At 120 BPM, 1 beat = 0.5 seconds
|
||||||
|
lookahead_secs,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_x: 0.5,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_y: 0.5,
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mouse_down: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pattern_with_sound(length: usize) -> PatternSnapshot {
|
||||||
|
PatternSnapshot {
|
||||||
|
speed: Default::default(),
|
||||||
|
length,
|
||||||
|
steps: (0..length)
|
||||||
|
.map(|_| StepSnapshot {
|
||||||
|
active: true,
|
||||||
|
script: "sine sound 500 freq .".into(),
|
||||||
|
source: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
quantization: LaunchQuantization::Immediate,
|
||||||
|
sync_mode: SyncMode::Reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lookahead_pre_evaluates_future_steps() {
|
||||||
|
let mut state = make_state();
|
||||||
|
|
||||||
|
state.tick(tick_with(
|
||||||
|
vec![SeqCommand::PatternUpdate {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
data: pattern_with_sound(4),
|
||||||
|
}],
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
state.tick(tick_with(
|
||||||
|
vec![SeqCommand::PatternStart {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
quantization: LaunchQuantization::Immediate,
|
||||||
|
sync_mode: SyncMode::Reset,
|
||||||
|
}],
|
||||||
|
0.5,
|
||||||
|
));
|
||||||
|
|
||||||
|
// With 100ms lookahead at 120 BPM = 0.2 beats lookahead
|
||||||
|
// At beat 0.75, future_beat = 0.95
|
||||||
|
// beat_int = 3, future_beat_int = 3
|
||||||
|
// next_beat_int = 4 > future_beat_int, so no lookahead yet
|
||||||
|
let output = state.tick(tick_with_lookahead(0.75, 0.1));
|
||||||
|
// Step fired (step 1), commands emitted immediately
|
||||||
|
assert!(output.shared_state.active_patterns.iter().any(|p| p.step_index == 2));
|
||||||
|
|
||||||
|
// With 500ms lookahead = 1 beat lookahead
|
||||||
|
// At beat 1.0, future_beat = 2.0
|
||||||
|
// beat_int = 4, future_beat_int = 8
|
||||||
|
// Should pre-evaluate steps at beat_ints 5, 6, 7, 8
|
||||||
|
let _output = state.tick(tick_with_lookahead(1.0, 0.5));
|
||||||
|
|
||||||
|
// Check that scheduled_steps contains the pre-evaluated steps
|
||||||
|
// At beat 1.0, step_index is 3 (step 2 just fired)
|
||||||
|
// Lookahead will schedule steps: 3@5, 0@6, 1@7, 2@8
|
||||||
|
assert!(state.scheduled_steps.contains_key(&(0, 0, 3, 5)));
|
||||||
|
assert!(state.scheduled_steps.contains_key(&(0, 0, 0, 6)));
|
||||||
|
assert!(state.scheduled_steps.contains_key(&(0, 0, 1, 7)));
|
||||||
|
assert!(state.scheduled_steps.contains_key(&(0, 0, 2, 8)));
|
||||||
|
|
||||||
|
// Pending commands should exist for future steps
|
||||||
|
assert!(!state.pending_commands.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lookahead_commands_emit_at_correct_time() {
|
||||||
|
let mut state = make_state();
|
||||||
|
|
||||||
|
state.tick(tick_with(
|
||||||
|
vec![SeqCommand::PatternUpdate {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
data: pattern_with_sound(4),
|
||||||
|
}],
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
state.tick(tick_with(
|
||||||
|
vec![SeqCommand::PatternStart {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
quantization: LaunchQuantization::Immediate,
|
||||||
|
sync_mode: SyncMode::Reset,
|
||||||
|
}],
|
||||||
|
0.5,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Pre-evaluate with 1 beat lookahead
|
||||||
|
state.tick(tick_with_lookahead(0.75, 0.5));
|
||||||
|
|
||||||
|
// Commands for step 2 (at beat 1.0) should be in pending_commands
|
||||||
|
let pending_for_step2: Vec<_> = state
|
||||||
|
.pending_commands
|
||||||
|
.iter()
|
||||||
|
.filter(|p| (p.target_beat - 1.0).abs() < 0.01)
|
||||||
|
.collect();
|
||||||
|
assert!(!pending_for_step2.is_empty());
|
||||||
|
|
||||||
|
// Advance to beat 1.0 - pending commands should be emitted
|
||||||
|
let output = state.tick(tick_with_lookahead(1.0, 0.5));
|
||||||
|
// The commands should have been moved to buf_audio_commands
|
||||||
|
assert!(!output.audio_commands.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lookahead_tempo_change_reschedules() {
|
||||||
|
let mut state = make_state();
|
||||||
|
|
||||||
|
state.tick(tick_with(
|
||||||
|
vec![SeqCommand::PatternUpdate {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
data: pattern_with_sound(4),
|
||||||
|
}],
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
state.tick(tick_with(
|
||||||
|
vec![SeqCommand::PatternStart {
|
||||||
|
bank: 0,
|
||||||
|
pattern: 0,
|
||||||
|
quantization: LaunchQuantization::Immediate,
|
||||||
|
sync_mode: SyncMode::Reset,
|
||||||
|
}],
|
||||||
|
0.5,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Pre-evaluate with lookahead
|
||||||
|
state.tick(tick_with_lookahead(0.75, 0.5));
|
||||||
|
|
||||||
|
// Record original event times
|
||||||
|
let original_times: Vec<_> = state
|
||||||
|
.pending_commands
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.cmd.time)
|
||||||
|
.collect();
|
||||||
|
assert!(!original_times.is_empty());
|
||||||
|
|
||||||
|
// Simulate tempo change by ticking with different tempo
|
||||||
|
let mut input = tick_with_lookahead(0.8, 0.5);
|
||||||
|
input.tempo = 140.0; // Changed from 120
|
||||||
|
state.tick(input);
|
||||||
|
|
||||||
|
// Event times should have been rescheduled
|
||||||
|
// (The exact times depend on the reschedule algorithm)
|
||||||
|
// At minimum, scheduled_steps should have updated tempo
|
||||||
|
for step in state.scheduled_steps.values() {
|
||||||
|
// Tempo should be updated for all scheduled steps
|
||||||
|
assert!((step.tempo_at_schedule - 140.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
src/input.rs
153
src/input.rs
@@ -11,8 +11,8 @@ 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::{
|
||||||
DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField,
|
CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus,
|
||||||
SampleBrowserState, SettingKind, SidePanel,
|
PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub enum InputResult {
|
pub enum InputResult {
|
||||||
@@ -641,6 +641,79 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Modal::EuclideanDistribution {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
source_step,
|
||||||
|
field,
|
||||||
|
pulses,
|
||||||
|
steps,
|
||||||
|
rotation,
|
||||||
|
} => {
|
||||||
|
let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => *field = field.prev(),
|
||||||
|
KeyCode::Down | KeyCode::Tab => *field = field.next(),
|
||||||
|
KeyCode::Left => {
|
||||||
|
let target = match field {
|
||||||
|
EuclideanField::Pulses => pulses,
|
||||||
|
EuclideanField::Steps => steps,
|
||||||
|
EuclideanField::Rotation => rotation,
|
||||||
|
};
|
||||||
|
if let Ok(val) = target.parse::<usize>() {
|
||||||
|
*target = val.saturating_sub(1).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
let target = match field {
|
||||||
|
EuclideanField::Pulses => pulses,
|
||||||
|
EuclideanField::Steps => steps,
|
||||||
|
EuclideanField::Rotation => rotation,
|
||||||
|
};
|
||||||
|
if let Ok(val) = target.parse::<usize>() {
|
||||||
|
*target = (val + 1).min(128).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if c.is_ascii_digit() => match field {
|
||||||
|
EuclideanField::Pulses => pulses.push(c),
|
||||||
|
EuclideanField::Steps => steps.push(c),
|
||||||
|
EuclideanField::Rotation => rotation.push(c),
|
||||||
|
},
|
||||||
|
KeyCode::Backspace => match field {
|
||||||
|
EuclideanField::Pulses => {
|
||||||
|
pulses.pop();
|
||||||
|
}
|
||||||
|
EuclideanField::Steps => {
|
||||||
|
steps.pop();
|
||||||
|
}
|
||||||
|
EuclideanField::Rotation => {
|
||||||
|
rotation.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
||||||
|
let steps_val: usize = steps.parse().unwrap_or(0);
|
||||||
|
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
||||||
|
if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val {
|
||||||
|
ctx.dispatch(AppCommand::ApplyEuclideanDistribution {
|
||||||
|
bank: bank_val,
|
||||||
|
pattern: pattern_val,
|
||||||
|
source_step: source_step_val,
|
||||||
|
pulses: pulses_val,
|
||||||
|
steps: steps_val,
|
||||||
|
rotation: rotation_val,
|
||||||
|
});
|
||||||
|
ctx.dispatch(AppCommand::CloseModal);
|
||||||
|
} else {
|
||||||
|
ctx.dispatch(AppCommand::SetStatus(
|
||||||
|
"Invalid: pulses must be > 0 and <= steps".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Modal::None => unreachable!(),
|
Modal::None => unreachable!(),
|
||||||
}
|
}
|
||||||
InputResult::Continue
|
InputResult::Continue
|
||||||
@@ -956,9 +1029,47 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
|||||||
name: current_name,
|
name: current_name,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('o') => {
|
||||||
|
ctx.app.audio.config.layout = ctx.app.audio.config.layout.next();
|
||||||
|
}
|
||||||
KeyCode::Char('?') => {
|
KeyCode::Char('?') => {
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||||
|
let (bank, pattern, step) = (
|
||||||
|
ctx.app.editor_ctx.bank,
|
||||||
|
ctx.app.editor_ctx.pattern,
|
||||||
|
ctx.app.editor_ctx.step,
|
||||||
|
);
|
||||||
|
let pattern_len = ctx.app.current_edit_pattern().length;
|
||||||
|
let default_steps = pattern_len.min(32);
|
||||||
|
let default_pulses = (default_steps / 2).max(1).min(default_steps);
|
||||||
|
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
|
||||||
|
bank,
|
||||||
|
pattern,
|
||||||
|
source_step: step,
|
||||||
|
field: EuclideanField::Pulses,
|
||||||
|
pulses: default_pulses.to_string(),
|
||||||
|
steps: default_steps.to_string(),
|
||||||
|
rotation: "0".to_string(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
KeyCode::Char('m') => {
|
||||||
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||||
|
ctx.dispatch(AppCommand::StageMute { bank, pattern });
|
||||||
|
}
|
||||||
|
KeyCode::Char('x') => {
|
||||||
|
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||||
|
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
|
||||||
|
}
|
||||||
|
KeyCode::Char('M') => {
|
||||||
|
ctx.dispatch(AppCommand::ClearMutes);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
KeyCode::Char('X') => {
|
||||||
|
ctx.dispatch(AppCommand::ClearSolos);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
InputResult::Continue
|
InputResult::Continue
|
||||||
@@ -975,7 +1086,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
|
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
|
||||||
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
|
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
if !ctx.app.playback.staged_changes.is_empty() {
|
if !ctx.app.playback.staged_changes.is_empty()
|
||||||
|
|| !ctx.app.playback.staged_mute_changes.is_empty()
|
||||||
|
{
|
||||||
ctx.dispatch(AppCommand::ClearStagedChanges);
|
ctx.dispatch(AppCommand::ClearStagedChanges);
|
||||||
} else {
|
} else {
|
||||||
ctx.dispatch(AppCommand::PatternsBack);
|
ctx.dispatch(AppCommand::PatternsBack);
|
||||||
@@ -987,7 +1100,12 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
ctx.dispatch(AppCommand::PatternsTogglePlay);
|
ctx.dispatch(AppCommand::PatternsTogglePlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges),
|
KeyCode::Char('c') if !ctrl => {
|
||||||
|
let mute_changed = ctx.app.commit_staged_changes();
|
||||||
|
if mute_changed {
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Char('q') => {
|
KeyCode::Char('q') => {
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -1070,6 +1188,24 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
|
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('m') => {
|
||||||
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||||
|
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||||
|
ctx.dispatch(AppCommand::StageMute { bank, pattern });
|
||||||
|
}
|
||||||
|
KeyCode::Char('x') => {
|
||||||
|
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||||
|
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||||
|
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
|
||||||
|
}
|
||||||
|
KeyCode::Char('M') => {
|
||||||
|
ctx.dispatch(AppCommand::ClearMutes);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
|
KeyCode::Char('X') => {
|
||||||
|
ctx.dispatch(AppCommand::ClearSolos);
|
||||||
|
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||||
|
}
|
||||||
KeyCode::Char('?') => {
|
KeyCode::Char('?') => {
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||||
}
|
}
|
||||||
@@ -1289,6 +1425,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
};
|
};
|
||||||
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
||||||
}
|
}
|
||||||
|
OptionsFocus::HueRotation => {
|
||||||
|
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
|
||||||
|
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
|
||||||
|
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
|
||||||
|
}
|
||||||
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
||||||
OptionsFocus::RuntimeHighlight => {
|
OptionsFocus::RuntimeHighlight => {
|
||||||
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
||||||
@@ -1302,10 +1443,6 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
OptionsFocus::ShowCompletion => {
|
OptionsFocus::ShowCompletion => {
|
||||||
ctx.dispatch(AppCommand::ToggleCompletion);
|
ctx.dispatch(AppCommand::ToggleCompletion);
|
||||||
}
|
}
|
||||||
OptionsFocus::FlashBrightness => {
|
|
||||||
let delta = if key.code == KeyCode::Left { -0.1 } else { 0.1 };
|
|
||||||
ctx.dispatch(AppCommand::AdjustFlashBrightness(delta));
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
// egui intercepts Ctrl+C/V/X and converts them to these high-level events
|
||||||
|
// instead of passing through raw Key events (see egui issue #4065).
|
||||||
|
// Synthesize the equivalent KeyEvent so the application's input handler receives them.
|
||||||
|
egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
||||||
|
egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
||||||
|
egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/main.rs
17
src/main.rs
@@ -98,9 +98,12 @@ 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;
|
|
||||||
app.ui.color_scheme = settings.display.color_scheme;
|
app.ui.color_scheme = settings.display.color_scheme;
|
||||||
theme::set(settings.display.color_scheme.to_theme());
|
app.ui.hue_rotation = settings.display.hue_rotation;
|
||||||
|
app.audio.config.layout = settings.display.layout;
|
||||||
|
let base_theme = settings.display.color_scheme.to_theme();
|
||||||
|
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation);
|
||||||
|
theme::set(rotated);
|
||||||
|
|
||||||
// Load MIDI settings
|
// Load MIDI settings
|
||||||
let outputs = midi::list_midi_outputs();
|
let outputs = midi::list_midi_outputs();
|
||||||
@@ -323,16 +326,6 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::state::ColorScheme;
|
use crate::state::{ColorScheme, MainLayout};
|
||||||
|
|
||||||
const APP_NAME: &str = "cagire";
|
const APP_NAME: &str = "cagire";
|
||||||
|
|
||||||
@@ -44,20 +44,20 @@ 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,
|
|
||||||
#[serde(default = "default_font")]
|
#[serde(default = "default_font")]
|
||||||
pub font: String,
|
pub font: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layout: MainLayout,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hue_rotation: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_font() -> String {
|
fn default_font() -> String {
|
||||||
"8x13".to_string()
|
"8x13".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -88,9 +88,10 @@ 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,
|
|
||||||
font: default_font(),
|
font: default_font(),
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
|
layout: MainLayout::default(),
|
||||||
|
hue_rotation: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
use doux::audio::AudioDeviceInfo;
|
use doux::audio::AudioDeviceInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::CyclicEnum;
|
use super::CyclicEnum;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum MainLayout {
|
||||||
|
#[default]
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CyclicEnum for MainLayout {
|
||||||
|
const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right];
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum RefreshRate {
|
pub enum RefreshRate {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -62,6 +76,7 @@ pub struct AudioConfig {
|
|||||||
pub show_scope: bool,
|
pub show_scope: bool,
|
||||||
pub show_spectrum: bool,
|
pub show_spectrum: bool,
|
||||||
pub lookahead_ms: u32,
|
pub lookahead_ms: u32,
|
||||||
|
pub layout: MainLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
impl Default for AudioConfig {
|
||||||
@@ -79,6 +94,7 @@ impl Default for AudioConfig {
|
|||||||
show_scope: true,
|
show_scope: true,
|
||||||
show_spectrum: true,
|
show_spectrum: true,
|
||||||
lookahead_ms: 15,
|
lookahead_ms: 15,
|
||||||
|
layout: MainLayout::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,32 @@ impl PatternPropsField {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum EuclideanField {
|
||||||
|
#[default]
|
||||||
|
Pulses,
|
||||||
|
Steps,
|
||||||
|
Rotation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EuclideanField {
|
||||||
|
pub fn next(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Pulses => Self::Steps,
|
||||||
|
Self::Steps => Self::Rotation,
|
||||||
|
Self::Rotation => Self::Rotation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Pulses => Self::Pulses,
|
||||||
|
Self::Steps => Self::Pulses,
|
||||||
|
Self::Rotation => Self::Steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct EditorContext {
|
pub struct EditorContext {
|
||||||
pub bank: usize,
|
pub bank: usize,
|
||||||
pub pattern: usize,
|
pub pattern: usize,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod editor;
|
|||||||
pub mod file_browser;
|
pub mod file_browser;
|
||||||
pub mod live_keys;
|
pub mod live_keys;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod mute;
|
||||||
pub mod options;
|
pub mod options;
|
||||||
pub mod panel;
|
pub mod panel;
|
||||||
pub mod patterns_nav;
|
pub mod patterns_nav;
|
||||||
@@ -27,17 +28,19 @@ pub mod project;
|
|||||||
pub mod sample_browser;
|
pub mod sample_browser;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind};
|
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
|
||||||
pub use color_scheme::ColorScheme;
|
pub use color_scheme::ColorScheme;
|
||||||
pub use editor::{
|
pub use editor::{
|
||||||
CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache,
|
CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField,
|
||||||
|
StackCache,
|
||||||
};
|
};
|
||||||
pub use live_keys::LiveKeyState;
|
pub use live_keys::LiveKeyState;
|
||||||
pub use modal::Modal;
|
pub use modal::Modal;
|
||||||
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};
|
||||||
pub use playback::{PlaybackState, StagedChange};
|
pub use mute::MuteState;
|
||||||
|
pub use playback::{PlaybackState, StagedChange, StagedMuteChange};
|
||||||
pub use project::ProjectState;
|
pub use project::ProjectState;
|
||||||
pub use sample_browser::SampleBrowserState;
|
pub use sample_browser::SampleBrowserState;
|
||||||
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};
|
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||||
use crate::state::editor::{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)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
@@ -66,4 +66,13 @@ pub enum Modal {
|
|||||||
KeybindingsHelp {
|
KeybindingsHelp {
|
||||||
scroll: usize,
|
scroll: usize,
|
||||||
},
|
},
|
||||||
|
EuclideanDistribution {
|
||||||
|
bank: usize,
|
||||||
|
pattern: usize,
|
||||||
|
source_step: usize,
|
||||||
|
field: EuclideanField,
|
||||||
|
pulses: String,
|
||||||
|
steps: String,
|
||||||
|
rotation: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/state/mute.rs
Normal file
53
src/state/mute.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MuteState {
|
||||||
|
pub muted: HashSet<(usize, usize)>,
|
||||||
|
pub soloed: HashSet<(usize, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MuteState {
|
||||||
|
pub fn toggle_mute(&mut self, bank: usize, pattern: usize) {
|
||||||
|
let key = (bank, pattern);
|
||||||
|
if self.muted.contains(&key) {
|
||||||
|
self.muted.remove(&key);
|
||||||
|
} else {
|
||||||
|
self.muted.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_solo(&mut self, bank: usize, pattern: usize) {
|
||||||
|
let key = (bank, pattern);
|
||||||
|
if self.soloed.contains(&key) {
|
||||||
|
self.soloed.remove(&key);
|
||||||
|
} else {
|
||||||
|
self.soloed.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_mute(&mut self) {
|
||||||
|
self.muted.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_solo(&mut self) {
|
||||||
|
self.soloed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
self.muted.contains(&(bank, pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_soloed(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
self.soloed.contains(&(bank, pattern))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
if self.muted.contains(&(bank, pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !self.soloed.is_empty() && !self.soloed.contains(&(bank, pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ use super::CyclicEnum;
|
|||||||
pub enum OptionsFocus {
|
pub enum OptionsFocus {
|
||||||
#[default]
|
#[default]
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
|
HueRotation,
|
||||||
RefreshRate,
|
RefreshRate,
|
||||||
RuntimeHighlight,
|
RuntimeHighlight,
|
||||||
ShowScope,
|
ShowScope,
|
||||||
ShowSpectrum,
|
ShowSpectrum,
|
||||||
ShowCompletion,
|
ShowCompletion,
|
||||||
FlashBrightness,
|
|
||||||
LinkEnabled,
|
LinkEnabled,
|
||||||
StartStopSync,
|
StartStopSync,
|
||||||
Quantum,
|
Quantum,
|
||||||
@@ -26,12 +26,12 @@ pub enum OptionsFocus {
|
|||||||
impl CyclicEnum for OptionsFocus {
|
impl CyclicEnum for OptionsFocus {
|
||||||
const VARIANTS: &'static [Self] = &[
|
const VARIANTS: &'static [Self] = &[
|
||||||
Self::ColorScheme,
|
Self::ColorScheme,
|
||||||
|
Self::HueRotation,
|
||||||
Self::RefreshRate,
|
Self::RefreshRate,
|
||||||
Self::RuntimeHighlight,
|
Self::RuntimeHighlight,
|
||||||
Self::ShowScope,
|
Self::ShowScope,
|
||||||
Self::ShowSpectrum,
|
Self::ShowSpectrum,
|
||||||
Self::ShowCompletion,
|
Self::ShowCompletion,
|
||||||
Self::FlashBrightness,
|
|
||||||
Self::LinkEnabled,
|
Self::LinkEnabled,
|
||||||
Self::StartStopSync,
|
Self::StartStopSync,
|
||||||
Self::Quantum,
|
Self::Quantum,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::engine::PatternChange;
|
use crate::engine::PatternChange;
|
||||||
use crate::model::{LaunchQuantization, SyncMode};
|
use crate::model::{LaunchQuantization, SyncMode};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct StagedChange {
|
pub struct StagedChange {
|
||||||
@@ -8,10 +9,17 @@ pub struct StagedChange {
|
|||||||
pub sync_mode: SyncMode,
|
pub sync_mode: SyncMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum StagedMuteChange {
|
||||||
|
ToggleMute { bank: usize, pattern: usize },
|
||||||
|
ToggleSolo { bank: usize, pattern: usize },
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PlaybackState {
|
pub struct PlaybackState {
|
||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
pub staged_changes: Vec<StagedChange>,
|
pub staged_changes: Vec<StagedChange>,
|
||||||
pub queued_changes: Vec<StagedChange>,
|
pub queued_changes: Vec<StagedChange>,
|
||||||
|
pub staged_mute_changes: HashSet<StagedMuteChange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlaybackState {
|
impl Default for PlaybackState {
|
||||||
@@ -20,6 +28,7 @@ impl Default for PlaybackState {
|
|||||||
playing: true,
|
playing: true,
|
||||||
staged_changes: Vec::new(),
|
staged_changes: Vec::new(),
|
||||||
queued_changes: Vec::new(),
|
queued_changes: Vec::new(),
|
||||||
|
staged_mute_changes: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,4 +42,38 @@ impl PlaybackState {
|
|||||||
self.staged_changes.clear();
|
self.staged_changes.clear();
|
||||||
self.queued_changes.clear();
|
self.queued_changes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stage_mute(&mut self, bank: usize, pattern: usize) {
|
||||||
|
let change = StagedMuteChange::ToggleMute { bank, pattern };
|
||||||
|
if self.staged_mute_changes.contains(&change) {
|
||||||
|
self.staged_mute_changes.remove(&change);
|
||||||
|
} else {
|
||||||
|
self.staged_mute_changes.insert(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stage_solo(&mut self, bank: usize, pattern: usize) {
|
||||||
|
let change = StagedMuteChange::ToggleSolo { bank, pattern };
|
||||||
|
if self.staged_mute_changes.contains(&change) {
|
||||||
|
self.staged_mute_changes.remove(&change);
|
||||||
|
} else {
|
||||||
|
self.staged_mute_changes.insert(change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_staged_mutes(&mut self) {
|
||||||
|
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleMute { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_staged_solos(&mut self) {
|
||||||
|
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleSolo { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_staged_mute(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
self.staged_mute_changes.contains(&StagedMuteChange::ToggleMute { bank, pattern })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_staged_solo(&self, bank: usize, pattern: usize) -> bool {
|
||||||
|
self.staged_mute_changes.contains(&StagedMuteChange::ToggleSolo { bank, pattern })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,8 @@ 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,
|
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
|
pub hue_rotation: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -74,10 +72,8 @@ 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,
|
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
|
hue_rotation: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
|||||||
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
||||||
bindings.push(("r", "Rename", "Rename current step"));
|
bindings.push(("r", "Rename", "Rename current step"));
|
||||||
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
||||||
|
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
|
||||||
}
|
}
|
||||||
Page::Patterns => {
|
Page::Patterns => {
|
||||||
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
||||||
|
|||||||
@@ -1,65 +1,117 @@
|
|||||||
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::widgets::Paragraph;
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::engine::SequencerSnapshot;
|
use crate::engine::SequencerSnapshot;
|
||||||
|
use crate::state::MainLayout;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::widgets::{Orientation, Scope, Spectrum, VuMeter};
|
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let [left_area, _spacer, vu_area] = Layout::horizontal([
|
let [patterns_area, _, main_area, _, vu_area] = Layout::horizontal([
|
||||||
|
Constraint::Length(13),
|
||||||
|
Constraint::Length(2),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Length(8),
|
Constraint::Length(10),
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
let show_scope = app.audio.config.show_scope;
|
let show_scope = app.audio.config.show_scope;
|
||||||
let show_spectrum = app.audio.config.show_spectrum;
|
let show_spectrum = app.audio.config.show_spectrum;
|
||||||
let viz_height = if show_scope || show_spectrum { 14 } else { 0 };
|
let has_viz = show_scope || show_spectrum;
|
||||||
|
let layout = app.audio.config.layout;
|
||||||
|
|
||||||
let [viz_area, sequencer_area] = Layout::vertical([
|
let (viz_area, sequencer_area) = match layout {
|
||||||
Constraint::Length(viz_height),
|
MainLayout::Top => {
|
||||||
Constraint::Fill(1),
|
let viz_height = if has_viz { 16 } else { 0 };
|
||||||
])
|
let [viz, seq] = Layout::vertical([
|
||||||
.areas(left_area);
|
Constraint::Length(viz_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
MainLayout::Bottom => {
|
||||||
|
let viz_height = if has_viz { 16 } else { 0 };
|
||||||
|
let [seq, viz] = Layout::vertical([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(viz_height),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
MainLayout::Left => {
|
||||||
|
let viz_width = if has_viz { 33 } else { 0 };
|
||||||
|
let [viz, _spacer, seq] = Layout::horizontal([
|
||||||
|
Constraint::Percentage(viz_width),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
MainLayout::Right => {
|
||||||
|
let viz_width = if has_viz { 33 } else { 0 };
|
||||||
|
let [seq, _spacer, viz] = Layout::horizontal([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Percentage(viz_width),
|
||||||
|
])
|
||||||
|
.areas(main_area);
|
||||||
|
(viz, seq)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if show_scope && show_spectrum {
|
if has_viz {
|
||||||
let [scope_area, _, spectrum_area] = Layout::horizontal([
|
render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum);
|
||||||
Constraint::Percentage(50),
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Percentage(50),
|
|
||||||
])
|
|
||||||
.areas(viz_area);
|
|
||||||
render_scope(frame, app, scope_area);
|
|
||||||
render_spectrum(frame, app, spectrum_area);
|
|
||||||
} else if show_scope {
|
|
||||||
render_scope(frame, app, viz_area);
|
|
||||||
} else if show_spectrum {
|
|
||||||
render_spectrum(frame, app, viz_area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render_sequencer(frame, app, snapshot, sequencer_area);
|
render_sequencer(frame, app, snapshot, sequencer_area);
|
||||||
|
render_vu_meter(frame, app, vu_area);
|
||||||
|
render_active_patterns(frame, app, snapshot, patterns_area);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate actual grid height to align VU meter
|
fn render_viz_area(
|
||||||
let pattern = app.current_edit_pattern();
|
frame: &mut Frame,
|
||||||
let page = app.editor_ctx.step / STEPS_PER_PAGE;
|
app: &App,
|
||||||
let page_start = page * STEPS_PER_PAGE;
|
area: Rect,
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern.length) - page_start;
|
layout: MainLayout,
|
||||||
let num_rows = steps_on_page.div_ceil(8);
|
show_scope: bool,
|
||||||
let spacing = num_rows.saturating_sub(1) as u16;
|
show_spectrum: bool,
|
||||||
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 is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right);
|
||||||
|
|
||||||
let aligned_vu_area = Rect {
|
if show_scope && show_spectrum {
|
||||||
y: sequencer_area.y,
|
if is_vertical_layout {
|
||||||
height: actual_grid_height,
|
let [scope_area, spectrum_area] = Layout::vertical([
|
||||||
..vu_area
|
Constraint::Fill(1),
|
||||||
};
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
render_vu_meter(frame, app, aligned_vu_area);
|
.areas(area);
|
||||||
|
render_scope(frame, app, scope_area, Orientation::Vertical);
|
||||||
|
render_spectrum(frame, app, spectrum_area);
|
||||||
|
} else {
|
||||||
|
let [scope_area, spectrum_area] = Layout::horizontal([
|
||||||
|
Constraint::Fill(1),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
render_scope(frame, app, scope_area, Orientation::Horizontal);
|
||||||
|
render_spectrum(frame, app, spectrum_area);
|
||||||
|
}
|
||||||
|
} else if show_scope {
|
||||||
|
let orientation = if is_vertical_layout {
|
||||||
|
Orientation::Vertical
|
||||||
|
} else {
|
||||||
|
Orientation::Horizontal
|
||||||
|
};
|
||||||
|
render_scope(frame, app, area, orientation);
|
||||||
|
} else if show_spectrum {
|
||||||
|
render_spectrum(frame, app, area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS_PER_PAGE: usize = 32;
|
const STEPS_PER_PAGE: usize = 32;
|
||||||
@@ -84,22 +136,15 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
|||||||
let num_rows = steps_on_page.div_ceil(8);
|
let num_rows = steps_on_page.div_ceil(8);
|
||||||
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||||
|
|
||||||
let spacing = num_rows.saturating_sub(1) as u16;
|
let row_height = area.height / num_rows as u16;
|
||||||
let row_height = area.height.saturating_sub(spacing) / num_rows as u16;
|
|
||||||
|
|
||||||
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
|
let row_constraints: Vec<Constraint> = (0..num_rows)
|
||||||
.map(|i| {
|
.map(|_| Constraint::Length(row_height))
|
||||||
if i % 2 == 0 {
|
|
||||||
Constraint::Length(row_height)
|
|
||||||
} else {
|
|
||||||
Constraint::Length(1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let rows = Layout::vertical(row_constraints).split(area);
|
let rows = Layout::vertical(row_constraints).split(area);
|
||||||
|
|
||||||
for row_idx in 0..num_rows {
|
for row_idx in 0..num_rows {
|
||||||
let row_area = rows[row_idx * 2];
|
let row_area = rows[row_idx];
|
||||||
let start_step = row_idx * steps_per_row;
|
let start_step = row_idx * steps_per_row;
|
||||||
let end_step = (start_step + steps_per_row).min(steps_on_page);
|
let end_step = (start_step + steps_per_row).min(steps_on_page);
|
||||||
let cols_in_row = end_step - start_step;
|
let cols_in_row = end_step - start_step;
|
||||||
@@ -174,6 +219,12 @@ fn render_tile(
|
|||||||
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let source_idx = step.and_then(|s| s.source);
|
let source_idx = step.and_then(|s| s.source);
|
||||||
let symbol = if is_playing {
|
let symbol = if is_playing {
|
||||||
"▶".to_string()
|
"▶".to_string()
|
||||||
@@ -191,17 +242,17 @@ fn render_tile(
|
|||||||
};
|
};
|
||||||
let num_lines = if step_name.is_some() { 2u16 } else { 1u16 };
|
let num_lines = if step_name.is_some() { 2u16 } else { 1u16 };
|
||||||
let content_height = num_lines;
|
let content_height = num_lines;
|
||||||
let y_offset = area.height.saturating_sub(content_height) / 2;
|
let y_offset = inner.height.saturating_sub(content_height) / 2;
|
||||||
|
|
||||||
// Fill background for entire tile
|
// Fill background for inner area
|
||||||
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
||||||
frame.render_widget(bg_fill, area);
|
frame.render_widget(bg_fill, inner);
|
||||||
|
|
||||||
if let Some(name) = step_name {
|
if let Some(name) = step_name {
|
||||||
let name_area = Rect {
|
let name_area = Rect {
|
||||||
x: area.x,
|
x: inner.x,
|
||||||
y: area.y + y_offset,
|
y: inner.y + y_offset,
|
||||||
width: area.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let name_widget = Paragraph::new(name.as_str())
|
let name_widget = Paragraph::new(name.as_str())
|
||||||
@@ -210,9 +261,9 @@ fn render_tile(
|
|||||||
frame.render_widget(name_widget, name_area);
|
frame.render_widget(name_widget, name_area);
|
||||||
|
|
||||||
let symbol_area = Rect {
|
let symbol_area = Rect {
|
||||||
x: area.x,
|
x: inner.x,
|
||||||
y: area.y + y_offset + 1,
|
y: inner.y + y_offset + 1,
|
||||||
width: area.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let symbol_widget = Paragraph::new(symbol)
|
let symbol_widget = Paragraph::new(symbol)
|
||||||
@@ -221,9 +272,9 @@ fn render_tile(
|
|||||||
frame.render_widget(symbol_widget, symbol_area);
|
frame.render_widget(symbol_widget, symbol_area);
|
||||||
} else {
|
} else {
|
||||||
let centered_area = Rect {
|
let centered_area = Rect {
|
||||||
x: area.x,
|
x: inner.x,
|
||||||
y: area.y + y_offset,
|
y: inner.y + y_offset,
|
||||||
width: area.width,
|
width: inner.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let tile = Paragraph::new(symbol)
|
let tile = Paragraph::new(symbol)
|
||||||
@@ -233,21 +284,83 @@ fn render_tile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let scope = Scope::new(&app.metrics.scope)
|
let scope = Scope::new(&app.metrics.scope)
|
||||||
.orientation(Orientation::Horizontal)
|
.orientation(orientation)
|
||||||
.color(theme.meter.low);
|
.color(theme.meter.low);
|
||||||
frame.render_widget(scope, area);
|
frame.render_widget(scope, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let area = Rect { height: area.height.saturating_sub(1), ..area };
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let spectrum = Spectrum::new(&app.metrics.spectrum);
|
let spectrum = Spectrum::new(&app.metrics.spectrum);
|
||||||
frame.render_widget(spectrum, area);
|
frame.render_widget(spectrum, inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
||||||
frame.render_widget(vu, area);
|
frame.render_widget(vu, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
|
use crate::widgets::MuteStatus;
|
||||||
|
|
||||||
|
let theme = theme::get();
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let patterns: Vec<(usize, usize, usize)> = snapshot
|
||||||
|
.active_patterns
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p.bank, p.pattern, p.iter))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mute_status: Vec<MuteStatus> = snapshot
|
||||||
|
.active_patterns
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
if app.mute.is_soloed(p.bank, p.pattern) {
|
||||||
|
MuteStatus::Soloed
|
||||||
|
} else if app.mute.is_muted(p.bank, p.pattern) {
|
||||||
|
MuteStatus::Muted
|
||||||
|
} else if app.mute.is_effectively_muted(p.bank, p.pattern) {
|
||||||
|
MuteStatus::EffectivelyMuted
|
||||||
|
} else {
|
||||||
|
MuteStatus::Normal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let step_info = snapshot
|
||||||
|
.get_step(app.editor_ctx.bank, app.editor_ctx.pattern)
|
||||||
|
.map(|step| (step, app.current_edit_pattern().length));
|
||||||
|
|
||||||
|
let mut widget = ActivePatterns::new(&patterns).with_mute_status(&mute_status);
|
||||||
|
if let Some((step, total)) = step_info {
|
||||||
|
widget = widget.with_step(step, total);
|
||||||
|
}
|
||||||
|
frame.render_widget(widget, inner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)),
|
Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
|
|
||||||
let quantum_str = format!("{:.0}", link.quantum());
|
let quantum_str = format!("{:.0}", link.quantum());
|
||||||
let tempo_str = format!("{:.1} BPM", link.tempo());
|
let tempo_str = format!("{:.1} BPM", link.tempo());
|
||||||
let beat_str = format!("{:.2}", link.beat());
|
let beat_str = format!("{:.2}", link.beat());
|
||||||
@@ -110,6 +109,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
let midi_in_2 = midi_in_display(2);
|
let midi_in_2 = midi_in_display(2);
|
||||||
let midi_in_3 = midi_in_display(3);
|
let midi_in_3 = midi_in_display(3);
|
||||||
|
|
||||||
|
let hue_str = format!("{}°", app.ui.hue_rotation as i32);
|
||||||
|
|
||||||
let lines: Vec<Line> = vec![
|
let lines: Vec<Line> = vec![
|
||||||
render_section_header("DISPLAY", &theme),
|
render_section_header("DISPLAY", &theme),
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
@@ -119,6 +120,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
focus == OptionsFocus::ColorScheme,
|
focus == OptionsFocus::ColorScheme,
|
||||||
&theme,
|
&theme,
|
||||||
),
|
),
|
||||||
|
render_option_line(
|
||||||
|
"Hue rotation",
|
||||||
|
&hue_str,
|
||||||
|
focus == OptionsFocus::HueRotation,
|
||||||
|
&theme,
|
||||||
|
),
|
||||||
render_option_line(
|
render_option_line(
|
||||||
"Refresh rate",
|
"Refresh rate",
|
||||||
app.audio.config.refresh_rate.label(),
|
app.audio.config.refresh_rate.label(),
|
||||||
@@ -153,7 +160,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
focus == OptionsFocus::ShowCompletion,
|
focus == OptionsFocus::ShowCompletion,
|
||||||
&theme,
|
&theme,
|
||||||
),
|
),
|
||||||
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness, &theme),
|
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
link_header,
|
link_header,
|
||||||
render_divider(content_width, &theme),
|
render_divider(content_width, &theme),
|
||||||
@@ -201,12 +207,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
|
|
||||||
let focus_line: usize = match focus {
|
let focus_line: usize = match focus {
|
||||||
OptionsFocus::ColorScheme => 2,
|
OptionsFocus::ColorScheme => 2,
|
||||||
OptionsFocus::RefreshRate => 3,
|
OptionsFocus::HueRotation => 3,
|
||||||
OptionsFocus::RuntimeHighlight => 4,
|
OptionsFocus::RefreshRate => 4,
|
||||||
OptionsFocus::ShowScope => 5,
|
OptionsFocus::RuntimeHighlight => 5,
|
||||||
OptionsFocus::ShowSpectrum => 6,
|
OptionsFocus::ShowScope => 6,
|
||||||
OptionsFocus::ShowCompletion => 7,
|
OptionsFocus::ShowSpectrum => 7,
|
||||||
OptionsFocus::FlashBrightness => 8,
|
OptionsFocus::ShowCompletion => 8,
|
||||||
OptionsFocus::LinkEnabled => 12,
|
OptionsFocus::LinkEnabled => 12,
|
||||||
OptionsFocus::StartStopSync => 13,
|
OptionsFocus::StartStopSync => 13,
|
||||||
OptionsFocus::Quantum => 14,
|
OptionsFocus::Quantum => 14,
|
||||||
|
|||||||
@@ -91,13 +91,41 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
|||||||
let is_playing = banks_with_playback.contains(&idx);
|
let is_playing = banks_with_playback.contains(&idx);
|
||||||
let is_staged = banks_with_staged.contains(&idx);
|
let is_staged = banks_with_staged.contains(&idx);
|
||||||
|
|
||||||
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) {
|
// Check if any pattern in this bank is muted/soloed (applied)
|
||||||
(true, _, _) => (theme.selection.cursor, theme.selection.cursor_fg, ""),
|
let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p));
|
||||||
(false, true, _) => (theme.list.playing_bg, theme.list.playing_fg, "> "),
|
let has_soloed = (0..MAX_PATTERNS).any(|p| app.mute.is_soloed(idx, p));
|
||||||
(false, false, true) => (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ "),
|
|
||||||
(false, false, false) if is_selected => (theme.list.hover_bg, theme.list.hover_fg, ""),
|
// Check if any pattern in this bank has staged mute/solo
|
||||||
(false, false, false) if is_edit => (theme.list.edit_bg, theme.list.edit_fg, ""),
|
let has_staged_mute = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_mute(idx, p));
|
||||||
(false, false, false) => (theme.ui.bg, theme.ui.text_muted, ""),
|
let has_staged_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p));
|
||||||
|
let has_staged_mute_solo = has_staged_mute || has_staged_solo;
|
||||||
|
|
||||||
|
let (bg, fg, prefix) = if is_cursor {
|
||||||
|
(theme.selection.cursor, theme.selection.cursor_fg, "")
|
||||||
|
} else if is_playing {
|
||||||
|
if has_staged_mute_solo {
|
||||||
|
(theme.list.staged_play_bg, theme.list.staged_play_fg, ">*")
|
||||||
|
} else if has_soloed {
|
||||||
|
(theme.list.soloed_bg, theme.list.soloed_fg, ">S")
|
||||||
|
} else if has_muted {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, ">M")
|
||||||
|
} else {
|
||||||
|
(theme.list.playing_bg, theme.list.playing_fg, "> ")
|
||||||
|
}
|
||||||
|
} else if is_staged {
|
||||||
|
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
||||||
|
} else if has_staged_mute_solo {
|
||||||
|
(theme.list.staged_play_bg, theme.list.staged_play_fg, " *")
|
||||||
|
} else if has_soloed && is_selected {
|
||||||
|
(theme.list.soloed_bg, theme.list.soloed_fg, " S")
|
||||||
|
} else if has_muted && is_selected {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, " M")
|
||||||
|
} else if is_selected {
|
||||||
|
(theme.list.hover_bg, theme.list.hover_fg, "")
|
||||||
|
} else if is_edit {
|
||||||
|
(theme.list.edit_bg, theme.list.edit_fg, "")
|
||||||
|
} else {
|
||||||
|
(theme.ui.bg, theme.ui.text_muted, "")
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = app.project_state.project.banks[idx]
|
let name = app.project_state.project.banks[idx]
|
||||||
@@ -250,14 +278,74 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
let is_staged_play = staged_to_play.contains(&idx);
|
let is_staged_play = staged_to_play.contains(&idx);
|
||||||
let is_staged_stop = staged_to_stop.contains(&idx);
|
let is_staged_stop = staged_to_stop.contains(&idx);
|
||||||
|
|
||||||
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) {
|
// Current applied mute/solo state
|
||||||
(true, _, _, _) => (theme.selection.cursor, theme.selection.cursor_fg, ""),
|
let is_muted = app.mute.is_muted(bank, idx);
|
||||||
(false, true, _, true) => (theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- "),
|
let is_soloed = app.mute.is_soloed(bank, idx);
|
||||||
(false, true, _, false) => (theme.list.playing_bg, theme.list.playing_fg, "> "),
|
|
||||||
(false, false, true, _) => (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ "),
|
// Staged mute/solo (will toggle on commit)
|
||||||
(false, false, false, _) if is_selected => (theme.list.hover_bg, theme.list.hover_fg, ""),
|
let has_staged_mute = app.playback.has_staged_mute(bank, idx);
|
||||||
(false, false, false, _) if is_edit => (theme.list.edit_bg, theme.list.edit_fg, ""),
|
let has_staged_solo = app.playback.has_staged_solo(bank, idx);
|
||||||
(false, false, false, _) => (theme.ui.bg, theme.ui.text_muted, ""),
|
|
||||||
|
// Preview state (what it will be after commit)
|
||||||
|
let preview_muted = is_muted ^ has_staged_mute;
|
||||||
|
let preview_soloed = is_soloed ^ has_staged_solo;
|
||||||
|
let is_effectively_muted = app.mute.is_effectively_muted(bank, idx);
|
||||||
|
|
||||||
|
let (bg, fg, prefix) = if is_cursor {
|
||||||
|
(theme.selection.cursor, theme.selection.cursor_fg, "")
|
||||||
|
} else if is_playing {
|
||||||
|
// Playing patterns
|
||||||
|
if is_staged_stop {
|
||||||
|
(theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ")
|
||||||
|
} else if has_staged_solo {
|
||||||
|
// Staged solo toggle on playing pattern
|
||||||
|
if preview_soloed {
|
||||||
|
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||||
|
} else {
|
||||||
|
(theme.list.playing_bg, theme.list.playing_fg, "-S")
|
||||||
|
}
|
||||||
|
} else if has_staged_mute {
|
||||||
|
// Staged mute toggle on playing pattern
|
||||||
|
if preview_muted {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||||
|
} else {
|
||||||
|
(theme.list.playing_bg, theme.list.playing_fg, "-M")
|
||||||
|
}
|
||||||
|
} else if is_soloed {
|
||||||
|
(theme.list.soloed_bg, theme.list.soloed_fg, ">S")
|
||||||
|
} else if is_muted {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, ">M")
|
||||||
|
} else if is_effectively_muted {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, "> ")
|
||||||
|
} else {
|
||||||
|
(theme.list.playing_bg, theme.list.playing_fg, "> ")
|
||||||
|
}
|
||||||
|
} else if is_staged_play {
|
||||||
|
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
||||||
|
} else if has_staged_solo {
|
||||||
|
// Staged solo on non-playing pattern
|
||||||
|
if preview_soloed {
|
||||||
|
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||||
|
} else {
|
||||||
|
(theme.ui.bg, theme.ui.text_muted, "-S")
|
||||||
|
}
|
||||||
|
} else if has_staged_mute {
|
||||||
|
// Staged mute on non-playing pattern
|
||||||
|
if preview_muted {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||||
|
} else {
|
||||||
|
(theme.ui.bg, theme.ui.text_muted, "-M")
|
||||||
|
}
|
||||||
|
} else if is_soloed {
|
||||||
|
(theme.list.soloed_bg, theme.list.soloed_fg, " S")
|
||||||
|
} else if is_muted {
|
||||||
|
(theme.list.muted_bg, theme.list.muted_fg, " M")
|
||||||
|
} else if is_selected {
|
||||||
|
(theme.list.hover_bg, theme.list.hover_fg, "")
|
||||||
|
} else if is_edit {
|
||||||
|
(theme.list.edit_bg, theme.list.edit_fg, "")
|
||||||
|
} else {
|
||||||
|
(theme.ui.bg, theme.ui.text_muted, "")
|
||||||
};
|
};
|
||||||
|
|
||||||
let pattern = &app.project_state.project.banks[bank].patterns[idx];
|
let pattern = &app.project_state.project.banks[bank].patterns[idx];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::time::Instant;
|
|||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
|
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -16,7 +16,9 @@ use crate::app::App;
|
|||||||
use crate::engine::{LinkState, SequencerSnapshot};
|
use crate::engine::{LinkState, SequencerSnapshot};
|
||||||
use crate::model::{SourceSpan, StepContext, Value};
|
use crate::model::{SourceSpan, StepContext, Value};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache};
|
use crate::state::{
|
||||||
|
EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache,
|
||||||
|
};
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
||||||
use crate::widgets::{
|
use crate::widgets::{
|
||||||
@@ -148,17 +150,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
|||||||
let term = frame.area();
|
let term = frame.area();
|
||||||
|
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let bg_color = if app.ui.event_flash > 0.0 {
|
let bg_color = theme.ui.bg;
|
||||||
let t = (app.ui.event_flash * app.ui.flash_brightness).min(1.0);
|
|
||||||
let (base_r, base_g, base_b) = theme.ui.bg_rgb;
|
|
||||||
let (tgt_r, tgt_g, tgt_b) = theme.flash.event_rgb;
|
|
||||||
let r = base_r + ((tgt_r as f32 - base_r as f32) * t) as u8;
|
|
||||||
let g = base_g + ((tgt_g as f32 - base_g as f32) * t) as u8;
|
|
||||||
let b = base_b + ((tgt_b as f32 - base_b as f32) * t) as u8;
|
|
||||||
Color::Rgb(r, g, b)
|
|
||||||
} else {
|
|
||||||
theme.ui.bg
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -1038,5 +1030,131 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
|||||||
hint_area,
|
hint_area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Modal::EuclideanDistribution {
|
||||||
|
source_step,
|
||||||
|
field,
|
||||||
|
pulses,
|
||||||
|
steps,
|
||||||
|
rotation,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let width = 50u16;
|
||||||
|
let height = 11u16;
|
||||||
|
let x = (term.width.saturating_sub(width)) / 2;
|
||||||
|
let y = (term.height.saturating_sub(height)) / 2;
|
||||||
|
let area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1))
|
||||||
|
.border_style(Style::default().fg(theme.modal.input));
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
|
// Fill background with theme color
|
||||||
|
let bg_fill = " ".repeat(area.width as usize);
|
||||||
|
for row in 0..area.height {
|
||||||
|
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)),
|
||||||
|
line_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let fields = [
|
||||||
|
(
|
||||||
|
"Pulses",
|
||||||
|
pulses.as_str(),
|
||||||
|
*field == EuclideanField::Pulses,
|
||||||
|
),
|
||||||
|
("Steps", steps.as_str(), *field == EuclideanField::Steps),
|
||||||
|
(
|
||||||
|
"Rotation",
|
||||||
|
rotation.as_str(),
|
||||||
|
*field == EuclideanField::Rotation,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, (label, value, selected)) in fields.iter().enumerate() {
|
||||||
|
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;
|
||||||
|
if preview_y < inner.y + inner.height {
|
||||||
|
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
||||||
|
let steps_val: usize = steps.parse().unwrap_or(0);
|
||||||
|
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
||||||
|
let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val);
|
||||||
|
let preview_line = Line::from(vec![
|
||||||
|
Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)),
|
||||||
|
Span::styled(preview, Style::default().fg(theme.modal.input)),
|
||||||
|
]);
|
||||||
|
let preview_area =
|
||||||
|
Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1);
|
||||||
|
frame.render_widget(Paragraph::new(preview_line), preview_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||||
|
let hint_line = Line::from(vec![
|
||||||
|
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
||||||
|
if pulses == 0 || steps == 0 || pulses > steps {
|
||||||
|
return "[invalid]".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pattern = vec![false; steps];
|
||||||
|
for i in 0..pulses {
|
||||||
|
let pos = (i * steps) / pulses;
|
||||||
|
pattern[pos] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rotation > 0 {
|
||||||
|
pattern.rotate_left(rotation % steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect();
|
||||||
|
format!("[{}]", chars.join(" "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use ratatui::style::Style;
|
|||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
use tui_big_text::{BigText, PixelSize};
|
use tui_big_text::{BigText, PixelSize};
|
||||||
|
|
||||||
use crate::state::ui::UiState;
|
use crate::state::ui::UiState;
|
||||||
@@ -16,13 +17,21 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
|||||||
let link_style = Style::new().fg(theme.title.link);
|
let link_style = Style::new().fg(theme.title.link);
|
||||||
let license_style = Style::new().fg(theme.title.license);
|
let license_style = Style::new().fg(theme.title.license);
|
||||||
|
|
||||||
|
#[cfg(not(feature = "desktop"))]
|
||||||
let big_title = BigText::builder()
|
let big_title = BigText::builder()
|
||||||
.pixel_size(PixelSize::Quadrant)
|
.pixel_size(PixelSize::Full)
|
||||||
.style(Style::new().fg(theme.title.big_title).bold())
|
.style(Style::new().fg(theme.title.big_title).bold())
|
||||||
.lines(vec!["CAGIRE".into()])
|
.lines(vec!["CAGIRE".into()])
|
||||||
.centered()
|
.centered()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
let big_title = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"CAGIRE",
|
||||||
|
Style::new().fg(theme.title.big_title).bold(),
|
||||||
|
)))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
let version_style = Style::new().fg(theme.title.subtitle);
|
let version_style = Style::new().fg(theme.title.subtitle);
|
||||||
|
|
||||||
let subtitle_lines = vec![
|
let subtitle_lines = vec![
|
||||||
@@ -49,21 +58,43 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
|
|||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
let big_text_height = 4;
|
#[cfg(not(feature = "desktop"))]
|
||||||
|
let big_text_height = 8;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
let big_text_height = 1;
|
||||||
|
let min_title_width = 30;
|
||||||
let subtitle_height = subtitle_lines.len() as u16;
|
let subtitle_height = subtitle_lines.len() as u16;
|
||||||
let total_height = big_text_height + subtitle_height;
|
|
||||||
let vertical_padding = area.height.saturating_sub(total_height) / 2;
|
|
||||||
|
|
||||||
let [_, title_area, subtitle_area, _] = Layout::vertical([
|
let show_big_title =
|
||||||
Constraint::Length(vertical_padding),
|
area.height >= (big_text_height + subtitle_height) && area.width >= min_title_width;
|
||||||
Constraint::Length(big_text_height),
|
|
||||||
Constraint::Length(subtitle_height),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
])
|
|
||||||
.areas(area);
|
|
||||||
|
|
||||||
frame.render_widget(big_title, title_area);
|
if show_big_title {
|
||||||
|
let total_height = big_text_height + subtitle_height;
|
||||||
|
let vertical_padding = area.height.saturating_sub(total_height) / 2;
|
||||||
|
|
||||||
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
|
let [_, title_area, subtitle_area, _] = Layout::vertical([
|
||||||
frame.render_widget(subtitle, subtitle_area);
|
Constraint::Length(vertical_padding),
|
||||||
|
Constraint::Length(big_text_height),
|
||||||
|
Constraint::Length(subtitle_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
frame.render_widget(big_title, title_area);
|
||||||
|
|
||||||
|
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
|
||||||
|
frame.render_widget(subtitle, subtitle_area);
|
||||||
|
} else {
|
||||||
|
let vertical_padding = area.height.saturating_sub(subtitle_height) / 2;
|
||||||
|
|
||||||
|
let [_, subtitle_area, _] = Layout::vertical([
|
||||||
|
Constraint::Length(vertical_padding),
|
||||||
|
Constraint::Length(subtitle_height),
|
||||||
|
Constraint::Fill(1),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
|
||||||
|
frame.render_widget(subtitle, subtitle_area);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
pub use cagire_ratatui::{
|
pub use cagire_ratatui::{
|
||||||
ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser,
|
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile,
|
||||||
Scope, Spectrum, TextInputModal, VuMeter,
|
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::harness::*;
|
use super::harness::*;
|
||||||
|
use cagire::forth::Value;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn define_and_use_word() {
|
fn define_and_use_word() {
|
||||||
@@ -113,3 +114,44 @@ fn define_word_with_conditional() {
|
|||||||
f.evaluate("10 maybe-double", &ctx).unwrap();
|
f.evaluate("10 maybe-double", &ctx).unwrap();
|
||||||
assert_eq!(stack_int(&f), 20);
|
assert_eq!(stack_int(&f), 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_removes_word() {
|
||||||
|
let f = forth();
|
||||||
|
let ctx = default_ctx();
|
||||||
|
f.evaluate(": double 2 * ;", &ctx).unwrap();
|
||||||
|
f.evaluate("5 double", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 10);
|
||||||
|
f.clear_stack();
|
||||||
|
f.evaluate("\"double\" forget", &ctx).unwrap();
|
||||||
|
f.evaluate("double", &ctx).unwrap();
|
||||||
|
let stack = f.stack();
|
||||||
|
assert_eq!(stack.len(), 1);
|
||||||
|
match &stack[0] {
|
||||||
|
Value::Str(s, _) => assert_eq!(s, "double"),
|
||||||
|
other => panic!("expected Str, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_nonexistent_is_noop() {
|
||||||
|
let f = forth();
|
||||||
|
let ctx = default_ctx();
|
||||||
|
f.evaluate("\"nosuchword\" forget", &ctx).unwrap();
|
||||||
|
f.evaluate("42", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forget_and_redefine() {
|
||||||
|
let f = forth();
|
||||||
|
let ctx = default_ctx();
|
||||||
|
f.evaluate(": foo 10 ;", &ctx).unwrap();
|
||||||
|
f.evaluate("foo", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 10);
|
||||||
|
f.clear_stack();
|
||||||
|
f.evaluate("\"foo\" forget", &ctx).unwrap();
|
||||||
|
f.evaluate(": foo 20 ;", &ctx).unwrap();
|
||||||
|
f.evaluate("foo", &ctx).unwrap();
|
||||||
|
assert_eq!(stack_int(&f), 20);
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,46 @@ fn tuck_underflow() {
|
|||||||
expect_error("1 tuck", "stack underflow");
|
expect_error("1 tuck", "stack underflow");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dup2() {
|
||||||
|
expect_stack("1 2 2dup", &[int(1), int(2), int(1), int(2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dup2_underflow() {
|
||||||
|
expect_error("1 2dup", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop2() {
|
||||||
|
expect_stack("1 2 3 2drop", &[int(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop2_underflow() {
|
||||||
|
expect_error("1 2drop", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap2() {
|
||||||
|
expect_stack("1 2 3 4 2swap", &[int(3), int(4), int(1), int(2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap2_underflow() {
|
||||||
|
expect_error("1 2 3 2swap", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn over2() {
|
||||||
|
expect_stack("1 2 3 4 2over", &[int(1), int(2), int(3), int(4), int(1), int(2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn over2_underflow() {
|
||||||
|
expect_error("1 2 3 2over", "stack underflow");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stack_persists() {
|
fn stack_persists() {
|
||||||
let f = forth();
|
let f = forth();
|
||||||
|
|||||||
54
website/public/script.js
Normal file
54
website/public/script.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const toggle = document.getElementById('theme-toggle');
|
||||||
|
const root = document.documentElement;
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const isLight = stored ? stored === 'light' : !prefersDark;
|
||||||
|
|
||||||
|
if (isLight) {
|
||||||
|
root.classList.add('light');
|
||||||
|
}
|
||||||
|
toggle.textContent = isLight ? 'DARK' : 'LIGHT';
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
root.classList.toggle('light');
|
||||||
|
const light = root.classList.contains('light');
|
||||||
|
toggle.textContent = light ? 'DARK' : 'LIGHT';
|
||||||
|
localStorage.setItem('theme', light ? 'light' : 'dark');
|
||||||
|
highlightForth();
|
||||||
|
});
|
||||||
|
|
||||||
|
function highlightForth() {
|
||||||
|
const words = ['note', 'sound', 'lpf', 'hpf', 'chorus', 'verb', 'distort', 'speed'];
|
||||||
|
const notes = ['c4'];
|
||||||
|
const chords = ['min7'];
|
||||||
|
const samples = ['kkick', 'sine', 'saw'];
|
||||||
|
const isLight = document.documentElement.classList.contains('light');
|
||||||
|
const numColor = isLight ? '#a855f7' : '#e8a0e8';
|
||||||
|
const dotColor = isLight ? '#0284c7' : '#7dd3fc';
|
||||||
|
const wordColor = isLight ? '#65a30d' : '#a3e635';
|
||||||
|
const noteColor = isLight ? '#d97706' : '#fbbf24';
|
||||||
|
const chordColor = isLight ? '#15803d' : '#4ade80';
|
||||||
|
const sampleColor = isLight ? '#dc2626' : '#f87171';
|
||||||
|
document.querySelectorAll('pre').forEach(pre => {
|
||||||
|
const text = pre.dataset.source || pre.textContent;
|
||||||
|
pre.dataset.source = text;
|
||||||
|
pre.innerHTML = text
|
||||||
|
.split(/(\s+)/)
|
||||||
|
.map(t => {
|
||||||
|
if (t === '.') return `<span style="color:${dotColor}">.</span>`;
|
||||||
|
if (/^-?\d+\.?\d*$/.test(t)) return `<span style="color:${numColor}">${t}</span>`;
|
||||||
|
if (words.includes(t)) return `<span style="color:${wordColor}">${t}</span>`;
|
||||||
|
if (notes.includes(t)) return `<span style="color:${noteColor}">${t}</span>`;
|
||||||
|
if (chords.includes(t)) return `<span style="color:${chordColor}">${t}</span>`;
|
||||||
|
if (samples.includes(t)) return `<span style="color:${sampleColor}">${t}</span>`;
|
||||||
|
return t;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', highlightForth);
|
||||||
|
} else {
|
||||||
|
highlightForth();
|
||||||
|
}
|
||||||
127
website/public/style.css
Normal file
127
website/public/style.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'CozetteVector';
|
||||||
|
src: url('/CozetteVector.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #000;
|
||||||
|
--surface: #121212;
|
||||||
|
--text: #fff;
|
||||||
|
--text-dim: #b4b4b4;
|
||||||
|
--text-muted: #787878;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.light {
|
||||||
|
--bg: #fff;
|
||||||
|
--surface: #f0f0f0;
|
||||||
|
--text: #000;
|
||||||
|
--text-dim: #505050;
|
||||||
|
--text-muted: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'CozetteVector', monospace;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: 0.25rem 0; }
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--text-dim); }
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li { margin: 0.1rem 0; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table th,
|
||||||
|
.downloads-table td {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table th {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table td:first-child {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-table tr:nth-child(even) {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support a {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-toggle {
|
||||||
|
font-family: 'CozetteVector', monospace;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
@@ -18,137 +18,12 @@
|
|||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
|
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
|
||||||
<meta name="twitter:description" content="Forth-based live coding music sequencer">
|
<meta name="twitter:description" content="Forth-based live coding music sequencer">
|
||||||
<style>
|
<link rel="stylesheet" href="/style.css">
|
||||||
@font-face {
|
|
||||||
font-family: 'CozetteVector';
|
|
||||||
src: url('/CozetteVector.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg: #000;
|
|
||||||
--surface: #121212;
|
|
||||||
--text: #fff;
|
|
||||||
--text-dim: #b4b4b4;
|
|
||||||
--text-muted: #787878;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root.light {
|
|
||||||
--bg: #fff;
|
|
||||||
--surface: #f0f0f0;
|
|
||||||
--text: #000;
|
|
||||||
--text-dim: #505050;
|
|
||||||
--text-muted: #8c8c8c;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'CozetteVector', monospace;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: var(--text);
|
|
||||||
margin-top: 1rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p { margin: 0.25rem 0; }
|
|
||||||
|
|
||||||
video {
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
a { color: var(--text-dim); }
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
li { margin: 0.1rem 0; }
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 0.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table th,
|
|
||||||
.downloads-table td {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table th {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table td:first-child {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloads-table tr:nth-child(even) {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note a {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle {
|
|
||||||
font-family: 'CozetteVector', monospace;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--text-muted);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#theme-toggle:hover {
|
|
||||||
background: var(--text-muted);
|
|
||||||
color: var(--bg);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
|
||||||
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
|
|
||||||
<button id="theme-toggle" aria-label="Toggle theme">LIGHT</button>
|
<p class="support">Cagire is free and open source. If you find it useful, consider <a href="https://ko-fi.com/raphaelbubo">supporting the project on Ko-fi</a>.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="downloads-table">
|
<table class="downloads-table">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -177,31 +52,43 @@
|
|||||||
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64.tar.gz">.tar.gz</a></td>
|
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64.tar.gz">.tar.gz</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="note">All releases on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub</a>. You can also compile the software yourself or get it from Cargo!</p>
|
<p class="note">All releases are available on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub</a>. You can also compile the software yourself by getting it from Cargo!</p>
|
||||||
|
|
||||||
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
|
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
|
||||||
|
|
||||||
<h2>About</h2>
|
<h2>About</h2>
|
||||||
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
|
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the associated script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
|
||||||
|
|
||||||
<h2>Code Examples</h2>
|
<h2>Code Examples</h2>
|
||||||
|
<br>
|
||||||
<p>A minimal script that plays a middle C note using a sine wave:</p>
|
<p>A minimal script that plays a middle C note using a sine wave:</p>
|
||||||
<pre>c4 note sine sound .</pre>
|
<pre>c4 note sine sound .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<p>And now let's make it polyphonic and add different parameters per voice:</p>
|
||||||
|
<pre>c4 min7 note
|
||||||
|
sine sound
|
||||||
|
0.1 chorus
|
||||||
|
500 1000 1500 lpf
|
||||||
|
.</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
|
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
|
||||||
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
|
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
<p>Pitched-down kick drum sample with distortion:</p>
|
<p>Pitched-down kick drum sample with distortion:</p>
|
||||||
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
|
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
<h2>Features</h2>
|
<h2>Features</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>32 banks × 32 patterns × 128 steps per project</li>
|
<li>Robust synthesis engine: synthesizers, sampling, effects, live input, and more to discover.</li>
|
||||||
<li>Ableton Link synchronization</li>
|
<li>Ableton Link: jam with your friends or include other software / hardware to your setup.</li>
|
||||||
<li>Built-in synthesis engine (oscillators, samples, wavetables)</li>
|
<li>32 banks × 32 patterns × 128 steps per project: (~131.000 scripts per project).</li>
|
||||||
<li>Effects: filters, reverb, delay, distortion, chorus</li>
|
<li>Forth: objectively the coolest / minimal / hackable language to make music with!</li>
|
||||||
<li>User-defined words and shared variables</li>
|
<li>Embedded dictionary and documentation!</li>
|
||||||
<li>Embedded dictionary and documentation</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Live Coding</h2>
|
<h2>Live Coding</h2>
|
||||||
@@ -209,10 +96,8 @@
|
|||||||
|
|
||||||
<h2>Credits</h2>
|
<h2>Credits</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Forment)</li>
|
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Maurice Forment).</li>
|
||||||
<li><a href="https://doux.livecoding.fr">Doux</a> audio engine, Rust port of Dough by <a href="https://eddyflux.cc/">Felix Roos</a></li>
|
<li>See <a href="https://doux.livecoding.fr">Doux</a> for engine credits.</li>
|
||||||
<li><a href="https://github.com/sourcebox/mi-plaits-dsp-rs">mi-plaits-dsp-rs</a> by Oliver Rockstedt, based on <a href="https://mutable-instruments.net/">Mutable Instruments</a> Plaits by Emilie Gillet</li>
|
|
||||||
<li>Related: <a href="https://strudel.cc">Strudel</a>, <a href="https://tidalcycles.org">TidalCycles</a>, <a href="https://sova.livecoding.fr">Sova</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Links</h2>
|
<h2>Links</h2>
|
||||||
@@ -221,23 +106,8 @@
|
|||||||
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
|
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p style="margin-top: 2rem; color: var(--text-muted);">AGPL-3.0 License</p>
|
<p style="margin-top: 2rem; color: var(--text-muted);">AGPL-3.0 License · <button id="theme-toggle" aria-label="Toggle theme">LIGHT</button></p>
|
||||||
|
|
||||||
<script>
|
<script is:inline src="/script.js"></script>
|
||||||
const toggle = document.getElementById('theme-toggle');
|
|
||||||
const root = document.documentElement;
|
|
||||||
|
|
||||||
if (localStorage.getItem('theme') === 'light') {
|
|
||||||
root.classList.add('light');
|
|
||||||
toggle.textContent = 'DARK';
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('light');
|
|
||||||
const isLight = root.classList.contains('light');
|
|
||||||
toggle.textContent = isLight ? 'DARK' : 'LIGHT';
|
|
||||||
localStorage.setItem('theme', isLight ? 'light' : 'dark');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user