use crate::measure::TextMeasure;
use crate::message::{Block, Message, MessageFlags};
use crate::span::{ParsedText, Span, Style};
use std::ops::Range;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct LayoutGeneration {
pub width: u32,
pub font: u32,
pub theme: u32,
pub zoom_permille: u32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LineSource {
Gutter,
Block(usize),
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LineBox {
pub y: u32,
pub height: u32,
pub range: Range<usize>,
pub source: LineSource,
pub x: u32,
pub width: u32,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LayoutCache {
pub generation: LayoutGeneration,
pub height: u32,
pub lines: Vec<LineBox>,
pub natural_indent: u32,
pub avatar: Option<AvatarBox>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct AvatarBox {
pub x: u32,
pub y: u32,
pub size: u32,
pub key: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct LayoutParams {
pub width: u32,
pub indent: bool,
pub max_indent: u32,
pub indent_width: u32,
pub stamp_width: u32,
pub gutter_gap: u32,
pub quote_indent: u32,
pub block_padding: u32,
pub word_wrap: bool,
pub avatar_size: u32,
}
impl Default for LayoutParams {
fn default() -> Self {
LayoutParams {
width: 640,
indent: true,
max_indent: 256,
indent_width: 0,
stamp_width: 0,
gutter_gap: 6,
quote_indent: 12,
block_padding: 2,
word_wrap: true,
avatar_size: 0,
}
}
}
pub fn layout_message(
msg: &Message,
params: &LayoutParams,
generation: LayoutGeneration,
measure: &dyn TextMeasure,
) -> LayoutCache {
let metrics = measure.metrics();
let line_h = metrics.line_height.max(1);
let mut lines = Vec::new();
let mut y = 0u32;
let grouped = msg.flags.contains(crate::message::MessageFlags::GROUPED);
let avatar_slot = if params.indent && params.avatar_size > 0 {
match &msg.speaker {
Some(s) if s.key != 0 => params.avatar_size + params.gutter_gap,
_ => 0,
}
} else {
0
};
let natural_indent = if params.indent {
let gutter_text = match (&msg.gutter, &msg.speaker) {
(Some(g), _) if !g.text.is_empty() => {
measure_styled(g, 0..g.text.len(), measure) + metrics.space_width
}
(_, Some(s)) => measure.run_width(&s.nick, Style::default()) + metrics.space_width * 2,
_ => 0,
};
params.stamp_width + avatar_slot + gutter_text
} else {
0
};
let body_x = if params.indent {
params.indent_width
} else {
0
};
let body_width = params.width.saturating_sub(body_x).max(line_h);
let avatar = if avatar_slot > 0 && !grouped {
msg.speaker.as_ref().map(|s| AvatarBox {
x: params.stamp_width,
y: 0,
size: params.avatar_size,
key: s.key,
})
} else {
None
};
let mut lead = 0u32;
let gutter = msg
.gutter
.as_ref()
.filter(|g| !g.text.is_empty() && !grouped);
if let Some(g) = gutter {
let gw = measure_styled(g, 0..g.text.len(), measure);
let gx = if params.indent {
params
.indent_width
.saturating_sub(gw + params.gutter_gap)
.max(params.stamp_width)
} else {
params.stamp_width
};
lines.push(LineBox {
y: 0,
height: line_h,
range: 0..g.text.len(),
source: LineSource::Gutter,
x: gx,
width: gw,
});
if !params.indent {
lead = gx + gw + metrics.space_width;
}
} else if !params.indent {
lead = params.stamp_width;
}
if lead > 0 && (!matches!(msg.blocks.first(), Some(Block::Text(_))) || lead > body_width / 2) {
y = line_h;
lead = 0;
}
for (bi, block) in msg.blocks.iter().enumerate() {
match block {
Block::Text(p) => {
let first = if bi == 0 { lead } else { 0 };
let n = wrap_text(
p,
body_width,
first,
params.word_wrap,
measure,
|range, lw, dx| {
lines.push(LineBox {
y,
height: line_h,
range,
source: LineSource::Block(bi),
x: body_x + dx,
width: lw,
});
y += line_h;
},
);
if n == 0 {
lines.push(LineBox {
y,
height: line_h,
range: 0..0,
source: LineSource::Block(bi),
x: body_x + first,
width: 0,
});
y += line_h;
}
}
Block::Code { text, .. } => {
y += params.block_padding;
let mut start = 0usize;
let plain = Style::default();
for (i, _) in text.match_indices('\n') {
lines.push(LineBox {
y,
height: line_h,
range: start..i,
source: LineSource::Block(bi),
x: body_x,
width: measure.run_width(&text[start..i], plain),
});
y += line_h;
start = i + 1;
}
lines.push(LineBox {
y,
height: line_h,
range: start..text.len(),
source: LineSource::Block(bi),
x: body_x,
width: measure.run_width(&text[start..], plain),
});
y += line_h + params.block_padding;
}
Block::Quote { content, depth } => {
let qx = body_x + params.quote_indent * u32::from(*depth).max(1);
let qw = params.width.saturating_sub(qx).max(line_h);
let n = wrap_text(content, qw, 0, params.word_wrap, measure, |range, lw, _| {
lines.push(LineBox {
y,
height: line_h,
range,
source: LineSource::Block(bi),
x: qx,
width: lw,
});
y += line_h;
});
if n == 0 {
lines.push(LineBox {
y,
height: line_h,
range: 0..0,
source: LineSource::Block(bi),
x: qx,
width: 0,
});
y += line_h;
}
}
Block::Image { size, alt, .. } => match size {
Some(sz) => {
y += params.block_padding;
let (iw, h) = measure.image_size((sz.width, sz.height), body_width);
lines.push(LineBox {
y,
height: h,
range: 0..alt.len(),
source: LineSource::Block(bi),
x: body_x,
width: iw,
});
y += h + params.block_padding;
}
None => {
let p = ParsedText::plain(alt.clone());
let n = wrap_text(
&p,
body_width,
0,
params.word_wrap,
measure,
|range, lw, _| {
lines.push(LineBox {
y,
height: line_h,
range,
source: LineSource::Block(bi),
x: body_x,
width: lw,
});
y += line_h;
},
);
if n == 0 {
lines.push(LineBox {
y,
height: line_h,
range: 0..0,
source: LineSource::Block(bi),
x: body_x,
width: 0,
});
y += line_h;
}
}
},
}
}
if lines.is_empty() {
y = line_h;
}
let text_h = y.max(line_h);
let row_h = text_h.max(avatar.map(|a| a.size).unwrap_or(0));
if row_h > text_h {
let shift = (row_h - text_h) / 2;
for l in &mut lines {
l.y += shift;
}
}
LayoutCache {
generation,
height: row_h,
lines,
natural_indent: natural_indent.min(params.max_indent),
avatar,
}
}
fn wrap_text(
p: &ParsedText,
width: u32,
first_indent: u32,
word_wrap: bool,
measure: &dyn TextMeasure,
mut emit: impl FnMut(Range<usize>, u32, u32),
) -> usize {
if p.text.is_empty() {
return 0;
}
let mut count = 0usize;
let mut line_start = 0usize;
let first_width = width.saturating_sub(first_indent).max(1);
let avail = |count: usize| if count == 0 { first_width } else { width };
let dx = |count: usize| if count == 0 { first_indent } else { 0 };
while line_start < p.text.len() {
let hard = p.text[line_start..]
.find('\n')
.map(|i| line_start + i)
.unwrap_or(p.text.len());
let segment = &p.text[line_start..hard];
if segment.is_empty() {
emit(line_start..line_start, 0, dx(count));
count += 1;
line_start = hard + 1;
continue;
}
let mut seg_start = line_start;
loop {
let rest = &p.text[seg_start..hard];
if rest.is_empty() {
break;
}
let w = measure_styled(p, seg_start..hard, measure);
let width = avail(count);
if w <= width {
emit(seg_start..hard, w, dx(count));
count += 1;
break;
}
let (fit_abs, fit_w) = fit_styled_prefix(p, seg_start..hard, width, measure);
let mut brk = fit_abs.max(seg_start + 1);
if word_wrap {
if let Some(sp) = p.text[seg_start..brk].rfind([' ', '\t']) {
if sp > 0 {
brk = seg_start + sp;
}
}
}
while brk < p.text.len() && !p.text.is_char_boundary(brk) {
brk += 1;
}
if brk <= seg_start {
brk = next_boundary(&p.text, seg_start);
}
let lw = if brk == fit_abs {
fit_w
} else {
measure_styled(p, seg_start..brk, measure)
};
emit(seg_start..brk, lw, dx(count));
count += 1;
seg_start = brk;
while seg_start < hard && p.text.as_bytes()[seg_start] == b' ' {
seg_start += 1;
}
}
line_start = hard + 1;
}
count
}
fn walk_style_runs<R>(
p: &ParsedText,
range: Range<usize>,
mut f: impl FnMut(Range<usize>, Style) -> Option<R>,
) -> Option<R> {
let mut cursor = range.start;
for s in &p.spans {
if s.range.end <= cursor || s.range.start >= range.end {
continue;
}
if s.range.start > cursor {
if let Some(r) = f(cursor..s.range.start, Style::default()) {
return Some(r);
}
cursor = s.range.start;
}
let end = s.range.end.min(range.end);
if end > cursor {
if let Some(r) = f(cursor..end, s.style) {
return Some(r);
}
cursor = end;
}
}
if cursor < range.end {
return f(cursor..range.end, Style::default());
}
None
}
fn measure_styled(p: &ParsedText, range: Range<usize>, measure: &dyn TextMeasure) -> u32 {
let mut total = 0u32;
walk_style_runs::<()>(p, range, |r, style| {
total += measure.run_width(&p.text[r], style);
None
});
total
}
fn fit_styled_prefix(
p: &ParsedText,
range: Range<usize>,
max_width: u32,
measure: &dyn TextMeasure,
) -> (usize, u32) {
let mut total = 0u32;
let mut cursor = range.start;
let straddled = walk_style_runs(p, range, |r, style| {
let seg = &p.text[r.clone()];
let w = measure.run_width(seg, style);
if total + w <= max_width {
total += w;
cursor = r.end;
return None;
}
let (fit, fw) = measure.fit_prefix(seg, style, max_width.saturating_sub(total));
Some((r.start + fit, total + fw))
});
straddled.unwrap_or((cursor, total))
}
fn next_boundary(s: &str, from: usize) -> usize {
let mut i = from + 1;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i.min(s.len())
}
pub fn estimate_height(msg: &Message, params: &LayoutParams, measure: &dyn TextMeasure) -> u32 {
let metrics = measure.metrics();
let line_h = metrics.line_height.max(1);
let body_x = if params.indent {
params.indent_width
} else {
0
};
let body_width = params.width.saturating_sub(body_x).max(line_h);
let cols = (body_width / metrics.space_width.max(1)).max(1) as usize;
let wrapped = |s: &str| {
s.split('\n')
.map(|seg| seg.len().div_ceil(cols).max(1))
.sum::<usize>()
.max(1)
};
let mut lines = 0usize;
let mut extra = 0u32;
let gutter_cols = msg
.gutter
.as_ref()
.filter(|g| !g.text.is_empty() && !msg.flags.contains(MessageFlags::GROUPED))
.map(|g| g.text.len() + 1);
let mut lead_cols = if params.indent {
0
} else {
(params.stamp_width / metrics.space_width.max(1)) as usize + gutter_cols.unwrap_or(0)
};
let beside = matches!(msg.blocks.first(), Some(Block::Text(_))) && lead_cols <= cols / 2;
if lead_cols > 0 && !beside {
lines += 1;
lead_cols = 0;
}
for (bi, b) in msg.blocks.iter().enumerate() {
match b {
Block::Text(p) if bi == 0 && lead_cols > 0 => {
let (first, rest) = p.text.split_once('\n').unwrap_or((&p.text, ""));
lines += (first.len() + lead_cols).div_ceil(cols).max(1);
if p.text.contains('\n') {
lines += wrapped(rest);
}
}
Block::Text(p) => lines += wrapped(&p.text),
Block::Code { text, .. } => {
lines += text.split('\n').count().max(1);
extra += params.block_padding * 2;
}
Block::Quote { content, depth } => {
let qx = body_x + params.quote_indent * u32::from(*depth).max(1);
let qw = params.width.saturating_sub(qx).max(line_h);
let qcols = (qw / metrics.space_width.max(1)).max(1) as usize;
lines += content
.text
.split('\n')
.map(|seg| seg.len().div_ceil(qcols).max(1))
.sum::<usize>()
.max(1);
}
Block::Image { size, alt, .. } => match size {
Some(sz) => {
let (_, h) = measure.image_size((sz.width, sz.height), body_width);
extra += h + params.block_padding * 2;
}
None => lines += wrapped(alt),
},
}
}
let text_h = (lines.max(1) as u32) * line_h + extra;
let avatar_h = if params.indent
&& params.avatar_size > 0
&& !msg.flags.contains(MessageFlags::GROUPED)
&& msg.speaker.as_ref().is_some_and(|s| s.key != 0)
{
params.avatar_size
} else {
0
};
text_h.max(avatar_h)
}
pub fn is_muted(msg: &Message) -> bool {
msg.flags.contains(MessageFlags::MUTED) || msg.flags.contains(MessageFlags::DELETED)
}
pub fn spans_in(p: &ParsedText, range: Range<usize>) -> Vec<Span> {
p.spans
.iter()
.filter(|s| s.range.start < range.end && s.range.end > range.start)
.map(|s| Span {
range: s.range.start.max(range.start)..s.range.end.min(range.end),
style: s.style,
})
.collect()
}