use std::sync::Arc;
use std::sync::LazyLock;
use cosmic_text::{Attrs, AttrsList, BufferLine, Family, LineEnding, Shaping, Wrap};
use iced::advanced::graphics::text::cosmic_text;
use super::media_markers;
use super::text_rendering::with_font_system;
use super::theme::MARKDOWN_TEXT_SIZE;
use crate::util::file_name_or_path;
pub(crate) const BODY_FONT_SIZE: f32 = MARKDOWN_TEXT_SIZE;
pub(crate) const MAX_PREVIEW_LINES: u32 = 3;
pub(crate) const WRAP_SAFETY_MARGIN_PX: f32 = 36.0;
const MAX_PREVIEW_LINE_CHARS: usize = 600;
const HEADING_SIZES: [f32; 6] = [
MARKDOWN_TEXT_SIZE * 2.0,
MARKDOWN_TEXT_SIZE * 1.75,
MARKDOWN_TEXT_SIZE * 1.5,
MARKDOWN_TEXT_SIZE * 1.25,
BODY_FONT_SIZE,
BODY_FONT_SIZE,
];
const TAB_WIDTH: u16 = 4;
fn shape_ceiling(avail: f32, need: u32) -> usize {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let per_line = (avail as usize).max(1) * 4;
per_line.saturating_mul(need as usize).saturating_add(4)
}
fn preview_line_char_budget(
font_sys: &mut cosmic_text::FontSystem,
attrs: &Attrs,
avail: f32,
) -> usize {
let mut probe = BufferLine::new(
"M",
LineEnding::None,
AttrsList::new(attrs),
Shaping::Advanced,
);
let advance = probe
.shape(font_sys, TAB_WIDTH)
.layout(BODY_FONT_SIZE, Some(1_000.0), Wrap::None, None, None)
.first()
.map_or(0.0, |l| l.w);
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let budget = ((avail / advance.max(1.0)) as usize).saturating_sub(1);
budget.max(1)
}
pub(crate) struct MessageMeasure {
pub(crate) width_bucket: u32,
pub(crate) wrapped_lines: u32,
pub(crate) preview: Option<String>,
pub(crate) source: Arc<str>,
pub(crate) processed: Arc<str>,
pub(crate) content_len: usize,
}
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub(crate) fn width_bucket(width: f32) -> u32 {
(width.max(0.0) / 16.0) as u32
}
pub(crate) fn measure_message(
display_text: impl Into<Arc<str>>,
width: f32,
content_len: usize,
) -> MessageMeasure {
let source = display_text.into();
let processed: Arc<str> = placeholder_media(&media_markers::preprocess(&source)).into();
measure_processed(source, processed, width, content_len)
}
pub(crate) fn re_measure(cached: &MessageMeasure, width: f32) -> MessageMeasure {
measure_processed(
cached.source.clone(),
cached.processed.clone(),
width,
cached.content_len,
)
}
#[expect(clippy::too_many_lines)]
fn measure_processed(
source: Arc<str>,
processed: Arc<str>,
width: f32,
content_len: usize,
) -> MessageMeasure {
let bucket = width_bucket(width);
let text = &processed;
let avail = (width - WRAP_SAFETY_MARGIN_PX).max(1.0);
let attrs = Attrs::new().family(Family::Name("JetBrains Mono"));
let mut total_lines = 0u32;
let mut preview = String::new();
let mut preview_lines = 0u32;
let mut fence: Option<(u8, usize)> = None;
with_font_system(|font_sys| {
let preview_line_chars = preview_line_char_budget(font_sys, &attrs, avail);
let mut line = BufferLine::new(
"",
LineEnding::None,
AttrsList::new(&attrs),
Shaping::Advanced,
);
let mut lines = text.split('\n').peekable();
while let Some(src_line) = lines.next() {
let next_line = lines.peek().copied();
if total_lines > MAX_PREVIEW_LINES {
break;
}
match fence {
Some((open_ch, open_count)) => {
let closing = fence_delimiter(src_line)
.is_some_and(|(ch, count)| ch == open_ch && count >= open_count)
&& fence_has_only_trailing_whitespace(src_line);
if closing {
fence = None;
}
count_source_line(
src_line,
&mut total_lines,
&mut preview,
&mut preview_lines,
preview_line_chars,
);
}
None => {
if let Some((ch, count)) = fence_delimiter(src_line) {
fence = Some((ch, count));
count_source_line(
src_line,
&mut total_lines,
&mut preview,
&mut preview_lines,
preview_line_chars,
);
} else {
let size = heading_size(src_line)
.or_else(|| setext_heading_size(src_line, next_line))
.unwrap_or(BODY_FONT_SIZE);
let need = MAX_PREVIEW_LINES + 1 - total_lines;
let ceiling = shape_ceiling(avail, need);
let shaped_src = if src_line.len() > ceiling {
&src_line[..src_line.floor_char_boundary(ceiling)]
} else {
src_line
};
line.reset_new(
shaped_src,
LineEnding::None,
AttrsList::new(&attrs),
Shaping::Advanced,
);
let laid = line.shape(font_sys, TAB_WIDTH).layout(
size,
Some(avail),
Wrap::Word,
None,
None,
);
let truncated = src_line.len() > ceiling;
let line_count = if truncated && laid.len() < need as usize {
need
} else {
u32::try_from(laid.len()).unwrap_or(u32::MAX)
};
total_lines = total_lines.saturating_add(line_count);
for laid_line in laid {
if preview_lines >= MAX_PREVIEW_LINES {
break;
}
let (glyph_start, glyph_end) = laid_line
.glyphs
.iter()
.fold((usize::MAX, 0usize), |(s, e), g| {
(s.min(g.start), e.max(g.end))
});
if preview_lines > 0 {
preview.push('\n');
}
if glyph_start <= glyph_end && glyph_start < shaped_src.len() {
let start = glyph_start.min(shaped_src.len());
let end = glyph_end.min(shaped_src.len());
let segment = &shaped_src[start..end];
let cap = if laid_line.w > avail
&& !segment.chars().any(char::is_whitespace)
{
preview_line_chars
} else {
MAX_PREVIEW_LINE_CHARS
};
preview.push_str(&crate::util::truncate(segment, cap));
}
preview_lines += 1;
}
}
}
}
}
});
MessageMeasure {
width_bucket: bucket,
wrapped_lines: total_lines,
preview: (total_lines > MAX_PREVIEW_LINES).then_some(preview),
source,
processed,
content_len,
}
}
fn count_source_line(
src_line: &str,
total_lines: &mut u32,
preview: &mut String,
preview_lines: &mut u32,
preview_line_chars: usize,
) {
*total_lines += 1;
if *preview_lines < MAX_PREVIEW_LINES {
if *preview_lines > 0 {
preview.push('\n');
}
preview.push_str(&crate::util::truncate(
src_line.trim_end(),
preview_line_chars,
));
*preview_lines += 1;
}
}
fn fence_delimiter(line: &str) -> Option<(u8, usize)> {
let trimmed = line.trim_start();
let ch = *trimmed.as_bytes().first()?;
if ch != b'`' && ch != b'~' {
return None;
}
let count = trimmed.bytes().take_while(|&b| b == ch).count();
(count >= 3).then_some((ch, count))
}
fn fence_has_only_trailing_whitespace(line: &str) -> bool {
let trimmed = line.trim_start();
let Some(&ch) = trimmed.as_bytes().first() else {
return false;
};
let run = trimmed.bytes().take_while(|&b| b == ch).count();
trimmed[run..].chars().all(char::is_whitespace)
}
fn heading_size(line: &str) -> Option<f32> {
let trimmed = line.trim_start_matches(' ');
if line.len() - trimmed.len() > 3 {
return None;
}
let bytes = trimmed.as_bytes();
let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
if !(1..=6).contains(&hashes) {
return None;
}
(bytes.get(hashes) == Some(&b' ')).then(|| HEADING_SIZES[hashes - 1])
}
fn setext_heading_size(current: &str, next: Option<&str>) -> Option<f32> {
if current.is_empty() {
return None;
}
let underline = next?.trim_end();
if underline.is_empty() {
return None;
}
let body = underline.trim_start_matches(' ');
if underline.len() - body.len() > 3 {
return None;
}
let ch = *body.as_bytes().first()?;
if ch != b'=' && ch != b'-' {
return None;
}
body.bytes()
.all(|b| b == ch)
.then(|| HEADING_SIZES[usize::from(ch == b'-')])
}
fn placeholder_media(processed: &str) -> String {
IMAGE_PLACEHOLDER_RE
.replace_all(processed, |caps: ®ex::Captures| {
let url = caps.get(1).map_or("", |m| m.as_str());
let label = if url.starts_with("data:") {
"image"
} else {
file_name_or_path(url)
};
format!("🖼️ {label}")
})
.into_owned()
}
static IMAGE_PLACEHOLDER_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"!\[[^\]]*\]\(([^)]*)\)").expect("image placeholder regex must compile")
});
#[cfg(test)]
mod tests {
use super::*;
use crate::util::UnwrapPoison;
use std::sync::OnceLock;
fn ensure_fonts_loaded() {
static ONCE: OnceLock<()> = OnceLock::new();
ONCE.get_or_init(|| {
let mut guard = iced::advanced::graphics::text::font_system()
.write()
.unwrap_poison();
guard.load_font(std::borrow::Cow::Borrowed(include_bytes!(
"JetBrainsMono-Regular.ttf"
)));
});
}
const TEST_WIDTH: f32 = 500.0;
fn measure(text: &str) -> MessageMeasure {
ensure_fonts_loaded();
measure_message(text, TEST_WIDTH, text.len())
}
fn assert_preview_lines(preview: &str, expected: usize) {
assert_eq!(
preview.split('\n').count(),
expected,
"preview: {preview:?}"
);
}
#[test]
fn short_message_stays_expanded() {
let m = measure("hello");
assert_eq!(m.wrapped_lines, 1);
assert!(m.preview.is_none());
}
#[test]
fn exactly_three_short_lines_stay_expanded() {
let m = measure("one\ntwo\nthree");
assert_eq!(m.wrapped_lines, 3);
assert!(m.preview.is_none());
}
#[test]
fn four_short_lines_collapse() {
let m = measure("one\ntwo\nthree\nfour");
assert_eq!(m.wrapped_lines, 4);
let preview = m.preview.expect("4-line message must collapse");
assert_preview_lines(&preview, 3);
assert_eq!(preview, "one\ntwo\nthree");
}
#[test]
fn long_prose_paragraph_wraps_and_collapses() {
let text = "The quick brown fox jumps over the lazy dog. ".repeat(4);
let m = measure(&text);
assert!(
m.wrapped_lines > 3,
"long paragraph must wrap past the budget ({} lines)",
m.wrapped_lines
);
let preview = m.preview.expect("long paragraph must collapse");
assert_preview_lines(&preview, 3);
let flat_preview: String = preview.chars().filter(|c| !c.is_whitespace()).collect();
let flat_text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
flat_text.starts_with(&flat_preview),
"preview must be the start of the wrapped text: {preview:?}"
);
}
#[test]
fn blank_lines_kept_in_preview() {
for text in ["a\n\nb\n", "a\n\nb\nc"] {
let m = measure(text);
assert!(m.wrapped_lines > 3, "{text:?}: {} lines", m.wrapped_lines);
let preview = m.preview.expect("4-line message must collapse");
assert_eq!(preview, "a\n\nb");
assert_preview_lines(&preview, 3);
}
let m = measure("\n\na\n\nb");
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
let preview = m.preview.expect("5-line message must collapse");
assert_eq!(preview, "\n\na");
assert_preview_lines(&preview, 3);
}
#[test]
fn code_fence_lines_count_as_source_lines() {
let text = "```\nline1\nline2\n```";
let m = measure(text);
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
let preview = m.preview.expect("4-line message must collapse");
assert_preview_lines(&preview, 3);
assert_eq!(preview, "```\nline1\nline2");
}
#[test]
fn short_code_fence_stays_expanded() {
let text = "```\nline1\n```";
let m = measure(text);
assert_eq!(m.wrapped_lines, 3);
assert!(m.preview.is_none());
}
#[test]
fn long_code_line_does_not_wrap() {
let text = format!("```\n{}\n```", "x".repeat(300));
let m = measure(&text);
assert_eq!(m.wrapped_lines, 3);
assert!(m.preview.is_none());
}
#[test]
fn heading_lines_measured_at_heading_size() {
let text = format!("# {}", "heading ".repeat(15));
let m = measure(&text);
assert!(
m.wrapped_lines > 3,
"long heading must wrap past the budget ({} lines)",
m.wrapped_lines
);
assert!(m.preview.is_some());
}
#[test]
fn indented_atx_heading_measured_at_heading_size() {
let text = format!(" # {}", "heading ".repeat(15));
let m = measure(&text);
assert!(
m.wrapped_lines > 3,
"long indented heading must wrap past the budget ({} lines)",
m.wrapped_lines
);
assert!(m.preview.is_some());
let m = measure(&format!(" # {}", "h".repeat(5)));
assert_eq!(m.wrapped_lines, 1);
assert!(m.preview.is_none());
}
#[test]
fn body_sized_heading_stays_short() {
let m = measure(&format!("##### {}", "h".repeat(20)));
assert_eq!(m.wrapped_lines, 1);
assert!(m.preview.is_none());
}
#[test]
fn setext_heading_measured_at_heading_size() {
let text = format!("{}\n===", "setext heading title ".repeat(5));
let m = measure(&text);
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
assert!(m.preview.is_some());
let m = measure("short title\n===");
assert_eq!(m.wrapped_lines, 2);
assert!(m.preview.is_none());
}
#[test]
fn setext_heading_size_maps_equals_to_h1_and_dash_to_h2() {
assert_eq!(
setext_heading_size("title", Some("===")),
Some(HEADING_SIZES[0])
);
assert_eq!(
setext_heading_size("title", Some("---")),
Some(HEADING_SIZES[1])
);
assert_eq!(
setext_heading_size("title", Some(" ===")),
Some(HEADING_SIZES[0])
);
assert_eq!(setext_heading_size("title", Some(" ===")), None);
assert_eq!(
setext_heading_size("title", Some("--- ")),
Some(HEADING_SIZES[1])
);
assert_eq!(setext_heading_size("title", Some("=-=")), None);
assert_eq!(setext_heading_size("title", Some("")), None);
assert_eq!(setext_heading_size("", Some("===")), None);
}
#[test]
fn setext_title_boundary_pins_h1_vs_h2_mapping() {
let text = format!("{}\n===", "setext title ".repeat(5));
let m = measure(&text);
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
assert!(m.preview.is_some());
}
#[test]
fn base64_data_uri_reduced_to_placeholder() {
let blob = "x".repeat(20_000);
let text = format!("before [IMAGE:data:image/png;base64,{blob}] after");
let m = measure(&text);
assert!(m.wrapped_lines <= 3, "{} lines", m.wrapped_lines);
assert!(m.preview.is_none());
let measured = placeholder_media(&media_markers::preprocess(&text));
assert!(!measured.contains("base64"));
assert!(measured.contains("🖼️ image"));
}
#[test]
fn image_marker_placeholder_uses_filename() {
let text = "see [IMAGE:/tmp/screenshots/cap.png] below";
let m = measure(text);
assert_eq!(m.wrapped_lines, 1);
assert!(m.preview.is_none());
let measured = placeholder_media(&media_markers::preprocess(text));
assert!(measured.contains("🖼️ cap.png"), "got: {measured:?}");
}
#[test]
fn preview_is_bounded_for_huge_messages() {
let text = format!("{}\n{}", "p ".repeat(50_000), "q ".repeat(50_000));
let m = measure(&text);
assert!(m.wrapped_lines > 3);
let preview = m.preview.expect("huge message must collapse");
assert_preview_lines(&preview, 3);
assert!(
preview.len() < 2_000,
"preview too large: {}",
preview.len()
);
}
#[test]
fn width_bucket_quantizes_for_cache_invalidation() {
assert_eq!(width_bucket(0.0), 0);
assert_eq!(width_bucket(15.9), 0);
assert_eq!(width_bucket(16.0), 1);
assert_eq!(width_bucket(500.0), 31);
assert_eq!(width_bucket(508.0), 31);
assert_eq!(width_bucket(540.0), 33);
}
#[test]
fn huge_single_line_message_is_bounded() {
let text = format!("{} end", "word ".repeat(20_000));
let m = measure(&text);
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
let preview = m.preview.expect("huge single-line message must collapse");
assert_preview_lines(&preview, 3);
let flat_preview: String = preview.chars().filter(|c| !c.is_whitespace()).collect();
let flat_text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
flat_text.starts_with(&flat_preview),
"preview must be the start of the wrapped line: {preview:?}"
);
}
#[test]
fn huge_unbroken_word_line_errs_toward_collapse() {
let text = format!("{} tail", "x".repeat(200_000));
let m = measure(&text);
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
assert!(m.preview.is_some());
}
#[test]
fn long_code_line_preview_capped_to_width() {
let text = format!("```\n{}\n```\nafter", "code ".repeat(200));
let m = measure(&text);
assert!(m.wrapped_lines > 3, "{} lines", m.wrapped_lines);
let preview = m.preview.expect("must collapse");
assert_preview_lines(&preview, 3);
let mut lines = preview.lines();
assert_eq!(lines.next(), Some("```"));
let code_line = lines.next().expect("second preview line is the code line");
assert!(
code_line.chars().count() <= 64,
"code preview line too long: {} chars",
code_line.chars().count()
);
}
#[test]
fn mixed_direction_wrap_never_panics() {
for n in 40..75 {
let text = format!("{} שלום עולם", "x".repeat(n));
let _ = measure(&text);
}
}
#[test]
fn mixed_direction_collapsing_message_preview_keeps_logical_order() {
for n in 45..70 {
let text = format!("{} שלום עולם {}", "x".repeat(n), "pad ".repeat(50));
let m = measure(&text);
assert!(m.wrapped_lines > 3, "n={n}: {} lines", m.wrapped_lines);
let preview = m.preview.expect("n={n}: long message must collapse");
let flat: String = preview.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
flat.contains("שלוםעולם"),
"n={n}: preview must keep the RTL phrase in logical order: {preview:?}"
);
}
}
}