use crate::backend::{MessageDialogChoice, MessageDialogOptions};
use crate::event::{Point, Rect, UiEvent};
use crate::primitives::dialog::{Dialog, DialogButton, DialogHit, DialogLayout, DialogMeasure};
use crate::primitives::toolbar::ToolbarItemMeasure;
use crate::types::StyledText;
use crate::{Backend, Key, Modifiers, NamedKey};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageDialogEvent {
Resolved(MessageDialogChoice),
Consumed,
Ignored,
}
pub struct MessageDialogController {
dialog: Dialog,
focused: usize,
}
impl MessageDialogController {
pub fn new(opts: MessageDialogOptions) -> Self {
let buttons: Vec<DialogButton> = opts
.buttons
.iter()
.map(|b| DialogButton {
id: b.id,
label: b.label.clone(),
is_default: b.is_default,
is_cancel: b.is_cancel,
tint: None,
})
.collect();
let focused = buttons.iter().position(|b| b.is_default).unwrap_or(0);
let dialog = Dialog {
id: crate::types::WidgetId::new("message_dialog"),
title: StyledText::plain(opts.title),
body: opts.body.lines().map(StyledText::plain).collect(),
buttons,
severity: opts.severity,
vertical_buttons: false,
table: None,
input: None,
};
Self { dialog, focused }
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.dialog.id = crate::types::WidgetId::new(id.into());
self
}
pub fn with_vertical_buttons(mut self, vertical: bool) -> Self {
self.dialog.vertical_buttons = vertical;
self
}
pub fn dialog(&self) -> &Dialog {
&self.dialog
}
pub fn focused_button_id(&self) -> Option<&crate::types::WidgetId> {
self.dialog.buttons.get(self.focused).map(|b| &b.id)
}
fn layout(&self, backend: &dyn Backend) -> DialogLayout {
let m = backend.measure();
let lh = m.line_height;
let char_w = m.char_width;
let viewport = backend.viewport();
let has_title = self.dialog.title.spans.iter().any(|s| !s.text.is_empty());
let measure = DialogMeasure {
width: (viewport.width * 0.5).clamp(char_w * 24.0, char_w * 64.0),
title_height: if has_title { lh } else { 0.0 },
body_height: lh * self.dialog.body.len().max(1) as f32,
table_height: 0.0,
input_height: 0.0,
button_row_height: lh,
button_width: char_w * 10.0,
button_gap: char_w * 2.0,
padding: lh,
};
let viewport_rect = Rect::new(0.0, 0.0, viewport.width, viewport.height);
self.dialog
.layout(viewport_rect, measure, |_| ToolbarItemMeasure::new(0.0))
}
pub fn render(&self, backend: &mut dyn Backend) {
let layout = self.layout(backend);
backend.draw_dialog(&self.dialog, &layout);
}
pub fn handle(&mut self, event: &UiEvent, backend: &dyn Backend) -> MessageDialogEvent {
match event {
UiEvent::KeyPressed { key, modifiers, .. } => self.handle_key(key, modifiers),
UiEvent::MouseDown { position, .. } => self.handle_click(*position, backend),
_ => MessageDialogEvent::Ignored,
}
}
fn handle_key(&mut self, key: &Key, modifiers: &Modifiers) -> MessageDialogEvent {
let n = self.dialog.buttons.len();
match key {
Key::Named(NamedKey::Escape) => {
match self
.dialog
.cancel_button_id()
.or_else(|| self.dialog.default_button_id())
{
Some(id) => MessageDialogEvent::Resolved(*id),
None => MessageDialogEvent::Ignored,
}
}
Key::Named(NamedKey::Enter) => match self.dialog.buttons.get(self.focused) {
Some(b) => MessageDialogEvent::Resolved(b.id),
None => MessageDialogEvent::Ignored,
},
Key::Named(NamedKey::Left) if n > 0 => {
self.focused = (self.focused + n - 1) % n;
MessageDialogEvent::Consumed
}
Key::Named(NamedKey::Right) if n > 0 => {
self.focused = (self.focused + 1) % n;
MessageDialogEvent::Consumed
}
Key::Named(NamedKey::Tab) if n > 0 => {
self.focused = if modifiers.shift {
(self.focused + n - 1) % n
} else {
(self.focused + 1) % n
};
MessageDialogEvent::Consumed
}
_ => MessageDialogEvent::Ignored,
}
}
fn handle_click(&mut self, position: Point, backend: &dyn Backend) -> MessageDialogEvent {
let layout = self.layout(backend);
match layout.hit_test(position.x, position.y) {
DialogHit::Button(id) => MessageDialogEvent::Resolved(id),
_ => MessageDialogEvent::Consumed,
}
}
}
#[cfg(all(test, feature = "tui"))]
mod tests {
use super::*;
use crate::backend::MessageDialogButton;
use crate::types::WidgetId;
fn opts_two_buttons() -> MessageDialogOptions {
MessageDialogOptions {
title: "Unsaved Changes".to_string(),
body: "Do you want to save?\nChanges will be lost.".to_string(),
buttons: vec![
MessageDialogButton {
id: WidgetId::new("save"),
label: "Save".into(),
is_default: true,
is_cancel: false,
},
MessageDialogButton {
id: WidgetId::new("cancel"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
},
],
severity: Some(crate::primitives::dialog::DialogSeverity::Warning),
}
}
fn opts_ok_only() -> MessageDialogOptions {
MessageDialogOptions {
title: "Heads up".to_string(),
body: "Something happened.".to_string(),
buttons: vec![MessageDialogButton {
id: WidgetId::new("ok"),
label: "OK".into(),
is_default: true,
is_cancel: false,
}],
severity: None,
}
}
#[test]
fn new_splits_body_into_lines() {
let controller = MessageDialogController::new(opts_two_buttons());
assert_eq!(controller.dialog().body.len(), 2);
}
#[test]
fn new_focuses_default_button() {
let controller = MessageDialogController::new(opts_two_buttons());
assert_eq!(controller.focused_button_id().unwrap().as_str(), "save");
}
#[test]
fn new_focuses_first_button_when_no_default() {
let mut opts = opts_two_buttons();
opts.buttons[0].is_default = false;
let controller = MessageDialogController::new(opts);
assert_eq!(controller.focused_button_id().unwrap().as_str(), "save");
}
#[test]
fn enter_resolves_focused_button() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let ev = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
let backend = crate::tui::backend::TuiBackend::new();
assert_eq!(
controller.handle(&ev, &backend),
MessageDialogEvent::Resolved(WidgetId::new("save"))
);
}
#[test]
fn right_then_enter_resolves_second_button() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let right = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Right),
modifiers: Modifiers::default(),
repeat: false,
};
assert_eq!(
controller.handle(&right, &backend),
MessageDialogEvent::Consumed
);
assert_eq!(controller.focused_button_id().unwrap().as_str(), "cancel");
let enter = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Enter),
modifiers: Modifiers::default(),
repeat: false,
};
assert_eq!(
controller.handle(&enter, &backend),
MessageDialogEvent::Resolved(WidgetId::new("cancel"))
);
}
#[test]
fn right_wraps_around() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let right = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Right),
modifiers: Modifiers::default(),
repeat: false,
};
controller.handle(&right, &backend);
controller.handle(&right, &backend);
assert_eq!(controller.focused_button_id().unwrap().as_str(), "save");
}
#[test]
fn left_wraps_around_from_first() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let left = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Left),
modifiers: Modifiers::default(),
repeat: false,
};
controller.handle(&left, &backend);
assert_eq!(controller.focused_button_id().unwrap().as_str(), "cancel");
}
#[test]
fn tab_cycles_focus_forward() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let tab = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Tab),
modifiers: Modifiers::default(),
repeat: false,
};
controller.handle(&tab, &backend);
assert_eq!(controller.focused_button_id().unwrap().as_str(), "cancel");
}
#[test]
fn shift_tab_cycles_focus_backward() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let shift_tab = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Tab),
modifiers: Modifiers {
shift: true,
..Modifiers::default()
},
repeat: false,
};
controller.handle(&shift_tab, &backend);
assert_eq!(controller.focused_button_id().unwrap().as_str(), "cancel");
}
#[test]
fn escape_resolves_cancel_button() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let esc = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
};
assert_eq!(
controller.handle(&esc, &backend),
MessageDialogEvent::Resolved(WidgetId::new("cancel"))
);
}
#[test]
fn escape_falls_back_to_default_when_no_cancel_button() {
let mut controller = MessageDialogController::new(opts_ok_only());
let backend = crate::tui::backend::TuiBackend::new();
let esc = UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
modifiers: Modifiers::default(),
repeat: false,
};
assert_eq!(
controller.handle(&esc, &backend),
MessageDialogEvent::Resolved(WidgetId::new("ok"))
);
}
#[test]
fn click_on_button_resolves_it() {
let controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let layout = controller.layout(&backend);
let btn = &layout.visible_buttons[0];
let cx = btn.bounds.x + 1.0;
let cy = btn.bounds.y;
let mut controller = controller;
let ev = UiEvent::MouseDown {
widget: None,
button: crate::MouseButton::Left,
position: Point::new(cx, cy),
modifiers: Modifiers::default(),
};
let result = controller.handle(&ev, &backend);
assert!(matches!(result, MessageDialogEvent::Resolved(_)));
}
#[test]
fn click_outside_buttons_is_consumed_not_ignored() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let ev = UiEvent::MouseDown {
widget: None,
button: crate::MouseButton::Left,
position: Point::new(-100.0, -100.0),
modifiers: Modifiers::default(),
};
assert_eq!(
controller.handle(&ev, &backend),
MessageDialogEvent::Consumed
);
}
#[test]
fn with_id_overrides_widget_id() {
let controller = MessageDialogController::new(opts_two_buttons()).with_id("confirm_close");
assert_eq!(controller.dialog().id.as_str(), "confirm_close");
}
#[test]
fn with_vertical_buttons_sets_flag() {
let controller =
MessageDialogController::new(opts_two_buttons()).with_vertical_buttons(true);
assert!(controller.dialog().vertical_buttons);
}
#[test]
fn other_events_are_ignored() {
let mut controller = MessageDialogController::new(opts_two_buttons());
let backend = crate::tui::backend::TuiBackend::new();
let ev = UiEvent::CharTyped('x');
assert_eq!(
controller.handle(&ev, &backend),
MessageDialogEvent::Ignored
);
}
}