use rustc_hash::{FxHashMap, FxHashSet};
use crate::access::{self, Role};
use crate::edit::EditStore;
use crate::key::Key;
use crate::resources::Foreign;
use crate::schema;
use crate::spec::{Dir, Sizing};
use crate::text::TextSystem;
use crate::tree::{NIL, Tree};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Warning {
pub code: &'static str,
pub key: Key,
pub message: String,
}
impl Warning {
pub fn to_value(&self, h: crate::value::Handles) -> crate::value::Value {
use crate::value::Value;
Value::map([
("code", Value::str(self.code)),
("key", (h.key)(self.key)),
("message", Value::Str(self.message.clone())),
])
}
}
pub struct WarningDef {
pub code: &'static str,
pub doc: &'static str,
}
macro_rules! warnings {
($( $(#[doc = $doc:literal])+ pub const $name:ident: &str = $code:literal; )*) => {
$( $(#[doc = $doc])+ pub const $name: &str = $code; )*
pub const WARNINGS: &[WarningDef] = &[
$( WarningDef { code: $code, doc: concat!($($doc, "\n"),+) }, )*
];
};
}
warnings! {
pub const GROW_WEIGHT_IGNORED: &str = "grow-weight-ignored";
pub const TRANSITION_AUTO_KEY: &str = "transition-auto-key";
pub const DUPLICATE_KEY: &str = "duplicate-key";
pub const AMBIGUOUS_KEY: &str = "ambiguous-key";
pub const FOCUS_REGION_WITHOUT_NODE: &str = "focus-region-without-node";
pub const LABEL_WITHOUT_NODE: &str = "label-without-node";
pub const UNKNOWN_FAMILY: &str = "unknown-family";
pub const NESTED_SELECTION_SCOPE: &str = "nested-selection-scope";
pub const IMAGE_WITHOUT_LABEL: &str = "image-without-label";
pub const SLIDER_VALUE_OUT_OF_RANGE: &str = "slider-value-out-of-range";
pub const FRAGMENT_REJECTED: &str = "fragment-rejected";
pub const FRAGMENT_PARAMS_TRUNCATED: &str = "fragment-params-truncated";
pub const POLYGON_POINTS_TRUNCATED: &str = "polygon-points-truncated";
pub const PATH_MALFORMED: &str = "path-malformed";
pub const PATH_TOO_LARGE: &str = "path-too-large";
pub const MODAL_BEHIND_CONTENT: &str = "modal-behind-content";
pub const CONTROL_WITHOUT_NAME: &str = "control-without-name";
pub const FOCUSABLE_INSIDE_ITEM: &str = "focusable-inside-item";
pub const ITEM_OUTSIDE_CONTAINER: &str = "item-outside-container";
pub const MODAL_WITHOUT_NAME: &str = "modal-without-name";
pub const LIVE_REGION_WITHOUT_NAME: &str = "live-region-without-name";
pub const ANNOUNCEMENT_REPEATED: &str = "announcement-repeated";
pub const WRAP_IGNORED: &str = "wrap-ignored";
pub const ALIGN_IGNORED: &str = "align-ignored";
pub const ASPECT_IGNORED: &str = "aspect-ignored";
pub const BACKDROP_BLUR_HIDDEN: &str = "backdrop-blur-hidden";
pub const TEXT_BEYOND_LINE: &str = "text-beyond-line";
pub const EXIT_BUDGET: &str = "exit-budget";
pub const UNKNOWN_PROP: &str = "unknown-prop";
pub const SIZE_EXPRESSIONS_FULL: &str = "size-expressions-full";
pub const DUPLICATE_WINDOW_CONFIG: &str = "duplicate-window-config";
pub const WINDOW_DECLARED_WHILE_CLOSED: &str = "window-declared-while-closed";
pub const UNKNOWN_WINDOW_KIND: &str = "unknown-window-kind";
pub const EDIT_TEXT_WITHOUT_EDITOR: &str = "edit-text-without-editor";
pub const TRUNCATED_PLAYBACK: &str = "truncated-playback";
pub const FOREIGN_RESOURCE: &str = "foreign-resource";
pub const UNKNOWN_SLOT: &str = "unknown-slot";
pub const UNKNOWN_TOKEN: &str = "unknown-token";
pub const RESERVED_TOKEN: &str = "reserved-token";
pub const DUPLICATE_SLOT: &str = "duplicate-slot";
pub const UNBALANCED_EXTENSION: &str = "unbalanced-extension";
pub const EXTENSION_VIEW_ERROR: &str = "extension-view-error";
pub const RECURSIVE_SLOT: &str = "recursive-slot";
pub const PLAYBACK_REFUSED: &str = "playback-refused";
pub const DUPLICATE_TAB: &str = "duplicate-tab";
pub const BAD_DEVTOOLS_TAB: &str = "bad-devtools-tab";
pub const SELECT_CURRENT_IGNORED: &str = "select-current-ignored";
}
pub fn duplicate_tab(name: &str) -> Warning {
Warning {
code: DUPLICATE_TAB,
key: Key::ROOT.str(DUPLICATE_TAB).str(name),
message: format!(
"devtools tab {name:?} was declared twice in one frame; the second was ignored — \
give it its own name"
),
}
}
pub fn bad_devtools_tab(name: &str, why: &str) -> Warning {
Warning {
code: BAD_DEVTOOLS_TAB,
key: Key::ROOT.str(BAD_DEVTOOLS_TAB).str(name),
message: format!("devtools tab {name:?} was not declared: {why}"),
}
}
pub fn text_beyond_line(text: Key, line: Key, reach: usize) -> Warning {
Warning {
code: TEXT_BEYOND_LINE,
key: text,
message: format!(
"the text ({:016x}) is more than {reach} levels below its `line` row ({:016x}), further than a place remembers, so a hit asked by the row's key answers byte 0; flatten the wrappers between them, or ask by a nearer key",
text.0, line.0
),
}
}
pub fn unknown_slot(extension: &str, namespace: &str, slot: &str) -> Warning {
let full = crate::slot::full_name(namespace, slot);
Warning {
code: UNKNOWN_SLOT,
key: Key::ROOT.str(UNKNOWN_SLOT).str(&full),
message: format!(
"extension `{extension}` (namespace `{namespace}`) fills slot {slot:?}, which no view \
declared this frame, so it drew nothing; declare `ui.slot({full:?})` where it \
should go, or drop the name from the extension's `slots`"
),
}
}
pub fn duplicate_slot(slot: &str) -> Warning {
Warning {
code: DUPLICATE_SLOT,
key: Key::ROOT.str(DUPLICATE_SLOT).str(slot),
message: format!(
"slot {slot:?} was declared twice in one frame; the second was ignored, since a \
fill is keyed by the slot's name — two places want two names"
),
}
}
pub fn unbalanced_extension(slot: &str, slot_key: Key, open: usize) -> Warning {
Warning {
code: UNBALANCED_EXTENSION,
key: slot_key.str(UNBALANCED_EXTENSION),
message: format!(
"the extension filling slot {slot:?} returned from view with {open} node{} still \
open; they were closed for it — it has an `open` without its `close`",
if open == 1 { "" } else { "s" }
),
}
}
pub fn extension_view_error(extension: &str, slot: &str, slot_key: Key, err: &str) -> Warning {
Warning {
code: EXTENSION_VIEW_ERROR,
key: slot_key.str(extension).str(EXTENSION_VIEW_ERROR),
message: format!("extension `{extension}` failed to build slot {slot:?}: {err}"),
}
}
pub fn recursive_slot(extension: &str, slot: &str, slot_key: Key) -> Warning {
Warning {
code: RECURSIVE_SLOT,
key: slot_key.str(RECURSIVE_SLOT),
message: format!(
"extension `{extension}` declared slot {slot:?} while it was drawing, which is its \
own; it was left empty, since filling it would mean calling `{extension}` inside \
itself. An extension may declare the slots of extensions it loaded — not its own"
),
}
}
pub fn foreign_resource(f: &Foreign) -> Warning {
Warning {
code: FOREIGN_RESOURCE,
key: Key::ROOT
.str(FOREIGN_RESOURCE)
.str(f.kind.name())
.index(f.raw),
message: f.message(),
}
}
pub fn size_expressions_full() -> Option<Warning> {
let (n, last) = crate::calc::refused();
(n > 0).then(|| Warning {
code: SIZE_EXPRESSIONS_FULL,
key: Key::ROOT.str(SIZE_EXPRESSIONS_FULL),
message: format!(
"{} size expressions are kept, and {n} more were refused, the last \"{last}\": each \
is laid out as if undeclared — declare one per layout, not one per frame",
crate::calc::MAX_CALCS
),
})
}
pub fn edit_text_without_editor_label(label: &str) -> Warning {
Warning {
code: EDIT_TEXT_WITHOUT_EDITOR,
key: Key::ROOT.str(label),
message: format!(
"`set_edit_text` named label {label:?}, and the frame after it declared no editor \
under that name, so the text was dropped; the label is the one an editor's `key` \
prop declares, and the call is held for one frame — for the view that draws the \
editor the same `update` opened — not longer"
),
}
}
pub fn playback_refused(key: crate::key::Key, playback: crate::audio::PlaybackId) -> Warning {
Warning {
code: PLAYBACK_REFUSED,
key,
message: format!(
"the audio device refused playback {} — its voices are all held, or the sound did \
not decode; a released (`finish`) playback holds one of the device's 128 voices \
until its file ends, so releasing faster than the sounds finish reaches the limit. \
The playback never started and will never report `ended`; a tagged one is told so \
with `phase: \"refused\"`",
playback.0
),
}
}
pub(crate) fn modal_under_layer(key: Key) -> Warning {
Warning {
code: MODAL_BEHIND_CONTENT,
key,
message: "a float from outside this modal's scope opened after it and paints on top \
of it: everything drawn over a modal is inert, which reads as a broken \
dialog (declare it inside the modal, or close it while the modal is up)"
.to_string(),
}
}
pub fn edit_text_without_editor(key: Key) -> Warning {
Warning {
code: EDIT_TEXT_WITHOUT_EDITOR,
key,
message: format!(
"`set_edit_text` named key {:#x}, and the frame after it declared no editor under \
that key, so the text was dropped; the call is held for one frame — for the view \
that draws the editor the same `update` opened — not longer",
key.0
),
}
}
pub fn truncated_playback(key: Key, why: crate::audio::Why, at: f64) -> Warning {
Warning {
code: TRUNCATED_PLAYBACK,
key,
message: format!(
"the `audio` node at key {:#x} was {} {:.2}s into its sound, cutting it off; keep \
the node declared until its `ended` event, or add `finish` so the playback is \
released to play itself out",
key.0,
why.verb(),
at
),
}
}
pub fn announcement_repeated(text: &str) -> Warning {
Warning {
code: ANNOUNCEMENT_REPEATED,
key: Key::ROOT.str(ANNOUNCEMENT_REPEATED).str(text),
message: format!(
"the announcement {text:?} was queued on two consecutive frames: `announce` says \
something once, and a view runs every frame, so a call made from a frame builder \
needs a guard the app clears (announce from the event handler, or keep a field the \
handler sets and the view clears)"
),
}
}
pub fn duplicate_window_config(name: &str) -> Warning {
Warning {
code: DUPLICATE_WINDOW_CONFIG,
key: Key::ROOT.str(DUPLICATE_WINDOW_CONFIG).str(name),
message: format!(
"window `{name}` was declared with two different configs on the frame it opened; \
the lowest declaring window's first declaration won, and a live window's config is \
never re-read, so the other one never applies — make them agree"
),
}
}
pub fn window_declared_while_closed(name: &str) -> Warning {
Warning {
code: WINDOW_DECLARED_WHILE_CLOSED,
key: Key::ROOT.str(WINDOW_DECLARED_WHILE_CLOSED).str(name),
message: format!(
"window `{name}` is still declared after the user closed it, so it stays closed: a \
declaration reopens a window only when it starts — handle the \
`{{kind:\"window\", phase:\"closed\"}}` event, stop declaring `{name}`, and declare \
it again to reopen"
),
}
}
pub fn unknown_window_kind(name: &str, kind: u32) -> Warning {
Warning {
code: UNKNOWN_WINDOW_KIND,
key: Key::ROOT
.str(UNKNOWN_WINDOW_KIND)
.str(name)
.index(kind as u64),
message: format!(
"window `{name}` was declared with kind {kind}, which this build does not have; \
`KUI_WINDOW_KIND_NORMAL` (0) and `KUI_WINDOW_KIND_POPUP` (1) are the ones there \
are, so it opened as a normal window"
),
}
}
pub(crate) fn focus_region_without_node(target: &crate::runtime::RegionTarget) -> Warning {
use crate::runtime::RegionTarget;
let (key, named) = match target {
RegionTarget::Main => (Key::ROOT, "the main ring".to_string()),
RegionTarget::Key(k) => (*k, format!("key {:016x}", k.0)),
RegionTarget::Label(label) => (Key::ROOT.str(label), format!("label {label:?}")),
};
Warning {
code: FOCUS_REGION_WITHOUT_NODE,
key,
message: format!(
"`focus_region` named {named}, and the frame after it declared no `focusRegion` node \
there, so nothing was entered; the name is the label the region's `key` prop \
declares, on a node that carries the `focusRegion` row"
),
}
}
pub(crate) fn label_without_node(verb: &str, label: &str) -> Warning {
Warning {
code: LABEL_WITHOUT_NODE,
key: Key::ROOT.str(verb).str(label),
message: format!(
"`{verb}` named the label {label:?}, and the frame it resolved against declared no node under it, so nothing moved; the name is the label a node's `key` declares"
),
}
}
pub fn ambiguous_key(label: &str, first: Key, count: usize) -> Warning {
Warning {
code: AMBIGUOUS_KEY,
key: first,
message: format!(
"{count} nodes are keyed {label:?} under different parents; the first in tree order \
({:016x}) was used — give the one meant a label nothing else declares, or pass its \
hex key",
first.0
),
}
}
pub fn select_current_ignored(
key: Key,
label: &str,
index: usize,
count: usize,
separator: bool,
) -> Warning {
let why = if separator {
"which is a separator".to_string()
} else {
format!(
"and the field has {count} option{}",
if count == 1 { "" } else { "s" }
)
};
Warning {
code: SELECT_CURRENT_IGNORED,
key,
message: format!(
"`current` of select {label:?} names option {index} counted from 0, {why}, so the \
field shows no choice and no row is checked — pass an index of an option, or none"
),
}
}
pub fn unknown_menu_item_key(name: &str) -> Warning {
let key = Key::ROOT
.str(UNKNOWN_PROP)
.str(crate::MenuItem::NAME)
.str(name);
if name == "items" {
return Warning {
code: UNKNOWN_PROP,
key,
message: "`items` on a select's option is dropped: an option is chosen, never \
opened, so it has no submenu"
.into(),
};
}
let keys = crate::MenuItem::KEYS
.iter()
.map(|k| format!("`{k}`"))
.collect::<Vec<_>>()
.join(", ");
let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
let hint = if name == "disabled" {
" (did you mean `enabled: false`?)".to_string()
} else {
match crate::MenuItem::KEYS
.iter()
.find(|k| squash(k) == squash(name))
{
Some(near) => format!(" (did you mean `{near}`?)"),
None => String::new(),
}
};
Warning {
code: UNKNOWN_PROP,
key,
message: format!(
"`{name}` is not a key of a menu item: a row takes {keys}, so this declaration is \
dropped{hint}"
),
}
}
pub fn unknown_prop(element: &str, name: &str, spelling: schema::Spelling) -> Warning {
if element == crate::MenuItem::NAME {
return unknown_menu_item_key(name);
}
let message = match schema::element_rows(element, spelling) {
Some(rows) if schema::shared_prop(name, spelling) => {
let takes = if rows.is_empty() {
"none of them".to_string()
} else {
format!(
"only {}",
rows.iter()
.map(|r| format!("`{r}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
format!(
"`{name}` is a prop, but not one {element} reads: its look is its own, and it \
takes {takes}, so this declaration is dropped — a box with `role` set takes \
every row"
)
}
_ => {
let hint = match schema::suggest(element, name, spelling) {
Some(near) => format!(" (did you mean `{near}`?)"),
None => String::new(),
};
format!(
"`{name}` is not a prop of {element}: no binding reads it, so this declaration \
is dropped{hint}"
)
}
};
Warning {
code: UNKNOWN_PROP,
key: Key::ROOT.str(UNKNOWN_PROP).str(element).str(name),
message,
}
}
const MAX_PENDING: usize = 256;
pub(crate) const CHECK_EVERY: u64 = 16;
pub(crate) struct Diagnostics {
pub(crate) enabled: bool,
pending: Vec<Warning>,
log: Vec<Warning>,
warned: FxHashSet<(&'static str, Key)>,
child_counts: FxHashMap<Key, u32>,
scratch: Vec<u64>,
}
impl Default for Diagnostics {
fn default() -> Self {
Self {
enabled: true,
pending: Vec::new(),
log: Vec::new(),
warned: FxHashSet::default(),
child_counts: FxHashMap::default(),
scratch: Vec::new(),
}
}
}
impl Diagnostics {
pub(crate) fn take(&mut self) -> Vec<Warning> {
std::mem::take(&mut self.pending)
}
pub(crate) fn raised(&self) -> &[Warning] {
&self.log
}
pub(crate) fn raise(&mut self, w: Warning) {
if !self.enabled
|| self.pending.len() >= MAX_PENDING
|| !self.warned.insert((w.code, w.key))
{
return;
}
self.log.push(w.clone());
self.pending.push(w);
}
fn warn(&mut self, code: &'static str, key: Key, message: impl FnOnce() -> String) {
if self.pending.len() >= MAX_PENDING || !self.warned.insert((code, key)) {
return;
}
let w = Warning {
code,
key,
message: message(),
};
self.log.push(w.clone());
self.pending.push(w);
}
pub(crate) fn check(
&mut self,
tree: &Tree,
text: &TextSystem,
edit: &EditStore,
frame_no: u64,
) {
if !self.enabled || tree.is_empty() {
return;
}
if frame_no > 2 && !frame_no.is_multiple_of(CHECK_EVERY) {
return;
}
self.check_grow_weights(tree);
self.check_wrap(tree);
self.check_align(tree);
if tree.any_backdrop_blur {
self.check_backdrop_blur(tree);
}
self.check_auto_keyed_transitions(tree);
self.check_duplicate_keys(tree);
self.check_modal(tree);
self.check_selection_scopes(tree);
self.check_composites(tree);
self.check_lone_items(tree);
self.check_access(tree, text, edit);
self.check_live_regions(tree, text);
}
fn check_selection_scopes(&mut self, tree: &Tree) {
if !tree.any_selectable {
return;
}
self.scratch.clear();
self.scratch.resize(tree.len(), 0);
for i in 0..tree.len() {
let inside = match tree.parent[i] {
NIL => 0,
p => self.scratch[p as usize],
};
let here = tree.specs[i].interact().selectable;
if here && inside == 1 {
self.warn(NESTED_SELECTION_SCOPE, tree.keys[i], || {
"a `selectable` node inside another one: selection scopes do not nest, so the inner one owns the text under it and the outer selects only what is outside it".to_string()
});
}
self.scratch[i] = u64::from(here || inside == 1);
}
}
fn check_modal(&mut self, tree: &Tree) {
let Some(i) = (0..tree.len())
.rev()
.find(|&i| tree.specs[i].events().modal.is_some())
else {
return;
};
let end = tree.subtree_end(i);
let floating = |mut j: usize| {
loop {
if tree.opens_layer(j) {
return true;
}
match tree.parent[j] {
NIL => return false,
p => j = p as usize,
}
}
};
if floating(i) {
return;
}
let over = (0..tree.len())
.filter(|j| !(i..end).contains(j))
.any(|j| j >= end || floating(j));
if !over {
return;
}
self.warn(MODAL_BEHIND_CONTENT, tree.keys[i], || {
"this modal is not in a float, and content declared after it paints on top of it: \
everything drawn over a modal is inert, which reads as a broken dialog (float it \
with `float=\"viewport\"`)"
.to_string()
});
}
fn check_composites(&mut self, tree: &Tree) {
let mut items: Vec<usize> = Vec::new();
for c in 0..tree.len() {
let Some(item) = tree.specs[c]
.access()
.role
.and_then(crate::composite::item_role)
else {
continue;
};
crate::composite::items(tree, c, item, &mut items);
if !crate::composite::is_composite(tree, c, &items) {
continue;
}
for &i in &items {
let end = tree.subtree_end(i);
for j in i + 1..end {
if !access::focusable(tree, j) {
continue;
}
let what = item.name();
self.warn(FOCUSABLE_INSIDE_ITEM, tree.keys[j], || {
format!(
"this node is focusable but sits inside a `{what}`, which is one \
roving Tab stop of a composite: the ring stops at the item, so \
nothing reaches this node (move it outside the item, or drop its \
focusable behaviour)"
)
});
}
}
}
}
fn check_lone_items(&mut self, tree: &Tree) {
use crate::composite::PAIRS;
const CHECKED: [Role; 2] = [Role::Radio, Role::Tab];
self.scratch.clear();
self.scratch.resize(tree.len(), 0);
for i in 0..tree.len() {
let above = match tree.parent[i] {
NIL => 0,
p => self.scratch[p as usize],
};
let mut here = above;
if let Some(role) = tree.specs[i].access().role {
for (bit, (container, item)) in PAIRS.iter().enumerate() {
if role == *container {
here |= 1 << bit;
} else if role == *item && CHECKED.contains(item) && above & (1 << bit) == 0 {
let (item, container) = (item.name(), container.name());
self.warn(ITEM_OUTSIDE_CONTAINER, tree.keys[i], || {
format!(
"this `{item}` has no `{container}` above it, so it is a Tab \
stop of its own: the arrows do not move the choice and a \
screen reader announces no position in the set (wrap the \
set in a `{container}` with a `label`)"
)
});
}
}
}
self.scratch[i] = here;
}
}
fn check_access(&mut self, tree: &Tree, text: &TextSystem, edit: &EditStore) {
let mut skip_until = 0usize;
for i in 0..tree.len() {
if i < skip_until {
continue;
}
let Some(sem) = access::semantic(tree, text, edit, None, i) else {
continue;
};
if sem.role == Role::None || sem.presentational {
skip_until = tree.subtree_end(i);
}
if sem.role == Role::Slider {
self.check_slider_range(tree, i);
}
if sem.name.is_some() || sem.role == Role::None {
continue;
}
let key = tree.keys[i];
if sem.role == Role::Image {
self.warn(IMAGE_WITHOUT_LABEL, key, || {
"this image has no label: assistive technology has nothing to say for it \
(give it a `label`, or `role=\"none\"` if it is decoration)"
.to_string()
});
} else if sem.role == Role::Dialog && tree.specs[i].events().modal.is_some() {
self.warn(MODAL_WITHOUT_NAME, key, || {
"this modal has no accessible name: a dialog is named by its `label`, never \
by the text inside it — a screen reader announces an unnamed dialog to the \
user it has just moved focus to (give it a `label`)"
.to_string()
});
} else if sem.role.is_control() {
let what = sem.role.name();
self.warn(CONTROL_WITHOUT_NAME, key, || {
format!(
"this {what} has no accessible name: no `label`, and no text inside it \
— a screen reader announces an unnamed {what} (give it a `label`)"
)
});
}
}
}
fn check_slider_range(&mut self, tree: &Tree, i: usize) {
let ax = tree.specs[i].access();
let (now, min, max) = (ax.value_now, ax.value_min, ax.value_max);
let reason = match (now, min, max) {
(_, Some(lo), Some(hi)) if lo > hi => {
format!("valueMin {lo} is above valueMax {hi}, so no value is in range")
}
(Some(v), Some(lo), _) if v < lo => {
format!("valueNow {v} is below valueMin {lo}")
}
(Some(v), _, Some(hi)) if v > hi => {
format!("valueNow {v} is above valueMax {hi}")
}
_ => return,
};
self.warn(SLIDER_VALUE_OUT_OF_RANGE, tree.keys[i], || {
format!(
"this slider's value and its declared range disagree: {reason} — the rows are \
read to a screen reader exactly as declared, so a value the app clamps \
somewhere else is announced unclamped (declare the range the value is really \
held to, or clamp where the view declares it)"
)
});
}
fn check_live_regions(&mut self, tree: &Tree, text: &TextSystem) {
for i in 0..tree.len() {
let spec = &tree.specs[i];
if spec.access().live == access::Live::Off
|| spec.access().role == Some(Role::None)
|| access::live_region_speaks(tree, text, i)
{
continue;
}
self.warn(LIVE_REGION_WITHOUT_NAME, tree.keys[i], || {
"this node is a live region but has no accessible name: no `label`, and no text \
inside it — every platform reads a live region by its name, so nothing this \
node ever does can be announced (put `live` on the node that holds the message)"
.to_string()
});
}
}
fn check_grow_weights(&mut self, tree: &Tree) {
for p in 0..tree.len() {
if tree.first_child[p] == NIL {
continue;
}
let row = tree.specs[p].layout.dir == Dir::Row;
let mut grow_children = 0u32;
let mut lone: Option<(u32, f32)> = None;
for c in tree.children(p as u32) {
let layout = tree.specs[c as usize].layout;
if layout.float.is_some() {
continue;
}
let (main, cross) = if row {
(layout.width, layout.height)
} else {
(layout.height, layout.width)
};
if let Sizing::Grow(f) = main {
grow_children += 1;
lone = Some((c, f));
}
if let Sizing::Grow(f) = cross
&& f != 1.0
{
let axis = if row { "height" } else { "width" };
let parent_axis = if row { "row" } else { "column" };
self.warn(GROW_WEIGHT_IGNORED, tree.keys[c as usize], || {
format!(
"{axis} grow {f} has no effect: across a {parent_axis}'s main axis a \
grow child fills the parent whatever its weight (use maxWidth / \
maxHeight to cap it)"
)
});
}
}
if grow_children == 1
&& let Some((c, f)) = lone
&& f != 1.0
{
let axis = if row { "width" } else { "height" };
self.warn(GROW_WEIGHT_IGNORED, tree.keys[c as usize], || {
format!(
"{axis} grow {f} has no effect: it is the only grow child of its parent, \
and a weight only splits free space between grow siblings — alone it \
takes all of it (cap it with max{}, or give a sibling a grow too)",
if row { "Width" } else { "Height" }
)
});
}
}
}
fn check_align(&mut self, tree: &Tree) {
use crate::spec::Align;
let spread = |a: Align| {
matches!(
a,
Align::SpaceBetween | Align::SpaceAround | Align::SpaceEvenly
)
};
let odd = |a: Align| spread(a) || a == Align::Baseline;
for i in 0..tree.len() {
let l = &tree.specs[i].layout;
let reason = if l.main_align == Align::Baseline {
Some("mainAlign baseline: a baseline lines children up across a row, not along it (use crossAlign)".to_string())
} else if spread(l.cross_align) {
Some(format!(
"crossAlign {}: a spread deals free space out between children, and there is one child per line across the axis (use mainAlign)",
l.cross_align.name()
))
} else if l.cross_align == Align::Baseline && l.dir == Dir::Column {
Some("crossAlign baseline on a column: a column's cross axis is horizontal, where a baseline is not a line (lay the text out in a row)".to_string())
} else {
l.float.and_then(|f| {
let pts = [
f.anchor_point.0,
f.anchor_point.1,
f.self_point.0,
f.self_point.1,
];
pts.into_iter().find(|&a| odd(a)).map(|a| {
format!(
"a float attaches at start, center or end, not {} (it lays out as {})",
a.name(),
if matches!(a, Align::SpaceAround | Align::SpaceEvenly) {
"center"
} else {
"start"
}
)
})
})
};
if let Some(reason) = reason {
self.warn(ALIGN_IGNORED, tree.keys[i], || {
format!("{reason}; it has no effect here")
});
}
if l.aspect > 0.0 && !l.aspect_height() && l.aspect_width().is_none() {
let why = if l.width == Sizing::Fit {
"the width is fit, and a grow or percent height is resolved only after every width is (give the height a fixed size, or let the height be the fit axis)"
} else {
"both axes are declared, so there is no fit axis for the ratio to size (leave one of them fit)"
};
self.warn(ASPECT_IGNORED, tree.keys[i], || {
format!("aspectRatio {} has no effect: {why}", l.aspect)
});
}
}
}
fn check_backdrop_blur(&mut self, tree: &Tree) {
for i in 0..tree.len() {
let spec = &tree.specs[i];
let blur = spec.interact().backdrop_blur;
if blur <= 0.0 || spec.style.bg.a < 1.0 || spec.style.opacity < 1.0 {
continue;
}
self.warn(BACKDROP_BLUR_HIDDEN, tree.keys[i], || {
format!(
"backdropBlur {blur} is hidden: the node's own bg is opaque and paints over \
the whole blur (give the bg some transparency, #ffffff40, for frosted glass)"
)
});
}
}
fn check_wrap(&mut self, tree: &Tree) {
for i in 0..tree.len() {
let layout = tree.specs[i].layout;
if !layout.wrap {
continue;
}
let reason = if layout.dir != Dir::Row {
"a column's main size is not resolved until after the pass that would have to \
sum the lines, so only a row wraps (turn the container into a row, or give the \
items a fixed size and lay them out yourself)"
} else if layout.scroll_x {
"a scrollX row's main axis is unbounded, and an axis with no bound has nothing \
to break against (drop scrollX, or drop wrapChildren and let it scroll)"
} else if layout.float.is_none()
&& tree.parent[i] != NIL
&& tree.specs[tree.parent[i] as usize].layout.is_table()
{
"a table's row cannot wrap: its children are the table's columns, one each \
(put the wrapping row inside a cell)"
} else {
continue;
};
self.warn(WRAP_IGNORED, tree.keys[i], || {
format!("wrapChildren has no effect here: {reason}")
});
}
}
fn check_auto_keyed_transitions(&mut self, tree: &Tree) {
let mut counts: FxHashMap<Key, u32> = FxHashMap::default();
for p in 0..tree.len() {
if tree.first_child[p] == NIL {
continue;
}
let parent_key = tree.keys[p];
let mut n = 0u32;
let mut auto_keyed_transition = false;
for (i, c) in tree.children(p as u32).enumerate() {
n += 1;
if tree.specs[c as usize].transition.is_some()
&& tree.keys[c as usize] == parent_key.index(i as u64)
{
auto_keyed_transition = true;
}
}
if auto_keyed_transition {
counts.insert(parent_key, n);
}
}
for (&parent, &n) in &counts {
if let Some(&prev) = self.child_counts.get(&parent)
&& prev != n
{
self.warn(TRANSITION_AUTO_KEY, parent, || {
format!(
"this node went from {prev} to {n} children while a child without a key \
carries a transition: an auto key is the child's position, so the \
children that shifted became new nodes and snapped instead of easing \
(and inserting before them will again) — give them a key"
)
});
}
}
self.child_counts = counts;
}
fn check_duplicate_keys(&mut self, tree: &Tree) {
let mut keys = std::mem::take(&mut self.scratch);
keys.clear();
keys.extend(tree.keys.iter().map(|k| k.0));
keys.sort_unstable();
for w in keys.windows(2) {
if w[0] == w[1] {
self.warn(DUPLICATE_KEY, Key(w[0]), || {
"two nodes share this key in one frame: state retained per key (transitions, \
scroll offsets, editors, layout events, hover) is mixed between them — \
siblings need distinct labels"
.to_string()
});
}
}
self.scratch = keys;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_outlives_the_drain() {
let mut d = Diagnostics::default();
let w = Warning {
code: "test-code",
key: Key::ROOT,
message: "once".into(),
};
d.raise(w.clone());
d.raise(w.clone());
assert_eq!(d.take(), vec![w.clone()]);
assert!(d.take().is_empty(), "drained");
assert_eq!(d.raised(), &[w]);
}
fn codes_in_source() -> Vec<&'static str> {
include_str!("diag.rs")
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix("pub const ")?;
let (_name, rest) = rest.split_once(": &str = \"")?;
let (code, tail) = rest.split_once('"')?;
(tail == ";").then_some(code)
})
.collect()
}
#[test]
fn every_code_is_in_the_table_and_vice_versa() {
let in_source = codes_in_source();
let in_table: Vec<&str> = WARNINGS.iter().map(|w| w.code).collect();
assert!(
in_source.len() >= 13,
"the scan missed the consts: {in_source:?}"
);
assert_eq!(in_source, in_table);
for (i, code) in in_table.iter().enumerate() {
assert!(!in_table[i + 1..].contains(code), "duplicate code {code}");
assert!(
code.bytes().all(|b| b == b'-' || b.is_ascii_lowercase()),
"{code}: codes are kebab-case"
);
}
}
#[test]
fn a_menu_rows_unknown_key_is_named_as_one() {
let name = crate::MenuItem::NAME;
let w = unknown_prop(name, "disabled", schema::Spelling::Camel);
assert_eq!(w, unknown_menu_item_key("disabled"));
assert_eq!(w.code, UNKNOWN_PROP);
assert_eq!(w.key, Key::ROOT.str(UNKNOWN_PROP).str(name).str("disabled"));
assert!(
w.message.contains("`disabled` is not a key of a menu item")
&& w.message
.contains("`label`, `role`, `enabled`, `checked`, `id`, `accel`")
&& w.message.ends_with("(did you mean `enabled: false`?)"),
"{}",
w.message
);
assert!(
unknown_menu_item_key("Label")
.message
.ends_with("(did you mean `label`?)")
);
assert!(unknown_menu_item_key("lable").message.ends_with("dropped"));
assert!(
unknown_prop("box", "disabled", schema::Spelling::Camel)
.message
.starts_with("`disabled` is"),
"an element's is the element's"
);
}
#[test]
fn every_row_has_a_doc() {
for w in WARNINGS {
assert!(
w.doc.split_whitespace().count() > 5,
"{}: no description",
w.code
);
assert!(
!w.doc.contains("[`"),
"{}: rustdoc link syntax leaks into the bindings' docs",
w.code
);
}
}
}