max7219_display/led_matrix/
scroll.rs

1//! Scroll Text Renderer Module
2//!
3//! This module provides a configurable text scroller for 8x8 LED matrix
4
5use crate::{
6    Result,
7    led_matrix::{buffer::MatrixBuffer, fonts::LedFont},
8};
9
10/// Configuration for scrolling text behavior
11#[derive(Clone, Copy)]
12pub struct ScrollConfig {
13    /// Delay between scroll steps in nanoseconds
14    pub step_delay_ns: u32,
15    /// Number of pixels to scroll per step (usually 1 for smooth scrolling)
16    pub pixels_per_step: u8,
17    /// Whether to loop the text continuously
18    pub loop_text: bool,
19    /// Padding between text repetitions when looping (in pixels)
20    pub loop_padding: u8,
21}
22
23impl Default for ScrollConfig {
24    fn default() -> Self {
25        Self {
26            step_delay_ns: 100_000_000, // 100ms
27            pixels_per_step: 1,
28            loop_text: true,
29            loop_padding: 16, // 2 character widths
30        }
31    }
32}
33
34/// Scrolling text renderer for LED matrix displays
35pub struct ScrollingText<'a> {
36    text: &'a str,
37    font: &'a LedFont,
38    config: ScrollConfig,
39    text_width: usize,
40    pub(crate) current_offset: i32,
41}
42
43impl<'a> ScrollingText<'a> {
44    /// Create a new scrolling text instance
45    pub fn new(text: &'a str, font: &'a LedFont, config: ScrollConfig) -> Self {
46        let mut scroller = Self {
47            text,
48            font,
49            config,
50            text_width: 0,
51            current_offset: 0,
52        };
53        scroller.calculate_text_width();
54        scroller
55    }
56
57    /// Create with default configuration
58    pub fn new_default(text: &'a str, font: &'a LedFont) -> Self {
59        Self::new(text, font, ScrollConfig::default())
60    }
61
62    /// Calculate the width
63    fn calculate_text_width(&mut self) {
64        self.text_width = self.text.chars().count() * 8;
65
66        // Add loop padding if configured
67        if self.config.loop_text {
68            self.text_width += self.config.loop_padding as usize;
69        }
70    }
71
72    /// Get the current 8x8 frame data based on the scroll offset.
73    /// This returns what should be displayed on the LED matrix at the current scroll position.
74    pub fn get_frame(&self) -> Result<MatrixBuffer> {
75        let mut buffer = MatrixBuffer::new();
76
77        for row in 0..8 {
78            let mut row_data = 0u8;
79            for col in 0..8 {
80                if self.pixel_on(col, row) {
81                    row_data |= 1 << (7 - col);
82                }
83            }
84            buffer.set_row(row as u8, row_data)?;
85        }
86
87        Ok(buffer)
88    }
89    /// Return true if the pixel at (source_col, row) should be on
90    fn pixel_on(&self, source_col: usize, row: usize) -> bool {
91        // Calculate the actual column position considering the offset
92        let actual_col = self.current_offset as isize + source_col as isize;
93
94        // If the actual column is negative, no pixel should be on
95        if actual_col < 0 {
96            return false;
97        }
98
99        let col = actual_col as usize;
100
101        // If outside text width and not looping, no pixel
102        if col >= self.text_width && !self.config.loop_text {
103            return false;
104        }
105
106        // Wrap around if looping
107        let final_col = if self.config.loop_text && col >= self.text_width {
108            col % self.text_width
109        } else {
110            col
111        };
112
113        // Only actual text columns (exclude padding)
114        let text_pixels = self.text.chars().count() * 8;
115        if final_col >= text_pixels {
116            return false;
117        }
118
119        let char_index = final_col / 8;
120        let bit_index = final_col % 8;
121
122        // Safe since char_index < char count
123        let ch = self.text.chars().nth(char_index).unwrap_or('?');
124        let bitmap = self.font.get_char(ch);
125        let row_data = bitmap[row];
126
127        // Check bit (left to right)
128        (row_data >> (7 - bit_index)) & 1 != 0
129    }
130
131    /// Advance the scroll position by the configured step size
132    pub fn step(&mut self) -> bool {
133        self.current_offset += self.config.pixels_per_step as i32;
134
135        if self.config.loop_text {
136            // Reset when we've scrolled past the text width
137            if self.current_offset >= self.text_width as i32 {
138                self.current_offset = 0;
139            }
140            true // Always continue when looping
141        } else {
142            // Stop when text has completely scrolled off screen
143            self.current_offset < (self.text_width as i32 + 8)
144        }
145    }
146
147    /// Reset scroll position to the beginning
148    pub fn reset(&mut self) {
149        self.current_offset = -(8i32); // Start with text off-screen to the right
150    }
151
152    /// Get current scroll offset
153    pub fn offset(&self) -> i32 {
154        self.current_offset
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    // Create a minimal font for testing
163    #[rustfmt::skip]
164    const TEST_FONT_DATA: &[([u8; 8], char)] = &[
165        (
166            [
167                0b00111100, 
168                0b01100110, 
169                0b01101110, 
170                0b01110110, 
171                0b01100110, 
172                0b01100110, 
173                0b00111100,
174                0b00000000,
175            ],
176            '0',
177        ),
178        (
179            [
180                0b00011000, 
181                0b00111000, 
182                0b00011000, 
183                0b00011000, 
184                0b00011000, 
185                0b00011000, 
186                0b01111110,
187                0b00000000,
188            ],
189            '1',
190        ),
191        (
192            [
193                0b00000000, 
194                0b00000000, 
195                0b00000000, 
196                0b00000000, 
197                0b00000000, 
198                0b00000000, 
199                0b00000000,
200                0b00000000,
201            ],
202            ' ',
203        ),
204    ];
205
206    const TEST_FONT: LedFont = LedFont::new(TEST_FONT_DATA);
207    #[test]
208    fn test_scroll_config_default() {
209        let config = ScrollConfig::default();
210        assert_eq!(config.step_delay_ns, 100_000_000);
211        assert_eq!(config.pixels_per_step, 1);
212        assert!(config.loop_text);
213        assert_eq!(config.loop_padding, 16);
214    }
215
216    #[test]
217    fn test_scrolling_text_new() {
218        let text = "01";
219        let config = ScrollConfig::default();
220        let scroller = ScrollingText::new(text, &TEST_FONT, config);
221
222        assert_eq!(scroller.text, text);
223        assert_eq!(scroller.text_width, 32);
224        assert_eq!(scroller.current_offset, 0);
225    }
226
227    #[test]
228    fn test_calculate_text_width() {
229        let text = "01";
230        let config = ScrollConfig {
231            loop_text: true,
232            loop_padding: 8,
233            ..Default::default()
234        };
235        let scroller = ScrollingText::new(text, &TEST_FONT, config);
236        assert_eq!(scroller.text_width, 24);
237    }
238
239    #[test]
240    fn test_reset() {
241        let mut scroller = ScrollingText::new_default("01", &TEST_FONT);
242        scroller.current_offset = 10;
243        scroller.reset();
244        assert_eq!(scroller.current_offset, -8);
245    }
246
247    #[test]
248    fn test_offset() {
249        let mut scroller = ScrollingText::new_default("01", &TEST_FONT);
250        assert_eq!(scroller.offset(), 0);
251        scroller.current_offset = 5;
252        assert_eq!(scroller.offset(), 5);
253    }
254
255    #[test]
256    fn test_step_looping() {
257        let mut scroller = ScrollingText::new_default("01", &TEST_FONT);
258        scroller.current_offset = 30;
259
260        let should_continue = scroller.step();
261        assert_eq!(scroller.current_offset, 31);
262        assert!(should_continue);
263
264        let should_continue = scroller.step();
265        assert_eq!(scroller.current_offset, 0);
266        assert!(should_continue);
267    }
268
269    #[test]
270    fn test_step_non_looping() {
271        let config = ScrollConfig {
272            loop_text: false,
273            ..Default::default()
274        };
275        let mut scroller = ScrollingText::new("01", &TEST_FONT, config);
276
277        // Text width is 24 (2 chars * 8 + 8 padding), so scrolling stops when offset >= 24
278        scroller.current_offset = 22;
279        assert!(scroller.step());
280        assert_eq!(scroller.current_offset, 23);
281
282        assert!(!scroller.step()); // Now completely off screen
283        assert_eq!(scroller.current_offset, 24);
284
285        // Test that it stays false
286        assert!(!scroller.step()); // Still off screen
287        assert_eq!(scroller.current_offset, 25);
288    }
289
290    #[test]
291    fn test_pixel_on_basic() {
292        let scroller = ScrollingText::new_default("0", &TEST_FONT);
293
294        assert!(!scroller.pixel_on(0, 0));
295        assert!(!scroller.pixel_on(1, 0));
296        assert!(scroller.pixel_on(2, 0));
297        assert!(scroller.pixel_on(3, 0));
298        assert!(scroller.pixel_on(4, 0));
299        assert!(scroller.pixel_on(5, 0));
300        assert!(!scroller.pixel_on(6, 0));
301        assert!(!scroller.pixel_on(7, 0));
302    }
303
304    #[test]
305    fn test_pixel_on_negative_offset() {
306        let mut scroller = ScrollingText::new_default("0", &TEST_FONT);
307        scroller.current_offset = -4;
308
309        // Columns 0-3 map to actual_cols -4 to -1, which are < 0, so pixel_on returns false
310        // i.e off the screen
311        assert!(!scroller.pixel_on(0, 0));
312        assert!(!scroller.pixel_on(1, 0));
313        assert!(!scroller.pixel_on(2, 0));
314        assert!(!scroller.pixel_on(3, 0));
315
316        // Column 4 maps to actual_col 0.
317        // The bitmap for '0' (from TEST_FONT) row 0 is 0b00111100.
318        // Bit 0 (rightmost shift) is 0.
319        assert!(!scroller.pixel_on(4, 0)); // This should be false based on the actual font data
320
321        // Column 5 maps to actual_col 1.
322        // Bit 1 of 0b00111100 is 0.
323        assert!(!scroller.pixel_on(5, 0)); // This should also be false based on the actual font data
324
325        // Column 6 maps to actual_col 2. This is the third pixel of '0'.
326        // Bit 2 of 0b00111100 is 1.
327        assert!(scroller.pixel_on(6, 0)); // This should be true based on the actual font data
328    }
329
330    #[test]
331    fn test_pixel_on_multiple_characters() {
332        let scroller = ScrollingText::new_default("01", &TEST_FONT);
333
334        assert!(!scroller.pixel_on(0, 0));
335        assert!(!scroller.pixel_on(1, 0));
336        assert!(scroller.pixel_on(2, 0));
337
338        assert!(!scroller.pixel_on(8, 0));
339        assert!(!scroller.pixel_on(9, 0));
340        assert!(!scroller.pixel_on(10, 0));
341        assert!(scroller.pixel_on(11, 0));
342        assert!(scroller.pixel_on(12, 0));
343    }
344
345    #[test]
346    fn test_pixel_on_looping() {
347        let config = ScrollConfig {
348            loop_text: true,
349            loop_padding: 0,
350            ..Default::default()
351        };
352        let mut scroller = ScrollingText::new("01", &TEST_FONT, config);
353
354        scroller.current_offset = scroller.text_width as i32;
355
356        assert!(!scroller.pixel_on(0, 0));
357        assert!(!scroller.pixel_on(1, 0));
358        assert!(scroller.pixel_on(2, 0));
359    }
360
361    #[test]
362    fn test_pixel_on_padding() {
363        let scroller = ScrollingText::new_default("0", &TEST_FONT);
364
365        for col in 8..24 {
366            assert!(
367                !scroller.pixel_on(col, 0),
368                "Pixel {col} should be off in padding",
369            );
370        }
371    }
372
373    #[test]
374    fn test_get_frame() {
375        let scroller = ScrollingText::new_default("0", &TEST_FONT);
376        let frame = scroller.get_frame().expect("Should get frame successfully");
377
378        // Expected bitmap for character '0' from TEST_FONT_DATA:
379        // Row 0: 0b00111100
380        // Row 1: 0b01100110
381        // Row 2: 0b01101110
382        // Row 3: 0b01110110
383        // Row 4: 0b01100110
384        // Row 5: 0b01100110
385        // Row 6: 0b00111100
386        // Row 7: 0b00000000
387        //
388        // get_frame processes pixels column by column (source_col 0-7) and builds
389        // each row's byte by setting bits from left to right (bit 7 to bit 0).
390        // So for row 0 with pixels [0,0,1,1,1,1,0,0], the byte becomes:
391        // Bit 7 (col 0): 0
392        // Bit 6 (col 1): 0
393        // Bit 5 (col 2): 1
394        // Bit 4 (col 3): 1
395        // Bit 3 (col 4): 1
396        // Bit 2 (col 5): 1
397        // Bit 1 (col 6): 0
398        // Bit 0 (col 7): 0
399        // Result: 0b00111100
400        //
401        // This means the frame row data should match the character bitmap data exactly.
402
403        let expected_rows = [
404            0b00111100, // Row 0
405            0b01100110, // Row 1
406            0b01101110, // Row 2
407            0b01110110, // Row 3
408            0b01100110, // Row 4
409            0b01100110, // Row 5
410            0b00111100, // Row 6
411            0b00000000, // Row 7
412        ];
413
414        for (row_index, &expected_row) in expected_rows.iter().enumerate() {
415            let actual_row = frame
416                .get_row(row_index as u8)
417                .expect("Should get row successfully");
418            assert_eq!(actual_row, expected_row, "Row {row_index} mismatch");
419        }
420    }
421}