A ton of bug fixes
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
13
src/input.rs
13
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> {
|
||||
@@ -937,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);
|
||||
}
|
||||
@@ -951,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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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,9 +1,10 @@
|
||||
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, Padding, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use tui_big_text::{BigText, PixelSize};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::views::highlight;
|
||||
@@ -11,8 +12,10 @@ use crate::views::highlight;
|
||||
// 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")),
|
||||
];
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
@@ -47,8 +50,36 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
const WELCOME_TOPIC: usize = 0;
|
||||
const BIG_TITLE_HEIGHT: u16 = 6;
|
||||
|
||||
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let (name, md) = DOCS[app.ui.help_topic];
|
||||
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();
|
||||
@@ -57,7 +88,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let has_search_bar = app.ui.help_search_active || has_query;
|
||||
let search_bar_height: u16 = u16::from(has_search_bar);
|
||||
let visible_height = area.height.saturating_sub(6 + search_bar_height) as usize;
|
||||
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);
|
||||
|
||||
@@ -76,18 +107,17 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let content_area = if has_search_bar {
|
||||
let [content, search] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
|
||||
render_search_bar(frame, app, search);
|
||||
content
|
||||
} else {
|
||||
area
|
||||
md_area
|
||||
};
|
||||
|
||||
let para = Paragraph::new(visible)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(name)
|
||||
.padding(Padding::new(2, 2, 2, 2)),
|
||||
)
|
||||
.wrap(Wrap { trim: false });
|
||||
@@ -158,8 +188,52 @@ 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;
|
||||
@@ -218,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 {
|
||||
@@ -240,5 +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);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user