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::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use std::io::{stdout, Stdout}; use crate::levels::{self, Difficulty, Level}; use crate::progress::{self, Progress}; use crate::similarity; // -- State ------------------------------------------------------------------ enum Screen { Welcome, TierSelect { cursor: u8 }, /// 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 full controls listing. Appended once on /// Welcome β†’ TierSelect so the player can scroll back to it. Intro, /// The full tier table with a marker on the chosen one. TierChoice { chosen: u8, // 0=Easy, 1=Medium, 2=Hard }, LevelPrompt { level_id: u8, level_name: String, tier_name: &'static str, flavor: String, description: String, }, ResultPass { level_name: String, score: f64, threshold: f64, }, ResultFail { level_name: String, score: f64, threshold: f64, }, InvalidYaml { error: String, }, Completed, } /// 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); } } /// 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 tier_index(t: Difficulty) -> u8 { match t { Difficulty::Easy => 0, Difficulty::Medium => 1, Difficulty::Hard => 2, } } fn initial_state(prog: &Progress, registry: &[Box]) -> (Screen, HistoryLog) { let mut log = HistoryLog::new(); // The Intro entry is always the bottom of the log on startup so that // Welcome and TierSelect can render it inline (the player can scroll // back to it any time). log.push(LogEntry::Intro); match (prog.tier, prog.current_level) { (None, 0) => (Screen::Welcome, log), (None, _) => (Screen::TierSelect { cursor: 0 }, log), (Some(tier), 0) => (Screen::TierSelect { cursor: tier_index(tier) }, log), (Some(_), n) if (n as usize) > registry.len() => { log.push(LogEntry::Completed); (Screen::Completed, log) } (Some(tier), _) => { log.push(LogEntry::TierChoice { chosen: tier_index(tier) }); log.push(level_prompt_entry(prog, registry, tier)); (Screen::Game, log) } } } fn level_prompt_entry( prog: &Progress, registry: &[Box], tier: Difficulty, ) -> 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(), tier_name: tier.name(), flavor: g.flavor, description: g.description, } } 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 (mut screen, mut log) = initial_state(prog, ®istry); let mut focus = default_focus(&screen); let mut editor = Editor::new(); loop { terminal.draw(|frame| { render(frame, &screen, focus, &editor, &log); })?; 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, key, prog, ®istry, )? { return Ok(()); } } } /// Returns `true` to quit the loop. fn step( screen: &mut Screen, focus: &mut Focus, editor: &mut Editor, log: &mut HistoryLog, key: KeyEvent, prog: &mut Progress, registry: &[Box], ) -> Result { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Global: Ctrl-Q quits. if ctrl && key.code == KeyCode::Char('q') { return Ok(true); } // Global: Ctrl-S grades while in Game. if ctrl && key.code == KeyCode::Char('s') && 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, TierSelect, ResetConfirm, Completed). match (&*screen, key.code) { (Screen::Welcome, KeyCode::Enter) => { *screen = Screen::TierSelect { cursor: 0 }; *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::TierSelect { cursor }, KeyCode::Up) => { let c = cursor.saturating_sub(1); *screen = Screen::TierSelect { cursor: c }; } (Screen::TierSelect { cursor }, KeyCode::Down) => { let c = (*cursor + 1).min(2); *screen = Screen::TierSelect { cursor: c }; } (Screen::TierSelect { cursor }, KeyCode::Enter) => { let chosen = *cursor; let tier = match chosen { 0 => Difficulty::Easy, 1 => Difficulty::Medium, _ => Difficulty::Hard, }; prog.tier = Some(tier); if prog.current_level == 0 { prog.current_level = 1; prog.current_seed = rand::random(); } progress::save(prog)?; editor.clear(); log.push(LogEntry::TierChoice { chosen }); log.push(level_prompt_entry(prog, registry, tier)); *screen = Screen::Game; *focus = default_focus(screen); } (Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true), (Screen::TierSelect { .. }, KeyCode::PageUp) => log.scroll_up(10), (Screen::TierSelect { .. }, 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); *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('r')) => { *screen = Screen::ResetConfirm; *focus = default_focus(screen); } (Screen::Completed, KeyCode::Char('q')) => return Ok(true), _ => {} } 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 tier = prog.tier.expect("tier set before grading"); let threshold = tier.threshold(); let passed = score >= threshold; let level_name = level.name().to_string(); let level_id = level.id(); if passed { prog.completed.push(level_id); prog.current_level += 1; prog.current_seed = rand::random(); progress::save(prog)?; log.push(LogEntry::ResultPass { level_name, score, threshold, }); // Append the next LevelPrompt, or Completed if we ran out. let next_idx = (prog.current_level - 1) as usize; if next_idx >= registry.len() { log.push(LogEntry::Completed); } else { log.push(level_prompt_entry(prog, registry, tier)); } } else { prog.attempts += 1; progress::save(prog)?; log.push(LogEntry::ResultFail { level_name, score, threshold, }); } Ok(()) } // -- Rendering -------------------------------------------------------------- fn render( frame: &mut Frame, screen: &Screen, focus: Focus, editor: &Editor, log: &HistoryLog, ) { let area = frame.size(); let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(area); let (left, right) = (chunks[0], chunks[1]); match screen { Screen::Welcome => render_log(frame, left, log, Some(&welcome_prompt_lines())), Screen::TierSelect { cursor } => { render_log(frame, left, log, Some(&tier_picker_lines(*cursor))) } Screen::Game | Screen::Completed => render_log(frame, left, log, None), 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); } } 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 welcome_prompt_lines() -> Vec { vec![ "🏰 You stand at the entrance.".to_string(), " [Enter] begin Β· [q] flee Β· [PgUp/PgDn] scroll the log".to_string(), ] } fn tier_picker_lines(cursor: u8) -> Vec { let rows = [ ("πŸ₯‰", "Easy", "70 %", "forgive small slips"), ("πŸ₯ˆ", "Medium", "80 %", "most details must match"), ("πŸ₯‡", "Hard", "95 %", "only near-perfect passes"), ]; let mut v = vec!["🎚 Choose your tier:".to_string()]; for (i, (emoji, name, pct, hint)) in rows.iter().enumerate() { let marker = if i == cursor as usize { "β€Ί" } else { " " }; v.push(format!(" {marker} {emoji} {name:<7} ({pct}) {hint}")); } v.push(String::new()); v.push(" ↑/↓ choose Β· [Enter] confirm Β· [PgUp/PgDn] scroll Β· [q] quit".to_string()); v } 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]>, ) { // 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()); } all_lines.extend(entry_lines(entry)); } // Append the transient prompt (Welcome / TierSelect) as the bottom block. // It's part of the bottom-anchored view but not part of the log itself. if let Some(prompt) = trailing_prompt { if !all_lines.is_empty() { all_lines.push(String::new()); } all_lines.extend_from_slice(prompt); } // Pick a visible slice anchored to the bottom, offset by `log.scroll`. let visible_h = (area.height as usize).saturating_sub(2); // borders let total = all_lines.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 = &all_lines[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() }; frame.render_widget(screen_widget(title, body), area); } 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-S to grade your attempt.".to_string(), String::new(), " ⌨ Controls".to_string(), " [Tab] swap focus between log and editor".to_string(), " [Ctrl-S] grade your YAML".to_string(), " [↑/↓] scroll the log (when log is focused)".to_string(), " [PgUp/PgDn] scroll faster".to_string(), " [r] reset progress".to_string(), " [q] / [Ctrl-Q] quit".to_string(), ], LogEntry::TierChoice { chosen } => { let rows = [ ("πŸ₯‰", "Easy", "70 %", "forgive small slips"), ("πŸ₯ˆ", "Medium", "80 %", "most details must match"), ("πŸ₯‡", "Hard", "95 %", "only near-perfect passes"), ]; let mut v = vec!["🎚 Difficulty".to_string(), String::new()]; for (i, (emoji, name, pct, hint)) in rows.iter().enumerate() { let mark = if i == *chosen as usize { " ← chosen" } else { "" }; v.push(format!(" {emoji} {name:<7} ({pct}) {hint}{mark}")); } v } LogEntry::LevelPrompt { level_id, level_name, tier_name, flavor, description, } => { let mut v = vec![ format!("πŸ—Ί Level {} β€” {} ({})", level_id, level_name, tier_name), flavor.clone(), String::new(), ]; for line in description.lines() { v.push(line.to_string()); } v.push(String::new()); v.push( " ⌨ [Ctrl-S] grade Β· [Tab] swap Β· [↑/↓] scroll Β· [r] reset Β· [q] quit" .to_string(), ); v } LogEntry::ResultPass { level_name, score, threshold, } => vec![format!( " πŸ— {} cleared at {:.0} % (threshold {:.0} %) ✨", level_name, score * 100.0, threshold * 100.0 )], LogEntry::ResultFail { level_name, score, threshold, } => vec![ format!( " πŸ•Έ {} not yet β€” {:.0} % (threshold {:.0} %)", level_name, score * 100.0, threshold * 100.0 ), " Refine your YAML and press Ctrl-S to retry.".to_string(), ], LogEntry::InvalidYaml { error } => vec![ " ⚠ Couldn't parse YAML".to_string(), format!(" {error}"), " Fix it in the editor and press Ctrl-S to retry.".to_string(), ], LogEntry::Completed => vec![ " πŸ† Labyrinth complete!".to_string(), " [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 body = if focused { let (cr, cc) = editor.cursor; let mut lines: Vec = Vec::with_capacity(editor.buffer.len()); for (r, line) in editor.buffer.iter().enumerate() { if r == cr { let col = cc.min(line.len()); let mut with_cursor = String::with_capacity(line.len() + 1); with_cursor.push_str(&line[..col]); with_cursor.push('_'); with_cursor.push_str(&line[col..]); lines.push(with_cursor); } else { lines.push(line.clone()); } } lines.join("\n") } else { editor.text() }; frame.render_widget(screen_widget(title, body), area); } fn render_editor_inactive(frame: &mut Frame, area: Rect) { let body = "\n πŸͺΆ The quill rests. Pick a tier first…".to_string(); frame.render_widget( screen_widget(" πŸ“ Editor (inactive) ".to_string(), body), area, ); }