use crate::backend::{MessageDialogButton, MessageDialogOptions};
use crate::event::Rect;
use crate::primitives::layout_metrics::TextMeasure;
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,
bounds: b,
});
hit_regions.push((b, DialogHit::Button(btn.id)));
}
} 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,
bounds: b,
});
hit_regions.push((b, DialogHit::Button(btn.id)));
}
}
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 {
self.measure_generic_inner(char_width, line_height, viewport, border_chrome_inset, None)
}
pub fn measure_generic_measured(
&self,
char_width: f32,
line_height: f32,
viewport: Rect,
border_chrome_inset: f32,
measure: &dyn TextMeasure,
) -> DialogMeasure {
self.measure_generic_inner(
char_width,
line_height,
viewport,
border_chrome_inset,
Some(measure),
)
}
fn measure_generic_inner(
&self,
char_width: f32,
line_height: f32,
viewport: Rect,
border_chrome_inset: f32,
measure: Option<&dyn TextMeasure>,
) -> 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);
let estimated_button_w = char_width * 8.0;
let button_width = match measure {
Some(m) => self
.buttons
.iter()
.map(|b| m.width_of(&b.label) + char_width * 2.0)
.fold(estimated_button_w, f32::max),
None => estimated_button_w,
};
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,
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,
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::style::Style;
use crate::theme::Theme;
use crate::types::Color;
use crate::{Point, Rect};
const APPROX_CHAR_WIDTH_RATIO: f32 = 0.6;
const COLUMN_SEP_GAP: f32 = 16.0;
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 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 += COLUMN_SEP_GAP;
}
}
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);
let draw_col_separators = |surface: &mut dyn PaintSurface, row_y: f32| {
for j in 0..ncols.saturating_sub(1) {
let sep_x = col_x[j] + col_w[j] + COLUMN_SEP_GAP / 2.0;
surface.surface_draw_line(
Point::new(sep_x, row_y + 1.0),
Point::new(sep_x, row_y + line_height - 1.0),
border,
1.0,
);
}
};
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);
}
draw_col_separators(surface, row_y);
row_y += line_height;
let total_w = col_x[ncols - 1] + col_w[ncols - 1] - bounds.x;
let rule_y = row_y + line_height / 2.0;
surface.surface_draw_line(
Point::new(bounds.x, rule_y),
Point::new(bounds.x + total_w, rule_y),
border,
1.0,
);
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);
}
draw_col_separators(surface, row_y);
row_y += line_height;
}
}
pub(crate) fn paint(
dialog: &Dialog,
dialog_layout: &DialogLayout,
line_height: f32,
surface: &mut dyn PaintSurface,
theme: &Theme,
style: &Style,
) -> Vec<Rect> {
let bounds = dialog_layout.bounds;
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return Vec::new();
}
surface.surface_draw_shadow(
bounds,
style.corner_radius,
style.shadow_elevation.max(3),
Color::rgba(0, 0, 0, 120),
);
surface.surface_fill_rounded_rect(bounds, style.corner_radius, theme.surface_bg);
surface.surface_stroke_rounded_rect(
bounds,
style.corner_radius,
theme.border_fg,
style.border_width,
);
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 = btn.label.clone();
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 + 8.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::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::dialog::{DialogButton, VisibleDialogButton};
use crate::types::{Color, StyledText, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
strokes: Vec<(Rect, Color, f32)>,
text_runs: Vec<(Rect, String, Color)>,
lines: Vec<(Point, Point, Color, f32)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(300.0, 200.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
14.0
}
fn surface_char_width(&self) -> f32 {
7.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 7.0, 12.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
from: crate::Point,
to: crate::Point,
color: Color,
stroke_width: f32,
) {
self.lines.push((from, to, color, stroke_width));
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
#[test]
fn draw_table_paints_lines_not_separator_glyphs() {
let table = DialogTable {
headers: Some(vec!["Key".to_string(), "Action".to_string()]),
rows: vec![vec!["Ctrl+S".to_string(), "Save".to_string()]],
column_widths: None,
};
let mut surface = RecordingSurface::default();
draw_table(
&mut surface,
&table,
Rect::new(0.0, 0.0, 200.0, 42.0),
14.0,
Color::rgb(255, 255, 255),
Color::rgb(128, 128, 128),
);
assert!(
!surface
.text_runs
.iter()
.any(|(_, t, _)| t.contains('│') || t.contains('─')),
"no ` │ ` / `─` glyph should be painted; text runs were {:?}",
surface.text_runs,
);
assert_eq!(
surface.lines.len(),
3,
"expected 2 column separators + 1 header rule; lines were {:?}",
surface.lines,
);
}
#[test]
fn default_button_has_no_leading_glyph() {
let dialog = Dialog {
id: WidgetId::new("dialog:confirm"),
title: StyledText::plain("Confirm"),
body: vec![],
buttons: vec![DialogButton {
id: WidgetId::new("dialog:ok"),
label: "OK".to_string(),
is_default: true,
is_cancel: false,
tint: None,
}],
severity: None,
vertical_buttons: true,
table: None,
input: None,
};
let dlayout = DialogLayout {
bounds: Rect::new(0.0, 0.0, 100.0, 60.0),
title_bounds: None,
body_bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
table_bounds: None,
input_bounds: None,
body_toolbar_layout: None,
button_row_bounds: Rect::new(0.0, 40.0, 100.0, 20.0),
visible_buttons: vec![VisibleDialogButton {
button_idx: 0,
id: dialog.buttons[0].id.clone(),
bounds: Rect::new(10.0, 40.0, 40.0, 16.0),
}],
hit_regions: vec![],
};
let mut surface = RecordingSurface::default();
paint(
&dialog,
&dlayout,
14.0,
&mut surface,
&Theme::default(),
&crate::style::Style::default(),
);
assert!(
surface.text_runs.iter().any(|(_, t, _)| t == "OK"),
"the button label itself must still be painted; text runs \
were {:?}",
surface.text_runs,
);
assert!(
!surface.text_runs.iter().any(|(_, t, _)| t.contains('▸')),
"no leading glyph should be painted on the default button; \
text runs were {:?}",
surface.text_runs,
);
let theme = Theme::default();
assert!(
surface.fills.iter().any(|(_, c)| *c == theme.selected_bg),
"the default button must still fill selected_bg as its \
marker; fills were {:?}",
surface.fills,
);
}
}
}
#[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"
);
}
struct FakeMeasure;
impl crate::primitives::layout_metrics::TextMeasure for FakeMeasure {
fn width_of(&self, text: &str) -> f32 {
text.chars().count() as f32 * 6.0
}
}
#[test]
fn measure_generic_measured_uses_real_label_width_not_the_estimate() {
let mut d = base_dialog(None);
d.buttons = vec![btn("a-very-long-button-label")];
let viewport = Rect::new(0.0, 0.0, 200.0, 30.0);
let char_width = 1.0;
let line_height = 1.0;
let estimated = d.measure_generic(char_width, line_height, viewport, 0.0);
let measured =
d.measure_generic_measured(char_width, line_height, viewport, 0.0, &FakeMeasure);
assert_eq!(
estimated.button_width, 8.0,
"measure_generic always uses the flat char_width * 8.0 estimate"
);
let label_w = FakeMeasure.width_of("a-very-long-button-label");
assert_eq!(
measured.button_width,
label_w + char_width * 2.0,
"measured button_width tracks the real label width + padding, \
not the flat estimate"
);
assert!(measured.button_width > estimated.button_width);
}
#[test]
fn measure_generic_measured_floors_at_the_char_width_estimate() {
struct NarrowMeasure;
impl crate::primitives::layout_metrics::TextMeasure for NarrowMeasure {
fn width_of(&self, text: &str) -> f32 {
text.chars().count() as f32 * 1.0
}
}
let d = base_dialog(None); let viewport = Rect::new(0.0, 0.0, 200.0, 30.0);
let char_width = 1.0;
let line_height = 1.0;
let measured =
d.measure_generic_measured(char_width, line_height, viewport, 0.0, &NarrowMeasure);
assert_eq!(measured.button_width, char_width * 8.0);
}
#[test]
fn measure_generic_measured_matches_estimate_for_other_fields() {
let d = table_dialog();
let viewport = Rect::new(0.0, 0.0, 100.0, 30.0);
let estimated = d.measure_generic(1.0, 1.0, viewport, 0.0);
let measured = d.measure_generic_measured(1.0, 1.0, viewport, 0.0, &FakeMeasure);
assert_eq!(measured.width, estimated.width);
assert_eq!(measured.title_height, estimated.title_height);
assert_eq!(measured.body_height, estimated.body_height);
assert_eq!(measured.table_height, estimated.table_height);
assert_eq!(measured.input_height, estimated.input_height);
assert_eq!(measured.button_row_height, estimated.button_row_height);
assert_eq!(measured.button_gap, estimated.button_gap);
assert_eq!(measured.padding, estimated.padding);
}
}