use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use unicode_width::UnicodeWidthChar; use std::io::{stdout, Stdout}; use std::time::{Duration, Instant}; use crate::config; use crate::levels::{self, Level, Nugget}; use crate::progress::{self, Progress}; use crate::similarity; // -- State ------------------------------------------------------------------ enum Screen { Welcome, /// Active play: left column = HistoryLog, right column = Editor. Game, ResetConfirm, Completed, } #[derive(Copy, Clone, PartialEq, Eq)] enum Focus { Game, Editor, } enum LogEntry { /// Game rules + the controls hint. Seeded at startup; appears instantly. Intro, /// "🏰 You stand at the entrance…" Lives as a separate entry so only /// the greeting (not the whole intro) gets the typewriter effect. Greeting, LevelPrompt { level_id: u8, level_name: String, flavor: String, description: String, }, ResultPass { level_name: String, score: f64, nugget: Nugget, }, ResultFail { level_name: String, score: f64, }, InvalidYaml { error: String, }, Completed { gold: u32, silver: u32, bronze: u32, }, } /// Append-only chronological log of game events. Rendered bottom-anchored /// in the left column. `scroll` is the number of lines offset from the /// bottom; 0 means pinned to the latest entry. struct HistoryLog { entries: Vec, scroll: usize, } impl HistoryLog { fn new() -> Self { Self { entries: vec![], scroll: 0, } } fn push(&mut self, entry: LogEntry) { self.entries.push(entry); // Always snap back to the bottom on a new event. self.scroll = 0; } fn scroll_up(&mut self, n: usize) { self.scroll = self.scroll.saturating_add(n); } fn scroll_down(&mut self, n: usize) { self.scroll = self.scroll.saturating_sub(n); } } /// Drives the character-by-character reveal of the latest `LevelPrompt`. /// `entry_idx` is the index into `HistoryLog::entries` being animated. struct Typewriter { entry_idx: usize, chars_shown: usize, total_chars: usize, char_duration: Duration, next_tick_at: Instant, } impl Typewriter { fn new(entry_idx: usize, total_chars: usize, char_duration: Duration) -> Self { Self { entry_idx, chars_shown: 0, total_chars, char_duration, next_tick_at: Instant::now() + char_duration, } } fn is_done(&self) -> bool { self.chars_shown >= self.total_chars } fn tick(&mut self) { let now = Instant::now(); while self.next_tick_at <= now && self.chars_shown < self.total_chars { self.chars_shown += 1; self.next_tick_at += self.char_duration; } } fn time_to_next_tick(&self) -> Duration { let now = Instant::now(); self.next_tick_at.saturating_duration_since(now) } } /// Multi-line YAML editor. Byte-indexed; ASCII-only (see risk R6). struct Editor { buffer: Vec, cursor: (usize, usize), } impl Editor { fn new() -> Self { Self { buffer: vec![String::new()], cursor: (0, 0), } } fn clear(&mut self) { self.buffer = vec![String::new()]; self.cursor = (0, 0); } fn text(&self) -> String { self.buffer.join("\n") } fn insert_char(&mut self, c: char) { let (r, col) = self.cursor; let line = &mut self.buffer[r]; let col = col.min(line.len()); line.insert(col, c); self.cursor = (r, col + 1); } fn backspace(&mut self) { let (r, col) = self.cursor; if col > 0 { self.buffer[r].remove(col - 1); self.cursor = (r, col - 1); } else if r > 0 { let cur = self.buffer.remove(r); let prev_len = self.buffer[r - 1].len(); self.buffer[r - 1].push_str(&cur); self.cursor = (r - 1, prev_len); } } fn newline(&mut self) { let (r, col) = self.cursor; let col = col.min(self.buffer[r].len()); let rest = self.buffer[r].split_off(col); self.buffer.insert(r + 1, rest); self.cursor = (r + 1, 0); } fn left(&mut self) { let (r, col) = self.cursor; if col > 0 { self.cursor.1 = col - 1; } else if r > 0 { self.cursor = (r - 1, self.buffer[r - 1].len()); } } fn right(&mut self) { let (r, col) = self.cursor; let line_len = self.buffer[r].len(); if col < line_len { self.cursor.1 = col + 1; } else if r + 1 < self.buffer.len() { self.cursor = (r + 1, 0); } } fn up(&mut self) { if self.cursor.0 > 0 { let r = self.cursor.0 - 1; self.cursor = (r, self.cursor.1.min(self.buffer[r].len())); } } fn down(&mut self) { if self.cursor.0 + 1 < self.buffer.len() { let r = self.cursor.0 + 1; self.cursor = (r, self.cursor.1.min(self.buffer[r].len())); } } fn home(&mut self) { self.cursor.1 = 0; } fn end(&mut self) { self.cursor.1 = self.buffer[self.cursor.0].len(); } } // -- Entry ------------------------------------------------------------------ pub fn run(prog: &mut Progress) -> Result<()> { install_panic_hook(); let mut terminal = enter()?; let result = main_loop(&mut terminal, prog); leave()?; result } fn install_panic_hook() { let prev = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = leave(); prev(info); })); } fn enter() -> Result>> { enable_raw_mode()?; execute!(stdout(), EnterAlternateScreen)?; Ok(Terminal::new(CrosstermBackend::new(stdout()))?) } fn leave() -> Result<()> { disable_raw_mode()?; execute!(stdout(), LeaveAlternateScreen)?; Ok(()) } fn initial_state(prog: &Progress, registry: &[Box]) -> (Screen, HistoryLog) { let mut log = HistoryLog::new(); // The welcome content (Intro rules + Greeting) is seeded only for a // fresh game β€” `current_level == 0` means never started or just reset. // A resumed run drops straight into its current level prompt; the // Greeting is re-seeded on reset (see the ResetConfirm handler). if prog.current_level == 0 { // Intro (rules + Ctrl-H hint) appears instantly. Greeting follows as // the dramatic last line β€” it's the entry that gets the typewriter // on a fresh start. log.push(LogEntry::Intro); log.push(LogEntry::Greeting); } match prog.current_level { 0 => (Screen::Welcome, log), n if (n as usize) > registry.len() => { let (gold, silver, bronze) = count_nuggets(&prog.nuggets); log.push(LogEntry::Completed { gold, silver, bronze, }); (Screen::Completed, log) } _ => { log.push(level_prompt_entry(prog, registry)); (Screen::Game, log) } } } fn level_prompt_entry(prog: &Progress, registry: &[Box]) -> LogEntry { let idx = (prog.current_level - 1) as usize; let level = ®istry[idx]; let g = level.generate(prog.current_seed); LogEntry::LevelPrompt { level_id: level.id(), level_name: level.name().to_string(), flavor: g.flavor, description: g.description, } } fn count_nuggets(nuggets: &[(u8, Nugget)]) -> (u32, u32, u32) { let mut gold = 0; let mut silver = 0; let mut bronze = 0; for (_, n) in nuggets { match n { Nugget::Gold => gold += 1, Nugget::Silver => silver += 1, Nugget::Bronze => bronze += 1, } } (gold, silver, bronze) } fn default_focus(screen: &Screen) -> Focus { match screen { Screen::Game => Focus::Editor, _ => Focus::Game, } } // -- Main loop -------------------------------------------------------------- fn main_loop( terminal: &mut Terminal>, prog: &mut Progress, ) -> Result<()> { let registry = levels::registry(); let cfg = config::load(); let (mut screen, mut log) = initial_state(prog, ®istry); let mut focus = default_focus(&screen); let mut editor = Editor::new(); let mut help_open = false; // Resume case: if the bottom of the log is a LevelPrompt, animate it // from scratch so resumed sessions feel the same as fresh ones. let mut typewriter = if cfg.typewriter_enabled { start_typewriter(&log, &cfg) } else { None }; let mut prev_log_len = log.entries.len(); loop { terminal.draw(|frame| { render( frame, &screen, focus, &editor, &log, typewriter.as_ref(), help_open, ); })?; let poll_timeout = typewriter .as_ref() .filter(|tw| !tw.is_done()) .map(|tw| tw.time_to_next_tick()) .unwrap_or_else(|| Duration::from_secs(3600)); if event::poll(poll_timeout)? { let Event::Key(key) = event::read()? else { continue; }; if !matches!(key.kind, KeyEventKind::Press) { continue; } if step( &mut screen, &mut focus, &mut editor, &mut log, &mut help_open, key, prog, ®istry, )? { return Ok(()); } if cfg.typewriter_enabled && log.entries.len() != prev_log_len { if let Some(tw) = start_typewriter(&log, &cfg) { // Growth: only animate genuinely new entries. // Shrink (reset wiped the log): always animate the new // bottom entry (Greeting). if log.entries.len() < prev_log_len || tw.entry_idx >= prev_log_len { typewriter = Some(tw); } } } prev_log_len = log.entries.len(); } else if let Some(tw) = typewriter.as_mut() { tw.tick(); if tw.is_done() { typewriter = None; } } } } /// Build a `Typewriter` for the most recent animated entry in the log /// (either a `LevelPrompt` or the startup/post-reset `Greeting`). fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option { let idx = log.entries.iter().enumerate().rev().find_map(|(i, e)| { if matches!(e, LogEntry::LevelPrompt { .. } | LogEntry::Greeting) { Some(i) } else { None } })?; let total = entry_lines(&log.entries[idx]) .join("\n") .chars() .count(); Some(Typewriter::new(idx, total, cfg.char_duration())) } /// Returns `true` to quit the loop. fn step( screen: &mut Screen, focus: &mut Focus, editor: &mut Editor, log: &mut HistoryLog, help_open: &mut bool, key: KeyEvent, prog: &mut Progress, registry: &[Box], ) -> Result { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Global: Ctrl-Q quits from anywhere, including the help dialog. if ctrl && key.code == KeyCode::Char('q') { return Ok(true); } // Help: Ctrl-H opens (idempotent). if ctrl && key.code == KeyCode::Char('h') { *help_open = true; return Ok(false); } // While help is open, only Esc closes it; everything else is swallowed. if *help_open { if key.code == KeyCode::Esc { *help_open = false; } return Ok(false); } // Global: Ctrl-X grades while in Game. if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) { grade(editor.text(), prog, registry, log)?; if matches!(log.entries.last(), Some(LogEntry::Completed { .. })) { *screen = Screen::Completed; *focus = default_focus(screen); } return Ok(false); } // Tab toggles focus while in Game. if key.code == KeyCode::Tab && matches!(screen, Screen::Game) { *focus = match *focus { Focus::Game => Focus::Editor, Focus::Editor => Focus::Game, }; return Ok(false); } // Editor focus inside Game: dispatch editing keys. if *focus == Focus::Editor && matches!(screen, Screen::Game) { match key.code { KeyCode::Char(c) if !ctrl => editor.insert_char(c), KeyCode::Backspace => editor.backspace(), KeyCode::Enter => editor.newline(), KeyCode::Left => editor.left(), KeyCode::Right => editor.right(), KeyCode::Up => editor.up(), KeyCode::Down => editor.down(), KeyCode::Home => editor.home(), KeyCode::End => editor.end(), KeyCode::Esc => *focus = Focus::Game, _ => {} } return Ok(false); } // Game focus inside Game: scroll the log + shortcuts. if matches!(screen, Screen::Game) && *focus == Focus::Game { match key.code { KeyCode::Up => log.scroll_up(1), KeyCode::Down => log.scroll_down(1), KeyCode::PageUp => log.scroll_up(10), KeyCode::PageDown => log.scroll_down(10), KeyCode::Char('r') => { *screen = Screen::ResetConfirm; *focus = default_focus(screen); } KeyCode::Char('q') => return Ok(true), _ => {} } return Ok(false); } // Other screens (Welcome, ResetConfirm, Completed). match (&*screen, key.code) { (Screen::Welcome, KeyCode::Enter) => { if prog.current_level == 0 { prog.current_level = 1; prog.current_seed = rand::random(); } progress::save(prog)?; editor.clear(); log.push(level_prompt_entry(prog, registry)); *screen = Screen::Game; *focus = default_focus(screen); } (Screen::Welcome, KeyCode::Char('q')) => return Ok(true), (Screen::Welcome, KeyCode::Up) => log.scroll_up(1), (Screen::Welcome, KeyCode::Down) => log.scroll_down(1), (Screen::Welcome, KeyCode::PageUp) => log.scroll_up(10), (Screen::Welcome, KeyCode::PageDown) => log.scroll_down(10), (Screen::ResetConfirm, KeyCode::Char('y')) => { let _ = std::fs::remove_file(progress::save_path()); *prog = Progress::default(); editor.clear(); *log = HistoryLog::new(); log.push(LogEntry::Intro); log.push(LogEntry::Greeting); *screen = Screen::Welcome; *focus = default_focus(screen); } (Screen::ResetConfirm, _) => { *screen = Screen::Game; *focus = default_focus(screen); } (Screen::Completed, KeyCode::Up) => log.scroll_up(1), (Screen::Completed, KeyCode::Down) => log.scroll_down(1), (Screen::Completed, KeyCode::PageUp) => log.scroll_up(10), (Screen::Completed, KeyCode::PageDown) => log.scroll_down(10), (Screen::Completed, KeyCode::Char('q')) => return Ok(true), (Screen::Completed, KeyCode::Char('r')) => { *screen = Screen::ResetConfirm; *focus = default_focus(screen); } _ => {} } Ok(false) } fn grade( candidate: String, prog: &mut Progress, registry: &[Box], log: &mut HistoryLog, ) -> Result<()> { // Parse-first guard. if let Err(e) = serde_yaml::from_str::(&candidate) { log.push(LogEntry::InvalidYaml { error: e.to_string(), }); return Ok(()); } let idx = (prog.current_level - 1) as usize; let level = ®istry[idx]; let g = level.generate(prog.current_seed); let score = similarity::semantic_or_textual(&g.target_yaml, &candidate); let level_name = level.name().to_string(); let level_id = level.id(); match Nugget::from_score(score) { Some(nugget) => { prog.nuggets.push((level_id, nugget)); prog.current_level += 1; prog.current_seed = rand::random(); progress::save(prog)?; log.push(LogEntry::ResultPass { level_name, score, nugget, }); // Append the next LevelPrompt, or Completed if we ran out. let next_idx = (prog.current_level - 1) as usize; if next_idx >= registry.len() { let (gold, silver, bronze) = count_nuggets(&prog.nuggets); log.push(LogEntry::Completed { gold, silver, bronze, }); } else { log.push(level_prompt_entry(prog, registry)); } } None => { prog.attempts += 1; progress::save(prog)?; log.push(LogEntry::ResultFail { level_name, score }); } } Ok(()) } // -- Rendering -------------------------------------------------------------- fn render( frame: &mut Frame, screen: &Screen, focus: Focus, editor: &Editor, log: &HistoryLog, typewriter: Option<&Typewriter>, help_open: bool, ) { // Outer layout: two-column area on top, 1-row status bar across the // full width below. let outer = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(1)].as_ref()) .split(frame.size()); let (top, status) = (outer[0], outer[1]); let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(top); let (left, right) = (chunks[0], chunks[1]); match screen { Screen::Welcome => render_log(frame, left, log, None, typewriter), Screen::Game | Screen::Completed => render_log(frame, left, log, None, typewriter), Screen::ResetConfirm => render_reset_confirm(frame, left), } if matches!(screen, Screen::Game) { render_editor(frame, right, editor, focus == Focus::Editor); } else { render_editor_inactive(frame, right); } render_status_bar(frame, status, screen, focus, help_open); // Help dialog overlay β€” drawn last so it sits on top of everything. if help_open { let area = centered_rect(70, 60, frame.size()); frame.render_widget(Clear, area); frame.render_widget(help_widget(), area); } } /// Center a rectangle of `pct_x` Γ— `pct_y` percent inside `area`. fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect { let vertical = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Percentage((100 - pct_y) / 2), Constraint::Percentage(pct_y), Constraint::Percentage((100 - pct_y) / 2), ] .as_ref(), ) .split(area); Layout::default() .direction(Direction::Horizontal) .constraints( [ Constraint::Percentage((100 - pct_x) / 2), Constraint::Percentage(pct_x), Constraint::Percentage((100 - pct_x) / 2), ] .as_ref(), ) .split(vertical[1])[1] } fn help_widget<'a>() -> Paragraph<'a> { let body = "\n\ ⌨ Controls\n\ \n\ [Tab] swap focus between log and editor\n\ [Ctrl-X] grade your YAML\n\ [Ctrl-H] open this help\n\ [Esc] close help Β· or focus log when editing\n\ [↑/↓] scroll the log (when log is focused)\n\ [PgUp/PgDn] scroll faster\n\ [r] reset progress\n\ [q] / [Ctrl-Q] quit\n\ \n\ Press [Esc] to close." .to_string(); Paragraph::new(body) .block( Block::default() .borders(Borders::ALL) .title(" πŸ“œ Controls "), ) .wrap(Wrap { trim: false }) } fn render_status_bar( frame: &mut Frame, area: Rect, screen: &Screen, focus: Focus, help_open: bool, ) { let style = Style::default().fg(Color::White).bg(Color::DarkGray); let p = Paragraph::new(status_text(screen, focus, help_open)).style(style); frame.render_widget(p, area); } fn status_text(screen: &Screen, focus: Focus, help_open: bool) -> String { if help_open { return " [Esc] close help Β· [Ctrl-Q] quit".into(); } match (screen, focus) { (Screen::Welcome, _) => { " [Enter] begin Β· [↑/↓] scroll Β· [PgUp/PgDn] page Β· [Ctrl-H] help Β· [q] quit".into() } (Screen::Game, Focus::Editor) => { " [Ctrl-X] grade Β· [Tab] focus log Β· [Esc] focus log Β· [Ctrl-H] help Β· [Ctrl-Q] quit" .into() } (Screen::Game, Focus::Game) => { " [Ctrl-X] grade Β· [Tab] focus editor Β· [↑/↓] scroll Β· [r] reset Β· [Ctrl-H] help Β· [q] quit" .into() } (Screen::ResetConfirm, _) => " [y] confirm wipe Β· any other key cancels".into(), (Screen::Completed, _) => { " [↑/↓] scroll Β· [PgUp/PgDn] page Β· [r] reset Β· [Ctrl-H] help Β· [q] quit".into() } } } fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> { Paragraph::new(body) .block(Block::default().borders(Borders::ALL).title(title)) .wrap(Wrap { trim: false }) } fn render_reset_confirm(frame: &mut Frame, area: Rect) { let body = "\nπŸ’€ Wipe progress and start over?\n\n[y] yes, wipe Β· any other key cancels" .to_string(); frame.render_widget(screen_widget(" Reset? ".to_string(), body), area); } fn render_log( frame: &mut Frame, area: Rect, log: &HistoryLog, trailing_prompt: Option<&[String]>, typewriter: Option<&Typewriter>, ) { // Flatten log entries into a single line stream, blank line between each. let mut all_lines: Vec = Vec::new(); for (i, entry) in log.entries.iter().enumerate() { if i > 0 { all_lines.push(String::new()); } let entry_view = match typewriter { Some(tw) if tw.entry_idx == i && !tw.is_done() => { truncate_entry_to_chars(entry, tw.chars_shown) } _ => entry_lines(entry), }; all_lines.extend(entry_view); } // Append the transient prompt (rare β€” none currently) as the bottom // block. Kept for future use. if let Some(prompt) = trailing_prompt { if !all_lines.is_empty() { all_lines.push(String::new()); } all_lines.extend_from_slice(prompt); } // Wrap every logical line to the column's inner width *before* the // bottom-anchor math. Otherwise ratatui's `Wrap` reflows long lines into // extra visual rows the slice/pad math never accounts for, pushing the // newest entries off the bottom edge. Wrapping here keeps the math in the // visual-row units the terminal actually draws. let inner_w = (area.width as usize).saturating_sub(2); // borders let visible_h = (area.height as usize).saturating_sub(2); // borders let mut rows: Vec = Vec::new(); for line in &all_lines { rows.extend(wrap_line(line, inner_w)); } // Pick a visible slice anchored to the bottom, offset by `log.scroll`. let total = rows.len(); let max_scroll = total.saturating_sub(visible_h); let scroll = log.scroll.min(max_scroll); let end = total.saturating_sub(scroll); let start = end.saturating_sub(visible_h); let slice = &rows[start..end]; // Pad the top so the slice sits flush with the bottom of the column. let pad = visible_h.saturating_sub(slice.len()); let mut body_lines: Vec = vec![String::new(); pad]; body_lines.extend_from_slice(slice); let body = body_lines.join("\n"); let title = if log.scroll > 0 { format!(" πŸ“œ Labyrinth log Β· scrolled {}/{} ", scroll, max_scroll) } else { " πŸ“œ Labyrinth log ".to_string() }; // Rows are pre-wrapped to `inner_w`, so render *without* `Wrap`: our // wrapping is the single source of truth and can't drift from ratatui's. let block = Block::default().borders(Borders::ALL).title(title); frame.render_widget(Paragraph::new(body).block(block), area); } /// Word-wrap one logical line to `width` display columns, returning the visual /// rows it occupies β€” always at least one, even for an empty line. Display /// width is measured with `unicode-width` so wide glyphs (emoji) count as the /// two cells they draw as. Leading indentation is kept; a word longer than the /// whole column is hard-split. fn wrap_line(line: &str, width: usize) -> Vec { if width == 0 || line.is_empty() { return vec![String::new()]; } let char_w = |c: char| UnicodeWidthChar::width(c).unwrap_or(0); // Tokenize into alternating runs of spaces and non-spaces so breaks land // between words while (most) whitespace is preserved. let mut tokens: Vec = Vec::new(); for c in line.chars() { let is_space = c == ' '; match tokens.last_mut() { Some(tok) if tok.starts_with(' ') == is_space => tok.push(c), _ => tokens.push(c.to_string()), } } let mut rows: Vec = Vec::new(); let mut row = String::new(); let mut row_w = 0usize; for token in tokens { let token_w: usize = token.chars().map(char_w).sum(); if row_w + token_w <= width { row.push_str(&token); row_w += token_w; } else if token.starts_with(' ') { // Whitespace that would overflow: swallow it at the break. rows.push(std::mem::take(&mut row)); row_w = 0; } else if token_w <= width { // Word fits on a row of its own β€” start a fresh row with it. rows.push(std::mem::take(&mut row)); row = token; row_w = token_w; } else { // Word wider than the whole column: hard-split it across rows. for c in token.chars() { let w = char_w(c); if row_w + w > width { rows.push(std::mem::take(&mut row)); row_w = 0; } row.push(c); row_w += w; } } } rows.push(row); rows } fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec { if max_chars == 0 { return Vec::new(); } let full = entry_lines(entry).join("\n"); let truncated: String = full.chars().take(max_chars).collect(); truncated.split('\n').map(str::to_string).collect() } fn entry_lines(entry: &LogEntry) -> Vec { match entry { LogEntry::Intro => vec![ "πŸŒ€ YAMLabyrinth β€” learn YAML by writing your way through.".to_string(), String::new(), " Each chamber reveals a target. Match it with YAML in the editor".to_string(), " on the right, then press Ctrl-X to grade your attempt.".to_string(), String::new(), " Score β‰₯ 95 % β†’ πŸ₯‡ Gold Β· β‰₯ 80 % β†’ πŸ₯ˆ Silver Β· β‰₯ 70 % β†’ πŸ₯‰ Bronze.".to_string(), " Below 70 % means retry β€” refine your YAML and press Ctrl-X again.".to_string(), String::new(), " Press [Ctrl-H] any time for the full controls.".to_string(), ], LogEntry::Greeting => { vec!["🏰 You stand at the entrance, brave explorer. Press [Enter] to ...well, enter the dungeon.".to_string()] } LogEntry::LevelPrompt { level_id, level_name, flavor, description, } => { let mut v = vec![ format!("πŸ—Ί Level {} β€” {}", level_id, level_name), flavor.clone(), String::new(), ]; for line in description.lines() { v.push(line.to_string()); } v } LogEntry::ResultPass { level_name, score, nugget, } => vec![format!( " {} {} cleared with a {} nugget at {:.0} % ✨", nugget.emoji(), level_name, nugget.name(), score * 100.0, )], LogEntry::ResultFail { level_name, score } => vec![ format!( " πŸ•Έ {} not yet β€” {:.0} % (need 70 % for a πŸ₯‰ Bronze)", level_name, score * 100.0, ), " Refine your YAML and press Ctrl-X to retry.".to_string(), ], LogEntry::InvalidYaml { error } => vec![ " ⚠ Couldn't parse YAML".to_string(), format!(" {error}"), " Fix it in the editor and press Ctrl-X to retry.".to_string(), ], LogEntry::Completed { gold, silver, bronze, } => vec![ " πŸ† Labyrinth complete!".to_string(), format!(" πŸ₯‡ Γ—{} πŸ₯ˆ Γ—{} πŸ₯‰ Γ—{}", gold, silver, bronze), " [r] reset and replay Β· [q] quit".to_string(), ], } } fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool) { let title = if focused { " πŸ“ Your YAML * ".to_string() } else { " πŸ“ Your YAML ".to_string() }; let block = Block::default().borders(Borders::ALL).title(title); if focused { // Highlight the character under the cursor with explicit bg + fg // colours (not `REVERSED`) so the cursor cell is filled even when // the underlying character is whitespace β€” some terminals skip // background fill on reverse-video spaces. let (cr, cc) = editor.cursor; let cursor_style = Style::default().bg(Color::White).fg(Color::Black); let lines: Vec = editor .buffer .iter() .enumerate() .map(|(r, line)| { if r == cr { editor_cursor_line(line, cc, cursor_style) } else { Line::from(line.clone()) } }) .collect(); let p = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); frame.render_widget(p, area); } else { let p = Paragraph::new(editor.text()) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(p, area); } } /// Build the cursor-bearing line as three spans: text before, the /// highlighted character at the cursor, text after. If the cursor sits /// past the end of the line (or on a whitespace cell), the highlight /// falls on a visible block glyph β€” some terminals / ratatui's wrap /// layer skip painting bg on styled space cells, which would leave the /// cursor invisible. const CURSOR_PLACEHOLDER: char = '▏'; fn editor_cursor_line(line: &str, col: usize, cursor_style: Style) -> Line<'static> { let col = col.min(line.len()); let before = line[..col].to_string(); if col < line.len() { let after = &line[col..]; let mut chars = after.char_indices(); let (_, c) = chars.next().expect("non-empty: col < line.len()"); let next_boundary = chars.next().map(|(i, _)| i).unwrap_or(after.len()); let glyph = if c.is_whitespace() { CURSOR_PLACEHOLDER } else { c }; Line::from(vec![ Span::raw(before), Span::styled(glyph.to_string(), cursor_style), Span::raw(after[next_boundary..].to_string()), ]) } else { // Cursor past end of line β€” show the placeholder glyph. Line::from(vec![ Span::raw(before), Span::styled(CURSOR_PLACEHOLDER.to_string(), cursor_style), ]) } } fn render_editor_inactive(frame: &mut Frame, area: Rect) { let body = "\n πŸͺΆ The quill rests. Press [Enter] on the welcome screen to begin.".to_string(); frame.render_widget( screen_widget(" πŸ“ Editor (inactive) ".to_string(), body), area, ); } #[cfg(test)] mod tests { use super::wrap_line; #[test] fn empty_line_still_occupies_one_row() { assert_eq!(wrap_line("", 10), vec![String::new()]); } #[test] fn short_line_is_left_intact() { assert_eq!(wrap_line("hello", 10), vec!["hello".to_string()]); } #[test] fn breaks_between_words() { assert_eq!( wrap_line("alpha beta gamma", 10), vec!["alpha beta".to_string(), "gamma".to_string()], ); } #[test] fn hard_splits_a_word_longer_than_the_column() { assert_eq!( wrap_line("abcdefghij", 4), vec!["abcd".to_string(), "efgh".to_string(), "ij".to_string()], ); } #[test] fn wide_emoji_counts_as_two_cells() { // "ab " is 3 cells, "πŸ₯‡" adds 2 β†’ fills width 5 exactly; " cd" wraps. assert_eq!( wrap_line("ab πŸ₯‡ cd", 5), vec!["ab πŸ₯‡".to_string(), "cd".to_string()], ); } }