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 struct DialogButton {
id: String,
label: String,
}
impl DialogButton {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
}
}
pub fn id(&self) -> &str {
&self.id
}
pub fn label(&self) -> &str {
&self.label
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DialogMessage {
FocusNext,
FocusPrev,
Press,
Close,
Open,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DialogOutput {
ButtonPressed(String),
Closed,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct DialogState {
title: String,
message: String,
buttons: Vec<DialogButton>,
primary_button: usize,
focused_button: usize,
visible: bool,
}
impl DialogState {
pub fn new(
title: impl Into<String>,
message: impl Into<String>,
buttons: Vec<DialogButton>,
) -> Self {
Self {
title: title.into(),
message: message.into(),
buttons,
primary_button: 0,
focused_button: 0,
visible: false,
}
}
pub fn with_primary(
title: impl Into<String>,
message: impl Into<String>,
buttons: Vec<DialogButton>,
primary: usize,
) -> Self {
let primary = if buttons.is_empty() {
0
} else {
primary.min(buttons.len() - 1)
};
Self {
title: title.into(),
message: message.into(),
buttons,
primary_button: primary,
focused_button: primary,
visible: false,
}
}
pub fn alert(title: impl Into<String>, message: impl Into<String>) -> Self {
Self::new(title, message, vec![DialogButton::new("ok", "OK")])
}
pub fn confirm(title: impl Into<String>, message: impl Into<String>) -> Self {
Self::with_primary(
title,
message,
vec![
DialogButton::new("cancel", "Cancel"),
DialogButton::new("ok", "OK"),
],
1,
)
}
pub fn title(&self) -> &str {
&self.title
}
pub fn message(&self) -> &str {
&self.message
}
pub fn buttons(&self) -> &[DialogButton] {
&self.buttons
}
pub fn primary_button(&self) -> usize {
self.primary_button
}
pub fn focused_button(&self) -> usize {
self.focused_button
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
pub fn set_message(&mut self, message: impl Into<String>) {
self.message = message.into();
}
pub fn set_buttons(&mut self, buttons: Vec<DialogButton>) {
self.buttons = buttons;
if self.buttons.is_empty() {
self.primary_button = 0;
self.focused_button = 0;
} else {
self.primary_button = self.primary_button.min(self.buttons.len() - 1);
self.focused_button = self.primary_button;
}
}
pub fn set_primary_button(&mut self, index: usize) {
if self.buttons.is_empty() {
self.primary_button = 0;
} else {
self.primary_button = index.min(self.buttons.len() - 1);
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self
}
pub fn with_buttons(mut self, buttons: Vec<DialogButton>) -> Self {
self.set_buttons(buttons);
self
}
pub fn with_primary_button(mut self, index: usize) -> Self {
self.set_primary_button(index);
self
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn set_visible(&mut self, visible: bool) {
Dialog::set_visible(self, visible);
}
pub fn with_visible(mut self, visible: bool) -> Self {
Dialog::set_visible(&mut self, visible);
self
}
pub fn handle_event(&self, event: &Event) -> Option<DialogMessage> {
Dialog::handle_event(self, event, &EventContext::default())
}
pub fn dispatch_event(&mut self, event: &Event) -> Option<DialogOutput> {
Dialog::dispatch_event(self, event, &EventContext::default())
}
pub fn update(&mut self, msg: DialogMessage) -> Option<DialogOutput> {
Dialog::update(self, msg)
}
}
pub struct Dialog;
impl Component for Dialog {
type State = DialogState;
type Message = DialogMessage;
type Output = DialogOutput;
fn init() -> Self::State {
DialogState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if !state.visible {
if matches!(msg, DialogMessage::Open) {
state.visible = true;
state.focused_button = state.primary_button;
}
return None;
}
match msg {
DialogMessage::FocusNext => {
if !state.buttons.is_empty() {
state.focused_button = (state.focused_button + 1) % state.buttons.len();
}
None
}
DialogMessage::FocusPrev => {
if !state.buttons.is_empty() {
state.focused_button = state
.focused_button
.checked_sub(1)
.unwrap_or(state.buttons.len() - 1);
}
None
}
DialogMessage::Press => state.buttons.get(state.focused_button).map(|btn| {
state.visible = false;
DialogOutput::ButtonPressed(btn.id.clone())
}),
DialogMessage::Close => {
state.visible = false;
Some(DialogOutput::Closed)
}
DialogMessage::Open => None,
}
}
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(DialogMessage::FocusPrev),
Key::Tab => Some(DialogMessage::FocusNext),
Key::Enter => Some(DialogMessage::Press),
Key::Esc => Some(DialogMessage::Close),
_ => 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::dialog(state.title.as_str())
.with_id("dialog")
.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 = 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_buttons(
state,
ctx.frame,
chunks[1],
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
impl Toggleable for Dialog {
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 = state.primary_button;
}
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
crate::util::centered_rect(width, height, area)
}
fn render_buttons(
state: &DialogState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
focused: bool,
disabled: bool,
) {
if state.buttons.is_empty() {
return;
}
let button_widths: Vec<u16> = state
.buttons
.iter()
.map(|b| (b.label.len() + 4) as u16)
.collect();
let total_width: u16 =
button_widths.iter().sum::<u16>() + (state.buttons.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, button) in state.buttons.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_primary = i == state.primary_button;
let style = if disabled {
theme.disabled_style()
} else if is_focused {
theme.focused_bold_style()
} else if is_primary {
Style::default().add_modifier(Modifier::BOLD)
} else {
theme.normal_style()
};
let border_style = if is_focused && !disabled {
theme.focused_border_style()
} else {
theme.border_style()
};
let btn = Paragraph::new(button.label.as_str())
.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;