From 73470ded79dad1913b864150b08ead8e2afcb1f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sun, 25 Jan 2026 21:37:53 +0100 Subject: [PATCH] WIP: menu --- crates/ratatui/src/lib.rs | 2 + crates/ratatui/src/nav_minimap.rs | 86 ++++++++++ src/app.rs | 59 ++++--- src/commands.rs | 19 ++- src/input.rs | 63 +++++-- src/page.rs | 78 +++++++-- src/state/mod.rs | 2 +- src/state/ui.rs | 25 ++- src/views/dict_view.rs | 186 +++++++++++++++++++++ src/views/doc_view.rs | 266 ------------------------------ src/views/help_view.rs | 139 ++++++++++++++++ src/views/mod.rs | 3 +- src/views/render.rs | 38 ++++- src/widgets/mod.rs | 4 +- 14 files changed, 635 insertions(+), 335 deletions(-) create mode 100644 crates/ratatui/src/nav_minimap.rs create mode 100644 src/views/dict_view.rs delete mode 100644 src/views/doc_view.rs create mode 100644 src/views/help_view.rs diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 9cd727d..80f782c 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -3,6 +3,7 @@ mod editor; mod file_browser; mod list_select; mod modal; +mod nav_minimap; mod sample_browser; mod scope; mod spectrum; @@ -14,6 +15,7 @@ pub use editor::{CompletionCandidate, Editor}; pub use file_browser::FileBrowserModal; pub use list_select::ListSelect; pub use modal::ModalFrame; +pub use nav_minimap::{NavMinimap, NavTile}; pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind}; pub use scope::{Orientation, Scope}; pub use spectrum::Spectrum; diff --git a/crates/ratatui/src/nav_minimap.rs b/crates/ratatui/src/nav_minimap.rs new file mode 100644 index 0000000..76a6dcb --- /dev/null +++ b/crates/ratatui/src/nav_minimap.rs @@ -0,0 +1,86 @@ +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::widgets::{Clear, Paragraph}; +use ratatui::Frame; + +/// A tile in the navigation grid +pub struct NavTile { + pub col: i8, + pub row: i8, + pub name: &'static str, +} + +/// Navigation minimap widget that renders a grid of page tiles +pub struct NavMinimap<'a> { + tiles: &'a [NavTile], + selected: (i8, i8), +} + +impl<'a> NavMinimap<'a> { + pub fn new(tiles: &'a [NavTile], selected: (i8, i8)) -> Self { + Self { tiles, selected } + } + + pub fn render_centered(self, frame: &mut Frame, term: Rect) { + if self.tiles.is_empty() { + return; + } + + // Compute grid bounds from tiles + let max_col = self.tiles.iter().map(|t| t.col).max().unwrap_or(0); + let max_row = self.tiles.iter().map(|t| t.row).max().unwrap_or(0); + let cols = (max_col + 1) as u16; + let rows = (max_row + 1) as u16; + + let tile_w: u16 = 12; + let tile_h: u16 = 3; + let gap: u16 = 1; + let pad: u16 = 2; + + let content_w = tile_w * cols + gap * (cols.saturating_sub(1)); + let content_h = tile_h * rows + gap * (rows.saturating_sub(1)); + let modal_w = content_w + pad * 2; + let modal_h = content_h + pad * 2; + + let x = term.x + (term.width.saturating_sub(modal_w)) / 2; + let y = term.y + (term.height.saturating_sub(modal_h)) / 2; + let area = Rect::new(x, y, modal_w, modal_h); + + frame.render_widget(Clear, area); + + let inner_x = area.x + pad; + let inner_y = area.y + pad; + + for tile in self.tiles { + let tile_x = inner_x + (tile.col as u16) * (tile_w + gap); + let tile_y = inner_y + (tile.row as u16) * (tile_h + gap); + let tile_area = Rect::new(tile_x, tile_y, tile_w, tile_h); + let is_selected = (tile.col, tile.row) == self.selected; + self.render_tile(frame, tile_area, tile.name, is_selected); + } + } + + fn render_tile(&self, frame: &mut Frame, area: Rect, label: &str, is_selected: bool) { + let (bg, fg) = if is_selected { + (Color::Rgb(50, 90, 110), Color::White) + } else { + (Color::Rgb(30, 35, 45), Color::Rgb(100, 105, 115)) + }; + + // Fill background + for row in 0..area.height { + let line_area = Rect::new(area.x, area.y + row, area.width, 1); + let fill = " ".repeat(area.width as usize); + frame.render_widget(Paragraph::new(fill).style(Style::new().bg(bg)), line_area); + } + + // Center text vertically + let text_y = area.y + area.height / 2; + let text_area = Rect::new(area.x, text_y, area.width, 1); + let paragraph = Paragraph::new(label) + .style(Style::new().bg(bg).fg(fg)) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, text_area); + } +} diff --git a/src/app.rs b/src/app.rs index dc82615..d6724f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,7 +19,7 @@ use crate::state::{ AudioSettings, EditorContext, Focus, LiveKeyState, Metrics, Modal, PanelState, PatternField, PatternsNav, PlaybackState, ProjectState, UiState, }; -use crate::views::doc_view; +use crate::views::{dict_view, help_view}; const STEPS_PER_PAGE: usize = 32; @@ -865,33 +865,46 @@ impl App { AppCommand::PageUp => self.page.up(), AppCommand::PageDown => self.page.down(), - // Doc navigation - AppCommand::DocNextTopic => { - self.ui.doc_topic = (self.ui.doc_topic + 1) % doc_view::topic_count(); - self.ui.doc_scroll = 0; - self.ui.doc_category = 0; + // Help navigation + AppCommand::HelpNextTopic => { + self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count(); + self.ui.help_scroll = 0; } - AppCommand::DocPrevTopic => { - let count = doc_view::topic_count(); - self.ui.doc_topic = (self.ui.doc_topic + count - 1) % count; - self.ui.doc_scroll = 0; - self.ui.doc_category = 0; + AppCommand::HelpPrevTopic => { + let count = help_view::topic_count(); + self.ui.help_topic = (self.ui.help_topic + count - 1) % count; + self.ui.help_scroll = 0; } - AppCommand::DocScrollDown(n) => { - self.ui.doc_scroll = self.ui.doc_scroll.saturating_add(n); + AppCommand::HelpScrollDown(n) => { + self.ui.help_scroll = self.ui.help_scroll.saturating_add(n); } - AppCommand::DocScrollUp(n) => { - self.ui.doc_scroll = self.ui.doc_scroll.saturating_sub(n); + AppCommand::HelpScrollUp(n) => { + self.ui.help_scroll = self.ui.help_scroll.saturating_sub(n); } - AppCommand::DocNextCategory => { - let count = doc_view::category_count(); - self.ui.doc_category = (self.ui.doc_category + 1) % count; - self.ui.doc_scroll = 0; + + // Dictionary navigation + AppCommand::DictToggleFocus => { + use crate::state::DictFocus; + self.ui.dict_focus = match self.ui.dict_focus { + DictFocus::Categories => DictFocus::Words, + DictFocus::Words => DictFocus::Categories, + }; } - AppCommand::DocPrevCategory => { - let count = doc_view::category_count(); - self.ui.doc_category = (self.ui.doc_category + count - 1) % count; - self.ui.doc_scroll = 0; + 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); + } + AppCommand::DictScrollUp(n) => { + self.ui.dict_scroll = self.ui.dict_scroll.saturating_sub(n); } // Patterns view diff --git a/src/commands.rs b/src/commands.rs index f18df50..49e8269 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -114,13 +114,18 @@ pub enum AppCommand { PageUp, PageDown, - // Doc navigation - DocNextTopic, - DocPrevTopic, - DocScrollDown(usize), - DocScrollUp(usize), - DocNextCategory, - DocPrevCategory, + // Help navigation + HelpNextTopic, + HelpPrevTopic, + HelpScrollDown(usize), + HelpScrollUp(usize), + + // Dictionary navigation + DictToggleFocus, + DictNextCategory, + DictPrevCategory, + DictScrollDown(usize), + DictScrollUp(usize), // Patterns view PatternsCursorLeft, diff --git a/src/input.rs b/src/input.rs index 99e3f06..56b728d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -2,6 +2,7 @@ use crossbeam_channel::Sender; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use crate::app::App; use crate::commands::AppCommand; @@ -38,6 +39,15 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { return InputResult::Continue; } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let is_arrow = matches!( + key.code, + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down + ); + if ctx.app.ui.minimap_until.is_some() && !(ctrl && is_arrow) { + ctx.app.ui.minimap_until = None; + } + if ctx.app.ui.show_title { ctx.app.ui.show_title = false; return InputResult::Continue; @@ -414,20 +424,25 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } if ctrl { + let minimap_timeout = Some(Instant::now() + Duration::from_millis(250)); match key.code { KeyCode::Left => { + ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageLeft); return InputResult::Continue; } KeyCode::Right => { + ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageRight); return InputResult::Continue; } KeyCode::Up => { + ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageUp); return InputResult::Continue; } KeyCode::Down => { + ctx.app.ui.minimap_until = minimap_timeout; ctx.dispatch(AppCommand::PageDown); return InputResult::Continue; } @@ -439,7 +454,8 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { Page::Main => handle_main_page(ctx, key, ctrl), Page::Patterns => handle_patterns_page(ctx, key), Page::Audio => handle_audio_page(ctx, key), - Page::Doc => handle_doc_page(ctx, key), + Page::Help => handle_help_page(ctx, key), + Page::Dict => handle_dict_page(ctx, key), } } @@ -865,16 +881,43 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { InputResult::Continue } -fn handle_doc_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { +fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { - KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::DocScrollDown(1)), - KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::DocScrollUp(1)), - KeyCode::Char('h') | KeyCode::Left => ctx.dispatch(AppCommand::DocPrevCategory), - KeyCode::Char('l') | KeyCode::Right => ctx.dispatch(AppCommand::DocNextCategory), - KeyCode::Tab => ctx.dispatch(AppCommand::DocNextTopic), - KeyCode::BackTab => ctx.dispatch(AppCommand::DocPrevTopic), - KeyCode::PageDown => ctx.dispatch(AppCommand::DocScrollDown(10)), - KeyCode::PageUp => ctx.dispatch(AppCommand::DocScrollUp(10)), + KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)), + KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)), + KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic), + KeyCode::BackTab => ctx.dispatch(AppCommand::HelpPrevTopic), + KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)), + KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)), + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + _ => {} + } + InputResult::Continue +} + +fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + use crate::state::DictFocus; + + match key.code { + KeyCode::Tab => ctx.dispatch(AppCommand::DictToggleFocus), + KeyCode::Char('j') | KeyCode::Down => { + match ctx.app.ui.dict_focus { + DictFocus::Categories => ctx.dispatch(AppCommand::DictNextCategory), + DictFocus::Words => ctx.dispatch(AppCommand::DictScrollDown(1)), + } + } + KeyCode::Char('k') | KeyCode::Up => { + match ctx.app.ui.dict_focus { + DictFocus::Categories => ctx.dispatch(AppCommand::DictPrevCategory), + DictFocus::Words => ctx.dispatch(AppCommand::DictScrollUp(1)), + } + } + KeyCode::PageDown => ctx.dispatch(AppCommand::DictScrollDown(10)), + KeyCode::PageUp => ctx.dispatch(AppCommand::DictScrollUp(10)), KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, diff --git a/src/page.rs b/src/page.rs index 3a34029..eb58fcd 100644 --- a/src/page.rs +++ b/src/page.rs @@ -4,35 +4,87 @@ pub enum Page { Main, Patterns, Audio, - Doc, + Help, + Dict, } impl Page { + /// All pages for iteration + pub const ALL: &'static [Page] = &[ + Page::Main, + Page::Patterns, + Page::Audio, + Page::Help, + Page::Dict, + ]; + + /// Grid dimensions (cols, rows) + pub const GRID_SIZE: (i8, i8) = (3, 2); + + /// Grid position (col, row) for each page + /// Layout: + /// col 0 col 1 col 2 + /// row 0 Patterns Help + /// row 1 Dict Sequencer Audio + pub const fn grid_pos(self) -> (i8, i8) { + match self { + Page::Dict => (0, 1), + Page::Main => (1, 1), + Page::Patterns => (1, 0), + Page::Audio => (2, 1), + Page::Help => (2, 0), + } + } + + /// Find page at grid position, if any + pub fn at_pos(col: i8, row: i8) -> Option { + Self::ALL.iter().copied().find(|p| p.grid_pos() == (col, row)) + } + + /// Display name for the page + pub const fn name(self) -> &'static str { + match self { + Page::Main => "Sequencer", + Page::Patterns => "Patterns", + Page::Audio => "Audio", + Page::Help => "Help", + Page::Dict => "Dict", + } + } + pub fn left(&mut self) { - *self = match self { - Page::Main | Page::Patterns => Page::Doc, - Page::Audio => Page::Main, - Page::Doc => Page::Audio, + let (col, row) = self.grid_pos(); + for offset in 1..=Self::GRID_SIZE.0 { + let new_col = (col - offset).rem_euclid(Self::GRID_SIZE.0); + if let Some(page) = Self::at_pos(new_col, row) { + *self = page; + return; + } } } pub fn right(&mut self) { - *self = match self { - Page::Main | Page::Patterns => Page::Audio, - Page::Audio => Page::Doc, - Page::Doc => Page::Main, + let (col, row) = self.grid_pos(); + for offset in 1..=Self::GRID_SIZE.0 { + let new_col = (col + offset).rem_euclid(Self::GRID_SIZE.0); + if let Some(page) = Self::at_pos(new_col, row) { + *self = page; + return; + } } } pub fn up(&mut self) { - if *self == Page::Main { - *self = Page::Patterns; + let (col, row) = self.grid_pos(); + if let Some(page) = Self::at_pos(col, row - 1) { + *self = page; } } pub fn down(&mut self) { - if *self == Page::Patterns { - *self = Page::Main; + let (col, row) = self.grid_pos(); + if let Some(page) = Self::at_pos(col, row + 1) { + *self = page; } } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 568a939..f4adcd0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -19,4 +19,4 @@ pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use playback::PlaybackState; pub use project::ProjectState; pub use sample_browser::SampleBrowserState; -pub use ui::UiState; +pub use ui::{DictFocus, UiState}; diff --git a/src/state/ui.rs b/src/state/ui.rs index 0e1cb5e..af65cac 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -11,18 +11,28 @@ pub struct Sparkle { pub life: u8, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum DictFocus { + #[default] + Categories, + Words, +} + pub struct UiState { pub sparkles: Vec, pub status_message: Option, pub flash_until: Option, pub flash_color: Color, pub modal: Modal, - pub doc_topic: usize, - pub doc_scroll: usize, - pub doc_category: usize, + pub help_topic: usize, + pub help_scroll: usize, + pub dict_focus: DictFocus, + pub dict_category: usize, + pub dict_scroll: usize, pub show_title: bool, pub runtime_highlight: bool, pub show_completion: bool, + pub minimap_until: Option, } impl Default for UiState { @@ -33,12 +43,15 @@ impl Default for UiState { flash_until: None, flash_color: Color::Green, modal: Modal::None, - doc_topic: 0, - doc_scroll: 0, - doc_category: 0, + help_topic: 0, + help_scroll: 0, + dict_focus: DictFocus::default(), + dict_category: 0, + dict_scroll: 0, show_title: true, runtime_highlight: false, show_completion: true, + minimap_until: None, } } } diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs new file mode 100644 index 0000000..8e2517d --- /dev/null +++ b/src/views/dict_view.rs @@ -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 = 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 = 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 = 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() +} diff --git a/src/views/doc_view.rs b/src/views/doc_view.rs deleted file mode 100644 index affb56c..0000000 --- a/src/views/doc_view.rs +++ /dev/null @@ -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 = 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 = 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 = 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 = 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 = 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> { - 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> = 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() -} diff --git a/src/views/help_view.rs b/src/views/help_view.rs new file mode 100644 index 0000000..3a4826b --- /dev/null +++ b/src/views/help_view.rs @@ -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 = 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 = 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> { + 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> = 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() +} diff --git a/src/views/mod.rs b/src/views/mod.rs index 702d23a..77e2977 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -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; diff --git a/src/views/render.rs b/src/views/render.rs index e36da34..e74bd1f 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -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 { 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 = 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(); diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index f4daa5e..1727cad 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,4 +1,4 @@ pub use cagire_ratatui::{ - ConfirmModal, FileBrowserModal, ModalFrame, Orientation, SampleBrowser, Scope, Spectrum, - TextInputModal, VuMeter, + ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser, + Scope, Spectrum, TextInputModal, VuMeter, };