use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use super::{Component, EventContext, RenderContext, Toggleable};
use crate::input::{Event, Key};
use crate::theme::Theme;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ButtonConfig {
Ok,
OkCancel,
YesNo,
YesNoCancel,
}
impl ButtonConfig {
fn labels(&self) -> Vec<(&'static str, ConfirmDialogResult)> {
match self {
ButtonConfig::Ok => vec![("OK", ConfirmDialogResult::Ok)],
ButtonConfig::OkCancel => vec![
("OK", ConfirmDialogResult::Ok),
("Cancel", ConfirmDialogResult::Cancel),
],
ButtonConfig::YesNo => vec![
("Yes", ConfirmDialogResult::Yes),
("No", ConfirmDialogResult::No),
],
ButtonConfig::YesNoCancel => vec![
("Yes", ConfirmDialogResult::Yes),
("No", ConfirmDialogResult::No),
("Cancel", ConfirmDialogResult::Cancel),
],
}
}
fn button_count(&self) -> usize {
self.labels().len()
}
fn has_yes_no(&self) -> bool {
matches!(self, ButtonConfig::YesNo | ButtonConfig::YesNoCancel)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ConfirmDialogResult {
Ok,
Cancel,
Yes,
No,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConfirmDialogMessage {
FocusNext,
FocusPrev,
Press,
Close,
Open,
SelectResult(ConfirmDialogResult),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConfirmDialogOutput {
Confirmed(ConfirmDialogResult),
Closed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ConfirmDialogState {
title: String,
message: String,
button_config: ButtonConfig,
focused_button: usize,
visible: bool,
destructive_button: Option<usize>,
}
impl Default for ConfirmDialogState {
fn default() -> Self {
Self {
title: String::new(),
message: String::new(),
button_config: ButtonConfig::Ok,
focused_button: 0,
visible: false,
destructive_button: None,
}
}
}
impl ConfirmDialogState {
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
..Self::default()
}
}
pub fn ok(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
button_config: ButtonConfig::Ok,
..Self::default()
}
}
pub fn ok_cancel(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
button_config: ButtonConfig::OkCancel,
..Self::default()
}
}
pub fn yes_no(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
button_config: ButtonConfig::YesNo,
..Self::default()
}
}
pub fn yes_no_cancel(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: message.into(),
button_config: ButtonConfig::YesNoCancel,
..Self::default()
}
}
pub fn destructive(
title: impl Into<String>,
message: impl Into<String>,
config: ButtonConfig,
destructive_index: usize,
) -> Self {
Self {
title: title.into(),
message: message.into(),
button_config: config,
destructive_button: Some(destructive_index),
..Self::default()
}
}
pub fn with_button_config(mut self, config: ButtonConfig) -> Self {
self.button_config = config;
self
}
pub fn with_destructive_button(mut self, index: Option<usize>) -> Self {
self.destructive_button = index;
self
}
pub fn title(&self) -> &str {
&self.title
}
pub fn message(&self) -> &str {
&self.message
}
pub fn button_config(&self) -> &ButtonConfig {
&self.button_config
}
pub fn focused_button(&self) -> usize {
self.focused_button
}
pub fn destructive_button(&self) -> Option<usize> {
self.destructive_button
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn set_visible(&mut self, visible: bool) {
ConfirmDialog::set_visible(self, visible);
}
pub fn with_visible(mut self, visible: bool) -> Self {
ConfirmDialog::set_visible(&mut self, visible);
self
}
pub fn handle_event(&self, event: &Event) -> Option<ConfirmDialogMessage> {
ConfirmDialog::handle_event(self, event, &EventContext::default())
}
pub fn dispatch_event(&mut self, event: &Event) -> Option<ConfirmDialogOutput> {
ConfirmDialog::dispatch_event(self, event, &EventContext::default())
}
pub fn update(&mut self, msg: ConfirmDialogMessage) -> Option<ConfirmDialogOutput> {
ConfirmDialog::update(self, msg)
}
}
pub struct ConfirmDialog;
impl Component for ConfirmDialog {
type State = ConfirmDialogState;
type Message = ConfirmDialogMessage;
type Output = ConfirmDialogOutput;
fn init() -> Self::State {
ConfirmDialogState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if !state.visible {
if matches!(msg, ConfirmDialogMessage::Open) {
state.visible = true;
state.focused_button = 0;
}
return None;
}
let button_count = state.button_config.button_count();
match msg {
ConfirmDialogMessage::FocusNext => {
if button_count > 0 {
state.focused_button = (state.focused_button + 1) % button_count;
}
None
}
ConfirmDialogMessage::FocusPrev => {
if button_count > 0 {
state.focused_button = state
.focused_button
.checked_sub(1)
.unwrap_or(button_count - 1);
}
None
}
ConfirmDialogMessage::Press => {
let labels = state.button_config.labels();
labels.get(state.focused_button).map(|(_, result)| {
state.visible = false;
ConfirmDialogOutput::Confirmed(result.clone())
})
}
ConfirmDialogMessage::Close => {
state.visible = false;
Some(ConfirmDialogOutput::Closed)
}
ConfirmDialogMessage::Open => None,
ConfirmDialogMessage::SelectResult(result) => {
state.visible = false;
Some(ConfirmDialogOutput::Confirmed(result))
}
}
}
fn handle_event(
state: &Self::State,
event: &Event,
_ctx: &EventContext,
) -> Option<Self::Message> {
if !state.visible {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Tab if key.modifiers.shift() => Some(ConfirmDialogMessage::FocusPrev),
Key::Tab => Some(ConfirmDialogMessage::FocusNext),
Key::Enter => Some(ConfirmDialogMessage::Press),
Key::Esc => Some(ConfirmDialogMessage::Close),
Key::Char('y') if state.button_config.has_yes_no() => {
Some(ConfirmDialogMessage::SelectResult(ConfirmDialogResult::Yes))
}
Key::Char('n') if state.button_config.has_yes_no() => {
Some(ConfirmDialogMessage::SelectResult(ConfirmDialogResult::No))
}
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if !state.visible {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::ConfirmDialog)
.with_id("confirm_dialog")
.with_label(state.title.as_str())
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let dialog_width = (ctx.area.width * 60 / 100).clamp(30, 80);
let message_lines = state.message.lines().count().max(1) as u16;
let dialog_height = (5 + message_lines).min(ctx.area.height);
let dialog_area = crate::util::centered_rect(dialog_width, dialog_height, ctx.area);
ctx.frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(format!(" {} ", state.title))
.borders(Borders::ALL)
.border_style(ctx.theme.border_style());
let inner = block.inner(dialog_area);
ctx.frame.render_widget(block, dialog_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(3), ])
.split(inner);
let message = Paragraph::new(state.message.as_str()).wrap(Wrap { trim: true });
ctx.frame.render_widget(message, chunks[0]);
render_confirm_buttons(
state,
ctx.frame,
chunks[1],
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
impl Toggleable for ConfirmDialog {
fn is_visible(state: &Self::State) -> bool {
state.visible
}
fn set_visible(state: &mut Self::State, visible: bool) {
state.visible = visible;
if visible {
state.focused_button = 0;
}
}
}
fn render_confirm_buttons(
state: &ConfirmDialogState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
focused: bool,
disabled: bool,
) {
let labels = state.button_config.labels();
if labels.is_empty() {
return;
}
let button_widths: Vec<u16> = labels.iter().map(|(l, _)| (l.len() + 4) as u16).collect();
let total_width: u16 =
button_widths.iter().sum::<u16>() + (labels.len().saturating_sub(1) as u16 * 2);
let start_x = area.x + area.width.saturating_sub(total_width) / 2;
let mut x = start_x;
for (i, (label, _)) in labels.iter().enumerate() {
let width = button_widths[i];
let button_area = Rect::new(x, area.y, width, 3.min(area.height));
let is_focused = i == state.focused_button && focused;
let is_destructive = state.destructive_button == Some(i);
let style = if disabled {
theme.disabled_style()
} else if is_destructive && is_focused {
theme.error_style().add_modifier(Modifier::BOLD)
} else if is_destructive {
theme.error_style()
} else if is_focused {
theme.focused_bold_style()
} else {
theme.normal_style()
};
let border_style = if is_focused && !disabled {
theme.focused_border_style()
} else {
theme.border_style()
};
let btn = Paragraph::new(*label)
.style(style)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style),
);
frame.render_widget(btn, button_area);
x += width + 2;
}
}
#[cfg(test)]
mod tests;