From dd77f6d92d31e2f54bfbd078d01dbb2313fd9069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 1 Feb 2026 13:39:25 +0100 Subject: [PATCH] Feat: continue refactoring --- Cargo.toml | 3 +- crates/forth/src/words.rs | 68 +++--- crates/markdown/Cargo.toml | 8 + crates/markdown/src/highlighter.rs | 13 ++ crates/markdown/src/lib.rs | 7 + crates/markdown/src/parser.rs | 327 ++++++++++++++++++++++++++ crates/markdown/src/theme.rs | 77 ++++++ docs/dictionary.md | 1 + src/app.rs | 65 +----- src/commands.rs | 28 +-- src/engine/audio.rs | 11 +- src/engine/sequencer.rs | 7 +- src/services/pattern_editor.rs | 45 ++-- src/state/audio.rs | 46 ++-- src/state/editor.rs | 8 - src/state/mod.rs | 21 +- src/state/options.rs | 67 +++--- src/state/ui.rs | 12 +- src/views/dict_view.rs | 173 ++++++++++---- src/views/help_view.rs | 360 ++++++----------------------- 20 files changed, 766 insertions(+), 581 deletions(-) create mode 100644 crates/markdown/Cargo.toml create mode 100644 crates/markdown/src/highlighter.rs create mode 100644 crates/markdown/src/lib.rs create mode 100644 crates/markdown/src/parser.rs create mode 100644 crates/markdown/src/theme.rs diff --git a/Cargo.toml b/Cargo.toml index 6cdc9a2..4eb924f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/forth", "crates/project", "crates/ratatui"] +members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"] [package] name = "cagire" @@ -32,6 +32,7 @@ desktop = [ [dependencies] cagire-forth = { path = "crates/forth" } +cagire-markdown = { path = "crates/markdown" } cagire-project = { path = "crates/project" } cagire-ratatui = { path = "crates/ratatui" } doux = { git = "https://github.com/sova-org/doux", features = ["native"] } diff --git a/crates/forth/src/words.rs b/crates/forth/src/words.rs index 935cdde..a34a4cf 100644 --- a/crates/forth/src/words.rs +++ b/crates/forth/src/words.rs @@ -466,7 +466,7 @@ pub const WORDS: &[Word] = &[ Word { name: "rand", aliases: &[], - category: "Randomness", + category: "Probability", stack: "(min max -- n|f)", desc: "Random in range. Int if both args are int, float otherwise", example: "1 6 rand => 4 | 0.0 1.0 rand => 0.42", @@ -476,7 +476,7 @@ pub const WORDS: &[Word] = &[ Word { name: "seed", aliases: &[], - category: "Randomness", + category: "Probability", stack: "(n --)", desc: "Set random seed", example: "12345 seed", @@ -486,7 +486,7 @@ pub const WORDS: &[Word] = &[ Word { name: "coin", aliases: &[], - category: "Randomness", + category: "Probability", stack: "(-- bool)", desc: "50/50 random boolean", example: "coin => 0 or 1", @@ -516,7 +516,7 @@ pub const WORDS: &[Word] = &[ Word { name: "choose", aliases: &[], - category: "Randomness", + category: "Probability", stack: "(..n n -- val)", desc: "Random pick from n items", example: "1 2 3 3 choose", @@ -526,7 +526,7 @@ pub const WORDS: &[Word] = &[ Word { name: "cycle", aliases: &[], - category: "Selection", + category: "Probability", stack: "(v1..vn n -- selected)", desc: "Cycle through n items by step runs", example: "60 64 67 3 cycle", @@ -536,7 +536,7 @@ pub const WORDS: &[Word] = &[ Word { name: "pcycle", aliases: &[], - category: "Selection", + category: "Probability", stack: "(v1..vn n -- selected)", desc: "Cycle through n items by pattern iteration", example: "60 64 67 3 pcycle", @@ -546,7 +546,7 @@ pub const WORDS: &[Word] = &[ Word { name: "tcycle", aliases: &[], - category: "Selection", + category: "Probability", stack: "(v1..vn n -- CycleList)", desc: "Create cycle list for emit-time resolution", example: "60 64 67 3 tcycle note", @@ -1186,7 +1186,7 @@ pub const WORDS: &[Word] = &[ Word { name: "gain", aliases: &[], - category: "Gain", + category: "Envelope", stack: "(f --)", desc: "Set volume (0-1)", example: "0.8 gain", @@ -1196,7 +1196,7 @@ pub const WORDS: &[Word] = &[ Word { name: "postgain", aliases: &[], - category: "Gain", + category: "Envelope", stack: "(f --)", desc: "Set post gain", example: "1.2 postgain", @@ -1206,7 +1206,7 @@ pub const WORDS: &[Word] = &[ Word { name: "velocity", aliases: &[], - category: "Gain", + category: "Envelope", stack: "(f --)", desc: "Set velocity", example: "100 velocity", @@ -1216,7 +1216,7 @@ pub const WORDS: &[Word] = &[ Word { name: "pan", aliases: &[], - category: "Gain", + category: "Stereo", stack: "(f --)", desc: "Set pan (-1 to 1)", example: "0.5 pan", @@ -1496,7 +1496,7 @@ pub const WORDS: &[Word] = &[ Word { name: "llpf", aliases: &[], - category: "Ladder Filter", + category: "Filter", stack: "(f --)", desc: "Set ladder lowpass frequency", example: "2000 llpf", @@ -1506,7 +1506,7 @@ pub const WORDS: &[Word] = &[ Word { name: "llpq", aliases: &[], - category: "Ladder Filter", + category: "Filter", stack: "(f --)", desc: "Set ladder lowpass resonance", example: "0.5 llpq", @@ -1516,7 +1516,7 @@ pub const WORDS: &[Word] = &[ Word { name: "lhpf", aliases: &[], - category: "Ladder Filter", + category: "Filter", stack: "(f --)", desc: "Set ladder highpass frequency", example: "100 lhpf", @@ -1526,7 +1526,7 @@ pub const WORDS: &[Word] = &[ Word { name: "lhpq", aliases: &[], - category: "Ladder Filter", + category: "Filter", stack: "(f --)", desc: "Set ladder highpass resonance", example: "0.5 lhpq", @@ -1536,7 +1536,7 @@ pub const WORDS: &[Word] = &[ Word { name: "lbpf", aliases: &[], - category: "Ladder Filter", + category: "Filter", stack: "(f --)", desc: "Set ladder bandpass frequency", example: "1000 lbpf", @@ -1546,7 +1546,7 @@ pub const WORDS: &[Word] = &[ Word { name: "lbpq", aliases: &[], - category: "Ladder Filter", + category: "Filter", stack: "(f --)", desc: "Set ladder bandpass resonance", example: "0.5 lbpq", @@ -1566,7 +1566,7 @@ pub const WORDS: &[Word] = &[ Word { name: "penv", aliases: &[], - category: "Pitch Env", + category: "Envelope", stack: "(f --)", desc: "Set pitch envelope", example: "0.5 penv", @@ -1576,7 +1576,7 @@ pub const WORDS: &[Word] = &[ Word { name: "patt", aliases: &[], - category: "Pitch Env", + category: "Envelope", stack: "(f --)", desc: "Set pitch attack", example: "0.01 patt", @@ -1586,7 +1586,7 @@ pub const WORDS: &[Word] = &[ Word { name: "pdec", aliases: &[], - category: "Pitch Env", + category: "Envelope", stack: "(f --)", desc: "Set pitch decay", example: "0.1 pdec", @@ -1596,7 +1596,7 @@ pub const WORDS: &[Word] = &[ Word { name: "psus", aliases: &[], - category: "Pitch Env", + category: "Envelope", stack: "(f --)", desc: "Set pitch sustain", example: "0 psus", @@ -1606,7 +1606,7 @@ pub const WORDS: &[Word] = &[ Word { name: "prel", aliases: &[], - category: "Pitch Env", + category: "Envelope", stack: "(f --)", desc: "Set pitch release", example: "0.1 prel", @@ -1646,7 +1646,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fm", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM frequency", example: "200 fm", @@ -1656,7 +1656,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fmh", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM harmonic ratio", example: "2 fmh", @@ -1666,7 +1666,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fmshape", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM shape", example: "0 fmshape", @@ -1676,7 +1676,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fme", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM envelope", example: "0.5 fme", @@ -1686,7 +1686,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fma", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM attack", example: "0.01 fma", @@ -1696,7 +1696,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fmd", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM decay", example: "0.1 fmd", @@ -1706,7 +1706,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fms", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM sustain", example: "0.5 fms", @@ -1716,7 +1716,7 @@ pub const WORDS: &[Word] = &[ Word { name: "fmr", aliases: &[], - category: "Modulation", + category: "FM", stack: "(f --)", desc: "Set FM release", example: "0.1 fmr", @@ -1886,7 +1886,7 @@ pub const WORDS: &[Word] = &[ Word { name: "eqlo", aliases: &[], - category: "EQ", + category: "Filter", stack: "(f --)", desc: "Set low shelf gain (dB)", example: "3 eqlo", @@ -1896,7 +1896,7 @@ pub const WORDS: &[Word] = &[ Word { name: "eqmid", aliases: &[], - category: "EQ", + category: "Filter", stack: "(f --)", desc: "Set mid peak gain (dB)", example: "-2 eqmid", @@ -1906,7 +1906,7 @@ pub const WORDS: &[Word] = &[ Word { name: "eqhi", aliases: &[], - category: "EQ", + category: "Filter", stack: "(f --)", desc: "Set high shelf gain (dB)", example: "1 eqhi", @@ -1916,7 +1916,7 @@ pub const WORDS: &[Word] = &[ Word { name: "tilt", aliases: &[], - category: "EQ", + category: "Filter", stack: "(f --)", desc: "Set tilt EQ (-1 dark, 1 bright)", example: "-0.5 tilt", diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml new file mode 100644 index 0000000..6103947 --- /dev/null +++ b/crates/markdown/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cagire-markdown" +version = "0.1.0" +edition = "2021" + +[dependencies] +minimad = "0.13" +ratatui = "0.30" diff --git a/crates/markdown/src/highlighter.rs b/crates/markdown/src/highlighter.rs new file mode 100644 index 0000000..4e5856d --- /dev/null +++ b/crates/markdown/src/highlighter.rs @@ -0,0 +1,13 @@ +use ratatui::style::Style; + +pub trait CodeHighlighter { + fn highlight(&self, line: &str) -> Vec<(Style, String)>; +} + +pub struct NoHighlight; + +impl CodeHighlighter for NoHighlight { + fn highlight(&self, line: &str) -> Vec<(Style, String)> { + vec![(Style::default(), line.to_string())] + } +} diff --git a/crates/markdown/src/lib.rs b/crates/markdown/src/lib.rs new file mode 100644 index 0000000..403aa11 --- /dev/null +++ b/crates/markdown/src/lib.rs @@ -0,0 +1,7 @@ +mod highlighter; +mod parser; +mod theme; + +pub use highlighter::{CodeHighlighter, NoHighlight}; +pub use parser::parse; +pub use theme::{DefaultTheme, MarkdownTheme}; diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs new file mode 100644 index 0000000..f3ea5bc --- /dev/null +++ b/crates/markdown/src/parser.rs @@ -0,0 +1,327 @@ +use minimad::{Composite, CompositeStyle, Compound, Line, TableRow}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line as RLine, Span}; + +use crate::highlighter::CodeHighlighter; +use crate::theme::MarkdownTheme; + +pub fn parse( + md: &str, + theme: &T, + highlighter: &H, +) -> Vec> { + let processed = preprocess_markdown(md); + let text = minimad::Text::from(processed.as_str()); + let mut lines = Vec::new(); + + let mut code_line_nr: usize = 0; + let mut table_buffer: Vec = Vec::new(); + + let flush_table = |buf: &mut Vec, out: &mut Vec>, theme: &T| { + if buf.is_empty() { + return; + } + let col_widths = compute_column_widths(buf); + for (row_idx, row) in buf.drain(..).enumerate() { + out.push(render_table_row(row, row_idx, &col_widths, theme)); + } + }; + + for line in text.lines { + match line { + Line::Normal(composite) if composite.style == CompositeStyle::Code => { + flush_table(&mut table_buffer, &mut lines, theme); + code_line_nr += 1; + let raw: String = composite + .compounds + .iter() + .map(|c: &minimad::Compound| c.src) + .collect(); + let mut spans = vec![ + Span::styled(format!(" {code_line_nr:>2} "), theme.code_border()), + Span::styled("│ ", theme.code_border()), + ]; + spans.extend( + highlighter + .highlight(&raw) + .into_iter() + .map(|(style, text)| Span::styled(text, style)), + ); + lines.push(RLine::from(spans)); + } + Line::Normal(composite) => { + flush_table(&mut table_buffer, &mut lines, theme); + code_line_nr = 0; + lines.push(composite_to_line(composite, theme)); + } + Line::TableRow(row) => { + code_line_nr = 0; + table_buffer.push(row); + } + Line::TableRule(_) => {} + _ => { + flush_table(&mut table_buffer, &mut lines, theme); + code_line_nr = 0; + lines.push(RLine::from("")); + } + } + } + flush_table(&mut table_buffer, &mut lines, theme); + + lines +} + +pub fn preprocess_markdown(md: &str) -> String { + let mut out = String::with_capacity(md.len()); + for line in md.lines() { + let line = convert_dash_lists(line); + 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(); + continue; + } + } + } + } + result.push(c); + } + out.push_str(&result); + out.push('\n'); + } + out +} + +pub fn convert_dash_lists(line: &str) -> String { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("- ") { + let indent = line.len() - trimmed.len(); + format!("{}* {}", " ".repeat(indent), rest) + } else { + line.to_string() + } +} + +fn cell_text_width(cell: &Composite) -> usize { + cell.compounds.iter().map(|c| c.src.chars().count()).sum() +} + +fn compute_column_widths(rows: &[TableRow]) -> Vec { + let mut widths: Vec = Vec::new(); + for row in rows { + for (i, cell) in row.cells.iter().enumerate() { + let w = cell_text_width(cell); + if i >= widths.len() { + widths.push(w); + } else if w > widths[i] { + widths[i] = w; + } + } + } + widths +} + +fn render_table_row( + row: TableRow, + row_idx: usize, + col_widths: &[usize], + theme: &T, +) -> RLine<'static> { + let is_header = row_idx == 0; + let bg = if is_header { + theme.table_header_bg() + } else if row_idx.is_multiple_of(2) { + theme.table_row_even() + } else { + theme.table_row_odd() + }; + + let base_style = if is_header { + theme.text().bg(bg).add_modifier(Modifier::BOLD) + } else { + theme.text().bg(bg) + }; + + let sep_style = theme.code_border().bg(bg); + let mut spans: Vec> = Vec::new(); + + for (i, cell) in row.cells.into_iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" │ ", sep_style)); + } + let target_width = col_widths.get(i).copied().unwrap_or(0); + let cell_width = cell + .compounds + .iter() + .map(|c| c.src.chars().count()) + .sum::(); + + for compound in cell.compounds { + compound_to_spans(compound, base_style, &mut spans, theme); + } + + let padding = target_width.saturating_sub(cell_width); + if padding > 0 { + spans.push(Span::styled(" ".repeat(padding), base_style)); + } + } + + RLine::from(spans) +} + +fn composite_to_line(composite: Composite, theme: &T) -> RLine<'static> { + let base_style = match composite.style { + CompositeStyle::Header(1) => theme.h1(), + CompositeStyle::Header(2) => theme.h2(), + CompositeStyle::Header(_) => theme.h3(), + CompositeStyle::ListItem(_) => theme.list(), + CompositeStyle::Quote => theme.quote(), + CompositeStyle::Code => theme.code(), + CompositeStyle::Paragraph => theme.text(), + }; + + let prefix: String = match composite.style { + CompositeStyle::ListItem(depth) => { + let indent = " ".repeat(depth as usize); + format!("{indent}• ") + } + CompositeStyle::Quote => " │ ".to_string(), + _ => String::new(), + }; + + let mut spans: Vec> = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled(prefix, base_style)); + } + + for compound in composite.compounds { + compound_to_spans(compound, base_style, &mut spans, theme); + } + + RLine::from(spans) +} + +fn compound_to_spans( + compound: Compound, + base: Style, + out: &mut Vec>, + theme: &T, +) { + 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 = theme.code(); + } + if compound.strikeout { + style = style.add_modifier(Modifier::CROSSED_OUT); + } + + let src = compound.src.to_string(); + let link_style = theme.link(); + + 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})"), theme.link_url())); + } + 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)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::highlighter::NoHighlight; + use crate::theme::DefaultTheme; + + #[test] + fn test_preprocess_underscores() { + assert_eq!(preprocess_markdown("_italic_"), "*italic*\n"); + assert_eq!(preprocess_markdown("word_with_underscores"), "word_with_underscores\n"); + assert_eq!(preprocess_markdown("hello _world_"), "hello *world*\n"); + } + + #[test] + fn test_preprocess_dash_lists() { + assert_eq!(convert_dash_lists("- item"), "* item"); + assert_eq!(convert_dash_lists(" - nested"), " * nested"); + assert_eq!(convert_dash_lists("not-a-list"), "not-a-list"); + } + + #[test] + fn test_parse_headings() { + let md = "# H1\n## H2\n### H3"; + let lines = parse(md, &DefaultTheme, &NoHighlight); + assert_eq!(lines.len(), 3); + } + + #[test] + fn test_parse_code_block() { + let md = "```\ncode line\n```"; + let lines = parse(md, &DefaultTheme, &NoHighlight); + assert!(!lines.is_empty()); + } + + #[test] + fn test_parse_table() { + let md = "| A | B |\n|---|---|\n| 1 | 2 |"; + let lines = parse(md, &DefaultTheme, &NoHighlight); + assert_eq!(lines.len(), 2); + } + + #[test] + fn test_default_theme_works() { + let md = "Hello **world**"; + let lines = parse(md, &DefaultTheme, &NoHighlight); + assert_eq!(lines.len(), 1); + } +} diff --git a/crates/markdown/src/theme.rs b/crates/markdown/src/theme.rs new file mode 100644 index 0000000..e259508 --- /dev/null +++ b/crates/markdown/src/theme.rs @@ -0,0 +1,77 @@ +use ratatui::style::{Color, Modifier, Style}; + +pub trait MarkdownTheme { + fn h1(&self) -> Style; + fn h2(&self) -> Style; + fn h3(&self) -> Style; + fn text(&self) -> Style; + fn code(&self) -> Style; + fn code_border(&self) -> Style; + fn link(&self) -> Style; + fn link_url(&self) -> Style; + fn quote(&self) -> Style; + fn list(&self) -> Style; + fn table_header_bg(&self) -> Color; + fn table_row_even(&self) -> Color; + fn table_row_odd(&self) -> Color; +} + +pub struct DefaultTheme; + +impl MarkdownTheme for DefaultTheme { + fn h1(&self) -> Style { + Style::new() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } + + fn h2(&self) -> Style { + Style::new().fg(Color::Blue).add_modifier(Modifier::BOLD) + } + + fn h3(&self) -> Style { + Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD) + } + + fn text(&self) -> Style { + Style::new().fg(Color::White) + } + + fn code(&self) -> Style { + Style::new().fg(Color::Yellow) + } + + fn code_border(&self) -> Style { + Style::new().fg(Color::DarkGray) + } + + fn link(&self) -> Style { + Style::new() + .fg(Color::Blue) + .add_modifier(Modifier::UNDERLINED) + } + + fn link_url(&self) -> Style { + Style::new().fg(Color::DarkGray) + } + + fn quote(&self) -> Style { + Style::new().fg(Color::Gray) + } + + fn list(&self) -> Style { + Style::new().fg(Color::White) + } + + fn table_header_bg(&self) -> Color { + Color::DarkGray + } + + fn table_row_even(&self) -> Color { + Color::Reset + } + + fn table_row_odd(&self) -> Color { + Color::Reset + } +} diff --git a/docs/dictionary.md b/docs/dictionary.md index 621aca4..4a08c97 100644 --- a/docs/dictionary.md +++ b/docs/dictionary.md @@ -10,6 +10,7 @@ The dictionary shows every available word organized by category: - **Arithmetic**: Math operations. - **Sound**: Sound sources and emission. - **Filter**, **Envelope**, **Effects**: Sound shaping. +- **MIDI**: External MIDI control (`chan`, `cc`, `emit`, `clock`, etc.). - **Context**: Sequencer state like `step`, `beat`, `tempo`. - And many more... diff --git a/src/app.rs b/src/app.rs index a1a95e8..37be45a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crate::page::Page; use crate::services::pattern_editor; use crate::settings::Settings; use crate::state::{ - AudioSettings, DictFocus, EditorContext, FlashKind, Focus, LiveKeyState, Metrics, Modal, + AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState, ProjectState, StagedChange, UiState, }; @@ -182,20 +182,6 @@ impl App { link.set_tempo((current - 1.0).max(20.0)); } - pub fn toggle_focus(&mut self, link: &LinkState) { - match self.editor_ctx.focus { - Focus::Sequencer => { - self.editor_ctx.focus = Focus::Editor; - self.load_step_to_editor(); - } - Focus::Editor => { - self.save_editor_to_step(); - self.compile_current_step(link); - self.editor_ctx.focus = Focus::Sequencer; - } - } - } - pub fn current_edit_pattern(&self) -> &Pattern { let (bank, pattern) = self.current_bank_pattern(); self.project_state.project.pattern_at(bank, pattern) @@ -1002,9 +988,6 @@ impl App { AppCommand::PrevStep => self.prev_step(), AppCommand::StepUp => self.step_up(), AppCommand::StepDown => self.step_down(), - AppCommand::ToggleFocus => self.toggle_focus(link), - AppCommand::SelectEditBank(bank) => self.select_edit_bank(bank), - AppCommand::SelectEditPattern(pattern) => self.select_edit_pattern(pattern), // Pattern editing AppCommand::ToggleSteps => self.toggle_steps(), @@ -1048,7 +1031,6 @@ impl App { // Script editing AppCommand::SaveEditorToStep => self.save_editor_to_step(), AppCommand::CompileCurrentStep => self.compile_current_step(link), - AppCommand::CompileAllSteps => self.compile_all_steps(link), AppCommand::DeleteStep { bank, pattern, @@ -1090,9 +1072,6 @@ impl App { AppCommand::DuplicateSteps => self.duplicate_steps(link), // Pattern playback (staging) - AppCommand::StagePatternToggle { bank, pattern } => { - self.stage_pattern_toggle(bank, pattern, snapshot); - } AppCommand::CommitStagedChanges => { self.commit_staged_changes(); } @@ -1130,11 +1109,6 @@ impl App { // UI AppCommand::SetStatus(msg) => self.ui.set_status(msg), AppCommand::ClearStatus => self.ui.clear_status(), - AppCommand::Flash { - message, - duration_ms, - 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 @@ -1241,18 +1215,18 @@ impl App { 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); + let s = self.ui.dict_scroll_mut(); + *s = s.saturating_add(n); } AppCommand::DictScrollUp(n) => { - self.ui.dict_scroll = self.ui.dict_scroll.saturating_sub(n); + let s = self.ui.dict_scroll_mut(); + *s = s.saturating_sub(n); } AppCommand::DictActivateSearch => { self.ui.dict_search_active = true; @@ -1261,15 +1235,15 @@ impl App { AppCommand::DictClearSearch => { self.ui.dict_search_query.clear(); self.ui.dict_search_active = false; - self.ui.dict_scroll = 0; + *self.ui.dict_scroll_mut() = 0; } AppCommand::DictSearchInput(c) => { self.ui.dict_search_query.push(c); - self.ui.dict_scroll = 0; + *self.ui.dict_scroll_mut() = 0; } AppCommand::DictSearchBackspace => { self.ui.dict_search_query.pop(); - self.ui.dict_scroll = 0; + *self.ui.dict_scroll_mut() = 0; } AppCommand::DictSearchConfirm => { self.ui.dict_search_active = false; @@ -1346,9 +1320,6 @@ impl App { AppCommand::SetSelectionAnchor(step) => { self.editor_ctx.selection_anchor = Some(step); } - AppCommand::ClearSelectionAnchor => { - self.editor_ctx.selection_anchor = None; - } // Audio settings (engine page) AppCommand::AudioNextSection => { @@ -1437,26 +1408,6 @@ impl App { AppCommand::ResetPeakVoices => { self.metrics.peak_voices = 0; } - - // MIDI connections - AppCommand::ConnectMidiOutput { slot, port } => { - if let Err(e) = self.midi.connect_output(slot, port) { - self.ui - .flash(&format!("MIDI error: {e}"), 300, FlashKind::Error); - } - } - AppCommand::DisconnectMidiOutput(slot) => { - self.midi.disconnect_output(slot); - } - AppCommand::ConnectMidiInput { slot, port } => { - if let Err(e) = self.midi.connect_input(slot, port) { - self.ui - .flash(&format!("MIDI error: {e}"), 300, FlashKind::Error); - } - } - AppCommand::DisconnectMidiInput(slot) => { - self.midi.disconnect_input(slot); - } } } diff --git a/src/commands.rs b/src/commands.rs index afad74a..7cfaf19 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,9 +1,8 @@ use std::path::PathBuf; use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; -use crate::state::{ColorScheme, DeviceKind, FlashKind, Modal, PatternField, SettingKind}; +use crate::state::{ColorScheme, DeviceKind, Modal, PatternField, SettingKind}; -#[allow(dead_code)] pub enum AppCommand { // Playback TogglePlaying, @@ -15,9 +14,6 @@ pub enum AppCommand { PrevStep, StepUp, StepDown, - ToggleFocus, - SelectEditBank(usize), - SelectEditPattern(usize), // Pattern editing ToggleSteps, @@ -39,7 +35,6 @@ pub enum AppCommand { // Script editing SaveEditorToStep, CompileCurrentStep, - CompileAllSteps, DeleteStep { bank: usize, pattern: usize, @@ -80,10 +75,6 @@ pub enum AppCommand { DuplicateSteps, // Pattern playback (staging) - StagePatternToggle { - bank: usize, - pattern: usize, - }, CommitStagedChanges, ClearStagedChanges, @@ -109,11 +100,6 @@ pub enum AppCommand { // UI SetStatus(String), ClearStatus, - Flash { - message: String, - duration_ms: u64, - kind: FlashKind, - }, OpenModal(Modal), CloseModal, OpenPatternModal(PatternField), @@ -187,7 +173,6 @@ pub enum AppCommand { // Selection SetSelectionAnchor(usize), - ClearSelectionAnchor, // Audio settings (engine page) AudioNextSection, @@ -222,15 +207,4 @@ pub enum AppCommand { // Metrics ResetPeakVoices, - // MIDI connections - ConnectMidiOutput { - slot: usize, - port: usize, - }, - DisconnectMidiOutput(usize), - ConnectMidiInput { - slot: usize, - port: usize, - }, - DisconnectMidiInput(usize), } diff --git a/src/engine/audio.rs b/src/engine/audio.rs index 4e24e63..03cc008 100644 --- a/src/engine/audio.rs +++ b/src/engine/audio.rs @@ -274,9 +274,9 @@ pub fn build_stream( let sr = sample_rate; let channels = config.channels as usize; let max_voices = config.max_voices; - let metrics_clone = Arc::clone(&metrics); - let mut engine = Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); + let mut engine = + Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); engine.sample_index = initial_samples; let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer); @@ -306,13 +306,6 @@ pub fn build_stream( AudioCommand::LoadSamples(samples) => { engine.sample_index.extend(samples); } - AudioCommand::ResetEngine => { - let old_samples = std::mem::take(&mut engine.sample_index); - engine = - Engine::new_with_metrics(sr, channels, max_voices, Arc::clone(&metrics_clone)); - engine.sample_index = old_samples; - audio_sample_pos.store(0, Ordering::Relaxed); - } } } diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 8495e8d..f09af7e 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -44,15 +44,10 @@ impl PatternChange { } pub enum AudioCommand { - Evaluate { - cmd: String, - time: Option, - }, + Evaluate { cmd: String, time: Option }, Hush, Panic, LoadSamples(Vec), - #[allow(dead_code)] - ResetEngine, } #[derive(Clone, Debug)] diff --git a/src/services/pattern_editor.rs b/src/services/pattern_editor.rs index 6143b5a..aac9bde 100644 --- a/src/services/pattern_editor.rs +++ b/src/services/pattern_editor.rs @@ -1,27 +1,22 @@ use crate::model::{PatternSpeed, Project}; #[derive(Debug, Clone, Copy)] -pub struct PatternChange { +pub struct PatternEdit { pub bank: usize, pub pattern: usize, } -impl PatternChange { +impl PatternEdit { pub fn new(bank: usize, pattern: usize) -> Self { Self { bank, pattern } } } -pub fn toggle_step( - project: &mut Project, - bank: usize, - pattern: usize, - step: usize, -) -> PatternChange { +pub fn toggle_step(project: &mut Project, bank: usize, pattern: usize, step: usize) -> PatternEdit { if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) { s.active = !s.active; } - PatternChange::new(bank, pattern) + PatternEdit::new(bank, pattern) } pub fn set_length( @@ -29,30 +24,22 @@ pub fn set_length( bank: usize, pattern: usize, length: usize, -) -> (PatternChange, usize) { +) -> (PatternEdit, usize) { project.pattern_at_mut(bank, pattern).set_length(length); let actual = project.pattern_at(bank, pattern).length; - (PatternChange::new(bank, pattern), actual) + (PatternEdit::new(bank, pattern), actual) } pub fn get_length(project: &Project, bank: usize, pattern: usize) -> usize { project.pattern_at(bank, pattern).length } -pub fn increase_length( - project: &mut Project, - bank: usize, - pattern: usize, -) -> (PatternChange, usize) { +pub fn increase_length(project: &mut Project, bank: usize, pattern: usize) -> (PatternEdit, usize) { let current = get_length(project, bank, pattern); set_length(project, bank, pattern, current + 1) } -pub fn decrease_length( - project: &mut Project, - bank: usize, - pattern: usize, -) -> (PatternChange, usize) { +pub fn decrease_length(project: &mut Project, bank: usize, pattern: usize) -> (PatternEdit, usize) { let current = get_length(project, bank, pattern); set_length(project, bank, pattern, current.saturating_sub(1)) } @@ -62,21 +49,21 @@ pub fn set_speed( bank: usize, pattern: usize, speed: PatternSpeed, -) -> PatternChange { +) -> PatternEdit { project.pattern_at_mut(bank, pattern).speed = speed; - PatternChange::new(bank, pattern) + PatternEdit::new(bank, pattern) } -pub fn increase_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternChange { +pub fn increase_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternEdit { let pat = project.pattern_at_mut(bank, pattern); pat.speed = pat.speed.next(); - PatternChange::new(bank, pattern) + PatternEdit::new(bank, pattern) } -pub fn decrease_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternChange { +pub fn decrease_speed(project: &mut Project, bank: usize, pattern: usize) -> PatternEdit { let pat = project.pattern_at_mut(bank, pattern); pat.speed = pat.speed.prev(); - PatternChange::new(bank, pattern) + PatternEdit::new(bank, pattern) } pub fn set_step_script( @@ -85,11 +72,11 @@ pub fn set_step_script( pattern: usize, step: usize, script: String, -) -> PatternChange { +) -> PatternEdit { if let Some(s) = project.pattern_at_mut(bank, pattern).step_mut(step) { s.script = script; } - PatternChange::new(bank, pattern) + PatternEdit::new(bank, pattern) } pub fn get_step_script( diff --git a/src/state/audio.rs b/src/state/audio.rs index 8cf2a70..d0da46f 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -1,6 +1,8 @@ use doux::audio::AudioDeviceInfo; use std::path::PathBuf; +use super::CyclicEnum; + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum RefreshRate { #[default] @@ -128,6 +130,10 @@ pub enum EngineSection { Samples, } +impl CyclicEnum for EngineSection { + const VARIANTS: &'static [Self] = &[Self::Devices, Self::Settings, Self::Samples]; +} + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum DeviceKind { #[default] @@ -145,26 +151,14 @@ pub enum SettingKind { Lookahead, } -impl SettingKind { - pub fn next(self) -> Self { - match self { - Self::Channels => Self::BufferSize, - Self::BufferSize => Self::Polyphony, - Self::Polyphony => Self::Nudge, - Self::Nudge => Self::Lookahead, - Self::Lookahead => Self::Channels, - } - } - - pub fn prev(self) -> Self { - match self { - Self::Channels => Self::Lookahead, - Self::BufferSize => Self::Channels, - Self::Polyphony => Self::BufferSize, - Self::Nudge => Self::Polyphony, - Self::Lookahead => Self::Nudge, - } - } +impl CyclicEnum for SettingKind { + const VARIANTS: &'static [Self] = &[ + Self::Channels, + Self::BufferSize, + Self::Polyphony, + Self::Nudge, + Self::Lookahead, + ]; } pub struct Metrics { @@ -242,19 +236,11 @@ impl AudioSettings { } pub fn next_section(&mut self) { - self.section = match self.section { - EngineSection::Devices => EngineSection::Settings, - EngineSection::Settings => EngineSection::Samples, - EngineSection::Samples => EngineSection::Devices, - }; + self.section = self.section.next(); } pub fn prev_section(&mut self) { - self.section = match self.section { - EngineSection::Devices => EngineSection::Samples, - EngineSection::Settings => EngineSection::Devices, - EngineSection::Samples => EngineSection::Settings, - }; + self.section = self.section.prev(); } pub fn current_output_device_index(&self) -> usize { diff --git a/src/state/editor.rs b/src/state/editor.rs index d2b64c2..7a6697b 100644 --- a/src/state/editor.rs +++ b/src/state/editor.rs @@ -3,12 +3,6 @@ use std::ops::RangeInclusive; use cagire_ratatui::Editor; -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Focus { - Sequencer, - Editor, -} - #[derive(Clone, Copy, PartialEq, Eq)] pub enum PatternField { Length, @@ -51,7 +45,6 @@ pub struct EditorContext { pub bank: usize, pub pattern: usize, pub step: usize, - pub focus: Focus, pub editor: Editor, pub selection_anchor: Option, pub copied_steps: Option, @@ -101,7 +94,6 @@ impl Default for EditorContext { bank: 0, pattern: 0, step: 0, - focus: Focus::Sequencer, editor: Editor::new(), selection_anchor: None, copied_steps: None, diff --git a/src/state/mod.rs b/src/state/mod.rs index e63fc4a..d6bb6c4 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -1,3 +1,18 @@ +pub trait CyclicEnum: Sized + Copy + PartialEq + 'static { + const VARIANTS: &'static [Self]; + + fn next(self) -> Self { + let pos = Self::VARIANTS.iter().position(|v| *v == self).unwrap_or(0); + Self::VARIANTS[(pos + 1) % Self::VARIANTS.len()] + } + + fn prev(self) -> Self { + let len = Self::VARIANTS.len(); + let pos = Self::VARIANTS.iter().position(|v| *v == self).unwrap_or(0); + Self::VARIANTS[(pos + len - 1) % len] + } +} + pub mod audio; pub mod color_scheme; pub mod editor; @@ -14,10 +29,12 @@ pub mod ui; pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind}; pub use color_scheme::ColorScheme; -pub use options::{OptionsFocus, OptionsState}; -pub use editor::{CopiedStepData, CopiedSteps, EditorContext, Focus, PatternField, PatternPropsField, StackCache}; +pub use editor::{ + CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache, +}; pub use live_keys::LiveKeyState; pub use modal::Modal; +pub use options::{OptionsFocus, OptionsState}; pub use panel::{PanelFocus, PanelState, SidePanel}; pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use playback::{PlaybackState, StagedChange}; diff --git a/src/state/options.rs b/src/state/options.rs index fd34b64..20f16f8 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -1,3 +1,5 @@ +use super::CyclicEnum; + #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum OptionsFocus { #[default] @@ -21,6 +23,29 @@ pub enum OptionsFocus { MidiInput3, } +impl CyclicEnum for OptionsFocus { + const VARIANTS: &'static [Self] = &[ + Self::ColorScheme, + Self::RefreshRate, + Self::RuntimeHighlight, + Self::ShowScope, + Self::ShowSpectrum, + Self::ShowCompletion, + Self::FlashBrightness, + Self::LinkEnabled, + Self::StartStopSync, + Self::Quantum, + Self::MidiOutput0, + Self::MidiOutput1, + Self::MidiOutput2, + Self::MidiOutput3, + Self::MidiInput0, + Self::MidiInput1, + Self::MidiInput2, + Self::MidiInput3, + ]; +} + #[derive(Default)] pub struct OptionsState { pub focus: OptionsFocus, @@ -28,48 +53,10 @@ pub struct OptionsState { impl OptionsState { pub fn next_focus(&mut self) { - self.focus = match self.focus { - OptionsFocus::ColorScheme => OptionsFocus::RefreshRate, - OptionsFocus::RefreshRate => OptionsFocus::RuntimeHighlight, - OptionsFocus::RuntimeHighlight => OptionsFocus::ShowScope, - OptionsFocus::ShowScope => OptionsFocus::ShowSpectrum, - OptionsFocus::ShowSpectrum => OptionsFocus::ShowCompletion, - OptionsFocus::ShowCompletion => OptionsFocus::FlashBrightness, - OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled, - OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync, - OptionsFocus::StartStopSync => OptionsFocus::Quantum, - OptionsFocus::Quantum => OptionsFocus::MidiOutput0, - OptionsFocus::MidiOutput0 => OptionsFocus::MidiOutput1, - OptionsFocus::MidiOutput1 => OptionsFocus::MidiOutput2, - OptionsFocus::MidiOutput2 => OptionsFocus::MidiOutput3, - OptionsFocus::MidiOutput3 => OptionsFocus::MidiInput0, - OptionsFocus::MidiInput0 => OptionsFocus::MidiInput1, - OptionsFocus::MidiInput1 => OptionsFocus::MidiInput2, - OptionsFocus::MidiInput2 => OptionsFocus::MidiInput3, - OptionsFocus::MidiInput3 => OptionsFocus::ColorScheme, - }; + self.focus = self.focus.next(); } pub fn prev_focus(&mut self) { - self.focus = match self.focus { - OptionsFocus::ColorScheme => OptionsFocus::MidiInput3, - OptionsFocus::RefreshRate => OptionsFocus::ColorScheme, - OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate, - OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight, - OptionsFocus::ShowSpectrum => OptionsFocus::ShowScope, - OptionsFocus::ShowCompletion => OptionsFocus::ShowSpectrum, - OptionsFocus::FlashBrightness => OptionsFocus::ShowCompletion, - OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness, - OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled, - OptionsFocus::Quantum => OptionsFocus::StartStopSync, - OptionsFocus::MidiOutput0 => OptionsFocus::Quantum, - OptionsFocus::MidiOutput1 => OptionsFocus::MidiOutput0, - OptionsFocus::MidiOutput2 => OptionsFocus::MidiOutput1, - OptionsFocus::MidiOutput3 => OptionsFocus::MidiOutput2, - OptionsFocus::MidiInput0 => OptionsFocus::MidiOutput3, - OptionsFocus::MidiInput1 => OptionsFocus::MidiInput0, - OptionsFocus::MidiInput2 => OptionsFocus::MidiInput1, - OptionsFocus::MidiInput3 => OptionsFocus::MidiInput2, - }; + self.focus = self.focus.prev(); } } diff --git a/src/state/ui.rs b/src/state/ui.rs index aef1d55..743a513 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -39,7 +39,7 @@ pub struct UiState { pub help_search_query: String, pub dict_focus: DictFocus, pub dict_category: usize, - pub dict_scroll: usize, + pub dict_scrolls: Vec, pub dict_search_query: String, pub dict_search_active: bool, pub show_title: bool, @@ -67,7 +67,7 @@ impl Default for UiState { help_search_query: String::new(), dict_focus: DictFocus::default(), dict_category: 0, - dict_scroll: 0, + dict_scrolls: vec![0; crate::views::dict_view::category_count()], dict_search_query: String::new(), dict_search_active: false, show_title: true, @@ -91,6 +91,14 @@ impl UiState { &mut self.help_scrolls[self.help_topic] } + pub fn dict_scroll(&self) -> usize { + self.dict_scrolls[self.dict_category] + } + + pub fn dict_scroll_mut(&mut self) -> &mut usize { + &mut self.dict_scrolls[self.dict_category] + } + 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)); diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs index 46c154d..368ca4e 100644 --- a/src/views/dict_view.rs +++ b/src/views/dict_view.rs @@ -9,36 +9,52 @@ use crate::model::{Word, WORDS}; use crate::state::DictFocus; use crate::theme; -const CATEGORIES: &[&str] = &[ +enum CatEntry { + Section(&'static str), + Category(&'static str), +} + +use CatEntry::{Category, Section}; + +const CATEGORIES: &[CatEntry] = &[ // Forth core - "Stack", - "Arithmetic", - "Comparison", - "Logic", - "Variables", - "Randomness", - "Probability", - "Lists", - "Definitions", + Section("Forth"), + Category("Stack"), + Category("Arithmetic"), + Category("Comparison"), + Category("Logic"), + Category("Control"), + Category("Variables"), + Category("Probability"), + Category("Definitions"), // Live coding - "Sound", - "Time", - "Context", - "Music", - "LFO", + Section("Live Coding"), + Category("Sound"), + Category("Time"), + Category("Context"), + Category("Music"), + Category("LFO"), // Synthesis - "Oscillator", - "Envelope", - "Pitch Env", - "Gain", - "Sample", + Section("Synthesis"), + Category("Oscillator"), + Category("Wavetable"), + Category("Generator"), + Category("Envelope"), + Category("Sample"), // Effects - "Filter", - "Modulation", - "Mod FX", - "Lo-fi", - "Delay", - "Reverb", + Section("Effects"), + Category("Filter"), + Category("FM"), + Category("Modulation"), + Category("Mod FX"), + Category("Lo-fi"), + Category("Stereo"), + Category("Delay"), + Category("Reverb"), + // External I/O + Section("I/O"), + Category("MIDI"), + Category("Desktop"), ]; pub fn render(frame: &mut Frame, app: &App, area: Rect) { @@ -76,22 +92,67 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) { let theme = theme::get(); let focused = app.ui.dict_focus == DictFocus::Categories && !dimmed; + let visible_height = area.height.saturating_sub(2) as usize; + let total_items = CATEGORIES.len(); + + // Find the visual index of the selected category (including sections) + let selected_visual_idx = { + let mut visual = 0; + let mut cat_count = 0; + for entry in CATEGORIES.iter() { + if let Category(_) = entry { + if cat_count == app.ui.dict_category { + break; + } + cat_count += 1; + } + visual += 1; + } + visual + }; + + // Calculate scroll to keep selection visible (centered when possible) + let scroll = if selected_visual_idx < visible_height / 2 { + 0 + } else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) { + total_items.saturating_sub(visible_height) + } else { + selected_visual_idx.saturating_sub(visible_height / 2) + }; + + // Count categories before the scroll offset to track cat_idx correctly + let mut cat_idx = CATEGORIES + .iter() + .take(scroll) + .filter(|e| matches!(e, Category(_))) + .count(); + let items: Vec = CATEGORIES .iter() - .enumerate() - .map(|(i, name)| { - let is_selected = i == app.ui.dict_category; - let style = if dimmed { - Style::new().fg(theme.dict.category_dimmed) - } else if is_selected && focused { - Style::new().fg(theme.dict.category_focused).add_modifier(Modifier::BOLD) - } else if is_selected { - Style::new().fg(theme.dict.category_selected) - } else { - Style::new().fg(theme.dict.category_normal) - }; - let prefix = if is_selected && !dimmed { "> " } else { " " }; - ListItem::new(format!("{prefix}{name}")).style(style) + .skip(scroll) + .take(visible_height) + .map(|entry| match entry { + Section(name) => { + let style = Style::new().fg(theme.ui.text_dim); + ListItem::new(format!("─ {name} ─")).style(style) + } + Category(name) => { + let is_selected = cat_idx == app.ui.dict_category; + let style = if dimmed { + Style::new().fg(theme.dict.category_dimmed) + } else if is_selected && focused { + Style::new() + .fg(theme.dict.category_focused) + .add_modifier(Modifier::BOLD) + } else if is_selected { + Style::new().fg(theme.dict.category_selected) + } else { + Style::new().fg(theme.dict.category_normal) + }; + let prefix = if is_selected && !dimmed { "> " } else { " " }; + cat_idx += 1; + ListItem::new(format!("{prefix}{name}")).style(style) + } }) .collect(); @@ -104,6 +165,17 @@ fn render_categories(frame: &mut Frame, app: &App, area: Rect, dimmed: bool) { frame.render_widget(list, area); } +fn get_category_name(index: usize) -> &'static str { + CATEGORIES + .iter() + .filter_map(|e| match e { + Category(name) => Some(*name), + Section(_) => None, + }) + .nth(index) + .unwrap_or("Unknown") +} + fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { let theme = theme::get(); let focused = app.ui.dict_focus == DictFocus::Words; @@ -119,7 +191,7 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { }) .collect() } else { - let category = CATEGORIES[app.ui.dict_category]; + let category = get_category_name(app.ui.dict_category); WORDS .iter() .filter(|w| w.category == category) @@ -195,18 +267,12 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { 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 = lines - .into_iter() - .skip(scroll) - .take(visible_height) - .collect(); + let scroll = app.ui.dict_scroll().min(max_scroll); let title = if is_searching { format!("Search: {} matches", words.len()) } else { - let category = CATEGORIES[app.ui.dict_category]; + let category = get_category_name(app.ui.dict_category); format!("{category} ({} words)", words.len()) }; let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; @@ -214,7 +280,9 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) { .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .title(title); - let para = Paragraph::new(visible).block(block); + let para = Paragraph::new(lines) + .scroll((scroll as u16, 0)) + .block(block); frame.render_widget(para, content_area); } @@ -232,5 +300,8 @@ fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { } pub fn category_count() -> usize { - CATEGORIES.len() + CATEGORIES + .iter() + .filter(|e| matches!(e, Category(_))) + .count() } diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 6d82aba..a4568c9 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -1,6 +1,6 @@ -use minimad::{Composite, CompositeStyle, Compound, Line, TableRow}; +use cagire_markdown::{CodeHighlighter, MarkdownTheme}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap}; use ratatui::Frame; @@ -11,6 +11,78 @@ use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; +struct AppTheme; + +impl MarkdownTheme for AppTheme { + fn h1(&self) -> Style { + Style::new() + .fg(theme::get().markdown.h1) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } + + fn h2(&self) -> Style { + Style::new() + .fg(theme::get().markdown.h2) + .add_modifier(Modifier::BOLD) + } + + fn h3(&self) -> Style { + Style::new() + .fg(theme::get().markdown.h3) + .add_modifier(Modifier::BOLD) + } + + fn text(&self) -> Style { + Style::new().fg(theme::get().markdown.text) + } + + fn code(&self) -> Style { + Style::new().fg(theme::get().markdown.code) + } + + fn code_border(&self) -> Style { + Style::new().fg(theme::get().markdown.code_border) + } + + fn link(&self) -> Style { + Style::new() + .fg(theme::get().markdown.link) + .add_modifier(Modifier::UNDERLINED) + } + + fn link_url(&self) -> Style { + Style::new().fg(theme::get().markdown.link_url) + } + + fn quote(&self) -> Style { + Style::new().fg(theme::get().markdown.quote) + } + + fn list(&self) -> Style { + Style::new().fg(theme::get().markdown.list) + } + + fn table_header_bg(&self) -> Color { + theme::get().ui.surface + } + + fn table_row_even(&self) -> Color { + theme::get().table.row_even + } + + fn table_row_odd(&self) -> Color { + theme::get().table.row_odd + } +} + +struct ForthHighlighter; + +impl CodeHighlighter for ForthHighlighter { + fn highlight(&self, line: &str) -> Vec<(Style, String)> { + highlight::highlight_line(line) + } +} + enum DocEntry { Section(&'static str), Topic(&'static str, &'static str), @@ -202,7 +274,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { let has_query = !query.is_empty(); let query_lower = query.to_lowercase(); - let lines = parse_markdown(md); + let lines = cagire_markdown::parse(md, &AppTheme, &ForthHighlighter); let has_search_bar = app.ui.help_search_active || has_query; let content_area = if has_search_bar { @@ -333,285 +405,3 @@ pub fn find_match(query: &str) -> Option<(usize, usize)> { } None } - -fn code_border_style() -> Style { - let theme = theme::get(); - Style::new().fg(theme.markdown.code_border) -} - -fn preprocess_markdown(md: &str) -> String { - let mut out = String::with_capacity(md.len()); - for line in md.lines() { - // Convert dash list markers to asterisks (minimad only recognizes *) - let line = convert_dash_lists(line); - 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 convert_dash_lists(line: &str) -> String { - let trimmed = line.trim_start(); - if let Some(rest) = trimmed.strip_prefix("- ") { - let indent = line.len() - trimmed.len(); - format!("{}* {}", " ".repeat(indent), rest) - } else { - line.to_string() - } -} - -fn parse_markdown(md: &str) -> Vec> { - let processed = preprocess_markdown(md); - let text = minimad::Text::from(processed.as_str()); - let mut lines = Vec::new(); - - let mut code_line_nr: usize = 0; - let mut table_buffer: Vec = Vec::new(); - - let flush_table = |buf: &mut Vec, out: &mut Vec>| { - if buf.is_empty() { - return; - } - let col_widths = compute_column_widths(buf); - for (row_idx, row) in buf.drain(..).enumerate() { - out.push(render_table_row(row, row_idx, &col_widths)); - } - }; - - for line in text.lines { - match line { - Line::Normal(composite) if composite.style == CompositeStyle::Code => { - flush_table(&mut table_buffer, &mut lines); - code_line_nr += 1; - let raw: String = composite - .compounds - .iter() - .map(|c: &minimad::Compound| c.src) - .collect(); - let mut spans = vec![ - Span::styled(format!(" {code_line_nr:>2} "), code_border_style()), - Span::styled("│ ", code_border_style()), - ]; - spans.extend( - highlight::highlight_line(&raw) - .into_iter() - .map(|(style, text)| Span::styled(text, style)), - ); - lines.push(RLine::from(spans)); - } - Line::Normal(composite) => { - flush_table(&mut table_buffer, &mut lines); - code_line_nr = 0; - lines.push(composite_to_line(composite)); - } - Line::TableRow(row) => { - code_line_nr = 0; - table_buffer.push(row); - } - Line::TableRule(_) => { - // Skip the separator line (---|---|---) - } - _ => { - flush_table(&mut table_buffer, &mut lines); - code_line_nr = 0; - lines.push(RLine::from("")); - } - } - } - flush_table(&mut table_buffer, &mut lines); - - lines -} - -fn cell_text_width(cell: &Composite) -> usize { - cell.compounds.iter().map(|c| c.src.chars().count()).sum() -} - -fn compute_column_widths(rows: &[TableRow]) -> Vec { - let mut widths: Vec = Vec::new(); - for row in rows { - for (i, cell) in row.cells.iter().enumerate() { - let w = cell_text_width(cell); - if i >= widths.len() { - widths.push(w); - } else if w > widths[i] { - widths[i] = w; - } - } - } - widths -} - -fn render_table_row(row: TableRow, row_idx: usize, col_widths: &[usize]) -> RLine<'static> { - let theme = theme::get(); - let is_header = row_idx == 0; - let bg = if is_header { - theme.ui.surface - } else if row_idx.is_multiple_of(2) { - theme.table.row_even - } else { - theme.table.row_odd - }; - - let base_style = if is_header { - Style::new() - .fg(theme.markdown.text) - .bg(bg) - .add_modifier(Modifier::BOLD) - } else { - Style::new().fg(theme.markdown.text).bg(bg) - }; - - let sep_style = Style::new().fg(theme.markdown.code_border).bg(bg); - let mut spans: Vec> = Vec::new(); - - for (i, cell) in row.cells.into_iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" │ ", sep_style)); - } - let target_width = col_widths.get(i).copied().unwrap_or(0); - let cell_width = cell - .compounds - .iter() - .map(|c| c.src.chars().count()) - .sum::(); - - for compound in cell.compounds { - compound_to_spans(compound, base_style, &mut spans); - } - - let padding = target_width.saturating_sub(cell_width); - if padding > 0 { - spans.push(Span::styled(" ".repeat(padding), base_style)); - } - } - - RLine::from(spans) -} - -fn composite_to_line(composite: Composite) -> RLine<'static> { - let theme = theme::get(); - let base_style = match composite.style { - CompositeStyle::Header(1) => Style::new() - .fg(theme.markdown.h1) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - CompositeStyle::Header(2) => Style::new() - .fg(theme.markdown.h2) - .add_modifier(Modifier::BOLD), - CompositeStyle::Header(_) => Style::new() - .fg(theme.markdown.h3) - .add_modifier(Modifier::BOLD), - CompositeStyle::ListItem(_) => Style::new().fg(theme.markdown.list), - CompositeStyle::Quote => Style::new().fg(theme.markdown.quote), - CompositeStyle::Code => Style::new().fg(theme.markdown.code), - CompositeStyle::Paragraph => Style::new().fg(theme.markdown.text), - }; - - let prefix: String = match composite.style { - CompositeStyle::ListItem(depth) => { - let indent = " ".repeat(depth as usize); - format!("{indent}• ") - } - CompositeStyle::Quote => " │ ".to_string(), - _ => String::new(), - }; - - let mut spans: Vec> = Vec::new(); - if !prefix.is_empty() { - spans.push(Span::styled(prefix, base_style)); - } - - for compound in composite.compounds { - compound_to_spans(compound, base_style, &mut spans); - } - - RLine::from(spans) -} - -fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec>) { - let theme = theme::get(); - 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(theme.markdown.code); - } - if compound.strikeout { - style = style.add_modifier(Modifier::CROSSED_OUT); - } - - let src = compound.src.to_string(); - let link_style = Style::new() - .fg(theme.markdown.link) - .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(theme.markdown.link_url), - )); - } - 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)); - } -}