kui_core/diag.rs
1//! Warnings as data: misconfigurations the core notices, drained through
2//! [`crate::Core::take_warnings`].
3//!
4//! A grow weight with nothing to split against, a transition on a
5//! positional key under a changing sibling list, two nodes sharing one
6//! key: all of these fail silently and look like "the feature is broken"
7//! from the outside. The core can see them, so it reports each as a
8//! [`Warning`] with a stable `code` (the constants in this module), the
9//! node it is about and a message for people. Windowed runners print
10//! them; a headless test asserts on them, or on their absence.
11//!
12//! ```rust
13//! use kui_core::{Core, NodeSpec, Size};
14//!
15//! let mut core = Core::new();
16//! let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
17//! ui.leaf_keyed("a", NodeSpec::row());
18//! ui.leaf_keyed("a", NodeSpec::row()); // the same key twice
19//! ui.finish();
20//!
21//! let warnings = core.take_warnings();
22//! assert!(warnings.iter().any(|w| w.code == kui_core::diag::DUPLICATE_KEY));
23//! ```
24//!
25//! Each distinct (code, node) pair is reported once per core, so a
26//! condition that persists across frames costs one line, not a stream.
27//! The tree checks run on the first two frames and then every 16 frames.
28//! A bare `Core` runs them; runners turn them off for release builds
29//! through [`crate::Core::set_diagnostics`].
30//!
31//! Not every code comes from the tree walk. A prop name nothing claims
32//! ([`UNKNOWN_PROP`]) is raised by the binding that dropped it, through
33//! [`crate::Core::warn`]; the window codes ([`DUPLICATE_WINDOW_CONFIG`],
34//! [`WINDOW_DECLARED_WHILE_CLOSED`], [`UNKNOWN_WINDOW_KIND`]) come from the
35//! declared window set and are keyed by the window's name; a handle from
36//! another session ([`FOREIGN_RESOURCE`]) is noticed wherever it resolves;
37//! and the two about sounds ([`TRUNCATED_PLAYBACK`], [`PLAYBACK_REFUSED`])
38//! are answered by the driver, since only the audio device knows, through
39//! `Core::audio_truncated` and `Core::audio_refused`.
40
41use rustc_hash::{FxHashMap, FxHashSet};
42
43use crate::access::{self, Role};
44use crate::edit::EditStore;
45use crate::key::Key;
46use crate::resources::Foreign;
47use crate::schema;
48use crate::spec::{Dir, Sizing};
49use crate::text::TextSystem;
50use crate::tree::{NIL, Tree};
51
52/// A silent misconfiguration the core noticed while finishing a frame.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct Warning {
55 /// A stable identifier for the kind of problem: one of the constants
56 /// in this module. Match on it; the message is for people.
57 pub code: &'static str,
58 /// The node the warning is about — the parent, for
59 /// [`TRANSITION_AUTO_KEY`]; the shared key, for [`DUPLICATE_KEY`].
60 pub key: Key,
61 pub message: String,
62}
63
64impl Warning {
65 /// `{code, key, message}`, the key spelled by `h`.
66 pub fn to_value(&self, h: crate::value::Handles) -> crate::value::Value {
67 use crate::value::Value;
68 Value::map([
69 ("code", Value::str(self.code)),
70 ("key", (h.key)(self.key)),
71 ("message", Value::Str(self.message.clone())),
72 ])
73 }
74}
75
76/// One warning code and what it means, for the tables the bindings
77/// generate from the core (`docs/props.md`, the Node `WarningCode` union).
78/// `doc` is the const's own doc comment, so there is one text to edit.
79pub struct WarningDef {
80 pub code: &'static str,
81 pub doc: &'static str,
82}
83
84/// Declares the codes as the `pub const`s they have always been *and* the
85/// [`WARNINGS`] table from the same tokens: the doc comment on each const
86/// is the table's `doc`. A code added outside this block compiles, but
87/// `every_code_is_in_the_table_and_vice_versa` fails, so the two cannot
88/// drift.
89macro_rules! warnings {
90 ($( $(#[doc = $doc:literal])+ pub const $name:ident: &str = $code:literal; )*) => {
91 $( $(#[doc = $doc])+ pub const $name: &str = $code; )*
92 /// Every warning code with its description, in declaration order.
93 pub const WARNINGS: &[WarningDef] = &[
94 $( WarningDef { code: $code, doc: concat!($($doc, "\n"),+) }, )*
95 ];
96 };
97}
98
99warnings! {
100 /// A grow weight other than 1 on the only grow child of its parent (the
101 /// weight splits space between grow siblings, so alone it changes nothing)
102 /// or across the parent's main axis (cross-axis grow fills the parent
103 /// whatever its weight).
104 pub const GROW_WEIGHT_IGNORED: &str = "grow-weight-ignored";
105 /// The child count of a node changed while one of its children carries a
106 /// transition under an auto-assigned key. Auto keys are sibling positions,
107 /// so the children that shifted became new nodes and snapped instead of
108 /// easing. Give list items a key.
109 pub const TRANSITION_AUTO_KEY: &str = "transition-auto-key";
110 /// Two nodes in one frame share a key: everything retained per key
111 /// (transitions, scroll offsets, editors, layout events, hover state) is
112 /// mixed between them. Siblings need distinct keys.
113 pub const DUPLICATE_KEY: &str = "duplicate-key";
114 /// A label resolved by name (`focus("beta")` in Node, `env.set_focus("beta")`
115 /// in Lua, `kui_key_of` in C) is declared by more than one node in the
116 /// frame, under different parents, so they have distinct keys and the name
117 /// picked the first in tree order. Labels are unique among siblings, not
118 /// across a tree. An extension asking from inside its fill is answered
119 /// from the nodes it opened and no one else's, and the host from its own
120 /// first — so this is a clash among the asker's own. Give the node meant
121 /// a label nothing else declares, or pass the hex key an event carried.
122 /// Two nodes with the *same* key are `duplicate-key`.
123 pub const AMBIGUOUS_KEY: &str = "ambiguous-key";
124 /// A `focusRegion(name)` (`Core::focus_region`, `env.focus_region`,
125 /// `kui_focus_region`) named a node the frame after it did not declare
126 /// as a `focusRegion` — no node under the label, or a node without the
127 /// row — so nothing was entered and focus stayed where it was. The call
128 /// is resolved against the frame it lands on, so an `update` that
129 /// toggles a dock on and enters it in one go is fine; this is that
130 /// call with the view half missing, with a name the view spells
131 /// differently, or naming a node that is not a region.
132 pub const FOCUS_REGION_WITHOUT_NODE: &str = "focus-region-without-node";
133 /// A `reveal` or `setScroll` by label (`env.reveal("rows")`,
134 /// `win.reveal("rows")`, `Core::reveal_label`) named a label the frame
135 /// it resolved against did not declare, so nothing moved. A label is
136 /// resolved when the frame finishes, so a view may name a node it is
137 /// declaring right now, or one the next frame declares; this is the
138 /// name spelled differently from the `key` that declares it, or the
139 /// node not declared at all.
140 pub const LABEL_WITHOUT_NODE: &str = "label-without-node";
141 /// A text's `family` named a family no installed or loaded font has,
142 /// so it shaped as sans. `sans`, `serif` and `mono` are
143 /// kui's own; any other name is matched as `addSystemFont` matches it,
144 /// and `systemFonts()` lists the names a machine has.
145 pub const UNKNOWN_FAMILY: &str = "unknown-family";
146 /// A `selectable` node inside another `selectable` node. Selection
147 /// scopes do not nest: the innermost one owns every run under it, so
148 /// the outer scope selects only the text outside the inner one — and
149 /// a drag that crosses the boundary stops there, which reads as a
150 /// selection that will not extend. Declare the scope once, on the
151 /// container whose text should select as one.
152 pub const NESTED_SELECTION_SCOPE: &str = "nested-selection-scope";
153 /// An image with no `label`: assistive technology has nothing to say
154 /// for it. Decorative images take `role="none"`.
155 pub const IMAGE_WITHOUT_LABEL: &str = "image-without-label";
156 /// A `slider` whose `valueNow` lies outside its own `valueMin` /
157 /// `valueMax`, or whose `valueMin` is above its `valueMax`. The row is
158 /// advertised verbatim, so a screen reader reads a value the range
159 /// says is impossible; the app that clamps in its own `update` keeps the
160 /// range in two places with nothing tying them, and this is the tie.
161 /// Declare the range the value is really held to, or clamp where the
162 /// view declares it.
163 pub const SLIDER_VALUE_OUT_OF_RANGE: &str = "slider-value-out-of-range";
164 /// `Core::add_fragment` was given WGSL that does not compile, so no
165 /// handle was minted and nothing will draw. The message carries naga's
166 /// own error with the line numbers moved into the app's source.
167 /// The source is rejected here rather than at the first
168 /// frame that shows it, so a headless test sees it too.
169 pub const FRAGMENT_REJECTED: &str = "fragment-rejected";
170 /// A `fragment` node declared more than sixteen `params`. The shader
171 /// takes four `vec4<f32>` and no more, so the extra numbers were
172 /// dropped; pass fewer, or pack what the fragment needs into the
173 /// sixteen it has.
174 pub const FRAGMENT_PARAMS_TRUNCATED: &str = "fragment-params-truncated";
175 /// A `polygon` declared more than eight points: the stock fragment
176 /// takes eight vertices in the sixteen params it has, so the rest
177 /// were dropped. Two polygons, or the path primitive kui does not have.
178 pub const POLYGON_POINTS_TRUNCATED: &str = "polygon-points-truncated";
179 /// The frame's modal surface is not in a float, and content painted after
180 /// it is drawn on top of it: everything the user can see over the modal is
181 /// inert, which looks like inert-behind is broken. A modal that has to
182 /// cover the app is a float (`float="viewport"`). Also raised for a modal that *is*
183 /// a float when another float from outside its scope stacks over it:
184 /// a HUD
185 /// opened after the dialog is the same inert surface over it.
186 pub const MODAL_BEHIND_CONTENT: &str = "modal-behind-content";
187 /// A control (a button, link, tab, checkbox, slider, editor) with no
188 /// computable name: no `label`, and no text inside it. Icon buttons and
189 /// editors need a `label`.
190 pub const CONTROL_WITHOUT_NAME: &str = "control-without-name";
191 /// A focusable node inside a composite's *item* — a button inside a list
192 /// row, a link inside a tab. The item is one roving stop of a composite,
193 /// so the Tab ring
194 /// stops at the item and nothing reaches what is inside it: declared, and
195 /// impossible, which is what `modal-behind-content` set the precedent
196 /// for. A focusable node inside the *container* but outside every item —
197 /// a "+" at the end of a tab bar — is reachable and is not reported.
198 pub const FOCUSABLE_INSIDE_ITEM: &str = "focusable-inside-item";
199 /// A `radio` with no `radioGroup` above it, or a `tab` with no
200 /// `tabList` — the stock `<radio>` included. Outside its container an
201 /// item is no composite's:
202 /// each one is a Tab stop of its own, the arrows, Home and End do not
203 /// move the choice, and a screen reader announces no "2 of 3". Wrap the
204 /// set in the container, labelled with what the choice is. A `menuItem`
205 /// or `listItem` on its own is not reported: the menus build their own
206 /// container, and a row outside a list is only a looser reading.
207 pub const ITEM_OUTSIDE_CONTAINER: &str = "item-outside-container";
208 /// A `modal` surface with no `label`. A dialog is not named by the text
209 /// inside it (it is not one of ARIA's name-from-content roles), so a
210 /// screen reader announces it as an unnamed dialog — the same silent
211 /// defect `control-without-name` catches, on the node that just took
212 /// the user's focus. Only the derived dialog role is checked: a modal
213 /// that says what it is with an explicit `role` says it with a `label`
214 /// too, or means something naming works differently for.
215 pub const MODAL_WITHOUT_NAME: &str = "modal-without-name";
216 /// A node declares `live` but carries no `label` and holds no text, so
217 /// nothing it ever does can be announced: every platform derives the
218 /// spoken string from a name, and there is none to derive. The same
219 /// silent defect `image-without-label` catches, on the node that was
220 /// meant to speak.
221 pub const LIVE_REGION_WITHOUT_NAME: &str = "live-region-without-name";
222 /// The same announcement text was queued on two consecutive frames.
223 /// That is what an unguarded `announce` in a frame builder looks like
224 /// — a view runs every frame, so the message is said every frame —
225 /// and it is never what an app means: a message genuinely repeated is
226 /// repeated across frames the user did something in between. The
227 /// announcement still goes through; this names the builder that is
228 /// shouting.
229 pub const ANNOUNCEMENT_REPEATED: &str = "announcement-repeated";
230 /// `wrapChildren` on a container that cannot break lines: a column, a
231 /// row whose main axis scrolls, or a row of a table, whose children are
232 /// the table's columns. All lay out exactly as if the flag were absent,
233 /// which reads as "wrapping is broken"; see `LayoutSpec::wrap` for why
234 /// a column cannot have it.
235 pub const WRAP_IGNORED: &str = "wrap-ignored";
236 /// An alignment declared where it means nothing: a spread
237 /// (`spaceBetween` / `spaceAround` / `spaceEvenly`) on `crossAlign`,
238 /// `baseline` on `mainAlign` or on a column's `crossAlign`, or either
239 /// as a float's attach point. Each lays out as `start` — the two
240 /// centring spreads as `center` — which reads as "the value is
241 /// broken" when it is the axis that is wrong.
242 pub const ALIGN_IGNORED: &str = "align-ignored";
243 /// An `aspectRatio` with nothing it can set: both axes are
244 /// declared, or the width is `fit` under a `grow` or percent height,
245 /// which is resolved only after every width is. The ratio sizes a fit
246 /// height from the width, or a fit width from a fixed height.
247 pub const ASPECT_IGNORED: &str = "aspect-ignored";
248 /// A text node sits more than four levels below the `line` row above
249 /// it, which is as far as a text's place remembers its ancestors — so
250 /// `textHit` / `caretRect` asked by that row's key cannot find the run,
251 /// and a press inside it reports `byte: 0`. Flatten the wrappers between
252 /// the row and its text, or ask by a nearer key.
253 pub const TEXT_BEYOND_LINE: &str = "text-beyond-line";
254 /// One frame removed more nodes declaring `exit` than the exit store
255 /// will hold (4096, `depart::MAX_NODES`), so none of that frame's removal
256 /// animated: every departing node of it vanished at once, as a node with
257 /// no `exit` does, rather than some sliding out and the rest blinking.
258 /// Correct, and
259 /// invisible from the outside, which is the whole reason it is a line
260 /// here: a list that drops a thousand rows wants `exit` on the list, not
261 /// on every row. A removal that fits the budget but finds earlier exits
262 /// still in flight evicts those, oldest first, and is not this warning.
263 pub const EXIT_BUDGET: &str = "exit-budget";
264 /// A prop name nothing claims: not a schema row, not a composite, not one of
265 /// the element's own props (see `schema::known_prop`). The binding threw the
266 /// declaration away — `hoverBg` in a Lua table, `onclick` in JSX — so unlike
267 /// every other code here this one is raised by the frontend that saw it,
268 /// through `Core::warn`: by the time a frame is a tree the name is
269 /// gone. The message names the likely spelling. Also raised for a key
270 /// a menu row map carried that no row reads — `disabled` on a select's
271 /// option, where the key is `enabled` — by the binding that read the
272 /// row.
273 pub const UNKNOWN_PROP: &str = "unknown-prop";
274 /// The process has spelled 65 536 distinct size expressions — the most
275 /// the table every window shares keeps, and never lets go of — and one
276 /// more was declared: that prop is left at its default (a `width` is
277 /// fit, a clamp none) instead of failing the frame, and so is every new
278 /// expression after it; the ones kept still resolve. A view that makes
279 /// a new expression per frame reaches it — a `{ max: [dragX, { percent:
280 /// 30 }] }` fed a splitter's fractional drag, a `format!` of the
281 /// pointer — where one expression per layout, with the moving part a px
282 /// size beside it, would not. Raised once per core; the message names
283 /// the last expression refused.
284 pub const SIZE_EXPRESSIONS_FULL: &str = "size-expressions-full";
285 /// One name declared with two different window configs on the frame it
286 /// opened. The config is read on the opening edge only, and on that edge
287 /// the lowest declaring window wins (the first declaration within one
288 /// frame), so the pick is deterministic — but two places in the app
289 /// disagree about what `"palette"` is, and only one of them is right.
290 pub const DUPLICATE_WINDOW_CONFIG: &str = "duplicate-window-config";
291 /// A window the user closed is still declared, so it stays closed: a
292 /// declaration reopens a window only when it *starts*, and this one never
293 /// stopped. The first version of every multi-window app does this — it
294 /// declares the window unconditionally — and from outside it looks like
295 /// `windows` being ignored. Handle the `{kind:"window", phase:"closed"}`
296 /// event, stop declaring the name, and declare it again to reopen.
297 pub const WINDOW_DECLARED_WHILE_CLOSED: &str = "window-declared-while-closed";
298 /// A window declared with a `KUI_WINDOW_KIND_*` this build does not
299 /// have — `KUI_WINDOW_KIND_NORMAL` and `KUI_WINDOW_KIND_POPUP` are the
300 /// two there are. Only a C host can reach this: JSX and Lua name a kind
301 /// by string, so an unknown one is refused where it is written rather
302 /// than reported a frame later. The window still opens, as a normal
303 /// one, so a host built against a later header degrades to a window
304 /// rather than to nothing; this line is what keeps that from being
305 /// silent.
306 pub const UNKNOWN_WINDOW_KIND: &str = "unknown-window-kind";
307
308 /// A `setEditText` (`Core::set_edit_text`, `kui_edit_set_text`) named a
309 /// key or a label, the text was held for the frame that would declare
310 /// it, and the frame after the call declared no editor under that name
311 /// — so nothing was ever seeded and the text is dropped. The call is
312 /// meant to run from an `update` that also opens the editor, one frame
313 /// ahead of the view that declares it; this is the same call with the
314 /// view half missing, or with a name the view spells differently. Pass
315 /// the label the editor's `key` prop declares — the spelling that
316 /// needs nothing to exist yet — or the hex key an event carried.
317 /// `keyOf`/`kui_key_of` turns a label into that key, but only for an
318 /// editor some frame declared (Lua's verbs take the label itself). An editor that already
319 /// exists takes the text where the call is made and never reaches
320 /// this.
321 pub const EDIT_TEXT_WITHOUT_EDITOR: &str = "edit-text-without-editor";
322
323 /// A one-shot `audio` node went away — or changed its `src` — while
324 /// the sound it started was still playing, so the user heard it cut
325 /// off. Almost always a duration guessed short: the view keeps the
326 /// node declared for a constant it picked, and the asset is longer.
327 /// Ask for the sound instead of the guess —
328 /// `finish` (`AudioSpec::finish`, `finish` in JSX and Lua) releases the
329 /// playback on removal so it plays itself out, and a `tag` reports
330 /// `{kind:"sound", phase:"ended"}` when it gets there. A view that
331 /// means to cut the sound off says so by stopping what it started
332 /// (`Core::stop`), which is not reported, and a `looped` playback
333 /// never is: it has no end to be short of.
334 ///
335 /// Only a driver with a real device raises it, because only a device
336 /// knows the sound was still running: the core queues the `stop` and
337 /// the driver answers `Core::audio_truncated` for the ones its handle
338 /// found still playing. A headless `Ctx` therefore never raises it —
339 /// nothing plays — and the assertion point there is the other end of
340 /// the same fact: `audioCommands()` holding a `stop` for the node,
341 /// where the suite expected none.
342 pub const TRUNCATED_PLAYBACK: &str = "truncated-playback";
343
344 /// A `FontId` / `ImageId` / `SoundId` registered in one `Session` and used
345 /// through a core of another. Handles are unique to the process, so it
346 /// cannot resolve to somebody else's resource; it behaves as a removed
347 /// handle does (draws nothing, shapes as sans-serif, plays nothing), which
348 /// from outside looks like the resource never registered. Two
349 /// `Core::new()`s are two sessions; windows that share resources are built
350 /// with `Core::new_in` against one `Session`.
351 pub const FOREIGN_RESOURCE: &str = "foreign-resource";
352
353 /// An extension names a slot no host declared this frame, so it drew
354 /// nothing. A slot is a position the host declares in its own view by
355 /// full name, `ui.slot("ns/name")` — the namespace the host gave the
356 /// extension, then the name the extension lists; one listing none fills
357 /// `"ns/root"` after the host's view. Declare the slot, or drop the name
358 /// from the extension's list.
359 pub const UNKNOWN_SLOT: &str = "unknown-slot";
360 /// A colour or length prop named a token — `bg = "$peach"` — that
361 /// nothing declared and that is no theme or metrics role, or named one
362 /// of the other kind (a length in a colour slot). The slot is left at
363 /// the row's default, as if the prop had not been written — no `bg`,
364 /// a fit width, the theme's foreground for a text's `color` — never an
365 /// explicit transparent or zero, which would hide the node a typo was
366 /// on; the same in every binding and in every place a `$name` can go,
367 /// a keyframe stop and an entrance included, and what
368 /// `ui.token_color` / `token_length` answer `None` for in Rust. Raised
369 /// by the binding that lowered the reference, through
370 /// `Core::warn_unknown_token`, once per name, since the name is gone
371 /// by the time the frame is a tree. Also
372 /// raised at the declaration for a derived token whose source — the
373 /// `from`, or the colour a `mix` or `readable` names — is no colour
374 /// token declared before it and no theme role: that token is dropped,
375 /// the message names both, and the rest of the table lands.
376 pub const UNKNOWN_TOKEN: &str = "unknown-token";
377 /// A declared token took a theme or metrics role's name (`surface`,
378 /// `radius`) and was dropped: the roles are the corpus's contract and
379 /// `$surface` always means the theme's, so an app cannot shadow one.
380 /// Rename the token.
381 pub const RESERVED_TOKEN: &str = "reserved-token";
382 /// A slot name declared twice in one frame. The second declaration was
383 /// ignored: a fill is keyed by the slot's full name, so two fills of one
384 /// name would share every key. Two places for one extension are two
385 /// names.
386 pub const DUPLICATE_SLOT: &str = "duplicate-slot";
387 /// An extension returned from `view` with nodes still open. The core
388 /// closed them at the depth the fill began, so the host's tree is what
389 /// the host declared; outside the guard, the rest of the host's view
390 /// would have landed inside the extension's last open node. The
391 /// extension has an `open` without its `close`.
392 pub const UNBALANCED_EXTENSION: &str = "unbalanced-extension";
393 /// An extension's `view` returned an error. The message is drawn in
394 /// red where the fill would have been, and reported here once per
395 /// extension and slot rather than once per frame.
396 pub const EXTENSION_VIEW_ERROR: &str = "extension-view-error";
397 /// An extension declared one of its *own* slots while it was drawing,
398 /// so filling it would have meant calling it inside itself. The slot
399 /// is left empty. An extension may host extensions (`Fill::add`), and
400 /// may declare their slots — what it cannot do is be its own guest.
401 pub const RECURSIVE_SLOT: &str = "recursive-slot";
402
403 /// The device refused a play: its voices are all held, or the sound
404 /// did not decode. A released playback (`finish`) holds one of the
405 /// device's 128 voices until its file ends, so a view that releases
406 /// faster than its sounds finish reaches the limit and the 129th play
407 /// is refused. The refusal is not left silent because the playback
408 /// never starts and so never ends: a `tag`ged node waiting for
409 /// `ended` would wait forever. It gets
410 /// `{kind:"sound", phase:"refused"}` instead, and this line says why.
411 /// Stop what the view no longer needs rather than releasing it, or
412 /// release shorter sounds.
413 pub const PLAYBACK_REFUSED: &str = "playback-refused";
414 /// A devtools tab name declared twice in one frame: two `devtools_tab` /
415 /// `devtools_tab_with` calls, a host
416 /// form and an extension form of one name, or an extension declaring
417 /// from its fill under a name the host took. The first declaration
418 /// stands and the second is ignored; give the second tab its own name.
419 pub const DUPLICATE_TAB: &str = "duplicate-tab";
420 /// A `devtoolsTab` declaration a binding could not read as either
421 /// form: a child that is not a function, both a
422 /// `slot` and a child, or a `view` that is not a function in Lua. The
423 /// tab was not declared. A tab names a slot for an extension to fill,
424 /// or carries a function the binding calls only when the tab is shown.
425 pub const BAD_DEVTOOLS_TAB: &str = "bad-devtools-tab";
426 /// A select's `current` names no option the field can show: an index
427 /// past its options, or a separator's. The field is drawn as if none
428 /// were in force — an empty description, no row checked — rather than
429 /// blank with a check on a divider; the options are drawn as declared.
430 /// A `current` the view computes from a list it also filters is how
431 /// this happens; the index is into the options as passed, separators
432 /// counted. Raised once per field.
433 pub const SELECT_CURRENT_IGNORED: &str = "select-current-ignored";
434}
435
436/// The [`DUPLICATE_TAB`] warning for one name. Keyed by the name, the way
437/// `duplicate_slot` is: a tab is not a node.
438pub fn duplicate_tab(name: &str) -> Warning {
439 Warning {
440 code: DUPLICATE_TAB,
441 key: Key::ROOT.str(DUPLICATE_TAB).str(name),
442 message: format!(
443 "devtools tab {name:?} was declared twice in one frame; the second was ignored — \
444 give it its own name"
445 ),
446 }
447}
448
449/// The [`BAD_DEVTOOLS_TAB`] warning for one declaration, with what was
450/// wrong with it.
451pub fn bad_devtools_tab(name: &str, why: &str) -> Warning {
452 Warning {
453 code: BAD_DEVTOOLS_TAB,
454 key: Key::ROOT.str(BAD_DEVTOOLS_TAB).str(name),
455 message: format!("devtools tab {name:?} was not declared: {why}"),
456 }
457}
458
459/// The [`TEXT_BEYOND_LINE`] warning for one text node under `line`.
460/// Keyed by the text's node.
461pub fn text_beyond_line(text: Key, line: Key, reach: usize) -> Warning {
462 Warning {
463 code: TEXT_BEYOND_LINE,
464 key: text,
465 message: format!(
466 "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",
467 text.0, line.0
468 ),
469 }
470}
471
472/// The [`UNKNOWN_SLOT`] warning for one extension and slot. Keyed by the
473/// full name: there is no node, and one line per slot is the useful
474/// count however many frames repeat it.
475pub fn unknown_slot(extension: &str, namespace: &str, slot: &str) -> Warning {
476 let full = crate::slot::full_name(namespace, slot);
477 Warning {
478 code: UNKNOWN_SLOT,
479 key: Key::ROOT.str(UNKNOWN_SLOT).str(&full),
480 message: format!(
481 "extension `{extension}` (namespace `{namespace}`) fills slot {slot:?}, which no view \
482 declared this frame, so it drew nothing; declare `ui.slot({full:?})` where it \
483 should go, or drop the name from the extension's `slots`"
484 ),
485 }
486}
487
488/// The [`DUPLICATE_SLOT`] warning for one name. Keyed by the name: a slot
489/// is not a node, and the conflict is between two declarations of it.
490pub fn duplicate_slot(slot: &str) -> Warning {
491 Warning {
492 code: DUPLICATE_SLOT,
493 key: Key::ROOT.str(DUPLICATE_SLOT).str(slot),
494 message: format!(
495 "slot {slot:?} was declared twice in one frame; the second was ignored, since a \
496 fill is keyed by the slot's name — two places want two names"
497 ),
498 }
499}
500
501/// The [`UNBALANCED_EXTENSION`] warning for one fill. Keyed by the slot,
502/// which names the extension through its namespace, so a plugin that
503/// never closes costs one line.
504pub fn unbalanced_extension(slot: &str, slot_key: Key, open: usize) -> Warning {
505 Warning {
506 code: UNBALANCED_EXTENSION,
507 key: slot_key.str(UNBALANCED_EXTENSION),
508 message: format!(
509 "the extension filling slot {slot:?} returned from view with {open} node{} still \
510 open; they were closed for it — it has an `open` without its `close`",
511 if open == 1 { "" } else { "s" }
512 ),
513 }
514}
515
516/// The [`EXTENSION_VIEW_ERROR`] warning for one extension in one slot.
517/// Keyed by both, so an error that repeats every frame costs one line.
518pub fn extension_view_error(extension: &str, slot: &str, slot_key: Key, err: &str) -> Warning {
519 Warning {
520 code: EXTENSION_VIEW_ERROR,
521 key: slot_key.str(extension).str(EXTENSION_VIEW_ERROR),
522 message: format!("extension `{extension}` failed to build slot {slot:?}: {err}"),
523 }
524}
525
526/// The [`RECURSIVE_SLOT`] warning for one fill. Keyed by the slot, the
527/// same way `unbalanced-extension` is: the conflict is a name, and one
528/// line per name is the useful count however many frames repeat it.
529pub fn recursive_slot(extension: &str, slot: &str, slot_key: Key) -> Warning {
530 Warning {
531 code: RECURSIVE_SLOT,
532 key: slot_key.str(RECURSIVE_SLOT),
533 message: format!(
534 "extension `{extension}` declared slot {slot:?} while it was drawing, which is its \
535 own; it was left empty, since filling it would mean calling `{extension}` inside \
536 itself. An extension may declare the slots of extensions it loaded — not its own"
537 ),
538 }
539}
540
541/// The [`FOREIGN_RESOURCE`] warning for one handle. Keyed by kind and
542/// handle: there is no node — an image node, a text style and a `play`
543/// call can all carry the same handle — and one line per handle is the
544/// useful count however many frames repeat it.
545pub fn foreign_resource(f: &Foreign) -> Warning {
546 Warning {
547 code: FOREIGN_RESOURCE,
548 key: Key::ROOT
549 .str(FOREIGN_RESOURCE)
550 .str(f.kind.name())
551 .index(f.raw),
552 message: f.message(),
553 }
554}
555
556/// The [`SIZE_EXPRESSIONS_FULL`] warning, if the table has refused an
557/// expression: one key for the process's one table, so a core says it
558/// once.
559pub fn size_expressions_full() -> Option<Warning> {
560 let (n, last) = crate::calc::refused();
561 (n > 0).then(|| Warning {
562 code: SIZE_EXPRESSIONS_FULL,
563 key: Key::ROOT.str(SIZE_EXPRESSIONS_FULL),
564 message: format!(
565 "{} size expressions are kept, and {n} more were refused, the last \"{last}\": each \
566 is laid out as if undeclared — declare one per layout, not one per frame",
567 crate::calc::MAX_CALCS
568 ),
569 })
570}
571
572/// The [`EDIT_TEXT_WITHOUT_EDITOR`] warning for one label. Keyed by
573/// `Key::ROOT.str(label)` — not a node's key, since no node took the
574/// name, but one key per label all the same, so two unclaimed labels in
575/// a frame are two lines under the once-per-(code, key) dedup.
576pub fn edit_text_without_editor_label(label: &str) -> Warning {
577 Warning {
578 code: EDIT_TEXT_WITHOUT_EDITOR,
579 key: Key::ROOT.str(label),
580 message: format!(
581 "`set_edit_text` named label {label:?}, and the frame after it declared no editor \
582 under that name, so the text was dropped; the label is the one an editor's `key` \
583 prop declares, and the call is held for one frame — for the view that draws the \
584 editor the same `update` opened — not longer"
585 ),
586 }
587}
588
589/// The [`PLAYBACK_REFUSED`] warning for one playback. Keyed by the node
590/// that asked for the sound — the tagged node, the `audio` element, or
591/// the origin's root for an imperative `play` — so a view that keeps
592/// asking past the device's limit costs one line rather than one per
593/// refusal, which is the whole point of the dedup.
594pub fn playback_refused(key: crate::key::Key, playback: crate::audio::PlaybackId) -> Warning {
595 Warning {
596 code: PLAYBACK_REFUSED,
597 key,
598 message: format!(
599 "the audio device refused playback {} — its voices are all held, or the sound did \
600 not decode; a released (`finish`) playback holds one of the device's 128 voices \
601 until its file ends, so releasing faster than the sounds finish reaches the limit. \
602 The playback never started and will never report `ended`; a tagged one is told so \
603 with `phase: \"refused\"`",
604 playback.0
605 ),
606 }
607}
608
609/// The [`EDIT_TEXT_WITHOUT_EDITOR`] warning for one key. Keyed by the
610/// editor's own key, so a view that never declares it reports once, the
611/// way every node-shaped code does.
612/// [`MODAL_BEHIND_CONTENT`], the float-stack case: raised from emission,
613/// where the stack exists, rather than from the tree walk.
614pub(crate) fn modal_under_layer(key: Key) -> Warning {
615 Warning {
616 code: MODAL_BEHIND_CONTENT,
617 key,
618 message: "a float from outside this modal's scope opened after it and paints on top \
619 of it: everything drawn over a modal is inert, which reads as a broken \
620 dialog (declare it inside the modal, or close it while the modal is up)"
621 .to_string(),
622 }
623}
624
625pub fn edit_text_without_editor(key: Key) -> Warning {
626 Warning {
627 code: EDIT_TEXT_WITHOUT_EDITOR,
628 key,
629 message: format!(
630 "`set_edit_text` named key {:#x}, and the frame after it declared no editor under \
631 that key, so the text was dropped; the call is held for one frame — for the view \
632 that draws the editor the same `update` opened — not longer",
633 key.0
634 ),
635 }
636}
637
638/// The [`TRUNCATED_PLAYBACK`] warning for one cut-off playback. Keyed by
639/// the `audio` node, so a view that truncates the same chime every time it
640/// runs costs one line; `at` is where the playback was, in seconds, which
641/// is the half of "how much was lost" a device can actually report.
642pub fn truncated_playback(key: Key, why: crate::audio::Why, at: f64) -> Warning {
643 Warning {
644 code: TRUNCATED_PLAYBACK,
645 key,
646 message: format!(
647 "the `audio` node at key {:#x} was {} {:.2}s into its sound, cutting it off; keep \
648 the node declared until its `ended` event, or add `finish` so the playback is \
649 released to play itself out",
650 key.0,
651 why.verb(),
652 at
653 ),
654 }
655}
656
657/// The [`ANNOUNCEMENT_REPEATED`] warning for one text. Keyed by the text:
658/// there is no node behind an announcement, and one line per repeated
659/// message is the useful count however many frames repeat it.
660pub fn announcement_repeated(text: &str) -> Warning {
661 Warning {
662 code: ANNOUNCEMENT_REPEATED,
663 key: Key::ROOT.str(ANNOUNCEMENT_REPEATED).str(text),
664 message: format!(
665 "the announcement {text:?} was queued on two consecutive frames: `announce` says \
666 something once, and a view runs every frame, so a call made from a frame builder \
667 needs a guard the app clears (announce from the event handler, or keep a field the \
668 handler sets and the view clears)"
669 ),
670 }
671}
672
673/// The [`DUPLICATE_WINDOW_CONFIG`] warning for one window name. Keyed by
674/// the name: there is no node, and the conflict is between declarations,
675/// however many frames repeat it.
676pub fn duplicate_window_config(name: &str) -> Warning {
677 Warning {
678 code: DUPLICATE_WINDOW_CONFIG,
679 key: Key::ROOT.str(DUPLICATE_WINDOW_CONFIG).str(name),
680 message: format!(
681 "window `{name}` was declared with two different configs on the frame it opened; \
682 the lowest declaring window's first declaration won, and a live window's config is \
683 never re-read, so the other one never applies — make them agree"
684 ),
685 }
686}
687
688/// The [`WINDOW_DECLARED_WHILE_CLOSED`] warning for one window name. Keyed
689/// by the name, and raised on the core whose frame declared it, so an app
690/// that keeps asking every frame reads one line.
691pub fn window_declared_while_closed(name: &str) -> Warning {
692 Warning {
693 code: WINDOW_DECLARED_WHILE_CLOSED,
694 key: Key::ROOT.str(WINDOW_DECLARED_WHILE_CLOSED).str(name),
695 message: format!(
696 "window `{name}` is still declared after the user closed it, so it stays closed: a \
697 declaration reopens a window only when it starts — handle the \
698 `{{kind:\"window\", phase:\"closed\"}}` event, stop declaring `{name}`, and declare \
699 it again to reopen"
700 ),
701 }
702}
703
704/// The [`UNKNOWN_WINDOW_KIND`] warning for one declaration. Keyed by the
705/// window name and the kind, the way the other two window codes are keyed by
706/// a name and not a node: a declaration is not a node, and one line per
707/// (window, kind) is the useful count however many frames repeat it.
708pub fn unknown_window_kind(name: &str, kind: u32) -> Warning {
709 Warning {
710 code: UNKNOWN_WINDOW_KIND,
711 key: Key::ROOT
712 .str(UNKNOWN_WINDOW_KIND)
713 .str(name)
714 .index(kind as u64),
715 message: format!(
716 "window `{name}` was declared with kind {kind}, which this build does not have; \
717 `KUI_WINDOW_KIND_NORMAL` (0) and `KUI_WINDOW_KIND_POPUP` (1) are the ones there \
718 are, so it opened as a normal window"
719 ),
720 }
721}
722
723/// The [`FOCUS_REGION_WITHOUT_NODE`] warning for a `focus_region` the frame
724/// could not resolve. Keyed by the key or the label's hash, so a call
725/// repeated every frame costs one line.
726pub(crate) fn focus_region_without_node(target: &crate::runtime::RegionTarget) -> Warning {
727 use crate::runtime::RegionTarget;
728 let (key, named) = match target {
729 RegionTarget::Main => (Key::ROOT, "the main ring".to_string()),
730 RegionTarget::Key(k) => (*k, format!("key {:016x}", k.0)),
731 RegionTarget::Label(label) => (Key::ROOT.str(label), format!("label {label:?}")),
732 };
733 Warning {
734 code: FOCUS_REGION_WITHOUT_NODE,
735 key,
736 message: format!(
737 "`focus_region` named {named}, and the frame after it declared no `focusRegion` node \
738 there, so nothing was entered; the name is the label the region's `key` prop \
739 declares, on a node that carries the `focusRegion` row"
740 ),
741 }
742}
743
744/// The [`LABEL_WITHOUT_NODE`] warning for a deferred `verb` by `label`.
745/// Keyed by the verb and the label, so a call repeated every frame costs
746/// one line, and a `reveal` and a `set_scroll` of one typo are two.
747pub(crate) fn label_without_node(verb: &str, label: &str) -> Warning {
748 Warning {
749 code: LABEL_WITHOUT_NODE,
750 key: Key::ROOT.str(verb).str(label),
751 message: format!(
752 "`{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"
753 ),
754 }
755}
756
757/// The [`AMBIGUOUS_KEY`] warning for one label `Core::key_of` found `count`
758/// nodes under. Keyed by the node the name resolved to, so a label asked
759/// for every frame costs one line.
760pub fn ambiguous_key(label: &str, first: Key, count: usize) -> Warning {
761 Warning {
762 code: AMBIGUOUS_KEY,
763 key: first,
764 message: format!(
765 "{count} nodes are keyed {label:?} under different parents; the first in tree order \
766 ({:016x}) was used — give the one meant a label nothing else declares, or pass its \
767 hex key",
768 first.0
769 ),
770 }
771}
772
773/// The [`SELECT_CURRENT_IGNORED`] warning for one field: `current` was
774/// `index` over `count` options and `separator` says whether it landed on
775/// one rather than past the end. Keyed by the field, so a view that draws
776/// it that way every frame costs one line.
777pub fn select_current_ignored(
778 key: Key,
779 label: &str,
780 index: usize,
781 count: usize,
782 separator: bool,
783) -> Warning {
784 let why = if separator {
785 "which is a separator".to_string()
786 } else {
787 format!(
788 "and the field has {count} option{}",
789 if count == 1 { "" } else { "s" }
790 )
791 };
792 Warning {
793 code: SELECT_CURRENT_IGNORED,
794 key,
795 message: format!(
796 "`current` of select {label:?} names option {index} counted from 0, {why}, so the \
797 field shows no choice and no row is checked — pass an index of an option, or none"
798 ),
799 }
800}
801
802/// The [`UNKNOWN_PROP`] warning for a key a menu row map carried that
803/// [`crate::MenuItem::from_value`] does not read — `disabled` for
804/// `enabled: false` — so the row was built without it.
805/// Keyed by the name, as [`unknown_prop`]'s are: one line per spelling.
806pub fn unknown_menu_item_key(name: &str) -> Warning {
807 let keys = crate::MenuItem::KEYS
808 .iter()
809 .map(|k| format!("`{k}`"))
810 .collect::<Vec<_>>()
811 .join(", ");
812 // The one misspelling with a meaning of its own gets the value it was
813 // after; the rest the nearest key by letters, as `schema::suggest` does.
814 let squash = |s: &str| s.replace('_', "").to_ascii_lowercase();
815 let hint = if name == "disabled" {
816 " (did you mean `enabled: false`?)".to_string()
817 } else {
818 match crate::MenuItem::KEYS
819 .iter()
820 .find(|k| squash(k) == squash(name))
821 {
822 Some(near) => format!(" (did you mean `{near}`?)"),
823 None => String::new(),
824 }
825 };
826 Warning {
827 code: UNKNOWN_PROP,
828 key: Key::ROOT
829 .str(UNKNOWN_PROP)
830 .str(crate::MenuItem::NAME)
831 .str(name),
832 message: format!(
833 "`{name}` is not a key of a menu item: a row takes {keys}, so this declaration is \
834 dropped{hint}"
835 ),
836 }
837}
838
839/// The [`UNKNOWN_PROP`] warning for one dropped name, with the nearest
840/// legitimate spelling when there is an obvious one. The key is derived from
841/// the element and the name rather than from a node, so a misspelling costs
842/// one line however many nodes carry it and however many frames draw them.
843/// `element` [`crate::MenuItem::NAME`] is a menu row's key rather than a
844/// node's prop, and takes [`unknown_menu_item_key`]'s wording.
845pub fn unknown_prop(element: &str, name: &str, spelling: schema::Spelling) -> Warning {
846 if element == crate::MenuItem::NAME {
847 return unknown_menu_item_key(name);
848 }
849 // A real row on an element that reads only some of them is not a
850 // misspelling, and the nearest spelling would be the row itself; the
851 // warning says which rows the element does read instead.
852 let message = match schema::element_rows(element, spelling) {
853 Some(rows) if schema::shared_prop(name, spelling) => {
854 let takes = if rows.is_empty() {
855 "none of them".to_string()
856 } else {
857 format!(
858 "only {}",
859 rows.iter()
860 .map(|r| format!("`{r}`"))
861 .collect::<Vec<_>>()
862 .join(", ")
863 )
864 };
865 format!(
866 "`{name}` is a prop, but not one {element} reads: its look is its own, and it \
867 takes {takes}, so this declaration is dropped — a box with `role` set takes \
868 every row"
869 )
870 }
871 _ => {
872 let hint = match schema::suggest(element, name, spelling) {
873 Some(near) => format!(" (did you mean `{near}`?)"),
874 None => String::new(),
875 };
876 format!(
877 "`{name}` is not a prop of {element}: no binding reads it, so this declaration \
878 is dropped{hint}"
879 )
880 }
881 };
882 Warning {
883 code: UNKNOWN_PROP,
884 key: Key::ROOT.str(UNKNOWN_PROP).str(element).str(name),
885 message,
886 }
887}
888
889/// Pending warnings are capped so a host that never drains them cannot
890/// grow the queue without bound.
891const MAX_PENDING: usize = 256;
892/// The checks run on the first two frames and every this many after.
893pub(crate) const CHECK_EVERY: u64 = 16;
894
895pub(crate) struct Diagnostics {
896 pub(crate) enabled: bool,
897 pending: Vec<Warning>,
898 /// Everything that ever reached `pending`, kept after the drain: the
899 /// warnings of a core's whole life, for a reader that is not the
900 /// driver (a dev overlay showing what the runner printed). Bounded
901 /// the way `warned` is, since each (code, key) lands here once.
902 log: Vec<Warning>,
903 warned: FxHashSet<(&'static str, Key)>,
904 /// Child count at the last check, for parents with an auto-keyed child
905 /// that carries a transition.
906 child_counts: FxHashMap<Key, u32>,
907 scratch: Vec<u64>,
908}
909
910impl Default for Diagnostics {
911 fn default() -> Self {
912 Self {
913 enabled: true,
914 pending: Vec::new(),
915 log: Vec::new(),
916 warned: FxHashSet::default(),
917 child_counts: FxHashMap::default(),
918 scratch: Vec::new(),
919 }
920 }
921}
922
923impl Diagnostics {
924 pub(crate) fn take(&mut self) -> Vec<Warning> {
925 std::mem::take(&mut self.pending)
926 }
927
928 /// Every warning raised so far, drained or not, oldest first.
929 pub(crate) fn raised(&self) -> &[Warning] {
930 &self.log
931 }
932
933 /// A warning built elsewhere — by a binding, for what it saw before the
934 /// tree existed. Same gate and same once-per-(code, key) dedup as the
935 /// checks below, so a frontend can raise one per node per frame and the
936 /// host still reads one line.
937 pub(crate) fn raise(&mut self, w: Warning) {
938 if !self.enabled
939 || self.pending.len() >= MAX_PENDING
940 || !self.warned.insert((w.code, w.key))
941 {
942 return;
943 }
944 self.log.push(w.clone());
945 self.pending.push(w);
946 }
947
948 fn warn(&mut self, code: &'static str, key: Key, message: impl FnOnce() -> String) {
949 if self.pending.len() >= MAX_PENDING || !self.warned.insert((code, key)) {
950 return;
951 }
952 let w = Warning {
953 code,
954 key,
955 message: message(),
956 };
957 self.log.push(w.clone());
958 self.pending.push(w);
959 }
960
961 /// Runs every check over the finished frame's tree, on the frames the
962 /// cadence picks (see the module docs).
963 pub(crate) fn check(
964 &mut self,
965 tree: &Tree,
966 text: &TextSystem,
967 edit: &EditStore,
968 frame_no: u64,
969 ) {
970 if !self.enabled || tree.is_empty() {
971 return;
972 }
973 if frame_no > 2 && !frame_no.is_multiple_of(CHECK_EVERY) {
974 return;
975 }
976 self.check_grow_weights(tree);
977 self.check_wrap(tree);
978 self.check_align(tree);
979 self.check_auto_keyed_transitions(tree);
980 self.check_duplicate_keys(tree);
981 self.check_modal(tree);
982 self.check_selection_scopes(tree);
983 self.check_composites(tree);
984 self.check_lone_items(tree);
985 self.check_access(tree, text, edit);
986 self.check_live_regions(tree, text);
987 }
988
989 /// A modal painted under content it makes inert. Paint order is
990 /// preorder with floating subtrees last, so anything after the modal's
991 /// subtree — or any float outside it — draws over it; a modal that is
992 /// itself inside a float is already on top of both.
993 /// A selection scope inside another one. Cheap to skip:
994 /// the tree says whether any node declared one at all.
995 fn check_selection_scopes(&mut self, tree: &Tree) {
996 if !tree.any_selectable {
997 return;
998 }
999 // Parents precede children, so one forward pass carries the
1000 // nearest enclosing scope down without a stack.
1001 self.scratch.clear();
1002 self.scratch.resize(tree.len(), 0);
1003 for i in 0..tree.len() {
1004 let inside = match tree.parent[i] {
1005 NIL => 0,
1006 p => self.scratch[p as usize],
1007 };
1008 let here = tree.specs[i].interact().selectable;
1009 if here && inside == 1 {
1010 self.warn(NESTED_SELECTION_SCOPE, tree.keys[i], || {
1011 "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()
1012 });
1013 }
1014 self.scratch[i] = u64::from(here || inside == 1);
1015 }
1016 }
1017
1018 fn check_modal(&mut self, tree: &Tree) {
1019 let Some(i) = (0..tree.len())
1020 .rev()
1021 .find(|&i| tree.specs[i].events().modal.is_some())
1022 else {
1023 return;
1024 };
1025 let end = tree.subtree_end(i);
1026 let floating = |mut j: usize| {
1027 loop {
1028 if tree.specs[j].layout.float.is_some() {
1029 return true;
1030 }
1031 match tree.parent[j] {
1032 NIL => return false,
1033 p => j = p as usize,
1034 }
1035 }
1036 };
1037 if floating(i) {
1038 return;
1039 }
1040 let over = (0..tree.len())
1041 .filter(|j| !(i..end).contains(j))
1042 .any(|j| j >= end || floating(j));
1043 if !over {
1044 return;
1045 }
1046 self.warn(MODAL_BEHIND_CONTENT, tree.keys[i], || {
1047 "this modal is not in a float, and content declared after it paints on top of it: \
1048 everything drawn over a modal is inert, which reads as a broken dialog (float it \
1049 with `float=\"viewport\"`)"
1050 .to_string()
1051 });
1052 }
1053
1054 /// Focusable nodes buried inside a composite's items, which nothing
1055 /// can reach (see [`FOCUSABLE_INSIDE_ITEM`]). One walk per composite,
1056 /// on the same cadence as every other check here.
1057 fn check_composites(&mut self, tree: &Tree) {
1058 let mut items: Vec<usize> = Vec::new();
1059 for c in 0..tree.len() {
1060 let Some(item) = tree.specs[c]
1061 .access()
1062 .role
1063 .and_then(crate::composite::item_role)
1064 else {
1065 continue;
1066 };
1067 crate::composite::items(tree, c, item, &mut items);
1068 if !crate::composite::is_composite(tree, c, &items) {
1069 continue;
1070 }
1071 for &i in &items {
1072 let end = tree.subtree_end(i);
1073 for j in i + 1..end {
1074 if !access::focusable(tree, j) {
1075 continue;
1076 }
1077 let what = item.name();
1078 self.warn(FOCUSABLE_INSIDE_ITEM, tree.keys[j], || {
1079 format!(
1080 "this node is focusable but sits inside a `{what}`, which is one \
1081 roving Tab stop of a composite: the ring stops at the item, so \
1082 nothing reaches this node (move it outside the item, or drop its \
1083 focusable behaviour)"
1084 )
1085 });
1086 }
1087 }
1088 }
1089 }
1090
1091 /// A `radio` or `tab` with no container of its pair above it (see
1092 /// [`ITEM_OUTSIDE_CONTAINER`]). The pairs are `composite::PAIRS`, less
1093 /// the menu's and the list's. Parents precede children, so one forward
1094 /// pass carries down a bit per pair for the containers above each node,
1095 /// as the selection-scope check carries its one.
1096 fn check_lone_items(&mut self, tree: &Tree) {
1097 use crate::composite::PAIRS;
1098 const CHECKED: [Role; 2] = [Role::Radio, Role::Tab];
1099 self.scratch.clear();
1100 self.scratch.resize(tree.len(), 0);
1101 for i in 0..tree.len() {
1102 let above = match tree.parent[i] {
1103 NIL => 0,
1104 p => self.scratch[p as usize],
1105 };
1106 let mut here = above;
1107 if let Some(role) = tree.specs[i].access().role {
1108 for (bit, (container, item)) in PAIRS.iter().enumerate() {
1109 if role == *container {
1110 here |= 1 << bit;
1111 } else if role == *item && CHECKED.contains(item) && above & (1 << bit) == 0 {
1112 let (item, container) = (item.name(), container.name());
1113 self.warn(ITEM_OUTSIDE_CONTAINER, tree.keys[i], || {
1114 format!(
1115 "this `{item}` has no `{container}` above it, so it is a Tab \
1116 stop of its own: the arrows do not move the choice and a \
1117 screen reader announces no position in the set (wrap the \
1118 set in a `{container}` with a `label`)"
1119 )
1120 });
1121 }
1122 }
1123 }
1124 self.scratch[i] = here;
1125 }
1126 }
1127
1128 /// Images without a label, controls without a computable name and
1129 /// modals without one, by the same derivation the access tree uses
1130 /// (see `access::semantic`).
1131 fn check_access(&mut self, tree: &Tree, text: &TextSystem, edit: &EditStore) {
1132 let mut skip_until = 0usize;
1133 for i in 0..tree.len() {
1134 if i < skip_until {
1135 continue;
1136 }
1137 let Some(sem) = access::semantic(tree, text, edit, None, i) else {
1138 continue;
1139 };
1140 if sem.role == Role::None || sem.presentational {
1141 skip_until = tree.subtree_end(i);
1142 }
1143 if sem.role == Role::Slider {
1144 self.check_slider_range(tree, i);
1145 }
1146 if sem.name.is_some() || sem.role == Role::None {
1147 continue;
1148 }
1149 let key = tree.keys[i];
1150 if sem.role == Role::Image {
1151 self.warn(IMAGE_WITHOUT_LABEL, key, || {
1152 "this image has no label: assistive technology has nothing to say for it \
1153 (give it a `label`, or `role=\"none\"` if it is decoration)"
1154 .to_string()
1155 });
1156 } else if sem.role == Role::Dialog && tree.specs[i].events().modal.is_some() {
1157 self.warn(MODAL_WITHOUT_NAME, key, || {
1158 "this modal has no accessible name: a dialog is named by its `label`, never \
1159 by the text inside it — a screen reader announces an unnamed dialog to the \
1160 user it has just moved focus to (give it a `label`)"
1161 .to_string()
1162 });
1163 } else if sem.role.is_control() {
1164 let what = sem.role.name();
1165 self.warn(CONTROL_WITHOUT_NAME, key, || {
1166 format!(
1167 "this {what} has no accessible name: no `label`, and no text inside it \
1168 — a screen reader announces an unnamed {what} (give it a `label`)"
1169 )
1170 });
1171 }
1172 }
1173 }
1174
1175 /// A slider's declared value against its declared range (see
1176 /// [`SLIDER_VALUE_OUT_OF_RANGE`]). Only the rows it declares are
1177 /// compared: a slider with no `valueMin` has no floor to fall under.
1178 fn check_slider_range(&mut self, tree: &Tree, i: usize) {
1179 let ax = tree.specs[i].access();
1180 let (now, min, max) = (ax.value_now, ax.value_min, ax.value_max);
1181 let reason = match (now, min, max) {
1182 (_, Some(lo), Some(hi)) if lo > hi => {
1183 format!("valueMin {lo} is above valueMax {hi}, so no value is in range")
1184 }
1185 (Some(v), Some(lo), _) if v < lo => {
1186 format!("valueNow {v} is below valueMin {lo}")
1187 }
1188 (Some(v), _, Some(hi)) if v > hi => {
1189 format!("valueNow {v} is above valueMax {hi}")
1190 }
1191 _ => return,
1192 };
1193 self.warn(SLIDER_VALUE_OUT_OF_RANGE, tree.keys[i], || {
1194 format!(
1195 "this slider's value and its declared range disagree: {reason} — the rows are \
1196 read to a screen reader exactly as declared, so a value the app clamps \
1197 somewhere else is announced unclamped (declare the range the value is really \
1198 held to, or clamp where the view declares it)"
1199 )
1200 });
1201 }
1202
1203 /// Live regions that can never say anything: `live` declared with no
1204 /// `label` and no text inside. Its own walk rather than a branch in
1205 /// [`Self::check_access`], because that one skips the subtree of a
1206 /// presentational role and a live region can sit inside one.
1207 fn check_live_regions(&mut self, tree: &Tree, text: &TextSystem) {
1208 for i in 0..tree.len() {
1209 let spec = &tree.specs[i];
1210 if spec.access().live == access::Live::Off
1211 || spec.access().role == Some(Role::None)
1212 || access::live_region_speaks(tree, text, i)
1213 {
1214 continue;
1215 }
1216 self.warn(LIVE_REGION_WITHOUT_NAME, tree.keys[i], || {
1217 "this node is a live region but has no accessible name: no `label`, and no text \
1218 inside it — every platform reads a live region by its name, so nothing this \
1219 node ever does can be announced (put `live` on the node that holds the message)"
1220 .to_string()
1221 });
1222 }
1223 }
1224
1225 fn check_grow_weights(&mut self, tree: &Tree) {
1226 for p in 0..tree.len() {
1227 if tree.first_child[p] == NIL {
1228 continue;
1229 }
1230 let row = tree.specs[p].layout.dir == Dir::Row;
1231 let mut grow_children = 0u32;
1232 let mut lone: Option<(u32, f32)> = None;
1233 for c in tree.children(p as u32) {
1234 let layout = tree.specs[c as usize].layout;
1235 if layout.float.is_some() {
1236 continue;
1237 }
1238 let (main, cross) = if row {
1239 (layout.width, layout.height)
1240 } else {
1241 (layout.height, layout.width)
1242 };
1243 if let Sizing::Grow(f) = main {
1244 grow_children += 1;
1245 lone = Some((c, f));
1246 }
1247 if let Sizing::Grow(f) = cross
1248 && f != 1.0
1249 {
1250 let axis = if row { "height" } else { "width" };
1251 let parent_axis = if row { "row" } else { "column" };
1252 self.warn(GROW_WEIGHT_IGNORED, tree.keys[c as usize], || {
1253 format!(
1254 "{axis} grow {f} has no effect: across a {parent_axis}'s main axis a \
1255 grow child fills the parent whatever its weight (use maxWidth / \
1256 maxHeight to cap it)"
1257 )
1258 });
1259 }
1260 }
1261 if grow_children == 1
1262 && let Some((c, f)) = lone
1263 && f != 1.0
1264 {
1265 let axis = if row { "width" } else { "height" };
1266 self.warn(GROW_WEIGHT_IGNORED, tree.keys[c as usize], || {
1267 format!(
1268 "{axis} grow {f} has no effect: it is the only grow child of its parent, \
1269 and a weight only splits free space between grow siblings — alone it \
1270 takes all of it (cap it with max{}, or give a sibling a grow too)",
1271 if row { "Width" } else { "Height" }
1272 )
1273 });
1274 }
1275 }
1276 }
1277
1278 /// Alignments and ratios with nothing to act on: see
1279 /// [`ALIGN_IGNORED`] and [`ASPECT_IGNORED`].
1280 fn check_align(&mut self, tree: &Tree) {
1281 use crate::spec::Align;
1282 let spread = |a: Align| {
1283 matches!(
1284 a,
1285 Align::SpaceBetween | Align::SpaceAround | Align::SpaceEvenly
1286 )
1287 };
1288 let odd = |a: Align| spread(a) || a == Align::Baseline;
1289 for i in 0..tree.len() {
1290 let l = &tree.specs[i].layout;
1291 let reason = if l.main_align == Align::Baseline {
1292 Some("mainAlign baseline: a baseline lines children up across a row, not along it (use crossAlign)".to_string())
1293 } else if spread(l.cross_align) {
1294 Some(format!(
1295 "crossAlign {}: a spread deals free space out between children, and there is one child per line across the axis (use mainAlign)",
1296 l.cross_align.name()
1297 ))
1298 } else if l.cross_align == Align::Baseline && l.dir == Dir::Column {
1299 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())
1300 } else {
1301 l.float.and_then(|f| {
1302 let pts = [
1303 f.anchor_point.0,
1304 f.anchor_point.1,
1305 f.self_point.0,
1306 f.self_point.1,
1307 ];
1308 pts.into_iter().find(|&a| odd(a)).map(|a| {
1309 format!(
1310 "a float attaches at start, center or end, not {} (it lays out as {})",
1311 a.name(),
1312 if matches!(a, Align::SpaceAround | Align::SpaceEvenly) {
1313 "center"
1314 } else {
1315 "start"
1316 }
1317 )
1318 })
1319 })
1320 };
1321 if let Some(reason) = reason {
1322 self.warn(ALIGN_IGNORED, tree.keys[i], || {
1323 format!("{reason}; it has no effect here")
1324 });
1325 }
1326 if l.aspect > 0.0 && !l.aspect_height() && l.aspect_width().is_none() {
1327 let why = if l.width == Sizing::Fit {
1328 "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)"
1329 } else {
1330 "both axes are declared, so there is no fit axis for the ratio to size (leave one of them fit)"
1331 };
1332 self.warn(ASPECT_IGNORED, tree.keys[i], || {
1333 format!("aspectRatio {} has no effect: {why}", l.aspect)
1334 });
1335 }
1336 }
1337 }
1338
1339 /// `wrapChildren` where nothing can break: see [`WRAP_IGNORED`].
1340 fn check_wrap(&mut self, tree: &Tree) {
1341 for i in 0..tree.len() {
1342 let layout = tree.specs[i].layout;
1343 if !layout.wrap {
1344 continue;
1345 }
1346 let reason = if layout.dir != Dir::Row {
1347 "a column's main size is not resolved until after the pass that would have to \
1348 sum the lines, so only a row wraps (turn the container into a row, or give the \
1349 items a fixed size and lay them out yourself)"
1350 } else if layout.scroll_x {
1351 "a scrollX row's main axis is unbounded, and an axis with no bound has nothing \
1352 to break against (drop scrollX, or drop wrapChildren and let it scroll)"
1353 } else if layout.float.is_none()
1354 && tree.parent[i] != NIL
1355 && tree.specs[tree.parent[i] as usize].layout.is_table()
1356 {
1357 "a table's row cannot wrap: its children are the table's columns, one each \
1358 (put the wrapping row inside a cell)"
1359 } else {
1360 continue;
1361 };
1362 self.warn(WRAP_IGNORED, tree.keys[i], || {
1363 format!("wrapChildren has no effect here: {reason}")
1364 });
1365 }
1366 }
1367
1368 fn check_auto_keyed_transitions(&mut self, tree: &Tree) {
1369 let mut counts: FxHashMap<Key, u32> = FxHashMap::default();
1370 for p in 0..tree.len() {
1371 if tree.first_child[p] == NIL {
1372 continue;
1373 }
1374 let parent_key = tree.keys[p];
1375 let mut n = 0u32;
1376 let mut auto_keyed_transition = false;
1377 for (i, c) in tree.children(p as u32).enumerate() {
1378 n += 1;
1379 // An auto key is the parent's key mixed with the sibling
1380 // index; a labeled key never collides with one.
1381 if tree.specs[c as usize].transition.is_some()
1382 && tree.keys[c as usize] == parent_key.index(i as u64)
1383 {
1384 auto_keyed_transition = true;
1385 }
1386 }
1387 if auto_keyed_transition {
1388 counts.insert(parent_key, n);
1389 }
1390 }
1391 for (&parent, &n) in &counts {
1392 if let Some(&prev) = self.child_counts.get(&parent)
1393 && prev != n
1394 {
1395 self.warn(TRANSITION_AUTO_KEY, parent, || {
1396 format!(
1397 "this node went from {prev} to {n} children while a child without a key \
1398 carries a transition: an auto key is the child's position, so the \
1399 children that shifted became new nodes and snapped instead of easing \
1400 (and inserting before them will again) — give them a key"
1401 )
1402 });
1403 }
1404 }
1405 self.child_counts = counts;
1406 }
1407
1408 fn check_duplicate_keys(&mut self, tree: &Tree) {
1409 let mut keys = std::mem::take(&mut self.scratch);
1410 keys.clear();
1411 keys.extend(tree.keys.iter().map(|k| k.0));
1412 keys.sort_unstable();
1413 for w in keys.windows(2) {
1414 if w[0] == w[1] {
1415 self.warn(DUPLICATE_KEY, Key(w[0]), || {
1416 "two nodes share this key in one frame: state retained per key (transitions, \
1417 scroll offsets, editors, layout events, hover) is mixed between them — \
1418 siblings need distinct labels"
1419 .to_string()
1420 });
1421 }
1422 }
1423 self.scratch = keys;
1424 }
1425}
1426
1427#[cfg(test)]
1428mod tests {
1429 use super::*;
1430
1431 /// The drain hands a warning to the driver once; the log keeps it for
1432 /// anyone else, and a repeat of the same (code, key) is neither.
1433 #[test]
1434 fn the_log_outlives_the_drain() {
1435 let mut d = Diagnostics::default();
1436 let w = Warning {
1437 code: "test-code",
1438 key: Key::ROOT,
1439 message: "once".into(),
1440 };
1441 d.raise(w.clone());
1442 d.raise(w.clone());
1443 assert_eq!(d.take(), vec![w.clone()]);
1444 assert!(d.take().is_empty(), "drained");
1445 assert_eq!(d.raised(), &[w]);
1446 }
1447
1448 /// The `pub const NAME: &str = "code";` lines of this file, read back
1449 /// from the source: a code declared outside the `warnings!` block would
1450 /// compile and be missing from the table, and this is what notices.
1451 fn codes_in_source() -> Vec<&'static str> {
1452 include_str!("diag.rs")
1453 .lines()
1454 .filter_map(|line| {
1455 let rest = line.trim_start().strip_prefix("pub const ")?;
1456 let (_name, rest) = rest.split_once(": &str = \"")?;
1457 let (code, tail) = rest.split_once('"')?;
1458 (tail == ";").then_some(code)
1459 })
1460 .collect()
1461 }
1462
1463 #[test]
1464 fn every_code_is_in_the_table_and_vice_versa() {
1465 let in_source = codes_in_source();
1466 let in_table: Vec<&str> = WARNINGS.iter().map(|w| w.code).collect();
1467 assert!(
1468 in_source.len() >= 13,
1469 "the scan missed the consts: {in_source:?}"
1470 );
1471 assert_eq!(in_source, in_table);
1472 for (i, code) in in_table.iter().enumerate() {
1473 assert!(!in_table[i + 1..].contains(code), "duplicate code {code}");
1474 assert!(
1475 code.bytes().all(|b| b == b'-' || b.is_ascii_lowercase()),
1476 "{code}: codes are kebab-case"
1477 );
1478 }
1479 }
1480
1481 /// A menu row's dropped key takes the row's wording, whichever door
1482 /// raises it: `unknown_prop` under `MENU_ITEM` is `unknown_menu_item_key`.
1483 /// One hint for the key with a meaning of its own, the
1484 /// letters' nearest for the rest, none for anything fuzzier.
1485 #[test]
1486 fn a_menu_rows_unknown_key_is_named_as_one() {
1487 let name = crate::MenuItem::NAME;
1488 let w = unknown_prop(name, "disabled", schema::Spelling::Camel);
1489 assert_eq!(w, unknown_menu_item_key("disabled"));
1490 assert_eq!(w.code, UNKNOWN_PROP);
1491 assert_eq!(w.key, Key::ROOT.str(UNKNOWN_PROP).str(name).str("disabled"));
1492 assert!(
1493 w.message.contains("`disabled` is not a key of a menu item")
1494 && w.message
1495 .contains("`label`, `role`, `enabled`, `checked`, `id`, `accel`")
1496 && w.message.ends_with("(did you mean `enabled: false`?)"),
1497 "{}",
1498 w.message
1499 );
1500 assert!(
1501 unknown_menu_item_key("Label")
1502 .message
1503 .ends_with("(did you mean `label`?)")
1504 );
1505 assert!(unknown_menu_item_key("lable").message.ends_with("dropped"));
1506 assert!(
1507 unknown_prop("box", "disabled", schema::Spelling::Camel)
1508 .message
1509 .starts_with("`disabled` is"),
1510 "an element's is the element's"
1511 );
1512 }
1513
1514 #[test]
1515 fn every_row_has_a_doc() {
1516 for w in WARNINGS {
1517 assert!(
1518 w.doc.split_whitespace().count() > 5,
1519 "{}: no description",
1520 w.code
1521 );
1522 assert!(
1523 !w.doc.contains("[`"),
1524 "{}: rustdoc link syntax leaks into the bindings' docs",
1525 w.code
1526 );
1527 }
1528 }
1529}