dotzuki_engine/text/mod.rs
1//! # text
2//!
3//! Trait-based text engine for JRPG dialog systems.
4//!
5//! This module defines the [`TextProvider`] trait — the abstraction that
6//! game-specific implementations must fulfill to provide character mapping,
7//! rendering, and control-code handling. It also provides [`TileBuffer`]
8//! (the rendering surface), [`DialogState`], [`TextStream`], [`ControlAction`],
9//! and [`DialogEngine`] — a general-purpose dialog engine that drives text
10//! display one character per frame.
11//!
12//! ## Design
13//!
14//! - **Provider pattern**: All game-specific text encoding lives in the
15//! [`TextProvider`] implementation. The engine owns no charmap data.
16//! - **No game-specific semantics**: Control codes like trainer name, monster
17//! name, and item substitutions come from the game-specific provider.
18//! - **Frame-by-frame typing**: [`DialogEngine::update`] processes one
19//! character per call, enabling the classic typewriter effect.
20
21// ── TilePos ────────────────────────────────────────────────────────
22
23/// A position on the tile grid, measured in tiles.
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct TilePos {
27 /// Horizontal position (0 = left edge).
28 pub x: u16,
29 /// Vertical position (0 = top edge).
30 pub y: u16,
31}
32
33impl TilePos {
34 /// Creates a new tile position.
35 pub fn new(x: u16, y: u16) -> Self {
36 Self { x, y }
37 }
38}
39
40// ── TileEntry ──────────────────────────────────────────────────────
41
42/// A single cell in the tile buffer — which tile to draw and with what ink.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct TileEntry {
45 /// Index into the tileset's tile table.
46 pub tile_id: u16,
47 /// Ink / colour index (0 = transparent / background).
48 pub ink: u8,
49}
50
51impl Default for TileEntry {
52 fn default() -> Self {
53 Self { tile_id: 0, ink: 0 }
54 }
55}
56
57// ── TileBuffer ─────────────────────────────────────────────────────
58
59/// A configurable-width tile grid representing the text rendering surface.
60///
61/// Each cell carries a tile ID and ink colour. The `cursor` tracks the
62/// current write position for the next character. Dimensions are set at
63/// construction time via [`TileBuffer::new`].
64pub struct TileBuffer {
65 /// Row-major tile entries. `tiles[y * width_tiles + x]`.
66 pub tiles: Vec<TileEntry>,
67 /// Width of the buffer in tiles.
68 pub width_tiles: u16,
69 /// Height of the buffer in tiles.
70 pub height_tiles: u16,
71 /// Current text cursor position.
72 pub cursor: TilePos,
73}
74
75impl TileBuffer {
76 /// Creates a new, empty tile buffer with the cursor at (0, 0).
77 pub fn new(width_tiles: u16, height_tiles: u16) -> Self {
78 let size = (width_tiles as usize) * (height_tiles as usize);
79 Self {
80 tiles: vec![TileEntry::default(); size],
81 width_tiles,
82 height_tiles,
83 cursor: TilePos::new(0, 0),
84 }
85 }
86
87 /// Converts (x, y) to a linear index. Does not bounds-check.
88 #[inline]
89 fn index(&self, x: u16, y: u16) -> usize {
90 (y * self.width_tiles + x) as usize
91 }
92
93 /// Clears all tiles and resets the cursor to (0, 0).
94 pub fn clear(&mut self) {
95 self.tiles.fill(TileEntry::default());
96 self.cursor = TilePos::new(0, 0);
97 }
98
99 /// Writes a tile at the given position. Silently does nothing if
100 /// the position is out of bounds.
101 pub fn set_tile(&mut self, pos: TilePos, tile_id: u16, ink: u8) {
102 if pos.x < self.width_tiles && pos.y < self.height_tiles {
103 let idx = self.index(pos.x, pos.y);
104 self.tiles[idx] = TileEntry { tile_id, ink };
105 }
106 }
107
108 /// Moves the cursor to the start of the next line.
109 /// Does not wrap or scroll.
110 pub fn newline(&mut self) {
111 self.cursor.x = 0;
112 self.cursor.y += 1;
113 }
114
115 /// Scrolls the entire buffer up by one row. The top row is discarded
116 /// and the bottom row is filled with default entries.
117 pub fn scroll(&mut self) {
118 for y in 1..self.height_tiles {
119 for x in 0..self.width_tiles {
120 let src = self.index(x, y);
121 let dst = self.index(x, y - 1);
122 self.tiles[dst] = self.tiles[src];
123 }
124 }
125 for x in 0..self.width_tiles {
126 let idx = self.index(x, self.height_tiles - 1);
127 self.tiles[idx] = TileEntry::default();
128 }
129 }
130}
131
132impl Default for TileBuffer {
133 fn default() -> Self {
134 Self::new(20, 18)
135 }
136}
137
138// ── TextStream ─────────────────────────────────────────────────────
139
140/// A decoded character stream with a cursor for sequential reading.
141///
142/// `C` is the character type produced by the [`TextProvider`].
143pub struct TextStream<C> {
144 /// All decoded characters.
145 pub chars: Vec<C>,
146 /// Current read position.
147 pub pos: usize,
148}
149
150impl<C> TextStream<C> {
151 /// Creates a new stream from a vector of decoded characters.
152 pub fn new(chars: Vec<C>) -> Self {
153 Self { chars, pos: 0 }
154 }
155
156 /// Returns the next character and advances the cursor, or `None`
157 /// if the stream is exhausted.
158 pub fn next(&mut self) -> Option<&C> {
159 let c = self.chars.get(self.pos);
160 if c.is_some() {
161 self.pos += 1;
162 }
163 c
164 }
165
166 /// Returns the next character without advancing the cursor,
167 /// or `None` if the stream is exhausted.
168 pub fn peek(&self) -> Option<&C> {
169 self.chars.get(self.pos)
170 }
171
172 /// Advances the cursor by `n` positions, clamped to the stream length.
173 pub fn skip(&mut self, n: usize) {
174 self.pos = (self.pos + n).min(self.chars.len());
175 }
176
177 /// Returns a slice of all characters from the current position onward.
178 pub fn remaining(&self) -> &[C] {
179 &self.chars[self.pos..]
180 }
181
182 /// Returns `true` if the cursor has reached the end of the stream.
183 pub fn is_at_end(&self) -> bool {
184 self.pos >= self.chars.len()
185 }
186
187 /// Returns the current cursor position.
188 pub fn position(&self) -> usize {
189 self.pos
190 }
191}
192
193// ── ControlAction ──────────────────────────────────────────────────
194
195/// Action produced when a control code is processed by the text provider.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ControlAction {
198 /// No action — continue processing.
199 None,
200 /// Move the cursor to the next line.
201 Newline,
202 /// Pause until the player presses a button, then clear the text box.
203 PageBreak,
204 /// End the current dialog.
205 Done,
206 /// Change the text speed (0 = instant, higher = slower).
207 SetSpeed(u8),
208 /// Clear the text buffer.
209 Clear,
210 /// Jump to a named script handler.
211 CallScript,
212 /// Pause until the player presses a button.
213 WaitInput,
214 /// Move the cursor to a specific tile position.
215 MoveCursor { x: u16, y: u16 },
216 /// Scroll the buffer up by one row (discards top row).
217 Scroll,
218 /// Pause for a given number of frames.
219 Pause(u8),
220}
221
222// ── DialogState ────────────────────────────────────────────────────
223
224/// The current operational mode of the dialog engine.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum DialogMode {
227 /// Characters are being typed out, one per frame.
228 Typing,
229 /// The text engine is paused (e.g. a `TX_PAUSE` delay).
230 Paused,
231 /// Waiting for the player to press A to continue.
232 WaitingForInput,
233 /// Waiting for the player to press A after a scroll prompt.
234 Scrolling,
235 /// The dialog has ended.
236 Done,
237}
238
239/// Full state of the dialog engine.
240///
241/// Carries mode information together with positioning bookmarks that
242/// survive across page breaks and scroll events.
243#[derive(Debug, Clone)]
244pub struct DialogState {
245 /// Current operational mode.
246 pub mode: DialogMode,
247 /// Which page of the dialog is currently active.
248 pub page_index: usize,
249 /// Index into the current page's character sequence.
250 pub char_index: usize,
251 /// Vertical scroll offset in pixels (for smooth scrolling).
252 pub scroll_offset: u16,
253}
254
255impl Default for DialogState {
256 fn default() -> Self {
257 Self {
258 mode: DialogMode::Typing,
259 page_index: 0,
260 char_index: 0,
261 scroll_offset: 0,
262 }
263 }
264}
265
266// ── TextProvider trait ─────────────────────────────────────────────
267
268/// The core abstraction for text encoding in a JRPG engine.
269///
270/// Implementations provide:
271///
272/// - A character type (`Char`) representing decoded text units.
273/// - Single-byte decoding (`decode_byte`) from a custom charmap.
274/// - Stream decoding (`decode_stream`) from raw byte sequences.
275/// - Tile rendering (`render_char`) into a [`TileBuffer`].
276/// - Width measurement (`string_width`) for layout.
277/// - Control-code detection (`is_control_code`) and processing
278/// (`process_control`).
279///
280/// # Associated Type
281///
282/// * `Char` — The decoded character type. May be an enum with variants
283/// for printable characters, control codes, and substitutions.
284///
285/// # Example
286///
287/// ```ignore
288/// struct MyProvider;
289///
290/// impl TextProvider for MyProvider {
291/// type Char = MyChar;
292/// // ...
293/// }
294/// ```
295pub trait TextProvider {
296 /// The decoded character type produced by this provider.
297 type Char: Clone + core::fmt::Debug;
298
299 /// Decodes a single byte into an optional character.
300 ///
301 /// Returns `None` if the byte is not recognised by this charmap.
302 fn decode_byte(&self, byte: u8) -> Option<Self::Char>;
303
304 /// Decodes a byte slice into a [`TextStream`].
305 ///
306 /// The default implementation calls [`decode_byte`] for each byte,
307 /// collecting all `Some` results. Override for multi-byte encodings.
308 fn decode_stream(&self, bytes: &[u8]) -> TextStream<Self::Char> {
309 let chars: Vec<Self::Char> = bytes.iter().filter_map(|&b| self.decode_byte(b)).collect();
310 TextStream::new(chars)
311 }
312
313 /// Draws a single character into the tile buffer.
314 ///
315 /// Implementations should write the character's tile at the buffer's
316 /// current cursor position and advance the cursor by the appropriate
317 /// amount (typically one tile for fixed-width fonts).
318 fn render_char(&self, c: &Self::Char, buffer: &mut TileBuffer);
319
320 /// Returns the pixel width of the given text when rendered.
321 ///
322 /// Used for text layout and centering calculations.
323 fn string_width(&self, text: &[Self::Char]) -> u16;
324
325 /// Returns `true` if the character is a control code (not printable).
326 fn is_control_code(&self, c: &Self::Char) -> bool;
327
328 /// Processes a control code and returns the resulting action.
329 ///
330 /// The `state` parameter allows the provider to inspect or modify
331 /// the dialog state (e.g. to track page breaks).
332 fn process_control(&self, c: &Self::Char, state: &mut DialogState) -> ControlAction;
333}
334
335// ── DialogEngine ───────────────────────────────────────────────────
336
337/// A general-purpose dialog engine that drives text display one character
338/// per frame.
339///
340/// `P` is the [`TextProvider`] implementation that supplies the game's
341/// character encoding and rendering logic.
342///
343/// # Usage
344///
345/// ```ignore
346/// let provider = MyProvider::new();
347/// let mut engine = DialogEngine::new(provider);
348/// let mut buffer = TileBuffer::new(20, 18);
349///
350/// engine.open_dialog(&[0x48, 0x45, 0x4C, 0x4C, 0x4F]); // "HELLO"
351///
352/// while engine.is_active() {
353/// engine.update(&mut buffer); // one char per frame
354/// }
355///
356/// engine.advance(); // close dialog
357/// ```
358pub struct DialogEngine<P: TextProvider> {
359 /// The text provider (charmap, rendering, control codes).
360 pub provider: P,
361 /// Active character stream, or `None` if no dialog is open.
362 pub stream: Option<TextStream<P::Char>>,
363 /// Current dialog state.
364 pub state: DialogState,
365}
366
367impl<P: TextProvider> DialogEngine<P> {
368 /// Creates a new dialog engine with the given text provider.
369 pub fn new(provider: P) -> Self {
370 Self {
371 provider,
372 stream: None,
373 state: DialogState::default(),
374 }
375 }
376
377 /// Opens a dialog with the given raw byte text.
378 ///
379 /// The bytes are decoded via the provider's [`TextProvider::decode_stream`]
380 /// and the state is reset to the initial typing mode.
381 pub fn open_dialog(&mut self, text: &[u8]) {
382 self.stream = Some(self.provider.decode_stream(text));
383 self.state = DialogState::default();
384 }
385
386 /// Processes one character from the current dialog stream.
387 ///
388 /// Call this once per frame to achieve the classic typewriter effect.
389 /// Control codes are dispatched to the provider's
390 /// [`TextProvider::process_control`]; printable characters are drawn
391 /// via [`TextProvider::render_char`].
392 ///
393 /// If the engine is not in [`DialogMode::Typing`], this call is a no-op.
394 /// If the stream is exhausted or a `Done` control action is returned,
395 /// the dialog state transitions to [`DialogMode::Done`].
396 pub fn update(&mut self, buffer: &mut TileBuffer) {
397 if self.state.mode != DialogMode::Typing {
398 return;
399 }
400
401 let stream = match &mut self.stream {
402 Some(s) => s,
403 None => return,
404 };
405
406 let c = match stream.next() {
407 Some(c) => c.clone(),
408 None => {
409 self.state.mode = DialogMode::Done;
410 return;
411 }
412 };
413
414 if self.provider.is_control_code(&c) {
415 let action = self.provider.process_control(&c, &mut self.state);
416 match action {
417 ControlAction::Newline => buffer.newline(),
418 ControlAction::Done => {
419 self.state.mode = DialogMode::Done;
420 }
421 ControlAction::Clear => buffer.clear(),
422 ControlAction::PageBreak => {
423 self.state.mode = DialogMode::WaitingForInput;
424 }
425 ControlAction::WaitInput => {
426 self.state.mode = DialogMode::WaitingForInput;
427 }
428 ControlAction::MoveCursor { x, y } => {
429 buffer.cursor = TilePos::new(x, y);
430 }
431 ControlAction::Scroll => buffer.scroll(),
432 ControlAction::Pause(_frames) => {
433 self.state.mode = DialogMode::Paused;
434 }
435 _ => {}
436 }
437 } else {
438 self.provider.render_char(&c, buffer);
439 }
440
441 // Check if we exhausted the stream in this update.
442 // Don't override waiting/paused states — let advance() handle completion.
443 if stream.is_at_end() && self.state.mode == DialogMode::Typing {
444 self.state.mode = DialogMode::Done;
445 }
446 }
447
448 /// Advances the dialog past the current page or closes it.
449 ///
450 /// - If paused, resumes typing.
451 /// - If waiting for input or scrolling, resumes typing.
452 /// - If the dialog is done, clears the stream and resets state
453 /// (so [`is_active`] returns `false`).
454 ///
455 /// Call this in response to the player pressing the A button.
456 pub fn advance(&mut self) {
457 match self.state.mode {
458 DialogMode::Paused => {
459 self.state.mode = DialogMode::Typing;
460 }
461 DialogMode::WaitingForInput | DialogMode::Scrolling => {
462 self.state.mode = DialogMode::Typing;
463 }
464 DialogMode::Done => {
465 self.stream = None;
466 self.state = DialogState::default();
467 }
468 _ => {}
469 }
470 }
471
472 /// Returns `true` if a dialog is currently active.
473 ///
474 /// A dialog is active when a stream is loaded and the mode is not
475 /// [`DialogMode::Done`].
476 pub fn is_active(&self) -> bool {
477 self.stream.is_some() && self.state.mode != DialogMode::Done
478 }
479}
480
481// ===========================================================================
482// Unit tests
483// ===========================================================================
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 // ── Mock types ────────────────────────────────────────────────
490
491 /// Mock character type for testing with a simple ASCII-like encoding.
492 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
493 enum MockChar {
494 /// Printable ASCII character (0x41-0x5A → 'A'-'Z').
495 Ascii(char),
496 /// Digit (0x00-0x09 → '0'-'9').
497 Digit(u8),
498 /// Move to next line (0xFE).
499 Newline,
500 /// End of dialog (0xFF).
501 Done,
502 }
503
504 /// A [`TextProvider`] that uses a minimal ASCII-like charmap.
505 ///
506 /// Mapping:
507 ///
508 /// | Byte range | MockChar |
509 /// |-----------|---------------------|
510 /// | 0x00-0x09 | `Digit(d)` |
511 /// | 0x41-0x5A | `Ascii(c)` |
512 /// | 0xFE | `Newline` |
513 /// | 0xFF | `Done` |
514 struct MockAsciiProvider;
515
516 impl TextProvider for MockAsciiProvider {
517 type Char = MockChar;
518
519 fn decode_byte(&self, byte: u8) -> Option<Self::Char> {
520 match byte {
521 b @ 0x00..=0x09 => Some(MockChar::Digit(b)),
522 0x41..=0x5A => Some(MockChar::Ascii(byte as char)),
523 0xFE => Some(MockChar::Newline),
524 0xFF => Some(MockChar::Done),
525 _ => None,
526 }
527 }
528
529 fn render_char(&self, c: &Self::Char, buffer: &mut TileBuffer) {
530 let tile_id = match c {
531 MockChar::Ascii(ch) => *ch as u16,
532 MockChar::Digit(d) => (b'0' + d) as u16,
533 // Control codes should not reach `render_char`.
534 MockChar::Newline | MockChar::Done => return,
535 };
536 let pos = buffer.cursor;
537 buffer.set_tile(pos, tile_id, 0);
538 buffer.cursor.x += 1;
539 }
540
541 fn string_width(&self, text: &[Self::Char]) -> u16 {
542 // Fixed-width: 8 pixels per character.
543 text.len() as u16 * 8
544 }
545
546 fn is_control_code(&self, c: &Self::Char) -> bool {
547 matches!(c, MockChar::Newline | MockChar::Done)
548 }
549
550 fn process_control(&self, c: &Self::Char, _state: &mut DialogState) -> ControlAction {
551 match c {
552 MockChar::Newline => ControlAction::Newline,
553 MockChar::Done => ControlAction::Done,
554 _ => ControlAction::None,
555 }
556 }
557 }
558
559 // ── Tests ─────────────────────────────────────────────────────
560
561 #[test]
562 fn test_decode_hello_world() {
563 let provider = MockAsciiProvider;
564 let bytes = [0x48, 0x45, 0x4C, 0x4C, 0x4F]; // HELLO
565 let stream = provider.decode_stream(&bytes);
566 assert_eq!(stream.chars.len(), 5);
567 assert_eq!(stream.chars[0], MockChar::Ascii('H'));
568 assert_eq!(stream.chars[1], MockChar::Ascii('E'));
569 assert_eq!(stream.chars[2], MockChar::Ascii('L'));
570 assert_eq!(stream.chars[3], MockChar::Ascii('L'));
571 assert_eq!(stream.chars[4], MockChar::Ascii('O'));
572 }
573
574 #[test]
575 fn test_decode_done_control() {
576 let provider = MockAsciiProvider;
577 assert_eq!(provider.decode_byte(0xFF), Some(MockChar::Done));
578 assert!(provider.is_control_code(&MockChar::Done));
579 }
580
581 #[test]
582 fn test_dialog_engine_hello() {
583 let provider = MockAsciiProvider;
584 let mut engine = DialogEngine::new(provider);
585 let mut buffer = TileBuffer::new(20, 18);
586
587 // "HELLO" + DONE
588 engine.open_dialog(&[0x48, 0x45, 0x4C, 0x4C, 0x4F, 0xFF]);
589
590 assert!(engine.is_active());
591
592 // Process all 6 characters
593 for _ in 0..6 {
594 engine.update(&mut buffer);
595 }
596
597 // Verify H, E, L, L, O at positions (0,0)-(4,0)
598 let tiles = &buffer.tiles;
599 assert_eq!(tiles[0].tile_id, b'H' as u16);
600 assert_eq!(tiles[1].tile_id, b'E' as u16);
601 assert_eq!(tiles[2].tile_id, b'L' as u16);
602 assert_eq!(tiles[3].tile_id, b'L' as u16);
603 assert_eq!(tiles[4].tile_id, b'O' as u16);
604
605 // Position (5,0) should still be default
606 assert_eq!(tiles[5].tile_id, 0);
607 }
608
609 #[test]
610 fn test_dialog_engine_done_deactivates() {
611 let provider = MockAsciiProvider;
612 let mut engine = DialogEngine::new(provider);
613 let mut buffer = TileBuffer::new(20, 18);
614
615 engine.open_dialog(&[0x48, 0xFF]); // "H" + DONE
616
617 engine.update(&mut buffer); // 'H'
618 engine.update(&mut buffer); // DONE
619 assert!(!engine.is_active());
620 }
621
622 #[test]
623 fn test_advance_after_done() {
624 let provider = MockAsciiProvider;
625 let mut engine = DialogEngine::new(provider);
626 let mut buffer = TileBuffer::new(20, 18);
627
628 engine.open_dialog(&[0x48, 0xFF]); // "H" + DONE
629
630 engine.update(&mut buffer); // 'H'
631 engine.update(&mut buffer); // DONE
632
633 // After DONE, engine should not be active
634 assert!(!engine.is_active());
635 assert_eq!(engine.stream.is_some(), true); // stream still there
636
637 // advance() should clear the stream
638 engine.advance();
639 assert!(!engine.is_active());
640 assert!(engine.stream.is_none());
641 }
642
643 #[test]
644 fn test_tile_buffer_clear() {
645 let mut buffer = TileBuffer::new(20, 18);
646 buffer.set_tile(TilePos::new(5, 5), 0x42, 1);
647
648 assert_eq!(buffer.tiles[buffer.index(5, 5)].tile_id, 0x42);
649 buffer.clear();
650 assert_eq!(buffer.tiles[buffer.index(5, 5)].tile_id, 0);
651 assert_eq!(buffer.cursor, TilePos::new(0, 0));
652 }
653
654 #[test]
655 fn test_tile_buffer_newline() {
656 let mut buffer = TileBuffer::new(20, 18);
657 buffer.cursor = TilePos::new(12, 5);
658
659 buffer.newline();
660 assert_eq!(buffer.cursor.x, 0);
661 assert_eq!(buffer.cursor.y, 6);
662 }
663
664 #[test]
665 fn test_tile_buffer_scroll() {
666 let mut buffer = TileBuffer::new(20, 18);
667
668 // Put a marker in row 1
669 buffer.set_tile(TilePos::new(0, 1), 0xAB, 0);
670 // Put a marker in row 0
671 buffer.set_tile(TilePos::new(0, 0), 0xCD, 0);
672
673 buffer.scroll();
674
675 // Row 0 was discarded, row 1 moved up
676 assert_eq!(buffer.tiles[buffer.index(0, 0)].tile_id, 0xAB);
677 // Bottom row should be default
678 assert_eq!(
679 buffer.tiles[buffer.index(0, buffer.height_tiles - 1)].tile_id,
680 0
681 );
682 }
683
684 #[test]
685 fn test_text_stream_operations() {
686 let mut stream = TextStream::new(vec![1, 2, 3, 4, 5]);
687 assert_eq!(stream.peek(), Some(&1));
688 assert_eq!(stream.next(), Some(&1));
689 assert_eq!(stream.next(), Some(&2));
690 assert_eq!(stream.position(), 2);
691
692 stream.skip(1); // skip 3
693 assert_eq!(stream.peek(), Some(&4));
694 assert_eq!(stream.remaining(), &[4, 5]);
695
696 assert_eq!(stream.next(), Some(&4));
697 assert_eq!(stream.next(), Some(&5));
698 assert!(stream.is_at_end());
699 assert_eq!(stream.next(), None);
700 }
701
702 #[test]
703 fn test_control_action_equality() {
704 assert_eq!(ControlAction::None, ControlAction::None);
705 assert_ne!(ControlAction::Newline, ControlAction::Done);
706 assert_eq!(ControlAction::SetSpeed(3), ControlAction::SetSpeed(3));
707 assert_ne!(ControlAction::SetSpeed(1), ControlAction::SetSpeed(2));
708 assert_eq!(
709 ControlAction::MoveCursor { x: 5, y: 3 },
710 ControlAction::MoveCursor { x: 5, y: 3 }
711 );
712 assert_ne!(
713 ControlAction::MoveCursor { x: 1, y: 1 },
714 ControlAction::MoveCursor { x: 2, y: 2 }
715 );
716 assert_eq!(ControlAction::Scroll, ControlAction::Scroll);
717 assert_eq!(ControlAction::Pause(30), ControlAction::Pause(30));
718 assert_ne!(ControlAction::Pause(10), ControlAction::Pause(30));
719 }
720
721 #[test]
722 fn test_dialog_state_default() {
723 let state = DialogState::default();
724 assert_eq!(state.mode, DialogMode::Typing);
725 assert_eq!(state.page_index, 0);
726 assert_eq!(state.char_index, 0);
727 assert_eq!(state.scroll_offset, 0);
728 }
729
730 #[test]
731 fn test_string_width() {
732 let provider = MockAsciiProvider;
733 let chars = vec![MockChar::Ascii('H'), MockChar::Ascii('I')];
734 assert_eq!(provider.string_width(&chars), 16);
735 assert_eq!(provider.string_width(&[]), 0);
736 }
737
738 #[test]
739 fn test_decode_digit() {
740 let provider = MockAsciiProvider;
741 assert_eq!(provider.decode_byte(0x00), Some(MockChar::Digit(0)));
742 assert_eq!(provider.decode_byte(0x05), Some(MockChar::Digit(5)));
743 assert_eq!(provider.decode_byte(0x09), Some(MockChar::Digit(9)));
744 }
745}