use super::context::{BasicContext, Context};
use super::{Element, ViewLimits, ViewStretch};
use crate::support::canvas::Canvas;
use crate::support::color::Color;
use crate::support::markdown::{self, LaidOutRun, StyledRun, TextRun, WrappedLine};
use crate::support::math;
use crate::support::point::Point;
use crate::support::rect::Rect;
use crate::support::theme::get_theme;
use std::any::Any;
use std::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatSender {
User,
Assistant,
System,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub sender: ChatSender,
pub thinking: String,
pub response: String,
}
impl ChatMessage {
pub fn new(sender: ChatSender, text: impl Into<String>) -> Self {
Self {
sender,
thinking: String::new(),
response: text.into(),
}
}
}
struct LaidOutMessage {
sender: ChatSender,
thinking_lines: Vec<WrappedLine>,
response_lines: Vec<WrappedLine>,
bubble: Rect,
}
struct CachedMessageLayout {
thinking_text: String,
response_text: String,
max_width: f32,
thinking_lines: Option<Vec<WrappedLine>>,
response_lines: Option<Vec<WrappedLine>>,
}
pub struct ChatHistory {
messages: RwLock<Vec<ChatMessage>>,
layout_cache: RwLock<Vec<Option<CachedMessageLayout>>>,
scroll_offset: RwLock<f32>,
width: f32,
height: f32,
font_size: f32,
padding: f32,
bubble_padding: f32,
bubble_max_width_ratio: f32,
corner_radius: f32,
gap: f32,
background_color: Color,
user_bubble_color: Color,
assistant_bubble_color: Color,
user_text_color: Color,
assistant_text_color: Color,
system_text_color: Color,
thinking_text_color: Color,
enabled: bool,
}
impl ChatHistory {
pub fn new() -> Self {
let theme = get_theme();
Self {
messages: RwLock::new(Vec::new()),
layout_cache: RwLock::new(Vec::new()),
scroll_offset: RwLock::new(0.0),
width: 400.0,
height: 300.0,
font_size: theme.label_font_size,
padding: 10.0,
bubble_padding: 10.0,
bubble_max_width_ratio: 0.75,
corner_radius: 10.0,
gap: 8.0,
background_color: theme.input_box_color,
user_bubble_color: theme.chat_user_bubble_color,
assistant_bubble_color: theme.chat_assistant_bubble_color,
user_text_color: Color::from_rgb_u8(255, 255, 255),
assistant_text_color: theme.label_font_color,
system_text_color: theme.chat_system_text_color,
thinking_text_color: theme.chat_thinking_text_color,
enabled: true,
}
}
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = height;
self
}
pub fn push_message(&self, sender: ChatSender, text: impl Into<String>) {
self.messages
.write()
.unwrap()
.push(ChatMessage::new(sender, text));
self.layout_cache.write().unwrap().push(None);
*self.scroll_offset.write().unwrap() = f32::MAX;
}
pub fn start_streaming_message(&self, sender: ChatSender) {
self.messages
.write()
.unwrap()
.push(ChatMessage::new(sender, ""));
self.layout_cache.write().unwrap().push(None);
*self.scroll_offset.write().unwrap() = f32::MAX;
}
pub fn append_thinking(&self, delta: &str) {
if let Some(last) = self.messages.write().unwrap().last_mut() {
last.thinking.push_str(delta);
}
*self.scroll_offset.write().unwrap() = f32::MAX;
}
pub fn append_response(&self, delta: &str) {
if let Some(last) = self.messages.write().unwrap().last_mut() {
last.response.push_str(delta);
}
*self.scroll_offset.write().unwrap() = f32::MAX;
}
pub fn clear(&self) {
self.messages.write().unwrap().clear();
self.layout_cache.write().unwrap().clear();
*self.scroll_offset.write().unwrap() = 0.0;
}
fn max_text_width(&self) -> f32 {
(self.width * self.bubble_max_width_ratio - 2.0 * self.bubble_padding).max(20.0)
}
fn wrap_markdown(
canvas: &mut Canvas,
text: &str,
max_width: f32,
force_italic: bool,
) -> Option<Vec<WrappedLine>> {
if text.is_empty() {
return None;
}
let mut runs = markdown::markdown_to_runs(text);
if force_italic {
for line in &mut runs {
for run in line.iter_mut() {
if let StyledRun::Text(text_run) = run {
text_run.italic = true;
}
}
}
}
Some(markdown::wrap_runs(canvas, &runs, max_width))
}
fn measure_lines_width(canvas: &mut Canvas, lines: &[WrappedLine]) -> f32 {
lines
.iter()
.map(|line| {
line.runs
.iter()
.map(|run| match run {
LaidOutRun::Text(text_run) => {
canvas.font(markdown::run_font(text_run));
canvas.text_width(&text_run.text)
}
LaidOutRun::Math { layout, .. } => layout.width,
})
.sum::<f32>()
})
.fold(0.0f32, f32::max)
}
fn layout_messages(&self, ctx: &Context) -> (Vec<LaidOutMessage>, f32) {
let messages = self.messages.read().unwrap();
let max_text_width = self.max_text_width();
let mut out = Vec::with_capacity(messages.len());
let mut y = self.padding;
{
let mut canvas = ctx.canvas.borrow_mut();
canvas.font_size(self.font_size);
let mut cache = self.layout_cache.write().unwrap();
debug_assert_eq!(
cache.len(),
messages.len(),
"layout_cache must stay parallel-indexed to messages"
);
for (i, msg) in messages.iter().enumerate() {
let cache_hit = cache
.get(i)
.and_then(|slot| slot.as_ref())
.is_some_and(|c| {
c.thinking_text == msg.thinking
&& c.response_text == msg.response
&& c.max_width == max_text_width
});
if !cache_hit {
let thinking_lines =
Self::wrap_markdown(&mut canvas, &msg.thinking, max_text_width, true).map(
|mut lines| {
canvas.font_size(self.font_size);
let metrics = canvas.font_metrics();
lines.insert(
0,
WrappedLine {
runs: vec![LaidOutRun::Text(TextRun {
text: "Thinking".to_string(),
bold: false,
italic: true,
monospace: false,
})],
height: metrics.ascent,
depth: metrics.descent,
},
);
lines
},
);
let response_lines =
Self::wrap_markdown(&mut canvas, &msg.response, max_text_width, false);
if i < cache.len() {
cache[i] = Some(CachedMessageLayout {
thinking_text: msg.thinking.clone(),
response_text: msg.response.clone(),
max_width: max_text_width,
thinking_lines,
response_lines,
});
}
}
let cached = cache[i].as_ref().unwrap();
let thinking_lines = cached.thinking_lines.clone();
let response_lines = cached.response_lines.clone();
let thinking_height = thinking_lines.as_ref().map_or(0.0, |l| {
markdown::measure_wrapped_height(&mut canvas, l, self.font_size)
});
let response_height = response_lines.as_ref().map_or(0.0, |l| {
markdown::measure_wrapped_height(&mut canvas, l, self.font_size)
});
let section_gap = if thinking_lines.is_some() && response_lines.is_some() {
self.gap * 0.5
} else {
0.0
};
let text_height = thinking_height + section_gap + response_height;
let thinking_lines = thinking_lines.unwrap_or_default();
let response_lines = response_lines.unwrap_or_default();
let bubble = match msg.sender {
ChatSender::System => {
Rect::new(self.padding, y, self.width - self.padding, y + text_height)
}
ChatSender::User | ChatSender::Assistant => {
let natural_width = Self::measure_lines_width(&mut canvas, &thinking_lines)
.max(Self::measure_lines_width(&mut canvas, &response_lines))
+ 2.0 * self.bubble_padding;
let bubble_width =
natural_width.min(self.width * self.bubble_max_width_ratio);
let (left, right) = if msg.sender == ChatSender::User {
(
self.width - self.padding - bubble_width,
self.width - self.padding,
)
} else {
(self.padding, self.padding + bubble_width)
};
Rect::new(left, y, right, y + text_height + 2.0 * self.bubble_padding)
}
};
y = bubble.bottom + self.gap;
out.push(LaidOutMessage {
sender: msg.sender,
thinking_lines,
response_lines,
bubble,
});
}
}
let total_height = (y - self.gap + self.padding).max(0.0);
drop(messages);
let visible_height = ctx.bounds.height();
let scroll = {
let mut scroll_guard = self.scroll_offset.write().unwrap();
*scroll_guard = if total_height <= visible_height {
0.0
} else {
(*scroll_guard).min(total_height - visible_height).max(0.0)
};
*scroll_guard
};
let dx = ctx.bounds.left;
let dy = ctx.bounds.top - scroll;
let out = out
.into_iter()
.map(|m| LaidOutMessage {
bubble: m.bubble.translate(dx, dy),
..m
})
.collect();
(out, total_height)
}
fn draw_background(&self, ctx: &Context) {
let mut canvas = ctx.canvas.borrow_mut();
canvas.fill_style(self.background_color);
canvas.fill_round_rect(ctx.bounds, self.corner_radius);
}
fn draw_messages(&self, ctx: &Context, messages: &[LaidOutMessage]) {
let mut canvas = ctx.canvas.borrow_mut();
canvas.font_size(self.font_size);
for msg in messages {
if msg.bubble.bottom < ctx.bounds.top || msg.bubble.top > ctx.bounds.bottom {
continue;
}
match msg.sender {
ChatSender::System => {
let leading = {
canvas.font_size(self.font_size);
canvas.font_metrics().leading.max(self.font_size * 0.2)
};
let mut y = msg.bubble.top + self.font_size * 0.85;
for line in &msg.response_lines {
canvas.font_size(self.font_size);
let text_metrics = canvas.font_metrics();
let width: f32 = line
.runs
.iter()
.map(|run| match run {
LaidOutRun::Text(text_run) => {
canvas.font(markdown::run_font(text_run));
canvas.text_width(&text_run.text)
}
LaidOutRun::Math { layout, .. } => layout.width,
})
.sum();
canvas.fill_style(self.system_text_color);
let mut x = msg.bubble.left + (msg.bubble.width() - width) * 0.5;
let baseline = y + line.height.max(text_metrics.ascent);
for run in &line.runs {
match run {
LaidOutRun::Text(text_run) => {
canvas.font(markdown::run_font(text_run));
canvas.fill_text(&text_run.text, Point::new(x, baseline));
x += canvas.text_width(&text_run.text);
}
LaidOutRun::Math { layout, .. } => {
math::draw::draw_math_box(
&mut canvas,
layout,
Point::new(x, baseline),
self.system_text_color,
);
x += layout.width;
}
}
}
y = baseline + line.depth.max(text_metrics.descent) + leading;
}
}
ChatSender::User | ChatSender::Assistant => {
let (bubble_color, text_color) = if msg.sender == ChatSender::User {
(self.user_bubble_color, self.user_text_color)
} else {
(self.assistant_bubble_color, self.assistant_text_color)
};
canvas.fill_style(bubble_color);
canvas.fill_round_rect(msg.bubble, self.corner_radius);
let mut y = msg.bubble.top + self.bubble_padding + self.font_size * 0.85;
if !msg.thinking_lines.is_empty() {
markdown::draw_runs(
&mut canvas,
&msg.thinking_lines,
Point::new(msg.bubble.left + self.bubble_padding, y),
self.font_size,
self.thinking_text_color,
);
y += markdown::measure_wrapped_height(
&mut canvas,
&msg.thinking_lines,
self.font_size,
);
if !msg.response_lines.is_empty() {
y += self.gap * 0.5;
}
}
if !msg.response_lines.is_empty() {
markdown::draw_runs(
&mut canvas,
&msg.response_lines,
Point::new(msg.bubble.left + self.bubble_padding, y),
self.font_size,
text_color,
);
}
}
}
}
}
fn draw_scrollbar(&self, ctx: &Context, total_height: f32, visible_height: f32) {
if total_height <= visible_height {
return;
}
let theme = get_theme();
let scroll = *self.scroll_offset.read().unwrap();
let scrollbar_height = (visible_height / total_height * visible_height).max(20.0);
let scrollbar_y =
scroll / (total_height - visible_height) * (visible_height - scrollbar_height);
let scrollbar_rect = Rect::new(
ctx.bounds.right - 8.0,
ctx.bounds.top + scrollbar_y,
ctx.bounds.right - 2.0,
ctx.bounds.top + scrollbar_y + scrollbar_height,
);
let mut canvas = ctx.canvas.borrow_mut();
canvas.fill_style(theme.scrollbar_color);
canvas.fill_round_rect(scrollbar_rect, 3.0);
}
}
impl Default for ChatHistory {
fn default() -> Self {
Self::new()
}
}
impl Element for ChatHistory {
fn limits(&self, _ctx: &BasicContext) -> ViewLimits {
ViewLimits::min_size(self.width, self.height)
}
fn stretch(&self) -> ViewStretch {
ViewStretch::new(1.0, 1.0)
}
fn draw(&self, ctx: &Context) {
self.draw_background(ctx);
let (messages, total_height) = self.layout_messages(ctx);
let visible_height = ctx.bounds.height();
{
let mut canvas = ctx.canvas.borrow_mut();
canvas.save();
let clip_bounds = Rect::new(
ctx.bounds.left + self.corner_radius,
ctx.bounds.top + self.corner_radius,
ctx.bounds.right - self.corner_radius,
ctx.bounds.bottom - self.corner_radius,
);
canvas.clip(clip_bounds);
}
self.draw_messages(ctx, &messages);
{
let mut canvas = ctx.canvas.borrow_mut();
canvas.restore();
}
self.draw_scrollbar(ctx, total_height, visible_height);
}
fn hit_test(
&self,
ctx: &Context,
p: Point,
_leaf: bool,
_control: bool,
) -> Option<&dyn Element> {
if ctx.bounds.contains(p) && self.enabled {
Some(self)
} else {
None
}
}
fn wants_control(&self) -> bool {
self.enabled
}
fn scroll(&mut self, ctx: &Context, dir: Point, p: Point) -> bool {
self.handle_scroll(ctx, dir, p)
}
fn handle_scroll(&self, ctx: &Context, dir: Point, _p: Point) -> bool {
if !self.enabled {
return false;
}
let (_, total_height) = self.layout_messages(ctx);
let visible_height = ctx.bounds.height();
if total_height <= visible_height {
return false;
}
let mut scroll = self.scroll_offset.write().unwrap();
*scroll = (*scroll - dir.y * 20.0)
.min(total_height - visible_height)
.max(0.0);
true
}
fn enable(&mut self, state: bool) {
self.enabled = state;
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
pub fn chat_history() -> ChatHistory {
ChatHistory::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::support::point::Extent;
use crate::view::View;
#[test]
fn wrap_markdown_keeps_every_line_within_max_width() {
let mut canvas = Canvas::new(400, 400).unwrap();
canvas.font_size(14.0);
let text = "the quick brown fox jumps over the lazy dog again and again and again";
let lines =
ChatHistory::wrap_markdown(&mut canvas, text, 100.0, false).expect("non-empty text");
assert!(
lines.len() > 1,
"expected wrapping to produce multiple lines"
);
for line in &lines {
let width: f32 = line
.runs
.iter()
.map(|run| match run {
LaidOutRun::Text(text_run) => {
canvas.font(markdown::run_font(text_run));
canvas.text_width(&text_run.text)
}
LaidOutRun::Math { layout, .. } => layout.width,
})
.sum();
assert!(width <= 101.0, "line exceeds the 100px max width ({width})");
}
}
#[test]
fn wrap_markdown_forces_a_break_on_a_blank_line_even_under_the_width_limit() {
let mut canvas = Canvas::new(400, 400).unwrap();
canvas.font_size(14.0);
let lines = ChatHistory::wrap_markdown(&mut canvas, "line1\n\nline2", 1000.0, false)
.expect("non-empty text");
let rendered: Vec<String> = lines
.iter()
.map(|line| {
line.runs
.iter()
.map(|r| match r {
LaidOutRun::Text(t) => t.text.as_str(),
LaidOutRun::Math { .. } => "",
})
.collect()
})
.collect();
assert_eq!(rendered, vec!["line1".to_string(), "line2".to_string()]);
}
#[test]
fn wrap_markdown_returns_none_for_empty_text() {
let mut canvas = Canvas::new(400, 400).unwrap();
assert!(ChatHistory::wrap_markdown(&mut canvas, "", 100.0, false).is_none());
}
#[test]
fn push_message_grows_total_content_height() {
let history = ChatHistory::new().width(300.0).height(200.0);
let view = View::new(Extent::new(300.0, 200.0));
let canvas = std::cell::RefCell::new(Canvas::new(300, 200).unwrap());
let bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
let ctx = Context::new(&view, &canvas, bounds);
let (_, height_before) = history.layout_messages(&ctx);
history.push_message(ChatSender::User, "hello");
let (_, height_after) = history.layout_messages(&ctx);
assert!(height_after > height_before);
}
#[test]
fn user_bubbles_align_further_right_than_assistant_bubbles_for_the_same_text() {
let history = ChatHistory::new().width(300.0).height(200.0);
history.push_message(ChatSender::User, "hi");
history.push_message(ChatSender::Assistant, "hi");
let view = View::new(Extent::new(300.0, 200.0));
let canvas = std::cell::RefCell::new(Canvas::new(300, 200).unwrap());
let bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
let ctx = Context::new(&view, &canvas, bounds);
let (messages, _) = history.layout_messages(&ctx);
assert_eq!(messages.len(), 2);
assert!(
messages[0].bubble.right > messages[1].bubble.right,
"user bubble (right-aligned) should sit further right than the assistant bubble \
(left-aligned) for identical text"
);
}
#[test]
fn handle_scroll_is_a_noop_when_content_already_fits() {
let history = ChatHistory::new().width(300.0).height(400.0);
history.push_message(ChatSender::User, "short");
let view = View::new(Extent::new(300.0, 400.0));
let canvas = std::cell::RefCell::new(Canvas::new(300, 400).unwrap());
let bounds = Rect::new(0.0, 0.0, 300.0, 400.0);
let ctx = Context::new(&view, &canvas, bounds);
let handled = history.handle_scroll(&ctx, Point::new(0.0, -5.0), Point::zero());
assert!(!handled);
assert_eq!(*history.scroll_offset.read().unwrap(), 0.0);
}
#[test]
fn streaming_append_grows_the_last_messages_response_in_place() {
let history = ChatHistory::new().width(300.0).height(200.0);
history.start_streaming_message(ChatSender::Assistant);
history.append_response("Hello");
history.append_response(", world!");
let messages = history.messages.read().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender, ChatSender::Assistant);
assert_eq!(messages[0].response, "Hello, world!");
assert!(messages[0].thinking.is_empty());
}
#[test]
fn streaming_append_thinking_and_response_target_separate_fields() {
let history = ChatHistory::new().width(300.0).height(200.0);
history.start_streaming_message(ChatSender::Assistant);
history.append_thinking("reasoning here");
history.append_response("the answer");
let messages = history.messages.read().unwrap();
assert_eq!(messages[0].thinking, "reasoning here");
assert_eq!(messages[0].response, "the answer");
}
#[test]
fn append_before_any_streaming_message_started_is_a_harmless_noop() {
let history = ChatHistory::new().width(300.0).height(200.0);
history.append_response("should go nowhere");
assert!(history.messages.read().unwrap().is_empty());
}
#[test]
fn a_message_with_thinking_text_lays_out_taller_than_one_with_only_a_response() {
let history = ChatHistory::new().width(300.0).height(200.0);
history.push_message(ChatSender::Assistant, "just an answer");
let view = View::new(Extent::new(300.0, 200.0));
let canvas = std::cell::RefCell::new(Canvas::new(300, 200).unwrap());
let bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
let ctx = Context::new(&view, &canvas, bounds);
let (_, height_without_thinking) = history.layout_messages(&ctx);
history.clear();
history.start_streaming_message(ChatSender::Assistant);
history.append_thinking("some reasoning about the problem");
history.append_response("just an answer");
let (messages, height_with_thinking) = history.layout_messages(&ctx);
assert!(
height_with_thinking > height_without_thinking,
"a message with a thinking section should lay out taller"
);
assert!(!messages[0].thinking_lines.is_empty());
assert!(!messages[0].response_lines.is_empty());
}
#[test]
fn handle_scroll_clamps_into_a_valid_range_when_content_overflows() {
let history = ChatHistory::new().width(200.0).height(80.0);
for i in 0..20 {
history.push_message(ChatSender::Assistant, format!("message number {i}"));
}
let view = View::new(Extent::new(200.0, 80.0));
let canvas = std::cell::RefCell::new(Canvas::new(200, 80).unwrap());
let bounds = Rect::new(0.0, 0.0, 200.0, 80.0);
let ctx = Context::new(&view, &canvas, bounds);
let handled = history.handle_scroll(&ctx, Point::new(0.0, 1000.0), Point::zero());
assert!(handled);
assert_eq!(*history.scroll_offset.read().unwrap(), 0.0);
let handled = history.handle_scroll(&ctx, Point::new(0.0, -100000.0), Point::zero());
assert!(handled);
let (_, total_height) = history.layout_messages(&ctx);
assert_eq!(*history.scroll_offset.read().unwrap(), total_height - 80.0);
}
#[test]
fn a_second_layout_call_with_unchanged_text_reuses_the_cached_wrapped_lines() {
let history = ChatHistory::new().width(300.0).height(200.0);
history.push_message(ChatSender::Assistant, "$\\frac{1}{2}$ and some more text");
let view = View::new(Extent::new(300.0, 200.0));
let canvas = std::cell::RefCell::new(Canvas::new(300, 200).unwrap());
let bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
let ctx = Context::new(&view, &canvas, bounds);
let (first, _) = history.layout_messages(&ctx);
let (second, _) = history.layout_messages(&ctx);
let first_math = first[0].response_lines.iter().find_map(|line| {
line.runs.iter().find_map(|r| match r {
LaidOutRun::Math { layout, .. } => Some(layout.clone()),
LaidOutRun::Text(_) => None,
})
});
let second_math = second[0].response_lines.iter().find_map(|line| {
line.runs.iter().find_map(|r| match r {
LaidOutRun::Math { layout, .. } => Some(layout.clone()),
LaidOutRun::Text(_) => None,
})
});
let (Some(first_math), Some(second_math)) = (first_math, second_math) else {
panic!("expected a math run in the response");
};
assert!(
std::sync::Arc::ptr_eq(&first_math, &second_math),
"expected the second layout_messages call to reuse the cached MathBox, not recompute it"
);
}
}