Auto docs

This commit is contained in:
2026-01-20 17:48:24 +01:00
parent f7797664bd
commit 8b0b98024a
14 changed files with 1909 additions and 520 deletions

View File

@@ -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))
```

View File

@@ -549,7 +549,7 @@ impl App {
if let Some(src) = &self.copied_pattern {
let mut pat = src.clone();
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()),
None => Some("(copy)".to_string()),
};
@@ -572,7 +572,7 @@ impl App {
if let Some(src) = &self.copied_bank {
let mut b = src.clone();
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()),
None => Some("(copy)".to_string()),
};
@@ -844,11 +844,13 @@ impl App {
AppCommand::DocNextTopic => {
self.ui.doc_topic = (self.ui.doc_topic + 1) % doc_view::topic_count();
self.ui.doc_scroll = 0;
self.ui.doc_category = 0;
}
AppCommand::DocPrevTopic => {
let count = doc_view::topic_count();
self.ui.doc_topic = (self.ui.doc_topic + count - 1) % count;
self.ui.doc_scroll = 0;
self.ui.doc_category = 0;
}
AppCommand::DocScrollDown(n) => {
self.ui.doc_scroll = self.ui.doc_scroll.saturating_add(n);
@@ -856,6 +858,16 @@ impl App {
AppCommand::DocScrollUp(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
AppCommand::PatternsCursorLeft => {

View File

@@ -116,6 +116,8 @@ pub enum AppCommand {
DocPrevTopic,
DocScrollDown(usize),
DocScrollUp(usize),
DocNextCategory,
DocPrevCategory,
// Patterns view
PatternsCursorLeft,

View File

@@ -1,20 +1,50 @@
use std::sync::atomic::{AtomicU64, Ordering};
use rusty_link::{AblLink, SessionState};
pub struct LinkState {
link: AblLink,
quantum: f64,
quantum: AtomicU64,
}
impl LinkState {
pub fn new(tempo: f64, quantum: f64) -> Self {
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) {
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 {
self.link.clock_micros()
}
@@ -29,14 +59,14 @@ impl LinkState {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);
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 {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);
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 {

View File

@@ -569,6 +569,11 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
AudioFocus::Channels => ctx.app.audio.adjust_channels(-1),
AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
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 {
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::BufferSize => ctx.app.audio.adjust_buffer_size(64),
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('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 {
match key.code {
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocNextTopic),
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocPrevTopic),
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocScrollDown(1)),
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::PageUp => ctx.dispatch(AppCommand::DocScrollUp(10)),
KeyCode::Char('q') => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
mod file;
mod forth;
pub mod forth;
mod project;
mod script;

View File

@@ -34,6 +34,9 @@ pub enum AudioFocus {
Channels,
BufferSize,
SamplePaths,
LinkEnabled,
StartStopSync,
Quantum,
}
pub struct Metrics {
@@ -94,17 +97,23 @@ impl AudioSettings {
AudioFocus::InputDevice => AudioFocus::Channels,
AudioFocus::Channels => AudioFocus::BufferSize,
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) {
self.focus = match self.focus {
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
AudioFocus::OutputDevice => AudioFocus::Quantum,
AudioFocus::InputDevice => AudioFocus::OutputDevice,
AudioFocus::Channels => AudioFocus::InputDevice,
AudioFocus::BufferSize => AudioFocus::Channels,
AudioFocus::SamplePaths => AudioFocus::BufferSize,
AudioFocus::LinkEnabled => AudioFocus::SamplePaths,
AudioFocus::StartStopSync => AudioFocus::LinkEnabled,
AudioFocus::Quantum => AudioFocus::StartStopSync,
};
}

View File

@@ -8,6 +8,7 @@ pub struct UiState {
pub modal: Modal,
pub doc_topic: usize,
pub doc_scroll: usize,
pub doc_category: usize,
pub show_title: bool,
}
@@ -19,6 +20,7 @@ impl Default for UiState {
modal: Modal::None,
doc_topic: 0,
doc_scroll: 0,
doc_category: 0,
show_title: true,
}
}

View File

@@ -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::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
use ratatui::Frame;
use crate::app::App;
use crate::engine::LinkState;
use crate::state::AudioFocus;
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [config_area, stats_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area);
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [left_col, _, right_col] = Layout::horizontal([
Constraint::Percentage(52),
Constraint::Length(2),
Constraint::Percentage(48),
])
.areas(area);
render_config(frame, app, config_area);
render_stats(frame, app, stats_area);
render_audio_section(frame, app, left_col);
render_link_section(frame, app, link, right_col);
}
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()
.borders(Borders::ALL)
.title("Configuration")
.title(" Audio ")
.border_style(Style::new().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let [output_area, input_area, channels_area, buffer_area, rate_area, samples_area, _, hints_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 [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
Constraint::Min(3),
])
.areas(inner);
.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 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_style = if app.audio.focus == AudioFocus::OutputDevice {
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 output_name = truncate_name(app.audio.current_output_device_name(), 35);
let input_name = truncate_name(app.audio.current_input_device_name(), 35);
let input_name = truncate_name(app.audio.current_input_device_name(), 25);
let input_style = if 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 output_focused = app.audio.focus == AudioFocus::OutputDevice;
let input_focused = app.audio.focus == AudioFocus::InputDevice;
let channels_style = if app.audio.focus == AudioFocus::Channels {
highlight
} else {
normal
};
let channels_line = Line::from(vec![
Span::styled("Channels ", dim),
Span::styled("< ", channels_style),
Span::styled(format!("{:2}", app.audio.config.channels), channels_style),
Span::styled(" >", channels_style),
]);
frame.render_widget(Paragraph::new(channels_line), channels_area);
let rows = vec![
Row::new(vec![
Span::styled("Output", label_style),
render_selector(&output_name, output_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Input", label_style),
render_selector(&input_name, input_focused, highlight, normal),
]),
];
let buffer_style = if app.audio.focus == AudioFocus::BufferSize {
highlight
} 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 table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]);
frame.render_widget(table, content_area);
}
let rate_line = Line::from(vec![
Span::styled("Rate ", dim),
Span::styled(format!("{:.0} Hz", app.audio.config.sample_rate), normal),
]);
frame.render_widget(Paragraph::new(rate_line), rate_area);
fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let header_style = Style::new()
.fg(Color::Rgb(100, 160, 180))
.add_modifier(Modifier::BOLD);
let samples_style = if app.audio.focus == AudioFocus::SamplePaths {
highlight
} else {
normal
};
let [header_area, content_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
let mut sample_lines = vec![Line::from(vec![
Span::styled("Samples ", dim),
frame.render_widget(Paragraph::new("Settings").style(header_style), header_area);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
let label_style = Style::new().fg(Color::Rgb(120, 125, 135));
let value_style = Style::new().fg(Color::Rgb(180, 180, 190));
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,
),
]),
Row::new(vec![
Span::styled("Buffer", label_style),
render_selector(
&format!("{}", app.audio.config.buffer_size),
buffer_focused,
highlight,
normal,
),
]),
Row::new(vec![
Span::styled("Rate", label_style),
Span::styled(
format!("{:.0} Hz", app.audio.config.sample_rate),
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",
"{} paths · {} indexed",
app.audio.config.sample_paths.len(),
app.audio.config.sample_count
),
samples_style,
highlight,
),
])];
])
} else {
Line::from(Span::styled(header_text, header_style))
};
frame.render_widget(Paragraph::new(header_line), header_area);
for (i, path) in app.audio.config.sample_paths.iter().take(2).enumerate() {
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, 35);
sample_lines.push(Line::from(vec![
Span::styled(" ", dim),
Span::styled(
format!("{}: {}", i + 1, display),
Style::new().fg(Color::DarkGray),
),
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![
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);
frame.render_widget(Paragraph::new(lines), content_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()
.borders(Borders::ALL)
.title("Engine Stats")
.title(" Ableton Link ")
.border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(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(2),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(inner);
.areas(padded);
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 {
Color::Red
} else if cpu_pct > 50.0 {
Color::Yellow
} else {
Color::Green
};
let gauge = Gauge::default()
.block(Block::default().title("CPU"))
.gauge_style(Style::new().fg(cpu_color).bg(Color::DarkGray))
.percent(cpu_pct as u16)
.label(format!("{cpu_pct:.1}%"));
frame.render_widget(gauge, cpu_area);
let voice_color = if app.metrics.active_voices > 24 {
Color::Red
} else if app.metrics.active_voices > 16 {
Color::Yellow
} else {
Color::Cyan
};
let voices = Paragraph::new(Line::from(vec![
Span::raw("Active: "),
Span::styled(
format!("{:3}", app.metrics.active_voices),
Style::new().fg(voice_color).add_modifier(Modifier::BOLD),
),
Span::raw(" Peak: "),
Span::styled(
format!("{:3}", app.metrics.peak_voices),
Style::new().fg(Color::Yellow),
),
]));
frame.render_widget(voices, voices_area);
let extra = Paragraph::new(vec![
Line::from(vec![
Span::raw("Schedule: "),
Span::styled(
format!("{}", app.metrics.schedule_depth),
Style::new().fg(Color::White),
),
]),
Line::from(vec![
Span::raw("Pool: "),
Span::styled(
format!("{:.1} MB", app.sample_pool_mb),
Style::new().fg(Color::White),
),
]),
]);
frame.render_widget(extra, extra_area);
render_link_status(frame, link, status_area);
render_link_config(frame, app, link, config_area);
render_link_info(frame, link, info_area);
}
fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) {
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", Color::Rgb(120, 60, 60))
} else if peers > 0 {
("CONNECTED", Color::Rgb(60, 120, 60))
} else {
("LISTENING", Color::Rgb(120, 120, 60))
};
let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD);
let peer_text = if enabled {
if peers == 0 {
"No peers".to_string()
} else if peers == 1 {
"1 peer".to_string()
} else {
format!("{peers} peers")
}
} else {
String::new()
};
let lines = vec![
Line::from(Span::styled(status_text, status_style)),
Line::from(Span::styled(
peer_text,
Style::new().fg(Color::Rgb(120, 125, 135)),
)),
];
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area);
}
fn render_link_config(frame: &mut Frame, app: &App, 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("Configuration").style(header_style),
header_area,
);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
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)
}
}

View File

@@ -6,26 +6,49 @@ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
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")),
("Scripting", include_str!("../../docs/scripting.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) {
let [topics_area, content_area] =
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(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) {
let items: Vec<ListItem> = DOCS
let items: Vec<ListItem> = TOPICS
.iter()
.enumerate()
.map(|(i, (name, _))| {
.map(|(i, name)| {
let style = if i == app.ui.doc_topic {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
@@ -40,8 +63,12 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(list, area);
}
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let (title, md) = DOCS[app.ui.doc_topic];
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("");
let lines = parse_markdown(md);
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)
.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));
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>> {
let text = minimad::Text::from(md);
let mut lines = Vec::new();
@@ -128,5 +258,9 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
}
pub fn topic_count() -> usize {
DOCS.len()
TOPICS.len()
}
pub fn category_count() -> usize {
CATEGORIES.len()
}

View File

@@ -190,7 +190,7 @@ pub fn tokenize_line(line: &str) -> Vec<Token> {
if c == '"' {
let mut end = start + 1;
while let Some((i, ch)) = chars.next() {
for (i, ch) in chars.by_ref() {
end = i + ch.len_utf8();
if ch == '"' {
break;

View File

@@ -286,7 +286,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
// Column 2: length
let length_line = Line::from(vec![
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);

View File

@@ -41,7 +41,7 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
match app.page {
Page::Main => main_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),
}
@@ -95,7 +95,7 @@ fn render_header(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let bank_name = bank
.name
.as_deref()
.map(|n| format!(" {} ", n))
.map(|n| format!(" {n} "))
.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);
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 peers = link.peers();
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()
.bg(Color::Rgb(35, 35, 40))
.fg(Color::Rgb(150, 150, 160));