1use crate::atoms::Button;
23use crate::Theme;
24use egui::{RichText, ScrollArea, Ui};
25use std::collections::VecDeque;
26use std::time::Instant;
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ChatRole {
31 User,
33 Assistant,
35 System,
37}
38
39impl ChatRole {
40 pub fn label(&self) -> &'static str {
42 match self {
43 Self::User => "You",
44 Self::Assistant => "Assistant",
45 Self::System => "System",
46 }
47 }
48}
49
50#[derive(Clone, Debug)]
52pub struct ChatMessage {
53 pub role: ChatRole,
55 pub content: String,
57 pub timestamp: Instant,
59 pub sender_name: Option<String>,
61}
62
63impl ChatMessage {
64 pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
66 Self {
67 role,
68 content: content.into(),
69 timestamp: Instant::now(),
70 sender_name: None,
71 }
72 }
73
74 pub fn with_sender(mut self, name: impl Into<String>) -> Self {
76 self.sender_name = Some(name.into());
77 self
78 }
79
80 pub fn sender_display(&self) -> &str {
82 self.sender_name.as_deref().unwrap_or(self.role.label())
83 }
84}
85
86pub struct ChatState {
88 messages: VecDeque<ChatMessage>,
89 max_messages: usize,
90 pub input_text: String,
92 pub auto_scroll: bool,
94 scroll_to_bottom: bool,
96}
97
98impl Default for ChatState {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104impl ChatState {
105 pub fn new() -> Self {
107 Self {
108 messages: VecDeque::new(),
109 max_messages: 500,
110 input_text: String::new(),
111 auto_scroll: true,
112 scroll_to_bottom: false,
113 }
114 }
115
116 pub fn with_max_messages(mut self, max: usize) -> Self {
118 self.max_messages = max;
119 self
120 }
121
122 pub fn push(&mut self, message: ChatMessage) {
124 self.messages.push_back(message);
125
126 while self.messages.len() > self.max_messages {
128 self.messages.pop_front();
129 }
130
131 if self.auto_scroll {
132 self.scroll_to_bottom = true;
133 }
134 }
135
136 pub fn push_user(&mut self, content: impl Into<String>) {
138 self.push(ChatMessage::new(ChatRole::User, content));
139 }
140
141 pub fn push_assistant(&mut self, content: impl Into<String>) {
143 self.push(ChatMessage::new(ChatRole::Assistant, content));
144 }
145
146 pub fn push_system(&mut self, content: impl Into<String>) {
148 self.push(ChatMessage::new(ChatRole::System, content));
149 }
150
151 pub fn push_from(
153 &mut self,
154 role: ChatRole,
155 sender: impl Into<String>,
156 content: impl Into<String>,
157 ) {
158 self.push(ChatMessage::new(role, content).with_sender(sender));
159 }
160
161 pub fn clear(&mut self) {
163 self.messages.clear();
164 }
165
166 pub fn len(&self) -> usize {
168 self.messages.len()
169 }
170
171 pub fn is_empty(&self) -> bool {
173 self.messages.is_empty()
174 }
175
176 pub fn messages(&self) -> impl Iterator<Item = &ChatMessage> {
178 self.messages.iter()
179 }
180
181 fn take_scroll_flag(&mut self) -> bool {
183 std::mem::take(&mut self.scroll_to_bottom)
184 }
185}
186
187pub struct Chat<'a> {
189 state: &'a mut ChatState,
190 height: Option<f32>,
191 show_input: bool,
192 show_timestamp: bool,
193 placeholder: &'a str,
194 submit_label: &'a str,
195 system_message_color: Option<egui::Color32>,
197}
198
199impl<'a> Chat<'a> {
200 pub fn new(state: &'a mut ChatState) -> Self {
202 Self {
203 state,
204 height: None,
205 show_input: true,
206 show_timestamp: false,
207 placeholder: "Type a message...",
208 submit_label: "Send",
209 system_message_color: None,
210 }
211 }
212
213 pub fn height(mut self, h: f32) -> Self {
215 self.height = Some(h);
216 self
217 }
218
219 pub fn show_input(mut self, show: bool) -> Self {
221 self.show_input = show;
222 self
223 }
224
225 pub fn show_timestamp(mut self, show: bool) -> Self {
227 self.show_timestamp = show;
228 self
229 }
230
231 pub fn placeholder(mut self, text: &'a str) -> Self {
233 self.placeholder = text;
234 self
235 }
236
237 pub fn submit_label(mut self, label: &'a str) -> Self {
239 self.submit_label = label;
240 self
241 }
242
243 pub fn system_message_color(mut self, color: egui::Color32) -> Self {
245 self.system_message_color = Some(color);
246 self
247 }
248
249 pub fn show(mut self, ui: &mut Ui) -> Option<String> {
251 let theme = Theme::current(ui.ctx());
252 let mut submitted: Option<String> = None;
253
254 ui.vertical(|ui| {
255 self.render_messages(ui, &theme);
257
258 if self.show_input {
260 ui.add_space(theme.spacing_sm);
261 submitted = self.render_input(ui, &theme);
262 }
263 });
264
265 if submitted.is_some() {
267 self.state.scroll_to_bottom = true;
268 }
269
270 submitted
271 }
272
273 fn render_messages(&mut self, ui: &mut Ui, theme: &Theme) {
274 let scroll_to_bottom = self.state.take_scroll_flag();
275
276 let scroll_area = if let Some(h) = self.height {
277 ScrollArea::vertical().max_height(h)
278 } else {
279 ScrollArea::vertical()
280 };
281
282 scroll_area
283 .auto_shrink([false, false])
284 .stick_to_bottom(true)
285 .show(ui, |ui| {
286 if self.state.is_empty() {
287 ui.label(
288 RichText::new("No messages yet")
289 .italics()
290 .color(theme.text_muted),
291 );
292 } else {
293 let now = Instant::now();
294 let msg_count = self.state.messages.len();
295 for (i, message) in self.state.messages.iter().enumerate() {
296 self.render_message(ui, message, theme, now);
297 if i < msg_count - 1 {
299 ui.add_space(theme.spacing_xs);
300 }
301 }
302 }
303
304 let response = ui.allocate_response(egui::vec2(0.0, 0.0), egui::Sense::hover());
306 if scroll_to_bottom {
307 response.scroll_to_me(Some(egui::Align::BOTTOM));
308 }
309 });
310 }
311
312 fn render_message(&self, ui: &mut Ui, message: &ChatMessage, theme: &Theme, now: Instant) {
313 match message.role {
314 ChatRole::System => {
315 let system_color = self.system_message_color.unwrap_or(theme.text_muted);
317 ui.horizontal(|ui| {
318 ui.add_space(ui.available_width() * 0.1);
319 ui.vertical(|ui| {
320 ui.label(
321 RichText::new(&message.content)
322 .italics()
323 .color(system_color)
324 .size(theme.font_size_sm),
325 );
326 });
327 });
328 }
329 _ => {
330 let is_user = message.role == ChatRole::User;
332 let bubble_color = if is_user {
333 theme.primary
334 } else {
335 theme.bg_secondary
336 };
337 let text_color = if is_user {
338 theme.primary_text
339 } else {
340 theme.text_primary
341 };
342
343 let max_bubble_width = ui.available_width() * 0.7;
344
345 ui.horizontal(|ui| {
346 if is_user {
348 ui.add_space(ui.available_width() - max_bubble_width);
349 }
350
351 egui::Frame::none()
352 .fill(bubble_color)
353 .inner_margin(theme.spacing_sm)
354 .corner_radius(theme.radius_md)
355 .show(ui, |ui| {
356 ui.set_max_width(max_bubble_width);
357
358 if !is_user {
360 ui.label(
361 RichText::new(message.sender_display())
362 .strong()
363 .size(theme.font_size_sm)
364 .color(theme.text_secondary),
365 );
366 }
367
368 ui.label(RichText::new(&message.content).color(text_color));
370
371 if self.show_timestamp {
373 let elapsed = now.duration_since(message.timestamp);
374 let ts = if elapsed.as_secs() < 60 {
375 "just now".to_string()
376 } else if elapsed.as_secs() < 3600 {
377 format!("{}m ago", elapsed.as_secs() / 60)
378 } else {
379 format!("{}h ago", elapsed.as_secs() / 3600)
380 };
381 ui.horizontal(|ui| {
382 ui.add_space((ui.available_width() - 60.0).max(0.0));
383 ui.label(RichText::new(ts).size(theme.font_size_xs).color(
384 if is_user {
385 theme.primary_text.gamma_multiply(0.7)
386 } else {
387 theme.text_muted
388 },
389 ));
390 });
391 }
392 });
393 });
394 }
395 }
396 }
397
398 fn render_input(&mut self, ui: &mut Ui, theme: &Theme) -> Option<String> {
399 let mut submitted = None;
400
401 let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
403
404 ui.horizontal(|ui| {
405 let input_width = (ui.available_width() - 80.0).max(100.0);
407 let input_response = egui::Frame::none()
408 .stroke(egui::Stroke::new(1.0, theme.border))
409 .corner_radius(theme.radius_sm)
410 .fill(theme.bg_primary)
411 .inner_margin(egui::Margin::symmetric(
412 theme.spacing_sm as i8,
413 theme.spacing_xs as i8,
414 ))
415 .show(ui, |ui| {
416 ui.add(
417 egui::TextEdit::multiline(&mut self.state.input_text)
418 .hint_text(self.placeholder)
419 .desired_width((input_width - 20.0).max(50.0))
420 .desired_rows(1)
421 .frame(false),
422 )
423 });
424 let response = input_response.inner;
425
426 let can_send = !self.state.input_text.trim().is_empty();
428 let send_clicked = ui
429 .add_enabled(can_send, Button::primary(self.submit_label))
430 .clicked();
431
432 if can_send && response.has_focus() && enter_pressed {
434 self.state.input_text = self.state.input_text.trim_end().to_string();
436 submitted = Some(std::mem::take(&mut self.state.input_text));
437 } else if can_send && send_clicked {
438 submitted = Some(std::mem::take(&mut self.state.input_text));
439 }
440 });
441
442 submitted
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn test_chat_message_creation() {
452 let msg = ChatMessage::new(ChatRole::User, "Hello").with_sender("Alice");
453
454 assert_eq!(msg.role, ChatRole::User);
455 assert_eq!(msg.content, "Hello");
456 assert_eq!(msg.sender_display(), "Alice");
457 }
458
459 #[test]
460 fn test_chat_state_push() {
461 let mut state = ChatState::new().with_max_messages(5);
462
463 for i in 0..10 {
464 state.push_user(format!("Message {}", i));
465 }
466
467 assert_eq!(state.len(), 5);
469 }
470
471 #[test]
472 fn test_chat_state_roles() {
473 let mut state = ChatState::new();
474 state.push_user("Hi");
475 state.push_assistant("Hello!");
476 state.push_system("User joined");
477
478 assert_eq!(state.len(), 3);
479
480 let msgs: Vec<_> = state.messages().collect();
481 assert_eq!(msgs[0].role, ChatRole::User);
482 assert_eq!(msgs[1].role, ChatRole::Assistant);
483 assert_eq!(msgs[2].role, ChatRole::System);
484 }
485
486 #[test]
487 fn test_role_labels() {
488 assert_eq!(ChatRole::User.label(), "You");
489 assert_eq!(ChatRole::Assistant.label(), "Assistant");
490 assert_eq!(ChatRole::System.label(), "System");
491 }
492}