git_iris/studio/components/
message_editor.rs

1//! Message editor component for Iris Studio
2//!
3//! Text editor for commit messages using tui-textarea.
4
5use crate::studio::theme;
6use crate::studio::utils::truncate_width;
7use crate::types::GeneratedMessage;
8use crossterm::event::{KeyCode, KeyEvent};
9use ratatui::Frame;
10use ratatui::layout::Rect;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Paragraph};
14use tui_textarea::TextArea;
15
16// ═══════════════════════════════════════════════════════════════════════════════
17// Message Editor State
18// ═══════════════════════════════════════════════════════════════════════════════
19
20/// State for the message editor component
21pub struct MessageEditorState {
22    /// Text area for editing
23    textarea: TextArea<'static>,
24    /// Generated messages from Iris
25    generated_messages: Vec<GeneratedMessage>,
26    /// Currently selected generated message index
27    selected_message: usize,
28    /// Is the editor in edit mode (vs view mode)
29    edit_mode: bool,
30    /// Original message (for reset)
31    original_message: String,
32}
33
34impl Default for MessageEditorState {
35    fn default() -> Self {
36        Self::new()
37    }
38}
39
40impl MessageEditorState {
41    /// Create a new message editor state
42    pub fn new() -> Self {
43        let mut textarea = TextArea::default();
44        textarea.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
45        textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
46
47        Self {
48            textarea,
49            generated_messages: Vec::new(),
50            selected_message: 0,
51            edit_mode: false,
52            original_message: String::new(),
53        }
54    }
55
56    /// Set generated messages (replaces all existing)
57    pub fn set_messages(&mut self, messages: Vec<GeneratedMessage>) {
58        self.generated_messages = messages;
59        self.selected_message = 0;
60        let first_msg = self.generated_messages.first().cloned();
61        if let Some(msg) = first_msg {
62            self.load_message(&msg);
63        }
64    }
65
66    /// Add messages to existing list (preserves history)
67    /// Returns the index of the first new message
68    pub fn add_messages(&mut self, messages: Vec<GeneratedMessage>) -> usize {
69        let first_new_index = self.generated_messages.len();
70        self.generated_messages.extend(messages);
71        self.selected_message = first_new_index;
72        if let Some(msg) = self.generated_messages.get(first_new_index).cloned() {
73            self.load_message(&msg);
74        }
75        first_new_index
76    }
77
78    /// Load a message into the editor
79    fn load_message(&mut self, msg: &GeneratedMessage) {
80        let full_message = format_message(msg);
81        self.original_message.clone_from(&full_message);
82
83        // Clear and set new content
84        self.textarea = TextArea::from(full_message.lines().map(String::from).collect::<Vec<_>>());
85        self.textarea
86            .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
87        self.textarea
88            .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
89    }
90
91    /// Get current message count
92    pub fn message_count(&self) -> usize {
93        self.generated_messages.len()
94    }
95
96    /// Get currently selected index
97    pub fn selected_index(&self) -> usize {
98        self.selected_message
99    }
100
101    /// Select next message
102    pub fn next_message(&mut self) {
103        if !self.generated_messages.is_empty() {
104            self.selected_message = (self.selected_message + 1) % self.generated_messages.len();
105            if let Some(msg) = self.generated_messages.get(self.selected_message) {
106                self.load_message(&msg.clone());
107            }
108            self.edit_mode = false;
109        }
110    }
111
112    /// Select previous message
113    pub fn prev_message(&mut self) {
114        if !self.generated_messages.is_empty() {
115            self.selected_message = if self.selected_message == 0 {
116                self.generated_messages.len() - 1
117            } else {
118                self.selected_message - 1
119            };
120            if let Some(msg) = self.generated_messages.get(self.selected_message) {
121                self.load_message(&msg.clone());
122            }
123            self.edit_mode = false;
124        }
125    }
126
127    /// Enter edit mode
128    pub fn enter_edit_mode(&mut self) {
129        self.edit_mode = true;
130    }
131
132    /// Exit edit mode
133    pub fn exit_edit_mode(&mut self) {
134        self.edit_mode = false;
135    }
136
137    /// Is in edit mode?
138    pub fn is_editing(&self) -> bool {
139        self.edit_mode
140    }
141
142    /// Reset to original message
143    pub fn reset(&mut self) {
144        self.textarea = TextArea::from(
145            self.original_message
146                .lines()
147                .map(String::from)
148                .collect::<Vec<_>>(),
149        );
150        self.textarea
151            .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
152        self.textarea
153            .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
154        self.edit_mode = false;
155    }
156
157    /// Clear all messages and reset state
158    pub fn clear(&mut self) {
159        self.generated_messages.clear();
160        self.selected_message = 0;
161        self.original_message.clear();
162        self.textarea = TextArea::default();
163        self.textarea
164            .set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
165        self.textarea
166            .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
167        self.edit_mode = false;
168    }
169
170    /// Get current message text
171    pub fn get_message(&self) -> String {
172        self.textarea.lines().join("\n")
173    }
174
175    /// Get the current generated message (if any)
176    pub fn current_generated(&self) -> Option<&GeneratedMessage> {
177        self.generated_messages.get(self.selected_message)
178    }
179
180    /// Handle key input (when in edit mode)
181    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
182        if !self.edit_mode {
183            return false;
184        }
185
186        // Handle special keys
187        if let (KeyCode::Esc, _) = (key.code, key.modifiers) {
188            self.exit_edit_mode();
189            true
190        } else {
191            // Forward to textarea
192            self.textarea.input(key);
193            true
194        }
195    }
196
197    /// Check if message was modified
198    pub fn is_modified(&self) -> bool {
199        self.get_message() != self.original_message
200    }
201
202    /// Get textarea reference for rendering
203    pub fn textarea(&self) -> &TextArea<'static> {
204        &self.textarea
205    }
206}
207
208// ═══════════════════════════════════════════════════════════════════════════════
209// Helpers
210// ═══════════════════════════════════════════════════════════════════════════════
211
212/// Format a generated message for display
213pub fn format_message(msg: &GeneratedMessage) -> String {
214    let emoji = msg.emoji.as_deref().unwrap_or("");
215    let title = if emoji.is_empty() {
216        msg.title.clone()
217    } else {
218        format!("{} {}", emoji, msg.title)
219    };
220
221    if msg.message.is_empty() {
222        title
223    } else {
224        format!("{}\n\n{}", title, msg.message)
225    }
226}
227
228// ═══════════════════════════════════════════════════════════════════════════════
229// Rendering
230// ═══════════════════════════════════════════════════════════════════════════════
231
232/// Render the message editor widget
233pub fn render_message_editor(
234    frame: &mut Frame,
235    area: Rect,
236    state: &MessageEditorState,
237    title: &str,
238    focused: bool,
239    generating: bool,
240    status_message: Option<&str>,
241) {
242    // Build title with message count indicator
243    let count_indicator = if state.message_count() > 1 {
244        format!(
245            " ({}/{})",
246            state.selected_index() + 1,
247            state.message_count()
248        )
249    } else {
250        String::new()
251    };
252
253    let mode_indicator = if state.is_editing() { " [EDITING]" } else { "" };
254
255    let full_title = format!(" {}{}{} ", title, count_indicator, mode_indicator);
256
257    let block = Block::default()
258        .title(full_title)
259        .borders(Borders::ALL)
260        .border_style(if focused {
261            if state.is_editing() {
262                Style::default().fg(theme::accent_primary())
263            } else {
264                theme::focused_border()
265            }
266        } else {
267            theme::unfocused_border()
268        });
269
270    let inner = block.inner(area);
271    frame.render_widget(block, area);
272
273    if inner.height == 0 || inner.width == 0 {
274        return;
275    }
276
277    if state.message_count() == 0 {
278        // No messages - show placeholder or generating state
279        let placeholder = if generating {
280            // Show generating spinner with braille pattern
281            let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
282            let frame_idx = (std::time::SystemTime::now()
283                .duration_since(std::time::UNIX_EPOCH)
284                .unwrap_or_default()
285                .as_millis()
286                / 100) as usize
287                % spinner_frames.len();
288            let spinner = spinner_frames[frame_idx];
289
290            // Use dynamic status message if available, otherwise fallback
291            let status_text = status_message.unwrap_or("Iris is crafting your commit message");
292
293            Paragraph::new(vec![
294                Line::from(""),
295                Line::from(vec![
296                    Span::styled(
297                        format!("{} ", spinner),
298                        Style::default().fg(theme::accent_primary()),
299                    ),
300                    Span::styled(
301                        status_text,
302                        Style::default().fg(theme::text_primary_color()),
303                    ),
304                ]),
305            ])
306        } else {
307            Paragraph::new(vec![
308                Line::from(Span::styled("No commit message generated", theme::dimmed())),
309                Line::from(""),
310                Line::from(Span::styled(
311                    "Press 'r' to regenerate",
312                    Style::default().fg(theme::accent_secondary()),
313                )),
314            ])
315        };
316        frame.render_widget(placeholder, inner);
317    } else if state.is_editing() {
318        // Render textarea in edit mode
319        frame.render_widget(state.textarea(), inner);
320    } else {
321        // Render as read-only view
322        render_message_view(frame, inner, state);
323    }
324}
325
326/// Render the message in view mode (non-editing)
327fn render_message_view(frame: &mut Frame, area: Rect, state: &MessageEditorState) {
328    let Some(msg) = state.current_generated() else {
329        return;
330    };
331
332    let width = area.width as usize;
333    let mut lines = Vec::new();
334
335    // Emoji and title (truncated to fit)
336    let emoji = msg.emoji.as_deref().unwrap_or("");
337    let title_width = if emoji.is_empty() {
338        width
339    } else {
340        width.saturating_sub(emoji.chars().count() + 1)
341    };
342    let title = truncate_width(&msg.title, title_width);
343
344    if emoji.is_empty() {
345        lines.push(Line::from(Span::styled(
346            title,
347            Style::default()
348                .fg(theme::text_primary_color())
349                .add_modifier(Modifier::BOLD),
350        )));
351    } else {
352        lines.push(Line::from(vec![
353            Span::styled(emoji, Style::default()),
354            Span::raw(" "),
355            Span::styled(
356                title,
357                Style::default()
358                    .fg(theme::text_primary_color())
359                    .add_modifier(Modifier::BOLD),
360            ),
361        ]));
362    }
363
364    // Empty line
365    lines.push(Line::from(""));
366
367    // Body (truncated lines)
368    for body_line in msg.message.lines() {
369        let truncated = truncate_width(body_line, width);
370        lines.push(Line::from(Span::styled(
371            truncated,
372            Style::default().fg(theme::text_primary_color()),
373        )));
374    }
375
376    // Help hints at bottom
377    lines.push(Line::from(""));
378    lines.push(Line::from(vec![
379        Span::styled("e", Style::default().fg(theme::accent_secondary())),
380        Span::styled(" edit  ", theme::dimmed()),
381        Span::styled("n/p", Style::default().fg(theme::accent_secondary())),
382        Span::styled(" cycle  ", theme::dimmed()),
383        Span::styled("Enter", Style::default().fg(theme::accent_secondary())),
384        Span::styled(" commit", theme::dimmed()),
385    ]));
386
387    let paragraph = Paragraph::new(lines);
388    frame.render_widget(paragraph, area);
389}
390
391/// Render a compact message preview (for lists)
392pub fn render_message_preview(msg: &GeneratedMessage, width: usize) -> Line<'static> {
393    let emoji = msg.emoji.as_deref().unwrap_or("");
394    let title_width = if emoji.is_empty() {
395        width
396    } else {
397        width.saturating_sub(emoji.chars().count() + 1)
398    };
399    let title = truncate_width(&msg.title, title_width);
400
401    if emoji.is_empty() {
402        Line::from(Span::styled(title, theme::dimmed()))
403    } else {
404        Line::from(vec![
405            Span::raw(emoji.to_string()),
406            Span::raw(" "),
407            Span::styled(title, theme::dimmed()),
408        ])
409    }
410}