use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::error::Suggestion;
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![];
}
let mut lines = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
for ch in text.chars() {
let ch_width = UnicodeWidthStr::width(ch.to_string().as_str());
if current_width + ch_width > max_width {
if !current_line.is_empty() {
lines.push(current_line);
current_line = String::new();
current_width = 0;
}
}
current_line.push(ch);
current_width += ch_width;
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() && !text.is_empty() {
lines.push(String::new());
}
lines
}
fn wrap_text_with_prefix(prefix: &str, text: &str, max_width: usize) -> Vec<String> {
let prefix_width = UnicodeWidthStr::width(prefix);
if prefix_width >= max_width {
return vec![format!("{}{}", prefix, text)];
}
let text_width = max_width - prefix_width;
let wrapped = wrap_text(text, text_width);
let indent = " ".repeat(prefix_width);
wrapped
.into_iter()
.enumerate()
.map(|(i, line)| {
if i == 0 {
format!("{}{}", prefix, line)
} else {
format!("{}{}", indent, line)
}
})
.collect()
}
#[derive(Debug, Clone, Copy)]
pub enum NoticeVariant {
Success,
Error,
}
pub struct NoticeWidget<'a> {
variant: NoticeVariant,
title: &'a str,
messages: &'a [String],
details: Vec<(String, String)>,
suggestions: Vec<Suggestion>,
}
impl<'a> NoticeWidget<'a> {
pub fn success(title: &'a str, messages: &'a [String]) -> Self {
Self {
variant: NoticeVariant::Success,
title,
messages,
details: Vec::new(),
suggestions: Vec::new(),
}
}
pub fn error(title: &'a str, messages: &'a [String]) -> Self {
Self {
variant: NoticeVariant::Error,
title,
messages,
details: Vec::new(),
suggestions: Vec::new(),
}
}
pub fn with_details(mut self, details: Vec<(String, String)>) -> Self {
self.details = details;
self
}
pub fn with_suggestions(mut self, suggestions: Vec<Suggestion>) -> Self {
self.suggestions = suggestions;
self
}
pub fn required_height(&self, width: u16) -> u16 {
let inner_width = if width > 2 { (width - 2) as usize } else { 1 };
let mut height: u16 = 2;
height += 2;
for msg in self.messages.iter() {
let lines = wrap_text(msg, inner_width);
height += lines.len().max(1) as u16;
}
if !self.details.is_empty() {
height += 1; for (key, value) in &self.details {
let prefix = format!("{}: ", key);
let lines = wrap_text_with_prefix(&prefix, value, inner_width);
height += lines.len().max(1) as u16;
}
}
if !self.suggestions.is_empty() {
height += 2; for (i, s) in self.suggestions.iter().enumerate() {
let prefix = format!(" {}. ", i + 1);
let lines = wrap_text_with_prefix(&prefix, &s.description, inner_width);
height += lines.len().max(1) as u16;
if let Some(ref cmd) = s.command {
let cmd_prefix = " $ ";
let cmd_lines = wrap_text_with_prefix(cmd_prefix, cmd, inner_width);
height += cmd_lines.len().max(1) as u16;
}
}
}
height += 2;
height
}
}
impl Widget for NoticeWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 10 || area.height < 3 {
return;
}
let (color, icon) = match self.variant {
NoticeVariant::Success => (Color::Green, "✓"),
NoticeVariant::Error => (Color::Red, "✗"),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(color));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 1 {
return;
}
let mut current_y = inner.y;
let inner_width = inner.width as usize;
let title_line = format!("{} {}", icon, self.title);
buf.set_string(
inner.x,
current_y,
&title_line,
Style::default().fg(color).add_modifier(Modifier::BOLD),
);
current_y += 2;
for msg in self.messages.iter() {
let wrapped_lines = wrap_text(msg, inner_width);
for line in wrapped_lines {
if current_y >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, current_y, &line, Style::default().fg(Color::White));
current_y += 1;
}
}
if !self.details.is_empty() {
current_y += 1;
for (key, value) in &self.details {
if current_y >= inner.y + inner.height {
break;
}
let prefix = format!("{}: ", key);
let wrapped_lines = wrap_text_with_prefix(&prefix, value, inner_width);
for (line_idx, line) in wrapped_lines.iter().enumerate() {
if current_y >= inner.y + inner.height {
break;
}
if line_idx == 0 {
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
buf.set_string(
inner.x,
current_y,
&prefix,
Style::default().fg(Color::Gray),
);
if line.len() > prefix.len() {
buf.set_string(
inner.x + prefix_width as u16,
current_y,
&line[prefix.len()..],
Style::default().fg(Color::Cyan),
);
}
} else {
buf.set_string(inner.x, current_y, line, Style::default().fg(Color::Cyan));
}
current_y += 1;
}
}
}
if !self.suggestions.is_empty() {
current_y += 1;
if current_y < inner.y + inner.height {
buf.set_string(
inner.x,
current_y,
"Suggestions:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
current_y += 1;
}
for (i, suggestion) in self.suggestions.iter().enumerate() {
if current_y >= inner.y + inner.height {
break;
}
let prefix = format!(" {}. ", i + 1);
let wrapped_lines =
wrap_text_with_prefix(&prefix, &suggestion.description, inner_width);
for line in &wrapped_lines {
if current_y >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, current_y, line, Style::default().fg(Color::White));
current_y += 1;
}
if let Some(ref cmd) = suggestion.command {
let cmd_prefix = " $ ";
let cmd_lines = wrap_text_with_prefix(cmd_prefix, cmd, inner_width);
for cmd_line in &cmd_lines {
if current_y >= inner.y + inner.height {
break;
}
buf.set_string(
inner.x,
current_y,
cmd_line,
Style::default().fg(Color::Cyan),
);
current_y += 1;
}
}
}
}
current_y += 1;
if current_y < inner.y + inner.height {
buf.set_string(
inner.x,
current_y,
"Press any key to continue...",
Style::default().fg(Color::DarkGray),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_notice_success() {
let messages = vec!["Test message".to_string()];
let notice = NoticeWidget::success("Success!", &messages);
assert_eq!(notice.title, "Success!");
}
#[test]
fn test_notice_error() {
let messages = vec!["Error message".to_string()];
let notice = NoticeWidget::error("Error!", &messages);
assert_eq!(notice.title, "Error!");
}
#[test]
fn test_notice_success_variant() {
let messages = vec!["Worktree created".to_string()];
let notice = NoticeWidget::success("Done", &messages);
assert!(matches!(notice.variant, NoticeVariant::Success));
assert_eq!(notice.messages.len(), 1);
}
#[test]
fn test_notice_error_variant() {
let messages = vec!["Failed to create worktree".to_string()];
let notice = NoticeWidget::error("Error", &messages);
assert!(matches!(notice.variant, NoticeVariant::Error));
assert_eq!(notice.messages.len(), 1);
}
#[test]
fn test_notice_multiple_messages() {
let messages = vec![
"Path: /path/to/worktree".to_string(),
"Branch: feature/test".to_string(),
"Hooks: completed".to_string(),
];
let notice = NoticeWidget::success("Worktree created!", &messages);
assert_eq!(notice.messages.len(), 3);
assert_eq!(notice.messages[0], "Path: /path/to/worktree");
assert_eq!(notice.messages[1], "Branch: feature/test");
assert_eq!(notice.messages[2], "Hooks: completed");
}
#[test]
fn test_notice_empty_messages() {
let messages: Vec<String> = vec![];
let notice = NoticeWidget::success("Success", &messages);
assert_eq!(notice.messages.len(), 0);
}
#[test]
fn test_notice_variant_debug() {
let success = NoticeVariant::Success;
let error = NoticeVariant::Error;
assert_eq!(format!("{:?}", success), "Success");
assert_eq!(format!("{:?}", error), "Error");
}
#[test]
fn test_notice_with_details() {
let messages = vec!["Test".to_string()];
let details = vec![
("Path".to_string(), "/path/to/worktree".to_string()),
("Branch".to_string(), "feature/test".to_string()),
];
let notice = NoticeWidget::error("Error", &messages).with_details(details.clone());
assert_eq!(notice.details.len(), 2);
assert_eq!(notice.details[0].0, "Path");
assert_eq!(notice.details[1].1, "feature/test");
}
#[test]
fn test_notice_with_suggestions() {
let messages = vec!["Test".to_string()];
let suggestions = vec![
Suggestion::new("Do this"),
Suggestion::with_command("Run this", "git status"),
];
let notice = NoticeWidget::error("Error", &messages).with_suggestions(suggestions);
assert_eq!(notice.suggestions.len(), 2);
assert_eq!(notice.suggestions[0].description, "Do this");
assert!(notice.suggestions[0].command.is_none());
assert_eq!(notice.suggestions[1].description, "Run this");
assert_eq!(
notice.suggestions[1].command,
Some("git status".to_string())
);
}
#[test]
fn test_notice_chaining() {
let messages = vec!["Test".to_string()];
let details = vec![("Key".to_string(), "Value".to_string())];
let suggestions = vec![Suggestion::new("Suggestion")];
let notice = NoticeWidget::error("Error", &messages)
.with_details(details)
.with_suggestions(suggestions);
assert_eq!(notice.details.len(), 1);
assert_eq!(notice.suggestions.len(), 1);
}
#[test]
fn test_notice_required_height_simple() {
let messages = vec!["Test message".to_string()];
let notice = NoticeWidget::success("Success!", &messages);
assert_eq!(notice.required_height(80), 7);
}
#[test]
fn test_notice_required_height_multiple_messages() {
let messages = vec![
"Message 1".to_string(),
"Message 2".to_string(),
"Message 3".to_string(),
];
let notice = NoticeWidget::success("Success!", &messages);
assert_eq!(notice.required_height(80), 9);
}
#[test]
fn test_notice_required_height_with_details() {
let messages = vec!["Test".to_string()];
let details = vec![
("Path".to_string(), "/path/to/worktree".to_string()),
("Branch".to_string(), "feature/test".to_string()),
];
let notice = NoticeWidget::error("Error", &messages).with_details(details);
assert_eq!(notice.required_height(80), 10);
}
#[test]
fn test_notice_required_height_with_suggestions() {
let messages = vec!["Test".to_string()];
let suggestions = vec![
Suggestion::new("Do this"),
Suggestion::with_command("Run this", "git status"),
];
let notice = NoticeWidget::error("Error", &messages).with_suggestions(suggestions);
assert_eq!(notice.required_height(80), 12);
}
#[test]
fn test_notice_required_height_full() {
let messages = vec!["Error message".to_string()];
let details = vec![("Key".to_string(), "Value".to_string())];
let suggestions = vec![Suggestion::with_command("Fix it", "git fix")];
let notice = NoticeWidget::error("Error", &messages)
.with_details(details)
.with_suggestions(suggestions);
assert_eq!(notice.required_height(80), 13);
}
#[test]
fn test_notice_required_height_with_wrapping() {
let short_message = "Short message that fits in one line".to_string();
let messages = vec![short_message];
let notice = NoticeWidget::success("Success!", &messages);
assert_eq!(notice.required_height(80), 7);
let long_message = "This is a very long message that should wrap to multiple lines when the width is narrow enough to cause text wrapping".to_string();
let messages_long = vec![long_message];
let notice_long = NoticeWidget::success("Success!", &messages_long);
let height_narrow = notice_long.required_height(22);
assert!(
height_narrow > 7,
"Narrow width should cause wrapping, got {}",
height_narrow
);
}
}