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