Files
yamlabyrinth/src/tui.rs
2026-05-21 21:24:00 +03:00

1081 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use unicode_width::UnicodeWidthChar;
use std::io::{stdout, Stdout};
use std::time::{Duration, Instant};
use crate::config;
use crate::levels::{self, Level, Nugget};
use crate::progress::{self, Progress};
use crate::similarity;
// -- State ------------------------------------------------------------------
enum Screen {
Welcome,
/// Active play: left column = HistoryLog, right column = Editor.
Game,
ResetConfirm,
Completed,
}
#[derive(Copy, Clone, PartialEq, Eq)]
enum Focus {
Game,
Editor,
}
enum LogEntry {
/// Game rules + the controls hint. Seeded at startup; appears instantly.
Intro,
/// "🏰 You stand at the entrance…" Lives as a separate entry so only
/// the greeting (not the whole intro) gets the typewriter effect.
Greeting,
LevelPrompt {
level_id: u8,
level_name: String,
flavor: String,
description: String,
},
ResultPass {
level_name: String,
score: f64,
nugget: Nugget,
},
ResultFail {
level_name: String,
score: f64,
},
InvalidYaml {
error: String,
},
Completed {
gold: u32,
silver: u32,
bronze: u32,
},
}
/// Append-only chronological log of game events. Rendered bottom-anchored
/// in the left column. `scroll` is the number of lines offset from the
/// bottom; 0 means pinned to the latest entry.
struct HistoryLog {
entries: Vec<LogEntry>,
scroll: usize,
}
impl HistoryLog {
fn new() -> Self {
Self {
entries: vec![],
scroll: 0,
}
}
fn push(&mut self, entry: LogEntry) {
self.entries.push(entry);
// Always snap back to the bottom on a new event.
self.scroll = 0;
}
fn scroll_up(&mut self, n: usize) {
self.scroll = self.scroll.saturating_add(n);
}
fn scroll_down(&mut self, n: usize) {
self.scroll = self.scroll.saturating_sub(n);
}
}
/// Drives the character-by-character reveal of the latest `LevelPrompt`.
/// `entry_idx` is the index into `HistoryLog::entries` being animated.
struct Typewriter {
entry_idx: usize,
chars_shown: usize,
total_chars: usize,
char_duration: Duration,
next_tick_at: Instant,
}
impl Typewriter {
fn new(entry_idx: usize, total_chars: usize, char_duration: Duration) -> Self {
Self {
entry_idx,
chars_shown: 0,
total_chars,
char_duration,
next_tick_at: Instant::now() + char_duration,
}
}
fn is_done(&self) -> bool {
self.chars_shown >= self.total_chars
}
fn tick(&mut self) {
let now = Instant::now();
while self.next_tick_at <= now && self.chars_shown < self.total_chars {
self.chars_shown += 1;
self.next_tick_at += self.char_duration;
}
}
fn time_to_next_tick(&self) -> Duration {
let now = Instant::now();
self.next_tick_at.saturating_duration_since(now)
}
}
/// Multi-line YAML editor. Byte-indexed; ASCII-only (see risk R6).
struct Editor {
buffer: Vec<String>,
cursor: (usize, usize),
}
impl Editor {
fn new() -> Self {
Self {
buffer: vec![String::new()],
cursor: (0, 0),
}
}
fn clear(&mut self) {
self.buffer = vec![String::new()];
self.cursor = (0, 0);
}
fn text(&self) -> String {
self.buffer.join("\n")
}
fn insert_char(&mut self, c: char) {
let (r, col) = self.cursor;
let line = &mut self.buffer[r];
let col = col.min(line.len());
line.insert(col, c);
self.cursor = (r, col + 1);
}
fn backspace(&mut self) {
let (r, col) = self.cursor;
if col > 0 {
self.buffer[r].remove(col - 1);
self.cursor = (r, col - 1);
} else if r > 0 {
let cur = self.buffer.remove(r);
let prev_len = self.buffer[r - 1].len();
self.buffer[r - 1].push_str(&cur);
self.cursor = (r - 1, prev_len);
}
}
fn newline(&mut self) {
let (r, col) = self.cursor;
let col = col.min(self.buffer[r].len());
let rest = self.buffer[r].split_off(col);
self.buffer.insert(r + 1, rest);
self.cursor = (r + 1, 0);
}
fn left(&mut self) {
let (r, col) = self.cursor;
if col > 0 {
self.cursor.1 = col - 1;
} else if r > 0 {
self.cursor = (r - 1, self.buffer[r - 1].len());
}
}
fn right(&mut self) {
let (r, col) = self.cursor;
let line_len = self.buffer[r].len();
if col < line_len {
self.cursor.1 = col + 1;
} else if r + 1 < self.buffer.len() {
self.cursor = (r + 1, 0);
}
}
fn up(&mut self) {
if self.cursor.0 > 0 {
let r = self.cursor.0 - 1;
self.cursor = (r, self.cursor.1.min(self.buffer[r].len()));
}
}
fn down(&mut self) {
if self.cursor.0 + 1 < self.buffer.len() {
let r = self.cursor.0 + 1;
self.cursor = (r, self.cursor.1.min(self.buffer[r].len()));
}
}
fn home(&mut self) {
self.cursor.1 = 0;
}
fn end(&mut self) {
self.cursor.1 = self.buffer[self.cursor.0].len();
}
}
// -- Entry ------------------------------------------------------------------
pub fn run(prog: &mut Progress) -> Result<()> {
install_panic_hook();
let mut terminal = enter()?;
let result = main_loop(&mut terminal, prog);
leave()?;
result
}
fn install_panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = leave();
prev(info);
}));
}
fn enter() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen)?;
Ok(Terminal::new(CrosstermBackend::new(stdout()))?)
}
fn leave() -> Result<()> {
disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?;
Ok(())
}
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) {
let mut log = HistoryLog::new();
// The welcome content (Intro rules + Greeting) is seeded only for a
// fresh game — `current_level == 0` means never started or just reset.
// A resumed run drops straight into its current level prompt; the
// Greeting is re-seeded on reset (see the ResetConfirm handler).
if prog.current_level == 0 {
// Intro (rules + Ctrl-H hint) appears instantly. Greeting follows as
// the dramatic last line — it's the entry that gets the typewriter
// on a fresh start.
log.push(LogEntry::Intro);
log.push(LogEntry::Greeting);
}
match prog.current_level {
0 => (Screen::Welcome, log),
n if (n as usize) > registry.len() => {
let (gold, silver, bronze) = count_nuggets(&prog.nuggets);
log.push(LogEntry::Completed {
gold,
silver,
bronze,
});
(Screen::Completed, log)
}
_ => {
log.push(level_prompt_entry(prog, registry));
(Screen::Game, log)
}
}
}
fn level_prompt_entry(prog: &Progress, registry: &[Box<dyn Level>]) -> LogEntry {
let idx = (prog.current_level - 1) as usize;
let level = &registry[idx];
let g = level.generate(prog.current_seed);
LogEntry::LevelPrompt {
level_id: level.id(),
level_name: level.name().to_string(),
flavor: g.flavor,
description: g.description,
}
}
fn count_nuggets(nuggets: &[(u8, Nugget)]) -> (u32, u32, u32) {
let mut gold = 0;
let mut silver = 0;
let mut bronze = 0;
for (_, n) in nuggets {
match n {
Nugget::Gold => gold += 1,
Nugget::Silver => silver += 1,
Nugget::Bronze => bronze += 1,
}
}
(gold, silver, bronze)
}
fn default_focus(screen: &Screen) -> Focus {
match screen {
Screen::Game => Focus::Editor,
_ => Focus::Game,
}
}
// -- Main loop --------------------------------------------------------------
fn main_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
prog: &mut Progress,
) -> Result<()> {
let registry = levels::registry();
let cfg = config::load();
let (mut screen, mut log) = initial_state(prog, &registry);
let mut focus = default_focus(&screen);
let mut editor = Editor::new();
let mut help_open = false;
// Resume case: if the bottom of the log is a LevelPrompt, animate it
// from scratch so resumed sessions feel the same as fresh ones.
let mut typewriter = if cfg.typewriter_enabled {
start_typewriter(&log, &cfg)
} else {
None
};
let mut prev_log_len = log.entries.len();
loop {
terminal.draw(|frame| {
render(
frame,
&screen,
focus,
&editor,
&log,
typewriter.as_ref(),
help_open,
);
})?;
let poll_timeout = typewriter
.as_ref()
.filter(|tw| !tw.is_done())
.map(|tw| tw.time_to_next_tick())
.unwrap_or_else(|| Duration::from_secs(3600));
if event::poll(poll_timeout)? {
let Event::Key(key) = event::read()? else {
continue;
};
if !matches!(key.kind, KeyEventKind::Press) {
continue;
}
if step(
&mut screen,
&mut focus,
&mut editor,
&mut log,
&mut help_open,
key,
prog,
&registry,
)? {
return Ok(());
}
if cfg.typewriter_enabled && log.entries.len() != prev_log_len {
if let Some(tw) = start_typewriter(&log, &cfg) {
// Growth: only animate genuinely new entries.
// Shrink (reset wiped the log): always animate the new
// bottom entry (Greeting).
if log.entries.len() < prev_log_len || tw.entry_idx >= prev_log_len {
typewriter = Some(tw);
}
}
}
prev_log_len = log.entries.len();
} else if let Some(tw) = typewriter.as_mut() {
tw.tick();
if tw.is_done() {
typewriter = None;
}
}
}
}
/// Build a `Typewriter` for the most recent animated entry in the log
/// (either a `LevelPrompt` or the startup/post-reset `Greeting`).
fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option<Typewriter> {
let idx = log.entries.iter().enumerate().rev().find_map(|(i, e)| {
if matches!(e, LogEntry::LevelPrompt { .. } | LogEntry::Greeting) {
Some(i)
} else {
None
}
})?;
let total = entry_lines(&log.entries[idx])
.join("\n")
.chars()
.count();
Some(Typewriter::new(idx, total, cfg.char_duration()))
}
/// Returns `true` to quit the loop.
fn step(
screen: &mut Screen,
focus: &mut Focus,
editor: &mut Editor,
log: &mut HistoryLog,
help_open: &mut bool,
key: KeyEvent,
prog: &mut Progress,
registry: &[Box<dyn Level>],
) -> Result<bool> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Global: Ctrl-Q quits from anywhere, including the help dialog.
if ctrl && key.code == KeyCode::Char('q') {
return Ok(true);
}
// Help: Ctrl-H opens (idempotent).
if ctrl && key.code == KeyCode::Char('h') {
*help_open = true;
return Ok(false);
}
// While help is open, only Esc closes it; everything else is swallowed.
if *help_open {
if key.code == KeyCode::Esc {
*help_open = false;
}
return Ok(false);
}
// Global: Ctrl-X grades while in Game.
if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) {
grade(editor.text(), prog, registry, log)?;
if matches!(log.entries.last(), Some(LogEntry::Completed { .. })) {
*screen = Screen::Completed;
*focus = default_focus(screen);
}
return Ok(false);
}
// Tab toggles focus while in Game.
if key.code == KeyCode::Tab && matches!(screen, Screen::Game) {
*focus = match *focus {
Focus::Game => Focus::Editor,
Focus::Editor => Focus::Game,
};
return Ok(false);
}
// Editor focus inside Game: dispatch editing keys.
if *focus == Focus::Editor && matches!(screen, Screen::Game) {
match key.code {
KeyCode::Char(c) if !ctrl => editor.insert_char(c),
KeyCode::Backspace => editor.backspace(),
KeyCode::Enter => editor.newline(),
KeyCode::Left => editor.left(),
KeyCode::Right => editor.right(),
KeyCode::Up => editor.up(),
KeyCode::Down => editor.down(),
KeyCode::Home => editor.home(),
KeyCode::End => editor.end(),
KeyCode::Esc => *focus = Focus::Game,
_ => {}
}
return Ok(false);
}
// Game focus inside Game: scroll the log + shortcuts.
if matches!(screen, Screen::Game) && *focus == Focus::Game {
match key.code {
KeyCode::Up => log.scroll_up(1),
KeyCode::Down => log.scroll_down(1),
KeyCode::PageUp => log.scroll_up(10),
KeyCode::PageDown => log.scroll_down(10),
KeyCode::Char('r') => {
*screen = Screen::ResetConfirm;
*focus = default_focus(screen);
}
KeyCode::Char('q') => return Ok(true),
_ => {}
}
return Ok(false);
}
// Other screens (Welcome, ResetConfirm, Completed).
match (&*screen, key.code) {
(Screen::Welcome, KeyCode::Enter) => {
if prog.current_level == 0 {
prog.current_level = 1;
prog.current_seed = rand::random();
}
progress::save(prog)?;
editor.clear();
log.push(level_prompt_entry(prog, registry));
*screen = Screen::Game;
*focus = default_focus(screen);
}
(Screen::Welcome, KeyCode::Char('q')) => return Ok(true),
(Screen::Welcome, KeyCode::Up) => log.scroll_up(1),
(Screen::Welcome, KeyCode::Down) => log.scroll_down(1),
(Screen::Welcome, KeyCode::PageUp) => log.scroll_up(10),
(Screen::Welcome, KeyCode::PageDown) => log.scroll_down(10),
(Screen::ResetConfirm, KeyCode::Char('y')) => {
let _ = std::fs::remove_file(progress::save_path());
*prog = Progress::default();
editor.clear();
*log = HistoryLog::new();
log.push(LogEntry::Intro);
log.push(LogEntry::Greeting);
*screen = Screen::Welcome;
*focus = default_focus(screen);
}
(Screen::ResetConfirm, _) => {
*screen = Screen::Game;
*focus = default_focus(screen);
}
(Screen::Completed, KeyCode::Up) => log.scroll_up(1),
(Screen::Completed, KeyCode::Down) => log.scroll_down(1),
(Screen::Completed, KeyCode::PageUp) => log.scroll_up(10),
(Screen::Completed, KeyCode::PageDown) => log.scroll_down(10),
(Screen::Completed, KeyCode::Char('q')) => return Ok(true),
(Screen::Completed, KeyCode::Char('r')) => {
*screen = Screen::ResetConfirm;
*focus = default_focus(screen);
}
_ => {}
}
Ok(false)
}
fn grade(
candidate: String,
prog: &mut Progress,
registry: &[Box<dyn Level>],
log: &mut HistoryLog,
) -> Result<()> {
// Parse-first guard.
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
log.push(LogEntry::InvalidYaml {
error: e.to_string(),
});
return Ok(());
}
let idx = (prog.current_level - 1) as usize;
let level = &registry[idx];
let g = level.generate(prog.current_seed);
let score = similarity::semantic_or_textual(&g.target_yaml, &candidate);
let level_name = level.name().to_string();
let level_id = level.id();
match Nugget::from_score(score) {
Some(nugget) => {
prog.nuggets.push((level_id, nugget));
prog.current_level += 1;
prog.current_seed = rand::random();
progress::save(prog)?;
log.push(LogEntry::ResultPass {
level_name,
score,
nugget,
});
// Append the next LevelPrompt, or Completed if we ran out.
let next_idx = (prog.current_level - 1) as usize;
if next_idx >= registry.len() {
let (gold, silver, bronze) = count_nuggets(&prog.nuggets);
log.push(LogEntry::Completed {
gold,
silver,
bronze,
});
} else {
log.push(level_prompt_entry(prog, registry));
}
}
None => {
prog.attempts += 1;
progress::save(prog)?;
log.push(LogEntry::ResultFail { level_name, score });
}
}
Ok(())
}
// -- Rendering --------------------------------------------------------------
fn render(
frame: &mut Frame,
screen: &Screen,
focus: Focus,
editor: &Editor,
log: &HistoryLog,
typewriter: Option<&Typewriter>,
help_open: bool,
) {
// Outer layout: two-column area on top, 1-row status bar across the
// full width below.
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)].as_ref())
.split(frame.size());
let (top, status) = (outer[0], outer[1]);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(top);
let (left, right) = (chunks[0], chunks[1]);
match screen {
Screen::Welcome => render_log(frame, left, log, None, typewriter),
Screen::Game | Screen::Completed => render_log(frame, left, log, None, typewriter),
Screen::ResetConfirm => render_reset_confirm(frame, left),
}
if matches!(screen, Screen::Game) {
render_editor(frame, right, editor, focus == Focus::Editor);
} else {
render_editor_inactive(frame, right);
}
render_status_bar(frame, status, screen, focus, help_open);
// Help dialog overlay — drawn last so it sits on top of everything.
if help_open {
let area = centered_rect(70, 60, frame.size());
frame.render_widget(Clear, area);
frame.render_widget(help_widget(), area);
}
}
/// Center a rectangle of `pct_x` × `pct_y` percent inside `area`.
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
]
.as_ref(),
)
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
]
.as_ref(),
)
.split(vertical[1])[1]
}
fn help_widget<'a>() -> Paragraph<'a> {
let body = "\n\
⌨ Controls\n\
\n\
[Tab] swap focus between log and editor\n\
[Ctrl-X] grade your YAML\n\
[Ctrl-H] open this help\n\
[Esc] close help · or focus log when editing\n\
[↑/↓] scroll the log (when log is focused)\n\
[PgUp/PgDn] scroll faster\n\
[r] reset progress\n\
[q] / [Ctrl-Q] quit\n\
\n\
Press [Esc] to close."
.to_string();
Paragraph::new(body)
.block(
Block::default()
.borders(Borders::ALL)
.title(" 📜 Controls "),
)
.wrap(Wrap { trim: false })
}
fn render_status_bar(
frame: &mut Frame,
area: Rect,
screen: &Screen,
focus: Focus,
help_open: bool,
) {
let style = Style::default().fg(Color::White).bg(Color::DarkGray);
let p = Paragraph::new(status_text(screen, focus, help_open)).style(style);
frame.render_widget(p, area);
}
fn status_text(screen: &Screen, focus: Focus, help_open: bool) -> String {
if help_open {
return " [Esc] close help · [Ctrl-Q] quit".into();
}
match (screen, focus) {
(Screen::Welcome, _) => {
" [Enter] begin · [↑/↓] scroll · [PgUp/PgDn] page · [Ctrl-H] help · [q] quit".into()
}
(Screen::Game, Focus::Editor) => {
" [Ctrl-X] grade · [Tab] focus log · [Esc] focus log · [Ctrl-H] help · [Ctrl-Q] quit"
.into()
}
(Screen::Game, Focus::Game) => {
" [Ctrl-X] grade · [Tab] focus editor · [↑/↓] scroll · [r] reset · [Ctrl-H] help · [q] quit"
.into()
}
(Screen::ResetConfirm, _) => " [y] confirm wipe · any other key cancels".into(),
(Screen::Completed, _) => {
" [↑/↓] scroll · [PgUp/PgDn] page · [r] reset · [Ctrl-H] help · [q] quit".into()
}
}
}
fn screen_widget<'a>(title: String, body: String) -> Paragraph<'a> {
Paragraph::new(body)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false })
}
fn render_reset_confirm(frame: &mut Frame, area: Rect) {
let body = "\n💀 Wipe progress and start over?\n\n[y] yes, wipe · any other key cancels"
.to_string();
frame.render_widget(screen_widget(" Reset? ".to_string(), body), area);
}
fn render_log(
frame: &mut Frame,
area: Rect,
log: &HistoryLog,
trailing_prompt: Option<&[String]>,
typewriter: Option<&Typewriter>,
) {
// Flatten log entries into a single line stream, blank line between each.
let mut all_lines: Vec<String> = Vec::new();
for (i, entry) in log.entries.iter().enumerate() {
if i > 0 {
all_lines.push(String::new());
}
let entry_view = match typewriter {
Some(tw) if tw.entry_idx == i && !tw.is_done() => {
truncate_entry_to_chars(entry, tw.chars_shown)
}
_ => entry_lines(entry),
};
all_lines.extend(entry_view);
}
// Append the transient prompt (rare — none currently) as the bottom
// block. Kept for future use.
if let Some(prompt) = trailing_prompt {
if !all_lines.is_empty() {
all_lines.push(String::new());
}
all_lines.extend_from_slice(prompt);
}
// Wrap every logical line to the column's inner width *before* the
// bottom-anchor math. Otherwise ratatui's `Wrap` reflows long lines into
// extra visual rows the slice/pad math never accounts for, pushing the
// newest entries off the bottom edge. Wrapping here keeps the math in the
// visual-row units the terminal actually draws.
let inner_w = (area.width as usize).saturating_sub(2); // borders
let visible_h = (area.height as usize).saturating_sub(2); // borders
let mut rows: Vec<String> = Vec::new();
for line in &all_lines {
rows.extend(wrap_line(line, inner_w));
}
// Pick a visible slice anchored to the bottom, offset by `log.scroll`.
let total = rows.len();
let max_scroll = total.saturating_sub(visible_h);
let scroll = log.scroll.min(max_scroll);
let end = total.saturating_sub(scroll);
let start = end.saturating_sub(visible_h);
let slice = &rows[start..end];
// Pad the top so the slice sits flush with the bottom of the column.
let pad = visible_h.saturating_sub(slice.len());
let mut body_lines: Vec<String> = vec![String::new(); pad];
body_lines.extend_from_slice(slice);
let body = body_lines.join("\n");
let title = if log.scroll > 0 {
format!(" 📜 Labyrinth log · scrolled {}/{} ", scroll, max_scroll)
} else {
" 📜 Labyrinth log ".to_string()
};
// Rows are pre-wrapped to `inner_w`, so render *without* `Wrap`: our
// wrapping is the single source of truth and can't drift from ratatui's.
let block = Block::default().borders(Borders::ALL).title(title);
frame.render_widget(Paragraph::new(body).block(block), area);
}
/// Word-wrap one logical line to `width` display columns, returning the visual
/// rows it occupies — always at least one, even for an empty line. Display
/// width is measured with `unicode-width` so wide glyphs (emoji) count as the
/// two cells they draw as. Leading indentation is kept; a word longer than the
/// whole column is hard-split.
fn wrap_line(line: &str, width: usize) -> Vec<String> {
if width == 0 || line.is_empty() {
return vec![String::new()];
}
let char_w = |c: char| UnicodeWidthChar::width(c).unwrap_or(0);
// Tokenize into alternating runs of spaces and non-spaces so breaks land
// between words while (most) whitespace is preserved.
let mut tokens: Vec<String> = Vec::new();
for c in line.chars() {
let is_space = c == ' ';
match tokens.last_mut() {
Some(tok) if tok.starts_with(' ') == is_space => tok.push(c),
_ => tokens.push(c.to_string()),
}
}
let mut rows: Vec<String> = Vec::new();
let mut row = String::new();
let mut row_w = 0usize;
for token in tokens {
let token_w: usize = token.chars().map(char_w).sum();
if row_w + token_w <= width {
row.push_str(&token);
row_w += token_w;
} else if token.starts_with(' ') {
// Whitespace that would overflow: swallow it at the break.
rows.push(std::mem::take(&mut row));
row_w = 0;
} else if token_w <= width {
// Word fits on a row of its own — start a fresh row with it.
rows.push(std::mem::take(&mut row));
row = token;
row_w = token_w;
} else {
// Word wider than the whole column: hard-split it across rows.
for c in token.chars() {
let w = char_w(c);
if row_w + w > width {
rows.push(std::mem::take(&mut row));
row_w = 0;
}
row.push(c);
row_w += w;
}
}
}
rows.push(row);
rows
}
fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
if max_chars == 0 {
return Vec::new();
}
let full = entry_lines(entry).join("\n");
let truncated: String = full.chars().take(max_chars).collect();
truncated.split('\n').map(str::to_string).collect()
}
fn entry_lines(entry: &LogEntry) -> Vec<String> {
match entry {
LogEntry::Intro => vec![
"🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(),
String::new(),
" Each chamber reveals a target. Match it with YAML in the editor".to_string(),
" on the right, then press Ctrl-X to grade your attempt.".to_string(),
String::new(),
" Score ≥ 95 % → 🥇 Gold · ≥ 80 % → 🥈 Silver · ≥ 70 % → 🥉 Bronze.".to_string(),
" Below 70 % means retry — refine your YAML and press Ctrl-X again.".to_string(),
String::new(),
" Press [Ctrl-H] any time for the full controls.".to_string(),
],
LogEntry::Greeting => {
vec!["🏰 You stand at the entrance, brave explorer. Press [Enter] to ...well, enter the dungeon.".to_string()]
}
LogEntry::LevelPrompt {
level_id,
level_name,
flavor,
description,
} => {
let mut v = vec![
format!("🗺 Level {}{}", level_id, level_name),
flavor.clone(),
String::new(),
];
for line in description.lines() {
v.push(line.to_string());
}
v
}
LogEntry::ResultPass {
level_name,
score,
nugget,
} => vec![format!(
" {} {} cleared with a {} nugget at {:.0} % ✨",
nugget.emoji(),
level_name,
nugget.name(),
score * 100.0,
)],
LogEntry::ResultFail { level_name, score } => vec![
format!(
" 🕸 {} not yet — {:.0} % (need 70 % for a 🥉 Bronze)",
level_name,
score * 100.0,
),
" Refine your YAML and press Ctrl-X to retry.".to_string(),
],
LogEntry::InvalidYaml { error } => vec![
" ⚠ Couldn't parse YAML".to_string(),
format!(" {error}"),
" Fix it in the editor and press Ctrl-X to retry.".to_string(),
],
LogEntry::Completed {
gold,
silver,
bronze,
} => vec![
" 🏆 Labyrinth complete!".to_string(),
format!(" 🥇 ×{} 🥈 ×{} 🥉 ×{}", gold, silver, bronze),
" [r] reset and replay · [q] quit".to_string(),
],
}
}
fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool) {
let title = if focused {
" 📝 Your YAML * ".to_string()
} else {
" 📝 Your YAML ".to_string()
};
let block = Block::default().borders(Borders::ALL).title(title);
if focused {
// Highlight the character under the cursor with explicit bg + fg
// colours (not `REVERSED`) so the cursor cell is filled even when
// the underlying character is whitespace — some terminals skip
// background fill on reverse-video spaces.
let (cr, cc) = editor.cursor;
let cursor_style = Style::default().bg(Color::White).fg(Color::Black);
let lines: Vec<Line> = editor
.buffer
.iter()
.enumerate()
.map(|(r, line)| {
if r == cr {
editor_cursor_line(line, cc, cursor_style)
} else {
Line::from(line.clone())
}
})
.collect();
let p = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
frame.render_widget(p, area);
} else {
let p = Paragraph::new(editor.text())
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(p, area);
}
}
/// Build the cursor-bearing line as three spans: text before, the
/// highlighted character at the cursor, text after. If the cursor sits
/// past the end of the line (or on a whitespace cell), the highlight
/// falls on a visible block glyph — some terminals / ratatui's wrap
/// layer skip painting bg on styled space cells, which would leave the
/// cursor invisible.
const CURSOR_PLACEHOLDER: char = '▏';
fn editor_cursor_line(line: &str, col: usize, cursor_style: Style) -> Line<'static> {
let col = col.min(line.len());
let before = line[..col].to_string();
if col < line.len() {
let after = &line[col..];
let mut chars = after.char_indices();
let (_, c) = chars.next().expect("non-empty: col < line.len()");
let next_boundary = chars.next().map(|(i, _)| i).unwrap_or(after.len());
let glyph = if c.is_whitespace() { CURSOR_PLACEHOLDER } else { c };
Line::from(vec![
Span::raw(before),
Span::styled(glyph.to_string(), cursor_style),
Span::raw(after[next_boundary..].to_string()),
])
} else {
// Cursor past end of line — show the placeholder glyph.
Line::from(vec![
Span::raw(before),
Span::styled(CURSOR_PLACEHOLDER.to_string(), cursor_style),
])
}
}
fn render_editor_inactive(frame: &mut Frame, area: Rect) {
let body = "\n 🪶 The quill rests. Press [Enter] on the welcome screen to begin.".to_string();
frame.render_widget(
screen_widget(" 📝 Editor (inactive) ".to_string(), body),
area,
);
}
#[cfg(test)]
mod tests {
use super::wrap_line;
#[test]
fn empty_line_still_occupies_one_row() {
assert_eq!(wrap_line("", 10), vec![String::new()]);
}
#[test]
fn short_line_is_left_intact() {
assert_eq!(wrap_line("hello", 10), vec!["hello".to_string()]);
}
#[test]
fn breaks_between_words() {
assert_eq!(
wrap_line("alpha beta gamma", 10),
vec!["alpha beta".to_string(), "gamma".to_string()],
);
}
#[test]
fn hard_splits_a_word_longer_than_the_column() {
assert_eq!(
wrap_line("abcdefghij", 4),
vec!["abcd".to_string(), "efgh".to_string(), "ij".to_string()],
);
}
#[test]
fn wide_emoji_counts_as_two_cells() {
// "ab " is 3 cells, "🥇" adds 2 → fills width 5 exactly; " cd" wraps.
assert_eq!(
wrap_line("ab 🥇 cd", 5),
vec!["ab 🥇".to_string(), "cd".to_string()],
);
}
}