Update TUI to scroll from the bottom
This commit is contained in:
596
src/tui.rs
596
src/tui.rs
@@ -18,18 +18,9 @@ use crate::similarity;
|
|||||||
|
|
||||||
enum Screen {
|
enum Screen {
|
||||||
Welcome,
|
Welcome,
|
||||||
TierSelect {
|
TierSelect { cursor: u8 },
|
||||||
cursor: u8,
|
/// Active play: left column = HistoryLog, right column = Editor.
|
||||||
},
|
Game,
|
||||||
Level,
|
|
||||||
Result {
|
|
||||||
score: f64,
|
|
||||||
passed: bool,
|
|
||||||
level_name: String,
|
|
||||||
},
|
|
||||||
InvalidYaml {
|
|
||||||
error: String,
|
|
||||||
},
|
|
||||||
ResetConfirm,
|
ResetConfirm,
|
||||||
Completed,
|
Completed,
|
||||||
}
|
}
|
||||||
@@ -40,12 +31,72 @@ enum Focus {
|
|||||||
Editor,
|
Editor,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multi-line YAML editor backing the right column. Byte-indexed; assumes
|
enum LogEntry {
|
||||||
/// ASCII content (YAML is conventionally ASCII). Splitting at a multi-byte
|
/// Game rules + the full controls listing. Appended once on
|
||||||
/// boundary would panic — that's flagged as risk R6.
|
/// 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 {
|
struct Editor {
|
||||||
buffer: Vec<String>,
|
buffer: Vec<String>,
|
||||||
cursor: (usize, usize), // (row, col)
|
cursor: (usize, usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Editor {
|
impl Editor {
|
||||||
@@ -166,34 +217,61 @@ fn leave() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initial_screen(prog: &Progress, registry_len: usize) -> Screen {
|
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) {
|
match (prog.tier, prog.current_level) {
|
||||||
(None, 0) => Screen::Welcome,
|
(None, 0) => (Screen::Welcome, log),
|
||||||
(None, _) => Screen::TierSelect { cursor: 0 },
|
(None, _) => (Screen::TierSelect { cursor: 0 }, log),
|
||||||
(Some(_), 0) => Screen::TierSelect { cursor: 0 },
|
(Some(tier), 0) => (Screen::TierSelect { cursor: tier_index(tier) }, log),
|
||||||
(Some(_), n) if (n as usize) > registry_len => Screen::Completed,
|
(Some(_), n) if (n as usize) > registry.len() => {
|
||||||
(Some(_), _) => Screen::Level,
|
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 {
|
fn default_focus(screen: &Screen) -> Focus {
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Level | Screen::InvalidYaml { .. } => Focus::Editor,
|
Screen::Game => Focus::Editor,
|
||||||
_ => Focus::Game,
|
_ => 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 --------------------------------------------------------------
|
// -- Main loop --------------------------------------------------------------
|
||||||
|
|
||||||
fn main_loop(
|
fn main_loop(
|
||||||
@@ -201,12 +279,14 @@ fn main_loop(
|
|||||||
prog: &mut Progress,
|
prog: &mut Progress,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let registry = levels::registry();
|
let registry = levels::registry();
|
||||||
let mut screen = initial_screen(prog, registry.len());
|
let (mut screen, mut log) = initial_state(prog, ®istry);
|
||||||
let mut focus = default_focus(&screen);
|
let mut focus = default_focus(&screen);
|
||||||
let mut editor = Editor::new();
|
let mut editor = Editor::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| render(frame, &screen, prog, ®istry, focus, &editor))?;
|
terminal.draw(|frame| {
|
||||||
|
render(frame, &screen, focus, &editor, &log);
|
||||||
|
})?;
|
||||||
let Event::Key(key) = event::read()? else {
|
let Event::Key(key) = event::read()? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -214,38 +294,49 @@ fn main_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if step(&mut screen, &mut focus, &mut editor, key, prog, ®istry)? {
|
if step(
|
||||||
|
&mut screen,
|
||||||
|
&mut focus,
|
||||||
|
&mut editor,
|
||||||
|
&mut log,
|
||||||
|
key,
|
||||||
|
prog,
|
||||||
|
®istry,
|
||||||
|
)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when the user wants to quit.
|
/// Returns `true` to quit the loop.
|
||||||
fn step(
|
fn step(
|
||||||
screen: &mut Screen,
|
screen: &mut Screen,
|
||||||
focus: &mut Focus,
|
focus: &mut Focus,
|
||||||
editor: &mut Editor,
|
editor: &mut Editor,
|
||||||
|
log: &mut HistoryLog,
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
prog: &mut Progress,
|
prog: &mut Progress,
|
||||||
registry: &[Box<dyn Level>],
|
registry: &[Box<dyn Level>],
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
// Global: Ctrl-Q quits from anywhere.
|
// Global: Ctrl-Q quits.
|
||||||
if ctrl && key.code == KeyCode::Char('q') {
|
if ctrl && key.code == KeyCode::Char('q') {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global: Ctrl-S grades when the editor is in play.
|
// Global: Ctrl-S grades while in Game.
|
||||||
if ctrl && key.code == KeyCode::Char('s') && editor_interactive(screen) {
|
if ctrl && key.code == KeyCode::Char('s') && matches!(screen, Screen::Game) {
|
||||||
let next = grade(editor.text(), prog, registry)?;
|
grade(editor.text(), prog, registry, log)?;
|
||||||
*focus = default_focus(&next);
|
if matches!(log.entries.last(), Some(LogEntry::Completed)) {
|
||||||
*screen = next;
|
*screen = Screen::Completed;
|
||||||
|
*focus = default_focus(screen);
|
||||||
|
}
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab toggles focus while the editor is interactive.
|
// Tab toggles focus while in Game.
|
||||||
if key.code == KeyCode::Tab && editor_interactive(screen) {
|
if key.code == KeyCode::Tab && matches!(screen, Screen::Game) {
|
||||||
*focus = match *focus {
|
*focus = match *focus {
|
||||||
Focus::Game => Focus::Editor,
|
Focus::Game => Focus::Editor,
|
||||||
Focus::Editor => Focus::Game,
|
Focus::Editor => Focus::Game,
|
||||||
@@ -253,10 +344,8 @@ fn step(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor keys (only when Editor is focused and screen is interactive).
|
// Editor focus inside Game: dispatch editing keys.
|
||||||
if *focus == Focus::Editor && editor_interactive(screen) {
|
if *focus == Focus::Editor && matches!(screen, Screen::Game) {
|
||||||
let was_invalid = matches!(screen, Screen::InvalidYaml { .. });
|
|
||||||
let mut consumed = true;
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char(c) if !ctrl => editor.insert_char(c),
|
KeyCode::Char(c) if !ctrl => editor.insert_char(c),
|
||||||
KeyCode::Backspace => editor.backspace(),
|
KeyCode::Backspace => editor.backspace(),
|
||||||
@@ -267,32 +356,40 @@ fn step(
|
|||||||
KeyCode::Down => editor.down(),
|
KeyCode::Down => editor.down(),
|
||||||
KeyCode::Home => editor.home(),
|
KeyCode::Home => editor.home(),
|
||||||
KeyCode::End => editor.end(),
|
KeyCode::End => editor.end(),
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => *focus = Focus::Game,
|
||||||
// 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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game-focus keys.
|
// 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) {
|
match (&*screen, key.code) {
|
||||||
(Screen::Welcome, KeyCode::Enter) => {
|
(Screen::Welcome, KeyCode::Enter) => {
|
||||||
*screen = Screen::TierSelect { cursor: 0 };
|
*screen = Screen::TierSelect { cursor: 0 };
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
(Screen::Welcome, KeyCode::Char('q')) => return Ok(true),
|
(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) => {
|
(Screen::TierSelect { cursor }, KeyCode::Up) => {
|
||||||
let c = cursor.saturating_sub(1);
|
let c = cursor.saturating_sub(1);
|
||||||
@@ -303,7 +400,8 @@ fn step(
|
|||||||
*screen = Screen::TierSelect { cursor: c };
|
*screen = Screen::TierSelect { cursor: c };
|
||||||
}
|
}
|
||||||
(Screen::TierSelect { cursor }, KeyCode::Enter) => {
|
(Screen::TierSelect { cursor }, KeyCode::Enter) => {
|
||||||
let tier = match cursor {
|
let chosen = *cursor;
|
||||||
|
let tier = match chosen {
|
||||||
0 => Difficulty::Easy,
|
0 => Difficulty::Easy,
|
||||||
1 => Difficulty::Medium,
|
1 => Difficulty::Medium,
|
||||||
_ => Difficulty::Hard,
|
_ => Difficulty::Hard,
|
||||||
@@ -315,63 +413,38 @@ fn step(
|
|||||||
}
|
}
|
||||||
progress::save(prog)?;
|
progress::save(prog)?;
|
||||||
editor.clear();
|
editor.clear();
|
||||||
*screen = Screen::Level;
|
log.push(LogEntry::TierChoice { chosen });
|
||||||
|
log.push(level_prompt_entry(prog, registry, tier));
|
||||||
|
*screen = Screen::Game;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
(Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true),
|
(Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true),
|
||||||
|
(Screen::TierSelect { .. }, KeyCode::PageUp) => log.scroll_up(10),
|
||||||
(Screen::Level, KeyCode::Char('r')) => {
|
(Screen::TierSelect { .. }, KeyCode::PageDown) => log.scroll_down(10),
|
||||||
*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')) => {
|
(Screen::ResetConfirm, KeyCode::Char('y')) => {
|
||||||
let _ = std::fs::remove_file(progress::save_path());
|
let _ = std::fs::remove_file(progress::save_path());
|
||||||
*prog = Progress::default();
|
*prog = Progress::default();
|
||||||
editor.clear();
|
editor.clear();
|
||||||
|
*log = HistoryLog::new();
|
||||||
|
log.push(LogEntry::Intro);
|
||||||
*screen = Screen::Welcome;
|
*screen = Screen::Welcome;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
(Screen::ResetConfirm, _) => {
|
(Screen::ResetConfirm, _) => {
|
||||||
*screen = Screen::Level;
|
*screen = Screen::Game;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
|
|
||||||
(Screen::Completed, KeyCode::Char('q')) => return Ok(true),
|
(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::Completed, KeyCode::Char('r')) => {
|
||||||
*screen = Screen::ResetConfirm;
|
*screen = Screen::ResetConfirm;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
|
(Screen::Completed, KeyCode::Char('q')) => return Ok(true),
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -383,19 +456,22 @@ fn grade(
|
|||||||
candidate: String,
|
candidate: String,
|
||||||
prog: &mut Progress,
|
prog: &mut Progress,
|
||||||
registry: &[Box<dyn Level>],
|
registry: &[Box<dyn Level>],
|
||||||
) -> Result<Screen> {
|
log: &mut HistoryLog,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Parse-first guard.
|
||||||
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
|
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
|
||||||
return Ok(Screen::InvalidYaml { error: e.to_string() });
|
log.push(LogEntry::InvalidYaml {
|
||||||
|
error: e.to_string(),
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let idx = (prog.current_level - 1) as usize;
|
let idx = (prog.current_level - 1) as usize;
|
||||||
let level = ®istry[idx];
|
let level = ®istry[idx];
|
||||||
let g = level.generate(prog.current_seed);
|
let g = level.generate(prog.current_seed);
|
||||||
let score = similarity::semantic_or_textual(&g.target_yaml, &candidate);
|
let score = similarity::semantic_or_textual(&g.target_yaml, &candidate);
|
||||||
let threshold = prog
|
let tier = prog.tier.expect("tier set before grading");
|
||||||
.tier
|
let threshold = tier.threshold();
|
||||||
.expect("tier set before reaching the Level screen")
|
|
||||||
.threshold();
|
|
||||||
let passed = score >= threshold;
|
let passed = score >= threshold;
|
||||||
let level_name = level.name().to_string();
|
let level_name = level.name().to_string();
|
||||||
let level_id = level.id();
|
let level_id = level.id();
|
||||||
@@ -404,16 +480,31 @@ fn grade(
|
|||||||
prog.completed.push(level_id);
|
prog.completed.push(level_id);
|
||||||
prog.current_level += 1;
|
prog.current_level += 1;
|
||||||
prog.current_seed = rand::random();
|
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 {
|
} else {
|
||||||
prog.attempts += 1;
|
prog.attempts += 1;
|
||||||
}
|
|
||||||
progress::save(prog)?;
|
progress::save(prog)?;
|
||||||
|
log.push(LogEntry::ResultFail {
|
||||||
Ok(Screen::Result {
|
|
||||||
score,
|
|
||||||
passed,
|
|
||||||
level_name,
|
level_name,
|
||||||
})
|
score,
|
||||||
|
threshold,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Rendering --------------------------------------------------------------
|
// -- Rendering --------------------------------------------------------------
|
||||||
@@ -421,10 +512,9 @@ fn grade(
|
|||||||
fn render(
|
fn render(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
screen: &Screen,
|
screen: &Screen,
|
||||||
prog: &Progress,
|
|
||||||
registry: &[Box<dyn Level>],
|
|
||||||
focus: Focus,
|
focus: Focus,
|
||||||
editor: &Editor,
|
editor: &Editor,
|
||||||
|
log: &HistoryLog,
|
||||||
) {
|
) {
|
||||||
let area = frame.size();
|
let area = frame.size();
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
@@ -434,22 +524,16 @@ fn render(
|
|||||||
let (left, right) = (chunks[0], chunks[1]);
|
let (left, right) = (chunks[0], chunks[1]);
|
||||||
|
|
||||||
match screen {
|
match screen {
|
||||||
Screen::Welcome => render_welcome(frame, left),
|
Screen::Welcome => render_log(frame, left, log, Some(&welcome_prompt_lines())),
|
||||||
Screen::TierSelect { cursor } => render_tier_select(frame, left, *cursor),
|
Screen::TierSelect { cursor } => {
|
||||||
Screen::Level => render_level(frame, left, prog, registry),
|
render_log(frame, left, log, Some(&tier_picker_lines(*cursor)))
|
||||||
Screen::Result {
|
}
|
||||||
score,
|
Screen::Game | Screen::Completed => render_log(frame, left, log, None),
|
||||||
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::ResetConfirm => render_reset_confirm(frame, left),
|
||||||
Screen::Completed => render_completed(frame, left),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if editor_active(screen) {
|
if matches!(screen, Screen::Game) {
|
||||||
let editor_focused = focus == Focus::Editor && editor_interactive(screen);
|
render_editor(frame, right, editor, focus == Focus::Editor);
|
||||||
render_editor(frame, right, editor, editor_focused);
|
|
||||||
} else {
|
} else {
|
||||||
render_editor_inactive(frame, right);
|
render_editor_inactive(frame, right);
|
||||||
}
|
}
|
||||||
@@ -461,113 +545,172 @@ fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> {
|
|||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_welcome(frame: &mut Frame, area: Rect) {
|
fn welcome_prompt_lines() -> Vec<String> {
|
||||||
let body = "\n\
|
vec![
|
||||||
You stand at the entrance to the YAML labyrinth.\n\
|
"🏰 You stand at the entrance.".to_string(),
|
||||||
Within, broken syntax festers and dictionaries\n\
|
" [Enter] begin · [q] flee · [PgUp/PgDn] scroll the log".to_string(),
|
||||||
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) {
|
fn tier_picker_lines(cursor: u8) -> Vec<String> {
|
||||||
let rows = [
|
let rows = [
|
||||||
("Easy", "70 %", "forgive small slips"),
|
("🥉", "Easy", "70 %", "forgive small slips"),
|
||||||
("Medium", "80 %", "most details must match"),
|
("🥈", "Medium", "80 %", "most details must match"),
|
||||||
("Hard", "95 %", "only near-perfect passes"),
|
("🥇", "Hard", "95 %", "only near-perfect passes"),
|
||||||
];
|
];
|
||||||
let mut body = String::from("\n");
|
let mut v = vec!["🎚 Choose your tier:".to_string()];
|
||||||
for (i, (name, pct, hint)) in rows.iter().enumerate() {
|
for (i, (emoji, name, pct, hint)) in rows.iter().enumerate() {
|
||||||
let marker = if i == cursor as usize { ">" } else { " " };
|
let marker = if i == cursor as usize { "›" } else { " " };
|
||||||
body.push_str(&format!(" {marker} {name:<7} ({pct}) {hint}\n"));
|
v.push(format!(" {marker} {emoji} {name:<7} ({pct}) {hint}"));
|
||||||
}
|
}
|
||||||
body.push_str("\n↑/↓ choose · [Enter] confirm · [q] quit");
|
v.push(String::new());
|
||||||
frame.render_widget(screen_widget(" Choose your tier ".to_string(), body), area);
|
v.push(" ↑/↓ choose · [Enter] confirm · [PgUp/PgDn] scroll · [q] quit".to_string());
|
||||||
}
|
v
|
||||||
|
|
||||||
fn render_level(
|
|
||||||
frame: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
prog: &Progress,
|
|
||||||
registry: &[Box<dyn Level>],
|
|
||||||
) {
|
|
||||||
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) {
|
fn render_reset_confirm(frame: &mut Frame, area: Rect) {
|
||||||
let body =
|
let body = "\n💀 Wipe progress and start over?\n\n[y] yes, wipe · any other key cancels"
|
||||||
"\nWipe progress and start over?\n\n[y] yes, wipe · any other key cancels".to_string();
|
.to_string();
|
||||||
frame.render_widget(screen_widget(" Reset? ".to_string(), body), area);
|
frame.render_widget(screen_widget(" Reset? ".to_string(), body), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_completed(frame: &mut Frame, area: Rect) {
|
fn render_log(
|
||||||
let body =
|
frame: &mut Frame,
|
||||||
"\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string();
|
area: Rect,
|
||||||
frame.render_widget(screen_widget(" Labyrinth complete ".to_string(), body), area);
|
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) {
|
fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool) {
|
||||||
let title = if focused {
|
let title = if focused {
|
||||||
" Your YAML * ".to_string()
|
" 📝 Your YAML * ".to_string()
|
||||||
} else {
|
} else {
|
||||||
" Your YAML ".to_string()
|
" 📝 Your YAML ".to_string()
|
||||||
};
|
};
|
||||||
let body = if focused {
|
let body = if focused {
|
||||||
let (cr, cc) = editor.cursor;
|
let (cr, cc) = editor.cursor;
|
||||||
@@ -592,6 +735,9 @@ fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_editor_inactive(frame: &mut Frame, area: Rect) {
|
fn render_editor_inactive(frame: &mut Frame, area: Rect) {
|
||||||
let body = "\n The editor opens when you start a level.".to_string();
|
let body = "\n 🪶 The quill rests. Pick a tier first…".to_string();
|
||||||
frame.render_widget(screen_widget(" Editor (inactive) ".to_string(), body), area);
|
frame.render_widget(
|
||||||
|
screen_widget(" 📝 Editor (inactive) ".to_string(), body),
|
||||||
|
area,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user