use crate::compose::markdown::render_markdown_to_styled;
use crate::primitives::scrollbar::{scroll_by_clamped, wheel_row_delta, PressZone, ThumbDrag};
use crate::text_util::{
next_char_boundary, prev_char_boundary, safe_prefix, snap_to_char_boundary, wrap_spans,
WrapPolicy,
};
use crate::theme::Theme;
use crate::types::StyledSpan;
use crate::{
Backend, ButtonMask, Color, FieldKind, Form, FormField, Key, MessageList, MessageListHit,
MessageListMeasure, MessageRow, Modifiers, MouseButton, NamedKey, Point, Rect, Scrollbar,
Spinner, StyledText, TextInput, TextInputHit, UiEvent, WidgetId,
};
use serde::{Deserialize, Serialize};
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
const TRANSCRIPT_ROWS_PER_NOTCH: f32 = 3.0;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChatRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatTurn {
pub role: ChatRole,
pub text: StyledText,
pub timestamp_unix: Option<f64>,
#[serde(default)]
pub line_scales: Vec<f32>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChatControllerEvent {
Submit { text: String },
StreamChunk { text: String },
Cancelled,
StopRequested,
KeyPressed { key: String, modifiers: Modifiers },
TurnClicked { turn_idx: usize, row_in_turn: usize },
Consumed,
Ignored,
}
struct ChatLayout {
status: Rect,
transcript: Rect,
scrollbar: Option<Rect>,
spinner: Option<Rect>,
hint: Option<Rect>,
input: Rect,
send: Option<Rect>,
}
pub struct ChatController {
id: WidgetId,
transcript: Vec<ChatTurn>,
status_label: StyledText,
busy: bool,
model_label: String,
spinner_frame: usize,
input_buf: String,
input_cursor: usize,
input_scroll_offset: usize,
input_has_focus: bool,
history: Vec<String>,
history_pos: Option<usize>,
saved_input: Option<(String, usize)>,
transcript_scroll_top: Cell<usize>,
transcript_drag: Option<ThumbDrag>,
stuck_to_bottom: bool,
focused_turn: Option<usize>,
turn_collapsed: HashSet<usize>,
turn_summary: HashMap<usize, String>,
input_min_rows: usize,
input_max_rows: usize,
scrollbar_width: Option<f32>,
submit_on_enter: bool,
hint: Option<StyledText>,
}
impl ChatController {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: WidgetId::new(id),
transcript: Vec::new(),
status_label: StyledText::plain(""),
busy: false,
model_label: String::new(),
spinner_frame: 0,
input_buf: String::new(),
input_cursor: 0,
input_scroll_offset: 0,
input_has_focus: true,
history: Vec::new(),
history_pos: None,
saved_input: None,
transcript_scroll_top: Cell::new(0),
transcript_drag: None,
stuck_to_bottom: true,
focused_turn: None,
turn_collapsed: HashSet::new(),
turn_summary: HashMap::new(),
input_min_rows: 1,
input_max_rows: 8,
scrollbar_width: None,
submit_on_enter: false,
hint: None,
}
}
pub fn set_transcript(&mut self, turns: Vec<ChatTurn>) {
self.transcript = turns;
}
pub fn set_status(&mut self, label: StyledText) {
self.status_label = label;
}
pub fn set_busy(&mut self, busy: bool) {
self.busy = busy;
}
pub fn set_model_label(&mut self, label: &str) {
self.model_label = label.to_string();
}
pub fn set_spinner_frame(&mut self, frame: usize) {
self.spinner_frame = frame;
}
pub fn input_text(&self) -> &str {
&self.input_buf
}
pub fn clear_input(&mut self) {
self.input_buf.clear();
self.input_cursor = 0;
self.input_scroll_offset = 0;
self.history_pos = None;
self.saved_input = None;
}
pub fn input_has_focus(&self) -> bool {
self.input_has_focus
}
pub fn set_input_has_focus(&mut self, focus: bool) {
self.input_has_focus = focus;
}
pub fn set_input_height_range(&mut self, min: usize, max: usize) {
let min = min.max(1);
let max = max.max(min);
self.input_min_rows = min;
self.input_max_rows = max;
}
pub fn set_scrollbar_width(&mut self, width: Option<f32>) {
self.scrollbar_width = width;
}
pub fn set_submit_on_enter(&mut self, submit_on_enter: bool) {
self.submit_on_enter = submit_on_enter;
}
pub fn submit_on_enter(&self) -> bool {
self.submit_on_enter
}
pub fn set_hint(&mut self, hint: Option<StyledText>) {
self.hint = hint;
}
pub fn push_turn(&mut self, role: ChatRole, text: StyledText) {
self.transcript.push(ChatTurn {
role,
text,
timestamp_unix: None,
line_scales: Vec::new(),
});
}
pub fn push_turn_markdown(&mut self, role: ChatRole, markdown: &str, theme: &Theme) {
let rendered = render_markdown_to_styled(markdown, theme);
let line_scales = rendered.line_scales.clone();
let mut spans: Vec<StyledSpan> = Vec::new();
for (i, line) in rendered.lines.into_iter().enumerate() {
if i > 0 {
spans.push(StyledSpan::plain("\n"));
}
spans.extend(line.spans);
}
self.transcript.push(ChatTurn {
role,
text: StyledText { spans },
timestamp_unix: None,
line_scales,
});
}
pub fn transcript_scroll_top(&self) -> usize {
self.transcript_scroll_top.get()
}
pub fn set_transcript_scroll_top(&mut self, top: usize) {
self.transcript_scroll_top.set(top);
}
pub fn is_turn_collapsed(&self, turn_idx: usize) -> bool {
turn_idx < self.transcript.len() && self.turn_collapsed.contains(&turn_idx)
}
pub fn set_turn_collapsed(&mut self, turn_idx: usize, collapsed: bool) {
if turn_idx >= self.transcript.len() {
return;
}
if collapsed {
self.turn_collapsed.insert(turn_idx);
} else {
self.turn_collapsed.remove(&turn_idx);
}
}
pub fn toggle_turn_collapsed(&mut self, turn_idx: usize) {
if turn_idx >= self.transcript.len() {
return;
}
if !self.turn_collapsed.insert(turn_idx) {
self.turn_collapsed.remove(&turn_idx);
}
}
pub fn set_turn_summary(&mut self, turn_idx: usize, summary: Option<String>) {
match summary {
Some(s) => {
self.turn_summary.insert(turn_idx, s);
}
None => {
self.turn_summary.remove(&turn_idx);
}
}
}
pub fn turn_summary_line(&self, turn_idx: usize) -> String {
if let Some(s) = self.turn_summary.get(&turn_idx) {
return s.clone();
}
let Some(turn) = self.transcript.get(turn_idx) else {
return String::new();
};
let plain: String = turn.text.spans.iter().map(|s| s.text.as_str()).collect();
plain
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.to_string()
}
pub fn focused_turn(&self) -> Option<usize> {
self.focused_turn
}
pub fn set_focused_turn(&mut self, turn: Option<usize>) {
self.focused_turn = turn;
if turn.is_some() {
self.input_has_focus = false;
}
}
pub fn render(&self, backend: &mut dyn Backend, rect: Rect) {
let layout = self.compute_layout(backend, rect);
let status_rows = self.build_status_rows();
let status_list = MessageList {
id: WidgetId::new(format!("{}-status", self.id)),
rows: status_rows,
scroll_top: 0,
};
backend.draw_message_list(layout.status, &status_list);
if self.busy {
if let Some(sp_rect) = layout.spinner {
let spinner = Spinner {
id: WidgetId::new(format!("{}-spinner", self.id)),
label: String::new(),
frame_idx: self.spinner_frame,
accent: None,
};
backend.draw_spinner(sp_rect, &spinner);
}
}
let col_budget = self.transcript_col_budget(backend.char_width(), layout.transcript);
let wrapped_rows = self.build_transcript_rows(col_budget);
let total_rows = wrapped_rows.len();
let visible_rows =
Self::transcript_visible_rows_for(backend.line_height(), layout.transcript);
if self.stuck_to_bottom {
self.transcript_scroll_top
.set(total_rows.saturating_sub(visible_rows));
}
let list = MessageList {
id: WidgetId::new(format!("{}-transcript", self.id)),
rows: wrapped_rows,
scroll_top: self.transcript_scroll_top.get(),
};
backend.draw_message_list(layout.transcript, &list);
if let Some(sb_rect) = layout.scrollbar {
let track_w = self.scrollbar_track_width(backend.line_height());
let sb = build_scrollbar(
format!("{}-sb", self.id),
sb_rect,
self.transcript_scroll_top.get(),
total_rows,
visible_rows,
self.transcript_drag.is_some(),
track_w.max(1.0),
);
backend.draw_scrollbar(sb_rect, &sb);
}
if let Some(hint_rect) = layout.hint {
let hint_text: String = self
.hint
.as_ref()
.map(|h| h.spans.iter().map(|s| s.text.as_str()).collect())
.unwrap_or_default();
let hint_list = MessageList {
id: WidgetId::new(format!("{}-hint", self.id)),
rows: vec![MessageRow::new(hint_text, Color::rgb(140, 140, 140), 0.0)],
scroll_top: 0,
};
backend.draw_message_list(hint_rect, &hint_list);
}
let col_budget = TextInput::content_cols(layout.input.width, backend.char_width());
let ti = self.build_text_input(col_budget);
backend.draw_text_input(layout.input, &ti);
if let Some(send_rect) = layout.send {
backend.draw_form(send_rect, &self.build_send_form());
}
}
pub fn handle(
&mut self,
event: &UiEvent,
backend: &dyn Backend,
rect: Rect,
) -> ChatControllerEvent {
let layout = self.compute_layout(backend, rect);
match event {
UiEvent::CharTyped(ch) => {
self.focus_input();
self.input_insert_char(*ch);
ChatControllerEvent::Consumed
}
UiEvent::ClipboardPaste(text) => {
self.focus_input();
self.input_insert_str(text);
ChatControllerEvent::Consumed
}
UiEvent::KeyPressed { key, modifiers, .. } => {
self.handle_key(key, modifiers, backend, &layout)
}
UiEvent::Scroll { delta, .. } => {
let visible =
Self::transcript_visible_rows_for(backend.line_height(), layout.transcript);
let col_budget =
self.transcript_col_budget(backend.char_width(), layout.transcript);
let total = self.build_transcript_rows(col_budget).len();
let rows = wheel_row_delta(delta.y, TRANSCRIPT_ROWS_PER_NOTCH);
self.scroll_transcript_by(rows, total, visible);
ChatControllerEvent::Consumed
}
UiEvent::MouseDown {
button: MouseButton::Left,
position,
..
} => self.handle_click(backend, &layout, position.x, position.y),
UiEvent::MouseMoved {
position,
buttons: ButtonMask { left: true, .. },
} => self.handle_drag(position.y),
UiEvent::MouseUp {
button: MouseButton::Left,
..
} => {
self.transcript_drag = None;
ChatControllerEvent::Ignored
}
_ => ChatControllerEvent::Ignored,
}
}
pub fn scroll_transcript_by(&mut self, delta: isize, total_rows: usize, visible_rows: usize) {
let max = total_rows.saturating_sub(visible_rows);
let new = scroll_by_clamped(self.transcript_scroll_top.get(), delta, max);
self.transcript_scroll_top.set(new);
self.stuck_to_bottom =
self.transcript_scroll_top.get() >= total_rows.saturating_sub(visible_rows);
}
fn focus_next_turn(&mut self) {
if self.transcript.is_empty() {
self.focused_turn = None;
self.input_has_focus = true;
return;
}
self.focused_turn = match self.focused_turn {
None => Some(0),
Some(i) if i + 1 < self.transcript.len() => Some(i + 1),
Some(_) => None,
};
self.input_has_focus = self.focused_turn.is_none();
}
fn focus_prev_turn(&mut self) {
if self.transcript.is_empty() {
self.focused_turn = None;
self.input_has_focus = true;
return;
}
self.focused_turn = match self.focused_turn {
None => Some(self.transcript.len() - 1),
Some(0) => None,
Some(i) => Some(i - 1),
};
self.input_has_focus = self.focused_turn.is_none();
}
fn focus_input(&mut self) {
self.focused_turn = None;
self.input_has_focus = true;
}
fn compute_layout(&self, backend: &dyn Backend, rect: Rect) -> ChatLayout {
let lh = backend.line_height().max(1.0);
let status_h = lh;
let send_w = self.send_segment_width(backend.char_width());
let show_send = rect.width > send_w * 2.0;
let input_row_w = if show_send {
rect.width - send_w
} else {
rect.width
};
let col_budget = TextInput::content_cols(input_row_w, backend.char_width());
let visual_rows = wrap_input_rows(&self.input_buf, col_budget).len().max(1);
let input_rows = visual_rows.clamp(self.input_min_rows, self.input_max_rows);
let input_h = input_rows as f32 * lh + 2.0;
let hint_h = if self.hint.is_some() { lh } else { 0.0 };
let middle_h = (rect.height - status_h - input_h - hint_h).max(0.0);
let status = Rect::new(rect.x, rect.y, rect.width, status_h);
let input_y = rect.y + rect.height - input_h;
let input = Rect::new(rect.x, input_y, input_row_w, input_h);
let send = if show_send {
Some(Rect::new(rect.x + input_row_w, input_y, send_w, input_h))
} else {
None
};
let hint = if hint_h > 0.0 {
Some(Rect::new(rect.x, input_y - hint_h, rect.width, hint_h))
} else {
None
};
let spinner = if self.busy && rect.width > lh {
Some(Rect::new(rect.x + rect.width - lh, rect.y, lh, status_h))
} else {
None
};
let track_w = self.scrollbar_track_width(backend.line_height());
let (transcript, scrollbar) = if !self.transcript.is_empty() && rect.width > track_w {
let t = Rect::new(rect.x, rect.y + status_h, rect.width - track_w, middle_h);
let s = Rect::new(
rect.x + rect.width - track_w,
rect.y + status_h,
track_w,
middle_h,
);
(t, Some(s))
} else {
let t = Rect::new(rect.x, rect.y + status_h, rect.width, middle_h);
(t, None)
};
ChatLayout {
status,
transcript,
scrollbar,
spinner,
hint,
input,
send,
}
}
fn scrollbar_track_width(&self, line_height: f32) -> f32 {
self.scrollbar_width.unwrap_or(line_height)
}
fn send_segment_width(&self, char_width: f32) -> f32 {
const SEND_SEGMENT_COLS: f32 = 8.0;
SEND_SEGMENT_COLS * char_width.max(1.0)
}
fn build_send_form(&self) -> Form {
let disabled = !self.busy && self.input_buf.trim().is_empty();
Form {
id: WidgetId::new(format!("{}-send-form", self.id)),
fields: vec![FormField {
id: WidgetId::new(format!("{}-send-btn", self.id)),
label: StyledText::plain(if self.busy { "Stop" } else { "Send" }),
kind: FieldKind::Button,
hint: StyledText::plain(""),
disabled,
validation: None,
}],
focused_field: None,
scroll_offset: 0,
has_focus: false,
}
}
fn transcript_visible_rows_for(line_height: f32, rect: Rect) -> usize {
if line_height <= 0.0 {
0
} else {
(rect.height / line_height).floor() as usize
}
}
fn transcript_col_budget(&self, char_width: f32, rect: Rect) -> usize {
if char_width <= 0.0 {
80
} else {
((rect.width / char_width).floor() as usize).max(1)
}
}
fn build_status_rows(&self) -> Vec<MessageRow> {
let label: String = self
.status_label
.spans
.iter()
.map(|s| s.text.as_str())
.collect();
let text = if self.model_label.is_empty() {
label
} else {
format!("{label} [{}]", self.model_label)
};
vec![MessageRow::new(text, Color::rgb(180, 180, 180), 0.0)]
}
fn build_transcript_rows(&self, col_budget: usize) -> Vec<MessageRow> {
self.build_transcript_rows_indexed(col_budget).0
}
fn build_transcript_rows_indexed(&self, col_budget: usize) -> (Vec<MessageRow>, Vec<usize>) {
let mut rows = Vec::new();
let mut turn_starts = Vec::with_capacity(self.transcript.len());
for (idx, turn) in self.transcript.iter().enumerate() {
turn_starts.push(rows.len());
let (role_label, role_fg, content_fg) = match turn.role {
ChatRole::User => ("You", Color::rgb(100, 180, 255), Color::rgb(220, 220, 220)),
ChatRole::Assistant => ("AI", Color::rgb(120, 220, 120), Color::rgb(210, 210, 210)),
ChatRole::System => (
"System",
Color::rgb(200, 160, 80),
Color::rgb(190, 190, 190),
),
};
let collapsed = self.is_turn_collapsed(idx);
let header = if collapsed {
format!("{role_label} \u{25b8}")
} else {
role_label.to_string()
};
rows.push(MessageRow::new(header, role_fg, 0.0));
if collapsed {
rows.push(MessageRow::new(
self.turn_summary_line(idx),
content_fg,
2.0,
));
} else {
let content_budget = col_budget.saturating_sub(2);
if !turn.line_scales.is_empty() {
let lines_spans = split_spans_by_newline(&turn.text.spans);
for (i, line_spans) in lines_spans.iter().enumerate() {
let scale = turn.line_scales.get(i).copied().unwrap_or(1.0);
let wrapped_groups =
wrap_spans(line_spans, content_budget, WrapPolicy::Char);
for group in wrapped_groups {
let text: String = group.iter().map(|s| s.text.as_str()).collect();
rows.push(MessageRow {
text,
fg: content_fg,
indent: 2.0,
spans: group,
scale,
});
}
}
} else {
let plain: String = turn.text.spans.iter().map(|s| s.text.as_str()).collect();
for raw_line in plain.split('\n') {
for wrapped in wrap_text(raw_line, content_budget) {
rows.push(MessageRow::new(wrapped, content_fg, 2.0));
}
}
}
}
rows.push(MessageRow::new("", Color::rgb(50, 50, 50), 0.0));
}
(rows, turn_starts)
}
fn build_text_input(&self, col_budget: usize) -> TextInput {
let rows = wrap_input_rows(&self.input_buf, col_budget);
self.text_input_from_rows(&rows)
}
fn text_input_from_rows(&self, rows: &[InputRow]) -> TextInput {
let lines: Vec<String> = rows.iter().map(|r| r.text.clone()).collect();
let (logical_line, logical_col) =
cursor_byte_to_line_col(&self.input_buf, self.input_cursor);
let (visual_row, visual_col) = input_logical_to_visual(rows, logical_line, logical_col);
let mut ti = TextInput::new(WidgetId::new(format!("{}-input", self.id)));
ti.lines = lines;
ti.cursor_line = visual_row;
ti.cursor_col = visual_col;
ti.placeholder = Some(
if self.submit_on_enter {
"Type a message\u{2026} (Enter to send, Shift+Enter for newline, Esc to cancel)"
} else {
"Type a message\u{2026} (Ctrl+S or Alt+Enter to send, Enter for newline, Esc to cancel)"
}
.into(),
);
ti.scroll_offset = self.input_scroll_offset;
ti.scroll_col = 0;
ti.has_focus = self.input_has_focus;
ti
}
fn handle_key(
&mut self,
key: &Key,
modifiers: &Modifiers,
backend: &dyn Backend,
layout: &ChatLayout,
) -> ChatControllerEvent {
match key {
Key::Named(NamedKey::Tab) if !modifiers.ctrl && !modifiers.alt && !modifiers.shift => {
self.focus_next_turn();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Tab) if !modifiers.ctrl && !modifiers.alt && modifiers.shift => {
self.focus_prev_turn();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::BackTab) if !modifiers.ctrl && !modifiers.alt => {
self.focus_prev_turn();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Enter)
if !modifiers.ctrl && self.focused_turn.is_some() && !self.input_has_focus =>
{
if let Some(idx) = self.focused_turn {
self.toggle_turn_collapsed(idx);
}
ChatControllerEvent::Consumed
}
Key::Char('s') if modifiers.ctrl => self.try_submit(),
Key::Named(NamedKey::Enter) if modifiers.ctrl => self.try_submit(),
Key::Named(NamedKey::Enter)
if self.submit_on_enter && (modifiers.shift || modifiers.alt) =>
{
self.input_insert_char('\n');
self.history_pos = None;
self.saved_input = None;
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Enter) if !self.submit_on_enter && modifiers.alt => {
self.try_submit()
}
Key::Named(NamedKey::Enter) if self.submit_on_enter => self.try_submit(),
Key::Named(NamedKey::Escape) => ChatControllerEvent::Cancelled,
Key::Named(NamedKey::Enter) => {
self.input_insert_char('\n');
self.history_pos = None;
self.saved_input = None;
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Backspace) => {
self.input_backspace();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Delete) => {
self.input_delete();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Left) => {
self.input_move_left();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Right) => {
self.input_move_right();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Home) => {
self.input_move_home();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::End) => {
self.input_move_end();
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::Up) => {
let col_budget = TextInput::content_cols(layout.input.width, backend.char_width());
let rows = wrap_input_rows(&self.input_buf, col_budget);
let (logical_line, logical_col) =
cursor_byte_to_line_col(&self.input_buf, self.input_cursor);
let (row_idx, col) = input_logical_to_visual(&rows, logical_line, logical_col);
if row_idx == 0 {
self.history_prev()
} else {
self.input_cursor =
input_visual_to_byte(&self.input_buf, &rows, row_idx - 1, col);
ChatControllerEvent::Consumed
}
}
Key::Named(NamedKey::Down) => {
let col_budget = TextInput::content_cols(layout.input.width, backend.char_width());
let rows = wrap_input_rows(&self.input_buf, col_budget);
let (logical_line, logical_col) =
cursor_byte_to_line_col(&self.input_buf, self.input_cursor);
let (row_idx, col) = input_logical_to_visual(&rows, logical_line, logical_col);
if row_idx + 1 >= rows.len() {
self.history_next()
} else {
self.input_cursor =
input_visual_to_byte(&self.input_buf, &rows, row_idx + 1, col);
ChatControllerEvent::Consumed
}
}
Key::Named(NamedKey::PageUp) => {
let visible =
Self::transcript_visible_rows_for(backend.line_height(), layout.transcript);
let col_budget =
self.transcript_col_budget(backend.char_width(), layout.transcript);
let total = self.build_transcript_rows(col_budget).len();
let jump = (visible.max(1) - 1).max(1) as isize;
self.scroll_transcript_by(-jump, total, visible);
ChatControllerEvent::Consumed
}
Key::Named(NamedKey::PageDown) => {
let visible =
Self::transcript_visible_rows_for(backend.line_height(), layout.transcript);
let col_budget =
self.transcript_col_budget(backend.char_width(), layout.transcript);
let total = self.build_transcript_rows(col_budget).len();
let jump = (visible.max(1) - 1).max(1) as isize;
self.scroll_transcript_by(jump, total, visible);
ChatControllerEvent::Consumed
}
Key::Char('a') if modifiers.ctrl => {
self.input_move_home();
ChatControllerEvent::Consumed
}
Key::Char('e') if modifiers.ctrl => {
self.input_move_end();
ChatControllerEvent::Consumed
}
Key::Char(c) if !modifiers.ctrl && !modifiers.alt => {
self.focus_input();
self.input_insert_char(*c);
self.history_pos = None;
self.saved_input = None;
ChatControllerEvent::Consumed
}
_ => ChatControllerEvent::KeyPressed {
key: format!("{key:?}"),
modifiers: *modifiers,
},
}
}
fn try_submit(&mut self) -> ChatControllerEvent {
if self.input_buf.is_empty() {
return ChatControllerEvent::Ignored;
}
let text = self.input_buf.clone();
if !text.trim().is_empty() && self.history.last() != Some(&text) {
self.history.push(text.clone());
}
self.history_pos = None;
self.saved_input = None;
ChatControllerEvent::Submit { text }
}
fn history_prev(&mut self) -> ChatControllerEvent {
if self.history.is_empty() {
return ChatControllerEvent::Ignored;
}
match self.history_pos {
None => {
self.saved_input = Some((self.input_buf.clone(), self.input_cursor));
let idx = self.history.len() - 1;
self.history_pos = Some(idx);
let entry = self.history[idx].clone();
self.input_cursor = entry.len();
self.input_buf = entry;
ChatControllerEvent::Consumed
}
Some(pos) if pos > 0 => {
let idx = pos - 1;
self.history_pos = Some(idx);
let entry = self.history[idx].clone();
self.input_cursor = entry.len();
self.input_buf = entry;
ChatControllerEvent::Consumed
}
Some(_) => {
ChatControllerEvent::Consumed
}
}
}
fn history_next(&mut self) -> ChatControllerEvent {
match self.history_pos {
None => ChatControllerEvent::Ignored,
Some(pos) => {
if pos + 1 < self.history.len() {
let idx = pos + 1;
self.history_pos = Some(idx);
let entry = self.history[idx].clone();
self.input_cursor = entry.len();
self.input_buf = entry;
} else {
let (text, cursor) = self.saved_input.take().unwrap_or_default();
self.input_buf = text;
self.input_cursor = cursor;
self.history_pos = None;
}
ChatControllerEvent::Consumed
}
}
}
fn handle_click(
&mut self,
backend: &dyn Backend,
layout: &ChatLayout,
x: f32,
y: f32,
) -> ChatControllerEvent {
if let Some(send_rect) = layout.send {
if send_rect.contains(Point::new(x, y)) {
return self.click_send();
}
}
if let Some(sb_rect) = layout.scrollbar {
if sb_rect.contains(Point::new(x, y)) {
return self.click_scrollbar(backend, layout, sb_rect, y);
}
}
if layout.transcript.contains(Point::new(x, y)) {
let col_budget = self.transcript_col_budget(backend.char_width(), layout.transcript);
let (rows, turn_starts) = self.build_transcript_rows_indexed(col_budget);
let list = MessageList {
id: WidgetId::new(format!("{}-transcript", self.id)),
rows,
scroll_top: self.transcript_scroll_top.get(),
};
let measure = MessageListMeasure::new(backend.line_height());
return match list.hit_test(layout.transcript, measure, x, y) {
MessageListHit::Row(row_idx) => {
let turn_idx = match turn_starts.binary_search(&row_idx) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
};
let row_in_turn = row_idx - turn_starts[turn_idx];
self.set_focused_turn(Some(turn_idx));
ChatControllerEvent::TurnClicked {
turn_idx,
row_in_turn,
}
}
MessageListHit::Empty => ChatControllerEvent::Consumed,
};
}
if layout.input.contains(Point::new(x, y)) {
self.focus_input();
let col_budget = TextInput::content_cols(layout.input.width, backend.char_width());
let rows = wrap_input_rows(&self.input_buf, col_budget);
let ti = self.text_input_from_rows(&rows);
let til = backend.text_input_layout(layout.input, &ti);
let local_y = y - layout.input.y;
for (r, hit) in &til.hit_regions {
if local_y >= r.y && local_y < r.y + r.height {
if let TextInputHit::Line { line_idx } = hit {
let cw = backend.char_width().max(1.0);
let local_x = (x - layout.input.x - r.x).max(0.0);
let col = (local_x / cw).floor() as usize;
self.input_cursor =
input_visual_to_byte(&self.input_buf, &rows, *line_idx, col);
}
break;
}
}
return ChatControllerEvent::Consumed;
}
ChatControllerEvent::Ignored
}
fn click_send(&mut self) -> ChatControllerEvent {
if self.busy {
ChatControllerEvent::StopRequested
} else {
self.try_submit()
}
}
fn click_scrollbar(
&mut self,
backend: &dyn Backend,
layout: &ChatLayout,
sb_rect: Rect,
y: f32,
) -> ChatControllerEvent {
let visible = Self::transcript_visible_rows_for(backend.line_height(), layout.transcript);
let col_budget = self.transcript_col_budget(backend.char_width(), layout.transcript);
let total = self.build_transcript_rows(col_budget).len();
let max_offset = total.saturating_sub(visible);
if max_offset == 0 {
return ChatControllerEvent::Ignored;
}
let track_w = self.scrollbar_track_width(backend.line_height());
let sb = build_scrollbar(
format!("{}-sb", self.id),
sb_rect,
self.transcript_scroll_top.get(),
total,
visible,
false,
track_w.max(1.0),
);
match sb.press_zone(y) {
PressZone::Thumb => {
self.transcript_drag = Some(ThumbDrag::begin(
y,
self.transcript_scroll_top.get() as f32,
sb.travel(),
max_offset as f32,
));
ChatControllerEvent::Consumed
}
PressZone::Before => {
self.scroll_transcript_by(-(visible as isize), total, visible);
ChatControllerEvent::Consumed
}
PressZone::After => {
self.scroll_transcript_by(visible as isize, total, visible);
ChatControllerEvent::Consumed
}
}
}
fn handle_drag(&mut self, y: f32) -> ChatControllerEvent {
let Some(drag) = &self.transcript_drag else {
return ChatControllerEvent::Ignored;
};
let Some(new) = drag.offset_at(y) else {
return ChatControllerEvent::Ignored;
};
let new = new.round() as usize;
let max_offset = drag.max_offset() as usize;
if new == self.transcript_scroll_top.get() {
return ChatControllerEvent::Ignored;
}
self.transcript_scroll_top.set(new);
self.stuck_to_bottom = new >= max_offset;
ChatControllerEvent::Consumed
}
pub fn input_insert_char(&mut self, ch: char) {
let cursor = snap_to_char_boundary(&self.input_buf, self.input_cursor);
self.input_buf.insert(cursor, ch);
self.input_cursor = cursor + ch.len_utf8();
}
pub fn input_insert_str(&mut self, s: &str) {
let cursor = snap_to_char_boundary(&self.input_buf, self.input_cursor);
self.input_buf.insert_str(cursor, s);
self.input_cursor = cursor + s.len();
}
fn input_backspace(&mut self) {
if self.input_cursor == 0 {
return;
}
let prev = prev_char_boundary(&self.input_buf, self.input_cursor);
self.input_buf.replace_range(prev..self.input_cursor, "");
self.input_cursor = prev;
}
fn input_delete(&mut self) {
if self.input_cursor >= self.input_buf.len() {
return;
}
let next = next_char_boundary(&self.input_buf, self.input_cursor);
self.input_buf.replace_range(self.input_cursor..next, "");
}
fn input_move_left(&mut self) {
self.input_cursor = prev_char_boundary(&self.input_buf, self.input_cursor);
}
fn input_move_right(&mut self) {
self.input_cursor = next_char_boundary(&self.input_buf, self.input_cursor);
}
fn input_move_home(&mut self) {
let before = &self.input_buf[..self.input_cursor];
let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
self.input_cursor = line_start;
}
fn input_move_end(&mut self) {
let after = &self.input_buf[self.input_cursor..];
let line_end = after
.find('\n')
.map(|i| self.input_cursor + i)
.unwrap_or(self.input_buf.len());
self.input_cursor = line_end;
}
}
fn build_scrollbar(
id: String,
rect: Rect,
scroll_top: usize,
total_rows: usize,
visible_rows: usize,
is_dragging: bool,
min_thumb: f32,
) -> Scrollbar {
let mut sb = Scrollbar::vertical(
id,
rect,
scroll_top as f32,
total_rows as f32,
visible_rows as f32,
min_thumb,
);
sb.dragging = is_dragging;
sb
}
fn wrap_text(text: &str, col_budget: usize) -> Vec<String> {
crate::text_util::word_wrap(text, col_budget)
}
fn split_spans_by_newline(spans: &[StyledSpan]) -> Vec<Vec<StyledSpan>> {
let mut lines: Vec<Vec<StyledSpan>> = Vec::new();
let mut current: Vec<StyledSpan> = Vec::new();
for span in spans {
if span.text == "\n" {
lines.push(std::mem::take(&mut current));
} else {
current.push(span.clone());
}
}
lines.push(current);
lines
}
fn cursor_byte_to_line_col(text: &str, cursor: usize) -> (usize, usize) {
let before = safe_prefix(text, cursor);
let line = before.bytes().filter(|&b| b == b'\n').count();
let col_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
let col = before[col_start..].chars().count();
(line, col)
}
fn line_col_to_byte(text: &str, target_line: usize, target_col: usize) -> usize {
let mut current_line = 0usize;
let mut line_start = 0usize;
for (i, ch) in text.char_indices() {
if current_line == target_line {
let segment = &text[i..];
let col_byte = segment
.char_indices()
.nth(target_col)
.map(|(b, _)| b)
.unwrap_or_else(|| {
segment.find('\n').unwrap_or(segment.len())
});
return i + col_byte;
}
if ch == '\n' {
current_line += 1;
line_start = i + 1;
}
}
if current_line == target_line {
let segment = &text[line_start..];
let col_byte = segment
.char_indices()
.nth(target_col)
.map(|(b, _)| b)
.unwrap_or_else(|| segment.find('\n').unwrap_or(segment.len()));
return line_start + col_byte;
}
text.len()
}
struct InputRow {
logical_line: usize,
col_offset: usize,
text: String,
}
fn wrap_input_rows(text: &str, col_budget: usize) -> Vec<InputRow> {
let mut rows = Vec::new();
for (logical_line, line) in text.split('\n').enumerate() {
for (col_offset, row_text) in wrap_line_exact(line, col_budget) {
rows.push(InputRow {
logical_line,
col_offset,
text: row_text,
});
}
}
rows
}
fn wrap_line_exact(line: &str, col_budget: usize) -> Vec<(usize, String)> {
let chars: Vec<char> = line.chars().collect();
if col_budget == 0 || chars.len() <= col_budget {
return vec![(0, line.to_string())];
}
let mut rows = Vec::new();
let mut start = 0usize;
while start < chars.len() {
let mut end = (start + col_budget).min(chars.len());
if end < chars.len() {
if let Some(space_rel) = chars[start..end].iter().rposition(|&c| c == ' ') {
let space_idx = start + space_rel;
if space_idx > start {
end = space_idx + 1;
}
}
}
rows.push((start, chars[start..end].iter().collect()));
start = end;
}
rows
}
fn input_logical_to_visual(rows: &[InputRow], line: usize, col: usize) -> (usize, usize) {
let mut last_for_line: Option<usize> = None;
for (i, r) in rows.iter().enumerate() {
if r.logical_line != line {
continue;
}
last_for_line = Some(i);
let len = r.text.chars().count();
if col >= r.col_offset && col < r.col_offset + len {
return (i, col - r.col_offset);
}
}
match last_for_line {
Some(i) => {
let r = &rows[i];
let len = r.text.chars().count();
(i, col.saturating_sub(r.col_offset).min(len))
}
None => (0, 0),
}
}
fn input_visual_to_byte(text: &str, rows: &[InputRow], row_idx: usize, col: usize) -> usize {
let Some(last) = rows.len().checked_sub(1) else {
return 0;
};
let r = &rows[row_idx.min(last)];
let len = r.text.chars().count();
line_col_to_byte(text, r.logical_line, r.col_offset + col.min(len))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::RecordingBackend;
fn make_rect() -> Rect {
Rect::new(0.0, 0.0, 80.0, 24.0)
}
fn make_turn(role: ChatRole, text: &str) -> ChatTurn {
ChatTurn {
role,
text: StyledText::plain(text),
timestamp_unix: None,
line_scales: Vec::new(),
}
}
#[test]
fn new_starts_empty() {
let cc = ChatController::new("chat");
assert_eq!(cc.input_text(), "");
assert_eq!(cc.transcript_scroll_top(), 0);
assert!(cc.input_has_focus());
}
#[test]
fn input_insert_char_appends() {
let mut cc = ChatController::new("c");
cc.input_insert_char('h');
cc.input_insert_char('i');
assert_eq!(cc.input_text(), "hi");
}
#[test]
fn input_insert_str_paste() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello\nworld");
assert_eq!(cc.input_text(), "hello\nworld");
}
#[test]
fn clear_input_resets_state() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello\nworld");
cc.clear_input();
assert_eq!(cc.input_text(), "");
assert_eq!(cc.input_cursor, 0);
}
#[test]
fn ctrl_enter_submits_and_emits_event() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
}
#[test]
fn alt_enter_submits_and_emits_event() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
alt: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
}
#[test]
fn ctrl_enter_on_empty_input_ignored() {
let mut cc = ChatController::new("c");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Ignored);
}
#[test]
fn ctrl_s_submits_and_emits_event() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Char('s'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
}
#[test]
fn ctrl_s_on_empty_input_ignored() {
let mut cc = ChatController::new("c");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Char('s'),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Ignored);
}
#[test]
fn submit_adds_to_history() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
};
cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(cc.history.len(), 1);
assert_eq!(cc.history[0], "hello");
}
fn ctrl_key(ch: char) -> UiEvent {
UiEvent::KeyPressed {
key: Key::Char(ch),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
}
}
#[test]
fn ctrl_a_moves_cursor_to_line_start() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
assert_eq!(cc.input_cursor, 5);
let ev = cc.handle(&ctrl_key('a'), &RecordingBackend::new(), make_rect());
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_cursor, 0);
}
#[test]
fn ctrl_e_moves_cursor_to_line_end() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
cc.handle(&ctrl_key('a'), &RecordingBackend::new(), make_rect());
assert_eq!(cc.input_cursor, 0);
let ev = cc.handle(&ctrl_key('e'), &RecordingBackend::new(), make_rect());
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_cursor, 5);
}
#[test]
fn ctrl_a_e_respect_current_line_in_multiline() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello\nworld");
assert_eq!(cc.input_cursor, 11);
cc.handle(&ctrl_key('a'), &RecordingBackend::new(), make_rect());
assert_eq!(cc.input_cursor, 6);
cc.handle(&ctrl_key('e'), &RecordingBackend::new(), make_rect());
assert_eq!(cc.input_cursor, 11);
}
#[test]
fn chat_turn_serde_roundtrip() {
let turn = ChatTurn {
role: ChatRole::Assistant,
text: StyledText::colored("hi there", Color::rgb(180, 230, 180)),
timestamp_unix: Some(1_700_000_000.0),
line_scales: Vec::new(),
};
let json = serde_json::to_string(&turn).expect("serialize ChatTurn");
let decoded: ChatTurn = serde_json::from_str(&json).expect("deserialize ChatTurn");
assert_eq!(decoded.role, turn.role);
assert_eq!(decoded.text, turn.text);
assert_eq!(decoded.timestamp_unix, turn.timestamp_unix);
}
#[test]
fn chat_role_serde_roundtrip() {
for role in [ChatRole::User, ChatRole::Assistant, ChatRole::System] {
let json = serde_json::to_string(&role).expect("serialize ChatRole");
let decoded: ChatRole = serde_json::from_str(&json).expect("deserialize ChatRole");
assert_eq!(decoded, role);
}
}
#[test]
fn esc_emits_cancelled() {
let mut cc = ChatController::new("c");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Cancelled);
}
#[test]
fn plain_enter_inserts_newline() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "hello\n");
}
#[test]
fn submit_on_enter_defaults_to_false() {
let cc = ChatController::new("c");
assert!(!cc.submit_on_enter());
}
#[test]
fn submit_on_enter_plain_enter_submits() {
let mut cc = ChatController::new("c");
cc.set_submit_on_enter(true);
cc.input_insert_str("hello");
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), make_rect());
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
}
#[test]
fn submit_on_enter_shift_enter_inserts_newline() {
let mut cc = ChatController::new("c");
cc.set_submit_on_enter(true);
cc.input_insert_str("hello");
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
shift: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), make_rect());
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "hello\n");
}
#[test]
fn submit_on_enter_alt_enter_inserts_newline_fallback() {
let mut cc = ChatController::new("c");
cc.set_submit_on_enter(true);
cc.input_insert_str("hello");
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
alt: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), make_rect());
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "hello\n");
}
#[test]
fn submit_on_enter_ctrl_enter_still_submits() {
let mut cc = ChatController::new("c");
cc.set_submit_on_enter(true);
cc.input_insert_str("hello");
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), make_rect());
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
}
#[test]
fn submit_on_enter_plain_enter_on_empty_input_ignored() {
let mut cc = ChatController::new("c");
cc.set_submit_on_enter(true);
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), make_rect());
assert_eq!(ev, ChatControllerEvent::Ignored);
}
#[test]
fn up_on_empty_history_returns_ignored() {
let mut cc = ChatController::new("c");
let ev = cc.history_prev();
assert_eq!(ev, ChatControllerEvent::Ignored);
}
#[test]
fn up_enters_history_with_newest_entry() {
let mut cc = ChatController::new("c");
cc.history.push("first".into());
cc.history.push("second".into());
let ev = cc.history_prev();
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "second");
assert_eq!(cc.history_pos, Some(1));
}
#[test]
fn up_twice_goes_to_older_entry() {
let mut cc = ChatController::new("c");
cc.history.push("first".into());
cc.history.push("second".into());
cc.history_prev(); let ev = cc.history_prev();
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "first");
assert_eq!(cc.history_pos, Some(0));
}
#[test]
fn up_at_oldest_entry_stays() {
let mut cc = ChatController::new("c");
cc.history.push("only".into());
cc.history_prev(); let ev = cc.history_prev(); assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "only"); }
#[test]
fn down_after_history_nav_restores_saved_input() {
let mut cc = ChatController::new("c");
cc.input_insert_str("draft");
cc.history.push("first".into());
cc.history.push("second".into());
cc.history_prev(); cc.history_prev(); cc.history_next(); let ev = cc.history_next(); assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "draft");
assert_eq!(cc.history_pos, None);
}
#[test]
fn down_when_not_navigating_returns_ignored() {
let mut cc = ChatController::new("c");
let ev = cc.history_next();
assert_eq!(ev, ChatControllerEvent::Ignored);
}
#[test]
fn up_key_on_line_0_enters_history() {
let mut cc = ChatController::new("c");
cc.history.push("prev".into());
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Up),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "prev");
}
#[test]
fn up_key_on_line_1_moves_cursor_up() {
let mut cc = ChatController::new("c");
cc.input_insert_str("line0\nline1");
let (line, _) = cursor_byte_to_line_col(cc.input_text(), cc.input_cursor);
assert_eq!(line, 1);
let rect = make_rect();
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Up),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
let (new_line, _) = cursor_byte_to_line_col(cc.input_text(), cc.input_cursor);
assert_eq!(new_line, 0);
assert_eq!(cc.history_pos, None);
}
#[test]
fn scroll_transcript_clamps_to_bounds() {
let mut cc = ChatController::new("c");
cc.scroll_transcript_by(100, 10, 5); assert_eq!(cc.transcript_scroll_top(), 5);
cc.scroll_transcript_by(-100, 10, 5);
assert_eq!(cc.transcript_scroll_top(), 0);
}
#[test]
fn page_up_scrolls_transcript() {
let mut cc = ChatController::new("c");
cc.set_transcript_scroll_top(10);
cc.scroll_transcript_by(-5, 20, 5);
assert_eq!(cc.transcript_scroll_top(), 5);
}
#[test]
fn wheel_scroll_consumes_the_delta_magnitude_not_just_its_sign() {
let mut cc = ChatController::new("c");
cc.set_transcript(
(0..60)
.map(|i| make_turn(ChatRole::User, &format!("line {i}")))
.collect(),
);
cc.set_transcript_scroll_top(40);
let ev = UiEvent::Scroll {
widget: None,
delta: crate::ScrollDelta::new(0.0, 2.0),
position: crate::Point::new(5.0, 5.0),
};
assert_eq!(
cc.handle(&ev, &RecordingBackend::new(), make_rect()),
ChatControllerEvent::Consumed
);
assert_eq!(
cc.transcript_scroll_top(),
40 - 2 * TRANSCRIPT_ROWS_PER_NOTCH as usize,
"two notches move two notches' worth of rows"
);
}
#[test]
fn wheel_scroll_still_moves_for_a_sub_notch_gesture() {
let mut cc = ChatController::new("c");
cc.set_transcript(
(0..60)
.map(|i| make_turn(ChatRole::User, &format!("line {i}")))
.collect(),
);
cc.set_transcript_scroll_top(40);
let ev = UiEvent::Scroll {
widget: None,
delta: crate::ScrollDelta::new(0.0, 0.1),
position: crate::Point::new(5.0, 5.0),
};
cc.handle(&ev, &RecordingBackend::new(), make_rect());
assert!(
cc.transcript_scroll_top() < 40,
"a sub-notch gesture still scrolls at least one row"
);
}
#[test]
fn new_controller_is_stuck_to_bottom() {
let cc = ChatController::new("c");
assert!(
cc.stuck_to_bottom,
"new controller must start stuck_to_bottom=true"
);
}
#[test]
fn follow_tail_advances_scroll_top_on_new_content() {
let mut cc = ChatController::new("c");
for i in 0..7 {
cc.push_turn(ChatRole::User, StyledText::plain(&format!("m{i}")));
}
assert_eq!(cc.transcript_scroll_top(), 0, "scroll_top must start at 0");
cc.render(&mut RecordingBackend::new(), make_rect());
assert!(
cc.transcript_scroll_top() > 0,
"follow-tail must advance scroll_top when content exceeds the visible window; \
scroll_top={}",
cc.transcript_scroll_top()
);
}
#[test]
fn scrolled_up_position_preserved_across_new_content() {
let mut cc = ChatController::new("c");
cc.scroll_transcript_by(-5, 20, 5); cc.scroll_transcript_by(3, 20, 5); assert!(
!cc.stuck_to_bottom,
"scroll_transcript_by up must disengage follow-tail"
);
assert_eq!(cc.transcript_scroll_top(), 3);
cc.push_turn(ChatRole::Assistant, StyledText::plain("new reply"));
cc.render(&mut RecordingBackend::new(), make_rect());
assert_eq!(
cc.transcript_scroll_top(),
3,
"scroll position must be preserved when follow-tail is disengaged; \
scroll_top={}",
cc.transcript_scroll_top()
);
}
#[test]
fn scroll_to_bottom_reengages_follow_tail() {
let mut cc = ChatController::new("c");
cc.scroll_transcript_by(-5, 20, 5); cc.scroll_transcript_by(3, 20, 5); assert!(!cc.stuck_to_bottom);
cc.scroll_transcript_by(100, 20, 5); assert!(
cc.stuck_to_bottom,
"scrolling back to the last page must re-engage follow-tail"
);
}
#[test]
fn push_turn_appends_turn_to_transcript() {
let mut cc = ChatController::new("c");
cc.push_turn(ChatRole::User, StyledText::plain("hello"));
assert_eq!(cc.transcript.len(), 1);
assert_eq!(cc.transcript[0].role, ChatRole::User);
let text: String = cc.transcript[0]
.text
.spans
.iter()
.map(|s| s.text.as_str())
.collect();
assert_eq!(text, "hello");
}
#[test]
fn push_turn_multiple_turns_accumulate() {
let mut cc = ChatController::new("c");
cc.push_turn(ChatRole::User, StyledText::plain("first"));
cc.push_turn(ChatRole::Assistant, StyledText::plain("second"));
assert_eq!(cc.transcript.len(), 2);
assert_eq!(cc.transcript[0].role, ChatRole::User);
assert_eq!(cc.transcript[1].role, ChatRole::Assistant);
}
#[test]
fn push_turn_markdown_joins_lines_with_newline() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "line one\nline two", &theme);
assert_eq!(cc.transcript.len(), 1);
assert_eq!(cc.transcript[0].role, ChatRole::Assistant);
let text: String = cc.transcript[0]
.text
.spans
.iter()
.map(|s| s.text.as_str())
.collect();
assert!(
text.contains("line one"),
"first line must be present; got: {text:?}"
);
assert!(
text.contains("line two"),
"second line must be present; got: {text:?}"
);
assert!(
text.contains('\n'),
"lines must be joined with a newline separator; got: {text:?}"
);
}
#[test]
fn push_turn_markdown_structural_glyphs_survive_in_plain_text() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "- item one\n- item two", &theme);
let text: String = cc.transcript[0]
.text
.spans
.iter()
.map(|s| s.text.as_str())
.collect();
assert!(
text.contains('\u{2022}'),
"bullet glyph must survive as a structural text cue; got: {text:?}"
);
assert!(
text.contains("item one"),
"first item content must be present; got: {text:?}"
);
}
#[test]
fn push_turn_markdown_empty_string_produces_one_turn_no_panic() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::System, "", &theme);
assert_eq!(cc.transcript.len(), 1);
}
#[test]
fn build_transcript_rows_includes_role_header() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "hi")]);
let rows = cc.build_transcript_rows(80);
assert!(rows.len() >= 3);
assert_eq!(rows[0].text, "You");
assert_eq!(rows[1].text, "hi");
}
#[test]
fn build_transcript_rows_wraps_long_line() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::Assistant, "1234567890")]);
let rows = cc.build_transcript_rows(7);
let content: Vec<_> = rows
.iter()
.filter(|r| !r.text.is_empty() && r.indent > 0.0)
.collect();
assert_eq!(content.len(), 2);
}
#[test]
fn push_turn_markdown_stores_line_scales() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "# Heading\nbody", &theme);
let turn = &cc.transcript[0];
assert_eq!(
turn.line_scales.len(),
2,
"line_scales must have one entry per markdown line; got {:?}",
turn.line_scales
);
assert!(
(turn.line_scales[0] - 2.0).abs() < f32::EPSILON,
"H1 line_scale must be 2.0; got {}",
turn.line_scales[0]
);
assert!(
(turn.line_scales[1] - 1.0).abs() < f32::EPSILON,
"body line_scale must be 1.0; got {}",
turn.line_scales[1]
);
}
#[test]
fn push_turn_has_empty_line_scales() {
let mut cc = ChatController::new("c");
cc.push_turn(ChatRole::User, StyledText::plain("plain text"));
assert!(
cc.transcript[0].line_scales.is_empty(),
"push_turn must leave line_scales empty"
);
}
#[test]
fn build_transcript_rows_flat_path_unchanged() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "hello")]);
let rows = cc.build_transcript_rows(80);
let content_row = rows.iter().find(|r| r.text == "hello").unwrap();
assert!(
content_row.spans.is_empty(),
"flat-path rows must have empty spans; got {:?}",
content_row.spans
);
assert!(
(content_row.scale - 1.0).abs() < f32::EPSILON,
"flat-path rows must have scale 1.0"
);
}
#[test]
fn build_transcript_rows_styled_path_produces_spans() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "**bold** text", &theme);
let rows = cc.build_transcript_rows(80);
let content_rows: Vec<_> = rows.iter().filter(|r| r.indent > 0.0).collect();
assert!(
!content_rows.is_empty(),
"expected at least one content row"
);
let first = &content_rows[0];
assert!(
!first.spans.is_empty(),
"markdown turns must produce rows with non-empty spans; got empty"
);
let has_bold = first.spans.iter().any(|s| s.bold);
assert!(
has_bold,
"expected a bold span in the row; spans: {:?}",
first.spans
);
}
#[test]
fn build_transcript_rows_heading_carries_scale() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "# Title", &theme);
let rows = cc.build_transcript_rows(80);
let content_rows: Vec<_> = rows.iter().filter(|r| r.indent > 0.0).collect();
assert!(!content_rows.is_empty(), "expected content rows");
let heading_row = &content_rows[0];
assert!(
(heading_row.scale - 2.0).abs() < f32::EPSILON,
"H1 content row must have scale 2.0; got {}",
heading_row.scale
);
}
#[test]
fn build_transcript_rows_plain_role_header_has_no_spans() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::User, "**bold**", &theme);
let rows = cc.build_transcript_rows(80);
let header = rows.iter().find(|r| r.text == "You").unwrap();
assert!(
header.spans.is_empty(),
"role header must be a flat row; spans: {:?}",
header.spans
);
}
#[test]
fn build_transcript_rows_styled_wraps_across_budget() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "1234567890", &theme);
let rows = cc.build_transcript_rows(7);
let content_rows: Vec<_> = rows.iter().filter(|r| r.indent > 0.0).collect();
assert_eq!(
content_rows.len(),
2,
"10-char styled line at budget=5 must wrap to 2 rows; got {}",
content_rows.len()
);
}
#[test]
fn build_transcript_rows_styled_wraps_cjk_at_display_width() {
let mut cc = ChatController::new("c");
let theme = crate::Theme::default();
cc.push_turn_markdown(ChatRole::Assistant, "**中文中文中文**", &theme);
let rows = cc.build_transcript_rows(8); let content_rows: Vec<_> = rows.iter().filter(|r| r.indent > 0.0).collect();
assert_eq!(
content_rows.len(),
2,
"6 CJK chars (12 cells) at a 6-cell content budget must wrap to \
2 rows, not 1; got {}",
content_rows.len()
);
for row in &content_rows {
assert!(
crate::text_util::display_width(&row.text) <= 6,
"row {:?} exceeds the 6-cell budget",
row.text
);
}
}
#[test]
fn split_spans_by_newline_single_line() {
let spans = vec![StyledSpan::plain("hello")];
let lines = split_spans_by_newline(&spans);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].len(), 1);
assert_eq!(lines[0][0].text, "hello");
}
#[test]
fn split_spans_by_newline_two_lines() {
let spans = vec![
StyledSpan::plain("line1"),
StyledSpan::plain("\n"),
StyledSpan::plain("line2"),
];
let lines = split_spans_by_newline(&spans);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0][0].text, "line1");
assert_eq!(lines[1][0].text, "line2");
}
#[test]
fn split_spans_by_newline_empty_produces_one_empty_line() {
let lines = split_spans_by_newline(&[]);
assert_eq!(lines.len(), 1);
assert!(lines[0].is_empty());
}
#[test]
fn wrap_text_short_line_is_not_split() {
let v = wrap_text("hello", 80);
assert_eq!(v, vec!["hello".to_string()]);
}
#[test]
fn wrap_text_splits_at_budget() {
let v = wrap_text("abcde", 3);
assert_eq!(v, vec!["abc", "de"]);
}
#[test]
fn wrap_text_empty_returns_one_empty_row() {
let v = wrap_text("", 80);
assert_eq!(v, vec![String::new()]);
}
#[test]
fn wrap_line_exact_short_line_not_split() {
assert_eq!(wrap_line_exact("hi", 10), vec![(0, "hi".to_string())]);
}
#[test]
fn wrap_line_exact_breaks_at_space() {
assert_eq!(
wrap_line_exact("hello world", 7),
vec![(0, "hello ".to_string()), (6, "world".to_string())]
);
}
#[test]
fn wrap_line_exact_hard_breaks_a_single_long_word() {
assert_eq!(
wrap_line_exact("abcdefgh", 3),
vec![
(0, "abc".to_string()),
(3, "def".to_string()),
(6, "gh".to_string())
]
);
}
#[test]
fn wrap_line_exact_reconstructs_original_text() {
let line = "the quick brown fox jumps over the lazy dog";
for budget in 1..line.chars().count() + 2 {
let rows = wrap_line_exact(line, budget);
let joined: String = rows.iter().map(|(_, t)| t.as_str()).collect();
assert_eq!(joined, line, "budget={budget}");
}
}
#[test]
fn wrap_input_rows_one_row_per_short_logical_line() {
let rows = wrap_input_rows("ab\ncd", 80);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].logical_line, 0);
assert_eq!(rows[1].logical_line, 1);
}
#[test]
fn wrap_input_rows_empty_buffer_yields_one_empty_row() {
let rows = wrap_input_rows("", 80);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].text, "");
}
#[test]
fn logical_to_visual_round_trips_through_visual_to_byte() {
let text = "hello world\nsecond line";
let rows = wrap_input_rows(text, 6); assert!(rows.len() > 2, "expected wrapping to produce >2 rows");
let (line, col) = cursor_byte_to_line_col(text, 8);
assert_eq!((line, col), (0, 8));
let (row_idx, visual_col) = input_logical_to_visual(&rows, line, col);
let byte = input_visual_to_byte(text, &rows, row_idx, visual_col);
assert_eq!(byte, 8, "round trip through visual space must be lossless");
}
#[test]
fn logical_to_visual_end_of_line_lands_on_last_visual_row() {
let text = "hello world";
let rows = wrap_input_rows(text, 6); let last_row = rows.len() - 1;
let (line, col) = cursor_byte_to_line_col(text, text.len());
let (row_idx, _) = input_logical_to_visual(&rows, line, col);
assert_eq!(row_idx, last_row);
}
#[test]
fn up_within_wrapped_paragraph_moves_cursor_not_history() {
let mut cc = ChatController::new("c");
cc.set_input_height_range(1, 8);
cc.input_insert_str("hello world this wraps");
cc.history.push("prev".into());
let rect = Rect::new(0.0, 0.0, 12.0, 24.0);
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Up),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.history_pos, None);
assert_eq!(cc.input_text(), "hello world this wraps");
}
#[test]
fn up_on_first_visual_row_of_wrapped_paragraph_enters_history() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello world this wraps");
cc.input_cursor = 3; cc.history.push("prev".into());
let rect = Rect::new(0.0, 0.0, 12.0, 24.0);
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Up),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.input_text(), "prev", "should have entered history");
}
#[test]
fn down_on_last_visual_row_of_wrapped_paragraph_enters_history_next() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello world this wraps");
cc.history.push("first".into());
cc.history.push("second".into());
let rect = Rect::new(0.0, 0.0, 12.0, 24.0);
cc.history_prev();
assert_eq!(cc.input_text(), "second");
let event = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Down),
modifiers: Modifiers::default(),
repeat: false,
};
let ev = cc.handle(&event, &RecordingBackend::new(), rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert_eq!(cc.history_pos, None);
assert_eq!(cc.input_text(), "hello world this wraps");
}
#[test]
fn byte_to_line_col_single_line() {
assert_eq!(cursor_byte_to_line_col("hello", 3), (0, 3));
}
#[test]
fn byte_to_line_col_multiline() {
assert_eq!(cursor_byte_to_line_col("ab\ncd", 4), (1, 1));
}
#[test]
fn byte_to_line_col_multibyte_mid_char_offset_does_not_panic() {
let text = "héllo";
assert!(!text.is_char_boundary(2)); let (line, col) = cursor_byte_to_line_col(text, 2);
assert_eq!((line, col), (0, 1));
}
#[test]
fn line_col_to_byte_first_line() {
assert_eq!(line_col_to_byte("hello", 0, 3), 3);
}
#[test]
fn line_col_to_byte_second_line() {
assert_eq!(line_col_to_byte("ab\ncd", 1, 1), 4);
}
#[test]
fn line_col_to_byte_clamps_to_end_of_line() {
assert_eq!(line_col_to_byte("ab\ncd", 0, 99), 2);
}
#[test]
fn line_col_to_byte_nonexistent_line_clamps_to_end() {
assert_eq!(line_col_to_byte("ab", 5, 0), 2);
}
#[test]
fn backspace_deletes_previous_char() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hi");
cc.input_backspace();
assert_eq!(cc.input_text(), "h");
assert_eq!(cc.input_cursor, 1);
}
#[test]
fn backspace_at_start_is_noop() {
let mut cc = ChatController::new("c");
cc.input_insert_str("x");
cc.input_cursor = 0;
cc.input_backspace();
assert_eq!(cc.input_text(), "x");
}
#[test]
fn delete_removes_char_at_cursor() {
let mut cc = ChatController::new("c");
cc.input_insert_str("ab");
cc.input_cursor = 0;
cc.input_delete();
assert_eq!(cc.input_text(), "b");
}
#[test]
fn layout_status_at_top() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert_eq!(layout.status.y, 0.0);
assert_eq!(layout.status.height, 1.0); }
#[test]
fn layout_input_at_bottom() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
let expected_input_y = make_rect().height - (1.0 * 1.0 + 2.0);
assert_eq!(layout.input.y, expected_input_y);
}
#[test]
fn layout_input_grows_with_wrapped_row_count() {
let mut cc = ChatController::new("c");
cc.input_insert_str("line one\nline two\nline three");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
let expected_input_y = make_rect().height - (3.0 * 1.0 + 2.0);
assert_eq!(layout.input.y, expected_input_y);
}
#[test]
fn layout_input_clamps_to_max_rows() {
let mut cc = ChatController::new("c");
for _ in 0..20 {
cc.input_insert_char('\n');
}
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
let expected_input_y = make_rect().height - (8.0 * 1.0 + 2.0);
assert_eq!(layout.input.y, expected_input_y);
}
#[test]
fn set_input_height_range_overrides_default_clamp() {
let mut cc = ChatController::new("c");
cc.set_input_height_range(2, 3);
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
let expected_input_y = make_rect().height - (2.0 * 1.0 + 2.0);
assert_eq!(layout.input.y, expected_input_y);
}
#[test]
fn set_input_height_range_floors_max_to_min() {
let mut cc = ChatController::new("c");
cc.set_input_height_range(5, 2); assert_eq!(cc.input_min_rows, 5);
assert_eq!(cc.input_max_rows, 5);
}
#[test]
fn layout_no_scrollbar_when_transcript_empty() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.scrollbar.is_none());
}
#[test]
fn layout_scrollbar_present_when_transcript_nonempty() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "hello")]);
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.scrollbar.is_some());
assert!(layout.transcript.width < make_rect().width);
}
#[test]
fn layout_spinner_present_when_busy() {
let mut cc = ChatController::new("c");
cc.set_busy(true);
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.spinner.is_some());
}
#[test]
fn layout_spinner_absent_when_not_busy() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.spinner.is_none());
}
#[test]
fn layout_send_segment_present_when_row_wide_enough() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.send.is_some());
assert!(layout.input.width < make_rect().width);
}
#[test]
fn layout_send_segment_absent_when_row_too_narrow() {
let cc = ChatController::new("c");
let narrow = Rect::new(0.0, 0.0, 10.0, 24.0);
let layout = cc.compute_layout(&RecordingBackend::new(), narrow);
assert!(layout.send.is_none());
assert_eq!(layout.input.width, narrow.width);
}
#[test]
fn layout_send_segment_spans_full_input_height() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
let send = layout.send.expect("send segment present");
assert_eq!(send.y, layout.input.y);
assert_eq!(send.height, layout.input.height);
}
#[test]
fn layout_hint_absent_by_default() {
let cc = ChatController::new("c");
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.hint.is_none());
}
#[test]
fn layout_hint_present_when_set() {
let mut cc = ChatController::new("c");
cc.set_hint(Some(StyledText::plain(
"Enter to send, Shift+Enter for newline",
)));
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
let hint = layout.hint.expect("hint row present");
assert_eq!(hint.y + hint.height, layout.input.y);
}
#[test]
fn layout_hint_cleared_by_set_hint_none() {
let mut cc = ChatController::new("c");
cc.set_hint(Some(StyledText::plain("hint")));
cc.set_hint(None);
let layout = cc.compute_layout(&RecordingBackend::new(), make_rect());
assert!(layout.hint.is_none());
}
fn click_at(cc: &mut ChatController, rect: Rect, x: f32, y: f32) -> ChatControllerEvent {
let event = crate::mouse_down(MouseButton::Left, x, y, Modifiers::default());
cc.handle(&event, &RecordingBackend::new(), rect)
}
#[test]
fn click_send_segment_while_idle_submits() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
let rect = make_rect();
let send = cc
.compute_layout(&RecordingBackend::new(), rect)
.send
.expect("send segment present");
let ev = click_at(&mut cc, rect, send.x + 1.0, send.y + 1.0);
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
}
#[test]
fn click_send_segment_while_idle_and_empty_is_ignored() {
let mut cc = ChatController::new("c");
let rect = make_rect();
let send = cc
.compute_layout(&RecordingBackend::new(), rect)
.send
.expect("send segment present");
let ev = click_at(&mut cc, rect, send.x + 1.0, send.y + 1.0);
assert_eq!(ev, ChatControllerEvent::Ignored);
}
#[test]
fn click_send_segment_while_busy_emits_stop_requested() {
let mut cc = ChatController::new("c");
cc.input_insert_str("hello");
cc.set_busy(true);
let rect = make_rect();
let send = cc
.compute_layout(&RecordingBackend::new(), rect)
.send
.expect("send segment present");
let ev = click_at(&mut cc, rect, send.x + 1.0, send.y + 1.0);
assert_eq!(ev, ChatControllerEvent::StopRequested);
assert_eq!(cc.input_text(), "hello");
}
#[test]
fn turn_summary_line_defaults_to_first_nonblank_line() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(
ChatRole::Assistant,
"\n \nreal content\nmore",
)]);
assert_eq!(cc.turn_summary_line(0), "real content");
}
#[test]
fn turn_summary_line_prefers_explicit_summary() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::Assistant, "full body text")]);
cc.set_turn_summary(0, Some("custom summary".to_string()));
assert_eq!(cc.turn_summary_line(0), "custom summary");
}
#[test]
fn turn_summary_line_empty_for_turn_with_no_content() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::Assistant, "")]);
assert_eq!(cc.turn_summary_line(0), "");
}
#[test]
fn turn_summary_line_empty_for_out_of_range_turn_idx() {
let cc = ChatController::new("c");
assert_eq!(cc.turn_summary_line(0), "");
}
#[test]
fn set_turn_summary_none_clears_the_override() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::Assistant, "fallback line")]);
cc.set_turn_summary(0, Some("override".to_string()));
assert_eq!(cc.turn_summary_line(0), "override");
cc.set_turn_summary(0, None);
assert_eq!(cc.turn_summary_line(0), "fallback line");
}
#[test]
fn new_turns_start_expanded() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "hi")]);
assert!(!cc.is_turn_collapsed(0));
}
#[test]
fn toggle_turn_collapsed_flips_state() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "hi")]);
cc.toggle_turn_collapsed(0);
assert!(cc.is_turn_collapsed(0));
cc.toggle_turn_collapsed(0);
assert!(!cc.is_turn_collapsed(0));
}
#[test]
fn set_turn_collapsed_out_of_range_is_a_noop() {
let mut cc = ChatController::new("c");
cc.set_turn_collapsed(5, true);
cc.toggle_turn_collapsed(5);
assert!(!cc.is_turn_collapsed(5));
}
#[test]
fn build_transcript_rows_collapsed_turn_emits_header_and_summary_only() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(
ChatRole::Assistant,
"first line\nsecond line",
)]);
cc.set_turn_collapsed(0, true);
let rows = cc.build_transcript_rows(80);
assert_eq!(rows.len(), 3);
assert!(
rows[0].text.contains("AI"),
"header row should still show the role label: {:?}",
rows[0].text
);
assert_eq!(rows[1].text, "first line");
}
#[test]
fn build_transcript_rows_expanded_turn_unchanged_by_collapsed_field() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "hello")]);
let rows = cc.build_transcript_rows(80);
assert_eq!(rows[0].text, "You");
assert_eq!(rows[1].text, "hello");
assert_eq!(rows[1].indent, 2.0);
assert_eq!(rows.len(), 3);
}
#[test]
fn transcript_click_on_first_turn_header_returns_turn_clicked() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![
make_turn(ChatRole::User, "Hello"),
make_turn(ChatRole::Assistant, "Hi"),
]);
let rect = make_rect();
let t = cc.compute_layout(&RecordingBackend::new(), rect).transcript;
let ev = click_at(&mut cc, rect, t.x + 1.0, t.y + 0.5);
assert_eq!(
ev,
ChatControllerEvent::TurnClicked {
turn_idx: 0,
row_in_turn: 0
}
);
assert_eq!(cc.focused_turn(), Some(0));
}
#[test]
fn transcript_click_on_second_turn_resolves_its_turn_idx() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![
make_turn(ChatRole::User, "Hello"),
make_turn(ChatRole::Assistant, "Hi"),
]);
let rect = make_rect();
let t = cc.compute_layout(&RecordingBackend::new(), rect).transcript;
let ev = click_at(&mut cc, rect, t.x + 1.0, t.y + 3.5);
assert_eq!(
ev,
ChatControllerEvent::TurnClicked {
turn_idx: 1,
row_in_turn: 0
}
);
assert_eq!(cc.focused_turn(), Some(1));
}
#[test]
fn click_then_enter_toggles_the_clicked_turn_not_a_stale_focus() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![
make_turn(ChatRole::User, "Hello"),
make_turn(ChatRole::Assistant, "Hi"),
]);
let rect = make_rect();
let backend = RecordingBackend::new();
let t = cc.compute_layout(&backend, rect).transcript;
click_at(&mut cc, rect, t.x + 1.0, t.y + 3.5);
assert_eq!(cc.focused_turn(), Some(1));
assert!(!cc.is_turn_collapsed(1));
let enter = key_event(Key::Named(NamedKey::Enter), Modifiers::default());
let ev = cc.handle(&enter, &backend, rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert!(cc.is_turn_collapsed(1));
assert!(!cc.is_turn_collapsed(0));
}
#[test]
fn transcript_click_below_last_row_is_consumed_not_turn_clicked() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "Hi")]);
let rect = make_rect();
let t = cc.compute_layout(&RecordingBackend::new(), rect).transcript;
let ev = click_at(&mut cc, rect, t.x + 1.0, t.y + t.height - 0.5);
assert_eq!(ev, ChatControllerEvent::Consumed);
}
fn key_event(key: Key, modifiers: Modifiers) -> UiEvent {
UiEvent::KeyPressed {
key,
modifiers,
repeat: false,
}
}
#[test]
fn tab_cycles_focused_turn_then_back_to_input() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![
make_turn(ChatRole::User, "one"),
make_turn(ChatRole::Assistant, "two"),
]);
let rect = make_rect();
let backend = RecordingBackend::new();
let tab = key_event(Key::Named(NamedKey::Tab), Modifiers::default());
assert_eq!(cc.focused_turn(), None);
assert!(cc.input_has_focus());
cc.handle(&tab, &backend, rect);
assert_eq!(cc.focused_turn(), Some(0));
assert!(!cc.input_has_focus());
cc.handle(&tab, &backend, rect);
assert_eq!(cc.focused_turn(), Some(1));
cc.handle(&tab, &backend, rect);
assert_eq!(cc.focused_turn(), None);
assert!(cc.input_has_focus());
}
#[test]
fn shift_tab_cycles_focused_turn_backward() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![
make_turn(ChatRole::User, "one"),
make_turn(ChatRole::Assistant, "two"),
]);
let rect = make_rect();
let backend = RecordingBackend::new();
let shift_tab = key_event(
Key::Named(NamedKey::Tab),
Modifiers {
shift: true,
..Default::default()
},
);
cc.handle(&shift_tab, &backend, rect);
assert_eq!(cc.focused_turn(), Some(1));
cc.handle(&shift_tab, &backend, rect);
assert_eq!(cc.focused_turn(), Some(0));
cc.handle(&shift_tab, &backend, rect);
assert_eq!(cc.focused_turn(), None);
}
#[test]
fn tab_with_empty_transcript_is_a_noop() {
let mut cc = ChatController::new("c");
let rect = make_rect();
let backend = RecordingBackend::new();
let tab = key_event(Key::Named(NamedKey::Tab), Modifiers::default());
cc.handle(&tab, &backend, rect);
assert_eq!(cc.focused_turn(), None);
assert!(cc.input_has_focus());
}
#[test]
fn enter_while_turn_focused_toggles_collapsed_instead_of_submitting() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::Assistant, "body")]);
cc.set_focused_turn(Some(0));
let rect = make_rect();
let backend = RecordingBackend::new();
let enter = key_event(Key::Named(NamedKey::Enter), Modifiers::default());
assert!(!cc.is_turn_collapsed(0));
let ev = cc.handle(&enter, &backend, rect);
assert_eq!(ev, ChatControllerEvent::Consumed);
assert!(cc.is_turn_collapsed(0));
let ev2 = cc.handle(&enter, &backend, rect);
assert_eq!(ev2, ChatControllerEvent::Consumed);
assert!(!cc.is_turn_collapsed(0));
}
#[test]
fn ctrl_enter_still_submits_while_a_turn_is_focused() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::Assistant, "body")]);
cc.set_focused_turn(Some(0));
cc.input_insert_str("hello");
let rect = make_rect();
let backend = RecordingBackend::new();
let ctrl_enter = key_event(
Key::Named(NamedKey::Enter),
Modifiers {
ctrl: true,
..Default::default()
},
);
let ev = cc.handle(&ctrl_enter, &backend, rect);
assert_eq!(
ev,
ChatControllerEvent::Submit {
text: "hello".into()
}
);
assert!(!cc.is_turn_collapsed(0));
}
#[test]
fn typing_returns_focus_to_input_from_a_focused_turn() {
let mut cc = ChatController::new("c");
cc.set_transcript(vec![make_turn(ChatRole::User, "one")]);
cc.set_focused_turn(Some(0));
assert!(!cc.input_has_focus());
let rect = make_rect();
let backend = RecordingBackend::new();
cc.handle(&UiEvent::CharTyped('x'), &backend, rect);
assert_eq!(cc.focused_turn(), None);
assert!(cc.input_has_focus());
assert_eq!(cc.input_text(), "x");
}
#[cfg(feature = "tui")]
#[test]
fn tui_render_paints_glyphs_into_buffer() {
use crate::tui::TuiBackend;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
const W: u16 = 48;
const H: u16 = 14;
let mut cc = ChatController::new("chat");
cc.set_status(StyledText::plain("Refining issue #264"));
cc.set_model_label("claude-opus-4-5");
cc.set_transcript(vec![
make_turn(ChatRole::User, "Hello there"),
make_turn(ChatRole::Assistant, "General Kenobi"),
]);
let mut terminal = Terminal::new(TestBackend::new(W, H)).expect("construct test terminal");
let mut backend = TuiBackend::new();
backend.begin_frame(crate::Viewport {
width: W as f32,
height: H as f32,
scale: 1.0,
});
let rect = Rect::new(0.0, 0.0, W as f32, H as f32);
terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
cc.render(b, rect);
});
})
.expect("draw frame");
let buf = terminal.backend().buffer();
let mut painted = String::new();
for y in 0..H {
for x in 0..W {
painted.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
painted.push('\n');
}
assert!(
painted.contains("Refining issue #264"),
"status label not painted:\n{painted}"
);
assert!(
painted.contains("claude-opus-4-5"),
"model chip not painted:\n{painted}"
);
assert!(
painted.contains("You"),
"user role header not painted:\n{painted}"
);
assert!(
painted.contains("General Kenobi"),
"assistant content row not painted:\n{painted}"
);
assert!(
painted.contains("Type a message"),
"input placeholder not painted:\n{painted}"
);
}
#[cfg(feature = "tui")]
#[test]
fn tui_render_paints_send_stop_segment_and_hint() {
use crate::tui::TuiBackend;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
const W: u16 = 48;
const H: u16 = 14;
fn paint(cc: &ChatController) -> String {
let mut terminal =
Terminal::new(TestBackend::new(W, H)).expect("construct test terminal");
let mut backend = TuiBackend::new();
backend.begin_frame(crate::Viewport {
width: W as f32,
height: H as f32,
scale: 1.0,
});
let rect = Rect::new(0.0, 0.0, W as f32, H as f32);
terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
cc.render(b, rect);
});
})
.expect("draw frame");
let buf = terminal.backend().buffer();
let mut painted = String::new();
for y in 0..H {
for x in 0..W {
painted.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
painted.push('\n');
}
painted
}
let mut cc = ChatController::new("chat");
cc.set_hint(Some(StyledText::plain("Enter to send")));
let idle = paint(&cc);
assert!(idle.contains("Send"), "Send label not painted:\n{idle}");
assert!(
idle.contains("Enter to send"),
"hint row not painted:\n{idle}"
);
cc.set_busy(true);
let busy = paint(&cc);
assert!(busy.contains("Stop"), "Stop label not painted:\n{busy}");
}
#[cfg(feature = "tui")]
#[test]
fn tui_placeholder_tracks_submit_on_enter_mode() {
use crate::tui::TuiBackend;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
const W: u16 = 110;
const H: u16 = 14;
fn paint(cc: &ChatController) -> String {
let mut terminal =
Terminal::new(TestBackend::new(W, H)).expect("construct test terminal");
let mut backend = TuiBackend::new();
backend.begin_frame(crate::Viewport {
width: W as f32,
height: H as f32,
scale: 1.0,
});
let rect = Rect::new(0.0, 0.0, W as f32, H as f32);
terminal
.draw(|frame| {
backend.enter_frame_scope(frame, |b| {
cc.render(b, rect);
});
})
.expect("draw frame");
let buf = terminal.backend().buffer();
let mut painted = String::new();
for y in 0..H {
for x in 0..W {
painted.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
painted.push('\n');
}
painted
}
let mut cc = ChatController::new("chat");
let default_mode = paint(&cc);
assert!(
default_mode.contains("Alt+Enter to send"),
"default-mode placeholder should advertise Alt+Enter as submit:\n{default_mode}"
);
assert!(
default_mode.contains("Enter for newline"),
"default-mode placeholder should advertise plain Enter as newline:\n{default_mode}"
);
cc.set_submit_on_enter(true);
let enter_mode = paint(&cc);
assert!(
enter_mode.contains("Enter to send"),
"submit_on_enter placeholder should advertise Enter as submit:\n{enter_mode}"
);
assert!(
enter_mode.contains("Shift+Enter for newline"),
"submit_on_enter placeholder should advertise Shift+Enter as newline:\n{enter_mode}"
);
assert!(
!enter_mode.contains("Alt+Enter to send"),
"submit_on_enter placeholder must not still claim Alt+Enter submits — in this \
mode Alt+Enter inserts a newline:\n{enter_mode}"
);
}
#[cfg(feature = "gtk")]
#[test]
fn gtk_render_inks_surface_and_layout_nonempty() {
use crate::gtk::GtkBackend;
use pangocairo::cairo::{Context, Format, ImageSurface};
const W: i32 = 360;
const H: i32 = 220;
let mut cc = ChatController::new("chat");
cc.set_status(StyledText::plain("Plan review for #264"));
cc.set_model_label("claude-opus-4-5");
cc.set_transcript(vec![
make_turn(ChatRole::User, "Hello there"),
make_turn(ChatRole::Assistant, "General Kenobi"),
]);
let mut backend = GtkBackend::new();
backend.set_theme(crate::Theme {
background: crate::Color::rgb(255, 255, 255),
..crate::Theme::default()
});
let rect = Rect::new(0.0, 0.0, W as f32, H as f32);
let mut surface = ImageSurface::create(Format::ARgb32, W, H).expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("cairo Context");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().expect("paint white baseline");
let layout = pangocairo::functions::create_layout(&cr);
backend.enter_frame_scope(&cr, &layout, |b| {
cc.render(b, rect);
});
}
let stride = surface.stride() as usize;
let inked = {
let data = surface.data().expect("surface data");
let mut found = false;
'scan: for y in 0..H {
for x in 0..W {
let off = y as usize * stride + x as usize * 4;
let (r, g, b) = (data[off + 2], data[off + 1], data[off]);
if !(r == 255 && g == 255 && b == 255) {
found = true;
break 'scan;
}
}
}
found
};
assert!(
inked,
"controller.render painted nothing into the GTK surface"
);
let mut ti = TextInput::new(WidgetId::new("probe-input"));
ti.lines = vec!["hello".to_string()];
ti.cursor_col = 5;
ti.has_focus = true;
let input_rect = Rect::new(0.0, H as f32 - 80.0, W as f32, 80.0);
let ti_layout = backend.text_input_layout(input_rect, &ti);
assert!(
!ti_layout.visible_lines.is_empty(),
"text_input_layout returned no visible lines"
);
}
}