1081 lines
34 KiB
Rust
1081 lines
34 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::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<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);
|
||
}
|
||
}
|
||
|
||
/// 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<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 initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (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<dyn Level>]) -> 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<CrosstermBackend<Stdout>>,
|
||
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<Typewriter> {
|
||
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<dyn Level>],
|
||
) -> Result<bool> {
|
||
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<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 = ®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<String> = 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<String> = 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<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()
|
||
};
|
||
// 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<String> {
|
||
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<String> = 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<String> = 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<String> {
|
||
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<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-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<Line> = 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()],
|
||
);
|
||
}
|
||
}
|