Skip to main content

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
22// ── TilePos ────────────────────────────────────────────────────────
23
24/// A position on the tile grid, measured in tiles.
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 + std::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
310            .iter()
311            .filter_map(|&b| self.decode_byte(b))
312            .collect();
313        TextStream::new(chars)
314    }
315
316    /// Draws a single character into the tile buffer.
317    ///
318    /// Implementations should write the character's tile at the buffer's
319    /// current cursor position and advance the cursor by the appropriate
320    /// amount (typically one tile for fixed-width fonts).
321    fn render_char(&self, c: &Self::Char, buffer: &mut TileBuffer);
322
323    /// Returns the pixel width of the given text when rendered.
324    ///
325    /// Used for text layout and centering calculations.
326    fn string_width(&self, text: &[Self::Char]) -> u16;
327
328    /// Returns `true` if the character is a control code (not printable).
329    fn is_control_code(&self, c: &Self::Char) -> bool;
330
331    /// Processes a control code and returns the resulting action.
332    ///
333    /// The `state` parameter allows the provider to inspect or modify
334    /// the dialog state (e.g. to track page breaks).
335    fn process_control(&self, c: &Self::Char, state: &mut DialogState) -> ControlAction;
336}
337
338// ── DialogEngine ───────────────────────────────────────────────────
339
340/// A general-purpose dialog engine that drives text display one character
341/// per frame.
342///
343/// `P` is the [`TextProvider`] implementation that supplies the game's
344/// character encoding and rendering logic.
345///
346/// # Usage
347///
348/// ```ignore
349/// let provider = MyProvider::new();
350/// let mut engine = DialogEngine::new(provider);
351/// let mut buffer = TileBuffer::new(20, 18);
352///
353/// engine.open_dialog(&[0x48, 0x45, 0x4C, 0x4C, 0x4F]); // "HELLO"
354///
355/// while engine.is_active() {
356///     engine.update(&mut buffer); // one char per frame
357/// }
358///
359/// engine.advance(); // close dialog
360/// ```
361pub struct DialogEngine<P: TextProvider> {
362    /// The text provider (charmap, rendering, control codes).
363    pub provider: P,
364    /// Active character stream, or `None` if no dialog is open.
365    pub stream: Option<TextStream<P::Char>>,
366    /// Current dialog state.
367    pub state: DialogState,
368}
369
370impl<P: TextProvider> DialogEngine<P> {
371    /// Creates a new dialog engine with the given text provider.
372    pub fn new(provider: P) -> Self {
373        Self {
374            provider,
375            stream: None,
376            state: DialogState::default(),
377        }
378    }
379
380    /// Opens a dialog with the given raw byte text.
381    ///
382    /// The bytes are decoded via the provider's [`TextProvider::decode_stream`]
383    /// and the state is reset to the initial typing mode.
384    pub fn open_dialog(&mut self, text: &[u8]) {
385        self.stream = Some(self.provider.decode_stream(text));
386        self.state = DialogState::default();
387    }
388
389    /// Processes one character from the current dialog stream.
390    ///
391    /// Call this once per frame to achieve the classic typewriter effect.
392    /// Control codes are dispatched to the provider's
393    /// [`TextProvider::process_control`]; printable characters are drawn
394    /// via [`TextProvider::render_char`].
395    ///
396    /// If the engine is not in [`DialogMode::Typing`], this call is a no-op.
397    /// If the stream is exhausted or a `Done` control action is returned,
398    /// the dialog state transitions to [`DialogMode::Done`].
399    pub fn update(&mut self, buffer: &mut TileBuffer) {
400        if self.state.mode != DialogMode::Typing {
401            return;
402        }
403
404        let stream = match &mut self.stream {
405            Some(s) => s,
406            None => return,
407        };
408
409        let c = match stream.next() {
410            Some(c) => c.clone(),
411            None => {
412                self.state.mode = DialogMode::Done;
413                return;
414            }
415        };
416
417        if self.provider.is_control_code(&c) {
418            let action = self.provider.process_control(&c, &mut self.state);
419            match action {
420                ControlAction::Newline => buffer.newline(),
421                ControlAction::Done => {
422                    self.state.mode = DialogMode::Done;
423                }
424                ControlAction::Clear => buffer.clear(),
425                ControlAction::PageBreak => {
426                    self.state.mode = DialogMode::WaitingForInput;
427                }
428                ControlAction::WaitInput => {
429                    self.state.mode = DialogMode::WaitingForInput;
430                }
431                ControlAction::MoveCursor { x, y } => {
432                    buffer.cursor = TilePos::new(x, y);
433                }
434                ControlAction::Scroll => buffer.scroll(),
435                ControlAction::Pause(_frames) => {
436                    self.state.mode = DialogMode::Paused;
437                }
438                _ => {}
439            }
440        } else {
441            self.provider.render_char(&c, buffer);
442        }
443
444        // Check if we exhausted the stream in this update.
445        // Don't override waiting/paused states — let advance() handle completion.
446        if stream.is_at_end() && self.state.mode == DialogMode::Typing {
447            self.state.mode = DialogMode::Done;
448        }
449    }
450
451    /// Advances the dialog past the current page or closes it.
452    ///
453    /// - If paused, resumes typing.
454    /// - If waiting for input or scrolling, resumes typing.
455    /// - If the dialog is done, clears the stream and resets state
456    ///   (so [`is_active`] returns `false`).
457    ///
458    /// Call this in response to the player pressing the A button.
459    pub fn advance(&mut self) {
460        match self.state.mode {
461            DialogMode::Paused => {
462                self.state.mode = DialogMode::Typing;
463            }
464            DialogMode::WaitingForInput | DialogMode::Scrolling => {
465                self.state.mode = DialogMode::Typing;
466            }
467            DialogMode::Done => {
468                self.stream = None;
469                self.state = DialogState::default();
470            }
471            _ => {}
472        }
473    }
474
475    /// Returns `true` if a dialog is currently active.
476    ///
477    /// A dialog is active when a stream is loaded and the mode is not
478    /// [`DialogMode::Done`].
479    pub fn is_active(&self) -> bool {
480        self.stream.is_some() && self.state.mode != DialogMode::Done
481    }
482}
483
484// ===========================================================================
485// Unit tests
486// ===========================================================================
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    // ── Mock types ────────────────────────────────────────────────
493
494    /// Mock character type for testing with a simple ASCII-like encoding.
495    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
496    enum MockChar {
497        /// Printable ASCII character (0x41-0x5A → 'A'-'Z').
498        Ascii(char),
499        /// Digit (0x00-0x09 → '0'-'9').
500        Digit(u8),
501        /// Move to next line (0xFE).
502        Newline,
503        /// End of dialog (0xFF).
504        Done,
505    }
506
507    /// A [`TextProvider`] that uses a minimal ASCII-like charmap.
508    ///
509    /// Mapping:
510    ///
511    /// | Byte range | MockChar            |
512    /// |-----------|---------------------|
513    /// | 0x00-0x09 | `Digit(d)`          |
514    /// | 0x41-0x5A | `Ascii(c)`          |
515    /// | 0xFE      | `Newline`           |
516    /// | 0xFF      | `Done`              |
517    struct MockAsciiProvider;
518
519    impl TextProvider for MockAsciiProvider {
520        type Char = MockChar;
521
522        fn decode_byte(&self, byte: u8) -> Option<Self::Char> {
523            match byte {
524                b @ 0x00..=0x09 => Some(MockChar::Digit(b)),
525                0x41..=0x5A => Some(MockChar::Ascii(byte as char)),
526                0xFE => Some(MockChar::Newline),
527                0xFF => Some(MockChar::Done),
528                _ => None,
529            }
530        }
531
532        fn render_char(&self, c: &Self::Char, buffer: &mut TileBuffer) {
533            let tile_id = match c {
534                MockChar::Ascii(ch) => *ch as u16,
535                MockChar::Digit(d) => (b'0' + d) as u16,
536                // Control codes should not reach `render_char`.
537                MockChar::Newline | MockChar::Done => return,
538            };
539            let pos = buffer.cursor;
540            buffer.set_tile(pos, tile_id, 0);
541            buffer.cursor.x += 1;
542        }
543
544        fn string_width(&self, text: &[Self::Char]) -> u16 {
545            // Fixed-width: 8 pixels per character.
546            text.len() as u16 * 8
547        }
548
549        fn is_control_code(&self, c: &Self::Char) -> bool {
550            matches!(c, MockChar::Newline | MockChar::Done)
551        }
552
553        fn process_control(&self, c: &Self::Char, _state: &mut DialogState) -> ControlAction {
554            match c {
555                MockChar::Newline => ControlAction::Newline,
556                MockChar::Done => ControlAction::Done,
557                _ => ControlAction::None,
558            }
559        }
560    }
561
562    // ── Tests ─────────────────────────────────────────────────────
563
564    #[test]
565    fn test_decode_hello_world() {
566        let provider = MockAsciiProvider;
567        let bytes = [0x48, 0x45, 0x4C, 0x4C, 0x4F]; // HELLO
568        let stream = provider.decode_stream(&bytes);
569        assert_eq!(stream.chars.len(), 5);
570        assert_eq!(stream.chars[0], MockChar::Ascii('H'));
571        assert_eq!(stream.chars[1], MockChar::Ascii('E'));
572        assert_eq!(stream.chars[2], MockChar::Ascii('L'));
573        assert_eq!(stream.chars[3], MockChar::Ascii('L'));
574        assert_eq!(stream.chars[4], MockChar::Ascii('O'));
575    }
576
577    #[test]
578    fn test_decode_done_control() {
579        let provider = MockAsciiProvider;
580        assert_eq!(provider.decode_byte(0xFF), Some(MockChar::Done));
581        assert!(provider.is_control_code(&MockChar::Done));
582    }
583
584    #[test]
585    fn test_dialog_engine_hello() {
586        let provider = MockAsciiProvider;
587        let mut engine = DialogEngine::new(provider);
588        let mut buffer = TileBuffer::new(20, 18);
589
590        // "HELLO" + DONE
591        engine.open_dialog(&[0x48, 0x45, 0x4C, 0x4C, 0x4F, 0xFF]);
592
593        assert!(engine.is_active());
594
595        // Process all 6 characters
596        for _ in 0..6 {
597            engine.update(&mut buffer);
598        }
599
600        // Verify H, E, L, L, O at positions (0,0)-(4,0)
601        let tiles = &buffer.tiles;
602        assert_eq!(tiles[0].tile_id, b'H' as u16);
603        assert_eq!(tiles[1].tile_id, b'E' as u16);
604        assert_eq!(tiles[2].tile_id, b'L' as u16);
605        assert_eq!(tiles[3].tile_id, b'L' as u16);
606        assert_eq!(tiles[4].tile_id, b'O' as u16);
607
608        // Position (5,0) should still be default
609        assert_eq!(tiles[5].tile_id, 0);
610    }
611
612    #[test]
613    fn test_dialog_engine_done_deactivates() {
614        let provider = MockAsciiProvider;
615        let mut engine = DialogEngine::new(provider);
616        let mut buffer = TileBuffer::new(20, 18);
617
618        engine.open_dialog(&[0x48, 0xFF]); // "H" + DONE
619
620        engine.update(&mut buffer); // 'H'
621        engine.update(&mut buffer); // DONE
622        assert!(!engine.is_active());
623    }
624
625    #[test]
626    fn test_advance_after_done() {
627        let provider = MockAsciiProvider;
628        let mut engine = DialogEngine::new(provider);
629        let mut buffer = TileBuffer::new(20, 18);
630
631        engine.open_dialog(&[0x48, 0xFF]); // "H" + DONE
632
633        engine.update(&mut buffer); // 'H'
634        engine.update(&mut buffer); // DONE
635
636        // After DONE, engine should not be active
637        assert!(!engine.is_active());
638        assert_eq!(engine.stream.is_some(), true); // stream still there
639
640        // advance() should clear the stream
641        engine.advance();
642        assert!(!engine.is_active());
643        assert!(engine.stream.is_none());
644    }
645
646    #[test]
647    fn test_tile_buffer_clear() {
648        let mut buffer = TileBuffer::new(20, 18);
649        buffer.set_tile(TilePos::new(5, 5), 0x42, 1);
650
651        assert_eq!(buffer.tiles[buffer.index(5, 5)].tile_id, 0x42);
652        buffer.clear();
653        assert_eq!(buffer.tiles[buffer.index(5, 5)].tile_id, 0);
654        assert_eq!(buffer.cursor, TilePos::new(0, 0));
655    }
656
657    #[test]
658    fn test_tile_buffer_newline() {
659        let mut buffer = TileBuffer::new(20, 18);
660        buffer.cursor = TilePos::new(12, 5);
661
662        buffer.newline();
663        assert_eq!(buffer.cursor.x, 0);
664        assert_eq!(buffer.cursor.y, 6);
665    }
666
667    #[test]
668    fn test_tile_buffer_scroll() {
669        let mut buffer = TileBuffer::new(20, 18);
670
671        // Put a marker in row 1
672        buffer.set_tile(TilePos::new(0, 1), 0xAB, 0);
673        // Put a marker in row 0
674        buffer.set_tile(TilePos::new(0, 0), 0xCD, 0);
675
676        buffer.scroll();
677
678        // Row 0 was discarded, row 1 moved up
679        assert_eq!(buffer.tiles[buffer.index(0, 0)].tile_id, 0xAB);
680        // Bottom row should be default
681        assert_eq!(
682            buffer.tiles[buffer.index(0, buffer.height_tiles - 1)].tile_id,
683            0
684        );
685    }
686
687    #[test]
688    fn test_text_stream_operations() {
689        let mut stream = TextStream::new(vec![1, 2, 3, 4, 5]);
690        assert_eq!(stream.peek(), Some(&1));
691        assert_eq!(stream.next(), Some(&1));
692        assert_eq!(stream.next(), Some(&2));
693        assert_eq!(stream.position(), 2);
694
695        stream.skip(1); // skip 3
696        assert_eq!(stream.peek(), Some(&4));
697        assert_eq!(stream.remaining(), &[4, 5]);
698
699        assert_eq!(stream.next(), Some(&4));
700        assert_eq!(stream.next(), Some(&5));
701        assert!(stream.is_at_end());
702        assert_eq!(stream.next(), None);
703    }
704
705    #[test]
706    fn test_control_action_equality() {
707        assert_eq!(ControlAction::None, ControlAction::None);
708        assert_ne!(ControlAction::Newline, ControlAction::Done);
709        assert_eq!(ControlAction::SetSpeed(3), ControlAction::SetSpeed(3));
710        assert_ne!(ControlAction::SetSpeed(1), ControlAction::SetSpeed(2));
711        assert_eq!(
712            ControlAction::MoveCursor { x: 5, y: 3 },
713            ControlAction::MoveCursor { x: 5, y: 3 }
714        );
715        assert_ne!(
716            ControlAction::MoveCursor { x: 1, y: 1 },
717            ControlAction::MoveCursor { x: 2, y: 2 }
718        );
719        assert_eq!(ControlAction::Scroll, ControlAction::Scroll);
720        assert_eq!(ControlAction::Pause(30), ControlAction::Pause(30));
721        assert_ne!(ControlAction::Pause(10), ControlAction::Pause(30));
722    }
723
724    #[test]
725    fn test_dialog_state_default() {
726        let state = DialogState::default();
727        assert_eq!(state.mode, DialogMode::Typing);
728        assert_eq!(state.page_index, 0);
729        assert_eq!(state.char_index, 0);
730        assert_eq!(state.scroll_offset, 0);
731    }
732
733    #[test]
734    fn test_string_width() {
735        let provider = MockAsciiProvider;
736        let chars = vec![
737            MockChar::Ascii('H'),
738            MockChar::Ascii('I'),
739        ];
740        assert_eq!(provider.string_width(&chars), 16);
741        assert_eq!(provider.string_width(&[]), 0);
742    }
743
744    #[test]
745    fn test_decode_digit() {
746        let provider = MockAsciiProvider;
747        assert_eq!(provider.decode_byte(0x00), Some(MockChar::Digit(0)));
748        assert_eq!(provider.decode_byte(0x05), Some(MockChar::Digit(5)));
749        assert_eq!(provider.decode_byte(0x09), Some(MockChar::Digit(9)));
750    }
751}