1#![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 #[inline]
89 pub fn new(inner: S) -> Self {
90 Self { inner }
91 }
92
93 #[inline]
95 #[must_use]
96 pub fn inner(&self) -> &S {
97 &self.inner
98 }
99
100 #[inline]
102 pub fn inner_mut(&mut self) -> &mut S {
103 &mut self.inner
104 }
105
106 #[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
132fn 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 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
187fn 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 => {} 255 => cell.bg = bg, _ => cell.bg = bg.over(cell.bg), }
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 #[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 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 #[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 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 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 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 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 adapter.view(&mut frame);
350
351 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 adapter.view(&mut frame);
377
378 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 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 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}