Feat: continue refactoring
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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",
|
||||
|
||||
8
crates/markdown/Cargo.toml
Normal file
8
crates/markdown/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "cagire-markdown"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
minimad = "0.13"
|
||||
ratatui = "0.30"
|
||||
13
crates/markdown/src/highlighter.rs
Normal file
13
crates/markdown/src/highlighter.rs
Normal file
@@ -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())]
|
||||
}
|
||||
}
|
||||
7
crates/markdown/src/lib.rs
Normal file
7
crates/markdown/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod highlighter;
|
||||
mod parser;
|
||||
mod theme;
|
||||
|
||||
pub use highlighter::{CodeHighlighter, NoHighlight};
|
||||
pub use parser::parse;
|
||||
pub use theme::{DefaultTheme, MarkdownTheme};
|
||||
327
crates/markdown/src/parser.rs
Normal file
327
crates/markdown/src/parser.rs
Normal file
@@ -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<T: MarkdownTheme, H: CodeHighlighter>(
|
||||
md: &str,
|
||||
theme: &T,
|
||||
highlighter: &H,
|
||||
) -> Vec<RLine<'static>> {
|
||||
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<TableRow> = Vec::new();
|
||||
|
||||
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>, 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<usize> {
|
||||
let mut widths: Vec<usize> = 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<T: MarkdownTheme>(
|
||||
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<Span<'static>> = 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::<usize>();
|
||||
|
||||
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<T: MarkdownTheme>(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<Span<'static>> = 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<T: MarkdownTheme>(
|
||||
compound: Compound,
|
||||
base: Style,
|
||||
out: &mut Vec<Span<'static>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
77
crates/markdown/src/theme.rs
Normal file
77
crates/markdown/src/theme.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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...
|
||||
|
||||
|
||||
65
src/app.rs
65
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,15 +44,10 @@ impl PatternChange {
|
||||
}
|
||||
|
||||
pub enum AudioCommand {
|
||||
Evaluate {
|
||||
cmd: String,
|
||||
time: Option<f64>,
|
||||
},
|
||||
Evaluate { cmd: String, time: Option<f64> },
|
||||
Hush,
|
||||
Panic,
|
||||
LoadSamples(Vec<doux::sampling::SampleEntry>),
|
||||
#[allow(dead_code)]
|
||||
ResetEngine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<usize>,
|
||||
pub copied_steps: Option<CopiedSteps>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<usize>,
|
||||
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));
|
||||
|
||||
@@ -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<ListItem> = CATEGORIES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let is_selected = i == app.ui.dict_category;
|
||||
.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)
|
||||
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<RLine> = 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()
|
||||
}
|
||||
|
||||
@@ -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<RLine<'static>> {
|
||||
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<TableRow> = Vec::new();
|
||||
|
||||
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>| {
|
||||
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<usize> {
|
||||
let mut widths: Vec<usize> = 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<Span<'static>> = 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::<usize>();
|
||||
|
||||
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<Span<'static>> = 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<Span<'static>>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user