gbui 0.3
The release that made the toolkit reachable. Everything below the components is
the same three-stage pipeline it was; what changed is that every control now
says what it is, that saying so is a rule rather than a milestone, and that
seven components arrived — including the three the component inventory had named
as the gaps that bite first.
Added
-
splitPane— two panes and a divider the reader can drag, which is the
shape every IDE-shaped application is built from.The share is a percentage basis rather than a
growratio, and finding
out why is the useful part: this layout engine computes its free space from
the hypothetical sizes, which are already clamped to each item's minimum, so
two panes with a 120-pixel floor take their 240 first and split only what is
left — asking for a quarter of 600 got 208 instead of 148, and with large
minimums the fraction stopped meaning anything. A basis ofp%plusshrink
is exact: the overflow the divider causes comes back off each pane in
proportion to its basis, which lands the leading one atp × (width − divider).The minimums are the layout's rather than the drag's, so they hold when the
window shrinks under a split nobody touched. The divider is ARIA's window
splitter — aSeparatorthat takes the keyboard and carries a value — because
a split only draggable with a pointer is a layout most people cannot change. -
treeView— the expandable hierarchy the component inventory calls the
single biggest gap for a git client. Expansion, keyboard walking and
virtualisation, which are each easy and never all three.The data is a flat vector in pre-order with a depth on each row. Flat is
what makes virtualisation possible at all — a slice of a tree is only a slice
if the tree is already a sequence — and it is what a caller usually has from a
git ls-treewalk or a directory listing. Which rows are visible is worked
out here in one pass with a watermark, so a caller that collapses a node
passes exactly the same vector as before.Right opens a closed node and steps into an open one; Left closes an open one
and steps out of a closed one. That pair is the whole of why a tree feels like
a tree. The twisty opens without choosing and the row chooses, because "show
me what is in here" and "I want this one" are two gestures.Each row reports its
leveland its position among its siblings — new
Accessibility::level, ARIA'saria-level— since "item 2 of 5" in a
hierarchy means whose five and "row 340 of 900" is the size of the repository
rather than of the directory. Computed in two linear passes with a counter per
depth, because the obvious version is quadratic on a directory with a thousand
files in it, which is a directory people have.VirtualListOptions::itemRoleis new with it:Role::Nonehands the slot's
semantics to the row callback, so a tree's rows can be counted among their
siblings rather than among the nine hundred the list holds. -
selectfilters, which is thecomboboxthe inventory called the gap that
bites first — a branch picker past about thirty branches is unusable without
type-to-filter. An option rather than a component of its own, for the reason
textInputabsorbed two fields: everything that makes a select a select is
unchanged by typing into it, and the two would be one control described twice.SelectResultgrew afocus, and it is the caller's half of the deal: a
filter box has to hold the keyboard to be typed into, so the control cannot
keep it on the closed box — and a component here never moves focus behind the
caller's back. Same contractlabelandfieldalready have. Not wiring it
leaves a filter that works only once clicked.The highlight stays an index into the caller's list rather than into the
filtered view of it, which is the invariant this is easiest to get wrong. The
match is a case-insensitive substring rather than a fuzzy score, because fuzzy
matching reorders the list under the reader and matches things they cannot see
the reason for. Escape clears the filter before it closes the list; Space
types a space instead of committing, since a combobox that cannot have a space
in its query cannot findfeat/nord tuning; the arrows walk what is on screen
rather than stepping into rows that are not.The filter box carries
controlsandactiveDescendantbecause that is where
the keyboard is; the match count is aStatuslive region; and each row
reports its place in what is shown, since "3 of 40" in a list narrowed to
four is three lies in five words.MenuItemOptionsgrewpositionInSetand
setSizeto carry it. -
carousel— a strip of slides, one screenful at a time, with indicators,
navigators, looping, a fractionalslidesPerPageand autoplay. It moves by
slides rather than by pages even when several are showing, which is the
convention that keeps a four-across gallery usable: "next" is the thing after
the one you are looking at.An autoplaying carousel always draws a pause button, and there is no option
to remove it. WCAG's "pause, stop, hide" is a rule rather than a judgement,
and an option to remove the button would be a switch labelled "make this
inaccessible". Hovering the slides pauses it and so does the keyboard being
inside them — but not reaching for a control, because the first attempt
paused on focus anywhere in the carousel and pressing Play then left focus on
Play and refused to move.Off-screen slides are
hiddenfrom the accessibility tree rather than left in
it: eight slides all present at once turns a control into a list a reader has
to find their way out of. The dots are aTabListwithactiveDescendant,
one of the two patterns ARIA blesses for a carousel. -
gallery— one picture at a time out of a set, with arrows, a caption and
a thumbnail strip that keeps the current one in view. Every picture has a
name: itsalt, its caption, or "Image 3 of 9", because an unnamed picture in
a set of nine is "image, image, image".Zoom, rotate, flip, download and fullscreen are absent, and each for a
reason written into the header rather than left to be discovered: the first
two need a transform on a node that the painter has not got, download needs a
native file dialog and nothing here touches the filesystem, and fullscreen is
a second window. A rotate button that does not rotate is worse than no button. -
compare— two things in the same rectangle with a handle saying how much
of each, which is the shape PrimeVue calls Compare and every before-and-after
on the web is. Both sides are drawn at the full size of the box and one is
revealed over the other, because a comparison laid out side by side is asking
the reader to remember rather than to see.The seam is a percentage, not a measured width, and that is the whole
design: a clip sized from last frame's geometry is a frame late and jumps on
every resize, while a percentage resolves during layout and is right on the
first frame. The content inside the clip is100 / positionpercent of it,
which comes back out to the full width — a layout identity rather than an
arithmetic one. The handle is placed by two flexible spacers for the same
reason, and that also keeps it wholly inside the box at either end.It is a slider and genuinely one — value, range, arrow keys at 2%, Page at
10%, Home and End — rather than PrimeVue's hidden range input beside a div.
The value is announced as"60% Retouched", because "60 percent" alone says
neither how much of what nor revealing what, and both sides stay named in the
tree whatever the handle is doing. -
toast— short-lived messages, stacked in a corner and gone on their own.
The last of the three the component inventory called blocking, and the one
it described as "a queue, a timer and a live region".The queue is
ToastState, owned by the application. That matters more here
than usual: toasts are raised from anywhere — a network reply, a file watcher,
a shortcut three screens away — and a component that owned them would be a
component with a global.The id is the whole of the grouping. Two entries with the same id are one
toast with a count on it, and an empty id is derived from the kind, the title
and the message — so a retry loop reports "still offline ×40" instead of forty
copies of one sentence, which is the failure every application's first toast
queue has.groupis a second and different axis: it routes an entry to an
outlet, so a dialog can report into itself while the application's messages
go to the corner.Placement is six corners, or anywhere at all.
ToastPlacement::Anchored
puts the stack against a tagged node with the same engine a popover uses, and
boundssays which rectangle the corners are measured from, so a stack can
live inside a panel. Which way it grows is never a decision the caller
makes. A bottom stack does not measure itself to find its own bottom either —
the container is the whole column andjustifyputs the toasts at the end of
it, which is right on the first frame where arithmetic on last frame's height
is not.The timer stops while it is being read, which is Toastify's behaviour and
also what WCAG's "enough time" rule asks for: the stack pauses while the
pointer is over a toast or the keyboard is inside it.duration = 0never
expires. Only what is on screen ages, so an entry waiting behindmaxVisible
has not started its clock. The progress bar is drawn only where there is a
time to show, and dims while paused.Each toast is its own live region —
Statusfor info and success,Alertfor
warning and error, because the next thing the reader was about to do will not
work. The stack never takes the keyboard, and each × is named after its
message, since four buttons called "Dismiss" are four buttons nobody can tell
apart. -
An accessibility layer: every control now says what it is. Until this,
aNodecarried a style, a tag and a frame and nothing that said what it
was — a screen reader was handed one blank rectangle where an application
should have been.gbui/a11y/role.hppandaccessibility.hppare stages 1 to
3 of the plan (stage 4 is below): aRoleand a name on every control, the
state and value that
go with it, and the relations that tie a caption to its field and an error to
its input. Set withui.accessible({…})beside thetagand thefocusable
that were already there;ui.role(…)andui.name(…)are the shorthands.The names are ARIA's, which are also AccessKit's, so the platform bridge in
stage 5 is a lookup table rather than a translation with opinions in it. Three
deviate and each says why in the header. There is no role for anything this
toolkit cannot build.Unsetis notFalse. A checkbox that is not checked is announced as "not
checked"; a button, which has no checked state, is announced as a button — so
every state is a four-valuedFlagand a state nobody set stays unsaid. The
value carriestextas well as a number, because a slider that announces "70"
is a slider nobody can use and only the caller knows it means "70 percent".
positionInSet/setSizeexist for one reason: a virtualised list builds only
the rows on screen, and without them a reader walking fifty thousand commits is
told "row 3 of 14" for the rest of their life.The record is not on the
Node. Most nodes have nothing to say, so it
lives in a side table the arena owns and a node names by index — four bytes
each, the full record only where there is one — exactly as vector art already
does.Two relations point the other way,
labelsanddescribes, because the end
that knows is not the end that carries it: a caption is built before the input
it names and afield's error after it, and no component reaches into
another's node.<label for>is the same shape.New options where a component could not otherwise be named:
ButtonOptions::name(for the icon-only button, the classic failure),
TextInputOptions::name,TextareaOptions::name,SelectOptions::name,
SliderOptions::nameandvalueText,ProgressOptions::name,
TableOptions::name,VirtualListOptions::name,ScrollOptions::name,
MarqueeOptions::name,RichEditorOptions::name,ColorPickerOptions::name,
the chart options'name,MenuItemOptions::role,PopoverOptions::roleand
name, andBoxOptions::roleandname. -
gbui_demo --a11y, which audits the call sites. The other half of
tests/accessibilityTest: that one covers the library, and this walks the
accessibility tree of every demo screen and every catalogue example and fails
naming any control that has nothing to announce. It exists because the names
the toolkit cannot invent — an icon-only button, a chart, a table — are the
application's to supply, and nothing but a walk over the real screens can tell
whether anyone did.Host::accessibility()exposes the tree for it, and CI
runs it beside--coverage.It found sixteen on its first run, all in the six demo screens: five
unnamed tables, three charts, six switches and a slider. All fixed — and one
of them was a library gap rather than a call-site one:ToggleOptions,
CheckboxOptionsandRadioOptionshad no way to be named when the words are
drawn beside the control instead of by it, which is exactly how the SCADA
screen lays its pump switches out. They take anamenow, defaulting to the
label. -
The accessibility tree, and a diff of it. Stage 4:
buildAccessibilityTree(arena, root, interaction)reads the records above into
one node per thing a reader can perceive, anddiffAccessibilitysays what
changed. Three jobs. It prunes — every box that exists for layout is
collapsed away and its children re-parented, so a button wrapped in three
containers is one node and not four. It resolves —labelsanddescribes
become thelabelledByanddescribedBythat belong on the control, a control
with no name takes its caption's, and one with neither takes the text inside
it, stopping at anything that is a node of its own so a table is not announced
as every cell it holds. And it diffs, because pushing a whole tree at a
screen reader sixty times a second is how an application becomes unusable
with accessibility turned on.An
AccessibilityIdis a hash of the tag — the identity scheme focus, hit
testing and the animation clock already run on, and the only kind that survives
a tree being rebuilt. Untagged nodes derive one from their parent and their
position. The consequence is the one worth having: an unchanged frame diffs to
nothing, even though every node in the arena is new. Focus is reported
separately, because it moves between two nodes that are otherwise identical.The relations are resolved in two passes, and it has to be two. A caption
is built before the control it names, so a single pass writes the control's
labelledByand then reaches the control and overwrites it with the nothing
the control knows.This is a tested data model, not something a screen reader can read yet.
Stage 5 pushes it through AccessKit, and that is a decision before it is a task
because it would be the library's second dependency. The reference
page lists the rest of what is missing —
including thatmodalstill does not trap focus and the colour picker's
saturation square has no keyboard at all.
Changed
-
The header house style is a gate, not a habit.
tools/generate_meta.pynow fails — and CI with it — when a widget header
opens with no sentence saying what it is, when that opening paragraph runs
past 240 characters, or when an options member has no doc comment.It is in the generator rather than in a linter because the generator is
already the thing that reads every header, and because the output depends
on both: the first paragraph becomes the gallery card and a member's comment
becomes its row in the properties table, so a header that skips either ships a
blank in the documentation. A check that lives beside the harvest cannot drift
from it.The length rule catches one specific failure the roadmap named: a header whose
first line is a design argument ships that argument as its summary. A blank
//line ends the paragraph, which is all a long preamble has to do.The member rule has an exemption, and it is the interesting half. The
words this library uses with one meaning everywhere —width,gap,
disabled,padding,grow, and any compound ending in a dimension such as
cellPadding— need no comment, because documentingfloat widthin forty
structs is forty copies to keep in step and is exactly the restating-the-code
comment the style says to delete. They are listed inSHARED_NAMESwith the
reason. Everything else is specific to its component and has to say what it
is; 35 members did not, and now do. Three of those were a parser artefact
worth fixing anyway —columnLabels,fallingand the candlestick's
categoryAxiswere sharing a comment with the member above them, so the
generated table had them blank. -
Accessibility is now a rule, not a milestone. Rule 7 in
CONTRIBUTING:
every component that is added or changed carries working accessibility in the
same commit, with a case intests/accessibilityTest.cpp. The last case in
that file is the gate — it walks a form and fails on any Tab stop with no role
or nothing to announce — and it found three the first time it ran. -
One
textInputwith anInputType, where there weretextFieldand
numberField. The HTML shape:Text,PasswordandNumberare one box
that draws its content differently and refuses different things, and the two
files that said so separately drifted exactly as two copies do —numberField
held adoubleand no text, so the control in the set that most wants typing
was the one that could not be typed into. It cannot be calledinput: every
call site here names itsInteractionparameter that, and a local hides a
namespace-scope function of the same name completely.Number entry is rewritten, not renamed. The state is a
TextEditState
like every other input's, and while the box has the keyboard the text is the
source of truth —-,1.and empty are all states a number is typed
through, and nothing rewrites the text underneath the caret.result.valueis
clamped even when the text is not (typing500into a box that stops at60
shows500and returns60),result.hasValueis how an empty box says it
has no value, and blur normalises the text from the clamped value. Anything
that could still become a number is accepted; anything else is refused.Two behaviours went with the rewrite, both on purpose: Home and End move the
caret rather than jumping to the bounds, and+/-type rather than step —
a box that takes typing cannot have those keys mean something else. Up, Down,
the wheel and the step buttons still step. Two bugs went with it too: the
stacked spin box drewChevronDownfor both arrows, and a number box sized
to its digits changed width as they were typed, walking the steppers out from
under the pointer clicking them — a number now takes 120 px unless told
otherwise.invalidis new on all three types, and is the control's half of
the statefieldowns the message for.This removes public API:
textField,TextFieldOptions,
TextFieldResult,numberField,NumberFieldOptionsandNumberFieldResult
are gone, andStepperPlacementmoved togbui/widgets/textInput.hpp. A
compiler finds every call site;{.password = true}becomes
{.type = InputType::Password}. -
Every
begin*container is named after what it makes.beginPanelis
panel,beginBoxisbox, and so on throughtoolbar,listRow,
popover,modalandmodalActions;beginScrollisscrollArea, because
scrollis what half the call sites already call theirScrollState. On the
builder,ui.beginisui.scope,ui.beginRow/beginColumnareui.row
andui.column, andui.beginIdsisui.ids. A container and a leaf now
share one naming rule and differ only in what they return —Ui::Scope
againstNodeId— which is the distinction that was worth spelling, and
beginwas never it. This renames public API; the mapping above is the
whole of it, and a compiler finds every call site. -
Documentation: every component has its own page, listed in the sidebar and
found by search, generated from the same metadata the gallery read. Components
declared in one header share one page —colorFieldwithcolorPicker,
scrollAreawithscrollbar,textwithstrongandemphasis— because
the file they are in is the toolkit's own statement that they are one idea.
/componentsis the contents page for them.
Changed
- The component set is grouped by four questions instead of one word.
"Controls" held a checkbox and a date picker as if they were peers. The groups
are now Elements (22 — leaves with a counterpart in HTML, deciding nothing
beyond the theme), Containers (16 — anything whose job is the content
inside it), Overlays (7 — anything that leaves the flow and floats),
Components (8 — the composed editors) and Charts (9). The questions are
asked in that order and the order is the taxonomy: it is whypaneland
toolbarare containers rather than the composed things they plainly are, and
whyselectis an element even though its list is a popover — the question is
asked of the control, not of its menu.elements.hppis the new umbrella;
controls.hppstill compiles and now includes the two that replaced it, so no
existing include breaks.components.hppno longer pulls intext,
button,icon,imageorspacing— includeelements.hppfor those. switchToggleistoggle, andSwitchOptionsisToggleOptions, in
gbui/widgets/toggle.hpp. The component is a switch and the documentation
still calls it one;switchis a C++ keyword and cannot be a function name,
which is the whole of the reason and is now written at the top of the header
so nobody rediscovers it. Fluent and Carbon landed on the same word for the
same reason. This renames public API; a compiler finds every call site.- Documentation: the three pickers share one page. A date, a time and a
date-and-time are the same control with different amounts of it, and a reader
who lands on one wants the other two under it — the reasoning that already put
colorFieldbesidecolorPicker, except these could not share a header
without breaking "one component, one header". A page may now gather several
headers, and it lists all of them rather than the first.
Added
textarea, the multi-line plain text box — the gap betweentextInput
andrichEditor, and a wide one: a commit message, a description, a note.
Return takes a newline and Ctrl+Return (Cmd+Return on macOS) submits, which is
what every composer does.rowsis a floor andmaxRowsa ceiling, so a box
can start small and open up as the writing goes on before it scrolls; the view
follows the caret and nothing else moves it. Lines are the ones a\nmakes —
Up, Down, Home and End move by hard line, not by the line a box happens to
wrap to, the same limitrichEditorhas and for the same reason. The editing
model gained amultilinemode rather than a second copy of itself, and a
pasted paragraph now keeps its line breaks instead of arriving as one line.field, the wrapper every form writes by hand: a caption, the control,
and a line underneath that is either guidance or a complaint — never both, as
advice and a complaint in the same place is two things asking to be read
first. It is also where the accessibility relations will attach, since a label
is only a label because it is for something and something has to know both
ends.- A tag now publishes a release.
Fixed
- A modal did not trap focus. Tab walked straight out of the back of the
dialog and into the page the backdrop says cannot be used, with nothing on
screen saying where the keyboard had gone.Node::trapsFocusand
Ui::trapsFocus()say a subtree confines Tab;Interactionresolves it,
because Tab is resolved there and nowhere else and because a component cannot
see the tree it is in. Focus moves inside on the frame the dialog appears and
returns to whatever opened it on the frame it stops being built. Nested
dialogs work: the innermost tagged trap wins. - The colour picker could only be used with a pointer. The square had no
Tab stop and no keys at all, which is a worse gap than a missing role — a
role at least says the control is there. The square and both rails are Tab
stops now: Left and Right move along, Up and Down are the square's second
axis, Home and End go to the ends of the axis the key belongs to, and Shift
is ten times the step. Each draws a focus ring, and the square's description
says so, because nothing about aGroupimplies it answers the arrows. - A button was never a Tab stop. It had not been since focus was built:
activatedgives every control Space and Return once it has the keyboard, and
nothing could ever give the keyboard to a button — so the most ordinary control
in the set was reachable by pointer alone. A tagged, enabled button is now
focusable. An untagged one still is not, which is the contract
Node::focusablestates and not a second gap. - A press focused whatever node it landed on, rather than the control. A
control is rarely one node — a textarea is a box around a scroll view around a
column of runs — and a click on the text resolved to a tag the scroll view
invented. The keyboard went there, no key handler was listening, and typing
did nothing. The press now walks up to the nearest focusable ancestor, which
is what the browser does when you click the text inside a<textarea>.
Clicking nothing focusable still clears focus. Until now a tag was the whole of a release:
CI built it and nothing else happened, so everyreleases/tag/v…link in this
file and the documentation's "Release notes" entry led to an empty page.
release.ymlpublishes the GitHub release, with that version's section of
this changelog as its body — read, not rewritten, because notes typed a second
time start drifting the day they are made.tools/release_notes.shprints the
same text locally. It also checks the tag against theVERSIONin
CMakeLists.txtat that tag, which is whatrelease: 0.3— tagged, then
reverted — had no way of failing on. Releases for 0.2 and 0.2.1 have been
published from the sections already written here.
Fixed
- A release cancelled its own CI run. The concurrency group was the commit,
and cutting a release pushes one commit twice — once as a tag, once as a
branch update. The second push cancelled the first while it was still queued,
and the run it took with it was the tag's: the one that says the released
artefact builds. The group is the ref and the commit now. - A version could be advertised before its tag existed.
build_docs.sh
prints "no tag for 0.2, skipping" and carries on, so a version listed in
archivedtoo early became a live dropdown entry leading nowhere and the
deploy still went green. CI checks the list against the tags, on the pull
request rather than after publication. - An archived version is built from the newest tag in its line, so
/v0.2/
says what 0.2.1 says rather than what 0.2 said — 0.2.1 is what a reader of
/v0.2/would install. The documentation job also runs the node that can read
versions.ts; on the older one the archived list came back empty instead of
failing.