Skip to main content

egui_cha_ds/molecules/
chat.rs

1//! Chat - Simple chat component
2//!
3//! A scrollable chat view with message input.
4//!
5//! # Example
6//!
7//! ```ignore
8//! // State initialization (in Model)
9//! let mut chat_state = ChatState::new();
10//!
11//! // Adding messages
12//! chat_state.push_user("Hello!");
13//! chat_state.push_assistant("Hi! How can I help?");
14//!
15//! // Rendering with callback
16//! if let Some(msg) = Chat::new(&mut chat_state).show(ui) {
17//!     // User submitted a message
18//!     println!("User sent: {}", msg);
19//! }
20//! ```
21
22use crate::atoms::Button;
23use crate::Theme;
24use egui::{RichText, ScrollArea, Ui};
25use std::collections::VecDeque;
26use std::time::Instant;
27
28/// Message sender role
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ChatRole {
31    /// User message
32    User,
33    /// Assistant/Bot message
34    Assistant,
35    /// System message (centered, muted)
36    System,
37}
38
39impl ChatRole {
40    /// Get display label
41    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/// A single chat message
51#[derive(Clone, Debug)]
52pub struct ChatMessage {
53    /// Message role/sender
54    pub role: ChatRole,
55    /// Message content
56    pub content: String,
57    /// Timestamp when created
58    pub timestamp: Instant,
59    /// Optional custom sender name (overrides role label)
60    pub sender_name: Option<String>,
61}
62
63impl ChatMessage {
64    /// Create a new message
65    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    /// Set custom sender name
75    pub fn with_sender(mut self, name: impl Into<String>) -> Self {
76        self.sender_name = Some(name.into());
77        self
78    }
79
80    /// Get display name for sender
81    pub fn sender_display(&self) -> &str {
82        self.sender_name.as_deref().unwrap_or(self.role.label())
83    }
84}
85
86/// State for Chat (owned by parent)
87pub struct ChatState {
88    messages: VecDeque<ChatMessage>,
89    max_messages: usize,
90    /// Input text buffer
91    pub input_text: String,
92    /// Auto-scroll to bottom on new messages
93    pub auto_scroll: bool,
94    /// Track if new messages were added
95    scroll_to_bottom: bool,
96}
97
98impl Default for ChatState {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl ChatState {
105    /// Create a new empty state
106    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    /// Set maximum messages to keep
117    pub fn with_max_messages(mut self, max: usize) -> Self {
118        self.max_messages = max;
119        self
120    }
121
122    /// Push a message
123    pub fn push(&mut self, message: ChatMessage) {
124        self.messages.push_back(message);
125
126        // Trim old messages
127        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    /// Push a user message
137    pub fn push_user(&mut self, content: impl Into<String>) {
138        self.push(ChatMessage::new(ChatRole::User, content));
139    }
140
141    /// Push an assistant message
142    pub fn push_assistant(&mut self, content: impl Into<String>) {
143        self.push(ChatMessage::new(ChatRole::Assistant, content));
144    }
145
146    /// Push a system message
147    pub fn push_system(&mut self, content: impl Into<String>) {
148        self.push(ChatMessage::new(ChatRole::System, content));
149    }
150
151    /// Push with custom sender name
152    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    /// Clear all messages
162    pub fn clear(&mut self) {
163        self.messages.clear();
164    }
165
166    /// Get message count
167    pub fn len(&self) -> usize {
168        self.messages.len()
169    }
170
171    /// Check if empty
172    pub fn is_empty(&self) -> bool {
173        self.messages.is_empty()
174    }
175
176    /// Iterate messages
177    pub fn messages(&self) -> impl Iterator<Item = &ChatMessage> {
178        self.messages.iter()
179    }
180
181    /// Check and consume scroll flag
182    fn take_scroll_flag(&mut self) -> bool {
183        std::mem::take(&mut self.scroll_to_bottom)
184    }
185}
186
187/// Chat component - displays a scrollable chat view with input
188pub 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    /// Custom color for system messages (default: theme.text_muted)
196    system_message_color: Option<egui::Color32>,
197}
198
199impl<'a> Chat<'a> {
200    /// Create a new Chat viewer
201    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    /// Set fixed height (None = fill available space)
214    pub fn height(mut self, h: f32) -> Self {
215        self.height = Some(h);
216        self
217    }
218
219    /// Show/hide input area
220    pub fn show_input(mut self, show: bool) -> Self {
221        self.show_input = show;
222        self
223    }
224
225    /// Show/hide timestamps
226    pub fn show_timestamp(mut self, show: bool) -> Self {
227        self.show_timestamp = show;
228        self
229    }
230
231    /// Set placeholder text for input
232    pub fn placeholder(mut self, text: &'a str) -> Self {
233        self.placeholder = text;
234        self
235    }
236
237    /// Set submit button label
238    pub fn submit_label(mut self, label: &'a str) -> Self {
239        self.submit_label = label;
240        self
241    }
242
243    /// Set custom color for system messages (default: theme.text_muted)
244    pub fn system_message_color(mut self, color: egui::Color32) -> Self {
245        self.system_message_color = Some(color);
246        self
247    }
248
249    /// Show the chat and return submitted message (if any)
250    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            // Message area
256            self.render_messages(ui, &theme);
257
258            // Input area
259            if self.show_input {
260                ui.add_space(theme.spacing_sm);
261                submitted = self.render_input(ui, &theme);
262            }
263        });
264
265        // Trigger scroll to bottom on next frame when message is submitted
266        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                        // Add spacing between messages (not after the last one)
298                        if i < msg_count - 1 {
299                            ui.add_space(theme.spacing_xs);
300                        }
301                    }
302                }
303
304                // Invisible anchor at bottom for scrolling
305                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                // System messages are centered
316                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                // User/Assistant messages
331                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                    // Left spacing for user messages (right-align)
347                    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                            // Sender name (only for assistant)
359                            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                            // Content
369                            ui.label(RichText::new(&message.content).color(text_color));
370
371                            // Timestamp (inline, right side)
372                            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        // Check for Enter without Shift (submit) before rendering
402        let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
403
404        ui.horizontal(|ui| {
405            // Text input with custom frame
406            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            // Send button - match input height
427            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            // Submit on Enter (without Shift) or button click
433            if can_send && response.has_focus() && enter_pressed {
434                // Remove trailing newline that Enter adds
435                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        // Should only keep last 5
468        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}