use std::fmt::Write as _;
use crate::layout;
use crate::metrics::MetricsRole;
use crate::state::UiState;
use crate::tree::*;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Finding {
pub kind: FindingKind,
pub node_id: String,
pub source: Source,
pub message: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FindingKind {
RawColor,
Overflow,
TextOverflow,
DuplicateId,
Alignment,
Spacing,
MissingSurfaceFill,
ReinventedWidget,
FocusRingObscured,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct LintReport {
pub findings: Vec<Finding>,
}
impl LintReport {
pub fn text(&self) -> String {
if self.findings.is_empty() {
return "no findings\n".to_string();
}
let mut s = String::new();
for f in &self.findings {
let _ = writeln!(
s,
"{kind:?} node={id} {source} :: {msg}",
kind = f.kind,
id = f.node_id,
source = if f.source.line == 0 {
"<no-source>".to_string()
} else {
format!("{}:{}", short_path(f.source.file), f.source.line)
},
msg = f.message,
);
}
s
}
}
pub fn lint(root: &El, ui_state: &UiState) -> LintReport {
let mut r = LintReport::default();
let mut seen_ids: std::collections::BTreeMap<String, usize> = Default::default();
walk(
root,
None,
None,
ClipCtx::None,
ui_state,
&mut r,
&mut seen_ids,
);
for (id, n) in seen_ids {
if n > 1 {
r.findings.push(Finding {
kind: FindingKind::DuplicateId,
node_id: id.clone(),
source: Source::default(),
message: format!("{n} nodes share id {id}"),
});
}
}
r
}
fn is_from_user(source: Source) -> bool {
!source.from_library
}
#[derive(Clone, Copy)]
enum ClipCtx {
None,
Static(Rect),
Scrolling {
rect: Rect,
scroll_axis: Axis,
},
}
fn walk(
n: &El,
parent_kind: Option<&Kind>,
parent_blame: Option<Source>,
nearest_clip: ClipCtx,
ui_state: &UiState,
r: &mut LintReport,
seen: &mut std::collections::BTreeMap<String, usize>,
) {
*seen.entry(n.computed_id.clone()).or_default() += 1;
let computed = ui_state.rect(&n.computed_id);
let from_user_self = is_from_user(n.source);
let self_blame = if from_user_self {
Some(n.source)
} else {
parent_blame
};
let inside_inlines = matches!(parent_kind, Some(Kind::Inlines));
if from_user_self {
if let Some(c) = n.fill
&& c.token.is_none()
&& c.a > 0
{
r.findings.push(Finding {
kind: FindingKind::RawColor,
node_id: n.computed_id.clone(),
source: n.source,
message: format!(
"fill is a raw rgba({},{},{},{}) — use a token",
c.r, c.g, c.b, c.a
),
});
}
if let Some(c) = n.stroke
&& c.token.is_none()
&& c.a > 0
{
r.findings.push(Finding {
kind: FindingKind::RawColor,
node_id: n.computed_id.clone(),
source: n.source,
message: format!(
"stroke is a raw rgba({},{},{},{}) — use a token",
c.r, c.g, c.b, c.a
),
});
}
if let Some(c) = n.text_color
&& c.token.is_none()
&& c.a > 0
{
r.findings.push(Finding {
kind: FindingKind::RawColor,
node_id: n.computed_id.clone(),
source: n.source,
message: format!(
"text_color is a raw rgba({},{},{},{}) — use a token",
c.r, c.g, c.b, c.a
),
});
}
if n.fill.is_none() && matches!(n.surface_role, SurfaceRole::Panel) {
r.findings.push(Finding {
kind: FindingKind::MissingSurfaceFill,
node_id: n.computed_id.clone(),
source: n.source,
message:
"surface_role(Panel) without a fill paints only stroke + shadow — \
wrap in card() / sidebar() / dialog() for the canonical recipe, or set .fill(tokens::CARD)"
.to_string(),
});
}
if matches!(n.kind, Kind::Group) && !n.children.is_empty() {
let card_fill = n
.fill
.as_ref()
.and_then(|c| c.token)
.is_some_and(|t| t == "card");
let border_stroke = n
.stroke
.as_ref()
.and_then(|c| c.token)
.is_some_and(|t| t == "border");
if card_fill && border_stroke {
let is_panel_surface = matches!(n.surface_role, SurfaceRole::Panel);
let sidebar_width = matches!(n.width, Size::Fixed(w) if (w - crate::tokens::SIDEBAR_WIDTH).abs() < 0.5);
if !is_panel_surface {
if sidebar_width {
r.findings.push(Finding {
kind: FindingKind::ReinventedWidget,
node_id: n.computed_id.clone(),
source: n.source,
message:
"Group with fill=CARD, stroke=BORDER, width=SIDEBAR_WIDTH reinvents sidebar() — \
use sidebar([sidebar_header(...), sidebar_group([sidebar_menu([sidebar_menu_button(label, current)])])]) \
for the panel surface and the canonical row recipe"
.to_string(),
});
} else {
r.findings.push(Finding {
kind: FindingKind::ReinventedWidget,
node_id: n.computed_id.clone(),
source: n.source,
message:
"Group with fill=CARD, stroke=BORDER reinvents the panel-surface recipe — \
use card([card_header([card_title(\"...\")]), card_content([...])]) / titled_card(\"Title\", [...]) for boxed content, \
or sidebar([...]) for a full-height nav/inspector pane (sidebar() also handles the custom-width case via .width(Size::Fixed(...)))"
.to_string(),
});
}
}
}
}
}
if let Some(blame) = self_blame {
lint_row_alignment(n, computed, ui_state, r, blame);
lint_overlay_alignment(n, computed, ui_state, r, blame);
lint_row_visual_text_spacing(n, ui_state, r, blame);
}
if n.text.is_some()
&& !inside_inlines
&& let Some(blame) = self_blame
{
let available_width = match n.text_wrap {
TextWrap::NoWrap => None,
TextWrap::Wrap => Some(computed.w),
};
if let Some(text_layout) = layout::text_layout(n, available_width) {
let text_w = text_layout.width + n.padding.left + n.padding.right;
let text_h = text_layout.height + n.padding.top + n.padding.bottom;
let raw_overflow_x = (text_w - computed.w).max(0.0);
let overflow_x = if matches!(
(n.text_wrap, n.text_overflow),
(TextWrap::NoWrap, TextOverflow::Ellipsis)
) {
0.0
} else {
raw_overflow_x
};
let overflow_y = (text_h - computed.h).max(0.0);
if overflow_x > 0.5 || overflow_y > 0.5 {
let is_clipped_nowrap = overflow_x > 0.5
&& matches!(
(n.text_wrap, n.text_overflow),
(TextWrap::NoWrap, TextOverflow::Clip)
);
let kind = if is_clipped_nowrap {
FindingKind::TextOverflow
} else {
FindingKind::Overflow
};
let message = if kind == FindingKind::TextOverflow {
format!(
"nowrap text exceeds its box by X={overflow_x:.0}; use .ellipsis(), wrap_text(), or a wider box"
)
} else {
format!(
"text content exceeds its box by X={overflow_x:.0} Y={overflow_y:.0}; use paragraph()/wrap_text(), a wider box, or explicit clipping"
)
};
r.findings.push(Finding {
kind,
node_id: n.computed_id.clone(),
source: blame,
message,
});
}
}
}
let suppress_overflow = n.scrollable
|| matches!(n.kind, Kind::Inlines)
|| matches!(n.kind, Kind::Custom("toast_stack"));
let child_clip = if n.clip {
if n.scrollable {
ClipCtx::Scrolling {
rect: computed,
scroll_axis: n.axis,
}
} else {
ClipCtx::Static(computed)
}
} else {
nearest_clip
};
for (child_idx, c) in n.children.iter().enumerate() {
let from_user_child = is_from_user(c.source);
let child_blame = if from_user_child {
Some(c.source)
} else {
self_blame
};
let c_rect = ui_state.rect(&c.computed_id);
if !suppress_overflow
&& !rect_contains(computed, c_rect, 0.5)
&& let Some(blame) = child_blame
{
let dx_left = (computed.x - c_rect.x).max(0.0);
let dx_right = (c_rect.right() - computed.right()).max(0.0);
let dy_top = (computed.y - c_rect.y).max(0.0);
let dy_bottom = (c_rect.bottom() - computed.bottom()).max(0.0);
r.findings.push(Finding {
kind: FindingKind::Overflow,
node_id: c.computed_id.clone(),
source: blame,
message: format!(
"child overflows parent {parent_id} by L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}",
parent_id = n.computed_id,
),
});
}
if from_user_child
&& c.focusable
&& has_paint_overflow(c.paint_overflow)
&& let Some(blame) = child_blame
{
check_focus_ring_obscured(
c,
c_rect,
child_clip,
&n.children[child_idx + 1..],
ui_state,
r,
blame,
);
}
walk(c, Some(&n.kind), child_blame, child_clip, ui_state, r, seen);
}
}
fn has_paint_overflow(s: Sides) -> bool {
s.left > 0.0 || s.right > 0.0 || s.top > 0.0 || s.bottom > 0.0
}
fn check_focus_ring_obscured(
n: &El,
n_rect: Rect,
nearest_clip: ClipCtx,
later_siblings: &[El],
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
let band = n_rect.outset(n.paint_overflow);
let (clip_rect, check_horiz, check_vert) = match nearest_clip {
ClipCtx::None => (None, false, false),
ClipCtx::Static(rect) => (Some(rect), true, true),
ClipCtx::Scrolling { rect, scroll_axis } => match scroll_axis {
Axis::Column => (Some(rect), true, false),
Axis::Row => (Some(rect), false, true),
Axis::Overlay => (Some(rect), true, true),
},
};
if let Some(clip) = clip_rect {
let dx_left = if check_horiz {
(clip.x - band.x).max(0.0)
} else {
0.0
};
let dx_right = if check_horiz {
(band.right() - clip.right()).max(0.0)
} else {
0.0
};
let dy_top = if check_vert {
(clip.y - band.y).max(0.0)
} else {
0.0
};
let dy_bottom = if check_vert {
(band.bottom() - clip.bottom()).max(0.0)
} else {
0.0
};
if dx_left + dx_right + dy_top + dy_bottom > 0.5 {
r.findings.push(Finding {
kind: FindingKind::FocusRingObscured,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"focus ring band clipped by ancestor scissor (L={dx_left:.0} R={dx_right:.0} T={dy_top:.0} B={dy_bottom:.0}) — give a clipping ancestor padding ≥ tokens::RING_WIDTH on the clipped side",
),
});
}
}
for sib in later_siblings {
let sib_rect = ui_state.rect(&sib.computed_id);
if let Some(side) = bleed_occlusion(n_rect, n.paint_overflow, sib_rect)
&& paints_pixels(sib)
{
r.findings.push(Finding {
kind: FindingKind::FocusRingObscured,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"focus ring band occluded on the {side} edge by later-painted sibling {sib_id} — increase gap to ≥ tokens::RING_WIDTH or restructure so the neighbor doesn't sit on the edge",
sib_id = sib.computed_id,
),
});
break;
}
}
}
fn paints_pixels(n: &El) -> bool {
n.fill.is_some()
|| n.stroke.is_some()
|| n.image.is_some()
|| n.icon.is_some()
|| n.shadow > 0.0
|| n.text.is_some()
|| !matches!(n.surface_role, SurfaceRole::None)
}
fn bleed_occlusion(n_rect: Rect, overflow: Sides, sib_rect: Rect) -> Option<&'static str> {
const EPS: f32 = 0.5;
let bands: [(&'static str, Rect); 4] = [
(
"top",
Rect::new(n_rect.x, n_rect.y - overflow.top, n_rect.w, overflow.top),
),
(
"bottom",
Rect::new(n_rect.x, n_rect.bottom(), n_rect.w, overflow.bottom),
),
(
"left",
Rect::new(n_rect.x - overflow.left, n_rect.y, overflow.left, n_rect.h),
),
(
"right",
Rect::new(n_rect.right(), n_rect.y, overflow.right, n_rect.h),
),
];
for (side, band) in bands {
if band.w <= 0.0 || band.h <= 0.0 {
continue;
}
let iw = band.right().min(sib_rect.right()) - band.x.max(sib_rect.x);
let ih = band.bottom().min(sib_rect.bottom()) - band.y.max(sib_rect.y);
if iw > EPS && ih > EPS {
return Some(side);
}
}
None
}
fn lint_row_alignment(
n: &El,
computed: Rect,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
if !matches!(n.axis, Axis::Row) || !matches!(n.align, Align::Stretch) || n.children.len() < 2 {
return;
}
if !n.children.iter().any(is_text_like_child) {
return;
}
let inner = computed.inset(n.padding);
if inner.h <= 0.0 {
return;
}
for child in &n.children {
if !is_fixed_visual_child(child) {
continue;
}
let child_rect = ui_state.rect(&child.computed_id);
let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
let visibly_short = child_rect.h + 2.0 < inner.h;
if top_pinned && visibly_short {
r.findings.push(Finding {
kind: FindingKind::Alignment,
node_id: n.computed_id.clone(),
source: blame,
message: "row has a fixed-size visual child pinned to the top beside text; add .align(Align::Center) to vertically center row content"
.to_string(),
});
return;
}
}
}
fn lint_overlay_alignment(
n: &El,
computed: Rect,
ui_state: &UiState,
r: &mut LintReport,
blame: Source,
) {
if !matches!(n.axis, Axis::Overlay)
|| n.children.is_empty()
|| !matches!(n.align, Align::Start | Align::Stretch)
|| !matches!(n.justify, Justify::Start | Justify::SpaceBetween)
|| !has_visible_surface(n)
{
return;
}
let inner = computed.inset(n.padding);
if inner.w <= 0.0 || inner.h <= 0.0 {
return;
}
for child in &n.children {
if !is_fixed_visual_child(child) {
continue;
}
let child_rect = ui_state.rect(&child.computed_id);
let left_pinned = (child_rect.x - inner.x).abs() <= 0.5;
let top_pinned = (child_rect.y - inner.y).abs() <= 0.5;
let visibly_narrow = child_rect.w + 2.0 < inner.w;
let visibly_short = child_rect.h + 2.0 < inner.h;
if left_pinned && top_pinned && visibly_narrow && visibly_short {
r.findings.push(Finding {
kind: FindingKind::Alignment,
node_id: n.computed_id.clone(),
source: blame,
message: "overlay has a smaller fixed-size visual child pinned to the top-left; add .align(Align::Center).justify(Justify::Center) to center overlay content"
.to_string(),
});
return;
}
}
}
fn lint_row_visual_text_spacing(n: &El, ui_state: &UiState, r: &mut LintReport, blame: Source) {
if !matches!(n.axis, Axis::Row) || n.children.len() < 2 {
return;
}
for pair in n.children.windows(2) {
let [visual, text] = pair else {
continue;
};
if !is_visual_cluster_child(visual) || !is_text_like_child(text) {
continue;
}
let visual_rect = ui_state.rect(&visual.computed_id);
let text_rect = ui_state.rect(&text.computed_id);
let gap = text_rect.x - visual_rect.right();
if gap < 4.0 {
r.findings.push(Finding {
kind: FindingKind::Spacing,
node_id: n.computed_id.clone(),
source: blame,
message: format!(
"row places text {:.0}px after an icon/control slot; add .gap(tokens::SPACE_2) or use a stock menu/list row",
gap.max(0.0)
),
});
return;
}
}
}
fn is_text_like_child(c: &El) -> bool {
c.text.is_some()
|| c.children
.iter()
.any(|child| child.text.is_some() || matches!(child.kind, Kind::Text | Kind::Heading))
}
fn has_visible_surface(n: &El) -> bool {
n.fill.is_some() || n.stroke.is_some()
}
fn is_fixed_visual_child(c: &El) -> bool {
let fixed_height = matches!(c.height, Size::Fixed(_));
fixed_height
&& (c.icon.is_some()
|| matches!(c.kind, Kind::Badge)
|| matches!(
c.metrics_role,
Some(
MetricsRole::Button
| MetricsRole::IconButton
| MetricsRole::Input
| MetricsRole::Badge
| MetricsRole::TabTrigger
| MetricsRole::ChoiceControl
| MetricsRole::Slider
| MetricsRole::Progress
)
))
}
fn is_visual_cluster_child(c: &El) -> bool {
let fixed_box = matches!(c.width, Size::Fixed(_)) && matches!(c.height, Size::Fixed(_));
fixed_box
&& (c.icon.is_some()
|| matches!(c.kind, Kind::Badge)
|| matches!(
c.metrics_role,
Some(MetricsRole::IconButton | MetricsRole::Badge | MetricsRole::ChoiceControl)
)
|| (has_visible_surface(c) && c.children.iter().any(is_fixed_visual_child)))
}
fn rect_contains(parent: Rect, child: Rect, tol: f32) -> bool {
child.x >= parent.x - tol
&& child.y >= parent.y - tol
&& child.right() <= parent.right() + tol
&& child.bottom() <= parent.bottom() + tol
}
fn short_path(p: &str) -> String {
let parts: Vec<&str> = p.split(['/', '\\']).collect();
if parts.len() >= 2 {
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
p.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lint_one(mut root: El) -> LintReport {
let mut ui_state = UiState::new();
layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
lint(&root, &ui_state)
}
#[test]
fn clipped_nowrap_text_reports_text_overflow() {
let root = crate::text("A very long dashboard label")
.width(Size::Fixed(42.0))
.height(Size::Fixed(20.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn ellipsis_nowrap_text_satisfies_horizontal_overflow_policy() {
let root = crate::text("A very long dashboard label")
.ellipsis()
.width(Size::Fixed(42.0))
.height(Size::Fixed(20.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn padding_aware_text_overflow_fires_when_text_spills_past_padded_region() {
let leaf = crate::text("dashboard")
.width(Size::Fixed(80.0))
.height(Size::Fixed(28.0))
.padding(Sides::xy(20.0, 0.0));
let root = crate::row([leaf]);
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::TextOverflow),
"{}",
report.text()
);
}
#[test]
fn stretch_row_with_top_pinned_icon_and_text_suggests_center_alignment() {
let root = crate::row([
crate::icon("settings").icon_size(crate::tokens::ICON_SM),
crate::text("Settings").width(Size::Fill(1.0)),
])
.height(Size::Fixed(36.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment
&& finding.message.contains(".align(Align::Center)")),
"{}",
report.text()
);
}
#[test]
fn centered_row_with_icon_and_text_satisfies_alignment_policy() {
let root = crate::row([
crate::icon("settings").icon_size(crate::tokens::ICON_SM),
crate::text("Settings").width(Size::Fill(1.0)),
])
.height(Size::Fixed(36.0))
.align(Align::Center);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment),
"{}",
report.text()
);
}
#[test]
fn row_with_icon_slot_touching_text_reports_spacing() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.align(Align::Center)
.justify(Justify::Center)
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
.height(Size::Fixed(32.0))
.align(Align::Center);
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Spacing
&& finding.message.contains(".gap(tokens::SPACE_2)")),
"{}",
report.text()
);
}
#[test]
fn row_with_icon_slot_and_text_gap_satisfies_spacing_policy() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.align(Align::Center)
.justify(Justify::Center)
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::row([icon_slot, crate::text("Settings").width(Size::Fill(1.0))])
.height(Size::Fixed(32.0))
.align(Align::Center)
.gap(crate::tokens::SPACE_2);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Spacing),
"{}",
report.text()
);
}
#[test]
fn overlay_with_top_left_pinned_icon_suggests_center_alignment() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::column([icon_slot]);
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment
&& finding.message.contains(".justify(Justify::Center)")),
"{}",
report.text()
);
}
#[test]
fn centered_overlay_icon_satisfies_alignment_policy() {
let icon_slot = crate::stack([crate::icon("settings").icon_size(crate::tokens::ICON_XS)])
.align(Align::Center)
.justify(Justify::Center)
.fill(crate::tokens::MUTED)
.width(Size::Fixed(26.0))
.height(Size::Fixed(26.0));
let root = crate::column([icon_slot]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|finding| finding.kind == FindingKind::Alignment),
"{}",
report.text()
);
}
#[test]
fn overflow_findings_attribute_to_nearest_user_source_ancestor() {
let user_source = Source {
file: "src/screen.rs",
line: 42,
from_library: false,
};
let widget_source = Source {
file: "src/widgets/tabs.rs",
line: 200,
from_library: true,
};
let mut leaf = crate::text("A very long dashboard label")
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0));
leaf.source = widget_source;
let mut root = crate::row([leaf])
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
root.source = user_source;
let mut ui_state = UiState::new();
layout::layout(&mut root, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
let report = lint(&root, &ui_state);
let text_overflow = report
.findings
.iter()
.find(|f| f.kind == FindingKind::TextOverflow)
.unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
assert_eq!(text_overflow.source.file, user_source.file);
assert_eq!(text_overflow.source.line, user_source.line);
}
#[test]
fn overflow_finding_self_attributes_when_node_is_already_user_source() {
let mut node = crate::text("A very long dashboard label")
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0));
let user_source = Source {
file: "src/screen.rs",
line: 99,
from_library: false,
};
node.source = user_source;
let mut ui_state = UiState::new();
layout::layout(&mut node, &mut ui_state, Rect::new(0.0, 0.0, 160.0, 48.0));
let report = lint(&node, &ui_state);
let text_overflow = report
.findings
.iter()
.find(|f| f.kind == FindingKind::TextOverflow)
.unwrap_or_else(|| panic!("expected TextOverflow finding\n{}", report.text()));
assert_eq!(text_overflow.source.line, user_source.line);
}
#[test]
fn overflow_lint_fires_for_external_app_paths_issue_13() {
let user_source = Source {
file: "src/sidebar.rs",
line: 17,
from_library: false,
};
let mut child = crate::column(Vec::<El>::new())
.width(Size::Fixed(32.0))
.height(Size::Fixed(32.0));
child.source = user_source;
let mut row = crate::row([child])
.width(Size::Fixed(256.0))
.height(Size::Fixed(28.0));
row.source = user_source;
let mut ui_state = UiState::new();
layout::layout(&mut row, &mut ui_state, Rect::new(0.0, 0.0, 256.0, 28.0));
let report = lint(&row, &ui_state);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::Overflow),
"expected an Overflow finding for the 32px child in a 28px row\n{}",
report.text()
);
}
#[test]
fn overflow_finding_suppressed_when_no_user_ancestor_exists() {
let widget_source = Source {
file: "src/widgets/tabs.rs",
line: 200,
from_library: true,
};
let mut leaf = crate::text("A very long dashboard label")
.width(Size::Fixed(40.0))
.height(Size::Fixed(20.0));
leaf.source = widget_source;
let mut wrapper = crate::row([leaf])
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
wrapper.source = widget_source;
let mut ui_state = UiState::new();
layout::layout(
&mut wrapper,
&mut ui_state,
Rect::new(0.0, 0.0, 160.0, 48.0),
);
let report = lint(&wrapper, &ui_state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::TextOverflow || f.kind == FindingKind::Overflow),
"{}",
report.text()
);
}
#[test]
fn panel_role_without_fill_reports_missing_surface_fill() {
let root = crate::column([crate::text("body")])
.surface_role(SurfaceRole::Panel)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn panel_role_with_fill_satisfies_surface_policy() {
let root = crate::column([crate::text("body")])
.surface_role(SurfaceRole::Panel)
.fill(crate::tokens::CARD)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn card_widget_satisfies_surface_policy() {
let root = crate::widgets::card::card([crate::text("body")])
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn handrolled_card_recipe_reports_reinvented_widget() {
let root = crate::column([crate::text("body")])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_LG)
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("card(")),
"{}",
report.text()
);
}
#[test]
fn real_card_widget_does_not_report_reinvented_widget() {
let root = crate::widgets::card::card([crate::text("body")])
.width(Size::Fixed(160.0))
.height(Size::Fixed(48.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn handrolled_sidebar_recipe_reports_reinvented_widget() {
let root = crate::column([crate::text("nav")])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.width(Size::Fixed(crate::tokens::SIDEBAR_WIDTH))
.height(Size::Fill(1.0));
let report = lint_one(root);
assert!(
report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget && f.message.contains("sidebar(")),
"{}",
report.text()
);
}
#[test]
fn real_sidebar_widget_does_not_report_reinvented_widget() {
let root = crate::widgets::sidebar::sidebar([crate::text("nav")]);
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn empty_visual_swatch_does_not_report_reinvented_widget() {
let root = crate::column(Vec::<El>::new())
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.radius(crate::tokens::RADIUS_SM)
.width(Size::Fixed(42.0))
.height(Size::Fixed(34.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn plain_column_does_not_report_reinvented_widget() {
let root = crate::column([crate::text("a"), crate::text("b")])
.gap(crate::tokens::SPACE_2)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::ReinventedWidget),
"{}",
report.text()
);
}
#[test]
fn fill_providing_roles_do_not_require_explicit_fill() {
let root = crate::column([crate::text("body")])
.surface_role(SurfaceRole::Sunken)
.width(Size::Fixed(120.0))
.height(Size::Fixed(40.0));
let report = lint_one(root);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::MissingSurfaceFill),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_fires_when_input_clipped_on_scroll_cross_axis() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::scroll([crate::tree::column([
crate::widgets::text_input::text_input("", &selection, "field"),
])])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured
&& f.message.contains("clipped")
&& (f.message.contains("L=2") || f.message.contains("R=2"))
}),
"expected a FocusRingObscured clipping finding (L=2 or R=2)\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_silenced_when_scroll_supplies_horizontal_slack() {
let selection = crate::selection::Selection::default();
let mut root =
crate::tree::scroll(
[crate::tree::column([crate::widgets::text_input::text_input(
"", &selection, "field",
)])
.padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))],
)
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_skips_clipping_on_scroll_axis() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::scroll([crate::tree::column([
crate::tree::column(Vec::<El>::new())
.width(Size::Fill(1.0))
.height(Size::Fixed(200.0)),
crate::widgets::text_input::text_input("", &selection, "field"),
])
.padding(Sides::xy(crate::tokens::RING_WIDTH, 0.0))])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"expected no FocusRingObscured finding for a row clipped on the scroll axis\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_fires_on_static_clip_in_any_direction() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::column([crate::widgets::text_input::text_input(
"", &selection, "field",
)])
.clip()
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured && f.message.contains("clipped")
}),
"expected a static-clip FocusRingObscured finding\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_fires_on_painted_later_sibling_overlap() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::row([
crate::widgets::text_input::text_input("", &selection, "field"),
crate::tree::column([crate::text("neighbor")])
.fill(crate::tokens::CARD)
.stroke(crate::tokens::BORDER)
.width(Size::Fixed(80.0))
.height(Size::Fixed(32.0)),
])
.gap(0.0)
.width(Size::Fixed(400.0))
.height(Size::Fixed(32.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
let report = lint(&root, &state);
assert!(
report.findings.iter().any(|f| {
f.kind == FindingKind::FocusRingObscured
&& f.message.contains("occluded")
&& f.message.contains("right")
}),
"expected an occlusion finding on the right edge\n{}",
report.text()
);
}
#[test]
fn focus_ring_lint_ignores_unpainted_structural_sibling() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::row([
crate::widgets::text_input::text_input("", &selection, "field"),
crate::tree::column(Vec::<El>::new())
.width(Size::Fixed(80.0))
.height(Size::Fixed(32.0)),
])
.gap(0.0)
.width(Size::Fixed(400.0))
.height(Size::Fixed(32.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 60.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
#[test]
fn focus_ring_lint_is_quiet_inside_form_after_padding_fix() {
let selection = crate::selection::Selection::default();
let mut root = crate::tree::scroll([crate::widgets::form::form([
crate::widgets::form::form_item([crate::widgets::form::form_control(
crate::widgets::text_input::text_input("", &selection, "field"),
)]),
])])
.width(Size::Fixed(300.0))
.height(Size::Fixed(120.0));
let mut state = UiState::new();
layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 300.0, 120.0));
let report = lint(&root, &state);
assert!(
!report
.findings
.iter()
.any(|f| f.kind == FindingKind::FocusRingObscured),
"{}",
report.text()
);
}
}