Files
yamlabyrinth/src/tui.rs
2026-05-21 17:51:22 +03:00

598 lines
18 KiB
Rust

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<String>,
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<Terminal<CrosstermBackend<Stdout>>> {
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<CrosstermBackend<Stdout>>,
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, &registry, 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, &registry)? {
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<dyn Level>],
) -> Result<bool> {
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<dyn Level>],
) -> Result<Screen> {
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
return Ok(Screen::InvalidYaml { error: e.to_string() });
}
let idx = (prog.current_level - 1) as usize;
let level = &registry[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<dyn Level>],
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<dyn Level>],
) {
let idx = (prog.current_level - 1) as usize;
let level = &registry[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<String> = 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);
}