From 0f9bee02d291d563a13148fca51a82fa121629ff Mon Sep 17 00:00:00 2001 From: Jean GOUDY Date: Tue, 20 Jan 2026 22:37:36 +0100 Subject: [PATCH 1/3] feat: add composable widgets module - StatusBar, Popup, InputField, ConfirmDialog widgets - Refactor tui_draw.rs to use widgets --- src/lib.rs | 1 + src/tui_draw.rs | 72 +++--------- src/widgets/confirm_dialog.rs | 186 +++++++++++++++++++++++++++++++ src/widgets/input_field.rs | 200 ++++++++++++++++++++++++++++++++++ src/widgets/mod.rs | 15 +++ src/widgets/popup.rs | 173 +++++++++++++++++++++++++++++ src/widgets/status_bar.rs | 103 +++++++++++++++++ 7 files changed, 694 insertions(+), 56 deletions(-) create mode 100644 src/widgets/confirm_dialog.rs create mode 100644 src/widgets/input_field.rs create mode 100644 src/widgets/mod.rs create mode 100644 src/widgets/popup.rs create mode 100644 src/widgets/status_bar.rs diff --git a/src/lib.rs b/src/lib.rs index b842fc0..be60a9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,3 +18,4 @@ pub mod tui_events; pub mod tui_image; pub mod tui_types; pub mod tui_utils; +pub mod widgets; diff --git a/src/tui_draw.rs b/src/tui_draw.rs index 7420514..718b999 100644 --- a/src/tui_draw.rs +++ b/src/tui_draw.rs @@ -10,6 +10,7 @@ use crate::issues::IssueContent; use crate::markdown::{parse_markdown_content, render_markdown_line}; use crate::tui_types::{CommandSuggestion, CreateStage, IssueFilterFocus, IssueStatus, PrFilterFocus, PrStatus, TuiView}; use crate::tui_utils::{format_date, truncate_str}; +use crate::widgets::{ConfirmDialog, Popup, StatusBar}; use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, @@ -494,33 +495,23 @@ fn build_list_title(browser: &IssueBrowser) -> String { format_status_bar(CommandContext::IssueList, &parts.join(" ")) } -/// Draw centered search popup +/// Draw centered search popup using composable widgets pub fn draw_search_popup(f: &mut Frame, input: &str) { let area = f.area(); - // Calculate centered popup area (50 chars wide, 3 lines tall) - let popup_width = 50.min(area.width.saturating_sub(4)); - let popup_height = 3; - let popup_x = (area.width.saturating_sub(popup_width)) / 2; - let popup_y = (area.height.saturating_sub(popup_height)) / 2; - - let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - - // Clear the background behind the popup - let clear = Block::default().style(Style::default().bg(Color::Black)); - f.render_widget(clear, popup_area); + // Use Popup widget for the container + let popup = Popup::new(" Search GitHub ") + .percent(50, 10) + .min_size(50, 3) + .border_color(Color::Yellow); - let block = Block::default() - .borders(Borders::ALL) - .title(" Search GitHub ") - .border_style(Style::default().fg(Color::Yellow)); + let inner = popup.inner(area); + f.render_widget(popup, area); + // Render the input text with cursor let text = format!("{}_", input); - let paragraph = Paragraph::new(text) - .block(block) - .style(Style::default().fg(Color::White)); - - f.render_widget(paragraph, popup_area); + let paragraph = Paragraph::new(text).style(Style::default().fg(Color::White)); + f.render_widget(paragraph, inner); } /// Draw issue detail view @@ -653,45 +644,14 @@ pub fn draw_comment_input(f: &mut Frame, area: Rect, input: &str, status: Option f.render_widget(paragraph, area); } -/// Draw confirmation dialog +/// Draw confirmation dialog using the ConfirmDialog widget pub fn draw_confirmation(f: &mut Frame, area: Rect, message: &str) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Yellow)); - - let paragraph = Paragraph::new(message) - .block(block) - .style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center); - - f.render_widget(paragraph, area); + f.render_widget(ConfirmDialog::new(message), area); } -/// Draw status bar +/// Draw status bar using the StatusBar widget pub fn draw_status_bar(f: &mut Frame, area: Rect, message: &str) { - let color = if message.contains("Failed") - || message.contains("No ") - || message.contains("error") - { - Color::Red - } else { - Color::Green - }; - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(color)); - - let paragraph = Paragraph::new(message) - .block(block) - .style(Style::default().fg(color)) - .alignment(Alignment::Center); - - f.render_widget(paragraph, area); + f.render_widget(StatusBar::new(message), area); } /// Draw assignee picker diff --git a/src/widgets/confirm_dialog.rs b/src/widgets/confirm_dialog.rs new file mode 100644 index 0000000..10a9bf6 --- /dev/null +++ b/src/widgets/confirm_dialog.rs @@ -0,0 +1,186 @@ +//! Confirmation dialog widget. + +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +/// A confirmation dialog widget with customizable message and hint. +/// +/// # Example +/// ``` +/// use assistant::widgets::ConfirmDialog; +/// +/// let dialog = ConfirmDialog::new("Close issue #42?") +/// .hint("y/n"); +/// ``` +#[derive(Debug, Clone)] +pub struct ConfirmDialog<'a> { + message: &'a str, + hint: Option<&'a str>, + border_color: Color, +} + +impl<'a> ConfirmDialog<'a> { + /// Create a new confirmation dialog with the given message. + pub fn new(message: &'a str) -> Self { + Self { + message, + hint: Some("y/n"), + border_color: Color::Yellow, + } + } + + /// Set the hint text shown after the message. + pub fn hint(mut self, hint: &'a str) -> Self { + self.hint = Some(hint); + self + } + + /// Remove the hint text. + pub fn no_hint(mut self) -> Self { + self.hint = None; + self + } + + /// Set the border color. + pub fn border_color(mut self, color: Color) -> Self { + self.border_color = color; + self + } +} + +impl Widget for ConfirmDialog<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(self.border_color)); + + let text = if let Some(hint) = self.hint { + format!("{} ({})", self.message, hint) + } else { + self.message.to_string() + }; + + let paragraph = Paragraph::new(text) + .block(block) + .style( + Style::default() + .fg(self.border_color) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center); + + paragraph.render(area, buf); + } +} + +/// A more detailed confirmation dialog with title, body, and action hints. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DetailedConfirmDialog<'a> { + title: &'a str, + body_lines: Vec<&'a str>, + confirm_key: &'a str, + confirm_label: &'a str, + cancel_key: &'a str, + cancel_label: &'a str, +} + +#[allow(dead_code)] +impl<'a> DetailedConfirmDialog<'a> { + pub fn new(title: &'a str) -> Self { + Self { + title, + body_lines: Vec::new(), + confirm_key: "y", + confirm_label: "Yes", + cancel_key: "n", + cancel_label: "No", + } + } + + pub fn body(mut self, lines: Vec<&'a str>) -> Self { + self.body_lines = lines; + self + } + + pub fn confirm(mut self, key: &'a str, label: &'a str) -> Self { + self.confirm_key = key; + self.confirm_label = label; + self + } + + pub fn cancel(mut self, key: &'a str, label: &'a str) -> Self { + self.cancel_key = key; + self.cancel_label = label; + self + } +} + +impl Widget for DetailedConfirmDialog<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + use ratatui::text::{Line, Span}; + + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", self.title)) + .style(Style::default().bg(Color::Black)); + + let inner = block.inner(area); + block.render(area, buf); + + let mut lines = vec![Line::from("")]; + + for body_line in &self.body_lines { + lines.push(Line::from(*body_line)); + } + + lines.push(Line::from("")); + lines.push(Line::from("")); + + // Action hints + lines.push(Line::from(vec![ + Span::styled( + self.confirm_key, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(": {} │ ", self.confirm_label)), + Span::styled( + self.cancel_key, + Style::default() + .fg(Color::Red) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!(": {}", self.cancel_label)), + ])); + + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + paragraph.render(inner, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confirm_dialog_creation() { + let dialog = ConfirmDialog::new("Delete?").hint("y/n"); + assert_eq!(dialog.message, "Delete?"); + assert_eq!(dialog.hint, Some("y/n")); + } + + #[test] + fn test_detailed_dialog() { + let dialog = DetailedConfirmDialog::new("Confirm") + .body(vec!["Line 1", "Line 2"]) + .confirm("y", "Yes, do it") + .cancel("n", "Cancel"); + assert_eq!(dialog.title, "Confirm"); + assert_eq!(dialog.body_lines.len(), 2); + } +} diff --git a/src/widgets/input_field.rs b/src/widgets/input_field.rs new file mode 100644 index 0000000..0eeac77 --- /dev/null +++ b/src/widgets/input_field.rs @@ -0,0 +1,200 @@ +//! Input field widget for text entry. + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph, Widget, Wrap}, +}; + +/// A text input field with placeholder support and cursor display. +/// +/// # Example +/// ``` +/// use assistant::widgets::InputField; +/// +/// let input = InputField::new("search_term") +/// .placeholder("Type to search...") +/// .title(" Search "); +/// ``` +#[derive(Debug, Clone)] +pub struct InputField<'a> { + value: &'a str, + placeholder: Option<&'a str>, + title: Option<&'a str>, + border_color: Color, + show_cursor: bool, + focused: bool, + prefix: Option<&'a str>, +} + +impl<'a> InputField<'a> { + /// Create a new input field with the current value. + pub fn new(value: &'a str) -> Self { + Self { + value, + placeholder: None, + title: None, + border_color: Color::Yellow, + show_cursor: true, + focused: true, + prefix: None, + } + } + + /// Set the placeholder text shown when the field is empty. + pub fn placeholder(mut self, text: &'a str) -> Self { + self.placeholder = Some(text); + self + } + + /// Set the title displayed in the border. + pub fn title(mut self, title: &'a str) -> Self { + self.title = Some(title); + self + } + + /// Set the border color. + pub fn border_color(mut self, color: Color) -> Self { + self.border_color = color; + self + } + + /// Set whether to show the cursor. + pub fn show_cursor(mut self, show: bool) -> Self { + self.show_cursor = show; + self + } + + /// Set whether the field is focused. + pub fn focused(mut self, focused: bool) -> Self { + self.focused = focused; + self + } + + /// Set a prefix to display before the input (e.g., "/" for commands, "@" for usernames). + pub fn prefix(mut self, prefix: &'a str) -> Self { + self.prefix = Some(prefix); + self + } +} + +impl Widget for InputField<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let border_style = if self.focused { + Style::default().fg(self.border_color) + } else { + Style::default().fg(Color::DarkGray) + }; + + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(border_style); + + if let Some(title) = self.title { + block = block.title(title); + } + + let (display_text, text_style) = if self.value.is_empty() { + let text = self.placeholder.unwrap_or(""); + (text.to_string(), Style::default().fg(Color::DarkGray)) + } else { + let prefix = self.prefix.unwrap_or(""); + let cursor = if self.show_cursor && self.focused { + "_" + } else { + "" + }; + ( + format!("{}{}{}", prefix, self.value, cursor), + Style::default().fg(Color::White), + ) + }; + + let paragraph = Paragraph::new(display_text) + .block(block) + .style(text_style) + .wrap(Wrap { trim: false }); + + paragraph.render(area, buf); + } +} + +/// A simpler inline input (no borders) for use inside other widgets. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct InlineInput<'a> { + value: &'a str, + placeholder: Option<&'a str>, + prefix: Option<&'a str>, + show_cursor: bool, +} + +#[allow(dead_code)] +impl<'a> InlineInput<'a> { + pub fn new(value: &'a str) -> Self { + Self { + value, + placeholder: None, + prefix: None, + show_cursor: true, + } + } + + pub fn placeholder(mut self, text: &'a str) -> Self { + self.placeholder = Some(text); + self + } + + pub fn prefix(mut self, prefix: &'a str) -> Self { + self.prefix = Some(prefix); + self + } + + pub fn show_cursor(mut self, show: bool) -> Self { + self.show_cursor = show; + self + } +} + +impl Widget for InlineInput<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let (text, style) = if self.value.is_empty() { + ( + self.placeholder.unwrap_or("").to_string(), + Style::default().fg(Color::DarkGray), + ) + } else { + let prefix = self.prefix.unwrap_or(""); + let cursor = if self.show_cursor { "_" } else { "" }; + ( + format!("{}{}{}", prefix, self.value, cursor), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + }; + + let paragraph = Paragraph::new(text).style(style); + paragraph.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_input_field_creation() { + let input = InputField::new("test") + .placeholder("Enter text") + .title(" Input "); + assert_eq!(input.value, "test"); + assert_eq!(input.placeholder, Some("Enter text")); + } + + #[test] + fn test_input_with_prefix() { + let input = InputField::new("search").prefix("/"); + assert_eq!(input.prefix, Some("/")); + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..0ce1831 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,15 @@ +//! Composable widgets for the TUI. +//! +//! This module provides reusable widget components that encapsulate +//! both state and rendering logic, making the UI code more modular +//! and easier to maintain. + +mod confirm_dialog; +mod input_field; +mod popup; +mod status_bar; + +pub use confirm_dialog::ConfirmDialog; +pub use input_field::InputField; +pub use popup::Popup; +pub use status_bar::StatusBar; diff --git a/src/widgets/popup.rs b/src/widgets/popup.rs new file mode 100644 index 0000000..1c38ea2 --- /dev/null +++ b/src/widgets/popup.rs @@ -0,0 +1,173 @@ +//! Popup widget for centered modal dialogs. + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Clear, Widget}, +}; + +/// A centered popup container that clears the background and draws a bordered box. +/// +/// # Example +/// ``` +/// use assistant::widgets::Popup; +/// use ratatui::style::Color; +/// +/// let popup = Popup::new(" Search ") +/// .percent(50, 30) +/// .border_color(Color::Yellow); +/// ``` +#[derive(Debug, Clone)] +pub struct Popup<'a> { + title: &'a str, + percent_x: u16, + percent_y: u16, + border_color: Color, + min_width: u16, + min_height: u16, +} + +impl<'a> Popup<'a> { + /// Create a new popup with the given title. + pub fn new(title: &'a str) -> Self { + Self { + title, + percent_x: 50, + percent_y: 30, + border_color: Color::Cyan, + min_width: 20, + min_height: 5, + } + } + + /// Set the popup size as a percentage of the parent area. + pub fn percent(mut self, x: u16, y: u16) -> Self { + self.percent_x = x; + self.percent_y = y; + self + } + + /// Set the border color. + pub fn border_color(mut self, color: Color) -> Self { + self.border_color = color; + self + } + + /// Set minimum dimensions. + pub fn min_size(mut self, width: u16, height: u16) -> Self { + self.min_width = width; + self.min_height = height; + self + } + + /// Calculate the centered popup area within the given outer area. + pub fn area(&self, outer: Rect) -> Rect { + let popup_width = (outer.width * self.percent_x / 100) + .max(self.min_width) + .min(outer.width.saturating_sub(4)); + let popup_height = (outer.height * self.percent_y / 100) + .max(self.min_height) + .min(outer.height.saturating_sub(4)); + let popup_x = (outer.width.saturating_sub(popup_width)) / 2; + let popup_y = (outer.height.saturating_sub(popup_height)) / 2; + + Rect::new(popup_x, popup_y, popup_width, popup_height) + } + + /// Get the inner area (inside the borders) for content rendering. + pub fn inner(&self, outer: Rect) -> Rect { + let popup_area = self.area(outer); + let block = Block::default().borders(Borders::ALL); + block.inner(popup_area) + } +} + +impl Widget for Popup<'_> { + fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + let popup_area = self.area(area); + + // Clear the background + Clear.render(popup_area, buf); + + // Draw the bordered box + let block = Block::default() + .borders(Borders::ALL) + .title(self.title) + .border_style(Style::default().fg(self.border_color)) + .style(Style::default().bg(Color::Black)); + + block.render(popup_area, buf); + } +} + +/// Helper to render a popup and return its inner area for content. +/// +/// # Example +/// ``` +/// use assistant::widgets::render_popup; +/// use ratatui::Frame; +/// +/// fn draw(f: &mut Frame) { +/// let inner = render_popup(f, " Title ", 50, 30); +/// // Now render content in `inner` +/// } +/// ``` +#[allow(dead_code)] +pub fn render_popup( + f: &mut ratatui::Frame, + title: &str, + percent_x: u16, + percent_y: u16, +) -> Rect { + let popup = Popup::new(title).percent(percent_x, percent_y); + let area = f.area(); + let inner = popup.inner(area); + f.render_widget(popup, area); + inner +} + +/// Render a popup with custom border color and return its inner area. +#[allow(dead_code)] +pub fn render_popup_colored( + f: &mut ratatui::Frame, + title: &str, + percent_x: u16, + percent_y: u16, + border_color: Color, +) -> Rect { + let popup = Popup::new(title) + .percent(percent_x, percent_y) + .border_color(border_color); + let area = f.area(); + let inner = popup.inner(area); + f.render_widget(popup, area); + inner +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_popup_area_calculation() { + let popup = Popup::new("Test").percent(50, 50); + let outer = Rect::new(0, 0, 100, 50); + let area = popup.area(outer); + + assert_eq!(area.width, 50); + assert_eq!(area.height, 25); + assert_eq!(area.x, 25); + assert_eq!(area.y, 12); + } + + #[test] + fn test_popup_min_size() { + let popup = Popup::new("Test").percent(10, 10).min_size(30, 10); + let outer = Rect::new(0, 0, 100, 50); + let area = popup.area(outer); + + // Should use min_size since 10% is smaller + assert_eq!(area.width, 30); + assert_eq!(area.height, 10); + } +} diff --git a/src/widgets/status_bar.rs b/src/widgets/status_bar.rs new file mode 100644 index 0000000..2af7648 --- /dev/null +++ b/src/widgets/status_bar.rs @@ -0,0 +1,103 @@ +//! Status bar widget for displaying messages at the bottom of the screen. + +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +/// A status bar widget that displays messages with automatic color coding. +/// +/// # Example +/// ``` +/// use assistant::widgets::StatusBar; +/// +/// let status = StatusBar::new("Operation successful"); +/// // Renders as a green bordered box with centered text +/// +/// let error = StatusBar::new("Failed to connect"); +/// // Automatically renders as red because message contains "Failed" +/// ``` +#[derive(Debug, Clone)] +pub struct StatusBar<'a> { + message: &'a str, + style: Option