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    pub fn new(inner: S) -> Self {
89        Self { inner }
90    }
91
92    /// Get a reference to the inner model.
93    pub fn inner(&self) -> &S {
94        &self.inner
95    }
96
97    /// Get a mutable reference to the inner model.
98    pub fn inner_mut(&mut self) -> &mut S {
99        &mut self.inner
100    }
101
102    /// Consume the adapter and return the inner model.
103    pub fn into_inner(self) -> S {
104        self.inner
105    }
106}
107
108impl<S: StringModel> Model for StringModelAdapter<S> {
109    type Message = S::Message;
110
111    fn init(&mut self) -> Cmd<Self::Message> {
112        self.inner.init()
113    }
114
115    fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
116        self.inner.update(msg)
117    }
118
119    fn view(&self, frame: &mut Frame) {
120        let s = self.inner.view_string();
121        let text = Text::raw(&s);
122        render_text_to_frame(&text, frame);
123    }
124}
125
126/// Render a `Text` into a `Buffer`, line by line with span styles.
127///
128/// Each line is rendered left-aligned from (0, y). Lines beyond the
129/// buffer height are clipped. Characters beyond buffer width are clipped.
130fn render_text_to_frame(text: &Text, frame: &mut Frame) {
131    let width = frame.width();
132    let height = frame.height();
133
134    for (y, line) in text.lines().iter().enumerate() {
135        if y as u16 >= height {
136            break;
137        }
138
139        let mut x: u16 = 0;
140        for span in line.spans() {
141            if x >= width {
142                break;
143            }
144
145            let style = span.style.unwrap_or_default();
146
147            for grapheme in span.content.graphemes(true) {
148                if x >= width {
149                    break;
150                }
151
152                let w = grapheme_width(grapheme);
153                if w == 0 {
154                    continue;
155                }
156
157                // Skip if the wide character would exceed the buffer width
158                if x + w as u16 > width {
159                    break;
160                }
161
162                let content = if w > 1 || grapheme.chars().count() > 1 {
163                    let id = frame.intern_with_width(grapheme, w as u8);
164                    CellContent::from_grapheme(id)
165                } else if let Some(c) = grapheme.chars().next() {
166                    CellContent::from_char(c)
167                } else {
168                    continue;
169                };
170
171                let mut cell = Cell::new(content);
172                apply_style(&mut cell, style);
173                frame.buffer.set(x, y as u16, cell);
174
175                x = x.saturating_add(w as u16);
176            }
177        }
178    }
179}
180
181/// Apply a style to a cell.
182fn apply_style(cell: &mut Cell, style: ftui_style::Style) {
183    if let Some(fg) = style.fg {
184        cell.fg = fg;
185    }
186    if let Some(bg) = style.bg {
187        cell.bg = bg;
188    }
189    if let Some(attrs) = style.attrs {
190        let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
191        cell.attrs = cell.attrs.with_flags(cell_flags);
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use ftui_render::grapheme_pool::GraphemePool;
199
200    // ---------- Shared test message type ----------
201
202    #[derive(Debug)]
203    enum TestMsg {
204        Increment,
205        Decrement,
206        Quit,
207        NoOp,
208    }
209
210    impl From<Event> for TestMsg {
211        fn from(_: Event) -> Self {
212            TestMsg::NoOp
213        }
214    }
215
216    // ---------- Test StringModel ----------
217
218    struct CounterModel {
219        value: i32,
220    }
221
222    impl StringModel for CounterModel {
223        type Message = TestMsg;
224
225        fn update(&mut self, msg: TestMsg) -> Cmd<TestMsg> {
226            match msg {
227                TestMsg::Increment => {
228                    self.value += 1;
229                    Cmd::none()
230                }
231                TestMsg::Decrement => {
232                    self.value -= 1;
233                    Cmd::none()
234                }
235                TestMsg::Quit => Cmd::quit(),
236                TestMsg::NoOp => Cmd::none(),
237            }
238        }
239
240        fn view_string(&self) -> String {
241            format!("Count: {}", self.value)
242        }
243    }
244
245    // ---------- Tests ----------
246
247    #[test]
248    fn adapter_delegates_update() {
249        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
250        adapter.update(TestMsg::Increment);
251        assert_eq!(adapter.inner().value, 1);
252        adapter.update(TestMsg::Decrement);
253        assert_eq!(adapter.inner().value, 0);
254    }
255
256    #[test]
257    fn adapter_delegates_quit() {
258        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
259        let cmd = adapter.update(TestMsg::Quit);
260        assert!(matches!(cmd, Cmd::Quit));
261    }
262
263    #[test]
264    fn adapter_view_renders_text() {
265        let adapter = StringModelAdapter::new(CounterModel { value: 42 });
266        let mut pool = GraphemePool::new();
267        let mut frame = Frame::new(80, 24, &mut pool);
268
269        adapter.view(&mut frame);
270
271        // "Count: 42" should be rendered
272        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
273        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('o'));
274        assert_eq!(frame.buffer.get(7, 0).unwrap().content.as_char(), Some('4'));
275        assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('2'));
276    }
277
278    #[test]
279    fn adapter_view_multiline() {
280        struct MultiLineModel;
281
282        impl StringModel for MultiLineModel {
283            type Message = TestMsg;
284
285            fn update(&mut self, _msg: TestMsg) -> Cmd<TestMsg> {
286                Cmd::none()
287            }
288
289            fn view_string(&self) -> String {
290                "Line 1\nLine 2\nLine 3".to_string()
291            }
292        }
293
294        let adapter = StringModelAdapter::new(MultiLineModel);
295        let mut pool = GraphemePool::new();
296        let mut frame = Frame::new(20, 5, &mut pool);
297
298        adapter.view(&mut frame);
299
300        // Line 1 at y=0
301        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
302        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('1'));
303
304        // Line 2 at y=1
305        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('L'));
306        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('2'));
307
308        // Line 3 at y=2
309        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('L'));
310        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('3'));
311    }
312
313    #[test]
314    fn adapter_clips_to_buffer_height() {
315        struct TallModel;
316
317        impl StringModel for TallModel {
318            type Message = TestMsg;
319            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
320                Cmd::none()
321            }
322            fn view_string(&self) -> String {
323                (0..100)
324                    .map(|i| format!("Line {}", i))
325                    .collect::<Vec<_>>()
326                    .join("\n")
327            }
328        }
329
330        let adapter = StringModelAdapter::new(TallModel);
331        let mut pool = GraphemePool::new();
332        let mut frame = Frame::new(20, 3, &mut pool);
333
334        // Should not panic even with 100 lines in a 3-row buffer
335        adapter.view(&mut frame);
336
337        // Only first 3 lines rendered
338        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('0'));
339        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('1'));
340        assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('2'));
341    }
342
343    #[test]
344    fn adapter_clips_to_buffer_width() {
345        struct WideModel;
346
347        impl StringModel for WideModel {
348            type Message = TestMsg;
349            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
350                Cmd::none()
351            }
352            fn view_string(&self) -> String {
353                "ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string()
354            }
355        }
356
357        let adapter = StringModelAdapter::new(WideModel);
358        let mut pool = GraphemePool::new();
359        let mut frame = Frame::new(5, 1, &mut pool);
360
361        // Should not panic
362        adapter.view(&mut frame);
363
364        // Only first 5 chars rendered
365        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
366        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('E'));
367    }
368
369    #[test]
370    fn adapter_renders_grapheme_clusters() {
371        struct EmojiModel;
372
373        impl StringModel for EmojiModel {
374            type Message = TestMsg;
375            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
376                Cmd::none()
377            }
378            fn view_string(&self) -> String {
379                "👩‍🚀X".to_string()
380            }
381        }
382
383        let adapter = StringModelAdapter::new(EmojiModel);
384        let mut pool = GraphemePool::new();
385        let mut frame = Frame::new(6, 1, &mut pool);
386
387        adapter.view(&mut frame);
388
389        let grapheme_width = grapheme_width("👩‍🚀");
390        assert!(grapheme_width >= 2);
391
392        let head = frame.buffer.get(0, 0).unwrap();
393        assert!(head.content.is_grapheme());
394        assert_eq!(head.content.width(), grapheme_width);
395
396        for i in 1..grapheme_width {
397            let tail = frame.buffer.get(i as u16, 0).unwrap();
398            assert!(tail.is_continuation(), "cell {i} should be continuation");
399        }
400
401        let next = frame.buffer.get(grapheme_width as u16, 0).unwrap();
402        assert_eq!(next.content.as_char(), Some('X'));
403    }
404
405    #[test]
406    fn adapter_inner_access() {
407        let adapter = StringModelAdapter::new(CounterModel { value: 99 });
408        assert_eq!(adapter.inner().value, 99);
409    }
410
411    #[test]
412    fn adapter_inner_mut_access() {
413        let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
414        adapter.inner_mut().value = 50;
415        assert_eq!(adapter.inner().value, 50);
416    }
417
418    #[test]
419    fn adapter_into_inner() {
420        let adapter = StringModelAdapter::new(CounterModel { value: 42 });
421        let model = adapter.into_inner();
422        assert_eq!(model.value, 42);
423    }
424
425    #[test]
426    fn empty_view_string() {
427        struct EmptyModel;
428
429        impl StringModel for EmptyModel {
430            type Message = TestMsg;
431            fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
432                Cmd::none()
433            }
434            fn view_string(&self) -> String {
435                String::new()
436            }
437        }
438
439        let adapter = StringModelAdapter::new(EmptyModel);
440        let mut pool = GraphemePool::new();
441        let mut frame = Frame::new(10, 5, &mut pool);
442
443        // Should not panic
444        adapter.view(&mut frame);
445    }
446}