308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
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<ListItem> = 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<RLine> = 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()
|
|
}
|