use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::theme::ThemeStyles;
use crate::widgets::chat::layout::LayoutEntry;
pub(crate) const STICKY_CANDIDATES: usize = 3;
pub(crate) const STICKY_MAX_HEIGHT: u16 = 1;
pub(crate) fn compute_sticky_candidates(layout: &[LayoutEntry]) -> Vec<(usize, u32)> {
let mut candidates = Vec::new();
let mut last_msg_idx: Option<usize> = None;
for entry in layout {
if let crate::widgets::chat::layout::LayoutKind::Text { is_user: true, .. } = entry.kind
&& last_msg_idx != Some(entry.msg_idx)
{
candidates.push((entry.msg_idx, entry.y));
last_msg_idx = Some(entry.msg_idx);
}
}
if candidates.len() > STICKY_CANDIDATES {
let skip = candidates.len() - STICKY_CANDIDATES;
candidates.drain(..skip);
}
candidates
}
pub(crate) fn render_sticky_headers(
candidates: &[(usize, u32)],
viewport_area: Rect,
viewport_height: u16,
scroll_offset: u32,
styles: &ThemeStyles,
buf: &mut Buffer,
new_answer_pending: bool,
) {
let sticky_h = STICKY_MAX_HEIGHT.min(viewport_area.height);
if sticky_h == 0 {
return;
}
let sticky_rect = Rect::new(
viewport_area.x,
viewport_area.y,
viewport_area.width,
sticky_h,
);
let mut lines: Vec<String> = Vec::new();
for (msg_idx, entry_y) in candidates {
let at_top = *entry_y >= scroll_offset
&& *entry_y < scroll_offset.saturating_add(viewport_height as u32);
if at_top {
continue;
}
lines.push(format!("▸ you (msg {})", msg_idx));
}
let badge = if new_answer_pending {
" ↓ new answer"
} else {
""
};
if lines.is_empty() && !new_answer_pending {
return;
}
let display = if lines.is_empty() {
badge.trim().to_string()
} else if lines.len() == 1 {
format!("{}{}", lines[0], badge)
} else {
format!("{} +{} more{}", lines[0], lines.len() - 1, badge)
};
let truncated: String = display.chars().take(sticky_rect.width as usize).collect();
let padded = format!("{:width$}", truncated, width = sticky_rect.width as usize);
let style = styles.muted;
for x in sticky_rect.x..sticky_rect.x.saturating_add(sticky_rect.width) {
if let Some(cell) = buf.cell_mut((x, sticky_rect.y)) {
cell.set_symbol(" ");
cell.set_style(style);
}
}
for (i, c) in padded.chars().enumerate() {
let x = sticky_rect.x + i as u16;
if x < sticky_rect.x.saturating_add(sticky_rect.width)
&& let Some(cell) = buf.cell_mut((x, sticky_rect.y))
{
cell.set_symbol(&c.to_string());
cell.set_style(style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::chat::layout::{LayoutEntry, LayoutKind};
use ratatui::text::Line;
fn make_text_entry(y: u32, height: u32, msg_idx: usize, is_user: bool) -> LayoutEntry {
LayoutEntry {
y,
height,
kind: LayoutKind::Text {
lines: vec![Line::from("")],
is_user,
},
msg_idx,
}
}
#[test]
fn test_picks_last_n_user_messages() {
let layout = vec![
make_text_entry(0, 1, 0, true),
make_text_entry(1, 1, 1, false),
make_text_entry(2, 1, 2, true),
make_text_entry(3, 1, 3, false),
make_text_entry(4, 1, 4, true),
make_text_entry(5, 1, 5, true),
];
let candidates = compute_sticky_candidates(&layout);
assert_eq!(candidates.len(), 3);
assert_eq!(candidates[0].0, 2);
assert_eq!(candidates[1].0, 4);
assert_eq!(candidates[2].0, 5);
}
#[test]
fn test_caps_at_sticky_n() {
let mut layout = Vec::new();
for i in 0..5 {
layout.push(make_text_entry(i as u32, 1, i, true));
}
let candidates = compute_sticky_candidates(&layout);
assert_eq!(candidates.len(), 3);
assert_eq!(candidates[0].0, 2); assert_eq!(candidates[1].0, 3);
assert_eq!(candidates[2].0, 4); }
#[test]
fn test_no_user_messages_yields_empty() {
let layout = vec![
make_text_entry(0, 1, 0, false),
make_text_entry(1, 1, 1, false),
];
let candidates = compute_sticky_candidates(&layout);
assert!(candidates.is_empty());
}
#[test]
fn test_empty_layout_yields_empty() {
let candidates = compute_sticky_candidates(&[]);
assert!(candidates.is_empty());
}
}