Various pre-release cosmetic fixes
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1496,6 +1496,7 @@ dependencies = [
|
|||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"similar",
|
"similar",
|
||||||
"tera",
|
"tera",
|
||||||
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ dirs = "5"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
ratatui = "0.26"
|
ratatui = "0.26"
|
||||||
crossterm = "0.27"
|
crossterm = "0.27"
|
||||||
|
unicode-width = "0.1"
|
||||||
|
|||||||
4
game.ini
4
game.ini
@@ -6,5 +6,5 @@
|
|||||||
typewriter_effect = true
|
typewriter_effect = true
|
||||||
|
|
||||||
# Typing speed in characters per minute. Higher = faster.
|
# Typing speed in characters per minute. Higher = faster.
|
||||||
# Defaults: 1000 cpm (~60 ms per character).
|
# Defaults: 2000 cpm (~30 ms per character).
|
||||||
typewriter_speed_cpm = 1000
|
typewriter_speed_cpm = 2000
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ Third level is dictionaries. Each direction now leads to a feature with its own
|
|||||||
depth: 10
|
depth: 10
|
||||||
straight:
|
straight:
|
||||||
type: wall
|
type: wall
|
||||||
depth:
|
depth:
|
||||||
|
|
||||||
|
The rendered description ends with a hint reminding the player that each
|
||||||
|
feature's name goes under a `type:` key (the property keeps its own name).
|
||||||
148
src/levels/l03_dict.rs
Normal file
148
src/levels/l03_dict.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//! Level 3 — dictionaries. Each direction leads to a feature with its
|
||||||
|
//! own type + one characteristic property.
|
||||||
|
//!
|
||||||
|
//! Paired design note: `l03.md`.
|
||||||
|
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
use rand_chacha::ChaCha8Rng;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_yaml::{Mapping, Value};
|
||||||
|
|
||||||
|
use crate::describe::Describer;
|
||||||
|
|
||||||
|
use super::{Generated, Level};
|
||||||
|
|
||||||
|
pub struct Dict;
|
||||||
|
|
||||||
|
const DIRECTIONS: &[&str] = &["left", "right", "straight", "back", "up", "down"];
|
||||||
|
|
||||||
|
enum Feature {
|
||||||
|
Door,
|
||||||
|
Tunnel,
|
||||||
|
Pit,
|
||||||
|
Stairs,
|
||||||
|
Wall,
|
||||||
|
Altar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Feature {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Feature::Door => "door",
|
||||||
|
Feature::Tunnel => "tunnel",
|
||||||
|
Feature::Pit => "pit",
|
||||||
|
Feature::Stairs => "stairs",
|
||||||
|
Feature::Wall => "wall",
|
||||||
|
Feature::Altar => "altar",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random(rng: &mut ChaCha8Rng) -> Self {
|
||||||
|
match rng.gen_range(0..6) {
|
||||||
|
0 => Feature::Door,
|
||||||
|
1 => Feature::Tunnel,
|
||||||
|
2 => Feature::Pit,
|
||||||
|
3 => Feature::Stairs,
|
||||||
|
4 => Feature::Wall,
|
||||||
|
_ => Feature::Altar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One characteristic property: (key, value).
|
||||||
|
fn property(&self, rng: &mut ChaCha8Rng) -> (&'static str, Value) {
|
||||||
|
match self {
|
||||||
|
Feature::Door => ("locked", Value::Bool(rng.gen_bool(0.5))),
|
||||||
|
Feature::Tunnel => ("depth", Value::from(rng.gen_range(5..=30i64))),
|
||||||
|
Feature::Pit => ("depth", Value::from(rng.gen_range(5..=30i64))),
|
||||||
|
Feature::Stairs => {
|
||||||
|
let going = if rng.gen_bool(0.5) { "up" } else { "down" };
|
||||||
|
("going", Value::String(going.to_string()))
|
||||||
|
}
|
||||||
|
Feature::Wall => ("cracked", Value::Bool(rng.gen_bool(0.5))),
|
||||||
|
Feature::Altar => ("blessed", Value::Bool(rng.gen_bool(0.5))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DescCtx {
|
||||||
|
entries: Vec<DescEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DescEntry {
|
||||||
|
direction: String,
|
||||||
|
feature: String,
|
||||||
|
prop_name: String,
|
||||||
|
prop_value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Level for Dict {
|
||||||
|
fn id(&self) -> u8 {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Dictionaries"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate(&self, seed: u64) -> Generated {
|
||||||
|
// Per-level constant so the same `current_seed` produces different
|
||||||
|
// content per level.
|
||||||
|
let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x0000_0000_0000_0003);
|
||||||
|
let n = rng.gen_range(2..=3);
|
||||||
|
let directions: Vec<&'static str> =
|
||||||
|
DIRECTIONS.choose_multiple(&mut rng, n).copied().collect();
|
||||||
|
|
||||||
|
let mut top = Mapping::new();
|
||||||
|
let mut entries = Vec::with_capacity(directions.len());
|
||||||
|
for d in &directions {
|
||||||
|
let feature = Feature::random(&mut rng);
|
||||||
|
let (prop_name, prop_value) = feature.property(&mut rng);
|
||||||
|
|
||||||
|
let mut inner = Mapping::new();
|
||||||
|
inner.insert(
|
||||||
|
Value::String("type".to_string()),
|
||||||
|
Value::String(feature.name().to_string()),
|
||||||
|
);
|
||||||
|
inner.insert(
|
||||||
|
Value::String(prop_name.to_string()),
|
||||||
|
prop_value.clone(),
|
||||||
|
);
|
||||||
|
top.insert(Value::String((*d).to_string()), Value::Mapping(inner));
|
||||||
|
|
||||||
|
let prop_value_str = match &prop_value {
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
entries.push(DescEntry {
|
||||||
|
direction: (*d).to_string(),
|
||||||
|
feature: feature.name().to_string(),
|
||||||
|
prop_name: prop_name.to_string(),
|
||||||
|
prop_value: prop_value_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_yaml =
|
||||||
|
serde_yaml::to_string(&Value::Mapping(top)).expect("serialise mapping");
|
||||||
|
|
||||||
|
let mut d = Describer::new();
|
||||||
|
d.register(
|
||||||
|
"l03",
|
||||||
|
"{% for e in entries %}- {{ e.direction }} → {{ e.feature }} ({{ e.prop_name }}: {{ e.prop_value }})\n{% endfor %}\n💡 Each feature is a dictionary — give it a `type:` key plus its property.",
|
||||||
|
)
|
||||||
|
.expect("register template");
|
||||||
|
let description = d
|
||||||
|
.render("l03", &DescCtx { entries })
|
||||||
|
.expect("render template");
|
||||||
|
|
||||||
|
Generated {
|
||||||
|
target_yaml,
|
||||||
|
description,
|
||||||
|
flavor: "🧭 You stand at a junction. Each path reveals its own detail.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
pub mod l01_minimum;
|
pub mod l01_minimum;
|
||||||
pub mod l02_kv;
|
pub mod l02_kv;
|
||||||
|
pub mod l03_dict;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -81,5 +82,6 @@ pub fn registry() -> Vec<Box<dyn Level>> {
|
|||||||
vec![
|
vec![
|
||||||
Box::new(l01_minimum::Minimum),
|
Box::new(l01_minimum::Minimum),
|
||||||
Box::new(l02_kv::KeyValue),
|
Box::new(l02_kv::KeyValue),
|
||||||
|
Box::new(l03_dict::Dict),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/lib.rs
18
src/lib.rs
@@ -84,7 +84,7 @@ mod smoke {
|
|||||||
#[test]
|
#[test]
|
||||||
fn levels_generate_canonical_yaml() {
|
fn levels_generate_canonical_yaml() {
|
||||||
let registry = levels::registry();
|
let registry = levels::registry();
|
||||||
assert_eq!(registry.len(), 2);
|
assert_eq!(registry.len(), 3);
|
||||||
|
|
||||||
// Level 1: any null-equivalent passes via the semantic short-circuit.
|
// Level 1: any null-equivalent passes via the semantic short-circuit.
|
||||||
let g1 = registry[0].generate(0);
|
let g1 = registry[0].generate(0);
|
||||||
@@ -110,5 +110,21 @@ mod smoke {
|
|||||||
g2.target_yaml, g2_again.target_yaml,
|
g2.target_yaml, g2_again.target_yaml,
|
||||||
"same seed should produce the same target"
|
"same seed should produce the same target"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Level 3: deterministic per seed; produces a mapping of mappings,
|
||||||
|
// each inner mapping has a `type` key.
|
||||||
|
let g3 = registry[2].generate(123);
|
||||||
|
let v3: serde_yaml::Value = serde_yaml::from_str(&g3.target_yaml).unwrap();
|
||||||
|
let m3 = v3.as_mapping().expect("level 3 produces a mapping");
|
||||||
|
assert!(!m3.is_empty());
|
||||||
|
for (_dir, feature) in m3 {
|
||||||
|
let inner = feature.as_mapping().expect("level 3 inner is a mapping");
|
||||||
|
assert!(
|
||||||
|
inner.get(serde_yaml::Value::String("type".into())).is_some(),
|
||||||
|
"each direction must carry a `type` key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let g3_again = registry[2].generate(123);
|
||||||
|
assert_eq!(g3.target_yaml, g3_again.target_yaml);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/tui.rs
232
src/tui.rs
@@ -7,8 +7,10 @@ use crossterm::terminal::{
|
|||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
||||||
use ratatui::{Frame, Terminal};
|
use ratatui::{Frame, Terminal};
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
use std::io::{stdout, Stdout};
|
use std::io::{stdout, Stdout};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -34,8 +36,11 @@ enum Focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum LogEntry {
|
enum LogEntry {
|
||||||
/// Game rules + the full controls listing. Seeded at startup.
|
/// Game rules + the controls hint. Seeded at startup; appears instantly.
|
||||||
Intro,
|
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 {
|
LevelPrompt {
|
||||||
level_id: u8,
|
level_id: u8,
|
||||||
level_name: String,
|
level_name: String,
|
||||||
@@ -257,10 +262,18 @@ fn leave() -> Result<()> {
|
|||||||
|
|
||||||
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) {
|
fn initial_state(prog: &Progress, registry: &[Box<dyn Level>]) -> (Screen, HistoryLog) {
|
||||||
let mut log = HistoryLog::new();
|
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
|
// The welcome content (Intro rules + Greeting) is seeded only for a
|
||||||
// time during play.
|
// fresh game — `current_level == 0` means never started or just reset.
|
||||||
log.push(LogEntry::Intro);
|
// 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 {
|
match prog.current_level {
|
||||||
0 => (Screen::Welcome, log),
|
0 => (Screen::Welcome, log),
|
||||||
@@ -373,9 +386,12 @@ fn main_loop(
|
|||||||
)? {
|
)? {
|
||||||
return Ok(());
|
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 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);
|
typewriter = Some(tw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,11 +406,11 @@ fn main_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `Typewriter` for the most recent `LevelPrompt` entry in the log,
|
/// Build a `Typewriter` for the most recent animated entry in the log
|
||||||
/// if any.
|
/// (either a `LevelPrompt` or the startup/post-reset `Greeting`).
|
||||||
fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option<Typewriter> {
|
fn start_typewriter(log: &HistoryLog, cfg: &config::GameConfig) -> Option<Typewriter> {
|
||||||
let idx = log.entries.iter().enumerate().rev().find_map(|(i, e)| {
|
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)
|
Some(i)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -518,6 +534,7 @@ fn step(
|
|||||||
editor.clear();
|
editor.clear();
|
||||||
*log = HistoryLog::new();
|
*log = HistoryLog::new();
|
||||||
log.push(LogEntry::Intro);
|
log.push(LogEntry::Intro);
|
||||||
|
log.push(LogEntry::Greeting);
|
||||||
*screen = Screen::Welcome;
|
*screen = Screen::Welcome;
|
||||||
*focus = default_focus(screen);
|
*focus = default_focus(screen);
|
||||||
}
|
}
|
||||||
@@ -773,14 +790,25 @@ fn render_log(
|
|||||||
all_lines.extend_from_slice(prompt);
|
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 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 max_scroll = total.saturating_sub(visible_h);
|
||||||
let scroll = log.scroll.min(max_scroll);
|
let scroll = log.scroll.min(max_scroll);
|
||||||
let end = total.saturating_sub(scroll);
|
let end = total.saturating_sub(scroll);
|
||||||
let start = end.saturating_sub(visible_h);
|
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.
|
// Pad the top so the slice sits flush with the bottom of the column.
|
||||||
let pad = visible_h.saturating_sub(slice.len());
|
let pad = visible_h.saturating_sub(slice.len());
|
||||||
@@ -793,7 +821,66 @@ fn render_log(
|
|||||||
} else {
|
} else {
|
||||||
" 📜 Labyrinth log ".to_string()
|
" 📜 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> {
|
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> {
|
fn entry_lines(entry: &LogEntry) -> Vec<String> {
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::Intro => vec![
|
LogEntry::Intro => vec![
|
||||||
"🏰 You stand at the entrance, brave explorer.".to_string(),
|
|
||||||
String::new(),
|
|
||||||
"🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(),
|
"🌀 YAMLabyrinth — learn YAML by writing your way through.".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
" Each chamber reveals a target. Match it with YAML in the editor".to_string(),
|
" 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(),
|
String::new(),
|
||||||
" Press [Ctrl-H] any time for the full controls.".to_string(),
|
" 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 {
|
LogEntry::LevelPrompt {
|
||||||
level_id,
|
level_id,
|
||||||
level_name,
|
level_name,
|
||||||
@@ -878,26 +966,62 @@ fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool)
|
|||||||
} else {
|
} else {
|
||||||
" 📝 Your YAML ".to_string()
|
" 📝 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 (cr, cc) = editor.cursor;
|
||||||
let mut lines: Vec<String> = Vec::with_capacity(editor.buffer.len());
|
let cursor_style = Style::default().bg(Color::White).fg(Color::Black);
|
||||||
for (r, line) in editor.buffer.iter().enumerate() {
|
|
||||||
if r == cr {
|
let lines: Vec<Line> = editor
|
||||||
let col = cc.min(line.len());
|
.buffer
|
||||||
let mut with_cursor = String::with_capacity(line.len() + 1);
|
.iter()
|
||||||
with_cursor.push_str(&line[..col]);
|
.enumerate()
|
||||||
with_cursor.push('_');
|
.map(|(r, line)| {
|
||||||
with_cursor.push_str(&line[col..]);
|
if r == cr {
|
||||||
lines.push(with_cursor);
|
editor_cursor_line(line, cc, cursor_style)
|
||||||
} else {
|
} else {
|
||||||
lines.push(line.clone());
|
Line::from(line.clone())
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
lines.join("\n")
|
.collect();
|
||||||
|
|
||||||
|
let p = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(p, area);
|
||||||
} else {
|
} else {
|
||||||
editor.text()
|
let p = Paragraph::new(editor.text())
|
||||||
};
|
.block(block)
|
||||||
frame.render_widget(screen_widget(title, body), area);
|
.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) {
|
fn render_editor_inactive(frame: &mut Frame, area: Rect) {
|
||||||
@@ -907,3 +1031,43 @@ fn render_editor_inactive(frame: &mut Frame, area: Rect) {
|
|||||||
area,
|
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