Auto docs
This commit is contained in:
@@ -1,115 +0,0 @@
|
|||||||
# Scripting
|
|
||||||
|
|
||||||
Steps are programmed using Rhai, a simple scripting language.
|
|
||||||
|
|
||||||
## Basic Syntax
|
|
||||||
|
|
||||||
Create sounds using `sound()` and chain parameters:
|
|
||||||
|
|
||||||
```
|
|
||||||
sound("kick").gain(0.8)
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
sound("hat").freq(8000).decay(0.1)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Context Variables
|
|
||||||
|
|
||||||
These are available in every step script:
|
|
||||||
|
|
||||||
- `step`: Current step index (0-based)
|
|
||||||
- `beat`: Current beat position
|
|
||||||
- `bank`: Current bank index
|
|
||||||
- `pattern`: Current pattern index
|
|
||||||
- `tempo`: Current BPM
|
|
||||||
- `phase`: Phase within the bar (0.0 to 1.0)
|
|
||||||
- `slot`: Slot number playing this pattern
|
|
||||||
|
|
||||||
## Randomness
|
|
||||||
|
|
||||||
- `rand(min, max)`: Random float in range
|
|
||||||
- `rrand(min, max)`: Random integer in range (inclusive)
|
|
||||||
- `seed(n)`: Set random seed for reproducibility
|
|
||||||
|
|
||||||
## Variables
|
|
||||||
|
|
||||||
Store and retrieve values across steps:
|
|
||||||
|
|
||||||
- `set("name", value)`: Store a value
|
|
||||||
- `get("name")`: Retrieve a value
|
|
||||||
|
|
||||||
## Sound Parameters
|
|
||||||
|
|
||||||
### Core
|
|
||||||
|
|
||||||
- `sound(name)`: Create sound command
|
|
||||||
- `freq(hz)`: Frequency
|
|
||||||
- `note(midi)`: MIDI note number
|
|
||||||
- `gain(amp)`: Volume (0.0-1.0)
|
|
||||||
- `pan(pos)`: Stereo position (-1.0 to 1.0)
|
|
||||||
- `dur(secs)`: Duration
|
|
||||||
- `gate(secs)`: Gate time
|
|
||||||
|
|
||||||
### Envelope
|
|
||||||
|
|
||||||
- `attack(secs)`: Attack time
|
|
||||||
- `decay(secs)`: Decay time
|
|
||||||
- `sustain(level)`: Sustain level
|
|
||||||
- `release(secs)`: Release time
|
|
||||||
|
|
||||||
### Filter
|
|
||||||
|
|
||||||
- `lpf(hz)`: Lowpass frequency
|
|
||||||
- `lpq(q)`: Lowpass resonance
|
|
||||||
- `hpf(hz)`: Highpass frequency
|
|
||||||
- `bpf(hz)`: Bandpass frequency
|
|
||||||
|
|
||||||
### Effects
|
|
||||||
|
|
||||||
- `delay(mix)`: Delay amount
|
|
||||||
- `delaytime(secs)`: Delay time
|
|
||||||
- `delayfeedback(amt)`: Delay feedback
|
|
||||||
- `verb(mix)`: Reverb amount
|
|
||||||
- `verbdecay(secs)`: Reverb decay
|
|
||||||
|
|
||||||
### Modulation
|
|
||||||
|
|
||||||
- `vib(hz)`: Vibrato rate
|
|
||||||
- `vibmod(amt)`: Vibrato depth
|
|
||||||
- `fm(hz)`: FM modulator frequency
|
|
||||||
- `fmh(ratio)`: FM harmonic ratio
|
|
||||||
|
|
||||||
### Sample Playback
|
|
||||||
|
|
||||||
- `speed(ratio)`: Playback speed
|
|
||||||
- `begin(pos)`: Start position (0.0-1.0)
|
|
||||||
- `end(pos)`: End position (0.0-1.0)
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
Conditional based on step:
|
|
||||||
|
|
||||||
```
|
|
||||||
if step % 4 == 0 {
|
|
||||||
sound("kick").gain(1.0)
|
|
||||||
} else {
|
|
||||||
sound("hat").gain(0.5)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Random variation:
|
|
||||||
|
|
||||||
```
|
|
||||||
sound("synth")
|
|
||||||
.freq(rand(200.0, 800.0))
|
|
||||||
.gain(rand(0.3, 0.7))
|
|
||||||
```
|
|
||||||
|
|
||||||
Using variables:
|
|
||||||
|
|
||||||
```
|
|
||||||
let n = get("counter");
|
|
||||||
set("counter", n + 1);
|
|
||||||
sound("beep").note(60 + (n % 12))
|
|
||||||
```
|
|
||||||
@@ -549,7 +549,7 @@ impl App {
|
|||||||
if let Some(src) = &self.copied_pattern {
|
if let Some(src) = &self.copied_pattern {
|
||||||
let mut pat = src.clone();
|
let mut pat = src.clone();
|
||||||
pat.name = match &src.name {
|
pat.name = match &src.name {
|
||||||
Some(name) if !name.ends_with(" (copy)") => Some(format!("{} (copy)", name)),
|
Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")),
|
||||||
Some(name) => Some(name.clone()),
|
Some(name) => Some(name.clone()),
|
||||||
None => Some("(copy)".to_string()),
|
None => Some("(copy)".to_string()),
|
||||||
};
|
};
|
||||||
@@ -572,7 +572,7 @@ impl App {
|
|||||||
if let Some(src) = &self.copied_bank {
|
if let Some(src) = &self.copied_bank {
|
||||||
let mut b = src.clone();
|
let mut b = src.clone();
|
||||||
b.name = match &src.name {
|
b.name = match &src.name {
|
||||||
Some(name) if !name.ends_with(" (copy)") => Some(format!("{} (copy)", name)),
|
Some(name) if !name.ends_with(" (copy)") => Some(format!("{name} (copy)")),
|
||||||
Some(name) => Some(name.clone()),
|
Some(name) => Some(name.clone()),
|
||||||
None => Some("(copy)".to_string()),
|
None => Some("(copy)".to_string()),
|
||||||
};
|
};
|
||||||
@@ -844,11 +844,13 @@ impl App {
|
|||||||
AppCommand::DocNextTopic => {
|
AppCommand::DocNextTopic => {
|
||||||
self.ui.doc_topic = (self.ui.doc_topic + 1) % doc_view::topic_count();
|
self.ui.doc_topic = (self.ui.doc_topic + 1) % doc_view::topic_count();
|
||||||
self.ui.doc_scroll = 0;
|
self.ui.doc_scroll = 0;
|
||||||
|
self.ui.doc_category = 0;
|
||||||
}
|
}
|
||||||
AppCommand::DocPrevTopic => {
|
AppCommand::DocPrevTopic => {
|
||||||
let count = doc_view::topic_count();
|
let count = doc_view::topic_count();
|
||||||
self.ui.doc_topic = (self.ui.doc_topic + count - 1) % count;
|
self.ui.doc_topic = (self.ui.doc_topic + count - 1) % count;
|
||||||
self.ui.doc_scroll = 0;
|
self.ui.doc_scroll = 0;
|
||||||
|
self.ui.doc_category = 0;
|
||||||
}
|
}
|
||||||
AppCommand::DocScrollDown(n) => {
|
AppCommand::DocScrollDown(n) => {
|
||||||
self.ui.doc_scroll = self.ui.doc_scroll.saturating_add(n);
|
self.ui.doc_scroll = self.ui.doc_scroll.saturating_add(n);
|
||||||
@@ -856,6 +858,16 @@ impl App {
|
|||||||
AppCommand::DocScrollUp(n) => {
|
AppCommand::DocScrollUp(n) => {
|
||||||
self.ui.doc_scroll = self.ui.doc_scroll.saturating_sub(n);
|
self.ui.doc_scroll = self.ui.doc_scroll.saturating_sub(n);
|
||||||
}
|
}
|
||||||
|
AppCommand::DocNextCategory => {
|
||||||
|
let count = doc_view::category_count();
|
||||||
|
self.ui.doc_category = (self.ui.doc_category + 1) % count;
|
||||||
|
self.ui.doc_scroll = 0;
|
||||||
|
}
|
||||||
|
AppCommand::DocPrevCategory => {
|
||||||
|
let count = doc_view::category_count();
|
||||||
|
self.ui.doc_category = (self.ui.doc_category + count - 1) % count;
|
||||||
|
self.ui.doc_scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Patterns view
|
// Patterns view
|
||||||
AppCommand::PatternsCursorLeft => {
|
AppCommand::PatternsCursorLeft => {
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ pub enum AppCommand {
|
|||||||
DocPrevTopic,
|
DocPrevTopic,
|
||||||
DocScrollDown(usize),
|
DocScrollDown(usize),
|
||||||
DocScrollUp(usize),
|
DocScrollUp(usize),
|
||||||
|
DocNextCategory,
|
||||||
|
DocPrevCategory,
|
||||||
|
|
||||||
// Patterns view
|
// Patterns view
|
||||||
PatternsCursorLeft,
|
PatternsCursorLeft,
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use rusty_link::{AblLink, SessionState};
|
use rusty_link::{AblLink, SessionState};
|
||||||
|
|
||||||
pub struct LinkState {
|
pub struct LinkState {
|
||||||
link: AblLink,
|
link: AblLink,
|
||||||
quantum: f64,
|
quantum: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LinkState {
|
impl LinkState {
|
||||||
pub fn new(tempo: f64, quantum: f64) -> Self {
|
pub fn new(tempo: f64, quantum: f64) -> Self {
|
||||||
let link = AblLink::new(tempo);
|
let link = AblLink::new(tempo);
|
||||||
Self { link, quantum }
|
Self {
|
||||||
|
link,
|
||||||
|
quantum: AtomicU64::new(quantum.to_bits()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.link.is_enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_enabled(&self, enabled: bool) {
|
||||||
|
self.link.enable(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn enable(&self) {
|
pub fn enable(&self) {
|
||||||
self.link.enable(true);
|
self.link.enable(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_start_stop_sync_enabled(&self) -> bool {
|
||||||
|
self.link.is_start_stop_sync_enabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_start_stop_sync_enabled(&self, enabled: bool) {
|
||||||
|
self.link.enable_start_stop_sync(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn quantum(&self) -> f64 {
|
||||||
|
f64::from_bits(self.quantum.load(Ordering::Relaxed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_quantum(&self, quantum: f64) {
|
||||||
|
let clamped = quantum.clamp(1.0, 16.0);
|
||||||
|
self.quantum.store(clamped.to_bits(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clock_micros(&self) -> i64 {
|
pub fn clock_micros(&self) -> i64 {
|
||||||
self.link.clock_micros()
|
self.link.clock_micros()
|
||||||
}
|
}
|
||||||
@@ -29,14 +59,14 @@ impl LinkState {
|
|||||||
let mut state = SessionState::new();
|
let mut state = SessionState::new();
|
||||||
self.link.capture_app_session_state(&mut state);
|
self.link.capture_app_session_state(&mut state);
|
||||||
let time = self.link.clock_micros();
|
let time = self.link.clock_micros();
|
||||||
state.beat_at_time(time, self.quantum)
|
state.beat_at_time(time, self.quantum())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn phase(&self) -> f64 {
|
pub fn phase(&self) -> f64 {
|
||||||
let mut state = SessionState::new();
|
let mut state = SessionState::new();
|
||||||
self.link.capture_app_session_state(&mut state);
|
self.link.capture_app_session_state(&mut state);
|
||||||
let time = self.link.clock_micros();
|
let time = self.link.clock_micros();
|
||||||
state.phase_at_time(time, self.quantum)
|
state.phase_at_time(time, self.quantum())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn peers(&self) -> u64 {
|
pub fn peers(&self) -> u64 {
|
||||||
|
|||||||
@@ -569,6 +569,11 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
AudioFocus::Channels => ctx.app.audio.adjust_channels(-1),
|
AudioFocus::Channels => ctx.app.audio.adjust_channels(-1),
|
||||||
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
|
||||||
AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(),
|
AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(),
|
||||||
|
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||||
|
AudioFocus::StartStopSync => ctx
|
||||||
|
.link
|
||||||
|
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||||
|
AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0),
|
||||||
},
|
},
|
||||||
KeyCode::Right => match ctx.app.audio.focus {
|
KeyCode::Right => match ctx.app.audio.focus {
|
||||||
AudioFocus::OutputDevice => ctx.app.audio.next_output_device(),
|
AudioFocus::OutputDevice => ctx.app.audio.next_output_device(),
|
||||||
@@ -576,6 +581,11 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
AudioFocus::Channels => ctx.app.audio.adjust_channels(1),
|
AudioFocus::Channels => ctx.app.audio.adjust_channels(1),
|
||||||
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64),
|
||||||
AudioFocus::SamplePaths => {}
|
AudioFocus::SamplePaths => {}
|
||||||
|
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||||
|
AudioFocus::StartStopSync => ctx
|
||||||
|
.link
|
||||||
|
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||||
|
AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0),
|
||||||
},
|
},
|
||||||
KeyCode::Char('R') => ctx.app.audio.trigger_restart(),
|
KeyCode::Char('R') => ctx.app.audio.trigger_restart(),
|
||||||
KeyCode::Char('A') => {
|
KeyCode::Char('A') => {
|
||||||
@@ -613,8 +623,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
|
|
||||||
fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocNextTopic),
|
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocScrollDown(1)),
|
||||||
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocPrevTopic),
|
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocScrollUp(1)),
|
||||||
|
KeyCode::Char('h') | KeyCode::Left => ctx.dispatch(AppCommand::DocPrevCategory),
|
||||||
|
KeyCode::Char('l') | KeyCode::Right => ctx.dispatch(AppCommand::DocNextCategory),
|
||||||
|
KeyCode::Tab => ctx.dispatch(AppCommand::DocNextTopic),
|
||||||
|
KeyCode::BackTab => ctx.dispatch(AppCommand::DocPrevTopic),
|
||||||
KeyCode::PageDown => ctx.dispatch(AppCommand::DocScrollDown(10)),
|
KeyCode::PageDown => ctx.dispatch(AppCommand::DocScrollDown(10)),
|
||||||
KeyCode::PageUp => ctx.dispatch(AppCommand::DocScrollUp(10)),
|
KeyCode::PageUp => ctx.dispatch(AppCommand::DocScrollUp(10)),
|
||||||
KeyCode::Char('q') => {
|
KeyCode::Char('q') => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
mod file;
|
mod file;
|
||||||
mod forth;
|
pub mod forth;
|
||||||
mod project;
|
mod project;
|
||||||
mod script;
|
mod script;
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ pub enum AudioFocus {
|
|||||||
Channels,
|
Channels,
|
||||||
BufferSize,
|
BufferSize,
|
||||||
SamplePaths,
|
SamplePaths,
|
||||||
|
LinkEnabled,
|
||||||
|
StartStopSync,
|
||||||
|
Quantum,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Metrics {
|
pub struct Metrics {
|
||||||
@@ -94,17 +97,23 @@ impl AudioSettings {
|
|||||||
AudioFocus::InputDevice => AudioFocus::Channels,
|
AudioFocus::InputDevice => AudioFocus::Channels,
|
||||||
AudioFocus::Channels => AudioFocus::BufferSize,
|
AudioFocus::Channels => AudioFocus::BufferSize,
|
||||||
AudioFocus::BufferSize => AudioFocus::SamplePaths,
|
AudioFocus::BufferSize => AudioFocus::SamplePaths,
|
||||||
AudioFocus::SamplePaths => AudioFocus::OutputDevice,
|
AudioFocus::SamplePaths => AudioFocus::LinkEnabled,
|
||||||
|
AudioFocus::LinkEnabled => AudioFocus::StartStopSync,
|
||||||
|
AudioFocus::StartStopSync => AudioFocus::Quantum,
|
||||||
|
AudioFocus::Quantum => AudioFocus::OutputDevice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prev_focus(&mut self) {
|
pub fn prev_focus(&mut self) {
|
||||||
self.focus = match self.focus {
|
self.focus = match self.focus {
|
||||||
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
|
AudioFocus::OutputDevice => AudioFocus::Quantum,
|
||||||
AudioFocus::InputDevice => AudioFocus::OutputDevice,
|
AudioFocus::InputDevice => AudioFocus::OutputDevice,
|
||||||
AudioFocus::Channels => AudioFocus::InputDevice,
|
AudioFocus::Channels => AudioFocus::InputDevice,
|
||||||
AudioFocus::BufferSize => AudioFocus::Channels,
|
AudioFocus::BufferSize => AudioFocus::Channels,
|
||||||
AudioFocus::SamplePaths => AudioFocus::BufferSize,
|
AudioFocus::SamplePaths => AudioFocus::BufferSize,
|
||||||
|
AudioFocus::LinkEnabled => AudioFocus::SamplePaths,
|
||||||
|
AudioFocus::StartStopSync => AudioFocus::LinkEnabled,
|
||||||
|
AudioFocus::Quantum => AudioFocus::StartStopSync,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct UiState {
|
|||||||
pub modal: Modal,
|
pub modal: Modal,
|
||||||
pub doc_topic: usize,
|
pub doc_topic: usize,
|
||||||
pub doc_scroll: usize,
|
pub doc_scroll: usize,
|
||||||
|
pub doc_category: usize,
|
||||||
pub show_title: bool,
|
pub show_title: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ impl Default for UiState {
|
|||||||
modal: Modal::None,
|
modal: Modal::None,
|
||||||
doc_topic: 0,
|
doc_topic: 0,
|
||||||
doc_scroll: 0,
|
doc_scroll: 0,
|
||||||
|
doc_category: 0,
|
||||||
show_title: true,
|
show_title: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::engine::LinkState;
|
||||||
use crate::state::AudioFocus;
|
use crate::state::AudioFocus;
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||||
let [config_area, stats_area] =
|
let [left_col, _, right_col] = Layout::horizontal([
|
||||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area);
|
Constraint::Percentage(52),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Percentage(48),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
render_config(frame, app, config_area);
|
render_audio_section(frame, app, left_col);
|
||||||
render_stats(frame, app, stats_area);
|
render_link_section(frame, app, link, right_col);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_name(name: &str, max_len: usize) -> String {
|
fn truncate_name(name: &str, max_len: usize) -> String {
|
||||||
@@ -23,204 +28,337 @@ fn truncate_name(name: &str, max_len: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_config(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title("Configuration")
|
.title(" Audio ")
|
||||||
.border_style(Style::new().fg(Color::Magenta));
|
.border_style(Style::new().fg(Color::Magenta));
|
||||||
|
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let [output_area, input_area, channels_area, buffer_area, rate_area, samples_area, _, hints_area] =
|
let padded = Rect {
|
||||||
Layout::vertical([
|
x: inner.x + 1,
|
||||||
Constraint::Length(1),
|
y: inner.y + 1,
|
||||||
Constraint::Length(1),
|
width: inner.width.saturating_sub(2),
|
||||||
Constraint::Length(1),
|
height: inner.height.saturating_sub(1),
|
||||||
Constraint::Length(1),
|
};
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(3),
|
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
||||||
Constraint::Min(1),
|
Constraint::Length(4),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
])
|
Constraint::Length(4),
|
||||||
.areas(inner);
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(3),
|
||||||
|
])
|
||||||
|
.areas(padded);
|
||||||
|
|
||||||
|
render_devices(frame, app, devices_area);
|
||||||
|
render_settings(frame, app, settings_area);
|
||||||
|
render_samples(frame, app, samples_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let header_style = Style::new()
|
||||||
|
.fg(Color::Rgb(100, 160, 180))
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let [header_area, content_area] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new("Devices").style(header_style), header_area);
|
||||||
|
|
||||||
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
let normal = Style::new().fg(Color::White);
|
let normal = Style::new().fg(Color::White);
|
||||||
let dim = Style::new().fg(Color::DarkGray);
|
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||||
|
|
||||||
let output_name = truncate_name(app.audio.current_output_device_name(), 25);
|
let output_name = truncate_name(app.audio.current_output_device_name(), 35);
|
||||||
let output_style = if app.audio.focus == AudioFocus::OutputDevice {
|
let input_name = truncate_name(app.audio.current_input_device_name(), 35);
|
||||||
highlight
|
|
||||||
} else {
|
|
||||||
normal
|
|
||||||
};
|
|
||||||
let output_line = Line::from(vec![
|
|
||||||
Span::styled("Output ", dim),
|
|
||||||
Span::styled("< ", output_style),
|
|
||||||
Span::styled(output_name, output_style),
|
|
||||||
Span::styled(" >", output_style),
|
|
||||||
]);
|
|
||||||
frame.render_widget(Paragraph::new(output_line), output_area);
|
|
||||||
|
|
||||||
let input_name = truncate_name(app.audio.current_input_device_name(), 25);
|
let output_focused = app.audio.focus == AudioFocus::OutputDevice;
|
||||||
let input_style = if app.audio.focus == AudioFocus::InputDevice {
|
let input_focused = app.audio.focus == AudioFocus::InputDevice;
|
||||||
highlight
|
|
||||||
} else {
|
|
||||||
normal
|
|
||||||
};
|
|
||||||
let input_line = Line::from(vec![
|
|
||||||
Span::styled("Input ", dim),
|
|
||||||
Span::styled("< ", input_style),
|
|
||||||
Span::styled(input_name, input_style),
|
|
||||||
Span::styled(" >", input_style),
|
|
||||||
]);
|
|
||||||
frame.render_widget(Paragraph::new(input_line), input_area);
|
|
||||||
|
|
||||||
let channels_style = if app.audio.focus == AudioFocus::Channels {
|
let rows = vec![
|
||||||
highlight
|
Row::new(vec![
|
||||||
} else {
|
Span::styled("Output", label_style),
|
||||||
normal
|
render_selector(&output_name, output_focused, highlight, normal),
|
||||||
};
|
]),
|
||||||
let channels_line = Line::from(vec![
|
Row::new(vec![
|
||||||
Span::styled("Channels ", dim),
|
Span::styled("Input", label_style),
|
||||||
Span::styled("< ", channels_style),
|
render_selector(&input_name, input_focused, highlight, normal),
|
||||||
Span::styled(format!("{:2}", app.audio.config.channels), channels_style),
|
]),
|
||||||
Span::styled(" >", channels_style),
|
];
|
||||||
]);
|
|
||||||
frame.render_widget(Paragraph::new(channels_line), channels_area);
|
|
||||||
|
|
||||||
let buffer_style = if app.audio.focus == AudioFocus::BufferSize {
|
let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]);
|
||||||
highlight
|
frame.render_widget(table, content_area);
|
||||||
} else {
|
}
|
||||||
normal
|
|
||||||
};
|
|
||||||
let buffer_line = Line::from(vec![
|
|
||||||
Span::styled("Buffer ", dim),
|
|
||||||
Span::styled("< ", buffer_style),
|
|
||||||
Span::styled(format!("{:4}", app.audio.config.buffer_size), buffer_style),
|
|
||||||
Span::styled(" >", buffer_style),
|
|
||||||
]);
|
|
||||||
frame.render_widget(Paragraph::new(buffer_line), buffer_area);
|
|
||||||
|
|
||||||
let rate_line = Line::from(vec![
|
fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
Span::styled("Rate ", dim),
|
let header_style = Style::new()
|
||||||
Span::styled(format!("{:.0} Hz", app.audio.config.sample_rate), normal),
|
.fg(Color::Rgb(100, 160, 180))
|
||||||
]);
|
.add_modifier(Modifier::BOLD);
|
||||||
frame.render_widget(Paragraph::new(rate_line), rate_area);
|
|
||||||
|
|
||||||
let samples_style = if app.audio.focus == AudioFocus::SamplePaths {
|
let [header_area, content_area] =
|
||||||
highlight
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
||||||
} else {
|
|
||||||
normal
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut sample_lines = vec![Line::from(vec![
|
frame.render_widget(Paragraph::new("Settings").style(header_style), header_area);
|
||||||
Span::styled("Samples ", dim),
|
|
||||||
Span::styled(
|
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
format!(
|
let normal = Style::new().fg(Color::White);
|
||||||
"{} paths, {} indexed",
|
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||||
app.audio.config.sample_paths.len(),
|
let value_style = Style::new().fg(Color::Rgb(180, 180, 190));
|
||||||
app.audio.config.sample_count
|
|
||||||
|
let channels_focused = app.audio.focus == AudioFocus::Channels;
|
||||||
|
let buffer_focused = app.audio.focus == AudioFocus::BufferSize;
|
||||||
|
|
||||||
|
let rows = vec![
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Channels", label_style),
|
||||||
|
render_selector(
|
||||||
|
&format!("{}", app.audio.config.channels),
|
||||||
|
channels_focused,
|
||||||
|
highlight,
|
||||||
|
normal,
|
||||||
),
|
),
|
||||||
samples_style,
|
]),
|
||||||
),
|
Row::new(vec![
|
||||||
])];
|
Span::styled("Buffer", label_style),
|
||||||
|
render_selector(
|
||||||
for (i, path) in app.audio.config.sample_paths.iter().take(2).enumerate() {
|
&format!("{}", app.audio.config.buffer_size),
|
||||||
let path_str = path.to_string_lossy();
|
buffer_focused,
|
||||||
let display = truncate_name(&path_str, 35);
|
highlight,
|
||||||
sample_lines.push(Line::from(vec![
|
normal,
|
||||||
Span::styled(" ", dim),
|
),
|
||||||
|
]),
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Rate", label_style),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{}: {}", i + 1, display),
|
format!("{:.0} Hz", app.audio.config.sample_rate),
|
||||||
Style::new().fg(Color::DarkGray),
|
value_style,
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]);
|
||||||
|
frame.render_widget(table, content_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let header_style = Style::new()
|
||||||
|
.fg(Color::Rgb(100, 160, 180))
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let [header_area, content_area] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
||||||
|
|
||||||
|
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
|
let samples_focused = app.audio.focus == AudioFocus::SamplePaths;
|
||||||
|
|
||||||
|
let header_text = format!(
|
||||||
|
"Samples {} paths · {} indexed",
|
||||||
|
app.audio.config.sample_paths.len(),
|
||||||
|
app.audio.config.sample_count
|
||||||
|
);
|
||||||
|
|
||||||
|
let header_line = if samples_focused {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Samples ", header_style),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"{} paths · {} indexed",
|
||||||
|
app.audio.config.sample_paths.len(),
|
||||||
|
app.audio.config.sample_count
|
||||||
|
),
|
||||||
|
highlight,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Line::from(Span::styled(header_text, header_style))
|
||||||
|
};
|
||||||
|
frame.render_widget(Paragraph::new(header_line), header_area);
|
||||||
|
|
||||||
|
let dim = Style::new().fg(Color::Rgb(80, 85, 95));
|
||||||
|
let path_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() {
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
let display = truncate_name(&path_str, 45);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {} ", i + 1), dim),
|
||||||
|
Span::styled(display, path_style),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
frame.render_widget(Paragraph::new(sample_lines), samples_area);
|
if lines.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" No sample paths configured",
|
||||||
|
dim,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let hints_line = Line::from(vec![
|
frame.render_widget(Paragraph::new(lines), content_area);
|
||||||
Span::styled("[R] Restart ", Style::new().fg(Color::Cyan)),
|
|
||||||
Span::styled("[A] Add path ", Style::new().fg(Color::DarkGray)),
|
|
||||||
Span::styled("[D] Refresh", Style::new().fg(Color::DarkGray)),
|
|
||||||
]);
|
|
||||||
frame.render_widget(Paragraph::new(hints_line), hints_area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title("Engine Stats")
|
.title(" Ableton Link ")
|
||||||
.border_style(Style::new().fg(Color::Cyan));
|
.border_style(Style::new().fg(Color::Cyan));
|
||||||
|
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let [cpu_area, voices_area, extra_area] = Layout::vertical([
|
let padded = Rect {
|
||||||
|
x: inner.x + 1,
|
||||||
|
y: inner.y + 1,
|
||||||
|
width: inner.width.saturating_sub(2),
|
||||||
|
height: inner.height.saturating_sub(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
let [status_area, _, config_area, _, info_area] = Layout::vertical([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Length(2),
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(1),
|
||||||
Constraint::Min(1),
|
Constraint::Min(1),
|
||||||
])
|
])
|
||||||
.areas(inner);
|
.areas(padded);
|
||||||
|
|
||||||
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
|
render_link_status(frame, link, status_area);
|
||||||
let cpu_color = if cpu_pct > 80.0 {
|
render_link_config(frame, app, link, config_area);
|
||||||
Color::Red
|
render_link_info(frame, link, info_area);
|
||||||
} else if cpu_pct > 50.0 {
|
}
|
||||||
Color::Yellow
|
|
||||||
} else {
|
fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) {
|
||||||
Color::Green
|
let enabled = link.is_enabled();
|
||||||
};
|
let peers = link.peers();
|
||||||
|
|
||||||
let gauge = Gauge::default()
|
let (status_text, status_color) = if !enabled {
|
||||||
.block(Block::default().title("CPU"))
|
("DISABLED", Color::Rgb(120, 60, 60))
|
||||||
.gauge_style(Style::new().fg(cpu_color).bg(Color::DarkGray))
|
} else if peers > 0 {
|
||||||
.percent(cpu_pct as u16)
|
("CONNECTED", Color::Rgb(60, 120, 60))
|
||||||
.label(format!("{cpu_pct:.1}%"));
|
} else {
|
||||||
|
("LISTENING", Color::Rgb(120, 120, 60))
|
||||||
frame.render_widget(gauge, cpu_area);
|
};
|
||||||
|
|
||||||
let voice_color = if app.metrics.active_voices > 24 {
|
let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD);
|
||||||
Color::Red
|
|
||||||
} else if app.metrics.active_voices > 16 {
|
let peer_text = if enabled {
|
||||||
Color::Yellow
|
if peers == 0 {
|
||||||
} else {
|
"No peers".to_string()
|
||||||
Color::Cyan
|
} else if peers == 1 {
|
||||||
};
|
"1 peer".to_string()
|
||||||
|
} else {
|
||||||
let voices = Paragraph::new(Line::from(vec![
|
format!("{peers} peers")
|
||||||
Span::raw("Active: "),
|
}
|
||||||
Span::styled(
|
} else {
|
||||||
format!("{:3}", app.metrics.active_voices),
|
String::new()
|
||||||
Style::new().fg(voice_color).add_modifier(Modifier::BOLD),
|
};
|
||||||
),
|
|
||||||
Span::raw(" Peak: "),
|
let lines = vec![
|
||||||
Span::styled(
|
Line::from(Span::styled(status_text, status_style)),
|
||||||
format!("{:3}", app.metrics.peak_voices),
|
Line::from(Span::styled(
|
||||||
Style::new().fg(Color::Yellow),
|
peer_text,
|
||||||
),
|
Style::new().fg(Color::Rgb(120, 125, 135)),
|
||||||
]));
|
)),
|
||||||
|
];
|
||||||
frame.render_widget(voices, voices_area);
|
|
||||||
|
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area);
|
||||||
let extra = Paragraph::new(vec![
|
}
|
||||||
Line::from(vec![
|
|
||||||
Span::raw("Schedule: "),
|
fn render_link_config(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||||
Span::styled(
|
let header_style = Style::new()
|
||||||
format!("{}", app.metrics.schedule_depth),
|
.fg(Color::Rgb(100, 160, 180))
|
||||||
Style::new().fg(Color::White),
|
.add_modifier(Modifier::BOLD);
|
||||||
),
|
|
||||||
]),
|
let [header_area, content_area] =
|
||||||
Line::from(vec![
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
||||||
Span::raw("Pool: "),
|
|
||||||
Span::styled(
|
frame.render_widget(
|
||||||
format!("{:.1} MB", app.sample_pool_mb),
|
Paragraph::new("Configuration").style(header_style),
|
||||||
Style::new().fg(Color::White),
|
header_area,
|
||||||
),
|
);
|
||||||
]),
|
|
||||||
]);
|
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||||
|
let normal = Style::new().fg(Color::White);
|
||||||
frame.render_widget(extra, extra_area);
|
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||||
|
|
||||||
|
let enabled_focused = app.audio.focus == AudioFocus::LinkEnabled;
|
||||||
|
let startstop_focused = app.audio.focus == AudioFocus::StartStopSync;
|
||||||
|
let quantum_focused = app.audio.focus == AudioFocus::Quantum;
|
||||||
|
|
||||||
|
let enabled_text = if link.is_enabled() { "On" } else { "Off" };
|
||||||
|
let startstop_text = if link.is_start_stop_sync_enabled() {
|
||||||
|
"On"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
};
|
||||||
|
let quantum_text = format!("{:.0}", link.quantum());
|
||||||
|
|
||||||
|
let rows = vec![
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Enabled", label_style),
|
||||||
|
render_selector(enabled_text, enabled_focused, highlight, normal),
|
||||||
|
]),
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Start/Stop", label_style),
|
||||||
|
render_selector(startstop_text, startstop_focused, highlight, normal),
|
||||||
|
]),
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Quantum", label_style),
|
||||||
|
render_selector(&quantum_text, quantum_focused, highlight, normal),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]);
|
||||||
|
frame.render_widget(table, content_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_link_info(frame: &mut Frame, link: &LinkState, area: Rect) {
|
||||||
|
let header_style = Style::new()
|
||||||
|
.fg(Color::Rgb(100, 160, 180))
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let [header_area, content_area] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new("Session").style(header_style), header_area);
|
||||||
|
|
||||||
|
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
||||||
|
let value_style = Style::new().fg(Color::Rgb(180, 180, 190));
|
||||||
|
let tempo_style = Style::new()
|
||||||
|
.fg(Color::Rgb(220, 180, 100))
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
let tempo = link.tempo();
|
||||||
|
let beat = link.beat();
|
||||||
|
let phase = link.phase();
|
||||||
|
|
||||||
|
let rows = vec![
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Tempo", label_style),
|
||||||
|
Span::styled(format!("{tempo:.1} BPM"), tempo_style),
|
||||||
|
]),
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Beat", label_style),
|
||||||
|
Span::styled(format!("{beat:.2}"), value_style),
|
||||||
|
]),
|
||||||
|
Row::new(vec![
|
||||||
|
Span::styled("Phase", label_style),
|
||||||
|
Span::styled(format!("{phase:.2}"), value_style),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]);
|
||||||
|
frame.render_widget(table, content_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> {
|
||||||
|
let style = if focused { highlight } else { normal };
|
||||||
|
if focused {
|
||||||
|
Span::styled(format!("< {value} >"), style)
|
||||||
|
} else {
|
||||||
|
Span::styled(format!(" {value} "), style)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,49 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::model::forth::{Word, WordCompile, WORDS};
|
||||||
|
|
||||||
const DOCS: &[(&str, &str)] = &[
|
const STATIC_DOCS: &[(&str, &str)] = &[
|
||||||
("Keybindings", include_str!("../../docs/keybindings.md")),
|
("Keybindings", include_str!("../../docs/keybindings.md")),
|
||||||
("Scripting", include_str!("../../docs/scripting.md")),
|
|
||||||
("Sequencer", include_str!("../../docs/sequencer.md")),
|
("Sequencer", include_str!("../../docs/sequencer.md")),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TOPICS: &[&str] = &["Keybindings", "Forth Reference", "Sequencer"];
|
||||||
|
|
||||||
|
const CATEGORIES: &[&str] = &[
|
||||||
|
"Stack",
|
||||||
|
"Arithmetic",
|
||||||
|
"Comparison",
|
||||||
|
"Logic",
|
||||||
|
"Sound",
|
||||||
|
"Variables",
|
||||||
|
"Randomness",
|
||||||
|
"Probability",
|
||||||
|
"Context",
|
||||||
|
"Music",
|
||||||
|
"Time",
|
||||||
|
"Parameters",
|
||||||
|
];
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let [topics_area, content_area] =
|
let [topics_area, content_area] =
|
||||||
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
|
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
|
||||||
|
|
||||||
render_topics(frame, app, topics_area);
|
render_topics(frame, app, topics_area);
|
||||||
render_content(frame, app, content_area);
|
|
||||||
|
let topic = TOPICS[app.ui.doc_topic];
|
||||||
|
if topic == "Forth Reference" {
|
||||||
|
render_forth_reference(frame, app, content_area);
|
||||||
|
} else {
|
||||||
|
render_markdown_content(frame, app, content_area, topic);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let items: Vec<ListItem> = DOCS
|
let items: Vec<ListItem> = TOPICS
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (name, _))| {
|
.map(|(i, name)| {
|
||||||
let style = if i == app.ui.doc_topic {
|
let style = if i == app.ui.doc_topic {
|
||||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
@@ -40,8 +63,12 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
frame.render_widget(list, area);
|
frame.render_widget(list, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
|
||||||
let (title, md) = DOCS[app.ui.doc_topic];
|
let md = STATIC_DOCS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == topic)
|
||||||
|
.map(|(_, content)| *content)
|
||||||
|
.unwrap_or("");
|
||||||
let lines = parse_markdown(md);
|
let lines = parse_markdown(md);
|
||||||
|
|
||||||
let visible_height = area.height.saturating_sub(2) as usize;
|
let visible_height = area.height.saturating_sub(2) as usize;
|
||||||
@@ -55,10 +82,113 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
.take(visible_height)
|
.take(visible_height)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
|
||||||
|
frame.render_widget(para, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_forth_reference(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let [cat_area, words_area] =
|
||||||
|
Layout::horizontal([Constraint::Length(14), Constraint::Fill(1)]).areas(area);
|
||||||
|
|
||||||
|
render_categories(frame, app, cat_area);
|
||||||
|
render_words(frame, app, words_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_categories(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let items: Vec<ListItem> = CATEGORIES
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, name)| {
|
||||||
|
let style = if i == app.ui.doc_category {
|
||||||
|
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::new().fg(Color::White)
|
||||||
|
};
|
||||||
|
let prefix = if i == app.ui.doc_category { "> " } else { " " };
|
||||||
|
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Category"));
|
||||||
|
frame.render_widget(list, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_words(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let category = CATEGORIES[app.ui.doc_category];
|
||||||
|
let words: Vec<&Word> = WORDS
|
||||||
|
.iter()
|
||||||
|
.filter(|w| word_category(w.name, &w.compile) == category)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let word_style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD);
|
||||||
|
let stack_style = Style::new().fg(Color::Magenta);
|
||||||
|
let desc_style = Style::new().fg(Color::White);
|
||||||
|
let example_style = Style::new().fg(Color::Rgb(150, 150, 150));
|
||||||
|
|
||||||
|
let mut lines: Vec<RLine> = Vec::new();
|
||||||
|
|
||||||
|
for word in &words {
|
||||||
|
lines.push(RLine::from(vec![
|
||||||
|
Span::styled(format!("{:<14}", word.name), word_style),
|
||||||
|
Span::styled(format!("{:<18}", word.stack), stack_style),
|
||||||
|
Span::styled(word.desc.to_string(), desc_style),
|
||||||
|
]));
|
||||||
|
lines.push(RLine::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(format!("e.g. {}", word.example), example_style),
|
||||||
|
]));
|
||||||
|
lines.push(RLine::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.doc_scroll.min(max_scroll);
|
||||||
|
|
||||||
|
let visible: Vec<RLine> = lines
|
||||||
|
.into_iter()
|
||||||
|
.skip(scroll)
|
||||||
|
.take(visible_height)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let title = format!("{category} ({} words)", words.len());
|
||||||
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(title));
|
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(title));
|
||||||
frame.render_widget(para, area);
|
frame.render_widget(para, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn word_category(name: &str, compile: &WordCompile) -> &'static str {
|
||||||
|
const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
|
||||||
|
const ARITH: &[&str] = &[
|
||||||
|
"+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max",
|
||||||
|
];
|
||||||
|
const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="];
|
||||||
|
const LOGIC: &[&str] = &["and", "or", "not"];
|
||||||
|
const SOUND: &[&str] = &["sound", "s", "emit"];
|
||||||
|
const VAR: &[&str] = &["get", "set"];
|
||||||
|
const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"];
|
||||||
|
const MUSIC: &[&str] = &["mtof", "ftom"];
|
||||||
|
const TIME: &[&str] = &[
|
||||||
|
"at", "window", "pop", "div", "each", "tempo!", "[", "]", "?",
|
||||||
|
];
|
||||||
|
|
||||||
|
match compile {
|
||||||
|
WordCompile::Simple if STACK.contains(&name) => "Stack",
|
||||||
|
WordCompile::Simple if ARITH.contains(&name) => "Arithmetic",
|
||||||
|
WordCompile::Simple if CMP.contains(&name) => "Comparison",
|
||||||
|
WordCompile::Simple if LOGIC.contains(&name) => "Logic",
|
||||||
|
WordCompile::Simple if SOUND.contains(&name) => "Sound",
|
||||||
|
WordCompile::Alias(_) => "Sound",
|
||||||
|
WordCompile::Simple if VAR.contains(&name) => "Variables",
|
||||||
|
WordCompile::Simple if RAND.contains(&name) => "Randomness",
|
||||||
|
WordCompile::Probability(_) => "Probability",
|
||||||
|
WordCompile::Context(_) => "Context",
|
||||||
|
WordCompile::Simple if MUSIC.contains(&name) => "Music",
|
||||||
|
WordCompile::Simple if TIME.contains(&name) => "Time",
|
||||||
|
WordCompile::Param => "Parameters",
|
||||||
|
_ => "Other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
|
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
|
||||||
let text = minimad::Text::from(md);
|
let text = minimad::Text::from(md);
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
@@ -128,5 +258,9 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn topic_count() -> usize {
|
pub fn topic_count() -> usize {
|
||||||
DOCS.len()
|
TOPICS.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category_count() -> usize {
|
||||||
|
CATEGORIES.len()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ pub fn tokenize_line(line: &str) -> Vec<Token> {
|
|||||||
|
|
||||||
if c == '"' {
|
if c == '"' {
|
||||||
let mut end = start + 1;
|
let mut end = start + 1;
|
||||||
while let Some((i, ch)) = chars.next() {
|
for (i, ch) in chars.by_ref() {
|
||||||
end = i + ch.len_utf8();
|
end = i + ch.len_utf8();
|
||||||
if ch == '"' {
|
if ch == '"' {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
// Column 2: length
|
// Column 2: length
|
||||||
let length_line = Line::from(vec![
|
let length_line = Line::from(vec![
|
||||||
Span::styled("Length: ", bold_style),
|
Span::styled("Length: ", bold_style),
|
||||||
Span::styled(format!("{}", length), base_style),
|
Span::styled(format!("{length}"), base_style),
|
||||||
]);
|
]);
|
||||||
frame.render_widget(Paragraph::new(length_line), length_area);
|
frame.render_widget(Paragraph::new(length_line), length_area);
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
|
|||||||
match app.page {
|
match app.page {
|
||||||
Page::Main => main_view::render(frame, app, snapshot, body_area),
|
Page::Main => main_view::render(frame, app, snapshot, body_area),
|
||||||
Page::Patterns => patterns_view::render(frame, app, snapshot, body_area),
|
Page::Patterns => patterns_view::render(frame, app, snapshot, body_area),
|
||||||
Page::Audio => audio_view::render(frame, app, body_area),
|
Page::Audio => audio_view::render(frame, app, link, body_area),
|
||||||
Page::Doc => doc_view::render(frame, app, body_area),
|
Page::Doc => doc_view::render(frame, app, body_area),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
let bank_name = bank
|
let bank_name = bank
|
||||||
.name
|
.name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|n| format!(" {} ", n))
|
.map(|n| format!(" {n} "))
|
||||||
.unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
|
.unwrap_or_else(|| format!(" Bank {:02} ", app.editor_ctx.bank + 1));
|
||||||
let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White);
|
let bank_style = Style::new().bg(Color::Rgb(30, 60, 70)).fg(Color::White);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -129,7 +129,7 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
|
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
|
||||||
let peers = link.peers();
|
let peers = link.peers();
|
||||||
let voices = app.metrics.active_voices;
|
let voices = app.metrics.active_voices;
|
||||||
let stats_text = format!(" CPU {:.0}% V:{} L:{} ", cpu_pct, voices, peers);
|
let stats_text = format!(" CPU {cpu_pct:.0}% V:{voices} L:{peers} ");
|
||||||
let stats_style = Style::new()
|
let stats_style = Style::new()
|
||||||
.bg(Color::Rgb(35, 35, 40))
|
.bg(Color::Rgb(35, 35, 40))
|
||||||
.fg(Color::Rgb(150, 150, 160));
|
.fg(Color::Rgb(150, 150, 160));
|
||||||
|
|||||||
Reference in New Issue
Block a user