Skip to main content

ftui_runtime/
string_model.rs

1#![forbid(unsafe_code)]
2
3//! Easy-mode adapter for string-based views.
4//!
5//! `StringModel` provides a simpler alternative to the full [`Model`] trait
6//! for applications that render their view as a string rather than directly
7//! manipulating a [`Frame`]. The string is parsed as styled text and rendered
8//! into the frame automatically.
9//!
10//! This preserves the full kernel pipeline: String -> Text -> Frame -> Diff -> Presenter.
11//!
12//! # Example
13//!
14//! ```ignore
15//! use ftui_runtime::string_model::StringModel;
16//! use ftui_runtime::program::Cmd;
17//! use ftui_core::event::Event;
18//!
19//! struct Counter { count: i32 }
20//!
21//! enum Msg { Increment, Quit }
22//!
23//! impl From<Event> for Msg {
24//!     fn from(_: Event) -> Self { Msg::Increment }
25//! }
26//!
27//! impl StringModel for Counter {
28//!     type Message = Msg;
29//!
30//!     fn update(&mut self, msg: Msg) -> Cmd<Msg> {
31//!         match msg {
32//!             Msg::Increment => { self.count += 1; Cmd::none() }
33//!             Msg::Quit => Cmd::quit(),
34//!         }
35//!     }
36//!
37//!     fn view_string(&self) -> String {
38//!         format!("Count: {}", self.count)
39//!     }
40//! }
41//! ```
42
43use crate::program::{Cmd, Model};
44use ftui_core::event::Event;
45use ftui_render::cell::{Cell, CellContent};
46use ftui_render::frame::Frame;
47use ftui_text::{Text, grapheme_width};
48use unicode_segmentation::UnicodeSegmentation;
49
50/// A simplified model trait that uses string-based views.
51///
52/// Instead of rendering directly to a [`Frame`], implementations return
53/// a `String` from [`view_string`](Self::view_string). The string is
54/// converted to [`Text`] and rendered automatically.
55///
56/// This is ideal for quick prototyping and simple applications where
57/// full frame control isn't needed.
58pub trait StringModel: Sized {
59    /// The message type for this model.
60    type Message: From<Event> + Send + 'static;
61
62    /// Initialize the model with startup commands.
63    fn init(&mut self) -> Cmd<Self::Message> {
64        Cmd::none()
65    }
66
67    /// Update the model in response to a message.
68    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
69
70    /// Render the view as a string.
71    ///
72    /// The returned string is split by newlines and rendered into the frame.
73    /// Each line is rendered left-aligned starting from the top of the frame area.
74    fn view_string(&self) -> String;
75}
76
77/// Adapter that bridges a [`StringModel`] to the full [`Model`] trait.
78///
79/// This wrapper converts the string output of `view_string()` into
80/// `Text` and renders it into the frame, preserving the full kernel
81/// pipeline (Text -> Buffer -> Diff -> Presenter).
82pub struct StringModelAdapter<S: StringModel> {
83    inner: S,
84}
85
86impl<S: StringModel> StringModelAdapter<S> {
87    /// Create a new adapter wrapping the given string model.
88    #[inline]
89    pub fn new(inner: S) -> Self {
90        Self { inner }
91    }
92
93    /// Get a reference to the inner model.
94    #[inline]
95    #[must_use]
96    pub fn inner(&self) -> &S {
97        &self.inner
98    }
99
100    /// Get a mutable reference to the inner model.
101    #[inline]
102    pub fn inner_mut(&mut self) -> &mut S {
103        &mut self.inner
104    }
105
106    /// Consume the adapter and return the inner model.
107    #[inline]
108    #[must_use]
109    pub fn into_inner(self) -> S {
110        self.inner
111    }
112}
113
114impl<S: StringModel> Model for StringModelAdapter<S> {
115    type Message = S::Message;
116
117    fn init(&mut self) -> Cmd<Self::Message> {
118        self.inner.init()
119    }
120
121    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
122        self.inner.update(msg)
123    }
124
125    fn view(&self, frame: &mut Frame) {
126        let s = self.inner.view_string();
127        let text = Text::raw(&s);
128        render_text_to_frame(&text, frame);
129    }
130}
131
132/// Render a `Text` into a `Buffer`, line by line with span styles.
133///
134/// Each line is rendered left-aligned from (0, y). Lines beyond the
135/// buffer height are clipped. Characters beyond buffer width are clipped.
136fn render_text_to_frame(text: &Text, frame: &mut Frame) {
137    let width = frame.width();
138    let height = frame.height();
139
140    for (y, line) in text.lines().iter().enumerate() {
141        if y as u16 >= height {
142            break;
143        }
144
145        let mut x: u16 = 0;
146        for span in line.spans() {
147            if x >= width {
148                break;
149            }
150
151            let style = span.style.unwrap_or_default();
152
153            for grapheme in span.content.graphemes(true) {
154                if x >= width {
155                    break;
156                }
157
158                let w = grapheme_width(grapheme);
159                if w == 0 {
160                    continue;
161                }
162
163                // Skip if the wide character would exceed the buffer width
164                if x + w as u16 > width {
165                    break;
166                }
167
168                let content = if w > 1 || grapheme.chars().count() > 1 {
169                    let id = frame.intern_with_width(grapheme, w as u8);
170                    CellContent::from_grapheme(id)
171                } else if let Some(c) = grapheme.chars().next() {
172                    CellContent::from_char(c)
173                } else {
174                    continue;
175                };
176
177                let mut cell = Cell::new(content);
178                apply_style(&mut cell, style);
179                frame.buffer.set(x, y as u16, cell);
180
181                x = x.saturating_add(w as u16);
182            }
183        }
184    }
185}
186
187/// Apply a style to a cell.
188fn apply_style(cell: &mut Cell, style: ftui_style::Style) {
189    if let Some(fg) = style.fg {
190        cell.fg = fg;
191    }
192    if let Some(bg) = style.bg {
193        cell.bg = bg;
194    }
195    if let Some(attrs) = style.attrs {
196        let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
197        cell.attrs = cell.attrs.with_flags(cell_flags);
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use ftui_render::grapheme_pool::GraphemePool;
205
206    // ---------- Shared test message type ----------
207
208    #[derive(Debug)]
209    enum TestMsg {
210        Increment,
211        Decrement,
212        Quit,
213        NoOp,
214    }
215
216    impl From<Event> for TestMsg {
217        fn from(_: Event) -> Self {
218            TestMsg::NoOp
219        }
220    }
221
222    // ---------- Test StringModel ----------
223
224    struct CounterModel {
225        value: i32,
226    }
227
228    impl StringModel for CounterModel {
229        type Message = TestMsg;
230
231        fn update(&mut self, msg: TestMsg) -> Cmd<TestMsg> {
232            match msg {
233                TestMsg::Increment => {
234                    self.value += 1;
235                    Cmd::none()
236                }
237                TestMsg::Decrement => {
238                    self.value -= 1;
239                    Cmd::none()
240                }
241                TestMsg::Quit => Cmd::quit(),
242                TestMsg::NoOp => Cmd::none(),
243            }
244        }
245
246        fn view_string(&self) -> String {
247            format!("Count: {}", self.value)
248        }
249    }
250
251    // ---------- Tests ----------
252
253    #[test]
254    fn adapter_delegates_update() {
255        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
256        adapter.update(TestMsg::Increment);
257        assert_eq!(adapter.inner().value, 1);
258        adapter.update(TestMsg::Decrement);
259        assert_eq!(adapter.inner().value, 0);
260    }
261
262    #[test]
263    fn adapter_delegates_quit() {
264        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
265        let cmd = adapter.update(TestMsg::Quit);
266        assert!(matches!(cmd, Cmd::Quit));
267    }
268
269    #[test]
270    fn adapter_view_renders_text() {
271        let adapter = StringModelAdapter::new(CounterModel { value: 42 });
272        let mut pool = GraphemePool::new();
273        let mut frame = Frame::new(80, 24, &mut pool);
274
275        adapter.view(&mut frame);
276
277        // "Count: 42" should be rendered
278        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
279        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('o'));
280        assert_eq!(frame.buffer.get(7, 0).unwrap().content.as_char(), Some('4'));
281        assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('2'));
282    }
283
284    #[test]
285    fn adapter_view_multiline() {
286        struct MultiLineModel;
287
288        impl StringModel for MultiLineModel {
289            type Message = TestMsg;
290
291            fn update(&mut self, _msg: TestMsg) -> Cmd<TestMsg> {
292                Cmd::none()
293            }
294
295            fn view_string(&self) -> String {
296                "Line 1\nLine 2\nLine 3".to_string()
297            }
298        }
299
300        let adapter = StringModelAdapter::new(MultiLineModel);
301        let mut pool = GraphemePool::new();
302        let mut frame = Frame::new(20, 5, &mut pool);
303
304        adapter.view(&mut frame);
305
306        // Line 1 at y=0
307        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
308        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('1'));
309
310        // Line 2 at y=1
311        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('L'));
312        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('2'));
313
314        // Line 3 at y=2
315        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('L'));
316        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('3'));
317    }
318
319    #[test]
320    fn adapter_clips_to_buffer_height() {
321        struct TallModel;
322
323        impl StringModel for TallModel {
324            type Message = TestMsg;
325            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
326                Cmd::none()
327            }
328            fn view_string(&self) -> String {
329                (0..100)
330                    .map(|i| format!("Line {}", i))
331                    .collect::<Vec<_>>()
332                    .join("\n")
333            }
334        }
335
336        let adapter = StringModelAdapter::new(TallModel);
337        let mut pool = GraphemePool::new();
338        let mut frame = Frame::new(20, 3, &mut pool);
339
340        // Should not panic even with 100 lines in a 3-row buffer
341        adapter.view(&mut frame);
342
343        // Only first 3 lines rendered
344        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('0'));
345        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('1'));
346        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('2'));
347    }
348
349    #[test]
350    fn adapter_clips_to_buffer_width() {
351        struct WideModel;
352
353        impl StringModel for WideModel {
354            type Message = TestMsg;
355            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
356                Cmd::none()
357            }
358            fn view_string(&self) -> String {
359                "ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string()
360            }
361        }
362
363        let adapter = StringModelAdapter::new(WideModel);
364        let mut pool = GraphemePool::new();
365        let mut frame = Frame::new(5, 1, &mut pool);
366
367        // Should not panic
368        adapter.view(&mut frame);
369
370        // Only first 5 chars rendered
371        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
372        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('E'));
373    }
374
375    #[test]
376    fn adapter_renders_grapheme_clusters() {
377        struct EmojiModel;
378
379        impl StringModel for EmojiModel {
380            type Message = TestMsg;
381            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
382                Cmd::none()
383            }
384            fn view_string(&self) -> String {
385                "👩‍🚀X".to_string()
386            }
387        }
388
389        let adapter = StringModelAdapter::new(EmojiModel);
390        let mut pool = GraphemePool::new();
391        let mut frame = Frame::new(6, 1, &mut pool);
392
393        adapter.view(&mut frame);
394
395        let grapheme_width = grapheme_width("👩‍🚀");
396        assert!(grapheme_width >= 2);
397
398        let head = frame.buffer.get(0, 0).unwrap();
399        assert!(head.content.is_grapheme());
400        assert_eq!(head.content.width(), grapheme_width);
401
402        for i in 1..grapheme_width {
403            let tail = frame.buffer.get(i as u16, 0).unwrap();
404            assert!(tail.is_continuation(), "cell {i} should be continuation");
405        }
406
407        let next = frame.buffer.get(grapheme_width as u16, 0).unwrap();
408        assert_eq!(next.content.as_char(), Some('X'));
409    }
410
411    #[test]
412    fn adapter_inner_access() {
413        let adapter = StringModelAdapter::new(CounterModel { value: 99 });
414        assert_eq!(adapter.inner().value, 99);
415    }
416
417    #[test]
418    fn adapter_inner_mut_access() {
419        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
420        adapter.inner_mut().value = 50;
421        assert_eq!(adapter.inner().value, 50);
422    }
423
424    #[test]
425    fn adapter_into_inner() {
426        let adapter = StringModelAdapter::new(CounterModel { value: 42 });
427        let model = adapter.into_inner();
428        assert_eq!(model.value, 42);
429    }
430
431    #[test]
432    fn empty_view_string() {
433        struct EmptyModel;
434
435        impl StringModel for EmptyModel {
436            type Message = TestMsg;
437            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
438                Cmd::none()
439            }
440            fn view_string(&self) -> String {
441                String::new()
442            }
443        }
444
445        let adapter = StringModelAdapter::new(EmptyModel);
446        let mut pool = GraphemePool::new();
447        let mut frame = Frame::new(10, 5, &mut pool);
448
449        // Should not panic
450        adapter.view(&mut frame);
451    }
452
453    #[test]
454    fn default_init_returns_none() {
455        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
456        let cmd = adapter.init();
457        assert!(matches!(cmd, Cmd::None));
458    }
459
460    #[test]
461    fn render_text_styled_fg() {
462        use ftui_render::cell::PackedRgba;
463        use ftui_style::Style;
464        use ftui_text::{Line, Span, Text};
465
466        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
467        let line = Line::from_spans([Span::styled("Hi", style)]);
468        let text = Text::from_lines([line]);
469
470        let mut pool = GraphemePool::new();
471        let mut frame = Frame::new(10, 1, &mut pool);
472        render_text_to_frame(&text, &mut frame);
473
474        let cell = frame.buffer.get(0, 0).unwrap();
475        assert_eq!(cell.content.as_char(), Some('H'));
476        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
477    }
478
479    #[test]
480    fn render_blank_lines_between_content() {
481        let text = Text::raw("A\n\nB");
482
483        let mut pool = GraphemePool::new();
484        let mut frame = Frame::new(10, 5, &mut pool);
485        render_text_to_frame(&text, &mut frame);
486
487        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
488        // blank line at y=1 remains default
489        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('B'));
490    }
491
492    #[test]
493    fn adapter_noop_message() {
494        let mut adapter = StringModelAdapter::new(CounterModel { value: 5 });
495        let cmd = adapter.update(TestMsg::NoOp);
496        assert!(matches!(cmd, Cmd::None));
497        assert_eq!(adapter.inner().value, 5);
498    }
499}