Compare commits
3 Commits
250e359fc5
...
ce98acacd0
| Author | SHA1 | Date | |
|---|---|---|---|
| ce98acacd0 | |||
| d2d6ef5b06 | |||
| 6efcabd32d |
10
Cargo.toml
10
Cargo.toml
@@ -33,3 +33,13 @@ minimad = "0.13"
|
||||
crossbeam-channel = "0.5"
|
||||
confy = "2"
|
||||
rustfft = "6"
|
||||
thread-priority = "1"
|
||||
ringbuf = "0.4"
|
||||
arc-swap = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
@@ -3,6 +3,7 @@ mod editor;
|
||||
mod file_browser;
|
||||
mod list_select;
|
||||
mod modal;
|
||||
mod nav_minimap;
|
||||
mod sample_browser;
|
||||
mod scope;
|
||||
mod spectrum;
|
||||
@@ -14,6 +15,7 @@ pub use editor::{CompletionCandidate, Editor};
|
||||
pub use file_browser::FileBrowserModal;
|
||||
pub use list_select::ListSelect;
|
||||
pub use modal::ModalFrame;
|
||||
pub use nav_minimap::{NavMinimap, NavTile};
|
||||
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
|
||||
pub use scope::{Orientation, Scope};
|
||||
pub use spectrum::Spectrum;
|
||||
|
||||
86
crates/ratatui/src/nav_minimap.rs
Normal file
86
crates/ratatui/src/nav_minimap.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::{Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// A tile in the navigation grid
|
||||
pub struct NavTile {
|
||||
pub col: i8,
|
||||
pub row: i8,
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
/// Navigation minimap widget that renders a grid of page tiles
|
||||
pub struct NavMinimap<'a> {
|
||||
tiles: &'a [NavTile],
|
||||
selected: (i8, i8),
|
||||
}
|
||||
|
||||
impl<'a> NavMinimap<'a> {
|
||||
pub fn new(tiles: &'a [NavTile], selected: (i8, i8)) -> Self {
|
||||
Self { tiles, selected }
|
||||
}
|
||||
|
||||
pub fn render_centered(self, frame: &mut Frame, term: Rect) {
|
||||
if self.tiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute grid bounds from tiles
|
||||
let max_col = self.tiles.iter().map(|t| t.col).max().unwrap_or(0);
|
||||
let max_row = self.tiles.iter().map(|t| t.row).max().unwrap_or(0);
|
||||
let cols = (max_col + 1) as u16;
|
||||
let rows = (max_row + 1) as u16;
|
||||
|
||||
let tile_w: u16 = 12;
|
||||
let tile_h: u16 = 3;
|
||||
let gap: u16 = 1;
|
||||
let pad: u16 = 2;
|
||||
|
||||
let content_w = tile_w * cols + gap * (cols.saturating_sub(1));
|
||||
let content_h = tile_h * rows + gap * (rows.saturating_sub(1));
|
||||
let modal_w = content_w + pad * 2;
|
||||
let modal_h = content_h + pad * 2;
|
||||
|
||||
let x = term.x + (term.width.saturating_sub(modal_w)) / 2;
|
||||
let y = term.y + (term.height.saturating_sub(modal_h)) / 2;
|
||||
let area = Rect::new(x, y, modal_w, modal_h);
|
||||
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
let inner_x = area.x + pad;
|
||||
let inner_y = area.y + pad;
|
||||
|
||||
for tile in self.tiles {
|
||||
let tile_x = inner_x + (tile.col as u16) * (tile_w + gap);
|
||||
let tile_y = inner_y + (tile.row as u16) * (tile_h + gap);
|
||||
let tile_area = Rect::new(tile_x, tile_y, tile_w, tile_h);
|
||||
let is_selected = (tile.col, tile.row) == self.selected;
|
||||
self.render_tile(frame, tile_area, tile.name, is_selected);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tile(&self, frame: &mut Frame, area: Rect, label: &str, is_selected: bool) {
|
||||
let (bg, fg) = if is_selected {
|
||||
(Color::Rgb(50, 90, 110), Color::White)
|
||||
} else {
|
||||
(Color::Rgb(30, 35, 45), Color::Rgb(100, 105, 115))
|
||||
};
|
||||
|
||||
// Fill background
|
||||
for row in 0..area.height {
|
||||
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
|
||||
let fill = " ".repeat(area.width as usize);
|
||||
frame.render_widget(Paragraph::new(fill).style(Style::new().bg(bg)), line_area);
|
||||
}
|
||||
|
||||
// Center text vertically
|
||||
let text_y = area.y + area.height / 2;
|
||||
let text_area = Rect::new(area.x, text_y, area.width, 1);
|
||||
let paragraph = Paragraph::new(label)
|
||||
.style(Style::new().bg(bg).fg(fg))
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
frame.render_widget(paragraph, text_area);
|
||||
}
|
||||
}
|
||||
109
src/app.rs
109
src/app.rs
@@ -5,7 +5,6 @@ use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::{
|
||||
@@ -16,10 +15,10 @@ use crate::page::Page;
|
||||
use crate::services::pattern_editor;
|
||||
use crate::settings::Settings;
|
||||
use crate::state::{
|
||||
AudioSettings, EditorContext, Focus, LiveKeyState, Metrics, Modal, PanelState, PatternField,
|
||||
PatternsNav, PlaybackState, ProjectState, UiState,
|
||||
AudioSettings, DictFocus, EditorContext, FlashKind, Focus, LiveKeyState, Metrics, Modal,
|
||||
PanelState, PatternField, PatternsNav, PlaybackState, ProjectState, UiState,
|
||||
};
|
||||
use crate::views::doc_view;
|
||||
use crate::views::{dict_view, help_view};
|
||||
|
||||
const STEPS_PER_PAGE: usize = 32;
|
||||
|
||||
@@ -320,7 +319,7 @@ impl App {
|
||||
Some(cmds.join("\n"))
|
||||
};
|
||||
}
|
||||
self.ui.flash("Script compiled", 150, Color::White);
|
||||
self.ui.flash("Script compiled", 150, FlashKind::Info);
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(step) = self
|
||||
@@ -331,7 +330,7 @@ impl App {
|
||||
{
|
||||
step.command = None;
|
||||
}
|
||||
self.ui.flash(&format!("Script error: {e}"), 300, Color::Red);
|
||||
self.ui.flash(&format!("Script error: {e}"), 300, FlashKind::Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,7 +539,7 @@ impl App {
|
||||
{
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash("Step deleted", 150, Color::Green);
|
||||
self.ui.flash("Step deleted", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn reset_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
@@ -549,7 +548,7 @@ impl App {
|
||||
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash("Pattern reset", 150, Color::Green);
|
||||
self.ui.flash("Pattern reset", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn reset_bank(&mut self, bank: usize) {
|
||||
@@ -560,13 +559,13 @@ impl App {
|
||||
if self.editor_ctx.bank == bank {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash("Bank reset", 150, Color::Green);
|
||||
self.ui.flash("Bank reset", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn copy_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
let pat = self.project_state.project.banks[bank].patterns[pattern].clone();
|
||||
self.copied_pattern = Some(pat);
|
||||
self.ui.flash("Pattern copied", 150, Color::Green);
|
||||
self.ui.flash("Pattern copied", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn paste_pattern(&mut self, bank: usize, pattern: usize) {
|
||||
@@ -582,14 +581,14 @@ impl App {
|
||||
if self.editor_ctx.bank == bank && self.editor_ctx.pattern == pattern {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash("Pattern pasted", 150, Color::Green);
|
||||
self.ui.flash("Pattern pasted", 150, FlashKind::Success);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_bank(&mut self, bank: usize) {
|
||||
let b = self.project_state.project.banks[bank].clone();
|
||||
self.copied_bank = Some(b);
|
||||
self.ui.flash("Bank copied", 150, Color::Green);
|
||||
self.ui.flash("Bank copied", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn paste_bank(&mut self, bank: usize) {
|
||||
@@ -607,7 +606,7 @@ impl App {
|
||||
if self.editor_ctx.bank == bank {
|
||||
self.load_step_to_editor();
|
||||
}
|
||||
self.ui.flash("Bank pasted", 150, Color::Green);
|
||||
self.ui.flash("Bank pasted", 150, FlashKind::Success);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,7 +675,7 @@ impl App {
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
self.ui
|
||||
.flash(&format!("Linked to step {:02}", copied.step + 1), 150, Color::Green);
|
||||
.flash(&format!("Linked to step {:02}", copied.step + 1), 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn harden_step(&mut self) {
|
||||
@@ -709,7 +708,7 @@ impl App {
|
||||
}
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
self.load_step_to_editor();
|
||||
self.ui.flash("Step hardened", 150, Color::Green);
|
||||
self.ui.flash("Step hardened", 150, FlashKind::Success);
|
||||
}
|
||||
|
||||
pub fn open_pattern_modal(&mut self, field: PatternField) {
|
||||
@@ -842,8 +841,8 @@ impl App {
|
||||
AppCommand::Flash {
|
||||
message,
|
||||
duration_ms,
|
||||
color,
|
||||
} => self.ui.flash(&message, duration_ms, color),
|
||||
kind,
|
||||
} => self.ui.flash(&message, duration_ms, kind),
|
||||
AppCommand::OpenModal(modal) => {
|
||||
if matches!(modal, Modal::Editor) {
|
||||
// If current step is a shallow copy, navigate to source step
|
||||
@@ -865,33 +864,65 @@ impl App {
|
||||
AppCommand::PageUp => self.page.up(),
|
||||
AppCommand::PageDown => self.page.down(),
|
||||
|
||||
// Doc navigation
|
||||
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;
|
||||
// Help navigation
|
||||
AppCommand::HelpNextTopic => {
|
||||
self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count();
|
||||
self.ui.help_scroll = 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::HelpPrevTopic => {
|
||||
let count = help_view::topic_count();
|
||||
self.ui.help_topic = (self.ui.help_topic + count - 1) % count;
|
||||
self.ui.help_scroll = 0;
|
||||
}
|
||||
AppCommand::DocScrollDown(n) => {
|
||||
self.ui.doc_scroll = self.ui.doc_scroll.saturating_add(n);
|
||||
AppCommand::HelpScrollDown(n) => {
|
||||
self.ui.help_scroll = self.ui.help_scroll.saturating_add(n);
|
||||
}
|
||||
AppCommand::DocScrollUp(n) => {
|
||||
self.ui.doc_scroll = self.ui.doc_scroll.saturating_sub(n);
|
||||
AppCommand::HelpScrollUp(n) => {
|
||||
self.ui.help_scroll = self.ui.help_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;
|
||||
|
||||
// Dictionary navigation
|
||||
AppCommand::DictToggleFocus => {
|
||||
self.ui.dict_focus = match self.ui.dict_focus {
|
||||
DictFocus::Categories => DictFocus::Words,
|
||||
DictFocus::Words => DictFocus::Categories,
|
||||
};
|
||||
}
|
||||
AppCommand::DocPrevCategory => {
|
||||
let count = doc_view::category_count();
|
||||
self.ui.doc_category = (self.ui.doc_category + count - 1) % count;
|
||||
self.ui.doc_scroll = 0;
|
||||
AppCommand::DictNextCategory => {
|
||||
let count = dict_view::category_count();
|
||||
self.ui.dict_category = (self.ui.dict_category + 1) % count;
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictPrevCategory => {
|
||||
let count = dict_view::category_count();
|
||||
self.ui.dict_category = (self.ui.dict_category + count - 1) % count;
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictScrollDown(n) => {
|
||||
self.ui.dict_scroll = self.ui.dict_scroll.saturating_add(n);
|
||||
}
|
||||
AppCommand::DictScrollUp(n) => {
|
||||
self.ui.dict_scroll = self.ui.dict_scroll.saturating_sub(n);
|
||||
}
|
||||
AppCommand::DictActivateSearch => {
|
||||
self.ui.dict_search_active = true;
|
||||
self.ui.dict_focus = DictFocus::Words;
|
||||
}
|
||||
AppCommand::DictClearSearch => {
|
||||
self.ui.dict_search_query.clear();
|
||||
self.ui.dict_search_active = false;
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictSearchInput(c) => {
|
||||
self.ui.dict_search_query.push(c);
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictSearchBackspace => {
|
||||
self.ui.dict_search_query.pop();
|
||||
self.ui.dict_scroll = 0;
|
||||
}
|
||||
AppCommand::DictSearchConfirm => {
|
||||
self.ui.dict_search_active = false;
|
||||
}
|
||||
|
||||
// Patterns view
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::engine::PatternChange;
|
||||
use crate::model::PatternSpeed;
|
||||
use crate::state::{Modal, PatternField};
|
||||
use crate::state::{FlashKind, Modal, PatternField};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum AppCommand {
|
||||
@@ -102,7 +100,7 @@ pub enum AppCommand {
|
||||
Flash {
|
||||
message: String,
|
||||
duration_ms: u64,
|
||||
color: Color,
|
||||
kind: FlashKind,
|
||||
},
|
||||
OpenModal(Modal),
|
||||
CloseModal,
|
||||
@@ -114,13 +112,23 @@ pub enum AppCommand {
|
||||
PageUp,
|
||||
PageDown,
|
||||
|
||||
// Doc navigation
|
||||
DocNextTopic,
|
||||
DocPrevTopic,
|
||||
DocScrollDown(usize),
|
||||
DocScrollUp(usize),
|
||||
DocNextCategory,
|
||||
DocPrevCategory,
|
||||
// Help navigation
|
||||
HelpNextTopic,
|
||||
HelpPrevTopic,
|
||||
HelpScrollDown(usize),
|
||||
HelpScrollUp(usize),
|
||||
|
||||
// Dictionary navigation
|
||||
DictToggleFocus,
|
||||
DictNextCategory,
|
||||
DictPrevCategory,
|
||||
DictScrollDown(usize),
|
||||
DictScrollUp(usize),
|
||||
DictActivateSearch,
|
||||
DictClearSearch,
|
||||
DictSearchInput(char),
|
||||
DictSearchBackspace,
|
||||
DictSearchConfirm,
|
||||
|
||||
// Patterns view
|
||||
PatternsCursorLeft,
|
||||
|
||||
@@ -2,9 +2,11 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use cpal::Stream;
|
||||
use crossbeam_channel::Receiver;
|
||||
use doux::{Engine, EngineMetrics};
|
||||
use ringbuf::{traits::*, HeapRb};
|
||||
use rustfft::{num_complex::Complex, FftPlanner};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use super::AudioCommand;
|
||||
|
||||
@@ -75,6 +77,7 @@ impl SpectrumBuffer {
|
||||
|
||||
const FFT_SIZE: usize = 512;
|
||||
const NUM_BANDS: usize = 32;
|
||||
const ANALYSIS_RING_SIZE: usize = 4096;
|
||||
|
||||
struct SpectrumAnalyzer {
|
||||
ring: Vec<f32>,
|
||||
@@ -151,6 +154,72 @@ impl SpectrumAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AnalysisHandle {
|
||||
running: Arc<AtomicBool>,
|
||||
#[allow(dead_code)]
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl AnalysisHandle {
|
||||
#[allow(dead_code)]
|
||||
pub fn shutdown(mut self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AnalysisHandle {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_analysis_thread(
|
||||
sample_rate: f32,
|
||||
spectrum_buffer: Arc<SpectrumBuffer>,
|
||||
) -> (ringbuf::HeapProd<f32>, AnalysisHandle) {
|
||||
let rb = HeapRb::<f32>::new(ANALYSIS_RING_SIZE);
|
||||
let (producer, consumer) = rb.split();
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_clone = Arc::clone(&running);
|
||||
|
||||
let thread = thread::Builder::new()
|
||||
.name("fft-analysis".into())
|
||||
.spawn(move || {
|
||||
analysis_loop(consumer, spectrum_buffer, sample_rate, running_clone);
|
||||
})
|
||||
.expect("Failed to spawn FFT analysis thread");
|
||||
|
||||
let handle = AnalysisHandle {
|
||||
running,
|
||||
thread: Some(thread),
|
||||
};
|
||||
|
||||
(producer, handle)
|
||||
}
|
||||
|
||||
fn analysis_loop(
|
||||
mut consumer: ringbuf::HeapCons<f32>,
|
||||
spectrum_buffer: Arc<SpectrumBuffer>,
|
||||
sample_rate: f32,
|
||||
running: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut analyzer = SpectrumAnalyzer::new(sample_rate);
|
||||
let mut local_buf = [0.0f32; 256];
|
||||
|
||||
while running.load(Ordering::Relaxed) {
|
||||
let count = consumer.pop_slice(&mut local_buf);
|
||||
if count > 0 {
|
||||
analyzer.feed(&local_buf[..count], &spectrum_buffer);
|
||||
} else {
|
||||
thread::sleep(std::time::Duration::from_micros(500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioStreamConfig {
|
||||
pub output_device: Option<String>,
|
||||
pub channels: u16,
|
||||
@@ -165,7 +234,7 @@ pub fn build_stream(
|
||||
spectrum_buffer: Arc<SpectrumBuffer>,
|
||||
metrics: Arc<EngineMetrics>,
|
||||
initial_samples: Vec<doux::sample::SampleEntry>,
|
||||
) -> Result<(Stream, f32), String> {
|
||||
) -> Result<(Stream, f32, AnalysisHandle), String> {
|
||||
let host = cpal::default_host();
|
||||
|
||||
let device = match &config.output_device {
|
||||
@@ -199,7 +268,7 @@ pub fn build_stream(
|
||||
let mut engine = Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics));
|
||||
engine.sample_index = initial_samples;
|
||||
|
||||
let mut analyzer = SpectrumAnalyzer::new(sample_rate);
|
||||
let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
|
||||
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
@@ -235,11 +304,11 @@ pub fn build_stream(
|
||||
engine.process_block(data, &[], &[]);
|
||||
scope_buffer.write(&engine.output);
|
||||
|
||||
// Feed mono mix to spectrum analyzer
|
||||
let mono: Vec<f32> = engine.output.chunks(channels)
|
||||
.map(|ch| ch.iter().sum::<f32>() / channels as f32)
|
||||
.collect();
|
||||
analyzer.feed(&mono, &spectrum_buffer);
|
||||
// Feed mono mix to analysis thread via ring buffer (non-blocking)
|
||||
for chunk in engine.output.chunks(channels) {
|
||||
let mono = chunk.iter().sum::<f32>() / channels as f32;
|
||||
let _ = fft_producer.try_push(mono);
|
||||
}
|
||||
},
|
||||
|err| eprintln!("stream error: {err}"),
|
||||
None,
|
||||
@@ -249,5 +318,5 @@ pub fn build_stream(
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| format!("Failed to play stream: {e}"))?;
|
||||
Ok((stream, sample_rate))
|
||||
Ok((stream, sample_rate, analysis_handle))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
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::{MAX_BANKS, MAX_PATTERNS};
|
||||
@@ -89,12 +91,14 @@ pub struct SharedSequencerState {
|
||||
pub active_patterns: Vec<ActivePatternState>,
|
||||
pub step_traces: HashMap<(usize, usize, usize), ExecutionTrace>,
|
||||
pub event_count: usize,
|
||||
pub dropped_events: usize,
|
||||
}
|
||||
|
||||
pub struct SequencerSnapshot {
|
||||
pub active_patterns: Vec<ActivePatternState>,
|
||||
pub step_traces: HashMap<(usize, usize, usize), ExecutionTrace>,
|
||||
pub event_count: usize,
|
||||
pub dropped_events: usize,
|
||||
}
|
||||
|
||||
impl SequencerSnapshot {
|
||||
@@ -127,17 +131,18 @@ pub struct SequencerHandle {
|
||||
pub cmd_tx: Sender<SeqCommand>,
|
||||
pub audio_tx: Sender<AudioCommand>,
|
||||
pub audio_rx: Receiver<AudioCommand>,
|
||||
shared_state: Arc<Mutex<SharedSequencerState>>,
|
||||
shared_state: Arc<ArcSwap<SharedSequencerState>>,
|
||||
thread: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SequencerHandle {
|
||||
pub fn snapshot(&self) -> SequencerSnapshot {
|
||||
let state = self.shared_state.lock().unwrap();
|
||||
let state = self.shared_state.load();
|
||||
SequencerSnapshot {
|
||||
active_patterns: state.active_patterns.clone(),
|
||||
step_traces: state.step_traces.clone(),
|
||||
event_count: state.event_count,
|
||||
dropped_events: state.dropped_events,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +190,7 @@ pub fn spawn_sequencer(
|
||||
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
|
||||
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
|
||||
|
||||
let shared_state = Arc::new(Mutex::new(SharedSequencerState::default()));
|
||||
let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
|
||||
let shared_state_clone = Arc::clone(&shared_state);
|
||||
let audio_tx_clone = audio_tx.clone();
|
||||
|
||||
@@ -296,17 +301,20 @@ fn sequencer_loop(
|
||||
dict: Dictionary,
|
||||
rng: Rng,
|
||||
quantum: f64,
|
||||
shared_state: Arc<Mutex<SharedSequencerState>>,
|
||||
shared_state: Arc<ArcSwap<SharedSequencerState>>,
|
||||
live_keys: Arc<LiveKeyState>,
|
||||
) {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let _ = set_current_thread_priority(ThreadPriority::Max);
|
||||
|
||||
let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng);
|
||||
let mut audio_state = AudioState::new();
|
||||
let mut pattern_cache = PatternCache::new();
|
||||
let mut runs_counter = RunsCounter::new();
|
||||
let mut step_traces: HashMap<(usize, usize, usize), ExecutionTrace> = HashMap::new();
|
||||
let mut event_count: usize = 0;
|
||||
let mut dropped_events: usize = 0;
|
||||
|
||||
loop {
|
||||
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||
@@ -339,7 +347,7 @@ fn sequencer_loop(
|
||||
}
|
||||
|
||||
if !playing.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_micros(500));
|
||||
thread::sleep(Duration::from_micros(200));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -350,6 +358,7 @@ fn sequencer_loop(
|
||||
|
||||
let bar = (beat / quantum).floor() as i64;
|
||||
let prev_bar = (audio_state.prev_beat / quantum).floor() as i64;
|
||||
let mut stopped_chain_keys: Vec<String> = Vec::new();
|
||||
if bar != prev_bar && audio_state.prev_beat >= 0.0 {
|
||||
for id in audio_state.pending_starts.drain(..) {
|
||||
audio_state.active_patterns.insert(
|
||||
@@ -367,13 +376,14 @@ fn sequencer_loop(
|
||||
step_traces.retain(|&(bank, pattern, _), _| {
|
||||
bank != id.bank || pattern != id.pattern
|
||||
});
|
||||
let chain_key = format!("__chain_{}_{}__", id.bank, id.pattern);
|
||||
variables.lock().unwrap().remove(&chain_key);
|
||||
stopped_chain_keys.push(format!("__chain_{}_{}__", id.bank, id.pattern));
|
||||
}
|
||||
}
|
||||
|
||||
let prev_beat = audio_state.prev_beat;
|
||||
let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new();
|
||||
let mut chain_keys_to_remove: Vec<String> = Vec::new();
|
||||
let mut new_tempo: Option<f64> = None;
|
||||
|
||||
for (_id, active) in audio_state.active_patterns.iter_mut() {
|
||||
let Some(pattern) = pattern_cache.get(active.bank, active.pattern) else {
|
||||
@@ -424,18 +434,16 @@ fn sequencer_loop(
|
||||
Ok(()) => {
|
||||
event_count += 1;
|
||||
}
|
||||
Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Full(_)) => {
|
||||
dropped_events += 1;
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(new_tempo) = {
|
||||
let mut vars = variables.lock().unwrap();
|
||||
vars.remove("__tempo__").and_then(|v| v.as_float().ok())
|
||||
} {
|
||||
link.set_tempo(new_tempo);
|
||||
}
|
||||
// Defer tempo check to batched variable read
|
||||
new_tempo = None; // Will be read in batch below
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,32 +453,61 @@ fn sequencer_loop(
|
||||
if next_step >= pattern.length {
|
||||
active.iter += 1;
|
||||
let chain_key = format!("__chain_{}_{}__", active.bank, active.pattern);
|
||||
let chain_target = {
|
||||
let vars = variables.lock().unwrap();
|
||||
vars.get(&chain_key).and_then(|v| {
|
||||
if let Value::Str(s, _) = v {
|
||||
let parts: Vec<&str> = s.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
let b = parts[0].parse::<usize>().ok()?;
|
||||
let p = parts[1].parse::<usize>().ok()?;
|
||||
Some(PatternId { bank: b, pattern: p })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(target) = chain_target {
|
||||
let source = PatternId { bank: active.bank, pattern: active.pattern };
|
||||
chain_transitions.push((source, target));
|
||||
}
|
||||
chain_keys_to_remove.push(chain_key);
|
||||
}
|
||||
active.step_index = next_step % pattern.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Batched variable operations: read chain targets, check tempo, remove keys
|
||||
let needs_var_access = !chain_keys_to_remove.is_empty() || !stopped_chain_keys.is_empty();
|
||||
if needs_var_access {
|
||||
let mut vars = variables.lock().unwrap();
|
||||
|
||||
// Check for tempo change
|
||||
if let Some(t) = vars.remove("__tempo__").and_then(|v| v.as_float().ok()) {
|
||||
new_tempo = Some(t);
|
||||
}
|
||||
|
||||
// Read chain targets and queue transitions
|
||||
for key in &chain_keys_to_remove {
|
||||
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 };
|
||||
// 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 };
|
||||
chain_transitions.push((source, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all chain keys (both from stopped patterns and completed iterations)
|
||||
for key in chain_keys_to_remove {
|
||||
vars.remove(&key);
|
||||
}
|
||||
for key in stopped_chain_keys {
|
||||
vars.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tempo change
|
||||
if let Some(t) = new_tempo {
|
||||
link.set_tempo(t);
|
||||
}
|
||||
|
||||
// Apply chain transitions
|
||||
for (source, target) in chain_transitions {
|
||||
if !audio_state.pending_stops.contains(&source) {
|
||||
audio_state.pending_stops.push(source);
|
||||
@@ -478,13 +515,10 @@ fn sequencer_loop(
|
||||
if !audio_state.pending_starts.contains(&target) {
|
||||
audio_state.pending_starts.push(target);
|
||||
}
|
||||
let chain_key = format!("__chain_{}_{}__", source.bank, source.pattern);
|
||||
variables.lock().unwrap().remove(&chain_key);
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = shared_state.lock().unwrap();
|
||||
state.active_patterns = audio_state
|
||||
let new_state = SharedSequencerState {
|
||||
active_patterns: audio_state
|
||||
.active_patterns
|
||||
.values()
|
||||
.map(|a| ActivePatternState {
|
||||
@@ -493,13 +527,15 @@ fn sequencer_loop(
|
||||
step_index: a.step_index,
|
||||
iter: a.iter,
|
||||
})
|
||||
.collect();
|
||||
state.step_traces = step_traces.clone();
|
||||
state.event_count = event_count;
|
||||
}
|
||||
.collect(),
|
||||
step_traces: step_traces.clone(),
|
||||
event_count,
|
||||
dropped_events,
|
||||
};
|
||||
shared_state.store(Arc::new(new_state));
|
||||
|
||||
audio_state.prev_beat = beat;
|
||||
|
||||
thread::sleep(Duration::from_micros(500));
|
||||
thread::sleep(Duration::from_micros(200));
|
||||
}
|
||||
}
|
||||
|
||||
83
src/input.rs
83
src/input.rs
@@ -2,6 +2,7 @@ use crossbeam_channel::Sender;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::commands::AppCommand;
|
||||
@@ -38,6 +39,15 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let is_arrow = matches!(
|
||||
key.code,
|
||||
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down
|
||||
);
|
||||
if ctx.app.ui.minimap_until.is_some() && !(ctrl && is_arrow) {
|
||||
ctx.app.ui.minimap_until = None;
|
||||
}
|
||||
|
||||
if ctx.app.ui.show_title {
|
||||
ctx.app.ui.show_title = false;
|
||||
return InputResult::Continue;
|
||||
@@ -414,20 +424,25 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
}
|
||||
|
||||
if ctrl {
|
||||
let minimap_timeout = Some(Instant::now() + Duration::from_millis(250));
|
||||
match key.code {
|
||||
KeyCode::Left => {
|
||||
ctx.app.ui.minimap_until = minimap_timeout;
|
||||
ctx.dispatch(AppCommand::PageLeft);
|
||||
return InputResult::Continue;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
ctx.app.ui.minimap_until = minimap_timeout;
|
||||
ctx.dispatch(AppCommand::PageRight);
|
||||
return InputResult::Continue;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
ctx.app.ui.minimap_until = minimap_timeout;
|
||||
ctx.dispatch(AppCommand::PageUp);
|
||||
return InputResult::Continue;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
ctx.app.ui.minimap_until = minimap_timeout;
|
||||
ctx.dispatch(AppCommand::PageDown);
|
||||
return InputResult::Continue;
|
||||
}
|
||||
@@ -439,7 +454,8 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
Page::Main => handle_main_page(ctx, key, ctrl),
|
||||
Page::Patterns => handle_patterns_page(ctx, key),
|
||||
Page::Audio => handle_audio_page(ctx, key),
|
||||
Page::Doc => handle_doc_page(ctx, key),
|
||||
Page::Help => handle_help_page(ctx, key),
|
||||
Page::Dict => handle_dict_page(ctx, key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,16 +881,63 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
InputResult::Continue
|
||||
}
|
||||
|
||||
fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
match key.code {
|
||||
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('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)),
|
||||
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)),
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic),
|
||||
KeyCode::BackTab => ctx.dispatch(AppCommand::HelpPrevTopic),
|
||||
KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)),
|
||||
KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)),
|
||||
KeyCode::Char('q') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||
selected: false,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
}
|
||||
|
||||
fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
use crate::state::DictFocus;
|
||||
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
// Handle search input mode
|
||||
if ctx.app.ui.dict_search_active {
|
||||
match key.code {
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::DictClearSearch),
|
||||
KeyCode::Enter => ctx.dispatch(AppCommand::DictSearchConfirm),
|
||||
KeyCode::Backspace => ctx.dispatch(AppCommand::DictSearchBackspace),
|
||||
KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::DictSearchInput(c)),
|
||||
_ => {}
|
||||
}
|
||||
return InputResult::Continue;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => {
|
||||
ctx.dispatch(AppCommand::DictActivateSearch);
|
||||
}
|
||||
KeyCode::Esc if !ctx.app.ui.dict_search_query.is_empty() => {
|
||||
ctx.dispatch(AppCommand::DictClearSearch);
|
||||
}
|
||||
KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus),
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)),
|
||||
}
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
match ctx.app.ui.dict_focus {
|
||||
DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory),
|
||||
DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)),
|
||||
}
|
||||
}
|
||||
KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)),
|
||||
KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)),
|
||||
KeyCode::Char('q') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||
selected: false,
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@@ -116,7 +116,7 @@ fn main() -> io::Result<()> {
|
||||
max_voices: app.audio.config.max_voices,
|
||||
};
|
||||
|
||||
let mut _stream = match build_stream(
|
||||
let (mut _stream, mut _analysis_handle) = match build_stream(
|
||||
&stream_config,
|
||||
sequencer.audio_rx.clone(),
|
||||
Arc::clone(&scope_buffer),
|
||||
@@ -124,14 +124,14 @@ fn main() -> io::Result<()> {
|
||||
Arc::clone(&metrics),
|
||||
initial_samples,
|
||||
) {
|
||||
Ok((s, sample_rate)) => {
|
||||
Ok((s, sample_rate, analysis)) => {
|
||||
app.audio.config.sample_rate = sample_rate;
|
||||
Some(s)
|
||||
(Some(s), Some(analysis))
|
||||
}
|
||||
Err(e) => {
|
||||
app.ui.set_status(format!("Audio failed: {e}"));
|
||||
app.audio.error = Some(e);
|
||||
None
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
app.mark_all_patterns_dirty();
|
||||
@@ -147,6 +147,7 @@ fn main() -> io::Result<()> {
|
||||
if app.audio.restart_pending {
|
||||
app.audio.restart_pending = false;
|
||||
_stream = None;
|
||||
_analysis_handle = None;
|
||||
|
||||
let new_config = AudioStreamConfig {
|
||||
output_device: app.audio.config.output_device.clone(),
|
||||
@@ -170,8 +171,9 @@ fn main() -> io::Result<()> {
|
||||
Arc::clone(&metrics),
|
||||
restart_samples,
|
||||
) {
|
||||
Ok((new_stream, sr)) => {
|
||||
Ok((new_stream, sr, new_analysis)) => {
|
||||
_stream = Some(new_stream);
|
||||
_analysis_handle = Some(new_analysis);
|
||||
app.audio.config.sample_rate = sr;
|
||||
app.audio.error = None;
|
||||
app.ui.set_status("Audio restarted".to_string());
|
||||
@@ -197,6 +199,7 @@ fn main() -> io::Result<()> {
|
||||
|
||||
let seq_snapshot = sequencer.snapshot();
|
||||
app.metrics.event_count = seq_snapshot.event_count;
|
||||
app.metrics.dropped_events = seq_snapshot.dropped_events;
|
||||
|
||||
app.flush_queued_changes(&sequencer.cmd_tx);
|
||||
app.flush_dirty_patterns(&sequencer.cmd_tx);
|
||||
|
||||
78
src/page.rs
78
src/page.rs
@@ -4,35 +4,87 @@ pub enum Page {
|
||||
Main,
|
||||
Patterns,
|
||||
Audio,
|
||||
Doc,
|
||||
Help,
|
||||
Dict,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// All pages for iteration
|
||||
pub const ALL: &'static [Page] = &[
|
||||
Page::Main,
|
||||
Page::Patterns,
|
||||
Page::Audio,
|
||||
Page::Help,
|
||||
Page::Dict,
|
||||
];
|
||||
|
||||
/// Grid dimensions (cols, rows)
|
||||
pub const GRID_SIZE: (i8, i8) = (3, 2);
|
||||
|
||||
/// Grid position (col, row) for each page
|
||||
/// Layout:
|
||||
/// col 0 col 1 col 2
|
||||
/// row 0 Patterns Help
|
||||
/// row 1 Dict Sequencer Audio
|
||||
pub const fn grid_pos(self) -> (i8, i8) {
|
||||
match self {
|
||||
Page::Dict => (0, 1),
|
||||
Page::Main => (1, 1),
|
||||
Page::Patterns => (1, 0),
|
||||
Page::Audio => (2, 1),
|
||||
Page::Help => (2, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find page at grid position, if any
|
||||
pub fn at_pos(col: i8, row: i8) -> Option<Page> {
|
||||
Self::ALL.iter().copied().find(|p| p.grid_pos() == (col, row))
|
||||
}
|
||||
|
||||
/// Display name for the page
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Page::Main => "Sequencer",
|
||||
Page::Patterns => "Patterns",
|
||||
Page::Audio => "Audio",
|
||||
Page::Help => "Help",
|
||||
Page::Dict => "Dict",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn left(&mut self) {
|
||||
*self = match self {
|
||||
Page::Main | Page::Patterns => Page::Doc,
|
||||
Page::Audio => Page::Main,
|
||||
Page::Doc => Page::Audio,
|
||||
let (col, row) = self.grid_pos();
|
||||
for offset in 1..=Self::GRID_SIZE.0 {
|
||||
let new_col = (col - offset).rem_euclid(Self::GRID_SIZE.0);
|
||||
if let Some(page) = Self::at_pos(new_col, row) {
|
||||
*self = page;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn right(&mut self) {
|
||||
*self = match self {
|
||||
Page::Main | Page::Patterns => Page::Audio,
|
||||
Page::Audio => Page::Doc,
|
||||
Page::Doc => Page::Main,
|
||||
let (col, row) = self.grid_pos();
|
||||
for offset in 1..=Self::GRID_SIZE.0 {
|
||||
let new_col = (col + offset).rem_euclid(Self::GRID_SIZE.0);
|
||||
if let Some(page) = Self::at_pos(new_col, row) {
|
||||
*self = page;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn up(&mut self) {
|
||||
if *self == Page::Main {
|
||||
*self = Page::Patterns;
|
||||
let (col, row) = self.grid_pos();
|
||||
if let Some(page) = Self::at_pos(col, row - 1) {
|
||||
*self = page;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn down(&mut self) {
|
||||
if *self == Page::Patterns {
|
||||
*self = Page::Main;
|
||||
let (col, row) = self.grid_pos();
|
||||
if let Some(page) = Self::at_pos(col, row + 1) {
|
||||
*self = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ pub enum AudioFocus {
|
||||
|
||||
pub struct Metrics {
|
||||
pub event_count: usize,
|
||||
pub dropped_events: usize,
|
||||
pub active_voices: usize,
|
||||
pub peak_voices: usize,
|
||||
pub cpu_load: f32,
|
||||
@@ -153,6 +154,7 @@ impl Default for Metrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
event_count: 0,
|
||||
dropped_events: 0,
|
||||
active_voices: 0,
|
||||
peak_voices: 0,
|
||||
cpu_load: 0.0,
|
||||
|
||||
@@ -19,4 +19,4 @@ pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||
pub use playback::PlaybackState;
|
||||
pub use project::ProjectState;
|
||||
pub use sample_browser::SampleBrowserState;
|
||||
pub use ui::UiState;
|
||||
pub use ui::{DictFocus, FlashKind, UiState};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use crate::state::Modal;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum FlashKind {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
Info,
|
||||
}
|
||||
|
||||
pub struct Sparkle {
|
||||
pub x: u16,
|
||||
pub y: u16,
|
||||
@@ -11,18 +17,30 @@ pub struct Sparkle {
|
||||
pub life: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DictFocus {
|
||||
#[default]
|
||||
Categories,
|
||||
Words,
|
||||
}
|
||||
|
||||
pub struct UiState {
|
||||
pub sparkles: Vec<Sparkle>,
|
||||
pub status_message: Option<String>,
|
||||
pub flash_until: Option<Instant>,
|
||||
pub flash_color: Color,
|
||||
pub flash_kind: FlashKind,
|
||||
pub modal: Modal,
|
||||
pub doc_topic: usize,
|
||||
pub doc_scroll: usize,
|
||||
pub doc_category: usize,
|
||||
pub help_topic: usize,
|
||||
pub help_scroll: usize,
|
||||
pub dict_focus: DictFocus,
|
||||
pub dict_category: usize,
|
||||
pub dict_scroll: usize,
|
||||
pub dict_search_query: String,
|
||||
pub dict_search_active: bool,
|
||||
pub show_title: bool,
|
||||
pub runtime_highlight: bool,
|
||||
pub show_completion: bool,
|
||||
pub minimap_until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -31,27 +49,32 @@ impl Default for UiState {
|
||||
sparkles: Vec::new(),
|
||||
status_message: None,
|
||||
flash_until: None,
|
||||
flash_color: Color::Green,
|
||||
flash_kind: FlashKind::Success,
|
||||
modal: Modal::None,
|
||||
doc_topic: 0,
|
||||
doc_scroll: 0,
|
||||
doc_category: 0,
|
||||
help_topic: 0,
|
||||
help_scroll: 0,
|
||||
dict_focus: DictFocus::default(),
|
||||
dict_category: 0,
|
||||
dict_scroll: 0,
|
||||
dict_search_query: String::new(),
|
||||
dict_search_active: false,
|
||||
show_title: true,
|
||||
runtime_highlight: false,
|
||||
show_completion: true,
|
||||
minimap_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn flash(&mut self, msg: &str, duration_ms: u64, color: Color) {
|
||||
pub fn flash(&mut self, msg: &str, duration_ms: u64, kind: FlashKind) {
|
||||
self.status_message = Some(msg.to_string());
|
||||
self.flash_until = Some(Instant::now() + Duration::from_millis(duration_ms));
|
||||
self.flash_color = color;
|
||||
self.flash_kind = kind;
|
||||
}
|
||||
|
||||
pub fn flash_color(&self) -> Option<Color> {
|
||||
if self.is_flashing() { Some(self.flash_color) } else { None }
|
||||
pub fn flash_kind(&self) -> Option<FlashKind> {
|
||||
if self.is_flashing() { Some(self.flash_kind) } else { None }
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, msg: String) {
|
||||
|
||||
231
src/views/dict_view.rs
Normal file
231
src/views/dict_view.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::model::{Word, WordCompile, WORDS};
|
||||
use crate::state::DictFocus;
|
||||
|
||||
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 [header_area, body_area] =
|
||||
Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(area);
|
||||
|
||||
render_header(frame, header_area);
|
||||
|
||||
let [cat_area, words_area] =
|
||||
Layout::horizontal([Constraint::Length(16), Constraint::Fill(1)]).areas(body_area);
|
||||
|
||||
let is_searching = !app.ui.dict_search_query.is_empty();
|
||||
render_categories(frame, app, cat_area, is_searching);
|
||||
render_words(frame, app, words_area, is_searching);
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, area: Rect) {
|
||||
use ratatui::widgets::Wrap;
|
||||
let desc = "Forth uses a stack: values are pushed, functions (called words) consume and \
|
||||
produce values. Read left to right: 3 4 + -> push 3, push 4, + pops both, \
|
||||
pushes 7. This page lists all words with their signature ( inputs -- outputs ).";
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(Color::Rgb(60, 60, 70)))
|
||||
.title("Dictionary");
|
||||
let para = Paragraph::new(desc)
|
||||
.style(Style::new().fg(Color::Rgb(140, 145, 155)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(block);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) {
|
||||
let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed;
|
||||
|
||||
let items: Vec<ListItem> = CATEGORIES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let is_selected = i == app.ui.dict_category;
|
||||
let style = if dimmed {
|
||||
Style::new().fg(Color::Rgb(80, 80, 90))
|
||||
} else if is_selected && focused {
|
||||
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
Style::new().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::new().fg(Color::White)
|
||||
};
|
||||
let prefix = if is_selected && !dimmed { "> " } else { " " };
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let border_color = if focused { Color::Yellow } else { Color::Rgb(60, 60, 70) };
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(border_color))
|
||||
.title("Categories");
|
||||
let list = List::new(items).block(block);
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
|
||||
let focused = app.ui.dict_focus == DictFocus::Words;
|
||||
|
||||
// Filter words by search query or category
|
||||
let words: Vec<&Word> = if is_searching {
|
||||
let query = app.ui.dict_search_query.to_lowercase();
|
||||
WORDS
|
||||
.iter()
|
||||
.filter(|w| w.name.to_lowercase().contains(&query))
|
||||
.collect()
|
||||
} else {
|
||||
let category = CATEGORIES[app.ui.dict_category];
|
||||
WORDS
|
||||
.iter()
|
||||
.filter(|w| word_category(w.name, &w.compile) == category)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Split area for search bar when search is active or has query
|
||||
let show_search = app.ui.dict_search_active || is_searching;
|
||||
let (search_area, content_area) = if show_search {
|
||||
let [s, c] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
|
||||
(Some(s), c)
|
||||
} else {
|
||||
(None, area)
|
||||
};
|
||||
|
||||
// Render search bar
|
||||
if let Some(sa) = search_area {
|
||||
render_search_bar(frame, app, sa);
|
||||
}
|
||||
|
||||
let content_width = content_area.width.saturating_sub(2) as usize;
|
||||
|
||||
let mut lines: Vec<RLine> = Vec::new();
|
||||
|
||||
for word in &words {
|
||||
let name_bg = Color::Rgb(40, 50, 60);
|
||||
let name_style = Style::new()
|
||||
.fg(Color::Green)
|
||||
.bg(name_bg)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let name_line = format!(" {}", word.name);
|
||||
let padding = " ".repeat(content_width.saturating_sub(name_line.chars().count()));
|
||||
lines.push(RLine::from(Span::styled(
|
||||
format!("{name_line}{padding}"),
|
||||
name_style,
|
||||
)));
|
||||
|
||||
let stack_style = Style::new().fg(Color::Magenta);
|
||||
lines.push(RLine::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(word.stack.to_string(), stack_style),
|
||||
]));
|
||||
|
||||
let desc_style = Style::new().fg(Color::White);
|
||||
lines.push(RLine::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(word.desc.to_string(), desc_style),
|
||||
]));
|
||||
|
||||
let example_style = Style::new().fg(Color::Rgb(120, 130, 140));
|
||||
lines.push(RLine::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("e.g. {}", word.example), example_style),
|
||||
]));
|
||||
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
|
||||
let visible_height = content_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.dict_scroll.min(max_scroll);
|
||||
|
||||
let visible: Vec<RLine> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.collect();
|
||||
|
||||
let title = if is_searching {
|
||||
format!("Search: {} matches", words.len())
|
||||
} else {
|
||||
let category = CATEGORIES[app.ui.dict_category];
|
||||
format!("{category} ({} words)", words.len())
|
||||
};
|
||||
let border_color = if focused { Color::Yellow } else { Color::Rgb(60, 60, 70) };
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(border_color))
|
||||
.title(title);
|
||||
let para = Paragraph::new(visible).block(block);
|
||||
frame.render_widget(para, content_area);
|
||||
}
|
||||
|
||||
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let style = if app.ui.dict_search_active {
|
||||
Style::new().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
};
|
||||
let cursor = if app.ui.dict_search_active { "_" } else { "" };
|
||||
let text = format!(" /{}{}", app.ui.dict_search_query, cursor);
|
||||
let line = RLine::from(Span::styled(text, style));
|
||||
frame.render_widget(Paragraph::new(vec![line]), 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",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_count() -> usize {
|
||||
CATEGORIES.len()
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
use minimad::{Composite, CompositeStyle, Compound, Line};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::model::{Word, WordCompile, WORDS};
|
||||
|
||||
const STATIC_DOCS: &[(&str, &str)] = &[
|
||||
("Keybindings", include_str!("../../docs/keybindings.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);
|
||||
|
||||
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> = TOPICS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let style = if i == app.ui.doc_topic {
|
||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new().fg(Color::White)
|
||||
};
|
||||
let prefix = if i == app.ui.doc_topic { "> " } else { " " };
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics"));
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
|
||||
let md = STATIC_DOCS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == topic)
|
||||
.map(|(_, content)| *content)
|
||||
.unwrap_or("");
|
||||
let lines = parse_markdown(md);
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll = app.ui.doc_scroll.min(max_scroll);
|
||||
|
||||
let visible: Vec<RLine> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.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();
|
||||
|
||||
for line in text.lines {
|
||||
match line {
|
||||
Line::Normal(composite) => {
|
||||
lines.push(composite_to_line(composite));
|
||||
}
|
||||
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn composite_to_line(composite: Composite) -> RLine<'static> {
|
||||
let base_style = match composite.style {
|
||||
CompositeStyle::Header(1) => Style::new()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
|
||||
CompositeStyle::ListItem(_) => Style::new().fg(Color::White),
|
||||
CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)),
|
||||
CompositeStyle::Code => Style::new().fg(Color::Green),
|
||||
CompositeStyle::Paragraph => Style::new().fg(Color::White),
|
||||
};
|
||||
|
||||
let prefix = match composite.style {
|
||||
CompositeStyle::ListItem(_) => " • ",
|
||||
CompositeStyle::Quote => " │ ",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(prefix.to_string(), base_style));
|
||||
}
|
||||
|
||||
for compound in composite.compounds {
|
||||
spans.push(compound_to_span(compound, base_style));
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
||||
let mut style = base;
|
||||
|
||||
if compound.bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if compound.italic {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if compound.code {
|
||||
style = Style::new().fg(Color::Green);
|
||||
}
|
||||
if compound.strikeout {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
|
||||
Span::styled(compound.src.to_string(), style)
|
||||
}
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
TOPICS.len()
|
||||
}
|
||||
|
||||
pub fn category_count() -> usize {
|
||||
CATEGORIES.len()
|
||||
}
|
||||
139
src/views/help_view.rs
Normal file
139
src/views/help_view.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use minimad::{Composite, CompositeStyle, Compound, Line};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line as RLine, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
const STATIC_DOCS: &[(&str, &str)] = &[
|
||||
("Keybindings", include_str!("../../docs/keybindings.md")),
|
||||
("Sequencer", include_str!("../../docs/sequencer.md")),
|
||||
];
|
||||
|
||||
const TOPICS: &[&str] = &["Keybindings", "Sequencer"];
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [topics_area, content_area] =
|
||||
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
|
||||
|
||||
render_topics(frame, app, topics_area);
|
||||
|
||||
let topic = TOPICS[app.ui.help_topic];
|
||||
render_markdown_content(frame, app, content_area, topic);
|
||||
}
|
||||
|
||||
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = TOPICS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let style = if i == app.ui.help_topic {
|
||||
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new().fg(Color::White)
|
||||
};
|
||||
let prefix = if i == app.ui.help_topic { "> " } else { " " };
|
||||
ListItem::new(format!("{prefix}{name}")).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics"));
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
|
||||
let md = STATIC_DOCS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == topic)
|
||||
.map(|(_, content)| *content)
|
||||
.unwrap_or("");
|
||||
let lines = parse_markdown(md);
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let max_scroll = total_lines.saturating_sub(visible_height);
|
||||
let scroll = app.ui.help_scroll.min(max_scroll);
|
||||
|
||||
let visible: Vec<RLine> = lines
|
||||
.into_iter()
|
||||
.skip(scroll)
|
||||
.take(visible_height)
|
||||
.collect();
|
||||
|
||||
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
|
||||
let text = minimad::Text::from(md);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for line in text.lines {
|
||||
match line {
|
||||
Line::Normal(composite) => {
|
||||
lines.push(composite_to_line(composite));
|
||||
}
|
||||
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
|
||||
lines.push(RLine::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn composite_to_line(composite: Composite) -> RLine<'static> {
|
||||
let base_style = match composite.style {
|
||||
CompositeStyle::Header(1) => Style::new()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
|
||||
CompositeStyle::ListItem(_) => Style::new().fg(Color::White),
|
||||
CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)),
|
||||
CompositeStyle::Code => Style::new().fg(Color::Green),
|
||||
CompositeStyle::Paragraph => Style::new().fg(Color::White),
|
||||
};
|
||||
|
||||
let prefix = match composite.style {
|
||||
CompositeStyle::ListItem(_) => " • ",
|
||||
CompositeStyle::Quote => " │ ",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(prefix.to_string(), base_style));
|
||||
}
|
||||
|
||||
for compound in composite.compounds {
|
||||
spans.push(compound_to_span(compound, base_style));
|
||||
}
|
||||
|
||||
RLine::from(spans)
|
||||
}
|
||||
|
||||
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
||||
let mut style = base;
|
||||
|
||||
if compound.bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if compound.italic {
|
||||
style = style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if compound.code {
|
||||
style = Style::new().fg(Color::Green);
|
||||
}
|
||||
if compound.strikeout {
|
||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
|
||||
Span::styled(compound.src.to_string(), style)
|
||||
}
|
||||
|
||||
pub fn topic_count() -> usize {
|
||||
TOPICS.len()
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod audio_view;
|
||||
pub mod doc_view;
|
||||
pub mod dict_view;
|
||||
pub mod help_view;
|
||||
pub mod highlight;
|
||||
pub mod main_view;
|
||||
pub mod patterns_view;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -8,11 +10,11 @@ use crate::app::App;
|
||||
use crate::engine::{LinkState, SequencerSnapshot};
|
||||
use crate::model::SourceSpan;
|
||||
use crate::page::Page;
|
||||
use crate::state::{Modal, PanelFocus, PatternField, SidePanel};
|
||||
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
|
||||
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
||||
use crate::widgets::{ConfirmModal, ModalFrame, SampleBrowser, TextInputModal};
|
||||
use crate::widgets::{ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal};
|
||||
|
||||
use super::{audio_view, doc_view, main_view, patterns_view, title_view};
|
||||
use super::{audio_view, dict_view, help_view, main_view, patterns_view, title_view};
|
||||
|
||||
fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usize) -> Vec<SourceSpan> {
|
||||
spans.iter().filter_map(|s| {
|
||||
@@ -81,7 +83,8 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
|
||||
Page::Main => main_view::render(frame, app, snapshot, page_area),
|
||||
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
|
||||
Page::Audio => audio_view::render(frame, app, link, page_area),
|
||||
Page::Doc => doc_view::render(frame, app, page_area),
|
||||
Page::Help => help_view::render(frame, app, page_area),
|
||||
Page::Dict => dict_view::render(frame, app, page_area),
|
||||
}
|
||||
|
||||
if let Some(side_area) = panel_area {
|
||||
@@ -90,6 +93,24 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
|
||||
|
||||
render_footer(frame, app, footer_area);
|
||||
render_modal(frame, app, snapshot, term);
|
||||
|
||||
let show_minimap = app
|
||||
.ui
|
||||
.minimap_until
|
||||
.map(|until| Instant::now() < until)
|
||||
.unwrap_or(false);
|
||||
|
||||
if show_minimap {
|
||||
let tiles: Vec<NavTile> = Page::ALL
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (col, row) = p.grid_pos();
|
||||
NavTile { col, row, name: p.name() }
|
||||
})
|
||||
.collect();
|
||||
let selected = app.page.grid_pos();
|
||||
NavMinimap::new(&tiles, selected).render_centered(frame, term);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -241,7 +262,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
Page::Main => "[MAIN]",
|
||||
Page::Patterns => "[PATTERNS]",
|
||||
Page::Audio => "[AUDIO]",
|
||||
Page::Doc => "[DOC]",
|
||||
Page::Help => "[HELP]",
|
||||
Page::Dict => "[DICT]",
|
||||
};
|
||||
|
||||
let content = if let Some(ref msg) = app.ui.status_message {
|
||||
@@ -281,12 +303,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
("t", "Test"),
|
||||
("Space", "Play"),
|
||||
],
|
||||
Page::Doc => vec![
|
||||
Page::Help => vec![
|
||||
("↑↓", "Scroll"),
|
||||
("←→", "Category"),
|
||||
("Tab", "Topic"),
|
||||
("PgUp/Dn", "Page"),
|
||||
],
|
||||
Page::Dict => vec![
|
||||
("Tab", "Focus"),
|
||||
("↑↓", "Navigate"),
|
||||
("/", "Search"),
|
||||
],
|
||||
};
|
||||
|
||||
let page_width = page_indicator.chars().count();
|
||||
@@ -498,9 +524,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
let height = (term.height * 60 / 100).max(10);
|
||||
let step_num = app.editor_ctx.step + 1;
|
||||
|
||||
let flash_color = app.ui.flash_color();
|
||||
let border_color = match flash_color {
|
||||
Some(c) => c,
|
||||
let flash_kind = app.ui.flash_kind();
|
||||
let border_color = match flash_kind {
|
||||
Some(FlashKind::Error) => Color::Red,
|
||||
Some(FlashKind::Info) => Color::White,
|
||||
Some(FlashKind::Success) => Color::Green,
|
||||
None => Color::Rgb(100, 160, 180),
|
||||
};
|
||||
|
||||
@@ -540,11 +568,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
let editor_area = Rect::new(inner.x, inner.y, inner.width, inner.height.saturating_sub(1));
|
||||
let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1);
|
||||
|
||||
if let Some(c) = flash_color {
|
||||
let bg = match c {
|
||||
Color::Red => Color::Rgb(60, 10, 10),
|
||||
Color::White => Color::Rgb(30, 30, 40),
|
||||
_ => Color::Rgb(10, 30, 10),
|
||||
if let Some(kind) = flash_kind {
|
||||
let bg = match kind {
|
||||
FlashKind::Error => Color::Rgb(60, 10, 10),
|
||||
FlashKind::Info => Color::Rgb(30, 30, 40),
|
||||
FlashKind::Success => Color::Rgb(10, 30, 10),
|
||||
};
|
||||
let flash_block = Block::default().style(Style::default().bg(bg));
|
||||
frame.render_widget(flash_block, editor_area);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub use cagire_ratatui::{
|
||||
ConfirmModal, FileBrowserModal, ModalFrame, Orientation, SampleBrowser, Scope, Spectrum,
|
||||
TextInputModal, VuMeter,
|
||||
ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser,
|
||||
Scope, Spectrum, TextInputModal, VuMeter,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user