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, }, Level, Result { score: f64, passed: bool, level_name: String, }, InvalidYaml { error: String, }, ResetConfirm, Completed, } #[derive(Copy, Clone, PartialEq, Eq)] enum Focus { Game, Editor, } /// Multi-line YAML editor backing the right column. Byte-indexed; assumes /// ASCII content (YAML is conventionally ASCII). Splitting at a multi-byte /// boundary would panic — that's flagged as risk R6. struct Editor { buffer: Vec, cursor: (usize, usize), // (row, col) } 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_screen(prog: &Progress, registry_len: usize) -> Screen { match (prog.tier, prog.current_level) { (None, 0) => Screen::Welcome, (None, _) => Screen::TierSelect { cursor: 0 }, (Some(_), 0) => Screen::TierSelect { cursor: 0 }, (Some(_), n) if (n as usize) > registry_len => Screen::Completed, (Some(_), _) => Screen::Level, } } fn default_focus(screen: &Screen) -> Focus { match screen { Screen::Level | Screen::InvalidYaml { .. } => Focus::Editor, _ => Focus::Game, } } fn editor_active(screen: &Screen) -> bool { matches!( screen, Screen::Level | Screen::Result { .. } | Screen::InvalidYaml { .. } ) } fn editor_interactive(screen: &Screen) -> bool { matches!(screen, Screen::Level | Screen::InvalidYaml { .. }) } // -- Main loop -------------------------------------------------------------- fn main_loop( terminal: &mut Terminal>, prog: &mut Progress, ) -> Result<()> { let registry = levels::registry(); let mut screen = initial_screen(prog, registry.len()); let mut focus = default_focus(&screen); let mut editor = Editor::new(); loop { terminal.draw(|frame| render(frame, &screen, prog, ®istry, focus, &editor))?; let Event::Key(key) = event::read()? else { continue; }; if !matches!(key.kind, KeyEventKind::Press) { continue; } if step(&mut screen, &mut focus, &mut editor, key, prog, ®istry)? { return Ok(()); } } } /// Returns `true` when the user wants to quit. fn step( screen: &mut Screen, focus: &mut Focus, editor: &mut Editor, key: KeyEvent, prog: &mut Progress, registry: &[Box], ) -> Result { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Global: Ctrl-Q quits from anywhere. if ctrl && key.code == KeyCode::Char('q') { return Ok(true); } // Global: Ctrl-S grades when the editor is in play. if ctrl && key.code == KeyCode::Char('s') && editor_interactive(screen) { let next = grade(editor.text(), prog, registry)?; *focus = default_focus(&next); *screen = next; return Ok(false); } // Tab toggles focus while the editor is interactive. if key.code == KeyCode::Tab && editor_interactive(screen) { *focus = match *focus { Focus::Game => Focus::Editor, Focus::Editor => Focus::Game, }; return Ok(false); } // Editor keys (only when Editor is focused and screen is interactive). if *focus == Focus::Editor && editor_interactive(screen) { let was_invalid = matches!(screen, Screen::InvalidYaml { .. }); let mut consumed = true; 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 => { // Esc dismisses InvalidYaml (without retry) or returns focus // to the Game column on Level. if was_invalid { *screen = Screen::Level; *focus = default_focus(screen); } else { *focus = Focus::Game; } } _ => consumed = false, } if consumed && was_invalid { // Any edit implicitly dismisses the error overlay. *screen = Screen::Level; } return Ok(false); } // Game-focus keys. 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::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 tier = match cursor { 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(); *screen = Screen::Level; *focus = default_focus(screen); } (Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true), (Screen::Level, KeyCode::Char('r')) => { *screen = Screen::ResetConfirm; *focus = default_focus(screen); } (Screen::Level, KeyCode::Char('q')) => return Ok(true), (Screen::Result { passed, .. }, KeyCode::Enter) => { let pass = *passed; if pass { editor.clear(); if (prog.current_level as usize) > registry.len() { *screen = Screen::Completed; } else { *screen = Screen::Level; } } else { *screen = Screen::Level; } *focus = default_focus(screen); } (Screen::InvalidYaml { .. }, KeyCode::Enter) => { *screen = Screen::Level; *focus = default_focus(screen); } (Screen::InvalidYaml { .. }, KeyCode::Esc) => { *screen = Screen::Level; *focus = default_focus(screen); } (Screen::InvalidYaml { .. }, KeyCode::Char('r')) => { *screen = Screen::ResetConfirm; *focus = default_focus(screen); } (Screen::InvalidYaml { .. }, KeyCode::Char('q')) => return Ok(true), (Screen::ResetConfirm, KeyCode::Char('y')) => { let _ = std::fs::remove_file(progress::save_path()); *prog = Progress::default(); editor.clear(); *screen = Screen::Welcome; *focus = default_focus(screen); } (Screen::ResetConfirm, _) => { *screen = Screen::Level; *focus = default_focus(screen); } (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], ) -> Result { if let Err(e) = serde_yaml::from_str::(&candidate) { return Ok(Screen::InvalidYaml { error: e.to_string() }); } 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 threshold = prog .tier .expect("tier set before reaching the Level screen") .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(); } else { prog.attempts += 1; } progress::save(prog)?; Ok(Screen::Result { score, passed, level_name, }) } // -- Rendering -------------------------------------------------------------- fn render( frame: &mut Frame, screen: &Screen, prog: &Progress, registry: &[Box], focus: Focus, editor: &Editor, ) { 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_welcome(frame, left), Screen::TierSelect { cursor } => render_tier_select(frame, left, *cursor), Screen::Level => render_level(frame, left, prog, registry), Screen::Result { score, passed, level_name, } => render_result(frame, left, *score, *passed, level_name, prog), Screen::InvalidYaml { error } => render_invalid_yaml(frame, left, error), Screen::ResetConfirm => render_reset_confirm(frame, left), Screen::Completed => render_completed(frame, left), } if editor_active(screen) { let editor_focused = focus == Focus::Editor && editor_interactive(screen); render_editor(frame, right, editor, editor_focused); } 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 render_welcome(frame: &mut Frame, area: Rect) { let body = "\n\ You stand at the entrance to the YAML labyrinth.\n\ Within, broken syntax festers and dictionaries\n\ sprawl like vines.\n\ \n\ [Enter] begin · [q] flee" .to_string(); frame.render_widget(screen_widget(" YAMLabyrinth ".to_string(), body), area); } fn render_tier_select(frame: &mut Frame, area: Rect, cursor: u8) { let rows = [ ("Easy", "70 %", "forgive small slips"), ("Medium", "80 %", "most details must match"), ("Hard", "95 %", "only near-perfect passes"), ]; let mut body = String::from("\n"); for (i, (name, pct, hint)) in rows.iter().enumerate() { let marker = if i == cursor as usize { ">" } else { " " }; body.push_str(&format!(" {marker} {name:<7} ({pct}) {hint}\n")); } body.push_str("\n↑/↓ choose · [Enter] confirm · [q] quit"); frame.render_widget(screen_widget(" Choose your tier ".to_string(), body), area); } fn render_level( frame: &mut Frame, area: Rect, prog: &Progress, registry: &[Box], ) { let idx = (prog.current_level - 1) as usize; let level = ®istry[idx]; let g = level.generate(prog.current_seed); let tier_label = prog.tier.map(|t| t.label()).unwrap_or("?"); let title = format!( " Level {}: {} · Tier: {} ", level.id(), level.name(), tier_label ); let body = format!( "\n{}\n\n{}\n\n[Tab] swap focus · [Ctrl-S] grade · [r] reset · [q] quit", g.flavor, g.description ); frame.render_widget(screen_widget(title, body), area); } fn render_result( frame: &mut Frame, area: Rect, score: f64, passed: bool, level_name: &str, prog: &Progress, ) { let threshold = prog.tier.map(|t| t.threshold()).unwrap_or(0.0); let (title, narration) = if passed { ( format!(" {level_name} cleared "), "The door creaks open. You step deeper into the labyrinth.", ) } else { ( " Not yet ".to_string(), "The labyrinth resists. Refine your YAML and try again.", ) }; let body = format!( "\nScore {:.0} % Threshold {:.0} %\n\n{}\n\n[Enter] continue", score * 100.0, threshold * 100.0, narration, ); frame.render_widget(screen_widget(title, body), area); } fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) { let body = format!( "\n{error}\n\nFix the YAML in the editor (it's still there) and press\n\ [Ctrl-S] to retry. Attempts counter is not bumped.\n\n\ [Enter] dismiss · [r] reset · [q] quit" ); frame.render_widget( screen_widget(" Couldn't parse your YAML ".to_string(), body), area, ); } fn render_reset_confirm(frame: &mut Frame, area: Rect) { let body = "\nWipe 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_completed(frame: &mut Frame, area: Rect) { let body = "\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string(); frame.render_widget(screen_widget(" Labyrinth complete ".to_string(), body), area); } 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 editor opens when you start a level.".to_string(); frame.render_widget(screen_widget(" Editor (inactive) ".to_string(), body), area); }