use crate::event::Rect;
use crate::types::{Decoration, Modifiers, StyledSpan, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextDisplay {
pub id: WidgetId,
pub lines: Vec<TextDisplayLine>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default = "default_auto_scroll")]
pub auto_scroll: bool,
#[serde(default)]
pub max_lines: usize,
#[serde(default)]
pub has_focus: bool,
#[serde(default)]
pub title: Option<StyledText>,
#[serde(default)]
pub show_scrollbar: bool,
}
fn default_auto_scroll() -> bool {
true
}
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
mod wrap {
use super::{StyledSpan, TextDisplayLine};
pub(crate) const WRAP_CONTINUATION_MARKER: &str = "\u{21B3} ";
pub(crate) fn wrap_continuation_marker_width() -> usize {
crate::text_util::display_width(WRAP_CONTINUATION_MARKER)
}
pub(crate) fn line_timestamp_cols(line: &TextDisplayLine) -> usize {
line.timestamp
.as_ref()
.map(|ts| crate::text_util::display_width(ts) + 1)
.unwrap_or(0)
}
pub(crate) fn wrap_display_line(
line: &TextDisplayLine,
col_budget: usize,
) -> Vec<Vec<StyledSpan>> {
crate::text_util::wrap_spans(&line.spans, col_budget, crate::text_util::WrapPolicy::Word)
}
pub(crate) fn content_budget_cols(line: &TextDisplayLine, col_budget: usize) -> usize {
let gutter = line_timestamp_cols(line).max(wrap_continuation_marker_width());
col_budget.saturating_sub(gutter).max(1)
}
pub(crate) fn wrap_row_count(line: &TextDisplayLine, col_budget: usize) -> usize {
wrap_display_line(line, content_budget_cols(line, col_budget))
.len()
.max(1)
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn px_to_cols(width_px: f32, char_width: f32) -> usize {
if char_width <= 0.0 || width_px <= 0.0 {
0
} else {
(width_px / char_width).floor() as usize
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Decoration;
fn make_td_line(text: &str) -> TextDisplayLine {
TextDisplayLine {
spans: vec![StyledSpan::plain(text)],
decoration: Decoration::Normal,
timestamp: None,
}
}
#[test]
fn wrap_display_line_short_line_is_one_row() {
let line = make_td_line("short");
let rows = wrap_display_line(&line, 80);
assert_eq!(rows.len(), 1);
}
#[test]
fn wrap_display_line_long_line_produces_multiple_rows() {
let line = make_td_line("the quick brown fox jumps over the lazy dog");
let rows = wrap_display_line(&line, 10);
assert!(rows.len() > 1, "expected wrapping, got {rows:?}");
for row in &rows {
let w: usize = row
.iter()
.map(|s| crate::text_util::display_width(&s.text))
.sum();
assert!(w <= 10, "row {row:?} exceeds the 10-cell budget");
}
}
#[test]
fn wrap_row_count_matches_wrap_display_line_len() {
let line = make_td_line("the quick brown fox jumps over the lazy dog");
assert_eq!(
wrap_row_count(&line, 10),
wrap_display_line(
&line,
10_usize.saturating_sub(wrap_continuation_marker_width())
)
.len()
);
}
#[test]
fn line_timestamp_cols_accounts_for_separator_space() {
let mut line = make_td_line("x");
assert_eq!(line_timestamp_cols(&line), 0);
line.timestamp = Some("12:00:00".to_string());
assert_eq!(line_timestamp_cols(&line), 8 + 1);
}
#[test]
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
fn px_to_cols_floors_and_handles_degenerate_input() {
assert_eq!(px_to_cols(100.0, 8.0), 12);
assert_eq!(px_to_cols(100.0, 0.0), 0);
assert_eq!(px_to_cols(0.0, 8.0), 0);
}
}
}
#[cfg(any(
feature = "tui",
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) use wrap::{
content_budget_cols, wrap_display_line, wrap_row_count, WRAP_CONTINUATION_MARKER,
};
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) use wrap::px_to_cols;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextDisplayLine {
pub spans: Vec<StyledSpan>,
#[serde(default)]
pub decoration: Decoration,
#[serde(default)]
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextDisplayLineMeasure {
pub height: f32,
}
impl TextDisplayLineMeasure {
pub fn new(height: f32) -> Self {
Self { height }
}
pub fn from_metrics(m: &crate::backend::Metrics) -> Self {
Self::new(m.line_height)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleTextDisplayLine {
pub line_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextDisplayHit {
Line(usize),
ScrollbarThumb,
ScrollbarTrackBefore,
ScrollbarTrackAfter,
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextDisplayLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub visible_lines: Vec<VisibleTextDisplayLine>,
pub hit_regions: Vec<(Rect, TextDisplayHit)>,
pub resolved_scroll_offset: usize,
pub scrollbar_bounds: Option<Rect>,
pub thumb_bounds: Option<Rect>,
}
impl TextDisplayLayout {
pub fn hit_test(&self, x: f32, y: f32) -> TextDisplayHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
TextDisplayHit::Empty
}
}
impl TextDisplay {
pub fn layout<F>(
&self,
viewport_width: f32,
viewport_height: f32,
measure_line: F,
) -> TextDisplayLayout
where
F: Fn(usize) -> TextDisplayLineMeasure,
{
self.layout_inner(viewport_width, viewport_height, 0.0, 0.0, measure_line)
}
pub fn layout_with_scrollbar<F>(
&self,
viewport_width: f32,
viewport_height: f32,
scrollbar_gutter: f32,
min_thumb: f32,
measure_line: F,
) -> TextDisplayLayout
where
F: Fn(usize) -> TextDisplayLineMeasure,
{
self.layout_inner(
viewport_width,
viewport_height,
scrollbar_gutter,
min_thumb,
measure_line,
)
}
fn layout_inner<F>(
&self,
viewport_width: f32,
viewport_height: f32,
scrollbar_gutter: f32,
min_thumb: f32,
measure_line: F,
) -> TextDisplayLayout
where
F: Fn(usize) -> TextDisplayLineMeasure,
{
let mut visible_lines: Vec<VisibleTextDisplayLine> = Vec::new();
let mut hit_regions: Vec<(Rect, TextDisplayHit)> = Vec::new();
let body_width = if self.show_scrollbar && scrollbar_gutter > 0.0 {
(viewport_width - scrollbar_gutter).max(0.0)
} else {
viewport_width
};
if self.lines.is_empty() || viewport_height <= 0.0 {
return TextDisplayLayout {
viewport_width,
viewport_height,
visible_lines,
hit_regions,
resolved_scroll_offset: 0,
scrollbar_bounds: None,
thumb_bounds: None,
};
}
let resolved_scroll_offset = if self.auto_scroll {
let mut used = 0.0_f32;
let mut offset = self.lines.len();
while offset > 0 {
let cand = offset - 1;
let h = measure_line(cand).height;
if used + h > viewport_height + f32::EPSILON {
break;
}
used += h;
offset = cand;
}
offset
} else {
crate::primitives::scrollbar::clamp_scroll_offset(self.scroll_offset, self.lines.len())
};
let mut y = 0.0_f32;
for i in resolved_scroll_offset..self.lines.len() {
if y >= viewport_height {
break;
}
let m = measure_line(i);
let remaining = viewport_height - y;
let height = m.height.min(remaining).max(0.0);
if height <= 0.0 {
break;
}
let bounds = Rect::new(0.0, y, body_width, height);
visible_lines.push(VisibleTextDisplayLine {
line_idx: i,
bounds,
});
hit_regions.push((bounds, TextDisplayHit::Line(i)));
y += m.height;
}
let (scrollbar_bounds, thumb_bounds) =
if self.show_scrollbar && scrollbar_gutter > 0.0 && !self.lines.is_empty() {
let gutter = Rect::new(body_width, 0.0, scrollbar_gutter, viewport_height);
let visible_count = visible_lines.len() as f32;
let total = self.lines.len() as f32;
let (thumb_start, thumb_len) = crate::primitives::scrollbar::fit_thumb(
resolved_scroll_offset as f32,
total,
visible_count,
viewport_height,
min_thumb,
);
if thumb_len > 0.0 {
let thumb = Rect::new(body_width, thumb_start, scrollbar_gutter, thumb_len);
let track_before = Rect::new(body_width, 0.0, scrollbar_gutter, thumb_start);
let track_after = Rect::new(
body_width,
thumb_start + thumb_len,
scrollbar_gutter,
(viewport_height - thumb_start - thumb_len).max(0.0),
);
hit_regions.insert(0, (thumb, TextDisplayHit::ScrollbarThumb));
if track_before.height > 0.0 {
hit_regions.insert(1, (track_before, TextDisplayHit::ScrollbarTrackBefore));
}
if track_after.height > 0.0 {
hit_regions.insert(
if track_before.height > 0.0 { 2 } else { 1 },
(track_after, TextDisplayHit::ScrollbarTrackAfter),
);
}
(Some(gutter), Some(thumb))
} else {
(Some(gutter), None)
}
} else {
(None, None)
};
TextDisplayLayout {
viewport_width,
viewport_height,
visible_lines,
hit_regions,
resolved_scroll_offset,
scrollbar_bounds,
thumb_bounds,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TextDisplayEvent {
Scrolled { new_offset: usize },
AutoScrollToggled { enabled: bool },
Copied { text: String },
KeyPressed { key: String, modifiers: Modifiers },
}
impl TextDisplay {
pub fn new(id: WidgetId) -> Self {
Self {
id,
lines: Vec::new(),
scroll_offset: 0,
auto_scroll: true,
max_lines: 0,
has_focus: false,
title: None,
show_scrollbar: false,
}
}
pub fn append_line(&mut self, line: TextDisplayLine) {
self.lines.push(line);
if self.max_lines > 0 && self.lines.len() > self.max_lines {
let drop = self.lines.len() - self.max_lines;
self.lines.drain(..drop);
self.scroll_offset = self.scroll_offset.saturating_sub(drop);
}
}
pub fn clear(&mut self) {
self.lines.clear();
self.scroll_offset = 0;
}
pub fn set_max_lines(&mut self, max: usize) {
self.max_lines = max;
if max > 0 && self.lines.len() > max {
let drop = self.lines.len() - max;
self.lines.drain(..drop);
self.scroll_offset = self.scroll_offset.saturating_sub(drop);
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
mod native_surface_paint {
use super::{
content_budget_cols, px_to_cols, wrap_display_line, wrap_row_count, TextDisplay,
TextDisplayLineMeasure, WRAP_CONTINUATION_MARKER,
};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::types::Decoration;
use crate::Rect;
pub(crate) fn paint(
display: &TextDisplay,
rect: Rect,
surface: &mut dyn PaintSurface,
theme: &Theme,
line_height: f32,
char_width: f32,
) {
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
surface.surface_fill_rect(rect, theme.background);
let (body_y, body_h) = if let Some(ref title) = display.title {
let mut cursor_x = rect.x;
for span in &title.spans {
let span_fg = span.fg.unwrap_or(theme.foreground);
let (sw, _) = surface.surface_measure_text(&span.text);
surface.surface_draw_text_run(
Rect::new(cursor_x, rect.y, sw.max(1.0), line_height),
&span.text,
span_fg,
);
cursor_x += sw;
}
(rect.y + line_height, (rect.height - line_height).max(0.0))
} else {
(rect.y, rect.height)
};
if body_h <= 0.0 {
return;
}
let gutter = 12.0_f32;
let min_thumb = 8.0_f32;
let body_width_px = if display.show_scrollbar {
(rect.width - gutter).max(0.0)
} else {
rect.width
};
let col_budget = px_to_cols(body_width_px, char_width);
let measure = |i: usize| {
let rows = wrap_row_count(&display.lines[i], col_budget);
TextDisplayLineMeasure::new(rows as f32 * line_height)
};
let layout = if display.show_scrollbar {
display.layout_with_scrollbar(rect.width, body_h, gutter, min_thumb, measure)
} else {
display.layout(rect.width, body_h, measure)
};
for vis in &layout.visible_lines {
let line = &display.lines[vis.line_idx];
let row0_y = body_y + vis.bounds.y;
if row0_y >= body_y + body_h {
break;
}
let line_fg = match line.decoration {
Decoration::Error => theme.error_fg,
Decoration::Warning => theme.warning_fg,
Decoration::Muted => theme.muted_fg,
_ => theme.foreground,
};
let rows = wrap_display_line(line, content_budget_cols(line, col_budget));
for (row_i, row_spans) in rows.iter().enumerate() {
let row_y = row0_y + row_i as f32 * line_height;
if row_y + line_height > body_y + body_h {
break;
}
let mut cursor_x = rect.x;
if row_i == 0 {
if let Some(ref ts) = line.timestamp {
let (tw, _) = surface.surface_measure_text(ts);
surface.surface_draw_text_run(
Rect::new(cursor_x, row_y, tw.max(1.0), line_height),
ts,
theme.muted_fg,
);
cursor_x += tw + 6.0;
}
} else {
let (mw, _) = surface.surface_measure_text(WRAP_CONTINUATION_MARKER);
surface.surface_draw_text_run(
Rect::new(cursor_x, row_y, mw.max(1.0), line_height),
WRAP_CONTINUATION_MARKER,
theme.muted_fg,
);
cursor_x += mw;
}
for span in row_spans {
let span_fg = span.fg.unwrap_or(line_fg);
let (sw, _) = surface.surface_measure_text(&span.text);
if let Some(span_bg) = span.bg {
surface.surface_fill_rect(
Rect::new(cursor_x, row_y, sw, line_height),
span_bg,
);
}
surface.surface_draw_text_run(
Rect::new(cursor_x, row_y, sw.max(1.0), line_height),
&span.text,
span_fg,
);
cursor_x += sw;
}
}
}
if display.show_scrollbar {
if let Some(gutter) = layout.scrollbar_bounds {
surface.surface_fill_rect(
Rect::new(
rect.x + gutter.x,
body_y + gutter.y,
gutter.width,
gutter.height,
),
theme.scrollbar_track,
);
}
if let Some(thumb) = layout.thumb_bounds {
let inset = 2.0;
surface.surface_fill_rect(
Rect::new(
rect.x + thumb.x + inset,
body_y + thumb.y,
(thumb.width - inset * 2.0).max(2.0),
thumb.height,
),
theme.scrollbar_thumb,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::text_display::TextDisplayLine;
use crate::types::{Color, StyledSpan, StyledText, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(240.0, 160.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, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, 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));
}
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 td_line(text: &str) -> TextDisplayLine {
TextDisplayLine {
spans: vec![StyledSpan::plain(text)],
decoration: Decoration::Normal,
timestamp: None,
}
}
fn make_td(lines: usize, show_scrollbar: bool) -> TextDisplay {
TextDisplay {
id: WidgetId::new("td"),
lines: (0..lines).map(|i| td_line(&format!("ln{i}"))).collect(),
scroll_offset: 0,
auto_scroll: false,
max_lines: 0,
has_focus: false,
title: None,
show_scrollbar,
}
}
#[test]
fn background_fills_theme_background() {
let td = make_td(0, false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 240.0, 160.0);
paint(&td, rect, &mut surface, &theme, 16.0, 8.0);
assert!(
surface
.fills
.iter()
.any(|&(r, c)| r == rect && c == theme.background),
"expected a full-rect background fill, fills were {:?}",
surface.fills,
);
}
#[test]
fn zero_size_rect_paints_nothing() {
let td = make_td(5, false);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&td,
Rect::new(0.0, 0.0, 0.0, 0.0),
&mut surface,
&theme,
16.0,
8.0,
);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
}
#[test]
fn title_row_paints_before_the_body_and_shrinks_it() {
let mut td = make_td(1, false);
td.title = Some(StyledText::plain("Logs"));
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 240.0, 32.0);
paint(&td, rect, &mut surface, &theme, 16.0, 8.0);
assert!(
surface
.text_runs
.iter()
.any(|(r, t, _)| t == "Logs" && r.y == 0.0),
"expected the title span painted at the top row, got {:?}",
surface.text_runs,
);
assert!(
surface
.text_runs
.iter()
.any(|(r, t, _)| t == "ln0" && r.y == 16.0),
"expected the body's first line shifted down by one line_height, got {:?}",
surface.text_runs,
);
}
#[test]
fn line_decoration_resolves_fallback_color() {
let mut td = make_td(1, false);
td.lines[0].decoration = Decoration::Error;
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&td,
Rect::new(0.0, 0.0, 240.0, 32.0),
&mut surface,
&theme,
16.0,
8.0,
);
assert!(
surface
.text_runs
.iter()
.any(|(_, t, c)| t == "ln0" && *c == theme.error_fg),
"expected the error-decorated line painted in theme.error_fg, got {:?}",
surface.text_runs,
);
}
#[test]
fn scrollbar_gutter_and_thumb_paint_when_shown() {
let td = make_td(100, true);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&td,
Rect::new(0.0, 0.0, 240.0, 160.0),
&mut surface,
&theme,
16.0,
8.0,
);
assert!(
surface
.fills
.iter()
.any(|&(_, c)| c == theme.scrollbar_track),
"expected a scrollbar-track fill"
);
assert!(
surface
.fills
.iter()
.any(|&(_, c)| c == theme.scrollbar_thumb),
"expected a scrollbar-thumb fill"
);
}
#[test]
fn wrap_splits_long_line_across_multiple_rows_with_continuation_marker() {
let mut td = make_td(0, false);
td.lines.push(TextDisplayLine {
spans: vec![StyledSpan::plain(
"the quick brown fox jumps over the lazy dog",
)],
decoration: Decoration::Normal,
timestamp: None,
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&td,
Rect::new(0.0, 0.0, 80.0, 160.0),
&mut surface,
&theme,
16.0,
8.0,
);
let row_ys: std::collections::BTreeSet<i64> = surface
.text_runs
.iter()
.map(|(r, _, _)| (r.y * 1000.0).round() as i64)
.collect();
assert!(
row_ys.len() > 1,
"expected the line to wrap onto multiple rows, got runs {:?}",
surface.text_runs
);
assert!(
surface
.text_runs
.iter()
.any(|(_, t, _)| t == WRAP_CONTINUATION_MARKER),
"expected the continuation marker painted on a wrapped row, got {:?}",
surface.text_runs,
);
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(unused_imports)]
pub(crate) use native_surface_paint::paint;
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Color, StyledSpan};
fn line(text: &str) -> TextDisplayLine {
TextDisplayLine {
spans: vec![StyledSpan::plain(text)],
decoration: Decoration::Normal,
timestamp: None,
}
}
fn make_td(lines: usize, show_scrollbar: bool, scroll: usize, auto: bool) -> TextDisplay {
TextDisplay {
id: WidgetId::new("td"),
lines: (0..lines).map(|i| line(&format!("line{i}"))).collect(),
scroll_offset: scroll,
auto_scroll: auto,
max_lines: 0,
has_focus: false,
title: None,
show_scrollbar,
}
}
#[test]
fn scrollbar_layout_reserves_gutter_width() {
let td = make_td(20, true, 0, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
for vis in &layout.visible_lines {
assert!(
(vis.bounds.width - 39.0).abs() < 0.01,
"line body width should be 39, got {}",
vis.bounds.width
);
}
assert!(layout.scrollbar_bounds.is_some());
let gutter = layout.scrollbar_bounds.unwrap();
assert!((gutter.x - 39.0).abs() < 0.01);
assert!((gutter.width - 1.0).abs() < 0.01);
}
#[test]
fn scrollbar_thumb_at_top_when_scroll_zero() {
let td = make_td(20, true, 0, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
let thumb = layout.thumb_bounds.expect("thumb present");
assert!(
thumb.y.abs() < 0.01,
"thumb should start at top, got y={}",
thumb.y
);
}
#[test]
fn scrollbar_thumb_at_bottom_when_fully_scrolled() {
let td = make_td(20, true, 10, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
let thumb = layout.thumb_bounds.expect("thumb present");
assert!(
(thumb.y + thumb.height - 10.0).abs() < 0.01,
"thumb should touch bottom: y={}, h={}, viewport=10",
thumb.y,
thumb.height
);
}
#[test]
fn scrollbar_hit_test_regions_present() {
let td = make_td(20, true, 5, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
let has_thumb = layout
.hit_regions
.iter()
.any(|(_, h)| matches!(h, TextDisplayHit::ScrollbarThumb));
let has_track_before = layout
.hit_regions
.iter()
.any(|(_, h)| matches!(h, TextDisplayHit::ScrollbarTrackBefore));
let has_track_after = layout
.hit_regions
.iter()
.any(|(_, h)| matches!(h, TextDisplayHit::ScrollbarTrackAfter));
assert!(has_thumb, "thumb hit region missing");
assert!(has_track_before, "track-before hit region missing");
assert!(has_track_after, "track-after hit region missing");
}
#[test]
fn no_scrollbar_when_content_fits() {
let td = make_td(5, true, 0, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
assert!(layout.thumb_bounds.is_none());
}
#[test]
fn no_scrollbar_when_disabled() {
let td = make_td(20, false, 0, false);
let layout = td.layout(40.0, 10.0, |_| TextDisplayLineMeasure::new(1.0));
assert!(layout.scrollbar_bounds.is_none());
assert!(layout.thumb_bounds.is_none());
let scrollbar_hits = layout.hit_regions.iter().any(|(_, h)| {
matches!(
h,
TextDisplayHit::ScrollbarThumb
| TextDisplayHit::ScrollbarTrackBefore
| TextDisplayHit::ScrollbarTrackAfter
)
});
assert!(!scrollbar_hits, "no scrollbar hits when disabled");
}
#[test]
fn hit_test_line_in_body_area() {
let td = make_td(20, true, 0, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
match layout.hit_test(5.0, 3.5) {
TextDisplayHit::Line(idx) => assert_eq!(idx, 3),
other => panic!("expected Line(3), got {:?}", other),
}
}
#[test]
fn hit_test_scrollbar_thumb() {
let td = make_td(20, true, 0, false);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
let thumb = layout.thumb_bounds.expect("thumb present");
match layout.hit_test(thumb.x + 0.5, thumb.y + thumb.height / 2.0) {
TextDisplayHit::ScrollbarThumb => {}
other => panic!("expected ScrollbarThumb, got {:?}", other),
}
}
#[test]
fn auto_scroll_with_scrollbar() {
let td = make_td(20, true, 0, true);
let layout =
td.layout_with_scrollbar(40.0, 10.0, 1.0, 1.0, |_| TextDisplayLineMeasure::new(1.0));
assert_eq!(layout.resolved_scroll_offset, 10);
assert_eq!(layout.visible_lines.last().unwrap().line_idx, 19);
}
#[test]
fn append_line_and_max_lines() {
let mut td = TextDisplay::new(WidgetId::new("td"));
td.set_max_lines(5);
for i in 0..10 {
td.append_line(line(&format!("l{i}")));
}
assert_eq!(td.lines.len(), 5);
assert_eq!(td.lines[0].spans[0].text, "l5");
assert_eq!(td.lines[4].spans[0].text, "l9");
}
#[test]
fn text_display_append_and_cap() {
let mut td = TextDisplay::new(WidgetId::new("logs"));
td.set_max_lines(3);
let mk = |text: &str| TextDisplayLine {
spans: vec![StyledSpan::plain(text)],
decoration: Decoration::Normal,
timestamp: None,
};
td.append_line(mk("a"));
td.append_line(mk("b"));
td.append_line(mk("c"));
assert_eq!(td.lines.len(), 3);
td.append_line(mk("d"));
assert_eq!(td.lines.len(), 3);
assert_eq!(td.lines.first().unwrap().spans[0].text, "b");
assert_eq!(td.lines.last().unwrap().spans[0].text, "d");
td.set_max_lines(2);
assert_eq!(td.lines.len(), 2);
assert_eq!(td.lines.first().unwrap().spans[0].text, "c");
td.clear();
assert_eq!(td.lines.len(), 0);
assert_eq!(td.scroll_offset, 0);
}
#[test]
fn text_display_roundtrip_serde() {
let td = TextDisplay {
id: WidgetId::new("td"),
lines: vec![
TextDisplayLine {
spans: vec![StyledSpan::plain("hello")],
decoration: Decoration::Normal,
timestamp: Some("12:00:00".to_string()),
},
TextDisplayLine {
spans: vec![
StyledSpan::plain("error: "),
StyledSpan::with_fg("not found", Color::rgb(255, 80, 80)),
],
decoration: Decoration::Error,
timestamp: None,
},
],
scroll_offset: 0,
auto_scroll: false,
max_lines: 1000,
has_focus: true,
title: None,
show_scrollbar: false,
};
let json = serde_json::to_string(&td).unwrap();
let back: TextDisplay = serde_json::from_str(&json).unwrap();
assert_eq!(td, back);
}
#[test]
fn text_display_event_roundtrip_serde() {
let events = vec![
TextDisplayEvent::Scrolled { new_offset: 42 },
TextDisplayEvent::AutoScrollToggled { enabled: false },
TextDisplayEvent::Copied {
text: "selected line".to_string(),
},
TextDisplayEvent::KeyPressed {
key: "G".to_string(),
modifiers: Modifiers::default(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let back: TextDisplayEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, &back);
}
}
fn make_td_line(text: &str) -> TextDisplayLine {
TextDisplayLine {
spans: vec![StyledSpan::plain(text)],
decoration: Decoration::Normal,
timestamp: None,
}
}
fn make_td_from_lines(lines: Vec<TextDisplayLine>, scroll: usize, auto: bool) -> TextDisplay {
TextDisplay {
id: WidgetId::new("td"),
lines,
scroll_offset: scroll,
auto_scroll: auto,
max_lines: 0,
has_focus: true,
title: None,
show_scrollbar: false,
}
}
#[test]
fn text_display_layout_empty() {
let td = make_td_from_lines(vec![], 0, true);
let layout = td.layout(40.0, 10.0, |_| TextDisplayLineMeasure::new(1.0));
assert_eq!(layout.visible_lines.len(), 0);
assert_eq!(layout.hit_test(5.0, 5.0), TextDisplayHit::Empty);
}
#[test]
fn text_display_layout_manual_scroll() {
let td = make_td_from_lines(
(0..10).map(|i| make_td_line(&format!("l{i}"))).collect(),
3,
false,
);
let layout = td.layout(40.0, 5.0, |_| TextDisplayLineMeasure::new(1.0));
assert_eq!(layout.resolved_scroll_offset, 3);
assert_eq!(layout.visible_lines.len(), 5);
assert_eq!(layout.visible_lines[0].line_idx, 3);
}
#[test]
fn text_display_layout_auto_scroll_pins_bottom() {
let td = make_td_from_lines(
(0..10).map(|i| make_td_line(&format!("l{i}"))).collect(),
0, true,
);
let layout = td.layout(40.0, 5.0, |_| TextDisplayLineMeasure::new(1.0));
assert_eq!(layout.resolved_scroll_offset, 5);
assert_eq!(layout.visible_lines.len(), 5);
assert_eq!(layout.visible_lines[0].line_idx, 5);
assert_eq!(layout.visible_lines[4].line_idx, 9);
}
#[test]
fn text_display_layout_auto_scroll_short_stream() {
let td = make_td_from_lines(
(0..3).map(|i| make_td_line(&format!("l{i}"))).collect(),
0,
true,
);
let layout = td.layout(40.0, 5.0, |_| TextDisplayLineMeasure::new(1.0));
assert_eq!(layout.resolved_scroll_offset, 0);
assert_eq!(layout.visible_lines.len(), 3);
}
#[test]
fn text_display_layout_wrap_heights() {
let td = make_td_from_lines(
(0..3).map(|i| make_td_line(&format!("l{i}"))).collect(),
0,
false,
);
let heights = [3.0, 1.0, 2.0];
let layout = td.layout(40.0, 5.0, |i| TextDisplayLineMeasure::new(heights[i]));
assert_eq!(layout.visible_lines.len(), 3);
assert_eq!(layout.visible_lines[0].bounds.height, 3.0);
assert_eq!(layout.visible_lines[1].bounds.y, 3.0);
assert_eq!(layout.visible_lines[1].bounds.height, 1.0);
assert_eq!(layout.visible_lines[2].bounds.y, 4.0);
assert_eq!(layout.visible_lines[2].bounds.height, 1.0);
}
}