Update TUI to scroll from the bottom

This commit is contained in:
2026-05-21 18:31:29 +03:00
parent fdcf6f3af3
commit 845cad7f74

View File

@@ -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 = &registry[idx];
let g = level.generate(prog.current_seed);
LogEntry::LevelPrompt {
level_id: level.id(),
level_name: level.name().to_string(),
tier_name: tier.name(),
flavor: g.flavor,
description: g.description,
} }
} }
fn default_focus(screen: &Screen) -> Focus { 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, &registry);
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, &registry, 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, &registry)? { if step(
&mut screen,
&mut focus,
&mut editor,
&mut log,
key,
prog,
&registry,
)? {
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 = &registry[idx]; let level = &registry[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 = &registry[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,
);
} }