Compare commits
2 Commits
a3a39ea28e
...
f4eafdf5b2
| Author | SHA1 | Date | |
|---|---|---|---|
| f4eafdf5b2 | |||
| 935df84920 |
@@ -28,6 +28,7 @@ pub struct StepContext {
|
||||
pub iter: usize,
|
||||
pub speed: f64,
|
||||
pub fill: bool,
|
||||
pub nudge_secs: f64,
|
||||
}
|
||||
|
||||
impl StepContext {
|
||||
|
||||
@@ -86,7 +86,7 @@ impl Forth {
|
||||
|
||||
// Resolve root scope at end of script
|
||||
if let Some(scope) = scope_stack.pop() {
|
||||
resolve_scope(&scope, ctx.step_duration(), &mut outputs);
|
||||
resolve_scope(&scope, ctx.step_duration(), ctx.nudge_secs, &mut outputs);
|
||||
}
|
||||
|
||||
Ok(outputs)
|
||||
@@ -281,10 +281,20 @@ impl Forth {
|
||||
Op::Add => binary_op(stack, |a, b| a + b)?,
|
||||
Op::Sub => binary_op(stack, |a, b| a - b)?,
|
||||
Op::Mul => binary_op(stack, |a, b| a * b)?,
|
||||
Op::Div => binary_op(stack, |a, b| a / b)?,
|
||||
Op::Div => {
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||
return Err("division by zero".into());
|
||||
}
|
||||
stack.push(lift_binary(a, b, |x, y| x / y)?);
|
||||
}
|
||||
Op::Mod => {
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
if b.as_float().map_or(true, |v| v == 0.0) {
|
||||
return Err("modulo by zero".into());
|
||||
}
|
||||
let result = lift_binary(a, b, |x, y| (x as i64 % y as i64) as f64)?;
|
||||
stack.push(result);
|
||||
}
|
||||
@@ -448,17 +458,19 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Rand => {
|
||||
let max = stack.pop().ok_or("stack underflow")?;
|
||||
let min = stack.pop().ok_or("stack underflow")?;
|
||||
match (&min, &max) {
|
||||
(Value::Int(min_i, _), Value::Int(max_i, _)) => {
|
||||
let val = self.rng.lock().unwrap().gen_range(*min_i..=*max_i);
|
||||
let b = stack.pop().ok_or("stack underflow")?;
|
||||
let a = stack.pop().ok_or("stack underflow")?;
|
||||
match (&a, &b) {
|
||||
(Value::Int(a_i, _), Value::Int(b_i, _)) => {
|
||||
let (lo, hi) = if a_i <= b_i { (*a_i, *b_i) } else { (*b_i, *a_i) };
|
||||
let val = self.rng.lock().unwrap().gen_range(lo..=hi);
|
||||
stack.push(Value::Int(val, None));
|
||||
}
|
||||
_ => {
|
||||
let min_f = min.as_float()?;
|
||||
let max_f = max.as_float()?;
|
||||
let val = self.rng.lock().unwrap().gen_range(min_f..max_f);
|
||||
let a_f = a.as_float()?;
|
||||
let b_f = b.as_float()?;
|
||||
let (lo, hi) = if a_f <= b_f { (a_f, b_f) } else { (b_f, a_f) };
|
||||
let val = self.rng.lock().unwrap().gen_range(lo..hi);
|
||||
stack.push(Value::Float(val, None));
|
||||
}
|
||||
}
|
||||
@@ -545,7 +557,11 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Pick => {
|
||||
let idx = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||
let idx_i = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||
if idx_i < 0 {
|
||||
return Err(format!("pick index must be >= 0, got {}", idx_i));
|
||||
}
|
||||
let idx = idx_i as usize;
|
||||
let mut quots: Vec<Value> = Vec::new();
|
||||
while let Some(val) = stack.pop() {
|
||||
match &val {
|
||||
@@ -580,6 +596,9 @@ impl Forth {
|
||||
}
|
||||
|
||||
Op::Degree(pattern) => {
|
||||
if pattern.is_empty() {
|
||||
return Err("empty scale pattern".into());
|
||||
}
|
||||
let val = stack.pop().ok_or("stack underflow")?;
|
||||
let len = pattern.len() as i64;
|
||||
let result = lift_unary_int(val, |degree| {
|
||||
@@ -623,6 +642,9 @@ impl Forth {
|
||||
|
||||
Op::Loop => {
|
||||
let beats = stack.pop().ok_or("stack underflow")?.as_float()?;
|
||||
if ctx.tempo == 0.0 || ctx.speed == 0.0 {
|
||||
return Err("tempo and speed must be non-zero".into());
|
||||
}
|
||||
let dur = beats * 60.0 / ctx.tempo / ctx.speed;
|
||||
cmd.set_param("fit".into(), Value::Float(dur, None));
|
||||
cmd.set_param("dur".into(), Value::Float(dur, None));
|
||||
@@ -726,7 +748,7 @@ impl Forth {
|
||||
let child = scope_stack.pop().unwrap();
|
||||
|
||||
if child.stacked {
|
||||
resolve_scope(&child, ctx.step_duration(), outputs);
|
||||
resolve_scope(&child, ctx.step_duration(), ctx.nudge_secs, outputs);
|
||||
} else {
|
||||
let parent = scope_stack.last_mut().ok_or("scope stack underflow")?;
|
||||
let parent_slot = parent.claim_slot();
|
||||
@@ -763,7 +785,12 @@ impl Forth {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_scope(scope: &ScopeContext, step_duration: f64, outputs: &mut Vec<String>) {
|
||||
fn resolve_scope(
|
||||
scope: &ScopeContext,
|
||||
step_duration: f64,
|
||||
nudge_secs: f64,
|
||||
outputs: &mut Vec<String>,
|
||||
) {
|
||||
let slot_dur = if scope.slot_count == 0 {
|
||||
scope.duration * scope.weight
|
||||
} else {
|
||||
@@ -814,6 +841,7 @@ fn resolve_scope(scope: &ScopeContext, step_duration: f64, outputs: &mut Vec<Str
|
||||
em.delta,
|
||||
em.dur,
|
||||
step_duration,
|
||||
nudge_secs,
|
||||
outputs,
|
||||
);
|
||||
}
|
||||
@@ -889,12 +917,14 @@ fn emit_output(
|
||||
delta: f64,
|
||||
dur: f64,
|
||||
step_duration: f64,
|
||||
nudge_secs: f64,
|
||||
outputs: &mut Vec<String>,
|
||||
) {
|
||||
let nudged_delta = delta + nudge_secs;
|
||||
let mut pairs = vec![("sound".into(), sound.to_string())];
|
||||
pairs.extend(params.iter().cloned());
|
||||
if delta > 0.0 {
|
||||
pairs.push(("delta".into(), delta.to_string()));
|
||||
if nudged_delta > 0.0 {
|
||||
pairs.push(("delta".into(), nudged_delta.to_string()));
|
||||
}
|
||||
if !pairs.iter().any(|(k, _)| k == "dur") {
|
||||
pairs.push(("dur".into(), dur.to_string()));
|
||||
|
||||
18
docs/about.md
Normal file
18
docs/about.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# About
|
||||
|
||||
Cagire is an experimental step sequencer built by BuboBubo (Raphaël Maurice Forment). It is a free and open-source project licensed under the AGPL-3.0 License. Cagire has been developed as a side project. I wanted to learn more about using Forth and needed a playground for experimentating with this audio engine! You are free to contribute to the project by making direct contributions to the codebase or by providing feedback and suggestions.
|
||||
|
||||
## Credits
|
||||
|
||||
- **Doux** (audio engine) is a Rust port of Dough, originally written in C by Felix Roos.
|
||||
- **mi-plaits-dsp-rs** is a Rust port of the code used by the Mutable Instruments Plaits.
|
||||
* _Author_: Oliver Rockstedt [info@sourcebox.de](info@sourcebox.de).
|
||||
* _Original author_: Emilie Gillet [emilie.o.gillet@gmail.com](emilie.o.gillet@gmail.com).
|
||||
|
||||
## About live coding
|
||||
|
||||
Live coding is a technique where a programmer writes code in real-time in front of an audience. It is a way to experiment with code, to share things and thoughts openly, to express yourself through code. It can be technical, poetical, weird, preferably all at once. Live coding can be used to create music, visual art, and other forms of media. Learn more about live coding on [https://toplap.org](https://toplap.org) or [https://livecoding.fr](https://livecoding.fr). Live coding is an autotelic activity: it is an activity that is intrinsically rewarding, and the act of doing it is its own reward. There are no errors, only fun to be found by playing music.
|
||||
|
||||
## About the tool
|
||||
|
||||
I do not want to pair it with a DAW, I do not want to make it fit with other commercial software. I'm not interested in VSTs or other proprietary workstations. Please, try to think of Cagire as an alternative to other tools.
|
||||
73
docs/audio_engine.md
Normal file
73
docs/audio_engine.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Audio Engine
|
||||
|
||||
Cagire uses **Doux** as its audio engine ([https://doux.livecoding.fr](https://doux.livecoding.fr)). Doux is a standalone synthesis and sampling engine that receives commands as strings and turns them into sound. Doux is a fixed graph synthesizer, which means that the structure of the sound is defined by a fixed set of nodes and connections, and the parameters of these nodes can be adjusted to create different sounds. Doux is extremely versatile and you are likely to find it useful for a wide range of musical styles and genres.
|
||||
|
||||
## How Sound is Produced
|
||||
|
||||
When the sequencer hits an active step, the Forth script is compiled and executed. Each emit operation (`.`) generates a command string that is sent to Doux. The command encodes the sound name and all accumulated parameters. The following example script:
|
||||
|
||||
```
|
||||
"saw" sound c4 note 0.5 gain 2000 lpf .
|
||||
```
|
||||
|
||||
will produce a command string that Doux interprets to _play a saw wave at C4 with gain 0.5 and a 2kHz lowpass filter_.
|
||||
|
||||
## Sound sources
|
||||
|
||||
Each sound needs a source. Sources are defined by typing their name followed by the `sound` keyword. Sources are raw waveforms or samples. They are shaped by passing additional parameters that will modify the characteristics of the sound: envelopes, effects, synthesis options, etc. The following example defines a source named `saw` with a frequency of 440 Hz, a gain of 0.5 and some reverb:
|
||||
|
||||
```
|
||||
"saw" source 440 freq 0.5 gain 0.5 verb .
|
||||
```
|
||||
|
||||
The audio engine offers a vast array (~20+) of sources including oscillators, noises, live input, and more.
|
||||
|
||||
## Settings
|
||||
|
||||
- **Channels**: Output channel count (1-64)
|
||||
- **Buffer Size**: Audio buffer in samples (64-4096). Lower values reduce latency but increase CPU load.
|
||||
- **Voices**: Maximum polyphony (1-128, default 32). When the limit is reached, the oldest voice is stolen.
|
||||
|
||||
Settings are persisted across sessions.
|
||||
|
||||
## Samples
|
||||
|
||||
Cagire scans sample directories recursively and indexes all audio files. Add sample paths on the Engine page with **a**, remove with **d**. Use samples in scripts by name:
|
||||
|
||||
```
|
||||
"kick" s .
|
||||
"hat" s 0.5 gain .
|
||||
```
|
||||
|
||||
The sample index is shown on the Engine page with the total count.
|
||||
|
||||
## Visualizers
|
||||
|
||||
The Engine page displays two real-time visualizers:
|
||||
|
||||
- **Scope**: Waveform display (64 samples), updated on every audio callback
|
||||
- **Spectrum**: 32-band FFT analyzer with logarithmic frequency scaling (20Hz to Nyquist), Hann window, displayed in dB
|
||||
|
||||
Both can be toggled on or off in the Options page.
|
||||
|
||||
## Monitoring
|
||||
|
||||
The Engine page shows live metrics:
|
||||
|
||||
- **Active voices**: Current polyphony count
|
||||
- **Peak voices**: Highest voice count since last reset (press **r** to reset)
|
||||
- **CPU load**: Audio thread utilization
|
||||
- **Events**: Total emitted and dropped event counts
|
||||
|
||||
## Tempo Scaling
|
||||
|
||||
Some parameters are automatically scaled by step duration so they sound consistent across tempos. These include envelope times (attack, decay, release), filter envelopes, pitch envelopes, FM envelopes, glide, and reverb/delay times.
|
||||
|
||||
## Commands
|
||||
|
||||
On the Engine page:
|
||||
|
||||
- **h**: Hush (graceful fade-out of all voices)
|
||||
- **p**: Panic (hard stop all voices immediately)
|
||||
- **r**: Reset peak voice counter
|
||||
- **t**: Test sound (plays a 440Hz sine)
|
||||
16
docs/welcome.md
Normal file
16
docs/welcome.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Welcome to 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. 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). This help view will teach you everything you need to know to start using Cagire and and to live code with it.
|
||||
|
||||
## Pages
|
||||
|
||||
Cagire is organized in several views. Navigate between them using **Ctrl+Left/Right/Up/Down**:
|
||||
|
||||
- **Sequencer**: Main view. Edit or preview patterns and scripts. Write Forth scripts.
|
||||
- **Patterns**: Project patterns management. 32 banks of 32 patterns per project. Edit pattern properties (name, length, etc).
|
||||
- **Engine**: Internal audio engine management: device selection, sample loading, performance options and voice / state monitoring.
|
||||
- **Options**: General application settings.
|
||||
- **Dict**: Forth word dictionary, organized by category. Learn about the internal programming language and its features.
|
||||
- **Help**: Documentation. This is the page view you are looking at right now.
|
||||
|
||||
Have fun with Cagire! Remember that live coding is all about experimentation and exploration!
|
||||
37
src/app.rs
37
src/app.rs
@@ -309,6 +309,7 @@ impl App {
|
||||
iter: 0,
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
};
|
||||
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
@@ -385,6 +386,7 @@ impl App {
|
||||
iter: 0,
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
};
|
||||
|
||||
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
|
||||
@@ -939,18 +941,45 @@ impl App {
|
||||
// Help navigation
|
||||
AppCommand::HelpNextTopic => {
|
||||
self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count();
|
||||
self.ui.help_scroll = 0;
|
||||
}
|
||||
AppCommand::HelpPrevTopic => {
|
||||
let count = help_view::topic_count();
|
||||
self.ui.help_topic = (self.ui.help_topic + count - 1) % count;
|
||||
self.ui.help_scroll = 0;
|
||||
}
|
||||
AppCommand::HelpScrollDown(n) => {
|
||||
self.ui.help_scroll = self.ui.help_scroll.saturating_add(n);
|
||||
let s = self.ui.help_scroll_mut();
|
||||
*s = s.saturating_add(n);
|
||||
}
|
||||
AppCommand::HelpScrollUp(n) => {
|
||||
self.ui.help_scroll = self.ui.help_scroll.saturating_sub(n);
|
||||
let s = self.ui.help_scroll_mut();
|
||||
*s = s.saturating_sub(n);
|
||||
}
|
||||
AppCommand::HelpActivateSearch => {
|
||||
self.ui.help_search_active = true;
|
||||
}
|
||||
AppCommand::HelpClearSearch => {
|
||||
self.ui.help_search_query.clear();
|
||||
self.ui.help_search_active = false;
|
||||
}
|
||||
AppCommand::HelpSearchInput(c) => {
|
||||
self.ui.help_search_query.push(c);
|
||||
if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) {
|
||||
self.ui.help_topic = topic;
|
||||
self.ui.help_scrolls[topic] = line;
|
||||
}
|
||||
}
|
||||
AppCommand::HelpSearchBackspace => {
|
||||
self.ui.help_search_query.pop();
|
||||
if self.ui.help_search_query.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some((topic, line)) = help_view::find_match(&self.ui.help_search_query) {
|
||||
self.ui.help_topic = topic;
|
||||
self.ui.help_scrolls[topic] = line;
|
||||
}
|
||||
}
|
||||
AppCommand::HelpSearchConfirm => {
|
||||
self.ui.help_search_active = false;
|
||||
}
|
||||
|
||||
// Dictionary navigation
|
||||
|
||||
@@ -130,6 +130,11 @@ pub enum AppCommand {
|
||||
HelpPrevTopic,
|
||||
HelpScrollDown(usize),
|
||||
HelpScrollUp(usize),
|
||||
HelpActivateSearch,
|
||||
HelpClearSearch,
|
||||
HelpSearchInput(char),
|
||||
HelpSearchBackspace,
|
||||
HelpSearchConfirm,
|
||||
|
||||
// Dictionary navigation
|
||||
DictToggleFocus,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use thread_priority::{set_current_thread_priority, ThreadPriority};
|
||||
|
||||
use super::LinkState;
|
||||
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
|
||||
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
|
||||
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
|
||||
use crate::state::LiveKeyState;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
@@ -203,6 +204,7 @@ pub fn spawn_sequencer(
|
||||
rng: Rng,
|
||||
quantum: f64,
|
||||
live_keys: Arc<LiveKeyState>,
|
||||
nudge_us: Arc<AtomicI64>,
|
||||
) -> (SequencerHandle, Receiver<AudioCommand>) {
|
||||
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
|
||||
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
|
||||
@@ -226,6 +228,7 @@ pub fn spawn_sequencer(
|
||||
quantum,
|
||||
shared_state_clone,
|
||||
live_keys,
|
||||
nudge_us,
|
||||
);
|
||||
})
|
||||
.expect("Failed to spawn sequencer thread");
|
||||
@@ -356,6 +359,7 @@ fn sequencer_loop(
|
||||
quantum: f64,
|
||||
shared_state: Arc<ArcSwap<SharedSequencerState>>,
|
||||
live_keys: Arc<LiveKeyState>,
|
||||
nudge_us: Arc<AtomicI64>,
|
||||
) {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -432,12 +436,8 @@ fn sequencer_loop(
|
||||
// Process pending starts with per-pattern quantization
|
||||
let mut started_ids: Vec<PatternId> = Vec::new();
|
||||
for pending in &audio_state.pending_starts {
|
||||
let should_start = check_quantization_boundary(
|
||||
pending.quantization,
|
||||
beat,
|
||||
prev_beat,
|
||||
quantum,
|
||||
);
|
||||
let should_start =
|
||||
check_quantization_boundary(pending.quantization, beat, prev_beat, quantum);
|
||||
if should_start {
|
||||
let start_step = match pending.sync_mode {
|
||||
SyncMode::Reset => 0,
|
||||
@@ -462,17 +462,15 @@ fn sequencer_loop(
|
||||
started_ids.push(pending.id);
|
||||
}
|
||||
}
|
||||
audio_state.pending_starts.retain(|p| !started_ids.contains(&p.id));
|
||||
audio_state
|
||||
.pending_starts
|
||||
.retain(|p| !started_ids.contains(&p.id));
|
||||
|
||||
// Process pending stops with per-pattern quantization
|
||||
let mut stopped_ids: Vec<PatternId> = Vec::new();
|
||||
for pending in &audio_state.pending_stops {
|
||||
let should_stop = check_quantization_boundary(
|
||||
pending.quantization,
|
||||
beat,
|
||||
prev_beat,
|
||||
quantum,
|
||||
);
|
||||
let should_stop =
|
||||
check_quantization_boundary(pending.quantization, beat, prev_beat, quantum);
|
||||
if should_stop {
|
||||
audio_state.active_patterns.remove(&pending.id);
|
||||
step_traces.retain(|&(bank, pattern, _), _| {
|
||||
@@ -485,7 +483,9 @@ fn sequencer_loop(
|
||||
stopped_ids.push(pending.id);
|
||||
}
|
||||
}
|
||||
audio_state.pending_stops.retain(|p| !stopped_ids.contains(&p.id));
|
||||
audio_state
|
||||
.pending_stops
|
||||
.retain(|p| !stopped_ids.contains(&p.id));
|
||||
|
||||
let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new();
|
||||
let mut chain_keys_to_remove: Vec<String> = Vec::new();
|
||||
@@ -513,6 +513,7 @@ fn sequencer_loop(
|
||||
let source_idx = pattern.resolve_source(step_idx);
|
||||
let runs =
|
||||
runs_counter.get_and_increment(active.bank, active.pattern, source_idx);
|
||||
let nudge_secs = nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0;
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat,
|
||||
@@ -525,6 +526,7 @@ fn sequencer_loop(
|
||||
iter: active.iter,
|
||||
speed: speed_mult,
|
||||
fill: live_keys.fill(),
|
||||
nudge_secs,
|
||||
};
|
||||
if let Some(script) = resolved_script {
|
||||
let mut trace = ExecutionTrace::default();
|
||||
@@ -582,15 +584,25 @@ fn sequencer_loop(
|
||||
if let Some(Value::Str(s, _)) = vars.get(key) {
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
if let (Ok(b), Ok(p)) = (parts[0].parse::<usize>(), parts[1].parse::<usize>()) {
|
||||
let target = PatternId { bank: b, pattern: p };
|
||||
if let (Ok(b), Ok(p)) =
|
||||
(parts[0].parse::<usize>(), parts[1].parse::<usize>())
|
||||
{
|
||||
let target = PatternId {
|
||||
bank: b,
|
||||
pattern: p,
|
||||
};
|
||||
// Extract bank/pattern from key: "__chain_{bank}_{pattern}__"
|
||||
if let Some(rest) = key.strip_prefix("__chain_") {
|
||||
if let Some(rest) = rest.strip_suffix("__") {
|
||||
let kparts: Vec<&str> = rest.split('_').collect();
|
||||
if kparts.len() == 2 {
|
||||
if let (Ok(sb), Ok(sp)) = (kparts[0].parse::<usize>(), kparts[1].parse::<usize>()) {
|
||||
let source = PatternId { bank: sb, pattern: sp };
|
||||
if let (Ok(sb), Ok(sp)) =
|
||||
(kparts[0].parse::<usize>(), kparts[1].parse::<usize>())
|
||||
{
|
||||
let source = PatternId {
|
||||
bank: sb,
|
||||
pattern: sp,
|
||||
};
|
||||
chain_transitions.push((source, target));
|
||||
}
|
||||
}
|
||||
|
||||
66
src/input.rs
66
src/input.rs
@@ -1,7 +1,7 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::Sender;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct InputContext<'a> {
|
||||
pub snapshot: &'a SequencerSnapshot,
|
||||
pub playing: &'a Arc<AtomicBool>,
|
||||
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
|
||||
pub nudge_us: &'a Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
impl<'a> InputContext<'a> {
|
||||
@@ -361,7 +362,11 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
} else {
|
||||
let dir = state.current_dir();
|
||||
if dir.is_dir() { Some(dir) } else { None }
|
||||
if dir.is_dir() {
|
||||
Some(dir)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(path) = sample_path {
|
||||
let index = doux::loader::scan_samples_dir(&path);
|
||||
@@ -573,8 +578,8 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
|
||||
fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
use cagire_ratatui::TreeLineKind;
|
||||
use crate::engine::AudioCommand;
|
||||
use cagire_ratatui::TreeLineKind;
|
||||
|
||||
let state = match &mut ctx.app.panel.side {
|
||||
Some(SidePanel::SampleBrowser(s)) => s,
|
||||
@@ -625,8 +630,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
TreeLineKind::File => {
|
||||
let folder = &entry.folder;
|
||||
let idx = entry.index;
|
||||
let cmd =
|
||||
format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
|
||||
let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1");
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(cmd));
|
||||
}
|
||||
_ => state.toggle_expand(),
|
||||
@@ -934,6 +938,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
SettingKind::Channels => ctx.app.audio.adjust_channels(-1),
|
||||
SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
||||
SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(-1),
|
||||
SettingKind::Nudge => {
|
||||
let prev = ctx.nudge_us.load(Ordering::Relaxed);
|
||||
ctx.nudge_us
|
||||
.store((prev - 1000).max(-100_000), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
@@ -948,6 +957,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
SettingKind::Channels => ctx.app.audio.adjust_channels(1),
|
||||
SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
||||
SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(1),
|
||||
SettingKind::Nudge => {
|
||||
let prev = ctx.nudge_us.load(Ordering::Relaxed);
|
||||
ctx.nudge_us
|
||||
.store((prev + 1000).min(100_000), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
@@ -979,10 +993,9 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0,
|
||||
KeyCode::Char('t') => {
|
||||
let _ = ctx
|
||||
.audio_tx
|
||||
.load()
|
||||
.send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into()));
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(
|
||||
"/sound/sine/dur/0.5/decay/0.2".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1035,7 +1048,22 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
|
||||
fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
if ctx.app.ui.help_search_active {
|
||||
match key.code {
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch),
|
||||
KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm),
|
||||
KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace),
|
||||
KeyCode::Char(c) => ctx.dispatch(AppCommand::HelpSearchInput(c)),
|
||||
_ => {}
|
||||
}
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('/') => ctx.dispatch(AppCommand::HelpActivateSearch),
|
||||
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
|
||||
ctx.dispatch(AppCommand::HelpClearSearch);
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)),
|
||||
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)),
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic),
|
||||
@@ -1077,18 +1105,14 @@ fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.dispatch(AppCommand::DictClearSearch);
|
||||
}
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus),
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)),
|
||||
}
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)),
|
||||
}
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)),
|
||||
},
|
||||
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)),
|
||||
},
|
||||
KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)),
|
||||
KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)),
|
||||
KeyCode::Char('q') => {
|
||||
|
||||
17
src/main.rs
17
src/main.rs
@@ -12,12 +12,12 @@ mod widgets;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use crossterm::event::{self, Event, EnableBracketedPaste, DisableBracketedPaste};
|
||||
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
@@ -27,7 +27,9 @@ use ratatui::prelude::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use app::App;
|
||||
use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer};
|
||||
use engine::{
|
||||
build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer,
|
||||
};
|
||||
use input::{handle_key, InputContext, InputResult};
|
||||
use settings::Settings;
|
||||
use state::audio::RefreshRate;
|
||||
@@ -66,6 +68,7 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
|
||||
let playing = Arc::new(AtomicBool::new(true));
|
||||
let nudge_us = Arc::new(AtomicI64::new(0));
|
||||
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -111,6 +114,7 @@ fn main() -> io::Result<()> {
|
||||
Arc::clone(&app.rng),
|
||||
settings.link.quantum,
|
||||
Arc::clone(&app.live_keys),
|
||||
Arc::clone(&nudge_us),
|
||||
);
|
||||
|
||||
let stream_config = AudioStreamConfig {
|
||||
@@ -201,6 +205,7 @@ fn main() -> io::Result<()> {
|
||||
app.metrics.scope = scope_buffer.read();
|
||||
(app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks();
|
||||
app.metrics.spectrum = spectrum_buffer.read();
|
||||
app.metrics.nudge_ms = nudge_us.load(Ordering::Relaxed) as f64 / 1000.0;
|
||||
}
|
||||
|
||||
let seq_snapshot = sequencer.snapshot();
|
||||
@@ -215,7 +220,9 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot))?;
|
||||
|
||||
if event::poll(Duration::from_millis(app.audio.config.refresh_rate.millis()))? {
|
||||
if event::poll(Duration::from_millis(
|
||||
app.audio.config.refresh_rate.millis(),
|
||||
))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
let mut ctx = InputContext {
|
||||
@@ -224,6 +231,7 @@ fn main() -> io::Result<()> {
|
||||
snapshot: &seq_snapshot,
|
||||
playing: &playing,
|
||||
audio_tx: &sequencer.audio_tx,
|
||||
nudge_us: &nudge_us,
|
||||
};
|
||||
|
||||
if let InputResult::Quit = handle_key(&mut ctx, key) {
|
||||
@@ -238,7 +246,6 @@ fn main() -> io::Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
disable_raw_mode()?;
|
||||
|
||||
@@ -139,6 +139,7 @@ pub enum SettingKind {
|
||||
Channels,
|
||||
BufferSize,
|
||||
Polyphony,
|
||||
Nudge,
|
||||
}
|
||||
|
||||
impl SettingKind {
|
||||
@@ -146,15 +147,17 @@ impl SettingKind {
|
||||
match self {
|
||||
Self::Channels => Self::BufferSize,
|
||||
Self::BufferSize => Self::Polyphony,
|
||||
Self::Polyphony => Self::Channels,
|
||||
Self::Polyphony => Self::Nudge,
|
||||
Self::Nudge => Self::Channels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Channels => Self::Polyphony,
|
||||
Self::Channels => Self::Nudge,
|
||||
Self::BufferSize => Self::Channels,
|
||||
Self::Polyphony => Self::BufferSize,
|
||||
Self::Nudge => Self::Polyphony,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +173,7 @@ pub struct Metrics {
|
||||
pub peak_left: f32,
|
||||
pub peak_right: f32,
|
||||
pub spectrum: [f32; 32],
|
||||
pub nudge_ms: f64,
|
||||
}
|
||||
|
||||
impl Default for Metrics {
|
||||
@@ -185,6 +189,7 @@ impl Default for Metrics {
|
||||
peak_left: 0.0,
|
||||
peak_right: 0.0,
|
||||
spectrum: [0.0; 32],
|
||||
nudge_ms: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,8 +216,14 @@ impl Default for AudioSettings {
|
||||
setting_kind: SettingKind::default(),
|
||||
output_devices: doux::audio::list_output_devices(),
|
||||
input_devices: doux::audio::list_input_devices(),
|
||||
output_list: ListSelectState { cursor: 0, scroll_offset: 0 },
|
||||
input_list: ListSelectState { cursor: 0, scroll_offset: 0 },
|
||||
output_list: ListSelectState {
|
||||
cursor: 0,
|
||||
scroll_offset: 0,
|
||||
},
|
||||
input_list: ListSelectState {
|
||||
cursor: 0,
|
||||
scroll_offset: 0,
|
||||
},
|
||||
restart_pending: false,
|
||||
error: None,
|
||||
}
|
||||
@@ -290,7 +301,6 @@ impl AudioSettings {
|
||||
self.config.refresh_rate = self.config.refresh_rate.toggle();
|
||||
}
|
||||
|
||||
|
||||
pub fn add_sample_path(&mut self, path: PathBuf) {
|
||||
if !self.config.sample_paths.contains(&path) {
|
||||
self.config.sample_paths.push(path);
|
||||
|
||||
@@ -26,7 +26,9 @@ pub struct UiState {
|
||||
pub flash_kind: FlashKind,
|
||||
pub modal: Modal,
|
||||
pub help_topic: usize,
|
||||
pub help_scroll: usize,
|
||||
pub help_scrolls: Vec<usize>,
|
||||
pub help_search_active: bool,
|
||||
pub help_search_query: String,
|
||||
pub dict_focus: DictFocus,
|
||||
pub dict_category: usize,
|
||||
pub dict_scroll: usize,
|
||||
@@ -47,7 +49,9 @@ impl Default for UiState {
|
||||
flash_kind: FlashKind::Success,
|
||||
modal: Modal::None,
|
||||
help_topic: 0,
|
||||
help_scroll: 0,
|
||||
help_scrolls: vec![0; crate::views::help_view::topic_count()],
|
||||
help_search_active: false,
|
||||
help_search_query: String::new(),
|
||||
dict_focus: DictFocus::default(),
|
||||
dict_category: 0,
|
||||
dict_scroll: 0,
|
||||
@@ -62,6 +66,14 @@ impl Default for UiState {
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn help_scroll(&self) -> usize {
|
||||
self.help_scrolls[self.help_topic]
|
||||
}
|
||||
|
||||
pub fn help_scroll_mut(&mut self) -> &mut usize {
|
||||
&mut self.help_scrolls[self.help_topic]
|
||||
}
|
||||
|
||||
pub fn flash(&mut self, msg: &str, duration_ms: u64, kind: FlashKind) {
|
||||
self.status_message = Some(msg.to_string());
|
||||
self.flash_until = Some(Instant::now() + Duration::from_millis(duration_ms));
|
||||
@@ -69,7 +81,11 @@ impl UiState {
|
||||
}
|
||||
|
||||
pub fn flash_kind(&self) -> Option<FlashKind> {
|
||||
if self.is_flashing() { Some(self.flash_kind) } else { None }
|
||||
if self.is_flashing() {
|
||||
Some(self.flash_kind)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, msg: String) {
|
||||
|
||||
@@ -45,7 +45,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
||||
Constraint::Length(devices_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(6),
|
||||
])
|
||||
@@ -106,7 +106,11 @@ fn truncate_name(name: &str, max_len: usize) -> String {
|
||||
|
||||
fn list_height(item_count: usize) -> u16 {
|
||||
let visible = item_count.min(5) as u16;
|
||||
if item_count > 5 { visible + 1 } else { visible }
|
||||
if item_count > 5 {
|
||||
visible + 1
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
}
|
||||
|
||||
fn devices_section_height(app: &App) -> u16 {
|
||||
@@ -116,10 +120,8 @@ fn devices_section_height(app: &App) -> u16 {
|
||||
}
|
||||
|
||||
fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
|
||||
let [header_area, divider_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
]).areas(area);
|
||||
let [header_area, divider_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
||||
|
||||
let header_style = if focused {
|
||||
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
@@ -139,10 +141,8 @@ fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Re
|
||||
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let section_focused = app.audio.section == EngineSection::Devices;
|
||||
|
||||
let [header_area, content_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(1),
|
||||
]).areas(area);
|
||||
let [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
|
||||
|
||||
render_section_header(frame, "DEVICES", section_focused, header_area);
|
||||
|
||||
@@ -150,14 +150,17 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
||||
Constraint::Percentage(48),
|
||||
Constraint::Length(3),
|
||||
Constraint::Percentage(48),
|
||||
]).areas(content_area);
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
let output_focused = section_focused && app.audio.device_kind == DeviceKind::Output;
|
||||
let input_focused = section_focused && app.audio.device_kind == DeviceKind::Input;
|
||||
|
||||
render_device_column(
|
||||
frame, output_col,
|
||||
"Output", &app.audio.output_devices,
|
||||
frame,
|
||||
output_col,
|
||||
"Output",
|
||||
&app.audio.output_devices,
|
||||
app.audio.current_output_device_index(),
|
||||
app.audio.output_list.cursor,
|
||||
app.audio.output_list.scroll_offset,
|
||||
@@ -172,8 +175,10 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(Paragraph::new(sep_lines), separator);
|
||||
|
||||
render_device_column(
|
||||
frame, input_col,
|
||||
"Input", &app.audio.input_devices,
|
||||
frame,
|
||||
input_col,
|
||||
"Input",
|
||||
&app.audio.input_devices,
|
||||
app.audio.current_input_device_index(),
|
||||
app.audio.input_list.cursor,
|
||||
app.audio.input_list.scroll_offset,
|
||||
@@ -193,10 +198,8 @@ fn render_device_column(
|
||||
focused: bool,
|
||||
section_focused: bool,
|
||||
) {
|
||||
let [label_area, list_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
]).areas(area);
|
||||
let [label_area, list_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
||||
|
||||
let label_style = if focused {
|
||||
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
@@ -212,9 +215,7 @@ fn render_device_column(
|
||||
label_area,
|
||||
);
|
||||
|
||||
let items: Vec<String> = devices.iter()
|
||||
.map(|d| truncate_name(&d.name, 25))
|
||||
.collect();
|
||||
let items: Vec<String> = devices.iter().map(|d| truncate_name(&d.name, 25)).collect();
|
||||
|
||||
ListSelect::new(&items, selected_idx, cursor)
|
||||
.focused(focused)
|
||||
@@ -238,10 +239,25 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let channels_focused = section_focused && app.audio.setting_kind == SettingKind::Channels;
|
||||
let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize;
|
||||
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
|
||||
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
|
||||
|
||||
let nudge_ms = app.metrics.nudge_ms;
|
||||
let nudge_label = if nudge_ms == 0.0 {
|
||||
"0 ms".to_string()
|
||||
} else {
|
||||
format!("{nudge_ms:+.1} ms")
|
||||
};
|
||||
|
||||
let rows = vec![
|
||||
Row::new(vec![
|
||||
Span::styled(if channels_focused { "> Channels" } else { " Channels" }, label_style),
|
||||
Span::styled(
|
||||
if channels_focused {
|
||||
"> Channels"
|
||||
} else {
|
||||
" Channels"
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(
|
||||
&format!("{}", app.audio.config.channels),
|
||||
channels_focused,
|
||||
@@ -250,7 +266,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Span::styled(if buffer_focused { "> Buffer" } else { " Buffer" }, label_style),
|
||||
Span::styled(
|
||||
if buffer_focused {
|
||||
"> Buffer"
|
||||
} else {
|
||||
" Buffer"
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(
|
||||
&format!("{}", app.audio.config.buffer_size),
|
||||
buffer_focused,
|
||||
@@ -259,7 +282,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Span::styled(if polyphony_focused { "> Voices" } else { " Voices" }, label_style),
|
||||
Span::styled(
|
||||
if polyphony_focused {
|
||||
"> Voices"
|
||||
} else {
|
||||
" Voices"
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(
|
||||
&format!("{}", app.audio.config.max_voices),
|
||||
polyphony_focused,
|
||||
@@ -267,6 +297,13 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
normal,
|
||||
),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Span::styled(
|
||||
if nudge_focused { "> Nudge" } else { " Nudge" },
|
||||
label_style,
|
||||
),
|
||||
render_selector(&nudge_label, nudge_focused, highlight, normal),
|
||||
]),
|
||||
Row::new(vec![
|
||||
Span::styled(" Sample rate", label_style),
|
||||
Span::styled(
|
||||
|
||||
@@ -1,40 +1,47 @@
|
||||
use minimad::{Composite, CompositeStyle, Compound, Line};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::style::{Color, Modifier, Style, Stylize};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use tui_big_text::{BigText, PixelSize};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::views::highlight;
|
||||
|
||||
const STATIC_DOCS: &[(&str, &str)] = &[
|
||||
// To add a new help topic: drop a .md file in docs/ and add one line here.
|
||||
const DOCS: &[(&str, &str)] = &[
|
||||
("Welcome", include_str!("../../docs/welcome.md")),
|
||||
("Audio Engine", include_str!("../../docs/audio_engine.md")),
|
||||
("Keybindings", include_str!("../../docs/keybindings.md")),
|
||||
("Sequencer", include_str!("../../docs/sequencer.md")),
|
||||
("About", include_str!("../../docs/about.md")),
|
||||
];
|
||||
|
||||
const TOPICS: &[&str] = &["Keybindings", "Sequencer"];
|
||||
pub fn topic_count() -> usize {
|
||||
DOCS.len()
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [topics_area, content_area] =
|
||||
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
|
||||
|
||||
render_topics(frame, app, topics_area);
|
||||
|
||||
let topic = TOPICS[app.ui.help_topic];
|
||||
render_markdown_content(frame, app, content_area, topic);
|
||||
render_content(frame, app, content_area);
|
||||
}
|
||||
|
||||
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = TOPICS
|
||||
let items: Vec<ListItem> = DOCS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let style = if i == app.ui.help_topic {
|
||||
.map(|(i, (name, _))| {
|
||||
let selected = i == app.ui.help_topic;
|
||||
let style = if selected {
|
||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new().fg(Color::White)
|
||||
};
|
||||
let prefix = if i == app.ui.help_topic { "> " } else { " " };
|
||||
let prefix = if selected { "> " } else { " " };
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
})
|
||||
.collect();
|
||||
@@ -43,39 +50,215 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
|
||||
let md = STATIC_DOCS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == topic)
|
||||
.map(|(_, content)| *content)
|
||||
.unwrap_or("");
|
||||
const WELCOME_TOPIC: usize = 0;
|
||||
const BIG_TITLE_HEIGHT: u16 = 6;
|
||||
|
||||
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let (_, md) = DOCS[app.ui.help_topic];
|
||||
|
||||
let is_welcome = app.ui.help_topic == WELCOME_TOPIC;
|
||||
let md_area = if is_welcome {
|
||||
let [title_area, rest] =
|
||||
Layout::vertical([Constraint::Length(BIG_TITLE_HEIGHT), Constraint::Fill(1)])
|
||||
.areas(area);
|
||||
let big_title = BigText::builder()
|
||||
.pixel_size(PixelSize::Quadrant)
|
||||
.style(Style::new().cyan().bold())
|
||||
.lines(vec!["CAGIRE".into()])
|
||||
.centered()
|
||||
.build();
|
||||
let subtitle = Paragraph::new(RLine::from(Span::styled(
|
||||
"A Forth Sequencer",
|
||||
Style::new().fg(Color::White),
|
||||
)))
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
let [big_area, subtitle_area] =
|
||||
Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area);
|
||||
frame.render_widget(big_title, big_area);
|
||||
frame.render_widget(subtitle, subtitle_area);
|
||||
rest
|
||||
} else {
|
||||
area
|
||||
};
|
||||
let query = &app.ui.help_search_query;
|
||||
let has_query = !query.is_empty();
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let lines = parse_markdown(md);
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll = app.ui.help_scroll.min(max_scroll);
|
||||
let has_search_bar = app.ui.help_search_active || has_query;
|
||||
let search_bar_height: u16 = u16::from(has_search_bar);
|
||||
let visible_height = md_area.height.saturating_sub(6 + search_bar_height) as usize;
|
||||
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||
let scroll = app.ui.help_scroll().min(max_scroll);
|
||||
|
||||
let visible: Vec<RLine> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.map(|line| {
|
||||
if has_query {
|
||||
highlight_line(line, &query_lower)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
|
||||
frame.render_widget(para, area);
|
||||
let content_area = if has_search_bar {
|
||||
let [content, search] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
|
||||
render_search_bar(frame, app, search);
|
||||
content
|
||||
} else {
|
||||
md_area
|
||||
};
|
||||
|
||||
let para = Paragraph::new(visible)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.padding(Padding::new(2, 2, 2, 2)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(para, content_area);
|
||||
}
|
||||
|
||||
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let style = if app.ui.help_search_active {
|
||||
Style::new().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
let cursor = if app.ui.help_search_active { "█" } else { "" };
|
||||
let text = format!(" /{}{cursor}", app.ui.help_search_query);
|
||||
frame.render_widget(Paragraph::new(text).style(style), area);
|
||||
}
|
||||
|
||||
fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
|
||||
let mut result: Vec<Span<'a>> = Vec::new();
|
||||
for span in line.spans {
|
||||
let lower = span.content.to_lowercase();
|
||||
if !lower.contains(query) {
|
||||
result.push(span);
|
||||
continue;
|
||||
}
|
||||
let content = span.content.to_string();
|
||||
let base_style = span.style;
|
||||
let hl_style = base_style.bg(Color::Yellow).fg(Color::Black);
|
||||
let mut start = 0;
|
||||
let lower_bytes = lower.as_bytes();
|
||||
let query_bytes = query.as_bytes();
|
||||
while let Some(pos) = find_bytes(&lower_bytes[start..], query_bytes) {
|
||||
let abs = start + pos;
|
||||
if abs > start {
|
||||
result.push(Span::styled(content[start..abs].to_string(), base_style));
|
||||
}
|
||||
result.push(Span::styled(
|
||||
content[abs..abs + query.len()].to_string(),
|
||||
hl_style,
|
||||
));
|
||||
start = abs + query.len();
|
||||
}
|
||||
if start < content.len() {
|
||||
result.push(Span::styled(content[start..].to_string(), base_style));
|
||||
}
|
||||
}
|
||||
RLine::from(result)
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Find first line matching query across all topics. Returns (topic_index, line_index).
|
||||
pub fn find_match(query: &str) -> Option<(usize, usize)> {
|
||||
let query = query.to_lowercase();
|
||||
for (topic_idx, (_, content)) in DOCS.iter().enumerate() {
|
||||
for (line_idx, line) in content.lines().enumerate() {
|
||||
if line.to_lowercase().contains(&query) {
|
||||
return Some((topic_idx, line_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn code_border_style() -> Style {
|
||||
Style::new().fg(Color::Rgb(60, 60, 70))
|
||||
}
|
||||
|
||||
fn preprocess_underscores(md: &str) -> String {
|
||||
let mut out = String::with_capacity(md.len());
|
||||
for line in md.lines() {
|
||||
let mut result = String::with_capacity(line.len());
|
||||
let mut chars = line.char_indices().peekable();
|
||||
let bytes = line.as_bytes();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c == '`' {
|
||||
result.push(c);
|
||||
for (_, ch) in chars.by_ref() {
|
||||
result.push(ch);
|
||||
if ch == '`' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '_' {
|
||||
let before_is_space = i == 0 || bytes[i - 1] == b' ';
|
||||
if before_is_space {
|
||||
if let Some(end) = line[i + 1..].find('_') {
|
||||
let inner = &line[i + 1..i + 1 + end];
|
||||
if !inner.is_empty() {
|
||||
result.push('*');
|
||||
result.push_str(inner);
|
||||
result.push('*');
|
||||
for _ in 0..end {
|
||||
chars.next();
|
||||
}
|
||||
chars.next(); // skip closing _
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(c);
|
||||
}
|
||||
out.push_str(&result);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
|
||||
let text = minimad::Text::from(md);
|
||||
let processed = preprocess_underscores(md);
|
||||
let text = minimad::Text::from(processed.as_str());
|
||||
let mut lines = Vec::new();
|
||||
|
||||
let mut code_line_nr: usize = 0;
|
||||
|
||||
for line in text.lines {
|
||||
match line {
|
||||
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
|
||||
code_line_nr += 1;
|
||||
let raw: String = composite.compounds.iter().map(|c| &*c.src).collect();
|
||||
let mut spans = vec![
|
||||
Span::styled(format!(" {code_line_nr:>2} "), code_border_style()),
|
||||
Span::styled("│ ", code_border_style()),
|
||||
];
|
||||
spans.extend(
|
||||
highlight::highlight_line(&raw)
|
||||
.into_iter()
|
||||
.map(|(style, text)| Span::styled(text, style)),
|
||||
);
|
||||
lines.push(RLine::from(spans));
|
||||
}
|
||||
Line::Normal(composite) => {
|
||||
code_line_nr = 0;
|
||||
lines.push(composite_to_line(composite));
|
||||
}
|
||||
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
|
||||
_ => {
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
}
|
||||
@@ -109,13 +292,13 @@ fn composite_to_line(composite: Composite) -> RLine<'static> {
|
||||
}
|
||||
|
||||
for compound in composite.compounds {
|
||||
spans.push(compound_to_span(compound, base_style));
|
||||
compound_to_spans(compound, base_style, &mut spans);
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
||||
fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec<Span<'static>>) {
|
||||
let mut style = base;
|
||||
|
||||
if compound.bold {
|
||||
@@ -131,9 +314,39 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
|
||||
Span::styled(compound.src.to_string(), style)
|
||||
}
|
||||
let src = compound.src.to_string();
|
||||
let link_style = Style::new()
|
||||
.fg(Color::Rgb(120, 200, 180))
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
TOPICS.len()
|
||||
let mut rest = src.as_str();
|
||||
while let Some(start) = rest.find('[') {
|
||||
let after_bracket = &rest[start + 1..];
|
||||
if let Some(text_end) = after_bracket.find("](") {
|
||||
let url_start = start + 1 + text_end + 2;
|
||||
if let Some(url_end) = rest[url_start..].find(')') {
|
||||
if start > 0 {
|
||||
out.push(Span::styled(rest[..start].to_string(), style));
|
||||
}
|
||||
let text = &rest[start + 1..start + 1 + text_end];
|
||||
let url = &rest[url_start..url_start + url_end];
|
||||
if text == url {
|
||||
out.push(Span::styled(url.to_string(), link_style));
|
||||
} else {
|
||||
out.push(Span::styled(text.to_string(), link_style));
|
||||
out.push(Span::styled(
|
||||
format!(" ({url})"),
|
||||
Style::new().fg(Color::Rgb(100, 100, 100)),
|
||||
));
|
||||
}
|
||||
rest = &rest[url_start + url_end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(Span::styled(rest[..start + 1].to_string(), style));
|
||||
rest = &rest[start + 1..];
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push(Span::styled(rest.to_string(), style));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,31 @@ use crate::model::SourceSpan;
|
||||
use crate::page::Page;
|
||||
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
|
||||
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
||||
use crate::widgets::{ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal};
|
||||
use crate::widgets::{
|
||||
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal,
|
||||
};
|
||||
|
||||
use super::{dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view};
|
||||
use super::{
|
||||
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
|
||||
};
|
||||
|
||||
fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usize) -> Vec<SourceSpan> {
|
||||
spans.iter().filter_map(|s| {
|
||||
if s.end <= line_start || s.start >= line_start + line_len {
|
||||
return None;
|
||||
}
|
||||
Some(SourceSpan {
|
||||
start: s.start.max(line_start) - line_start,
|
||||
end: (s.end.min(line_start + line_len)) - line_start,
|
||||
fn adjust_spans_for_line(
|
||||
spans: &[SourceSpan],
|
||||
line_start: usize,
|
||||
line_len: usize,
|
||||
) -> Vec<SourceSpan> {
|
||||
spans
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if s.end <= line_start || s.start >= line_start + line_len {
|
||||
return None;
|
||||
}
|
||||
Some(SourceSpan {
|
||||
start: s.start.max(line_start) - line_start,
|
||||
end: (s.end.min(line_start + line_len)) - line_start,
|
||||
})
|
||||
})
|
||||
}).collect()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
|
||||
@@ -60,19 +71,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
||||
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
|
||||
if body_area.width >= 120 {
|
||||
let panel_width = body_area.width * 35 / 100;
|
||||
let [main, side] = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(panel_width),
|
||||
])
|
||||
.areas(body_area);
|
||||
let [main, side] =
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
|
||||
.areas(body_area);
|
||||
(main, Some(side))
|
||||
} else {
|
||||
let panel_height = body_area.height * 40 / 100;
|
||||
let [main, side] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(panel_height),
|
||||
])
|
||||
.areas(body_area);
|
||||
let [main, side] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
|
||||
.areas(body_area);
|
||||
(main, Some(side))
|
||||
}
|
||||
} else {
|
||||
@@ -106,7 +113,11 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (col, row) = p.grid_pos();
|
||||
NavTile { col, row, name: p.name() }
|
||||
NavTile {
|
||||
col,
|
||||
row,
|
||||
name: p.name(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let selected = app.page.grid_pos();
|
||||
@@ -170,9 +181,13 @@ fn render_header(
|
||||
// Fill indicator
|
||||
let fill = app.live_keys.fill();
|
||||
let fill_style = if fill {
|
||||
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(100, 220, 100))
|
||||
Style::new()
|
||||
.bg(Color::Rgb(30, 30, 35))
|
||||
.fg(Color::Rgb(100, 220, 100))
|
||||
} else {
|
||||
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(60, 60, 70))
|
||||
Style::new()
|
||||
.bg(Color::Rgb(30, 30, 35))
|
||||
.fg(Color::Rgb(60, 60, 70))
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(if fill { "F" } else { "·" })
|
||||
@@ -303,21 +318,14 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("Enter", "Select"),
|
||||
("A", "Add path"),
|
||||
],
|
||||
Page::Options => vec![
|
||||
("Tab", "Next"),
|
||||
("←→", "Toggle"),
|
||||
("Space", "Play"),
|
||||
],
|
||||
Page::Options => vec![("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play")],
|
||||
Page::Help => vec![
|
||||
("↑↓", "Scroll"),
|
||||
("Tab", "Topic"),
|
||||
("PgUp/Dn", "Page"),
|
||||
],
|
||||
Page::Dict => vec![
|
||||
("Tab", "Focus"),
|
||||
("↑↓", "Navigate"),
|
||||
("/", "Search"),
|
||||
],
|
||||
Page::Dict => vec![("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search")],
|
||||
};
|
||||
|
||||
let page_width = page_indicator.chars().count();
|
||||
@@ -505,8 +513,16 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.lines()
|
||||
.map(|line_str| {
|
||||
let tokens = if let Some(t) = trace {
|
||||
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
|
||||
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
|
||||
let exec = adjust_spans_for_line(
|
||||
&t.executed_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
let sel = adjust_spans_for_line(
|
||||
&t.selected_spans,
|
||||
line_start,
|
||||
line_str.len(),
|
||||
);
|
||||
highlight_line_with_runtime(line_str, &exec, &sel)
|
||||
} else {
|
||||
highlight_line(line_str)
|
||||
@@ -544,7 +560,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
.render_centered(frame, term);
|
||||
|
||||
let trace = if app.ui.runtime_highlight && app.playback.playing {
|
||||
let source = app.current_edit_pattern().resolve_source(app.editor_ctx.step);
|
||||
let source = app
|
||||
.current_edit_pattern()
|
||||
.resolve_source(app.editor_ctx.step);
|
||||
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
|
||||
} else {
|
||||
None
|
||||
@@ -575,11 +593,22 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
let (search_area, editor_area, hint_area) = if show_search {
|
||||
let search_area = Rect::new(inner.x, inner.y, inner.width, 1);
|
||||
let editor_area = Rect::new(inner.x, inner.y + 1, inner.width, inner.height.saturating_sub(2));
|
||||
let hint_area = Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
|
||||
let editor_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y + 1,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(2),
|
||||
);
|
||||
let hint_area =
|
||||
Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
|
||||
(Some(search_area), editor_area, hint_area)
|
||||
} else {
|
||||
let editor_area = Rect::new(inner.x, inner.y, inner.width, inner.height.saturating_sub(1));
|
||||
let editor_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(1),
|
||||
);
|
||||
let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1);
|
||||
(None, editor_area, hint_area)
|
||||
};
|
||||
@@ -590,7 +619,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let cursor = if app.editor_ctx.editor.search_active() { "_" } else { "" };
|
||||
let cursor = if app.editor_ctx.editor.search_active() {
|
||||
"_"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let text = format!("/{}{}", app.editor_ctx.editor.search_query(), cursor);
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), sa);
|
||||
}
|
||||
@@ -604,24 +637,35 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
let flash_block = Block::default().style(Style::default().bg(bg));
|
||||
frame.render_widget(flash_block, editor_area);
|
||||
}
|
||||
app.editor_ctx.editor.render(frame, editor_area, &highlighter);
|
||||
app.editor_ctx
|
||||
.editor
|
||||
.render(frame, editor_area, &highlighter);
|
||||
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let key = Style::default().fg(Color::Yellow);
|
||||
let hint = if app.editor_ctx.editor.search_active() {
|
||||
Line::from(vec![
|
||||
Span::styled("Enter", key), Span::styled(" confirm ", dim),
|
||||
Span::styled("Esc", key), Span::styled(" cancel", dim),
|
||||
Span::styled("Enter", key),
|
||||
Span::styled(" confirm ", dim),
|
||||
Span::styled("Esc", key),
|
||||
Span::styled(" cancel", dim),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
Span::styled("Esc", key), Span::styled(" save ", dim),
|
||||
Span::styled("C-e", key), Span::styled(" eval ", dim),
|
||||
Span::styled("C-f", key), Span::styled(" find ", dim),
|
||||
Span::styled("C-n", key), Span::styled("/", dim),
|
||||
Span::styled("C-p", key), Span::styled(" next/prev ", dim),
|
||||
Span::styled("C-u", key), Span::styled("/", dim),
|
||||
Span::styled("C-r", key), Span::styled(" undo/redo", dim),
|
||||
Span::styled("Esc", key),
|
||||
Span::styled(" save ", dim),
|
||||
Span::styled("C-e", key),
|
||||
Span::styled(" eval ", dim),
|
||||
Span::styled("C-f", key),
|
||||
Span::styled(" find ", dim),
|
||||
Span::styled("C-n", key),
|
||||
Span::styled("/", dim),
|
||||
Span::styled("C-p", key),
|
||||
Span::styled(" next/prev ", dim),
|
||||
Span::styled("C-u", key),
|
||||
Span::styled("/", dim),
|
||||
Span::styled("C-r", key),
|
||||
Span::styled(" undo/redo", dim),
|
||||
])
|
||||
};
|
||||
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
|
||||
@@ -654,7 +698,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
let fields = [
|
||||
("Name", name.as_str(), *field == PatternPropsField::Name),
|
||||
("Length", length.as_str(), *field == PatternPropsField::Length),
|
||||
(
|
||||
"Length",
|
||||
length.as_str(),
|
||||
*field == PatternPropsField::Length,
|
||||
),
|
||||
("Speed", speed.label(), *field == PatternPropsField::Speed),
|
||||
(
|
||||
"Quantization",
|
||||
@@ -676,7 +724,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
|
||||
let (label_style, value_style) = if *selected {
|
||||
(
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(Color::White).bg(Color::DarkGray),
|
||||
)
|
||||
} else {
|
||||
@@ -693,10 +743,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
Paragraph::new(format!("{label}:")).style(label_style),
|
||||
label_area,
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(*value).style(value_style),
|
||||
value_area,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
|
||||
}
|
||||
|
||||
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use cagire::forth::{Dictionary, Forth, Rng, StepContext, Value, Variables};
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
use cagire::forth::{Dictionary, Forth, Rng, StepContext, Value, Variables};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn default_ctx() -> StepContext {
|
||||
iter: 0,
|
||||
speed: 1.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,4 +131,3 @@ pub fn expect_outputs(script: &str, count: usize) -> Vec<String> {
|
||||
assert_eq!(outputs.len(), count, "expected {} outputs", count);
|
||||
outputs
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user