use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, StatefulWidget, Widget,
};
use super::super::style::{ALL_STATUSES, Theme};
pub(in crate::tui) struct StatusModalWidget<'a> {
pub selected: usize,
pub theme: &'a Theme,
}
impl<'a> StatusModalWidget<'a> {
pub fn new(selected: usize, theme: &'a Theme) -> Self {
Self { selected, theme }
}
}
impl Widget for StatusModalWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let modal_width = 30u16;
let modal_height = (ALL_STATUSES.len() as u16) + 2;
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width, modal_height);
Clear.render(modal_area, buf);
let items: Vec<ListItem> = ALL_STATUSES
.iter()
.enumerate()
.map(|(i, status)| {
let marker = if i == self.selected { "▶ " } else { " " };
ListItem::new(format!("{marker}{status}"))
})
.collect();
let mut modal_list_state = ListState::default();
modal_list_state.select(Some(self.selected));
let list = List::new(items)
.block(
Block::default()
.title(" Set Status ")
.borders(Borders::ALL)
.style(Style::default().bg(self.theme.modal_bg)),
)
.highlight_style(self.theme.highlight_style());
StatefulWidget::render(list, modal_area, buf, &mut modal_list_state);
}
}
pub(in crate::tui) struct InputModalWidget<'a> {
pub title: &'a str,
pub input: &'a str,
pub theme: &'a Theme,
}
impl<'a> InputModalWidget<'a> {
pub fn new(title: &'a str, input: &'a str, theme: &'a Theme) -> Self {
Self {
title,
input,
theme,
}
}
}
impl Widget for InputModalWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let modal_width = (area.width / 2).max(30).min(area.width);
let modal_height = 3u16;
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width, modal_height);
render_text_box(buf, modal_area, self.title, self.input, self.theme);
}
}
pub(in crate::tui) struct InputPromptWidget<'a> {
pub title: &'a str,
pub input: &'a str,
pub theme: &'a Theme,
}
impl<'a> InputPromptWidget<'a> {
pub fn new(title: &'a str, input: &'a str, theme: &'a Theme) -> Self {
Self {
title,
input,
theme,
}
}
}
impl Widget for InputPromptWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let input_area = Rect::new(
area.x,
area.y + area.height.saturating_sub(3),
area.width,
3,
);
render_text_box(buf, input_area, self.title, self.input, self.theme);
}
}
pub(in crate::tui) fn truncate_input_for_display(input: &str, inner_width: usize) -> String {
let char_count = input.chars().count();
if char_count > inner_width && inner_width > 1 {
let tail_len = inner_width - 1; let tail: String = input
.chars()
.rev()
.take(tail_len)
.collect::<String>()
.chars()
.rev()
.collect();
format!("…{tail}")
} else {
input.to_string()
}
}
fn render_text_box(buf: &mut Buffer, area: Rect, title: &str, input: &str, theme: &Theme) {
Clear.render(area, buf);
let inner_width = area.width.saturating_sub(2) as usize;
let display_text = truncate_input_for_display(input, inner_width);
Paragraph::new(display_text)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.style(Style::default().bg(theme.modal_bg)),
)
.style(Style::default().fg(theme.title_fg))
.render(area, buf);
}
#[cfg(test)]
mod tests {
use super::truncate_input_for_display;
#[test]
fn short_input_returned_unchanged() {
assert_eq!(truncate_input_for_display("hello", 10), "hello");
}
#[test]
fn exact_fit_not_truncated() {
assert_eq!(truncate_input_for_display("hello", 5), "hello");
}
#[test]
fn ascii_truncation_adds_ellipsis() {
let result = truncate_input_for_display("abcdefghij", 5);
assert!(result.starts_with('…'), "should start with ellipsis");
assert_eq!(
result.chars().count(),
5,
"should be exactly inner_width chars"
);
}
#[test]
fn multibyte_utf8_no_panic_and_correct_tail() {
let input = "café naïve résumé 🦀 done";
let inner_width = 8;
let result = truncate_input_for_display(input, inner_width);
assert!(
result.starts_with('…'),
"result should start with ellipsis: {result:?}"
);
assert_eq!(
result.chars().count(),
inner_width,
"result should be exactly inner_width chars: {result:?}"
);
let expected_tail: String = input
.chars()
.rev()
.take(inner_width - 1)
.collect::<String>()
.chars()
.rev()
.collect();
assert!(
result.ends_with(&expected_tail),
"result {result:?} should end with {expected_tail:?}"
);
}
#[test]
fn emoji_only_input_no_panic() {
let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀"; let result = truncate_input_for_display(input, 5);
assert!(result.starts_with('…'));
assert_eq!(result.chars().count(), 5);
}
}