git_iris/studio/components/
message_editor.rs1use 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
16pub struct MessageEditorState {
22 textarea: TextArea<'static>,
24 generated_messages: Vec<GeneratedMessage>,
26 selected_message: usize,
28 edit_mode: bool,
30 original_message: String,
32}
33
34impl Default for MessageEditorState {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl MessageEditorState {
41 #[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 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 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 fn load_message(&mut self, msg: &GeneratedMessage) {
81 let full_message = format_message(msg);
82 self.original_message.clone_from(&full_message);
83
84 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 #[must_use]
94 pub fn message_count(&self) -> usize {
95 self.generated_messages.len()
96 }
97
98 #[must_use]
100 pub fn selected_index(&self) -> usize {
101 self.selected_message
102 }
103
104 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 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 pub fn enter_edit_mode(&mut self) {
132 self.edit_mode = true;
133 }
134
135 pub fn exit_edit_mode(&mut self) {
137 self.edit_mode = false;
138 }
139
140 pub fn is_editing(&self) -> bool {
142 self.edit_mode
143 }
144
145 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 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 pub fn get_message(&self) -> String {
175 self.textarea.lines().join("\n")
176 }
177
178 pub fn current_generated(&self) -> Option<&GeneratedMessage> {
180 self.generated_messages.get(self.selected_message)
181 }
182
183 pub fn handle_key(&mut self, key: KeyEvent) -> bool {
185 if !self.edit_mode {
186 return false;
187 }
188
189 if let (KeyCode::Esc, _) = (key.code, key.modifiers) {
191 self.exit_edit_mode();
192 true
193 } else {
194 self.textarea.input(key);
196 true
197 }
198 }
199
200 pub fn is_modified(&self) -> bool {
202 self.get_message() != self.original_message
203 }
204
205 pub fn textarea(&self) -> &TextArea<'static> {
207 &self.textarea
208 }
209}
210
211#[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
232pub 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 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 let placeholder = if generating {
284 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 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 frame.render_widget(state.textarea(), inner);
324 } else {
325 render_message_view(frame, inner, state);
327 }
328}
329
330fn 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 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 lines.push(Line::from(""));
370
371 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 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#[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}