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 using merge semantics.
188///
189/// - **fg:** replaced when set.
190/// - **bg:** alpha-aware compositing (Porter-Duff SourceOver).
191/// - **attrs:** OR-merged on top of existing flags (never cleared).
192fn apply_style(cell: &mut Cell, style: ftui_style::Style) {
193    if let Some(fg) = style.fg {
194        cell.fg = fg;
195    }
196    if let Some(bg) = style.bg {
197        match bg.a() {
198            0 => {}                          // Fully transparent: no-op
199            255 => cell.bg = bg,             // Fully opaque: replace
200            _ => cell.bg = bg.over(cell.bg), // Composite src-over-dst
201        }
202    }
203    if let Some(attrs) = style.attrs {
204        let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
205        cell.attrs = cell.attrs.merged_flags(cell_flags);
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use ftui_render::grapheme_pool::GraphemePool;
213
214    // ---------- Shared test message type ----------
215
216    #[derive(Debug)]
217    enum TestMsg {
218        Increment,
219        Decrement,
220        Quit,
221        NoOp,
222    }
223
224    impl From<Event> for TestMsg {
225        fn from(_: Event) -> Self {
226            TestMsg::NoOp
227        }
228    }
229
230    // ---------- Test StringModel ----------
231
232    struct CounterModel {
233        value: i32,
234    }
235
236    impl StringModel for CounterModel {
237        type Message = TestMsg;
238
239        fn update(&mut self, msg: TestMsg) -> Cmd<TestMsg> {
240            match msg {
241                TestMsg::Increment => {
242                    self.value += 1;
243                    Cmd::none()
244                }
245                TestMsg::Decrement => {
246                    self.value -= 1;
247                    Cmd::none()
248                }
249                TestMsg::Quit => Cmd::quit(),
250                TestMsg::NoOp => Cmd::none(),
251            }
252        }
253
254        fn view_string(&self) -> String {
255            format!("Count: {}", self.value)
256        }
257    }
258
259    // ---------- Tests ----------
260
261    #[test]
262    fn adapter_delegates_update() {
263        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
264        adapter.update(TestMsg::Increment);
265        assert_eq!(adapter.inner().value, 1);
266        adapter.update(TestMsg::Decrement);
267        assert_eq!(adapter.inner().value, 0);
268    }
269
270    #[test]
271    fn adapter_delegates_quit() {
272        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
273        let cmd = adapter.update(TestMsg::Quit);
274        assert!(matches!(cmd, Cmd::Quit));
275    }
276
277    #[test]
278    fn adapter_view_renders_text() {
279        let adapter = StringModelAdapter::new(CounterModel { value: 42 });
280        let mut pool = GraphemePool::new();
281        let mut frame = Frame::new(80, 24, &mut pool);
282
283        adapter.view(&mut frame);
284
285        // "Count: 42" should be rendered
286        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
287        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('o'));
288        assert_eq!(frame.buffer.get(7, 0).unwrap().content.as_char(), Some('4'));
289        assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('2'));
290    }
291
292    #[test]
293    fn adapter_view_multiline() {
294        struct MultiLineModel;
295
296        impl StringModel for MultiLineModel {
297            type Message = TestMsg;
298
299            fn update(&mut self, _msg: TestMsg) -> Cmd<TestMsg> {
300                Cmd::none()
301            }
302
303            fn view_string(&self) -> String {
304                "Line 1\nLine 2\nLine 3".to_string()
305            }
306        }
307
308        let adapter = StringModelAdapter::new(MultiLineModel);
309        let mut pool = GraphemePool::new();
310        let mut frame = Frame::new(20, 5, &mut pool);
311
312        adapter.view(&mut frame);
313
314        // Line 1 at y=0
315        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
316        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('1'));
317
318        // Line 2 at y=1
319        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('L'));
320        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('2'));
321
322        // Line 3 at y=2
323        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('L'));
324        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('3'));
325    }
326
327    #[test]
328    fn adapter_clips_to_buffer_height() {
329        struct TallModel;
330
331        impl StringModel for TallModel {
332            type Message = TestMsg;
333            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
334                Cmd::none()
335            }
336            fn view_string(&self) -> String {
337                (0..100)
338                    .map(|i| format!("Line {}", i))
339                    .collect::<Vec<_>>()
340                    .join("\n")
341            }
342        }
343
344        let adapter = StringModelAdapter::new(TallModel);
345        let mut pool = GraphemePool::new();
346        let mut frame = Frame::new(20, 3, &mut pool);
347
348        // Should not panic even with 100 lines in a 3-row buffer
349        adapter.view(&mut frame);
350
351        // Only first 3 lines rendered
352        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('0'));
353        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('1'));
354        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('2'));
355    }
356
357    #[test]
358    fn adapter_clips_to_buffer_width() {
359        struct WideModel;
360
361        impl StringModel for WideModel {
362            type Message = TestMsg;
363            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
364                Cmd::none()
365            }
366            fn view_string(&self) -> String {
367                "ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string()
368            }
369        }
370
371        let adapter = StringModelAdapter::new(WideModel);
372        let mut pool = GraphemePool::new();
373        let mut frame = Frame::new(5, 1, &mut pool);
374
375        // Should not panic
376        adapter.view(&mut frame);
377
378        // Only first 5 chars rendered
379        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
380        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('E'));
381    }
382
383    #[test]
384    fn adapter_renders_grapheme_clusters() {
385        struct EmojiModel;
386
387        impl StringModel for EmojiModel {
388            type Message = TestMsg;
389            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
390                Cmd::none()
391            }
392            fn view_string(&self) -> String {
393                "👩‍🚀X".to_string()
394            }
395        }
396
397        let adapter = StringModelAdapter::new(EmojiModel);
398        let mut pool = GraphemePool::new();
399        let mut frame = Frame::new(6, 1, &mut pool);
400
401        adapter.view(&mut frame);
402
403        let grapheme_width = grapheme_width("👩‍🚀");
404        assert!(grapheme_width >= 2);
405
406        let head = frame.buffer.get(0, 0).unwrap();
407        assert!(head.content.is_grapheme());
408        assert_eq!(head.content.width(), grapheme_width);
409
410        for i in 1..grapheme_width {
411            let tail = frame.buffer.get(i as u16, 0).unwrap();
412            assert!(tail.is_continuation(), "cell {i} should be continuation");
413        }
414
415        let next = frame.buffer.get(grapheme_width as u16, 0).unwrap();
416        assert_eq!(next.content.as_char(), Some('X'));
417    }
418
419    #[test]
420    fn adapter_inner_access() {
421        let adapter = StringModelAdapter::new(CounterModel { value: 99 });
422        assert_eq!(adapter.inner().value, 99);
423    }
424
425    #[test]
426    fn adapter_inner_mut_access() {
427        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
428        adapter.inner_mut().value = 50;
429        assert_eq!(adapter.inner().value, 50);
430    }
431
432    #[test]
433    fn adapter_into_inner() {
434        let adapter = StringModelAdapter::new(CounterModel { value: 42 });
435        let model = adapter.into_inner();
436        assert_eq!(model.value, 42);
437    }
438
439    #[test]
440    fn empty_view_string() {
441        struct EmptyModel;
442
443        impl StringModel for EmptyModel {
444            type Message = TestMsg;
445            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
446                Cmd::none()
447            }
448            fn view_string(&self) -> String {
449                String::new()
450            }
451        }
452
453        let adapter = StringModelAdapter::new(EmptyModel);
454        let mut pool = GraphemePool::new();
455        let mut frame = Frame::new(10, 5, &mut pool);
456
457        // Should not panic
458        adapter.view(&mut frame);
459    }
460
461    #[test]
462    fn default_init_returns_none() {
463        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
464        let cmd = adapter.init();
465        assert!(matches!(cmd, Cmd::None));
466    }
467
468    #[test]
469    fn render_text_styled_fg() {
470        use ftui_render::cell::PackedRgba;
471        use ftui_style::Style;
472        use ftui_text::{Line, Span, Text};
473
474        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
475        let line = Line::from_spans([Span::styled("Hi", style)]);
476        let text = Text::from_lines([line]);
477
478        let mut pool = GraphemePool::new();
479        let mut frame = Frame::new(10, 1, &mut pool);
480        render_text_to_frame(&text, &mut frame);
481
482        let cell = frame.buffer.get(0, 0).unwrap();
483        assert_eq!(cell.content.as_char(), Some('H'));
484        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
485    }
486
487    #[test]
488    fn render_blank_lines_between_content() {
489        let text = Text::raw("A\n\nB");
490
491        let mut pool = GraphemePool::new();
492        let mut frame = Frame::new(10, 5, &mut pool);
493        render_text_to_frame(&text, &mut frame);
494
495        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
496        // blank line at y=1 remains default
497        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('B'));
498    }
499
500    #[test]
501    fn adapter_noop_message() {
502        let mut adapter = StringModelAdapter::new(CounterModel { value: 5 });
503        let cmd = adapter.update(TestMsg::NoOp);
504        assert!(matches!(cmd, Cmd::None));
505        assert_eq!(adapter.inner().value, 5);
506    }
507}