use crate::backend::{MessageDialogButton, MessageDialogOptions};
use crate::event::Rect;
use crate::primitives::toolbar::{Toolbar, ToolbarHit, ToolbarItemMeasure, ToolbarLayout};
use crate::types::{Color, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DialogTable {
pub headers: Option<Vec<String>>,
pub rows: Vec<Vec<String>>,
#[serde(default)]
pub column_widths: Option<Vec<u16>>,
}
impl DialogTable {
pub fn num_cols(&self) -> usize {
let from_rows = self.rows.iter().map(|r| r.len()).max().unwrap_or(0);
let from_headers = self.headers.as_ref().map(|h| h.len()).unwrap_or(0);
from_rows.max(from_headers)
}
pub fn auto_col_widths(&self) -> Vec<usize> {
let ncols = self.num_cols();
let mut widths = vec![0usize; ncols];
if let Some(explicit) = &self.column_widths {
for (j, &w) in explicit.iter().enumerate() {
if j < ncols {
widths[j] = w as usize;
}
}
}
if let Some(headers) = &self.headers {
for (j, h) in headers.iter().enumerate() {
if j < ncols {
widths[j] = widths[j].max(h.chars().count());
}
}
}
for row in &self.rows {
for (j, cell) in row.iter().enumerate() {
if j < ncols {
widths[j] = widths[j].max(cell.chars().count());
}
}
}
widths
}
pub fn tui_total_width(&self) -> usize {
let col_widths = self.auto_col_widths();
if col_widths.is_empty() {
return 0;
}
let sum: usize = col_widths.iter().sum();
sum + 3 * col_widths.len().saturating_sub(1)
}
pub fn tui_total_height(&self) -> usize {
let header_rows = if self.headers.is_some() { 2 } else { 0 };
header_rows + self.rows.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Dialog {
pub id: WidgetId,
pub title: StyledText,
pub body: Vec<StyledText>,
pub buttons: Vec<DialogButton>,
#[serde(default)]
pub severity: Option<DialogSeverity>,
#[serde(default)]
pub vertical_buttons: bool,
#[serde(default)]
pub table: Option<DialogTable>,
#[serde(default)]
pub input: Option<DialogInput>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DialogInput {
TextInput(DialogTextInput),
Toolbar(Toolbar),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DialogTextInput {
pub value: String,
#[serde(default)]
pub placeholder: String,
#[serde(default)]
pub cursor: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DialogSeverity {
Info,
Question,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DialogButton {
pub id: WidgetId,
pub label: String,
#[serde(default)]
pub is_default: bool,
#[serde(default)]
pub is_cancel: bool,
#[serde(default)]
pub tint: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DialogMeasure {
pub width: f32,
pub title_height: f32,
pub body_height: f32,
pub table_height: f32,
pub input_height: f32,
pub button_row_height: f32,
pub button_width: f32,
pub button_gap: f32,
pub padding: f32,
}
impl DialogMeasure {
pub fn total_height(&self) -> f32 {
self.title_height
+ self.body_height
+ self.table_height
+ self.input_height
+ self.button_row_height
+ self.padding * 2.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleDialogButton {
pub button_idx: usize,
pub id: WidgetId,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialogHit {
Button(WidgetId),
BodyToolbarButton(WidgetId),
Body,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DialogLayout {
pub bounds: Rect,
pub title_bounds: Option<Rect>,
pub body_bounds: Rect,
pub table_bounds: Option<Rect>,
pub input_bounds: Option<Rect>,
pub body_toolbar_layout: Option<ToolbarLayout>,
pub button_row_bounds: Rect,
pub visible_buttons: Vec<VisibleDialogButton>,
pub hit_regions: Vec<(Rect, DialogHit)>,
}
impl DialogLayout {
pub fn hit_test(&self, x: f32, y: f32) -> DialogHit {
let inside = x >= self.bounds.x
&& x < self.bounds.x + self.bounds.width
&& y >= self.bounds.y
&& y < self.bounds.y + self.bounds.height;
if !inside {
return DialogHit::Outside;
}
if let Some(ref tl) = self.body_toolbar_layout {
match tl.hit_test(x, y) {
ToolbarHit::Button(id) => return DialogHit::BodyToolbarButton(id),
ToolbarHit::Empty => {}
}
}
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
DialogHit::Body
}
}
impl Dialog {
pub fn layout<F>(
&self,
viewport: Rect,
measure: DialogMeasure,
measure_toolbar_item: F,
) -> DialogLayout
where
F: Fn(&crate::primitives::toolbar::ToolbarButton) -> ToolbarItemMeasure,
{
let button_block_h = if self.vertical_buttons {
measure.button_row_height * self.buttons.len().max(1) as f32
} else {
measure.button_row_height
};
let total_h = measure.total_height() - measure.button_row_height + button_block_h;
let box_x = viewport.x + (viewport.width - measure.width) * 0.5;
let box_y = viewport.y + (viewport.height - total_h) * 0.5;
let bounds = Rect::new(box_x, box_y, measure.width, total_h);
let content_x = box_x + measure.padding;
let content_w = (measure.width - measure.padding * 2.0).max(0.0);
let mut cursor_y = box_y + measure.padding;
let title_bounds = if measure.title_height > 0.0 {
let b = Rect::new(content_x, cursor_y, content_w, measure.title_height);
cursor_y += measure.title_height;
Some(b)
} else {
None
};
let body_bounds = Rect::new(content_x, cursor_y, content_w, measure.body_height);
cursor_y += measure.body_height;
let table_bounds = if self.table.is_some() && measure.table_height > 0.0 {
let b = Rect::new(content_x, cursor_y, content_w, measure.table_height);
cursor_y += measure.table_height;
Some(b)
} else {
None
};
let (input_bounds, body_toolbar_layout) =
if self.input.is_some() && measure.input_height > 0.0 {
let b = Rect::new(content_x, cursor_y, content_w, measure.input_height);
cursor_y += measure.input_height;
let tl = match &self.input {
Some(DialogInput::Toolbar(toolbar)) => {
Some(toolbar.layout(b.x, b.y, b.width, b.height, &measure_toolbar_item))
}
_ => None,
};
(Some(b), tl)
} else {
(None, None)
};
let button_row_bounds = Rect::new(content_x, cursor_y, content_w, button_block_h);
let mut visible_buttons: Vec<VisibleDialogButton> = Vec::new();
let mut hit_regions: Vec<(Rect, DialogHit)> = Vec::new();
if self.vertical_buttons {
let btn_h = measure.button_row_height;
for (i, btn) in self.buttons.iter().enumerate() {
let y = cursor_y + (i as f32) * btn_h;
let b = Rect::new(content_x, y, content_w, btn_h);
visible_buttons.push(VisibleDialogButton {
button_idx: i,
id: btn.id.clone(),
bounds: b,
});
hit_regions.push((b, DialogHit::Button(btn.id.clone())));
}
} else {
let total_btns_w = self.buttons.len() as f32 * measure.button_width
+ (self.buttons.len().saturating_sub(1)) as f32 * measure.button_gap;
let start_x = content_x + content_w - total_btns_w;
for (i, btn) in self.buttons.iter().enumerate() {
let x = start_x + (i as f32) * (measure.button_width + measure.button_gap);
let b = Rect::new(x, cursor_y, measure.button_width, measure.button_row_height);
visible_buttons.push(VisibleDialogButton {
button_idx: i,
id: btn.id.clone(),
bounds: b,
});
hit_regions.push((b, DialogHit::Button(btn.id.clone())));
}
}
DialogLayout {
bounds,
title_bounds,
body_bounds,
table_bounds,
input_bounds,
body_toolbar_layout,
button_row_bounds,
visible_buttons,
hit_regions,
}
}
pub fn default_button_id(&self) -> Option<&WidgetId> {
self.buttons
.iter()
.find(|b| b.is_default)
.map(|b| &b.id)
.or_else(|| self.buttons.last().map(|b| &b.id))
}
pub fn cancel_button_id(&self) -> Option<&WidgetId> {
self.buttons.iter().find(|b| b.is_cancel).map(|b| &b.id)
}
pub fn measure_generic(
&self,
char_width: f32,
line_height: f32,
viewport: Rect,
border_chrome_inset: f32,
) -> DialogMeasure {
let table = self.table.as_ref();
let table_total_h = table
.map(|t| t.tui_total_height() as f32 * line_height)
.unwrap_or(0.0);
let table_preferred_w = table
.map(|t| t.tui_total_width() as f32 * char_width + char_width * 2.0)
.unwrap_or(0.0);
let title_h = if self.title.spans.iter().any(|s| !s.text.is_empty()) {
line_height
} else {
0.0
};
let body_h = self.body.len() as f32 * line_height;
let input_h = if self.input.is_some() {
line_height
} else {
0.0
};
let min_w = char_width * 30.0; let max_w = char_width * 60.0; let default_w = (viewport.width * 0.5).clamp(min_w, max_w);
let dialog_w = default_w
.max(table_preferred_w)
.min(viewport.width - char_width * 4.0 - border_chrome_inset * 2.0);
DialogMeasure {
width: dialog_w,
title_height: title_h,
body_height: body_h,
table_height: table_total_h,
input_height: input_h,
button_row_height: line_height,
button_width: char_width * 8.0,
button_gap: char_width * 2.0,
padding: line_height,
}
}
}
fn flatten(text: &StyledText) -> String {
text.spans.iter().map(|s| s.text.as_str()).collect()
}
pub fn native_dialog_options(d: &Dialog) -> Option<MessageDialogOptions> {
if d.table.is_some() || d.input.is_some() {
return None;
}
Some(MessageDialogOptions {
title: flatten(&d.title),
body: d.body.iter().map(flatten).collect::<Vec<_>>().join("\n"),
buttons: d
.buttons
.iter()
.map(|b| MessageDialogButton {
id: b.id.clone(),
label: b.label.clone(),
is_default: b.is_default,
is_cancel: b.is_cancel,
})
.collect(),
severity: d.severity,
})
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{Dialog, DialogInput, DialogLayout, DialogTable};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::Rect;
const APPROX_CHAR_WIDTH_RATIO: f32 = 0.6;
fn draw_table(
surface: &mut dyn PaintSurface,
table: &DialogTable,
bounds: Rect,
line_height: f32,
fg: crate::Color,
border: crate::Color,
) {
let ncols = table.num_cols();
if ncols == 0 {
return;
}
let measure_w = |surface: &dyn PaintSurface, s: &str| surface.surface_measure_text(s).0;
let mut col_w = vec![0.0f32; ncols];
if let Some(headers) = &table.headers {
for (j, h) in headers.iter().enumerate().take(ncols) {
col_w[j] = col_w[j].max(measure_w(surface, h));
}
}
for row in &table.rows {
for (j, cell) in row.iter().enumerate().take(ncols) {
col_w[j] = col_w[j].max(measure_w(surface, cell));
}
}
if let Some(explicit) = &table.column_widths {
for (j, &w) in explicit.iter().enumerate() {
if j < ncols {
let px = w as f32 * (line_height * APPROX_CHAR_WIDTH_RATIO);
col_w[j] = col_w[j].max(px);
}
}
}
let sep_w = measure_w(surface, " │ ");
let mut col_x = vec![0.0f32; ncols];
let mut cursor_x = bounds.x;
for j in 0..ncols {
col_x[j] = cursor_x;
cursor_x += col_w[j];
if j + 1 < ncols {
cursor_x += sep_w;
}
}
let mut row_y = bounds.y;
let row_rect = |x: f32, y: f32, w: f32| Rect::new(x, y, w.max(1.0), line_height);
if let Some(headers) = &table.headers {
for (j, h) in headers.iter().enumerate().take(ncols) {
surface.surface_draw_text_run(row_rect(col_x[j], row_y, col_w[j]), h, fg);
}
for j in 0..ncols.saturating_sub(1) {
let sep_x = col_x[j] + col_w[j];
surface.surface_draw_text_run(row_rect(sep_x, row_y, sep_w), " │ ", border);
}
row_y += line_height;
let total_w = col_x[ncols - 1] + col_w[ncols - 1] - bounds.x;
let char_w = line_height * APPROX_CHAR_WIDTH_RATIO;
let dash_count = ((total_w / char_w.max(1.0)).ceil() as usize + 4).max(1);
let dash_str: String = "─".repeat(dash_count);
surface.surface_draw_text_run(row_rect(bounds.x, row_y, total_w), &dash_str, border);
row_y += line_height;
}
for row in &table.rows {
for (j, cell) in row.iter().enumerate().take(ncols) {
surface.surface_draw_text_run(row_rect(col_x[j], row_y, col_w[j]), cell, fg);
}
for j in 0..ncols.saturating_sub(1) {
let sep_x = col_x[j] + col_w[j];
surface.surface_draw_text_run(row_rect(sep_x, row_y, sep_w), " │ ", border);
}
row_y += line_height;
}
}
pub(crate) fn paint(
dialog: &Dialog,
dialog_layout: &DialogLayout,
line_height: f32,
surface: &mut dyn PaintSurface,
theme: &Theme,
) -> Vec<Rect> {
let bounds = dialog_layout.bounds;
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return Vec::new();
}
surface.surface_fill_rect(bounds, theme.surface_bg);
surface.surface_stroke_rect(bounds, theme.border_fg, 1.0);
if let Some(title_rect) = dialog_layout.title_bounds {
surface.surface_draw_text_run(
title_rect,
&super::flatten(&dialog.title),
theme.title_fg,
);
}
let body_b = dialog_layout.body_bounds;
for (i, line) in dialog.body.iter().enumerate() {
let row_y = body_b.y + i as f32 * line_height;
if row_y + line_height > body_b.y + body_b.height {
break;
}
let row_rect = Rect::new(body_b.x, row_y, body_b.width, line_height);
surface.surface_draw_text_run(row_rect, &super::flatten(line), theme.surface_fg);
}
if let (Some(table_b), Some(table)) = (dialog_layout.table_bounds, dialog.table.as_ref()) {
draw_table(
surface,
table,
table_b,
line_height,
theme.surface_fg,
theme.border_fg,
);
}
if let (Some(input_b), Some(DialogInput::TextInput(input))) =
(dialog_layout.input_bounds, dialog.input.as_ref())
{
surface.surface_fill_rect(input_b, theme.input_bg);
surface.surface_stroke_rect(input_b, theme.border_fg, 1.0);
let display = if input.value.is_empty() {
format!(" {}", input.placeholder)
} else {
format!(" {}", input.value)
};
let (_, ih) = surface.surface_measure_text(&display);
let text_rect = Rect::new(
input_b.x + 2.0,
input_b.y + (input_b.height - ih) / 2.0,
(input_b.width - 2.0).max(0.0),
ih.max(1.0),
);
surface.surface_draw_text_run(text_rect, &display, theme.surface_fg);
}
let mut rects = Vec::with_capacity(dialog_layout.visible_buttons.len());
for vis in &dialog_layout.visible_buttons {
let btn = &dialog.buttons[vis.button_idx];
rects.push(vis.bounds);
if btn.is_default {
surface.surface_fill_rect(vis.bounds, theme.selected_bg);
}
surface.surface_stroke_rect(vis.bounds, theme.border_fg, 1.0);
let label = if dialog.vertical_buttons {
let prefix = if btn.is_default { "▸ " } else { " " };
format!("{prefix}{}", btn.label)
} else {
format!(" {} ", btn.label)
};
let label_fg = btn.tint.unwrap_or(theme.surface_fg);
let (lw, lh) = surface.surface_measure_text(&label);
let label_x = if dialog.vertical_buttons {
vis.bounds.x + 4.0
} else {
vis.bounds.x + (vis.bounds.width - lw) / 2.0
};
let label_y = vis.bounds.y + (vis.bounds.height - lh) / 2.0;
surface.surface_draw_text_run(
Rect::new(label_x, label_y, lw.max(1.0), lh.max(1.0)),
&label,
label_fg,
);
}
rects
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::toolbar::{Toolbar, ToolbarButton};
use crate::types::WidgetId;
fn no_measure(_: &crate::primitives::toolbar::ToolbarButton) -> ToolbarItemMeasure {
ToolbarItemMeasure::new(0.0)
}
fn viewport() -> Rect {
Rect::new(0.0, 0.0, 400.0, 300.0)
}
fn measure_no_input() -> DialogMeasure {
DialogMeasure {
width: 200.0,
title_height: 20.0,
body_height: 20.0,
table_height: 0.0,
input_height: 0.0,
button_row_height: 20.0,
button_width: 60.0,
button_gap: 8.0,
padding: 10.0,
}
}
fn measure_with_input() -> DialogMeasure {
DialogMeasure {
input_height: 20.0,
..measure_no_input()
}
}
fn btn(id: &str) -> DialogButton {
DialogButton {
id: WidgetId::new(id),
label: id.to_string(),
is_default: false,
is_cancel: false,
tint: None,
}
}
fn base_dialog(input: Option<DialogInput>) -> Dialog {
Dialog {
id: WidgetId::new("d"),
title: crate::types::StyledText::plain("Title"),
body: vec![crate::types::StyledText::plain("Body")],
buttons: vec![btn("ok")],
severity: None,
vertical_buttons: false,
table: None,
input,
}
}
#[test]
fn serde_dialog_text_input_round_trip() {
let input = DialogInput::TextInput(DialogTextInput {
value: "hello".into(),
placeholder: "type here".into(),
cursor: Some(5),
});
let json = serde_json::to_string(&input).unwrap();
let back: DialogInput = serde_json::from_str(&json).unwrap();
assert_eq!(input, back);
}
#[test]
fn serde_dialog_toolbar_round_trip() {
let input = DialogInput::Toolbar(Toolbar {
id: WidgetId::new("body-tb"),
buttons: vec![
ToolbarButton::Action {
id: WidgetId::new("preview"),
label: "Preview".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
},
ToolbarButton::Separator,
ToolbarButton::Action {
id: WidgetId::new("apply"),
label: "Apply".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
},
],
bg: None,
focused_index: None,
});
let json = serde_json::to_string(&input).unwrap();
let back: DialogInput = serde_json::from_str(&json).unwrap();
assert_eq!(input, back);
}
#[test]
fn layout_toolbar_variant_sets_body_toolbar_layout() {
let d = base_dialog(Some(DialogInput::Toolbar(Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Action {
id: WidgetId::new("a"),
label: "A".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}],
bg: None,
focused_index: None,
})));
let layout = d.layout(viewport(), measure_with_input(), |_| {
ToolbarItemMeasure::new(10.0)
});
assert!(
layout.input_bounds.is_some(),
"input_bounds should be Some for Toolbar variant"
);
assert!(
layout.body_toolbar_layout.is_some(),
"body_toolbar_layout should be Some for Toolbar variant"
);
assert!(
layout
.body_toolbar_layout
.as_ref()
.unwrap()
.visible_items
.len()
== 1
);
}
#[test]
fn layout_text_input_variant_does_not_set_body_toolbar_layout() {
let d = base_dialog(Some(DialogInput::TextInput(DialogTextInput {
value: "x".into(),
placeholder: String::new(),
cursor: None,
})));
let layout = d.layout(viewport(), measure_with_input(), no_measure);
assert!(layout.input_bounds.is_some());
assert!(
layout.body_toolbar_layout.is_none(),
"body_toolbar_layout should be None for TextInput variant"
);
}
#[test]
fn hit_test_body_toolbar_button_routes_correctly() {
let d = base_dialog(Some(DialogInput::Toolbar(Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Action {
id: WidgetId::new("preview"),
label: "Preview".into(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}],
bg: None,
focused_index: None,
})));
let layout = d.layout(viewport(), measure_with_input(), |_| {
ToolbarItemMeasure::new(60.0)
});
let tl = layout.body_toolbar_layout.as_ref().unwrap();
let vis = &tl.visible_items[0];
let cx = vis.bounds.x + 1.0;
let cy = vis.bounds.y;
match layout.hit_test(cx, cy) {
DialogHit::BodyToolbarButton(id) => {
assert_eq!(id.as_str(), "preview");
}
other => panic!("expected BodyToolbarButton, got {:?}", other),
}
}
#[test]
fn hit_test_no_toolbar_no_body_toolbar_hit() {
let d = base_dialog(None);
let layout = d.layout(viewport(), measure_no_input(), no_measure);
let cx = layout.body_bounds.x + 5.0;
let cy = layout.body_bounds.y + 5.0;
assert_eq!(layout.hit_test(cx, cy), DialogHit::Body);
}
fn table_dialog() -> Dialog {
Dialog {
id: WidgetId::new("d"),
title: crate::types::StyledText::plain("Keybindings"),
body: vec![],
buttons: vec![btn("close")],
severity: None,
vertical_buttons: false,
table: Some(DialogTable {
headers: Some(vec!["Key".into(), "Action".into()]),
rows: vec![
vec!["Ctrl+S".into(), "Save".into()],
vec!["Ctrl+Z".into(), "Undo".into()],
],
column_widths: None,
}),
input: None,
}
}
#[test]
fn dialog_table_num_cols() {
let t = DialogTable {
headers: Some(vec!["A".into(), "B".into(), "C".into()]),
rows: vec![vec!["x".into(), "y".into()]],
column_widths: None,
};
assert_eq!(t.num_cols(), 3, "headers win when wider than rows");
}
#[test]
fn dialog_table_auto_col_widths() {
let t = DialogTable {
headers: Some(vec!["Key".into(), "Action".into()]),
rows: vec![
vec!["Ctrl+S".into(), "Save".into()],
vec!["Ctrl+Shift+Z".into(), "Redo".into()],
],
column_widths: None,
};
let widths = t.auto_col_widths();
assert_eq!(widths.len(), 2);
assert_eq!(widths[0], "Ctrl+Shift+Z".chars().count()); assert_eq!(widths[1], "Action".chars().count()); }
#[test]
fn dialog_table_tui_total_width() {
let t = DialogTable {
headers: Some(vec!["Key".into(), "Action".into()]),
rows: vec![vec!["Ctrl+S".into(), "Save".into()]],
column_widths: None,
};
assert_eq!(t.tui_total_width(), 15);
}
#[test]
fn dialog_table_tui_total_height_with_headers() {
let t = DialogTable {
headers: Some(vec!["Key".into()]),
rows: vec![vec!["a".into()], vec!["b".into()]],
column_widths: None,
};
assert_eq!(t.tui_total_height(), 4);
}
#[test]
fn dialog_table_tui_total_height_no_headers() {
let t = DialogTable {
headers: None,
rows: vec![vec!["a".into()], vec!["b".into()], vec!["c".into()]],
column_widths: None,
};
assert_eq!(t.tui_total_height(), 3);
}
#[test]
fn layout_with_table_creates_table_bounds() {
let d = table_dialog();
let measure = DialogMeasure {
width: 200.0,
title_height: 20.0,
body_height: 0.0,
table_height: 80.0,
input_height: 0.0,
button_row_height: 20.0,
button_width: 60.0,
button_gap: 8.0,
padding: 10.0,
};
let layout = d.layout(viewport(), measure, no_measure);
assert!(
layout.table_bounds.is_some(),
"table_bounds should be Some when table is set"
);
let tb = layout.table_bounds.unwrap();
assert_eq!(tb.height, 80.0);
}
#[test]
fn layout_without_table_has_no_table_bounds() {
let d = base_dialog(None);
let layout = d.layout(viewport(), measure_no_input(), no_measure);
assert!(
layout.table_bounds.is_none(),
"table_bounds should be None when table is None"
);
}
#[test]
fn layout_table_placed_below_body() {
let d = table_dialog();
let measure = DialogMeasure {
width: 200.0,
title_height: 10.0,
body_height: 0.0,
table_height: 40.0,
input_height: 0.0,
button_row_height: 10.0,
button_width: 60.0,
button_gap: 8.0,
padding: 5.0,
};
let layout = d.layout(viewport(), measure, no_measure);
let body_bottom = layout.body_bounds.y + layout.body_bounds.height;
let table_top = layout.table_bounds.unwrap().y;
assert_eq!(
table_top, body_bottom,
"table starts immediately after body"
);
}
#[test]
fn serde_dialog_table_round_trip() {
let t = DialogTable {
headers: Some(vec!["Key".into(), "Action".into()]),
rows: vec![vec!["Ctrl+S".into(), "Save".into()]],
column_widths: Some(vec![10, 20]),
};
let json = serde_json::to_string(&t).unwrap();
let back: DialogTable = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
#[test]
fn serde_dialog_with_table_round_trip() {
let d = table_dialog();
let json = serde_json::to_string(&d).unwrap();
let back: Dialog = serde_json::from_str(&json).unwrap();
assert_eq!(d, back);
}
#[test]
fn serde_dialog_without_table_defaults_to_none() {
let json = r#"{"id":"d","title":{"spans":[{"text":"T","fg":null,"bg":null,"bold":false,"italic":false,"underline":false,"strike":false}]},"body":[],"buttons":[]}"#;
let d: Dialog = serde_json::from_str(json).unwrap();
assert!(d.table.is_none());
}
fn dialog_with_buttons() -> Dialog {
Dialog {
id: WidgetId::new("d"),
title: crate::types::StyledText::plain("Unsaved Changes"),
body: vec![
crate::types::StyledText::plain("Do you want to save?"),
crate::types::StyledText::plain("Changes will be lost."),
],
buttons: vec![
DialogButton {
id: WidgetId::new("save"),
label: "Save".into(),
is_default: true,
is_cancel: false,
tint: None,
},
DialogButton {
id: WidgetId::new("cancel"),
label: "Cancel".into(),
is_default: false,
is_cancel: true,
tint: None,
},
],
severity: Some(DialogSeverity::Warning),
vertical_buttons: false,
table: None,
input: None,
}
}
#[test]
fn native_dialog_options_maps_title_body_buttons_default_cancel_severity() {
let d = dialog_with_buttons();
let opts =
native_dialog_options(&d).expect("no table/input — must be natively expressible");
assert_eq!(opts.title, "Unsaved Changes");
assert_eq!(opts.body, "Do you want to save?\nChanges will be lost.");
assert_eq!(opts.buttons.len(), 2);
assert_eq!(opts.buttons[0].id.as_str(), "save");
assert_eq!(opts.buttons[0].label, "Save");
assert!(opts.buttons[0].is_default);
assert!(!opts.buttons[0].is_cancel);
assert_eq!(opts.buttons[1].id.as_str(), "cancel");
assert_eq!(opts.buttons[1].label, "Cancel");
assert!(opts.buttons[1].is_cancel);
assert!(!opts.buttons[1].is_default);
assert_eq!(opts.severity, Some(DialogSeverity::Warning));
}
#[test]
fn native_dialog_options_passes_through_none_severity() {
let mut d = dialog_with_buttons();
d.severity = None;
let opts =
native_dialog_options(&d).expect("no table/input — must be natively expressible");
assert_eq!(opts.severity, None);
}
#[test]
fn native_dialog_options_none_for_table() {
let d = table_dialog();
assert!(
native_dialog_options(&d).is_none(),
"no native alert facility hosts a DialogTable"
);
}
#[test]
fn native_dialog_options_none_for_input() {
let d = base_dialog(Some(DialogInput::TextInput(DialogTextInput {
value: "x".into(),
placeholder: String::new(),
cursor: None,
})));
assert!(
native_dialog_options(&d).is_none(),
"no native alert facility hosts a DialogInput text field"
);
}
fn mk_dialog_button(id: &str, label: &str) -> DialogButton {
DialogButton {
id: WidgetId::new(id),
label: label.to_string(),
is_default: false,
is_cancel: false,
tint: None,
}
}
#[test]
fn dialog_layout_centered_horizontal_buttons() {
let mut ok = mk_dialog_button("ok", "OK");
ok.is_default = true;
let mut cancel = mk_dialog_button("cancel", "Cancel");
cancel.is_cancel = true;
let d = Dialog {
id: WidgetId::new("confirm"),
title: StyledText::plain("Close unsaved file?"),
body: vec![StyledText::plain("You have unsaved changes. Close anyway?")],
buttons: vec![cancel, ok],
severity: Some(DialogSeverity::Question),
vertical_buttons: false,
table: None,
input: None,
};
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let measure = DialogMeasure {
width: 400.0,
title_height: 24.0,
body_height: 40.0,
table_height: 0.0,
input_height: 0.0,
button_row_height: 32.0,
button_width: 80.0,
button_gap: 8.0,
padding: 16.0,
};
let layout = d.layout(viewport, measure, |_| {
crate::primitives::toolbar::ToolbarItemMeasure::new(0.0)
});
assert_eq!(layout.bounds.x, 200.0);
assert_eq!(layout.bounds.y, 236.0);
assert!(layout.title_bounds.is_some());
assert_eq!(layout.visible_buttons.len(), 2);
let last = &layout.visible_buttons[1];
assert_eq!(
last.bounds.x + last.bounds.width,
layout.bounds.x + measure.width - measure.padding
);
}
#[test]
fn dialog_layout_vertical_buttons() {
let d = Dialog {
id: WidgetId::new("code-actions"),
title: StyledText::plain("Code actions"),
body: vec![StyledText::default()],
buttons: vec![
mk_dialog_button("a1", "Add import"),
mk_dialog_button("a2", "Use fully qualified path"),
mk_dialog_button("a3", "Ignore"),
],
severity: None,
vertical_buttons: true,
table: None,
input: None,
};
let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
let measure = DialogMeasure {
width: 300.0,
title_height: 20.0,
body_height: 0.0,
table_height: 0.0,
input_height: 0.0,
button_row_height: 90.0,
button_width: 280.0,
button_gap: 0.0,
padding: 10.0,
};
let layout = d.layout(viewport, measure, |_| {
crate::primitives::toolbar::ToolbarItemMeasure::new(0.0)
});
assert_eq!(layout.visible_buttons.len(), 3);
for b in &layout.visible_buttons {
assert_eq!(b.bounds.width, 280.0);
assert_eq!(b.bounds.height, 90.0);
}
assert!(layout.visible_buttons[0].bounds.y < layout.visible_buttons[1].bounds.y);
}
#[test]
fn dialog_hit_test_on_button_returns_id() {
let d = Dialog {
id: WidgetId::new("confirm"),
title: StyledText::plain("T"),
body: vec![StyledText::plain("B")],
buttons: vec![
mk_dialog_button("cancel", "Cancel"),
mk_dialog_button("ok", "OK"),
],
severity: None,
vertical_buttons: false,
table: None,
input: None,
};
let viewport = Rect::new(0.0, 0.0, 400.0, 300.0);
let measure = DialogMeasure {
width: 200.0,
title_height: 20.0,
body_height: 20.0,
table_height: 0.0,
input_height: 0.0,
button_row_height: 20.0,
button_width: 60.0,
button_gap: 10.0,
padding: 10.0,
};
let layout = d.layout(viewport, measure, |_| {
crate::primitives::toolbar::ToolbarItemMeasure::new(0.0)
});
let ok_btn = &layout.visible_buttons[1];
let cx = ok_btn.bounds.x + ok_btn.bounds.width / 2.0;
let cy = ok_btn.bounds.y + ok_btn.bounds.height / 2.0;
match layout.hit_test(cx, cy) {
DialogHit::Button(id) => assert_eq!(id.as_str(), "ok"),
_ => panic!(),
}
assert_eq!(
layout.hit_test(layout.body_bounds.x + 5.0, layout.body_bounds.y + 5.0),
DialogHit::Body
);
assert_eq!(layout.hit_test(0.0, 0.0), DialogHit::Outside);
}
#[test]
fn dialog_with_input_field_layout() {
let ok = mk_dialog_button("ok", "OK");
let cancel = mk_dialog_button("cancel", "Cancel");
let d = Dialog {
id: WidgetId::new("rename"),
title: StyledText::plain("Rename"),
body: vec![StyledText::plain("New name:")],
buttons: vec![cancel, ok],
severity: None,
vertical_buttons: false,
table: None,
input: Some(DialogInput::TextInput(DialogTextInput {
value: "old_name.rs".to_string(),
placeholder: String::new(),
cursor: Some(11),
})),
};
let viewport = Rect::new(0.0, 0.0, 400.0, 300.0);
let measure = DialogMeasure {
width: 200.0,
title_height: 20.0,
body_height: 20.0,
table_height: 0.0,
input_height: 24.0,
button_row_height: 20.0,
button_width: 60.0,
button_gap: 10.0,
padding: 10.0,
};
let layout = d.layout(viewport, measure, |_| {
crate::primitives::toolbar::ToolbarItemMeasure::new(0.0)
});
assert!(layout.input_bounds.is_some());
let ib = layout.input_bounds.unwrap();
assert_eq!(ib.y, layout.body_bounds.y + layout.body_bounds.height);
assert_eq!(ib.height, 24.0);
assert_eq!(layout.button_row_bounds.y, ib.y + ib.height);
}
#[test]
fn dialog_default_and_cancel_resolution() {
let mut ok = mk_dialog_button("ok", "OK");
ok.is_default = true;
let mut cancel = mk_dialog_button("cancel", "Cancel");
cancel.is_cancel = true;
let d = Dialog {
id: WidgetId::new("d"),
title: StyledText::default(),
body: vec![StyledText::default()],
buttons: vec![cancel, ok],
severity: None,
vertical_buttons: false,
table: None,
input: None,
};
assert_eq!(d.default_button_id().unwrap().as_str(), "ok");
assert_eq!(d.cancel_button_id().unwrap().as_str(), "cancel");
}
#[test]
fn measure_generic_tui_shaped_matches_hand_rolled_formula() {
let d = table_dialog();
let viewport = Rect::new(0.0, 0.0, 100.0, 30.0);
let m = d.measure_generic(1.0, 1.0, viewport, 0.0);
assert_eq!(m.title_height, 1.0, "non-empty title reserves one line");
assert_eq!(m.body_height, 0.0, "table_dialog() has no body lines");
let table = d.table.as_ref().unwrap();
assert_eq!(m.table_height, table.tui_total_height() as f32);
assert_eq!(m.button_row_height, 1.0);
assert_eq!(m.button_width, 8.0);
assert_eq!(m.button_gap, 2.0);
assert_eq!(m.padding, 1.0);
assert_eq!(m.input_height, 0.0, "no DialogInput on table_dialog()");
}
#[test]
fn measure_generic_empty_title_reserves_no_height() {
let mut d = base_dialog(None);
d.title = StyledText::default();
let viewport = Rect::new(0.0, 0.0, 100.0, 30.0);
let m = d.measure_generic(1.0, 1.0, viewport, 0.0);
assert_eq!(m.title_height, 0.0);
}
#[test]
fn measure_generic_input_present_reserves_one_line() {
let d = base_dialog(Some(DialogInput::TextInput(DialogTextInput {
value: "x".into(),
placeholder: String::new(),
cursor: None,
})));
let viewport = Rect::new(0.0, 0.0, 100.0, 30.0);
let m = d.measure_generic(1.0, 1.0, viewport, 0.0);
assert_eq!(m.input_height, 1.0);
}
#[test]
fn measure_generic_widens_for_wide_table() {
let wide_table_dialog = Dialog {
id: WidgetId::new("wide"),
title: StyledText::plain("Wide"),
body: vec![],
buttons: vec![btn("close")],
severity: None,
vertical_buttons: false,
table: Some(DialogTable {
headers: None,
rows: vec![vec!["x".repeat(80)]],
column_widths: None,
}),
input: None,
};
let viewport = Rect::new(0.0, 0.0, 200.0, 50.0);
let m = wide_table_dialog.measure_generic(1.0, 1.0, viewport, 0.0);
assert!(
m.width >= 82.0,
"width {} should fit the wide table",
m.width
);
}
#[test]
fn measure_generic_border_chrome_inset_shrinks_width_when_viewport_bound() {
let d = base_dialog(None);
let viewport = Rect::new(0.0, 0.0, 20.0, 30.0);
let no_inset = d.measure_generic(1.0, 1.0, viewport, 0.0);
let with_inset = d.measure_generic(1.0, 1.0, viewport, 2.0);
assert_eq!(
with_inset.width,
no_inset.width - 4.0,
"border_chrome_inset shrinks width by 2x the inset"
);
}
}