Files
yamlabyrinth/src/tui.rs

744 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<LogEntry>,
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<String>,
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<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 tier_index(t: Difficulty) -> u8 {
match t {
Difficulty::Easy => 0,
Difficulty::Medium => 1,
Difficulty::Hard => 2,
}
}
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (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<dyn Level>],
tier: Difficulty,
) -> LogEntry {
let idx = (prog.current_level - 1) as usize;
let level = &registry[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<CrosstermBackend<Stdout>>,
prog: &mut Progress,
) -> Result<()> {
let registry = levels::registry();
let (mut screen, mut log) = initial_state(prog, &registry);
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,
&registry,
)? {
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<dyn Level>],
) -> Result<bool> {
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<dyn Level>],
log: &mut HistoryLog,
) -> Result<()> {
// Parse-first guard.
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
log.push(LogEntry::InvalidYaml {
error: e.to_string(),
});
return Ok(());
}
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 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<String> {
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<String> {
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<String> = 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<String> = 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<String> {
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<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 quill rests. Pick a tier first…".to_string();
frame.render_widget(
screen_widget(" 📝 Editor (inactive) ".to_string(), body),
area,
);
}