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 {
|
||||
Welcome,
|
||||
TierSelect {
|
||||
cursor: u8,
|
||||
},
|
||||
Level,
|
||||
Result {
|
||||
score: f64,
|
||||
passed: bool,
|
||||
level_name: String,
|
||||
},
|
||||
InvalidYaml {
|
||||
error: String,
|
||||
},
|
||||
TierSelect { cursor: u8 },
|
||||
/// Active play: left column = HistoryLog, right column = Editor.
|
||||
Game,
|
||||
ResetConfirm,
|
||||
Completed,
|
||||
}
|
||||
@@ -40,12 +31,72 @@ enum Focus {
|
||||
Editor,
|
||||
}
|
||||
|
||||
/// Multi-line YAML editor backing the right column. Byte-indexed; assumes
|
||||
/// ASCII content (YAML is conventionally ASCII). Splitting at a multi-byte
|
||||
/// boundary would panic — that's flagged as risk R6.
|
||||
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), // (row, col)
|
||||
cursor: (usize, usize),
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
@@ -166,34 +217,61 @@ fn leave() -> Result<()> {
|
||||
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) {
|
||||
(None, 0) => Screen::Welcome,
|
||||
(None, _) => Screen::TierSelect { cursor: 0 },
|
||||
(Some(_), 0) => Screen::TierSelect { cursor: 0 },
|
||||
(Some(_), n) if (n as usize) > registry_len => Screen::Completed,
|
||||
(Some(_), _) => Screen::Level,
|
||||
(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::Level | Screen::InvalidYaml { .. } => Focus::Editor,
|
||||
Screen::Game => Focus::Editor,
|
||||
_ => Focus::Game,
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_active(screen: &Screen) -> bool {
|
||||
matches!(
|
||||
screen,
|
||||
Screen::Level | Screen::Result { .. } | Screen::InvalidYaml { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn editor_interactive(screen: &Screen) -> bool {
|
||||
matches!(screen, Screen::Level | Screen::InvalidYaml { .. })
|
||||
}
|
||||
|
||||
// -- Main loop --------------------------------------------------------------
|
||||
|
||||
fn main_loop(
|
||||
@@ -201,12 +279,14 @@ fn main_loop(
|
||||
prog: &mut Progress,
|
||||
) -> Result<()> {
|
||||
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 editor = Editor::new();
|
||||
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
@@ -214,38 +294,49 @@ fn main_loop(
|
||||
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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the user wants to quit.
|
||||
/// 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 from anywhere.
|
||||
// Global: Ctrl-Q quits.
|
||||
if ctrl && key.code == KeyCode::Char('q') {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Global: Ctrl-S grades when the editor is in play.
|
||||
if ctrl && key.code == KeyCode::Char('s') && editor_interactive(screen) {
|
||||
let next = grade(editor.text(), prog, registry)?;
|
||||
*focus = default_focus(&next);
|
||||
*screen = next;
|
||||
// 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 the editor is interactive.
|
||||
if key.code == KeyCode::Tab && editor_interactive(screen) {
|
||||
// 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,
|
||||
@@ -253,10 +344,8 @@ fn step(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Editor keys (only when Editor is focused and screen is interactive).
|
||||
if *focus == Focus::Editor && editor_interactive(screen) {
|
||||
let was_invalid = matches!(screen, Screen::InvalidYaml { .. });
|
||||
let mut consumed = true;
|
||||
// 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(),
|
||||
@@ -267,32 +356,40 @@ fn step(
|
||||
KeyCode::Down => editor.down(),
|
||||
KeyCode::Home => editor.home(),
|
||||
KeyCode::End => editor.end(),
|
||||
KeyCode::Esc => {
|
||||
// Esc dismisses InvalidYaml (without retry) or returns focus
|
||||
// to the Game column on Level.
|
||||
if was_invalid {
|
||||
*screen = Screen::Level;
|
||||
*focus = default_focus(screen);
|
||||
} else {
|
||||
*focus = Focus::Game;
|
||||
}
|
||||
}
|
||||
_ => consumed = false,
|
||||
}
|
||||
if consumed && was_invalid {
|
||||
// Any edit implicitly dismisses the error overlay.
|
||||
*screen = Screen::Level;
|
||||
KeyCode::Esc => *focus = Focus::Game,
|
||||
_ => {}
|
||||
}
|
||||
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) {
|
||||
(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);
|
||||
@@ -303,7 +400,8 @@ fn step(
|
||||
*screen = Screen::TierSelect { cursor: c };
|
||||
}
|
||||
(Screen::TierSelect { cursor }, KeyCode::Enter) => {
|
||||
let tier = match cursor {
|
||||
let chosen = *cursor;
|
||||
let tier = match chosen {
|
||||
0 => Difficulty::Easy,
|
||||
1 => Difficulty::Medium,
|
||||
_ => Difficulty::Hard,
|
||||
@@ -315,63 +413,38 @@ fn step(
|
||||
}
|
||||
progress::save(prog)?;
|
||||
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);
|
||||
}
|
||||
(Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true),
|
||||
|
||||
(Screen::Level, KeyCode::Char('r')) => {
|
||||
*screen = Screen::ResetConfirm;
|
||||
*focus = default_focus(screen);
|
||||
}
|
||||
(Screen::Level, KeyCode::Char('q')) => return Ok(true),
|
||||
|
||||
(Screen::Result { passed, .. }, KeyCode::Enter) => {
|
||||
let pass = *passed;
|
||||
if pass {
|
||||
editor.clear();
|
||||
if (prog.current_level as usize) > registry.len() {
|
||||
*screen = Screen::Completed;
|
||||
} else {
|
||||
*screen = Screen::Level;
|
||||
}
|
||||
} else {
|
||||
*screen = Screen::Level;
|
||||
}
|
||||
*focus = default_focus(screen);
|
||||
}
|
||||
|
||||
(Screen::InvalidYaml { .. }, KeyCode::Enter) => {
|
||||
*screen = Screen::Level;
|
||||
*focus = default_focus(screen);
|
||||
}
|
||||
(Screen::InvalidYaml { .. }, KeyCode::Esc) => {
|
||||
*screen = Screen::Level;
|
||||
*focus = default_focus(screen);
|
||||
}
|
||||
(Screen::InvalidYaml { .. }, KeyCode::Char('r')) => {
|
||||
*screen = Screen::ResetConfirm;
|
||||
*focus = default_focus(screen);
|
||||
}
|
||||
(Screen::InvalidYaml { .. }, KeyCode::Char('q')) => return Ok(true),
|
||||
(Screen::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::Level;
|
||||
*screen = Screen::Game;
|
||||
*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 = Screen::ResetConfirm;
|
||||
*focus = default_focus(screen);
|
||||
}
|
||||
(Screen::Completed, KeyCode::Char('q')) => return Ok(true),
|
||||
|
||||
_ => {}
|
||||
}
|
||||
@@ -383,19 +456,22 @@ fn grade(
|
||||
candidate: String,
|
||||
prog: &mut Progress,
|
||||
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) {
|
||||
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 level = ®istry[idx];
|
||||
let g = level.generate(prog.current_seed);
|
||||
let score = similarity::semantic_or_textual(&g.target_yaml, &candidate);
|
||||
let threshold = prog
|
||||
.tier
|
||||
.expect("tier set before reaching the Level screen")
|
||||
.threshold();
|
||||
let 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();
|
||||
@@ -404,16 +480,31 @@ fn grade(
|
||||
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,
|
||||
});
|
||||
}
|
||||
progress::save(prog)?;
|
||||
|
||||
Ok(Screen::Result {
|
||||
score,
|
||||
passed,
|
||||
level_name,
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- Rendering --------------------------------------------------------------
|
||||
@@ -421,10 +512,9 @@ fn grade(
|
||||
fn render(
|
||||
frame: &mut Frame,
|
||||
screen: &Screen,
|
||||
prog: &Progress,
|
||||
registry: &[Box<dyn Level>],
|
||||
focus: Focus,
|
||||
editor: &Editor,
|
||||
log: &HistoryLog,
|
||||
) {
|
||||
let area = frame.size();
|
||||
let chunks = Layout::default()
|
||||
@@ -434,22 +524,16 @@ fn render(
|
||||
let (left, right) = (chunks[0], chunks[1]);
|
||||
|
||||
match screen {
|
||||
Screen::Welcome => render_welcome(frame, left),
|
||||
Screen::TierSelect { cursor } => render_tier_select(frame, left, *cursor),
|
||||
Screen::Level => render_level(frame, left, prog, registry),
|
||||
Screen::Result {
|
||||
score,
|
||||
passed,
|
||||
level_name,
|
||||
} => render_result(frame, left, *score, *passed, level_name, prog),
|
||||
Screen::InvalidYaml { error } => render_invalid_yaml(frame, left, error),
|
||||
Screen::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),
|
||||
Screen::Completed => render_completed(frame, left),
|
||||
}
|
||||
|
||||
if editor_active(screen) {
|
||||
let editor_focused = focus == Focus::Editor && editor_interactive(screen);
|
||||
render_editor(frame, right, editor, editor_focused);
|
||||
if matches!(screen, Screen::Game) {
|
||||
render_editor(frame, right, editor, focus == Focus::Editor);
|
||||
} else {
|
||||
render_editor_inactive(frame, right);
|
||||
}
|
||||
@@ -461,113 +545,172 @@ fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> {
|
||||
.wrap(Wrap { trim: false })
|
||||
}
|
||||
|
||||
fn render_welcome(frame: &mut Frame, area: Rect) {
|
||||
let body = "\n\
|
||||
You stand at the entrance to the YAML labyrinth.\n\
|
||||
Within, broken syntax festers and dictionaries\n\
|
||||
sprawl like vines.\n\
|
||||
\n\
|
||||
[Enter] begin · [q] flee"
|
||||
.to_string();
|
||||
frame.render_widget(screen_widget(" YAMLabyrinth ".to_string(), body), area);
|
||||
fn 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 render_tier_select(frame: &mut Frame, area: Rect, cursor: u8) {
|
||||
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"),
|
||||
("🥉", "Easy", "70 %", "forgive small slips"),
|
||||
("🥈", "Medium", "80 %", "most details must match"),
|
||||
("🥇", "Hard", "95 %", "only near-perfect passes"),
|
||||
];
|
||||
let mut body = String::from("\n");
|
||||
for (i, (name, pct, hint)) in rows.iter().enumerate() {
|
||||
let marker = if i == cursor as usize { ">" } else { " " };
|
||||
body.push_str(&format!(" {marker} {name:<7} ({pct}) {hint}\n"));
|
||||
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}"));
|
||||
}
|
||||
body.push_str("\n↑/↓ choose · [Enter] confirm · [q] quit");
|
||||
frame.render_widget(screen_widget(" Choose your tier ".to_string(), body), area);
|
||||
}
|
||||
|
||||
fn render_level(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
prog: &Progress,
|
||||
registry: &[Box<dyn Level>],
|
||||
) {
|
||||
let idx = (prog.current_level - 1) as usize;
|
||||
let level = ®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,
|
||||
);
|
||||
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 =
|
||||
"\nWipe progress and start over?\n\n[y] yes, wipe · any other key cancels".to_string();
|
||||
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_completed(frame: &mut Frame, area: Rect) {
|
||||
let body =
|
||||
"\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string();
|
||||
frame.render_widget(screen_widget(" Labyrinth complete ".to_string(), body), area);
|
||||
fn render_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()
|
||||
" 📝 Your YAML * ".to_string()
|
||||
} else {
|
||||
" Your YAML ".to_string()
|
||||
" 📝 Your YAML ".to_string()
|
||||
};
|
||||
let body = if focused {
|
||||
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) {
|
||||
let body = "\n The editor opens when you start a level.".to_string();
|
||||
frame.render_widget(screen_widget(" Editor (inactive) ".to_string(), body), area);
|
||||
let body = "\n 🪶 The quill rests. Pick a tier first…".to_string();
|
||||
frame.render_widget(
|
||||
screen_widget(" 📝 Editor (inactive) ".to_string(), body),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user