use crate::event::Rect as QRect;
use crate::primitives::board::{BoardLayout, BoardMeasure, BoardModel};
use crate::primitives::chart::{Chart, ChartLayout, ChartMeasure};
use crate::primitives::data_table::{ColumnMeasure, DataTable, DataTableLayout};
use crate::primitives::form::{
FieldKind, FormField, FormFieldMeasure, FormItemMeasure, ValidationState,
};
use crate::primitives::list::{ListItemMeasure, ListView, ListViewLayout};
use crate::primitives::minimap::{Minimap, MinimapLayout, MinimapScale, MinimapSizing};
use crate::primitives::multi_section_view::{
MsvLayoutMetrics, MultiSectionView, MultiSectionViewLayout, SectionAux, SectionBody,
SectionMeasure,
};
use crate::primitives::panel::{Panel, PanelLayout, PanelMeasure};
use crate::primitives::pipeline_view::{PipelineView, PipelineViewLayout, PipelineViewMeasure};
use crate::primitives::progress::{ProgressBar, ProgressBarLayout, ProgressBarMeasure};
use crate::primitives::split::{Split, SplitLayout, SplitMeasure};
use crate::primitives::split_tree::{SplitTree, SplitTreeLayout, SplitTreeMeasure};
use crate::primitives::tab_bar::{
SegmentMeasure, TabBar, TabBarLayout, TabChrome, TabFrame, TabMeasure,
};
use crate::primitives::toast::{ToastMeasure, ToastOverlay, ToastStackLayout};
use crate::primitives::tree::{TreeRowMeasure, TreeView, TreeViewLayout};
use crate::types::Decoration;
use crate::WidgetId;
pub trait TextMeasure {
fn width_of(&self, text: &str) -> f32;
}
pub mod pixel {
pub const DIVIDER: f32 = 4.0;
pub const TOAST_WIDTH: f32 = 320.0;
pub const TOAST_MARGIN: f32 = 12.0;
pub const TOAST_GAP: f32 = 8.0;
pub const TOAST_PADDING: f32 = 8.0;
pub const TOAST_DISMISS_WIDTH: f32 = 28.0;
pub const TOAST_ACTION_PADDING: f32 = 16.0;
pub const TOAST_ACTION_GAP: f32 = 8.0;
pub const PROGRESS_CANCEL_WIDTH: f32 = 28.0;
pub const PROGRESS_PULSE_WIDTH: f32 = 40.0;
pub const PANEL_ACTION_BUTTON: f32 = 24.0;
pub const PIPELINE_ARROW_WIDTH: f32 = 32.0;
pub const PIPELINE_ACTION_HEIGHT: f32 = 22.0;
pub const PIPELINE_FOCUS_INDICATOR_H: f32 = 8.0;
pub const CORNER_RADIUS: f64 = 4.0;
pub const PIPELINE_H_PAD: f64 = 8.0;
pub const PIPELINE_BORDER_WIDTH: f64 = 1.0;
pub const DATA_TABLE_SCROLLBAR_WIDTH: f32 = 8.0;
pub const TREE_VSCROLLBAR_WIDTH: f32 = 10.0;
pub const FORM_BUTTON_ITEM_PADDING: f32 = 16.0;
pub const MINIMAP_LINES_PER_ROW: usize = 1;
pub const TAB_PAD: f32 = 14.0;
pub const TAB_INNER_GAP: f32 = 10.0;
pub const TAB_OUTER_GAP: f32 = 1.0;
pub const TAB_ICON_GAP: f32 = 6.0;
pub const TAB_CELL_WIDTH_SAMPLE: &str = "ABCDabcd0123.:_";
}
pub fn tree_layout(tree: &TreeView, area: QRect, line_height: f64) -> TreeViewLayout {
tree_layout_inner(tree, area, line_height, None)
}
pub fn tree_layout_measured(
tree: &TreeView,
area: QRect,
line_height: f64,
measure: &dyn TextMeasure,
) -> TreeViewLayout {
tree_layout_inner(tree, area, line_height, Some(measure))
}
fn tree_layout_inner(
tree: &TreeView,
area: QRect,
line_height: f64,
measure: Option<&dyn TextMeasure>,
) -> TreeViewLayout {
let header_height = (line_height * 1.2).round();
let item_height = tree_row_pitch(tree, line_height);
let indent_px = (line_height * 0.9).round();
let show_chevrons = tree.style.show_chevrons;
tree.layout(area.width, area.height, |i| {
let row = &tree.rows[i];
let is_header = matches!(row.decoration, Decoration::Header);
let row_h = if is_header {
header_height as f32
} else {
item_height as f32
};
let chevron_end_x = if row.is_expanded.is_some() && show_chevrons {
let glyph_w = match (measure, row.is_expanded) {
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
(Some(m), Some(expanded)) => m.width_of(
&crate::primitives::tree::tree_chevron_glyph(&tree.style, expanded),
) as f64,
_ => line_height * 0.65,
};
Some((2.0 + row.indent as f64 * indent_px + glyph_w + 4.0) as f32)
} else {
None
};
TreeRowMeasure {
height: row_h,
chevron_end_x,
}
})
}
pub fn tree_row_pitch(tree: &TreeView, line_height: f64) -> f64 {
let is_cell_grid = line_height <= 1.0;
tree.style
.row_height
.filter(|_| !is_cell_grid)
.map(|h| h as f64)
.unwrap_or(line_height * 1.4)
.round()
}
pub fn list_layout(
list: &ListView,
w: f64,
h: f64,
line_height: f64,
char_width: f64,
border_inset: f64,
) -> ListViewLayout {
let visible_px = (w - border_inset * 2.0).max(0.0);
let needs_hscrollbar = list
.max_content_width
.is_some_and(|n| n as f64 * char_width > visible_px);
let hscrollbar_h = if needs_hscrollbar { line_height } else { 0.0 };
let title_h = if list.title.is_some() {
line_height as f32
} else {
0.0
};
let layout_w = (w - border_inset * 2.0) as f32;
let layout_h = (h - border_inset * 2.0 - hscrollbar_h).max(0.0) as f32;
list.layout(layout_w, layout_h, title_h, |_| {
ListItemMeasure::new(line_height as f32)
})
}
pub fn msv_metrics(line_height: f64, allow_resize: bool) -> MsvLayoutMetrics {
MsvLayoutMetrics {
header_size: (line_height * 1.4) as f32,
divider_size: if allow_resize { 1.0 } else { 0.0 },
scrollbar_size: 8.0,
cell_quantum: 0.0,
}
}
pub fn msv_layout(
view: &MultiSectionView,
bounds: QRect,
line_height: f64,
) -> MultiSectionViewLayout {
let metrics = msv_metrics(line_height, view.allow_resize);
view.layout(bounds, metrics, |i| {
msv_body_measure(&view.sections[i].body, &view.sections[i].aux, line_height)
})
}
pub fn msv_body_measure(
body: &SectionBody,
aux: &Option<SectionAux>,
line_height: f64,
) -> SectionMeasure {
let item_h = (line_height * 1.4).round() as f32;
let aux_size = if aux.is_some() { item_h } else { 0.0 };
let content_size = match body {
SectionBody::Tree(t) => {
let header_h = (line_height * 1.2).round() as f32;
let mut total = 0.0_f32;
for row in &t.rows {
let is_header = matches!(row.decoration, Decoration::Header);
total += if is_header { header_h } else { item_h };
}
total
}
SectionBody::List(l) => {
let title_h = if l.title.is_some() {
line_height as f32
} else {
0.0
};
title_h + l.items.len() as f32 * item_h
}
SectionBody::Form(f) => f.fields.len() as f32 * item_h,
SectionBody::Chart(c) => {
if matches!(c.kind, crate::primitives::chart::ChartKind::Sparkline) {
line_height as f32
} else {
item_h * 8.0
}
}
SectionBody::MessageList(m) => {
m.rows
.iter()
.map(|r| {
let lines = r.text.lines().count().max(1) as f32;
line_height as f32 + lines * line_height as f32
})
.sum()
}
SectionBody::Terminal(_) => 0.0,
SectionBody::Text(lines) => lines.len() as f32 * line_height as f32,
SectionBody::Empty(_) => item_h * 4.0, SectionBody::Custom(_) => 0.0,
};
SectionMeasure {
content_size,
aux_size,
}
}
pub fn form_row_height(line_height: f64) -> f32 {
(line_height * 1.4).round() as f32
}
fn items_start_x(label: &str, measure: &dyn TextMeasure, for_toolbar: bool) -> f32 {
if for_toolbar && label.is_empty() {
return 6.0;
}
let label_w = measure.width_of(label);
6.0 + label_w + 12.0
}
fn label_text(field: &FormField) -> String {
field.label.spans.iter().map(|s| s.text.as_str()).collect()
}
fn validation_message(field: &FormField) -> Option<&str> {
match &field.validation {
Some(ValidationState::Error(m)) | Some(ValidationState::Warning(m)) if !m.is_empty() => {
Some(m.as_str())
}
_ => None,
}
}
pub fn form_field_measure(
field: &FormField,
row_h: f32,
measure: &dyn TextMeasure,
) -> FormFieldMeasure {
let mut m = form_field_measure_inner(field, row_h, measure);
if validation_message(field).is_some() {
m.height += row_h / 1.4;
}
m
}
fn form_field_measure_inner(
field: &FormField,
row_h: f32,
measure: &dyn TextMeasure,
) -> FormFieldMeasure {
match &field.kind {
FieldKind::ToggleGroup { toggles } => {
let start_x = items_start_x(&label_text(field), measure, false);
let items = toggles
.iter()
.map(|t| FormItemMeasure {
id: t.id,
width: measure.width_of(&t.label),
})
.collect();
FormFieldMeasure::with_items(row_h, start_x, 8.0, items)
}
FieldKind::ButtonRow { buttons } => {
let start_x = items_start_x(&label_text(field), measure, false);
let items = buttons
.iter()
.map(|b| FormItemMeasure {
id: b.id,
width: measure.width_of(&b.label) + pixel::FORM_BUTTON_ITEM_PADDING,
})
.collect();
FormFieldMeasure::with_items(row_h, start_x, 8.0, items)
}
FieldKind::SegmentedControl { options, .. } => {
let start_x = items_start_x(&label_text(field), measure, false);
let items = options
.iter()
.enumerate()
.map(|(idx, opt)| FormItemMeasure {
id: WidgetId::new(format!("{}__seg_{idx}", field.id.as_str())),
width: measure.width_of(opt) + pixel::FORM_BUTTON_ITEM_PADDING,
})
.collect();
FormFieldMeasure::with_items(row_h, start_x, 0.0, items)
}
FieldKind::Toolbar(toolbar) => {
use crate::primitives::toolbar::{measure_button, ToolbarButton};
let label = label_text(field);
let start_x = items_start_x(&label, measure, true);
let items = toolbar
.buttons
.iter()
.map(|btn| {
let id = match btn {
ToolbarButton::Action { id, .. } => *id,
_ => field.id,
};
let width = measure_button(measure, btn);
FormItemMeasure { id, width }
})
.collect();
FormFieldMeasure::with_items(row_h, start_x, 0.0, items)
}
_ => FormFieldMeasure::new(row_h),
}
}
#[allow(clippy::too_many_arguments)]
pub fn pixel_toast_stack_layout(
stack: &ToastOverlay,
measure: &dyn TextMeasure,
origin_x: f32,
origin_y: f32,
viewport_width: f32,
viewport_height: f32,
line_height: f32,
) -> ToastStackLayout {
stack.layout(
origin_x,
origin_y,
viewport_width,
viewport_height,
pixel::TOAST_MARGIN,
pixel::TOAST_GAP,
|i| {
let toast = &stack.toasts[i];
let width =
pixel::TOAST_WIDTH.min((viewport_width - pixel::TOAST_MARGIN * 2.0).max(0.0));
let body_avail = (width - pixel::TOAST_PADDING * 2.0).max(0.0);
let body_lines = if toast.body.is_empty() {
0
} else {
crate::primitives::toast::wrap_text_lines(
&toast.body,
body_avail,
crate::primitives::toast::MAX_BODY_LINES,
&|s| measure.width_of(s),
)
.len()
.max(1)
};
let has_actions = !toast.actions.is_empty();
let button_row_h = if has_actions {
pixel::TOAST_PADDING + line_height
} else {
0.0
};
let h = line_height
+ pixel::TOAST_PADDING * 2.0
+ body_lines as f32 * line_height
+ button_row_h;
let action_widths: Vec<f32> = toast
.actions
.iter()
.map(|a| measure.width_of(&a.label) + pixel::TOAST_ACTION_PADDING)
.collect();
let (dismiss_rect, action_rects) = crate::primitives::toast::toast_button_rects(
width,
h,
pixel::TOAST_PADDING,
pixel::TOAST_DISMISS_WIDTH,
line_height,
&action_widths,
line_height,
pixel::TOAST_ACTION_GAP,
);
ToastMeasure {
width,
height: h,
dismiss_rect,
action_rects,
}
},
)
}
pub fn pixel_split_layout(split: &Split, bounds: QRect) -> SplitLayout {
split.layout(bounds, SplitMeasure::new(pixel::DIVIDER))
}
pub fn pixel_split_tree_layout(tree: &SplitTree, bounds: QRect) -> SplitTreeLayout {
tree.layout(bounds, SplitTreeMeasure::new(pixel::DIVIDER))
}
pub fn pixel_progress_layout(
bar: &ProgressBar,
x: f32,
y: f32,
w: f32,
h: f32,
) -> ProgressBarLayout {
let cancel_width = if bar.cancellable {
pixel::PROGRESS_CANCEL_WIDTH
} else {
0.0
};
bar.layout(
x,
y,
ProgressBarMeasure {
width: w,
height: h,
cancel_width,
},
)
}
pub fn pixel_pipeline_view_layout(
view: &PipelineView,
x: f32,
y: f32,
w: f32,
h: f32,
) -> PipelineViewLayout {
let action_h = if view.stages.iter().any(|s| s.action.is_some()) {
pixel::PIPELINE_ACTION_HEIGHT
} else {
0.0
};
view.layout(
x,
y + pixel::PIPELINE_FOCUS_INDICATOR_H,
PipelineViewMeasure::new(
w,
(h - pixel::PIPELINE_FOCUS_INDICATOR_H).max(0.0),
pixel::PIPELINE_ARROW_WIDTH,
action_h,
),
)
}
pub fn pixel_board_layout(model: &BoardModel, x: f32, y: f32, w: f32, h: f32) -> BoardLayout {
use crate::primitives::board::{
board_layout, BOARD_CARD_GAP_PX, BOARD_CARD_H_PX, BOARD_COL_GAP_PX, BOARD_COL_MIN_PX,
BOARD_HEADER_H_PX,
};
board_layout(
model,
x,
y,
w,
h,
BoardMeasure::new(
BOARD_COL_MIN_PX,
BOARD_COL_GAP_PX,
BOARD_HEADER_H_PX,
BOARD_CARD_H_PX,
BOARD_CARD_GAP_PX,
),
)
}
pub fn pixel_panel_layout(
panel: &Panel,
x: f32,
y: f32,
w: f32,
h: f32,
line_height: f32,
) -> PanelLayout {
let bounds = QRect::new(x, y, w, h);
let measure = PanelMeasure {
title_bar_height: if panel.title.is_some() {
line_height
} else {
0.0
},
action_button_width: pixel::PANEL_ACTION_BUTTON,
content_padding: 0.0,
};
panel.layout(bounds, measure)
}
pub fn pixel_chart_layout(
chart: &Chart,
x: f32,
y: f32,
w: f32,
h: f32,
line_height: f32,
char_width: f32,
) -> ChartLayout {
chart.layout(
x,
y,
ChartMeasure {
width: w,
height: h,
char_width,
line_height,
},
)
}
pub fn pixel_data_table_layout(
table: &DataTable,
w: f32,
h: f32,
line_height: f32,
measure: &dyn TextMeasure,
) -> DataTableLayout {
let header_height = (line_height * 1.2).round();
table.layout(
w,
h,
line_height,
header_height,
pixel::DATA_TABLE_SCROLLBAR_WIDTH,
|col| ColumnMeasure::new(measure.width_of(&col.title)),
)
}
pub fn pixel_minimap_layout_scaled(
minimap: &Minimap,
bounds: QRect,
scale: MinimapScale,
) -> MinimapLayout {
minimap.layout_with_sizing(
bounds,
pixel::MINIMAP_LINES_PER_ROW,
MinimapSizing::FixedPitch(scale.row_pitch_px() as f32),
)
}
#[allow(clippy::too_many_arguments)]
pub fn pixel_tab_bar_layout(
bar: &TabBar,
width: f32,
height: f32,
tab_pad: f32,
tab_inner_gap: f32,
tab_outer_gap: f32,
tab_name_widths: &[f32],
tab_icon_extras: &[f32],
chrome: &TabChrome,
measure: &dyn TextMeasure,
) -> (TabBarLayout, usize, usize) {
let close_glyph_w = if bar.show_tab_close {
measure.width_of("×")
} else {
0.0
};
let brackets = matches!(chrome.active_frame, TabFrame::Brackets);
let (bracket_open_w, bracket_close_w) = if brackets {
(measure.width_of("["), measure.width_of("]"))
} else {
(0.0, 0.0)
};
let measure_tab = |i: usize| -> TabMeasure {
let name_w = tab_name_widths[i];
let icon_extra = tab_icon_extras[i];
let has_close = bar.show_tab_close && bar.tabs[i].is_closable;
let is_bracket = brackets && bar.tabs[i].is_active;
let close_extra = if has_close {
tab_inner_gap + close_glyph_w
} else {
0.0
};
let bracket_extra = if is_bracket {
bracket_open_w + bracket_close_w
} else {
0.0
};
let total =
tab_pad + bracket_extra + icon_extra + name_w + close_extra + tab_pad + tab_outer_gap;
if has_close {
let close_w = tab_inner_gap + close_glyph_w;
let trailing = tab_pad + tab_outer_gap + if is_bracket { bracket_close_w } else { 0.0 };
TabMeasure::new(total, close_w).with_trailing(trailing)
} else {
TabMeasure::new(total, 0.0)
}
};
let tab_measures: Vec<TabMeasure> = (0..bar.tabs.len()).map(&measure_tab).collect();
let seg_widths: Vec<f32> = (0..bar.right_segments.len())
.map(|i| measure.width_of(&bar.right_segments[i].text))
.collect();
let layout = bar.layout(
width,
height,
0.0, |i| tab_measures[i],
|i| SegmentMeasure::new(seg_widths[i]),
);
let reserved_px: f32 = seg_widths.iter().sum();
let effective_tab_area = (width - reserved_px).max(0.0);
let active_idx = bar.tabs.iter().position(|t| t.is_active);
let corrected_scroll_offset = match active_idx {
Some(active) => TabBar::fit_active_scroll_offset(
active,
bar.tabs.len(),
effective_tab_area as usize,
|i| tab_measures[i].total_width.ceil() as usize,
),
None => bar.scroll_offset,
};
let sample_w = measure.width_of(pixel::TAB_CELL_WIDTH_SAMPLE);
let char_w = (sample_w / pixel::TAB_CELL_WIDTH_SAMPLE.chars().count() as f32).max(1.0);
let available_cols = (effective_tab_area / char_w).floor().max(0.0) as usize;
(layout, corrected_scroll_offset, available_cols)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::message_list::{MessageList, MessageRow};
use crate::primitives::multi_section_view::SectionBody;
use crate::types::Color;
#[test]
fn msv_body_measure_message_list_is_not_zero() {
let rows = vec![MessageRow::new(
"line one\nline two",
Color::rgb(255, 255, 255),
0.0,
)];
let body = SectionBody::MessageList(MessageList {
id: WidgetId::new("test-msg-list"),
rows,
scroll_top: 0,
});
let measure = msv_body_measure(&body, &None, 20.0);
assert!(
measure.content_size > 0.0,
"MessageList section must measure non-zero height, got {}",
measure.content_size
);
assert_eq!(measure.content_size, 20.0 + 2.0 * 20.0);
}
struct FakeMeasure;
impl TextMeasure for FakeMeasure {
fn width_of(&self, text: &str) -> f32 {
text.chars().count() as f32 * 6.0
}
}
fn field_with(id: &str, label: &str, kind: FieldKind) -> FormField {
FormField {
id: WidgetId::new(id),
label: crate::types::StyledText::plain(label),
kind,
hint: crate::types::StyledText::plain(""),
disabled: false,
validation: None,
}
}
#[test]
fn form_field_measure_toggle_group_computes_start_x_and_item_widths() {
use crate::primitives::form::ToggleGroupItem;
let field = field_with(
"flags",
"Flags", FieldKind::ToggleGroup {
toggles: vec![
ToggleGroupItem {
id: WidgetId::new("case"),
label: "Aa".into(), value: false,
},
ToggleGroupItem {
id: WidgetId::new("word"),
label: "Word".into(), value: true,
},
],
},
);
let m = form_field_measure(&field, 20.0, &FakeMeasure);
assert_eq!(m.height, 20.0);
assert_eq!(m.items_start_x, 6.0 + 30.0 + 12.0);
assert_eq!(m.item_gap, 8.0);
assert_eq!(m.item_measures.len(), 2);
assert_eq!(m.item_measures[0].width, 2.0 * 6.0);
assert_eq!(m.item_measures[1].width, 4.0 * 6.0);
}
#[test]
fn form_field_measure_button_row_item_width_has_no_bracket_glyphs() {
use crate::primitives::form::ButtonRowItem;
let field = field_with(
"actions",
"",
FieldKind::ButtonRow {
buttons: vec![ButtonRowItem {
id: WidgetId::new("find"),
label: "Find".into(), disabled: false,
icon: None,
}],
},
);
let m = form_field_measure(&field, 20.0, &FakeMeasure);
assert_eq!(
m.item_measures[0].width,
4.0 * 6.0 + pixel::FORM_BUTTON_ITEM_PADDING,
"width must be the plain label width plus the padding \
constant, not a `[Find]` bracket-wrapped measurement"
);
}
#[test]
fn form_field_measure_segmented_control_has_no_item_gap() {
let field = field_with(
"scope",
"",
FieldKind::SegmentedControl {
options: vec!["File".into(), "Project".into()],
selected_idx: 0,
},
);
let m = form_field_measure(&field, 20.0, &FakeMeasure);
assert_eq!(m.items_start_x, 6.0 + 0.0 + 12.0);
assert_eq!(m.item_gap, 0.0);
assert_eq!(m.item_measures[0].id, WidgetId::new("scope__seg_0"));
assert_eq!(m.item_measures[1].id, WidgetId::new("scope__seg_1"));
}
#[test]
fn form_field_measure_toolbar_start_x_skips_gap_only_when_label_empty() {
use crate::primitives::toolbar::{Toolbar, ToolbarButton};
let toolbar = Toolbar {
id: WidgetId::new("tb"),
buttons: vec![ToolbarButton::Separator],
bg: None,
focused_index: None,
};
let empty_label = field_with("tb", "", FieldKind::Toolbar(toolbar.clone()));
let m = form_field_measure(&empty_label, 20.0, &FakeMeasure);
assert_eq!(m.items_start_x, 6.0);
let with_label = field_with("tb", "Go", FieldKind::Toolbar(toolbar)); let m = form_field_measure(&with_label, 20.0, &FakeMeasure);
assert_eq!(m.items_start_x, 6.0 + 12.0 + 12.0);
}
#[test]
fn form_field_measure_default_kind_has_no_items() {
let field = field_with("name", "Name", FieldKind::Button);
let m = form_field_measure(&field, 20.0, &FakeMeasure);
assert_eq!(m.height, 20.0);
assert!(m.item_measures.is_empty());
assert_eq!(m.items_start_x, 0.0);
assert_eq!(m.item_gap, 0.0);
}
#[test]
fn form_field_measure_reserves_extra_height_for_validation_message() {
use crate::primitives::form::ValidationState;
const ROW_H: f32 = 20.0;
let mut field = field_with("name", "Name", FieldKind::Button);
let base = form_field_measure(&field, ROW_H, &FakeMeasure);
assert_eq!(base.height, ROW_H);
field.validation = Some(ValidationState::Error("Required".to_string()));
let with_msg = form_field_measure(&field, ROW_H, &FakeMeasure);
assert!(
with_msg.height > ROW_H,
"a non-empty validation message must reserve extra height \
beyond row_h ({ROW_H}), got {}",
with_msg.height
);
field.validation = Some(ValidationState::Error(String::new()));
let empty_msg = form_field_measure(&field, ROW_H, &FakeMeasure);
assert_eq!(
empty_msg.height, ROW_H,
"an empty validation message must not reserve extra height"
);
}
#[test]
fn form_field_measure_height_is_one_row_for_every_field_kind() {
use crate::primitives::form::{ButtonRowItem, ToggleGroupItem};
use crate::primitives::toolbar::Toolbar;
use crate::types::{Color, StyledText};
const ROW_H: f32 = 20.0;
let kinds = vec![
("label", FieldKind::Label),
("toggle", FieldKind::Toggle { value: true }),
(
"text-input",
FieldKind::TextInput {
value: "hello".into(),
placeholder: String::new(),
cursor: Some(2),
selection_anchor: None,
},
),
("button", FieldKind::Button),
(
"read-only",
FieldKind::ReadOnly {
value: StyledText::plain("v1.0"),
},
),
(
"slider",
FieldKind::Slider {
value: 5.0,
min: 0.0,
max: 10.0,
step: 1.0,
},
),
(
"color-picker",
FieldKind::ColorPicker {
value: Color::rgb(255, 0, 0),
},
),
(
"dropdown",
FieldKind::Dropdown {
options: vec![StyledText::plain("a"), StyledText::plain("b")],
selected_idx: 0,
},
),
(
"text-area",
FieldKind::TextArea {
value: "line one\nline two\nline three".into(),
placeholder: String::new(),
cursor: Some(3),
visible_rows: 6,
},
),
(
"password",
FieldKind::PasswordInput {
value: "hunter2".into(),
placeholder: String::new(),
cursor: Some(3),
mask_char: '•',
},
),
(
"segmented",
FieldKind::SegmentedControl {
options: vec!["File".into(), "Folder".into()],
selected_idx: 0,
},
),
(
"toggle-group",
FieldKind::ToggleGroup {
toggles: vec![
ToggleGroupItem {
id: WidgetId::new("a"),
label: "Aa".into(),
value: false,
},
ToggleGroupItem {
id: WidgetId::new("b"),
label: "Bb".into(),
value: true,
},
],
},
),
(
"button-row",
FieldKind::ButtonRow {
buttons: vec![ButtonRowItem {
id: WidgetId::new("find"),
label: "Find".into(),
disabled: false,
icon: None,
}],
},
),
(
"toolbar",
FieldKind::Toolbar(Toolbar {
id: WidgetId::new("tb"),
buttons: vec![],
bg: None,
focused_index: None,
}),
),
(
"date-picker",
FieldKind::DatePicker(crate::primitives::date_picker::DatePicker::new(
WidgetId::new("dp"),
crate::primitives::date_picker::CalendarDate::new(2024, 3, 15),
)),
),
];
for (name, kind) in kinds {
let field = field_with(name, "Label", kind);
let m = form_field_measure(&field, ROW_H, &FakeMeasure);
assert_eq!(
m.height, ROW_H,
"FieldKind::{name} must measure exactly one row_h ({ROW_H}), got {}",
m.height,
);
}
}
fn list_with_max_content_width(n_items: usize, max_content_width: Option<usize>) -> ListView {
use crate::primitives::list::ListItem;
use crate::types::StyledText;
ListView {
id: WidgetId::new("l"),
title: None,
items: (0..n_items)
.map(|i| ListItem {
text: StyledText::plain(format!("row {i}")),
icon: None,
detail: None,
decoration: Decoration::Normal,
})
.collect(),
selected_idx: 0,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width,
show_v_scrollbar: false,
}
}
#[test]
fn list_layout_reserves_hscrollbar_row_when_content_overflows() {
const LINE_HEIGHT: f64 = 10.0;
const CHAR_WIDTH: f64 = 8.0;
const W: f64 = 200.0; const H: f64 = 100.0;
let overflowing = list_with_max_content_width(12, Some(1000));
let fitting = list_with_max_content_width(12, None);
let with_reservation = list_layout(&overflowing, W, H, LINE_HEIGHT, CHAR_WIDTH, 0.0);
let without_reservation = list_layout(&fitting, W, H, LINE_HEIGHT, CHAR_WIDTH, 0.0);
assert_eq!(
without_reservation.visible_items.len(),
10,
"sanity: 100px / 10px rows == 10 full rows with no reservation"
);
assert_eq!(
with_reservation.visible_items.len(),
9,
"overflowing max_content_width must reserve exactly one \
line_height row for the h-scrollbar, leaving 9 rows"
);
let last = with_reservation.visible_items.last().unwrap();
assert!(
(last.bounds.y + last.bounds.height) as f64 <= H - LINE_HEIGHT,
"reserved layout's content must stop at or before the \
scrollbar row's top edge"
);
}
#[test]
fn list_layout_border_inset_reduces_visible_width_for_overflow_check() {
const LINE_HEIGHT: f64 = 10.0;
const CHAR_WIDTH: f64 = 8.0;
const W: f64 = 200.0;
const H: f64 = 100.0;
const BORDER_INSET: f64 = 1.0;
let list = list_with_max_content_width(12, Some(25));
let with_border = list_layout(&list, W, H, LINE_HEIGHT, CHAR_WIDTH, BORDER_INSET);
let without_border = list_layout(&list, W, H, LINE_HEIGHT, CHAR_WIDTH, 0.0);
assert_eq!(
without_border.visible_items.len(),
10,
"200px content width == 200px visible width: no overflow, no reservation"
);
assert_eq!(
with_border.visible_items.len(),
9,
"200px content width > 198px visible width once border-inset \
narrows it: overflow, reservation kicks in"
);
}
fn bare_tree() -> TreeView {
TreeView {
id: WidgetId::new("t"),
rows: vec![],
selection_mode: crate::types::SelectionMode::Single,
selected_path: None,
scroll_offset: 0,
style: crate::types::TreeStyle::default(),
has_focus: false,
}
}
#[test]
fn tree_row_pitch_matches_tree_layout_non_header_pitch_without_override() {
let mut tree = bare_tree();
tree.style.row_height = None;
assert_eq!(tree_row_pitch(&tree, 16.0), (16.0_f64 * 1.4).round());
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
fn tree_with_expanded_row() -> TreeView {
let mut tree = bare_tree();
tree.rows = vec![crate::primitives::tree::TreeRow {
path: vec![0],
indent: 1,
icon: None,
text: crate::types::StyledText::plain("src/"),
badge: None,
is_expanded: Some(true),
decoration: Decoration::Normal,
edit: None,
}];
tree
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[test]
fn tree_layout_measured_uses_real_chevron_glyph_width_not_the_estimate() {
let tree = tree_with_expanded_row();
let area = QRect::new(0.0, 0.0, 100.0, 80.0);
let estimated = tree_layout(&tree, area, 16.0);
let measured = tree_layout_measured(&tree, area, 16.0, &FakeMeasure);
let est_x = estimated.hit_regions[0].0.width;
let measured_x = measured.hit_regions[0].0.width;
assert_ne!(
est_x, measured_x,
"measured chevron end-x must use the real glyph width, not the estimate"
);
let indent_px = (16.0_f64 * 0.9).round();
let expected = (2.0 + 1.0 * indent_px + 6.0 + 4.0) as f32;
assert_eq!(measured_x, expected);
}
#[test]
fn tree_row_pitch_honors_tree_style_row_height_override() {
let mut tree = bare_tree();
tree.style.row_height = Some(42);
assert_eq!(tree_row_pitch(&tree, 16.0), 42.0);
assert_eq!(tree_row_pitch(&tree, 100.0), 42.0);
}
#[test]
fn tree_row_pitch_suppresses_px_row_height_on_cell_grid() {
let tree = bare_tree(); assert_eq!(tree_row_pitch(&tree, 1.0), 1.0);
}
struct FakeTabMeasure;
impl TextMeasure for FakeTabMeasure {
fn width_of(&self, text: &str) -> f32 {
text.chars().count() as f32 * 6.0
}
}
fn tab_bar_with(tabs: Vec<crate::primitives::tab_bar::TabItem>) -> TabBar {
TabBar {
id: WidgetId::new("tabs"),
tabs,
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
}
}
fn closable_tab(label: &str, is_active: bool) -> crate::primitives::tab_bar::TabItem {
crate::primitives::tab_bar::TabItem {
label: label.to_string(),
is_active,
is_dirty: false,
is_preview: false,
is_closable: true,
}
}
#[test]
fn pixel_tab_bar_layout_populates_available_cols() {
let bar = tab_bar_with(vec![closable_tab("main.rs", true)]);
let name_widths = [bar.tabs[0].label.chars().count() as f32 * 6.0];
let icon_extras = [0.0_f32];
let (_, _, available_cols) = pixel_tab_bar_layout(
&bar,
400.0,
22.0,
pixel::TAB_PAD,
pixel::TAB_INNER_GAP,
pixel::TAB_OUTER_GAP,
&name_widths,
&icon_extras,
&TabChrome::default(),
&FakeTabMeasure,
);
assert!(
available_cols > 0,
"a 400px bar with a single short tab should report a non-zero \
available_cols, got {available_cols}"
);
}
#[test]
fn pixel_tab_bar_layout_close_region_excludes_trailing_padding() {
use crate::primitives::tab_bar::TabBarHit;
let bar = tab_bar_with(vec![closable_tab("main.rs", true)]);
let name_widths = [bar.tabs[0].label.chars().count() as f32 * 6.0];
let icon_extras = [0.0_f32];
let (layout, _, _) = pixel_tab_bar_layout(
&bar,
400.0,
22.0,
pixel::TAB_PAD,
pixel::TAB_INNER_GAP,
pixel::TAB_OUTER_GAP,
&name_widths,
&icon_extras,
&TabChrome::default(),
&FakeTabMeasure,
);
let vt = &layout.visible_tabs[0];
let close = vt
.close_bounds
.expect("closable tab must report close bounds");
let tab_right_edge = vt.bounds.x + vt.bounds.width;
assert!(
close.x + close.width < tab_right_edge,
"close region [{}, {}) must stop before the tab's right edge {}",
close.x,
close.x + close.width,
tab_right_edge,
);
let probe_x = tab_right_edge - 1.0;
assert!(
probe_x >= close.x + close.width,
"test fixture's probe point must actually fall in the trailing \
gap, past the close region's own right edge"
);
match layout.hit_test(probe_x, vt.bounds.y + vt.bounds.height / 2.0) {
TabBarHit::Tab(0) => {}
other => panic!(
"click at x={probe_x} (old padding, now outside the close glyph) \
expected Tab(0), got {other:?}"
),
}
let (cx, cy) = (
close.x + close.width / 2.0,
vt.bounds.y + vt.bounds.height / 2.0,
);
assert_eq!(layout.hit_test(cx, cy), TabBarHit::TabClose(0));
}
#[test]
fn pixel_tab_bar_layout_corrected_scroll_offset_keeps_active_tab_visible() {
let tabs: Vec<_> = (0..10)
.map(|i| closable_tab(&format!("t{i}"), i == 9))
.collect();
let mut bar = tab_bar_with(tabs);
bar.scroll_offset = 0; let name_widths = vec![6.0_f32; bar.tabs.len()]; let icon_extras = vec![0.0_f32; bar.tabs.len()];
let (_, corrected_scroll_offset, _) = pixel_tab_bar_layout(
&bar,
120.0,
22.0,
pixel::TAB_PAD,
pixel::TAB_INNER_GAP,
pixel::TAB_OUTER_GAP,
&name_widths,
&icon_extras,
&TabChrome::default(),
&FakeTabMeasure,
);
assert!(
corrected_scroll_offset > 0,
"active tab 9 isn't visible at the stale scroll_offset 0; the \
corrected offset must be non-zero"
);
}
#[test]
fn pixel_tab_bar_layout_bracket_chrome_still_excludes_trailing_padding() {
use crate::primitives::tab_bar::{TabBarHit, TabFrame};
let bar = tab_bar_with(vec![closable_tab("main.rs", true)]);
let name_widths = [bar.tabs[0].label.chars().count() as f32 * 6.0];
let icon_extras = [0.0_f32];
let chrome = TabChrome::new(TabFrame::Brackets);
let (layout, _, _) = pixel_tab_bar_layout(
&bar,
400.0,
22.0,
pixel::TAB_PAD,
pixel::TAB_INNER_GAP,
pixel::TAB_OUTER_GAP,
&name_widths,
&icon_extras,
&chrome,
&FakeTabMeasure,
);
let vt = &layout.visible_tabs[0];
let close = vt
.close_bounds
.expect("closable tab must report close bounds");
let tab_right_edge = vt.bounds.x + vt.bounds.width;
assert!(
close.x + close.width < tab_right_edge,
"bracket-framed close region must still stop before the tab's \
right edge (now with room for the closing bracket too)"
);
match layout.hit_test(tab_right_edge - 1.0, vt.bounds.y + vt.bounds.height / 2.0) {
TabBarHit::Tab(0) => {}
other => {
panic!("click in the trailing bracket+padding gap expected Tab(0), got {other:?}")
}
}
}
}