Feat: continue refactoring
This commit is contained in:
@@ -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;
|
||||
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<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