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) {
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 #[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 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 #[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 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 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 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 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 adapter.view(&mut frame);
342
343 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 adapter.view(&mut frame);
369
370 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 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 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}