Dialog
Group: Overlays
Code
/// Id the dialog is registered under in the [`quadraui::ModalStack`]. The
/// same id comes back on `MouseDown { widget }` for clicks inside it.
const DIALOG_ID: &str = "gallery:dialog:confirm-delete";
/// Rows in the list — enough that the centred dialog covers a
/// contiguous middle band, including one row's own text.
const ROW_COUNT: usize = 14;
pub struct DialogDemo {
rows: Vec<String>,
selected: Option<String>,
dialog_open: bool,
}
impl DialogDemo {
pub fn new() -> Self {
Self {
rows: (0..ROW_COUNT).map(|i| format!("pod-{i:02}")).collect(),
selected: None,
dialog_open: false,
}
}
fn dialog(&self, variant: usize) -> Dialog {
match variant {
0 => Dialog {
id: WidgetId::new(DIALOG_ID),
title: StyledText::plain("Confirm delete"),
body: vec![
StyledText::plain("Really delete?"),
StyledText::plain("This cannot be undone."),
],
table: None,
buttons: vec![
DialogButton {
id: WidgetId::new("gallery:dialog:ok"),
label: "OK".into(),
is_default: true,
is_cancel: false,
tint: None,
},
DialogButton {
id: WidgetId::new("gallery:dialog:cancel"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
tint: None,
},
],
severity: None,
vertical_buttons: false,
input: None,
},
1 => Dialog {
id: WidgetId::new(DIALOG_ID),
title: StyledText::plain("Delete permanently?"),
body: vec![
StyledText::plain("This removes the pod and all its logs."),
StyledText::plain("This cannot be undone."),
],
table: None,
buttons: vec![
DialogButton {
id: WidgetId::new("gallery:dialog:keep"),
label: "Keep".into(),
is_default: true,
is_cancel: true,
tint: None,
},
DialogButton {
id: WidgetId::new("gallery:dialog:delete"),
label: "Delete".into(),
is_default: false,
is_cancel: false,
tint: Some(Color::rgb(200, 60, 60)),
},
],
severity: Some(DialogSeverity::Error),
vertical_buttons: false,
input: None,
},
_ => Dialog {
id: WidgetId::new(DIALOG_ID),
title: StyledText::plain("Unsaved changes"),
body: vec![StyledText::plain(
"What should happen to your changes before closing?",
)],
table: None,
buttons: vec![
DialogButton {
id: WidgetId::new("gallery:dialog:save"),
label: "Save".into(),
is_default: true,
is_cancel: false,
tint: None,
},
DialogButton {
id: WidgetId::new("gallery:dialog:discard"),
label: "Discard".into(),
is_default: false,
is_cancel: false,
tint: Some(Color::rgb(200, 60, 60)),
},
DialogButton {
id: WidgetId::new("gallery:dialog:cancel-vertical"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
tint: None,
},
],
severity: Some(DialogSeverity::Warning),
vertical_buttons: true,
input: None,
},
}
}
/// Dialog layout for `area`. Called from both `render` and `handle`
/// so paint and hit-test can never disagree.
fn dialog_layout(&self, backend: &dyn Backend, area: Rect, variant: usize) -> DialogLayout {
let m = backend.measure();
let lh = m.line_height;
let char_w = m.char_width;
let dialog = self.dialog(variant);
let button_width = dialog
.buttons
.iter()
.map(|b| backend.measure_text(&b.label, FontRole::Chrome).0)
.fold(0.0_f32, f32::max)
+ char_w * 2.0;
// Snapped to a whole multiple of `char_w`, not the raw
// `area.width * 0.6` — see `snap_for_centering`'s doc for why a
// fractional content width breaks hit-testing on TUI.
let width = (((area.width * 0.6).clamp(char_w * 24.0, char_w * 48.0) / char_w).round()
* char_w)
.max(char_w);
let title_height = lh;
let body_height = lh * dialog.body.len() as f32;
let button_row_height = lh;
let padding = lh;
// A vertical button stack reserves one row per button instead
// of one row total — matches the block height `Dialog::layout`
// itself substitutes internally, so the two never disagree on
// the dialog's total content height.
let button_block_height = if dialog.vertical_buttons {
button_row_height * dialog.buttons.len().max(1) as f32
} else {
button_row_height
};
let total_height = padding * 2.0 + title_height + body_height + button_block_height;
let measure = DialogMeasure {
width,
title_height,
body_height,
table_height: 0.0,
input_height: 0.0,
button_row_height,
button_width,
button_gap: char_w * 2.0,
padding,
};
let centering_area = snap_for_centering(area, width, total_height, char_w, lh);
dialog.layout(centering_area, measure, |_| ToolbarItemMeasure::new(0.0))
}
fn row_at(&self, backend: &dyn Backend, area: Rect, y: f32) -> Option<usize> {
let lh = backend.line_height();
if lh <= 0.0 || y < area.y {
return None;
}
let idx = ((y - area.y) / lh).floor() as usize;
(idx < self.rows.len()).then_some(idx)
}
/// True if `y` falls on the `Open dialog` button row (immediately
/// below the last list row).
fn on_open_button(&self, backend: &dyn Backend, area: Rect, y: f32) -> bool {
let lh = backend.line_height();
lh > 0.0 && self.row_at(backend, area, y).is_none() && {
let idx = ((y - area.y) / lh).floor() as usize;
idx == self.rows.len()
}
}
fn status_bar(&self, variant: usize) -> StatusBar {
let selected = self.selected.clone().unwrap_or_else(|| "nothing".into());
let variant_label = match variant {
0 => "Confirm",
1 => "Destructive",
_ => "Vertical",
};
StatusBar {
id: WidgetId::new("gallery:dialog:status"),
left_segments: vec![StatusBarSegment {
text: format!(" [{variant_label}] selected: {selected} "),
fg: Color::rgb(255, 255, 255),
bg: Color::rgb(40, 80, 120),
bold: false,
action_id: None,
}],
right_segments: vec![StatusBarSegment {
text: " click a row, or Open dialog ".into(),
fg: Color::rgb(220, 220, 220),
bg: Color::rgb(40, 80, 120),
bold: false,
action_id: None,
}],
}
}
fn line(&self, backend: &mut dyn Backend, rect: Rect, id: &str, text: String) {
let bar = StatusBar {
id: WidgetId::new(id),
left_segments: vec![StatusBarSegment {
text,
fg: Color::rgb(210, 210, 210),
bg: Color::rgb(24, 24, 32),
bold: false,
action_id: None,
}],
right_segments: vec![],
};
let _ = backend.draw_status_bar_interactive(rect, &bar, &InteractionState::new());
}
fn open_dialog(&mut self, backend: &mut dyn Backend, area: Rect, variant: usize) {
self.dialog_open = true;
let bounds = self.dialog_layout(backend, area, variant).bounds;
backend
.modal_stack_handle()
.borrow_mut()
.push(WidgetId::new(DIALOG_ID), bounds);
}
fn close_dialog(&mut self, backend: &mut dyn Backend) {
self.dialog_open = false;
backend
.modal_stack_handle()
.borrow_mut()
.pop(&WidgetId::new(DIALOG_ID));
}
}
/// Trim `area` by less than two `char_w`/`lh` cells on each axis so that
/// `Dialog::layout`'s centering — `(area_dim - content_dim) / 2` —
/// always lands on a whole cell.
///
/// That division is only a whole number when `area_dim` and
/// `content_dim` share the same parity (both even or both odd
/// multiples of the cell size). `content_w`/`content_h` here are
/// always whole multiples of `char_w`/`lh` (every `DialogMeasure`
/// field this demo sets is), but `area` is this demo's own
/// Demo-tab content rect, sized by whatever the gallery's chrome
/// leaves over — not under this function's control, and not always
/// the same parity as the content. A half-cell offset is invisible on
/// GTK's sub-pixel rendering but breaks TUI outright: the painted
/// glyph lands on a whole cell regardless, while the unrounded
/// hit-test rect stays half a cell off, so a real click on a real
/// button can resolve to [`DialogHit::Body`] instead of
/// [`DialogHit::Button`].
fn snap_for_centering(area: Rect, content_w: f32, content_h: f32, char_w: f32, lh: f32) -> Rect {
let trim_w = if char_w > 0.0 {
(area.width - content_w).rem_euclid(2.0 * char_w)
} else {
0.0
};
let trim_h = if lh > 0.0 {
(area.height - content_h).rem_euclid(2.0 * lh)
} else {
0.0
};
Rect::new(area.x, area.y, area.width - trim_w, area.height - trim_h)
}
impl Default for DialogDemo {
fn default() -> Self {
Self::new()
}
}
impl Demo for DialogDemo {
fn name(&self) -> &'static str {
"Dialog"
}
fn group(&self) -> &'static str {
"Overlays"
}
fn variants(&self) -> &'static [&'static str] {
&["Confirm", "Destructive", "Vertical"]
}
fn render(&self, variant: usize, backend: &mut dyn Backend, area: Rect) {
let lh = backend.line_height();
for (i, row) in self.rows.iter().enumerate() {
self.line(
backend,
Rect::new(area.x, area.y + i as f32 * lh, area.width, lh),
&format!("gallery:dialog:row-{i}"),
format!(" {row} "),
);
}
self.line(
backend,
Rect::new(area.x, area.y + self.rows.len() as f32 * lh, area.width, lh),
"gallery:dialog:open-dialog",
" Open dialog ".into(),
);
let status_rect = Rect::new(area.x, area.y + area.height - lh, area.width, lh);
let _ = backend.draw_status_bar_interactive(
status_rect,
&self.status_bar(variant),
&InteractionState::new(),
);
// Modal paints last (highest z) — the ModalStack has no opinion
// on draw order, only on hit-test precedence.
if self.dialog_open {
let layout = self.dialog_layout(backend, area, variant);
backend.draw_dialog(&self.dialog(variant), &layout);
}
}
fn handle(
&mut self,
variant: usize,
event: &UiEvent,
backend: &mut dyn Backend,
area: Rect,
) -> Reaction {
match event {
UiEvent::KeyPressed {
key: Key::Named(NamedKey::Escape),
..
} if self.dialog_open => {
self.close_dialog(backend);
Reaction::Redraw
}
// ── Click inside the open modal ─────────────────────────────
//
// quadraui tagged this one with the dialog's id, so it can
// only be the dialog's. Note the absence of any
// `self.dialog_open` test: the tag *is* the test.
UiEvent::MouseDown {
widget: Some(ref id),
position,
..
} if id.as_str() == DIALOG_ID => {
let layout = self.dialog_layout(backend, area, variant);
if let DialogHit::Button(_) = layout.hit_test(position.x, position.y) {
self.close_dialog(backend);
}
Reaction::Redraw
}
// ── Base-layer click ────────────────────────────────────────
UiEvent::MouseDown {
widget: None,
position,
..
} => {
if self.on_open_button(backend, area, position.y) && !self.dialog_open {
self.open_dialog(backend, area, variant);
} else if let Some(idx) = self.row_at(backend, area, position.y) {
self.selected = Some(self.rows[idx].clone());
}
Reaction::Redraw
}
_ => Reaction::Continue,
}
}
fn source(&self) -> &'static str {
extract_region(SOURCE)
}
fn data(&self, _variant: usize) -> serde_json::Value {
serde_json::json!({
"selected": self.selected,
"dialog_open": self.dialog_open,
})
}
fn caps_note(&self, _variant: usize, _caps: &BackendCaps) -> Option<String> {
// `draw_dialog` + the `ModalStack` click-arbitration pipeline are
// required, backend-generic machinery with no capability flag —
// every backend routes clicks through the same stack.
None
}
}
Backends
| Variant | TUI | GTK | macOS | Windows |
|---|---|---|---|---|
| Confirm | ||||
| Destructive | ||||
| Vertical |