0.15.1

Pre-1.0: minor releases may introduce breaking changes. Pin your tested version. How to pin it →

News

Release notes from the Teksilo changelog.

Teksilo is pre-1.0. Review breaking changes before upgrading.

Version 0.15.1 2026-10-04

Added

Widgets
  • Esperanto (eo) translations for all 313 framework messages, available through teksilo_widgets::framework_locales().
  • NotificationArchiveModel::push_update, which archives an in-place update of a notice still on screen, beside push, which archives a notice raised. UPDATE_HISTORY_LIMIT is how many update records a row keeps.

Changed

Widgets
  • NotificationArchiveModel::push with the dedup_id of an archived row now takes the entry as that notice raised again: the row moves to the top, takes the entry’s time and read state, and records the occurrence even when nothing it says has changed. An app that pushed to the archive itself to update a notice still on screen calls push_update instead, which updates the row where it is.

Fixed

Core
  • A drag preview’s resources outlived the drag. When a drag ended, the preview widget was removed from the tree without the teardown every other removal goes through, so whatever it had registered (animations, animated indicators, bindings, subscriptions, shortcuts) stayed registered until the window closed. The preview is now torn down like any other widget.
  • A widget changed while out of view could come back showing its old content. A widget marked for repaint while it was clipped out of a scroll area or under a fully transparent ancestor (or itself fully transparent) lost the mark without repainting, and if it came back into view without moving it replayed the paint it had before the change. Such a widget now drops its stale paint and repaints when it is next visible; a widget that did not change keeps its cached paint as before.
  • An accordion opened in a window without focus stayed closed. The animation scheduler paused every animation of a window that was unfocused or occluded, one-shot tweens included, so a Collapse opened there (by an assistive technology, an automation client, or the app itself in a window behind another) held its body at no height while its header already said it was expanded, in a scrolling dialog as anywhere else. Only looping animations are paused now; a tween runs to its end, as it already did for a widget off screen. A loop started while its window had no focus also starts when the window gets it, rather than later by however long the window had been without focus before the loop existed.
Render
  • Clipping was misplaced at fractional scales and lost around blurs. A clip inside a translated scope (a panned scene, a moved transform wrapper) landed at the wrong place whenever the display scale was not 1, because the translation was scaled twice. Content drawn after a blur inside a clipped region (a scroll area, for instance) was not clipped at all, since the blur restarted drawing without restoring the clip. Both now clip where the content is, and a clip opened outside a blur no longer reaches the blur’s own offscreen drawing.
  • Blur used the wrong kernel width. Every pass of every blur in a frame read the kernel offset written for the frame’s last blur pass, because the per-pass parameters were rewritten into one buffer that the GPU reads only once the whole frame runs. A blur therefore came out softer or sharper than its radius asked for, and changed when another blur of a different size appeared on screen. Each pass now reads its own parameters.
Widgets
  • An image or raster icon could show another image’s pixels. ImageWidget::new and IconWidget::from_raster / from_animated named their texture after the icon’s memory address, so an icon created where a dropped one had lived reused the old texture. They now name it after the icon’s identity, which a clone shares. An IconWidget shown in both Tintable and FullColor mode also shared one texture between the two and drew both from whichever registered first; each mode now has its own. As before, these textures last as long as the window: an icon decoded afresh for every widget gets a new texture each time.
  • The notification log kept the first notice’s actions for a notice updated in place. An archived row merged from a later notice with the same id took only its title and body, so a progress notice offering Cancel that became a result offering Open now and See report stayed a Cancel row, under its first severity, and the log never offered the report. The row now takes the latest notice’s actions, replay names included, its severity, priority and audience; an update offering no actions leaves the row with none, as it leaves the live notice. The row keeps its id, its place in the log and the time the notice was raised, and its group and source unless the update names them. Updating a row nobody had read no longer adds to the unread count, which grew by one per update and stayed up under a bell scoped to a window or an audience.
  • A notice raised again was filed under the first one. A toast raised with the id of an archived notice that had already left the screen merged into that row like an update of a live toast: the row stayed where it was, dated when the first notice was raised, below every notice raised since. An import run on Friday under the same id as Monday’s showed its result in the log under Monday. The row now comes back to the top, unread, dated when the notice came back, with a record of the occurrence even when it says the same as before.
  • A progress notice grew its archive row without bound. Every in-place update appended a record to the row, written to the archive file, so a notice reporting each step of a long import gathered thousands, and an id reused for every run of an operation gathered them from every run. An update that repeats the row exactly now records nothing, and a row keeps only its 20 most recent records.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.15.0…v0.15.1

v0.15.1

Version 0.15.0 2026-10-04

Added

  • ChartSource and direct ChartWindow support in line, bar and pie charts and their legends, borrowing visible points and tracking window changes.
  • cargo teksilo search --path PREFIX to restrict documentation search before lexical and semantic results are combined.
  • The application guide in the bundled documentation search index.
  • A runnable live dashboard example with a worker event source, bounded history and tests of visible bindings.

Changed

  • Chart constructors (LineChart::new, BarChart::new, PieChart::new and ChartLegend::new) now accept impl Into<ChartSource<T>> instead of ChartModel<T>. Explicit ChartModel arguments continue to work. Calls that rely on the constructor to infer the argument type may need an explicit type:

    // Before
    LineChart::<String>::new(Default::default());
    // After
    LineChart::<String>::new(ChartModel::<String>::default());

Fixed

  • Stop dispatching the key presses winit makes up for keys held as a window gains focus on Windows and X11. Enter that answered a native modal on its press reached the parent window a second time once it took focus back, so a list behind a confirmation opened the row the cursor had moved to.

  • Resolve public unsafe traits by name in cargo teksilo symbol, including cross-crate lookup when the module name differs from the trait name.

  • Limit named-type heading boosts in documentation search so results relevant to the full query can outrank basic examples that only name the type.

  • Accept the application guide’s source path in corpus validation while retaining checks for relative paths and existing source files.

  • Update the app guide and bundled skill reference for the 0.14 API, including AccessKit re-exports and derived sample signals. Document categorical chart axes, history retention and scrolling limits. Refresh corpus vectors and cover common chart and scrolling queries.

  • Include public trait contracts, associated types and constants, and required and default methods in cargo teksilo symbol output.

  • Dispatch intents emitted by app callbacks, including window close guards, before returning to the event loop. A vetoed close that requests a confirmation dialog no longer waits for another key or pointer event to show that dialog.

  • Restored the generated gesture arbitration table and refreshed the search corpus after documentation edits changed its no-winner markers.

  • Removed an unnecessary borrow flagged by Clippy on Rust 1.99.

  • Restored crates.io sources and checksums in the release lockfile so clean checkouts can resolve dependencies with --locked.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.14.3…v0.15.0

v0.15.0

Version 0.14.3 2026-10-03

Changed

  • Updated the text-document dependency to 1.12.5.

  • Breaking: cargo-teksilo CLI. Removed setup, bare probe, and version. Use init, probe install, and --version (tool version) or status (resolved app version). User installation is now agent install <agents...> --user, which installs instructions only.

  • Explicit agent selection. agent install <agents...> and repeated init --agent options create the selected agents’ configuration even when no detection marker exists. Without explicit targets, init uses detection. agent list reports supported targets and installation state.

  • Explicit model download. model fetch replaces automatic downloads and the --no-model flag. Initialization never downloads weights; search loads complete local model files or falls back to BM25.

  • Concise CLI output. Short summaries and actionable errors replace lengthy explanations. --verbose adds diagnostics; --quiet suppresses informational messages without truncating API or document contents. Search, status, and agent listings support --json.

  • Updated command examples, migration documentation, the repository skill, and the embedded skill and agent instructions installed by cargo-teksilo.

  • Expanded onboarding and shortened user guides.

  • Moved engineering records out of the published docs and search corpus.

  • Removed obsolete chapters and corrected documentation claims.

  • Added README synchronization and documentation checks.

Fixed

cargo-teksilo
  • Resolve the selected app’s dependency graph instead of selecting another workspace member’s Teksilo version. Reject ambiguous framework dependencies.
  • Store probe provenance in workspace metadata for virtual workspaces, avoiding an invalid, incomplete [package] table.
  • Preserve conflicting probe files on first installation, even without a checksum manifest. Replacing modified generated instructions or harness files requires --force; shared-file text outside managed regions is retained.
  • Allow symbol lookup in applications depending only on non-widget crates, including the extractor’s cross-crate lookup without teksilo-widgets.
  • Refresh the bundled documentation corpus and its semantic embeddings.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.14.2…v0.14.3

v0.14.3

Version 0.14.2 2026-09-28

Changed

  • text-document 1.12.4.

Fixed

Widgets
  • Dragging a rich text selection that held a table dropped it a character early. A selection from inside a paragraph to the start of the paragraph after a table holds the table whole and leaves the two paragraphs apart, so its removal takes one position fewer than the selection spans. The editor moved the drop point back by the whole span, and the passage landed one character before it: inside the word there, or in front of a paragraph’s full stop, which was left in a paragraph of its own after the table. The drop point is now held by a cursor of its own, which the document moves across the removal, and the move is one edit, so one undo takes it back where it took two.
  • Dragging a selection of table cells moved a copy of the table and left the emptied grid behind. A selection from one cell to another copies as a table of those cells, but its removal only empties them. Dropped back into the editor it came from, such a drag now does nothing: no caret shows a landing place while it is over that editor, and the cells stay selected. The editor has no modifier that makes a drag a copy. Dropped into another editor, the cells are copied as before.
  • Shift and an arrow over a selection holding a whole table turned it into a range of the table’s cells. The text before and after the table left the selection at the first press. Shift with an arrow now moves the selection’s end as text, so the table stays whole or is given back, also when the table’s last cell is empty.
  • Shift with Up, Page Up or Home could not give back a table the selection had taken. A selection that runs into a table holds it whole, its moving end at the table’s far edge. A step back into the table with Shift and Up, Down, Page Up, Page Down or Home landed on that same edge again, so the selection could only grow. The step now gives the table back and takes the end to the table’s other side, as Shift with Left or Right does since text-document 1.12.4.
  • Ctrl+A in a table cell selected the cell, then the table, and showed neither. The second and third presses make cell selections, which the editor never handed to the paint, so both looked like the first press, which selects the cell’s paragraph. Cell selections, from Ctrl+A, from Shift and an arrow at a cell’s edge, or from a drag from one cell to another, are now painted cell by cell, and a selection running across tables paints the cells of every one of them whole instead of drawing each row’s highlight on over the next column. A table inside a quotation is still painted as before: the typesetter draws the cells of the main text’s tables only.
  • Shift and an arrow over a rectangle of a large table’s cells took a moment per press. Each press looked every selected cell up among all of the table’s cells: once Ctrl+A had selected a whole table, a press took about 80 milliseconds over a table of a thousand cells and two seconds over one of five thousand. The editor now reads the table’s size once.
  • Tab, Shift+Tab and Enter did nothing in a table inside a quotation. The editor looked the table up among the main text’s own paragraphs and tables, where a table in a quotation is not. It now reads into quotations however deeply they nest, so Tab and Shift+Tab move between the cells, Tab in the last cell adds a row and enters it, and Enter moves down a column or, on the last row, to the paragraph that follows the table, in the quotation or after it.
  • An input method composing over a selection made from left to right left its first candidate in the text. The caret stood at the selection’s end, and the composition’s range was measured from there although the candidate replaced the selection at its start. The next candidate and the commit then took away the wrong characters, or none: composing 你 over a selected word left n你 behind. The range is now measured back from where the candidate ends.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.14.1…v0.14.2

v0.14.2

Version 0.14.1 2026-09-28

Changed

  • text-document 1.12.3.
Accessibility
  • AccessKit 0.25.1. accesskit 0.25.1 and accesskit_consumer 0.39.1, and through accesskit_winit 0.34.1 the platform adapters accesskit_windows 0.35.1, accesskit_macos 0.27.1 and, on Linux, accesskit_unix 0.24.0 with accesskit_atspi_common 0.21.0. On Linux a disabled control is no longer reported enabled and sensitive, so Orca announces it as unavailable rather than as a control that should respond, and an image (Role::Image, and Canvas, GraphicsSymbol, SvgRoot) answers AT-SPI’s Image interface. The re-exported teksilo::accesskit stays on 0.25, so nothing changes for code that uses it.

Fixed

Widgets
  • Holding Tab in a rich text quote or list nested it without end. Each press wrapped the block in one more blockquote, or moved the list item one level deeper, for as long as the key repeated: about a hundred presses built a quote that a Djot loader bounding its parser’s recursion refuses to open again. Tab now stops at 64 blockquote levels and 16 list levels and then does nothing, rather than typing a tab character over the text. The ways back out (Shift+Tab, outdent, decrease_blockquote_depth, and Backspace at the start of a quote or a list item) are not limited by depth. A document already deeper than the limits opens and edits as it is. The deepest structure the gestures can now build, a level-16 list item inside 64 quotes, costs a Djot parser at most 80 levels of recursion. indent, increase_blockquote_depth and toggle_blockquote stop at the same place, and a toggle or a Tab over a selection is judged by the deepest quote the selection holds, since the wrap takes all of it one level down. The default context menu greys out its blockquote row where the toggle would do nothing.
  • Tab or Shift+Tab on a list item inside a quote took the item out of its list. Moving an item to another level rebuilds its list, and up to text-document 1.12.2 create_list only reached blocks in the document’s root frame, so inside a quote or a table cell the item left its list and joined none; every later Tab then nested the quote instead. With text-document 1.12.3, Tab and Shift+Tab in a quote, and indent and outdent there or in a table cell, move the item one level as they do in the main text, stop at the same list ceiling, and undo in one step.
  • Tab at the end of a list’s last item typed a tab character. The editor asked whether the caret was in a list by reading the block at its character index, which at the end of an item’s text is the paragraph after it. Tab there typed a tab into the item, or, where a quoted paragraph followed a quoted list, nested that paragraph’s quote one level deeper for every press. The check now reads the caret’s own block, as the list commands do.
  • increase_blockquote_depth quoted a paragraph that was not in a quote. It is documented as the command behind Tab in a quote and as doing nothing outside one, and now does nothing outside one.
Automation
  • The curated dialogs probe reported a popover’s focus as lost. example_dialogs.py, which cargo teksilo probe writes into a project, expected focus back where it was before a popover opened. Since 0.14.0 a custom popover trigger can take focus, so a mouse press on it does, and closing the popover rightly hands focus back to the trigger. The probe now expects the trigger, or, where the press gave the trigger no focus, the control that had it before.
Workspace
  • Cargo.lock recorded text-document and text-typeset with no source or checksum. A developer’s local [patch.crates-io] overlay strips both, and 0.13.1 and 0.14.0 were tagged with such a lockfile: cargo metadata --locked failed on a fresh clone, and cargo audit skipped those eleven crates. The lockfile resolves from crates.io again, a new CI job fails one that does not, and tools/relock-crates-io.sh repairs it. The script keeps every crate at the version the lockfile was tested with, and leaves the lockfile untouched with an error naming each crate when crates.io cannot supply that version, rather than taking the newest release.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.14.0…v0.14.1

v0.14.1

Version 0.14.0 2026-09-26

Added

Accessibility
  • teksilo_platform::key_report: each key reported to the AT-SPI registry. KeyReportGate reports every key press and release to org.a11y.atspi.DeviceEventController.NotifyListenersSync while an assistive technology is attached, and says which keys the screen reader took, so they are not dispatched. teksilo-app runs every KeyboardInput through it; Linux only, it does nothing on Windows and macOS.

  • tools/reader/: what a screen reader gets from an example, recorded. tools/reader/reader.py runs an example in a private, invisible desktop session (its own D-Bus, AT-SPI bus, KWin and runtime directory, no DISPLAY, no sound) and records the AT-SPI events it emits, the tree a reader walks, and what Orca 46.1 says, speaking to nothing. Acts are real key presses through the private compositor, AT-SPI actions, or the automation bridge. Scenarios state what a reader should get; tabwalk and tree need no scenario. Linux only. See docs/a11y/reader-harness.md.

  • Widget::accessibility_proxy: a composite can publish itself through the field that holds focus. Where a composite keeps its focus, text and value on one inner widget, that widget is the node a screen reader lands on and has to carry the composite’s name, while an application can only reach the composite’s id. Returning the inner widget from the hook makes the tree apply everything attached to the composite (its overrides, a FormLayout’s labelled_by, access_described_by, the tooltip it owns) to that widget’s node, after the widget’s own, and point relations naming the composite at it. The overrides guide has a section on it.

  • accessibility::audit::focusable_nodes_hidden: a control that takes focus inside a hidden subtree. A hidden node hides everything under it from every platform adapter, and only the focused node is let back through, alone, so a reader lands on the control and finds nothing around it. The audit lists every node that offers Action::Focus while hidden, itself or through an ancestor, and is not disabled. The widget previewer’s catalog census now runs it over every widget and documentation snippet. Beside it, audit::nodes_in_filtered_tree lists the nodes accesskit_consumer’s filter keeps, which are the ones a platform adapter walks and can announce.

  • RichTextEditor, CodeEditor, PlainTextEditor and LogView take a .label(..). It names the text itself, which is what a screen reader lands on and announces (“Notes, entry”, “Build output, document”), and stays locale-reactive. Before, nothing could name that text: a name given to the widget went to its outer node, not to the text a reader reads.

  • HandlerSet::access_customize: a widget can finish the accessibility of a node it reaches by id. The twin of WidgetWithHandlers::access_customize, for the nodes a widget writes to through BuildContext::apply_handlers, and chained after any customization the node already carries.

  • BuildContext::first_focus_capable_descendant: the control a wrapper stands for, even while it is disabled. Beside first_focusable_descendant, which finds only what can take focus now, this finds the first widget in a subtree that is declared focusable, so a control disabled as it mounts is still recognised as the control.

Internationalization
  • A formatted date can name its weekday, or stop at the month. TeksiloDateTimeFormatter::date_fields(DateFields) picks which fields the date part names: YearMonthDay, the default and what every date style rendered before; YearMonthDayWeekday, CLDR’s full date at DateStyle::Long (“lundi 31 août 2026”, “Monday, August 31, 2026”); or YearMonth, a month with its year (“août 2026”). ICU picks the pattern for the whole combination, so the order and the grammar are the locale’s: a Russian month is genitive after a day and nominative beside its year alone. format_in_locale(value, &lang) renders one value once, as a plain String, in a locale the caller names, for a string computed on demand, such as an accessibility name. calendar_system(CalendarSystem::Gregorian) keeps the date on the Gregorian calendar where CLDR gives the locale another one: by default fa-IR writes 24 September 2026 as the 2nd of Mehr 1405, and th-TH counts its year 2569.
Core
  • EventContext::context_menu_trigger: what asked for a context menu. A context_menu factory is handed a point whatever opened the menu, and only a pointer’s is one the user chose. Inside the factory, ctx.context_menu_trigger() now says ContextMenuTrigger::Pointer (a secondary click, or a hold), Keyboard (the Menu key, Shift+F10, Ctrl+Shift+M on macOS) or Accessibility (an assistive technology’s ShowContextMenu), so a factory can act on the point, moving a caret to it or picking the item under it, only when the user pointed there.

Changed

Data views
  • A single-selection TreeView over an index SelectionModel keeps the selected row selected through a structural change. An insert, a removal or a collapse above it used to leave the selection at its old position, on whichever row moved there. The selection now moves with its row, as a keyed selection does. Multiple selections are unchanged. Behaviour change.

Fixed

Core
  • Tabbing away from a control whose tooltip was showing sent focus back to it. Once focus had brought up a control’s rich or composite tooltip, Tab moved on to the next control and then, about 120 ms later as the tooltip faded out, focus returned to the control just left: a screen reader’s reading of the next control was cut, the old control was read again, and only a second Tab got away. Tabbing out of a tooltip that had turned sticky did the same. Focus now stays where Tab put it. A Snackbar that times out no longer sends focus back to its trigger after the user has moved on either.

  • Orca heard no key typed into a Teksilo window in a Wayland session. Orca 46 reads no keyboard there: it learns of a key only when the application reports it to the AT-SPI registry, as GTK and Qt do, and Teksilo did not. So Orca said nothing when an arrow key moved the caret, echoed no typing, and none of its own commands ran (Orca+T, which says the time): the keys went to the application instead. On Linux, while an assistive technology is attached, each key press and release is now reported to the registry before the application sees it, and a key the screen reader takes for one of its commands no longer reaches the application, nor does its release. A key typed into a secure field (a PasswordField) is reported without its character: its physical key and modifiers only, which the screen reader’s own commands need, so a program listening to the registry can still tell which keys were pressed there. GTK and Qt report the character too. A press waits at most 200 ms for the registry’s answer. A registry that stops answering costs one such pause, however many keys follow; one that answers every key more than 200 ms late costs up to 200 ms on each key typed after it has caught up. What a reader hears on an X11 session, where Orca reads the keyboard itself, on Windows and on macOS is unchanged.

  • An empty text measured by text-typeset had no text run. TextRunSource::from_geometry built no line from the geometry text-typeset returns for an empty string, so an empty label or field laid out by it offered no Text interface on AT-SPI and none of the text ranges the other platforms read. It now emits the one empty run TextRunSource::flat always did, so the first text such a node gains, and the edit that empties it, are reported like any other change.

  • A composite that takes focus itself was announced by its structural node. Where a focusable widget hands its accessibility node to a descendant (Widget::accessibility_proxy), a screen reader now lands on that descendant when the widget takes focus, hears its name and content, and is told of the caret and selection moves AT-SPI reports only for the focused node. A screen reader’s own request to focus that descendant puts the keyboard on the composite, where its caret, input method and focus ring live, and a context menu the composite owns is offered on it.

  • Focus that a rebuild took away came back in the wrong place. When a widget rebuilt itself and the control that had focus went with it, focus went to the first control of the whole rebuilt widget. For a widget that keeps its parts across a rebuild (a toast stack, a tab widget, a docking layout) that was the first part, whichever one the user was in. Focus now comes back in the innermost part that is still there.

  • A screen reader could act on the page behind a modal. While an in-tree modal was up (a MessageBox, a Dialog, any Centered overlay), its scrim kept the pointer off the page and Tab stayed inside it, but a screen reader’s own requests still reached the controls behind it: an AT-SPI click behind “Save changes?” opened a second box over it, a click behind “Close window?” toggled the document’s checkbox so that the next close quit with no question, and a focus request put the keyboard on a control the box covers. An assistive-technology action aimed behind the modal is now refused and reported unhandled, so the reader’s click does nothing and focus stays in the modal. The modal’s own controls, and a menu or drop-down opened over it, take every request as before. The page stays in the tree, readable, and is as alive to the reader once the modal closes as before it opened. The automation bridge answers such a request (invoke_action, focus_node, set_value, expand, collapse) with its unhandled-action error.

  • Every window was an unnamed “frame” to Orca. The accessibility tree’s root, which AT-SPI presents as the window, carried no name, so Orca 46.1 announced each window as it came up with the bare word “frame”. The root now takes the window’s title, and follows it when the title changes. Windows and macOS read the title from the native window and are unchanged.

  • Orca heard only the first message the framework’s announcer said. Every announce in every Teksilo application, after the first of a session, was dropped by Orca 46.1, and even a first one was lost whenever Orca handled the event after its node had gone. Each message is now spoken from a node the tree has never had, which stays in the tree for five seconds (teksilo_core::announcer::LINGER) and then leaves for good. A burst keeps at most eight in the tree at once. The tree no longer carries two hidden announcer nodes while nothing is being said, so a TreeUpdate of an idle tree has two nodes fewer.

  • A control a reader had met went silent on Orca once it left and came back. A tab page shown again, a reopened menu, combo list or date picker calendar, a snackbar, tooltip or Caps Lock warning shown a second time, a page of the widget catalog visited twice, a chart or combo box scrolled back into view, an editor focus came back to: each returned to the platform under the id it had left with, which the AT-SPI adapter had announced defunct as it left, and Orca 46.1 dropped every event from it, focus included, so the reader heard nothing from the second visit on. Anything that leaves the tree a reader sees and comes back now reaches the platform under an id it has never had, and is heard as it was the first time. The ids sync_accessibility returns, which the automation bridge and tests read, are unchanged. An integration that feeds an AccessKit adapter itself hands it WidgetTree::deliver_accessibility and routes the adapter’s action requests through WidgetTree::resolve_adapter_action. Windows and macOS keep no defunct state, and nothing changes there that a reader can tell.

  • An announcement made as focus moved was cut before it was heard. A keyboard move of a row or a tab (“Moved to 2 of 3”, “Doc 1 moved to 5 of 6”, “Moved to level 2”) puts focus on the moved item and says where it went, and Orca 46.1 stopped the message to read the item: no move in a ListView, TreeView, GridView or tab bar was ever heard whole. A message the announcer is asked for as focus moves, including to a list’s new current row, now reaches the screen reader after the focus change, one frame later, so the reader hears the item and then the message. A field focused as the message is announced leaves the message out of its description while focus stays, as it already did where the reader keeps the announcement.

  • The announcement ring recorded text no platform announces. WidgetTree::announcements_since, and the automation bridge’s pull_announcements that reads it, recorded a live node’s value before its label, from every live node the walk emitted, whenever that text changed. Every AccessKit adapter announces a node’s name instead, which is its value only for a Role::Label, and only for a node in the filtered tree. So a hidden live region, one inside a hidden subtree, and a Status or Alert carrying its text as a value were all recorded while every platform stayed silent, and a probe reading the ring passed over the silence. A tree that records announcements now replays every update sync_accessibility returns, including the re-placed copy a scroll hands out, through accesskit_consumer, and the ring records what all three adapters announce: a live node’s name as it enters the filtered tree, and again when it changes while the node stays there, at the politeness the node has or inherits from a live ancestor. A node inside a live region is recorded, as the adapters announce it. A change of politeness alone, which Windows and macOS announce and AT-SPI does not, is not, and neither is a blank name. Several in one update are kept in reading order, so a message the framework’s announcer says comes after a change of the application’s own live region in the same frame. The replay is a second pass over every node of every update, making a full sync of 3,000 nodes 1.4 to 2 times as long, so only a tree that has a reader records: one built with WidgetTree::new(), as every headless tree and every test is, and the tree of a window teksilo-app opens only when install_automation_bridge_in_debug() installed the bridge, which it does in a debug build with the automation feature. The new WidgetTree::set_records_announcements switches it. Behaviour change for anything that read the ring, and for anything that read it from a window without the automation bridge, where it is now empty.

  • announce_unless_widget_speaks kept quiet beside a live region nobody could hear. It took a widget to be speaking when any node in its subtree carried a politeness and a value or a label, so a hidden live region, or a Status holding its text as a value, silenced the framework’s own message, and the user heard nothing at all. It now reads the last update through accesskit_consumer, whether or not the tree records announcements, and counts only a live node the platform adapters walk, with a name to speak, whose politeness is set inside the widget. A button that is live only because it sits in a toast or another live region is not the one speaking, and does not keep the framework quiet about its own menu. The long-press context-menu announcement goes through it.

  • accessibility::announced_text and the label audits took a value for a name. For any role but Role::Label, announced_text fell back to the node’s value when it had no label, and audit::duplicate_label_leaks and labels_without_text_ranges read names the same way, while no platform adapter takes a value as the name of anything but a label. A Status holding its text as a value was reported as announcing it, and a label repeating such a value was reported as repeating a name nobody hears. Both now read a Role::Label’s value and any other node’s label, and nothing else. Behaviour change for a test that found a node by a value through announced_text.

  • A merged name took the text from under a hidden descendant. access_merge_subtree skipped a hidden descendant’s own name but went on merging its children, so text an application hid with access_hidden(true) on a wrapper was read aloud on the merged node. A hidden descendant and its whole subtree now contribute nothing, as the adapters treat them. The rustdoc of AccessNodeBuilder::set_hidden, clear_hidden and access_hidden said the flag was local to its node; it now says that it hides the subtree and that a wrapper which is only chrome is a Role::GenericContainer instead.

  • A field’s validation message was never read on arriving at the field. access_described_by, and the four stock inputs that wire their ValidationStrip with it (TextInput, PasswordField, DateTimeEdit, DateRangeEdit), promised WCAG 3.3.1: the message read as the field’s description when it gains focus. AccessKit 0.25 passes the relation to no screen reader, on any platform: the consumer never derives a description from it, and no adapter exports it. So coming back to a refused field said its name and value and not why. The tree now writes each described_by target’s text into the node’s description, after its own, which Orca, NVDA and VoiceOver read. Around focus it holds new text back, because Orca speaks a change to the description of the node it holds as focus: a message appearing while the user is in the field is said once, by its live region, is not begun again as the user leaves, and is read with the field on the next visit. A shown tooltip no longer takes away its anchor’s description either. Behaviour change: a node carrying described_by now carries a description. An application that announces a message itself as it sends focus to the field showing it is heard once on each platform, by a different voice: on Windows the arrival leaves the announced text out, since NVDA says the announcement; on Linux and macOS the arrival reads it, since Orca cuts an announcement to read the new focus (VoiceOver is unverified and treated the same way).

  • A throttled frame-tick subscriber held back every frame requested while it was on screen. A widget on subscribe_frame_tick_throttled set the pace not only of its own tick but of every request_frame: with a once-a-minute clock painted, the announcer’s follow-up syncs, a caret or a drag auto-scroll each waited up to a minute. An announcement queue drained one step per wait, so its later messages reached the screen reader at the user’s next key press, spoken ahead of whatever that key said. The interval now delays the subscriber’s own tick and nothing else; a requested frame is due at 60 Hz whatever is subscribed.

Widgets
  • A check box, a switch, a radio button or a slider changed without a screen reader being told. On Space, an arrow key, a click or a screen reader’s own activation, Checkbox, Toggle, RadioButton and Slider changed their state and no platform heard of it: the new state reached the accessibility tree only with the next unrelated update, most often the focus move that followed, where Orca’s “checked” or “52” was cut by the new focus. Each change now reaches every platform in the frame that makes it, whether the user, a screen reader or the application made it, and so does a Checkbox in a ListView or TreeView row checked with Space on the row. A Slider also gives the platform its figures as they are written: an f32 0.3 was published as 0.30000001192092896, which Orca spoke digit by digit, and is now 0.3, as are its minimum, maximum and steps. A value the arrow keys reach reads as the step it reached: three steps of 0.01 up from 0.3 are read as 0.33, not as the 0.32999998 the f32 sums come to.

  • A screen reader heard nothing as the highlight moved through a menu. In every MenuList, which is every menu-bar menu, context menu, popover menu and submenu, the arrow keys, Home, End, PageUp, PageDown and type-ahead moved a highlight no platform could see: Orca said “menu.” as a menu opened and nothing after it, and Enter ran a command the reader had never heard. Each move of the highlight is now a focus change to the highlighted item on Linux, Windows and macOS, so a reader hears “Open”, or “Word Wrap, check menu item, checked”, as it goes. A menu is named after what opened it, so opening one says “File menu”, “Recent menu” or “Add menu” (a context menu stays unnamed), and each item carries its place among the menu’s items for a reader that announces it (“3 of 5”).

  • A submenu opened from the keyboard closed itself. Right, Enter or Space on a submenu row, or its mnemonic, opened the submenu as a mouse click would, and it closed about 165 ms later whenever the mouse rested anywhere but on the row or the submenu, so a keyboard user could not reach File > Recent in the example, or a docking layout’s “Move to” sides. It now stays open until Escape, Left, a choice or a click outside, as one an assistive technology opens always did.

  • Text fields told a screen reader nothing of the caret, of an empty field, or of a password. In every field built on TextInputField (TextInput, SearchField, SpinBox, PasswordField, the date and time editors), an arrow, Home, End, a Shift selection or Ctrl+A reached no platform: Orca 46.1 said nothing, and a reader was told the caret and selection of the last edit. Each move and selection is now published as it happens, and Orca speaks the character or the selection. An empty field now offers the Text interface on AT-SPI, so the first character typed or pasted into it, and the deletion that empties it, are reported. A masked password field now exposes its mask as its text, one echo character per character and never the plaintext, so Orca echoes each keystroke and deletion; a NoEcho field exposes an empty text and reports nothing typed.

  • A PrivacySettings with telemetry configured crashed every debug build. Its consent switches are named by their row’s label, which the switch’s own check for a missing name could not see, so the application panicked as its accessibility tree was built, and the widget catalog’s Settings tab exited as it opened. The panel now stays up, and a screen reader reaches each switch by Tab, named by its row (“Anonymous usage metrics”, “Crash reports”, “Feature flags”) and read with its state. Release builds did not crash.

  • A date field’s calendar wrote a date over the field that the user had not chosen. The calendar of a DateEdit, a DateTimeEdit or a DateRangeEdit opened on the date the field held when it was built, or wherever the last opening had left its cursor, or on the months view it was closed on, and Enter wrote that day over the field’s value without a word. It now opens on the date the field holds (for a DateRangeEdit, the start; for a DateEdit, the text typed in it counts), in the day view, and the reader hears that day. A DateRangeEdit’s calendar closed with one end of a range picked kept that end, and one Enter in the next opening wrote a range from it to the cursor over the field’s and closed the calendar; each opening now begins a new range.

  • A calendar’s months and years views could not be used from the keyboard or by a screen reader. There the arrows moved the cursor of a day grid no one could see, in silence, and Enter or Space selected that hidden day. Now the arrows move over the months (the years), each heard with its year (“May 2026”), PageUp and PageDown move a year (a decade), Enter or Space opens the one under the cursor, and Escape goes back to the days; nothing is selected on the way. Activating the title takes the keyboard into the grid, on the month shown. A month or a year offers a screen reader’s click, and they are one Tab stop, the grid’s, where each month was a stop of its own. The years view follows the cursor, or a header arrow, into the next decade. Behaviour change: a month cell is named with its year.

  • The fields of the date and time editors were read without a name, or without their date. The field focus lands on in a DateEdit or a TimeEdit had no name, so Orca said “entry 05/02/2026”; it now carries the editor’s .label(), or “Date” and “Time”, and an access_label or access_described_by given to the editor reaches it. A DateTimeEdit’s parts and a DateRangeEdit’s halves reached Orca as date editors, read without their text (“End date date editor.”); they are now entries, read with their date or time. Behaviour change: those parts are Role::TextInput, no longer Role::DateInput or Role::TimeInput; the editor around them keeps its date role.

  • A custom overlay trigger could not be reached from the keyboard, or was reached as an unnamed panel. The widget handed to Dialog::trigger, Snackbar::trigger, Wizard::trigger or a PopoverCustom carried its button role and name on one node and took focus on another. A popover over a custom trigger was no Tab stop at all, and a screen reader could not focus it either; a dialog’s custom trigger took focus as the unnamed panel it wrapped, which Orca 46.1 read as “panel.”. The node that takes focus is now the trigger’s button: Tab lands on it, the reader hears its name, and Enter, Space and the reader’s own activation open the overlay. A trigger that is already a control, such as a Button, stays the one Tab stop under its own name, carries the popup state, and now opens the overlay when a screen reader activates it, where before nothing happened; the wrapper around it is no longer a second button, which for a Snackbar had been named with the snackbar’s message. Behaviour change: a custom trigger around a widget that takes no focus is now a Tab stop, including the filter glyph of a filterable TableView column.

  • Opening a popover with nothing to focus in it put the reader on a node with no name and no role. Orca 46.1 said nothing, and Tab from there went to the first control of the window. Focus now lands on the popover’s dialog, so the reader hears its name and the text it holds; Tab goes on to the control after the trigger and Shift+Tab back to the trigger, and either closes the popover. A popover’s dialog given no surface_name is now named by its trigger, where before it had no name. A bare() popover with nothing to focus leaves focus on its trigger.

  • Focus in a RichTextEditor, CodeEditor, PlainTextEditor or LogView reached no screen reader. Tabbing or clicking into one put focus on an unnamed node with no text, which Orca 46.1 announced as “section.” or not at all, and no caret move that followed was ever reported, so every arrow key was silent. A reader now lands on the text itself, an editable text or a document, hears its name and the line at the caret, and hears each caret move. Coming back to a rich text editor or viewer is heard too, where the return used to be silent. An .access_label(..), .access_labelled_by(..), .access_description(..) or tooltip attached to one of these widgets now names or describes the text. The rich-text-editor, rich-text-viewer, code_editor and log_view examples name their surfaces.

  • A keyboard user could not leave a CodeEditor or a PlainTextEditor. Tab indents in them, and so did Ctrl+Tab, while Ctrl+Shift+Tab dedented, so every key meant to move focus out wrote into the document instead, and a screen reader heard nothing of it. Ctrl+Tab and Ctrl+Shift+Tab now move focus to the next and the previous control and leave the text alone, as they already do out of a terminal and a table; Tab and Shift+Tab still indent and dedent. The editor’s text node now says so in its description (“Tab indents. Ctrl+Tab moves to the next control, Ctrl+Shift+Tab to the previous one.”), in the user’s language, and Orca reads it as focus arrives there. A read-only viewer does not take Tab, so Tab leaves it and it carries no such description.

  • Arrowing through an open ComboBox list was silent, and changed the value at every step. Focus stayed on the combo box (or on its search field) while the arrows moved its value, and nothing told a screen reader which option they had reached, so Orca heard nothing as a reader moved through a long list, the font list or the list of languages. The keys now move a highlight that the focused node names as its active descendant, so a screen reader on Linux, Windows or macOS hears each option as it is reached, and the option the list opens on. The value changes only on a commit: Enter or Space in the open list, or a click on a row; Escape, Tab and closing the list keep it. on_select therefore fires once, on that commit, and a LanguageSwitcher no longer switches the application’s language (nor a ThemeSwitcher its theme) at each arrow while a reader listens to the choices. Down from an empty combo box reaches the first item, where it skipped to the second. A long list is one list box of named options: each option used to sit inside an unnamed, unselected option of a second list box, and on Linux the list box reported no selected option. A searchable list’s search field is named after the combo box (“Search” when it has no label), and its placeholder is translated. Behaviour change: the arrows, Home / End, the page keys and type-ahead no longer write the bound selected signal or fire on_select; code that read the value after an arrow press presses Enter first.

  • Every change to the toast stack read every toast again and moved keyboard focus. Showing, updating or dismissing any toast announced each toast still on screen again, in no set order: with an Error toast shown after three others, Orca spoke all four titles. A progress toast updated in place announced its unchanged title at every step, twenty times over for toast-demo’s background job. Each change also replaced the control a keyboard user was on and put focus on the oldest toast, so the Cancel of a progress toast could not be pressed from the keyboard. A toast is now announced when it appears and when its title changes, and nothing else is read again. Focus stays where the reader put it, on a toast or on one of its actions, and an action kept across an update runs the update’s callback. When an update removes the focused action, focus stays in that toast.

  • A toast expired under keyboard focus. A reader who Tabbed to a toast’s action had the rest of its ten seconds to decide, then the toast went and Orca said “frame”. A toast now stays while keyboard or screen-reader focus is on it or inside it, as it does under a resting pointer.

  • A toast shown while another was up left with it. A second toast was charged for the time before it appeared and expired at the first one’s deadline: shown 5 s after another it lasted 4.8 s instead of 10, and an Error toast shown last lasted 2 s. Each toast now gets its own time.

  • The notification bell and log were rebuilt on every notification. Every toast is archived, and a background job archives each of its steps. Focus on the bell was fired again on a new button, which cut the toast being announced and said “Notifications push button” instead; an open bell popover closed under the reader and marked the new notification read unseen; and the log dialog threw focus back to Mark all read at every step of a job, so Clear all could not be pressed. The bell, its popover and the log’s buttons now stay put and keep focus; only the unread badge and the rows whose notification changed follow the archive. Clear all now says “No notifications”.

  • The chosen segment of a SegmentedControl was read as “not selected”. Each segment is a radio button, and a screen reader tells a radio button’s state from whether it is checked, which no segment ever was. Orca read the segment the user had just chosen as “not selected radio button”, and by the Windows and macOS adapters’ own code NVDA was given no state for it at all and VoiceOver no checked value. The chosen segment is now checked, as a RadioButton is, so Orca says “selected radio button”. A segment also stopped claiming to be selected in the list sense, which had Orca speak the chosen segment of a control whenever the control came into view or its choice changed, wherever the reader was: five cut “not selected radio button” at the launch of chart-demo, and a Tab onto a field that scrolled a control into view was cut short by that control’s segment. Behaviour change: a test or a probe that asserted a segment’s selected asserts its toggled now.

  • A dialog’s content was hidden from assistive technology. The panel RecipeDialogStyle draws around a ModalContainer’s content called set_hidden() to say it was only chrome, and the consumer every platform adapter reads through treats a hidden node as hiding everything under it. So on Linux, Windows and macOS a screen reader reached the focused control of a dialog and nothing else: not the title or the message as text, not the other fields and buttons, and not even the focused control when walking the dialog, whose children it no longer listed. Orca’s flat review gathers what it reviews from those children (getOnScreenObjects in its script_utilities.py), so it had nothing to review, and a walk of the UIA tree met the same empty dialog, since the Windows adapter lists children through the same filter; what NVDA’s object navigation made of it was not observed. The panel is now a bare Role::GenericContainer, which the adapters drop while keeping its children. Every ModalContainer is affected, on the default style and on the macOS and Fluent presets, which reuse it. A custom DialogStyle should do the same, as DialogStyle::make_panel now says. What that lets through is presented once: a ModalContainer whose content is itself a Role::Dialog or Role::AlertDialog, as a MessageBox’s and a CommandPalette’s is, now publishes a bare Role::GenericContainer instead of a second dialog of the same name, which Orca would speak as focus entered each; and the CommandPalette’s content is Live::Off, so the polite palette is announced by its name alone, and not row by row. Behaviour change: the container of a presented MessageBox is no longer a Role::Dialog named by the title. A test or a probe that found the box’s buttons under that node finds them under the box’s Role::AlertDialog, which holds them on this version and the last.

  • The content of a Card, a Panel, a Toolbar, a StatusBar, a snackbar and a RadioTile body was hidden from assistive technology. The frames RecipeCardStyle, RecipePanelStyle, RecipeSnackbarStyle and RecipeRadioTileStyle draw, and a Panel marked a11y_presentational, called set_hidden() the way the dialog panel did. A screen reader found a card, a panel, a toolbar, a status bar or a snackbar empty except for whichever of its controls held focus, and never reached a tile’s body. Each is now a bare Role::GenericContainer, and the snackbar’s frame is also Live::Off, so a snackbar that announces its message says it once. The macOS and Fluent presets build their cards, panels and snackbars from these frames, and Material 3 its cards, so they are fixed with them. The make_body of CardStyle, PanelStyle, SnackbarStyle and RadioTileStyle now says what a custom frame has to do.

  • An open menu listed none of its items to assistive technology. The panel RecipePopoverStyle draws for its Menu variant, under every MenuList, ComboBox drop-down and search suggestion list, called set_hidden(). Whatever held focus could still be heard, since the filter lets a focused node and an active descendant through, but the menu listed no children, so a screen reader could not review the items around it. The surface is now a bare Role::GenericContainer, and PopoverStyle::make_body says what a custom one has to do.

  • Toasts were never announced and could not be reached. The ToastHost every toast is mounted under called set_hidden(), so no toast ever entered the tree a platform adapter reads. The AT-SPI, UIA and macOS adapters all announce a live region as it enters that tree and pass over one that is filtered out, so a toast’s Role::Status or Role::Alert said nothing on any platform, and its text and actions could not be reviewed or used from a screen reader. The host is now a bare Role::GenericContainer, and the toast’s content is Live::Off, so a toast is announced by its title, once; its body is its description, read on reaching it, and its actions and close button stay reachable. ToastStyle::make_body says a custom style has to do the same.

  • A TabWidget’s panel, a Stepper’s step and anything inside a Switcher, MaxSize or AspectRatio was hidden from assistive technology. The three layout primitives called set_hidden() to publish no node of their own, and a hidden node takes its whole subtree out of every platform’s tree. TabWidget and Stepper show their pages through a Switcher, so the selected Role::TabPanel and everything in it could not be reached, and neither could a Calendar’s day grid, the title bar’s maximize button or the debug inspector’s panel, while a menu capped by max_visible_items lost its rows to the MaxSize around them. A screen reader met a control inside them only once focus reached it. All three are now a bare Role::GenericContainer, pruned from the tree, and the page a Switcher shows takes its place; its other pages stay out of the tree as before, because they are dormant. Behaviour change: none of the three publishes a node of its own any more, so what one wraps is a child of the node around it, in the update sync_accessibility returns and in an automation snapshot alike. The snapshot, which ignores the hidden flag, held the same pages before, under the hidden node.

  • A Snackbar’s button, the notification bell, a TitleBar’s centre content, an unlabelled Splitter pane and every TreeTableView cell were hidden from assistive technology. Each sits inside a shell that called set_hidden() to keep itself out of the tree: the Snackbar around its trigger, NotificationCenterButton around its IconButton, the title bar’s drag region around TitleBar::center, a splitter pane that has no pane_label (or a labelled one while collapsed, sliver and all), and the row inside each TreeTableView row. A screen reader could reach any of them only while it held focus, and a tree table’s cells not at all. Each shell is now a bare Role::GenericContainer. Behaviour change for a test that read the drag region’s hidden flag: it is no longer hidden, and still no stop.

  • A Banner was followed by its description, its action and its dismiss button. The banner is a polite live region named by its title, and every node inside it that sets no politeness of its own inherits it, so the AT-SPI, UIA and macOS adapters announced each of them as a message of its own as the banner appeared, in the order of the consumer’s hash set. Orca speaks each announcement with interrupt set, so what was left to hear was whichever came last. The banner’s content is now Live::Off: its title is what it announces, once, and the rest stays reachable. A StatusBar with announce_changes(true) keeps the inheritance, which is what it is for.

  • Opening a MessageBox announced its default button, and not its question. The box is an assertive live region so that its question is announced as it appears, and every node inside inherited the setting. The hidden dialog panel let only the focused default button through, and it was announced, assertively, as a message of its own, while the box’s name never was: a confirmation opening on its default button announced “No”, and its question reached a reader only as the name of the dialog presenting the box, around the focus. With the dialog’s content reachable, everything inside the box is now Live::Off: the question, the box’s own name, is what it announces, once, and its text and buttons stay reachable.

  • A focused SpinBox reported a nameless text field. Focus lands on the spin box’s editing field, while its name, value and range sat on a node around it, so a screen reader named the field once or not at all, and could not hear a step move the number. The editing field is now the spin button: Role::SpinButton, the label as its name, the value, range and step, Increment and Decrement, and its own text runs. A focus change says the name and value once, and a step changes the node the reader is on. What an application gives the spin box’s id (access_label, a FormLayout label, access_described_by, a tooltip) reaches that node through the new Widget::accessibility_proxy. The accessible value is now exactly the text a reader can review, so a painted suffix is no longer in it. Behaviour change: a test that read the spin button off the SpinBox’s own id finds a GenericContainer there; the spin button is the field, first_focusable_descendant of that id.

  • A SpinBox with a custom value_from_text could not be typed in words. The numeric input filter ran whatever the parser, so a month field that reads "march" as 3 dropped the letters as they were typed, and the commit put the old month back. A custom parser now receives every character and alone decides, at commit, what it can read; the default parser keeps its filter.

  • A SpinBox step threw away what had just been typed. Up, Down, the wheel, the step buttons and an assistive Increment stepped from the value last committed, so 35 typed over 10 in a field stepping by 5 became 15 on Up. A step now reads the typed text first, as Enter would, and moves on from it: 40, with one on_value_changed. Text that cannot be read steps nothing and the held value is shown again, as in Qt. Enter and focus loss now commit keystrokes delivered in the same batch as the key that commits them, which they used to miss by a frame. Behaviour change.

  • After a live change of language, a SpinBox wrote wrong values. Switched to French, every spin box showed 440,00, and then went on reading, stepping and filtering in the language it was built in: focus turned the text back to 440.00, a step said -0.5, a grouped 1 234 567 was read “1,234,567”, which is a decimal to a French reader, and the comma typed into a decimal field was dropped, so 12,5 wrote 125. The field now shows, reads, steps, commits and accepts the number in the language switched to: 12,5 writes 12.5, shown and spoken 12,50.

  • A SpinBox at its special_value_text told a screen reader “0”. Focus replaced “Auto” with the minimum as it arrived, so a reader moving to a timeout heard “Timeout 0 spin button” and never what 0 means there, while a step back down to the minimum said “Auto”. The special text now stays while the field has focus, as Qt’s specialValueText does: arriving says “Timeout Auto spin button”, keyboard focus selects it so a number typed replaces it, and leaving the field no longer rewrites its text, which Orca spoke as “Text unselected.”. Behaviour change: a click into the field puts the caret in the special text, not in the number; select it, or step, to replace it.

  • The calendar spoke English and ISO dates to a user in any language. In French the grid was “Calendar, septembre 2026”, a day was “lundi août 31, 2026”, and the value read “2026-09-24 (selected: 2027-03-12)”. Every string Calendar gives assistive technology is now in the user’s language: the words from the framework bundle, and every date written by ICU for the tree’s locale, in the locale’s order and grammar, and on the Gregorian calendar the grid is laid out in even where the locale prefers another. French says the first of the month as “premier” (“1er” on screen), where ICU’s bare “1” is read “un” by a speech engine, and Italian and Romanian say it “primo” and “întâi”, where the digit is read “uno” and “unu” (Serianni, cited by the Accademia della Crusca; DOOM2): “lunedì primo marzo 2027”, “luni, întâi martie 2027”. The Italian date drawn under a range calendar writes “1º mar 2027”, and the Romanian one keeps the digit, which is how Romanian writes it. No other shipped language is rewritten: Spanish and Portuguese read the digit right as it stands, and the languages that read every day as an ordinal are a question for every day, not the first. The same grid now reads “Calendrier, septembre 2026”, “lundi 31 août 2026” and “jeudi 24 septembre 2026 (sélection : vendredi 12 mars 2027)”, and a range is joined by words (“du … au …”). With the cursor on the one selected day, which is where a date field’s popover opens, the day is said once, marked selected, where the value used to say it twice: “vendredi 12 mars 2027 (sélectionné)”. The title is ICU’s month with its year, so Japanese reads “2027年3月”; the decade title reads “2020 to 2029” instead of joining the years with an em-dash; the line under a range calendar uses the locale’s medium date and now follows a range committed from the keyboard or by the application, not only by a click. DateEdit, DateRangeEdit and DateTimeEdit open this calendar, so their popovers are fixed with it. calendar-name-with-month now takes the month with its year as its one $month, the unused calendar-cell-name is gone, and calendar-value-with-selection, calendar-value-on-selection, calendar-date-range and calendar-decade are new, in all 23 locales. Behaviour change for anything that read the grid’s value as an ISO date.

  • DateEdit, DateRangeEdit and DateTimeEdit gave assistive technology an ISO value. The value on the node standing for the whole field was “2026-05-02”, “2026-05-01/2026-05-10” or “2026-05-02T14:35:07”, which UIA and macOS hand to a screen reader as it stands. It is now the day in full in the tree’s locale, “samedi 2 mai 2026”, the range joined by words, “du vendredi premier mai 2026 au dimanche 10 mai 2026”, and the day with its time, “samedi 2 mai 2026 à 14:35”, with the seconds only when the field shows them. The editable text inside keeps its pattern. Behaviour change for anything that parsed those values.

  • A Calendar’s days and header buttons could not be reached, and arrowing through its days said nothing on Linux. The day grid’s body and the header row called set_hidden() so as not to be announced beside the Role::Grid, and hid what they hold with them: the 42 cells, each naming its date in full with its selected and today states, and the arrows and the title button. A screen reader found nothing inside the grid to review, and reached a header button only once it held focus. The day under the keyboard cursor lived only in the grid’s value, which UIA and macOS report as a value change and AT-SPI carries on no interface, so Orca heard nothing while the cursor moved. Both are now a bare Role::GenericContainer, so the days are in the platform’s tree, six rows of seven cells under the grid, and so are the header’s five buttons. While it holds focus in the day view, the grid names the cell under the cursor as its active descendant, and AccessKit reports that cell as the focus on all three platforms: each arrow press, and each change of month from the keyboard, is a focus change to the new day (“samedi 13 mars 2027”). The value still says the cursor and the selection, for a client that reads the grid. A day offers Action::Click and, like a grouped RadioTile, no longer Action::Focus: the dispatcher moved keyboard focus onto a day an assistive technology focused, off the grid, and every arrow press after that moved the cursor in silence. DateEdit, DateRangeEdit and DateTimeEdit open this calendar and are fixed with it. Behaviour change for an automation client that focused a day, which is now reported unhandled.

  • A calendar said things twice. The grid was a polite live region, and a live node speaks its name as it enters the tree and on every rename, with every descendant inheriting the setting. Opening a date field’s calendar announced the grid’s name, then each of the seven weekday headers, and then focus said the name again; PageUp or PageDown in the grid announced the new month while Orca spoke the rename of its focus as well; and each header button Tab reached was announced as it appeared and again as it took focus. The grid is no longer live, and the calendar’s content is Live::Off, so one placed inside an application’s own live region lends it the grid’s name and no day, weekday or button. A change of month made from a header arrow, where focus stays on the arrow, is announced once through the tree’s announcer, as the title now reads; the Today button announces the day it moved to, in full. Neither announces while the grid itself holds focus. Behaviour change for anything that listened to the grid’s live region.

  • The context menu opened from the keyboard acted somewhere else. Shift+F10 or the Menu key in a RichTextEditor, a CodeEditor or a single-line field (TextInput, PasswordField, SpinBox, SearchField) moved the caret to the middle of the surface before the menu opened, as a right-click there would have. The reader heard nothing of it, or “Text unselected.” when a selection collapsed, and the menu’s Paste then wrote into the middle of the text while Cut and Copy found no selection. The caret and the selection now stay where they were, and the menu acts there. A right-click still moves the caret to the click.

  • Closing a field’s context menu selected the whole field. When focus came back to a single-line field from its own menu (Escape, one of its commands, a click outside it), the field treated it as a keyboard arrival and selected everything: Orca said the field’s text followed by “selected”, and the next key replaced it all, even right after the menu’s Paste. Focus now comes back to the caret and selection the menu found. Arriving by Tab still selects the field.

Data views
  • A single-selection data view could move its cursor off the selection. Ctrl+arrow and Ctrl (⌘ on macOS) + Home / End / Page moved the cursor and left the selection behind, so a screen reader announced one row while actions used another. They now move the selection with the cursor in any of the five data views whose selection holds one entry: a SelectionMode::Single model, including one handed to a table in the default MultiRow mode, or a TableSelectionMode::SingleRow / SingleCell table. Multi-selection views are unchanged. Behaviour change.
  • A selection set by the application left the cursor behind. In a single-selection data view the keyboard cursor stayed on the row the user last reached, so a screen reader went on announcing it and the next arrow key started from it. The cursor now moves with the selection when the application or a model change moves the selection. Multi-selection views are unchanged. Behaviour change.
  • A GridView never said how many tiles were selected, and announced each tile it realized. The count was an English value on a grid marked as a live region. A grid takes its name from its label, so no platform announced the value, while every tile inherited the live setting and was announced as it scrolled into the realized window. The grid is no longer live. A click, a key, an assistive click or a marquee that changes how many tiles are selected now says the new count once, through the tree’s announcer, in the user’s language (“3 éléments sélectionnés”), Space on a tile the grid has not realized included. Moving a single selection says no count, since the tile the reader lands on says it is selected. The grid’s value carries the same words. grid-view-selection-count is new, in all 23 locales. Behaviour change for anything that read the value in English.
  • A screen reader’s focus on a GridView tile made Enter open another tile. A tile offered a focus of its own, so UIA’s SetFocus, AT-SPI’s grab_focus or VoiceOver’s keyboard focus following its cursor put keyboard focus on that tile while the grid’s cursor stayed where it was. Enter and Space then acted on the cursor’s tile, not the one the reader had just heard, and the next key that changed the selection dropped focus onto the window, where the keys did nothing. A tile no longer offers focus: the request moves nothing, the reader’s focus and the grid’s cursor stay together, and a click on a tile, which a reader’s activation sends, chooses it. Behaviour change for anything that focused a tile through Action::Focus.
  • Every change of a GridView’s selection was heard as a move of focus. The tile under the cursor came back as a tile the reader had never met, so every platform reported a focus change to it, and Orca stopped reading the selection count it had just been given to read the tile’s name again. A toggle was never heard as a change of the tile’s selected state. The tile under the cursor now stays the same tile through a change of selection, its selected state changes where the reader is, and the count is heard in full.
  • A double click on a selected GridView tile opened nothing. Its first click set the selection again, which replaced every tile in view, so the second click reached a tile that had not seen the first. A double click on a tile that is already selected now opens it.
Internationalization
  • A message with a plural or a selector was said as its message id when no translations were installed. Without an I18nManager, tr!, tr_widget! and tr_signal! gave a message in the source language only when it was plain text and { $var }; any other came back as its key. A screen reader said “grid-view-selection-count” where “1 item selected” was meant, and a CommandPalette gave its result count the same way. Such a message is now formatted in the source language, plurals, functions and the messages and terms it refers to included.
Terminal
  • A Terminal never announced its new output. Each completed line went to a polite Role::Status live region as its value. A Status is named by its label, and every platform adapter announces a live node’s name, so no platform said the line; only the automation ring, which read the value, recorded it. The line is now the region’s name.
Automation
  • type_text and type_ime on a text editor’s own node blurred the editor. A RichTextEditor, CodeEditor or LogView is found as focused, and by its name, on the node that holds its text. Typing into that node still reached the document, but left the editor without its caret, input method and focus ring. The keyboard now stays on the editor.
  • list_live_regions listed live regions no screen reader can reach. It listed every node that declared a politeness: a hidden one, one inside a hidden subtree, and the framework’s two announcer nodes, which are hidden while they have nothing to say. It now lists only the ones accesskit_consumer’s filter keeps, which are the ones a platform adapter walks and can announce. The set comes from teksilo_core::accessibility::audit::nodes_in_filtered_tree. Behaviour change.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.13.1…v0.14.0

v0.14.0

Version 0.13.1 2026-09-22

Fixed

  • cargo teksilo build-vectors now runs on a Windows checkout. Git for Windows checks the tree out with CRLF, and corpus/index.json holds exactly one raw newline, so the round-trip guard saw a one-byte difference and refused to run, reporting that the Rust schema and tools/build_corpus.py had diverged. They had not: the checkout had rewritten the file. The guard now compares what the generator wrote rather than what Git handed the platform. cargo-teksilo’s two corpus tests failed on Windows only for the same reason and pass there now.
  • Line endings are pinned to LF for everyone. A new .gitattributes normalises the working tree on every platform, so a Windows clone means the same bytes as a Linux one. This is what the corpus depends on: index.json is compared byte for byte, and every file the corpus quotes is reconstructed line for line by cargo teksilo show. Existing Windows working trees keep their CRLF files until refreshed, git rm --cached -r . && git reset --hard, or a fresh clone.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.13.0…v0.13.1

v0.13.1

Version 0.13.0 2026-09-20

Added

  • cargo teksilo, agent tooling for apps that depend on Teksilo. An agent working inside this repository has the guides, the worked examples, the skill and the automation harness. An agent working in someone’s Teksilo app had none of them: docs/ ships in no crate, every example crate is publish = false, and the only probe harness lived in one app’s repository. cargo install cargo-teksilo closes that gap, and every answer is matched to the Teksilo the app actually resolved.

    • cargo teksilo symbol <Name>, the exact public API of a type, read from the resolved sources. Works with a crates.io, git or path dependency and needs no checkout: where one is reachable it runs that checkout’s own extractor, otherwise it stages a throwaway repository shaped like this one around the registry sources. A bare name resolves against every teksilo crate, so cargo teksilo symbol ListModel answers with teksilo-data’s without --crate data; the note on stderr says which crate answered, and names the others when more than one defines that name. Types declared directly in a crate’s lib.rs are included, teksilo-webview’s WebView among them. This is the one subcommand that needs python3 on PATH.

    • cargo teksilo search "<question>", retrieval over the 70 hand-written guides and 56 worked example crates, which reach no consumer today. Hits are ranked and carry no score: neither ranker produces a confidence, and the lexical and hybrid numbers are not in the same unit.

    • cargo teksilo show <path>, the document behind a search hit, in full, offline: cargo teksilo show docs/scroll-area.md, or just the lines the hit cited (--lines 166-172, 1-based and inclusive, as search prints them). Nothing is re-fetched, a chunk already carries its own text and the line range it occupied, so the document is reassembled from the corpus, byte for byte. It closes the last version-binding hole in the tool: the path a hit cites exists in no consumer’s project, and fetching it from blob/main/ or the published book serves main rather than the version the app pinned. search now says so in a footer line, and --list prints every path.

    • cargo teksilo probe, writes the automation probe harness into scripts/teksilo_probe/, so an agent can drive the running app and assert on it. Generated files are checksummed: a local edit is reported rather than silently overwritten, and your own probes live outside the generated tree and are never touched.

    • cargo teksilo setup, the above, plus the Teksilo briefing for every coding agent this project already configures, each in that agent’s own format: the full four-file skill into .claude/skills/teksilo/ where it is native, and a self-contained ~40-line brief into .cursor/rules/teksilo.mdc (MDC frontmatter), .windsurf/rules/teksilo.md (trigger: frontmatter), .github/copilot-instructions.md and AGENTS.md (each a <!-- BEGIN teksilo --> region) where it is not. These tools share no format, so copying the skill directory into .cursor/ would accomplish nothing; the brief never refers to the skill, which on those machines is not installed. It also pre-fetches the search encoder (~129 MB, into the per-user cache, --no-model to skip), so the first search does not stall on a download, and a failed fetch is a warning, because search degrades to BM25 by design.

      Three things it now refuses to do. It never writes $HOME from project scope, the previous version silently installed into ~/.claude/skills/ when the project had no .claude/ of its own, a machine-wide change from a project-scoped command; --user is now the only path that reaches the home directory. It never prompts where nobody can answer: with no terminal on stdin the question is an error naming the flag that would have skipped it (-y, or --user when there is no Cargo.toml anywhere above the working directory), because CI and agents run this and a hang is worse than a failure. And it never writes before showing the plan, every path, every detected vendor, and the download with its size, then a confirmation. Runs are idempotent: a second run reports unchanged and leaves the shared files byte for byte, including whatever the project wrote outside the markers.

      Cline is served too, and is the one vendor whose path is read off the disk rather than fixed: .clinerules/teksilo.md where that directory exists, a marker region where .clinerules is a plain file, which it may be, and which has nowhere to put a file of our own, and .cline/rules/teksilo.md where that is the only layout present.

      The order is deliberately not newest-first. Cline’s source calls .cline CLINE_CONFIG_DIR and .clinerules DEPRECATED_CONFIG_DIR, so the newer path looks like the obvious target; but the VS Code extension was hardcoded to .clinerules and ignored .cline/rules/ outright (cline/cline#14186), the cross-surface fix reached main only in September 2026 and is in no released build, and Cline’s own docs still say its Rules panel creates new workspace rules in .clinerules/. Preferring the modern name would install, in the most-used Cline surface, a file nothing reads, which this tool holds to be worse than installing nothing, because it reports success. Reachability beats recency, and a test says so, so that a later tidy-up does not quietly invert it.

      No frontmatter in any layout: Cline’s docs say a rule without one is always active, so its absence is what keeps the brief unconditional.

      Two things it refuses to do, both found by adversarially reviewing the command against itself and reproduced before being fixed. It will not rewrite a shared file it cannot read whole: AGENTS.md and copilot-instructions.md belong to the project, and reading them with read_to_string(..).unwrap_or_default() turned “cannot decode” into “the file is empty”, one Latin-1 byte was enough for setup -y to replace a project’s rules with nothing but its own region, silently, reporting success. It now stops and names the file. And its stray-pruning compares filenames case-insensitively: the skill is written through fs::write but audited through read_dir, and on APFS or NTFS those disagree about case, so an exact comparison classified a file it had just written as a leftover and deleted it, leaving a skill with no SKILL.md while reporting updated. Folding can only err towards keeping a stale file, which is recoverable; deleting a live one is not.

      In user scope it reaches three agents, Claude Code (~/.claude/skills/), Mistral Vibe (~/.vibe/AGENTS.md, or $VIBE_HOME) and opencode (~/.config/opencode/AGENTS.md, or $XDG_CONFIG_HOME), each gated on its directory already existing, so a home directory gains no config directory for a tool that was never run. Both environment overrides are honoured: Vibe relocates its entire state directory through VIBE_HOME, so writing the default path for a user who moved theirs would leave a file nothing reads. What was not found is reported with the path that was checked, not with a label that would send a relocated install looking in the wrong place. The rest have nowhere to go, which is likewise reported rather than silently narrowed: Cursor’s user rules are edited in its settings UI, Copilot’s personal instructions live on github.com, Windsurf’s global rules are one file at ~/.codeium/windsurf/memories/global_rules.md, and a repository-root AGENTS.md is per-repository by definition.

    cargo teksilo status reports what is actually installed, every agent, in both the project and the user scope, plus the search model, the Python 3 interpreter symbol needs, and where each sits on disk. It reads only, and its exit code never depends on what it finds.

    It is the dry run of setup, not a second opinion: every agent row comes from setup::inspect, the read-only twin of the function that decides whether a write is needed, and the two share their content computation. A test pins the equivalence for every form, inspect reports current exactly when setup would report unchanged, because a status that computed “installed” its own way would eventually disagree with the command it claims to predict, and the disagreement would surface as a user following advice that does nothing.

    Three words (here / not here / n/a) and a reason beside each, because three words cannot carry the difference between Cursor is not used in this project and Cursor is used here and has no brief, and that difference is the whole of what to do next. An install from an older release reads here, since it is being read right now, with “from another release” in the detail, calling it anything else would be false. And n/a always says why: a bare one beside Windsurf would read as “Windsurf has nothing”, when in fact it keeps a global file this tool declines to write.

    Its own command rather than setup --status: it reports on both scopes while setup is scope-selected, so the flag would have to mean something the unflagged command does not. Keeping the read-only thing out of a writing command’s flag space also leaves no --status -y to reason about.

    “Reads only” is enforced rather than asserted. It asks cargo for the resolved version through --locked, because plain cargo metadata resolves, it creates a missing Cargo.lock and rewrites a stale one, which a command whose headline claim is that it touches nothing must not do. A project without an up-to-date lockfile therefore gets an honest “unknown” rather than a lockfile it never asked for. Paths reaching the report from $VIBE_HOME, $XDG_CONFIG_HOME or the working directory have their control characters escaped, so a newline in one cannot split a row and let its tail pose as another agent’s line.

    Version binding is the design constraint, not a detail. The tool reads the app’s Cargo.lock, and a minor or major mismatch refuses instead of answering, serving 0.12 answers to an app on 0.9 is worse than serving nothing, because SplitView was deleted outright in favour of Splitter between them and the wrong answer reads exactly like the right one. A patch-level difference warns and proceeds.

    What a refusal can tell a model to run depends on which side of this release the app sits on, because cargo-teksilo is the first crate in this workspace without version parity across the framework’s history: it did not exist before 0.13.0, and no tag before that carries a crates/cargo-teksilo directory. cargo-teksilo-fmt is the contrast, it has been published at every teksilo version since 0.9.0, which is exactly why a --version <app> line can be emitted there unconditionally and cannot be emitted here. So there are three regimes. Below 0.13.0 no install command exists at all, and the refusal says so and says to move the app forward; printing routes that cannot work is worse than printing none, because the model spends its turn on them and then falls back on its own memory of the API anyway, which is the single failure this tool exists to prevent. At 0.13.0 or above both routes print unconditionally, --version <v> --locked from the registry, or cargo install --path <checkout>/crates/cargo-teksilo --locked from the framework tree, because whether a given version reached crates.io is not observable offline, and an app pinning teksilo by path or git resolved a version that was never published at all. An unparseable version gets the checkout routes, which are the ones that do not depend on the registry. Where routes print, the refusal also names the two ways to keep apps on different minors working at once, installing the second with --root <dir> and putting that <dir>/bin first on PATH for that tree, or running the tool straight out of a checkout with cargo run -p cargo-teksilo.

  • teksilo-corpus, the guides and the worked examples, chunked into a retrieval index and published per release so cargo resolves the corpus matching an app’s Teksilo. It is one file: a chunk carries its own text, and its path names the original in this repository (docs/scroll-area.md, examples/simple_button/src/main.rs), so a search result cites something that opens. Data only; it carries no ML dependency. A guide’s closing navigation footer, “See also”, “Reference”, “Code references”, is carried under its own kind and excluded from retrieval rather than from the corpus: show reassembles a document from its chunks, so dropping one truncates the file, while indexing one lets a short list of links outrank the prose it points at.

  • The automation probe harness, written into a consumer’s project by cargo teksilo probe. Python, stdlib only, embedded in the binary rather than published to an index, so it is version-matched by construction, lands in the repository where an agent reading that repository can see it, and needs no pip or virtualenv. It supplies the JSON-RPC client every probe previously hand-rolled, a tool surface generated from TOOL_CATALOG (so it cannot drift from the bridge), and the virtualized-view navigation rules that are otherwise rediscovered one misdiagnosis at a time: an off-screen row has no AT node, a row scrolled back into view is a new widget with a new id, and clicking a row’s reported bounds below the viewport hits empty chrome. Three worked examples ship with it and run against this repository’s own example apps in CI.

  • Three new guides: Agent tooling documents the above; Scroll areas is the reference the docs did not have, scrolling was covered only by an architecture chapter and the kinetic-scroll physics, so an ordinary “how do I make this scrollable?” had nothing to land on; and Docking layout is the consumer guide for DockingLayout, which had only a generated catalog page and a design note written for reviewers rather than for users. Those gaps were found by running retrieval tests over the corpus, not by reading the table of contents. The design note is gone; its unstarted work is now Horizontal activity rail (backlog), which the retrieval index leaves out, a proposal written in the instruction voice reads as shipped API to whatever retrieves it, and this one outranked the guide’s own activity-rail section.

  • tools/extract_widget_api.py now covers every crate with a public API (30, up from 4), including the types a crate declares in its own lib.rs. mdBook catalog generation stays scoped to the four cataloged crates, so docs/ is unchanged; the rest are queryable through --crate, --list, --all and by name.

  • cargo-teksilo ships a README, so a crates.io reader learns what the semantic default feature pulls in, that --no-default-features is a fully working tool, where the model cache lives, and that symbol needs python3, none of which was readable outside this repository.

Changed

Breaking changes
  • OverlayDismissCallback is Rc<dyn Fn(DismissReason, &mut EventContext)>, where it was Rc<dyn Fn()>. A closure that wants neither gains two ignored parameters, Rc::new(move |_, _| …). The new #[non_exhaustive] teksilo_core::overlay::DismissReason names the route that closed the overlay: Escape, OutsidePress, PointerLeave, Cascade or Programmatic. Both OverlayRequest::on_dismiss and ModalRequest::on_dismiss carry the new type. The five OverlayManager dismissal methods that do not name a reason, dismiss, dismiss_top, dismiss_all, dismiss_except and dismiss_with_focus_restore, keep the signatures they had and report Programmatic; their dismiss_because, dismiss_top_because, dismiss_all_because, dismiss_except_because and dismiss_with_focus_restore_because twins take the reason instead.
  • Dismissing an overlay straight through OverlayManager no longer runs its on_dismiss. Code reaching through WidgetTree::overlay_manager_mut() to dismiss calls WidgetTree::dismiss_overlay instead. The ordering is unchanged, the callback still runs during dismissal, before focus returns to the trigger.
  • MessageBoxResult::dismissed_by_escape is replaced by dismissal. MessageBoxDismissal is #[non_exhaustive] and names the route, Button, Escape, ClickOutside or Programmatic, where the boolean could only say Escape or not, while its own rustdoc claimed to cover a click outside that no code path could produce. result.dismissed_by_escape becomes result.dismissal == MessageBoxDismissal::Escape; where the flag was read as “the user did not choose a button”, read result.was_dismissed(). button is unchanged, and still carries the escape-button resolution on every route.

Fixed

  • A screen reader can open a Dialog that has a custom trigger. The trigger published a named Role::Button node that advertised no actions at all and answered none, so assistive technology could see the control and not press it, while a mouse worked. This reached the ordinary Dialog::new(label), not only an explicitly hand-built trigger, and the same shape affected Snackbar and a PopoverWidget over a custom trigger. The node now advertises Action::Click and acts on it.
  • A modal presented as a native OS window honours its ModalCloseBehavior. Escape did nothing to it on macOS and Windows, including in the default configuration, where Dialog asks for EscapeOrClickOutside and the presentation resolves to a real window. The click-outside half stays unavailable there, deliberately: the OS blocks the parent window while the modal is up, so there is no outside to click, and ClickOutside now means the same as Manual for that presentation. Linux and the BSDs were never affected, they present modals in-tree, where Escape already worked.
  • A MessageBox dismissed with Escape or a press outside reports its answer. The dialog closed and the application was told nothing, on_result ran only when a button was pressed, so a “Save changes?” prompt could be waved away and leave the caller with no idea whether to save. This is the in-tree presentation, which is what ModalPresentation::Auto resolves to on Linux and the BSDs, every unix but macOS, and so was the default behaviour there; ModalPresentation::InTree reaches it on any platform. Both routes now resolve the escape button and report it, with MessageBoxResult::dismissal naming which one closed the dialog, and a button that has already answered is not reported over. Distinct from the native-window fix above, which made Escape close such a window; this makes the closing report.
  • An InputDialog dismissed without a button press reports the cancellation. None is this dialog’s cancellation payload, so a dismissal that reported nothing was indistinguishable from a dialog still sitting open , against the type’s own contract, which promises the callback runs exactly once when the user accepts or cancels. Escape, a press outside and an application-driven dismissal now all deliver None. The in-tree presentation only: a natively presented InputDialog, macOS and Windows, still reports nothing when Escape closes it, and MessageBox reported correctly on that presentation already.
  • A CommandPalette runs its on_dismiss when the palette is dismissed. The hook fired only when a command was actually invoked, so a caller that installed it to release whatever opening the palette reserved was never told about the two commonest ways of closing the palette. CommandPalette presents in-tree on every platform, so every platform was affected. It now runs on every route, and once only.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.12.1…v0.13.0

v0.13.0

Version 0.12.1 2026-09-18

No changelog entry found for version 0.12.1

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.12.0…v0.12.1

v0.12.1

Version 0.12.0 2026-09-17

teksilo-scene becomes an editor. A selection can be moved, resized and rotated, with a pointer, from the keyboard and through a screen reader, where a heavyweight item could not be dragged with a pointer at all. SceneCard is the container a note lives in, and a card can hand its height to its own words. One picker now answers every “what did the pointer hit?”, over one geometry descriptor that also brings Qt’s four selection modes and a lasso. A pan over fifty thousand lightweight items costs what a pan over an empty scene costs, and the cards nobody can see cost no accessibility node and no Tab stop. Around that: a third paint band and a surface for ink that is still wet, every position the OS batched, a seam a data layer can reverse a scene edit through, and a finger that can pan a SceneView with selection or magnetism switched on.

Breaking, pre-1.0: PathItem::new takes one argument, SceneItem loses two methods and gains one, Path::commands is a method, item_change_signal carries an envelope, and several types the crate hands to consumer code are now #[non_exhaustive]. Each one is listed under Breaking changes below with what to do about it.

Added

Moving, resizing and rotating a selection
  • A selection transform controller, opted into per view with SceneView::transform_controller(TransformConfig): a frame around the selection, eight resize handles, a rotate handle and a body drag. Knobs for aspect lock, centred scaling, rotation snaps and their tolerance, a minimum size, handle and padding sizes, edge auto-pan distance and speed, and a chrome closure for painting the whole thing yourself. on_start / on_change / on_end report the gesture; LivePreview decides whether the content moves under the frame or only the frame does.
  • The model is written once, when the gesture ends, through Scene::apply_transform_delta. One gesture is one step to reverse, Esc cancels with nothing to roll back, and a sample costs a relayout rather than one model write per selected item.
  • Keyboard: the transform key (t by default) enters the controller, Tab cycles the handles, the arrows move or resize by a step, Esc cancels.
  • SceneView::transform_session_signal() publishes the live gesture, for an app-owned inspector, status bar or size readout.
  • Assistive technology: every handle is published with a role, a name and, where it drives a single number, a value. Increment and Decrement move the coordinates that handle actually drives, and each two-dimensional handle also advertises a named action per direction (TransformStep), so a width can be changed without a height. TransformLabels takes tr! like every other label.
  • Both tiers move. A heavyweight card is draggable with a pointer at last; the lightweight drag, the Alt+arrow nudge and the controller all go through one door.
  • SceneView::transform_enabled_signal() and magnetism_enabled_signal() for a toolbar to bind. Turning either off takes its chrome off the screen and out of the published accessibility tree.
SceneCard, and a card that sizes itself to its content
  • SceneCard, the container a heavyweight item usually wants: a surface, a header that is the grab handle, a selection ring, three CardModes (Idle / Selected / Editing) and one named Role::Group, with no opinion about what is inside it. Dragging the title strip moves the card; dragging the prose inside it selects text; a trailing header slot holds buttons that can be clicked, even with the jitter a real click carries, without starting the drag. Slots take a widget, a boxed widget or an id, and surface(..) replaces the chrome entirely.
  • SizePolicy on a heavyweight entry, Fixed (today’s behaviour and the default), HeightForWidth (the width is authored, the height follows the words) and Intrinsic (the entry shrink-wraps its widget). Set it with Scene::set_size_policy / SceneModel::set_size_policy, or with SceneCard::height_for_width(). Only the cards a pass lays out are measured, so an off-screen card keeps the estimate it was created with and a pan still costs the viewport. Scene::set_measured_size is there for an app measuring something the framework cannot.
  • A SceneView answers ScrollIntoView. A caret moving inside an embedded editor pans the camera to follow it, with no app wiring, and an outer ScrollArea is re-targeted to where the card will land. It honours reduced motion, which the public SceneView::ensure_visible cannot.
  • Double-clicking a card puts the caret in its body even when that body is a Switcher that swaps the editor in on the next pass.
One geometry, one picker
  • SceneItem::shape() -> ItemShape is how an item describes its geometry: a rectangle, a rounded rectangle, an ellipse or a path, with a fill rule and an optional stroke band in local or screen units. It replaces shape_contains and clone_shape_test, which had to be kept in agreement by hand.
  • Qt’s four ItemSelectionModes, IntersectsItemShape (the default), ContainsItemShape, IntersectsItemBoundingRect and ContainsItemBoundingRect, chosen per view with SceneView::marquee_selection_mode, or per call on Scene::items_in_region and colliding_items_with.
  • SceneRegion is a rectangle or a path, so a lasso is expressible: SceneRegion::lasso(path), ::stroke(path, width), ::from_screen_rect(..). A rotated marquee stops over-selecting.
  • One resolver decides every pick. PaintKey, band, then z, then insertion order, is the order the tap, the drag, the hover, the cursor, the tooltip, the hold and Scene::item_at all read. claims_press and hit_testable are public for a consumer running its own pass.
  • PathItem carries a real fill_rule, used by painting and hit-testing alike, and hit_stroke_width for a hairline that should still be clickable.
  • On Path: flatten(tolerance) (a polyline per subpath), exact_bounds(tolerance) (a tight box, where bounds() returns the control-point hull and so over-reports every curve), contains_point(p, rule, tolerance), the Subpath type, and arc_to_cubics shared rather than duplicated inside the path atlas.
Ink
  • A third paint band. SceneLayer::Interleaved orders a lightweight item against the heavyweight cards by z, so a dried stroke can sit above note A and below note B, which two bands could not express. The pointer still resolves through the lightweight hit snapshot, the item keeps its own accessibility node, and the Tab ring does not move.
  • WetLayer, a surface for content being authored right now, installed with SceneView::wet_layer(..). It repaints on its own without re-running the band beneath it, and always sits above every card and interleaved item and below the Over band. WetLayer::request_repaint(ctx) from a pointer handler; WetNode names the nodes it owns.
  • EventContext::coalesced(), the positions the OS batched into the packet being dispatched, oldest first, each with its own timestamp and its own pressure and tilt. A drawing surface fans out over it and then over pointer_position(). Opt the producer in with TeksiloAppBuilder::pen_batching(PenBatching::Coalesce), which emits one dispatch per pen drain instead of one per packet and never folds a transition. Not on_drag, the drag recogniser swallows every move inside its slop and reports the press position, so the start of a stroke never arrives there.
  • docs/ink.md, the shape of a drawing tool on a scene page: what to read a stroke from, what to put on which band, and what a wet stroke costs.
A seam a data layer can reverse a scene edit through

Undo itself stays in the data layer; this crate ships no stack and no undo(). What it ships is a record complete enough to invert.

  • Every notification carries a transaction id, a ChangeSource (User / Programmatic / Remote), a HistoryMode (Record / RecordPreserveRedo / Ignore) and an ephemeral flag. The framework stamps its own gesture commits User, so an app can tell a finished drag from a programmatic move.
  • One write scope is one transaction, so removing a subtree is one change to reverse rather than N. Group several calls with SceneModel::transaction / user_edit; nesting joins rather than splits. SceneTransaction::abandon tags a cancelled interaction, and squash (off by default) folds a transaction’s repeated writes to one quantity into its endpoints.
  • Scene::take / Scene::restore, an owning salvage door. take hands back the item box, its magnets (ids included) and its whole slice of the logical accessibility tree; restore puts it back at the same ItemId and the same place in the reading order, which is what selection, magnets, relations and every app side-map are keyed on. Scene::remove is the same call with the salvage routed to the edit sink, and RemovedItem::detach() forgets the recorded parent so a salvage restores at root level, or into a different scene.
  • SceneModel::set_edit_sink receives one owning SceneTransactionRecord per committed transaction, with the scene unborrowed so the sink may read and write it; the sink’s own writes are journaled in turn. transaction_signal fires once per transaction, after the sink.
  • Scene::replace_item swaps a lightweight item’s box while keeping the entry, the id and everything keyed on it. Placement { parent, z, local_pos, transform } is written as one property with set_placement, plus reparent_keeping_scene_pos for a drag into a group that must not move the item, and z_between, which reports when f32 precision has run out at a locus.
  • ItemChange::is_edit() separates the scene’s edits from the derived notifications emitted beside them, so an app counting the changes in an edit gets the number the transaction record has.
  • Accessibility readers to match the writers: Scene::a11y_live_of / a11y_landmark_of, and a11y_relations / a11y_live_of / a11y_landmark_of / a11y_categories_of on SceneModel.
Saying what a change should become before it is applied
  • A geometry constraint on SceneModel, one closure that rewrites a gesture’s proposed geometry before anything is applied, so snap-to-grid, axis lock and page clamping reach the drag ghost instead of correcting it a frame late. Install it with SceneModel::set_geometry_constraint; it is handed a ProposedChange (the scene read-only, the roots the gesture moves, and the gesture’s start and proposed frames in scene coordinates) and returns a ChangeVerdict, Accept, Adjust(frame) or Reject.
  • It governs the transform controller on both tiers, the lightweight item drag and the Alt+arrow nudge, for pointer, keyboard and assistive technology alike. An app driving its own drag calls SceneModel::constrain_move / constrain_frame. Programmatic mutators are never constrained.
  • SceneModel::downgrade() and WeakSceneModel, a non-owning handle. A constraint normally needs no handle at all (ProposedChange::scene is the whole read surface); this is for a policy object that holds one for its other work, and it is what keeps such a closure from leaking the scene that owns it.
Core, platform and canvas
  • Widget::accepts_child_hit(child, point), a parent can veto one child for one point, which is the per-point question hit_transparent (a per-node declaration) cannot ask.
  • EventContext::dispatch_target(), the arena’s own answer to who the press landed on, so a container need not guess it from a rectangle.
  • EventContext::release_cursor(), withdraws a handler’s cursor override so the node-declared one resolves again.
  • A pan claimant may carry its own drag. A node that declares a PanClaim and also installs on_drag now competes as both: the pan wins at pan_slop, and a hold arms the node’s own drag. LongPressRole::DragHandle declares that the hold inside a subtree belongs to that drag, and applies to a direct pointer only, a mouse enrols no pan member at all, so nothing about it changes.
  • Every pen sample carries the device’s own time. PenPacket::device_time_ms plus a shared back_date rule that places a drained batch on the tree’s timeline, wrapped 32-bit counters and missing clocks included. Windows now drains GetPointerPenInfoHistory, so a digitizer running above the message rate delivers every sample with its own pressure and tilt rather than one per message.
  • AccessNodeBuilder::set_custom_actions advertises Action::CustomAction itself, so a node’s custom actions are reachable rather than decorative.
  • A stroke carrying a StrokeStyle, dashed, dotted, or a custom cap or join, reaches the screen from every canvas stroke primitive, not only stroke_path.
Accessibility
  • SceneMinimap is operable. Given an on_click it is focusable, publishes a Role::Group node, advertises Click and the four scroll actions, pans with the arrow keys and recentres on Home / Enter / Space. access_readout supplies the phrasing for where the viewport sits, as a MinimapReadout, so it can be localised.
Demos
  • cargo run -p scene-ink, every pen sample through ctx.coalesced(), a WetLayer that repaints alone, and a dried stroke interleaved between two notes.
  • cargo run -p scene-corkboard, beats are SceneCards with real editors: drag the title strip to move one, drag its prose to select text, pan a beat off-screen and keep typing (the camera follows the caret), and “Fit to text” hands every beat’s height to its words. Both panes carry a transform controller, and one “Snap to grid” toggle drives a single geometry constraint shared by both.

Changed

Breaking changes
  • PathItem::new(path) takes one argument and derives its own bounds. Drop the local_bounds you were passing; if you were widening it to make a hairline clickable, use hit_stroke_width instead.
  • ItemFlags::NEGATIVE_Z_BEHIND_PARENT is removed. It was declared and documented but read by nothing, and PaintKey is flat, so it could not have been honoured without making the key hierarchical. Nothing observable changes; delete the reference.
  • PointerSequence::has_deferred_grab() is now has_deferred_grab_for(id). A deferred grab is a statement about the node it arms, not about the press: read sequence-wide it suppressed every descendant’s touch long-press and context menu. Pass the node you are asking about.
  • SceneItem::shape_contains and clone_shape_test are gone, replaced by shape() -> ItemShape. An item that overrode either overrides shape() now; the default returns the item’s local_bounds, which is what an item that overrode neither already got.
  • SceneItem::set_fill / set_stroke return AppearanceWrite<T> instead of bool, carrying the value they overwrote, the only place a journal can get it from. AppearanceWrite::Refused is the default, and is what an item with no such slot should keep returning.
  • SceneItemA11yContext publishes scene coordinates. screen_bounds, local_to_screen, advertised_bounds and bounds_space are replaced by scene_bounds and local_to_scene; the camera is declared once as an AccessKit transform above the whole subtree. An item emitting its own sub-element geometry stops projecting through the view transform and publishes in scene space. A11yBoundsSpace is removed with them.
  • item_change_signal carries a SceneChange, the change plus its transaction envelope, rather than a bare ItemChange. Observers read notification.change.
  • ItemChange is no longer Copy or PartialEq (it carries owned values now) and gains PlacementChanged, ItemReplaced, HandlersChanged, MeasuredSizeChanged and SizePolicyChanged. TransformChanged, PayloadChanged and AppearanceChanged carry both sides of the value they replaced.
  • Path::commands is a method. Read with commands(), append with push, build from a vector with Path::from_commands. The field backs a rolling content stamp that a public Vec could not be kept in step with.
  • PointerSample::coalesced is a Vec<CoalescedSample>, not a tuple, each batched position keeps its own axes, which the tuple dropped.
  • PenPacket::time: EventTime is replaced by device_time_ms: Option<u32>, the device’s own counter rather than a stamp with an unknown epoch, and ToolEvent::Frame carries time_ms. A backend reports what the device said; back_date places the batch on the tree’s timeline.
  • #[non_exhaustive] on the types the crate hands to consumer code, so a new field is not a source break next time: ItemChange, SceneLayer, MagnetVisualState, MagnetRef, MagnetConnection, MagnetSnap, MagnetMarker, MagnetFeedback, SceneItemPaintContext and SceneItemA11yContext. A match gains a _ arm; a struct literal becomes the type’s new constructor, which every one of them now has, with its fields still public.
  • WidgetPlacement gained a dormant field and is now #[non_exhaustive]. A place_children implementation reads and writes the slice it is handed and is unaffected. What breaks is a struct literal, WidgetPlacement { id, origin, size }, which becomes WidgetPlacement::new(id, origin, size). The field arrives pre-set to the child’s current state, so a parent that ignores it changes nothing; it is read only for a parent whose culls_children returns true.
  • SceneCard has one method per slot. header_id, header_boxed, header_trailing_id, header_trailing_boxed, body_id and body_boxed are gone; header, header_trailing and body each take a widget, a Box<dyn Widget> or a WidgetId. Drop the suffix, .body_id(id) becomes .body(id). This follows the slot-twin purge the widget catalog went through in 0.11.0, so a card addresses its slots the way every other container does, and last call wins where before an id set after a widget left both stored.
Performance
  • A pan costs the viewport, not the model. One pan sample over a scene with a 50 000-item lightweight off-screen tail went from 16 ms to 1.8 µs, what a pan over an empty scene costs. The two hit snapshots are pure functions of the model, so they are built once and patched per item from the change stream instead of being rebuilt on every sample. (crates/teksilo-scene/tests/pan_scaling_probe.rs.) A pan over off-screen heavyweight cards is cheaper than it was but is not flat: the cards are parked rather than laid out, and what remains is the framework’s own walk over their arena nodes. (crates/teksilo-scene/tests/heavyweight_retention_probe.rs prints both.)
  • Off-screen cards park. At 50 000 of them the published accessibility node count and the Tab-stop count equal the on-screen counts exactly, and the accessibility walk no longer grows with the tail. A card carrying focus or a live pointer is pinned wherever the camera goes. (crates/teksilo-scene/tests/heavyweight_retention_probe.rs.)
  • Moving one item costs the moved subtree, not the scene. Scene::set_local_pos walks a kept adjacency list instead of rebuilding a scene-wide parent map on every move. (crates/teksilo-scene/tests/mutation_scaling_probe.rs.)
  • A hover no longer costs the selection. Scene::selection_roots is linear rather than quadratic in the number of selected items, and the transform controller resolves it once per hover instead of thirteen times per pointer move. With the whole of a 5 000-item scene selected, one selection_roots call went from 177 ms to a figure that no longer grows with the selection, and a drag sample in a scene with no constraint installed no longer pays for the selection at all. (crates/teksilo-scene/tests/selection_roots_scaling_probe.rs, transform_scaling_probe.rs print the tables.)
  • A growing wet stroke is no longer quadratic. The renderer’s path-mask cache keys on a rolling content stamp, so a cache hit does not scale with the path’s length. (crates/teksilo-render/tests/wet_stroke_cost.rs.)
Scene
  • An item_change_signal observer may read and write the scene. Changes are queued and drained after the mutation’s borrow drops, the teksilo-data mutate-then-notify discipline, so the snap-to-grid pattern the docs describe is implementable at last. An observer that panics mid-flush costs that observer’s delivery and nothing else. A CascadeBudget bounds one drain’s observer-generated deliveries (100 000 by default, flat, a knob on SceneModel::set_cascade_budget), and its diagnostic names the subject that piled up.
  • SceneListAdapter keeps each row’s ItemId. A row whose content changed, and every row an insert or a removal shifted, keeps the id it had, so selection, magnets and accessibility parenting survive a data change instead of being retired with it. Each source change is one transaction.
  • ItemChange::VisibilityChanged is emitted by every door that flips IS_VISIBLE, not only set_flag, and is a derived notification rather than an edit. Hiding a card is one recorded edit and two notifications whichever door it went through; it used to be two edits through one and one through the other.
  • Scene::set_z ignores a write only when the entry already holds that exact value. Its old f32::EPSILON test never fired at 1e6 and swallowed millions of distinct floats near zero, including the midpoints z_between hands out, so a caller was told there was room and then got a silent no-op.
  • A rotation applied by apply_transform_delta that also moves the item now emits TransformChanged as well as LocalPosChanged; set_transform ignores a write that changes nothing.
  • ItemChange::HandlersChanged carries the handler sets it replaced. handlers_mut cannot know what the caller does with the &mut it hands out, so it announces without recording.
  • A geometry constraint returning Adjust with a frame that cannot be applied, non-finite, or a negative extent, is refused rather than stored, and a debug build panics naming the frame. A NaN frame used to be kept verbatim, which removed the item from hit-testing, from the marquee and from every spatial query for good, with nothing raised to say so.
  • SceneModel::constrain_move / constrain_frame called inside an open write scope panic naming the rule instead of reporting RefCell already mutably borrowed.
  • An Over item occludes a card only if it would act on a press. A decorative halo over an embedded note takes nothing and focus stays in the note; a hover affordance is not a press claim, and neither is accepting a drop.
  • A marquee no longer takes screen-pinned items. commit_marquee runs through Scene::items_in_region, which answers in scene coordinates, and a pinned item does not live there, it holds a fixed place in the viewport, so the scene rectangle a rubber band describes says nothing about whether the pointer crossed it. Dragging a band across the page therefore leaves a pinned badge alone where it used to select it. Selecting one deliberately still works by click. There is no screen-space region query yet; the deferral is recorded at the query’s own site.
  • SceneView::retention_margin(px) sets how far outside the viewport a heavyweight card stays mounted, the band that keeps a fling from mounting and unmounting cards under the finger. Default 96 screen px; read it back with current_retention_margin.
Platform
  • PlatformWindow::reconfigure_surface answers bool rather than (): false means the display server is gone and the caller should wind down instead of asking for another frame, which would return to the same place.
  • FrameOutcome gains DisplayLost, distinct from Error and NeedsReconfigure because it is terminal: reconfiguring or redrawing after it spins the loop.

Fixed

Scene
  • A SceneView with selection or magnetism switched on can be panned with a finger. The view’s own drag was enrolled in a way that was never resolved, so its recogniser latched at the mouse slop and the pan was never evaluated.
  • A won pan no longer fires the claimant’s own on_tap, panning a list with a finger used to tap a row on the way past.
  • A revoked drag no longer leaves the item displaced. The scene had no PointerCancel arm at all, so a cancelled touch or a lost capture left the item wherever the last sample put it.
  • Leaving a SceneView clears what leaving it should clear. Its PointerLeave arm had never run, so an item stayed hovered for ever, the tooltip stayed armed, and the cursor stayed on Grab with the pointer outside the view.
  • Two ItemFlags do what their documentation says. An item without IS_VISIBLE is no longer clickable or draggable, and one without IS_ENABLED no longer eats a click.
  • Paint order and hit order agree. Equal-z ties were resolved to the bottom-most item, exactly inverting paint, and the scene’s two pickers were wired to opposite halves of the dispatch pipeline, so a tap beat every card and a drag lost to every card.
  • A card’s published rectangle tracks the camera. A heavyweight item’s accessibility bounds were its arena rectangle in scene coordinates while a lightweight item’s were projected to the screen, so the two tiers disagreed and a pan, zoom or rotation moved neither. Both are published in scene space now, under one declared camera transform, and an accessibility hit-test at the painted position finds the card.
  • A selection change no pointer made, “select all”, a search result, or the other pane of a shared SceneSelection, reaches the published accessibility tree. Both panes kept describing the previous selection, at its previous position.
  • An item’s height can be changed from assistive technology. Increment on a “Resize top” or “Resize bottom” slider reported the action handled, announced an unchanged number and moved nothing.
  • Esc cancels a transform the pointer started, not only one begun from the keyboard.
  • A revoked contact ends the gesture through the cancel path: on_end fires with TransformOutcome::Cancelled and the edge auto-pan stops. The pan used to keep tweening for up to thirty seconds with no input, and the hook an app tears its overlay down on never fired.
  • TransformConfig::min_size floors only the axes the dragged handle drives. Dragging the bottom edge of a 2 × 100 hairline used to widen it to 4.
  • A geometry constraint that accepts a change leaves a magnetised drag alone on a rotated item; the comparison deciding whether the rule had overruled the magnet was exact across a rotation round-trip that is not bit-exact.
  • A banded region, a lasso drawn as a stroke, is answered in the frame where its band is round, so an anisotropic scale no longer lets a containment query pick an item the matching shape query then rejects. A zero-length segment, and an open polyline’s phantom closing chord, are both handled.
The minimap
  • It stays inside its own frame. Zooming in projected the viewport rectangle outside the widget, and nothing clipped the result, so the minimap painted over its neighbours, a caller-supplied border by half its own width, unclamped. The projection now runs through the union of content and viewport, and the widget clips its own paint.
  • Its content extent is computed in one place; paint and click used to carry separate copies of the same formula.
Rendering and canvas
  • A dashed or dotted stroke reaches the screen. stroke_rect, stroke_rounded_rect, stroke_circle, stroke_ellipse and draw_line all accepted a StrokeStyle and dropped it, along with line_cap and line_join. A styled stroke now routes to the tier that carries the pattern, and a solid one keeps its cheaper tier.
  • The renderer no longer paints what hit-testing cannot see. A path opening with an arc was rasterised with an injected MoveTo at the bitmap origin, drawing a long spoke from there to the shape, visibly painted, and a click inside it hit nothing. The atlas opens each implicit subpath where the flattener opens it.
  • Path::transformed and Path::flatten agree about where a subpath begins. They did not for an arc following a Close, so rotating a two-loop lasso changed what it selected.
Input
  • Pen samples are spaced by the device’s own clock. Both pen backends stamped zero and the translator then stamped one instant across a whole drained batch, so velocity, smoothing and prediction were all computed from zero spacing. On Windows the batch was also genuinely one packet per message, the history buffer was never drained.
  • PointerSample::coalesced reaches a handler. It was populated and then discarded, with no accessor and two constructors writing it out empty, so a tool written the obvious way drew at frame rate on a 200–360 Hz digitizer.
Platform
  • A compositor that exits no longer takes the application down as a crash. When the display server goes away, the window surface stops answering for the adapter it was matched against, and the next Surface::configure failed inside wgpu’s default error handler, which panics. What the user saw was wgpu error: Validation Error / In Surface::configure / Surface does not support the adapter's queue family, and what that reads like is a GPU mismatch. It is not one: request_adapter filters candidates on the very query that fails there, so the adapter had answered yes to the same question moments earlier. The message cost a real investigation, and a bug report aimed at the renderer for a compositor crash. Every Surface::configure now runs inside an error scope, so the failure is returned rather than thrown. A surface with no formats left for the adapter it was matched against is reported as a lost display server, in those words; anything else keeps wgpu’s own message, so a genuine mistake is not relabelled as a dead compositor.
  • The same event reaching winit first is no longer a panic either. Both Linux backends report a failed dispatch or flush as ExitFailure(errno), and run treated every error alike. A lost display connection now exits cleanly with one line naming the cause; NotSupported, Os and RecreationAttempt stay fatal, since those are genuine startup faults where a backtrace is the useful answer.
  • Opening a window no longer kills a GNOME session on a machine with no touchscreen. The Wayland drag-and-drop backend asked the seat for its pointer and its touch as soon as a window attached, on the reasoning that a seat lacking the capability would hand back a proxy that never emits. The protocol says otherwise: wl_seat::get_touch without the touch capability is a missing_capability error, and the client is the one at fault. Mutter (GNOME 50) does not answer with that error, it serves the request out of a MetaWaylandTouch whose list head is still all-zero, because the wl_list is initialised only when a touch device appears, and writes through it. gnome-shell takes SIGSEGV, and since the compositor is the session, every window on the desktop dies with it, a second or two after the app opened one. KWin is unaffected, which is what made it read as a compositor bug rather than ours. Both objects are now bound from the wl_seat::capabilities event instead, and released when a capability is withdrawn, the press serials start_drag needs arrive on the same terms as before, since a seat that can start a drag is by definition one that announced the capability. (This is the cause of the lost display server the two entries above learned to survive.)

Known limitations

  • An assistive-technology probe and the pointer still disagree over an interactive scene overlay. accesskit_consumer resolves a hit by walking a node’s children in reverse, and the scene emits its children in band order, so the heavyweight tier wins an explore-by-touch probe whatever band the overlay is in and whatever it claims. The press-claiming rule below fixes the pointer answer, not this one. Closing it means reordering an AccessKit child list, which is also the reading order, an accessibility-tree decision rather than a hit-test one. Documented in docs/teksilo-scene-a11y.md and pinned by what_the_press_claiming_rule_does_and_does_not_reconcile, so it cannot drift silently.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.11.0…v0.12.0

v0.12.0

Version 0.11.0 2026-09-17

The teksu! DSL reaches the code applications are actually made of: a call to your own fn row(..) -> impl Widget is a child, where before only a stock widget was. Alongside it the container API loses its twin methods, so a slot has one name and that name takes a WidgetId or a widget.

Underneath, the GPU floor drops to what the renderer actually needs. A machine with no Vulkan driver could not open a window at all; it now falls back to OpenGL, which is what an older GPU has.

Added

teksu
  • A lowercase identifier that continues into a call, a method chain or an index at body position is a child: VStack { my_row(x) } and VStack { row(1).spacing(4.0) } compile. A lowercase identifier standing alone is still the argument-free property.
  • A keyword-rooted head is a child too: self.row(x), Self::header(), crate::ui::header(), super::row().
  • #{ expr } carries a widget value as well as a WidgetId, which is how a Rust struct literal is passed at body position: #{ Card { title: t } }.
Widgets
  • on_change on Checkbox, Toggle, RadioButton and Slider, taking the value the activation produced and an EventContext, so flipping one can send an intent, set the theme or open a window, things a bare Signal write cannot do, because an observer receives only &T. They fire for the pointer, for Space, for an assistive-technology Click, and, for a checkbox inside a data-view row, for Space on that row. They do not fire for programmatic writes to the bound signal: there is no event in flight to carry, and the signal remains the source of truth. A tristate checkbox reports a bool too, since activation cycles Checked ↔ Unchecked only. RadioButton reports only a real change, so re-activating the selected button is silent. Slider reports every value a drag produces, but not a write that changes nothing (a drag past the end, a snap onto the grid point already held); it has no commit-on-release callback, so once-per-interaction work still belongs on the signal.
  • StandardListItem::on_checkbox_toggle and the StandardTreeItem forwarder, which make the embedded checkbox’s on_change reachable, the canonical row builds its own checkbox, so it was the one place the callback could not be installed. To read check state, CheckedModel remains the answer: it is the source of truth and it survives row recycling, which a per-row callback does not.
  • child_opt on every container that has child, 38 of them, up from 7. A bare teksu! if now works inside a single-child wrapper, not only inside a stack.
  • 45 accumulator builders gained the plural twin that a for loop needs: tabs, static_tabs, panes, items, actions, lines, rails, docks, radios, full_width_rows, add_children and others.
Core
  • Behaviour change. The row-activation marker a widget publishes with BuildContext::set_keyboard_toggle, and the fallback passed to EventContext::row_space_activate, are now Rc<dyn Fn(&mut EventContext)> rather than Rc<dyn Fn()>. Space on a data view’s focused row therefore runs with a context, which is what lets a row checkbox fire on_change on that path instead of only under the pointer.
  • Box<W> implements Widget for any W: Widget + ?Sized, so a boxed widget goes wherever a widget goes and adds no arena node.
  • HandlerSet::merge_under composes two handler sets, the later declaration winning.
Documentation
  • The teksu! reference leads with the real widget catalog: worked examples for ListView, TreeView, TableView, TabWidget, FormLayout, MenuList, Toolbar, Switcher and DockingLayout, and a section listing only what genuinely does not work, with the compiler’s own text.
  • The spec gains a Why there is no v4 section: what the DSL was measured to cost, the four charges against it that measurement did not support, the three silent-wrong-program bugs and the one grammar rule that did stand, and the two exits that stay available if the question reopens. It replaces the standalone decision note, which is removed.
  • The binding trap is written down in both documents: binding names are one flat namespace per block, so two bindings sharing a name alias, both attach sites resolving to the later widget while the earlier one is built and attached nowhere.
  • A root NOTICE file records what the theme presets derive from and on what terms: the WinUI theme-resource values under Microsoft’s MIT licence, the Material 3 baseline tokens from documentation Google publishes under CC-BY-4.0, the macOS preset’s mix of published and measured numbers, and every bundled font with its licence, its copyright line, and whether it is embedded by default.
  • The trademark policy gains a Third-party trademarks section: it names the owners of macOS, Fluent, Material Design and Int UI, states that Teksilo is neither affiliated with nor endorsed by any of them, and separates the fluent theme preset from Project Fluent, the unrelated localization system behind Teksilo’s translations.
  • The two bundled-font license files are corrected. roboto-LICENSE.txt carried a summary of Apache-2.0 – four of its nine clauses, paraphrased, with the redistribution conditions collapsed to one line – where section 4(a) requires a copy; it now carries the complete text. noto-LICENSE.txt named one project and one year for two faces; it now carries the copyright line each font’s own name table declares, notofonts/arabic 2022 and notofonts/hebrew 2024.

Changed

  • CI runs cargo teksilo-fmt --check over crates and examples.
  • FormLayout::lines takes impl IntoTeksiChild in both columns; the field column used to require impl Widget, so a loop holding ids could not use it.
  • teksu-language-spec-v3.md is the design rationale and names teksu-macro-reference.md normative for behaviour; fourteen divergences from the implementation are corrected, and appendices A.2, A.3, A.4 and A.6 are marked superseded where they describe an API that has since been removed.

Removed

Behaviour change, breaking.

  • StandardTreeItem::on_toggle / on_toggle_rc are renamed on_chevron_toggle / on_chevron_toggle_rc. They are the expand / collapse control, and the name became ambiguous the moment a row gained on_checkbox_toggle.

  • add_child, child_id, child_boxed and every *_id slot twin, 80 methods. Call the slot by its own name instead: .child(id), .content(id), .header(id), .pane(id). Every widget-accepting slot method takes impl IntoTeksiChild, which is implemented for WidgetId and for every Widget. Not affected: shortcut_id, static_tab_with_id, the alternate constructors (from_id, new_id, around_id, custom_id, with_child_id), and Breadcrumb::item_id.

  • WidgetBuilder::dim_when_inactive and dim_when_inactive_default. Wrap instead: DimWhenInactive::new().factor(f).child(w).

  • Checkbox::labels_hidden(bool) is now Checkbox::labelled_externally(), the name and shape Toggle already used for the same thing: the control’s accessible name comes from an ancestor, so it renders no label of its own.

  • FormLayout::line_ids no longer adds one row from two ids; it takes an iterator of (label_id, field_id) pairs and adds a row per pair. The single-row form is gone because line takes impl IntoTeksiChild and accepts a WidgetId directly.

Fixed

Platform
  • Teksilo opens a window on a machine with no Vulkan driver. OpenGL is the only backend such a machine has left, an older GPU, a VM whose guest driver stops at GL, and it was never handed the platform’s display connection, so it could render offscreen but never present to a window. Startup died before the first frame with incompatible_surface_backends: GL. Confirmed fixed on both Wayland and X11 with the Vulkan driver removed.
  • Adapter selection is a search rather than a single request. An adapter that enumerates but cannot open a device no longer ends the process, and an explicit software fallback is tried before giving up, the resilience the offscreen test device already had, and the window path did not.
  • wgpu’s own environment variables take effect: WGPU_BACKEND, WGPU_POWER_PREF and the rest were silently ignored, leaving no way to move off a backend whose driver is the problem.
  • The remaining “no GPU” failure names the backends tried, the errors each gave, and what to install, in place of a Debug-printed wgpu struct.
Rendering
  • Neither atlas asks for a texture the device cannot allocate. Both grow toward a 4096-pixel ceiling that is this renderer’s own, not a fact about the hardware, and a downlevel device can sit below it; the path atlas now caps its growth to what the device reports, and a glyph atlas that arrives oversized is skipped rather than failing the frame.
Core
  • Behaviour change. A builder method with no inherent twin on WidgetWithHandlers wrapped an already-wrapped widget, and only the outer handler set reached the node, so .on_tap(cb).clips_children_on(true) never fired. Handler sets at any depth now arrive.
  • Chaining past dim_when_inactive retargeted the rest of the chain at the wrapper, so VStack::new().dim_when_inactive(0.7).child(a).child(b) built DimWhenInactive > b and lost the stack and a. The method is gone; teksilo-teksu-guard fails the build if a WidgetBuilder method returns a foreign wrapper again.
  • ArrowLeft reaches a text editor again when a second one is mounted beside it. The dispatch root reads the inline-start arrow as “back toward the parent overlay” once two or more non-host overlays are stacked, and it counted every band alike. Each mounted RichTextEditor keeps one full-viewport affordance host in the TextAffordance band for its selection handles, so two editors on one page, a prose column and the synopsis next to it, read as a submenu over its parent menu: the key tore down an affordance host and returned, and no editor in that window saw an ArrowLeft for as long as both were up. The count now considers OverlayBand::Standard overlays only, the same line OverlayBand::dismissed_by_outside_press already draws for presses. A submenu over a mounted affordance still closes on the back key.
teksu
  • teksilo-fmt re-indented a multi-line expression child by the block’s own indent on every run, walking a multi-line argument list further right each time.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.10.0…v0.11.0

v0.11.0

Version 0.10.0 2026-09-15

One strand above all: the input model is a pointer model. A touchscreen, a pen and a trackpad reach the widget tree as real pointers, a density knob resizes every target with a 24 dp conformance floor at each density, and a mouse behaves exactly as it always has. Around it: touch and a stylus in the automation bridge, a target-size gate over every shipped preset, a previewer that exports at a chosen density, every SVG icon drawn pixel-exact, and a window that opens on GLES-3.1 class hardware. The rich text editor takes text-document 1.12.2, so a sentence cut at a paragraph’s end is the sentence and nothing more. Breaking, pre-1.0: the pointer-event variants gained fields, two positions were renamed, WebViewHandle gained a required method and MemoryShared is non-exhaustive; docs/porting-widgets-to-the-pointer-model.md is the contract for a widget crossing over.

Added

Touch, pen and density

The framework’s input model was a mouse: one pointer, always hovering, always precise, always present. It is now a pointer model, and a mouse behaves exactly as it always has.

  • Touch input, end to end. A touchscreen’s contacts reach widgets as real pointers with their own identities, and a finger scrolls, selects, reorders, edits text, pinches, drags to and from the OS, and reaches a 24 dp target. Each contact gets a fresh identity per press, so an OS that reuses slot numbers cannot make two gestures look like one, and up to a documented cap of simultaneous contacts are tracked at once.
  • Pen and stylus. Pressure, tilt, barrel rotation, the eraser end, the barrel button, and proximity as a first-class state, a stylus hovers before it touches. Wayland through zwp_tablet_v2, Windows through a WM_POINTER* window subclass; no winit upgrade required.
  • Trackpad gestures. Pinch, rotate and two-finger scroll reach the widget tree.
  • TargetDensity, Compact, Comfortable and Touch. One knob resizes every interactive target, gap and padding in the application: 24 / 32 / 44 dp targets, 6 / 10 / 16 dp grab handles, spacing ×1.00 / 1.15 / 1.30. Compact is the default and is today’s layout unchanged. Set it with Theme::with_density or WidgetTree::set_input_density; the shipped Material 3, Fluent and macOS presets project their own dimensions onto the ladder rather than inheriting Teksilo’s.
  • A 24 dp conformance floor at every density, never scaled, WCAG 2.2 SC 2.5.8 (level AA). A build-breaking test measures every named widget fixture against it at all three densities.
  • Three hit mechanisms with disjoint jobs, so a small control is reachable without being redrawn: Widget::hit_outset widens a target for one pointer kind only, Widget::target_regions lets a self-painting widget say where its parts are, and a miss-only slop pass re-attributes a near miss for a coarse pointer. None of them moves a painted pixel.
  • Kinetic scrolling. A fling coasts and settles on the platform’s own curve, Android’s OverScroller or iOS’s bouncing simulation, chosen per host by default , with rubber-band overscroll available per surface. Nine scrollable surfaces adopt it from one implementation.
  • Touch text editing in every editing surface: the caret lands on the release, a hold selects the word under the finger, two draggable handles adjust the range, a magnifier shows the text under the handle, and a selection toolbar offers the clipboard. RichTextEditor, CodeEditor, PlainTextEditor, LogView, TextInput, PasswordField, SearchField, SpinBox and the date/time family.
  • A hold is a route. Where a widget installs no long-press handler of its own, the tree resolves a hold to the node’s context menu, else its tooltip, else nothing, so a finger reaches both affordances that were previously mouse-only. LongPressRole overrides the choice per subtree.
  • touch_action and pan_claim node declarations, the CSS touch-action model: a subtree can forbid panning or pinching over itself, and a scrollable declares which axes it claims. A press freezes the effective value, so nothing a widget does mid-gesture can change what that gesture was allowed to become.
  • A runtime kill switch. With InputTokens::touch_enabled off, the platform translator drops touch input and the router installs no touch-only recognizers, a mouse-only fallback with no rebuild of the binary.
  • Input tracing. TEKSILO_TRACE_INPUT=samples|gestures|all prints every sample, every arbitration decision and every cancellation, guarded so that a normal run formats nothing.
  • examples/touch_playground, a live pointer inspector (identity, kind, primary flag, pressure, tilt, twist, contact patch, speed, the frozen touch action, the press and the capture), five arbitration scenarios each naming which contender won the press, a density toggle, an editable kinetic-constants panel, and a touch-text surface.
  • A Touch tab in examples/widget_catalog, and --density compact|comfortable|touch on the catalog itself.
  • docs/touch-verification.md, the hardware procedure, and its sign-off sheet.
Automation
  • An agent can drive touch, a stylus and two contacts. New operations: a whole multi-touch sequence in one call (reporting the arbitration after every step), a two-finger pinch, a fling described in simulated time, a long press held for the active profile’s own threshold, a pointer cancelled the way the system cancels one, a query of every live pointer with its capture and arbitration state, and a density switch. inject_pointer, inject_key and type_text take a command modifier beside ctrl, because a chord declared Ctrl+S resolves to ⌘S on macOS.
Accessibility
  • The target-size gate measures every shipped preset, not the default theme alone: Int UI, macOS, Fluent and Material 3, each at all three densities, with light and dark compared for identical geometry. The fixtures moved into a crate the theme crates can reach, so a preset is audited by the same 68 fixtures the default theme is.
Previewer
  • --export-docs renders at a chosen density: --density=compact,touch writes docs/widgets/img/<slug>-touch.png beside the canonical image, and a catalog page grows a ## Density section when one exists. Compact stays canonical and keeps every existing filename, so nothing committed moves.
  • teksilo_preview::PreviewPass, the density a preview renders at, and the image-naming rule that follows from it.
Documentation
  • docs/porting-widgets-to-the-pointer-model.md, the contract for moving a widget onto the pointer model, as numbered clauses, each stating the rule, how to comply, and what checks it.
  • Documented constants are asserted against the pages that document them. The density ladder, all three gesture profiles and the kinetic constants are read back from their own tables by a test, so a number lives in one place.

Changed

  • text-document 1.12.2 and text-typeset 1.11.1.
Core

The pointer-model behaviour changes. Each is a token or a knob an application can override, and none of them changes what a mouse does.

  • A mouse drag never pans. Dragging the content of a scrollable surface scrolls it only for a touch or a stylus; a mouse scrolls with its wheel, exactly as before. Widen it per surface with ScrollHandlingOptions::pan_devices.
  • A touch or pen drag of an item waits for a long press. Reordering a list row, a grid tile, a tab or a table column with a finger means holding it first, because the surface underneath has first refusal on a direct pointer’s press. A mouse still drags from the first few pixels. DragActivation on the node decides; Auto is the default and resolves per pointer kind.
  • A touch or pen press commits on the release. Selection, activation and caret placement happen when the contact lifts, not when it lands, so a press that slides off its target commits nothing. A mouse commits on the press, unchanged.
  • A press outside an overlay dismisses it on the release, and the dismissing press does not reach what is under it. Dismissing a menu and activating the control beneath it are now two presses, on every pointer kind.
  • on_hover never fires for a touch contact, and neither do hover_within and the hovered signals. A contact does not hover, that is what a contact is, so an affordance revealed only on hover is unreachable with a finger. InputTokens::reveal promotes those affordances to always-visible at the Touch density, and the tree’s hold route reaches a tooltip regardless.
  • A pen in proximity hovers, and hover belongs to one pointer. On a machine with both a mouse and a stylus, each one’s hover follows its own device instead of a single shared “hovered” notion.
  • advance_time ticks gesture recognizers. A test that advances the virtual clock now sees a long press ripen, a fling coast and a hold dispatch; the clock is one axis for animation, gestures and kinetics rather than three.
  • WidgetEvent::PointerDown, PointerUp, PointerMove, PointerEnter and PointerLeave now carry pointer: PointerInfo, so a handler can tell a finger from a stylus from a mouse without reaching for the context. PointerEnter and PointerLeave become struct variants; a pattern that named them bare now needs { .. }.
  • PointerMove also carries modifiers: Modifiers. A drag reads Shift and Ctrl from the move rather than from the press, so a modifier pressed mid-drag reaches the widget.
  • New constructors keep a mouse-describing call site to one line and default the pointer to PointerInfo::mouse at the epoch: WidgetEvent::pointer_move_with, pointer_enter, pointer_leave, beside the existing pointer_down, pointer_up, pointer_move.
  • WidgetEvent::Scroll::position and PointerCancel::position are renamed window_position. Both stay in window-logical coordinates, the router routes by the first, and the kinetic tracker behind a pan follows the pointer rather than the widget, and they are the only positional fields a handler receives that are not localised to it. The frame is now in the name, so a reader that needs content coordinates is told to convert at every use.
  • GestureEvent::PinchChanged carries deltas, and says so. scale is the factor since the previous sample and rotation the twist since the previous sample, in radians; fold each in rather than assigning it.
  • A drag handler is told which device carries the drag at every stage, including the per-layout tick. Those previously reported the mouse, so a finger never got the wider auto-scroll band.
  • A coarse pointer’s drag preview is placed clear of the contact patch; a mouse’s is unchanged.
  • The widget under an inbound OS drag revises the OS accept state, so the cursor no longer promises a drop the target refuses. An OS drag aborted over a second window of the same application tears that window’s session down, and a drag exported from a finger on Wayland uses that finger’s press serial.
  • WindowOps::begin_os_drag and ExternalDndGuard::begin_drag take the device’s PointerKind, and both gain set_drop_accepted. ExternalDragEvent gains a Cancelled variant.
  • TouchSelection::on_long_press takes a pointer: PointerInfo. A hold is recognised by a timer, so the context it is dispatched under had no device to report; the holding contact’s identity is now an argument.
  • WebViewHandle::set_input_passthrough is a new required trait method, for out-of-tree backends. WebViewEvent::EngineFocusChanged is new.
  • MemoryShared is #[non_exhaustive] and gained four fields.
  • InspectorState::toggle() is public. New: teksilo_widgets::drop_target::{band_depth, region_at_floored, region_rect_floored}, styles::recipe_menu_item_style::menu_item_height.
Widgets
  • Nine surfaces pan under a finger from one implementation: ScrollArea, the five data views and the three text surfaces, plus Terminal and SceneView, which claim their own axes.
  • The window chrome answers a finger. A resize strip stays 6 dp of paint and reaches the density’s target size for a coarse pointer; a hold on the title bar asks the OS for its window menu where the platform has one.
  • A SplitButton chevron takes a direct pointer’s press up to the conformance floor, borrowing from the action half; a mouse’s boundary stays where it is painted.
  • A TableView header cell’s filter zone is the density’s target size, and the cell reports its three parts through Widget::target_regions.
  • A DropTarget’s edge zones, and a docking pane’s five, are floored per axis and capped at a third of the extent. Not pointer-kind-gated: the acting zone and the painted one must be one rectangle.
  • List and tree row minimum heights, combo-box dropdown rows, calendar navigation arrows, menu rows and code-editor completion rows follow the density ladder.
  • A finger’s tap on a ToolBox header or a RadioTile leaves it untinted rather than resting hovered.
  • A toast pauses on a press-and-hold and dismisses on a horizontal swipe for a coarse pointer, because hover-to-pause is unreachable with a finger.
  • Every reorder a drag can do, a keyboard and a menu can do too, WCAG 2.5.7.
  • WebView in the default Native input mode answers a press itself and revokes the pointer’s interaction, declares no touch default over its rectangle, and opts out of hit widening; Transparent asks the engine to stop taking input. Its subview is mirrored at the intersection with every clipping ancestor, hidden when out of view, and stood down while an interactive overlay covers it. Engine focus moves the toolkit’s focus onto the frame.
  • The debug inspector opens from InspectorState::toggle() or a corner-grip hold, its pressed rows take the density’s target size, and it has a Pointers tab listing every pointer declaration in the application’s tree.
  • The widget previewer takes --density, switches it live, and its canvas zoom works (pinch and Ctrl-wheel).
Terminal
  • A finger pans the scrollback with a kinetic hand-off, quantised to lines so a sub-line sample is banked rather than dropped. Double- and triple-tap select the word and the line, a hold opens the terminal’s own menu, and selection handles snap to cell boundaries.
  • A finger is never reported to the child program as a mouse. Touch reporting is its own policy, separate from the mouse reporting a program requests.

Fixed

Rendering
  • CPU-rasterized paths, which is every SVG icon, draw pixel-exact. A Tier-3 path’s coverage mask is rasterized on its own integer grid, but its quad was placed at bounds × scale_factor and sized to ceil of that, so the quad sat at a fractional device position, and at a fractional display scale it was not even the same size as its atlas region. Sampled through the atlas’s linear filter, both errors smear. A 1 px hairline drawn this way peaked at 48 % coverage instead of 100 %, and a 16 dp dashed ring lost its gaps entirely and read as a grey haze. The path pipeline now snaps the quad out to whole device pixels and bakes the bitmap against that same origin, so one texel lands on one pixel, the guarantee QuadVertex::from_glyph_quad_transformed has always given glyphs, which is why text was sharp and icons were not. The rect now travels with the raster as one PathPlacement, so the two can no longer be derived apart. Snapping is skipped under a transform, where the mask is being resampled anyway and rounding would make a translating path step between pixels instead of gliding; a gradient’s geometry is re-based onto the snapped quad, so it lands in the same place either way.
  • Two path-atlas entries no longer share an edge. The shelf packer placed them flush, so an edge fragment of any quad that is not pixel-exact on its region read a texel belonging to the next icon rather than transparent space. Each entry now reserves a one-texel transparent gutter, matching the glyph atlas.
Core
  • A two-finger pinch reaches the zoom it asked for instead of running into max_zoom. A spread to twice the starting span now leaves a SceneView at exactly twice, whatever the sample rate; before, each sample carried the ratio to the start of the gesture and the handler multiplied every one of them in, so a single spread compounded to the product of its intermediate ratios.
  • A trackpad twist turns content by the angle the user twisted. One degree of rotation on the trackpad rotated a SceneView by one radian, about 57°, because winit reports degrees and the payload is read as radians. The conversion now happens where the incoming unit is known.
  • GestureEvent::PinchChanged and PinchPhase::Changed now document scale and rotation: both are deltas against the previous sample, and rotation is in radians. Fold each sample in (zoom *= scale, rotation += rotation) rather than assigning it.
  • TouchPinchRecognizer::scale and rotation are renamed cumulative_scale and cumulative_rotation. They report the totals since the gesture started, which is not what a PinchChanged carries; the names now say which of the two a caller is reading.
  • A PointerCancel for a touch contact reported the mouse, with no position, so an application branching on the cancelled pointer’s kind behaved wrongly.
  • A container that preserves its children across a rebuild destroyed and rebuilt its whole subtree instead, whenever any builder method wrapped it. That covers every Switcher, TabWidget, SceneView, DockingLayout, Repeater and PopoverWidget with a handler attached.
  • A wrapped widget’s declared shortcuts never reached the registry, so they were missing from the rebinding UI as well as from the keyboard; a wrapped widget that opts out of layout memoisation was memoised anyway; a wrapped title bar stopped publishing its OS caption regions, a defect its own documentation had written up as a rule. Wrapped dialog content stopped lending its title to its shell and stopped directing initial focus; the context-menu key opened the view’s menu rather than the selected row’s; a table read its body to assistive technology before its header; a scene stopped grafting its items into the application’s accessibility tree; empty tooltip content still raised a bubble.
  • A coarser density could lower a grip’s reach: an outset and the miss-only slop pass were combined in the wrong order.
  • A caret could be left outside a viewport that shrank; a shrink now records a reveal.
  • Two overlay-dismissal defects a mouse could feel, and a hold dispatched under a mouse identity that was never there.
  • A target-size allow-list entry is held to the floor the audit judged against, not to the generic 24 dp table. Under a theme raising InputTokens::min_target_conformance, a PinnedDp::ClearsFloor axis excused the very failures the raised floor exists to report. TargetMeasurement/TargetViolation carry the measured floor as conformance_floor, and every consumer reads it instead of re-deriving one.
Platform
  • Teksilo could not open a window at all on GLES-3.1 class hardware. A window asked its device for wgpu::Limits::default(), which demands eight colour attachments. A Raspberry Pi 4’s V3D driver allows four, so device creation was refused and the application panicked before its first window existed. Nothing in the renderer wanted that headroom: every render pass has one colour attachment, it binds no storage buffers, its widest uniform binding is 8 KiB and its widest shader carries ten inter-stage variables. A window now asks for downlevel_defaults with the texture-dimension limits lifted to the adapter’s own, which is the set the offscreen test renderer already opens with, so a frame that renders in a test renders in a window. An adapter sitting below even that floor gets one retry with its own reported limits, which cannot be refused on limit grounds.
Widgets
  • A Fluent list or tree row can be clicked again. The preset’s selection pill spans the whole row and was hit-tested ahead of the row’s contents, so nothing inside a Fluent row, a checkbox, a disclosure arrow, a trailing button, took a press, by mouse or by finger.
  • A macOS switch and an icon button meet the 24 dp target floor. Both are drawn at the size the preset asks for, Apple’s 22 dp track and the 18 dp compact icon button among them; the node around the chrome is what grew.
  • Three controls showed no pressed appearance for a pointer at all, the ToolBox header, RadioTile, and the calendar’s month/year cell, the last only ever writing false into its own state.
  • A declared row height inside the row-metrics dead band was silently refused, so an exact-height list reported a different total height from a uniform one.
  • A leaf row in a tree table no longer carries an invisible pointer target.
  • The previewer’s knob rows were unreachable by any pointer, mouse included: an infinite height cap inside a scroll area measured inf tall, its y resolved to NaN, and a rectangle containing NaN contains no point.
  • With the debug inspector installed, switching density destroyed the wrapped application’s tree.
  • Four dimensions a shipped theme sets now reach the screen. A table header cell pads by the Fluent gutter (12 dp, not 8); a calendar’s navigation arrows are drawn the size macOS asks for (20 dp, not 24); a search field’s suggestion rows carry the macOS row gutters (8 x 3 dp, not 10 x 4). Each was written on the theme’s recipe and discarded, because the widget measured the shipped Int UI constant instead of asking the style. TableStyle, CalendarStyle and SearchFieldStyle now hand the dimension over, and a custom style keeps the ladder it had unless it says otherwise. Int UI at the default density is unchanged.
  • A theme may draw a control below the 24 dp target floor without costing the app its WCAG 2.2 SC 2.5.8 conformance. A calendar’s navigation arrow keeps whatever size the theme asks for and sits inside a box that reaches the floor, so macOS’s 20 dp stepper is drawn at 20 dp and still answers a 24 dp press, from a mouse as much as from a finger. Under Int UI, whose arrow is already the floor, nothing moves at any density.
Terminal
  • A terminal placed anywhere but the window’s top-left corner accepted no pointer input at all.
  • The wheel scrolled the scrollback backwards, and its report to the child program named cell (0, 0) instead of the cell under the pointer, so a full-screen program that splits its window scrolled the wrong pane.
  • Dragging a selection handle moved the selection to the neighbouring row.
Previewer
  • The toolbar’s Export PNG ignored the live density, so exporting while the previewer was set to Touch produced a Compact image, over the Compact filename. The density now reaches both the render and the name.
Text
  • A sentence cut at a paragraph’s end is the sentence, and bold lands on the selection. From text-document 1.12.2. Once anything had been typed earlier in the document since the last deletion, a use case addressing a range read a block start the keystrokes had left behind, so Cut and Copy at a paragraph’s end took the paragraph break and the head of the next paragraph along with the sentence, and bold, italic, replace and “make a list” landed the same number of characters late. In a document holding a table the caret now also reaches the last characters after it, and a selection to End includes them.
  • --all-features builds. Five fonts-* features named a Noto face that is not in the repository, and because include_bytes! resolves at compile time, enabling one was a hard build error, so those five features, both fonts-all meta-features, and any --all-features build of the workspace had never compiled on any revision. A face is now embedded only if its file is present; enabling a feature without it warns and names the path to drop it at, rather than failing the build.
  • Dragging the caret handle moves the on-screen keyboard’s candidate window with it. Every editing surface reported the caret’s position when a press placed it, and none did when a finger dragged it, so composing Japanese, Chinese or Korean after a handle drag put the candidate list at the caret’s old position. RichTextEditor, CodeEditor, PlainTextEditor, TextInput, PasswordField, SearchField, SpinBox and the date/time family; the assistive-technology route onto the same handle reports it too.
  • The code editor’s gutter returned from inside a clip scope when no text backend was installed, leaving the render frame unbalanced.
  • A PlainTextEditor could neither replace nor suppress the context menu its documentation offered it, and the CodeEditor family gained the right-click menu it never had.
  • --all-features builds. Five fonts-* features named a Noto face that is not in the repository, and because include_bytes! resolves at compile time, enabling one was a hard build error, so those five features, both fonts-all meta-features, and any --all-features build of the workspace had never compiled on any revision. A face is now embedded only if its file is present; enabling a feature without it warns and names the path to drop it at, rather than failing the build.
Documentation
  • A ScrollArea in the Thin scroll-bar mode documented a keyboard route it does not have. The bar’s arrow / Home / End / Page handlers sit on a node that cannot take focus, under every mode, the same limit the ScrollBarPolicy::AlwaysOff documentation states from the other side.
  • PointerInfo::primary’s documentation described the wrong one of three similarly-named things. The field is the W3C per-kind flag, a mouse and a first finger are both primary at once on a hybrid machine, and the rule it carried (“exactly one live pointer, a mouse always wins”) belongs to the pointer table’s own election.
  • StandardListItem’s documentation claimed the row reads its recipe’s projected heights. It projects the module constants instead; those two recipe fields have no reader.
  • The soft-keyboard documentation claimed a touch-placed caret leaves a stale IME area standing. Each editing stack reports the area from its own touch path; the unused core method is superseded rather than missing.
  • Two committed catalog images had gone stale, one of them contradicting a safety fix: the message-box preview showed Yes as the accented default after the widget had switched to No, and the colour-picker preview predated the widget growing its HSV row.
  • Catalog pages linked to rustdoc module pages that do not exist on docs.rs, because the module is crate-internal; each link now resolves to the nearest published module.
teksu
  • teksu! bodies could not place gesture_dead_zone or keyboard_capture before a child; both failed with “no method named child”. A compile-time guard now keeps the DSL’s builder-method list complete.
  • 31 teksu! UI fixtures had never run, a glob naming a directory that does not exist, which trybuild reports as “no tests enabled yet” and then passes, and one of them was asserting a caret column rustc does not emit.
  • A bare child in any popover produced the generic error instead of the slot hint.

Known limitations

  • The hardware sign-off is not done. Everything above that a headless Linux host can check is checked by the suite; docs/touch-verification.md is the procedure for the rest, and its sign-off sheet is empty. One question in it can only be answered on a macOS trackpad: whether a positive trackpad rotation delta should be negated at the platform seam.
  • A running application does not switch density because a finger arrived. DensityPolicy::FollowLastPointer has an ingress and no writer; what a stray tap should cost and when hysteresis commits are unanswered.
  • Overscroll is published but not painted. ScrollableAxes::overscroll carries the value; nothing renders a stretch or a glow.
  • A plain ScrollArea has no keyboard scroll route. Assistive technology can scroll it and the data views bring their own key handling, but a keyboard user facing a scroll region whose content holds no focus has none, a pre-existing WCAG 2.1.1 gap, now written down.
  • A finger cannot drag a window on Wayland. No protocol for it exists.
  • The target-size gate is green for three crates’ named fixtures, not for the framework: four crates that own targets have no fixture list, and the lists install one preset.
  • Reordering a data-view row with a finger is unreliable on short rows. A deferred drag is revoked if the first sample after the hold leaves the pressed row, and the touch drag slop is wider than a default tree row.
  • cargo check --workspace --all-features does not build on any revision, including before this release: five of the seven optional fallback font faces are named by the build and were never committed.
  • The full ledger, each entry with its measurement, is Touch & pen §10.

Full Changelog: https://github.com/FernTech-EU/teksilo/compare/v0.9.5…v0.10.0

v0.10.0

Release data updated 2026-10-05. https://github.com/ferntech-eu/teksilo/releases