use crate::studio::theme;
use crate::studio::utils::truncate_width;
use crate::types::GeneratedMessage;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui_textarea::TextArea;
pub struct MessageEditorState {
textarea: TextArea<'static>,
generated_messages: Vec<GeneratedMessage>,
selected_message: usize,
edit_mode: bool,
original_message: String,
}
impl Default for MessageEditorState {
fn default() -> Self {
Self::new()
}
}
impl MessageEditorState {
#[must_use]
pub fn new() -> Self {
let mut textarea = TextArea::default();
textarea.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
Self {
textarea,
generated_messages: Vec::new(),
selected_message: 0,
edit_mode: false,
original_message: String::new(),
}
}
pub fn set_messages(&mut self, messages: Vec<GeneratedMessage>) {
self.generated_messages = messages;
self.selected_message = 0;
let first_msg = self.generated_messages.first().cloned();
if let Some(msg) = first_msg {
self.load_message(&msg);
}
}
pub fn add_messages(&mut self, messages: Vec<GeneratedMessage>) -> usize {
let first_new_index = self.generated_messages.len();
self.generated_messages.extend(messages);
self.selected_message = first_new_index;
if let Some(msg) = self.generated_messages.get(first_new_index).cloned() {
self.load_message(&msg);
}
first_new_index
}
fn load_message(&mut self, msg: &GeneratedMessage) {
let full_message = format_message(msg);
self.original_message.clone_from(&full_message);
self.textarea = TextArea::from(full_message.lines().map(String::from).collect::<Vec<_>>());
self.textarea
.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
self.textarea
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
}
#[must_use]
pub fn message_count(&self) -> usize {
self.generated_messages.len()
}
#[must_use]
pub fn selected_index(&self) -> usize {
self.selected_message
}
pub fn next_message(&mut self) {
if !self.generated_messages.is_empty() {
self.selected_message = (self.selected_message + 1) % self.generated_messages.len();
if let Some(msg) = self.generated_messages.get(self.selected_message) {
self.load_message(&msg.clone());
}
self.edit_mode = false;
}
}
pub fn prev_message(&mut self) {
if !self.generated_messages.is_empty() {
self.selected_message = if self.selected_message == 0 {
self.generated_messages.len() - 1
} else {
self.selected_message - 1
};
if let Some(msg) = self.generated_messages.get(self.selected_message) {
self.load_message(&msg.clone());
}
self.edit_mode = false;
}
}
pub fn enter_edit_mode(&mut self) {
self.edit_mode = true;
}
pub fn exit_edit_mode(&mut self) {
self.edit_mode = false;
}
pub fn is_editing(&self) -> bool {
self.edit_mode
}
pub fn reset(&mut self) {
self.textarea = TextArea::from(
self.original_message
.lines()
.map(String::from)
.collect::<Vec<_>>(),
);
self.textarea
.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
self.textarea
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
self.edit_mode = false;
}
pub fn clear(&mut self) {
self.generated_messages.clear();
self.selected_message = 0;
self.original_message.clear();
self.textarea = TextArea::default();
self.textarea
.set_cursor_line_style(Style::default().bg(theme::bg_highlight_color()));
self.textarea
.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
self.edit_mode = false;
}
pub fn get_message(&self) -> String {
self.textarea.lines().join("\n")
}
pub fn current_generated(&self) -> Option<&GeneratedMessage> {
self.generated_messages.get(self.selected_message)
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
if !self.edit_mode {
return false;
}
if let (KeyCode::Esc, _) = (key.code, key.modifiers) {
self.exit_edit_mode();
true
} else {
self.textarea.input(key);
true
}
}
pub fn is_modified(&self) -> bool {
self.get_message() != self.original_message
}
pub fn textarea(&self) -> &TextArea<'static> {
&self.textarea
}
}
#[must_use]
pub fn format_message(msg: &GeneratedMessage) -> String {
let emoji = msg.emoji.as_deref().unwrap_or("");
let title = if emoji.is_empty() {
msg.title.clone()
} else {
format!("{} {}", emoji, msg.title)
};
if msg.message.is_empty() {
title
} else {
format!("{}\n\n{}", title, msg.message)
}
}
pub fn render_message_editor(
frame: &mut Frame,
area: Rect,
state: &MessageEditorState,
title: &str,
focused: bool,
generating: bool,
status_message: Option<&str>,
) {
let count_indicator = if state.message_count() > 1 {
format!(
" ({}/{})",
state.selected_index() + 1,
state.message_count()
)
} else {
String::new()
};
let mode_indicator = if state.is_editing() { " [EDITING]" } else { "" };
let full_title = format!(" {}{}{} ", title, count_indicator, mode_indicator);
let block = Block::default()
.title(full_title)
.borders(Borders::ALL)
.border_style(if focused {
if state.is_editing() {
Style::default().fg(theme::accent_primary())
} else {
theme::focused_border()
}
} else {
theme::unfocused_border()
});
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
if state.message_count() == 0 {
let placeholder = if generating {
let spinner_frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let frame_idx = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
/ 100) as usize
% spinner_frames.len();
let spinner = spinner_frames[frame_idx];
let status_text = status_message.unwrap_or("Iris is crafting your commit message");
Paragraph::new(vec![
Line::from(""),
Line::from(vec![
Span::styled(
format!("{} ", spinner),
Style::default().fg(theme::accent_primary()),
),
Span::styled(
status_text,
Style::default().fg(theme::text_primary_color()),
),
]),
])
} else {
Paragraph::new(vec![
Line::from(Span::styled("No commit message generated", theme::dimmed())),
Line::from(""),
Line::from(Span::styled(
"Press 'r' to regenerate",
Style::default().fg(theme::accent_secondary()),
)),
])
};
frame.render_widget(placeholder, inner);
} else if state.is_editing() {
frame.render_widget(state.textarea(), inner);
} else {
render_message_view(frame, inner, state);
}
}
fn render_message_view(frame: &mut Frame, area: Rect, state: &MessageEditorState) {
let Some(msg) = state.current_generated() else {
return;
};
let width = area.width as usize;
let mut lines = Vec::new();
let emoji = msg.emoji.as_deref().unwrap_or("");
let title_width = if emoji.is_empty() {
width
} else {
width.saturating_sub(emoji.chars().count() + 1)
};
let title = truncate_width(&msg.title, title_width);
if emoji.is_empty() {
lines.push(Line::from(Span::styled(
title,
Style::default()
.fg(theme::text_primary_color())
.add_modifier(Modifier::BOLD),
)));
} else {
lines.push(Line::from(vec![
Span::styled(emoji, Style::default()),
Span::raw(" "),
Span::styled(
title,
Style::default()
.fg(theme::text_primary_color())
.add_modifier(Modifier::BOLD),
),
]));
}
lines.push(Line::from(""));
for body_line in msg.message.lines() {
let truncated = truncate_width(body_line, width);
lines.push(Line::from(Span::styled(
truncated,
Style::default().fg(theme::text_primary_color()),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("e", Style::default().fg(theme::accent_secondary())),
Span::styled(" edit ", theme::dimmed()),
Span::styled("n/p", Style::default().fg(theme::accent_secondary())),
Span::styled(" cycle ", theme::dimmed()),
Span::styled("Enter", Style::default().fg(theme::accent_secondary())),
Span::styled(" commit", theme::dimmed()),
]));
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, area);
}
#[must_use]
pub fn render_message_preview(msg: &GeneratedMessage, width: usize) -> Line<'static> {
let emoji = msg.emoji.as_deref().unwrap_or("");
let title_width = if emoji.is_empty() {
width
} else {
width.saturating_sub(emoji.chars().count() + 1)
};
let title = truncate_width(&msg.title, title_width);
if emoji.is_empty() {
Line::from(Span::styled(title, theme::dimmed()))
} else {
Line::from(vec![
Span::raw(emoji.to_string()),
Span::raw(" "),
Span::styled(title, theme::dimmed()),
])
}
}