use crate::event::Rect;
use crate::types::{Color, StyledSpan, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageRow {
pub text: String,
pub fg: Color,
#[serde(default)]
pub indent: f32,
#[serde(default)]
pub spans: Vec<StyledSpan>,
#[serde(default = "MessageRow::default_scale")]
pub scale: f32,
}
impl MessageRow {
pub fn new(text: impl Into<String>, fg: Color, indent: f32) -> Self {
Self {
text: text.into(),
fg,
indent,
spans: Vec::new(),
scale: 1.0,
}
}
pub fn styled(styled: StyledText, fg: Color, indent: f32, scale: f32) -> Self {
let text: String = styled.spans.iter().map(|s| s.text.as_str()).collect();
Self {
text,
fg,
indent,
spans: styled.spans,
scale,
}
}
fn default_scale() -> f32 {
1.0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageList {
pub id: WidgetId,
pub rows: Vec<MessageRow>,
#[serde(default)]
pub scroll_top: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MessageListMeasure {
pub line_height: f32,
}
impl MessageListMeasure {
pub fn new(line_height: f32) -> Self {
Self { line_height }
}
pub fn from_metrics(m: &crate::backend::Metrics) -> Self {
Self::new(m.line_height)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageListHit {
Row(usize),
Empty,
}
impl MessageList {
pub fn hit_test(
&self,
rect: Rect,
measure: MessageListMeasure,
x: f32,
y: f32,
) -> MessageListHit {
if measure.line_height <= 0.0 {
return MessageListHit::Empty;
}
if x < rect.x || x >= rect.x + rect.width || y < rect.y || y >= rect.y + rect.height {
return MessageListHit::Empty;
}
let row_offset = ((y - rect.y) / measure.line_height) as usize;
let idx = self.scroll_top + row_offset;
if idx < self.rows.len() {
MessageListHit::Row(idx)
} else {
MessageListHit::Empty
}
}
}
#[cfg(any(feature = "win", all(feature = "macos", target_os = "macos")))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::MessageList;
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
pub(crate) fn paint(
list: &MessageList,
surface: &mut dyn PaintSurface,
rect: Rect,
line_height: f32,
) {
if line_height <= 0.0 {
return;
}
let max_y = rect.y + rect.height;
for (i, row) in list.rows.iter().skip(list.scroll_top).enumerate() {
let ry = rect.y + i as f32 * line_height;
if ry + line_height > max_y {
break;
}
if !row.spans.is_empty() {
let mut cursor_x = rect.x + row.indent;
for span in &row.spans {
let span_fg = span.fg.unwrap_or(row.fg);
let (sw, sh) = surface.surface_measure_text_styled(&span.text, span.bold);
let sy = ry + (line_height - sh) / 2.0;
surface.surface_draw_text_run_styled(
Rect::new(cursor_x, sy, sw.max(1.0), sh.max(1.0)),
&span.text,
span_fg,
span.bold,
span.italic,
span.underline,
1.0,
);
cursor_x += sw;
}
} else {
let (sw, sh) = surface.surface_measure_text(&row.text);
let sy = ry + (line_height - sh) / 2.0;
surface.surface_draw_text_run(
Rect::new(rect.x + row.indent, sy, sw.max(1.0), sh.max(1.0)),
&row.text,
row.fg,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::primitives::message_list::MessageRow;
use crate::types::{Color, StyledSpan, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
text_runs: Vec<(Rect, String, Color)>,
styled_runs: Vec<(Rect, String, Color, bool, bool, bool)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: crate::Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> crate::Viewport {
crate::Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_measure_text_styled(&self, text: &str, _bold: bool) -> (f32, f32) {
self.surface_measure_text(text)
}
fn surface_fill_rect(&mut self, _rect: Rect, _color: Color) {}
fn surface_fill_rounded_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
#[allow(clippy::too_many_arguments)]
fn surface_draw_text_run_styled(
&mut self,
rect: Rect,
text: &str,
color: Color,
bold: bool,
italic: bool,
underline: bool,
_scale_x: f32,
) {
self.styled_runs
.push((rect, text.to_string(), color, bold, italic, underline));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn sample_list() -> MessageList {
MessageList {
id: WidgetId::new("ml"),
rows: vec![
MessageRow::new("You:", Color::rgb(255, 220, 0), 0.0),
MessageRow::new("hi there", Color::rgb(220, 220, 220), 8.0),
],
scroll_top: 0,
}
}
#[test]
fn flat_rows_paint_via_plain_text_run() {
let list = sample_list();
let mut surface = RecordingSurface::default();
paint(&list, &mut surface, Rect::new(0.0, 0.0, 200.0, 100.0), 16.0);
assert_eq!(surface.text_runs.len(), 2);
assert!(surface.styled_runs.is_empty());
assert_eq!(surface.text_runs[0].1, "You:");
assert_eq!(surface.text_runs[0].2, Color::rgb(255, 220, 0));
}
#[test]
fn styled_rows_paint_per_span_via_styled_run() {
let mut list = sample_list();
list.rows = vec![MessageRow {
text: "bold text".into(),
fg: Color::rgb(220, 220, 220),
indent: 0.0,
spans: vec![
StyledSpan {
text: "bold".into(),
fg: Some(Color::rgb(255, 0, 0)),
bg: None,
bold: true,
italic: false,
underline: false,
},
StyledSpan::plain(" text"),
],
scale: 1.0,
}];
let mut surface = RecordingSurface::default();
paint(&list, &mut surface, Rect::new(0.0, 0.0, 200.0, 100.0), 16.0);
assert!(surface.text_runs.is_empty());
assert_eq!(surface.styled_runs.len(), 2);
assert_eq!(surface.styled_runs[0].1, "bold");
assert_eq!(surface.styled_runs[0].2, Color::rgb(255, 0, 0));
assert!(surface.styled_runs[0].3, "first span should paint bold");
assert_eq!(surface.styled_runs[1].2, Color::rgb(220, 220, 220));
assert!(!surface.styled_runs[1].3);
}
#[test]
fn scroll_top_skips_leading_rows() {
let mut list = sample_list();
list.scroll_top = 1;
let mut surface = RecordingSurface::default();
paint(&list, &mut surface, Rect::new(0.0, 0.0, 200.0, 100.0), 16.0);
assert_eq!(surface.text_runs.len(), 1);
assert_eq!(surface.text_runs[0].1, "hi there");
}
#[test]
fn rows_past_bottom_edge_are_not_painted() {
let mut list = sample_list();
for i in 0..20 {
list.rows.push(MessageRow::new(
format!("row {i}"),
Color::rgb(200, 200, 200),
0.0,
));
}
let mut surface = RecordingSurface::default();
paint(&list, &mut surface, Rect::new(0.0, 0.0, 200.0, 48.0), 16.0);
assert_eq!(surface.text_runs.len(), 3);
}
#[test]
fn zero_line_height_paints_nothing() {
let list = sample_list();
let mut surface = RecordingSurface::default();
paint(&list, &mut surface, Rect::new(0.0, 0.0, 200.0, 100.0), 0.0);
assert!(surface.text_runs.is_empty());
assert!(surface.styled_runs.is_empty());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_has_empty_spans_and_unit_scale() {
let row = MessageRow::new("hello", Color::rgb(200, 200, 200), 2.0);
assert!(
row.spans.is_empty(),
"MessageRow::new must produce empty spans"
);
assert!(
(row.scale - 1.0).abs() < f32::EPSILON,
"MessageRow::new must set scale to 1.0"
);
assert_eq!(row.text, "hello");
assert_eq!(row.indent, 2.0);
}
#[test]
fn styled_carries_spans_and_scale() {
let spans = vec![
StyledSpan {
text: "bold".to_string(),
fg: Some(Color::rgb(255, 255, 0)),
bg: None,
bold: true,
italic: false,
underline: false,
},
StyledSpan::plain(" text"),
];
let styled = StyledText {
spans: spans.clone(),
};
let row = MessageRow::styled(styled, Color::rgb(200, 200, 200), 2.0, 1.5);
assert_eq!(row.spans, spans);
assert!((row.scale - 1.5).abs() < f32::EPSILON);
assert_eq!(row.text, "bold text");
assert_eq!(row.indent, 2.0);
}
#[test]
fn styled_with_empty_styled_text_produces_empty_spans_and_empty_text() {
let row = MessageRow::styled(StyledText::default(), Color::rgb(100, 100, 100), 0.0, 1.0);
assert!(row.spans.is_empty());
assert_eq!(row.text, "");
}
#[test]
fn new_and_equivalent_styled_plain_rows_have_same_text() {
let plain = StyledText::plain("same content");
let flat = MessageRow::new("same content", Color::rgb(200, 200, 200), 2.0);
let rich = MessageRow::styled(plain, Color::rgb(200, 200, 200), 2.0, 1.0);
assert_eq!(flat.text, rich.text);
assert_eq!(flat.fg, rich.fg);
assert_eq!(flat.indent, rich.indent);
assert_eq!(flat.scale, rich.scale);
assert_eq!(rich.spans.len(), 1);
assert!(flat.spans.is_empty());
}
#[test]
fn serde_round_trip_flat_row() {
let row = MessageRow::new("flat", Color::rgb(1, 2, 3), 0.0);
let json = serde_json::to_string(&row).unwrap();
let decoded: MessageRow = serde_json::from_str(&json).unwrap();
assert_eq!(row, decoded);
}
#[test]
fn serde_round_trip_styled_row() {
let styled = StyledText {
spans: vec![StyledSpan {
text: "hi".to_string(),
fg: Some(Color::rgb(255, 0, 0)),
bg: None,
bold: true,
italic: false,
underline: false,
}],
};
let row = MessageRow::styled(styled, Color::rgb(200, 200, 200), 2.0, 1.5);
let json = serde_json::to_string(&row).unwrap();
let decoded: MessageRow = serde_json::from_str(&json).unwrap();
assert_eq!(row, decoded);
}
#[test]
fn serde_legacy_json_without_spans_defaults_to_empty() {
let legacy = r#"{"text":"legacy","fg":{"r":100,"g":100,"b":100,"a":255},"indent":0.0}"#;
let row: MessageRow = serde_json::from_str(legacy).unwrap();
assert!(row.spans.is_empty(), "spans must default to empty");
assert!(
(row.scale - 1.0).abs() < f32::EPSILON,
"scale must default to 1.0"
);
assert_eq!(row.text, "legacy");
}
fn list_with_rows(n: usize, scroll_top: usize) -> MessageList {
MessageList {
id: WidgetId::new("ml"),
rows: (0..n)
.map(|i| MessageRow::new(format!("row{i}"), Color::rgb(0, 0, 0), 0.0))
.collect(),
scroll_top,
}
}
#[test]
fn hit_test_returns_row_index_at_scroll_offset() {
let list = list_with_rows(10, 2);
let rect = Rect::new(0.0, 0.0, 40.0, 5.0);
let measure = MessageListMeasure::new(1.0);
assert_eq!(
list.hit_test(rect, measure, 5.0, 1.5),
MessageListHit::Row(3)
);
}
#[test]
fn hit_test_below_last_row_is_empty() {
let list = list_with_rows(2, 0);
let rect = Rect::new(0.0, 0.0, 40.0, 5.0);
let measure = MessageListMeasure::new(1.0);
assert_eq!(
list.hit_test(rect, measure, 5.0, 3.5),
MessageListHit::Empty
);
}
#[test]
fn hit_test_outside_rect_is_empty() {
let list = list_with_rows(5, 0);
let rect = Rect::new(10.0, 10.0, 40.0, 5.0);
let measure = MessageListMeasure::new(1.0);
assert_eq!(
list.hit_test(rect, measure, 0.0, 0.0),
MessageListHit::Empty
);
}
#[test]
fn hit_test_zero_line_height_is_empty() {
let list = list_with_rows(5, 0);
let rect = Rect::new(0.0, 0.0, 40.0, 5.0);
let measure = MessageListMeasure::new(0.0);
assert_eq!(
list.hit_test(rect, measure, 1.0, 1.0),
MessageListHit::Empty
);
}
}