Various pre-release cosmetic fixes

This commit is contained in:
2026-05-21 21:21:32 +03:00
parent cb0abb3e3b
commit f817c7b93e
8 changed files with 373 additions and 38 deletions

View File

@@ -7,8 +7,10 @@ use crossterm::terminal::{
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};
@@ -34,8 +36,11 @@ enum Focus {
}
enum LogEntry {
/// Game rules + the full controls listing. Seeded at startup.
/// 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,
@@ -257,10 +262,18 @@ fn leave() -> Result<()> {
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) {
let mut log = HistoryLog::new();
// The Intro entry is always at the bottom of the log on startup so
// Welcome can show it inline; the player can scroll back to it any
// time during play.
log.push(LogEntry::Intro);
// 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),
@@ -373,9 +386,12 @@ fn main_loop(
)? {
return Ok(());
}
if cfg.typewriter_enabled && log.entries.len() > prev_log_len {
if cfg.typewriter_enabled && log.entries.len() != prev_log_len {
if let Some(tw) = start_typewriter(&log, &cfg) {
if tw.entry_idx >= prev_log_len {
// 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);
}
}
@@ -390,11 +406,11 @@ fn main_loop(
}
}
/// Build a `Typewriter` for the most recent `LevelPrompt` entry in the log,
/// if any.
/// 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 { .. }) {
if matches!(e, LogEntry::LevelPrompt { .. } | LogEntry::Greeting) {
Some(i)
} else {
None
@@ -518,6 +534,7 @@ fn step(
editor.clear();
*log = HistoryLog::new();
log.push(LogEntry::Intro);
log.push(LogEntry::Greeting);
*screen = Screen::Welcome;
*focus = default_focus(screen);
}
@@ -773,14 +790,25 @@ fn render_log(
all_lines.extend_from_slice(prompt);
}
// Pick a visible slice anchored to the bottom, offset by `log.scroll`.
// 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 total = all_lines.len();
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 = &all_lines[start..end];
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());
@@ -793,7 +821,66 @@ fn render_log(
} else {
" 📜 Labyrinth log ".to_string()
};
frame.render_widget(screen_widget(title, body), area);
// 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> {
@@ -808,8 +895,6 @@ fn truncate_entry_to_chars(entry: &LogEntry, max_chars: usize) -> Vec<String> {
fn entry_lines(entry: &LogEntry) -> Vec<String> {
match entry {
LogEntry::Intro => vec![
"🏰 You stand at the entrance, brave explorer.".to_string(),
String::new(),
"🌀 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(),
@@ -820,6 +905,9 @@ fn entry_lines(entry: &LogEntry) -> Vec<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,
@@ -878,26 +966,62 @@ fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool)
} else {
" 📝 Your YAML ".to_string()
};
let body = if focused {
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 mut lines: Vec<String> = Vec::with_capacity(editor.buffer.len());
for (r, line) in editor.buffer.iter().enumerate() {
if r == cr {
let col = cc.min(line.len());
let mut with_cursor = String::with_capacity(line.len() + 1);
with_cursor.push_str(&line[..col]);
with_cursor.push('_');
with_cursor.push_str(&line[col..]);
lines.push(with_cursor);
} else {
lines.push(line.clone());
}
}
lines.join("\n")
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 {
editor.text()
};
frame.render_widget(screen_widget(title, body), area);
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, the highlight falls on a trailing space.
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());
Line::from(vec![
Span::raw(before),
Span::styled(c.to_string(), cursor_style),
Span::raw(after[next_boundary..].to_string()),
])
} else {
Line::from(vec![
Span::raw(before),
Span::styled(" ".to_string(), cursor_style),
])
}
}
fn render_editor_inactive(frame: &mut Frame, area: Rect) {
@@ -907,3 +1031,43 @@ fn render_editor_inactive(frame: &mut Frame, area: Rect) {
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()],
);
}
}