use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; use ratatui::Frame; use crate::app::App; use crate::model::{Word, WORDS}; use crate::state::DictFocus; use crate::theme; enum CatEntry { Section(&'static str), Category(&'static str), } use CatEntry::{Category, Section}; const CATEGORIES: &[CatEntry] = &[ // Forth core Section("Forth"), Category("Stack"), Category("Arithmetic"), Category("Comparison"), Category("Logic"), Category("Control"), Category("Variables"), Category("Probability"), Category("Definitions"), // Live coding Section("Live Coding"), Category("Sound"), Category("Time"), Category("Context"), Category("Music"), Category("LFO"), // Synthesis Section("Synthesis"), Category("Oscillator"), Category("Wavetable"), Category("Generator"), Category("Envelope"), Category("Sample"), // Effects 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) { let [header_area, body_area] = Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(area); render_header(frame, header_area); let [cat_area, words_area] = Layout::horizontal([Constraint::Length(16), Constraint::Fill(1)]).areas(body_area); let is_searching = !app.ui.dict_search_query.is_empty(); render_categories(frame, app, cat_area, is_searching); render_words(frame, app, words_area, is_searching); } fn render_header(frame: &mut Frame, area: Rect) { use ratatui::widgets::Wrap; let theme = theme::get(); let desc = "Forth uses a stack: values are pushed, functions (called words) consume and \ produce values. Read left to right: 3 4 + -> push 3, push 4, + pops both, \ pushes 7. This page lists all words with their signature ( inputs -- outputs )."; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(theme.dict.border_normal)) .title("Dictionary"); let para = Paragraph::new(desc) .style(Style::new().fg(theme.dict.header_desc)) .wrap(Wrap { trim: false }) .block(block); frame.render_widget(para, area); } 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() .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(); let border_color = if focused { theme.dict.border_focused } else { theme.dict.border_normal }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .title("Categories"); let list = List::new(items).block(block); frame.render_widget(list, area); } fn 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; // Filter words by search query or category let words: Vec<&Word> = if is_searching { let query = app.ui.dict_search_query.to_lowercase(); WORDS .iter() .filter(|w| { w.name.to_lowercase().contains(&query) || w.aliases.iter().any(|a| a.to_lowercase().contains(&query)) }) .collect() } else { let category = get_category_name(app.ui.dict_category); WORDS .iter() .filter(|w| w.category == category) .collect() }; // Split area for search bar when search is active or has query let show_search = app.ui.dict_search_active || is_searching; let (search_area, content_area) = if show_search { let [s, c] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); (Some(s), c) } else { (None, area) }; // Render search bar if let Some(sa) = search_area { render_search_bar(frame, app, sa); } let content_width = content_area.width.saturating_sub(2) as usize; let mut lines: Vec = Vec::new(); for word in &words { let name_bg = theme.dict.word_bg; let name_style = Style::new() .fg(theme.dict.word_name) .bg(name_bg) .add_modifier(Modifier::BOLD); let alias_style = Style::new().fg(theme.dict.alias).bg(name_bg); let name_text = if word.aliases.is_empty() { format!(" {}", word.name) } else { format!(" {} ({})", word.name, word.aliases.join(", ")) }; let padding = " ".repeat(content_width.saturating_sub(name_text.chars().count())); if word.aliases.is_empty() { lines.push(RLine::from(Span::styled( format!("{name_text}{padding}"), name_style, ))); } else { lines.push(RLine::from(vec![ Span::styled(format!(" {}", word.name), name_style), Span::styled(format!(" ({})", word.aliases.join(", ")), alias_style), Span::styled(padding, name_style), ])); } let stack_style = Style::new().fg(theme.dict.stack_sig); lines.push(RLine::from(vec![ Span::raw(" "), Span::styled(word.stack.to_string(), stack_style), ])); let desc_style = Style::new().fg(theme.dict.description); lines.push(RLine::from(vec![ Span::raw(" "), Span::styled(word.desc.to_string(), desc_style), ])); let example_style = Style::new().fg(theme.dict.example); lines.push(RLine::from(vec![ Span::raw(" "), Span::styled(format!("e.g. {}", word.example), example_style), ])); lines.push(RLine::from("")); } let visible_height = content_area.height.saturating_sub(2) as usize; let total_lines = lines.len(); let max_scroll = total_lines.saturating_sub(visible_height); let scroll = app.ui.dict_scroll().min(max_scroll); let title = if is_searching { format!("Search: {} matches", words.len()) } else { 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 }; let block = Block::default() .borders(Borders::ALL) .border_style(Style::new().fg(border_color)) .title(title); let para = Paragraph::new(lines) .scroll((scroll as u16, 0)) .block(block); frame.render_widget(para, content_area); } fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let style = if app.ui.dict_search_active { Style::new().fg(theme.search.active) } else { Style::new().fg(theme.search.inactive) }; let cursor = if app.ui.dict_search_active { "_" } else { "" }; let text = format!(" /{}{}", app.ui.dict_search_query, cursor); let line = RLine::from(Span::styled(text, style)); frame.render_widget(Paragraph::new(vec![line]), area); } pub fn category_count() -> usize { CATEGORIES .iter() .filter(|e| matches!(e, Category(_))) .count() }