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 emoji = msg.emoji.as_deref().unwrap_or("");
219    let title = if emoji.is_empty() {
220        msg.title.clone()
221    } else {
222        format!("{} {}", emoji, msg.title)
223    };
224
225    if msg.message.is_empty() {
226        title
227    } else {
228        format!("{}\n\n{}", title, msg.message)
229    }
230}
231
232// ═══════════════════════════════════════════════════════════════════════════════
233// Rendering
234// ═══════════════════════════════════════════════════════════════════════════════
235
236/// Render the message editor widget
237pub fn render_message_editor(
238    frame: &mut Frame,
239    area: Rect,
240    state: &MessageEditorState,
241    title: &str,
242    focused: bool,
243    generating: bool,
244    status_message: Option<&str>,
245) {
246    // Build title with message count indicator
247    let count_indicator = if state.message_count() > 1 {
248        format!(
249            " ({}/{})",
250            state.selected_index() + 1,
251            state.message_count()
252        )
253    } else {
254        String::new()
255    };
256
257    let mode_indicator = if state.is_editing() { " [EDITING]" } else { "" };
258
259    let full_title = format!(" {}{}{} ", title, count_indicator, mode_indicator);
260
261    let block = Block::default()
262        .title(full_title)
263        .borders(Borders::ALL)
264        .border_style(if focused {
265            if state.is_editing() {
266                Style::default().fg(theme::accent_primary())
267            } else {
268                theme::focused_border()
269            }
270        } else {
271            theme::unfocused_border()
272        });
273
274    let inner = block.inner(area);
275    frame.render_widget(block, area);
276
277    if inner.height == 0 || inner.width == 0 {
278        return;
279    }
280
281    if state.message_count() == 0 {
282        // No messages - show placeholder or generating state
283        let placeholder = if generating {
284            // Show generating spinner with braille pattern
285            let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
286            let frame_idx = (std::time::SystemTime::now()
287                .duration_since(std::time::UNIX_EPOCH)
288                .unwrap_or_default()
289                .as_millis()
290                / 100) as usize
291                % spinner_frames.len();
292            let spinner = spinner_frames[frame_idx];
293
294            // Use dynamic status message if available, otherwise fallback
295            let status_text = status_message.unwrap_or("Iris is crafting your commit message");
296
297            Paragraph::new(vec![
298                Line::from(""),
299                Line::from(vec![
300                    Span::styled(
301                        format!("{} ", spinner),
302                        Style::default().fg(theme::accent_primary()),
303                    ),
304                    Span::styled(
305                        status_text,
306                        Style::default().fg(theme::text_primary_color()),
307                    ),
308                ]),
309            ])
310        } else {
311            Paragraph::new(vec![
312                Line::from(Span::styled("No commit message generated", theme::dimmed())),
313                Line::from(""),
314                Line::from(Span::styled(
315                    "Press 'r' to regenerate",
316                    Style::default().fg(theme::accent_secondary()),
317                )),
318            ])
319        };
320        frame.render_widget(placeholder, inner);
321    } else if state.is_editing() {
322        // Render textarea in edit mode
323        frame.render_widget(state.textarea(), inner);
324    } else {
325        // Render as read-only view
326        render_message_view(frame, inner, state);
327    }
328}
329
330/// Render the message in view mode (non-editing)
331fn render_message_view(frame: &mut Frame, area: Rect, state: &MessageEditorState) {
332    let Some(msg) = state.current_generated() else {
333        return;
334    };
335
336    let width = area.width as usize;
337    let mut lines = Vec::new();
338
339    // Emoji and title (truncated to fit)
340    let emoji = msg.emoji.as_deref().unwrap_or("");
341    let title_width = if emoji.is_empty() {
342        width
343    } else {
344        width.saturating_sub(emoji.chars().count() + 1)
345    };
346    let title = truncate_width(&msg.title, title_width);
347
348    if emoji.is_empty() {
349        lines.push(Line::from(Span::styled(
350            title,
351            Style::default()
352                .fg(theme::text_primary_color())
353                .add_modifier(Modifier::BOLD),
354        )));
355    } else {
356        lines.push(Line::from(vec![
357            Span::styled(emoji, Style::default()),
358            Span::raw(" "),
359            Span::styled(
360                title,
361                Style::default()
362                    .fg(theme::text_primary_color())
363                    .add_modifier(Modifier::BOLD),
364            ),
365        ]));
366    }
367
368    // Empty line
369    lines.push(Line::from(""));
370
371    // Body (truncated lines)
372    for body_line in msg.message.lines() {
373        let truncated = truncate_width(body_line, width);
374        lines.push(Line::from(Span::styled(
375            truncated,
376            Style::default().fg(theme::text_primary_color()),
377        )));
378    }
379
380    // Help hints at bottom
381    lines.push(Line::from(""));
382    lines.push(Line::from(vec![
383        Span::styled("e", Style::default().fg(theme::accent_secondary())),
384        Span::styled(" edit  ", theme::dimmed()),
385        Span::styled("n/p", Style::default().fg(theme::accent_secondary())),
386        Span::styled(" cycle  ", theme::dimmed()),
387        Span::styled("Enter", Style::default().fg(theme::accent_secondary())),
388        Span::styled(" commit", theme::dimmed()),
389    ]));
390
391    let paragraph = Paragraph::new(lines);
392    frame.render_widget(paragraph, area);
393}
394
395/// Render a compact message preview (for lists)
396#[must_use]
397pub fn render_message_preview(msg: &GeneratedMessage, width: usize) -> Line<'static> {
398    let emoji = msg.emoji.as_deref().unwrap_or("");
399    let title_width = if emoji.is_empty() {
400        width
401    } else {
402        width.saturating_sub(emoji.chars().count() + 1)
403    };
404    let title = truncate_width(&msg.title, title_width);
405
406    if emoji.is_empty() {
407        Line::from(Span::styled(title, theme::dimmed()))
408    } else {
409        Line::from(vec![
410            Span::raw(emoji.to_string()),
411            Span::raw(" "),
412            Span::styled(title, theme::dimmed()),
413        ])
414    }
415}