Skip to main content

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