shared/
textbox.rs

1use embedded_graphics::{
2    draw_target::DrawTarget,
3    mono_font::{MonoTextStyle, ascii::FONT_6X9},
4    pixelcolor::BinaryColor,
5    prelude::*,
6    primitives::{PrimitiveStyle, Rectangle},
7    text::{
8        Text,
9        renderer::{CharacterStyle, TextRenderer},
10    },
11};
12
13pub struct Textbox<'a, A> {
14    buffer: A,
15    mono_text_style: MonoTextStyle<'a, BinaryColor>,
16    character_bounding_box: Rectangle,
17    row_index: usize,
18    highlighted_range: Option<core::ops::Range<usize>>,
19}
20
21impl<'a, A> Textbox<'a, A> {
22    pub fn new(buffer: A) -> Self {
23        let mono_text_style = MonoTextStyle::new(&FONT_6X9, BinaryColor::Off);
24
25        Self {
26            buffer,
27            character_bounding_box: mono_text_style
28                .measure_string("A", Point::zero(), embedded_graphics::text::Baseline::Top)
29                .bounding_box,
30            mono_text_style,
31            row_index: 0,
32            highlighted_range: None,
33        }
34    }
35
36    fn erase_index<D>(&mut self, device: &mut D, index: usize)
37    where
38        D: DrawTarget<Color = BinaryColor>,
39    {
40        let _ = Rectangle::new(
41            self.position_from_index(device, index),
42            self.character_bounding_box.size,
43        )
44        .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
45        .draw(device);
46    }
47
48    fn characters_per_row<D>(&self, device: &mut D) -> u32
49    where
50        D: DrawTarget<Color = BinaryColor>,
51    {
52        device.bounding_box().size.width / self.character_bounding_box.size.width
53    }
54
55    fn row<D>(&self, device: &mut D, index: usize) -> usize
56    where
57        D: DrawTarget<Color = BinaryColor>,
58    {
59        index / self.characters_per_row(device) as usize
60    }
61
62    fn position_from_index<D>(&self, device: &mut D, index: usize) -> Point
63    where
64        D: DrawTarget<Color = BinaryColor>,
65    {
66        let y = self.row(device, index) as u32 * self.character_bounding_box.size.height;
67        let col = index as u32 % self.characters_per_row(device);
68        let x = col * self.character_bounding_box.size.width;
69
70        Point::new(
71            x.try_into().unwrap(),
72            y as i32 - self.character_bounding_box.size.height as i32 * self.row_index as i32,
73        )
74    }
75
76    #[allow(dead_code)]
77    fn scroll_up(&mut self) {
78        self.row_index = (self.row_index - 1).max(0)
79    }
80
81    fn scroll_to<D>(&mut self, device: &mut D, index: usize) -> bool
82    where
83        D: DrawTarget<Color = BinaryColor>,
84    {
85        let row_index = index / self.characters_per_row(device) as usize;
86        if row_index == self.row_index {
87            false
88        } else {
89            self.row_index = row_index;
90            true
91        }
92    }
93}
94
95impl<'a, A> Textbox<'a, A>
96where
97    A: core::ops::Deref<Target = str>,
98{
99    pub fn highlight<D>(&mut self, device: &mut D, range: core::ops::Range<usize>)
100    where
101        D: DrawTarget<Color = BinaryColor>,
102    {
103        if let Some(highlighted_range) = &self.highlighted_range {
104            self.draw(device, Some(highlighted_range.clone()), false);
105        }
106        self.highlighted_range = Some(range.clone());
107        if self.scroll_to(device, range.end) {
108            let _ = device.clear(BinaryColor::On);
109            self.draw(device, None, false);
110        }
111        self.draw(device, self.highlighted_range.clone(), true);
112    }
113
114    fn draw_character_at_index<D>(&mut self, device: &mut D, index: usize, invert: bool)
115    where
116        D: DrawTarget<Color = BinaryColor>,
117    {
118        let _ = Rectangle::new(
119            self.position_from_index(device, index),
120            self.character_bounding_box.size,
121        )
122        .into_styled(if invert {
123            PrimitiveStyle::with_fill(BinaryColor::Off)
124        } else {
125            PrimitiveStyle::with_fill(BinaryColor::On)
126        })
127        .draw(device);
128
129        let mut mono_text_style = self.mono_text_style;
130        if invert {
131            mono_text_style.set_background_color(Some(BinaryColor::Off));
132            mono_text_style.set_text_color(Some(BinaryColor::On));
133        }
134
135        let _ = Text::with_baseline(
136            self.buffer.get(index..(index + 1)).unwrap(),
137            self.position_from_index(device, index),
138            mono_text_style,
139            embedded_graphics::text::Baseline::Top,
140        )
141        .draw(device);
142    }
143
144    #[allow(dead_code)]
145    fn scroll_down<D>(&mut self, device: &mut D)
146    where
147        D: DrawTarget<Color = BinaryColor>,
148    {
149        self.row_index = (self.row_index + 1).min(self.max_row_index(device));
150        self.clear_last_row(device)
151    }
152
153    #[allow(dead_code)]
154    fn clear_last_row<D>(&mut self, device: &mut D)
155    where
156        D: DrawTarget<Color = BinaryColor>,
157    {
158        let n_rows_on_screen =
159            device.bounding_box().size.width / self.character_bounding_box.size.height - 1;
160        let rectangle = Rectangle::new(
161            Point::new(
162                0,
163                (self.character_bounding_box.size.height * n_rows_on_screen)
164                    .try_into()
165                    .unwrap(),
166            ),
167            Size::new(
168                device.bounding_box().size.width,
169                device.bounding_box().size.height
170                    - (self.character_bounding_box.size.height * n_rows_on_screen),
171            ),
172        );
173        let _ = rectangle
174            .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
175            .draw(device);
176    }
177
178    #[allow(dead_code)]
179    fn max_row_index<D>(&mut self, device: &mut D) -> usize
180    where
181        D: DrawTarget<Color = BinaryColor>,
182    {
183        let n_rows_on_screen =
184            device.bounding_box().size.width / self.character_bounding_box.size.height;
185        let n_rows_in_buffer = self
186            .buffer
187            .len()
188            .div_ceil(self.characters_per_row(device).try_into().unwrap());
189        n_rows_in_buffer.saturating_sub(n_rows_on_screen as usize)
190    }
191
192    pub fn draw<D>(&mut self, device: &mut D, range: Option<core::ops::Range<usize>>, invert: bool)
193    where
194        D: DrawTarget<Color = BinaryColor>,
195    {
196        for i in range.unwrap_or(0..self.buffer.len()) {
197            self.draw_character_at_index(device, i, invert);
198        }
199    }
200}
201
202impl<'a, const N: usize> Textbox<'a, heapless::String<N>> {
203    pub fn backspace<D>(&mut self, device: &mut D)
204    where
205        D: DrawTarget<Color = BinaryColor>,
206    {
207        if !self.buffer.is_empty() {
208            self.erase_index(device, self.buffer.len() - 1);
209            let _ = self.buffer.pop();
210        }
211    }
212
213    pub fn push<D>(&mut self, device: &mut D, character: char, invert: bool)
214    where
215        D: DrawTarget<Color = BinaryColor>,
216    {
217        if self.buffer.len() < self.buffer.capacity() {
218            let _ = self.buffer.push(character);
219            self.draw_character_at_index(device, self.buffer.len() - 1, invert);
220        }
221    }
222
223    pub fn release(self) -> heapless::String<N> {
224        self.buffer
225    }
226}
227
228#[cfg(test)]
229mod test {
230    extern crate std;
231    use core::str::FromStr;
232
233    use embedded_graphics::{Pixel, mock_display::MockDisplay};
234    use embedded_graphics_core::{draw_target::DrawTarget, pixelcolor::BinaryColor};
235
236    pub struct Device {
237        pub display: MockDisplay<BinaryColor>,
238    }
239
240    impl Device {
241        pub fn new() -> Self {
242            let mut display = MockDisplay::new();
243            display.set_allow_out_of_bounds_drawing(true);
244            display.set_allow_overdraw(true);
245
246            Self { display }
247        }
248    }
249
250    impl crate::Keypad for Device {
251        async fn event(&mut self) -> crate::KeyEvent {
252            crate::KeyEvent::Down(crate::Key::Down)
253        }
254
255        fn last_pressed(&mut self) -> Option<embassy_time::Duration> {
256            None
257        }
258    }
259
260    impl DrawTarget for Device {
261        type Color = BinaryColor;
262        type Error = core::convert::Infallible;
263        fn draw_iter<
264            I: IntoIterator<Item = Pixel<<Self as embedded_graphics::draw_target::DrawTarget>::Color>>,
265        >(
266            &mut self,
267            pixels: I,
268        ) -> Result<(), <Self as embedded_graphics::draw_target::DrawTarget>::Error> {
269            self.display.draw_iter(pixels)
270        }
271    }
272
273    impl embedded_graphics::prelude::Dimensions for Device {
274        fn bounding_box(&self) -> embedded_graphics::primitives::Rectangle {
275            self.display.bounding_box()
276        }
277    }
278
279    #[test]
280    fn test_textbox() {
281        let characters = heapless::String::<15>::from_str("abc").unwrap();
282        let mut device = Device::new();
283        let _ = device.clear(BinaryColor::On);
284        let mut textbox = super::Textbox::new(characters);
285        textbox.draw(&mut device, None, false);
286        textbox.erase_index(&mut device, 1);
287        textbox.push(&mut device, 'G', false);
288        textbox.backspace(&mut device);
289        textbox.push(&mut device, 'H', false);
290        textbox.push(&mut device, 'I', false);
291        textbox.push(&mut device, 'J', false);
292        textbox.push(&mut device, 'K', false);
293        textbox.push(&mut device, 'L', false);
294        textbox.push(&mut device, 'M', false);
295        textbox.push(&mut device, 'N', false);
296        textbox.push(&mut device, 'O', false);
297
298        device.display.assert_pattern(&[
299            "################################################################",
300            "###################.##.##...####...##.##.##.####.###.##.##.#####",
301            "###################.##.###.######.###.#.###.####..#..##..#.#####",
302            "##...#########...##....###.######.###..####.####.#.#.##.#..#####",
303            "#.##.########.#####.##.###.######.###.#.###.####.#.#.##.##.#####",
304            "#.##.########.#####.##.###.###.##.###.##.##.####.###.##.##.#####",
305            "##...#########...##.##.##...###..####.##.##....#.###.##.##.#####",
306            "################################################################",
307            "################################################################",
308            "################################################################",
309            "#...############################################################",
310            ".###.###########################################################",
311            ".###.###########################################################",
312            ".###.###########################################################",
313            ".###.###########################################################",
314            "#...############################################################",
315            "################################################################",
316            "################################################################",
317            "################################################################",
318            "################################################################",
319            "################################################################",
320            "################################################################",
321            "################################################################",
322            "################################################################",
323            "################################################################",
324            "################################################################",
325            "################################################################",
326            "################################################################",
327            "################################################################",
328            "################################################################",
329            "################################################################",
330            "################################################################",
331            "################################################################",
332            "################################################################",
333            "################################################################",
334            "################################################################",
335            "################################################################",
336            "################################################################",
337            "################################################################",
338            "################################################################",
339            "################################################################",
340            "################################################################",
341            "################################################################",
342            "################################################################",
343            "################################################################",
344            "################################################################",
345            "################################################################",
346            "################################################################",
347            "################################################################",
348            "################################################################",
349            "################################################################",
350            "################################################################",
351            "################################################################",
352            "################################################################",
353            "################################################################",
354            "################################################################",
355            "################################################################",
356            "################################################################",
357            "################################################################",
358            "################################################################",
359            "################################################################",
360            "################################################################",
361            "################################################################",
362            "################################################################",
363        ]);
364        assert_eq!(textbox.release(), "abcHIJKLMNO");
365    }
366
367    #[test]
368    fn test_highlight() {
369        let characters = heapless::String::<15>::from_str("abcdefghijklmn").unwrap();
370        let mut device = Device::new();
371        let _ = device.clear(BinaryColor::On);
372        let mut textbox = super::Textbox::new(characters);
373        textbox.draw(&mut device, None, false);
374        textbox.draw(&mut device, Some(4..7), true);
375
376        device.display.assert_pattern(&[
377            "########################..................######################",
378            "#######.##############.#.........#........#.######.######.######",
379            "#######.##############.#........#.#.......#.####################",
380            "##...##...####...###...#..##....#.....##..#...###..#####..######",
381            "#.##.##.##.##.#####.##.#.#.##..###...#..#.#.##.###.######.######",
382            "#.##.##.##.##.#####.##.#.##.....#....#..#.#.##.###.######.######",
383            "##...##...####...###...#..###...#.....###.#.##.##...#####.######",
384            "########################................#.#############.#.######",
385            "########################..............##..##############.#######",
386            "################################################################",
387            "#.#####..#######################################################",
388            "#.######.#######################################################",
389            "#.#.####.###..#.###...##########################################",
390            "#..#####.###.#.#.##.##.#########################################",
391            "#.#.####.###.#.#.##.##.#########################################",
392            "#.##.##...##.###.##.##.#########################################",
393            "################################################################",
394            "################################################################",
395            "################################################################",
396            "################################################################",
397            "################################################################",
398            "################################################################",
399            "################################################################",
400            "################################################################",
401            "################################################################",
402            "################################################################",
403            "################################################################",
404            "################################################################",
405            "################################################################",
406            "################################################################",
407            "################################################################",
408            "################################################################",
409            "################################################################",
410            "################################################################",
411            "################################################################",
412            "################################################################",
413            "################################################################",
414            "################################################################",
415            "################################################################",
416            "################################################################",
417            "################################################################",
418            "################################################################",
419            "################################################################",
420            "################################################################",
421            "################################################################",
422            "################################################################",
423            "################################################################",
424            "################################################################",
425            "################################################################",
426            "################################################################",
427            "################################################################",
428            "################################################################",
429            "################################################################",
430            "################################################################",
431            "################################################################",
432            "################################################################",
433            "################################################################",
434            "################################################################",
435            "################################################################",
436            "################################################################",
437            "################################################################",
438            "################################################################",
439            "################################################################",
440            "################################################################",
441        ]);
442        assert_eq!(
443            textbox.release(),
444            heapless::String::<15>::from_str("abcdefghijklmn").unwrap()
445        );
446    }
447
448    #[test]
449    fn test_scroll() {
450        let mut device = Device::new();
451        let _ = device.clear(BinaryColor::On);
452        let mut textbox = super::Textbox::new(
453            "AAAAAAAAAABBBBBBBBBBCCCCCCCCCCDDDDDDDDDDEEEEEEEEEEFFFFFFFFFFGGGGGGGGGGHH",
454        );
455        textbox.scroll_down(&mut device);
456        textbox.draw(&mut device, None, false);
457
458        device.display.assert_pattern(&[
459            "################################################################",
460            "....##....##....##....##....##....##....##....##....##....######",
461            ".###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#####",
462            "....##....##....##....##....##....##....##....##....##....######",
463            ".###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#####",
464            ".###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#####",
465            "....##....##....##....##....##....##....##....##....##....######",
466            "################################################################",
467            "################################################################",
468            "################################################################",
469            "##..####..####..####..####..####..####..####..####..####..######",
470            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
471            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
472            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
473            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
474            "##..####..####..####..####..####..####..####..####..####..######",
475            "################################################################",
476            "################################################################",
477            "################################################################",
478            "#...###...###...###...###...###...###...###...###...###...######",
479            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
480            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
481            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
482            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
483            "#...###...###...###...###...###...###...###...###...###...######",
484            "################################################################",
485            "################################################################",
486            "################################################################",
487            "#....##....##....##....##....##....##....##....##....##....#####",
488            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
489            "#...###...###...###...###...###...###...###...###...###...######",
490            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
491            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
492            "#....##....##....##....##....##....##....##....##....##....#####",
493            "################################################################",
494            "################################################################",
495            "################################################################",
496            "#....##....##....##....##....##....##....##....##....##....#####",
497            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
498            "#...###...###...###...###...###...###...###...###...###...######",
499            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
500            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
501            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
502            "################################################################",
503            "################################################################",
504            "################################################################",
505            "##..####..####..####..####..####..####..####..####..####..######",
506            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
507            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
508            "#.#..##.#..##.#..##.#..##.#..##.#..##.#..##.#..##.#..##.#..#####",
509            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
510            "##..####..####..####..####..####..####..####..####..####..######",
511            "################################################################",
512            "################################################################",
513            "################################################################",
514            "#.##.##.##.#####################################################",
515            "#.##.##.##.#####################################################",
516            "#....##....#####################################################",
517            "#.##.##.##.#####################################################",
518            "#.##.##.##.#####################################################",
519            "#.##.##.##.#####################################################",
520            "################################################################",
521            "################################################################",
522            "################################################################",
523        ]);
524    }
525
526    #[test]
527    fn test_tail_on_push() {
528        let mut device = Device::new();
529        let _ = device.clear(BinaryColor::On);
530        let mut textbox = super::Textbox::new(
531            heapless::String::<72>::from_str(
532                "AAAAAAAAAABBBBBBBBBBCCCCCCCCCCDDDDDDDDDDEEEEEEEEEEFFFFFFFFFFGGGGGGGGGG",
533            )
534            .unwrap(),
535        );
536        textbox.draw(&mut device, None, false);
537
538        // should tail on push
539        textbox.push(&mut device, 'H', false);
540        textbox.push(&mut device, 'H', false);
541        textbox.scroll_down(&mut device);
542        textbox.draw(&mut device, None, false);
543
544        assert_eq!(
545            textbox.release(),
546            heapless::String::<72>::from_str(
547                "AAAAAAAAAABBBBBBBBBBCCCCCCCCCCDDDDDDDDDDEEEEEEEEEEFFFFFFFFFFGGGGGGGGGGHH"
548            )
549            .unwrap(),
550        );
551
552        device.display.assert_pattern(&[
553            "################################################################",
554            "....##....##....##....##....##....##....##....##....##....######",
555            ".###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#####",
556            "....##....##....##....##....##....##....##....##....##....######",
557            ".###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#####",
558            ".###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#.###.#####",
559            "....##....##....##....##....##....##....##....##....##....######",
560            "################################################################",
561            "################################################################",
562            "################################################################",
563            "##..####..####..####..####..####..####..####..####..####..######",
564            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
565            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
566            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
567            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
568            "##..####..####..####..####..####..####..####..####..####..######",
569            "################################################################",
570            "################################################################",
571            "################################################################",
572            "#...###...###...###...###...###...###...###...###...###...######",
573            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
574            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
575            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
576            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
577            "#...###...###...###...###...###...###...###...###...###...######",
578            "################################################################",
579            "################################################################",
580            "################################################################",
581            "#....##....##....##....##....##....##....##....##....##....#####",
582            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
583            "#...###...###...###...###...###...###...###...###...###...######",
584            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
585            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
586            "#....##....##....##....##....##....##....##....##....##....#####",
587            "################################################################",
588            "################################################################",
589            "################################################################",
590            "#....##....##....##....##....##....##....##....##....##....#####",
591            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
592            "#...###...###...###...###...###...###...###...###...###...######",
593            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
594            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
595            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
596            "################################################################",
597            "################################################################",
598            "################################################################",
599            "##..####..####..####..####..####..####..####..####..####..######",
600            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
601            "#.#####.#####.#####.#####.#####.#####.#####.#####.#####.########",
602            "#.#..##.#..##.#..##.#..##.#..##.#..##.#..##.#..##.#..##.#..#####",
603            "#.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.##.#####",
604            "##..####..####..####..####..####..####..####..####..####..######",
605            "################################################################",
606            "################################################################",
607            "################################################################",
608            "#.##.##.##.#####################################################",
609            "#.##.##.##.#####################################################",
610            "#....##....#####################################################",
611            "#.##.##.##.#####################################################",
612            "#.##.##.##.#####################################################",
613            "#.##.##.##.#####################################################",
614            "################################################################",
615            "################################################################",
616            "################################################################",
617        ]);
618    }
619}