Various pre-release cosmetic fixes
This commit is contained in:
232
src/tui.rs
232
src/tui.rs
@@ -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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user