Design spec for include/morph/ui/backend.hpp — the retained widgets a mount drives and the
IViewBackend that makes them — and for the two test instruments that fix the contract:
ui::testing::RecordingBackend (include/morph/ui/testing/recording_backend.hpp) and the
conformance cases (include/morph/ui/testing/backend_conformance.hpp).
Read this before implementing a backend for a frontend, and before changing a widget interface.
What the mount does with these widgets is view_tree.md.
- Widgets
- Handlers
- Containers
- Interfaces
- Selection by key
- Drag and drop
- RecordingBackend
- The conformance suite
- Design decisions
IViewBackendhas one factory per kind. Each makes the widget, appends it as the last child ofparent— or makes a root whenparentis null — and never returns null. A kind's fixed parameters (a TextInput's mode, a stack's, scroll area's or splitter's axis, a DateTimeInput's mode and display zone, a Select's style, a FilePicker's mode, a Drawer's side) are factory arguments.createCustom(parent, {name, props, minVersion})is the one factory that may return null, and is not pure: null means the backend did not load the component, and the mount shows the node's fallback instead. A backend that loads no components keeps the default, which returns null; one that loads a component returns aCustomWidget, whose props are set by name (setTextProp,setBoolProp,setNumberProp) and whose events go to the handler given tosetOnEvent.- A new widget is visible, enabled, content-sized, not draggable and not a drop target, and has
no accessible name or role of its own, no test id, no tooltip, the theme's default surface and no
chords. A new field (text and the six inputs) is editable, not required, without errors and not
stale. A new dialog or drawer is closed; a new collapsible section is open. The mount calls the
Widgetsetters for those defaults (setVisible,setEnabled,setLayout,setDragKey,setDropHandler,setAccessibleName,setAccessibleRole,setTestId,setTooltip,setSurface,setKeys) and theFieldWidgetsetters (setReadOnly,setRequired,setErrors,setStale) only to change them; it calls a dialog'ssetOpenat mount whatever the value,falseincluded. - Focus.
focus()moves keyboard focus to the widget. The mount calls it on the firstautofocuswidget in document order once a mount completes (view tree). - Hover.
setOnHover(onHover)callsonHovereach time the pointer comes to rest over the widget. The default does nothing, so a backend without a pointer conforms. Hovering is a hint, such as a prefetch, never something a widget relies on. Qt Quick reports it throughMorphWidget'sHoverHandlerwhen a handler is set. - Opening a select.
SelectWidget::setOnOpen(onOpen)callsonOpeneach time the user opens a dropdown, also one with no options to show. The default does nothing, and a radio group is never opened. Like hovering it is a hint, used to fetch again options whose fetch failed. Qt Quick reports the combo box's popup opening; the TUI reports the list opening, or the attempt to open it when there are no options. - Chords.
setKeys(chords, onChord)declares the chords a widget handles while focus is inside it, andonChordreceives the chord pressed. A chord pressed while focus is inside several widgets that declare it goes to the innermost of them only, and a chord pressed inside a widget that takes no input (below) goes to none. - Read-only. A field made read-only by
setReadOnly(true)shows its value, takes focus and lets its text be selected, and reports no edit, submit or commit;setReadOnly(false)makes it editable again. - Date inputs. A graphical backend offers a calendar beside the typed date field, opening on
the field's date or on today in the display zone. A day chosen produces the same text an edit
would and reaches
onChangethe same way, so the field's text is the one source of the value. The terminal offers typed text only. A read-only date input shows no calendar. - Menus.
setItemsreplaces the whole tree ofMenuEntry{label, icon, keys, checked, enabled, items}.keysis only shown beside the label;checkedengaged shows a check mark that onlysetItemsmoves; an entry withitemsopens its submenu.onActivatereceives the chosen entry's path, its index at each level from the top, and only for an enabled entry without a submenu whose ancestors are all enabled. - Every setter takes UTF-8. A setter called with the value the widget already shows changes nothing: in particular a text input keeps its cursor, its selection and an input method's composition. A controlled input's re-assertion, or another renderer, may make such a call.
- Input widgets are controlled. A user's action changes what the widget shows, as the toolkit
does by itself, and reports the request through the handler; the mount then shows what the
application's slot says, in a turn posted after the request's flush, so a request the application
refuses snaps back (
view_tree.md, "Controlled inputs"). A commit requests no value, so it is not followed by a re-assertion. A user's dismissal closes a dialog or drawer beforeonDismissruns; one the application keeps open is opened again. A banner's dismissal changes nothing the banner shows. A splitter shows the sizes the user dragged to, and a collapsible section the state the user asked for, before the handler runs. - No setter calls a handler. Setting a value, replacing options, items or rows, requesting a
selection, opening or closing, collapsing — none of it is a user's doing, so none of it reaches
onChange,onToggle,onSelect,onSelectionChangeor any other handler. That covers what a widget does by itself in response to a setter: a Select re-marking its requested key aftersetOptions, a Table marking or unmarking a row that arrives or goes. A binding that writes back what the user typed therefore cannot loop. setVisible(false)hides the widget and everything inside it;setEnabled(false)stops input to the widget and everything inside it. A widget inside a hidden, disabled or collapsed container, inside a closed dialog or drawer, or inside a closed collapsible section other than in its header, takes no input, however deep it sits; it takes input again once every container around it is shown, enabled, expanded and open. A collapsed panel's own collapse control, and a closed section's own control and header, still work.- Banners. A dismissal of a dismissible banner reaches
onDismissand leaves the banner shown: the application hides it. A banner or empty state with an empty action label shows no button. - Drop zones. A drop is taken whole or refused whole: every file must have an accepted
extension (compared without regard to case; no extensions accepts every file), and a zone that is
not
multiplerefuses a drop of several files. A taken drop reachesonDropwith the paths in the order dropped. - Splitters.
setSizeswith an empty list, or with one whose length differs from the number of panes, shares the space equally. A user's drag reachesonResizewith every pane's size once the user lets go. - A widget's destructor detaches it from its parent and frees its native resources. The mount destroys children before parents.
- Handlers are handed over once (
setOnClick,setOnChange, …); an empty one does nothing. The mount wraps each inRuntime::widgetEvent, so a backend calls them directly from its native event handler. - A handler may destroy its own widget. Every backend keeps a handler alive for the duration
of its own call — for example by calling a copy of it — as
RecordingBackenddoes. A backend that calls the storedstd::functionin place frees the running closure when the handler destroys its widget. - No handler runs after its widget's destructor returns, even for input that arrived before. A backend whose input can be delivered later than the event that caused it (a queued connection) drops what is still pending for a destroyed widget.
- A backend may assume a handler never throws, a drop's
acceptspredicate included: the mount catches and reports what one throws (view_tree.md, "Callbacks").
ContainerWidget::moveChild(child, index) removes child and inserts it before the child now at
index; an index past the end means last. The child is moved, not recreated: it keeps its
native state — properties, handlers, focus, selection, the text a user typed into it. A backend
whose toolkit can only remove and re-add a child must carry that state across. Only the mount
reorders children, and only through this.
| Container | Children |
|---|---|
StackWidget |
In order along the axis fixed at creation, setGap apart; setAlign places them across the axis and setJustify the spare length along it |
GridWidget |
Row-major in setColumns columns, setGap apart; setSpan(child, n) widens one, and a child spans 1 until told; setAlign places each cell's content in its row |
PanelWidget |
Inside the frame, setPadding in; a collapsed panel hides its content, which takes no input. setHeader's widget is not part of "its content" for this: it stays shown and usable while collapsed |
ScrollWidget |
One scrolled viewport, on the axis fixed at creation, that keeps the focused child visible |
SlotWidget |
Shown as they are; hosts a Switch case or a Tabs page |
TabsWidget |
Page slots in the order first shown; the mount keeps exactly the selected page's slot visible, and setSelected moves the bar's highlight only |
DialogWidget |
The overlay's content; a focus trap while open; a closed dialog shows nothing and takes no input, its children included. footer() is a container the overlay owns and lays out after its content, showing nothing while it is empty; the mount puts the overlay's actions there. A DrawerWidget has the same footer |
TableWidget |
Rows, each a horizontal stack of cells in column order; setRowKey(row, key) once per row, right after it is built, then setRowTone(row, tone) for a toned row, and again on every change. A hidden cell keeps its column: the cells after it stay under their own headers |
Spacing. setGap and setPadding take a spacing step, 0 to kMaxSpacingStep (5); the mount clamps a document's
value with spacingStep, so a backend never sees another. Each backend maps a step to its own distance: the theme's
space.<step> in px on Qt Quick, cells by axis on the terminal.
Button emphasis. ButtonWidget::setEmphasis(ButtonEmphasis) takes Normal, Primary, Danger or Ghost; the
mount calls it only for another value than Normal, so a document without the emphasis key (normal, primary,
danger, ghost) records nothing new. Qt Quick fills a primary button in the accent and a danger one in the error
colour, and draws a ghost button with no border or fill, minimal padding, and a faint neutral tint on hover or press
— for an action embedded in other content (a table row's remove, a disclosure chevron) where full button chrome would
read as a second control beside it rather than part of it. The terminal draws Primary/Danger bold, a danger one
in the error colour, and falls back to Normal's plain style for Ghost.
Button style and selection. ButtonWidget::setStyle(ButtonStyle) makes a button an entry of a list (Item): it
behaves as a push button (activation, enablement, tooltip, hover) and is drawn as a line, as wide as its line, its
label at the start and unframed. The mount calls it once, only for Item. setSelected(bool) marks an item as the
current entry, or clears the mark; the mount calls it only for an item whose selected is bound or true, and a new
button is a push button and not selected. Qt Quick tints the current item in the accent with a bar at its leading
edge; the terminal draws it in the list's selected-item style.
The cross axis. A child sized content fills its container across the container's axis; fixed(n) gets n
units, at most the space there is; stretch is unchanged. In a column every content child is as wide as the column,
in a row as tall as the row; a grid cell, and the content of a panel, scroll area, slot, tab page, dialog or drawer,
fills its width. A kind whose width is its label (a button, a badge, a checkbox) fills the line but draws at its
natural width at the line's start, on Qt Quick as on the terminal. Along the axis a stack packs its children at the
start, setGap apart; only a stretch child takes spare length. A content-sized splitter keeps each pane at its own
size until the user drags. A splitter's panes and a table's cells are sized by the splitter and the table, not by this rule.
Alignment. All of the above is the default, Align::Stretch and Justify::Start; the mount calls setAlign and
setJustify only for another value, as it does setEmphasis, so a screen that says nothing of alignment records
nothing new. StackWidget::setAlign(Align) places every child across the axis, and a child's alignSelf (in its
LayoutHints) places that child instead: under Start, Center or End a content child keeps its own extent across
(a text is one line tall in a row, its natural width in a column) and sits at the start, centred (rounded down on the
terminal) or at the end; a fixed or stretch child keeps its extent, clamped into its bounds, and is placed the
same way, so a stretch card with maxWidth: 44 in a 70-unit column under Center is 44 units wide with 13 either
side. StackWidget::setJustify(Justify) places the spare length along the axis while no shown child stretches (one
that stretches takes it all): packed at the start, centred, at the end, or for Between in equal gaps, each at least
setGap's, the first child at the start and the last at the end. GridWidget::setAlign places each cell's content
vertically in its row as a row places its children; a grid's columns share its width, so it has no setJustify.
Bounds. A bound clamps the extent the sizing rule gives, in backend units. Along a stack's axis a stretch
child's share stops at its maximum and the rest goes to the other stretch children; a content or fixed child takes
at least its minimum and at most its maximum; a stack whose minimums do not fit overflows and clips, as a fixed
child does. Across, a child its maximum stops from filling is placed by its alignment, at the start by default: a card sized stretch with
maxWidth: 44 in a 70-unit column is 44 units wide, on both backends.
| Interface | Setters | Handlers |
|---|---|---|
Widget |
setVisible, setEnabled, setLayout(LayoutHints), setDragKey(optional<Key>), setAccessibleName, setAccessibleRole, setTestId, setTooltip, setSurface, focus() |
setDropHandler(accepts, onDrop), setKeys(chords, onChord) (the chord pressed) |
ContainerWidget |
moveChild(child, index) |
— |
FieldWidget (base of Text and the six inputs) |
setReadOnly, setRequired, setErrors(messages), setStale |
— |
TextWidget |
setText, setRole |
— |
ButtonWidget |
setLabel, setEmphasis, setIcon (once, for a button with an icon), setStyle (once, for an item), setSelected |
setOnClick (Enter, Space or a click) |
TextInputWidget |
setText (with the text shown, it keeps the cursor), setPlaceholder, setUnit (the unit shown, muted, at the trailing edge inside the field; single-line and password only), setDecimals (a number's entry places, for a backend or kit to read; single-line and password only), setIcon (an icon of the theme's icon set, drawn at the leading edge; single-line and password only, called once), setCaption(label, shown) (the field's label, and whether the input draws it as a visible caption above its control) |
setOnChange (the whole new text after each user edit), setOnSubmit (Enter in single-line mode), setOnCommit (Enter, before the submit, or focus leaving the field after an edit) |
CheckboxWidget |
setLabel, setChecked |
setOnToggle(bool) |
SelectWidget |
setOptions, setSelected(optional<Key>), setCaption(label, shown) |
setOnSelect(Key), setOnOpen() (optional, see below) |
MenuWidget |
setItems(entries) |
setOnActivate(path) |
StackWidget |
setGap, setAlign(Align), setJustify(Justify) |
— |
GridWidget |
setColumns (at least one), setGap, setSpan(child, span), setAlign(Align) |
— |
SpacerWidget |
— | — |
PanelWidget |
setTitle, setHeader(Widget&) (shown beside the title, called at most once), setPadding, setCollapsible, setCollapsed |
setOnToggle(collapsed), with the state the user asked for |
ScrollWidget, SlotWidget |
— | — |
TabsWidget |
setTabs(labels), setSelected(index) (past the last highlights none), setBarShown(shown) (whether the strip of tab buttons is drawn; the selected page shows either way, and a new tabs widget shows its strip) |
setOnSelect(index) |
DialogWidget |
setOpen, setTitle, footer() |
setOnDismiss (Esc on the TUI), for an open dialog, which the dismissal closes first |
ToastWidget |
show(text, tone) (one call: shows the message and restarts the dismiss interval regardless of whether the toast is already on-screen) |
— |
BusyWidget |
setActive, setLabel |
— |
TableWidget |
setColumns(vector<TableColumn>), setSelectionMode, setRowKey(row, key), setRowTone(row, optional<Tone>) (after setRowKey, and on every change), setSelection(keys) |
setOnSelectionChange(keys), setOnActivate(Key) (Enter or a double click) |
DateTimeInputWidget |
setValue(optional<Timestamp>) (nullopt or an empty Timestamp shows an empty field), setCaption(label, shown) |
setOnChange(optional<Timestamp>) (nullopt for a cleared field) |
SliderWidget |
setRange(minimum, maximum, step), setValue |
setOnChange(value), setOnCommit(value) (a finished move: a release after a drag, a key step, or a wheel step) |
FilePickerWidget |
setPath |
setOnPicked(path) |
BannerWidget |
setTone, setText, setActionLabel, setDismissible (once) |
setOnAction, setOnDismiss |
BadgeWidget |
setTone, setText, setIcon (once) |
— |
ProgressWidget |
setValue(optional<double>), setLabel |
— |
StepsWidget |
setSteps(vector<Step>), setCurrent(optional<size_t>) |
— |
KeyValueWidget |
setItems(vector<KeyValueItem>) |
— |
EmptyStateWidget |
setTitle, setText, setIcon (once), setActionLabel |
setOnAction |
DrawerWidget |
setOpen, setTitle, footer() |
setOnDismiss (Esc on the TUI), for an open drawer |
SplitterWidget |
setSizes(vector<int>) |
setOnResize(sizes) |
CollapsibleWidget |
setTitle, setHeader(child) (at most once), setOpen |
setOnToggle(open), with the state the user asked for |
DropZoneWidget |
setAccept(extensions), setMultiple (both once) |
setOnDrop(paths) |
CustomWidget |
setTextProp(name, text), setBoolProp(name, bool), setNumberProp(name, double) |
setOnEvent(event, payload) |
Both selecting widgets keep what was requested apart from what they mark, so a value and the list it refers to may arrive in either order.
- Select. The widget keeps the key last requested by
setSelectedor chosen by the user, and marks the option with that key whenever the current options contain one. A key outside the options marks none and stays requested: options that bring it back mark it again. - Table. The widget keeps the keys last requested by
setSelectionor selected by the user, and marks the current rows that have one of them. A key with no row marks nothing and stays requested, so a row that arrives with it later is marked, a row that goes is unmarked, and a reorder keeps the marks. The selection mode limits what the user can select, not whatsetSelectionmarks. - A Table shows the user's selection whether or not the application binds
selection. A user's selection replaces the requested keys, andonSelectionChangethen gets exactly the keys of the existing rows now selected, in any mode: a key still waiting for its row is dropped by a user's change, whilesetSelectionkeeps it waiting. A backend may report each step of the user's picking (a click, then a Ctrl-click); the last report is the whole selection, and no report names a key without a row.
A widget with a drag key can be dragged; a widget with a drop handler is a target. While dragging,
the backend asks the target's accepts(key) before highlighting it and before a drop. A drop it
refuses delivers nothing; an accepted one calls onDrop(key) once. The mount always passes a
predicate — an accept-everything one when the node's accepts is empty — so a backend never sees
an empty one. A source or target the user cannot reach (hidden, disabled, or inside such a
container) takes part in no drag.
The headless reference backend and the test double for everything above the contract.
-
Ids and kinds. Widget ids are creation numbers from 1. A stack's kind is
ColumnorRowby its axis; every other kind is the interface name withoutWidget(Text,Slot,Table, …). -
Operation log (
log(),clearLog()), one line per operation, oldest first:create Kind#id in Parent#id(in rootfor a root),set Kind#id name=value,move Kind#id to n,destroy Kind#id. Factory parameters are recorded as properties without a log line. Handlers are stored without a log line, except thatsetDropHandlerrecords the propertydrop=handlerandsetKeysthe propertykeys, the chords joined by commas.focus()logsfocus Kind#id,setOnHoverkeeps its handler forhover(id)andsetOnOpenforopen(id)without a log line, andfocused()returns the id of the widget that last took focus, or 0. What a widget changes by itself — an interaction helper's effect, a Select or Table re-marking its selection — changes a property without a log line. -
Golden dump (
dump()): one line per live widget,Kind#id name=value …, properties in name order, children indented two spaces per depth, every line ending in a newline. A child whose parent was destroyed first becomes a root. -
Properties. Every kind records
visible,enabled,layout(aswidth/height, each followed bymin=<n>andmax=<n>for its bounds, thenself=<align>for analignSelf),dragKey,drop,a11yName,a11yRole,testId,tooltip,surfaceandkeyswhen their setters run; text and the six inputs recordreadonly,required,errorsandstale; and each kind its own:text,role(Text);label,emphasis,icon,style,selected(Button);mode,text,placeholder(TextInput);label,checked(Checkbox);style,options,selected(Select, whereselectedis the option it marks, elsenone);items(Menu, as below);gap,align,justify(Column, Row);columns,gap,align, andspanon a child (Grid);title,padding,collapsible,collapsed(Panel);axis(Scroll);tabs,selected,barShown(Tabs);open,title(Dialog);active,label(Busy);columns,selectionMode,selection(the requested keys it marks, in the order requested), androwKeyon a row (Table);mode,offset,value(DateTimeInput);range,value(Slider);mode,path(FilePicker);tone,text,actionLabel,dismissible(Banner);tone,text,icon(Badge);value,label(Progress);steps,current(Steps);items(KeyValue);title,text,icon,actionLabel(EmptyState);side,open,title(Drawer);axis,sizes(Splitter);title,header(the header child's id),open(Collapsible);accept,multiple(DropZone);component,minVersionand each prop asprop.<name>(Custom). -
Components.
createCustomloads only the components named byloadsComponents(names), none until it is called, so a custom node shows its fallback by default. A loaded one is aCustomwidget, andraiseEvent(id, event, payload)raises its event as the component would. -
Values.
true/false; an enumerator by its name; a key as a decimal integer or a double-quoted string (7and"7"differ);nonefor an absent key or date; a date as an ISO-8601 UTC instant with milliseconds,emptyfor an emptyTimestamp; sizing ascontent,fixed(n)orstretch(n), a layout aswidth/height; lists as[a,b]; options as[key:label,…]; columns as[label:sizing,…]; a slider's range asminimum..maximum/step; a fraction in its shortest decimal form (0.25); steps as[label:State,…]; key-value pairs as[label:value,…]; sizes as[n,…]; a menu as[label;attr=value…,…], where an entry's attributes follow its label only when they differ from the default (;icon=,;keys=,;checked=trueorfalse,;enabled=false) and its submenu follows in brackets:[File[Open;keys=Ctrl+O,Recent[a.txt]],Wrap;checked=true]. -
Escaping. Free text is escaped so that every value stays on its line and reads as one field: a line feed becomes
\n, a carriage return\r, and a backslash or=is preceded by a backslash. Text inside a list also escapes,,[and], text inside a menu also;, and a string key also escapes". -
Lookups.
find(kind, name, value)is the first live widget, by id, of that kind whose property equalsvalue, compared against the escaped form;all(kind)lists them in creation order;prop(id, name)is the escaped value, or empty when never set, andhasProp(id, name)tells the two apart;exists(id);idOf(widget),kindOf(id),children(id)andwidget(id).prop,hasProp,kindOf,childrenandwidgetthrowstd::out_of_rangefor an id that names no live widget, whereexistsreturns false;idOfthrows it for a widget that is not one of this backend's live widgets. -
Interaction helpers act as the user would:
click(a button, or the action button of a banner or empty state),edit,submit(Enter: the commit, then the submit),commit(focus leaving: the text the field shows),moveCursor,toggle,choose,chooseIndex(a menu's top-level entry or a tab),chooseMenuEntry(id, path),collapse,expand(id, open)(a collapsible section),dismiss(a dialog, a drawer or a dismissible banner),resize(id, sizes),dropFiles(id, paths)(true when the zone took the drop),selectRows,activateRow,setDateTime,slide,pickanddrag. A helper first changes what the native widget would change by itself — the field's text and cursor, the box's check mark, the marked option or rows, the highlighted tab, the collapsed or open state, a dismissed dialog's or drawer'sopen, the pane sizes, the value or path — and then calls the stored handler. It calls a copy of the handler, so a handler may destroy its own widget.- A widget the user cannot reach ignores every helper: one that is hidden or disabled, or inside a
container that is hidden, disabled or collapsed, or a closed dialog or anything inside one.
activateRowasks that of the row,dragof both widgets. - A read-only field also ignores
edit,submit,commit,toggle,choose,setDateTime,slideandpick. chooseMenuEntryandchooseIndexon a menu run nothing for a path that names no entry, an entry with a submenu, or an entry that is disabled or inside a disabled one.press(id, chord)presses a chord with focus on widgetid: the innermost widget fromidoutwards that declares the chord receives it, andpressreturns whether one did. It changes no recorded focus.- A helper used on a kind it does not apply to throws
std::logic_error, and so does an action no user could take: choosing a key the Select does not offer, collapsing a panel that is not collapsible, selecting a key with no row, a row twice, any row in aNonetable or more than one in aSingleone, activating a key with no row, dragging a widget onto itself, clicking a banner or empty state that shows no action button, dismissing a banner that is not dismissible, resizing with a size count other than the pane count, dropping no files. That is a mistake in the test. drag(source, target)returns true when the source has a drag key, the target a drop handler, and the target's predicate accepted the key; thenonDrophas run. An empty predicate accepts nothing here, so a drop that lands on a mounted target withoutacceptsshows the mount's accept-everything default at work.
- A widget the user cannot reach ignores every helper: one that is hidden or disabled, or inside a
container that is hidden, disabled or collapsed, or a closed dialog or anything inside one.
-
Text cursor. A TextInput keeps a cursor, a byte offset into its text, read with
cursor(id).editandsubmitleave it at the end of the new text,moveCursor(id, position)moves it (clamped to the text),setTextwith the text the field shows leaves it alone, andsetTextwith another text puts it at the end.
tests/test_ui_recording_backend.cpp tests the log, the dump, the lookups and the helpers.
conformanceCases() returns every case, in a fixed order, as ConformanceCase{name, run}; each
run takes a ConformanceProbe and returns nullopt for a pass or the failure message. A
backend's test suite runs every case on a fresh probe. RecordingBackend passes them in
tests/test_ui_conformance.cpp.
layoutConformanceCases() are the cases of where a backend that lays widgets out draws them, read through the
probe's boundsOf(widget): the widget's bounds in the backend's own units (a cell on the terminal, the theme's unit
width and height on Qt Quick), a WidgetBounds{x, y, width, height} from the top left of the window or screen,
compared within one unit. A probe that reads no geometry answers none, and every layout case run on it fails rather
than passing unmeasured; RecordingBackend lays nothing out and runs only conformanceCases(). Each case mounts
the container it measures inside another, since a root fills the window or screen whatever its own sizing:
- Along a row, a
stretchchild's share stops at its maximum and itsstretchsibling takes the rest. - Along a row, a content child is at least its minimum wide.
- Across a column, a
stretchchild stops at its maximum, at the start. - Down a column, a
stretchchild's height stops at its maximum and its sibling takes the rest. - A row 3 units tall with
align: centerplaces a one-unit text one unit down. - A column with
justify: endplaces its last child's end at its own end; withbetween, its first child at the start and its last at the end. - A
stretchchild capped at 20 in a 70-unit column withalignSelf: center, and one capped at 44 under the column'salign: center, each have equal space either side.
A backend author implements one ConformanceProbe. Its driving operations act as a user would,
through the backend's own input path where it has one, and each may run pending work before and
after it; its reading operations report what the widget shows now.
| Operation | Meaning |
|---|---|
backend() |
The backend under test |
runtime() |
The runtime the cases' signals and mounts belong to |
settle() |
Runs posted flushes and pending native events until nothing is left |
textOf(widget) |
A Text's or TextInput's text, a Button's or Checkbox's label, the label of the option a Select marks (empty when it marks none) |
visibleOf(widget) |
The widget's own flag as its last setVisible left it: a hidden ancestor, a collapsed panel or a closed dialog around it does not make it false |
enabledOf(widget) |
The widget's own flag as its last setEnabled left it |
childCount(container) |
How many children a container has |
childAt(container, index) |
A child, or null past the end; a case drives it as it drives a root |
click(widget) |
Activates a button |
type(widget, text) |
Replaces a field's text as typing would: the field is cleared first, so it ends up holding exactly text; onChange may see intermediate texts |
drag(source, target) |
Drags one widget onto another |
dismiss(dialog) |
Dismisses a dialog or drawer (Esc on the TUI), or a dismissible banner |
selectRows(table, rows) |
Selects rows by position, in the order the user picks them — at least one, and only one in a Single table — so the user's selection is exactly those rows |
selectedRows(table) |
The positions of the rows the table shows as selected, ascending |
moveCursor(field, position) |
Moves a text field's cursor as the arrow keys or a click would, without changing the text |
cursorOf(field) |
Where a text field's cursor is, as a byte offset into its text |
press(focused, chord) |
Presses a chord with focus on a widget, moving focus there first if the probe must |
hasFocus(widget) |
Whether the widget has keyboard focus |
chooseMenuEntry(menu, path) |
Chooses a menu entry by its path, opening each submenu on the way |
expand(section, open) |
Opens or closes a collapsible section with its own control |
dropFiles(zone, paths) |
Drops files from the operating system onto a drop zone |
boundsOf(widget) |
Where the widget is drawn, in the backend's units, for the layout cases; none by default, for a probe that reads no geometry |
A case reaches widgets only through Mounted::root(), childAt and further roots it mounts, or
through widgets it makes with backend() itself, so a probe needs no lookup by name.
Each case is named by the rule it checks:
- A Text shows its constant text.
- A bound Text updates once per batch, and not for an equal write.
visibleandenabledfollow their bindings.- A click runs the button's action once, and what it wrote reaches the screen.
- Typing reaches
onChange, and a value set by the application is shown and not echoed. - A Switch shows the selected case, and nothing for a key without one.
- Tabs mount a page on its first selection, keep it, and show only the selected one.
- A ForEach updates a kept row in place: the same widget, its view not built again.
- A ForEach follows inserts, removals and reorders.
- A moved widget keeps its native state: the text a user typed into it.
- A Dialog holds its content only while open.
- A new Dialog is closed, and a closed Dialog takes no input — neither a dismissal nor a click on a child.
- A widget inside a hidden, disabled or collapsed container takes no click, typing, drag or row selection, with each gate at least two levels above the widget, and takes them again once the gate is lifted.
- A setter given the text a field already shows keeps the cursor where the user left it.
- A text field shows what the application makes of an edit: a refused edit snaps back, an upper-cased one shows the upper case.
- A dismissal the application refuses leaves the dialog open, with its content reachable.
- No setter calls a handler: values, options, menu items, rows, selections, tab indices, opening and collapsing, including a Select re-resolving its request after new options and a Table re-marking rows that arrive or go.
- A Select marks the requested key whenever its options contain it, whichever arrives first, and remembers it across options that lack it.
- A Table in
Multiplemode reports a user's change as exactly the existing rows selected; the application writes it back to the selection slot, as a controlled table needs. - A Table shows a user's selection with no selection bound.
- A hidden Table cell keeps its column in its row.
- No handler runs after its widget was destroyed.
- A handler may destroy its own widget; the backend keeps the running handler alive and stays usable afterwards.
- A drag onto an accepting target delivers the key once.
- A drag onto a refusing target delivers nothing.
- A drop target without
acceptstakes every key. - A chord goes to the innermost widget that declares it, and one only an outer widget declares reaches that widget.
- A chord pressed inside a container hidden two levels up runs nothing; while shown it runs once.
- The first
autofocuswidget in document order has focus once the mount completes. - A menu reports only an enabled entry without a submenu, and an entry enabled later can be chosen.
- Typing into a read-only input reaches no handler and leaves its value; once editable again,
typing reaches
onChange. - Dismissing a dismissible banner reaches
onDismissonce, and the banner stays shown. - A closed collapsible section's content takes no click and its header does; the user's opening
reaches
onToggle, and once the application opens it the content takes clicks. - A drop zone takes a drop of one accepted file, compared without case, and refuses a file it does not accept and several files when it takes one.
The probe drives clicks, typing, chords, menu choices, section toggles, file drops, drags, dismissals and row selection only, so some rules of this contract are each backend's own tests to make:
- that a widget the user cannot reach takes no
choose,toggleor row activation, and that a drag onto an unreachable target delivers nothing; - that a read-only check box, select, date-time input, slider or file picker takes no change, and that a read-only text input takes no submit or commit — rule 31 checks typing only;
- that rules 22 and 23 hold for handler kinds other than a click;
- that a hidden Table cell's neighbours are drawn under their own headers — rule 21 checks the row's structure only;
- that a controlled input other than a text field and a dialog snaps back —
RecordingBackend's own tests drive every kind.
Rule 22 is observable only for a backend whose input can be delivered later than the driving call,
such as a queued connection; a probe whose click delivers before it returns passes it without
exercising it.
| Decision | Why |
|---|---|
| Typed factories, one interface per kind | A backend implements exactly what each node needs; a missing setter is a compile error, not a runtime lookup. |
| Setters never call handlers | Two-way bindings (a field bound to the signal its onChange writes) cannot loop, on any toolkit. |
| A setter with the shown value changes nothing | A controlled input is re-asserted after every edit; a cursor or a composition that jumped on each would make typing impossible. |
| Defaults stated by the contract | The mount skips the setters that would only restate them, which keeps logs and native call counts to what a node actually says. |
| Selection kept as a request, marked against what exists | The value and the list it names come from different bindings, in either order; neither order may lose the selection. |
| The backend keeps a running handler alive | A button that closes its own dialog or removes its own row is ordinary UI, not an edge case. |
| A recording backend as the reference | Every mount behaviour is asserted as a log or a dump without a toolkit, and the real backends are held to the same cases through the probe. |