744 lines
23 KiB
Rust
744 lines
23 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 },
|
||
/// 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 = ®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<CrosstermBackend<Stdout>>,
|
||
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<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 = ®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<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,
|
||
);
|
||
}
|