#![forbid(unsafe_code)]
use crate::program::{Cmd, Model};
use ftui_core::event::Event;
use ftui_render::cell::{Cell, CellContent};
use ftui_render::frame::Frame;
use ftui_text::{Text, grapheme_width};
use unicode_segmentation::UnicodeSegmentation;
pub trait StringModel: Sized {
type Message: From<Event> + Send + 'static;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message>;
fn view_string(&self) -> String;
}
pub struct StringModelAdapter<S: StringModel> {
inner: S,
}
impl<S: StringModel> StringModelAdapter<S> {
#[inline]
pub fn new(inner: S) -> Self {
Self { inner }
}
#[inline]
#[must_use]
pub fn inner(&self) -> &S {
&self.inner
}
#[inline]
pub fn inner_mut(&mut self) -> &mut S {
&mut self.inner
}
#[inline]
#[must_use]
pub fn into_inner(self) -> S {
self.inner
}
}
impl<S: StringModel> Model for StringModelAdapter<S> {
type Message = S::Message;
fn init(&mut self) -> Cmd<Self::Message> {
self.inner.init()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
self.inner.update(msg)
}
fn view(&self, frame: &mut Frame) {
let s = self.inner.view_string();
let text = Text::raw(&s);
render_text_to_frame(&text, frame);
}
}
fn render_text_to_frame(text: &Text, frame: &mut Frame) {
let width = frame.width();
let height = frame.height();
for (y, line) in text.lines().iter().enumerate() {
if y as u16 >= height {
break;
}
let mut x: u16 = 0;
for span in line.spans() {
if x >= width {
break;
}
let style = span.style.unwrap_or_default();
for grapheme in span.content.graphemes(true) {
if x >= width {
break;
}
let w = grapheme_width(grapheme);
if w == 0 {
continue;
}
if x + w as u16 > width {
break;
}
let content = if w > 1 || grapheme.chars().count() > 1 {
let id = frame.intern_with_width(grapheme, w as u8);
CellContent::from_grapheme(id)
} else if let Some(c) = grapheme.chars().next() {
CellContent::from_char(c)
} else {
continue;
};
let mut cell = Cell::new(content);
apply_style(&mut cell, style);
frame.buffer.set(x, y as u16, cell);
x = x.saturating_add(w as u16);
}
}
}
}
fn apply_style(cell: &mut Cell, style: ftui_style::Style) {
if let Some(fg) = style.fg {
cell.fg = fg;
}
if let Some(bg) = style.bg {
match bg.a() {
0 => {} 255 => cell.bg = bg, _ => cell.bg = bg.over(cell.bg), }
}
if let Some(attrs) = style.attrs {
let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
cell.attrs = cell.attrs.merged_flags(cell_flags);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::grapheme_pool::GraphemePool;
#[derive(Debug)]
enum TestMsg {
Increment,
Decrement,
Quit,
NoOp,
}
impl From<Event> for TestMsg {
fn from(_: Event) -> Self {
TestMsg::NoOp
}
}
struct CounterModel {
value: i32,
}
impl StringModel for CounterModel {
type Message = TestMsg;
fn update(&mut self, msg: TestMsg) -> Cmd<TestMsg> {
match msg {
TestMsg::Increment => {
self.value += 1;
Cmd::none()
}
TestMsg::Decrement => {
self.value -= 1;
Cmd::none()
}
TestMsg::Quit => Cmd::quit(),
TestMsg::NoOp => Cmd::none(),
}
}
fn view_string(&self) -> String {
format!("Count: {}", self.value)
}
}
#[test]
fn adapter_delegates_update() {
let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
adapter.update(TestMsg::Increment);
assert_eq!(adapter.inner().value, 1);
adapter.update(TestMsg::Decrement);
assert_eq!(adapter.inner().value, 0);
}
#[test]
fn adapter_delegates_quit() {
let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
let cmd = adapter.update(TestMsg::Quit);
assert!(matches!(cmd, Cmd::Quit));
}
#[test]
fn adapter_view_renders_text() {
let adapter = StringModelAdapter::new(CounterModel { value: 42 });
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 24, &mut pool);
adapter.view(&mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('o'));
assert_eq!(frame.buffer.get(7, 0).unwrap().content.as_char(), Some('4'));
assert_eq!(frame.buffer.get(8, 0).unwrap().content.as_char(), Some('2'));
}
#[test]
fn adapter_view_multiline() {
struct MultiLineModel;
impl StringModel for MultiLineModel {
type Message = TestMsg;
fn update(&mut self, _msg: TestMsg) -> Cmd<TestMsg> {
Cmd::none()
}
fn view_string(&self) -> String {
"Line 1\nLine 2\nLine 3".to_string()
}
}
let adapter = StringModelAdapter::new(MultiLineModel);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 5, &mut pool);
adapter.view(&mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('L'));
assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('1'));
assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('L'));
assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('2'));
assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('L'));
assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('3'));
}
#[test]
fn adapter_clips_to_buffer_height() {
struct TallModel;
impl StringModel for TallModel {
type Message = TestMsg;
fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
Cmd::none()
}
fn view_string(&self) -> String {
(0..100)
.map(|i| format!("Line {}", i))
.collect::<Vec<_>>()
.join("\n")
}
}
let adapter = StringModelAdapter::new(TallModel);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 3, &mut pool);
adapter.view(&mut frame);
assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('0'));
assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some('1'));
assert_eq!(frame.buffer.get(5, 2).unwrap().content.as_char(), Some('2'));
}
#[test]
fn adapter_clips_to_buffer_width() {
struct WideModel;
impl StringModel for WideModel {
type Message = TestMsg;
fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
Cmd::none()
}
fn view_string(&self) -> String {
"ABCDEFGHIJKLMNOPQRSTUVWXYZ".to_string()
}
}
let adapter = StringModelAdapter::new(WideModel);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
adapter.view(&mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('E'));
}
#[test]
fn adapter_renders_grapheme_clusters() {
struct EmojiModel;
impl StringModel for EmojiModel {
type Message = TestMsg;
fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
Cmd::none()
}
fn view_string(&self) -> String {
"👩🚀X".to_string()
}
}
let adapter = StringModelAdapter::new(EmojiModel);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
adapter.view(&mut frame);
let grapheme_width = grapheme_width("👩🚀");
assert!(grapheme_width >= 2);
let head = frame.buffer.get(0, 0).unwrap();
assert!(head.content.is_grapheme());
assert_eq!(head.content.width(), grapheme_width);
for i in 1..grapheme_width {
let tail = frame.buffer.get(i as u16, 0).unwrap();
assert!(tail.is_continuation(), "cell {i} should be continuation");
}
let next = frame.buffer.get(grapheme_width as u16, 0).unwrap();
assert_eq!(next.content.as_char(), Some('X'));
}
#[test]
fn adapter_inner_access() {
let adapter = StringModelAdapter::new(CounterModel { value: 99 });
assert_eq!(adapter.inner().value, 99);
}
#[test]
fn adapter_inner_mut_access() {
let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
adapter.inner_mut().value = 50;
assert_eq!(adapter.inner().value, 50);
}
#[test]
fn adapter_into_inner() {
let adapter = StringModelAdapter::new(CounterModel { value: 42 });
let model = adapter.into_inner();
assert_eq!(model.value, 42);
}
#[test]
fn empty_view_string() {
struct EmptyModel;
impl StringModel for EmptyModel {
type Message = TestMsg;
fn update(&mut self, _: TestMsg) -> Cmd<TestMsg> {
Cmd::none()
}
fn view_string(&self) -> String {
String::new()
}
}
let adapter = StringModelAdapter::new(EmptyModel);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
adapter.view(&mut frame);
}
#[test]
fn default_init_returns_none() {
let mut adapter = StringModelAdapter::new(CounterModel { value: 0 });
let cmd = adapter.init();
assert!(matches!(cmd, Cmd::None));
}
#[test]
fn render_text_styled_fg() {
use ftui_render::cell::PackedRgba;
use ftui_style::Style;
use ftui_text::{Line, Span, Text};
let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
let line = Line::from_spans([Span::styled("Hi", style)]);
let text = Text::from_lines([line]);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
render_text_to_frame(&text, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('H'));
assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
}
#[test]
fn render_blank_lines_between_content() {
let text = Text::raw("A\n\nB");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
render_text_to_frame(&text, &mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('B'));
}
#[test]
fn adapter_noop_message() {
let mut adapter = StringModelAdapter::new(CounterModel { value: 5 });
let cmd = adapter.update(TestMsg::NoOp);
assert!(matches!(cmd, Cmd::None));
assert_eq!(adapter.inner().value, 5);
}
}