Add typewriter effect

This commit is contained in:
2026-05-21 18:52:19 +03:00
parent 740685afc5
commit 4b3b1ce5a0
4 changed files with 274 additions and 22 deletions

View File

@@ -9,7 +9,9 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use std::io::{stdout, Stdout};
use std::time::{Duration, Instant};
use crate::config;
use crate::levels::{self, Difficulty, Level};
use crate::progress::{self, Progress};
use crate::similarity;
@@ -93,6 +95,45 @@ impl HistoryLog {
}
}
/// 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>,
@@ -279,35 +320,82 @@ fn main_loop(
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();
// If resume put a LevelPrompt at the bottom, typewriter it from scratch.
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);
render(frame, &screen, focus, &editor, &log, typewriter.as_ref());
})?;
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,
key,
prog,
&registry,
)? {
return Ok(());
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,
key,
prog,
&registry,
)? {
return Ok(());
}
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 {
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 `LevelPrompt` entry in the log,
/// if any.
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 { .. }) {
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,
@@ -515,6 +603,7 @@ fn render(
focus: Focus,
editor: &Editor,
log: &HistoryLog,
typewriter: Option<&Typewriter>,
) {
let area = frame.size();
let chunks = Layout::default()
@@ -524,11 +613,17 @@ fn render(
let (left, right) = (chunks[0], chunks[1]);
match screen {
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::Welcome => {
render_log(frame, left, log, Some(&welcome_prompt_lines()), typewriter)
}
Screen::Game | Screen::Completed => render_log(frame, left, log, None),
Screen::TierSelect { cursor } => render_log(
frame,
left,
log,
Some(&tier_picker_lines(*cursor)),
typewriter,
),
Screen::Game | Screen::Completed => render_log(frame, left, log, None, typewriter),
Screen::ResetConfirm => render_reset_confirm(frame, left),
}
@@ -579,6 +674,7 @@ fn render_log(
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();
@@ -586,7 +682,13 @@ fn render_log(
if i > 0 {
all_lines.push(String::new());
}
all_lines.extend(entry_lines(entry));
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 (Welcome / TierSelect) as the bottom block.
// It's part of the bottom-anchored view but not part of the log itself.
@@ -620,6 +722,15 @@ fn render_log(
frame.render_widget(screen_widget(title, body), area);
}
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![