WIP: menu

This commit is contained in:
2026-01-25 21:37:53 +01:00
parent 250e359fc5
commit 6efcabd32d
14 changed files with 635 additions and 335 deletions

186
src/views/dict_view.rs Normal file
View File

@@ -0,0 +1,186 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, 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, WordCompile, WORDS};
use crate::state::DictFocus;
const CATEGORIES: &[&str] = &[
"Stack",
"Arithmetic",
"Comparison",
"Logic",
"Sound",
"Variables",
"Randomness",
"Probability",
"Context",
"Music",
"Time",
"Parameters",
];
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);
render_categories(frame, app, cat_area);
render_words(frame, app, words_area);
}
fn render_header(frame: &mut Frame, area: Rect) {
use ratatui::widgets::Wrap;
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(Color::Rgb(60, 60, 70)))
.title("Dictionary");
let para = Paragraph::new(desc)
.style(Style::new().fg(Color::Rgb(140, 145, 155)))
.wrap(Wrap { trim: false })
.block(block);
frame.render_widget(para, area);
}
fn render_categories(frame: &mut Frame, app: &App, area: Rect) {
let focused = app.ui.dict_focus == DictFocus::Categories;
let items: Vec<ListItem> = CATEGORIES
.iter()
.enumerate()
.map(|(i, name)| {
let is_selected = i == app.ui.dict_category;
let style = if is_selected && focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if is_selected {
Style::new().fg(Color::Cyan)
} else {
Style::new().fg(Color::White)
};
let prefix = if is_selected { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
let border_color = if focused { Color::Yellow } else { Color::Rgb(60, 60, 70) };
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 render_words(frame: &mut Frame, app: &App, area: Rect) {
let focused = app.ui.dict_focus == DictFocus::Words;
let category = CATEGORIES[app.ui.dict_category];
let words: Vec<&Word> = WORDS
.iter()
.filter(|w| word_category(w.name, &w.compile) == category)
.collect();
let content_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<RLine> = Vec::new();
for word in &words {
let name_bg = Color::Rgb(40, 50, 60);
let name_style = Style::new()
.fg(Color::Green)
.bg(name_bg)
.add_modifier(Modifier::BOLD);
let name_line = format!(" {}", word.name);
let padding = " ".repeat(content_width.saturating_sub(name_line.chars().count()));
lines.push(RLine::from(Span::styled(
format!("{name_line}{padding}"),
name_style,
)));
let stack_style = Style::new().fg(Color::Magenta);
lines.push(RLine::from(vec![
Span::raw(" "),
Span::styled(word.stack.to_string(), stack_style),
]));
let desc_style = Style::new().fg(Color::White);
lines.push(RLine::from(vec![
Span::raw(" "),
Span::styled(word.desc.to_string(), desc_style),
]));
let example_style = Style::new().fg(Color::Rgb(120, 130, 140));
lines.push(RLine::from(vec![
Span::raw(" "),
Span::styled(format!("e.g. {}", word.example), example_style),
]));
lines.push(RLine::from(""));
}
let visible_height = 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 title = format!("{category} ({} words)", words.len());
let border_color = if focused { Color::Yellow } else { Color::Rgb(60, 60, 70) };
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(border_color))
.title(title);
let para = Paragraph::new(visible).block(block);
frame.render_widget(para, area);
}
fn word_category(name: &str, compile: &WordCompile) -> &'static str {
const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
const ARITH: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max",
];
const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="];
const LOGIC: &[&str] = &["and", "or", "not"];
const SOUND: &[&str] = &["sound", "s", "emit"];
const VAR: &[&str] = &["get", "set"];
const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"];
const MUSIC: &[&str] = &["mtof", "ftom"];
const TIME: &[&str] = &[
"at", "window", "pop", "div", "each", "tempo!", "[", "]", "?",
];
match compile {
WordCompile::Simple if STACK.contains(&name) => "Stack",
WordCompile::Simple if ARITH.contains(&name) => "Arithmetic",
WordCompile::Simple if CMP.contains(&name) => "Comparison",
WordCompile::Simple if LOGIC.contains(&name) => "Logic",
WordCompile::Simple if SOUND.contains(&name) => "Sound",
WordCompile::Alias(_) => "Sound",
WordCompile::Simple if VAR.contains(&name) => "Variables",
WordCompile::Simple if RAND.contains(&name) => "Randomness",
WordCompile::Probability(_) => "Probability",
WordCompile::Context(_) => "Context",
WordCompile::Simple if MUSIC.contains(&name) => "Music",
WordCompile::Simple if TIME.contains(&name) => "Time",
WordCompile::Param => "Parameters",
_ => "Other",
}
}
pub fn category_count() -> usize {
CATEGORIES.len()
}

View File

@@ -1,266 +0,0 @@
use minimad::{Composite, CompositeStyle, Compound, Line};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, 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, WordCompile, WORDS};
const STATIC_DOCS: &[(&str, &str)] = &[
("Keybindings", include_str!("../../docs/keybindings.md")),
("Sequencer", include_str!("../../docs/sequencer.md")),
];
const TOPICS: &[&str] = &["Keybindings", "Forth Reference", "Sequencer"];
const CATEGORIES: &[&str] = &[
"Stack",
"Arithmetic",
"Comparison",
"Logic",
"Sound",
"Variables",
"Randomness",
"Probability",
"Context",
"Music",
"Time",
"Parameters",
];
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [topics_area, content_area] =
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
render_topics(frame, app, topics_area);
let topic = TOPICS[app.ui.doc_topic];
if topic == "Forth Reference" {
render_forth_reference(frame, app, content_area);
} else {
render_markdown_content(frame, app, content_area, topic);
}
}
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = TOPICS
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.ui.doc_topic {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
let prefix = if i == app.ui.doc_topic { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics"));
frame.render_widget(list, area);
}
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
let md = STATIC_DOCS
.iter()
.find(|(name, _)| *name == topic)
.map(|(_, content)| *content)
.unwrap_or("");
let lines = parse_markdown(md);
let visible_height = 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.doc_scroll.min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.collect();
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
frame.render_widget(para, area);
}
fn render_forth_reference(frame: &mut Frame, app: &App, area: Rect) {
let [cat_area, words_area] =
Layout::horizontal([Constraint::Length(14), Constraint::Fill(1)]).areas(area);
render_categories(frame, app, cat_area);
render_words(frame, app, words_area);
}
fn render_categories(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = CATEGORIES
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.ui.doc_category {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
let prefix = if i == app.ui.doc_category { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Category"));
frame.render_widget(list, area);
}
fn render_words(frame: &mut Frame, app: &App, area: Rect) {
let category = CATEGORIES[app.ui.doc_category];
let words: Vec<&Word> = WORDS
.iter()
.filter(|w| word_category(w.name, &w.compile) == category)
.collect();
let word_style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD);
let stack_style = Style::new().fg(Color::Magenta);
let desc_style = Style::new().fg(Color::White);
let example_style = Style::new().fg(Color::Rgb(150, 150, 150));
let mut lines: Vec<RLine> = Vec::new();
for word in &words {
lines.push(RLine::from(vec![
Span::styled(format!("{:<14}", word.name), word_style),
Span::styled(format!("{:<18}", word.stack), stack_style),
Span::styled(word.desc.to_string(), desc_style),
]));
lines.push(RLine::from(vec![
Span::raw(" "),
Span::styled(format!("e.g. {}", word.example), example_style),
]));
lines.push(RLine::from(""));
}
let visible_height = 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.doc_scroll.min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.collect();
let title = format!("{category} ({} words)", words.len());
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(para, area);
}
fn word_category(name: &str, compile: &WordCompile) -> &'static str {
const STACK: &[&str] = &["dup", "drop", "swap", "over", "rot", "nip", "tuck"];
const ARITH: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "floor", "ceil", "round", "min", "max",
];
const CMP: &[&str] = &["=", "<>", "<", ">", "<=", ">="];
const LOGIC: &[&str] = &["and", "or", "not"];
const SOUND: &[&str] = &["sound", "s", "emit"];
const VAR: &[&str] = &["get", "set"];
const RAND: &[&str] = &["rand", "rrand", "seed", "coin", "chance", "choose", "cycle"];
const MUSIC: &[&str] = &["mtof", "ftom"];
const TIME: &[&str] = &[
"at", "window", "pop", "div", "each", "tempo!", "[", "]", "?",
];
match compile {
WordCompile::Simple if STACK.contains(&name) => "Stack",
WordCompile::Simple if ARITH.contains(&name) => "Arithmetic",
WordCompile::Simple if CMP.contains(&name) => "Comparison",
WordCompile::Simple if LOGIC.contains(&name) => "Logic",
WordCompile::Simple if SOUND.contains(&name) => "Sound",
WordCompile::Alias(_) => "Sound",
WordCompile::Simple if VAR.contains(&name) => "Variables",
WordCompile::Simple if RAND.contains(&name) => "Randomness",
WordCompile::Probability(_) => "Probability",
WordCompile::Context(_) => "Context",
WordCompile::Simple if MUSIC.contains(&name) => "Music",
WordCompile::Simple if TIME.contains(&name) => "Time",
WordCompile::Param => "Parameters",
_ => "Other",
}
}
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
let text = minimad::Text::from(md);
let mut lines = Vec::new();
for line in text.lines {
match line {
Line::Normal(composite) => {
lines.push(composite_to_line(composite));
}
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
lines.push(RLine::from(""));
}
}
}
lines
}
fn composite_to_line(composite: Composite) -> RLine<'static> {
let base_style = match composite.style {
CompositeStyle::Header(1) => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
CompositeStyle::ListItem(_) => Style::new().fg(Color::White),
CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)),
CompositeStyle::Code => Style::new().fg(Color::Green),
CompositeStyle::Paragraph => Style::new().fg(Color::White),
};
let prefix = match composite.style {
CompositeStyle::ListItem(_) => "",
CompositeStyle::Quote => "",
_ => "",
};
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_string(), base_style));
}
for compound in composite.compounds {
spans.push(compound_to_span(compound, base_style));
}
RLine::from(spans)
}
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
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(Color::Green);
}
if compound.strikeout {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
Span::styled(compound.src.to_string(), style)
}
pub fn topic_count() -> usize {
TOPICS.len()
}
pub fn category_count() -> usize {
CATEGORIES.len()
}

139
src/views/help_view.rs Normal file
View File

@@ -0,0 +1,139 @@
use minimad::{Composite, CompositeStyle, Compound, Line};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::App;
const STATIC_DOCS: &[(&str, &str)] = &[
("Keybindings", include_str!("../../docs/keybindings.md")),
("Sequencer", include_str!("../../docs/sequencer.md")),
];
const TOPICS: &[&str] = &["Keybindings", "Sequencer"];
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [topics_area, content_area] =
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
render_topics(frame, app, topics_area);
let topic = TOPICS[app.ui.help_topic];
render_markdown_content(frame, app, content_area, topic);
}
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = TOPICS
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.ui.help_topic {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
let prefix = if i == app.ui.help_topic { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics"));
frame.render_widget(list, area);
}
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
let md = STATIC_DOCS
.iter()
.find(|(name, _)| *name == topic)
.map(|(_, content)| *content)
.unwrap_or("");
let lines = parse_markdown(md);
let visible_height = 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.help_scroll.min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.collect();
let para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
frame.render_widget(para, area);
}
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
let text = minimad::Text::from(md);
let mut lines = Vec::new();
for line in text.lines {
match line {
Line::Normal(composite) => {
lines.push(composite_to_line(composite));
}
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
lines.push(RLine::from(""));
}
}
}
lines
}
fn composite_to_line(composite: Composite) -> RLine<'static> {
let base_style = match composite.style {
CompositeStyle::Header(1) => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
CompositeStyle::ListItem(_) => Style::new().fg(Color::White),
CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)),
CompositeStyle::Code => Style::new().fg(Color::Green),
CompositeStyle::Paragraph => Style::new().fg(Color::White),
};
let prefix = match composite.style {
CompositeStyle::ListItem(_) => "",
CompositeStyle::Quote => "",
_ => "",
};
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_string(), base_style));
}
for compound in composite.compounds {
spans.push(compound_to_span(compound, base_style));
}
RLine::from(spans)
}
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
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(Color::Green);
}
if compound.strikeout {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
Span::styled(compound.src.to_string(), style)
}
pub fn topic_count() -> usize {
TOPICS.len()
}

View File

@@ -1,5 +1,6 @@
pub mod audio_view;
pub mod doc_view;
pub mod dict_view;
pub mod help_view;
pub mod highlight;
pub mod main_view;
pub mod patterns_view;

View File

@@ -1,3 +1,5 @@
use std::time::Instant;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
@@ -10,9 +12,9 @@ use crate::model::SourceSpan;
use crate::page::Page;
use crate::state::{Modal, PanelFocus, PatternField, SidePanel};
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
use crate::widgets::{ConfirmModal, ModalFrame, SampleBrowser, TextInputModal};
use crate::widgets::{ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal};
use super::{audio_view, doc_view, main_view, patterns_view, title_view};
use super::{audio_view, dict_view, help_view, main_view, patterns_view, title_view};
fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usize) -> Vec<SourceSpan> {
spans.iter().filter_map(|s| {
@@ -81,7 +83,8 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
Page::Main => main_view::render(frame, app, snapshot, page_area),
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
Page::Audio => audio_view::render(frame, app, link, page_area),
Page::Doc => doc_view::render(frame, app, page_area),
Page::Help => help_view::render(frame, app, page_area),
Page::Dict => dict_view::render(frame, app, page_area),
}
if let Some(side_area) = panel_area {
@@ -90,6 +93,24 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
render_footer(frame, app, footer_area);
render_modal(frame, app, snapshot, term);
let show_minimap = app
.ui
.minimap_until
.map(|until| Instant::now() < until)
.unwrap_or(false);
if show_minimap {
let tiles: Vec<NavTile> = Page::ALL
.iter()
.map(|p| {
let (col, row) = p.grid_pos();
NavTile { col, row, name: p.name() }
})
.collect();
let selected = app.page.grid_pos();
NavMinimap::new(&tiles, selected).render_centered(frame, term);
}
}
fn render_side_panel(frame: &mut Frame, app: &App, area: Rect) {
@@ -241,7 +262,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
Page::Main => "[MAIN]",
Page::Patterns => "[PATTERNS]",
Page::Audio => "[AUDIO]",
Page::Doc => "[DOC]",
Page::Help => "[HELP]",
Page::Dict => "[DICT]",
};
let content = if let Some(ref msg) = app.ui.status_message {
@@ -281,12 +303,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("t", "Test"),
("Space", "Play"),
],
Page::Doc => vec![
Page::Help => vec![
("↑↓", "Scroll"),
("←→", "Category"),
("Tab", "Topic"),
("PgUp/Dn", "Page"),
],
Page::Dict => vec![
("Tab", "Focus"),
("↑↓", "Navigate"),
("PgUp/Dn", "Page"),
],
};
let page_width = page_indicator.chars().count();