ftui_runtime/
string_model.rs1#![forbid(unsafe_code)]
2
3use 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
50pub trait StringModel: Sized {
59 type Message: From<Event> + Send + 'static;
61
62 fn init(&mut self) -> Cmd<Self::Message> {
64 Cmd::none()
65 }
66
67 fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
69
70 fn view_string(&self) -> String;
75}
76
77pub struct StringModelAdapter<S: StringModel> {
83 inner: S,
84}
85
86impl<S: StringModel> StringModelAdapter<S> {
87 pub fn new(inner: S) -> Self {
89 Self { inner }
90 }
91
92 pub fn inner(&self) -> &S {
94 &self.inner
95 }
96
97 pub fn inner_mut(&mut self) -> &mut S {
99 &mut self.inner
100 }
101
102 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
126fn 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 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
181fn 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 #[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 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 #[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 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 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 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 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 adapter.view(&mut frame);
336
337 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 adapter.view(&mut frame);
363
364 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 adapter.view(&mut frame);
445 }
446}