Skip to content

Latest commit

 

History

History
486 lines (440 loc) · 39.2 KB

backend_contract.md

File metadata and controls

486 lines (440 loc) · 39.2 KB

Backend contract — design

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.

Contents

Widgets

  • IViewBackend has one factory per kind. Each makes the widget, appends it as the last child of parent — or makes a root when parent is 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 a CustomWidget, whose props are set by name (setTextProp, setBoolProp, setNumberProp) and whose events go to the handler given to setOnEvent.
  • 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 Widget setters for those defaults (setVisible, setEnabled, setLayout, setDragKey, setDropHandler, setAccessibleName, setAccessibleRole, setTestId, setTooltip, setSurface, setKeys) and the FieldWidget setters (setReadOnly, setRequired, setErrors, setStale) only to change them; it calls a dialog's setOpen at mount whatever the value, false included.
  • Focus. focus() moves keyboard focus to the widget. The mount calls it on the first autofocus widget in document order once a mount completes (view tree).
  • Hover. setOnHover(onHover) calls onHover each 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 through MorphWidget's HoverHandler when a handler is set.
  • Opening a select. SelectWidget::setOnOpen(onOpen) calls onOpen each 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, and onChord receives 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 onChange the 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. setItems replaces the whole tree of MenuEntry{label, icon, keys, checked, enabled, items}. keys is only shown beside the label; checked engaged shows a check mark that only setItems moves; an entry with items opens its submenu. onActivate receives 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 before onDismiss runs; 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, onSelectionChange or any other handler. That covers what a widget does by itself in response to a setter: a Select re-marking its requested key after setOptions, 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 onDismiss and 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 multiple refuses a drop of several files. A taken drop reaches onDrop with the paths in the order dropped.
  • Splitters. setSizes with an empty list, or with one whose length differs from the number of panes, shares the space equally. A user's drag reaches onResize with 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

  • Handlers are handed over once (setOnClick, setOnChange, …); an empty one does nothing. The mount wraps each in Runtime::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 RecordingBackend does. A backend that calls the stored std::function in 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 accepts predicate included: the mount catches and reports what one throws (view_tree.md, "Callbacks").

Containers

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.

Interfaces

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)

Selection by key

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 setSelected or 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 setSelection or 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 what setSelection marks.
  • A Table shows the user's selection whether or not the application binds selection. A user's selection replaces the requested keys, and onSelectionChange then 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, while setSelection keeps 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.

Drag and drop

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.

RecordingBackend

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 Column or Row by its axis; every other kind is the interface name without Widget (Text, Slot, Table, …).

  • Operation log (log(), clearLog()), one line per operation, oldest first: create Kind#id in Parent#id (in root for 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 that setDropHandler records the property drop=handler and setKeys the property keys, the chords joined by commas. focus() logs focus Kind#id, setOnHover keeps its handler for hover(id) and setOnOpen for open(id) without a log line, and focused() 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 (as width/height, each followed by min=<n> and max=<n> for its bounds, then self=<align> for an alignSelf), dragKey, drop, a11yName, a11yRole, testId, tooltip, surface and keys when their setters run; text and the six inputs record readonly, required, errors and stale; 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, where selected is the option it marks, else none); items (Menu, as below); gap, align, justify (Column, Row); columns, gap, align, and span on 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), and rowKey on 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, minVersion and each prop as prop.<name> (Custom).

  • Components. createCustom loads only the components named by loadsComponents(names), none until it is called, so a custom node shows its fallback by default. A loaded one is a Custom widget, and raiseEvent(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 (7 and "7" differ); none for an absent key or date; a date as an ISO-8601 UTC instant with milliseconds, empty for an empty Timestamp; sizing as content, fixed(n) or stretch(n), a layout as width/height; lists as [a,b]; options as [key:label,…]; columns as [label:sizing,…]; a slider's range as minimum..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=true or false, ;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 equals value, compared against the escaped form; all(kind) lists them in creation order; prop(id, name) is the escaped value, or empty when never set, and hasProp(id, name) tells the two apart; exists(id); idOf(widget), kindOf(id), children(id) and widget(id). prop, hasProp, kindOf, children and widget throw std::out_of_range for an id that names no live widget, where exists returns false; idOf throws 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, pick and drag. 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's open, 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. activateRow asks that of the row, drag of both widgets.
    • A read-only field also ignores edit, submit, commit, toggle, choose, setDateTime, slide and pick.
    • chooseMenuEntry and chooseIndex on 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 widget id: the innermost widget from id outwards that declares the chord receives it, and press returns 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 a None table or more than one in a Single one, 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; then onDrop has run. An empty predicate accepts nothing here, so a drop that lands on a mounted target without accepts shows the mount's accept-everything default at work.
  • Text cursor. A TextInput keeps a cursor, a byte offset into its text, read with cursor(id). edit and submit leave it at the end of the new text, moveCursor(id, position) moves it (clamped to the text), setText with the text the field shows leaves it alone, and setText with another text puts it at the end.

tests/test_ui_recording_backend.cpp tests the log, the dump, the lookups and the helpers.

The conformance suite

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:

  1. Along a row, a stretch child's share stops at its maximum and its stretch sibling takes the rest.
  2. Along a row, a content child is at least its minimum wide.
  3. Across a column, a stretch child stops at its maximum, at the start.
  4. Down a column, a stretch child's height stops at its maximum and its sibling takes the rest.
  5. A row 3 units tall with align: center places a one-unit text one unit down.
  6. A column with justify: end places its last child's end at its own end; with between, its first child at the start and its last at the end.
  7. A stretch child capped at 20 in a 70-unit column with alignSelf: center, and one capped at 44 under the column's align: center, each have equal space either side.

The probe

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.

The cases

Each case is named by the rule it checks:

  1. A Text shows its constant text.
  2. A bound Text updates once per batch, and not for an equal write.
  3. visible and enabled follow their bindings.
  4. A click runs the button's action once, and what it wrote reaches the screen.
  5. Typing reaches onChange, and a value set by the application is shown and not echoed.
  6. A Switch shows the selected case, and nothing for a key without one.
  7. Tabs mount a page on its first selection, keep it, and show only the selected one.
  8. A ForEach updates a kept row in place: the same widget, its view not built again.
  9. A ForEach follows inserts, removals and reorders.
  10. A moved widget keeps its native state: the text a user typed into it.
  11. A Dialog holds its content only while open.
  12. A new Dialog is closed, and a closed Dialog takes no input — neither a dismissal nor a click on a child.
  13. 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.
  14. A setter given the text a field already shows keeps the cursor where the user left it.
  15. A text field shows what the application makes of an edit: a refused edit snaps back, an upper-cased one shows the upper case.
  16. A dismissal the application refuses leaves the dialog open, with its content reachable.
  17. 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.
  18. A Select marks the requested key whenever its options contain it, whichever arrives first, and remembers it across options that lack it.
  19. A Table in Multiple mode 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.
  20. A Table shows a user's selection with no selection bound.
  21. A hidden Table cell keeps its column in its row.
  22. No handler runs after its widget was destroyed.
  23. A handler may destroy its own widget; the backend keeps the running handler alive and stays usable afterwards.
  24. A drag onto an accepting target delivers the key once.
  25. A drag onto a refusing target delivers nothing.
  26. A drop target without accepts takes every key.
  27. A chord goes to the innermost widget that declares it, and one only an outer widget declares reaches that widget.
  28. A chord pressed inside a container hidden two levels up runs nothing; while shown it runs once.
  29. The first autofocus widget in document order has focus once the mount completes.
  30. A menu reports only an enabled entry without a submenu, and an entry enabled later can be chosen.
  31. Typing into a read-only input reaches no handler and leaves its value; once editable again, typing reaches onChange.
  32. Dismissing a dismissible banner reaches onDismiss once, and the banner stays shown.
  33. 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.
  34. 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.

What the probe cannot reach

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, toggle or 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.

Design decisions

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.