Teksilo Documentation
Teksilo is a pure-Rust GUI framework for serious desktop applications: a retained widget tree with SwiftUI-style layout, AccessKit accessibility, and a wgpu renderer.
This site has two layers:
- Widget Catalog — a per-widget discovery page for every shipped widget (its abilities, an example, and a deep link to the full rustdoc API). Start here if you are exploring what Teksilo can draw.
- Reference & design docs — the focused subsystem documents below (layout, styling, events, accessibility, data, …). Start here if you are building.
The catalog pages are generated from the widget source by
python3 tools/extract_widget_api.py --md-dir docs/widgets; the same tool prints
any widget's API to the terminal (python3 tools/extract_widget_api.py Button).
Architecture & roadmap
- architecture.md — framework-internals reference: scrolling, arena, Canvas API, rendering pipeline, HiDPI, threading, testability, crate dependency graph, design comparisons, open questions. Per-subsystem APIs live in the focused docs below.
teksilo-milestones.md— the demo-driven milestone roadmap; each milestone produces a runnable example exercising one slice of the architecture.
Widget catalog
- widgets-overview.md — every shipped widget
categorized (layout / visual / containers / buttons / inputs / text
family / menus / overlays / data-driven / charts / animations /
settings) with a one-line description and source-file link. Pair with
python3 tools/extract_widget_api.py <Widget…>for the full API surface of any widget.
Authoring widgets
- layout-primitives.md —
HStack/VStack/ZStack,Grid,Wrap,MasonryLayout,FormLayout,Switcher, and the size wrappers (Expand,FixedSize,MinSize,MaxSize,AspectRatio,Center,Padding,Spacer,Divider). - events-and-gestures.md — preview/bubble dispatch,
attached handlers (
.on_tap,.on_hover, …),on_key_preview,focus_within/hover_within,FocusScopeTab-traversal scopes, gesture recognizers. - styling-system.md — the four-tier styling ladder
(tokens → variants → recipes → style protocols);
Themeaggregator,ThemeAppearance, per-widget*Variantenums and*Styletraits, per-call vs theme-wide style installation, writing a custom preset. - reactive-theme.md —
Signal<Theme>, role-driven colors (ColorProp,TextStyleProp), reactive switching without rebuild. - animation.md —
Signal<f32>::animate_to,MotionTokens,AnimationSpecbuilder, the animated wrapper widgets (Fade, Pulse, Crossfade, Scale, Blur, …). - idle-and-animation.md — the zero-frame rule;
how
next_timer_deadline()keeps the event loop asleep when nothing is moving. - accessibility-overrides.md — builder-level
.access_*modifiers (label, description, subtree merge/exclude, custom actions, shortcut binding) for the cases widget-emitted a11y misses. - text-scale.md — the global "grow all text" accessibility
setting: the
TextScaleControlwidget, persistence + startup restore, theeffective_themeblanket mechanism,ctx.text_scale/text_scale_signal, the per-enginefont_scalefor editable text, and thefollow_text_scaleopt-in/opt-out surfaces (icons,RichTextEditor, scene text).
teksu! DSL & formatting
- teksu-macro-reference.md — user-facing reference
for the
teksu!block-DSL (parse → IR → builder calls). - teksu-language-spec-v3.md — design spec with full grammar, structural forms, and worked translations of catalog examples.
- teksilo-fmt.md —
cargo teksilo-fmt, the formatter forteksu!blocks (rustfmtskips macro bodies). - teksilo-fmt-vscode.md — wiring
teksilo-fmt-lspinto VS Code for in-editor formatting.
Input, navigation, chrome
- shortcut-intent-action.md —
Shortcut/Intent/Actionpipeline,#[derive(IntentKind)], rebindable keystrokes viaShortcutRegistry. - tooltips.md — plain
TooltipWidget, registry-drivenRichTooltipWidget, sticky-on-dwell promotion, focus-driven a11y promotion, attach helpers. - toast.md —
Toastfloating notifications (info/success/warning/error/loadingseverities, link + button actions,Toast::idupdate-in-place) +ToastHostqueue + persistentNotificationArchiveModel+NotificationLog/ bellNotificationCenterButton/NotificationLogDialogUI;TeksiloAppBuilder::install_toast_default()one-line install. - native-menu.md — declarative
MenuModelshared by the in-windowMenuBarand the macOS native menu bar (NSMenu);MenuBar::from_model(..).native_on_macos(..),TeksiloAppBuilder::install_native_menu(), reactive checks, ⌘ key equivalents, focus-follows-window. - web-view.md — embeddable
WebViewwidget (native OS subview on top of wgpu), pluggableWebViewBackend(wry default / Servo additive for Wayland / headless), the dormancy→set_visibleactivation bridge, JS↔Rust IPC,install_web_view_default(). - drag-and-drop.md — drag payloads, drop targets, hit testing, the three user stories that share the underlying machinery.
- multi-window.md —
WindowConfig, signal-driven multi-window orchestration, modal dialogs, restore-from-state. - title-bar.md — custom widget-level title bar plus the
per-OS
PlatformTitleBarHostfor drag / zoom / close / inset. - toolbar.md —
Toolbarcommand bar with automatic overflow: actions (priority /always_overflow/ toggle), pinned + collapsible custom widgets (overflow_asmenu row,overflow_widgetlive embedded control, theToolbarOverflowtrait), theMenuList-backed chevron menu, display modes / orientation, and the ARIA roving-tabindex pattern.
Data, persistence, telemetry
- data-models.md —
ListModel,TreeModel,SelectionModel,CheckedModel/TreeCheckedModel(per-row checkbox state with optional descendant→ancestor tristate aggregation), sort/filter projections; the model layer that sits above the widget tree. - data-source.md — the
ListDataSource/TreeDataSourceread-and-command interface every data view talks to: the capability protocol (identity, DnD validation viacan_accept/accept_drop, lazy/windowed loading), keyed selection, and how an external source of truth drives a view without a mirror model. - settings.md — reactive end-to-end persistence:
SettingsStore,SettingsFile<T>,MruList<T>, window-state auto save/restore. - telemetry.md — consent-gated event reporting, the
teksilo-collector / Plausible / OTLP adapters, the
events.yamlschema pipeline.
Async & concurrency
- async.md — the optional main-thread async executor
(
teksilo-async):install_async(),ctx.spawn_local(...)/spawn_local_with,spawn_blocking, the async-agnosticon_loop_tickhook, and theteksilo-tokio/teksilo-async-stdreactor adapters for awaiting native runtime futures. Off by default; complements the reactivesubscribe_eventdata path.
Specialized widgets
- splitter.md — N-pane
Splitterwith draggable, collapsible dividers, per-pane stretch, animated collapse (four triggers), a shared serializableSplitterModel, and a Tier-3SplitterStyle. The building block forDockingLayout. - docking.md — VS Code-style
DockingLayout: a centre slot + four collapsible/splittable/draggable side regions, per-corner ownership, an activity rail, drag-to-dock five-zone overlay, and a cloneable serializableDockingModelwithexport_state/import_state. - table-view.md — virtualized
TableViewandTreeTableView(multi-column, sort/filter, drag-resize, drag-reorder, full keyboard navigation). - tab-widget.md —
TabBar<T>andTabWidget(static + dynamic tabs,Signal<Option<TabId>>selection, pinned tabs, drag reorder, overflow dropdown, horizontal + vertical orientations). - charts.md —
BarChart/LineChart/PieChart(shared axis / palette / legend / tooltip infrastructure). - teksilo-scene.md — the pannable, zoomable scene viewport (canvases, board layouts, diagram editors), including magnetism (typed snap-and-connect between item anchors).
- teksilo-scene-a11y.md — shaping the accessibility
tree of a
teksilo-sceneviewport, including synthetic magnet nodes and the rovingactive_descendantkeyboard connect flow.
Visuals & resources
- icons-and-resources.md —
res!()-embedded SVG / PNG / WebP icons with theme-aware tinting.
Tooling
- inspector.md —
teksilo-inspector, the in-app debug surface (Tree / Properties / Accessibility / Theme / Models tabs; picker + bounds overlay; debug-only).
Teksilo Architecture Document
Version: 0.3 — slim refresh Date: May 6, 2026 Author: Cyril Jacquet, with Claude (Anthropic) and Mistral Medium as sounding boards and formatting help. Status: Living reference — framework-internals doc; companion focused docs in this directory own the per-subsystem API surface
Document scope. This document covers the framework-internals topics that have no dedicated home elsewhere: scrolling, arena state, Canvas, rendering pipeline, HiDPI, threading, testability, crate structure, and the comparative-design rationale. Every subsystem with a dedicated reference doc in this directory has been collapsed here to a one-paragraph pointer; section numbers are preserved so external links by
§Nand heading-slug anchors continue to resolve.If you are looking for how to use a subsystem, the focused doc is the right entry point. Read this doc when you are debugging the engine, porting to a new platform, writing a custom widget that needs the Canvas escape hatch, or onboarding to maintain the framework itself.
Where the per-subsystem references live:
- Layout:
layout-primitives.md- Events / gestures / focus / DnD lifecycle:
events-and-gestures.md- Animation:
animation.md- Idle / zero-frame rule:
idle-and-animation.md- Reactivity & theming:
reactive-theme.md- Shortcuts / intents / actions:
shortcut-intent-action.md- i18n:
i18n.md- Accessibility overrides:
accessibility-overrides.md- Drag and drop:
drag-and-drop.md- Data models:
data-models.md- Settings and persistence:
settings.md- Telemetry:
telemetry.md- Multi-window:
multi-window.md- Custom title bar:
title-bar.md- Tooltips and overlays:
tooltips.mdteksu!DSL:teksu-macro-reference.md,teksu-language-spec-v3.md- Inspector:
inspector.md- Widget catalog snapshot:
teksilo-milestones.md,tools/extract_widget_api.py --all- Per-widget reference docs:
table-view.md,tab-widget.md,charts.md,teksilo-scene.md
1. Vision and Positioning
Teksilo is a pure-Rust GUI framework for serious desktop applications — the kind of software where a user sits down for hours at a time and reaches for the keyboard first. A writing tool for novelists, an IDE, a dispatch console, a course manager for a taxi company's driver training. Teksilo is infrastructure for professional desktop software that needs native look and feel, full keyboard and screen-reader accessibility, and a rich text surface built from the ground up.
Teksilo's thesis rests on three pillars. First, accessibility is a structural requirement, not an afterthought — AccessKit is integrated at the trait level, not bolted on. Second, rich text is a first-class concern — the text-document and text-typeset crates provide a complete document model and typesetting engine covering shaping, bidi, line-breaking, and atlas rasterization. Third, the framework is designed to be consumed by applications with structured architecture (Clean Architecture, MVVM), providing a typed Shortcut / Intent / Action pipeline and reactive data-model crate (teksilo-data) rather than leaving application structure as an exercise for the developer.
1.1 Relationship to structured application architectures
Teksilo is the outermost layer of an application — the "Frameworks & UI" ring in Clean Architecture's concentric circles. It has no dependency on any particular application framework. A Qleany-structured application is one supported integration path and was the stress test that shaped several of Teksilo's architectural choices (typed intents for command flow, view-models over raw entities, data sources for paged external collections), but nothing in Teksilo requires Qleany.
The integration surface is the typed intent system (Teksilo widgets emit application-defined intent variants that ancestor Actions consume — see shortcut-intent-action.md) and the reactive data models in teksilo-data (application-written view-models hold entity collections as ListModel<EntityVM> / TreeModel<EntityVM> that widgets bind to — see data-models.md).
Teksilo splits internally into focused crates (see §25) each with a single concern, rather than imposing a Clean-Architecture split on its own internals. Layout, rendering, and event dispatch have fundamentally different performance characteristics from transactional domain operations; the useful seams fall in different places.
1.2 Reuse Strategy
Teksilo builds on established crates rather than reinventing solved problems. winit for windowing and HiDPI; wgpu for GPU rendering; text-document + text-typeset for the rich text model and typesetting (harfrust shaping, swash rasterization, etagere atlas, unicode-linebreak, unicode-bidi); AccessKit for cross-platform a11y; fluent-rs for i18n; tiny-skia for Tier 3 path rasterization.
2. Layout Model
Full reference: layout-primitives.md. The protocol is SwiftUI-style negotiation — the parent proposes a size, the child responds with LayoutResponse { size, flex, min, shrink }, the parent decides the main axis (grow via flex, shrink via shrink/min), measures the cross axis at each child's final main size (height-for-width), and places. Slack distribution, the shrink/over-constraint model, the Shrinkable wrapper, zero-basis vs respect_intrinsic, container/per-child alignment, and the size-wrapper primitives (Expand, Shrinkable, FixedSize, MinSize, MaxSize, Center, Padding, Spacer, Divider) all live there. A per-pass memoization cache (WidgetArena::cached_layout_response, keyed (id, proposal), cleared each pass) keeps the main-then-cross queries O(n); widgets that mutate state in layout_response opt out via Widget::cacheable_layout() -> false.
What's not in the focused doc and stays here:
2.1 Binding Levels and Dirty Propagation
Some property changes affect only a widget's visual appearance (a color change). Others affect the widget's size (a text change, a constraint change). The binding system distinguishes these two cases because they trigger different dirty-tracking responses.
Repaint-level bindings (color, background, border_color) mark the widget for repaint only when the bound state changes. The layout pass is skipped — the widget's position and size are unchanged. This is the fast path, used for interaction-driven visual state changes (hover color, pressed color, enabled/disabled appearance).
Relayout-level bindings (text, width, height, min_width, max_height) mark the widget for relayout when the bound state changes. The layout pass reruns on the affected subtree, and the dirty flag propagates upward to ancestors because a child's size change may affect its parent's size, which may affect the grandparent's size, and so on. Propagation stops at an ancestor whose own size is not affected by its children (for example, a FixedSize wrapper with a static width).
The classification is determined by the primitive widget's binding method implementation, not by the consumer. A TextWidget implementor knows that text is relayout-level because changing the text changes the widget's layout_response result. A composite widget author or application developer does not need to think about this distinction — they call text(state) and the framework handles the rest.
Layout utility widgets with dynamic constraints. The size constraint widgets (MinSize, MaxSize, FixedSize) accept state bindings for their constraint values, enabling dynamic resizing from application state changes, user-driven splitter interactions, or animation ticks. FixedSize::width(state) registers a relayout-level binding — when the state changes, the widget's constraint changes, triggering relayout of the affected subtree.
Relayout propagation. When a widget is marked for relayout, the framework marks the widget and all its ancestors up to the root as needing relayout. During the layout pass, it starts from the highest dirty ancestor and works downward, re-running layout_response and place_children for each dirty node. Clean subtrees are skipped. This is the same incremental layout approach used by web browsers and by Qt's layout system. A relayout always implies a repaint for the affected widgets.
3. Scrolling and Viewports
A scroll area is a container whose content may be larger than the visible space. The scroll area acts as a viewport — a window into a potentially large content region. Only the visible portion of the content is rendered, clipped to the viewport boundary.
Scrolling is designed to require minimal changes to the framework. The scroll offset is encoded through the existing layout placement mechanism, not as a separate coordinate transformation layer. Hit testing, event dispatch, and the state system require no modifications. The changes are confined to the arena (one new flag), the paint pass (clip rect support), the renderer (scissor rects), focus management (scroll-into-view), and the scroll area widget itself.
3.1 Layout: Unbounded Proposals and Offset Placement
A scroll area participates in layout like any other container widget. In layout_response, it claims the space its parent offers — this becomes the viewport size. In place_children, it proposes an unbounded size on the scroll axis to its content child. For a vertical scroll area, the content receives SizeProposal { width: Some(viewport_width), height: None } — "use the viewport width, but be as tall as you need." The content child responds with its natural height (potentially thousands of logical pixels).
The scroll area then positions its content child at (viewport.x, viewport.y - scroll_offset.y). This encodes the scroll offset as a position offset within the normal placement system. No special coordinate transformation infrastructure is needed — the existing place_children / WidgetPlacement mechanism handles it. The recursive layout function processes the content child and its descendants with the offset origin, and all bounds stored in the arena end up in correct screen-space positions.
SizeProposal already supports None values for unbounded dimensions. No changes to the SizeProposal type or to layout_widget_recursive are required.
3.2 Hit Testing: No Changes Required
The existing hit_test_recursive provides viewport clipping implicitly. It checks bounds.contains(point) on the parent before recursing into children. A point outside the scroll area's viewport bounds is rejected at the scroll area's bounds check, and no child is tested. Children scrolled above the viewport have negative screen-space y coordinates that no in-viewport point would match. Children within the viewport have correct screen-space bounds (computed from the offset placement) that match pointer positions directly.
No changes to the hit testing code are needed. The scroll offset encoded in placement positions and the existing parent-bounds containment check together provide correct viewport-clipped hit testing.
3.3 Clipping in the Paint Pass
The paint pass requires one new capability: clipping child rendering output to the scroll area's viewport bounds. Without clipping, children positioned near the edge of the viewport would render partially outside it.
The arena's WidgetNode gains a clips_children: bool flag (default false). The scroll area widget sets this flag to true on its own arena node. When paint_widget enters a node with clips_children: true, it pushes a clip rect (the node's own bounds, which represent the viewport) onto the Canvas before recursing into children, and clears the clip after all children are painted.
The Canvas already provides set_clip(Rect) and clear_clip() methods that produce DrawCommand::SetClip and DrawCommand::ClearClip entries in the RenderFrame. The change to paint_widget is approximately five lines: check the flag, push clip, recurse, pop clip.
3.4 Renderer: Scissor Rect Implementation
The SetClip and ClearClip draw commands exist in the RenderFrame but are currently no-ops in the renderer. The implementation maps directly to wgpu's scissor rect API: render_pass.set_scissor_rect(x, y, width, height) for SetClip (coordinates in physical pixels, multiplied by scale factor), and resetting the scissor to the full surface dimensions for ClearClip. This is approximately ten lines of code in the renderer.
Nested scroll areas (rare but valid — a scrollable sidebar inside a scrollable page) require a clip rect stack. Each SetClip pushes a rect, and the effective clip is the intersection of all rects in the stack. ClearClip pops the top rect and restores the previous intersection.
3.5 Focus and Scroll-Into-View
When Tab navigation moves focus to a widget that is inside a scroll area but outside the current viewport, the scroll area must scroll to make the focused widget visible. Without this, keyboard users cannot see what they have focused.
After focus_with_origin sets focus to a widget, the framework walks up the ancestor chain. If any ancestor has clips_children: true, the framework checks whether the focused widget's bounds are fully within that ancestor's viewport bounds. If not, the framework dispatches a WidgetEvent::ScrollIntoView { target_bounds: Rect } to the clipping ancestor. The scroll area handles this event by adjusting its scroll offset to bring the target bounds into view, using the minimum scroll change needed to make the widget fully visible (or centering it if the widget is larger than the viewport).
3.6 The ScrollBar Widget
The scroll bar is a standalone Level 2 widget in teksilo-widgets, not a rendering detail inside ScrollArea. A standalone widget participates in the framework's hit testing, event dispatch, focus, and accessibility systems. Its thumb is a region within its bounds that the framework's existing pointer routing handles. Its accessibility node declares Role::ScrollBar with set_numeric_value, set_min_numeric_value, set_max_numeric_value, and Action::SetValue.
The ScrollBar stores the current scroll position and the content-to-viewport ratio (both provided by the ScrollArea via shared Signal<f32>). It computes thumb position and size from these values. It handles PointerDown on the thumb (start drag), PointerMove during drag (update position), PointerUp (end drag), and PointerDown on the track (page-scroll toward click position). It supports both vertical and horizontal orientations.
3.7 ScrollArea and ScrollBar Interaction
The ScrollArea owns the scroll state (Signal<f32> for each axis). The ScrollBar reads from and writes to this shared state. The ScrollArea and ScrollBar communicate through the reactive binding system, not through events or callbacks.
The ScrollArea supports two scroll bar display modes via ScrollBarStyle.
Overlay mode (default, matching macOS and modern Linux). The ScrollArea's viewport occupies the full available width — the scroll bar does not reduce the content area. A thin passive scroll indicator (a few semi-transparent pixels at the trailing edge) is painted directly by the ScrollArea during scrolling as a visual hint. When the pointer enters the scroll bar activation zone (a region at the trailing edge wider than the thin indicator), the ScrollArea shows the full interactive ScrollBar widget as an overlay using the existing overlay system (OverlayPlacement::NearAnchor, DismissBehavior::PointerLeave). The overlay ScrollBar appears on top of the content, receives pointer events for thumb drag and track click, and dismisses when the pointer leaves. The viewport width never changes. The transition from thin indicator to full scroll bar can be animated using the animation scheduler.
Permanent mode (matching traditional Windows/GTK style, or when the user's accessibility preferences request always-visible scroll bars). The ScrollBar is a layout sibling of the content viewport. The ScrollArea's internal structure becomes an HStack of [clipping viewport] + [ScrollBar]. The viewport is narrower by the scroll bar's width. The scroll bar is always visible and always interactive. The viewport width is constant (reduced by the scroll bar width but never changing dynamically).
The mode is selected via ScrollArea::new(content).scroll_bar_style(ScrollBarStyle::Overlay) or ScrollBarStyle::Permanent. The application or the theme can set a default. An accessibility preference for "always show scroll bars" overrides to Permanent mode.
3.8 The Scroll Area Widget
The ScrollArea is a Level 2 (Widget trait) widget in teksilo-widgets. It is the viewport container — it owns the clipping behavior, the layout negotiation with unbounded proposals, and the content offset placement described in Sections 3.1–3.5.
The scroll offset for each axis is stored as a Signal<f32> (not a raw Vec2), because the ScrollBar widget needs to read and write the position through the reactive binding system. When the ScrollBar's thumb is dragged, it sets the shared Signal<f32>. The ScrollArea's binding on that state triggers a relayout, which re-runs place_children with the updated offset. When the user scrolls via mouse wheel or trackpad (WidgetEvent::Scroll), the ScrollArea updates the Signal<f32> directly, and the ScrollBar's thumb position updates via the same binding path.
The ScrollArea creates and manages a ScrollBar widget according to the active ScrollBarStyle (Section 3.7). In overlay mode, the ScrollArea paints a thin passive indicator during its own paint() pass and shows the interactive ScrollBar as an overlay on pointer proximity. In permanent mode, the ScrollBar is a layout child positioned as a sibling of the content viewport. The ScrollArea sets clips_children: true on its arena node so the paint pass clips content to the viewport bounds.
The ScrollArea handles WidgetEvent::ScrollIntoView to support focus-driven scrolling (Section 3.5) — it adjusts the Signal<f32> offset to bring the target bounds into view.
For accessibility, the ScrollArea declares Role::ScrollView with scroll position properties (set_scroll_x, set_scroll_y and their min/max ranges) and page-level scroll actions (Action::ScrollDown, Action::ScrollUp, Action::ScrollLeft, Action::ScrollRight). The ScrollBar declares its own Role::ScrollBar with set_numeric_value, set_orientation, and Action::SetValue for direct position control. These are two separate AccessKit nodes with complementary roles.
3.9 Interaction with Virtualized Lists
The ListView widget (backed by ListModel<T> or ListDataSource) depends on scrolling. The scroll offset determines which items are visible. The ListView only instantiates widget subtrees in the arena for visible items plus a small buffer above and below the viewport. As the user scrolls, items leaving the viewport have their subtrees destroyed and items entering the viewport have new subtrees created.
The ListView does not need a general-purpose "scroll area wrapper" — it implements the scrolling behavior internally, because it needs tight control over which items have widget subtrees. It uses the same mechanisms as the scroll area (offset placement, clips_children: true, WidgetEvent::Scroll handling) but also manages the item lifecycle in the arena.
3.10 Accessibility for Scroll Areas and Lists
The scroll system produces two AccessKit nodes with complementary roles. The ScrollArea declares Role::ScrollView with scroll position properties (set_scroll_x, set_scroll_y and their min/max ranges), set_clips_children(true), and page-level scroll actions (Action::ScrollUp, Action::ScrollDown, Action::ScrollLeft, Action::ScrollRight). The ScrollBar declares Role::ScrollBar with set_numeric_value (the current scroll position), set_min_numeric_value, set_max_numeric_value, set_orientation, and Action::SetValue for direct position control by assistive technologies. Screen readers use the ScrollView node to announce the scrollable region and the ScrollBar node to present the scroll position as an adjustable value.
For lists, AccessKit provides Role::List with Role::ListItem for static lists, and Role::ListBox with Role::ListBoxOption for interactive selectable lists. The critical properties for virtualized lists are set_position_in_set(index) on each visible item and set_size_of_set(total_count) on the list container. These tell screen readers the logical position of each item ("item 5 of 200") even when the AccessKit tree only contains the items currently visible in the viewport. Items outside the viewport do not exist in the arena and therefore do not appear in the AccessKit tree — no special mechanism is needed to exclude them.
4. Widget State Ownership
Teksilo uses a retained widget tree with arena-backed flat storage, following the approach proven by Masonry's TreeArena.
All widgets live in a flat SlotMap-like arena. Parent-child relationships are stored as ID references within the arena. The tree structure is explicit (unlike a pure ECS where relationships are implicit), but the flat storage avoids Rust's borrow-checker challenges with recursive mutable tree traversal.
The framework processes the tree through well-defined passes (event, layout, accessibility, paint), each of which traverses the arena without holding multiple mutable references simultaneously. This is the key insight from Masonry: separate the passes so that no pass needs to mutate a widget while reading another widget's state.
5. Widget Extensibility
The unified Widget trait has a single build(&mut self, ctx) for composition, a single paint() for own-visuals, and both are optional with sensible defaults. Leaf widgets implement layout_response + paint; container widgets implement layout_response + place_children + children; composing widgets implement build + layout_response (delegating to the child); hybrid widgets (Card, ScrollArea) implement build + paint. Reference: CLAUDE.md "Unified Widget Trait" and crates/teksilo-widgets/src/button.rs.
5.1 The Slot System
Standard widgets ship with named extension points — slots — at structural boundaries where extension is anticipated. A slot is an optional placeholder that takes zero space when empty and accommodates arbitrary widget content when filled. Slots are part of a widget's public API contract; standard composites in teksilo-widgets ship with leading_slot, trailing_slot, header_slot, footer_slot at positions where extension is commonly needed.
#![allow(unused)] fn main() { TabWidget::new() .tab("Chapter 1", || chapter_editor(1)) .trailing_slot(|ctx| { HStack::new() .child(Button::icon_only(Icon::Plus).on_activate_fn(|ctx| ctx.send_intent(AppIntent::AddChapter))) .child(Button::icon_only(Icon::ChevronDown).on_activate_fn(|ctx| ctx.send_intent(AppIntent::OpenChapterMenu))) }) }
6. UI Construction Patterns
The framework provides three child-addition methods on container builders — add_child(WidgetId) for pre-registered children, child(impl IntoWidgetTree) for inline insertion, and children(iter) / child_opt(Option<_>) for iterator and conditional shapes — plus the Repeater for dynamic non-virtualized collections driven by ListModel<T> change notifications. Composites use the static child() chain when content structure is fixed for the lifetime of the widget; visible_when(Signal<bool>) toggles individual subtrees between active and dormant without reconstruction; the Repeater handles small collections that change during interaction; ListView virtualizes large collections. The teksu! DSL desugars to these same builder calls.
References: CLAUDE.md "Widget Construction Patterns", teksu-macro-reference.md, data-models.md (Repeater vs ListView).
7. Reactivity Model
Signal<T> is the only reactive primitive. Signal::new(x) is mutable; signal.map(f) is read-only and derived; multi-source combinators (a.zip(&b), a.and(&b) / a.or(&b) / s.not()) compose, and selector.flat_map(|t| inner_signal(t)) switches the result to follow a dynamically-selected inner signal (reactive "switchLatest", O(1) binding). Prop<T> is the widget-property wrapper accepting either a static T or a signal-bound value. ObserverHandle provides RAII cleanup; WeakSignal<T> breaks reference cycles. Builders accept impl Into<Prop<T>> for properties and impl Into<ColorProp> / impl Into<TextStyleProp> for theme-aware colors and typography.
The division of labor: simple property reactivity is declarative (the widget declares a binding, the framework reacts); structural changes (switching tabs, adding/removing children, activating/dormant-ing subtrees) are imperative, requested from a handler via EventContext (ctx.set_dormant, ctx.activate, ctx.destroy, and ctx.with_widget_mut::<W>(id, level, |w| …) for a typed by-id mutation of any mounted widget that opts into Widget::as_any_mut — e.g. reaching SceneView::scene_mut()). These are deferred and applied after the handler returns, when the framework holds &mut arena access. This split is what lets Teksilo avoid both full view diffing and ad-hoc observer soup.
References: CLAUDE.md "Signals & Reactivity", reactive-theme.md, events-and-gestures.md (deferred operations).
8. Conditional Rendering and Dormancy
The widget arena supports three activation states for widget subtrees.
Active — fully operational. Participates in layout, receives events, paints, has AccessKit nodes, holds rendering resources.
Dormant — state preserved, rendering resources released. Does not participate in layout, receives no events, has no AccessKit nodes. The widget data and state values remain in the arenas. Reactivation triggers relayout and repaint, but no reconstruction.
Destroyed — removed from the arena entirely. State is gone. Must be rebuilt from scratch.
Three construction strategies control the memory/responsiveness tradeoff for multi-pane widgets:
Eager — all subtrees built at construction time, inactive ones set to Dormant. Switching is instant. Suitable for tab widgets with a small number of tabs.
Lazy — subtrees built on first activation, then preserved as Dormant. Suitable when building a subtree is expensive and the user may never visit all tabs.
Transient — subtrees built on activation, destroyed on deactivation. Lowest memory, highest switch cost. Suitable for browser-like scenarios where each tab is independent.
Rebuild and child reconciliation
When a widget's build() re-runs (a BindingLevel::Rebuild signal fired, or an explicit rebuild request), the framework reconciles the widget's children against what the new build() returned. Widget::preserves_children_on_rebuild() selects the policy:
false(default) — re-derive. Every old child subtree is destroyed up front, thenbuild()produces a fresh set. Correct for data-driven widgets (Repeater,ListView) that reconstruct children from current model state with freshWidgetIds. Afalsewidget must not re-attach an old child id — it is already gone.true— reconcile.build()re-attaches (by id) the children it keeps and drops the rest. The framework keeps every re-attached child's subtree intact — focus, scroll offset, text contents, signal subscriptions all survive — and destroys only the children the new build dropped and did not re-parent elsewhere. This is the mode for widgets that memoize stateful children across rebuilds:Switcherpages,TabWidget/DockingLayoutpanes, theCompositeTooltipbody,SceneView's heavyweight scene widgets, andMenuBar's leading/trailing slot widgets (re-derived menu triggers reaped, memoized slots kept — so a stateful slot control survives a model-version rebuild).
The reconcile is scoped (it only considers the rebuilt widget's own direct children, so floating retained nodes held outside the child tree — e.g. dormant menu/popover content registered via ctx.add — are never touched) and parent-authoritative: a kept subtree that the rebuild re-parents out of a dropped sibling and into the new tree survives, because the destroy walk follows live parent pointers rather than the dropped sibling's now-stale children list. The per-node teardown frees the arena slot via a single-node removal (Arena::remove_node), so the recursion the destroy walk already performed is not duplicated by the arena. Net effect: a preserve = true widget that removes a child (a closed tab, a superseded tooltip chrome) genuinely reaps it — it does not leave a stranded, still-Active orphan in the arena.
9. Event System
Full reference: events-and-gestures.md. Preview pass (root → strict ancestors of target) plus bubble pass (target → root); attached handlers stored on WidgetNode (.on_tap / .on_hover / .on_key / .on_key_preview / .on_focus / .on_scroll / .on_pointer_event / .on_access_action); auto-wired gesture recognizers; EventContext API including deferred mutations (set_dormant / activate / destroy / with_widget_mut::<W> / request_focus / request_accessibility_update / dismiss_all_overlays); subtree state signals (.focus_within(Signal<bool>) / .hover_within(Signal<bool>)); AccessAction routing through the same dispatch machinery. request_accessibility_update() (also on BuildContext) forces an AccessKit re-walk after a handler/build restructures its subtree in a way a relayout wouldn't otherwise surface to AT.
Backend events (database change notifiers, file watchers, message buses) plug in via the EventSource trait — widgets subscribe from build() via BuildContext::subscribe_event, and cross-thread forwarding goes through winit's EventLoopProxy. Per-widget lifetime cleanup: when the widget is destroyed, the subscription handle drops and the source unsubscribes. A rebuild is not a destroy: build() re-runs and re-subscribes, and the SubscriptionId it gets back is the one the previous build used at that same position (BuildContext::reusable_sub_ids). That identity has to span rebuilds because it is what crosses the thread boundary — a publisher captures the id and posts it, the UI thread dispatches it frames later, and minting a fresh id in between would strand every event already in flight. Matching is positional: the Nth subscribe call of one build inherits the Nth of the last, a build that subscribes fewer times leaves the surplus torn down, and a build that subscribes more allocates fresh ids for the extras.
10. Gesture Recognition
Full reference: events-and-gestures.md. UIKit-style state machines (TapRecognizer, DoubleTapRecognizer / TripleTapRecognizer, LongPressRecognizer, DragRecognizer) with a GestureArena for competition. Recognizers are auto-wired from attached handlers — the framework instantiates a TapRecognizer when a node has an on_tap handler, a DragRecognizer when it has on_drag, and so on. Tap-family callbacks receive &TapEvent { position, button, modifiers }; default acceptance is ButtonMask::PRIMARY only (right-click never spuriously fires on_tap), widened via .accept_tap_buttons(...) and friends.
11. Actions, Intents, and Shortcuts
Full reference: shortcut-intent-action.md. The three-layer pipeline: Shortcut (rebindable keystroke → intent name) → Intent (runtime DTO with optional typed payload) → Action (ancestor-registered handler keyed by intent name). #[derive(IntentKind)] with #[name = "..."] provides the typed-enum DTO bridge (unit, tuple, and struct variants). ShortcutRegistry holds two layers (declared defaults + persisted user overrides with graveyard semantics) and exposes a Signal<u64> version so menu labels and tooltips re-render on rebinds.
12. Internationalization
Full reference: i18n.md. Fluent-rs runtime (I18nManager, LocalizedString, locale resolution, .ftl file watcher, fallback chains, LayoutDirection signal); compile-time-validating macros tr! / tr_widget! / tr_signal! / tr_signal_widget! that read .ftl files at expansion and validate every call against the parsed key map; locale-aware formatters (NumberFormatter, TeksiloDateTimeFormatter, TeksiloDateTime) backed by ICU4X (icu_decimal / icu_datetime / icu_calendar) with a custom DATETIME() Fluent function and bundle set_formatter callback so { NUMBER(...) } / { DATETIME(...) } inside .ftl messages render correctly across locales. Framework-string registration for teksilo-widgets is explicit: applications call .framework_locales(teksilo_widgets::framework_locales()) on the I18nConfig builder chain.
13. Overlay System
Full reference: tooltips.md for tooltips (plain + rich + registry, sticky-on-dwell, focus-driven a11y promotion). Multi-window modal flow lives in multi-window.md.
Engine internals: OverlayManager per WidgetTree. Two rendering layers — OverlayLayer::InTree (drawn into the same RenderFrame as the host) and OverlayLayer::NativePopup (separate winit window, used for menus that must escape the host window's bounds); OverlayLayer::Auto picks based on placement and platform. OverlayPlacement covers Below / Above / BelowPreferred (auto-flips on insufficient space) / TrailingEdge / AtPointer / NearAnchor / BottomCenter. DismissBehavior::ClickOutside | PointerLeave | Manual plus an Escape-cascade root handler. Delayed-open overlays (submenu hover delays) cancel via EventContext::cancel_delayed_overlay(id). Overlay anchor positions invalidate on host relayout. AccessKit nodes for overlay content cascade under their logical parent, not the geometric root, so tooltips DescribeBy their anchor and menus Owned-By their menu bar item.
OverlayRequest::with_fade(duration) wires the framework-managed opacity tween at show/dismiss — caller specifies the duration, framework handles the signal, the set_opacity scope, and the deferred dormant-set after the dismiss tween completes.
14. Drag and Drop
Full reference: drag-and-drop.md. Three scenarios (intra-widget reorder, inter-widget transfer, external/OS drops) share one machinery: typed DragPayload, source/target traits, hit testing under the cursor, drop-zone preview overlay, edge auto-scroll during hover, spring-load on dwell, full keyboard equivalence (Cut / Copy / Paste actions on a focused list/tree). Inbound external drops (files / text / URLs dragged from the OS into a window) are implemented via the ExternalDndBackend per-OS backends (macOS NSDraggingDestination verified; Windows OLE, Wayland wl_data_device, X11 XDND via an XdndProxy helper window) and reuse the same machinery — see drag-and-drop.md §11 and the DropZone widget. Outbound drags (Teksilo window → another app) are implemented on every desktop target (macOS NSDraggingSource + Wayland wl_data_source verified; Windows OLE IDropSource; X11 XDND source) — a MIME-carrying start_drag auto-escalates at the window boundary, with typed re-entry enabling cross-window DnD. See drag-and-drop.md §11.5.
15. Data Model
Full reference: data-models.md. The teksilo-data crate sits between the widget tree and application view-models, providing ListModel<T>, TreeModel<T> + TreeSlice<T>, SelectionModel, and the ListDataSource trait for paged/external collections. SortFilterListModel<T> and SortFilterTreeModel<T> are projection wrappers that sort and filter without copying the source. DataChange / TreeChange notifications drive Repeater, ListView, TreeView, TableView, TreeTableView updates.
The crate is separate from teksilo-core because collections are a higher layer than the widget tree — view-models live in the application, hold these models as fields, and bind widgets to them. Qleany integration (generated EntityListModel / EntityTreeModel typed against entity DTOs) is one supported path; nothing in teksilo-data requires it.
16. Canvas API
16.1 Purpose
The Canvas is the high-level drawing API that widget authors program against. It replaces direct RenderFrame manipulation with operations that match how developers think about graphics — shapes, colors, text, transforms.
#![allow(unused)] fn main() { fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) { canvas.fill_rounded_rect(bounds, CornerRadius::uniform(6.0), theme.colors.primary); canvas.draw_text(&self.label, bounds.center(), &theme.typography.label); } }
16.2 Three-Tier Rendering
The Canvas internally classifies each drawing operation and routes it to the appropriate rendering tier.
Tier 1 — Axis-aligned rectangles. fill_rect, stroke_rect, simple draw_line. Translated directly to DecorationRect entries. Zero rasterization cost. Covers the majority of UI drawing.
Tier 2 — SDF shader shapes. Rounded rectangles, circles, ellipses, gradients. Rendered as quads with a specialized fragment shader computing signed distance fields. Smooth antialiasing at any resolution without rasterization. The RenderFrame gains ShapeQuad entries for this tier.
Tier 3 — Arbitrary paths. Complex shapes, custom curves, SVG icons. Rasterized on CPU via tiny-skia, cached in a shape atlas, rendered as textured quads. Rasterization is amortized by caching — static paths rasterize once.
16.3 Path Builder
The Path type provides a builder for arbitrary shapes:
#![allow(unused)] fn main() { let star = Path::star(center, outer_radius, inner_radius, 5); canvas.fill_path(&star, Color::GOLD); }
16.4 Text Integration
The Canvas delegates text rendering to the shared Typesetter instance from text-typeset. draw_text handles simple single-line text. draw_text_layout renders pre-measured text for cases where layout measurement and painting are separated. The RichTextEditor widget uses draw_render_frame to embed a complete text-typeset RenderFrame at a specific position, sharing the same glyph atlas.
16.5 Paint Types
Beyond solid colors, the Canvas supports Paint types: LinearGradient, RadialGradient, ConicGradient, and Image. Gradients are rendered in the SDF fragment shader (Tier 2). Widgets carry a fill as a PaintProp (a flat ColorProp or a gradient, in teksilo-core); RectWidget resolves it to a Paint at paint time (gradient endpoints are rect-local, computed from the widget's size). Anything Into<ColorProp> is also Into<PaintProp> as a solid, so the common case is unchanged.
17. Rendering Pipeline
17.1 Frame Lifecycle
A frame is produced only when something has changed. Between frames, the application is idle and the GPU is quiescent. The frame lifecycle has five phases executing sequentially on the main thread.
Phase 1: Event processing. Raw input from winit is translated and dispatched through the widget tree. State changes from property bindings are resolved. Widgets are marked dirty.
Phase 2: Layout. SwiftUI-style negotiation runs only on dirty subtrees. Output: positioned rectangle for every active widget.
Phase 3: Accessibility sync. The AccessKit tree is updated incrementally — only changed nodes are pushed.
Phase 4: Paint. Each dirty widget's paint() is called with a Canvas. The Canvas accumulates drawing operations and produces a merged RenderFrame.
Phase 5: GPU submission. Atlas textures are uploaded, vertex buffers are built, draw calls are issued through wgpu. The surface presents.
17.2 RenderFrame
The RenderFrame is the boundary between platform-independent logic (teksilo-core, teksilo-canvas) and GPU-specific code (teksilo-render). It contains five drawable types: GlyphQuad (textured from glyph atlas), ImageQuad (textured from image), DecorationRect (untextured colored rectangle), ShapeQuad (SDF-rendered shape), and RasterizedQuad (textured from shape atlas). A draw_order array records painter's order (back-to-front) for correct occlusion across all drawable types.
17.3 GPU Pipeline
Three shader pipelines in teksilo-render: the quad pipeline (textured quads for glyphs, images, rasterized paths), the rect pipeline (untextured colored quads for decorations), and the SDF pipeline (signed distance field shapes with optional gradient fills). A typical frame produces five to six draw calls total.
17.4 Atlas Management
Three atlas textures serve different purposes. The glyph atlas is owned by the shared Typesetter (from text-typeset), containing rasterized glyph bitmaps. The shape atlas stores Tier 3 rasterized path results from tiny-skia. The image atlas (or texture array) stores application images. All use LRU eviction — dormant widgets' entries age out naturally.
Glyph atlas lifecycle and the eviction contract
Glyph quads bake atlas pixel coordinates at paint time, and the framework retains painted output at several layers (per-widget cached_paint / cached_post_paint, the assembled cached_frame, the scene per-item cache). LRU eviction can therefore invalidate quads that are still being replayed — the contract that keeps this sound has three legs:
- Keep-alive. Every replay of a retained frame calls
TextBackend::touch_layoutfor each of the frame'slayout_keys(per-widget cache hits, post-paint cache hits, and the full-frame early-out inrendering_impl.rs), refreshing the glyphs' LRU timestamps so on-screen glyphs never age out. - Eviction reporting.
TextFontService::eviction_epochis the single source of truth: it is bumped by every eviction path — theatlas_snapshotscan, the scan at the start of every rich-textrender()(build_render_frame), and the wholesale reset on scale-factor change.TypesetterBridge::atlas_infocompares it against a last-seen value and reportsglyphs_evicted; the app-level recovery inteksilo-appthen clears the bridge caches and callsinvalidate_all_paintson every window (the bridge and atlas are shared process-wide), requesting redraws. Caches living outside the widget arena (the sceneItemCoordinateCache) instead self-gate onTextBackend::glyph_epochat paint time. - Versioned uploads. Each window's renderer owns its own GPU atlas texture.
atlas_info(seen_version)carries a monotonic contentversion; a window uploads (and receives pixels) only when its recordedatlas_uploaded_versionlags. This replaces consume-once dirty semantics, so several windows all converge on the same atlas content instead of the first caller consuming the upload for everyone.
Debug-build corruption catcher. In debug builds, every evicted atlas rectangle is poison-filled magenta (text-typeset), so any stale-UV sampling is visually unmistakable; and every retained-frame replay validates its layouts via TextBackend::debug_validate_layout — a glyph whose live atlas rect no longer matches the baked quads aborts with a diagnostic (RectMismatch), and a layout the backend no longer knows logs a loud warning (StaleKey). Release builds compile all of this out.
17.5 Dirty Tracking
Each widget has a dirty flag at two granularities: needs relayout (size may have changed) and needs repaint (appearance changed, size unchanged). Clean widgets replay cached Canvas output without recomputation.
18. HiDPI and Scaling
Layout works in logical pixels. Rendering works in physical pixels. The conversion happens at the boundary between Phase 4 (paint) and Phase 5 (GPU submission).
SizeProposal, widget dimensions, spacing, padding, and font sizes are all logical. The Canvas also works in logical coordinates — canvas.fill_circle(center, 10.0, color) draws a circle with a 10-logical-pixel radius regardless of display density.
The scale factor is applied in two places: text-typeset rasterizes glyphs at physical pixel size (logical × scale factor), and teksilo-render multiplies screen coordinates by the scale factor when building vertex buffers.
When the scale factor changes (window dragged to a different monitor), the glyph and shape atlases are invalidated and a full relayout is triggered.
19. Theming — the four-tier styling ladder
Full references: styling-system.md (the four-tier ladder — tokens → variants → recipes → style protocols) and reactive-theme.md (the Signal<Theme> reactive layer).
Theme lives in teksilo-core::styles (not teksilo-tokens) so the per-widget style trait protocols and the typed Rc<dyn FooStyle> slot bag can sit on the same struct. It carries a required appearance: ThemeAppearance ({Light, Dark} — drives shadow density, OS-theme matching, asset selection), five token groups (ColorTokens, LayoutTokens, TypographyTokens, ShapeTokens, MotionTokens), ComponentStyles (dimension data for the not-yet-themable widgets), ComponentStyleSlots (typed style-trait overrides), and a ThemeExtensions registry. There is no Theme::default() / Theme::*_default() — apps pick a preset explicitly (teksilo_core::presets::intui::{light, dark}).
Every themable widget composes its chrome through a Tier-3 style trait (ButtonStyle, ToggleStyle, …) rather than self-painting: the widget builds its parts, hands a *StyleConfig to the active style, and uses the returned WidgetId as its root child. The style is resolved per-call (.style(...)) → theme-wide (theme.style_slots.<widget>) → Recipe*Style default. Signal<Theme> reactivity — set_theme updates the signal and dirty-marks every node, no rebuild; focus, scroll, text-input cursor, expanded sections all survive a switch. Role-based widget surface (TextRole, SurfaceRole, BorderRole, TextStyleRole) plus ColorProp / TextStyleProp wrappers; widgets resolve roles against the current theme at paint/layout time. Subtree theme overrides via set_theme_override(id, |theme| …). Themes derive Serialize + Deserialize for user-loadable theme files (the style_slots and extensions fields are #[serde(skip)]).
20. Threading Model
20.1 Single UI Thread
All five phases of the frame lifecycle run sequentially on the main thread. The widget tree, state arena, overlay manager, Canvas, and all contexts are non-Send types — the compiler prevents accidental access from background threads.
This matches Qleany's synchronous model. A Qleany controller call from a Teksilo command handler executes synchronously. No async/await, no tokio, no runtime.
20.2 Background Work
Long operations use Qleany's LongOperationManager, which runs use cases on background threads. The background thread communicates with the UI thread through winit's EventLoopProxy — a unidirectional channel that wakes the event loop and delivers custom events. The UI thread processes these events like any other input, triggering data source refreshes and widget repaints.
20.3 Incremental Work
Operations that take 5–50ms (too short for a background thread, too long for a single frame) are broken into chunks via request_idle_callback. The event loop runs idle work during gaps between frames, respecting a time budget.
20.4 Event Loop
The winit event loop uses ControlFlow::Wait — it sleeps when no events are pending and no widgets are dirty. CPU and GPU consumption is near-zero when the user is not interacting. Full rationale and the four enforcement gates: idle-and-animation.md.
20.5 Animation
Teksilo does not ship a separate animation subsystem. Animation is a thin layer over Signal<f32>: signal.animate_to(target, duration, easing) asks the tree's AnimationScheduler to smoothly interpolate the value over time, and any widget bound to the signal re-paints on each tick as the value slides. The scheduler integrates with the frame lifecycle (pause when the window is occluded, rebase on resume, skip offscreen ticks, cancel animations on widget rebuild/destroy), so widgets never own animation lifetime manually.
The design intent is narrow: motion is reserved for a small set of floating transitions — dialog appearance, snackbar slide-in, accordion expansion, toggle thumb motion, indeterminate progress, smooth programmatic scroll. Hover, press, and focus state changes are explicitly instant in Int UI's vocabulary; they are expressed as Signal<Role> mapped from an interaction signal and resolved per-frame through the theme, not through the animation scheduler. Looping animations respect ctx.prefers_reduced_motion().
Full rationale, API, worked examples, and testing patterns: animation.md.
21. Accessibility
Full reference: accessibility-overrides.md. AccessKit is integrated at the Widget trait level — every widget's accessibility(builder) declares role, name, state, and available actions. AT actions flow through the same dispatch as pointer/keyboard input via WidgetEvent::AccessAction. Builder-level .access_* modifiers (access_label, access_description, access_hidden, access_role, access_disabled, access_controls / described_by / labelled_by, access_live, access_shortcut_id / access_shortcut_literal, access_action / access_remove_action / access_custom_action, access_exclude_subtree / access_merge_subtree, access_customize) let app authors augment, replace, or annotate any widget's accessibility info from the outside.
Dormant subtrees produce no AccessKit nodes (screen readers only see active content). Overlay content generates correct AccessKit tree structures — tab lists have Role::TabList and Role::Tab nodes, menus have Role::Menu and Role::MenuItem nodes, tooltips are linked to their anchor widget via DescribedBy. Scene-content a11y customization (off-screen modes, logical groups) lives in teksilo-scene-a11y.md.
22. Window Management
Full reference: multi-window.md. Each window owns its own independent WidgetTree, layout pass, paint pass, RenderFrame, and wgpu surface. Application-level context (theme, locale, ShortcutRegistry, data-model handles, app-scoped backend wiring) is shared across windows. WindowConfig is the single creation entry point for both initial and runtime-opened windows; WindowState is the per-window cloneable signal handle (placement, title, size, position, focused, resizable, always_on_top). Two-way OS↔state sync uses an applying_from_os re-entrancy guard to prevent observer→OS→observer loops.
Custom window chrome (drag region, resize strip, window controls, per-OS title bar host backends): title-bar.md.
23. Settings and Persistence
Full reference: settings.md. In-memory is the source of truth — Signal<T> and *Model<T> handles drive both UI and disk; the disk side is a debounced atomic projection (write-temp + rename, single shared I/O thread per process). Three persistence shapes: SettingsStore (dotted-key K/V for scalars), SettingsFile<T> (typed single-struct with Versioned + Migrator<T> migrations on raw toml::Value), and PersistedListModel<T> / PersistedTreeModel<T> (bridges from ListModel<T> / TreeModel<T>). Built-in services: MruList<T: MruEntry> for generic dedupe + pin + LRU-cap recents; WindowStateService with framework-driven auto-save/restore for any WindowConfig carrying id(...). Saved geometry is sanitized on restore against the current monitor's work area. Wayland ignores window position by protocol design (size and WindowPlacement round-trip).
24. Testability
24.1 Headless by Design
The widget tree runs without a window, without GPU, and without winit. All five phases (minus GPU submission) execute in pure Rust with no platform dependencies. Tests use teksilo-core's WidgetTree directly:
#![allow(unused)] fn main() { #[test] fn button_click_fires_action() { use std::cell::Cell; use std::rc::Rc; let mut tree = WidgetTree::new(); let clicked = Rc::new(Cell::new(false)); let clicked_flag = clicked.clone(); let root = tree.add(FillWidget::new()); tree.push_action( root, Action::new("app.save").on_invoke(move |_i, _c| clicked_flag.set(true)), ); let button = tree.add_child( root, Button::new(lit!("Save")).on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)), ); tree.layout(SizeProposal::exact(200.0, 40.0)); tree.click(button); assert!(clicked.get()); } }
24.2 What Is Testable
Layout (given a widget tree, do children end up at the right positions), event dispatch (does the right widget receive events, does focus cycle correctly), state transitions (hover/pressed/disabled), accessibility (correct AccessKit role, name, actions), render output (expected quads/shapes in the RenderFrame), theming (palette swap produces correct colors), gesture recognition (pure state machine tests), overlay behavior (tooltip timing via simulated clock), drag-and-drop (payload transfer, insertion indicator rendering), and composition (multiple widgets interacting correctly).
24.3 Mock Backend
Cargo feature flags (mock-backend) swap Qleany controller implementations with mock modules providing static data. Same API surface, zero backend. Familiar to the developer from Qleany's C++/Qt mock system for QtQuick.
24.4 CI Friendly
No Xvfb, no GPU, no display server required. Pure logic tests run in cargo test in milliseconds. The simulated clock (tree.advance_time()) enables deterministic testing of time-dependent behavior.
25. Crate Structure
Full per-crate descriptions live in CLAUDE.md "Crate Architecture". The dependency graph is the part that belongs here.
25.1 Dependency Graph
teksilo-tokens
↑
teksilo-canvas ← tiny-skia
↑ ↑
teksilo-core teksilo-text ← text-typeset
↑ ← accesskit
│
├── teksilo-data
│ ↑
│ teksilo-settings ← serde, toml, directories, tempfile
│ ↑
│ teksilo-telemetry ← uuid
│
├── teksilo-widgets
│ └── teksilo-text ← text-document, text-typeset
│
│ teksilo-i18n ← fluent-rs, icu_decimal, icu_datetime, icu_calendar
│
teksilo-render ← wgpu
↑
teksilo-platform ← winit, accesskit-winit
↑
teksilo-app (wires teksilo-text into Canvas, teksilo-widgets, teksilo-i18n,
teksilo-settings — auto-restores/saves window geometry,
optionally teksilo-text)
↑
teksilo (umbrella, re-exports)
teksilo-text depends only on teksilo-canvas (for the TextBackend trait) and text-typeset. It does not depend on teksilo-core, text-document, or any platform crate. The TextBackend trait is defined in teksilo-canvas so that the Canvas can call text rendering methods without knowing which backend implementation is active.
The RichTextEditor widget (in teksilo-widgets) depends directly on text-document and text-typeset. The application owns the TextDocument instance and passes it to the widget — Teksilo never owns or wraps the document model. The application depends on text-document directly for model access (highlighter, cursors, import/export). Cargo deduplicates the shared dependency automatically.
Platform-specific code (winit, wgpu, accesskit-winit) is confined to teksilo-render and teksilo-platform. Everything above them is platform-independent and headlessly testable.
25.2 The teksilo Umbrella
The standard application developer depends on a single crate: teksilo. It re-exports the public API and controls feature flags. text, i18n, and rich-text are default features (opt-out, not opt-in), because the kinds of applications Teksilo targets — writing tools, editors, IDEs, content managers, long-running desktop apps — routinely need text rendering, translations, and rich text editing. TextInput itself derives from the rich-text widget, so anything with an editable text field pulls in rich-text anyway. Sub-crates remain independently publishable for advanced users (custom widget authors, custom renderer implementors).
26. Button — Reference Widget Design
The button serves as the reference implementation exercising most architectural features: composition of primitives, interaction state as a Signal<InteractionState>, role-based color resolution per visual state, attached handler activation from multiple input paths, AccessKit role and actions. A new widget author implementing their first custom widget should read crates/teksilo-widgets/src/button.rs — it's the authoritative exemplar, and concrete code is more useful than prose at this point. See also reactive-theme.md for the Signal<Role> pattern Button uses for its visual states.
What Button exercises:
- Composition. A
RectWidget(background, border, corner radius) wrapping an internalHStackorVStack(byIconPosition) containing an optionalIconWidgetand aTextWidgetlabel. Leading/Trailing positions respect localeLayoutDirection. - Visual states. Five (idle, hovered, pressed, focused, disabled) × seven variants (
Filled,Tinted,Outlined,Plain,Ghost,Link,Destructive) → (background role, border role, text role) resolved at paint time viaSignal<InteractionState>mapped toSignal<Role>. - Behavior. Pointer enter/leave/down/up drives interaction state; keyboard Space/Enter triggers activation; cursor is
Pointeron hover;TapRecognizercommits the click. - Accessibility.
Role::Button, name from label (resolved viatr!/tr_widget!), disabled state, actions (Click,Focus). Focus ring painted only on keyboard focus (origin-aware).
27. Architectural Comparisons
27.1 vs. QPalette → Design Tokens
QPalette covers color roles across three interaction groups. Teksilo's design token system extends that scope to spacing, typography, and shape, uses typed Rust structs, and supports subtree overrides through environment propagation.
27.2 vs. QAbstractItemModel → ListModel<T> and TreeModel<T>
Qt's QAbstractItemModel uses a role-based, type-erased data access protocol (QVariant). Teksilo's ListModel<T> and TreeModel<T> are concrete generic types: the delegate closure receives &T directly, with compile-time type safety. The ListDataSource trait provides an escape hatch for large/external datasets, also with an associated Item type.
27.3 vs. Existing Rust GUI Frameworks
Teksilo's focus areas are accessibility (AccessKit at the trait level, tested by every test), text rendering (text-document + text-typeset), and widget extensibility (unified Widget trait with slots). Its layout and event design are comparable to Xilem/Masonry. It is currently weaker on rendering sophistication (quad-based vs. Vello's GPU compute renderer) and much younger than established frameworks.
The primary reference point for Teksilo's feature scope is Qt Widgets — the framework most commonly used for the kind of professional desktop applications Teksilo targets.
28. Widget Catalog
The current widget inventory is no longer maintained as prose in this document — it drifted faster than it could be edited. The authoritative sources are:
tools/extract_widget_api.py --all— emits the public surface (struct, builder methods, enums, module doc) of every widget inteksilo-widgets. Runpython3 tools/extract_widget_api.py --listto see the full file list, or pass widget names to extract just those.teksilo-milestones.md— the "Current State: What Exists" section enumerates every widget currently shipped, grouped by category, and tracks remaining milestone work.- CLAUDE.md — the "Implementation Status" block and the per-widget reference docs (
table-view.md,tab-widget.md,charts.md,tooltips.md,teksilo-scene.md) cover the widgets with the deepest API surface.
For a one-shot dump suitable for downstream tooling: python3 tools/extract_widget_api.py --all -f json -o widgets.json.
29. V2 Widget Authoring Model
The unified Widget trait, Signal<T> reactivity, attached handlers, BuildContext::signal / effect / animated_signal / app_state / subscribe_event, the four widget shapes (leaf / container / composing / hybrid), and the take_widget / restore_widget arena extraction pattern that makes build(&mut self) borrow-safe — all documented in CLAUDE.md "Unified Widget Trait" plus the focused docs (events-and-gestures.md, reactive-theme.md, animation.md). The V2 model is what the entire widget library is written against; reading crates/teksilo-widgets/src/button.rs is the fastest way to see all of it together in one ~200-line widget.
The teksu! DSL desugars to V2 builder calls one-to-one at macro-expansion time — no runtime, no virtual tree. References: teksu-macro-reference.md (user-facing) and teksu-language-spec-v3.md (grammar and desugaring spec).
30. Open Questions (Current, May 2026)
The bulk of the original post-milestone question list has landed. The short list below is what remains actively open; see teksilo-milestones.md for detailed status and the Next-candidates roadmap.
External (OS) drag-and-drop. Intra-app DnD works everywhere (Milestone 6). Inbound OS drops — files / text / URLs dragged from a file manager or another app into a Teksilo window — are implemented through the ExternalDndBackend trait in teksilo-platform (install_external_dnd()): macOS via a NSDraggingDestination overlay view (verified), Windows via OLE RegisterDragDrop/IDropTarget, Wayland via wl_data_device, X11 via XDND v5 (an XdndProxy helper window on its own connection — winit owns the toplevel's X connection and consumes XDND messages itself; see drag-and-drop.md §11.3.1). They reuse the in-app pipeline — an OS drop is a DragPayload with origin() == External. winit's own DroppedFile/HoveredFile are not used (no position, files-only, no Wayland). Outbound drags (Teksilo window → another app) are now implemented on every desktop target (macOS NSDraggingSource + Wayland wl_data_source, both verified; Windows OLE DoDragDrop; X11 an XDND source that polls the pointer rather than grabbing it): a normal start_drag whose payload carries MIME data auto-escalates to a native OS drag when the pointer leaves the window, completion is reported via on_drag_ended(DropOutcome), and the typed payload is recovered on re-entry (enabling drag-and-drop between two windows of the same app). See drag-and-drop.md §11.5.
Native menu bar on macOS. Done (on-device macOS validation still pending). A widget-free MenuModel is the single declarative source of truth, routed two ways: the in-window widget MenuBar on Windows/Linux (where menus live in the window chrome), and the global NSMenu on macOS via the NativeMenuBackend trait in teksilo-platform (install_native_menu() + MenuBar::from_model(..).native_on_macos(..)). Item clicks route back through the Intent/Action pipeline; the trait + plain NativeMenuSnapshot boundary are platform-neutral for future Windows HMENU / Linux DBus backends (currently a no-op). See native-menu.md.
Virtualized dropdowns. ComboBox now virtualizes via ListView under max_visible_items: lists beyond the cap materialize only the visible rows (plus ListView's small buffer) instead of building every DropdownItem eagerly. The searchable filtered path shares the same virtualized renderer. MenuList grew a max_visible_items builder that caps panel height and wraps the item column in a ScrollArea, but does not virtualize — its API still takes arbitrary impl Widget children, so true virtualization would require a model-driven MenuList rewrite (tracked as follow-up). The eager build is cheap enough that capped 100+ item menus are fine in practice.
31. First Milestone: Button in a Window
Status of every milestone (M1 through current) lives in teksilo-milestones.md. M1 — a window displaying a single themed button with click handling, hover/press states, text rendering, AccessKit accessibility, and keyboard activation — landed as the simple_button example. It exercised the full vertical slice (teksilo-tokens for theme, teksilo-canvas for the SDF rounded rect, teksilo-core for arena/layout/events/focus/a11y, teksilo-text for the label, teksilo-render for the wgpu pipeline, teksilo-platform for the winit window + AccessKit adapter, teksilo-app for the event loop) and proved the end-to-end stack before any further widget work.
Property-Based Testing Reference
Proptest was introduced to the workspace on the branch that produced this
document, into a codebase that had zero property tests before it. There are
now ~92 properties across teksilo-tokens, teksilo-data, teksilo-scene,
and teksilo-widgets, and they found eight real bugs (see What this
found below) — including one that exhausted 61 GiB of RAM and forced three
hard reboots of the developer's workstation before it was diagnosed. That
incident is itself part of what this document exists to prevent from
happening again.
Mental model in one line:
proptest generates hundreds of inputs per property and shrinks any failure to a minimal counterexample — cargo-fuzz's coverage, on stable, in `cargo test`
The convention below is not invented for Teksilo. It is carried over
unchanged from the author's sibling repos ../text-typeset and
../text-document, which have run proptest for longer; this document
writes that convention down for this workspace so it applies uniformly
here too, rather than living only as tribal knowledge.
Convention
File placement. Two shapes, chosen by the tested item's visibility — never by convenience:
-
tests/*.rsintegration test when the target ispuband reachable from outside the crate. Example:crates/teksilo-tokens/tests/prop_color.rstestsColor, a public type re-exported at the crate root. -
Inline
#[cfg(test)] mod proptests, placed as a sibling of the existing#[cfg(test)] mod testsin the same file, when the target ispub(crate)or otherwise unreachable fromtests/. The module doc must say why it lives inline rather than assume the reader can tell. Three worked examples, each stating a different reason:crates/teksilo-widgets/src/splitter/distribute.rs:distributeispub fn, but its declaring module (mod distribute;insplitter.rs) is private and not re-exported, so the function is unreachable from an external test crate even though the fn signature itself sayspub.crates/teksilo-scene/src/index.rs:GridHashIndexis declaredpub struct, but it lives insidepub(crate) mod index;inlib.rs— the module's visibility caps the struct's, regardless of thepubon the struct itself. Reading apubkeyword on the item is not sufficient to decide placement; check the declaring module's visibility too.crates/teksilo-widgets/src/primitives/column_flow.rs:ColumnFlowitself is public, butbalance_columns— the pure function the suite actually targets — ispub(crate).
Never widen an item's visibility to make it reachable from
tests/. If the item ispub(crate), the test lives inline; that is the whole decision procedure.
One property per proptest! {} block, each preceded by a numbered
banner comment stating the exact claim under test:
#![allow(unused)] fn main() { // ── 12. for_inactive_window desaturates exactly the accent family and // leaves everything else (except chart_palette) untouched, for an // arbitrary accent color — not just the two shipped IntUI presets ── proptest! { #[test] fn for_inactive_window_desaturates_only_the_accent_field(accent in arb_color()) { // ... } } }
Forbidden, deliberately, so every suite reads the same way: the
#[proptest] attribute macro, prop_compose!, #[derive(Arbitrary)], a
manually driven TestRunner, or a hand-rolled RNG. Strategies are built by
hand from proptest::prelude combinators (prop_oneof!, .prop_map,
.prop_flat_map, prop::collection::vec, …).
Hand-written local fn arb_x() -> impl Strategy<Value = X> generators,
one set per file. There is no shared generator module, and that is a
deliberate choice, not an oversight: arb_parent_sel/arb_insert_ops
appear near-verbatim in both
prop_tree_slice.rs and
prop_tree_checked.rs
rather than being factored out — per-file duplication is the accepted cost
of keeping each suite's generators legible and independently auditable
without chasing a shared abstraction across files.
Case counts are the only tuning knob, set per block:
#![allow(unused)] #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })] fn main() { }
There is no proptest.toml anywhere in this workspace. The default (256,
proptest's own default) is left alone for ordinary properties; blocks that
are cheap and unusually valuable (an oracle-vs-brute-force check, a
panic-freedom sweep over malformed input) opt into 512 or 1024 explicitly,
with the reason stated next to the override — see
from_hex_never_panics_and_stays_in_range (1024, attacker-adjacent hex
parsing) or the oracle properties in
prop_sort_filter.rs
(512, cheap per-case). The manual override for a one-off deeper run is
PROPTEST_CASES=N cargo test -p <crate> ... — every suite's module doc
states the exact invocation.
Assertions are prop_assert!/prop_assert_eq! only, and always carry a
format-string message citing the actual values that failed, never a bare
condition:
#![allow(unused)] fn main() { prop_assert!( from_brute.is_subset(&from_index), "cell_size={} query={:?}: index missed true intersections {:?} (model={:?})", cell_size, q, from_brute.difference(&from_index).collect::<Vec<_>>(), model, ); }
A bare assert!(x) inside a proptest! block is a debugging dead end: the
shrinker hands you a minimal failing case, and a bare assertion throws that
context away at the exact moment it matters most.
Test names read as English claims describing the property, never
test_-prefixed:
hex_roundtrip_is_stable_after_one_quantization,
selection_indices_never_dangle_past_the_end_of_the_list,
reorder_within_stays_acyclic_and_conserves_the_node_set,
a_single_items_cell_footprint_never_exceeds_the_per_item_cap.
.proptest-regressions files are committed, never gitignored — proptest
reruns every recorded seed before generating novel cases, so a shrunk
counterexample stays a permanent regression check once found. Two on-disk
shapes, matching the two file-placement shapes above:
tests/*.rssuites: the regressions file sits next to the test file, e.g.crates/teksilo-data/tests/prop_sort_filter.proptest-regressions.- Inline
mod proptestssuites: proptest names the file after the module path and roots it at the crate, e.g.crates/teksilo-widgets/proptest-regressions/splitter/distribute.txtand.../proptest-regressions/common/row_offsets.txt.
A suite with no regressions file (prop_tree_slice.rs, column_flow.rs,
column_geometry.rs in this workspace today) simply never shrank a
failure — that is a legitimate outcome, not a sign the suite is incomplete.
Stated rationale (from the workspace Cargo.toml, next to the
proptest = "1" dependency line): cargo-fuzz needs a nightly toolchain
(libfuzzer-sys links against compiler-rt's fuzzing runtime), which isn't a
guaranteed CI dependency. Proptest gives the same "never panics on weird
input" coverage plus shrinking, on stable, as a plain [dev-dependencies]
entry — no separate fuzzing job, no nightly pin. Every crate with a suite
re-states this in its own Cargo.toml next to proptest = { workspace = true } rather than assuming the reader finds the workspace manifest.
Scope — what belongs here, what does not
Proptest owns relational properties: statements that must hold between an input and an output, or between two independent ways of computing the same thing, for every input in a domain — not statements about one exact rendered result.
| Shape | What it checks | Example |
|---|---|---|
| Round-trip | encode then decode (or the reverse) reproduces the original, or a documented fixed point | hex_roundtrip_is_stable_after_one_quantization, hsv_roundtrip_holds_for_arbitrary_opaque_colors (teksilo-tokens) |
| Idempotence | a second application changes nothing once the first has converged | reaggregate_is_a_noop_when_the_model_is_already_consistent (teksilo-data), reinserting_identical_bounds_is_idempotent (teksilo-scene) |
| Conservation | a quantity (item multiset, total height, node set) survives a transform exactly | move_items_preserves_the_item_multiset (teksilo-data), total_conserves_the_sum_of_heights_and_gaps_and_insets (teksilo-widgets) |
| Monotonicity | an ordered change in input can only move the output in one direction | query_is_monotonic_in_rect_containment (teksilo-scene), desaturation_spread_is_monotone_in_amount (teksilo-tokens) |
| Oracle vs. brute force | a fast/incremental/cached path agrees with an independent from-scratch recompute | the_incremental_tree_projection_equals_a_full_recompute (teksilo-data), query_narrowed_matches_brute_force (teksilo-scene) |
| Metamorphic | the same operation under a transformed input yields a predictably related output | checked_state_survives_a_reload_with_a_different_shape_then_resyncs_on_reaggregate (teksilo-data) |
| Determinism | identical inputs always produce identical outputs | distribute_is_deterministic_for_identical_inputs (teksilo-widgets), query_is_independent_of_insertion_order (teksilo-scene) |
| Panic-freedom | the function returns rather than unwinds, across arbitrary — including malformed — input | from_hex_never_panics_and_stays_in_range, query_never_panics |
It does not own exact pixel or glyph output. A rendered bitmap is
font-version- and shaper-version-dependent, so pinning one down as a
property assertion means the property breaks on every font update rather
than on a real regression — that is insta snapshot territory in
../text-typeset, which owns real shaping/bidi/line-break/raster coverage
against real fonts. Proptest here never touches a GPU, a display server, or
real font shaping; every suite in this workspace runs against pure data
(Color, ListModel, TreeModel, GridHashIndex, PrefixSumOffsets,
distribute, …), headless, with no rendering in the loop.
The test for a candidate: can you state it as "for every valid x, f(x)
relates to x (or to g(x), an independently written second computation)
in such-and-such a way" — without needing to look at a rendered frame to
know if it passed? If yes, it is proptest's. If the honest assertion is
"this exact bitmap" or "this exact glyph outline", it belongs in a
snapshot test instead.
Generator cost discipline
This is the section that matters operationally. A generator explores a product space, and proptest will find the worst corner of that space within a few hundred cases — including corners the author never considered reachable.
Cost the most expensive combination before writing the generator, and
record that reasoning in a comment next to it. Every generator in this
workspace carries one; see the cost comment on arb_list_op in
prop_list_and_selection.rs
or on arb_pane_with_wild_bounds in
distribute.rs —
each states the worst-case element count and the worst-case per-op cost
before the strategy is defined, not after a failure.
Bound every prop::collection::vec length. Keep the modeled state
small — a tree of 20–30 nodes, an op sequence of 30 steps, a pane list of
8. Properties find bugs through many small cases, not a few large ones;
a generator that can build a 10,000-node tree buys nothing over one capped
at 30 except CI time and a harder-to-read shrunk counterexample.
Couple dependent inputs. Drawing two related quantities from two
independent strategies is the specific trap that caused the incident this
document opened with. GridHashIndex's generator originally drew a rect's
extent and the grid's cell_size from two separate arb_cell_size()
calls: a rect sized for a 256 px grid (extent up to 256 * 64) inserted
into an unrelated 1 px grid spans roughly 268 million cells. Separately,
cell_size: 1.0 paired with the incident's own 1e6 extent asked for
(1e6+1)² ≈ 1e12 cells — about 8 TB for the Vec<(i32, i32)> alone. That
exhausted 61 GiB of RAM and forced three hard reboots before it was
diagnosed as a generator bug compounding a real one.
The fix is prop_flat_map, so the dependent quantity is derived from the
one it depends on rather than drawn independently. The worked example is
arb_grid_and_two_rects in
crates/teksilo-scene/src/index.rs:
#![allow(unused)] fn main() { fn arb_grid_and_two_rects() -> impl Strategy<Value = (f32, Rect, Rect)> { arb_cell_size().prop_flat_map(|cs| (Just(cs), arb_rect(cs), arb_rect(cs))) } }
arb_rect(cs) bounds its extent to at most cs * MAX_CELLS_PER_AXIS
cells for that specific cs, so no generated pair can ever reproduce the
mismatch. Every property in that suite that builds a grid and inserts into
it draws its rects from here — never from two independent calls.
When a suite OOMs, the unbounded allocation in the production code is
usually the real finding — fix or report that, not just the generator.
That is exactly what happened here: cells_for_rect computed
(width / cell_size) * (height / cell_size) cells and reserved that many
(i32, i32) slots before the fill loop ran, with no upper bound and with
i32 arithmetic that could itself overflow. A single oversized item —
a scene backdrop, a full-document canvas rect, both reachable in
production, not exotic — could OOM or crash a real app with no adversarial
input required. The fix (b7f6e066, see What this found below) added
MAX_CELLS_PER_ITEM and an always-scanned oversized: HashMap<ItemId, Rect> side list for any item whose AABB would exceed it. Shrinking the
generator down to the smallest range that doesn't crash would have
silenced the finding without touching the actual bug.
The safe run protocol
Never execute a suite's binary directly under cargo test. A bare
cargo test runs every generator unbounded — exactly the condition that
caused the incident. Always build and run separately, with a hard memory
cap on the run:
cargo test -p <crate> --lib --no-run
BIN=$(cargo test -p <crate> --lib --no-run --message-format=json 2>/dev/null \
| jq -r 'select(.executable != null) | .executable' | tail -1)
( ulimit -v 6000000 -t 300; "$BIN" <filter> --test-threads=1 )
--no-runbuilds the test binary without executing anything, so a pathological generator can't run before you've had a chance to cap it.--message-format=jsonon the same--no-runbuild reports the exact binary path (.executable) without guessing attarget/debug/deps/...hashes.ulimit -v 6000000caps the subshell's virtual address space at ~6 GB (adjust to the machine);ulimit -t 300caps CPU time at 5 minutes as cheap insurance against a runaway loop that isn't merely allocating.--test-threads=1keeps the cap meaningful — proptest's own case parallelism would otherwise multiply the peak by however many threads are live.
This turns what would otherwise be a machine-killing OOM (invisible until
the OS starts swapping and the desktop freezes) into a clean, immediate
memory allocation of N bytes failed from the process itself — a report
you can read, not a hard reboot.
Working with a property that fails
Never weaken a property to make it green. Do not relax the assertion,
narrow the generator to dodge the failing input, add a prop_assume! that
filters the failing case out, or delete the property. Any of those hides
the finding instead of resolving it.
Instead: record the shrunk counterexample verbatim (proptest already does
this in the .proptest-regressions file — e.g.
cc 48dc58d1... # shrinks to ops = [InsertRoot(0, 0), InsertRoot(1, 3), SetSort(Some(Descending)), Update(7, 3)], mode = HideNonMatching
in
prop_sort_filter.proptest-regressions),
read the implementation the property targets, and decide honestly which of
the following applies.
A property that merely restates the implementation is worthless.
prop_list_and_selection.rs's property 5
(single_mode_never_holds_more_than_one_selected_index) notes in its own
comment that select_all "does not appear to special-case Single mode in
the source (it unconditionally selects 0..count)" — i.e. its first draft
was paraphrasing what the code did rather than stating a contract. Worse,
an earlier draft of the neighbouring property 7
(select_all_replaces_rather_than_unions_the_previous_selection) generated
SelectionMode::Single too and expected 0..second_count there — which
directly contradicts property 5's own invariant (0..second_count holds
more than one index whenever second_count > 1). Two properties in the
same file, each individually plausible, asserting incompatible things. The
resolution recorded in property 7's comment: narrow it to
Multi-only, with the reasoning why written down — "whether select_all
should no-op or collapse to one index in Single mode is that [other]
property's business, not this one's." Property 5 turned out to state the
real contract; property 7 was fixed to stop overreaching into it.
The same shape recurs in
prop_sort_filter.rs's
property 6: a brute-force "matches ∪ descendants" oracle agrees with
SortFilterTreeModel for HideNonMatching and KeepAncestors, but not for
KeepDescendants — traced by hand (not by running anything) to
flatten_visible starting its walk at each top-level root and bailing out
immediately if the root itself isn't visible, so a matching descendant
under a non-matching root is never reached even though the brute-force
definition says it should be visible. tree_row_filter.rs's own module doc
already calls this divergence deliberate. The property was narrowed to the
two modes the crate itself claims are equivalent
(arb_ancestor_preserving_filter_mode), with the reasoning recorded in the
property's comment, rather than asserting a stricter promise than the type
actually makes.
When it is a real bug but the fix is a design decision, park it —
#[ignore], the counterexample, and a "do NOT weaken this assertion" note
— never delete it silently. Two properties in this workspace went through
exactly that arc and were later resolved; both are worth reading as a pair,
before-and-after, in git history:
distribute.rs's NaN-bound property was committed as#[ignore = "unresolved: NaN min/max panics inside f32::clamp — see comment"]with a comment explaining the panic, why it is reachable (PaneDescriptorbounds are app-supplied; a min derived from a0/0ratio is NaN), and explicitly declining to guess at the right normalization without owner review. It was later un-ignored once03341d1f(see below) picked a resolution.row_metrics.rs's uniform-vs-exact agreement property was committed#[ignore = "unresolved: uniform and exact modes disagree on all-zero heights — see comment"], again with the reasoning ("touches everyPrefixSumOffsetsconsumer, so it is left for review") and was later un-ignored bya788e191.
Both comments, while parked, state outright: "Do NOT weaken this assertion."
Pin a shrunk counterexample as a named #[test] when it represents a bug
worth remembering permanently, alongside — not instead of — the
proptest! property that found it:
oversized_1e6_extent_at_cell_size_one_never_allocates_the_pathological_cell_count
is the literal incident input (Rect::new(0.0, 0.0, 1_000_000.0, 1_000_000.0) at cell_size: 1.0) pinned as a plain, deterministic
#[test] in mod tests — so the exact input that took a workstation down
three times stays a permanent regression check independent of proptest's
own seed file.
What this found
Eight bugs across four crates. The pattern is worth stating plainly: with
one exception (the GridHashIndex unbounded allocation, a missing
resource bound rather than a disagreement between two things), every one
was an inconsistency — one code path failing to honor an invariant a
sibling path already upheld — not a wrong formula. That is the class
example-based tests structurally cannot reach, because writing an example
that exercises it requires already suspecting the specific pairing that
diverges.
| Crate | Bug | Shape |
|---|---|---|
teksilo-tokens | Color::mix's t.clamp(0.0, 1.0) propagates a NaN factor into every channel, since f32::clamp returns NaN for a NaN input | NaN-unsafe guard written for the ordered case |
teksilo-scene | GridHashIndex::cells_for_rect reserved one (i32, i32) slot per covered cell with no upper bound; a single oversized item could request ~1e12 cells | unbounded allocation, not a disagreement |
teksilo-data | SelectionModel::select_all checked SelectionMode::None but not Single, so it selected the full 0..count range on a single-selection model | one mutator not upholding an invariant every sibling mutator (select, toggle, extend_to, select_indices) already honored |
teksilo-data | SortFilterListModel's incremental ItemUpdated fast path bailed out to a full rebuild only on a Greater comparison, not a tie | fast path disagreeing with the full stable-sort recompute it exists to optimise |
teksilo-data | SortFilterTreeModel's incremental NodeUpdated fast path had the identical tie-blindness, independently, in a different file | same class of bug, duplicated logic drifting apart |
teksilo-data | KeyedTreeCheckedModel::prune_missing used an empty stale-key list as its "nothing changed" signal, but a removed subtree that was never explicitly checked also produces an empty stale list | untracked-ness conflated with unchanged; skipped reaggregate() left a stale ancestor tristate |
teksilo-widgets | distribute's phase-0 clamp read if lo > hi { lo } else { req.clamp(lo, hi) } — the guard catches a finite min > max but not a NaN bound, since both NaN > hi and lo > NaN are false | the same NaN-unsafe-guard shape as the Color::mix bug, in an unrelated crate |
teksilo-widgets | RowMetrics::uniform and RowMetrics::exact describe the same geometry but disagreed on an all-zero-height, all-zero-spacing table (a fully collapsed or filtered list): uniform answers row 0, the offset table's partition_point answers the last row | two modes describing one geometry differently |
Two of these — the Color::mix NaN hole and the distribute NaN hole — are
literally the same defect shape (f32::clamp panics or propagates NaN; a
hand-written > guard doesn't catch NaN because every NaN comparison is
false) found independently in two unrelated crates by two unrelated
suites. Not one bug in this list is a case of the underlying formula itself
being wrong.
Where the suites live
| Crate | File(s) |
|---|---|
teksilo-tokens | tests/prop_color.rs |
teksilo-data | tests/prop_list_and_selection.rs, tests/prop_tree_slice.rs, tests/prop_tree_checked.rs, tests/prop_sort_filter.rs |
teksilo-scene | src/index.rs (mod proptests, inline) |
teksilo-widgets | src/common/row_offsets.rs, src/common/row_metrics.rs, src/primitives/column_flow.rs, src/common/column_geometry.rs, src/splitter/distribute.rs (all mod proptests, inline) |
Each file's own module doc states its case-count defaults, its
PROPTEST_CASES override invocation, and — where relevant — the specific
regression it was written against. Read the target file's doc comment
before adding a property to it; the conventions above are enforced by
precedent, not by a lint.
Teksilo Widget Catalog
A categorized index of every widget that ships in the workspace. One line
per widget; the source link is the authoritative reference. For full
public API surfaces (struct, builder methods, enums, module doc) of one
or more widgets, run python3 tools/extract_widget_api.py <Widget…>
or --all for everything.
For per-subsystem docs (data binding, accessibility overrides, animation, drag-and-drop, multi-window, settings, i18n, theming, shortcuts/intents/actions), see SUMMARY.md.
Styling status
All 33 themable widgets are on the four-tier styling system
(docs/styling-system.md): each ships a *Style
trait in teksilo-core::styles::* plus a default Recipe*Style impl in
teksilo-widgets/src/styles/*. The widget builds its parts, hands a
*StyleConfig to the active style, and uses the returned WidgetId
as its root child — no themable widget self-paints. Style resolution
is per-call .style(impl FooStyle) → theme-wide
theme.style_slots.<slot> → recipe default.
| Widget | Variant enum | Style trait | Slot |
|---|---|---|---|
Toggle | ToggleVariant (Switch/Pill/Square/Inset) | ToggleStyle | style_slots.toggle |
Button | ButtonVariant (Filled/Tinted/Outlined/Plain/Ghost/Link/Destructive) | ButtonStyle | style_slots.button |
Checkbox | CheckboxVariant (Square/Rounded/Circle) | CheckboxStyle | style_slots.checkbox |
RadioButton | RadioVariant (Circle/Square/Rounded) | RadioStyle | style_slots.radio |
RadioTile | RadioTileVariant (Outlined/Elevated/Filled) | RadioTileStyle | style_slots.radio_tile |
IconButton | IconButtonSize (Compact/Default/Toolbar/Large/Hero) | IconButtonStyle | style_slots.icon_button |
Panel | PanelVariant (Plain/Sunken/Raised/Highlighted) | PanelStyle | style_slots.panel |
Card | CardVariant (Plain/Elevated/Outlined/Filled) | CardStyle | style_slots.card |
TooltipWidget | — | TooltipStyle | style_slots.tooltip |
MenuItem | — | MenuItemStyle | style_slots.menu_item |
StandardListItem / StandardTreeItem | — | StandardItemStyle | style_slots.standard_item |
Popover | PopoverVariant (Default/Menu/Tooltip) — surface | PopoverStyle | style_slots.popover |
ScrollBar | ScrollBarVariant (Permanent/Overlay/Thin) + ScrollBarOrientation | ScrollBarStyle | style_slots.scroll_bar |
TabBar | — (carries TabBarOrientation) | TabStyle | style_slots.tab |
ComboBox | ComboBoxVariant (Outlined/Filled/Underline/Plain) | ComboBoxStyle | style_slots.combo_box |
Slider | SliderVariant (Continuous/Discrete/Range) + SliderOrientation | SliderStyle | style_slots.slider |
TextInput | TextInputVariant (Outlined/Filled/Underline/Bare) | TextInputStyle | style_slots.text_input |
The 17 legacy per-widget dimension structs in teksilo-tokens::components
were deleted; their IntUI constants now live in the matching
teksilo-widgets/src/styles/recipe_*_style.rs modules. The dimension
data for non-themable widgets (toolbar, status bar, dialog, accordion,
badge, progress bar, table, …) lives directly in those same
recipe_*_style.rs modules as pub const blocks. Three sibling preset
crates ship — Material 3 (theme-material3), Fluent / Windows 11
(theme-fluent) and macOS Aqua (theme-macos). Image-backed styles,
the ImageTheme TOML loader, and a GTK4-Adwaita preset are still
pending.
End-to-end demo of the slot bag + per-call override: see
examples/theme_styles/.
Layout primitives — crates/teksilo-widgets/src/primitives/
The composable building blocks of every widget tree. See layout-primitives.md for the layout protocol, slack distribution math, and worked examples.
- HStack — horizontal stack with cross-axis alignment, spacing, and slack distribution.
- VStack — vertical stack; same model.
- ZStack — overlay stack at a shared origin with two-axis alignment.
- Grid — fixed/fr/auto track grid (
TrackSize); explicit cell placement. - Wrap — flow layout that wraps to new rows when out of width.
- MasonryLayout — variable-height grid packing into the shortest column (Pinterest-style).
- ColumnFlow — newspaper columns whose count follows the available width: drops a column and re-partitions every child as the width shrinks. Contiguous source-order runs, so reading and focus order stay correct at every count;
min/max_column_width,max_columns,column_rule, opt-insemantic_list, reactivecolumn_count_signal(). Pair with aScrollAreafor vertical overflow. - FormLayout — labelled rows with column alignment for settings panels.
- Center — centers a single child within the space it is given (fills a bounded axis, shrink-wraps an open one;
flex = 0, so it does not claim stack slack — wrap inExpandfor that). - Expand — flex-basis-zero workhorse for ratio splits and full-bleed children.
- Shrinkable — shrink counterpart to
Expand: opts a child into compression (down to aminfloor) when a stack is over-constrained. Native shrink covers single-line / ellipsis text; controls (Buttonetc.) stay rigid and overflow viaToolbar. - Padding — uniform or per-edge inset around a single child (propagates flex/shrink/min).
- Spacer — flexible empty space that consumes slack via
flex = 1.0. - Divider — 1 dp themed line, horizontal or vertical.
- FixedSize — pins width/height regardless of parent proposal.
- MinSize — clamps response to a floor (touch-target enforcement, etc.).
- MaxSize — clamps response to a ceiling.
- AspectRatio — constrains a child to a fixed width-to-height ratio.
- Switcher — shows one of N children, driven by
Signal<usize>. - DeadZone — layout-transparent gesture dead zone: a press inside it never arms a drag/swipe on an ancestor. Wrap interactive controls (buttons, a
⋮menu) inside a draggable/swipeable container (a dock-panel header, a card, a list row) so clicking them — even with click jitter — can't start the ancestor's drag. The framework counterpart of Electron's-webkit-app-region: no-drag; backed by the node-levelgesture_dead_zoneflag (robust by construction, not a recognizer-timing race). - FocusScope — layout-transparent Tab traversal boundary (lives at crate root). Scopes its descendants'
tab_indexso sibling regions don't interleave, and traps or passes Tab viaTraversalScopePolicy::{Cycle, Continue}. See events-and-gestures.md §6.1.
Visual primitives
Direct draw surfaces with no internal composition.
- RectWidget — themed rectangle (background, border, corner radius); reactive bindings.
- TextWidget — single-line text via the
TextBackend; reactive content + color. - IconWidget — vector icon rendered through the path atlas;
IconModefor tinted vs. raw. - ImageWidget — bitmap with
ImageFit(fill / contain / cover / none / scale-down). - ImageMask — CPU-side anti-aliased alpha mask (
ImageMaskShape); used by Avatar and other shaped-image patterns. - ValidationStrip — inline error/warning/success strip under a field.
- TextInputField — primitive single-line editable text used inside the higher-level field widgets.
- TwistArrow — small chevron that indicates and toggles a tree node's expansion (used by
TreeView/TreeTableView).
Containers and chrome
Themed framing, sectioning, and window-level structure.
- Panel — themed background + border + corner radius + padding.
- Card — elevated panel with shadow and optional header/footer slots.
- GroupBox — labelled bordered group for related controls.
- GroupHeader — section header (label + trailing rule line) for settings forms.
- Toolbar — command bar (
ToolbarAction/ToolbarItem) with automatic overflow: excess actions collapse into a⌄MenuListpopover (Qt extension / NSToolbar overflow / WinUI CommandBar). Per-action overflow priority,always_overflow, toggle, pinned custom widgets, collapsible custom widgets (overflow_asmenu row /overflow_widgetlive embedded control / theToolbarOverflowtrait), separators, flexible space, display mode, orientation,is_overflowing(). Full ARIA toolbar a11y (Role::Toolbar + orientation, roving tab-index + arrow nav, chevronHasPopup::Menu, no AT duplication of overflowed items). Built onLayoutContext::measure_intrinsic. Reference: docs/toolbar.md. - StatusBar — bottom-of-window status text strip with
Role::Status. - Banner — persistent inline info / success / warning / error strip (
BannerSeverity);Role::Status+Live::Polite. - DropZone — standalone "drop files here" target for external (OS) drag-and-drop;
accept_extensionsfilter,allow_multiple,on_files_dropped/on_text_dropped/on_urls_dropped, keyboard Browse fallback; Tier-3DropZoneStyle,Role::Group+Live::Polite. See drag-and-drop.md §11.4. - DropTarget — wrapping drop container: turns any child into a drop target without hiding it (the child stays fully visible; the highlight is a border, not a fill). Reacts to internal (typed
DragPayload) and external drops; optional centered hint popup;accept_external_*/accept_typed::<T>/accept_whenfilters,on_drop/on_drop_typed::<T>,targeted_signal(SwiftUIisTargetedpattern); Tier-3DropTargetStyle,Role::Group. See drag-and-drop.md §11.6. - Accordion — vertically stacked collapsible sections, multiple-open allowed.
- ToolBox — vertically stacked collapsible pages, exactly one expanded (Qt
QToolBoxanalog). - ScrollArea — viewport with overlay or permanent scrollbars (
ScrollBarMode,ScrollBarPolicy). - ScrollBar — standalone scrollbar, drag/track-click/keyboard.
- Splitter — N-pane resizable splitter with draggable, collapsible dividers, per-pane stretch, and a serializable
SplitterModel. See docs/splitter.md. - DockingLayout — VS Code-style dockable layout: a centre slot + 4 collapsible/splittable/draggable side regions (leading/trailing/top/bottom), per-corner ownership, activity rail, drag-to-dock five-zone overlay, and a serializable
DockingModel. See docs/docking.md. - TabWidget — tab bar + content switcher; data-source-driven
TabBar<T>underneath. See tab-widget.md. - Stepper — embeddable step-flow widget (Material / Ant / Flutter "stepper"): horizontal or vertical, linear or non-linear, per-step completion state.
- Wizard — thin modal launcher built on
Stepper: a multi-step flow with header, footer, and step switching. - Breadcrumb — clickable path segments with chevron separators (
BreadcrumbItem). Automatic overflow: when too narrow the middle crumbs collapse into a trailing-of-root…MenuListdropdown (Windows Explorer / web breadcrumb pattern) while the root + current crumb stay;is_overflowing()signal. RTL-aware separators (chevron mirrors). Built onmeasure_intrinsic+MenuList::item_when. - TitleBar — custom window title bar with drag region, resize strip, and window controls. See title-bar.md.
Buttons
- Button — seven
ButtonVariants (Filled / Tinted / Outlined / Plain / Ghost / Link / Destructive) × five interaction states;IconLocationfor leading/trailing icon; chrome via theButtonStyletrait (see Styling status above). Reference exemplar — read the source. - IconButton — square icon-only button at five
IconButtonSizesteps (Compact / Default / Toolbar / Large / Hero)..embedded()mode for trailing-slot use inside fields. IncludesBuiltInIconsfactory. - CommandLinkButton — large two-line CTA: leading icon + bold title + secondary description; flat surface.
- PopoverButton — Button preset that opens a Popover when activated.
- PopoverIconButton — IconButton variant of the same.
- SplitButton — main action region + chevron region that opens a related-actions menu.
Inputs and indicators
- Checkbox — two-state and tristate (
CheckState). - RadioButton — single radio, bound to a shared value via RadioGroup for mutual exclusion.
- RadioTile / RadioTileGroup — "selectable card" radios: icon + title + inline radio + wrapping description. N-ary group with
TileLayout::{Row, Grid, Column, Vertical}(equal-size cards, adaptive wrapping grid, or a compact settings list with trailing meta), a WAI-ARIA roving radiogroup keyboard, andRole::RadioGroup+ per-tileRole::RadioButton. - Toggle — on/off control; four
ToggleVariants (Switch / Pill / Square / Inset) via theToggleStyletrait. - Slider — horizontal or vertical, optional stepping.
- SegmentedControl — segmented chooser keyed by
SegmentId(so a contributed segment can't re-point the selection); segments that don't fit overflow into a chevron menu, with the selected one always visible;RadioGroupAT role. See segmented-control.md. - ComboBox — selection-only dropdown; virtualized via
ListViewpastmax_visible_items. - FontPicker — lists/searches/filters all installed fonts with per-row in-font samples; spacing + writing-system filters (off-thread coverage index). See font-picker.md.
- ProgressBar — determinate or indeterminate; linear.
- Spinner — circular-arc loading indicator on the shader-driven
AnimatedQuadKind::SpinnerArcpipeline; honoursprefers-reduced-motion. - Link — typographic hyperlink with hover and visited states.
- Badge — passive count/label pill.
- Avatar — user identity (image / initials fallback / hash-derived tint); circular / rounded-square / square shapes; presence indicator with corner positioning.
Text input family
- TextInput — styled single-line input on top of
TextInputField;ValidationState. - RichTextEditor — full editing surface with IME, formatting commands, undo/redo, intrinsic-mode sizing (
min_lines/max_lines); also runs read-only as the rich-text viewer (ScrollPolicy). - CodeEditor / PlainTextEditor — multi-line source / plain-text editors over one core: gutter, current-line band, injected language-agnostic indentation / comment / bracket handling, multiple carets, caret-anchored completion, paragraph/run accessibility. See docs/code-editor.md.
- LogView — read-only, append-only, tail-following streaming view scaling to 100k+ lines via windowed layout; derived follow-tail, scrollback cap, injected per-line severity colour, windowed accessibility. See docs/log-view.md.
- SpinBox — numeric input with
WrapMode,StepType,ButtonLayout,WheelMode,WidthPolicy. - SearchField — TextInput preset with leading magnifier glyph and clear-X;
Role::SearchInput. - PasswordField — secure entry with an embedded reveal toggle, character masking, Caps Lock warning, and clipboard protection.
EchoMode(Masked / NoEcho / RevealWhileTyping),RevealMode(Toggle / Hold / None),AtRevealPolicy(SwapRole / AlwaysProtected). Masks at the text-engine layer (Role::PasswordInput; plaintext never reaches the shaper, glyph atlas, or AT value while masked). Demo:cargo run -p password-field. - FilePickerField — TextInput + Browse button wired to the native file dialog;
FilePickerKind::OpenFile / PickFolder / SaveFile. - InputDialog — single-field input modal: title + prompt + TextInput + Cancel/OK;
on_resultdeliversSome(value)/None.
Date and time
- Calendar — month grid with WAI-ARIA grid keyboard pattern;
CalendarMode::Single/Range(DateRange);WeekNumberDisplaytoggle. Locale-derived first day of week and format pattern. - DateEdit — date input with trailing calendar-icon trigger;
WidthPolicy,ValidationBehavior. - TimeEdit — time input;
TimeFormat,SecondsMode. - DateTimeEdit — combined date + time input.
- DateRangeEdit — two-date range input.
Color
- HexColorInput — hex code text input with live swatch.
- ColorEdit — compact color editor with swatch trigger.
- ColorPicker — full HSV picker;
ColorPickerLayoutcontrols panel arrangement.
Menus
-
MenuBar — top-of-window menu strip; widget-based on Windows/Linux. On macOS it mirrors a declarative
MenuModelinto the systemNSMenu—MenuBar::from_model(..).native_on_macos(..)+install_native_menu(), see native-menu.md (on-device validation pending).MenuBar::buildinstalls anRc<dyn MenubarDispatcher>intoWindowStateon every platform so the framework can intercept F10,Alt+<letter>, and bare-Alt-tap before focus-based key dispatch — matching Win32'sWM_SYSKEYDOWNsemantics. ReturnsMenubarAction::{OpenMenu, FocusTrigger, Intercept}. Alt-tap is detected on theWindowState::alt_downfalling edge withother_key_pressed_during_alt == false. Mnemonic-derived chords NEVER enterShortcutRegistry— by constructionShortcutSettingscannot list them, which is the correct behaviour (mnemonics are derived from labels, change with locale, and are not user-rebindable per Win32 / GNOME HIG).macOS-specific behaviour: the dispatcher's
Alt+<letter>branch is compiled out on macOS because the OS rewrites Option+letter into accented characters (Option+E → ´, Option+F → ƒ) before winit hands the keystroke to the app — the chord can never match the mnemonic table, and intercepting would silently break accented text input. F10, bare-Alt-tap → focus menubar, and bare-letter activation inside an open menu all continue to work on macOS (none involves a transformed letter key). Mnemonic underlines are also hidden on macOS viacfg!(target_os = "macos")inMenuLabel::paintso the UI doesn't promise a chord that won't fire. Use F10 + arrows + Enter for keyboard menu navigation, and the existingShortcutsystem for Cmd+? accelerators. -
MenuList — overlay menu panel; accepts arbitrary
impl Widgetchildren.MenuSeparatorfor inline rules. Full keyboard suite: ArrowUp/Down + wrap,Home/End, Enter/Space activates the focused item, ArrowRight opens submenus, ArrowLeft/Esc bubble. Type-ahead with 500 ms default reset (.type_ahead_timeout(d)override), ASCII case-fold, separators skipped. In-menu mnemonic activation: bare letter (no modifiers) inside an open menu activates the item whose&-marker matches; mnemonic wins over type-ahead when both could fire. -
MenuItem — keyboard-highlightable menu row with
for_shortcut(id)for live-rebinding labels. Three modes via builder methods:.checked(Signal<bool>)→Role::MenuItemCheckBox, checkmark glyph in the leading slot, click flips the bound signal..check_state(Signal<CheckState>)→ tri-state checkbox; click cycles Unchecked↔Checked (Indeterminate is external-source-only per Windows convention); rendered glyph: check / dash / spacer..radio(value, Signal<usize>)→Role::MenuItemRadio, filled-dot glyph whenselected == value. Radio items in the sameMenuListauto-group viaSignal::sameand announce "2 of N" viapush_to_radio_group.
All four (icon / check / tristate / radio) are mutually exclusive — a
debug_assert!fires if both.icon(...)and a check/radio mode are set. AT state mirrorsCheckboxexactly:set_toggled(bool)for binary,inner_mut().set_toggled(Toggled::Mixed)for Indeterminate. -
Mnemonics use the in-string Windows / Qt
&convention:&Saveunderlines 'S' when Alt is held;&&produces a literal&.MenuLabel(private leaf widget) renders the underline viacanvas.draw_underlinegated onWindowState::alt_down; the AT name strips the&, and the mnemonic letter is written toinner_mut().set_access_key("S")for Windows Narrator. Parser atmnemonic.rs. -
Safe-triangle submenu hover gate: when a submenu opens, the trigger MenuItem stamps a shared anchor (cursor position at open) into the enclosing MenuList's
SafeTriangleState; sibling items, before firing their hover-switch, callpoint_in_safe_triangle(cursor, anchor, submenu_bounds). The triangle's near edge is inferred fromanchor.xvssubmenu.x— the algorithm is RTL-symmetric automatically.EventContextexposestree_pointer_position()+overlay_bounds_for_content(content_id)(snapshotted per dispatch). The existing 150 msPointerLeaveclose stays as a graceful fallback.
Overlays and dialogs
See tooltips.md for the tooltip system.
- TooltipWidget — plain, rich, or composite tooltips (three tiers, per-anchor mutual exclusion); sticky-on-dwell promotion to non-modal
Role::Dialog;TooltipRegistryfor app-wide reuse. Rich tier carries inline markup + shortcut chip + "more" disclosure; composite tier (CompositeTooltipWidget) hosts an arbitrary widget tree (CK3-style: tabbed sections, charts, progress bars). - Popover — anchored overlay accepting arbitrary
impl Widgetcontent; configurable placement, dismissal, optional caret. - Dialog — modal dialog frame;
DialogContent/ModalContainerfor content + presentation. - MessageBox — predefined info/warning/error/question modals (
MessageBoxSeverity); semantic-role buttons (ButtonRole,StandardButton,MessageBoxButton,MessageBoxButtons) with platform-aware ordering; result viaMessageBoxResult. - Snackbar — queued auto-dismissing toast with animated slide-in.
- Toast — stackable, action-rich, severity-aware floating notification (
info/success/warning/error/loading); link + button actions;Toast::idupdate-in-place; persistent archive backing; corner-anchored hover-pause stack. The "upgrade path" fromSnackbar. Full reference: toast.md. - ToastHost — per-window invisible widget owning the toast queue + per-frame timer + hover-pause; mounted by
install_toast. - NotificationLog — archive UI: mark-all-read / clear toolbar + day-bucket section headers (Today / Yesterday / This week / Earlier) + replayable action buttons.
- NotificationCenterButton — bell icon + live unread-count badge + popover containing a
NotificationLog. Marks-all-read on popover open. - NotificationLogDialog — one-liner
::show(archive, ctx)modal preset. - Shadow — drop-shadow primitive used by elevated surfaces (
AttachedSidefor one-sided shadows).
Data-driven widgets
Backed by the teksilo-data reactive collections. See data-models.md for the underlying ListModel<T> / TreeModel<T> / SelectionModel / sort-filter projections.
- Repeater — non-virtualized siblings driven by
ListModel<T>change notifications; for small bounded collections. - ListView — virtualized vertical list for large/unbounded collections.
- GridView — virtualized 2D tile grid (photo-gallery / icon-view / collection-view) bound to
ListModel<T>/ListDataSource. PluggableGridLayoutStrategy:UniformGrid(fixed size / fixed column count / adaptive min-width),VariableRowGrid(rows sized to tallest tile, auto-measure + scroll-anchoring or exact.item_height),VirtualizedMasonry(Pinterest waterfall). FlatSelectionModel(Single/Multi) with click/Ctrl/Shift + rubber-band marquee, full 2D keyboard nav (arrows / Home-End / PageUp-Down / type-ahead / Alt+Arrow reorder), drag-to-reorder routed through the source'sdrag/can_accept/accept_drop(+on_item_dropescape hatch for foreign payloads), per-tile activation + context menu, sections (grouping_sections) with sticky pinned headers, empty/loading states, source-driven lazy loading (request_window+can_fetch_more/fetch_more+ placeholder rows), andRole::Grid > Role::GridCellaccessibility. See grid-view.md; democargo run -p grid-view. - TreeView — hierarchical list with twist-arrow expand/collapse. The 4-arg
new_with_contextvariant passes aTreeRowContextcarrying a one-linetoggle_callback()for chevron wiring. - StandardListItem — canonical row layout for
ListViewdelegates:[checkbox?] [leading_slot?] [center_slot?] [label] [Spacer] [trailing_slot?], plus an optional subtitle line with its own[subtitle_leading_slot?] [subtitle] [Spacer] [subtitle_trailing_slot?]. Selection / hover / pressed background routes throughSurfaceRole::Selected/AccentSubtle/Pressed(theme-driven, roundeditem_corner_radius: 8.0, mirrorsMenuItem/ComboBox). Optional two-state (Signal<bool>) or tri-state (Signal<CheckState>) checkbox at the start of the row, independent of row selection. See the worked example in examples/data_collections/src/main.rs. - StandardTreeItem —
StandardListItemplus depth-driven indent and a chevron column (always reserved, even for leaves, so labels at the same depth align)..from_entry(&FlatEntry)sets depth + has_children + is_expanded in one call;.on_toggle(...)/.on_toggle_rc(...)wires the chevron tap to aTreeSliceHandle::toggle_expandcallback (cleanest withTreeView::new_with_context). - TableView — multi-column, virtualized; sort/filter via
SortFilterListModel, drag-resize and drag-reorder columns, pinned Leading/Trailing, cell + row selection, edit hooks, row drag-drop reorder, fullRole::TableAT tree. See table-view.md. - TreeTableView — hierarchical multi-column variant of TableView;
Role::TreeGrid.
Worked TreeView delegate using both new pieces:
#![allow(unused)] fn main() { let tree_checks: TreeCheckedModel<Item> = state.app_state(); TreeView::new_with_context(model, move |item, entry, selected, ctx| { let mut row = StandardTreeItem::new(lit!(&item.title)) .from_entry(entry) .selected(selected) .on_toggle_rc(ctx.toggle_callback()); if entry.has_children { row = row.tristate_checkbox(tree_checks.signal_for(entry.node_id)); } Box::new(row) }) }
Charts — crates/teksilo-charts/src/
Sits at the same tier as teksilo-widgets (no dep on widgets). Series
data is a ChartModel<T>
(teksilo-data, see data-models.md), not a Prop/
Signal-bound Vec. See charts.md.
- BarChart — vertical or horizontal bars; single or grouped series; optional value labels, axis labels, grid lines, hover tooltips.
- LineChart — points connected by polylines; single or multiple series; optional area fill; hover tooltips on data points.
- PieChart — pie + donut variants; donut variant has a center slot.
All three sit on the Tier-3 styling ladder via
ChartStyle
(.style(...) / theme.style_slots.chart) — an all-recipe trait
distinct from the widget world's make_*(cfg, ctx) -> WidgetId
traits; its default RecipeChartStyle lives in teksilo-charts
itself, not teksilo-widgets/src/styles/* (see
styling-system.md).
Gridlines support dashed/dotted patterns (theme-wide via a custom
ChartStyle, or per-axis via AxisConfig::gridline_dash); area and
donut fills support gradients. Full reference:
charts.md §11.
Shared infrastructure (axis.rs, legend.rs, palette.rs, layout.rs, hit.rs) is reused across all three; ChartSeries<T> / ChartDatum<T> construction DTOs live in teksilo-data and are re-exported from teksilo_charts.
Animation wrappers — crates/teksilo-widgets/src/animations/
Wrappers that animate a child subtree without the caller managing scheduler state. See animation.md for Signal<f32>::animate_to and the underlying scheduler.
- Fade — opacity tween 0↔1; layout-transparent.
- Pulse — sine-driven looping opacity oscillation (recording-indicator pattern).
- Cycle — cycles through children on a fixed period.
- Crossfade — keyed builder; old fades to new on key change.
- Collapse — height-collapse tween used by Accordion and disclosure patterns.
- Unroll — the horizontal sibling of
Collapse: a width-unroll tween for side panels and inline reveals. - SmoothSize — auto-sizes to the child's intrinsic size and animates every change (
SmoothSizeAxes). - Slide — slides a child in/out from a chosen edge (
SlideEdge); layout-stable. - Shake — damped horizontal oscillation triggered by a
Signal<u32>bump (invalid-input feedback). - Scale — uniform 2D scale 0↔1 (
ScaleOrigin); visual-only by default, optional layout-driving mode. - Rotate — rotates a child subtree by a
Prop<f32>angle in radians. - Blur — Gaussian-equivalent blur on the child subtree via dual-Kawase chain; sub-perceptual radii are zero-cost.
Settings widgets
Pre-built UI for common app-level concerns.
- ShortcutSettings — full keyboard-shortcut rebind UI (Rebind / Reset / conflict auto-unbind / key capture). See shortcut-intent-action.md.
- PrivacySettings (
telemetryfeature) — consent toggles for telemetry adapters; ties into the telemetry.md consent gate. - TextScaleControl — a specialized
SpinBox(80 %–200 %) for the global "grow all text" accessibility setting; binds the persistedTEXT_SCALE_KEY, applies app-wide on edit. See text-scale.md. - ThemeSwitcher — drop-in app-theme picker for settings screens & toolbars (native / OS-follow themes, persistence); applies app-wide on select.
- LanguageSwitcher — drop-in UI-language picker for settings screens; switches the active locale app-wide. See i18n.md.
Cross-references
- Layout protocol and slack distribution: layout-primitives.md
- Events, gestures, focus, attached handlers: events-and-gestures.md
- Accessibility overrides on every widget: accessibility-overrides.md
- Animation scheduler and
MotionTokens: animation.md - Theming and role-based color resolution: reactive-theme.md
- Drag and drop integration: drag-and-drop.md
- Settings persistence (
SettingsStore,SettingsFile<T>,MruList<T>): settings.md - Global text-scale accessibility setting (
TextScaleControl,ctx.text_scale,follow_text_scale): text-scale.md - i18n (
tr!,tr_signal!, locale-aware formatting): i18n.md - Shortcuts / intents / actions: shortcut-intent-action.md
- Multi-window orchestration: multi-window.md
- Inspector for runtime introspection: inspector.md
- Framework internals (Canvas, rendering pipeline, threading, testability): architecture.md
- Full per-widget API extraction:
python3 tools/extract_widget_api.py <Widget…>or--all
Layout Primitives
Companion to: architecture.md §2 (Layout Model) Scope: Reference for the layout primitives in crates/teksilo-widgets/src/primitives/ — the containers and size wrappers every other widget composes against.
This document is a working reference: each primitive comes with a one-line summary, the public surface as you'd actually call it, the rule the layout engine applies, and at least one runnable example. Where two primitives can express the same intent, the trade-off is called out explicitly.
1. Mental model
Teksilo layout is a SwiftUI-style two-phase negotiation, recursive over the widget tree:
- The parent calls
child.layout_response(proposal, ctx). The child returns aLayoutResponse { size, flex, min, shrink }— the size it wants (a floor for growth), aflexweight for positive-slack distribution, amincompression floor, and ashrinkweight for over-constraint deficits.flexandshrinkare independent (CSS-flexbox grow vs shrink);From<Size>defaults to fully rigid (flex = 0,shrink = 0,min = size). - The parent decides each child's main-axis size (grow on surplus, shrink on a deficit), then measures each child's cross axis at its final main size (height-for-width), then calls
child.place_children(bounds, …)to position them.
SizeProposal { width: Option<f32>, height: Option<f32> } is the parent's offer. Some(_) means use this exact value; None means measure yourself, this axis is open. Stacks pass None on their main axis to let children declare their wanted size, and Some(bounds.cross) on the cross axis to let children fill it.
Three rules underlie every primitive in this document:
- Honest sizing. A widget that knows its size returns it. A widget that wants slack returns
flex > 0. The parent makes the placement decision; the child does not place itself. - Slack is a single rule. In an
HStack/VStack,slack = bounds.main − Σ wanted − Σ spacing. Whenslack ≥ 0each child's final size iswanted + (flex / Σ flex) × slack. Whenslack < 0(over-constraint) the deficit is distributed across children withshrink > 0proportional to their shrink weight, never belowmin(iterative clamp-and-redistribute). There is no special "spacer"/"expand"/"shrinkable" branch in the engine — they are ordinary widgets that reportflex > 0/shrink > 0. - Logical pixels, Leading / Trailing. All values in
f32logical px; the renderer multiplies by scale factor at the boundary.Leading/Trailingflip withLayoutDirection::RightToLeft.
Everything in the rest of this document follows from those three rules.
Stacks (HStack/VStack) ZStack Grid
┌─ HStack ───────────────────────┐ ┌─ ZStack ─────────┐ ┌─ Grid ────────┐
│ A │ B │ slack │ C │ │ │ ┌───────┐ │ │ A │ B │ C │
│ │ │ ←→ via flex │ │ │ │ │ bg │ ┌─fg─┐ │ ├───┼───┼───┤
└─ └─ └───────────── └─ └─ │ │ └───────┘ └────┘ │ │ D │ E │ F │
│ align=center │ └───┴───┴───┘
└────────────────────┘
2. The stack containers
Three containers cover almost everything: VStack, HStack, ZStack. They share the deferred children idiom — .child(widget) queues an inline child, .add_child(id) references a pre-registered WidgetId, .children(iter) adds many at once, .child_opt(opt) is a no-op when None. Pick whichever fits the call site; you can mix them on one builder.
2.1 VStack — vertical stack
crates/teksilo-widgets/src/primitives/vstack.rs
Lays children top-to-bottom. Cross-axis (horizontal) alignment is HAlignment — default Leading. Spacing accepts a static f32 or a Signal<f32>.
#![allow(unused)] fn main() { use teksilo::prelude::*; VStack::new() .spacing(8.0) .alignment(HAlignment::Center) .child(TextWidget::new(lit!("Title")).style(TextStyleRole::BodyBold)) .child(TextWidget::new(lit!("Subtitle"))) .child(Button::new(lit!("Save"))) }
Sizing rule: wants Σ heights + spacing on the main axis, max(width) on the cross axis. If any child reports flex > 0 and the parent bounds the height, the VStack greedily claims the offered height so slack exists.
Cross-axis floor. Every child receives the VStack's full width as its proposal.width. A TextWidget in TextOverflow::Wrap will measure-and-wrap against that width; an HStack child fills that width.
2.2 HStack — horizontal stack
crates/teksilo-widgets/src/primitives/hstack.rs
Mirror of VStack. Cross-axis (vertical) alignment is VAlignment — default Center. RTL-aware: in LayoutDirection::RightToLeft, children are placed right-to-left automatically. There is no manual mirroring.
#![allow(unused)] fn main() { // Inside build(): bind spacing reactively to a theme token. let gap = ctx.theme_signal().map(|t| t.layout.control_gap); HStack::new() .spacing(gap) .alignment(VAlignment::Center) .child(IconWidget::checkmark(16.0)) .child(TextWidget::new(lit!("Save"))) .child(Spacer::new()) .child(Button::new(lit!("Cancel"))) // pushed to trailing edge }
2.3 ZStack — overlay stack
crates/teksilo-widgets/src/primitives/zstack.rs
Children overlap; later children paint on top. Size is the max of children's intrinsic sizes; the proposal is only used as a fallback when no child has a queryable size. Container-level alignment is a full Alignment (both axes); per-child override via tree.set_alignment(id, …).
#![allow(unused)] fn main() { ZStack::new() .alignment(Alignment::TOP_TRAILING) .child(image_view) // the background .child( // close button in the corner Button::new(lit!("×")) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Close)), ) }
A common pattern: full-bleed background + foreground. Background widgets that report 0×0 for an unspecified proposal (e.g. RectWidget::new()) do not inflate the stack — only children with non-zero intrinsic size do. The place_children call then proposes the full ZStack bounds to every child, so an unsized background fills it.
2.4 Per-child alignment override
Container-level alignment applies uniformly. To diverge for one child, call tree.set_alignment(child_id, Alignment::BOTTOM_TRAILING). The override always takes a full two-axis Alignment; an HStack reads only the vertical axis, a VStack reads only the horizontal axis, a ZStack reads both. The override lives on the arena node, so it survives reactive theme switches, language flips, and reordering.
3. Slack and flex
Slack is the leftover space inside a stack after every child's wanted size and the inter-child spacing have been honored. It's distributed proportionally to each child's flex weight. Default flex is 0 (rigid). Two primitives ship flex > 0:
3.1 Spacer — fills available space
crates/teksilo-widgets/src/primitives/spacer.rs
Returns LayoutResponse::flexible(Size::new(min, min), 1.0). The min-length is a floor on the main axis (default 0); the parent stack adds slack share on top.
#![allow(unused)] fn main() { HStack::new() .child(label) .child(Spacer::new()) .child(button) // pushed to trailing HStack::new() .child(Spacer::new()) .child(label) .child(Spacer::new()) // centers `label` HStack::new() .child(a) .child(Spacer::new().min_length(20.0)) // ≥ 20 px gap, more if available .child(b) }
3.2 Expand — claim space and fill a child
crates/teksilo-widgets/src/primitives/expand.rs
Expand is the workhorse. It returns flex (default 1.0) and stretches its single child to its allocated bounds. Unlike Spacer, it has a child.
#![allow(unused)] fn main() { // Single panel filling the rest of the row: HStack::new() .child(sidebar) .child(Expand::new().child(main_panel)) // Ratio splits — Category-A flex layouts: HStack::new() .child(Expand::new().flex(1.0).child(left)) // 1/3 of slack .child(Expand::new().flex(2.0).child(right)) // 2/3 of slack // Single-axis variants — name the axis you compete for slack on: VStack::new() .child(header) // intrinsic height .child(Expand::vertical().child(content)) // takes remaining vertical .child(footer) // Opt out of fill — align the child at its natural size in claimed space: Expand::new() .align_child(Alignment::CENTER) // == Center::new() .child(label) }
Zero-basis vs auto-basis (CSS analog)
By default Expand reports wanted = 0 on its flex axes. That's CSS flex-basis: 0 — slack divides cleanly by weight, regardless of the child's natural size. [Expand::flex(1).child(60), Expand::flex(2).child(40)] in 300 px splits exactly 100 / 200.
Switch with .respect_intrinsic() (CSS flex-basis: auto) when the parent is unconstrained on the flex axis. The child's natural size acts as a floor and slack is added on top. Use this inside an outer VStack with height = None, where zero-basis would let the child overflow because the parent has no bound to share.
Trade-off (called out at expand.rs:130): with respect_intrinsic, exact ratios bend by content. The same [1, 2] split inside a 300 px parent now gives 60 + 66 = 126 and 40 + 133 = 173 rather than 100 / 200. Keep zero-basis for ratio layouts and reach for respect_intrinsic only when you actually need the floor.
horizontal() / vertical() semantics
The named axis is the one the wrapper competes for slack on. Cross-axis behavior depends on whether the parent bound that axis:
Expand::vertical()inside aVStack(parent binds width, distributes height) — fills the VStack's full width AND distributes vertical slack.Expand::horizontal()inside aVStack— claims the VStack's full width, but reportsflex = 0on the open vertical axis. It does not steal vertical slack from siblings — height stays at child intrinsic.
Symmetric for HStack. The behavior is documented and tested at expand.rs:25-41.
3.3 Center — center a child within given space
crates/teksilo-widgets/src/primitives/center.rs
Centers a single child within the space Center is given. Per axis: it
fills an axis the parent bounded and shrink-wraps to the child on an axis
the parent left open. It reports flex = 0 — a pure alignment wrapper, not a
space-claiming one.
#![allow(unused)] fn main() { Center::new().child(spinner) }
Consequence: a bare Center inside an HStack / VStack does not grab
the stack's slack — a stack leaves its main axis open, so Center sizes to its
child there (like Flutter's Center / Align, or Compose's Box), rather than
collapsing to zero. To center a child within the leftover space of a stack,
give it flex with Expand: Expand::horizontal { Center { w } } (the analogue
of Flutter's Expanded(child: Center(...))).
The child is measured under the constraint Center received (a
loose-but-bounded proposal, like Flutter's loose constraints): rigid children
keep their natural size and are centered; adaptive children respond to the
bound — an ellipsis TextWidget truncates at the slot width instead of
overflowing symmetrically around the center, and wrapping text reports its
real wrapped height. Expand's .align_child(...) mode measures its child
the same way.
3.4 Shrinkable — opt a child into compression
crates/teksilo-widgets/src/primitives/shrinkable.rs
The shrink counterpart to Expand. By default widgets are rigid: when a stack is over-constrained they keep their wanted size and overflow. Wrap a child in Shrinkable to let it absorb a share of the deficit, down to a floor:
#![allow(unused)] fn main() { // The label yields space before the (rigid) icon when the row is narrow: HStack::new() .child(Shrinkable::new().min_width(40.0).child(long_label)) .child(icon) // rigid — shrink == 0, never compresses }
Shrinkable preserves its child's flex (so a child can both grow and shrink) and forwards the proposal unchanged; when the stack compresses it, the child re-lays-out at the smaller size (a wrapped-text child re-wraps and reports its taller height via the height-for-width pass). The floor defaults to 0 on both axes — set .min_width / .min_height. Set .shrink(w) to weight how much of the deficit this child takes relative to siblings; .shrink(0.0) makes it rigid again.
"Compress A before B" = give A shrink > 0 and B shrink = 0: A absorbs the entire deficit (down to its floor) before B is touched.
Native shrink (no wrapper needed). Single-line / ellipsis TextWidget opts in for you: it reports shrink = 1 with a min of the ellipsis-glyph width, so display labels truncate-to-fit (tune with .min_shrink_width, disable with .no_shrink). Controls (Button, IconButton, Badge, ComboBox) are deliberately rigid — a truncated action reads poorly, so the desktop convention is to overflow excess actions into a menu rather than shrink them (see Toolbar). The wrappers Padding / ZStack / MinSize propagate flex + shrink + min, so a shrinkable child stays shrinkable through them, and a stack advertises its aggregate grow/shrink to its parent only on its own main axis.
3.5 Height-for-width
A stack decides each child's main-axis size first, then measures the cross axis at that final size. So a child whose height depends on its width — wrapped text, an AspectRatio image — reports the correct height for the width it actually got, and that height propagates up the tree (a wrapped paragraph in a narrowed Shrinkable grows taller, and its row grows with it).
To keep the resulting main-then-cross queries linear, layout_response is memoized per (widget, proposal) for the duration of a layout pass (WidgetArena::cached_layout_response, cleared each pass). A widget that deliberately writes state from layout_response (e.g. a debug probe) opts out via Widget::cacheable_layout() -> false.
Debugging over-constraint. When children still spill past their distributing parent (nothing left to shrink), the debug inspector paints Flutter-style yellow/black hazard stripes on the overhang — on by default, F12. See docs/inspector.md. Demo: cargo run -p over-constraint.
4. Size wrappers
Five primitives constrain what their child can be:
| Wrapper | Rule | When to use |
|---|---|---|
FixedSize | Child reports bound.width / bound.height (or its natural size on unbound axes); parent proposal is ignored on bound axes. | Dialog widths from settings, animated panel widths. |
MinSize | Child's wanted size is clamped upward on each constrained axis. | Touch targets (MinSize::new(48.0, 48.0)), readable column widths. |
MaxSize | Child's wanted size is clamped downward. Sets clips_children: true so overflow is scissored. | Reading-width caps (MaxSize::width(640.0)), modal max-height. |
AspectRatio | Wanted size fits within proposal at a fixed width / height. | Image previews, video tiles, square avatars. |
Padding | Wraps a child with insets; child receives proposal − insets, parent reports child + insets. | Inner spacing inside cards, dialogs, list rows. |
4.1 FixedSize
crates/teksilo-widgets/src/primitives/fixed_size.rs
#![allow(unused)] fn main() { // Static width, child decides height: FixedSize::new().width(280.0).child(content) // Reactive — animated sidebar: let sidebar_width = ctx.animated_signal(280.0); let sidebar = FixedSize::new() .width(sidebar_width.clone()) .child(sidebar_content); // Later, on toggle: ctx.animate().normal().standard().to_or_snap(&sidebar_width, 0.0); }
Both width and height accept impl Into<Prop<f32>> — pass an f32 for static, a Signal<f32> for reactive. The bound proposal is forwarded to the child, so wrap-aware children (TextWidget in TextOverflow::Wrap, ScrollArea, etc.) measure against the right constraint.
Without any binding, FixedSize just reports the child's natural size and ignores the parent proposal. That's how you opt a widget out of stretching inside an HStack where siblings expand.
4.2 MinSize
crates/teksilo-widgets/src/primitives/min_size.rs
#![allow(unused)] fn main() { // 48×48 minimum touch target — the Button composite uses this internally: MinSize::new(48.0, 48.0).child(content) // Single axis: MinSize::width(120.0).child(label) MinSize::height(36.0).child(row) // Reactive: MinSize::width(0.0).min_width(min_w_signal).child(text) }
The proposal forwarded to the child is clamped upward to the minimum. A wrapping TextWidget inside MinSize::width(100) measures against width >= 100, so its wrapped height reflects the minimum width — not the unconstrained natural width. Tested at min_size.rs:230-258.
4.3 MaxSize
crates/teksilo-widgets/src/primitives/max_size.rs
#![allow(unused)] fn main() { // Reading-width cap on a long article: MaxSize::width(640.0).child(article_text) // Both axes — modal content with hard ceiling: MaxSize::new(800.0, 600.0).child(dialog_content) // Reactive — user-resizable panel: MaxSize::width(9999.0).max_width(panel_width).child(content) }
Symmetric to MinSize: proposal clamped downward, wanted size clamped downward. Sets clips_children: true when any constraint is active — content that exceeds the cap is scissored, not bled. Hidden from the accessibility tree (builder.set_hidden()).
4.4 AspectRatio
crates/teksilo-widgets/src/primitives/aspect_ratio.rs
#![allow(unused)] fn main() { AspectRatio::widescreen().child(video_thumbnail) // 16:9 AspectRatio::square().child(avatar) // 1:1 AspectRatio::new(4.0 / 3.0).child(legacy_photo) }
Picks the largest size matching the ratio that fits the proposal. Given width = Some(w), height is w / ratio. Given height = Some(h), width is h × ratio. Given both, fits within both. Given neither, returns 0×0 — always wrap an unconstrained AspectRatio in a parent that bounds at least one axis.
The child fills the resolved bounds.
4.5 Padding
crates/teksilo-widgets/src/primitives/padding.rs
#![allow(unused)] fn main() { // All four insets: Padding::new(16.0, 24.0, 16.0, 24.0).child(content) // top, right, bottom, left // Symmetric — vertical and horizontal pairs: Padding::symmetric(12.0, 16.0).child(content) // Uniform: Padding::uniform(16.0).child(content) // Reactive — track a theme-derived inset: let pad = ctx.theme_signal().map(|t| t.layout.section_gap); Padding::uniform(pad).child(content) }
All four arguments accept impl Into<Prop<f32>> — static or reactive. The child is proposed parent − insets; the wrapper reports child + insets. No alignment — the child is anchored to the inner top-leading corner and stretched to fill the inner rect.
5. The grid and flow containers
For tables of mixed-size content, multi-column flow, and form-style label/field pairs.
5.1 Grid — explicit row and column tracks
crates/teksilo-widgets/src/primitives/grid.rs
Children are placed in row-major order — child i goes to row i / cols, column i % cols. Tracks come in three sizing modes:
#![allow(unused)] fn main() { use teksilo::widgets::{Grid, TrackSize}; // 3 columns: [auto | 1fr | 80px], 2 rows of intrinsic height Grid::new() .columns(vec![ TrackSize::Auto, TrackSize::Fractional(1.0), TrackSize::Fixed(80.0), ]) .rows(vec![TrackSize::Auto, TrackSize::Auto]) .column_gap(8.0) .row_gap(4.0) .child(label_a) .child(field_a) .child(unit_a) .child(label_b) .child(field_b) .child(unit_b) }
Fixed(px)— exactly that many logical pixels.Auto— sized to the largest child intrinsic size in that track.Fractional(weight)— splits remaining space (after Fixed and Auto are claimed) by weight.
Two-pass layout. Auto tracks are resolved against children's unspecified-proposal width. Fractional tracks then take the remainder. Children that landed in Fractional columns narrower than their intrinsic single-line width are re-measured at the resolved column width — wrapping content reports its actual wrapped height instead of bleeding outside its cell. See grid.rs:159-223 for the reasoning.
Both column_gap and row_gap accept impl Into<Prop<f32>>.
5.2 Wrap — line-breaking flow
crates/teksilo-widgets/src/primitives/wrap.rs
A horizontal flow that wraps to the next line when a child won't fit. Each child keeps its intrinsic size; lines are packed greedily.
#![allow(unused)] fn main() { Wrap::new() .spacing(8.0) // between items on a line .line_spacing(4.0) // between lines .children(tag_strings.iter().map(|t| Badge::new(lit!(t.clone())))) }
Reports total height = Σ line heights + line gaps, where each line's height is the max child height on that line. Width reports the longest line (so an unconstrained Wrap collapses to its widest single-line case — wrap it in something that bounds width to actually trigger wrapping).
Use cases: tag clouds, toolbar overflow, chip lists, breadcrumb segments that fold to a second line on narrow windows.
5.3 MasonryLayout — Pinterest-style packing
crates/teksilo-widgets/src/primitives/masonry.rs
Variable-height grid where each child slots into the shortest column at the time. Column count is fixed; column width is (available_width − gaps) / columns. RTL-aware (column 0 is the rightmost in RTL).
#![allow(unused)] fn main() { MasonryLayout::new(3) // 3 columns .column_spacing(12.0) .item_spacing(8.0) .children(photos.iter().map(|p| PhotoCard::new(p.clone()))) }
Each child is queried at column-width to get its real height, then placed under the shortest column. Ties break leftmost-first. Used for heterogeneous-height cards where you want dense packing without the rigid row breaks of a grid.
When to choose: masonry over grid when item heights vary a lot and you don't mind that the visual row alignment is broken; grid over masonry when columns must align horizontally.
5.4 ColumnFlow — responsive columns that reflow
crates/teksilo-widgets/src/primitives/column_flow.rs
The newspaper model. Content runs down column 0, then down column 1. The column count is derived from the available width and min_column_width; when the width no longer affords N columns the layout drops to N−1 and every child is re-partitioned across the survivors. Children are atomic — one child never straddles a column boundary.
#![allow(unused)] fn main() { ScrollArea::new().child( ColumnFlow::new() .min_column_width(240.0) // as many ≥240 dp columns as fit .max_columns(4) // …but never more than 4 .column_spacing(16.0) .item_spacing(12.0) .children(articles.iter().map(|a| ArticleCard::new(a.clone()))), ) }
Pair it with a ScrollArea for vertical overflow: ColumnFlow reports its true content height (the tallest column), so the scroll extent comes out right.
Reading order is the whole design
Children are distributed as contiguous runs in source order — column 0 takes children 0..i, column 1 takes i..j. So source order, visual reading order, and focus order are the same thing at every column count:
wide narrower
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ 1 │ │ 3 │ │ 5 │ │ 1 │ │ 4 │
├────┤ ├────┤ ├────┤ ├────┤ ├────┤
│ 2 │ │ 4 │ │ 6 │ ───► │ 2 │ │ 5 │
└────┘ └────┘ └────┘ ├────┤ ├────┤
│ 3 │ │ 6 │
└────┘ └────┘
reading order: 1..6 reading order: 1..6
This is why ColumnFlow does not reuse MasonryLayout's shortest-column packing: masonry interleaves children (child 4 may land above child 3), which divorces the visual order from the source order. Teksilo's focus traversal and its AccessKit walk both derive from tree order, so an interleaving layout would read out of order. WCAG 1.3.2 Meaningful Sequence names multi-column text as its first example and blesses exactly this column-major order.
Because the order is right by construction, no aria-flowto is needed — that attribute is an advisory fallback for when the logical order is wrong, and it doesn't affect Tab order anyway.
Balancing
The partition minimises the tallest column, subject to keeping runs contiguous, and uses exactly k columns when there are at least k children. Six equal cards in three columns give [2, 2, 2]; four equal cards in three columns give [2, 1, 1] rather than [2, 2, ∅] — both have the same tallest column, but stranding a trailing column looks broken.
Internally this bisects the column extent (greedy fill is the feasibility oracle) over a fixed iteration count. layout_response and place_children each run the search from scratch — there is no persisted partition state, following MasonryLayout — so the search is deterministic by construction and both hooks agree.
Column width
| Knob | Effect |
|---|---|
min_column_width(f32) | The narrowest a column may be; sets the count. Defaults to 240 dp. CSS column-width, SwiftUI GridItem(.adaptive(minimum:)), Compose GridCells.Adaptive(minSize). |
max_columns(usize) | Ceiling on the count however wide the layout gets. CSS's column-count when paired with column-width. |
max_column_width(f32) | The widest a column may be. Unset by default, so columns stretch to share the width evenly. |
alignment(HAlignment) | Where the block sits when max_column_width leaves leftover width. Defaults to Leading; RTL-aware. |
Set max_column_width when few columns fit a large display — two columns on a 4K monitor are otherwise ~1900 dp wide and unreadable. It's the same reason KDE's Kirigami.CardsLayout pairs minimumColumnWidth with maximumColumnWidth.
Reacting to the count
column_count_signal() -> Signal<usize> publishes the live count, written from the layout pass behind an equality guard so it only fires on a real change.
Binding contract. Safe for RepaintOnly / AccessibilityOnly consumers, and for Relayout consumers that do not feed back into this widget's own width. The count is a pure function of the width ColumnFlow is given — it never changes its own width, so it cannot oscillate on its own. But a Relayout consumer that resizes something which in turn resizes the ColumnFlow closes a feedback loop through the layout pass, which is what Widget::place_children's own documentation warns against.
Accessibility
By default ColumnFlow emits a bare Role::GenericContainer carrying no properties, and the accessibility walker prunes it, promoting the children to its parent in source order. That is the right outcome: a layout primitive contributes geometry, not meaning, and the reading order is already correct. Setting any property here — even an orientation — would keep the node alive as AT noise. For a layout, maximum accessibility means being invisible to assistive tech while preserving order.
Add semantics from the outside with the usual overrides:
#![allow(unused)] fn main() { ColumnFlow::new() .children(cards) .access_role(Role::Region) // a landmark users can jump to .access_label(tr!(latest_stories())) }
Or opt into list semantics when the children genuinely are a list of peers:
#![allow(unused)] fn main() { ColumnFlow::new().semantic_list(true).children(cards) // container → Role::List; each child → Role::ListItem with // position_in_set / size_of_set, so AT announces "list, 30 items", // "item 5 of 30". Costs one node per child. }
It is deliberately not Role::Grid: the ARIA grid pattern mandates arrow-key cell navigation and roving focus, which GridView implements and ColumnFlow does not. Claiming the role without the contract would lie to AT.
Column rule
.column_rule(width, color) draws a hairline centred in every inter-column gap — CSS column-rule. Decorative only; it emits no accessibility node. Pass BorderRole::Divider to track the theme.
Two limitations worth knowing
ColumnFlowis rigid (flex = 0,shrink = 0). It adapts when width is decided for it — as aVStackchild, inside aScrollArea, at a window root. As anHStackmain-axis child it gets its natural width and won't reflow; wrap it inExpandto claim main-axis width. Same asWrap.- It is not a CSS multicol port. CSS
column-fill: balancebalances within a column height it computes from a bounded block size.ColumnFlowderives the column count from the width and lets the height run free. No CSScolumn-fillmode does that.
When to choose: ColumnFlow when the column count should follow the width and items must read in order (article lists, card collections, settings panels). MasonryLayout when the count is fixed and dense packing beats reading order. Grid when cells must align in rows and columns. GridView when the items come from a ListModel and you need virtualization or cell-level keyboard navigation.
5.5 FormLayout — two-column label / field
crates/teksilo-widgets/src/primitives/form_layout.rs
A specialized two-column layout: label column auto-sizes to the widest label, field column takes the rest. Supports full-width rows for separators or wide inputs.
#![allow(unused)] fn main() { // host, port, timeout are Signal<String> / Signal<u16> / Signal<u32>. FormLayout::new() .label_gap(12.0) .row_spacing(8.0) .label(tr!(connection_settings())) // emits Role::Form landmark .line(TextWidget::new(tr!(host())), TextInput::new(host)) .line(TextWidget::new(tr!(port())), SpinBox::new(port, 0u16, 65535u16)) .full_width(Divider::new()) .full_width(GroupHeader::new(tr!(advanced()))) .line(TextWidget::new(tr!(timeout_ms())), TextInput::new(timeout)) }
.line(label, field)adds a paired row..full_width(widget)adds a row spanning both columns — sections, dividers, full-width inputs..label(LocalizedString)opts in to theRole::Formaccessibility landmark with that name. Without a label, the layout demotes toGenericContainer— an unnamed landmark hurts AT users more than it helps. Passtr!(…)directly.
Row height is max(label.height, field.height). The label column width is the widest label intrinsic — every row's label cell is sized to that uniform width, so the field columns line up vertically across all rows.
5.6 Switcher — show one child at a time
crates/teksilo-widgets/src/primitives/switcher.rs
Internally a ZStack where each child has a visible_when binding derived from selected.map(|i| i == index). Layout is the size of the active child.
#![allow(unused)] fn main() { let page = Signal::new(0_usize); Switcher::new(page.clone()) .child(welcome_view) .child(settings_view) .child(about_view) // Elsewhere: page.set(2); // jumps to about_view }
Use for tab content, wizard pages, or any "one of N visible" pattern. Hidden from the accessibility tree itself — the visible child supplies the AT presentation. Switcher::capture_child_ids_into(rc) exposes child IDs to callers that need to wire AT relationships (TabWidget does this for the Tab → TabPanel controls link).
6. Spacers and visual separators
6.1 Divider — themed separator line
crates/teksilo-widgets/src/primitives/divider.rs
A 1 px (theme-tokenable) line. Horizontal by default, fills the proposal's main axis, claims thickness on the cross axis.
#![allow(unused)] fn main() { VStack::new() .child(header) .child(Divider::new()) // full-width horizontal rule .child(body) HStack::new() .child(left_pane) .child(Divider::vertical().thickness(2.0).color(BorderRole::Strong)) .child(right_pane) }
color() accepts the full ColorProp range — Color, a role (typically BorderRole), or Signal<Color>. Defaults to BorderRole::Divider. Emits Role::Splitter to AT.
Note: Divider is a visual separator, not a draggable splitter — for drag-to-resize panes, use SplitView from teksilo-widgets.
6.2 Spacing summary
| Need | Use |
|---|---|
| Push siblings to the edges | Spacer::new() |
| Hard gap with grow-if-available | Spacer::new().min_length(n) |
| Static gap between siblings | HStack::new().spacing(n) / VStack::new().spacing(n) |
| Visual divider line | Divider::new() |
| Inset around a child | Padding::uniform(n) / Padding::symmetric(v, h) / Padding::new(t, r, b, l) |
7. When to use which
| Goal | Reach for |
|---|---|
| Vertical column of widgets | VStack |
| Horizontal row, RTL-safe | HStack |
| Background + foreground on the same area | ZStack |
| Push to one edge | Spacer in a stack |
| Equal split (1:1, 1:2, …) | Expand::flex(n) pairs in a stack |
| One panel takes the rest | Expand::new().child(panel) |
| Center one child | Center::new().child(w) |
| Force a minimum touch area | MinSize::new(48.0, 48.0) |
| Cap reading width | MaxSize::width(640.0) |
| Dialog with a fixed width | FixedSize::new().width(w) |
| Animated panel width | FixedSize::width(animated_signal) |
| Locked aspect ratio (image, video) | AspectRatio::new(w/h) |
| Inner spacing | Padding |
| Tabular data with mixed track sizes | Grid |
| Tag cloud / toolbar overflow | Wrap |
| Pinterest-style heterogeneous cards | MasonryLayout |
| Columns that follow the width and read in order | ColumnFlow |
| Settings forms | FormLayout |
| Tab pages / wizard steps | Switcher |
When two primitives could express the same thing, prefer the more specific one — the name is a hint to the next reader. Spacer::new() instead of Expand::new() when you mean "empty pushable region." MinSize::new(48, 48) instead of FixedSize::width(48.0).height(48.0) when you mean "at least," not "exactly." (Note Center is not a synonym for Expand::new().align_child(CENTER) — it reports flex = 0 and shrink-wraps an open axis, so it does not claim stack slack; see §3.3.)
8. Reactive sizing
Every size constraint that takes an impl Into<Prop<f32>> is reactive. Pass an f32 for a static value, a Signal<f32> for reactive, or use BuildContext::animated_signal(value) for an animatable one.
Whenever a bound size value changes, the framework dirty-marks the wrapper for relayout (not just repaint). The relayout starts at the highest dirty ancestor and runs layout_response + place_children for each dirty subtree; clean subtrees are skipped. This is the same incremental-layout model browsers and Qt use.
#![allow(unused)] fn main() { // Animated drawer: let drawer_w = ctx.animated_signal(0.0); let drawer = FixedSize::new() .width(drawer_w.clone()) .child(drawer_content); let toggle = Button::new(lit!("Open")) .on_activate_fn({ let drawer_w = drawer_w.clone(); move |ctx| { let target = if drawer_w.get() > 0.0 { 0.0 } else { 280.0 }; ctx.animate().normal().standard().to_or_snap(&drawer_w, target); } }); }
Behavior under prefers-reduced-motion: to_or_snap snaps the value instead of tweening. The relayout still fires, just once instead of per-frame.
9. Composing your own
A custom layout container is an ordinary Widget that returns children from build(), picks a wanted size in layout_response, and places its children in place_children. The layout engine doesn't care whether a widget is shipped in teksilo-widgets or written in your app crate.
#![allow(unused)] fn main() { use teksilo_canvas::{Point, Rect, Size, SizeProposal}; use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement}; use teksilo_core::widget_id::WidgetId; #[derive(Debug)] struct StaggeredColumn { child_ids: Vec<WidgetId>, offset: f32, } impl Widget for StaggeredColumn { fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse { let child_proposal = SizeProposal { width: proposal.width, height: None }; let mut total_h = 0.0; let mut max_w = 0.0_f32; for &id in &self.child_ids { if let Some(s) = ctx.child_size(id, child_proposal) { total_h += s.height; max_w = max_w.max(s.width + self.offset); } } Size::new(proposal.width.unwrap_or(max_w), total_h).into() } fn place_children( &self, bounds: Rect, _proposal: SizeProposal, children: &mut [WidgetPlacement], ctx: &LayoutContext, ) { let mut y = bounds.y; for (i, child) in children.iter_mut().enumerate() { let s = ctx.child_size(child.id, SizeProposal::with_width(bounds.width)) .unwrap_or(Size::ZERO); let dx = self.offset * (i as f32); child.origin = Point::new(bounds.x + dx, y); child.size = Size::new(s.width.min(bounds.width - dx), s.height); y += s.height; } } fn children(&self) -> Vec<WidgetId> { self.child_ids.clone() } } }
Three things to remember:
- Don't close over
bounds.widthfor the child proposal inlayout_response. That measures children against the parent's offered width, not the wrapper's bounds. Useproposal.width. - Match
place_children's child query to your sizing policy. Iflayout_responsequeried withSizeProposal::with_width(w), query the same way inplace_children— otherwise wrapping children measure twice with different results. - Honor flex. If your layout wants stacks-style slack distribution, sum
child_layout_response(...).flexand apply the standard rule. If your layout doesn't distribute slack, ignore flex; that's fine.
For testing, crates/teksilo-core/src/test_widgets.rs ships FillWidget and StackWidget (pub(crate)); for end-to-end layout tests use WidgetTree directly with tree.layout(SizeProposal::exact(w, h)) and assert tree.bounds(id).
10. References
- Architecture: architecture.md §2 Layout Model
- Reactive layer: reactive-theme.md,
Signal<T>/Prop<T>in crates/teksilo-core/src/signal.rs - Animation tied to layout: animation.md
- Custom widget patterns:
Widgettrait, BuildContext - Visual tour:
cargo run -p widget-catalog,cargo run -p text-and-layout,cargo run -p data-grid
Events and Gestures
Companion to: architecture.md
Scope: How input becomes widget behavior in Teksilo — attached handlers, preview/bubble dispatch, gesture recognizers, and the EventContext deferred-operations pattern.
1. What we designed for
The event system has to handle three unrelated things cleanly:
- Raw input from the platform — pointer moves, key presses, scroll, IME composition, trackpad pinch.
- Recognized gestures composed from raw events — tap, double-tap, long-press, drag, swipe.
- Accessibility actions — a screen reader or automation tool asking the widget to do something (click, set value, set selection) without any pointer or keyboard at all.
The V1 design unified these behind a single fn event(&mut self, event: &WidgetEvent, ctx: &mut EventContext) -> EventResponse method on every Widget. Every widget wrote one giant match statement on the event enum. This worked, but it forced a pile of incidental complexity:
- Gesture recognizers had to be instantiated per-widget by hand.
- The
RefCell<Option<State<T>>>pattern was mandatory to mutate state created duringbuild()from insideevent(). - Composition was painful: wrapping a widget and also listening for taps meant the wrapper had to re-dispatch the inner's events manually.
- Unused handler slots still cost the dispatcher a virtual call per widget per event.
V2 replaces the single method with attached handlers. Widget builders register typed closures for the specific events they care about; the framework stores those closures on the arena node and dispatches them automatically. The Widget trait itself has no event() method anymore.
2. Preview and bubble — the two-pass model
Every event that targets a specific widget (via hit testing for pointer events, via the focused widget for keyboard events, via the target node for AccessKit actions) travels through the tree twice:
- Preview pass: root → target. Each ancestor gets a chance to consume the event before the target sees it. A
MenuListoverlay uses the preview pass to intercept Arrow keys before any menu item sees them; a modal scrim uses it to swallow pointer events that fall outside the modal. Preview handlers returnEventResponse::Handledto stop the pass. - Bubble pass: target → root. The target handles the event first; if it returns
Ignored, the event walks up the parent chain until something handles it or the root is reached. This is how aButton's.on_key(Key::Space)handler can be registered on the button itself, but Ctrl+S falls through to a root-levelAction.
root
│ preview: root first
↓
ancestor
│
↓
parent
│
↓
target ← event fires here
│ bubble: target first
↑
parent
│
↑
ancestor
│
↑
root
Implementation is a single walk per pass in widget_tree/event_dispatch_impl.rs: dispatch_to_widget_returning_handled(target, &event) collects ancestors, runs preview top-down, then runs bubble target-up, returning on the first Handled.
The framework decides what "target" means per event type:
- Pointer events (
PointerDown,PointerMove,PointerUp,PointerEnter,PointerLeave) — hit-tested against layout bounds. The deepest hit wins. Preview runs from the root to that hit; bubble walks back up. - Scroll events — hit-tested at the pointer position; bubble to the nearest
on_scrollthat returnsHandled(a scroll container typically). - KeyDown / KeyUp / IME — routed to the focused widget. Preview from root down, bubble focused-widget up.
- AccessKit actions — routed to the target node directly. No pointer, no focus — the platform's AccessKit request carries a
NodeId. No preview pass; handler runs on the target only, then bubbles.
There is no "capture phase" distinct from preview, no event replay, no explicit listener list. The tree structure is the listener list.
3. Attached handlers
Widget builders register event handlers via blanket-implemented methods on the WidgetBuilder trait. Every widget gets them for free:
#![allow(unused)] fn main() { ctx.add( MinSize::new(48.0, 48.0).child(content) .on_tap(|event, ctx| { // event is &TapEvent { position, button, modifiers } ctx.send_intent(AppIntent::Clicked); }) .on_hover(move |entered, _ctx| { interaction.set(if entered { InteractionState::Hovered } else { InteractionState::Idle }); }) .focusable(true) .cursor(CursorIcon::Pointer) ); }
Under the hood, the builder wraps the widget in a WidgetWithHandlers<W> that carries a HandlerSet. On arena insertion the handlers are moved onto the WidgetNode; the wrapper evaporates. At dispatch time the framework looks up the relevant closure on the node and calls it with the event data and an EventContext. Absent handlers are None and cost nothing.
3.1 Handler catalogue
event_handlers.rs defines the full set. Summarized:
| Handler | Fires when | Signature (simplified) |
|---|---|---|
on_tap | A single primary-button tap completes | FnMut(&TapEvent, &mut EventContext) |
on_double_tap | Two taps within 300 ms, within 10 px | same |
on_triple_tap | Three taps within the recognizer window | same |
on_long_press | Pointer held past the long-press threshold | same |
on_hover | Pointer enters / leaves the widget's bounds | FnMut(bool, &mut EventContext) |
on_focus | Widget gains or loses focus | FnMut(bool, &mut EventContext) |
on_key | Focused widget receives a KeyDown / KeyUp | FnMut(&WidgetEvent, &mut EventContext) -> EventResponse |
on_scroll | Scroll event hits the widget | same |
on_pointer_event | Low-level pointer escape hatch (any Pointer* variant) | same |
on_drag | Gesture-based drag — Started, Moved*, Ended phases | FnMut(DragPhase, &mut EventContext) |
on_swipe | One-shot swipe with direction + velocity | FnMut(SwipeDirection, f32, &mut EventContext) |
on_pinch | OS trackpad magnify / rotate phases | FnMut(PinchPhase, &mut EventContext) |
on_drag_hover | DnD payload hovers over the widget | FnMut(&DragPayload, Point, &mut EventContext) -> DropFeedback |
on_drag_leave | Drag leaves the widget (target change, drop, cancel, or source destroyed) | FnMut(&mut EventContext) |
on_drag_tick | Per-frame tick while the widget is the current drop target | FnMut(Point, &mut EventContext) |
on_drop | DnD payload released on the widget | FnMut(DragPayload, Point, &mut EventContext) -> bool |
on_access_action | AccessKit action request targets the widget | FnMut(accesskit::Action, &mut EventContext) -> EventResponse |
on_access_action_request | Full AccessKit action with payload (SetTextSelection, SetValue, SetScrollOffset) | see source |
3.1.1 TapEvent — button + modifiers in the callback
The four click-style handlers (on_tap / on_double_tap / on_triple_tap / on_long_press) all receive a borrowed TapEvent:
#![allow(unused)] fn main() { #[non_exhaustive] pub struct TapEvent { pub position: Point, // widget-local coords pub button: PointerButton, // which button finalised the gesture pub modifiers: Modifiers, // held at the finalising event } }
This lets a single handler discriminate by mouse button and modifier without falling back to on_pointer_event:
#![allow(unused)] fn main() { .on_tap(|event, ctx| match (event.button, event.modifiers) { (PointerButton::Primary, Modifiers::SHIFT) => extend_selection(ctx), (PointerButton::Primary, Modifiers::CTRL) => toggle_selection(ctx), (PointerButton::Primary, _) => set_selection(ctx), (PointerButton::Secondary, _) => show_quick_actions(ctx), _ => {} }) }
Modifiers come from the finalising event — Up for on_tap / on_double_tap / on_triple_tap, the held Down for on_long_press (which recognises on a timer before any Up). The struct is #[non_exhaustive] so future fields don't break match patterns or constructors.
Coordinate space (framework invariant). Every pointer / gesture position delivered to a handler —
on_tap/on_double_tap/on_triple_tap/on_long_press,on_drag(DragPhase), andon_pointer_event(PointerDown/Move/Up) — is in that handler's widget-local space (relative to the node's top-left, with any ancestorScale/Rotatetransform undone). The framework converts once at dispatch viaWidgetArena::local_pointer_position; widgets must not subtract their own bounds origin. (Acontent_transformnode such asSceneViewis the one exception: it owns its view transform and receives positions in its parent-effective space.) The drag-and-drop drop callbacks (on_drop/on_drag_hover/on_drag_tick) and thecontext_menufactory are dispatched on a separate path and likewise receive widget-local / window-local positions as documented at their own call sites.
3.1.2 Button-acceptance filter — default Primary, opt-in to more
Each of the four recognizers defaults to [ButtonMask::PRIMARY] — left-click only. A right-click on a Button, Checkbox, MenuItem, etc. does not activate the widget; it can still open a context menu via .context_menu(...) or be handled directly via on_pointer_event. Multi-tap recognizers further require every tap in the sequence to use the same button — mixed-button sequences fail rather than spuriously firing.
To opt a handler into a wider button set, call the matching accept_*_buttons(...) knob:
#![allow(unused)] fn main() { Button::new(lit!("Action")) .accept_tap_buttons(ButtonMask::PRIMARY | ButtonMask::SECONDARY) .on_tap(|event, ctx| match event.button { PointerButton::Primary => primary_action(ctx), PointerButton::Secondary => alt_action(ctx), _ => {} }); }
ButtonMask exposes the obvious constants and bitwise operators; ButtonMask::ALL is the catch-everything shorthand and accept_any_button() on the recognizer types is the equivalent. The same family of knobs exists for double-tap (accept_double_tap_buttons), triple-tap (accept_triple_tap_buttons), and long-press (accept_long_press_buttons).
The PointerButton enum covers Primary, Secondary, Middle, plus Back and Forward (mouse 4 / 5). Platforms that don't surface the auxiliary buttons simply never emit them.
3.1.3 Other flag-like attachments
Plus a handful of flag-like attachments that don't take event-data closures:
| Flag | Purpose |
|---|---|
.focusable(true) | Opt the node into tab order |
.tab_index(n) | Explicit tab index, scoped to the nearest FocusScope (see §6) — Some sorts before unindexed, ascending |
.cursor(CursorIcon::Pointer) | Cursor when pointer is over the widget |
.clips_children(true) | Scissor clipping to bounds (ScrollArea, MaxSize) |
.context_menu(factory) | Right-click overlay factory — see §3.1.4 |
3.1.4 Context-menu factory — Fn(Point, &mut EventContext) -> Option<Box<dyn Widget>>
Right-click handling lives at a different tier from the four tap-family hooks. Instead of a recognizer-driven callback, the framework wires a single factory that produces the menu widget on demand. When the user right-clicks, the framework's show_context_menu_for walks up the parent chain looking for the nearest ancestor with a factory installed, calls it with the click position (widget-local) plus a full EventContext, and:
- Mounts the returned widget as an
OverlayLayer::InTreeoverlay anchored at the factory-owning widget, placed at the click position. - Dismisses pre-existing overlays first.
- Saves the previously-focused widget for restoration when the menu dismisses.
- Focuses the menu content so keyboard navigation works immediately.
#![allow(unused)] fn main() { .context_menu(|position, ctx| { // Use `position` to identify what was right-clicked (a row in a // list, a node in a tree, an item under a hit-test). let row = pick_row_at(position.y)?; // Use `ctx` to read window state, query app-state, send intents, // or update Signals before the menu mounts. ctx.send_intent(AppIntent::TelemetryRightClick { row_id: row.id }); Some(Box::new(build_menu_for(row))) }) }
The factory is Fn (re-entrant) and called fresh on every right-click — the menu's enabled / disabled flags read live state at the moment it opens, so a "Paste" item correctly greys out when the clipboard becomes empty between two right-clicks.
Returning None declines the click and the framework continues walking up the parent chain to the next ancestor with a factory. This lets a widget conditionally suppress its own menu without uninstalling the factory:
#![allow(unused)] fn main() { .context_menu(|_, _| if disabled.get() { None } else { Some(build_menu()) }) }
A factory that always returns None produces no menu and no fall-through visible effect — the right-click is consumed silently.
3.2 HandlerSet — handlers from inside build()
Attached handlers via WidgetBuilder methods only work on child widgets (ctx.add(MinSize::new().on_tap(...))). A composite widget that wants to install handlers on itself (typical for focusable containers that should swallow keyboard events) uses HandlerSet + ctx.apply_self_handlers:
#![allow(unused)] fn main() { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let handlers = HandlerSet::new() .focusable(self.enabled) .cursor(CursorIcon::Pointer) .on_tap(move |_event, ctx| { /* ... */ }) .on_key(move |event, ctx| { if let WidgetEvent::KeyDown { key: Key::Space, .. } = event { // handle activation return EventResponse::Handled; } EventResponse::Ignored }); ctx.apply_self_handlers(handlers); // ... then add children ... } }
Multiple apply_self_handlers calls across a widget's build() chain merge via HandlerSet::merge; two on_tap closures both run. This lets a composite widget compose its own behavior with a base trait's contributed handlers.
3.3 Why attached handlers won
The event()-method vs attached-handlers tradeoff flipped once three things became clear:
- Gesture auto-wiring. When a widget attaches
on_tap, the framework instantiates aTapRecognizerin the node's gesture arena on the fly. The widget author never touches the recognizer. Under the V1 model, every tappable widget had to declare the recognizer by hand. - Cheap composition. Wrapping a widget inside a
MinSizeand also listening for taps on the outer wrapper used to require a second widget with a customevent()impl. Now it's.child(content).on_tap(...)— theMinSizedoesn't need to know its parent wrote a tap handler, because the handler is on the wrapper's node, not insideMinSize. - Mutation without
RefCell<Option<State>>. V1'sevent(&mut self, ...)required&mutaccess to state built inbuild(&self), forcing theRefCell<Option<State<T>>>pattern. Attached handlers close overSignal<T>clones — the signal itself is clone-friendly and internally cells its own storage, so the handler closure doesn't need&mut self.
4. Gesture recognizers — composition with backpressure
gesture.rs defines the recognizer state machines. Each is a pure, platform-free value type that consumes RawPointerEvent::{Down, Move, Up} and emits GestureResult::{Pending, Recognized(GestureEvent), Failed}.
Built-in recognizers (the four click-style ones default to ButtonMask::PRIMARY — call .accept_buttons(...) / .accept_any_button() to widen):
TapRecognizer— fires on a down-up without movement past the tap-slop threshold. Down/Up button must match.DoubleTapRecognizer— two taps within 300 ms. Both taps must use the same button.TripleTapRecognizer— three. Same button across all three.LongPressRecognizer— pointer held past ~500 ms. Modifiers are captured atDown.DragRecognizer— emitsDragStartedonce the pointer moves past the drag-start threshold, thenDragMovedper move, thenDragEndedon pointer-up.SwipeRecognizer— pointer moves fast enough to qualify as a swipe in one of four cardinal directions.
PinchRecognizer is not in the list because on desktop the OS delivers TouchpadMagnify / RotationGesture events directly (winit passes them through); the framework turns those into PinchPhase events without needing a recognizer.
4.1 GestureArena — cooperating and competing
When a widget attaches multiple gesture handlers (on_tap + on_long_press), both recognizers run in parallel on the same event stream via GestureArena. The arena's rules:
- Each recognizer sees every raw event until it returns
RecognizedorFailed. - When one recognizes, competing recognizers whose
resets_on_peer_recognitionflag is set get reset (DoubleTapRecognizerpeers-reset whenTapRecognizeralone fires — so a single tap doesn't arm a phantom "missing second tap" in the double-tap recognizer). - Cooperative recognizers (tap and triple-tap, for instance) run to completion side-by-side.
Widget authors never touch the arena directly. Attaching handlers via WidgetBuilder or HandlerSet auto-wires the recognizers and the arena on the node.
4.2 Cross-widget tap/drag disambiguation — drag observers
The GestureArena is per-widget; there is no cross-widget arena. That
leaves one gap: a descendant's on_tap installs a TapRecognizer that
captures the pointer on PointerDown, which would otherwise route every
following PointerMove/PointerUp to the descendant alone — so an ancestor
that wants to start a drag (a SceneView behind tappable cards, a draggable
container wrapping tappable rows) would never see the move and could never
begin its drag.
The framework closes this without cross-arena arbitration, by leaning on the
existing active_drag takeover (an in-flight start_drag is consulted
before capture routing). On PointerDown, after the normal dispatch, if a
descendant captured the pointer the framework arms drag observers: it walks
the captured widget's strict ancestors and, for each one that carries an
on_drag / on_swipe recognizer, feeds the down event into that ancestor's
own gesture arena (no on_pointer_event, no second capture). On each
subsequent PointerMove (while no drag is yet active) it advances those
observers; the moment one recognizes a drag it calls start_drag, and the
active_drag takeover pulls the pointer away from the descendant. If no
ancestor drag fires, the descendant's tap completes normally on PointerUp.
Two consequences worth knowing:
- A widget that has its own
on_drag(a slider, a DnD row) is left untouched — the arming step skips a captured widget that already carries a drag recognizer, so only pure-tap descendants inside a draggable ancestor change behavior. - A quick press-release on the card is still a tap (no move crossed the drag threshold); only a press-and-drag escalates to the ancestor. This is exactly what makes "drag from on top of a select-only scene card starts a marquee, click selects it" work (see teksilo-scene.md "Drag mode").
5. EventContext — the deferred-operations pattern
Handlers don't mutate the tree directly. They request mutations on their EventContext and the framework applies them after the dispatch finishes:
#![allow(unused)] fn main() { pub struct EventContext { // tree structure tree_mutations: Vec<TreeMutation>, // SetDormant / Activate / Destroy // focus focus_requests: Vec<WidgetId>, // overlays overlay_requests: Vec<OverlayRequest>, overlay_dismissals: Vec<OverlayId>, delayed_overlay_requests: Vec<...>, timed_overlay_requests: Vec<...>, dismiss_all_overlays: bool, dismiss_top: bool, // modals modal_requests: Vec<ModalRequest>, dismiss_modal: bool, // repaint / layout repaint_requests: Vec<WidgetId>, // intents + shortcuts pending_intents: Vec<Intent>, pending_key_capture: Option<KeyCaptureSlot>, pending_shortcut_mutations: Vec<ShortcutMutation>, // window-level theme_request: Option<Theme>, locale_request: Option<String>, close_window_requested: bool, // cursor cursor_request: Option<CursorIcon>, // frame loop frame_requested: bool, // ... } }
This single-pass deferral matters for two reasons:
- Safety. A handler that destroys its own widget, then inspects state on that widget, would crash. Deferring the destroy until after the handler returns avoids use-after-free without runtime cost.
- Ordering. Multiple handlers along the bubble path can each queue mutations; the framework applies them in a well-defined order (intents first, then tree mutations, then repaints). A widget author doesn't have to reason about mid-handler tree shape changes.
5.1 Ambient ops available from any handler
Via EventContext, any handler can:
ctx.set_theme(theme)— swap the app theme; all windows rebuild.ctx.set_locale(id)— switch i18n locale; dirty-marks locale-bound signals.ctx.close_window()— request the owning window close.ctx.request_focus(widget_id)— programmatic focus transfer (overlay content on open, first error field on submit).ctx.dismiss_all_overlays()— useful after menu item activation.ctx.send_intent(AppIntent::X)— fire a typed intent; framework walks source → root invoking any matchingAction. See shortcut-intent-action.md.ctx.request_frame()— ask the event loop to pump one more frame (caret blink restart, drag auto-scroll, pending document events).ctx.app_state::<T>()— look up an app-scoped value registered onTeksiloAppBuilderbyTypeId.
These are the methods that make it possible to build app-level behavior (menu routing, theme switching, shortcut rebind UIs) without any global statics or hardcoded backchannels.
6. Focus management
Focus is a single Option<WidgetId> stored on the tree. Tab / Shift+Tab moves it across widgets whose node has focusable = true. The framework publishes three signals that widgets can observe:
- The currently focused node id (read via
tree.focused()). - Focus origin —
Keyboard(tab/shift-tab/programmatic) orPointer(tap) orProgrammatic. Used to paint a focus ring only on keyboard focus by default; pointer focus typically omits the ring per Int UI style. - Focus-gained / focus-lost events dispatched to widgets via
on_focus(gained: bool, ctx).
Programmatic focus transfer goes through ctx.request_focus(id). The framework also exposes first_focusable_descendant(id) for modal openers (dialogs that should land focus on the primary action button — it returns the widget Tab would land on first, respecting the scope rules below) and ScrollIntoView synthesized on focus change so that tab-focusing an offscreen widget scrolls the nearest clipping ancestor to reveal it.
Focus cleanup on destroy is automatic: destroying a focused widget clears focus; the next input event that requires focus routes to the nearest focusable ancestor or root.
6.1 Traversal scopes (FocusScope)
Tab order is not one flat global ring — it is a tree of traversal scopes. Every focusable widget belongs to its nearest enclosing FocusScope; the whole window (or, while a centered modal is open, that modal's content) is an implicit root scope. Within a scope, members — focusable leaves and nested scopes, each counted as one unit — are ordered by scoped tab_index (then document order). Because tab_index is compared only among siblings of the same scope, two sibling scopes that both number their children 1, 2, 3 never interleave. This is Teksilo's analogue of Flutter FocusTraversalGroup / WPF KeyboardNavigation.TabNavigation.
A scope is declared by wrapping a subtree in the layout-transparent FocusScope wrapper, which carries a TraversalScopePolicy governing what Tab does at the scope's ends:
| Policy | At the scope boundary |
|---|---|
Continue | Tab flows out into the enclosing scope's next member. Groups + scopes tab_index numbering without trapping focus — e.g. dock panels in a continuous Tab order. |
Cycle | Tab wraps within the scope and never leaves via keyboard — modal dialogs only. |
#![allow(unused)] fn main() { // teksu!: a modal dialog whose Tab order is confined to its own content FocusScope(TraversalScopePolicy::Cycle) { Button::new(lit!("OK")) Button::new(lit!("Cancel")) } // builder form FocusScope::new(TraversalScopePolicy::Cycle).child(dialog_body) }
Not for popovers or menus. A non-modal overlay is dismissed when keyboard
focus leaves it (see Overlays follow focus out, below) — the behaviour ARIA's
Disclosure and Menu patterns call for, and what stops an open panel from
covering the focus ring that just left it (WCAG 2.2 SC 2.4.11). Cycle-wrapping
one traps focus so that dismissal never fires.
The root scope is implicitly Cycle (whole-tree last↔first wrap, the historical behavior). A centered modal overlay folds into the same mechanism: its content subtree becomes the root Cycle scope, so Tab is confined to the modal with no special-case code. The FocusScope node itself is forced non-focusable (it is a boundary, never a Tab stop). A subtree with no FocusScope behaves exactly like the old flat wrapping ring.
Not to be confused with
view_focus_*.BuildContext::begin_view_focus/view_focus_active(formerly thefocus_scopechrome API) is an unrelated build-time mechanism that tracks "does this data view's subtree hold focus" to drive selection chrome and focus rings. It has nothing to do with Tab traversal. Traversal scopes are theFocusScopewidget +set_traversal_scope.
Implemented in cycle_focus (the recursive scope-tree walk) and set_traversal_scope (the node marker, directly usable from headless tests).
Overlays follow focus out
A non-modal overlay is dismissed when keyboard focus leaves it. Menus, popovers, dropdown panels and suggestion lists do not contain focus; Tab is an exit gesture for all of them, and the panel goes when focus does. Widgets get this for free — there is nothing to wire, and nothing to wrap.
This is what the patterns those surfaces implement actually specify. ARIA APG's Menu pattern is unqualified: Tab "moves focus out of the menu or menubar, and closes all menus and submenus" — only the arrows navigate within. A popover implements Disclosure, which mandates no containment. The alternative, trapping, is legal (WCAG 2.1.2 is satisfied by Escape alone) but unsupported by any of those patterns, and it leaves the real defect in place: an open panel sitting over the focus ring that just left it, which is WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum), Level AA.
An overlay is eligible when it is positioned at its anchor — Below, Above, TrailingEdge, AtPointer, NearAnchor, BelowPreferred. Those hang off a control, so "focus left that control" means something. The viewport-placed variants are excluded, and deliberately: Centered is the modal (the one surface whose pattern does contain focus), FullViewport is its scrim, and BottomCenter / ViewportCorner are notifications. A snackbar is shown from a focused button and leaves it focused — an anchor-aware rule that did not exclude it would tear the snackbar down on the user's very next keystroke, overriding both its timer and .persistent(). A toast's lifetime belongs to its timer, never to where the keyboard happens to be.
Two further exclusions: an overlay already fading out (dismissing it again collapses the tween it is mid-way through), and tooltips, which tooltip_focus_leave_outside owns end-to-end with a deliberately wider test — it keeps a tip alive while focus rests on its anchor, the normal state of a focus-promoted tip.
DismissBehavior is not consulted. It selects which of Escape / click-outside / hover-out apply, an orthogonal axis: a popover that opted out of click-outside did not thereby ask to survive being tabbed away from.
An overlay's anchor counts as part of it. A non-searchable ComboBox keeps focus on its trigger the whole time its dropdown is open, and a SearchField keeps it in the text input while suggestions float below — focus is never inside the overlay, so a content-only test would conclude nothing was ever open. Arriving on the anchor still counts as leaving, though, so Shift+Tab off the front of a popover closes it and lands on the trigger, where Escape would have left you.
Nested overlays close as a cascade: the walk goes up parent_overlay and dismisses the outermost eligible level, which takes every level below it — APG's plural "all menus and submenus" — while stopping at a host surface so a menu never drags its hosting dialog, composite tooltip or revealed menubar down with it.
Implemented in dismiss_overlays_left_by_focus, called from focus_with_origin_ops — the single funnel every focus change passes through, so Tab, click-to-focus, AccessKit and ctx.request_focus are all covered by one mechanism.
6.5 Drag-and-drop lifecycle
Target-side handlers fire in a strict order. A widget that accepts drops should assume this sequence and own the cleanup of any feedback state it sets:
on_drag_hover(payload, pos, ctx) -> DropFeedback— fires on everyPointerMovewhile this widget is the drop target (pointer inside its bounds and the framework picked it viafind_drop_target_at_or_above). The widget typically stashes its own feedback state (an insertion line y, a highlight rect) and returns the matchingDropFeedbackdescriptor.posis in target-local coordinates — origin at the target widget's top-left — so drop-index math can reuse the same coordinate system as the target's ownboundsandpaintlayout.on_drag_tick(local_pos, ctx)— fires once per layout pass while the widget is the current drop target. Use for per-frame behaviours that must keep progressing when the pointer is stationary: viewport-edge auto-scroll (linear ramp inside an edge zone), spring-loaded folder expansion after a dwell time. Receives the pointer position in widget-local coordinates.on_drag_leave(ctx)— fires exactly once when this widget stops being the drop target. The framework emits it for all four leave scenarios: pointer moved to a different target, drop completed (on this or another target), Escape-cancelled, or the drag source was destroyed mid-drag. Widgets MUST clear any feedback state they set inon_drag_hoverhere — the framework does not touch widget-owned state.on_drop(payload, pos, ctx) -> bool— fires onPointerUponly if this widget is the drop target at the release position. Already preceded byon_drag_leave(so feedback is cleared by the time the drop handler decides acceptance). Returnstrueif accepted.
Framework guarantees the ordering: on_drag_leave runs before on_drop on the same widget for a successful drop, and before cleanup_drag_preview for cancels. The DragPreview overlay (created via EventContext::start_drag_with_preview) follows the pointer throughout and is dismissed by the framework in all paths — widgets don't manage it.
7. Synthetic events
The framework dispatches a few synthetic events the widget code doesn't see from the platform:
PointerEnter/PointerLeave. Derived fromPointerMoveby comparing the hit target frame-over-frame. A widget moving out from under a stationary pointer still getsPointerLeave— the hit target changed even if the pointer didn't.FocusGained/FocusLost. Issued when focus moves.ScrollIntoView { target }. Issued by the focus system after a focus change to a widget outside the viewport. Nearest clipping ancestor handles it by adjusting its scroll offset.- Synthetic clicks.
ctx.synthetic_click(id)dispatches a simulated tap at the widget's center — used by AccessKit action routing (Action::Click), menu item activation, and some shortcut-triggered activations that want to go through the full tap path.
8. Testing
Events are synthesizable from tests without a real platform:
#![allow(unused)] fn main() { let mut tree = WidgetTree::new(); let btn_id = tree.add(Button::new(lit!("OK")).on_activate_fn(|ctx| { ctx.send_intent(AppIntent::Confirm); })); tree.layout(SizeProposal::exact(200.0, 100.0)); // Synthesize a pointer tap at the button's center: let bounds = tree.bounds(btn_id); tree.dispatch_event(WidgetEvent::PointerDown { position: bounds.center(), button: PointerButton::Primary, modifiers: Modifiers::NONE, }); tree.dispatch_event(WidgetEvent::PointerUp { position: bounds.center(), button: PointerButton::Primary, modifiers: Modifiers::NONE, }); }
For gesture-level assertions the test_api module on WidgetTree exposes helpers like synthesise_tap(id) that run the preview-bubble walk with a fabricated event. Timing-sensitive recognizers (double-tap, long-press) use the tree's simulated clock — advance_time(Duration) in tests.
No Xvfb, no GPU, no display server required.
9. Design rules in one list
- Widget authors register typed closures per event type; no monolithic
event()method. - Events travel preview (root → target) then bubble (target → root); first
Handledstops the pass. - Attach handlers on children with
.on_foo(…)viaWidgetBuilder; attach on self withHandlerSet+ctx.apply_self_handlers. - Gesture recognizers are auto-wired from attached handlers;
GestureArenaarbitrates cooperation and reset. - Handlers express mutations by calling methods on
EventContext; the framework applies them after dispatch. ctx.send_intent(X)is the single way to request app-level behavior from a handler;ctx.set_theme / set_locale / close_window / request_focus / dismiss_all_overlayscover the framework-level ambient ops.- Focus is a single optional WidgetId; transfers happen via
ctx.request_focus(id); Tab/Shift+Tab walks a tree ofFocusScopes (scopedtab_index, per-scopeContinue/Cyclepolicy), defaulting to a flat document-order ring when no scopes are present. - Everything is headless-testable — dispatch synthetic events, advance the simulated clock, inspect the tree.
See also
- animation.md —
Signal<f32>::animate_toand the scheduler. Handlers that kick off motion (toggle thumb, accordion height, snackbar slide-in) callanimate_toon animation-capable signals; the docs here and there are two halves of the "handler runs → something moves" path. - shortcut-intent-action.md — how intents travel source → root and fire
Actions; rebindable keystrokes viaShortcutRegistry. - architecture.md §22 Window Management — modal-vs-modeless, window focus routing.
- architecture.md §13 Overlay System — overlay stack, click-outside, Escape cascade, focus-restore on dismiss.
- crates/teksilo-core/src/event_handlers.rs —
EventHandlersstruct. - crates/teksilo-core/src/widget_builder.rs — blanket-impl builder methods.
- crates/teksilo-core/src/gesture.rs — recognizer state machines.
- crates/teksilo-core/src/widget_tree/event_dispatch_impl.rs — dispatch walk.
- crates/teksilo-core/src/widget.rs —
EventContext. - crates/teksilo-widgets/src/focus_scope.rs — the
FocusScopetraversal-scope wrapper (§6.1). - crates/teksilo-core/src/widget_tree/focus_impl.rs —
cycle_focusscope-tree traversal,set_traversal_scope,view_focus_*chrome signals.
Styling System
Teksilo's theming is a four-tier ladder. Each tier is independently opt-in: an app that only needs dark mode never sees the higher tiers; an app shipping a brutalist redesign uses every rung.
Tier 0: Tokens (colors, shapes, motion, typography, layout)
Tier 1: Variants (per-widget closed enums: Filled / Plain / …)
Tier 2: Recipes (paint vocabulary — shape, fill, border, shadow)
Tier 3: Style protocols (`trait FooStyle { fn make_body(...) -> WidgetId }`)
The default implementations of Tier 3 (the Recipe*Style types
shipped in teksilo-widgets/src/styles/) read Tier 2 recipes; the
default recipes read Tier 0 tokens. So Tier 3 contains Tiers 0-2 for
the IntUI preset — but the trait protocol at Tier 3 is the escape
hatch that lets apps replace the entire chrome of any widget without
touching the widget source.
Reference for designers — image-backed themes (Figma / Penpot / Canva exports → 9-slice assets → reskinned app) get their own deep reference at
docs/image-themes.md. The image-theme system is a parallel set ofimpl FooStyleblocks on top of the same Tier-3 surface — same widgets, different chrome.
Mental model — which tier do I use for X?
| Task | Tier | API |
|---|---|---|
| Tweak a color across the whole app | 0 | theme.colors.accent = … |
| Make a single Button red | n/a | Button::color(Color::RED) (always-allowed prop override) |
| Pick "outlined" instead of "filled" on a Button | 1 | Button::variant(ButtonVariant::Outlined) |
| Make every Outlined Button thicker | 2 | Modify a BorderRecipe in the IntUI preset, OR ship a new preset |
| Replace Button chrome entirely (glassmorphism / brutalist / Material-3) | 3 | impl ButtonStyle for MyGlassButton then theme.style_slots.button = Some(Rc::new(MyGlassButton)) |
| Reskin from designer-exported SVGs | 3 | Ship an ImageBackedButtonStyle via the manifest loader |
The cardinal rule: never edit widget source to change a look. If
the existing API doesn't get you there, write an impl FooStyle block
and install it.
Tier 0 — Tokens
The five token groups (ColorTokens, ShapeTokens, LayoutTokens,
TypographyTokens, MotionTokens) live in
teksilo-tokens/src/. They're pure data
structs with no widget knowledge.
Theme aggregates the five token groups plus appearance, component
dimensions, and the typed style-slot bag:
#![allow(unused)] fn main() { pub struct Theme { pub appearance: ThemeAppearance, // Light | Dark — required pub colors: ColorTokens, pub layout: LayoutTokens, pub typography: TypographyTokens, pub shape: ShapeTokens, pub motion: MotionTokens, pub style_slots: ComponentStyleSlots, // typed Rc<dyn FooStyle> slots pub extensions: ThemeExtensions, } }
There is no Theme::default(). Apps explicitly pick a preset:
#![allow(unused)] fn main() { use teksilo::prelude::intui; let theme = intui::light(); // or intui::dark() }
Other presets ship as opt-in Cargo features (Material 3, macOS, Fluent); until then only IntUI is bundled.
Reactive. Theme lives behind a Signal<Theme> on
WidgetTree — set_theme(...) dirty-marks every widget for repaint
without rebuilding the tree. Focus, scroll offsets, and animation
state survive theme swaps. See
docs/reactive-theme.md.
Extensions. theme.with_extension::<MyPalette>(...) /
theme.extension::<MyPalette>() attach app-specific extras that don't
fit any of the five token groups. Cheap (TypeId lookup), arbitrary
type.
How a widget goes grey when disabled
A widget is disabled when its own enabled prop is false or any ancestor's
is — a control inside a disabled form is disabled. Its chrome greys by one
of two routes, and there is a trap in each.
Role-driven chrome dims for free. ColorProp::resolve(theme, effective_enabled) — which every role-driven leaf (TextWidget,
IconWidget, RectWidget) calls at paint time — substitutes the disabled
counterpart of a role in a disabled subtree: any TextRole →
TextRole::Disabled; the accent family (SurfaceRole::Accent /
AccentHover / AccentPressed, BorderRole::Accent) → their
AccentDisabled counterpart; and the neutral interactive
SurfaceRole::Field / BorderRole::Field → their Disabled counterpart.
This is why most recipes never mention is_disabled.
⚠️ The substitution only reaches roles. ColorProp::Bound(Signal<Color>)
resolves to s.get() and ignores enabled entirely — so a recipe that folds
per-state colours into a flat reactive colour (as RecipeButtonStyle does via
PerStateRecipe + bind_fill) gets no paint-time safety net, and must
select its WidgetState::Disabled from cfg.is_disabled.
Neutral controls must opt into a Field role. The substitution
deliberately leaves passive surfaces alone — a disabled Panel keeps its
surface. It has to: a text field's frame and a passive Panel both painted
SurfaceRole::Content, so the hook could not tell them apart, and dimmed
neither. That is what Field is for. It resolves identically to Content
while enabled and substitutes to Disabled when not, so a field dims and a
panel does not:
#![allow(unused)] fn main() { // A field's frame. No `is_disabled` needed for the resting case — the role // dims itself at paint, from the live arena. let bg = RectWidget::new().background(SurfaceRole::Field); // The border still consults `is_disabled`, so disabled outranks *focus*. let border_role = cfg.is_focused.zip(&cfg.is_disabled).map(|(f, d)| { if *d { BorderRole::Disabled } else if *f { BorderRole::Focused } else { BorderRole::Field } }); }
SurfaceRole::Disabled / BorderRole::Disabled resolve to the neutral
surface_disabled / border_disabled tokens. Do not reach for
AccentDisabled here: it is a washed-out accent (pale cyan in IntUI),
right for an accent-filled Button and wrong for a grey field.
Getting the signal. ctx.effective_enabled_signal(self_id).map(|on| !*on)
ANDs the widget's own enabled prop with every ancestor's. It is a node-resident
signal that the framework refreshes from the live arena each state-change pass,
so it may be bound to a prop and passed to ctx.effect.
It is deliberately not a signal derived by walking ancestors at call time. A
widget's parent is still None while its own build() runs — insert_widget
inserts the node parentless and wires the parent only after build() returns —
so such a walk sees an empty chain and captures the widget's own enabled prop
as the whole answer, permanently. Prefer a Field role over the signal where you
can: the paint-time route reads the live tree and cannot go stale.
Raw-colour widgets bypass all of this. Anything shaping through a
RichTextEngine (e.g. TextInputField) hands GPU colours straight to the
engine, so no ColorProp is involved. Resolve against ctx.effective_enabled
in paint instead.
Color::mix and non-finite factors
darken, lighten, desaturated, and ColorTokens::for_inactive_window
(the window-deactivation accent projection — see
window-activation.md) all bottom out in
Color::mix(other, t). t is clamped to [0, 1] before use, but a bare
t.clamp(0.0, 1.0) is not enough on its own: f32::clamp returns NaN for
a NaN input rather than saturating it, so a NaN factor used to poison
every channel and produce an unrenderable colour. Color::mix now maps a
NaN factor to 0.0 (returning self unchanged) before clamping — the
safest reading of an undefined mix. ±inf needs no such special case: the
clamp already maps them to 1.0 / 0.0 correctly. A caller deriving t
from a ratio that can legitimately divide by zero no longer needs to guard
it before calling mix.
Tier 1 — Variants
Each themable widget exposes a closed *Variant enum naming its
design-language presentations. The variant is a hint: the active
Tier-3 style decides what it means.
#![allow(unused)] fn main() { ButtonVariant { Filled, Tinted, Outlined, Plain, Ghost, Link, Destructive } ToggleVariant { Switch, Pill, Square, Inset } CheckboxVariant { Square, Rounded, Circle } RadioVariant { Circle, Square, Rounded } IconButtonSize { Compact, Default, Toolbar, Large, Hero } // size = variant for IconButton CardVariant { Plain, Elevated*, Outlined, Filled } // * = #[default] PanelVariant { Plain, Sunken, Raised, Highlighted } PopoverVariant { Default, Menu, Tooltip } SliderVariant { Continuous, Discrete, Range } TextInputVariant { Outlined, Filled, Underline, Bare } ComboBoxVariant { Outlined, Filled, Underline, Plain } ScrollBarVariant { Permanent, Overlay, Thin } AvatarShape { Circle, Square, Rounded } // and AvatarSize, AvatarCorner, AvatarPresence }
Card defaults to Elevated (shadow + surface_main) — the "just
works" Card that matches pre-refactor behaviour. Use
.variant(CardVariant::Plain) for a flat surface.
The remaining themable widgets are variant-free: MenuItem,
StandardListItem / StandardTreeItem, TabBar, TooltipWidget,
Dialog, Snackbar, Banner, SegmentedControl, ProgressBar,
Link, Badge, SearchField, SpinBox, DateEdit, ColorPicker,
Calendar, RichTextEditor, ListView / TreeView (via
ListContainerStyle), TableView / TreeTableView (via TableStyle).
Their style traits take a *StyleConfig with no variant field —
the design language has a single canonical shape, or the variant
distinction lives elsewhere (e.g. ProgressBarKind for determinate
vs indeterminate).
Slider and ScrollBar additionally carry an orientation enum
(SliderOrientation, ScrollBarOrientation) alongside the variant,
since orientation changes layout, not just paint.
Several widgets have multi-method style traits where chrome
decomposes into named slots (e.g. TabStyle::make_body +
make_bar). See Multi-method styles below
for the full list.
Set per-call: Button::new(lit!("Save")).variant(ButtonVariant::Outlined).
Set per-app via a custom Tier-3 style that defaults a variant for
unspecified callers.
IntUI variant policy. Int UI is intentionally minimalist about
button styling — destructive actions live in confirmation dialogs
where the body carries the warning, not the button. So the IntUI
RecipeButtonStyle collapses several variants:
Destructive → Filled, Tinted/Outlined → Plain, Link → Ghost.
Other design languages (Material 3 if/when it ships) honour them
distinctly.
Tier 2 — Recipes
Recipes are pure data describing paint vocabulary. They live in
teksilo-core/src/styles/recipe.rs.
Primitive recipe types:
#![allow(unused)] fn main() { pub enum ShapeRecipe { Rect { corner_radius: CornerRadius }, Pill, // corner = min(w,h)/2 Circle, } pub enum FillRecipe { Solid(RecipeColor), // overlay composited over base at `alpha` → flat color. The M3 / // Fluent "state layer" (hover = 8 %, pressed = 12 % on-color). StateLayer { base: RecipeColor, overlay: RecipeColor, alpha: f32 }, LinearGradient { stops: Vec<GradientStop>, angle_deg: f32 }, RadialGradient { stops: Vec<GradientStop>, center: (f32, f32), radius: f32 }, None, } pub struct BorderRecipe { pub width: f32, pub color: RecipeColor, pub style: BorderStyle, // Solid | Dashed { dash, gap } | Dotted pub position: BorderPosition, // Inside | Center | Outside (now honoured) pub sides: Option<BorderSides>, // None = uniform; Some = per-side widths } // BorderSides { top, trailing, bottom, leading: f32 } — e.g. // BorderRecipe::underline(w, color) for an M3/Fluent filled-field underline. pub struct ShadowRecipe { pub offset: Vec2, pub blur: f32, pub spread: f32, pub color: RecipeColor, } }
Per-state cascades. Most widgets need different recipes for hover
/ pressed / focused / disabled. The answer is
PerStateRecipe<T> with an explicit fallback chain — Teksilo's
take on Flutter's WidgetStateProperty<T>:
#![allow(unused)] fn main() { pub struct PerStateRecipe<T> { pub idle: T, pub hover: Option<T>, // falls back to idle pub pressed: Option<T>, // falls back to hover, then idle pub focused: Option<T>, // falls back to hover, then idle pub disabled: Option<T>, // falls back to idle } }
PerStateRecipe::resolve(WidgetState) -> &T walks the chain. No
closures, fully Serde-serialisable, theme-file-friendly.
Colors in recipes. Recipes hold a RecipeColor enum (not
ColorProp) — Static | Surface(SurfaceRole) | Border(BorderRole) | Text(TextRole) — so the full theme cascade still applies but the
recipe stays plain data (serializes cleanly for inspector JSON Export
and TOML image-theme manifests).
Gradients are rendered. FillRecipe::LinearGradient /
RadialGradient paint through the SDF gradient pipeline (via
PaintProp, the gradient-or-solid fill prop RectWidget accepts).
Anything Into<ColorProp> is also Into<PaintProp> as a solid, so
existing fills are unchanged.
Configurable dimensions per widget. Every themable widget now
surfaces a public FooRecipe dimension struct, and its
RecipeFooStyle carries recipe: FooRecipe with a
RecipeFooStyle::new(recipe) constructor. Default fills the recipe
from the IntUI pub const dimension block (kept as the default source),
so a theme can tweak just the dimensions without writing a new Tier-3
impl:
#![allow(unused)] fn main() { let toggle = RecipeToggleStyle::new(ToggleRecipe { track_width: 52.0, track_height: 32.0, thumb_diameter: 24.0, thumb_inset: 4.0, }); theme.style_slots.toggle = Some(Rc::new(toggle)); }
The four multi-method widgets (Tab, Dialog, Table, Calendar) expose a
flat recipe each (TabRecipe, DialogRecipe, TableRecipe,
CalendarRecipe). A handful with no tunable dimensions (SpinBox,
SplitButton, GridView, ListContainer, RichTextEditor) stay unit structs.
Tier 3 — Style protocols
The escape hatch. Each themable widget exposes a trait:
#![allow(unused)] fn main() { pub trait ButtonStyle: 'static { fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId; } pub struct ButtonStyleConfig { pub label: WidgetId, // pre-built label subtree pub is_pressed: Signal<bool>, pub is_hovered: Signal<bool>, pub is_focused: Signal<bool>, pub is_disabled: Signal<bool>, pub variant: ButtonVariant, // a hint; impl may ignore } }
The widget builds the parts (label, optional icon, four state
signals), hands the bag to the active style, and uses the returned
WidgetId as its root child. Everything else — background, border,
focus ring, padding, min size — is the style's responsibility.
The trait is 'static only (not Send + Sync) because all Teksilo
trees are single-threaded by construction; Rc<dyn FooStyle> is the
public alias (SharedButtonStyle and friends).
Same shape across widgets. All 34 style traits live in
teksilo-core/src/styles/, all
return WidgetId from their make_* methods, all take a
*StyleConfig describing the inputs that vary by widget. The trait
is the public API; everything below it is implementation. The full
list lives in the migration status table.
Worked example — a Material-3-flavoured Button
#![allow(unused)] fn main() { use std::rc::Rc; use teksilo_core::build_context::BuildContext; use teksilo_core::styles::{ButtonStyle, ButtonStyleConfig}; use teksilo_core::widget_id::WidgetId; use teksilo_tokens::{Color, CornerRadius, SurfaceRole}; use teksilo_widgets::primitives::{Padding, RectWidget, ZStack}; struct MaterialFilledButton; impl ButtonStyle for MaterialFilledButton { fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId { // Material 3 filled buttons are tall (40 dp), pill-shaped, with // a small elevation that lifts on hover. State-driven `Accent` / // `AccentHover` / `AccentPressed` cover the surface; disabled // collapses to a flat translucent grey. let bg = cfg.is_pressed .zip3(&cfg.is_hovered, &cfg.is_disabled) .map(|(pressed, hovered, disabled)| { if *disabled { SurfaceRole::AccentDisabled } else if *pressed { SurfaceRole::AccentPressed } else if *hovered { SurfaceRole::AccentHover } else { SurfaceRole::Accent } }); let rect = ctx.add( RectWidget::new() .background(bg) .corner_radius(CornerRadius::uniform(20.0)), ); let padded_label = ctx.add( Padding::symmetric(10.0, 24.0) // M3 spec: 10×24 .child_id(cfg.label), ); ctx.add(ZStack::new().add_child(rect).add_child(padded_label)) } } }
Install per-call: Button::new(lit!("Save")).style(MaterialFilledButton).
Install theme-wide:
#![allow(unused)] fn main() { let mut theme = intui::light(); theme.style_slots.button = Some(Rc::new(MaterialFilledButton)); }
The widget honours this precedence at every build():
per-call .style(...) > theme.style_slots.button > RecipeButtonStyle::default()
Tested end-to-end in
teksilo-widgets/src/button.rs
under theme_slot_supplies_button_style_when_no_override /
per_call_style_override_wins_over_theme_slot.
Built-in presets
| Preset | Where | Status |
|---|---|---|
intui::light / intui::dark | teksilo_core::presets::intui | shipped — the default look |
material3::light / material3::dark | teksilo-theme-material3 crate | shipped — Material 3 |
fluent::light / fluent::dark | teksilo-theme-fluent crate | shipped — Windows 11 / WinUI 3 |
macos::light / macos::dark | teksilo-theme-macos crate | shipped — macOS Aqua / Dark Aqua |
| Image-backed themes | teksilo-image-theme crate | not yet shipped |
Each preset is just a function returning Theme. Apps can write their
own without depending on any sibling crate:
#![allow(unused)] fn main() { pub fn brutalist_light() -> Theme { let mut theme = intui::light(); theme.colors.accent = Color::new(1.0, 0.0, 0.4, 1.0); // hot pink theme.shape.radius_md = 0.0; // sharp corners everywhere theme.style_slots.button = Some(Rc::new(MyBrutalistButton)); theme.style_slots.checkbox = Some(Rc::new(MyBrutalistCheckbox)); theme } }
Migration status (as of this branch)
Every themable widget is on the Tier-3 trait + recipe-default +
slot lookup. No themable widget self-paints anymore. 43 widgets
across 38 style traits, spanning seven families (a "trait" can cover
more than one widget — e.g. ListContainerStyle styles both
ListView and TreeView; ChartStyle styles BarChart, LineChart,
and PieChart):
Controls
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
Toggle | ToggleStyle | RecipeToggleStyle | style_slots.toggle |
Button | ButtonStyle | RecipeButtonStyle | style_slots.button |
SplitButton | SplitButtonStyle | RecipeSplitButtonStyle | style_slots.split_button |
Checkbox | CheckboxStyle | RecipeCheckboxStyle | style_slots.checkbox |
RadioButton | RadioStyle | RecipeRadioStyle | style_slots.radio |
RadioTile | RadioTileStyle | RecipeRadioTileStyle | style_slots.radio_tile |
IconButton | IconButtonStyle | RecipeIconButtonStyle | style_slots.icon_button |
Slider | SliderStyle | RecipeSliderStyle | style_slots.slider |
SegmentedControl | SegmentedControlStyle | RecipeSegmentedControlStyle | style_slots.segmented_control |
ProgressBar | ProgressBarStyle | RecipeProgressBarStyle | style_slots.progress_bar |
Link | LinkStyle | RecipeLinkStyle | style_slots.link |
Avatar | AvatarStyle | RecipeAvatarStyle | style_slots.avatar |
Badge | BadgeStyle | RecipeBadgeStyle | style_slots.badge |
Inputs
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
TextInput | TextInputStyle | RecipeTextInputStyle | style_slots.text_input |
SearchField | SearchFieldStyle | RecipeSearchFieldStyle | style_slots.search_field |
ComboBox | ComboBoxStyle | RecipeComboBoxStyle | style_slots.combo_box |
SpinBox | SpinBoxStyle | RecipeSpinBoxStyle | style_slots.spin_box |
DateEdit | DateEditStyle | RecipeDateEditStyle | style_slots.date_edit |
ColorPicker | ColorPickerStyle | RecipeColorPickerStyle | style_slots.color_picker |
Calendar | CalendarStyle ¹ | RecipeCalendarStyle | style_slots.calendar |
RichTextEditor | RichTextEditorStyle | RecipeRichTextEditorStyle | style_slots.rich_text_editor |
Containers
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
Panel | PanelStyle | RecipePanelStyle | style_slots.panel |
Card | CardStyle | RecipeCardStyle | style_slots.card |
TabBar | TabStyle ¹ | RecipeTabStyle | style_slots.tab |
ListView / TreeView (container chrome) | ListContainerStyle | RecipeListContainerStyle | style_slots.list_container |
TableView / TreeTableView (header + sort + row chrome) | TableStyle ¹ | RecipeTableStyle | style_slots.table |
DropZone | DropZoneStyle | RecipeDropZoneStyle | style_slots.drop_zone |
DropTarget | DropTargetStyle | RecipeDropTargetStyle | style_slots.drop_target |
Overlays
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
TooltipWidget | TooltipStyle | RecipeTooltipStyle | style_slots.tooltip |
Popover | PopoverStyle | RecipePopoverStyle | style_slots.popover |
Dialog (in-tree modal) | DialogStyle ¹ | RecipeDialogStyle | style_slots.dialog |
Snackbar | SnackbarStyle | RecipeSnackbarStyle | style_slots.snackbar |
Toast | ToastStyle | RecipeToastStyle | style_slots.toast |
Banner | BannerStyle | RecipeBannerStyle | style_slots.banner |
Rows / Items
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
MenuItem | MenuItemStyle ³ | RecipeMenuItemStyle | style_slots.menu_item |
StandardListItem / StandardTreeItem | StandardItemStyle ³ | RecipeStandardItemStyle | style_slots.standard_item |
Chrome
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
ScrollBar | ScrollBarStyle | RecipeScrollBarStyle | style_slots.scroll_bar |
Data Visualization
| Widget | Trait | Default impl | Slot |
|---|---|---|---|
BarChart / LineChart / PieChart (teksilo-charts) | ChartStyle ² | RecipeChartStyle (in teksilo-charts, not teksilo-widgets) | style_slots.chart |
¹ Multi-method trait — see Multi-method styles below.
² All-recipe trait, no make_* methods — see
Data-visualization styling below. Its
default impl is the one entry in this table whose Recipe*Style does
not live under teksilo-widgets/src/styles/* — teksilo-charts
deliberately has no dependency on teksilo-widgets, so its default
style has to live where its own dependencies already reach. See
charts.md §11 for the
full reference.
³ Carries a defaulted label-role hook —
StandardItemStyle::selected_label_role and
MenuItemStyle::highlighted_label_role, both -> Option<TextRole>,
both None by default.
A row builds its label before any style's make_body runs, so a
style cannot recolour the text it is about to paint behind. That is
fine for a design language whose selection is a pale wash — IntUI and
Fluent both keep TextRole::Primary on top of theirs — and impossible
for one whose selection is a solid fill: macOS's accent capsule
would leave labelColor at roughly 3.5:1. The hook lets the style
declare the role and the widget compose it into the label's colour
signal (and, for a menu row, its shortcut's), gated on the row actually
being emphasised so an unemphasised or window-inactive row keeps its
normal label.
Same shape as ButtonStyle::label_text_role, and defaulted for the
same reason: every existing style is unchanged.
#![allow(unused)] fn main() { impl StandardItemStyle for MyStyle { fn make_body(&self, cfg: &StandardItemStyleConfig, ctx: &mut BuildContext) -> WidgetId { … } // Only needed when `make_body` fills the selection with a colour // the default label cannot read on. fn selected_label_role(&self) -> Option<TextRole> { Some(TextRole::OnAccent) } } }
The legacy per-widget dimension structs are gone: the 17
old teksilo-tokens::components::*Style structs were deleted and their
IntUI constants folded into the matching
teksilo-widgets/src/styles/recipe_*_style.rs modules.
The ComponentStyles struct has been fully removed from Theme.
Migrated widgets read entirely from theme.style_slots.* plus their
Recipe*Style defaults. Dimension data for any remaining non-themable
widgets (toolbar, status bar, accordion, …) lives directly in their
Recipe*Style modules as pub const blocks.
The teksilo-theme-material3 sibling preset is now a real Material 3
theme (baseline #6750A4 scheme, M3 shape/typography, pill 40 dp
buttons with state-layer hover, the M3 switch, 12 dp cards) and the
proving ground for the recipe-vocabulary additions above. Its optional
bundled-fonts feature embeds Roboto. The framework primitives it
needed — FillRecipe::StateLayer, per-side BorderRecipe +
BorderPosition, gradient PaintProp, the configurable FooRecipe
sweep, the cross-design-language color roles
(TextRole::OnError, SurfaceRole::{ErrorContainer, Container, ContainerRaised, ContainerSunken}), Easing::CubicBezier,
ToggleStyleConfig::is_pressed, and TeksiloAppBuilder::register_fonts
— are all in place, so the -fluent and -macos presets below and a
future GTK4-Adwaita one follow the same path.
The teksilo-theme-fluent sibling preset is a full Windows 11 /
WinUI 3 theme, transcribed from WinUI's own Common_themeresources_any.xaml
and the control theme-resource dictionaries: the light and dark colour
dictionaries (exposed in full through the FluentPalette theme
extension), the two-radius geometry (ControlCornerRadius 4 dp /
OverlayCornerRadius 8 dp), the WinUI type ramp at zero tracking, and
the four Control*AnimationDuration steps on
ControlFastOutSlowInKeySpline. It installs Tier-3 chrome for 25 style
slots: eight are real impl FooStyle blocks where the WinUI control is
structurally its own thing — the button's elevation edge (a heavier
stroke on the bottom edge in light, the top edge in dark, dropped on
press), the two-tone high-contrast focus ring, the ToggleSwitch's
off-state outline and morphing knob, the filled unchecked checkbox and
radio, the field's accent focus underline, the slider's two-circle
thumb, the menu row's neutral hover, and the list row's selection
pill — while the rest are the shipped Recipe*Style constructed with
Fluent metrics. light_with_accent / dark_with_accent rebuild the
whole accent family around a caller-supplied seed, the substitution
Windows performs when the user picks an accent colour. Mica and Acrylic
resolve to the opaque fallbacks WinUI itself uses when the compositor
material is unavailable; Segoe UI Variable cannot be redistributed, so
the optional system-fonts feature names it for the text engine to
resolve rather than bundling it.
The teksilo-theme-macos sibling preset is a full macOS Aqua /
Dark Aqua theme. It is the one preset whose source publishes almost
nothing: Apple attaches a standing disclaimer to every colour value it
prints, and states no corner radii, no control heights, no focus-ring
geometry and exactly one animation duration. Every literal in the crate
is therefore tagged at its definition as [HIG] (published — the
13-hue system-colour table and the whole typography ramp), [measured]
(a capture of the private NSColor enumeration, or a screen
measurement) or [derived] (computed, with the rule given). AppKit's
wider vocabulary — four label grades, two independent selection
families, the control bezel, the eight System Settings accents — is
exposed through the MacOsPalette theme extension.
Geometry is 6 dp in-page / 10 dp floating (menus at their own measured
9 dp) on a 22 dp control height, a third under Fluent's 32.
Typography is the published SF ramp — Body 13/16, Callout 12/15,
Subheadline 11/14 — carrying Apple's signed tracking: −0.08 pt at
13, exactly 0 at 12, +0.06 at 11. It is the only Teksilo preset that
tracks non-uniformly and the only one whose tracking changes sign.
Motion is Core Animation's default 0.25 s on
kCAMediaTimingFunctionEaseInEaseOut — cubic-bezier(0.42, 0, 0.58, 1),
symmetric where Fluent's is decelerate-only.
It installs Tier-3 chrome for 28 style slots; eight are real
impl FooStyle blocks: the push button's bezel (shadow, face
gradient, hairline, Dark-Aqua catch-light — dropped on press, and
deliberately absent from the accent-filled default button), a focus
ring that is the accent rather than Fluent's neutral outline, the
NSSwitch's 18 dp knob in a 22 dp track, the 14 dp bezelled checkbox
and radio, the field's accent focus halo, the slider's plain round
knob, the menu row's accent fill with a white label, and the list
row's selection capsule. light_with_accent / dark_with_accent
and the SystemAccent enum rebuild the accent family; linkColor
deliberately does not follow, as on macOS.
Four places deviate from Apple's own numbers to clear WCAG, each documented at its assignment with the measurement that forced it and each pinned by a test that also asserts the premise — so if Apple's value ever starts passing, the deviation can be reverted rather than inherited. Two framework additions came out of it: the defaulted label-role hooks described above, without which a solid-accent selection cannot recolour the text on top of it.
Known limitations are stated rather than deferred: the OS accent is not
read (Teksilo's platform layer returns only the light/dark preference
on macOS), vibrancy resolves to each material's opaque fallback, the
TableView / GridView selection band is an accent wash rather than
the capsule (those views paint the shared surface_selected token
behind app-supplied cells this preset cannot retint), and San Francisco
is named under the optional system-fonts feature rather than bundled.
Still ahead on the styling roadmap: image-backed styles, the
ImageTheme TOML manifest loader, and a GTK4-Adwaita sibling preset
crate.
Multi-method styles
Most style traits have a single make_body(cfg, ctx) -> WidgetId
method. Four widgets need finer granularity — the trait splits chrome
into multiple slots so a custom impl can replace one piece without
re-implementing the others:
TabStyle—make_bodythemes a single tab header (accent indicator + focus ring + label slot composition);make_barthemes the whole strip (optional backdrop fill, content-pane separator, drag-reorder drop indicator).TabStyleConfigcarriesindicator_position(TabIndicatorPosition::{OuterEdge, InnerEdge}) so the active-tab highlight can hug either edge; the defaultRecipeTabStylehonours all four edges (outer/inner × horizontal/ vertical, RTL-correct). Per-tab backgrounds, the bar backdrop, inter-tab dividers, and text-colour roles are widget-levelTabBar/TabWidgetbuilders rather than part of the trait — see tab-widget.md "Appearance".DialogStyle—make_panelthemes the modal surface (shadow + corner radius + padding + container chrome);make_scrimthemes the full-viewport overlay backdrop (the click-outside-to-dismiss layer). Wired into the in-tree modal pipeline so the scrim is a proper child of the dialog overlay, not a hand-rolled rect.TableStyle—make_header_cell(column header chrome: hover tint, resize-handle band, raised background),make_sort_indicator(the up/down arrow),make_row_background(per-row surface, with selection + hover + zebra states). The body cell stays app-controlled — same delegate that produces the cell's content also owns its paint.CalendarStyle—make_day_cell,make_zoom_cell(month / year picker grid),make_header(month-year label + nav buttons). Calendar is unusually paint-heavy and the three slots match the three distinct visual modes (day grid, zoom grid, header).
For these traits, a custom impl must implement every method (no
default impls beyond the trait's own — the recipe defaults compose
the four slots into the IntUI look). Apps that only want to tweak
one slot typically forward the others to Recipe*Style::default().
Data-visualization styling
ChartStyle (BarChart / LineChart / PieChart, teksilo-charts)
is a third trait shape, distinct from both the single-method
make_body traits and the multi-method traits above:
#![allow(unused)] fn main() { pub trait ChartStyle: 'static { fn bar_fill(&self, cfg: &ChartFillContext) -> FillRecipe; fn area_fill(&self, cfg: &ChartFillContext, opacity: f32) -> FillRecipe; fn donut_fill(&self, cfg: &ChartFillContext) -> FillRecipe; fn gridline(&self, theme: &Theme) -> BorderRecipe; } }
Every method returns a Tier-2 recipe (FillRecipe / BorderRecipe)
directly — none returns a WidgetId. Charts paint through Canvas
calls inside their own paint() instead of composing a child widget
subtree, so there is no make_*(cfg, ctx) -> WidgetId step for a
custom impl to hook: the widget resolves the active ChartStyle,
asks it for a recipe, and paints that recipe's fill/stroke directly.
Where TabStyle/DialogStyle/TableStyle/CalendarStyle split
chrome into named WidgetId-returning slots because each slot is a
distinct sub-tree, ChartStyle splits into named recipe-returning
methods because each is a distinct paint operation (bar fill vs. area
fill vs. donut fill vs. gridline stroke) inside one widget's own paint
pass. Resolution precedence is identical to every other trait:
per-call .style(impl ChartStyle) > theme.style_slots.chart >
RecipeChartStyle::default(). Full reference:
charts.md §11.
Custom widgets and the styling system
Writing your own composing widget? Three steps to make it themable:
- Declare a closed
MyWidgetVariantenum for the design-language presentations users can pick (mirrorButtonVariant's shape). - Define a
MyWidgetStyletrait in your own crate with amake_body(cfg, ctx) -> WidgetIdsignature. Thecfgstruct exposes the inputs that vary by interaction state (Signal<bool>s for hover/pressed/etc.), the variant, and pre-built child subtrees. - Ship a
RecipeMyWidgetStyleas the default impl. Add a slot to your own slot-bag struct (or attach viatheme.extensionsif you only need app-internal use).
The trait pattern doesn't require buying into Teksilo's slot bag —
you can ship the trait + default impl and let users override via
MyWidget::style(...) per call. The slot bag is for theme-wide
installation; it's optional, but it's how the framework's themable
widgets get reskinned across an app.
See also
- docs/reactive-theme.md — Signal-backed Theme, color signals, theme swaps without rebuild.
docs/image-themes.md— designer-workflow deep reference (Figma / Penpot / Canva → 9-slice manifest → theme). (Not yet shipped; design pending.)- docs/widgets-overview.md — per-widget variant + style trait references.
- docs/accessibility-overrides.md —
style trait impls do not participate in accessibility; the
widget owns its
accessibility(builder)regardless of which style is installed.
Reactive Theme Reference
This doc covers the reactive layer — how
Themeflows throughSignals, roles, and props so a theme swap repaints without a rebuild. For the broader styling picture (the four-tier ladder: tokens → variants → recipes → style protocols,Themeconstruction, per-widget*Variantenums and*Styletraits), seestyling-system.md.
Teksilo runs its theme through three layers of reactive primitives:
| Layer | Type | Lives on | Purpose |
|---|---|---|---|
| Root signal | Signal<Theme> | WidgetTree | Source of truth; set_theme fires this |
| Role enums | TextRole, SurfaceRole, BorderRole, TextStyleRole | teksilo-tokens | Name what a value represents, not which literal it is |
| Props | ColorProp, TextStyleProp | teksilo-core | Unified input type accepted by widget builders |
The rules:
set_themenever rebuilds. It updates the signal and dirty-marks every node; the next layout/paint pass reads the new theme and repaints affected widgets. Focus, scroll offsets, expanded panels — all interaction state survives a theme switch.- Roles resolve at paint/layout time. A widget that stores
ColorProp::TextRole(TextRole::Primary)looks upctx.theme.colors.text_primaryin itspaint— never at build time. - User code almost never needs to name
theme_signal. Builders accept roles directly;Signal<Role>covers the interaction-driven case.
Quick reference
#![allow(unused)] fn main() { use teksilo::prelude::*; // re-exports Color, Role enums, ColorProp, TextStyleProp // Plain text — default role is TextRole::Primary. TextWidget::new(lit!("Hello")) // Role-based color: resolved against current theme, reactive. TextWidget::new(lit!("Error!")).color(TextRole::Error) // Role-based typography: same story, but the text style role. TextWidget::new(lit!("Section")).style(TextStyleRole::BodyBold) // Static color: frozen literal. TextWidget::new(lit!("Custom")).color(Color::from_hex("#FF00FF")) // Reactive signal (usually interaction state): repaints on signal change. TextWidget::new(lit!("")).text(status).color(hover_color_signal) // Panel with role-based surface and border. Panel::new() .background(SurfaceRole::Raised) .border_color(BorderRole::Default) .corner_radius(8.0) // Reactive SurfaceRole — the role itself depends on interaction state. let bg_role = interaction.map(|s| match s { InteractionState::Hovered => SurfaceRole::Hover, InteractionState::Pressed => SurfaceRole::Pressed, _ => SurfaceRole::Transparent, }); RectWidget::new().background(bg_role) }
WidgetTree::set_theme
#![allow(unused)] fn main() { pub fn set_theme(&mut self, theme: Theme) }
Declared at crates/teksilo-core/src/widget_tree.rs (around line 1120). Sequence:
self.theme = theme.clone()— the cached&Themeaccessor still works.self.theme_signal.set(theme)— fires observers (derivedSignal<Theme>s, role-carryingColorProps via their bindings).self.arena.mark_all_dirty()— every node needs layout and paint.
No rebuild_built_widgets call, no focus clearing. set_locale follows the same pattern on locale_signal.
For per-subtree overrides:
#![allow(unused)] fn main() { tree.set_theme_override(panel_id, |theme| { theme.colors.surface_main = Color::from_hex("#..."); }); }
This only marks the subtree dirty; layout/paint contexts resolve ancestor overrides via WidgetArena::resolve_theme.
Constructing and loading themes
Built-in presets
There is no Theme::default() / Theme::*_default(). Theme lives in
teksilo-core::styles and is built through a preset constructor:
#![allow(unused)] fn main() { use teksilo::prelude::intui; let light = intui::light(); // teksilo_core::presets::intui::light let dark = intui::dark(); }
Both are neutral Int UI baselines — not visually distinctive, designed to be customized. Apps usually start from one of them and override the slots they care about. For the full styling picture (variants, recipes, style traits) see styling-system.md.
Programmatic customization via struct spread
Theme (teksilo-core::styles) and the token structs ColorTokens, TypographyTokens, ShapeTokens, LayoutTokens, MotionTokens (teksilo-tokens) are plain structs. Override the fields you want and spread the rest from a preset base:
#![allow(unused)] fn main() { use teksilo_core::styles::Theme; use teksilo_tokens::{ColorTokens, TypographyTokens, TextStyle, Color}; use teksilo::prelude::intui; let editor_light = Theme { colors: ColorTokens { accent: Color::from_hex("#2E7D32"), accent_hover: Color::from_hex("#1B5E20"), text_on_accent: Color::WHITE, surface_main: Color::from_hex("#FAFAF5"), ..ColorTokens::light_default() }, typography: TypographyTokens { body: TextStyle { family: "Literata".to_string(), size: 16.0, ..TextStyle::default() }, ..TypographyTokens::default() }, ..intui::light() }; tree.set_theme(editor_light); }
ColorTokens::light_default() and the other raw-token defaults still live in teksilo-tokens — only the Theme-level constructor moved.
The same pattern works for sub-trees via set_theme_override(panel_id, |theme| { ... }) (see above).
Loading from a file
Theme and every token struct derive serde::Serialize and serde::Deserialize, so themes round-trip through any serde format the app picks (TOML, JSON, RON, YAML). The style_slots and extensions fields are #[serde(skip)] — a deserialized Theme gets empty defaults for those, so style-trait overrides are re-installed in code, not loaded from the file. The runtime cost is one read + one deserialize + one set_theme call:
#![allow(unused)] fn main() { use std::fs; use teksilo_core::styles::Theme; let toml = fs::read_to_string("themes/editor-light.toml")?; let theme: Theme = toml::from_str(&toml)?; tree.set_theme(theme); }
Authoring a theme file is the inverse — toml::to_string(&intui::light())? writes a complete starter file the user can edit.
Partial files — current limitation
The token structs do not carry #[serde(default)] on their fields, so a file missing any field fails to deserialize. To accept hand-edited theme files that only specify a few overrides, the app needs to do the merge itself — typically by deserializing into an Option-wrapped or serde_json::Value shape, then folding non-null values onto a base produced by intui::light(). A future change can add #[serde(default)] so partial files merge automatically; until it lands, treat the file format as "all fields required."
Role enums
All defined in crates/teksilo-tokens/src/roles.rs, exported from teksilo_tokens::{TextRole, SurfaceRole, BorderRole, TextStyleRole} and re-exported through teksilo::prelude.
TextRole
Foreground text color.
Primary (default), Secondary, Disabled, OnAccent, Accent, Error, Warning, Success, OnError, OnErrorContainer, Link, LinkHover, LinkVisited, TooltipText, TooltipShortcut, EditorFg, EditorGutterFg.
SurfaceRole
Filled-area color (panel backgrounds, button fills, selection highlights).
Main (default), Content, Raised, Sunken, Hover, Pressed, Selected, SelectedInactive, AltRow, Accent, AccentHover, AccentPressed, AccentDisabled, AccentSubtle, StatusInfo, StatusSuccess, StatusWarning, StatusError, ErrorContainer, Container, ContainerRaised, ContainerSunken, TooltipBg, EditorBg, EditorCaret, EditorCurrentLineBg, EditorSelectionBg, Scrim, Transparent (paints nothing — the "no surface" slot in interaction chains).
The OnError/*Container roles are the cross-design-language slots shared by Material 3 / Fluent / macOS / GTK4-Adwaita; design-language-specific colors (M3 secondary/tertiary triads, full tonal ladder) live in per-theme extensions instead.
BorderRole
Stroke color.
Default (default), Strong, Focused, Error, Warning, Divider, DividerStrong, TooltipBorder, Accent, AccentDisabled, Transparent.
Disabled auto-dim. When a leaf resolves a role-based ColorProp in a disabled subtree (enabled == false), TextRole substitutes Disabled and the accent SurfaceRole/BorderRole family (Accent/AccentHover/AccentPressed, BorderRole::Accent) substitutes its AccentDisabled counterpart — so role-driven accent chrome dims without per-widget handling. Non-accent surfaces/borders pass through.
TextStyleRole
Typography role.
Body (default), BodyBold, Small, SmallBold, Tiny, Mono.
Every role has a resolve(&ColorTokens) (or resolve(&TypographyTokens) for TextStyleRole) method. Paint/layout code already calls those under the hood when reading a ColorProp / TextStyleProp.
Adding a role. Add the variant to the enum, extend resolve(..), re-export from teksilo::prelude. Add a role only when more than one widget repeatedly wants the same token — otherwise a .color(Color::..) literal is fine.
ColorProp
#![allow(unused)] fn main() { pub enum ColorProp { Static(Color), Bound(Signal<Color>), TextRole(TextRole), SurfaceRole(SurfaceRole), BorderRole(BorderRole), DynamicTextRole(Signal<TextRole>), DynamicSurfaceRole(Signal<SurfaceRole>), DynamicBorderRole(Signal<BorderRole>), } }
Widget color builders accept impl Into<ColorProp>. Every input shape above implements From, plus From<Prop<Color>> for migrating legacy callers. Call cp.resolve(&theme) in paint and cp.register_if_bound(self_id, registry, level) in build to hook signal-bearing variants into dirty-tracking.
Role variants need no binding registration — the tree-wide mark_all_dirty inside set_theme already forces a repaint.
Which variant to use
| Case | Variant | How to construct |
|---|---|---|
| "Normal" theme color | TextRole / SurfaceRole / BorderRole | .color(TextRole::Primary) |
| Interaction-dependent color (hover, focus, pressed) | DynamicSurfaceRole (etc.) | .background(interaction.map(|s| match s { .. })) |
| Brand / decoration color not in the theme | Static | .color(Color::from_hex("#...")) |
| Externally-provided signal (not tied to theme) | Bound | .color(user_color_signal) |
Legacy code still using Prop<Color> | via From impl | pass-through; no changes required |
TextStyleProp
#![allow(unused)] fn main() { pub enum TextStyleProp { Static(TextStyle), Role(TextStyleRole), } }
TextWidget::style(...) and the other style-accepting builders take impl Into<TextStyleProp>. Default is TextStyleRole::Body, so a bare TextWidget::new(lit!("x")) follows the theme typography.
Resolved at paint/layout via prop.resolve(&ctx.theme.typography). Changing Theme::typography on a running tree updates every TextWidget that uses a role; widgets that passed a raw TextStyle stay frozen (that's the user intent — custom fonts stay custom).
Interaction-driven colors: the Signal<Role> pattern
For state-dependent colors (hover, pressed, focus, disabled), emit a Signal<Role> from the interaction signal and pass it as ColorProp directly — the paint layer handles the theme lookup. No explicit theme_signal zip.
Template — Button (canonical example)
#![allow(unused)] fn main() { fn resolve_bg_role(style: ButtonVariant, state: InteractionState) -> SurfaceRole { match (style, state) { (ButtonVariant::Default, InteractionState::Hovered) => SurfaceRole::AccentHover, (ButtonVariant::Default, InteractionState::Pressed) => SurfaceRole::AccentPressed, (ButtonVariant::Default, _) => SurfaceRole::Accent, (ButtonVariant::Flat, InteractionState::Hovered) => SurfaceRole::Hover, (ButtonVariant::Flat, _) => SurfaceRole::Transparent, // ... Regular variant ... } } impl Widget for Button { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let interaction = ctx.signal(InteractionState::Idle); let style = self.style; let bg_role = interaction.map(move |s| resolve_bg_role(style, *s)); let text_role = interaction.map(move |s| resolve_text_role(style, *s)); let border_role = interaction.map(move |s| resolve_border_role(style, *s)); ctx.add( RectWidget::new() .background(bg_role) .border_color(border_role) // ... ); // TextWidget inside picks up the text role the same way. } } }
The interaction signal is the only upstream root the role signals observe. When the user moves the mouse off the button, only interaction fires; when the theme changes, mark_all_dirty triggers the repaint and the paint-time resolve(&theme) picks up the new colors. Two separate triggers, same rendering path.
See crates/teksilo-widgets/src/button.rs for the full widget; menu_list::KeyboardHighlightWrapper and combo_box::DropdownItem are smaller walk-throughs.
When Signal<Role> doesn't fit
Three cases keep an explicit theme_signal:
- Color transformations (
token.with_alpha(0.2)etc.) — no role represents "accent at 12 % alpha"; usetheme_signal.map(|t| t.colors.accent.with_alpha(0.12))to get aSignal<Color>. - Effects on external state — the rich-text engine's per-frame palette, for instance. See
primitives/text_input_field.rsfor thectx.effect(&theme_signal, move |theme| { ... })pattern. - Layout snapshots —
let shape = ctx.theme_signal().get().shapeat the top ofbuild()captures corner radii;let layout = ctx.theme_signal().get().layoutcaptures spacing values. These rarely differ across themes; the snapshot is fine.
Dimension props (Prop<f32>)
Layout primitives accept impl Into<Prop<f32>> for dimensions that may come from a theme-derived signal:
| Primitive | Method | File |
|---|---|---|
HStack / VStack / Wrap | .spacing(...) | primitives/hstack.rs, vstack.rs, wrap.rs |
Grid | .column_gap(...) / .row_gap(...) | primitives/grid.rs |
Padding | Padding::new / uniform / symmetric | primitives/padding.rs |
MinSize / MaxSize / FixedSize | .width / .height / etc. | existing .bind_* builders |
RectWidget | .border_width(...) / .corner_radius(...) | primitives/rect_widget.rs |
Pass a static f32, a Signal<f32>, or a Prop<f32>; the builder registers a BindingLevel::Relayout binding for signal variants so layout re-runs on theme-driven spacing changes.
DX — what developers should write
Good (most common paths):
#![allow(unused)] fn main() { // "I want a normal label": zero color code. TextWidget::new(lit!("Status")) // "I want an error-colored label": one role. TextWidget::new(lit!(msg)).color(TextRole::Error) // "I want a raised panel": one role. Panel::new().background(SurfaceRole::Raised).child(...) // "I want a Bold Heading": one style role. TextWidget::new(lit!("Settings")).style(TextStyleRole::BodyBold) }
Good (custom / reactive):
#![allow(unused)] fn main() { // Frozen brand color. Panel::new().background(Color::from_hex("#e2007a")) // Signal-driven non-theme color. Panel::new().background(animated_banner_color) // Interaction-driven role (the important pattern for new widgets). let bg = interaction.map(|s| map_to_surface_role(*s)); RectWidget::new().background(bg) }
Avoid (legacy pattern — only keep when no role fits):
#![allow(unused)] fn main() { // Don't write this for normal theme colors: .color(ctx.theme_signal().map(|t| t.colors.text_primary)) // Write this instead: .color(TextRole::Primary) }
Migration cheat sheet
| Old form | New form |
|---|---|
.color(ctx.theme().colors.text_primary) | .color(TextRole::Primary) (or drop — it's the default) |
.background(theme.colors.surface_raised) | .background(SurfaceRole::Raised) |
.border_color(theme.colors.border) | .border_color(BorderRole::Default) |
.style(theme.typography.body_bold.clone()) | .style(TextStyleRole::BodyBold) |
.color(theme_signal.map(|t| t.colors.X)) | .color(TextRole::X) (if X has a role) |
.background(theme_signal.map(|t| t.colors.X)) | .background(SurfaceRole::X) |
interaction.zip(&theme_signal).map(|(s, t)| resolve_bg(s, &t.colors)) | interaction.map(|s| resolve_bg_role(s)) returning Signal<SurfaceRole> |
Files to know
| File | Contents |
|---|---|
crates/teksilo-tokens/src/roles.rs | Role enums + resolve |
crates/teksilo-core/src/color_prop.rs | ColorProp, TextStyleProp, From impls |
crates/teksilo-core/src/widget_tree.rs | set_theme, set_locale, theme_signal, locale_signal |
crates/teksilo-core/src/build_context.rs | BuildContext::theme(), theme_signal(), locale_signal() |
crates/teksilo-widgets/src/button.rs | Canonical Signal<Role> pattern |
crates/teksilo-widgets/src/primitives/text_widget.rs | Default role usage, paint-time resolve |
crates/teksilo-widgets/src/panel.rs | ColorProp props + default fallbacks |
Animation
Companion to: architecture.md
Scope: Signal-driven animation in Teksilo — Signal<f32>::animate_to, the scheduler behind it, and the design rules for deciding when (and when not) to animate.
1. Why animation exists in a mostly-instant framework
Teksilo's motion vocabulary is borrowed from JetBrains's Int UI design language: hover and press are instant, and animation is reserved for a narrow set of floating transitions — a dialog appearing, a snackbar sliding in, an accordion expanding, a toggle thumb moving. A serious desktop application that a user drives for hours gets tiring fast if every state change fades or slides; interaction feedback needs to be crisp. Decorative animation is explicitly discouraged.
What's left is the minimum set of places where motion helps a user track a change:
- Transform transitions that would otherwise teleport the eye (toggle thumb 0 → 1, accordion height 0 → full).
- Floating element appearance that should not pop into existence (tooltip fade at ~120 ms, balloon slide at ~200 ms, dialog scale-in at ~300 ms).
- Indeterminate progress where a looping animation communicates "still working" (progress bar indeterminate mode).
- Smooth scrolling when programmatic
scroll_towould otherwise jump the viewport.
Everything else — hover color shifts, focus ring appearance, press feedback, checkmark toggles — is instant. The framework's theming pipeline (reactive Signal<Role> → theme lookup per frame) covers "the color changed" without a scheduler at all; see reactive-theme.md.
2. Signal as the animation substrate
The entire animation API is attached to Signal<f32>. There is no separate Animation type for widget authors to manage, no AnimationController, no lifetime tracking by hand:
#![allow(unused)] fn main() { // Somewhere in build() or a handler: knob_position.animate_to(1.0, Duration::from_millis(150), Easing::EaseInOut); }
The knob_position: Signal<f32> then interpolates from its current value to the target over the given duration. Any widget observing the signal (via Prop<f32>, via observe(), or via binding.bind_to(..., BindingLevel::RepaintOnly)) re-paints on each tick as the value slides. Because animation flows through the same signal plumbing as any other reactive value, animated widgets do not need special awareness of the scheduler.
A signal created with Signal::new(0.0_f32) does not support animation — animate_to panics. Animation-capable signals are created with Signal::new_animated(value), or — the usual path inside a widget's build() — with BuildContext::animated_signal(value), which also registers the signal with the tree's scheduler so the scheduler can cancel the animation if the owning widget is rebuilt or destroyed (see §4 below).
3. Easing and durations live in tokens
Easing curves and standard durations are design tokens, not ad-hoc magic numbers. They live in crates/teksilo-tokens/src/motion.rs:
#![allow(unused)] fn main() { pub enum Easing { Linear, EaseIn, EaseOut, EaseInOut, // General CSS cubic-bezier(x1,y1,x2,y2) — for design-language motion // specs that don't reduce to the named curves (M3 emphasized, Fluent). // Build via `ctx.animate().cubic_bezier(x1,y1,x2,y2)` / `.m3_emphasized()`. CubicBezier { x1: f32, y1: f32, x2: f32, y2: f32 }, } pub struct MotionTokens { pub duration_instant: Duration, // 0 ms — most state changes pub duration_fast: Duration, // 120 ms — tooltip fade, interactive feedback pub duration_normal: Duration, // 200 ms — notification slide pub duration_slow: Duration, // 300 ms — dialog scale-in pub duration_collapse: Duration, // 200 ms — accordion / disclosure tween pub duration_indeterminate_sweep: Duration, // 900 ms — indeterminate sweep / spinner period pub easing_standard: Easing, // mild ease-out } }
Widgets reach for MotionTokens through the current Theme. Int UI's guidance — one mild ease-out for everything — is the default; a theme can override if a platform target wants a different feel. Avoid hardcoding durations in widget code when a token fits; the tokens are the lever a designer rebrands a theme through.
The Easing::apply(t) method takes a linear parameter t ∈ [0, 1] and returns the eased value in the same range. lerp(a, b, t) in the same file does plain linear interpolation; the scheduler combines the two to produce each frame's value.
4. The scheduler — what the framework owns
AnimationScheduler is the signal-tween scheduler — one of three
visibility-aware motion subsystems on WidgetTree. The other two are
AnimatedQuadRegistry
(shader-driven quad uniforms — Spinner, ProgressBar::indeterminate,
animated IconWidget; see
idle-and-animation.md)
and
FrameTickScheduler
(per-frame-effect closures — Pulse, Cycle; see §5.6 below). All
three share the
motion_visibility
helpers so the visibility gate is one canonical primitive.
Widget code never constructs AnimationScheduler directly. Its job is
small but non-trivial:
- Tick every active animation on each frame the tree pumps. Current value =
lerp(start, end, easing.apply(t));tis elapsed/duration clamped to[0, 1]. - Stop cleanly when an animation reaches its target (set exactly the end value on the terminal tick, regardless of epsilon quantization).
- Pause when the window is occluded or unfocused (
set_window_active(false)). The scheduler reports no next deadline to the event loop during pause, so a hidden window doesn't keep the event loop inWaitUntil. On resume, each animation's start time is rebased by the paused duration — a sweep paused at 50% resumes from 50%, phase-continuous, not snapped. - Cancel when the driving widget disappears.
cancel_by_widget(id)runs whenever a widget is rebuilt or destroyed. Otherwise aSignal<f32>clone in the scheduler would outlive its widget, silently ticking a signal whose observers no longer exist. - Skip offscreen ticks for looping animations. If a widget hasn't painted in the latest paint epoch, the scheduler holds back ticks for any looping animation it owns — keeps the animation alive internally but doesn't drive work through it. This avoids re-paints for spinners and indeterminate progress bars inside a minimized split-view pane or a closed accordion subtree. One-shot animations always tick regardless of paint epoch: a widget like
Collapsewhose own size depends on the animated value (zero height when collapsed → never painted → never re-stamped) would deadlock if the gate also covered one-shots. The cost is bounded — a one-shot with no observers on screen still completes indurationand then stops itself.
The frame-loop integration point is WidgetTree::process_pending_animations (called from layout()) plus scheduler.tick(now, &arena, paint_epoch) called from the event loop. Widget authors don't invoke either directly.
4.1 Why animated_signal specifically
BuildContext::animated_signal(value) is the one-line way to get an animation-capable Signal<f32> that is correctly tied to the calling widget's lifetime:
#![allow(unused)] fn main() { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { self.knob_position = ctx.animated_signal(if self.on.get() { 1.0 } else { 0.0 }); // ... } }
The signal it returns supports animate_to, is registered with the scheduler, and has its owner recorded as ctx.self_id(). When the widget is rebuilt (or destroyed), the scheduler cancels all animations on signals owned by that widget — no orphan tickers.
If a widget constructs its Signal<f32> outside build() (a handful of widgets do, to share the signal with callers), it can call ctx.register_animated_signal(&signal) inside build() to associate the signal with the current widget for cancellation purposes. See ScrollArea and TreeView for examples.
4.2 animate_looping for indeterminate work
For animations that should run until explicitly cancelled — spinners, marquee tickers, indeterminate progress bars — Signal::animate_looping(target, period, easing, frame_interval) sets the signal to its start value each time it reaches the target and loops indefinitely. This is the path used by ProgressBar in indeterminate mode:
#![allow(unused)] fn main() { self.indeterminate_pos = ctx.animated_signal(0.0); self.indeterminate_pos.animate_looping( 1.0, ctx.theme().motion.duration_indeterminate_sweep, Easing::Linear, Some(INDETERMINATE_FRAME_INTERVAL), ); }
Looping animations respect prefers_reduced_motion: widgets check ctx.prefers_reduced_motion() before starting them and fall back to a static representation when the user has disabled motion. Non-looping transitions typically don't need the check — a one-shot 150 ms ease is below the threshold most accessibility guidance worries about — but looping ones always do.
4.3 AnimationSpec — the recommended façade
Reaching for animate_to(target, Duration::from_millis(150), Easing::EaseInOut) directly works, but it shifts three responsibilities onto the call site: pulling the right MotionTokens constant from the theme, picking pixel-stable epsilon / frame_interval defaults for looping animations, and remembering to honour prefers_reduced_motion. AnimationSpec is a fluent builder that captures all three at construction time:
#![allow(unused)] fn main() { // One-shot, theme-aware, accessibility-aware: let spec = ctx.animate().fast().standard(); spec.to_or_snap(&knob_position, target); // ^^^^^^^^^^^^ snaps without tween under prefers-reduced-motion // Looping with sub-perceptual epsilon and 60 Hz throttle baked in: ctx.animate().sweep().linear().to(&sweep_pos, 1.0); // ^^^^^^^ implies looping(), reads duration_indeterminate_sweep }
Duration presets (fast() / normal() / slow() / collapse() / sweep() / instant()) all read from the live theme's MotionTokens — no hardcoded Duration::from_millis(...) literals at the call site. Easing presets (standard() / linear() / ease_in_out() / etc.) similarly pull easing_standard from tokens. looping() flips on sub-perceptual ε = 1/255 and a 60 Hz frame interval (16.667 ms) — the safe defaults for paint-bound loops, matching the most common display refresh rate so a continuous loop advances once per vsync; frame_interval(d) overrides for slower loops (e.g. 66 ms = 15 Hz for a wide sweep where the eye can't resolve faster motion). to(&signal, target) always tweens; to_or_snap(&signal, target) snaps without tween when prefers_reduced_motion is true.
AnimationSpec is a thin façade — it constructs an AnimationRequest and calls Signal<f32>::try_animate_with_options. The lower-level animate_to / animate_looping paths remain public; reach for them only when you need control the spec doesn't expose (custom epsilon for non-pixel signals, max_duration for indefinite loops with a bounded budget). Source: crates/teksilo-core/src/animation_builder.rs.
5. Worked examples from the widget tree
5.1 Toggle thumb — the canonical transform transition
crates/teksilo-widgets/src/toggle.rs:
#![allow(unused)] fn main() { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let initial = if self.on.get() { 1.0 } else { 0.0 }; self.knob_position = ctx.animated_signal(initial); let knob_spec = ctx.animate().fast().standard(); // ... let toggle = move || { let new_on = !on.get(); on.set(new_on); let target = if new_on { 1.0 } else { 0.0 }; knob_spec.to_or_snap(&knob_position, target); }; // ... } }
knob_positionis recreated eachbuild()— not preserved across rebuilds. This is intentional: rebuilds are rare (theme change, structural dirty), and the value is trivially restorable fromself.on.- The
paint()method readsknob_position.get()directly and positions the knob atlerp(left_edge, right_edge, position). - No
Signal::map, noProp::Boundwrapping needed — direct read in paint is fine because the scheduler'ssignal.set()on each tick already dirty-marks the widget for repaint via the binding registry. to_or_snapquietly snaps to the target without tweening when the platform reportsprefers_reduced_motion— same handler code, accessible by default.
5.2 Accordion / Collapse — animating a layout dimension
Accordion wraps its content in a Collapse widget — the reusable primitive for the "animate child between hidden and natural height" pattern. Collapse drives an internal Signal<f32> from 0..1 with ctx.animate().collapse().standard().to_or_snap(...), and its layout_response reports natural * progress while place_children always lays the child out at its full natural size. The framework's clip pass crops the overflow during the tween. Effect: the visible height interpolates over the full duration without the child being squashed (which would re-wrap text and produce flicker).
Animating a layout-participating dimension is more expensive than animating a paint-only value — the binding level is Relayout, not RepaintOnly, so the tree dirtys the accordion's ancestor's layout on each tick. Use sparingly. Paint-only targets (offsets, scales, opacities — see §5.6) are cheaper.
5.3 Snackbar slide-in
crates/teksilo-widgets/src/snackbar.rs uses animate_to on a slide-offset signal that the paint phase applies as a y translation. The snackbar is placed via OverlayPlacement::BottomCenter and the animation slides the overlay in from below. At auto-dismiss time, the same signal animates back to its offscreen position before the overlay is removed.
5.4 Smooth programmatic scroll
When ScrollArea receives a ScrollIntoView request (for example after tab-focusing a child that is offscreen), it calls scroll_y.animate_to(target_offset, ...) instead of scroll_y.set(target_offset). The user sees the viewport slide to the new position rather than jumping. See scroll_area.rs.
5.5 Icon widget — sprite-sheet frame animation
icon_widget.rs animates a frame-index signal looping over the frame count for animated WebP icons. Frame interval comes from the asset, not from MotionTokens — this is a content-driven animation, not a UI transition.
5.6 Wrap-and-go animation widgets
Animation wrappers live under crates/teksilo-widgets/src/animations/ and are re-exported flat from teksilo::widgets. They package the common animation patterns so callers don't re-implement them:
Fadewraps a child and tweens an internal opacity signal between 0 and 1 driven by aSignal<bool>. Layout-transparent: the child reports its full natural size at all opacity values. Built onBuildContext::set_opacity, a node-level opacity scope (parallel toclips_children) emitted by the rendering walker asSetOpacity/RestoreOpacitydraw commands wrapping the subtree. Sub-perceptual opacities (< 1/512) are short-circuited — no draw passes.Collapse— see §5.2. The accordion-pattern primitive.Scalewraps a child and animates a uniform 2D scale on its entire subtree, driven by aProp<bool>. Built onBuildContext::set_transform(see §5.7) — the renderer composes the scale matrix onto its transform stack so the wrapped subtree's text and shapes visually shrink together. Two modes: visual-only (default,reflow=false) — the slot stays at the child's natural size, only the visual content scales around the chosen origin (use for overlay enter/exit, "boop" feedback); reflow (.reflow(true)) — the wrapper's reported size shrinks with progress so siblings reflow as the wrapped content disappears (use for "card removal", pair withScaleOrigin::TopLeadingso the visual stays anchored at the slot's top-left as it collapses). Distinct fromCollapse:Collapseshrinks one axis and "wipes" content via clipping (text inside stays full-size);Scaleshrinks uniformly and text/icons visually get smaller.Rotatewraps a child and applies a 2D rotation (radians) to its subtree viaset_transform. Layout-stable. No internal animation — the caller owns the angle signal and pairs it withSignal::animate_tofor animated rotations. Use for animated chevrons (replacing the old "flip-two-static-icons" trick), spinning loaders not covered bySpinner, dial controls.Blurwraps a child and applies a Gaussian-equivalent blur to the entire subtree, driven by aProp<f32>radius (logical pixels). Built onBuildContext::set_blur(see §5.7) — the renderer redirects the subtree's draws into an intermediate texture, runs a dual-Kawase chain at the requested radius, and composites the blurred result back at the widget's bounds. Layout-transparent: the child reports its full natural size at all radii. Sub-perceptual radii (< 0.5) are short-circuited at the walker — no offscreen pass, no allocation. Use for modal backdrops, click-to-reveal sensitive content (numerics / characters obscured by the blur), out-of-focus emphasis, animated frosted glass on modal show. Pair with ananimated_signalandanimate_tofor animated enable/disable. See §5.8 for the offscreen-pass cost model.Spinner— circular-arc loading indicator backed byAnimatedQuadKind::SpinnerArc, the shader-driven path (see idle-and-animation.md §"Three animation paths — signal vs shader vs per-frame-effect"). Onequeue.write_bufferofAnimParams+ onedraw_indexedper frame;paint()does not run while spinning. Edges are anti-aliased viafwidthsmoothstep ramps in the fragment shader. Honoursprefers-reduced-motionwith a static three-quarter arc fallback.
Pulse and Cycle drive their continuous motion through the per-frame-effect path rather than AnimationScheduler (which only knows linear tweens) or AnimatedQuadRegistry (which is paint-time GPU plumbing). They register a closure on ctx.frame_tick() for the tick action and a ctx.subscribe_frame_tick() RAII guard for visibility-aware chain management — the framework re-arms the frame chain after every render iff at least one subscriber's owner widget was painted in that frame, so a Pulse parked inside a non-selected Switcher branch contributes zero idle frames and resumes phase-continuous on the next show. The chain bootstrap (request_frame on subscription) and resume (post-render arm after the visible_when-driven repaint) are both handled by the framework. Widget code in the new shape:
#![allow(unused)] fn main() { pub struct Pulse { // … frame_tick_sub: Option<FrameTickSubscription>, } impl Widget for Pulse { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { // … set_opacity, reduced-motion early-out, … ctx.effect(&ctx.frame_tick(), move |&delta| { // mutate opacity from sine of accumulated phase }); self.frame_tick_sub = None; // drop old guard first self.frame_tick_sub = Some(ctx.subscribe_frame_tick()); // … } } }
If you find yourself reaching for ctx.frame_request_handle().set(true) from inside a frame_tick effect for a visual continuous animation, prefer subscribe_frame_tick() instead — the raw handle keeps the event loop pumping regardless of visibility, while the scheduler-backed path auto-pauses on hidden owners. The raw handle is still the right tool for short-lived, owner-driven needs that aren't visibility-bound (caret blink that depends on focus state, drag auto-scroll while the pointer is captured).
Other wrappers in the same module — SmoothSize, Crossfade, Slide, Shake — are documented inline in their source files; run cargo run -p animations-kit for a visual showcase of every wrapper.
5.7 Per-node paint scopes
The framework ships four per-node paint scopes that wrapper widgets attach to themselves:
BuildContext::set_opacity(id, prop)— the original. Render walker emitsSetOpacity(value)/RestoreOpacityaround the subtree; renderer maintains a stack and multiplies through. Bound atRepaintOnly. Used byFade,Pulse,Crossfade,OverlayRequest::with_fade.set_clips_children(id, true)— a per-node clip rectangle to the node's own bounds. Used byScrollArea,Collapse,MaxSize,Slide,Shake,SmoothSize,Scale,Rotate— anything whose subtree may overshoot the slot.BuildContext::set_transform(id, prop)— added withScaleandRotate. Render walker emitsPushTransform(matrix)/PopTransformaround the subtree; renderer maintains a transform stack. Bound atRepaintOnlyby default; reflow-driving wrappers (e.g.Scale::reflow(true)) must additionally bind their driver signal to themselves atRelayoutfor layout to track the value.BuildContext::set_blur(id, prop)— added withBlur. Render walker emitsBeginBlurredSubtree { bounds, radius }/EndBlurredSubtreearound the subtree; the renderer redirects drawing into an intermediate texture, runs a dual-Kawase blur chain at the requested radius, and composites the blurred result back. Bound atRepaintOnly. The only scope that triggers an offscreen render pass and per-frame texture allocation — see §5.8.
Scope nesting order on a single node, from outermost to innermost: BeginBlurredSubtree → SetOpacity → PushTransform → ...paint.... Reverse on close. The blur scope is OUTER on purpose: it captures the already-faded, already-transformed subtree into the intermediate texture so animated fade-in-and-blur behaves intuitively (the blur is applied to whatever the user would see, post-fade, post-transform).
The transform scope has one non-obvious twist worth pinning down for future widget authors: SetTransform semantics are "compose with stack-top", not "set absolute". A widget's own canvas-level transforms (canvas.translate(5, 5) etc.) emit SetTransform commands relative to the widget's own identity baseline; under a wrapper push, the renderer composes them onto the stack-top transform instead of clobbering it. With an empty stack (the default for any widget not under a transform wrapper), stack_top = identity and composition is a no-op — the change is purely additive and backwards-compatible. Identity-valued transforms are skipped at the walker layer (no push/pop emitted), so wrappers at their rest pose pay zero per-frame cost. Sub-perceptual blur radii (< 0.5 px) are similarly skipped — animated 0 → target_radius enable patterns pay zero cost when fully off.
5.8 Offscreen render passes — when blur breaks the single-pass model
The renderer is single-pass by default: every DrawCommand flows into one wgpu::RenderPass targeting the surface texture. Blur is the exception. A BeginBlurredSubtree { bounds, radius } / EndBlurredSubtree pair carves out a sub-pass that:
- Allocates an intermediate RGBA8 texture sized to
bounds × scale_factor(drawn from a renderer-side recycled pool keyed on power-of-two sizes — no per-frame allocation hot path). - Renders the subtree's draw commands into that texture (with a translation pushed onto the transform stack so the subtree paints at
(0, 0)of the intermediate). - Runs the dual-Kawase chain on it:
N = ceil(log2(radius))downsample passes (each halves the texture, applies a 4-tap bilinear shader), thenNupsample passes back to the source size with a different 4-tap shader. - Composites the final blurred texture into the parent pass at
boundsvia the standard quad pipeline (which already samples textures — this is just a textured-quad blit).
Each blur scope = N + N + 1 small render passes per frame for typical UI radii (R = 8–24 → N = 3–5). Cheap individually, but every blur scope opens a new pass on the encoder and breaks batch coalescing on the surrounding draws — don't sprinkle Blur widgets through a list view. Stable layouts (modal backdrops, sensitive-content panels, frosted side panels) are the natural fit.
The reference for this offscreen-render pattern is png_export.rs, which has been creating intermediate RENDER_ATTACHMENT | COPY_SRC textures and routing the renderer at them since the widget previewer shipped — the blur engine generalises that pattern into a recursive sub-pass.
For overlays, OverlayRequest::with_fade(duration) is the recommended path for tooltip / popover / snackbar fade-in / fade-out. The framework wires opacity internally — caller specifies just the duration:
#![allow(unused)] fn main() { tree.show_overlay(OverlayRequest { content_id, anchor, placement, dismiss, layer: OverlayLayer::InTree, parent_overlay: None, on_dismiss: None, fade_duration: Some(theme.motion.duration_fast), }); }
When fade_duration is Some, WidgetTree creates an animated Signal<f32>, applies it as an opacity scope on the content (via set_opacity — same primitive Fade uses), kicks off the 0→1 tween at show time, and on dismiss reverses to 0 then defers the actual stack removal by duration so the tween plays out before the content goes dormant. The OverlayManager tracks fade-out state on a dual sim/real clock so headless tests can use tree.advance_time(...) to drive deterministic dismissal.
6. When NOT to use the animation system
Animation via animate_to is for a value that crosses time smoothly. It is not for:
- Color shifts on hover / press. Those are instant in Int UI's vocabulary. Express them as
Signal<Role>mapped from the interaction state signal (see reactive-theme.md §"Interaction-driven colors"). No scheduler involved; color resolves from the current theme per frame. - Caret blink. The caret is either drawn or not, on a cadence. It uses
BuildContext::frame_tick()+request_frame()to pump the event loop on a schedule and flips a boolean — no smooth interpolation happens, soanimate_towould be the wrong tool. - Fade-in of a list of items appearing on filter change. Decorative; Int UI's guidance is "don't." If the visual disruption is bad enough to warrant fade, consider whether the list widget itself should not disrupt — for example, a virtualized list that only creates newly-visible items, rather than full remount on filter change.
- Tooltip delay. The opening delay is a timer, not an animation. Once the tooltip appears, its fade-in is driven by
OverlayRequest::with_fade(theme.motion.duration_fast)(see §5.6) — the framework owns the opacity tween, callers do not roll their ownanimate_toon the tooltip content. - Single-axis disclosure (an accordion section opening, a drawer expanding to a known height). Use
Collapseinstead ofScale—Collapseclips on one axis without re-running text layout or applying a transform scope, so it's cheaper and the visual ("shutter rolls down, text stays at full size") matches what users expect for disclosure.Scaleis for uniform shrink-around-a-pivot ("card disappears", "icon boops"), where the visual content itself should get smaller. - Per-list-row blur or anything that animates blur radius every frame.
Bluris the most expensive scope in the framework — every enabled scope opens a separate render pass and runs2N+1Kawase passes per frame. For "fade-blur on reveal" patterns, animate the radius up to a static value and leave it there. For per-row obscuration of sensitive data, prefer a per-row text-redaction primitive over wrapping each row inBlur.
7. Testing animations deterministically
The WidgetTree::advance_time(duration) method on the test_api advances the tree's simulated clock and runs the scheduler with the new now. This makes animation tests deterministic without needing real wall time:
#![allow(unused)] fn main() { let mut tree = WidgetTree::new(); let toggle_id = tree.add(Toggle::new(Signal::new(false))); tree.layout(SizeProposal::exact(200.0, 100.0)); // Trigger the toggle's tap handler: tree.click(toggle_id); // Advance past the 150 ms animation: tree.advance_time(Duration::from_millis(150)); // Assert the knob is fully at position 1.0: // (read through a test-api accessor, not shown) }
Headless tests that never call render() have paint_epoch == 0; the scheduler treats that as "all widgets visible" so the per-widget visibility gate doesn't make tests flaky.
AnimationScheduler::active_count() and has_active() are public for tests that want to assert the scheduler is (or isn't) still running.
8. Design rules in one list
- Recommended entry point:
ctx.animate().<duration>().<easing>().to_or_snap(&signal, target)(§4.3). CapturesMotionTokens, easing presets, andprefers_reduced_motionin one place. - Lower-level entry points for cases the spec doesn't cover:
Signal<f32>::animate_to(...),animate_looping(...),try_animate_with_options(AnimationRequest). - Create the signal with
BuildContext::animated_signal(value)insidebuild(). That handles scheduler registration and widget-lifetime cancellation. - Respect
ctx.prefers_reduced_motion()before starting a looping animation;to_or_snapalready does it for one-shots. - Durations come from
MotionTokens(duration_fast/_normal/_slow/_collapse/_indeterminate_sweep), not from literal constants in widget code. Literal constants are acceptable for one-off durations a designer doesn't plan to retune (icon sprite frame intervals, for example). - Easing curves come from
Easing.EaseInOutfor symmetric transitions (toggle thumbs),EaseOut/easing_standardfor appearance (snackbar slide-in, dialog fade),Linearfor loops and indeterminate work.Easing::CubicBezier { x1, y1, x2, y2 }(viactx.animate().cubic_bezier(...)or the.m3_emphasized()preset) is the general escape hatch for a design language's own motion curve. - For common shapes — fade an overlay, collapse a section, show a spinner — reach for
Fade/Collapse/Spinner/OverlayRequest::with_fade(§5.6) before hand-rolling a signal-driven path. - Don't animate colors, hovers, presses, focus states, or anything instant in Int UI's vocabulary — those are reactive theme work, not scheduler work.
See also
- reactive-theme.md — reactive theming for the "it's not really animation, it's just reactive color" path (hover, press, focus).
- architecture.md §20 Threading — where the per-frame tick fits in the event loop.
- crates/teksilo-core/src/animation.rs — scheduler source.
- crates/teksilo-core/src/signal.rs (
Signal<f32>::animate_toet al). - crates/teksilo-tokens/src/motion.rs —
EasingandMotionTokens.
Idle and animation — the zero-frame rule
The rule
An idle app must draw zero frames. Not "almost zero". Not "a
cheap 60 Hz". Zero — rendered_frames == 0 in the
TEKSILO_IDLE_TRACE=1 trace, ControlFlow::Wait in winit, no GPU submit,
no CPU wake, no battery drain.
"Idle" means:
- No user input (no cursor move, click, key, scroll, resize).
- No pending tooltip / delayed overlay / gesture deadline.
- No accessibility rebuild in flight.
If those conditions hold and the event loop still wakes up, it is a bug. Track it down.
Why so absolute
Teksilo is meant for long-running desktop apps. A 60 Hz idle pump costs CPU, GPU, battery, fan noise, and — on laptops — holds the package out of deep C-states. Compounded across every running animation, every unfocused window, every background process, it is the difference between "I left it open" and "my battery is dead".
A framework that draws at idle normalises wasted cycles. We refuse.
The machinery that enforces it
Four gates, applied uniformly across three motion subsystems:
- Signal-tween path —
Signal<f32>::animate_to/animate_looping, scheduled byAnimationScheduler. - Shader-quad path —
ctx.animated_quad(kind), scheduled byAnimatedQuadRegistry. - Per-frame-effect path —
ctx.subscribe_frame_tick(), scheduled byFrameTickScheduler. Used by widgets whose tick is neither a linear tween nor a quad uniform —Pulse(sine oscillation),Cycle(discrete index step), and any future hand-rolledframe_tickconsumer.
All three consult the same visibility primitives in
motion_visibility
(alive, painted_this_frame, painted_recently) so the
"is my owner visible enough to keep waking?" decision has one
canonical answer per scheduler shape. Any new source of idle wakes
must be designed to respect the gates below — or add its own
scheduler that consults the same helpers.
-
Widget-drop / rebuild auto-cancel. The scheduler holds strong
Signal<f32>clones; without an explicit cancel on widget death, a rebuilt widget leaks its old animation forever and ticks against an orphaned signal.WidgetTree::rebuild_single_widgetanddestroy_subtreeboth callscheduler.cancel_by_widget(id)before reconstructing. If you add a new lifecycle path that replaces widget state, it must do the same. (animation.rs, widget_tree.rs) -
Per-window active flag.
WindowEvent::Focused(false)(and on macOS,Occluded(true)) callstree.set_window_active(false), which makesAnimationScheduler::ticka no-op andnext_deadlinereturnNone. The event loop falls through toControlFlow::Wait. On resume, each animation'sstart_timeis rebased by the paused duration so phase is continuous — a half-swept sweep resumes at 50%, not snapped forward. (app.rs, window_manager.rs) -
Per-widget paint-epoch visibility.
WidgetTree::paint_epochticks on every non-cache-hitrender().paint_widget_cachedstampslast_painted_epochon each widget whose bounds survive clip intersection. The sharedmotion_visibilityhelpers turn that into a yes/no for each scheduler:-
Signal-tween path uses
painted_recently(last_painted_epoch + 1 >= paint_epoch) — tolerant, because the signalseton each tick dirties its widget, which forces a non-cache-hit paint that bumps both values in lockstep; the+1slack just rounds out the layout-then-paint adjacency on freshly-visible widgets. -
Shader-quad path and per-frame-effect path use
painted_this_frame(last_painted_epoch == paint_epoch) — strict, because their tick does not dirty the widget. The shader path advances per-slot uniforms in a buffer the fragment shader samples; the per-frame-effect path mutates a signal whose binding may or may not propagate to a paint dirty. Tolerance there would treat a never-painted widget (last_painted_epoch = 0,paint_epoch = 1) as visible forever — the originalPulse/Cyclebug, where a Pulse parked inside a non-selectedSwitcherbranch kept the event loop pumping at full frame rate.
Result: a scrolled-off spinner, an off-tab Pulse, an oscillating indicator inside a collapsed accordion all stop ticking. When the widget scrolls / switches back in, the resulting paint re-stamps its epoch —
update_control_flowre-queriesnext_deadlineinpost_event(signal + quad paths) andWidgetTree::renderre-armsframe_tick_requestedafter every visible-subscriber paint (per-frame-effect path) — and motion resumes phase-continuous.Signal-path one-shots are not gated by visibility. A widget like
Collapsedrives a one-shot 0..1 progress signal that determines its own height — so when collapsed, its bounds are zero, it never paints, never re-stampslast_painted_epoch, and a visibility gate would chicken-and-egg the expand: never tick, never grow, never paint. The signal scheduler gates only looping entries; the shader and per-frame-effect schedulers don't ship a one-shot shape, so the loop-only carve-out is signal-only.paint_epoch == 0is the "never rendered" sentinel: always visible, so headless unit tests that only calllayout()don't regress. (rendering_impl.rs, arena.rs, animation.rs, animated_quad.rs, frame_tick_scheduler.rs) -
-
Pixel-stable ε, mandatory terminal bypass. Each
AnimationRequestcan carry anepsilon(unit: the signal's own units, so usually logical pixels). Intermediate ticks skipsignal.setwhen the value hasn't moved by at least ε — no dirt, no frame. Terminal ticks (completion, loop restart) always set unconditionally so one-shots land on exactlyend_value. Ship ε for any looping animation whose minimum visible delta is known (ProgressBar → 1 px of track width is a safe choice).
The idle-work audit
WidgetTree::needs_redraw() is the predicate the event loop uses to
decide between ControlFlow::Wait and ControlFlow::WaitUntil. If
it returns true, the app is not idle — even if nothing visible is
animating. Any new "is there work pending" signal must be
included in this predicate, and paused/hidden variants must be
excluded. The scheduler's has_running (pause-aware) rather than
has_active (pause-oblivious) is the reference pattern; a paused
scheduler that still said has_active == true would defeat every
gate above.
Verifying you haven't regressed the rule
Run the catalog with the idle trace. A truly idle app emits no trace line at all (the trace is written on each wake — no wake, no line):
cargo build --profile profiling -p widget-catalog
TEKSILO_IDLE_TRACE=1 timeout 10 ./target/profiling/widget-catalog 2> /tmp/idle.log
wc -l /tmp/idle.log # expect 0
CPU and GPU deltas can be read from the kernel:
# Process CPU over 10s (see /tmp/measure_idle.sh in the tree for the
# full script — samples /proc/<pid>/stat and sysfs gpu_busy_percent)
/tmp/measure_idle.sh
# Expect: cpu < 0.5%, gpu delta ≈ baseline.
If the numbers are above baseline, something in the tree is waking the loop. Classify it:
- Looping animation not paused? Check
tree.is_window_active()and the widget'slast_painted_epochvs.tree.paint_epoch. - Timer source you forgot? Every timer-backed deadline must flow
through
next_timer_deadlinein overlay_impl.rs. If it doesn't, the event loop can't decide whether to sleep. - Poll mode forced?
ControlFlow::Pollis reserved for the async executor's loop-tick (loop_tick_poll), which must process runnable tasks as fast as possible and is not an animation. The per-frame-effect path (frame_tick_requested: Pulse, Cycle, caret blink, drag auto-scroll) does not force Poll — it publishes a fixed 60 Hz deadline viaWidgetTree::frame_tick_deadline, folded intonext_timer_deadline, so continuous animations render throughControlFlow::WaitUntilat 60 Hz regardless of the display's refresh rate. (It used to force Poll, which free-ran at the panel's refresh — a singlePulse/Cyclemeasured 300 fps / ~45 % CPU on a 300 Hz panel vs. 60 fps / ~13 % CPU after the cap.) This matches the signal-tweenAnimationSchedulerand shader-quadAnimatedQuadRegistry, which already pace at the same 16.667 ms interval. For visual continuous animations (Pulse, Cycle, …), preferctx.subscribe_frame_tick()over the rawframe_request_handle().set(true)re-arm — the scheduler-backed path automatically pauses the chain when the owner widget is hidden, while the raw handle keeps the event loop pumping regardless of visibility.
When in doubt, bisect: remove widgets from the scene until the idle returns to zero. The last removal is the culprit.
For widget authors
If your widget schedules anything time-driven — animation, timer,
poll, deferred callback — it must have an explicit answer for each
of the four gates. ctx.prefers_reduced_motion() is a fifth pre-gate
for decorative motion: honor it, and you get the zero-motion
accessibility behavior and a free idle win.
Off-thread repaint — RepaintWindowRequest
ctx.request_frame() is the UI-thread way to ask for a redraw. But some
widgets have content that changes on a background thread: a terminal
emulator's PTY-reader thread, a video decoder, a streaming data source. Those
threads can't touch the widget tree, and posting a bare wake-up is not enough —
a plain redraw re-presents each node's cached paint frame, so the render
walker never re-runs paint() for a node it still thinks is clean. Content that
changed off the UI thread would never appear.
teksilo_core::RepaintWindowRequest { window_id } is the off-thread analogue of
request_frame. A background thread posts it through the poster:
#![allow(unused)] fn main() { // captured once, in `ctx.run_after_mount(...)`, where poster + window are both reachable: let poster = ectx.poster().cloned(); // Arc<dyn AppEventPoster>, Send let window_id = ectx.window().map(|w| w.id()); // TeksiloWindowId, Copy // on the background thread, whenever off-thread content changed: poster.post_external(Box::new(RepaintWindowRequest { window_id })); }
teksilo-app routes the request by marking that window's tree paint-dirty
(WidgetTree::mark_all_needs_paint_only()) before the redraw, so the changed
widget's paint() runs again. It respects the zero-frame rule: nothing is
scheduled — a frame is drawn only when an actual off-thread event arrives, so an
idle terminal (no output) still draws zero frames. Under a flood of off-thread
events (e.g. yes piped into a terminal), coalesce: only post a request when one
isn't already outstanding, since each mark-dirty is O(nodes). This mechanism was
introduced for teksilo-terminal; see terminal.md.
Three animation paths — signal vs shader vs per-frame-effect
Teksilo carries three motion paths that coexist. Pick by shape:
| Path | When to use | Cost when visible | paint() re-runs per frame? |
|---|---|---|---|
Signal<f32>::animate_to / animate_looping (via AnimationScheduler) | Tweens driving arbitrary values: scroll offsets, sidebar slide, toggle knob, slider fill width, any custom interpolation your paint() consumes. One-shots and looping. | CPU: signal.set → paint() → vertex-buffer rewrite → wgpu submit. Tight but per-frame. | Yes. |
ctx.animated_quad(kind) (via AnimatedQuadRegistry) | Decorative motion that fits a quad + shader: ProgressBar::indeterminate (procedural sweep), Spinner (procedural arc), animated IconWidget (sprite-atlas frame cycling), future shimmer / skeleton | CPU: one queue.write_buffer of the AnimParams struct (64 B per active quad) + one draw_indexed call. paint() does not run. | No. |
ctx.subscribe_frame_tick() (via FrameTickScheduler) | Per-frame-effect closures that don't fit a tween or a quad: Pulse (sine opacity), Cycle (discrete index advance every period), and similar "I just need a callback every frame while my widget is visible" patterns. | CPU: framework re-arms the chain post-render iff at least one subscriber's owner painted this frame; effect closure mutates state via signals — cost matches whatever the closure does. | Yes (the closure typically dirties bound props, which dirty the widget). |
Use signal when paint() needs the current animated value to
compute its draw commands (e.g., scroll offset shifts every child's
coordinates). Use shader when the animation's visual is
expressible as "draw a quad, let a fragment shader decide pixels
from a small state struct." Use per-frame-effect when neither
fits and you genuinely need a closure called each visible frame.
The widget-level surface for the third path:
#![allow(unused)] fn main() { // On `self`: frame_tick_sub: Option<FrameTickSubscription>, // In `build()`: ctx.effect(&ctx.frame_tick(), move |&delta| { // mutate signals, advance phase, … }); self.frame_tick_sub = None; // drop the old guard first self.frame_tick_sub = Some(ctx.subscribe_frame_tick()); }
The chain auto-arms while at least one subscriber's owner is
painted, dies cleanly when all are hidden (parked inside a
non-selected Switcher branch, scrolled off-screen, …), and
resumes phase-continuous on a hidden→visible transition because
the visible_when flip's Relayout dirty triggers a repaint that
paints the subscriber, which the post-render arm then detects.
Widget-author surface. In build():
#![allow(unused)] fn main() { // Procedural sweep (ProgressBar). self.handle = Some(ctx.animated_quad(AnimatedQuadKind::IndeterminateSweep { period: Duration::from_millis(900), sweep_ratio: 0.42, track_color: SurfaceRole::Sunken.into(), fill_color: SurfaceRole::Accent.into(), })); // Procedural arc (Spinner). Anti-aliased via fwidth smoothstep // in shaders/anim_procedural.wgsl — soft alpha at radial bounds and // arc start/end. Pipeline already uses ALPHA_BLENDING, so the soft // alpha composites correctly. self.handle = Some(ctx.animated_quad(AnimatedQuadKind::SpinnerArc { period: theme.motion.duration_indeterminate_sweep, arc_fraction: 0.75, stroke_fraction: 0.12, color: TextRole::Accent.into(), })); // Sprite atlas (animated IconWidget — frames pre-packed into a grid). self.handle = Some(ctx.animated_quad(AnimatedQuadKind::SpriteCycle { image_name: atlas_name.clone(), frame_count, cols, rows, period: icon.total_duration(), tint: Some(TextRole::Primary.into()), // None for FullColor icons })); }
In paint():
#![allow(unused)] fn main() { canvas.draw_animated_quad(bounds, handle.slot(), AnimatedQuadClass::Procedural); // or: AnimatedQuadClass::Sprite { image_name: atlas_name.clone() } }
The four gates (pause-on-window-unfocused, per-widget paint-epoch
visibility, widget-drop/rebuild auto-cancel, prefers_reduced_motion)
apply to all three paths in identical shape — they share the
motion_visibility
helpers and rebuild auto-cancel by RAII (the signal scheduler via
scheduler.cancel_by_widget(id), the shader registry via slot
deallocation on widget destruction, the frame-tick scheduler via
the FrameTickSubscription Drop guard the widget stores on
itself). The only difference is where the tick runs: CPU-side
through a signal for the tween path, shader-side through a uniform
buffer for the quad path, CPU-side through an arbitrary closure
for the per-frame-effect path.
Adding a new kind: extend AnimatedQuadKind, add a kind: u32
discriminator branch in shaders/anim_procedural.wgsl (or
anim_sprite.wgsl for texture-sampling kinds), and update
AnimatedQuadRegistry::compute_params to populate the shared
AnimParams struct from the kind's fields.
Framework-level cost at 60 Hz
The shader-driven scenes (examples/animations,
examples/animations-kit)
measure ~5 % CPU per process. The per-frame-effect scenes
(Pulse / Cycle chains in widget_catalog --tab animations)
measured ~50 % CPU at first 60 Hz profile — roughly 10× the
shader path. The cost was not in the renderer; it was in the widget
tree's per-frame infrastructure that runs around it.
Profiling at 60 Hz (perf record -F 999 -g --call-graph fp on a
release+debug build) found the framework-level hotspots; the
optimisation work brought the catalog scene from ~50 % CPU to ~28 % CPU through:
| Phase | What it fixed | Recovery on catalog scene |
|---|---|---|
| 1 — A11y dirty-gate | layout() was unconditionally setting a11y_dirty = true every layout pass; the AT tree was rebuilt every animation tick (build_accessibility_recursive walked the whole tree at 60 Hz). Fixed by setting a11y_dirty only at events that actually change AT shape (activation transitions, overlay show / dismiss, focus changes, AccessibilityOnly bindings). | ~3.3 pt |
| 2 — Streaming arena iterators | WidgetArena::active_ids() allocated a Vec<WidgetId> on every call. Three call sites per frame accounted for ~13 % CPU together. Replaced with active_ids_iter() (zero-alloc streaming) for read-only callers and a pooled active_ids_scratch: Vec<WidgetId> on WidgetTree for the one mutation-during-iter caller (tick_gestures_with_ops, post-render dirty clear, post-layout layout-flag clear). | ~13 pt |
| 3 — Gesture-owners set | Per-frame gesture tick walked every active widget, even though only a handful actually carry a gesture arena. Added gesture_owners: HashSet<WidgetId> on WidgetTree; ensure_gesture_arena inserts on attach, rebuild / destroy paths remove. tick_gestures_with_ops and next_gesture_deadline now iterate just the owners. | ~3 pt (mostly absorbed by Phase 2) |
| 4 — Source-indexed binding registry | BindingRegistry was a flat Vec<Binding> walked linearly each frame; flush_dirty called is_dirty() per binding, even though many bindings shared one underlying source signal. Replaced with HashMap<source_id, BindingGroup> so is_dirty() runs once per unique source (~30-40 in the catalog) instead of per binding (~100-300+). Phase 4 also unified flush_dirty + flush_accessibility_dirty into one flush_all_dirty call to fix a latent bug where the two flushes raced on a shared per-Signal dirty flag. | ~5 pt |
Total: catalog scene from ~50 % CPU to ~28 % CPU sustained at 60 Hz.
Verification: bench/perf_post_phase4_summary.md (bench directory).
Damage rects — measured, deferred
A natural next optimisation for shader-driven animations would be
damage rects: track per-frame dirty regions, set
wgpu::RenderPass::set_scissor_rect so the GPU only rasterises the
changed pixels, and pass a damage region to the OS compositor so it
skips recompositing the rest of the window. Wayland has
wl_surface.damage_buffer; macOS has CAMetalLayer dirty rects.
We measured before committing to this. Two profiling rounds:
-
First round (8 s window on the Animated tab of
examples/animations, pre-Phase-1 framework code): the process showed ~1.83 % CPU on one core, dominated by wgpu staging-belt activity forqueue.write_buffer(anim_uniforms, 8 KiB)and command encoding. None of it was rasterisation time. -
Second round (60 Hz, full measurement on the catalog
--tab animationsscene): the framework-level path dominated at 50.7 % CPU, withqueue.write_buffernot in the top 22 hotspots — wgpu's per-queue staging belt amortises it completely. The work above (Phases 1-4) addressed the actual bottlenecks. Damage rects would still target a small slice (renderer at 1.5-5 % depending on scene) and remain deferred.
Revisit when any of these trigger:
- 120 Hz looping animations become common (we're 60 Hz).
- Target display resolution goes 4K / multi-monitor.
- Many simultaneous animated widgets (dozens of spinners across a dashboard).
- Battery-sensitive hand-held / laptop deployment where every milliwatt counts.
- Real workload profiling shows rasterisation or compositor cost exceeding the framework / renderer cost.
Cheaper follow-up that would actually help today: the
AnimatedQuadRegistry already tracks dirty slot ranges via
take_dirty_ranges (Phase 0). A future renderer
revision can use that to upload only the changed slots instead of
the full scratch_slice — single-call-site change in
teksilo-render, useful when many quads idle (e.g. paused indicators).
Accessibility Overrides Reference
Teksilo widgets declare their own a11y info via Widget::accessibility(&self, builder: &mut AccessNodeBuilder) — Button emits Role::Button + label, Slider emits Role::Slider + numeric range, Panel marks itself set_hidden() when it's a11y_presentational, etc. That covers ~95% of cases. The remaining 5% — when an icon-only Button needs an accessible label, when a card composite should read as one AT element, when a status region needs aria-live, when a custom action should appear in VoiceOver's Actions rotor — is where builder-level accessibility overrides come in.
The override layer is a one-method-per-concern surface (.access_label, .access_role, .access_merge_subtree, …) on WidgetBuilder and WidgetWithHandlers, analogous to SwiftUI's .accessibility* modifiers and Flutter's Semantics(...). App authors annotate widgets from the outside without touching widget internals.
#![allow(unused)] fn main() { Button::new(tr!(save_icon())) .icon(IconWidget::from_svg_icon(save_icon), IconLocation::IconOnly) .access_label(tr!(save())) // icon-only button needs a name for AT .access_shortcut_id("app.save") // tracks user rebinds via ShortcutRegistry .access_action(Action::ShowContextMenu, |ctx| ctx.send_intent(AppIntent::Menu)); }
Mental model in one line:
HandlerSet (carries) → WidgetNode (owns) → tree walker (applies) → accesskit::Node
End-to-end example: examples/widget_catalog/src/main.rs — the icon-only buttons in the Controls section show the canonical pattern in both builder and teksu! macro form.
Where overrides live
The override surface piggy-backs on the existing handler-extraction plumbing — the same path that already mirrors cursor, clips_children, focus_within_signal from HandlerSet onto WidgetNode.
- Builder chain —
Widget::new(...).access_label(...).access_role(...)each return aWidgetWithHandlers<W>whoseHandlerSetcarries anOption<Box<AccessibilityOverrides>>. The firstaccess_*call lazily allocates the box; subsequent calls extend it. - Insertion —
WidgetTree::add(...)callstake_handler_set()on the wrapper andapply_handler_set(crates/teksilo-core/src/arena.rs) mirrors the box onto the persistentWidgetNode::access_overridesfield. After this point, the wrapper has no override state — the source of truth is on the node. - AT tree build — when the framework calls
WidgetTree::sync_accessibility(), the walker at crates/teksilo-core/src/widget_tree/accessibility_impl.rs:137 runsnode.widget.accessibility(builder)first (so the inner widget emits its defaults), then callsnode.access_overrides.apply(builder)to layer the overrides on top.
Subsequent sync_accessibility() calls re-run the walker if the AT cache is dirty — which now also dirties on ShortcutRegistry::version() bumps so access_shortcut_id tracks rebinds (see Shortcuts below).
Method reference
Naming: .access_* prefix throughout. Three tiers by frequency of use.
Tier 1 — labeling and state
| Method | Sets | Notes |
|---|---|---|
.access_label(s) | Node::label | What screen readers announce. Replaces widget-emitted name. |
.access_label(lit!(s)) | same | #[doc(hidden)] grep marker for explicitly untranslated strings. |
.access_description(s) | Node::description | Long-form context. |
.access_description(lit!(s)) | same | #[doc(hidden)] grep marker. |
.access_hint(s) | Node::description | Alias for access_description (SwiftUI parity — AccessKit has no separate hint slot). |
.access_hint(lit!(s)) | same | #[doc(hidden)] grep marker. |
.access_value(s) | Node::value | Current value (sliders, spin boxes, text input). |
.access_value(lit!(s)) | same | #[doc(hidden)] grep marker. |
.access_role(role) | Node::role | Replace widget-emitted role. |
.access_hidden(bool) | Node::hidden flag | true hides from AT, false un-hides (clears even widget-emitted hidden). |
.access_disabled(bool) | Node::disabled flag | true marks disabled, false clears even arena-driven disabled. |
Tier 2 — relationships, live regions, identity
| Method | Sets | Notes |
|---|---|---|
.access_identifier(s) | Node::author_id | Stable test/debug id (like data-testid). Not user-visible. |
.access_controls(target_id) | Node::controls | Append. ARIA aria-controls. |
.access_described_by(target_id) | Node::described_by | Append. |
.access_labelled_by(target_id) | Node::labelled_by | Append. |
.access_live(mode) | Node::live | Politeness for status regions (Polite, Assertive). |
.access_current(c) | Node::aria_current | Mark this as the current item in its container (aria-current). |
.access_has_popup(kind) | Node::has_popup | Disclosure flag — Menu, Listbox, Dialog, … |
.access_orientation(o) | Node::orientation | Sliders, scrollbars, separators. |
Tier 3 — subtree modes, numeric, actions, escape hatch
| Method | Effect |
|---|---|
.access_exclude_subtree() | Prune all descendants from the AT tree. Parent still emitted. |
.access_merge_subtree() | Lift descendant labels / values / actions into parent, prune descendants. |
.access_subtree(mode) | Set explicit AccessSubtreeMode::{Inherit, Exclude, Merge}. |
.access_numeric_value(v) | Node::numeric_value. |
.access_numeric_range(min, max) | Node::min_numeric_value + max_numeric_value. |
.access_numeric_step(s) | Node::numeric_value_step. |
.access_action(action, handler) | Advertise an AT action AND register a callback. |
.access_remove_action(action) | Suppress an action the widget emitted. |
.access_custom_action(label, handler) | SwiftUI accessibilityAction(named:) — appears in VoiceOver's Actions rotor. |
.access_custom_action(lit!(label), handler) | #[doc(hidden)] grep marker. |
.access_shortcut_literal(s) | Pre-formatted chord string ("Ctrl+S"). |
.access_shortcut_id(id) | Bind to a registered Shortcut id; tracks user rebinds. |
.access_customize(|builder| ...) | Final escape hatch — runs last, full &mut AccessNodeBuilder access. |
Subtree modes
By default the AT tree mirrors the widget tree one-to-one — every widget emits one AT node, descendants are visible to AT. access_subtree controls how the walker handles descendants of the annotated node.
Inherit (default)
Normal walk. Descendants emit their own nodes. Used implicitly everywhere.
Exclude — access_exclude_subtree()
Keep the parent in the AT tree, prune all descendants. Equivalent to Flutter's excludeSemantics: true.
#![allow(unused)] fn main() { HStack::new() .child(IconWidget::from_svg_icon(logo_icon)) .child(TextWidget::new(lit!("Teksilo"))) .child(TextWidget::new(lit!("Pure-Rust GUI"))) .access_label(lit!("Teksilo logo")) .access_exclude_subtree(); }
Without access_exclude_subtree, a screen reader would walk all three children individually: "graphic", "Teksilo", "Pure-Rust GUI". With it, AT sees one node named "Teksilo logo".
Use for purely decorative composites: animated logos, icon clusters, splash content.
Merge — access_merge_subtree()
Keep the parent, but lift descendants' a11y info into the parent before pruning. The whole composite reads as one AT element. Equivalent to Flutter's mergeAllDescendants: true and SwiftUI's .accessibilityElement(children: .combine).
#![allow(unused)] fn main() { Card::new() .child(TextWidget::new(lit!("New message"))) .child(TextWidget::new(lit!("From Alice"))) .child(TextWidget::new(lit!("Hey, are we still on for…"))) .access_merge_subtree(); }
VoiceOver announces the card as one element: "New message · From Alice · Hey, are we still on for…". Tab-stops collapse, so a keyboard user moves card-by-card instead of line-by-line within the card.
Merge accumulator rules:
| Source | Merged into parent | Rule |
|---|---|---|
descendant name | parent name | Append with single space; existing parent name kept first if any. |
descendant value | parent value | First non-empty wins. |
| descendant supported actions | parent action set | Union, deduplicated. |
descendant role | — | Discarded. Parent's role wins. |
descendant numeric_value / range / step | — | Discarded. |
descendant hidden / disabled | — | Discarded. Parent's state governs the merged element. |
descendant description / controls / described_by / labelled_by | — | Currently dropped (no AccessNodeBuilder getters); use access_customize on the parent if you need them. |
Nested subtree modes:
MergecontainingExcludesomewhere — Exclude wins for that subtree (descendants of the excluded node contribute nothing to the merge).MergecontainingMerge— the inner merge runs first into a temp builder, the outer merge then absorbs the inner's already-merged label as one element.Excludecontaining anything — outer Exclude prunes everything; inner modes never run.
What merge can't reach. Widgets that don't expose their internals to the arena (e.g. a hand-rolled paint()-only widget that draws its own icon + label without inserting child WidgetNodes) have no descendants for the merge walker to find. Those widgets' authors should set accessibility() correctly internally; consumers can still use .access_label(...) to override the parent. This is an inherent property of the arena-based tree, not a deferred feature.
Automatic presentational collapse (no opt-in)
Layout primitives (HStack, VStack, ZStack, Center, Grid, Wrap, Padding, Expand, FixedSize, …) emit empty Role::GenericContainer / Role::Unknown AT nodes purely to carry visual structure. VoiceOver announces a bare GenericContainer as "group", so a composing control whose chrome is built from these primitives (a Button, a Checkbox, …) would otherwise read as "Save, button, group". To prevent that, the walker runs a final pass that collapses semantically-empty container nodes and promotes their children to the parent — the same "ignored / presentational node" pruning browsers do. Chains of nested empty containers collapse in one pass, so a Button → Padding → Center → HStack → (hidden label) subtree becomes a single childless Role::Button leaf.
This is automatic and requires no annotation. A node is collapsed only when, after the framework's structural additions (children, bounds, arena-driven disabled) are set aside, it is a bare node of its role — i.e. role is GenericContainer or Unknown and it carries no name, value, description, live region, popup, relationship, identifier, action, or any other author/widget property. The moment a container gains semantic content it is kept:
- An
HStackwith.access_label(lit!("Toolbar"))→Role::GenericContainerwith a name → kept (a named group). - A
Panel(Role::Group) orGroupBox→ non-presentational role → kept. - The Window root, the currently-focused node, and any node referenced by another node's
controls/described_by/labelled_by→ always kept.
So you rarely need Exclude just to silence layout scaffolding — that happens for free. Reach for Exclude / Merge only when you want to prune or combine descendants that do carry semantics (decorative icon clusters with labels, multi-line cards, …).
Action callbacks
AT-invoked actions arrive as WidgetEvent::AccessAction { action, target, target_node, data } on the widget's on_access_action handler. The override system layers an additional callback path on top of any user-installed handler.
Standard actions — access_action
#![allow(unused)] fn main() { use teksilo::core::accesskit::Action; let widget = my_widget .access_action(Action::ShowContextMenu, |ctx| { ctx.send_intent(AppIntent::OpenMenu); }) .access_action(Action::Increment, |ctx| { ctx.send_intent(AppIntent::StepUp); }); }
Both calls advertise the action on the AT node AND register the callback. Multiple access_action calls register separate callbacks for distinct actions; the dispatcher routes each invoked action to the matching callback.
Layering with on_access_action. If the developer also calls .on_access_action(|action, ctx| …) directly, both fire for the same dispatched event — the override-registered callback first, then the user's catch-all. Builder ordering doesn't matter; the dispatcher reads node.access_overrides.actions directly.
Action suppression — access_remove_action
A widget like Button emits Action::Click and Action::Focus unconditionally. To neutralize one (e.g. a Button used purely as a layout shim that shouldn't appear clickable to AT):
#![allow(unused)] fn main() { my_button.access_remove_action(Action::Click); }
Applied after the widget's accessibility() runs but before override-advertised actions are added — so a subsequent .access_action(Action::Click, …) call re-advertises Click with the override's callback.
Custom-named actions — access_custom_action
SwiftUI's accessibilityAction(named:) parity. The label is exposed verbatim by AT software (e.g. VoiceOver's Actions rotor reads "Reply to message").
#![allow(unused)] fn main() { my_message .access_custom_action(tr!(reply_now()), |ctx| { ctx.send_intent(AppIntent::Reply); }) .access_custom_action(tr!(delete()), |ctx| { ctx.send_intent(AppIntent::Delete); }); }
Each entry is assigned a stable i32 id in declaration order. AT triggers a custom action via WidgetEvent::AccessAction { action: Action::CustomAction, data: Some(ActionData::CustomAction(idx)), .. } and the dispatcher routes by idx into access_overrides.custom_actions.
Shortcuts
Two variants for announcing a chord on the AT node — pick by where the binding lives.
.access_shortcut_id("app.save") — the production path
Bind to a Shortcut registered in ShortcutRegistry. The walker resolves the current effective primary keystroke at AT-build time and writes it via KeyStroke::Display ("Ctrl+S"). On a user rebind via ShortcutSettings, the registry's version() signal bumps and sync_accessibility dirties the AT cache automatically — the announcement updates without any explicit signaling from the settings UI.
#![allow(unused)] fn main() { // Somewhere in your root widget's build(), register the Shortcut. ctx.register_shortcut_global( Shortcut::new("app.save").name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ); ctx.register_action(Action::new("app.save").on_invoke(|_, ctx| save(ctx))); // On the Save button, bind the AT announcement to the same id. Button::new(tr!(save())) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)) .access_shortcut_id("app.save"); }
If the registry has no entry for id yet (registration hasn't happened, or the app spelled the id wrong), the announcement is silently omitted — same fallback as MenuItem::for_shortcut(...) and TooltipContent::for_shortcut(...).
.access_shortcut_literal("Ctrl+S") — the explicit-string path
Frozen pre-formatted string. Use for chords NOT going through the Shortcut system: platform-native keys (Tab, Esc), app-internal hotkeys not exposed to user rebinding, or stand-alone demos.
#![allow(unused)] fn main() { my_button.access_shortcut_literal("Ctrl+Shift+P"); }
Does NOT track rebinds — that's the literal variant's tradeoff. For chords routed through Shortcut, prefer access_shortcut_id or the announcement and the actual binding will drift.
See shortcut-intent-action.md for the full Shortcut/Intent/Action pipeline.
Internationalization
User-visible string methods (access_label, access_description, access_hint, access_value, access_custom_action) accept impl Into<Prop<String>>. With the i18n feature enabled, teksilo_i18n::LocalizedString (the type produced by tr!(...)) implements From<LocalizedString> for Prop<String>, so:
#![allow(unused)] fn main() { button .access_label(tr!(save())) // Fluent-translated .access_description(tr!(save_explanation())) .access_custom_action(tr!(publish_now()), |ctx| ctx.send_intent(AppIntent::Publish)); }
flows through unchanged. The user-visible string overrides (access_label, access_description, access_hint, access_value, access_custom_action) take impl Into<Prop<String>> and store a Prop<String>, so tr!(...) stays locale-reactive: the accessibility tree re-walks on a locale change and re-resolves the announced value — no composite rebuild required. (AccessibilityOverrides lives in teksilo-core, which can't name LocalizedString; the bridge is From<LocalizedString> for Prop<String>.)
For explicitly-untranslated AT strings, wrap with lit!(...) — access_label(lit!("Debug")). A bare &str no longer compiles (it doesn't convert to Prop<String>), so the marker is mandatory. The #[doc(hidden)] _literal twins (access_label_literal, etc.) remain only as the literal path reachable from inside teksilo-core itself (where lit! isn't available); application code uses lit!.
State clearing
AccessNodeBuilder::set_hidden() and set_disabled() flip flags on. Some widgets call those setters unconditionally (e.g. Panel calls set_hidden() when a11y_presentational). To un-set widget-emitted state, the override system exposes:
.access_hidden(false)— clears even widget-emittedset_hidden(). Full clear (no framework re-application of hidden)..access_disabled(false)— clears widget-emitted disabled AND arena-driven disabled. The framework's gate ataccessibility_impl.rsrespects the override, so even a.disabled(true)set on the widget's enabled-state can be overridden for AT purposes.
Real use cases:
- App author wraps a
Panelconfigured as decorative but a screen reader user does need to know about it ("Settings panel — collapsed"). - App author force-disables a Button visually pending a save, but wants AT to keep announcing it as enabled because the disabled state is transient.
- Test scaffolding asserts a widget is exposed regardless of internal
a11y_presentationalplumbing.
Synthetic children — access_customize
Widgets like RichTextEditor emit synthetic AT children (paragraphs, text-runs) via push_paragraph_child / push_text_run_child — these live inside the parent's emitted Node, not as separate WidgetNodes in the arena. The override system can't reach them through .access_* modifiers (which target whole widgets, not sub-nodes).
The supported path is access_customize, which runs last in the apply pipeline with full &mut AccessNodeBuilder access:
#![allow(unused)] fn main() { my_widget.access_customize(|builder| { // builder.inner_mut() exposes the underlying accesskit::Node — any // AccessKit field the typed surface doesn't cover is reachable. builder.inner_mut().set_class_name("custom-widget"); builder.inner_mut().set_role_description("special panel"); }); }
Same escape-hatch model AccessKit itself uses internally. The closure runs every time the AT tree is built, so it has consistent re-render semantics with the rest of the override layer.
Apply order — the full pipeline
For each widget the AT walker visits, the sequence is:
node.widget.accessibility(&mut builder)— inner widget emits role, name, value, actions, hidden/disabled, etc.overrides.apply(&mut builder)in this order:- Scalars:
label,description,value,role(replace ifSome). - State flags:
hidden/disabledset or clear based onSome(true)/Some(false). - Identity:
identifier→set_author_id. - Relationships:
controls,described_by,labelled_by(append). - Live region /
aria_current/keyboard_shortcutliteral /has_popup/orientation. - Numeric:
numeric_value,min,max,step. - Action suppression:
removed_actions(callremove_action). - Action advertisement:
actions(calladd_action). - Custom actions: write
Vec<accesskit::CustomAction>with sequential ids. customizeclosure runs last with fullinner_mut()access.
- Scalars:
- Walker post-processing:
- Resolve
access_shortcut_idagainstShortcutRegistry(this needs tree access, so it lives outsideapply()). - Subtree dispatch — for
Merge, walk descendants and absorb into the current builder; forExclude, prune.
- Resolve
- Framework finalization:
- Push child NodeIds (skipped for Exclude / Merge).
- Inject layout bounds.
- Re-apply
set_disabled()from the arena's enabled-flag UNLESS the override hasdisabled: Some(false). - Tooltip →
push_described_by. builder.build(id)→ produces(NodeId, accesskit::Node, synthetic_children).
After every widget has been visited, one tree-wide pass runs over the assembled node list (see Automatic presentational collapse): semantically-empty GenericContainer / Unknown nodes are dropped and their children promoted to the parent, exempting the root, the focused node, and relationship targets. This is the only step that operates on the whole tree rather than per-widget.
Testing patterns
All headless. Assertions go through WidgetTree::accessibility_node(id) (synthetic snapshot) or WidgetTree::sync_accessibility() (full TreeUpdate, useful when checking pruning, custom_actions, controls relationships, etc.).
#![allow(unused)] fn main() { use teksilo::core::accesskit::{Action, Role, HasPopup}; // Scalar override let mut tree = WidgetTree::new(); let id = tree.add(MyWidget.access_label(lit!("Publish"))); tree.layout(SizeProposal::exact(100.0, 40.0)); assert_eq!(tree.accessibility_node(id).name(), Some("Publish")); // Action callback let flag = Signal::new(false); let cb = flag.clone(); let id = tree.add(MyWidget.access_action(Action::ShowContextMenu, move |_| cb.set(true))); tree.layout(...); tree.dispatch_event(WidgetEvent::AccessAction { action: Action::ShowContextMenu, target: Some(id), target_node: widget_id_to_node_id(id), data: None, }); assert!(flag.get()); // Subtree merge let title = tree.add(FillWidget::new().label("Title")); let body = tree.add(FillWidget::new().label("Body")); let card = tree.add(StackWidget::new().add_child(title).add_child(body).access_merge_subtree()); tree.layout(...); let update = tree.sync_accessibility(); assert_eq!(tree.text_content(card), Some("Title Body".to_string())); // Children pruned from output: assert!(find_node(&update, title).is_none()); assert!(find_node(&update, body).is_none()); // Shortcut id auto-tracks rebinds tree.shortcut_registry_mut().register( Shortcut::new("app.save").name("Save").primary(KeyStroke::ctrl(Key::S)).build(), ); let id = tree.add(MyButton.access_shortcut_id("app.save")); let node = find_node(&tree.sync_accessibility(), id).unwrap(); assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S")); tree.shortcut_registry_mut().rebind_primary("app.save", Some(KeyStroke::ctrl(Key::Q))); let node = find_node(&tree.sync_accessibility(), id).unwrap(); assert_eq!(node.keyboard_shortcut(), Some("Ctrl+Q")); }
The 54 in-crate tests at crates/teksilo-core/src/widget_tree/accessibility_impl.rs cover every method in this reference, including all subtree-mode edge cases (nested Exclude-in-Merge, Merge-in-Merge), action-callback layering with on_access_action, custom-action dispatch by index, state-clearing for both hidden and disabled, and the i18n Into<Prop<String>> conversion path (with locale-reactive re-walk covered by an integration test in teksilo-app).
End-to-end demo
examples/widget_catalog — the icon-only buttons in the Controls section show the canonical pattern in both builder and teksu! macro form. To verify a11y output against a real assistive tech stack:
cargo run -p widget-catalog
# In another terminal:
accerciser # Linux AT-SPI inspector
Navigate to the icon-only Save button; confirm the announced name is "Save", the keyboard shortcut field reads "Ctrl+S", and the chevron-down sibling reads "More options" with has_popup = Menu.
Styling never touches accessibility
The Tier-3 styling system (see styling-system.md) lets an app swap a widget's entire chrome — Button::style(MyGlassButton), theme.style_slots.toggle = Some(...), an image-backed theme — but style trait impls do not participate in the accessibility tree. A *Style::make_body return is decoration only; the widget owns its accessibility(builder) output and all .access_* overrides regardless of which style is installed. A glassmorphism button and the default RecipeButtonStyle button announce identically. This keeps AT identity stable across theme swaps and reskins — switching themes at runtime never disturbs a screen-reader's cursor or the AccessKit node ids.
Related references
- styling-system.md — the four-tier styling ladder; style traits decorate, they do not annotate.
- shortcut-intent-action.md — the
Shortcut/Intent/Actionpipeline that.access_shortcut_idbinds to. - events-and-gestures.md —
on_access_actionandon_access_action_requestevent handlers (what.access_actionlayers on top of). - reactive-theme.md — how locale and theme changes propagate via composite rebuilds (the same mechanism keeps
.access_label(tr!(...))translations current). - teksu-macro-reference.md —
teksu!DSL syntax forname: valuebody items, used by the catalog demo'scontrols_teksiblock. - crates/teksilo-core/src/widget_builder.rs —
AccessibilityOverridesstruct,AccessSubtreeModeenum, everyaccess_*method definition. - crates/teksilo-core/src/widget_tree/accessibility_impl.rs — walker integration,
merge_descendants_intohelper, the 36 unit tests. - automation-mcp.md — the in-process AT tree + AT-action channel exposed as a Model Context Protocol server, so an agent can observe and drive the same accessibility surface these overrides shape.
Global text scale (accessibility "grow all text")
A single app-wide setting that magnifies all text for low-vision users — persisted across launches and exposed through a ready-made settings control. It is the framework analogue of the "Text size" slider in an OS accessibility panel.
- One source of truth. The user factor (
1.0= 100 %) multiplies the OS accessibility text-scale preference; the product is the effective text scale. - No rebuild. Changing the scale marks the tree dirty (relayout + repaint); focus, scroll offsets, and interaction state survive.
- Persisted + restored automatically. Any app that installs settings gets startup restore for free.
For app developers
Drop in the control
TextScaleControl is a specialized SpinBox (80 %–200 %, step 10 %). Bind it to
the persisted key and place it in a settings window — it both persists the value
and applies it app-wide on edit. No other wiring.
#![allow(unused)] fn main() { use teksilo::prelude::*; // re-exports TEXT_SCALE_KEY use teksilo::widgets::TextScaleControl; // inside build(): let scale = ctx.settings().signal_for(&TEXT_SCALE_KEY); ctx.add(TextScaleControl::new(scale).label(tr!(text_size()))); }
Requirements: the app must install settings (.application(...) /
.app_paths(...) + .settings(SettingsBundle::new())). With settings present,
teksilo-app reads accessibility.text_scale at startup and seeds every
window; apps without settings simply stay at 1.0.
Apply / read it programmatically
- From any handler:
ctx.set_text_scale(factor)applies app-wide (every window) after the handler returns — same model asctx.set_theme/ctx.set_locale. Persist alongside it viactx.settings().signal_for(&TEXT_SCALE_KEY).set(...)(theTextScaleControldoes both for you). - Read the current factor at build via
ctx.text_scale(), or bind the reactivectx.text_scale_signal()(Signal<f32>) for values that must update without a rebuild.
How it works
Font sizes flow through Theme.typography (TypographyTokens), resolved on
every layout/paint pass. The tree keeps a cached effective_theme =
the active theme with its typography scaled by user_scale × OS_factor, and
the layout + paint walkers read it. So every widget that sizes text from
ctx.theme.typography scales for free — TextWidget, Button, Badge,
ListItem, MenuItem, TableView cells, and so on — with zero per-widget code.
The same combined factor is published two more ways for surfaces that size text from a source other than typography:
LayoutContext::text_scale/PaintContext::text_scale— thef32factor, read during layout/paint.WidgetTree::text_scale_signal()(andBuildContext::text_scale_signal()) — a reactiveSignal<f32>for build-time binders.
effective_text_scale and the signal are written in one place
(WidgetTree::recompute_effective_theme), so theme, OS-pref, and user-scale
changes all stay consistent.
Editable text and the rich-text engine
Editable widgets (TextInput, SpinBox, DateEdit, hex color input) and
RichTextEditor shape text through a per-widget RichTextEngine, whose size
does not come from the theme. They scale via a true per-engine logical
font scale in text-typeset: RichTextEngine::set_font_scale(f) multiplies
the resolved font size before shaping, so advances, line heights, content
height, and wrapping all grow correctly. Driven automatically from
ctx.text_scale at layout/paint.
This is distinct from two pre-existing factors — see the comparison below.
font_scale vs scale_factor
scale_factor | font_scale | |
|---|---|---|
| Question | physical px per logical px (HiDPI) | how big, logically (a11y + per-editor size) |
| Acts at | rasterization | shaping (size before layout) |
| Changes logical metrics? | No (cancels out) | Yes (grows + reflows) |
| Glyph sharpness | densifies | densifies (larger ppem) |
| Scope | global service | per-engine |
They are orthogonal and never double-count: physical shaping size =
base_pt × font_scale × scale_factor; logical metrics = base_pt × font_scale.
The standalone single-line shapers used by TextWidget/Canvas pass
font_scale = 1.0 (their size is already theme-scaled), which is what keeps
label text from being scaled twice.
Per-editor text size
RichTextEditor / CodeEditor / PlainTextEditor::font_size_scale(f) (and
set_font_size_scale on the rich-text handle) multiplies into the engine font
scale alongside a11y:
engine.font_scale = (follow_text_scale ? ctx.text_scale : 1.0) × font_size_scale
Use that for a "Text size 125%" preference. Editors no longer expose page zoom.
Opt-in / opt-out surfaces
A few surfaces don't follow the scale automatically, by design:
| Surface | Default | Knob |
|---|---|---|
IconWidget | off (fixed-footprint glyphs) | .follow_text_scale(true) |
Severity badges (Banner/Toast/MessageBox/NotificationLog) | on | (built in — enabled on the badge's icons) |
RichTextEditor | on | .follow_text_scale(false) to opt out (e.g. a WYSIWYG editor whose font sizes are document content) |
teksilo-scene TextItem | off (the scene has its own pan/zoom) | .follow_text_scale(true) |
Calendar | on (rebuilds with scaled cell/header constants) | — |
IconWidget::follow_text_scale(true) multiplies the reported size by
ctx.text_scale; paint fills the enlarged bounds automatically.
Reference
- Persisted key:
teksilo_settings::TEXT_SCALE_KEY("accessibility.text_scale", default1.0). - Widget:
TextScaleControl. - Core:
WidgetTree::{set_user_text_scale, effective_text_scale, text_scale_signal}(widget_tree.rs);text_scaleonLayoutContext/PaintContext;EventContext::set_text_scale. - App fan-out + startup seed:
WindowManager::{set_text_scale, set_initial_text_scale, drain_pending_text_scale_requests}(window_manager.rs). - Engine font scale:
RichTextEngine::set_font_scale(teksilo-text) →DocumentFlow::set_font_scale(text-typeset). - Demo:
cargo run -p widget-catalog— theTextScaleControlin the title bar next to the language buttons grows the whole catalog live.
Window-Active Appearance
Serious desktop apps change how a window looks when it loses OS focus: the text caret stops blinking and disappears, text and list selections desaturate to a muted grey, and accent-coloured chrome dims. Teksilo does this automatically and gives apps an opt-in hook for custom content.
This mirrors the modern, accepted pattern across toolkits — a reactive ambient
flag the view reads declaratively, plus theme-driven inactive colours:
SwiftUI @Environment(\.appearsActive) (macOS 15), Jetpack Compose
LocalWindowInfo.isWindowFocused, GTK4 :backdrop, Qt QPalette::Inactive,
WPF InactiveSelectionHighlightBrush.
What "active" means
A window is active when it is focused AND not occluded — it holds OS
keyboard focus and isn't fully hidden behind another window. This is computed
per window by teksilo-app from winit's Focused / Occluded events and
published as a reactive signal on each window's widget tree.
window_active is distinct from view focus. A ListView can hold keyboard
focus within its window while that window is inactive. The vivid selection
shows only when both are true (view focused and window active); otherwise
the selection is muted. The same muted colour serves both "focus is elsewhere in
this window" and "this window is inactive" — matching macOS's single
"unemphasized" selection colour and GTK's :backdrop.
State is per window: deactivating one window never affects another (there is no app-wide fan-out, unlike theme or text-scale).
Reading it
| Surface | API |
|---|---|
In build(), reactive | ctx.window_active_signal() -> Signal<bool> (bind at RepaintOnly) |
In build(), one-shot | ctx.window_active() -> bool |
In paint() | ctx.window_active: bool (on PaintContext) |
| In an event handler | ctx.window_active() -> bool (on EventContext) |
| On the tree | WidgetTree::window_active_signal() / is_window_active() |
It starts true — a window must not be born inactive before its first focus
event arrives. A flip triggers a repaint only (never a relayout): geometry is
unchanged, so the caret keeps its space and nothing reflows.
Automatic behaviour (no opt-in)
These are correctness, not features, so they are on by default:
- Accent desaturation (theme-side, covers every control). When the window is
inactive, the paint walker swaps in a theme projection
(
ColorTokens::for_inactive_window) whose accent family and focus indicators are desaturated toward graphite — the macOS / QtQPalette::Inactivemodel. Because every themed control resolves its accent from the liveColorTokensat paint time, this single swap greys out all of them with no per-widget code: the default (Filled)Button,Toggle's on-track, checkedCheckbox/RadioButton, the selectedTabBartab andSegmentedControlsegment,Sliderfills,ProgressBar,Badge, links-as-accent, and focus rings (BorderRole::Focused/focus_ring). It applies to any preset that populates these tokens — IntUI, Material 3 (whereaccent= M3 primary), and future presets — for free. Deliberately untouched: selection, status, and text tokens (see below). - Caret hiding. The text caret hides in an inactive window for every caret
policy, in both text stacks (
RichTextEditorand everyTextInput/PasswordField/SpinBox/SearchFieldbuilt onTextInputField). It returns immediately when the window reactivates and the field still holds focus. There is no opt-out — every native toolkit hides the caret here. - Selection desaturation. A selected row/cell/run shows the vivid selection only while its view is focused and the window is active; otherwise it falls back to the muted inactive colour. This is handled per widget (not theme-side) because it depends on view focus, which a theme projection can't express. Covered surfaces:
| Widget | Active role / token | Inactive role / token |
|---|---|---|
StandardListItem / StandardTreeItem | SurfaceRole::Selected | SurfaceRole::SelectedInactive |
TableView / TreeTableView | SurfaceRole::Selected | SurfaceRole::SelectedInactive |
RichTextEditor | editor_selection_bg | selection_bg_inactive |
TextInput family | selection_bg_active | selection_bg_inactive |
Keyboard focus rings grey out (not hide) in an inactive window, uniformly, via
the theme-side accent projection above — no per-widget check. MenuList is
excluded by design — an open menu is always active.
Custom selection colours stay fixed
If an app sets an explicit selection colour — e.g.
RichTextEditor::editor(doc).selection_color(my_blue) — that colour is used
as-is and is not auto-desaturated when the window goes inactive. This
matches macOS, where an app-set selection colour opts out of system management.
Only theme-driven (default) selections desaturate.
.dim_when_inactive(..) — opt-in for custom content
The automatic layers cover stock widgets. For custom content an app wants to fade back in a background window (a colourful side panel, a bespoke accent surface), wrap it:
#![allow(unused)] fn main() { use teksilo::prelude::*; ctx.add(my_panel.dim_when_inactive(0.4)); // 40 % opacity when inactive ctx.add(my_panel.dim_when_inactive_default()); // default 70 % }
.dim_when_inactive(factor) (on the WidgetBuilder trait) wraps the subtree in
DimWhenInactive, which drives a node-level opacity scope from
window_active_signal. It is layout- and a11y-transparent, and the opacity
snaps (no tween) — correct under prefers-reduced-motion, since window
activation is an OS state change, not a user-initiated motion.
Keeping a widget vivid when inactive
There is no need to opt out of the automatic behaviour for normal apps. If a
widget genuinely must stay vivid regardless of window focus (a live status
indicator, a kiosk display), paint it directly from theme tokens and simply
don't consult ctx.window_active — or, app-wide, never call
set_window_active(false).
Accessibility
Caret hiding and selection desaturation are paint-only. They do not change the AccessKit tree, the announced selection state, or any node value — a screen reader still reports the selection and caret position normally. The visual change is purely cosmetic.
Testing
WidgetTree::set_window_active(bool) drives the state in a headless test:
#![allow(unused)] fn main() { let mut tree = WidgetTree::new().with_theme(intui::light()); let id = tree.add(some_editor); tree.layout(SizeProposal::exact(400.0, 300.0)); // ...focus the widget... tree.set_window_active(false); let _ = tree.render(); // assert the caret is gone / the selection colour swapped }
A fresh tree starts active (is_window_active() == true). See the tests in
crates/teksilo-core/src/dim_when_inactive.rs,
crates/teksilo-widgets/src/rich_text/tests.rs, and the
window_active-named tests under teksilo-widgets.
Demo
cargo run -p multi_window — two windows, each with a status label, a
TextInput, and a .dim_when_inactive panel. Click between them to watch the
inactive window hide its caret, mute its selection, dim its panel, and flip its
status label.
Implementation notes
- The reactive primitive lives on
WidgetTree(window_active_signal), written byset_window_activeand threaded ontoPaintContext/BuildContext/EventContextexactly like the global text-scale value. - A focus flip calls
WidgetArena::mark_all_needs_paint_only()— a repaint of every active node, with no relayout and no cache clearing. Window-focus changes are rare (user-driven), so this is cheaper thanset_theme'smark_all_dirty(which also relayouts) and means any paint-timectx.window_activereader is correct without per-widget binding ceremony. - The frame-loop
tick()of each text stack has no context, sobuild()registers an effect onwindow_active_signalthat mirrors the value onto the editor state and, on deactivation, hides the caret synchronously (the frame loop may not tick while the window is parked).
Internationalization Reference
Teksilo's i18n stack (teksilo-i18n + teksilo-i18n-macros) is reactive
end-to-end and compile-time-validated. Translation keys live in
.ftl files (Mozilla Fluent syntax); the proc macros parse those files
at compile time and reject typos at build time, not at runtime; the
runtime resolution is mediated by a thread-local
I18nManager that owns one
FluentBundle per locale and exposes signals for the active locale,
the active layout direction, and a translation version counter that
fires on locale changes and on .ftl hot reloads.
Mental model in one line:
I18nConfig → I18nManager → bundles + signals → tr! / NumberFormatter / ... → reactive widgets
End-to-end example:
examples/internationalization.
Canonical app shape
use teksilo::app::TeksiloAppBuilder; use teksilo::i18n::I18nConfig; use teksilo::prelude::*; fn main() { let config = I18nConfig::new() .source_locale("en-US".parse().unwrap()) .supported_locales([ "en-US".parse().unwrap(), "fr-FR".parse().unwrap(), "ar-SA".parse().unwrap(), ]) .compile_in(&[ ("en-US", &[include_str!("../locales/en-US.ftl")]), ("fr-FR", &[include_str!("../locales/fr-FR.ftl")]), ("ar-SA", &[include_str!("../locales/ar-SA.ftl")]), ]) .auto_detect_os_locale(true) .fallback_locale("en-US".parse().unwrap()) .framework_locales(teksilo::widgets::framework_locales()); TeksiloAppBuilder::new() .theme(intui::light()) .i18n(config) .initial_window( WindowConfig::new() .title("My App") .size(800, 600) .root(|tree, _state| tree.add(Root::new())), ) .run(); }
Notes:
TeksiloAppBuilder::i18n(config)installs the resultingI18nManageron the thread-local; once installed, everytr!/tr_signal!/current_locale()/NumberFormattercall in the same thread routes through it.compile_inregisters.ftlstrings (typically pulled in viainclude_str!) so they're embedded in the binary. Usecompile_in_locales!for the multi-locale × multi-file case.- The framework's own widget strings come from
teksilo::widgets::framework_locales()— this is what givesMenuBar,Dialog, accessibility labels, keystroke names, etc. their localized text. Apps almost always want this. teksilo::prelude::*re-exportstr/tr_widget/LocalizedString/I18nConfig/LanguageIdentifier(gated on thei18ncargo feature). The reactive macros (tr_signal,tr_signal_widget) and the formatter types (NumberFormatter,TeksiloDateTimeFormatter,TeksiloDateTime,NumberStyle,DateStyle,TimeStyle) are reachable through theteksilo::i18n::*path — they're not in the prelude (yet), so import them explicitly.
I18nConfig
Source. Builder for the manager's initial state. Methods are chainable and order-independent.
| Method | Purpose |
|---|---|
new() | Defaults: source en-US, auto_detect_os_locale = true, fallback_locale = en-US, no resources. |
.source_locale(LanguageIdentifier) | The locale the source .ftl is written in (used as the bundle of last resort). |
.supported_locales(impl IntoIterator<Item = LanguageIdentifier>) | What the app advertises as available; powers UI locale pickers and auto-detect filtering. |
.fallback_locale(LanguageIdentifier) | Locale to fall back to if neither user choice nor OS detection lands on a supported locale. |
.user_locale(Option<LanguageIdentifier>) | Explicit user override (e.g. read from SettingsStore). Beats both auto-detect and fallback. |
.auto_detect_os_locale(bool) | Toggle the sys-locale step in I18nManager::resolve_initial_locale. |
.compile_in(&[(&str, &[&'static str])]) | Register compiled-in resources: per-locale arrays of .ftl source strings (typically include_str! outputs). Accumulates — see Composing catalogues. |
.framework_locales(&'static [...]) | Register framework strings (teksilo::widgets::framework_locales()). |
.override_widget_strings(&'static [...]) | App-supplied overrides for framework strings — pass after framework_locales to win in the tr_widget! lookup chain. |
.runtime_override(LanguageIdentifier, PathBuf) | Watch a .ftl file or a directory of them on disk and rebuild that locale's bundle on every save (translator workflow; not for production use). Pass the directory whenever the locale ships more than one file. See Hot reload. |
.test_only(source: &str, msgs: &[(&str, &str)]) | Construct a config for headless tests with inline messages, skipping .ftl files entirely. |
.with_locale(loc: &str, msgs: &[(&str, &str)]) | Add another locale's inline messages to a test_only config. |
Test config example:
#![allow(unused)] fn main() { use teksilo_i18n::{I18nConfig, I18nManager}; let cfg = I18nConfig::test_only("en-US", &[ ("greeting", "Hello, World!"), ("welcome", "Hello, { $name }!"), ]) .with_locale("fr-FR", &[ ("greeting", "Bonjour, le monde !"), ("welcome", "Bonjour, { $name } !"), ]); let mgr = I18nManager::from_config(&cfg); teksilo_i18n::thread_local::install(mgr.clone()); }
Composing catalogues from several crates
compile_in accumulates across calls, and repeated registrations of the
same locale are merged into that locale's single bundle. An application can
therefore compose its own catalogue with catalogues shipped by extensions,
plugins or sibling crates, none of which has to know the others exist:
#![allow(unused)] fn main() { let mut cfg = I18nConfig::new() .source_locale("en-US".parse().unwrap()) .supported_locales(["en-US".parse().unwrap(), "fr-FR".parse().unwrap()]) .compile_in(compile_in_locales!( base = "../locales/", locales = ["en-US", "fr-FR"], files = ["main.ftl"], )); for ext in &extensions { cfg = cfg.compile_in(ext.locales()); // merged into the same bundles } }
Two rules follow from Fluent's own semantics, and both are pinned by
crates/teksilo-i18n/tests/compile_in_additive.rs:
- First registration of a key wins.
FluentBundle::add_resourcekeeps the existing definition and reports the duplicate on stderr; it does not override. Register the application's catalogue first, and have contributors namespace their keys (myext-panel-title) rather than rely on shadowing. - A locale only a contributor supplies still gets its own bundle. Adding
fr-FRfrom an extension does not fold those strings into the application'sen-US.
Compile-time key validation is per calling crate: tr! resolves against the
.ftl files of the crate it is written in (see TEKSILO_I18N_SOURCE_DIR), so
each contributor validates its own keys against its own catalogue.
Locale resolution precedence
I18nManager::resolve_initial_locale
picks the active locale at startup using this precedence (first match wins):
-
config.user_locale— explicit app choice (e.g. read fromSettingsStore). Honored only if it's insupported_locales. -
OS locale, if
auto_detect_os_localeis on (the default). The writer's whole preferred-language chain is read from thesys-localecrate and walked in order; the first entry any supported locale can serve wins. Each entry is offered three tiers, narrowest first, before the next entry is tried at all:- exact — every subtag agrees;
- same language and script, any region — an OS-reported
en-GBmatches a supporteden-US, andfr,fr-CA,fr-BEorfr-CHall match a supportedfr-FR; - same language, script named on only one side —
zh-Hansmatches a supportedzh-Hans-CN. A script named on both sides and disagreeing is never bridged, sozh-Hanscan not land onzh-Hant-TW.
Per-entry rather than exact-across-the-whole-chain-first on purpose: a chain of
["fr-CA", "en-US"]means "French, and English if you must", so the near miss on the first entry must beat the exact hit on the second. An entry that does not parse as a language tag is stepped over. -
config.fallback_locale— returned unconditionally if neither of the above produced a hit. Defaults toen-USif not set; not validated againstsupported_locales, so make sure the fallback actually has a bundle.
The picked locale is later applied via manager.set_locale(...) —
typically called by TeksiloAppBuilder at startup, or by app code in
response to a settings change. set_locale itself does validate
against supported_locales and silently no-ops on an unsupported
target, so an out-of-tree fallback (e.g. a typo) silently degrades
to whatever the active locale already was.
tr! / tr_widget!
Compile-time-validating proc macros. Source:
teksilo-i18n-macros.
#![allow(unused)] fn main() { use teksilo::i18n::tr; let label = tr!(greeting()); // no args let hello = tr!(welcome(name = user_name)); // one arg let nested = tr!(auth::login_title()); // namespaced key }
Compile-time checks:
- The macro looks for the source
.ftlin this order: directory atTEKSILO_I18N_SOURCE_DIR(env var, dir of.ftlfiles) → single file atTEKSILO_I18N_SOURCE_PATH(env var, one.ftlfile) → directory at$CARGO_MANIFEST_DIR/locales/en-US/(auto-detected if it exists) → single file at$CARGO_MANIFEST_DIR/locales/en-US.ftl(the fallback). Both layouts are first-class — flat-file projects and multi-fileauth.ftl/editor.ftl/... projects work without configuration. - It checks the message key exists, and that every named arg matches
a
$variabledeclared in the message. Missing key, missing arg, unknown arg — all producecompile_error!at the call site, with Levenshtein-based "did you mean" hints for typos. - Every parsed
.ftlfile is registered as a build dependency via an emittedinclude_bytes!, so cargo rebuilds the calling crate when the source file changes.
Key path → Fluent key mapping:
tr!(count_items())→ fluent keycount-items(single underscore inside a segment becomes a dash).tr!(auth::login_title())→ fluent keyauth__login-title(the::separator becomes__; pick keys accordingly).__inside a segment is reserved and rejected — use::for nesting.
Runtime behaviour:
tr!(...) expands to a LocalizedString:
#![allow(unused)] fn main() { pub struct LocalizedString { /* resolver: Rc<dyn Fn() -> String> */ } }
The resolver closure captures the args by clone and, when invoked,
calls resolve_message(key, args) against the active manager's app
bundle. Two ways to consume it:
ls.resolve_now() -> String— eagerly resolve once.ls.to_signal() -> Signal<String>— reactive: re-resolves on every bump of the manager'sversion_signal, which fires on locale changes and.ftlhot reloads.
From impls let tr!(...) slot into common widget builder shapes:
#![allow(unused)] fn main() { impl From<String> for LocalizedString // literal, non-translated impl From<&str> for LocalizedString // literal impl From<LocalizedString> for Prop<String> // Bound if manager installed, Static otherwise impl From<LocalizedString> for String // eager resolve_now() }
tr_widget!(...) has the same surface but routes through the
manager's framework-strings lookup chain (override active → framework
active → override source → framework source → key placeholder). Used
inside teksilo-widgets and any app crate that ships overrides for
framework strings.
Fallback behaviour: when no manager is installed (e.g. low-level
widget tests) or the active bundle lacks the key, the resolver
returns the source-language reconstruction of the message — the
macro pre-parses the source .ftl for simple-pattern messages
(literal text + { $var } substitutions) and emits an inline
fallback. Selectors, plural rules, function calls, and message
references bail out and return the key as a placeholder.
tr_signal! / tr_signal_widget!
Reactive variant for Signal<T>-inside-translated-sentence —
when a reactive numeric, string, or temporal value belongs in the
middle of a localized message and the whole sentence must re-render
when the value, the locale, or a .ftl hot reload fires.
#![allow(unused)] fn main() { let count: Signal<i64> = ctx.signal(0); let price: Signal<f64> = ctx.signal(0.0); let label: Signal<String> = tr_signal!( cart_summary(count = count, price = price) ); // label re-renders on: // count.set(...) — any arg signal change // price.set(...) — any arg signal change // manager.set_locale(…) — locale change // reload_from_path(…) — hot reload (version bump) }
Argument shape: every named arg must be a Signal<T> where
T: Clone + 'static and FluentValue: From<T> (which covers the
standard numeric types, String, and TeksiloDateTime). For static
values, plain tr! is the right tool — tr_signal! is purely for
reactive interpolation.
The macro auto-clones the signal expressions, so the caller's handle survives:
#![allow(unused)] fn main() { let count = ctx.signal(0_i64); let price = ctx.signal(0.0_f64); let label = tr_signal!(cart_summary(count = count, price = price)); count.set(5); // fine — count was cloned, not moved }
Why this exists: the two non-macro alternatives both fail. Hand-
rolling signal.zip(locale).map(|(v, _)| tr!(...).resolve_now())
silently drops .ftl hot-reload re-renders (bypasses the version
signal). HStack-juxtaposing translated-prefix + numeric-widget
hardcodes prefix-then-value word order, breaking i18n correctness in
languages where the variable goes elsewhere in the sentence
(Japanese, Arabic, Hindi). tr_signal! is the correct path for
both.
Compile-time validation: identical to tr! — same KeyMap
parser, same key-existence + arg-name checks. The only difference is
the lowering: tr_signal! emits a Signal<String> subscribed to
each arg signal plus the version signal via Signal::observe +
attach_keepalive, instead of a LocalizedString resolver closure.
Observer cleanup on drop is verified in
tests/format_integration.rs
— dropping the result signal returns the source signals' observer
counts to baseline.
tr_signal_widget! mirrors tr_widget!: same surface, routes
through the framework-strings lookup chain.
Locale-aware formatting
Numbers, dates, and times that change with the user's locale flow
through one ICU4X-backed layer. Two consumer paths share the same
cache, so a UI mixing translated and untranslated displays stays
internally consistent on , vs ., grouping, currency suffixes, etc.
Bundle-side: NUMBER() / DATETIME() inside .ftl messages
I18nManager auto-installs a set_formatter callback on every
bundle and registers a DATETIME Fluent function. So .ftl messages
can use { NUMBER($v) } and { DATETIME($ts, dateStyle: "long") }
and they render correctly across locales — no app-side wiring.
# locales/en-US.ftl
price-display = The price is { NUMBER($v) }
last-saved = Last saved on { DATETIME($ts, dateStyle: "long") }
#![allow(unused)] fn main() { use teksilo::i18n::{tr, TeksiloDateTime}; tr!(price_display(v = 1234.56)) // "The price is 1,234.56" tr!(last_saved(ts = TeksiloDateTime::from(some_jiff_zoned))) }
For numeric args pass any f64/i32/u64/etc. (already covered by
FluentValue: From<T>). For date/time args, wrap in
TeksiloDateTime.
Signal-side: NumberFormatter / TeksiloDateTimeFormatter
For displays that don't go through translated messages — SpinBox values, TableView cells, status bars, numeric inputs:
#![allow(unused)] fn main() { use teksilo::i18n::{ DateStyle, TeksiloDateTimeFormatter, NumberFormatter, NumberStyle, }; // Plain decimal with locale-aware grouping. let display: Signal<String> = NumberFormatter::new() .fraction_digits(2, 2) .format(price_signal); // Signal<f64> → Signal<String> // Currency: the locale's own symbol, positioned the way the locale // positions it — "1 234,50 €" in fr-FR, "€1,234.50" in en-US. let cost = NumberFormatter::new() .currency("EUR") // implies NumberStyle::Currency .format(amount_signal); // Percent: value × 100, then the locale's percent form — "12,5 %" // in fr-FR, "%12,5" in tr-TR. let progress = NumberFormatter::new() .percent() // implies NumberStyle::Percent .format(ratio_signal); // Date/time. let when = TeksiloDateTimeFormatter::new() .date_style(DateStyle::Long) .format(timestamp_signal); // Signal<jiff::civil::DateTime> → Signal<String> }
The format(...) method accepts impl Into<Prop<f64>> (resp.
Prop<jiff::civil::DateTime>), so plain values work too:
#![allow(unused)] fn main() { let s = NumberFormatter::new().format(987_654.321_f64).get(); // "987,654.321" in en-US, "987 654,321" in fr-FR }
Result signals re-render on:
- The value signal firing (when bound to one).
manager.set_locale(...).manager.reload_from_path(...)— version-signal bumps drive recomputation.
If no I18nManager is installed on the thread (low-level widget
tests, isolated benchmarks), the formatter falls back to und
locale — the result is non-empty but locale-naive.
NumberFormatter builder methods:
| Method | Default | Purpose |
|---|---|---|
.style(NumberStyle) | Decimal | Set the style explicitly. |
.currency(impl Into<String>) | — | Set ISO-4217 code; implies Currency style. Renders the locale's symbol. |
.percent() | — | Implies Percent style; multiplies value by 100. |
.fraction_digits(min: u8, max: u8) | none | Min zero-pads; max rounds half-to-even. |
.use_grouping(bool) | true | Toggle locale grouping separators. |
TeksiloDateTimeFormatter builder methods:
| Method | Default | Purpose |
|---|---|---|
.date_style(DateStyle) | Medium if neither set | Long/Medium/Short. |
.time_style(TimeStyle) | none | Long/Medium/Short. |
.format(...) takes Prop<jiff::civil::DateTime>;
.format_zoned(...) takes Prop<jiff::Zoned> (rendered as
wall-clock value at the zoned datetime's zone).
TeksiloDateTime
The wrapper that bridges jiff types into Fluent's FluentValue
type system as a FluentValue::Custom. Used as a tr! argument
for the bundle-side DATETIME() function:
#![allow(unused)] fn main() { let now = jiff::Zoned::now(); let civil = jiff::civil::date(2026, 5, 4).at(14, 35, 0, 0); TeksiloDateTime::from(now) // From<jiff::Zoned> TeksiloDateTime::from(civil) // From<jiff::civil::DateTime> TeksiloDateTime::from_zoned(now) // explicit constructor TeksiloDateTime::from_civil(civil) // explicit constructor }
TeksiloDateTime implements From<...> for FluentValue<'static>, so
tr! accepts it as an argument value directly.
ICU coverage
Backed by icu_decimal 2.x, icu_datetime 2.x and
icu_experimental 0.6, all with the compiled_data feature (CLDR
baked into the binary; no runtime data provisioning).
- Decimal — full locale-aware grouping, digit shaping, signs.
- Percent — value × 100, then ICU's
PercentFormatter. The sign is the locale's own and sits where the locale puts it:"12.5%"in en-US,"12,5 %"(no-break space) in fr-FR,"%12,5"in tr-TR. - Currency — ICU's
CurrencyFormatterwith the short symbol:"$1,234.50"in en-US,"1 234,50 $US"in fr-FR. Two consequences worth knowing:- ICU applies the currency's CLDR fraction precision, which
overrides
.fraction_digits(...).JPYrenders"¥1,235", not"¥1,234.50". This is ECMA-402 behaviour. .use_grouping(false)does not reach currency: theCurrencyFormatterconstructors build their ownDecimalFormatterand expose no seam to pass ours. ACurrencystyle with no code, or a code ICU rejects, falls back to plain decimal rather than rendering a wrong currency.
- ICU applies the currency's CLDR fraction precision, which
overrides
- DateTime — full ICU support via
CompositeDateTimeFieldSet.
Reading numbers back: NumberSymbols
NumberFormatter is display-only and f64-based. For an editable
numeric surface you need the other direction too, and you need it to
agree with the display exactly — so NumberSymbols recovers a
locale's separators, signs and digits from ICU's own formatted
output rather than from a provider struct. It formats probe values
through the same DecimalFormatter the display path uses and reads
the separators out of the [parts] annotations, so the symbols are
the ones the formatter actually emits, by construction.
#![allow(unused)] fn main() { use teksilo::i18n::{NumberSymbols, delocalize_number}; let sym = NumberSymbols::current(); // or ::for_locale(&lang) sym.decimal_separator(); // "," in fr-FR sym.group_separator(); // "\u{202f}" in fr-FR sym.minus_sign(); // "−" (U+2212) in sv-SE sym.zero_digit(); // '٠' in ar-EG // Display → C locale, ready for `str::parse`. delocalize_number("1 234,56"); // Some("1234.56") in fr-FR // C locale → display. Takes and returns a *string*, so an i64 past // 2^53 keeps full precision — an f64 round-trip would not. sym.localize("9007199254740993", true); // "9,007,199,254,740,993" }
Parsing is lenient, matching ICU's default: whitespace is ignored
anywhere, and ASCII digits are accepted even where the numbering
system is not latn (people type on the keyboard they have). One
ambiguity is resolved in CLDR's favour — in a locale whose group
separator is . (de-DE), "1.5" reads as 15, because that is what
a . means when de-DE writes a number.
SpinBox is built on this; see the "Locale" section of
cargo run -p spin-box.
Binary-size cost from the ICU additions is ~2 MB stripped (release
build, with the CLDR subset baked in). No formatters cargo feature
flag — these types are always on.
Cache lifetime
ICU formatter instances are cached per (LanguageIdentifier, options). The bundle-side path uses IntlLangMemoizer::with_try_get
(per-bundle cache, lives as long as the bundle). The Signal-side
path uses a thread-local RefCell<HashMap<...>> (lives for the
thread's lifetime). First call per (lang, opts) constructs an ICU
formatter via the Memoizable trait; subsequent calls reuse the
cached instance. Construction is the expensive step (CLDR data
lookup); the cached instances are immutable and format() is cheap.
Direction (LTR / RTL)
I18nManager::direction_signal() exposes a Signal<LayoutDirection>
(LeftToRight | RightToLeft) computed via
rtl_from_locale when the
active locale changes. The window manager applies it to the widget
tree before the locale-driven composite rebuild, so HStack lays
out children Leading→Trailing → Trailing→Leading without any
widget-side wiring.
The internationalization example flips between en-US / fr-FR /
ar-SA to demonstrate this.
Hot reload
For translator workflows where editing a .ftl should immediately
re-render the running app:
#![allow(unused)] fn main() { let config = I18nConfig::new() .compile_in(&[("en-US", &[include_str!("../locales/en-US.ftl")])]) .runtime_override("fr-FR".parse().unwrap(), "/tmp/translation.ftl".into()); }
runtime_override(locale, path) registers a path. At startup the
manager wires an FtlFileWatcher
on it and calls manager.reload_from_path(locale, path) on every
change. That rebuilds the bundle and bumps version_signal, which
triggers every tr!-bound and Signal-side formatter to re-resolve.
Hot reload is for development only. Production apps ship
compile_in-bundled .ftl and don't expose runtime overrides.
The CLI flag pattern from
examples/internationalization:
cargo run -p internationalization -- \
--translation-dev fr-FR=/tmp/fr.ftl \
--translation-dev ar-SA=/tmp/ar.ftl
Point it at the directory, not one file
path is either a single .ftl file or a directory of them, and
for a multi-file catalogue only the directory form is correct.
A locale's bundle is the merge of every resource registered for it
(see Composing catalogues),
so an app shipping main.ftl + tooltips.ftl + tags.ftl has one
fr-FR bundle built from three files. reload_from_path replaces
that bundle. Point the override at one of the three and saving it drops
every key the other two defined. Those keys fall back to the source
locale silently, so the translator watches most of the app revert to
English with no error printed anywhere:
runtime_override("fr-FR", "locales/fr-FR/main.ftl") # ✗ one file of three
save main.ftl → fr-FR bundle = main.ftl alone
every tooltips.ftl / tags.ftl key now resolves via en-US
runtime_override("fr-FR", "locales/fr-FR") # ✓ the directory
save any of them → fr-FR bundle = all three, merged, as the binary ships it
Directory mode reads the .ftl files directly inside path. There
is no recursion, so pointing at locales/ rather than locales/fr-FR/
finds nothing and errors instead of quietly loading a sibling locale's
strings into the wrong bundle. Files are merged sorted by file name,
because Fluent keeps the first definition of a key and read_dir order
is unspecified: without the sort, a key defined in two files could
resolve differently between two saves of the same unchanged directory.
Failure is atomic. Every file is parsed before the bundle is assembled,
so one malformed file mid-edit leaves the previous bundle in place and
returns ReloadError rather
than installing a half-built catalogue that matches no build. A
directory holding no .ftl at all is ReloadError::NoFtlFiles. Cross-
file key collisions are logged and tolerated, exactly as
build_bundle_from_resources tolerates them at startup. Hot reload's job
is to reproduce what the shipped binary does, and refusing where the
compiled build loads happily would strand the translator on a stale
bundle.
The watcher follows the same split: a file target watches its parent
directory (catching the write-temp-then-rename pattern editors use,
which invalidates an inode watch on the file itself), while a
directory target is watched as-is. Climbing to its parent would
watch locales/ and wake every locale on any one locale's save. On a
directory, only .ftl writes wake the sink, so the swap files and
.ftl~ backups editors scatter alongside don't each cost a re-parse;
the sink is then handed the directory, not the file that changed,
which is what makes the rebuild read all of them.
Registering the same locale twice does not merge the two paths. Each change rebuilds that locale from whichever path fired, so pass the directory that holds both instead.
Switching locale at runtime
Any handler with an EventContext can flip the active locale:
#![allow(unused)] fn main() { Button::new(tr!(lang_french())) .on_activate_fn(|ctx| ctx.set_locale("fr-FR")) }
EventContext::set_locale(impl Into<String>) defers a tree-level
locale request; the framework parses the tag, calls
manager.set_locale(lid), applies the resulting layout direction
change (if any), and rebuilds composite widgets so tr! lookups
captured at build time get re-evaluated.
Reactive consumers (anything bound via tr!.to_signal(),
tr_signal!, NumberFormatter::format, TeksiloDateTimeFormatter::format)
update without a rebuild — their underlying version_signal /
locale_signal observers fire immediately. The composite rebuild
exists so tr!(...) calls embedded inside build() pick up new
text on the next layout pass.
Thread-local accessors
Every running thread that has called
teksilo_i18n::thread_local::install(mgr)
can reach the active manager via these crate-root functions:
| Function | Returns |
|---|---|
current_locale() | Option<Signal<LanguageIdentifier>> |
current_direction() | Option<Signal<LayoutDirection>> |
current_version_signal() | Option<Signal<u64>> |
current_supported_locales() | Option<Vec<LanguageIdentifier>> |
install and clear are exposed under teksilo_i18n::thread_local::*
for test harnesses; production apps shouldn't call them directly —
TeksiloAppBuilder::i18n(...) does it for you.
with_active(|mgr| ...) -> Option<R> runs a closure with a borrow
of the active manager if one is installed. Used internally by
resolve_message and the formatter signal builders; rarely needed
in app code.
compile_in_locales! declarative sugar
For multi-locale × multi-file projects, write the slice with the declarative macro instead of by hand:
#![allow(unused)] fn main() { use teksilo_i18n::compile_in_locales; let cfg = I18nConfig::new() .compile_in(compile_in_locales!( base = "../locales/", locales = ["en-US", "fr-FR", "es-ES", "ar-SA"], files = ["main.ftl", "auth.ftl", "editor.ftl"], )); }
Expands to:
#![allow(unused)] fn main() { &[ ("en-US", &[ include_str!("../locales/en-US/main.ftl"), include_str!("../locales/en-US/auth.ftl"), include_str!("../locales/en-US/editor.ftl"), ]), ("fr-FR", &[ /* …same files… */ ]), // … ] }
Constraints:
- Every
locale × filecombination must exist on disk —include_str!fails at compile time on a missing file. baseis relative to the source file that invokes the macro, not the crate root. For a binary crate withmain.rsinsrc/and locales at<crate>/locales/, the correct base is"../locales/".- If a locale ships a different subset of files, fall back to writing the explicit slice by hand — the sugar assumes uniform coverage.
Common pitfalls
Translation key not found at compile time. The macro reports
"translation key foo not found in <path>" with a Levenshtein-
based "did you mean" suggestion. Check the path it printed — if
it's wrong, set TEKSILO_I18N_SOURCE_PATH (single file) or
TEKSILO_I18N_SOURCE_DIR (directory) in the calling crate's
environment.
tr!(héllo()) rejected. Fluent message ids are ASCII-only
([a-zA-Z][a-zA-Z0-9_-]*). The macro rejects non-ASCII segments
upfront with a clearer message than "key not found".
{ NUMBER($v) } renders as {NUMBER()} placeholder. Means
bundle.add_builtins() wasn't called on the bundle. Every bundle
created via I18nManager (whether from from_config,
reload_from_path, framework registration) goes through
configure_bundle, which calls add_builtins. If you're seeing
this, you constructed a FluentBundle manually outside the
manager — don't.
tr_signal! resolver fires, but the displayed text doesn't
update. The result Signal<String> is dropped. The macro
attaches observers via attach_keepalive, so observer lifetimes
are tied to the result signal. If you bind the result to a widget
that's later removed, the observers detach automatically — that's
correct behaviour, not a bug.
Hot reload turned most of the app back to English. The
runtime_override is pointed at a single .ftl in a locale that ships
several. Reloading one file replaces the locale's whole bundle, so every
key the other files defined falls back to the source locale. Point the
override at the directory, as described in
Point it at the directory, not one file.
Signal<String> fallback when no manager is installed. Both
LocalizedString::to_signal() and the formatter signals work
without an installed manager; they emit a static or default-locale
value once and never re-render. Useful for low-level widget tests
that shouldn't pull in i18n state.
Calling format!("{}", ls) on a LocalizedString. Doesn't
work — there's no Display impl, and adding one would make it
ambiguous whether it resolves now or returns the resolver's debug
form. Use ls.resolve_now() or ls.to_signal() instead.
Files reference
| Topic | Path |
|---|---|
I18nConfig builder | crates/teksilo-i18n/src/config.rs |
I18nManager + bundle wiring | crates/teksilo-i18n/src/manager.rs |
LocalizedString + to_signal | crates/teksilo-i18n/src/localized_string.rs |
| Locale-aware formatting (Number/DateTime) | crates/teksilo-i18n/src/format.rs |
| Hot-reload file watcher | crates/teksilo-i18n/src/file_watcher.rs |
| Directory-override tests | crates/teksilo-i18n/tests/runtime_override_dir.rs |
| Layout direction (RTL) | crates/teksilo-i18n/src/direction.rs |
| Thread-local + crate-root accessors | crates/teksilo-i18n/src/thread_local.rs |
tr! / tr_widget! / tr_signal! / tr_signal_widget! | crates/teksilo-i18n-macros/src/lib.rs |
compile_in_locales! declarative macro | crates/teksilo-i18n/src/lib.rs |
| End-to-end demo | examples/internationalization |
| Format integration tests | crates/teksilo-i18n/tests/format_integration.rs |
tr! integration tests | crates/teksilo-i18n/tests/tr_macro.rs |
GridView — Virtualized 2D Tile Grid
GridView<T> is the photo-gallery / icon-view / file-manager-grid /
collection-view widget — the 2D sibling of ListView
and TableView. It is bound to a ListModel<T> / ListDataSource, realizes
only the tiles currently visible (plus a buffer), reflows on resize, and is
fully keyboard-navigable and accessible.
Source: crates/teksilo-widgets/src/grid_view.rs
(+ grid_view/ submodules). Demo: cargo run -p grid-view.
#![allow(unused)] fn main() { use teksilo::widgets::{GridView, GridSizing, grouping_sections}; GridView::new(model, |tc| { Box::new(card_for(tc.item, tc.is_selected)) }) .sizing(GridSizing::Adaptive { min_width: 140.0, max_width: Some(220.0), height: 110.0 }) .spacing(10.0) .selection(selection_model) // Multi → marquee + Ctrl/Shift .reorderable(true) .sections(grouping_sections(&model, |p| p.album)) .pinned_section_headers(true) .a11y_label("Photo library") }
Layout strategies
A pluggable GridLayoutStrategy drives virtualization; three ship:
| Strategy | Selected by | Heights | Notes |
|---|---|---|---|
| Uniform (default) | .sizing(...) / .tile_size / .column_count | fixed | Exact O(1) positions. The common photo/icon grid. |
| Variable row | .variable_row_heights(estimated) | each row = tallest tile | SwiftUI LazyVGrid. Auto-measure + scroll-anchoring, or exact via .item_height(i). |
| Waterfall | .waterfall(estimated) | per-item | Pinterest column-balanced flow. No scroll-anchoring (items reflow across columns). O(n) per layout on height change — fine for hundreds–low-thousands. |
Tile sizing ([GridSizing]):
Fixed { width, height }— exact tile size; column count derived (tiles not stretched).FixedColumnCount { count, height }— exactlycountstretched columns.Adaptive { min_width, max_width, height }— fit as many ≥min_widthcolumns as possible, stretch up tomax_width(CSSrepeat(auto-fill, minmax(...))/ FluttermaxCrossAxisExtent).
Sugar: .tile_size(w, h), .column_count(n, h). Spacing: .column_spacing,
.row_spacing, .spacing (both), .content_inset(EdgeInsets).
Reactive sizing. .sizing(...) accepts impl Into<Prop<GridSizing>>, so it
takes a plain GridSizing or a Signal<GridSizing>. A bound signal is
observed at BindingLevel::Rebuild: changing it rebuilds the cached layout
strategy and reflows the grid — the internal scroll_y / focused_index /
selection are field signals on the same widget instance, so they survive the
rebuild (no scroll jump). This is the "card-size slider" path — drive a
Signal<GridSizing> from a Slider and the tiles resize live. (.tile_size /
.column_count set a static size and clear any bound signal.)
Variable heights under virtualization
Off-screen tiles aren't built, so their heights are unknown. Two paths:
- Auto-measure (default for
variable_row_heights/waterfall): the body pane measures each realized tile (ctx.child_size, height-for-width) and feeds it back. Unmeasured rows use the estimate. When a corrected estimate shifts content at/above the viewport top,VariableRowGridadjustsscroll_yto keep it visually stationary (one-frame latency, no jump). Backed by a prefix-sum offset table with O(log n) row↔y lookups (PrefixSumOffsets, shared with the 1-D row widgets —ListView/TreeView/TableView/TreeTableView— fromcommon/row_offsets.rs). After each measure pass a realization re-check compares the corrected visible range against the realized tile range and requests a rebuild when tiles measured shorter than the estimate would otherwise leave a gap at the viewport bottom — convergence is guaranteed by the sub-pixel measurement epsilon. When a measure pass changes the content total, the pane pokes the container (aRelayout-bound signal) somax_scroll_yand the thumb ratio — computed parent-first, before the measurements — are re-derived next frame; without the poke, content past the estimated total would stay unreachable until the next scroll. - Exact (
.item_height(index)): row heights are seeded exactly asmax(item_height(i))over the row — exact scrollbar, zero jitter, no measurement.
Because PrefixSumOffsets is shared with the 1-D row widgets, a
zero-height row (.item_height has no floor above 0.0) hit-tests the
same way here as it does for TableView/TreeTableView's drop targeting —
row_at's raw result is the hit-tested tile index, so see
table-view.md "Which row a y coordinate resolves to" for
the degenerate-height tie-break.
Selection
Pass a flat SelectionModel (None / Single / Multi). Mouse: click =
select, Ctrl+click = toggle, Shift+click = reading-order range (Finder /
Explorer). Ctrl+A = select-all — a no-op in Single/None mode, matching
ListView and TableView (SelectionModel::select_all itself enforces this,
so no per-view gating is needed). Multi mode adds rubber-band marquee — a
drag on the empty background sweeps a rectangle and selects every intersecting
tile (Ctrl/Shift at drag-start = additive). The hit-test is geometric, so it
selects tiles outside the realized window. .on_selection_changed(|set|) fires
on every change (interactive or programmatic). .marquee_selection(false)
disables marquee.
Keyboard navigation
Focus (the current item) is tracked separately from selection and shown by a painted focus ring. Matrix (RTL-aware; horizontal arrows swap):
| Keys | Action |
|---|---|
| Arrow ←/→ | ±1 (within row; .wrap_navigation(true) to cross rows) |
| Arrow ↑/↓ | ±columns |
| Home / End | first / last of row |
| Ctrl+Home / Ctrl+End | first / last item |
| PageUp / PageDown | ± a viewport of rows + scroll |
| Space | toggle selection |
| Enter | .on_tile_activate (else select) |
| Esc | clear focus |
| Ctrl+A | select all (Multi mode only) |
| Alt+Arrow | reorder the focused tile (when .reorderable) |
| printable | type-ahead (needs .type_ahead_label(i); .type_ahead_timeout) |
| Tab | .tab_traversal(WithinGrid | OutOfGrid) |
Shift + any navigation extends the selection range. Every navigation scrolls the new focus into view.
Scrolling
.scroll_y_signal() / .max_scroll_y_signal() / .viewport_ratio_y_signal()
expose the reactive scroll state (wire an external ScrollBar with
.show_scrollbar(false) so it survives rebuilds). .ensure_index_visible(i, ScrollAnchor) / .scroll_to_index(i, ScrollAnchor) where ScrollAnchor is
Auto | Start | Center | End. .overscroll_behavior(Chain | Contain) controls
scroll chaining.
Lazy / incremental loading
There is no view-level on_near_end hook — incremental loading is a source
capability. When bound to a ListDataSource, the body pane calls
request_window(start..end) for the visible+buffer range each realize pass, and
when the scroll nears the end it consults can_fetch_more() → fetch_more() to
grow an append-only source. A tile whose item isn't resident yet (with_item
returns None) and whose row_state(i) is Loading renders a placeholder at
the estimated tile size instead of being skipped, so selection and focus still
work while the page loads. (.is_loading(signal) + .loading_view(...) is the
separate, whole-grid "first page is loading" overlay.) See
data-source.md.
Drag-and-drop reorder
.reorderable(true) enables intra-grid drag (and keyboard Alt+Arrow). Drags
route through the bound source's DnD capabilities: a tile is draggable only when
the source's drag(key) returns CanDrag; on hover the geometric
(target, position) is validated by can_accept(query) (a vertical insertion
bar shows an accepted landing; a rejected one suppresses it); the drop commits
via accept_drop(commit). A ListModel-backed source moves the item by
default. .on_item_drop(|payload, index, ctx| -> bool) is the escape hatch for
foreign / external payloads (cross-view or OS drops) the source's
can_accept rejects — it accepts at a flat insertion index, reusing the
framework DnD pipeline.
Sections & sticky headers
.sections(provider) groups the flat model; a header is rendered above each
section's tile band. grouping_sections(&model, key_fn) builds a provider by
partitioning consecutive equal-key runs. .section_header_delegate(|section, title|) customizes the header (default: bold title text);
.section_header_height(h). .pinned_section_headers(true) keeps the current
section's header pinned to the top while scrolling (one reused slot widget).
Sections compose with the uniform tile layout. The flat index space is
unchanged, so selection and keyboard navigation are unaffected.
Other
.on_tile_activate(|index, ctx|)— double-click / Enter (distinct from selection)..tile_context_menu(|index, pos, ctx| -> Option<Box<dyn Widget>>)— per-tile menu..empty_view(|| ...)when the model is empty;.loading_view(|| ...)+.is_loading(signal)for an overlaid loading state.- RTL is honored automatically (column 0 draws at the trailing edge; horizontal arrows swap).
Theming
GridView renders tiles through the app delegate, so its only widget-owned
chrome is paint-time decoration. The Tier-3 GridViewStyle protocol exposes
that as recipe data — focus ring (GridFocusRingRecipe), marquee
(GridMarqueeRecipe), drag-insertion bar (GridInsertionRecipe), and the
sticky-header surface role. Each method has a default, so a custom style
overrides only what it cares about. Precedence: .style(...) per-call →
theme.style_slots.grid_view theme-wide → the stock RecipeGridViewStyle.
The container itself uses theme roles (BorderRole::Focused / Accent,
SurfaceRole::Raised) by default.
Accessibility
The container emits Role::Grid with the logical row_count /
column_count (not the realized window), multiselectable in Multi mode,
active_descendant pointing at the focused tile (roving focus), and a
Live::Polite selection-count value. Each tile is wrapped in Role::GridCell
with 1-based row_index / column_index + pos_in_set / size_of_set.
Section headers are Role::RowHeader. Screen readers announce "row R, column
C — N of M" and the selection count.
.tile_a11y_label(|index| String) sets each GridCell's accessible name
(e.g. "Title, Type") so a screen reader announces a concise item name in
addition to the row/column position; without it the cell name is left to its
contents.
Tests
Headless (no GPU): crates/teksilo-widgets/src/grid_view/tests.rs
plus unit tests in layout/offsets.rs and layout/strategy.rs. Coverage:
virtualization window, column derivation, tile placement (uniform / variable /
waterfall / sectioned), prefix-sum + anchoring, selection, 2D keyboard,
reorder (source accept_drop), type-ahead, source-driven lazy loading
(fetch_more + placeholder rows), and accessibility roles.
teksu! Macro Reference
A block-structured DSL for Teksilo widget trees. teksu! is a thin syntactic
transform: every invocation desugars one-to-one to Teksilo V2 builder calls
at macro-expansion time. No hidden allocation, no runtime parsing, no
virtual tree — the output is exactly the code you could have written by
hand.
This document is the user-facing reference. For the design rationale and full grammar, see teksu-language-spec-v3.md.
Labels in these examples. For brevity the examples below pass bare string literals (
Button("Save")). With the defaulti18nfeature a widget label is aLocalizedStringand there is noFrom<&str>, so in a real app wrap it:Button(lit!("Save"))(untranslated) orButton(tr!(save()))(translated).teksu!passes whatever is inside(…)verbatim, so the wrapping just goes inside the parens. The fake widgets used to demonstrate macro mechanics (Probe,Tag,Marker, …) take a plain&strand need no wrapping.
Importing
#![allow(unused)] fn main() { use teksilo::prelude::*; // also provides: teksu! // or: use teksilo::teksu; }
Invocation
Two forms:
#![allow(unused)] fn main() { teksu!(ctx => <root-element>) // inserts the root via ctx.add, returns WidgetId teksu!(<root-element>) // returns a widget value (for .child(...), etc.) }
ctx in the preamble is an identifier — name it whatever your local
is called (tree, build_ctx, ctx, …). The => is literal syntax.
Expansion routes every internal add call through that ident, so
teksu!(tree => ...) emits tree.add(...).
Without the preamble, expansion falls back to an unqualified ctx
when bindings or escapes are present — that local must be in scope at
the call site. Pure teksu! blocks with no bindings or escapes (just
elements and properties) don't need ctx available.
Elements
An element is TypePath [::ctor] [(args)] [{ body }]. The constructor
part is optional — the macro emits ::new(args) when you omit it:
#![allow(unused)] fn main() { Button("Click") // → Button::new("Click") Button::new(lit!("Click")) // → Button::new(lit!("Click")) VStack // → VStack::new() Padding::uniform(24.0) // → Padding::uniform(24.0) }
Dispatch rule: the last path segment's first character decides. A
lowercase first letter (or a leading underscore) marks an explicit
constructor — emitted as-is. An UpperCamel first letter marks a type
name — the macro appends ::new automatically.
Positional args
Whatever sits in (...) is passed verbatim to the callable:
#![allow(unused)] fn main() { TitleBar(host_ident) // → TitleBar::new(host_ident) Padding::symmetric(12.0, 8.0) // → Padding::symmetric(12.0, 8.0) }
Body
The { ... } block contains body items: properties, bindings, bare
children, structural forms, body-position escapes. Items are separated
by newlines; commas between items are accepted as optional
separators (so Panel { padding: 8.0, color: RED } on one line works
the same as two newline-separated properties).
Properties
name: value desugars to a builder method call:
#![allow(unused)] fn main() { TextWidget("Hello") { style: t.body_bold.clone() color: c.text_primary } // ↓ TextWidget::new("Hello").style(t.body_bold.clone()).color(c.text_primary) }
Multi-argument properties
Commas continue the argument list until the parser sees a token that looks like a new body item:
#![allow(unused)] fn main() { TitleBar(host) { border: theme.colors.text_secondary, 2.0 background: theme.colors.surface_pressed } // ↓ .border(color, 2.0).background(...) }
A comma followed by a name: property, a structural keyword (if,
for, match, let), a spread .., an escape #{, or a binding
name = terminates the arg list — those tokens start a new body item.
An UpperCamel element after a comma stays as a continuation argument (so
tab: "Overview", Card { ... } works).
Argument-free bare-lowercase property
A single lowercase identifier at body position is a zero-arg method call:
#![allow(unused)] fn main() { Expand { fills_stack TextWidget("Body") } // ↓ Expand::new().fills_stack().child(TextWidget::new("Body")) }
Category A children
Stacks, panels, and single-child wrappers accept children by bare element at body position:
#![allow(unused)] fn main() { VStack { spacing: 12.0 TextWidget("Title") { style: t.body_bold.clone() } TextWidget("Body") Button("OK") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Submit) } } // ↓ VStack::new().spacing(12.0) // .child(TextWidget::new("Title").style(t.body_bold.clone())) // .child(TextWidget::new("Body")) // .child(Button::new("OK").on_activate_fn(|ctx| ctx.send_intent(AppIntent::Submit))) }
Body items are emitted in source order — you can interleave properties and children freely.
Category B slots
Widgets with named slots (Card, TitleBar, DialogContent, Breadcrumb, TabWidget, Popover, Snackbar, Dialog, Wizard, Accordion, SplitView) address content by slot name, not by bare child:
#![allow(unused)] fn main() { Card { header: TextWidget("Title") { style: t.body_bold.clone() } content: VStack { spacing: 12.0 TextWidget("Line one") TextWidget("Line two") } footer: Button("OK") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Ok) } padding: 16.0 } }
Slot values and scalar properties share syntax; the widget's builder decides what each name means. A bare child inside a Category B widget produces a targeted compile-time error pointing at the right slot name.
Bindings: name = Element
A binding names the WidgetId of an inserted widget so you can reference
it later. Bindings hoist to the enclosing teksu! block:
#![allow(unused)] fn main() { teksu!(ctx => VStack { open_btn = Button("Open") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Open) } TextWidget("Status") { linked_to: open_btn } } ) // ↓ { let open_btn: WidgetId = ctx.add(Button::new("Open").on_activate_fn(|ctx| ctx.send_intent(AppIntent::Open))); ctx.add( VStack::new() .add_child(open_btn) .child(TextWidget::new("Status").linked_to(open_btn)) ) } }
Binding at a slot position
When a slot value is a binding, the macro routes to the slot's *_id
twin:
#![allow(unused)] fn main() { Card { header: title = TextWidget("Manuscript") { style: bold } content: VStack { Button("Focus title") { on_tap: move |_, ctx| ctx.focus(title) } } } // ↓ // { // let title = ctx.add(TextWidget::new("Manuscript").style(bold)); // Card::new() // .header_id(title) // .content(VStack::new().child( // Button::new("Focus title") // .on_tap(move |_, ctx| ctx.focus(title)) // )) // } }
title is in scope for any subsequent item in the same teksu! block,
including nested closures.
Escape: #{ expr }
Insert a pre-registered WidgetId (or an arbitrary WidgetId expression)
at a body or slot position:
#![allow(unused)] fn main() { let toolbar_id = ctx.add(build_toolbar()); teksu!(ctx => VStack { #{ toolbar_id } // → .add_child(toolbar_id) Expand { fills_stack child_id: scroll_id // use property form where the // container's id method isn't // .add_child (e.g. single-child // wrappers use .child_id) } } ) }
At a slot position, #{ expr } forces the *_id slot routing:
#![allow(unused)] fn main() { Card { header: #{ existing_header_id } // → .header_id(existing_header_id) } }
A binding or #{ } escape is only needed when the same widget ID is
referenced from multiple places (e.g. a handler closure captures it).
If you just want to attach a pre-existing ID once, the equivalent
property forms — add_child: id for multi-child containers,
child_id: id for single-child wrappers, slot_name_id: id for
Category B slots — are shorter and plain Rust inside the arg
position.
Structural forms
if / else if / else
#![allow(unused)] fn main() { // No-else: child_opt path. VStack { if is_logged_in { ProfileCard(user) } } // ↓ .child_opt(if is_logged_in { Some(ProfileCard::new(user)) } else { None }) // Two arms: TeksiBranch<L, R>. VStack { if is_logged_in { ProfileCard(user) } else { Button("Sign in") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::SignIn) } } } // Three or four arms: TeksiBranch3 / TeksiBranch4. VStack { if count == 0 { TextWidget("Empty") } else if count == 1 { TextWidget("One item") } else { TextWidget(format!("{count} items")) } } }
Limits: up to 4 arms. Deeper dispatches use match or split into a
helper function returning Box<dyn Widget>.
Conditions are boolean expressions. For reactive visibility, bind a
Signal<bool> through the widget's own API (e.g.
ctx.visible_when(id, signal)).
match
#![allow(unused)] fn main() { VStack { match state { State::Loading => Spinner, State::Loaded(data) => DataView(data.clone()), State::Error(msg) => ErrorBanner(msg.clone()), } } // ↓ .child(match state { ... TeksiBranch3::{A,B,C}(...) }) }
2–4 arms supported. Each arm's body is a single element.
for
#![allow(unused)] fn main() { VStack { for item in items.iter() { let id = item.id; let title = item.title.clone(); ListItem(title) { on_tap: move |_, ctx| ctx.send_intent(AppIntent::Select(id)) } } } // ↓ .children(items.iter().map(|item| { // let id = item.id; // let title = item.title.clone(); // ListItem::new(title).on_tap(move |_, ctx| ctx.send_intent(AppIntent::Select(id))) // })) }
The for-body is zero or more let bindings followed by a single
element. The lets exist so move-closures capture owned values instead
of references.
let at body position
Introduces a local used by subsequent body items:
#![allow(unused)] fn main() { VStack { let heading_style = t.body_bold.clone(); let accent = c.accent; TextWidget("Title") { style: heading_style.clone(), color: accent } TextWidget("Body") { style: t.body.clone(), color: accent } } }
Switches the enclosing element to statement-sequence form. The let is scoped to the element's body block.
..spread
Inline an iterator of WidgetIds as children:
#![allow(unused)] fn main() { VStack { TextWidget("Header") ..plugin_widgets // for id in plugin_widgets { __parent.add_child(id) } TextWidget("Footer") } }
rust { ... }
Imperative escape for code that isn't a single element. Two shapes,
determined by whether the block's last statement has a trailing ;:
#![allow(unused)] fn main() { // Expression form — block value becomes a child. VStack { TextWidget("Header") rust { let tag = if cond { "a" } else { "b" }; MyWidget::new(tag) // no trailing ; } TextWidget("Footer") } // Side-effect form — runs for effect, produces no child. VStack { rust { ctx.subscribe_event(origin, move |e| { /* ... */ }); } TextWidget("Status") } }
Side-effect form forces statement-sequence lowering.
Handlers
Handlers are properties whose value is a closure. The macro preserves
closure syntax verbatim — move, capture, and arity stay as you wrote
them. Handler-attachment properties (on_tap, on_hover, on_key,
focusable, cursor, context_menu, and every other method on the
WidgetBuilder trait) are automatically moved to the end of the
emitted builder chain, so you can interleave them with children and
widget-specific properties in any order. Without the reorder, a call
like .context_menu(...).child(...) would fail to resolve because the
WidgetBuilder methods wrap the widget in WidgetWithHandlers<T>
which doesn't expose per-widget setters.
#![allow(unused)] fn main() { Button("Click") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Submit) } Button("Click") { on_tap: |_, ctx| ctx.send_intent(AppIntent::Submit) } Button("Click") { on_tap: move |_, ctx| { if counter.get() > 0 { ctx.send_intent(AppIntent::Submit); } } } }
Whether a handler attaches to the element itself or to an inner widget is the builder's concern; the DSL does not distinguish.
Desugaring cheat sheet
‹E› stands for the recursive lowering of a nested teksu element.
| Surface form | Expansion |
|---|---|
TypePath(args) | TypePath::new(args) |
TypePath::ctor(args) | TypePath::ctor(args) |
name: value | .name(value) |
name: a, b | .name(a, b) |
name (bare lowercase) | .name() |
Bare UpperCamel(...) at body | .child(‹E›) |
name = ‹E› at body | hoisted let name = ctx.add(‹E›); + .add_child(name) |
name = ‹E› in slot s | hoisted let + .s_id(name) |
#{ id_expr } at body | .add_child(id_expr) |
#{ id_expr } in slot s | .s_id(id_expr) |
if cond { ‹E› } | .child_opt(if cond { Some(‹E›) } else { None }) |
if cond { ‹A› } else { ‹B› } | .child(if cond { TeksiBranch::L(‹A›) } else { TeksiBranch::R(‹B›) }) |
match x { p => ‹E›, … } | .child(match x { p => TeksiBranchN::…(‹E›), … }) |
for p in it { ‹E› } | .children((it).map(|p| ‹E›)) |
..expr | stmt-form for id in expr { __parent = __parent.add_child(id); } |
rust { … expr } | .child({ … expr }) |
rust { …; } | inline side-effect block |
Diagnostics
The macro emits one targeted error for the common mistake:
- Bare child inside a Category B widget — "
Cardis a Category B widget with named slots — usecontent: <widget>instead of a bare child element". Points at the misplaced child.
Everything else (unknown property, wrong handler arity, constructor typo, type mismatches on property values) surfaces as a regular rustc diagnostic under the user's token, thanks to span-preserving emission.
Limitations
- 4-arm cap on
if/match: chains beyond four arms must be split into a helper returningBox<dyn Widget>(or refactored tomatch). - Binding hoist scope: bindings declared inside
if/else/match/forbodies currently hoist to the outermostteksu!block. The widget is created unconditionally; only the parent's attachment is gated by the arm. Usually a non-issue; rearrange the binding site if construction cost matters. - Reactive-if is not special-cased:
if signal { ... }wheresignal: Signal<bool>does not auto-bindvisible_when. Bind visibility throughctx.visible_when(id, signal)directly on a pre-registered child, or wrap the widget in your own helper. - Struct literals as arg values need parens:
prop: MyStruct { ... }is parsed as a teksu element (per the spec's "commit on distinctive prefix" rule). To pass a Rust struct literal, wrap it:prop: (MyStruct { ... }). Enum variants don't need this wrapping —prop: Type::Variantandprop: Type::Variant(inner)are recognized as expressions because of theUpperCamel::UpperCamelshape. - No method chains on widgets at property-arg position: write
item: MenuItem::new("x").on_activate(cmd).tooltip("t")as body form —item: MenuItem::new("x") { on_activate: cmd; tooltip: "t" }. The body-form reads uniformly with top-level elements and skips the element-vs-expression ambiguity. For non-widget method chains rooted in lowercase paths (signal.map(...),items.iter().collect()), no workaround is needed — lowercase paths go through the expression path unconditionally. For UpperCamel-rooted chains that don't fit the body form (rare), wrap in parens:prop: (MyWrapper::from(x).finalize()). - rust-analyzer: the macro expands cleanly under rust-analyzer's
proc-macro server; IDE features work on the expanded code. If you see
"expected an expression" errors on non-Rust-shaped tokens (
#{ }, bare-lowercase properties,Widget { body }at body position), the proc-macro server has stopped expanding — reload it from the command palette (rust-analyzer: Restart server) or rebuild the workspace.
Further reading
- teksu-language-spec-v3.md — complete grammar, design principles, and worked translations of the reference examples.
- crates/teksilo/tests/teksu/pass/ — trybuild fixtures exercising every supported form.
- crates/teksilo-macros/src/ — the implementation (parse → IR → lower).
The teksu! Language Specification (v3)
Status: Design draft, revision 3 Date: April 17, 2026 Companion to: architecture.md §28.9 Supersedes: teksu-language-spec-v2.md
Changelog from v2
Four structural changes, all driven by review of v2 against the actual widget catalog and by subsequent design discussion.
Bindings use name = Element instead of id: name. The new form reads like ordinary Rust assignment, removes one keyword from the grammar, and works uniformly at body position and in property-argument position.
Widget categories are now two, not three. The framework refactor (see Appendix A) dissolves the former Category C by moving primary content from constructors to setter methods on ScrollArea, Popover, Snackbar, and Dialog. ScrollArea joins Category A (has .child()). Popover, Snackbar, and Dialog join Category B (named slots). Wizard is not Category C and was never intended to be; v2 misclassified it.
The *_id convention is universal. Every widget-accepting slot method on every container now has a twin taking a WidgetId, named *_id. The existing .set_child(id) methods on Panel, Padding, Expand, GroupBox, and Accordion are renamed to .child_id(id) / .content_id(id) for consistency. SplitView's existing .first_id / .second_id fits the pattern unchanged. TabWidget gets .tab_id(label, id) to match.
Worked translations reflect the refactored API. Every code example in §7 is against post-refactor builder signatures. The seven uploaded example files themselves are assumed to be migrated; the framework changes required are listed in Appendix A.
Em-dashes and middle dots in quoted source strings are preserved verbatim. The "no em-dashes in English prose" rule continues to apply to the spec's own writing and does not apply to code being quoted.
Labels in these examples. For brevity the examples below pass bare string literals (
Button("Save")). With the defaulti18nfeature a widget label is aLocalizedStringand there is noFrom<&str>, so in a real app wrap it:Button(lit!("Save"))(untranslated) orButton(tr!(save()))(translated).teksu!passes whatever is inside(…)verbatim, so the wrapping just goes inside the parens. The fake widgets used to demonstrate macro mechanics (Probe,Tag,Marker, …) take a plain&strand need no wrapping.
1. Design Principles
The teksu! macro is a thin syntactic transform. It is not a new runtime, not a new type system, and not a new reactivity model. Every teksu! block desugars to a sequence of builder calls against Teksilo API. There is no hidden allocation, no intermediate virtual tree, no diff step. The macro's only job is to remove syntactic noise from code that already expresses a widget tree.
Five rules bind the design.
First, one-to-one desugaring. Every surface form has a unique, mechanically predictable expansion. No form that works only sometimes depending on macro inference.
Second, error spans follow the user. When expansion fails (wrong property name, wrong child type, wrong handler arity), the error points at the user's token, not at a synthetic span inside the expansion.
Third, reactivity and capture stay visible. Signal<T>, Prop<T>, and closure move appear in the source as themselves. The macro never synthesizes binding or capture semantics the user did not ask for.
Fourth, builder interop is symmetric. teksu! expressions and builder chains can be freely nested in either direction. Neither is a superset of the other.
Fifth, the macro never introduces new capabilities. If a construct cannot be expressed by the V2 builder API, teksu! will not invent a way to express it. Missing capabilities are fixed in the builder first, then surfaced in the DSL.
2. Lexical Structure
A teksu! invocation takes one of two forms.
#![allow(unused)] fn main() { teksu!(ctx => <root-element>) teksu!(<root-element>) }
The ctx => preamble binds the name used for the BuildContext inside the block, and causes the root element to be inserted into the arena via ctx.add(...) so the call returns a WidgetId. The shorter form has no preamble and returns a widget value suitable for passing to .child(...) or to a named-slot method.
Disambiguation is lexical. The macro parser looks at the first tokens: if the leading form is ident =>, the preamble is consumed; otherwise the macro starts parsing elements immediately.
3. Elements
An element is the fundamental unit of the language. It names a widget type (possibly with an explicit constructor path), optionally carries positional arguments and a body block containing properties, bindings, and child elements.
3.1 Grammar
element := type_path ( "::" constructor )? ( "(" positional_args ")" )?
( "{" body "}" )?
type_path := path_segment ( "::" path_segment )*
constructor := ident
body := ( body_item )*
body_item := property | binding | structural | child_element
property := ident ":" arg_list
binding := ident "=" element
arg_list := arg ( "," arg )*
arg := element | bound_element | expr
bound_element := ident "=" element
structural := if_form | for_form | match_form | let_form | spread_form | rust_form
child_element := element
Body items are separated by newlines. There are no commas between body items. This eliminates the , noise between .child(...).child(...) calls that dominates the uploaded example files.
At positions where an arg is expected, the parser uses "commit on distinctive prefix" to decide between element and expression: if the leading tokens form a TypePath followed by (, ::, or {, or match ident = TypePath(...), commit to element or bound-element parsing. Otherwise commit to expression parsing. This rule is local (no backtracking) and preserves clean error spans.
3.2 Constructors
The type path in an element may end with an explicit associated function name. If present, the macro emits that function as the constructor. If absent, the macro emits ::new as the default.
#![allow(unused)] fn main() { Button("Click") desugars to Button::new("Click") TextWidget("Hello") desugars to TextWidget::new("Hello") VStack desugars to VStack::new() Button::new(lit!("Click")) desugars to Button::new(lit!("Click")) Padding::uniform(24.0) desugars to Padding::uniform(24.0) Padding::symmetric(12.0, 8.0) desugars to Padding::symmetric(12.0, 8.0) ProgressBar::indeterminate() desugars to ProgressBar::indeterminate() }
Parenthesized arguments after the constructor are passed verbatim in order. An element with no parentheses (VStack, Spacer) is equivalent to one with empty parentheses.
3.3 Bindings: name = Element
Naming a widget binds its WidgetId to a local so it can be referenced later. Bindings work in two places: at body position inside a container, and in property-argument position.
Binding at body position (Category A containers only, see §4):
#![allow(unused)] fn main() { teksu!(ctx => VStack { open_btn = Button("Open") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Open) } TextWidget("Status") { linked_to: open_btn } } ) }
Desugars to:
#![allow(unused)] fn main() { { let open_btn: WidgetId = ctx.add( Button::new("Open").on_activate_fn(|ctx| ctx.send_intent(AppIntent::Open)) ); ctx.add( VStack::new() .add_child(open_btn) .child( TextWidget::new("Status") .linked_to(open_btn) ) ) } }
The binding is hoisted to the nearest enclosing statement-forming block (the teksu! expansion here), where it remains in scope for the rest of the block. The container uses .add_child(id) to attach the bound element at its body position.
Binding in property-argument position (Category B slots):
#![allow(unused)] fn main() { teksu!( Card { header: title = TextWidget("Manuscript") { style: t.body_bold.clone() } content: VStack { TextWidget("Set title:") Button("Focus title") { on_tap: move |_, ctx| ctx.focus(title) } } } ) }
Desugars to:
#![allow(unused)] fn main() { { let title: WidgetId = ctx.add( TextWidget::new("Manuscript").style(t.body_bold.clone()) ); Card::new() .header_id(title) .content( VStack::new() .child(TextWidget::new("Set title:")) .child( Button::new("Focus title") .on_tap(move |_, ctx| ctx.focus(title)) ) ) } }
The slot method switches from .header(widget) (widget-taking) to .header_id(id) (id-taking) to accommodate the binding. Every Category B slot method has an *_id twin by framework convention (see Appendix A).
Scope rules. A binding is in scope from the point of declaration to the end of the nearest enclosing statement-forming block. Statement-forming blocks are: a teksu!(...) expansion, a rust { } block, a match arm, an if or else arm, a for body, and a let form's scope. A binding declared in one arm is not visible from a sibling arm.
3.4 Properties
A property is name: arg1, arg2, ... and desugars to a builder method call with those arguments.
#![allow(unused)] fn main() { // Single argument TextWidget("Hello") { style: t.body_bold.clone() color: c.text_primary } // Desugars to TextWidget::new("Hello") .style(t.body_bold.clone()) .color(c.text_primary) }
#![allow(unused)] fn main() { // Multiple arguments TitleBar(host) { height: 40.0 border: theme.colors.text_secondary, 2.0 background: theme.colors.surface_pressed } // Desugars to TitleBar::new(host) .height(40.0) .border(theme.colors.text_secondary, 2.0) .background(theme.colors.surface_pressed) }
Bare lowercase identifier as argument-free method call. A body item consisting of a single lowercase identifier is a property call with no arguments.
#![allow(unused)] fn main() { Expand { fills_stack TextWidget("Body") } // Desugars to Expand::new() .fills_stack() .child(TextWidget::new("Body")) }
The distinction between "bare child element" and "argument-free property" is lexical: Rust naming convention is UpperCamel for types and snake_case for methods. A bare identifier at body position starting with an uppercase letter is a child element; starting with a lowercase letter, it is a property call.
Argument list termination. The argument list of a property terminates at the next newline, unless the last token on the line is inside an open bracket (paren, brace, or square), in which case parsing continues until the brackets balance. This handles struct literals, tuples, and multi-line element values as argument values correctly:
#![allow(unused)] fn main() { Panel { style: TextStyle { family: "sans-serif".into(), size: 14.0, weight: FontWeight::BOLD, } offset: (4.0, 2.0) TextWidget("Hello") } }
Each of these is a single-argument property whose value happens to contain commas inside brackets.
Multi-argument with element values. A property argument can be a full element, including one with its own body. This is the TabWidget pattern:
#![allow(unused)] fn main() { TabWidget(selected) { tab: lit!("Overview"), Card { header: TextWidget(lit!("Overview")) { style: t.body_bold.clone() } content: VStack { spacing: 12.0, ... } } tab: lit!("Inspector"), Panel { padding: 20.0, ... } trailing_slot: trailing_widget } }
The comma after lit!("Overview") is at depth 0 (not inside brackets) and separates the two arguments of tab. The next Card { ... } element opens a brace that may span multiple lines; the parser tracks bracket balance until the Card's closing }. After the Card closes, the next newline terminates the argument list and ends the tab property.
Desugars to (TabWidget::tab(label, content) is the title-only shorthand for static_tab(TabInfo::new().title(label), content)):
#![allow(unused)] fn main() { TabWidget::new(selected) .tab( lit!("Overview"), Card::new() .header(TextWidget::new(lit!("Overview")).style(t.body_bold.clone())) .content(VStack::new().spacing(12.0)...) ) .tab(lit!("Inspector"), Panel::new().padding(20.0)...) .trailing_slot(trailing_widget) }
Property ordering is preserved. The macro emits method calls in source order.
Properties are never reinterpreted. color: c.text_primary emits .color(c.text_primary) whether c.text_primary is a Color, a Signal<Color>, or a Prop<Color>. Conversion happens at the type level through impl Into<Prop<T>>, not in the macro.
3.5 Handlers
Handler attachment is a property. The grammar does not distinguish handlers from configuration. Convention names them on_*, but this is enforced by each widget's builder API, not by the macro.
#![allow(unused)] fn main() { Button("Click") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Submit) } Button("Click") { on_tap: |_, ctx| ctx.send_intent(AppIntent::Submit) } Button("Click") { on_tap: move |_, ctx| { if some_signal.get() > 0 { ctx.send_intent(AppIntent::Submit); } } } }
All three desugar to the method call named by the property. The macro does not modify closure syntax: move stays explicit where the user writes it, and is absent where the user omits it. This is rule 3 of the design principles.
3.6 Child Elements
A bare element at body position, with no name: prefix and no name = binding, is a child element. Children desugar to .child(...) calls on the parent, using the inline-child resolution path from architecture §6.1.
#![allow(unused)] fn main() { VStack { spacing: 12.0 TextWidget("Title") { style: t.body_bold.clone() } TextWidget("Body") { style: t.body.clone() } } // Desugars to VStack::new() .spacing(12.0) .child(TextWidget::new("Title").style(t.body_bold.clone())) .child(TextWidget::new("Body").style(t.body.clone())) }
Body items interleave freely. Properties, bindings, and children appear in the output chain in source order:
#![allow(unused)] fn main() { VStack { TextWidget("Header") spacing: 12.0 TextWidget("Body") } // Desugars to VStack::new() .child(TextWidget::new("Header")) .spacing(12.0) .child(TextWidget::new("Body")) }
Style guides may recommend "properties first, children last" as convention. The grammar does not enforce it.
Bare child elements are only meaningful for Category A containers (§4.1). For Category B widgets, the equivalent error from the compiler is no method named 'child' on Card, which is clear enough without special macro handling.
4. Widget Categories
Every Teksilo widget falls into one of two categories based on how it accepts content. The category determines which DSL form applies.
4.1 Category A: Has .child()
These widgets accept one or more children through a .child(widget) method and an .add_child(id) or .child_id(id) twin. Body-block child syntax in the DSL maps directly.
Members:
Layout primitives: VStack, HStack, ZStack, Padding, Expand, Switcher, Center, MinSize, MaxSize, FixedSize, AspectRatio, Wrap, Grid.
Flat containers: Panel, Toolbar, StatusBar, GroupBox.
Scrolling: ScrollArea (post-refactor; see Appendix A).
DSL form:
#![allow(unused)] fn main() { VStack { spacing: 12.0 TextWidget("Hello") Button("Click") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Go) } } }
Bare children desugar to .child(widget). Bound children desugar to .add_child(id) (or .child_id(id) where the container uses that name).
4.2 Category B: Named Slots
These widgets have no .child() method. Content goes through named setter methods, one per semantic slot. Each slot method has both a widget-taking form (.slot_name(widget)) and an id-taking twin (.slot_name_id(id)).
Members and their slots:
- Card (
header,content,footer) - Accordion (
content, withtitletaken as constructor arg) - SplitView (
first,second) - TitleBar (
leading,center,trailing, plus the non-widgetclose_actionhandler) - DialogContent (
body,footer, withtitleandsupporting_textas LocalizedString properties) - Breadcrumb (
itemandtrailing_slot) - TabWidget (
tab,tab_item,trailing_slot, with tabs being multi-arg(label, widget)pairs) - Popover (
content,trigger; post-refactor) - Snackbar (
content,trigger; post-refactor) - Dialog (
contenttaking aFn() -> impl Widgetfactory, plustrigger; post-refactor)
DSL form:
#![allow(unused)] fn main() { Card { header: TextWidget("Title") { style: t.body_bold.clone() } content: VStack { spacing: 12.0 TextWidget("Line one") TextWidget("Line two") } footer: Button("OK") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::Ok) } padding: 16.0 } }
Slot values and decoration properties use identical syntax. The widget's own documentation tells the reader which properties are slots. The DSL grammar does not distinguish.
4.3 Leaf Widgets
Widgets with no child-accepting methods at all. Buttons, TextWidget, IconWidget, ImageWidget, RectWidget, Badge, Link, Spacer, Divider, Toggle, Checkbox, RadioButton, Slider, ProgressBar. These have properties but no body children or slots. Their DSL form is just Type(args) { property: value, on_handler: closure, ... }.
4.4 Wizard
Wizard is structurally its own case: it takes a title in the constructor and wires multi-step content through .step(WizardStep) and .steps(iter) methods. It is not refactored in Appendix A because its shape does not fit cleanly into either Category A or B. For DSL authoring, treat Wizard like Category B with named slots, adding step and steps to the slot vocabulary. Details of Wizard's DSL form are deferred; the current builder API is usable directly.
5. Structural Forms
Pure element syntax handles fixed structure, fixed properties, fixed children. The remaining cases, conditional inclusion, iteration, local bindings, side effects, and programmatic subtree splicing, get first-class structural forms rather than forcing users back to builder syntax mid-block.
5.1 if Forms
The condition is an arbitrary Rust if head, including if let and else if chains.
#![allow(unused)] fn main() { VStack { if is_logged_in { ProfileCard(user.clone()) } else { Button("Sign in") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::SignIn) } } } VStack { if let Some(msg) = error_message.as_ref() { ErrorBanner(msg.clone()) } } VStack { if count == 0 { TextWidget("Empty") } else if count == 1 { TextWidget("One item") } else { TextWidget(format!("{} items", count)) } } }
Desugaring:
An if without else desugars to .child_opt(if cond { Some(widget) } else { None }).
An if/else with two arms of different widget types desugars via TeksiBranch<L, R> to a type that implements IntoTeksiChild by dispatching to the active variant. Three- and four-way branches use TeksiBranch3 and TeksiBranch4. Branches beyond four arms require explicit Box<dyn Widget>.
Reactive conditionals. If the condition is a bare identifier whose static type is Signal<bool> or Prop<bool>, the lowering is .visible_when(signal) on the child rather than an arena-level conditional. This is the one place where the macro performs type-directed inference. It is conservative: anything other than a bare identifier of the right type (a boolean expression, a function call, a dereferenced signal, an if let) falls into the regular child_opt / TeksiBranch path. To force the build-time conditional when the condition is a Signal identifier, write if signal.get() { ... }.
The same binding is also available explicitly as a per-widget property: Widget { visible_when: signal } desugars to .visible_when(signal) (a WidgetBuilder method accepting bool / Signal<bool> / Prop<bool>), equivalent to the imperative ctx.visible_when(id, signal). Use the property form when you want visibility to read as a widget attribute alongside its other properties rather than as a wrapping if.
5.2 for Forms
Iteration produces a stream of children from a regular Rust iterator.
#![allow(unused)] fn main() { VStack { TextWidget("Items:") { style: t.body_bold.clone() } for item in items.iter() { let id = item.id; let title = item.title.clone(); ListItem(title) { on_tap: move |_, ctx| ctx.send_intent(AppIntent::Select(id)) } } } // Desugars to VStack::new() .child(TextWidget::new("Items:").style(t.body_bold.clone())) .children(items.iter().map(|item| { let id = item.id; let title = item.title.clone(); ListItem::new(title) .on_tap(move |_, ctx| ctx.send_intent(AppIntent::Select(id))) })) }
The loop body is a sequence of let bindings followed by a single element. The let bindings exist to narrow captures to owned values (let id = item.id; copies the id out so the move closure does not try to capture &item). The macro does not inject these bindings automatically.
For dynamic item collections backed by ListModel<T>, use the ListView widget directly. The for form is for static iteration at build time, not reactive item lists.
5.3 match Forms
#![allow(unused)] fn main() { VStack { match state { State::Loading => Spinner(), State::Loaded(data) => DataView(data.clone()), State::Error(msg) => ErrorBanner(msg.clone()), } } // Desugars to VStack::new() .child(match state { State::Loading => TeksiBranch3::A(Spinner::new()), State::Loaded(data) => TeksiBranch3::B(DataView::new(data.clone())), State::Error(msg) => TeksiBranch3::C(ErrorBanner::new(msg.clone())), }) }
5.4 let Forms
A let binding at body position introduces a computed value used by subsequent elements.
#![allow(unused)] fn main() { VStack { let heading_style = t.body_bold.clone(); let accent = c.accent; TextWidget("Title") { style: heading_style.clone(), color: accent } TextWidget("Body") { style: t.body.clone(), color: accent } } }
When a body contains let bindings, the desugaring switches from a pure builder chain to a statement sequence. This desugaring also applies to body-position bindings (§3.3), spread forms (§5.5), and pure-side-effect rust blocks (§5.6). A body containing only properties and child elements continues to use the pure chain form for readability.
5.5 Spread Forms
A spread ..expr inlines a Vec<WidgetId> or an iterator of widgets as children at that position.
#![allow(unused)] fn main() { VStack { TextWidget("Header") ..plugin_widgets TextWidget("Footer") } // Desugars to { let mut __vstack = VStack::new(); __vstack = __vstack.child(TextWidget::new("Header")); for __id in plugin_widgets { __vstack = __vstack.add_child(__id); } __vstack = __vstack.child(TextWidget::new("Footer")); __vstack } }
Spread is for programmatic child list assembly (plugin registries, restored workspaces, tab managers).
5.6 rust Forms
A rust { ... } block switches to imperative construction. Two shapes, distinguished by whether the block produces a value.
Expression-producing form. The block ends with an expression without a trailing semicolon. The value is used as a child or spread across children via the IntoTeksiChild trait.
#![allow(unused)] fn main() { VStack { TextWidget("Header") rust { let mut items = Vec::new(); for ch in chapters.iter() { if ch.visible { items.push(ctx.add(ChapterRow::new(ch.clone()))); } } items } TextWidget("Footer") } }
Side-effect form. The block's last statement ends with ; (unit value). The block runs for its side effects and produces no children. Multiple ;-terminated statements are allowed.
#![allow(unused)] fn main() { VStack { rust { let item_label = self.item_label.clone(); let app_ctx = self.app_context.clone(); ctx.subscribe_event( Origin::DirectAccess(DirectAccessEntity::Item(EntityEvent::Created)), move |event: &Event| { if let Some(id) = event.ids.first() { if let Ok(Some(dto)) = item_commands::get_item(&app_ctx, id) { item_label.set( tr!(created_info(title = dto.title, id = dto.id)).resolve_now(), ); } } }, ); } TextWidget("") { text: self.item_label.clone() } } }
Disambiguation is mechanical. The macro looks at the last statement in the rust block. If it ends without ;, the block is expression-producing. If it ends with ;, the block is side-effect.
Failure mode for forgotten ;. If the user writes a side-effect block without a trailing ;, and the tail has type () (for example, a bare if let { ...; } without else), the macro treats it as expression form and tries to dispatch () through IntoTeksiChild. The compiler responds with "the trait IntoTeksiChild is not implemented for ()" pointing at the block's tail expression. This is a survivable error: the message is clear and the fix is to add the missing ;.
6. Escape Hatches
One escape into host Rust, in addition to the rust { } block.
6.1 Expression Escape: #{ expr }
Anywhere a child element or property value is expected, #{ expr } takes a Rust expression and inserts its value at that position. If the expression evaluates to a WidgetId, the child position emits .add_child(id) instead of .child(widget), and slot positions emit .slot_id(id) instead of .slot(widget). Dispatch is through the IntoTeksiChild blanket trait.
#![allow(unused)] fn main() { // Inserting a pre-built widget as a child VStack { TextWidget("Header") #{ build_complex_subtree(ctx, config) } TextWidget("Footer") } // Re-using a bound id in a slot teksu!(ctx => VStack { title = TextWidget("Manuscript") { style: bold } Card { header: #{ title } content: TextWidget("Body") } } ) }
The second case uses title (declared with name = Element binding) via #{ title } in a Category B slot. The escape pulls the WidgetId into the slot position; the macro routes it through .header_id(title) automatically.
For bare identifiers at property-value positions, #{ } is not required: text: selected_label parses as a property with a Rust expression value. The escape is only needed where the parser would otherwise try to interpret the value as something else (an element, a structural form).
7. Worked Translations
Each translation takes a block from one of the uploaded example files and shows the teksu! equivalent against the actual post-refactor constructor and method names. Translations assume Appendix A has been applied.
7.1 simple-button
Source:
#![allow(unused)] fn main() { .root(|tree| { tree.add( Button::new(lit!("Click Me")) .style(ButtonVariant::Default) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::ButtonClicked)) .tooltip(lit!("This is a simple button. Click it to see a message in the console.")), ) }) }
With teksu!:
#![allow(unused)] fn main() { .root(|tree| teksu!(tree => Button::new(lit!("Click Me")) { style: ButtonVariant::Default on_activate_fn: |ctx| ctx.send_intent(AppIntent::ButtonClicked) tooltip_literal: "This is a simple button. Click it to see a message in the console." } )) }
The explicit ::new_literal names the constructor. The tooltip_literal property matches the real method name. Four lines instead of six, property assignments read as assignments.
7.2 text-and-layout, outer Padding and VStack
Source:
#![allow(unused)] fn main() { let root = ctx.add( Padding::uniform(24.0).child( VStack::new() .spacing(20.0) .child( HStack::new() .child( TextWidget::new(lit!("Text & Layout")) .style(t.body_bold.clone()) .color(c.text_primary), ) .child(Spacer::new()) .child( Button::new(lit!("Toggle Dark Mode")) .style(ButtonVariant::Regular) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::ToggleDarkMode)), ), ), ), ); }
With teksu!:
#![allow(unused)] fn main() { let root = teksu!(ctx => Padding::uniform(24.0) { VStack { spacing: 20.0 HStack { TextWidget::new(lit!("Text & Layout")) { style: t.body_bold.clone() color: c.text_primary } Spacer Button::new(lit!("Toggle Dark Mode")) { style: ButtonVariant::Regular on_activate_fn: |ctx| ctx.send_intent(AppIntent::ToggleDarkMode) } } } } ); }
All .child(...) wrappers collapse. Siblings land at equal depth. ::uniform and ::new_literal appear where the builder uses them.
7.3 text-and-layout, build_color_box helper
Source:
#![allow(unused)] fn main() { fn build_color_box(color: Color, label: &str) -> Panel { Panel::new() .background(color) .corner_radius(6.0) .padding(8.0) .child( TextWidget::new(lit!(label)) .style(TextStyle { family: "sans-serif".into(), size: 14.0, weight: FontWeight::BOLD, line_height: 1.4, letter_spacing: 0.0, }) .color(Color::WHITE), ) } }
With teksu!:
#![allow(unused)] fn main() { fn build_color_box(color: Color, label: &str) -> impl Widget { teksu!( Panel { background: color corner_radius: 6.0 padding: 8.0 TextWidget::new(lit!(label)) { style: TextStyle { family: "sans-serif".into(), size: 14.0, weight: FontWeight::BOLD, line_height: 1.4, letter_spacing: 0.0, } color: Color::WHITE } } ) } }
The return type changes from Panel to impl Widget because the macro's output is opaque. The TextStyle { ... } struct literal has internal commas grouped by braces; the bracket-aware parser keeps them as a single argument to .style().
7.4 title-bar-demo, multi-argument properties and slots
Source:
#![allow(unused)] fn main() { TitleBar::new(host) .height(40.0) .background(theme.colors.surface_pressed) .border(theme.colors.text_secondary, 2.0) .leading( TextWidget::new(lit!(" Teksilo — Title Bar Demo")) .style(theme.typography.body_bold.clone()) .color(theme.colors.text_primary), ) .center( TextWidget::new(lit!("drag · double-click maximize · right-click for menu ")) .style(theme.typography.small.clone()) .color(theme.colors.text_secondary), ) .close_action(|ctx| ctx.close_window()) }
With teksu!:
#![allow(unused)] fn main() { teksu!( TitleBar(host) { height: 40.0 background: theme.colors.surface_pressed border: theme.colors.text_secondary, 2.0 leading: TextWidget::new(lit!(" Teksilo — Title Bar Demo")) { style: theme.typography.body_bold.clone() color: theme.colors.text_primary } center: TextWidget::new(lit!("drag · double-click maximize · right-click for menu ")) { style: theme.typography.small.clone() color: theme.colors.text_secondary } close_action: |ctx| ctx.close_window() } ) }
Three things to note. First, border: color, width is the multi-argument property form. Second, the em-dash in "Teksilo — Title Bar Demo" and the middle dots in "drag · double-click maximize · right-click for menu " are preserved verbatim from the source. Third, leading: and center: are Category B slot values, written as full nested elements.
7.5 tab-widget, full TabWidget with multi-arg element values
Source (abbreviated):
#![allow(unused)] fn main() { let selected = ctx.signal(0_usize); let selected_label = selected.map(|index| match *index { 0 => "Overview".to_string(), 1 => "Inspector".to_string(), _ => "Activity".to_string(), }); let trailing = HStack::new() .spacing(12.0) .child( TextWidget::new(lit!("")) .text(selected_label) .style(theme.typography.small.clone()), ) .child( Button::new(lit!("Toggle Theme")) .style(ButtonVariant::Flat) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::ToggleTheme)), ); let tabs = ctx.add( TabWidget::new(selected) .tab(lit!("Overview"), Card::new() .header(TextWidget::new(lit!("Overview")) .style(theme.typography.body_bold.clone()) .color(theme.colors.text_primary)) .content(VStack::new().spacing(12.0)...)) .tab(lit!("Inspector"), Panel::new().padding(20.0)...) .tab(lit!("Activity"), Panel::new().padding(20.0)...) .tab_item(TabItem::new(lit!("Disabled"), Panel::new()...).enabled(false)) .trailing_slot(trailing), ); }
With teksu!:
#![allow(unused)] fn main() { let selected = ctx.signal(0_usize); let selected_label = selected.map(|index| match *index { 0 => "Overview".to_string(), 1 => "Inspector".to_string(), _ => "Activity".to_string(), }); let trailing = teksu!( HStack { spacing: 12.0 TextWidget::new(lit!("")) { text: selected_label style: theme.typography.small.clone() } Button::new(lit!("Toggle Theme")) { style: ButtonVariant::Flat on_activate_fn: |ctx| ctx.send_intent(AppIntent::ToggleTheme) } } ); let tabs = teksu!(ctx => TabWidget(selected) { tab: lit!("Overview"), Card { header: TextWidget::new(lit!("Overview")) { style: theme.typography.body_bold.clone() color: theme.colors.text_primary } content: VStack { spacing: 12.0 TextWidget::new(lit!("This first Milestone 6 slice ships a real TabWidget...")) HStack { spacing: 8.0 Badge::new(lit!("Dormant Panes")) Badge::new(lit!("Arrow Navigation")) Badge::new(lit!("Trailing Slot")) } } } tab: lit!("Inspector"), Panel { padding: 20.0 VStack { spacing: 10.0 TextWidget::new(lit!("Inspector")) { style: theme.typography.body_bold.clone() } TextWidget::new(lit!("Use Tab to move focus...")) } } tab: lit!("Activity"), Panel { padding: 20.0 VStack { spacing: 10.0, ... } } tab_item: TabItem::new(lit!("Disabled"), Panel { padding: 20.0 TextWidget::new(lit!("Disabled tabs are visible but cannot be activated.")) }) { enabled: false } trailing_slot: trailing } ); }
The tab: lit!("name"), Card { ... } pattern is the multi-argument property form with an element-valued second argument (tab is TabWidget's title-only shorthand for static_tab(TabInfo::new().title(label), content)). The tab_item: property takes a full TabItem element with its own body (enabled: false is a property on TabItem). trailing_slot: takes the previously-built trailing widget. Signals (selected, selected_label) stay as regular Rust let bindings because they are computed values, not widgets.
7.6 overlay-demo, Dialog / Popover / Snackbar (post-refactor)
Source with post-refactor API:
#![allow(unused)] fn main() { let modal_trigger_id = ctx.add( Dialog::new(lit!("Adaptive modal window")) .content(move || { DialogContent::new() .title(lit!("Adaptive modal dialog")) .supporting_text(lit!("The framework chooses the best modal presentation...")) .body(TextWidget::new(lit!("The app code does not branch..."))) .footer(Button::new(lit!("Close")).on_tap(|_, ctx| ctx.dismiss_modal())) }) .style(ButtonVariant::Regular), ); let popover = Popover::new(lit!("Show popover")) .content(popover_content) .caret_size(12.0) .trigger(popover_trigger); let snackbar = Snackbar::new(lit!("Show snackbar")) .content(snackbar_content) .auto_dismiss_after(Duration::from_millis(2500)); }
With teksu!:
#![allow(unused)] fn main() { let root = teksu!(ctx => ScrollArea { widget_resizable: true VStack { spacing: 24.0 TextWidget::new(lit!("Dialogs and Popovers")) { style: t.body_bold.clone() color: c.text_primary } TextWidget::new(lit!("Teksilo now resolves dialogs through a shared modal presentation pipeline, alongside anchored popovers and timed snackbars.")) { style: t.body.clone() color: c.text_secondary } Panel { padding: 20.0 HStack { spacing: 16.0 Popover::new(lit!("Show popover")) { content: VStack { spacing: 12.0 TextWidget::new(lit!("Popover")) { style: t.small.clone() } TextWidget::new(lit!("Use popovers for compact contextual actions without leaving the current surface.")) { style: t.body.clone() color: c.text_secondary } HStack { spacing: 8.0 Badge::new(lit!("Quick actions")) Badge::new(lit!("Inline help")) Badge::new(lit!("Inspector")) } } caret_size: 12.0 trigger: Panel { padding: 12.0 HStack { spacing: 10.0 Badge::new(lit!("Context")) TextWidget::new(lit!("Popover actions")) { style: t.small.clone() } } } } modal_trigger = Dialog::new(lit!("Adaptive modal window")) { content: move || teksu!( DialogContent { title_literal: "Adaptive modal dialog" supporting_text_literal: "The framework chooses the best modal presentation for the current backend: a native modal child window when reliable, otherwise a centered in-tree dialog." body: TextWidget::new(lit!("The app code does not branch on Wayland or window-system support here; it issues one modal request and lets Teksilo resolve it.")) { style: t.body.clone() color: c.text_secondary } footer: Button::new(lit!("Close")) { style: ButtonVariant::Default on_tap: |_, ctx| ctx.dismiss_modal() } } ) style: ButtonVariant::Regular } Snackbar::new(lit!("Show snackbar")) { content: HStack { spacing: 14.0 TextWidget::new(lit!("Autosave complete")) { style: t.body.clone() color: c.tooltip_text } Button::new(lit!("Dismiss")) { style: ButtonVariant::Regular on_tap: |_, ctx| ctx.dismiss_top_overlay() } } auto_dismiss_after: Duration::from_millis(2500) } } } // ... additional Notes panel ... } } ); }
Five things exercise the language here. First, ScrollArea is Category A post-refactor, so its VStack content is a body-block child. Second, the Dialog binding modal_trigger = uses the new assignment form to bind the dialog's id. Third, Dialog's content: property takes a move || factory closure whose body contains a nested teksu!(...) building the DialogContent. Fourth, DialogContent is Category B with title_literal, supporting_text_literal, body, and footer as slot properties. Fifth, trigger: in Popover takes a full element value, and all three trigger-like widgets (Popover, Snackbar, and Dialog as a button itself) appear at the same depth in the HStack.
7.7 internationalization, mixed declarative and imperative
Source:
#![allow(unused)] fn main() { let direction_signal = teksilo::i18n::current_direction(); let direction_label = ctx.signal(direction_note_label_for(direction_signal.as_ref())); if let Some(sig) = direction_signal.as_ref() { let target = direction_label.clone(); ctx.effect(sig, move |dir| { target.set(direction_note_label(*dir)); }); } let heading = ctx.add( TextWidget::new(tr!(heading())) .style(theme.typography.body_bold.clone()) .color(theme.colors.text_primary), ); // ... more let-adds ... }
With teksu!:
#![allow(unused)] fn main() { let root = teksu!(ctx => Panel { padding: 24.0 VStack { spacing: 16.0 let direction_signal = teksilo::i18n::current_direction(); let direction_label = ctx.signal( direction_note_label_for(direction_signal.as_ref()) ); rust { if let Some(sig) = direction_signal.as_ref() { let target = direction_label.clone(); ctx.effect(sig, move |dir| { target.set(direction_note_label(*dir)); }); }; } TextWidget(tr!(heading())) { style: theme.typography.body_bold.clone() color: theme.colors.text_primary } TextWidget(tr!(greeting(name = name))) { style: theme.typography.body_bold.clone() color: theme.colors.text_primary } TextWidget(tr!(body_paragraph())) { style: theme.typography.body.clone() color: theme.colors.text_primary } TextWidget::new(lit!("")) { text: direction_label style: theme.typography.small.clone() color: theme.colors.text_secondary } HStack { spacing: 8.0 TextWidget(tr!(language_label())) { style: theme.typography.body_bold.clone() color: theme.colors.text_primary } Button(tr!(lang_english())) { style: ButtonVariant::Regular on_activate_fn: |ctx| ctx.send_intent(AppIntent::SetEnglish) } Button(tr!(lang_french())) { style: ButtonVariant::Regular on_activate_fn: |ctx| ctx.send_intent(AppIntent::SetFrench) } Button(tr!(lang_arabic())) { style: ButtonVariant::Regular on_activate_fn: |ctx| ctx.send_intent(AppIntent::SetArabic) } } HStack { spacing: 12.0 Button(tr!(leading_button())) { style: ButtonVariant::Regular } Button(tr!(trailing_button())) { style: ButtonVariant::Regular } } } } ); }
All the hoisted let id = ctx.add(...) in the source collapse into declarative elements. The conditional ctx.effect registration goes in a side-effect rust { } block with a ; on its tail. The let bindings for signal handles use the let form at body position, scoping the signals to the VStack construction. TextWidget(tr!(...)) uses the default ::new constructor (localized); TextWidget::new(lit!("")) uses the literal constructor where the source does.
7.8 widget-catalog, event subscription in rust block
Source (abbreviated):
#![allow(unused)] fn main() { impl Widget for App { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let theme = ctx.theme().clone(); let t = &theme.typography; let c = &theme.colors; { let item_label = self.item_label.clone(); let app_ctx_sub = self.app_context.clone(); ctx.subscribe_event( Origin::DirectAccess(DirectAccessEntity::Item(EntityEvent::Created)), move |event: &Event| { if let Some(id) = event.ids.first() { if let Ok(Some(dto)) = item_commands::get_item(&app_ctx_sub, id) { item_label.set( tr!(created_info(title = dto.title, id = dto.id)).resolve_now(), ); } } }, ); } let write_signal = self.write_signal.clone(); let label = self.write_signal.map(|text| format!("TeksiloApp Widget Catalog {}", text)); let item_label_for_bind = self.item_label.clone(); let item_label_for_handler = self.item_label.clone(); let app_ctx = self.app_context.clone(); let root = ctx.add( VStack::new() .child( TextWidget::new(tr!(title())) .text(label) .style(t.body.clone()) .color(c.text_primary), ) .child( Button::new(tr!(write_something_button())) .on_activate_fn(move |_| { write_signal.set("Hello from the button!".to_string()); }) .style(ButtonVariant::Default), ) // ... more children ... .child(Expand::new().fills_stack()) .child( StatusBar::new().child( TextWidget::new(tr!(milestone_status())) .style(t.tiny.clone()) .color(c.text_secondary), ), ), ); self.root_child_id = Some(root); vec![root] } } }
With teksu!:
#![allow(unused)] fn main() { impl Widget for App { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let theme = ctx.theme().clone(); let t = &theme.typography; let c = &theme.colors; let root = teksu!(ctx => VStack { let write_signal = self.write_signal.clone(); let label = self.write_signal.map(|text| format!("TeksiloApp Widget Catalog {}", text)); let item_label_for_bind = self.item_label.clone(); let item_label_for_handler = self.item_label.clone(); let app_ctx = self.app_context.clone(); rust { let item_label = self.item_label.clone(); let app_ctx_sub = self.app_context.clone(); ctx.subscribe_event( Origin::DirectAccess(DirectAccessEntity::Item(EntityEvent::Created)), move |event: &Event| { if let Some(id) = event.ids.first() { if let Ok(Some(dto)) = item_commands::get_item(&app_ctx_sub, id) { item_label.set( tr!(created_info(title = dto.title, id = dto.id)) .resolve_now(), ); } } }, ); } TextWidget(tr!(title())) { text: label style: t.body.clone() color: c.text_primary } Button(tr!(write_something_button())) { style: ButtonVariant::Default on_activate_fn: move |_| { write_signal.set("Hello from the button!".to_string()); } } Button(tr!(create_item_locally_button())) { on_activate_fn: move |_| { let result = create_orphan_item( &app_ctx, None, &CreateItemDto { title: "Local Item".to_string(), ..Default::default() }, ); if let Ok(item) = result { item_label_for_handler.set( format!("Got: {} (id={})", item.title, item.id) ); } } } Button(tr!(add_item_appcommand_button())) { style: ButtonVariant::Default on_activate_fn: |ctx| ctx.send_intent(AppIntent::AddItem) } TextWidget(tr!(add_item_label())) { text: item_label_for_bind style: t.body.clone() color: c.text_primary } Button(tr!(toggle_dark_mode_button())) { style: ButtonVariant::Regular on_activate_fn: |ctx| ctx.send_intent(AppIntent::ToggleDarkMode) } Expand { fills_stack } StatusBar { TextWidget(tr!(milestone_status())) { style: t.tiny.clone() color: c.text_secondary } } } ); self.root_child_id = Some(root); vec![root] } } }
The let forms at body position handle the signal cloning. The rust { } side-effect block registers the event subscription. Expand { fills_stack } uses the bare-lowercase-identifier rule for argument-free properties. StatusBar is Category A, so its child is a bare element.
7.9 Card, Category B with bound slot widget
An illustration of the name = Element binding used in slot position:
#![allow(unused)] fn main() { // Builder let title_id = ctx.add( TextWidget::new("Manuscript Title") .style(t.body_bold.clone()) .color(c.text_primary), ); let card = Card::new() .header_id(title_id) .content( VStack::new() .spacing(12.0) .child(TextWidget::new("Edit title:")) .child( Button::new("Focus title") .on_tap(move |_, ctx| ctx.focus(title_id)), ), ) .footer(Button::new("Save").on_activate_fn(|ctx| ctx.send_intent(AppIntent::SaveTitle))) .padding(16.0); }
With teksu!:
#![allow(unused)] fn main() { teksu!( Card { header: title = TextWidget("Manuscript Title") { style: t.body_bold.clone() color: c.text_primary } content: VStack { spacing: 12.0 TextWidget("Edit title:") Button("Focus title") { on_tap: move |_, ctx| ctx.focus(title) } } footer: Button("Save") { on_activate_fn: |ctx| ctx.send_intent(AppIntent::SaveTitle) } padding: 16.0 } ) }
title = binds the TextWidget's id at the slot position; the id is available anywhere in the enclosing block, including the on_tap closure in the content slot's Button. The macro emits ctx.add(TextWidget::new(...)...) as a hoisted statement, then uses .header_id(title) on the Card. The on_tap handler captures title by value through move, which is what the user wrote.
8. Handler Attachment Rules
The V2 model splits handlers between two attachment patterns (architecture §28.3): handlers on child widgets (Checkbox on MinSize, Accordion on its header) and handlers attached to self via HandlerSet::new() + ctx.apply_self_handlers() (Button, Toggle, Slider, SegmentedControl).
teksu! does not change this. Handlers written on an element attach via the builder methods of that element. Which attachment mechanism the builder uses internally is a per-widget implementation detail.
For the rarer case of attaching handlers to self inside a widget's own build() method, teksu! is not the tool. That is infrastructure code that uses HandlerSet and ctx.apply_self_handlers() directly. teksu! is for constructing trees, not for authoring internals.
9. Error Reporting Discipline
Every span the macro emits must be traceable to a user token. The tr! macro established the precedent: a missing translation key produces an error pointing at the key identifier. teksu! adheres to the same discipline.
9.1 Span Mapping Rules
Type errors on widget constructors point at the type path. Buton::new("x") { ... } fails with cannot find type 'Buton' under the Buton identifier.
Type errors on property values point at the value expression. TextWidget("x") { color: "red" } fails with expected Color, found &str under "red".
Method-not-found errors on properties point at the property name, via the compiler's existing method-resolution diagnostics.
Arity mismatches on handler closures point at the closure parameter list.
Structural form errors (if without a valid block, for without in) point at the structural keyword.
Parsing errors where an element prefix matched but the element failed to parse fully point at the token where parsing went wrong.
9.2 Common Errors
error: bindings use `=`, not `:`
--> src/main.rs:15:16
|
15 | Button("x") id: my_btn { }
| ^^
|
= help: use `my_btn = Button("x") { }` instead
error: expected property, binding, or child element, found `,`
--> src/main.rs:20:29
|
20 | VStack { spacing: 8.0, TextWidget("x") }
| ^
|
= help: teksu! blocks separate items by newlines, not commas
error: no method named `child` found on type `Card`
--> src/main.rs:25:5
|
25 | Card { TextWidget("hi") }
| ^^^^^^^^^^^^^^^^
|
= note: Card is a Category B widget with named slots (header, content, footer)
= help: use `content: TextWidget("hi")` to set the content slot
The last message is aspirational: the macro can detect bare-child usage in Category B contexts by maintaining a list of known .child()-having types and emitting a helpful diagnostic. This is worth the bookkeeping because it saves users from a generic method-resolution error on .child() that does not say why.
10. What teksu! Does Not Do
Implicit theme access. Every reference to theme tokens is an explicit Rust path. Implicit access would require a thread-local (fights multi-window) or an injected ctx parameter (breaks error messages). Mitigation: a small themed! helper macro that expands to let t = &theme.typography; let c = &theme.colors;.
Implicit reactive bindings. text: model.title passes the value once. To get reactivity, write text: signal.map(...). This matches Prop<T>.
Automatic animation syntax. Users call signal.animate_to(target, duration, easing) in regular Rust.
Hot-reload. teksu! expansions are Rust code. No runtime parser, no structural hot-reload. Translation hot-reload works through --translation-dev.
Inline doc comments on elements. Users put doc comments on helper functions or use regular Rust comments inside the block. Worth revisiting later.
Two-way binding syntax. Two-way binding in Teksilo is expressed by passing a Signal<T> to a widget's bind method; the widget commits changes back through its event handlers. No := form.
Implicit closure capture. move stays explicit. Rust users know the keyword; eliding it produces confusing errors.
11. Implementation Notes
The macro lives in a new crate teksilo-macros, exported through the teksilo umbrella as teksilo::teksu!. Four responsibilities.
Lexical parsing uses syn and a hand-written recursive-descent parser for the body grammar. syn handles positional-argument paren groups, body braces, and embedded Rust expressions. The "commit on distinctive prefix" rule is a fixed two-token lookahead, no backtracking.
IR construction produces a typed tree: TeksiElement, TeksiProperty, TeksiBinding, TeksiStructural, TeksiSpread, TeksiLet, TeksiEscape, TeksiRust. One IR node per grammar production.
Translation walks the IR and emits quote!-generated builder calls, preserving spans via quote_spanned!.
Diagnostic emission. Statically detectable errors (malformed grammar, id: used instead of =, name: with no arguments, bare child in Category B context) emit compile_error! with clean spans. Type errors emit clean builder calls and let the compiler's native diagnostics surface.
Supporting types. TeksiBranch<L, R>, TeksiBranch3, TeksiBranch4, IntoTeksiChild live in teksilo-core::widget_builder as public types. They are not DSL-specific: hand-written builder chains can use them.
Bootstrapping. Develop, test, and land the macro after the framework changes in Appendix A. The tr! macro's infrastructure (crate layout, trybuild tests, span discipline, rebuild tracking) is the template. Estimated cost: four to six weeks including trybuild corpus and documentation rewrite.
Test strategy. trybuild fixtures for every error class in §9. Golden-file tests for translation rules using cargo expand. Integration tests rewriting the seven uploaded examples to teksu! form and verifying rendered output is bitwise identical.
12. Summary
The teksu! language is a block-structured DSL for Teksilo widget trees. It reads like QML or Kotlin, compiles to V2 builder calls with no runtime overhead, preserves existing reactivity and capture semantics without new syntax, and produces user-facing error spans.
The grammar has three primary forms: elements with explicit constructors, bindings via name = Element, and properties including named slots; structural control flow (if, for, match, let, ..spread, rust { }); and one escape hatch (#{ expr }). Each form has a mechanical desugaring into existing Teksilo infrastructure.
The widget catalog divides into two categories: Category A containers accepting body-block children, and Category B composites with named slot properties. Appendix A specifies the framework changes that complete this split.
Appendix A: Required Framework Changes
The DSL assumes these framework changes are applied. Each is mechanical and takes roughly an hour; all together, an afternoon.
A.1 Category C Dissolution
Move primary content from constructor argument to setter method on four widgets.
ScrollArea:
- pub fn new(child: impl Widget + 'static) -> Self
+ pub fn new() -> Self
+ pub fn child(mut self, child: impl Widget + 'static) -> Self
Joins Category A. .from_id(id) stays as the id-taking alternate constructor.
Popover:
- pub fn new(label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self
+ pub fn new(label: impl Into<LocalizedString>) -> Self
+ pub fn content(mut self, content: impl Widget + 'static) -> Self
Joins Category B.
Snackbar:
- pub fn new(label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self
+ pub fn new(label: impl Into<LocalizedString>) -> Self
+ pub fn content(mut self, content: impl Widget + 'static) -> Self
Joins Category B.
Dialog:
- pub fn new<W, F>(label: impl Into<LocalizedString>, factory: F) -> Self
- where W: Widget + 'static, F: Fn() -> W + 'static
+ pub fn new(label: impl Into<LocalizedString>) -> Self
+ pub fn content<W, F>(mut self, factory: F) -> Self
+ where W: Widget + 'static, F: Fn() -> W + 'static
Joins Category B. .content() takes the factory closure (the lazy construction semantics are preserved).
A.2 Rename .set_* Methods
The .set_* prefix convention becomes *_id universally, matching SplitView's existing .first_id / .second_id.
Rename:
- Panel:
.set_child(id)→.child_id(id) - Padding:
.set_child(id)→.child_id(id) - Expand:
.set_child(id)→.child_id(id) - GroupBox:
.set_child(id)→.child_id(id) - Accordion:
.set_content(id)→.content_id(id)
A.3 New Id-Taking Twins
Every Category B slot method gains an *_id twin.
Card:
#![allow(unused)] fn main() { pub fn header_id(mut self, id: WidgetId) -> Self pub fn content_id(mut self, id: WidgetId) -> Self pub fn footer_id(mut self, id: WidgetId) -> Self }
TitleBar:
#![allow(unused)] fn main() { pub fn leading_id(mut self, id: WidgetId) -> Self pub fn center_id(mut self, id: WidgetId) -> Self pub fn trailing_id(mut self, id: WidgetId) -> Self }
DialogContent:
#![allow(unused)] fn main() { pub fn body_id(mut self, id: WidgetId) -> Self pub fn footer_id(mut self, id: WidgetId) -> Self }
Breadcrumb:
#![allow(unused)] fn main() { pub fn item_id(mut self, id: WidgetId) -> Self pub fn trailing_slot_id(mut self, id: WidgetId) -> Self }
TabWidget:
#![allow(unused)] fn main() { pub fn tab_id(mut self, label: impl Into<LocalizedString>, id: WidgetId) -> Self pub fn tab_item_id(mut self, item: TabItem) -> Self // TabItem carries the id internally pub fn trailing_slot_id(mut self, id: WidgetId) -> Self }
Popover:
#![allow(unused)] fn main() { pub fn content_id(mut self, id: WidgetId) -> Self pub fn trigger_id(mut self, id: WidgetId) -> Self }
Snackbar:
#![allow(unused)] fn main() { pub fn content_id(mut self, id: WidgetId) -> Self pub fn trigger_id(mut self, id: WidgetId) -> Self }
Dialog:
#![allow(unused)] fn main() { pub fn trigger_id(mut self, id: WidgetId) -> Self // No content_id; the factory closure can use ctx.add and from_id internally if needed. }
A.4 TeksiBranch Types and IntoTeksiChild Trait
Add to teksilo-core::widget_builder:
#![allow(unused)] fn main() { pub enum TeksiBranch<L: Widget, R: Widget> { L(L), R(R) } pub enum TeksiBranch3<A: Widget, B: Widget, C: Widget> { A(A), B(B), C(C) } pub enum TeksiBranch4<A, B, C, D> { ... } // Widget impl for each variant dispatches to the active arm. pub trait IntoTeksiChild { ... } // Blanket impls for impl Widget and WidgetId. // Used by child(), add_child(), slot_id() routing. }
A.5 Summary of Effort
Category C dissolution: 4 widgets, roughly 20 lines of change each. Method renames: 5 widgets, roughly 3 lines each. Id-taking twins: 8 widgets, roughly 30 new methods total at 3 lines each. TeksiBranch infrastructure: 1 new file, roughly 200 lines including the impls.
Total: an afternoon of mechanical work, plus test updates. The seven uploaded example files need migration to the new API; each example is a dozen lines of change on average.
Appendix B: Known Open Questions
One question carried over from v2's appendix.
Should let bindings inside a DSL body ever produce let mut, or always let? Current answer: always let. A user needing a mut binding writes a rust { } block.
No other open questions remain from prior drafts. Questions about the DSL's semantics that arise during implementation should be resolved against this spec or against the architecture document, with a changelog entry here if either changes.
cargo teksilo-fmt — formatter for teksu! blocks
rustfmt treats macro bodies as opaque token streams and won't descend
into a teksu!(...) invocation, so the DSL inside is hand-formatted by
default. cargo teksilo-fmt fills the gap: it walks Rust source files,
finds every teksu! invocation, reformats the body, and writes the
file back in place. Source outside teksu! blocks is byte-for-byte
unchanged — cargo fmt still owns Rust formatting.
For the surface language the formatter normalizes, see teksu-macro-reference.md.
Installation
The crate ships in this workspace under crates/cargo-teksilo-fmt. Install it with:
cargo install --path crates/cargo-teksilo-fmt
cargo install puts the binary in ~/.cargo/bin/cargo-teksilo-fmt,
which Cargo picks up as the cargo teksilo-fmt subcommand from any
directory. Verify with:
cargo teksilo-fmt --version
To uninstall: cargo uninstall cargo-teksilo-fmt.
Usage
cargo teksilo-fmt [OPTIONS] [paths...]
OPTIONS:
--check Read-only; exit 1 if any file would change
--quiet, -q Suppress per-file output
--help, -h Print help
--version, -V Print version
POSITIONAL:
paths Files or directories to format. Directories
are walked recursively for *.rs, skipping
target/ and hidden directories. Defaults to
the current directory.
Examples:
cargo teksilo-fmt # format from CWD
cargo teksilo-fmt --check # CI mode: exit 1 if dirty
cargo teksilo-fmt examples/widget_catalog # format one example
cargo teksilo-fmt src/main.rs src/build.rs # format specific files
Files containing no teksu! token are skipped before parsing — there's
no measurable cost on a workspace where most modules don't use the DSL.
Writes are atomic: the formatted output goes into a sibling
NamedTempFile and is renamed into place. An interrupted run never
leaves a truncated source file on disk.
What gets normalized
The formatter rewrites the shape of teksu! bodies. Rust
expressions inside (property values, positional args, closure bodies,
escape exprs, rust { … } blocks) are spliced verbatim from source —
the formatter does not reformat Rust.
Layout rules (v1):
- 4-space indent per nesting level.
- Elements with a non-empty body span multiple lines:
Type(args) {\n <items>\n}. - Elements with no body or with an empty
{}body emit on one line without braces:Divider { }→Divider. - One body item per line. Properties keep
name: valueshape. - Property order is preserved verbatim. The macro lowering reorders
handler properties (
on_tap,cursor, …) to the end of the chain at compile time; that's a lowering concern, not the formatter's, so what you wrote is what you get back. - The
ctx => <element>preamble joins to the root element on the same line:teksu!(ctx =>\n VStack { … })becomesteksu!(ctx => VStack { … }). - Continuation lines are aligned to where the user already had the
body's outermost
}in source — the closing brace of the reformatted output lands at the same column.
Trivia preservation
Comments and blank lines between body items survive a reformat.
#![allow(unused)] fn main() { // before teksu!(ctx => VStack { spacing: 12.0 // user-added section header Button(lit!("Save")) Button(lit!("Cancel")) } ) // after — unchanged teksu!(ctx => VStack { spacing: 12.0 // user-added section header Button(lit!("Save")) Button(lit!("Cancel")) } ) }
How it works: syn::ParseStream discards comments before they reach
the IR, so the formatter runs a separate pass over the original
TokenStream using proc_macro2::Span::byte_range() to record the
inter-token gaps in source. The pretty-printer then drains that table
by byte offset, emitting each comment / blank-line marker at the
right indent level before the next body item.
Multiple consecutive blank lines collapse to a single blank line.
Comments inside Rust expressions (e.g. Button(/* note */ lit!("ok")))
are preserved automatically because expression values are sliced
verbatim from source — they ride along with the rest of the slice.
CI integration
Use --check to fail the build when any file would be reformatted:
# .github/workflows/lint.yml (or equivalent)
- name: teksilo-fmt
run: cargo teksilo-fmt --check
--check is read-only, prints Would reformat: <path> for each dirty
file, and exits 1 if any.
For a pre-commit hook, run without --check:
# .git/hooks/pre-commit
cargo teksilo-fmt --quiet
git add -u
Library API
The CLI is a thin wrapper around the teksilo-fmt library crate. Editor integrations can call into it directly:
#![allow(unused)] fn main() { use teksilo_fmt::{format_block, format_file, FmtConfig, FmtError}; // Format a single teksu! body string (the contents inside the macro parens): let cfg = FmtConfig::default(); let formatted = format_block("ctx => VStack { spacing: 8.0 }", &cfg)?; // Format every teksu! invocation in a Rust file's source: let new_source = format_file(&source_text, &cfg)?; }
Both functions are pure: they take a &str and return a String.
There's no I/O at the library level. format_file detects the host
file's line ending convention (LF or CRLF) and applies it to every
newline the formatter emits, so a CRLF file round-trips as CRLF.
FmtConfig is empty in v1 — every invocation produces canonical
output. Style knobs may be added later; defaults will not change for
existing invocations.
Editor integration (LSP)
The teksilo-fmt-lsp crate ships a minimal Language Server Protocol server that wraps the formatter. Install:
cargo install --path crates/teksilo-fmt-lsp
The server speaks JSON-RPC over stdio and advertises a single
capability: documentFormattingProvider. Wire it into your editor
as a secondary formatter for Rust files (rust-analyzer stays
your primary).
Helix
# ~/.config/helix/languages.toml
[language-server.teksilo-fmt-lsp]
command = "teksilo-fmt-lsp"
[[language]]
name = "rust"
language-servers = [{ name = "rust-analyzer" }, { name = "teksilo-fmt-lsp" }]
VS Code
VS Code needs an extension to register an LSP server. See the
dedicated walkthrough in teksilo-fmt-vscode.md —
it covers two paths: a five-minute Run on Save hook (no LSP) and a
small custom extension that registers teksilo-fmt-lsp for Rust
documents.
Neovim (nvim-lspconfig)
local configs = require('lspconfig.configs')
configs.teksilo_fmt_lsp = {
default_config = {
cmd = { 'teksilo-fmt-lsp' },
filetypes = { 'rust' },
root_dir = require('lspconfig.util').root_pattern('Cargo.toml'),
settings = {},
},
}
require('lspconfig').teksilo_fmt_lsp.setup{}
Behavior
- On every
textDocument/formattingrequest, the server runsteksilo_fmt::format_fileon the buffer contents and returns either an empty edit list (already canonical) or a single full-documentTextEdit(entire buffer replaced with formatted output). - A parse error in the host file or in any
teksu!body is treated as "leave it alone" — the server returns empty edits, mirroring howrustfmtbehaves on save when Rust source is mid-edit. - Document sync is full (mode 1): every change resends the whole text. Cheaper than maintaining incremental-diff state for a format-only server.
Architecture
Four crates, layered:
- teksilo-parse — parser and IR for the
teksu!DSL. Extracted fromteksilo-macrosso non-proc-macro consumers (the formatter, future linters, editor tooling) can build on the same grammar without depending on aproc-macro = truecrate. The proc-macro crate now depends on it. - teksilo-fmt — pure formatter library.
Pretty-printer, byte-range trivia scanner, host-file
teksu!-macro visitor, LF/CRLF detection. - cargo-teksilo-fmt — CLI binary. File
walker, in-place rewriter with atomic writes,
--checkmode. - teksilo-fmt-lsp — LSP server binary.
Hand-rolled JSON-RPC over stdio (no tokio); thin wrapper around
format_file.
The CLI and LSP have no teksu! grammar knowledge — all parsing and
printing happens in the library crates.
Limitations (v1)
- Closure / multi-line expression bodies inside property values
use uniform dedent + reindent. The output is round-trip stable
(
format(format(x)) == format(x)) but brace alignment inside a closure body may drift one level from what hand-formatting would produce. Hand-fix as needed; the alignment doesn't affect parsing. - Empty bodies normalize to bodyless form:
Divider { }becomesDivider. The two are semantically identical to the macro; the formatter picks one. ctx =>line-break collapse: actx =>preamble joins to the root element on the same line, even if the user wrote them across lines. Multi-line preambles aren't a documented form anywhere; the formatter standardizes on the joined shape.- No configurable rules. Indent width, brace style, and reflow
thresholds are fixed. Configurability may come in a later version
through
FmtConfig.
Related
- teksu-macro-reference.md — surface language for the DSL the formatter operates on.
- teksu-language-spec-v3.md — design spec with worked translations of widget-catalog examples.
- crates/teksilo/tests/teksu/pass/
— trybuild fixtures that double as canonical examples of well-
formatted
teksu!blocks.
Using teksilo-fmt in VS Code
This guide walks through wiring teksilo-fmt-lsp
into VS Code so teksu! blocks reformat on save alongside rust-analyzer.
There are two practical paths:
- Path A — no LSP, run-on-save: invoke
cargo teksilo-fmtfrom a shell hook on every save. Simplest setup, no extension authoring. - Path B — LSP via a tiny custom extension: install
teksilo-fmt-lspand a ~30-line VS Code extension that registers it for Rust files. More moving parts, but you get the LSP capability surface (so it composes with rust-analyzer cleanly and obeys VS Code's standard format-on-save flow).
If you just want it to work today, do Path A. If you're already authoring VS Code extensions or want format-on-save to participate in the editor's normal formatter chain, do Path B.
Path A — run-on-save
1. Install the CLI
cargo install --path crates/cargo-teksilo-fmt
Verify:
cargo teksilo-fmt --version
2. Install the Run on Save extension
Open the Extensions view (Ctrl+Shift+X), search for emeraldwalk.runonsave
("Run on Save" by emeraldwalk), and install it.
3. Configure the hook
Add this to your workspace settings.json
(.vscode/settings.json at the repo root):
{
"emeraldwalk.runonsave": {
"commands": [
{
"match": "\\.rs$",
"isAsync": true,
"cmd": "cargo teksilo-fmt --quiet \"${file}\""
}
]
}
}
That's it. Save any .rs file and the hook runs cargo teksilo-fmt on
just that file. Files without teksu! are skipped before parsing
(cheap string scan), so the cost on non-teksu files is negligible.
Notes
- The hook runs after VS Code's own formatter (rust-analyzer / rustfmt). That's the right ordering — rustfmt formats Rust, then teksilo-fmt formats teksu! bodies, then the buffer is saved-then- reloaded by VS Code if the on-disk file changed.
- The async write is safe with
cargo teksilo-fmt's atomic-rename strategy — VS Code reloads the buffer when it sees the inode change. - Use
${workspaceFolder}instead of${file}if you want every save to format the entire workspace (slower; usually overkill).
Path B — LSP via a custom extension
Use this if you want the formatter to participate in VS Code's normal Format Document / Format Document With… UI, or if you're already authoring a VS Code extension and want to bundle this in.
1. Install the LSP server binary
cargo install --path crates/teksilo-fmt-lsp
Verify the binary is on PATH:
which teksilo-fmt-lsp
teksilo-fmt-lsp --help 2>&1 | head -3 || true
(teksilo-fmt-lsp doesn't print help — it just speaks JSON-RPC on
stdio. The which check is enough.)
2. Scaffold the extension
Create a directory anywhere outside this repo (e.g.
~/.vscode/teksilo-fmt-extension/) with the following files.
package.json
{
"name": "teksilo-fmt-vscode",
"displayName": "teksilo-fmt",
"description": "Format teksu! DSL blocks via teksilo-fmt-lsp",
"version": "0.1.0",
"publisher": "local",
"engines": { "vscode": "^1.75.0" },
"categories": ["Formatters"],
"activationEvents": ["onLanguage:rust"],
"main": "./out/extension.js",
"contributes": {},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/vscode": "^1.75.0",
"typescript": "^5.3.0"
}
}
tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"target": "es2022",
"outDir": "out",
"lib": ["es2022"],
"sourceMap": true,
"strict": true
},
"include": ["src/**/*"]
}
src/extension.ts
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient | undefined;
export function activate(context: vscode.ExtensionContext) {
const config = vscode.workspace.getConfiguration('teksiloFmt');
const command = config.get<string>('serverPath') ?? 'teksilo-fmt-lsp';
const serverOptions: ServerOptions = {
run: { command, transport: TransportKind.stdio },
debug: { command, transport: TransportKind.stdio },
};
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'rust' }],
};
client = new LanguageClient(
'teksiloFmt',
'teksilo-fmt LSP',
serverOptions,
clientOptions,
);
client.start();
}
export function deactivate(): Thenable<void> | undefined {
return client?.stop();
}
3. Build the extension
cd ~/.vscode/teksilo-fmt-extension
npm install
npm run compile
4. Sideload it
The simplest way is the Run Extension command:
- Open the extension folder in VS Code.
- Press
F5to launch a development host with the extension loaded.
For a permanent install without packaging:
- Symlink the folder into
~/.vscode/extensions/:ln -s ~/.vscode/teksilo-fmt-extension ~/.vscode/extensions/local.teksilo-fmt-vscode-0.1.0 - Restart VS Code.
For team-wide distribution, package with vsce package and share the
.vsix file (each developer runs code --install-extension teksilo-fmt-vscode-0.1.0.vsix).
5. Configure format-on-save
VS Code can run multiple formatters in sequence. Add this to your
workspace settings.json so rust-analyzer formats Rust first, then
teksilo-fmt formats teksu! bodies:
{
"[rust]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "rust-lang.rust-analyzer",
"editor.codeActionsOnSave": {
"source.formatDocument": "explicit"
}
}
}
To explicitly invoke teksilo-fmt on demand, use the Format Document
With… command (Ctrl+Shift+P → "Format Document With…") and pick
teksilo-fmt LSP. To make teksilo-fmt the default formatter (instead of
rust-analyzer), change editor.defaultFormatter to "local.teksilo-fmt-vscode".
Most users will want rust-analyzer as default and teksilo-fmt as a
secondary action.
6. (Optional) Custom server path
If teksilo-fmt-lsp isn't on PATH, point the extension at it:
{
"teksiloFmt.serverPath": "/home/you/.cargo/bin/teksilo-fmt-lsp"
}
The extension reads this on activation; reload the window after changing it.
Troubleshooting
Nothing happens on save. First check that the binary works at all:
echo '' | teksilo-fmt-lsp
(It should sit waiting for input. Press Ctrl+C to exit.)
If that works, open VS Code's Output panel and select the teksilo-fmt LSP channel — initialization errors and JSON-RPC traffic land there.
Format on save reformats things I didn't expect.
The formatter has documented normalizations (empty bodies collapse,
ctx => joins to root). Run cargo teksilo-fmt --check src/your-file.rs
from the terminal to see the exact diff before letting the editor
apply it.
Format-on-save fights with rust-analyzer.
With both formatters wired and formatOnSave true, VS Code runs only
the default formatter. To run both, either:
- Use Path A (run-on-save shell command) instead of the LSP — that runs after VS Code's own format pass.
- Add a code action that invokes both (
source.formatDocumentfor rust-analyzer, then a custom command for teksilo-fmt). See VS Code's code-actions-on-save docs.
The extension says "teksilo-fmt-lsp not found".
Set teksiloFmt.serverPath explicitly in settings (step 6) or add
~/.cargo/bin to your shell's PATH and restart VS Code.
Why two paths?
Path A is a five-minute setup with no maintenance burden — cargo teksilo-fmt is a self-contained tool, the Run on Save extension is
maintained by someone else, and there's nothing for you to keep
working as VS Code or LSP versions drift.
Path B integrates with VS Code's first-class formatter chain, but you own a TypeScript extension. For most users — including a single developer working on a personal project — Path A is the right trade-off. Path B is worth it when you have multiple developers and want the formatter discoverable through VS Code's standard UI rather than needing every dev to install a third-party extension and edit their settings.
Both paths use the same underlying formatter, produce the same output, and can coexist (set up Path A as a fallback when the extension isn't loaded, for example).
Related
- teksilo-fmt.md — full reference for the formatter, including library API, normalization rules, and architecture.
- crates/teksilo-fmt-lsp/ — server source.
- crates/cargo-teksilo-fmt/ — CLI source.
Shortcut / Intent / Action Reference
Teksilo's input-to-behavior pipeline has three first-class concepts:
Shortcut— a rebindable keyboard binding (KeyStroke→ intent name). Owned by theShortcutRegistry; user rebindings layer on top of widget-declared defaults.Intent— a runtime "something wants to happen" message: a stable name plus an optional type-erased payload. Produced by shortcuts, by widgets viactx.send_intent(...), or programmatically.Action— a widget-owned handler bound to an intent name. When an intent dispatches, the framework walks source-widget → root and lets the first matching enabled action consume (or propagate) it.
Pipeline in one line: KeyStroke → Shortcut → Intent → Action handler.
Typed DTO bridge between an app's intent enum and the runtime
usually derived with #[derive(IntentKind)] from teksilo-macros.
Full end-to-end example:
examples/shortcuts_demo.
Mental model: three paths, one dispatcher
Every intent hits the same dispatcher — actions don't care where the intent came from. The three firing paths:
| Path | How the intent is built | Anchor for source→root walk |
|---|---|---|
| Shortcut (keyboard chord) | Registry invokes on_activate or synthesizes Intent::new | Focused widget or root fallback |
Widget handler (ctx.send_intent) | Handler builds or returns an Into<Intent> value | The originating widget |
| Programmatic (tests, tools) | Build Intent by hand or via IntentKind::into_intent | Caller-supplied source id |
The name is the dispatch key; the payload (if any) is downcastable data the handler extracts when it needs typed fields.
KeyStroke
A single chord — one Key plus its Modifiers:
#![allow(unused)] fn main() { KeyStroke::new(Key::S, Modifiers::CTRL) KeyStroke::ctrl(Key::S) // same thing KeyStroke::ctrl_shift(Key::S) KeyStroke::command(Key::S) // ⌘S on macOS, Ctrl+S elsewhere KeyStroke::command_shift(Key::S) KeyStroke::alt(Key::Enter) KeyStroke::new(Key::PageUp, Modifiers::NONE) // plain PageUp }
Display renders "Ctrl+S" style text. Widgets displaying shortcuts to users
should call teksilo_widgets::keystroke_format::format_keystroke() which handles
platform-specific symbols (⌘ on macOS) and locale-aware modifier names via
tr_widget! (e.g., "Strg" in German). Serialize/Deserialize are derived
so user overrides can be persisted.
Ctrl means ⌘ on macOS
Desktop platforms disagree about which key carries application accelerators, and
on macOS the disagreement is not cosmetic: Control there belongs to the text
system and to the secondary click, while ⌘ is what a user presses for Save or
Find. So a declared shortcut default written with Ctrl is read as the
platform's primary accelerator and resolves to ⌘ on macOS — the convention Qt
spells Qt::CTRL, and the one Teksilo's native menu bar has always applied to
its key equivalents. Write the chord once:
#![allow(unused)] fn main() { Shortcut::new("editor.find").primary(KeyStroke::ctrl(Key::F)).build() // Ctrl+F on Windows and Linux, ⌘F on macOS — one declaration, no cfg branching. }
KeyStroke::command(Key::F) is the same chord with the intent stated outright;
prefer it for chords built outside the registry, such as the labels a
context menu renders for itself. Modifiers::COMMAND and Modifiers::command()
are the raw forms, for widgets testing a live Modifiers value.
Three consequences worth knowing:
- User overrides are literal. A chord captured in a settings UI is taken exactly as pressed, so physical ⌃F stays bindable on macOS. Only declared defaults go through the convention.
- A chord that names
Superexplicitly is left alone, soCtrl+Supersurvives as the genuine ⌃⌘ two-modifier chord. - Some chords really are Control everywhere. Ctrl+Tab cycles tabs on macOS
too (⌘⇥ belongs to the application switcher and never reaches an app), and the
⌘ form of Space, H or Q is taken by the system. Declare those with
ShortcutBuilder::literal_modifiers()and no rewriting happens on any platform:
#![allow(unused)] fn main() { Shortcut::new("view.next_tab") .literal_modifiers() .primary(KeyStroke::ctrl(Key::Tab)) .build() }
Caret motion is a separate question
Inside a text surface the accelerator is not the whole story. macOS lays the
caret motions out across three modifiers — ⌥←/→ for word, ⌘←/→ for the
line edge, ⌘↑/↓ for the document — where Windows and Linux use Ctrl+←/→
plus bare Home/End. No single "is the accelerator held?" flag can express
that, so RichTextEditor, CodeEditor and every field built on
TextInputField read their arrows through
common::text_nav instead.
That is internal to the widgets; app-declared Shortcuts are unaffected.
Shortcut
Declarative, rebindable record. Built with a fluent ShortcutBuilder:
#![allow(unused)] fn main() { use teksilo::core::shortcut::{KeyStroke, Shortcut, ShortcutScope}; Shortcut::new("app.save") // stable id (dispatch key) .name("Save") // menu/settings label .category("File") // settings-UI grouping .primary(KeyStroke::ctrl(Key::S)) // default primary chord .secondary(KeyStroke::new(Key::F12, Modifiers::NONE)) // .scope(ShortcutScope::Global) // default // .scope_to(scope_root_id) // widget-scoped variant // .enabled_when(has_selection_signal) // reactive "live" predicate // .propagate_when_disabled(false) // consume-when-disabled instead // .on_activate(|ks, ctx| AppIntent::ScrollBy(...)) // parametric .build(); }
Key fields (source):
id: &'static str— stable key used for persistence, menu lookups (MenuItem::for_shortcut), and dispatch. Dot-style convention:"editor.format.bold". Doubles as the intent name when.intent(...)isn't set.primary/secondary— the two default chords. User overrides (loaded from disk or set through the settings UI) are applied per slot independently.scope: ShortcutScope—Global(fires regardless of focus) orScoped(WidgetId)(fires only when focus is inside that subtree). Widget-declared shortcuts default to scoped; app-level declarations use global.on_activate— optional closure invoked at activation time. Receives the matchedKeyStroke(so you can branch on which chord fired) and anEventContext(for side effects). Returns anythingInto<Intent>— typically anIntentKindvariant. Omit when the shortcut only needs the name: the registry synthesizesIntent::new(intent_name)for you.enabled_when: Option<Signal<bool>>— reactive "is this shortcut live?" predicate. Whenfalse, the shortcut is treated as if not registered — the keystroke falls through to the focused widget's normalon_keydispatch. Compose composite predicates with theSignal<bool>combinators (and/or/not) orSignal::zipfor typed tuples.propagate_when_disabled: bool— controls what happens when the matchingActionis disabled:true(default) lets the intent continue bubbling;falseconsumes at that level ("owned but dormant").
Composing enabled_when predicates
enabled_when takes any Signal<bool>, and Signal ships combinators
for multi-source predicates that correctly dirty-track every upstream
root:
#![allow(unused)] fn main() { let editor_focused: Signal<bool> = …; let readonly: Signal<bool> = …; let in_editor: Signal<bool> = …; // `focus && !readonly && in_editor` — each source registered independently // with the binding registry, so widgets observing `when` re-render on any flip. let when = editor_focused.and(&readonly.not()).and(&in_editor); Shortcut::new("edit.format.bold") .primary(KeyStroke::ctrl(Key::B)) .enabled_when(when) .build(); }
Available on Signal<bool>: and, or, not. Available on any
Signal<T: Clone>: zip(&Signal<U>) -> Signal<(T, U)>,
zip3(&Signal<U>, &Signal<V>) -> Signal<(T, U, V)>, and map for
arbitrary projections. The same combinators work for Action::enabled_when.
ShortcutRegistry
Two-layer store, both keyed by shortcut id (&'static str):
- Defaults — records registered by widgets during
build()or declared statically viaWidget::declare_shortcuts. Re-registering the same id upserts: code-owned fields are refreshed, the user override is preserved. Id is the unique key, so two widgets declaring the same id share the entry — see Same-id collisions. - Overrides — user-supplied keystroke rebindings keyed by shortcut id, persisted across widget rebuilds (graveyard semantics — a widget that disappears and reappears keeps its customised bindings).
The merged view is EffectiveShortcut:
primary/secondary = user override if touched, else declared default.
Menus, tooltips, and dispatch consume this shape.
Every mutation bumps ShortcutRegistry::version(), a Signal<u64>.
Menus, tooltips, and settings widgets observe it and re-read through
effective(id) to refresh labels after rebinds.
Registration from a widget
Inside build():
#![allow(unused)] fn main() { // Widget-scoped (default: Scoped(self_id) — fires only when focus is // inside the widget's subtree): ctx.register_shortcut( Shortcut::new("editor.format.bold") .name("Bold") .primary(KeyStroke::ctrl(Key::B)) .build(), ); // App-level (Global — fires regardless of focus): ctx.register_shortcut_global( Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ); }
Both register with ownership: when the widget is destroyed or
rebuilt, the framework calls unregister_all_for_owner(widget_id) so
stale entries don't leak.
Static declaration — Widget::declare_shortcuts
ctx.register_shortcut runs from build(), so a chord only enters
the registry once its owning widget has actually been built. That's
fine for always-mounted widgets — build() runs immediately on
insert. It's not fine when the widget lives behind a lazy
boundary:
- A
Switcherarm that hasn't been selected yet (lazy mount: the page widget stays Boxed until first selection). - A subtree gated by a feature flag or a closed disclosure.
- Anything else that defers
build().
For those cases, the chord won't appear in ShortcutSettings (or any
other registry consumer) until the user happens to visit that
subtree at least once. A rebind UI whose contents depend on where
you've clicked is the wrong shape.
Widget::declare_shortcuts(&self) -> Vec<Shortcut> opts in to
eager registration of metadata — same id and keystrokes, no
handler:
#![allow(unused)] fn main() { impl Widget for SaveTools { fn declare_shortcuts(&self) -> Vec<Shortcut> { // Metadata only — no on_activate, no captured state. vec![ Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ] } fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { // Install the handler. Same id — the registry upserts. let do_save = self.do_save.clone(); ctx.register_shortcut( Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .on_activate(move |_, _| { (do_save)(); Intent::new("app.save") }) .build(), ); // ... } } }
The framework walks declare_shortcuts at three sites:
- Insertion —
tree.add(w)/ctx.add_child(parent, w), right after handler-set extraction, beforebuild(). - Rebuild — right after
unregister_all_for_ownerwipes the previous build's registrations, so declared metadata survives the rebuild cycle even ifbuild()only conditionally re-registers. Switcher::build— for every still-Pendingslot. The Switcher pre-registers each lazy page's declared shortcuts owned by itself, so the chord is visible from the moment the Switcher builds, without mounting the page. When the page is eventually mounted, the insertion walk re-registers the same id owned by the page widget; the registry's idempotent upsert transfers ownership cleanly and preserves any user override.
When you need it. Any widget that might live behind a lazy boundary, or any widget whose chord must appear in a rebind UI on the first frame regardless of which views the user has visited.
When you don't. Always-mounted widgets (app root, top-level
toolbar, modeless docked panels). Build-time register_shortcut
already runs immediately on insert — same visibility, no
duplication.
Pairing convention. When you opt in, mirror the metadata in
both methods (same id, name, default keystrokes). declare_shortcuts
omits on_activate; register_shortcut adds it. The registry's
upsert refreshes the entry with the handler-bearing version when
build() runs.
The default impl is empty (fn declare_shortcuts(&self) -> Vec<Shortcut> { Vec::new() }),
so existing widgets keep working unchanged. This is strictly
opt-in.
Same-id collisions
The registry is keyed by id, not by (id, owner). Two widgets
registering the same id is not an error — it's intentional aliasing.
Concrete behaviour:
defaults: HashMap<&'static str, Shortcut>— the second registration replaces the first (last-write-wins). Metadata, default keystrokes, and handler from the loser are discarded.overrides: HashMap<String, KeyStrokeOverride>— one override per id. A user rebind of"app.save"applies to whichever shortcut is currently indefaults. Two widgets sharing an id share the user rebind.by_ownertracks one owner per id at a time. The newer registration'sregister_ownedcallsdetach_owner_indexto pull the id off the previous owner's cleanup list, then the new owner inherits it. Destroying the previous owner doesn't touch the entry; destroying the current owner removes it (and any remaining declaration would have to re-register to refill).
Use this intentionally. If two widgets implement the same
logical action ("app.save" from a toolbar button, a menu item,
and a keyboard chord all targeting the same code), declaring the
same id is correct — the user rebinds once, all three follow.
The footgun. Two unrelated widgets accidentally picking the
same id. Rebinding one silently rebinds the other. Hierarchical
dotted ids prevent this in practice (editor.format.bold, not just
bold); there's no namespacing enforcement at the type level.
Framework-internal chords use a __ prefix by convention
(__teksilo_inspector.pick) so app ids can't collide with them.
Same-chord precedence
Distinct ids may bind the same chord — a normal IDE pattern (a
global Ctrl+W "close window" alongside a panel-scoped Ctrl+W "close
tab"). When a chord matches more than one enabled shortcut, the
dispatcher resolves them by focus and scope specificity, not by id
order:
- Applicability first. A
Scoped(id)binding is a candidate only when the focused widget is insideid's subtree. AGlobalbinding is always a candidate. - Most-specific scope wins. An applicable
Scopedbinding beats aGlobalone. Among nested applicable scopes, the one closest to the focused widget (deepest) wins. - Id order is only a tiebreak within equal specificity (the
deterministic
(category, id)order ofiter_effective).
So with focus in the editor, the editor-scoped Ctrl+W fires; move
focus to the sidebar and the global Ctrl+W fires instead. An
inapplicable scoped binding never "eats" the chord from an applicable
global one, and a global binding never shadows an in-focus scoped one.
Scope applicability needs the widget tree (descendant checks), which the
registry can't see — so it hands back every candidate via
matches_by_keystroke and the dispatcher does the focus-aware
selection. (find_by_keystroke, which returns just the first by id
order, ignores scope and is for non-dispatch queries only.)
This is cross-id collision resolution by focus; it is distinct from
the rebind-time conflict check (find_conflict,
used by the settings UI to auto-unbind), and from the same-id
aliasing above.
Per-slot overrides
User overrides are per-slot (SlotOverride::{Default, Bound(ks), Unbound}):
Default— delegate to whatever default the shortcut currently declares (a later code-side change flows through).Bound(ks)— lock the slot to this chord.Unbound— lock the slot to no chord.
Rebinding primary does not disturb secondary, and vice versa. The
registry's rebind_primary / rebind_secondary only touch the
targeted slot — they do not auto-unbind conflicting shortcuts.
Use ShortcutRegistry::find_conflict(keystroke, excluding_id) before
rebinding if you want the "exactly one effective binding per chord"
invariant; that is what the pre-built
ShortcutSettings
widget does in its capture-event handler.
CaptureHandle — one-shot key capture
Used to implement "press a chord" rebind UIs. ctx.begin_key_capture
returns a CaptureHandle: the
next KeyDown bypasses shortcut resolution and runs the callback
with access to the registry and an EventContext. RAII: dropping the
handle cancels an unfired capture.
#![allow(unused)] fn main() { let handle = ctx.begin_key_capture(|ks, registry, _ctx| { // Escape cancels, Del/Backspace unbinds, everything else rebinds. registry.rebind_primary("app.save", Some(ks)); }); self.active_capture = Some(handle); // hold onto it }
Re-arming (calling begin_key_capture again while a previous handle
is still alive) creates a fresh slot; the old slot is already orphaned
so dropping the old handle cancels only the old slot — no race with
the newer capture. The pre-built
ShortcutSettings
widget packages this flow (Rebind buttons, conflict resolution, reset).
Intent
Runtime message — name + optional payload. Construction:
#![allow(unused)] fn main() { use teksilo::core::Intent; // Name-only (parameter-less): let i = Intent::new("app.save"); // Typed payload (any T: 'static — stored in an Rc<dyn Any>): let i = Intent::with_payload("app.scroll_by", -1_i32); let i = Intent::with_payload("app.add_item", my_dto); // Blanket conversion from any IntentKind variant: let i: Intent = AppIntent::Save.into(); }
Recover the payload by type:
#![allow(unused)] fn main() { if let Some(&delta) = intent.payload::<i32>() { … } }
from_intent is the typed counterpart when the payload was built from
an IntentKind:
#![allow(unused)] fn main() { if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) { open_file(path); } }
IntentResponse
Action handlers return IntentResponse:
Handled(default) — stop walking; the intent is consumed here.Propagated— observe-and-keep-going; ancestor widgets also get a chance. Useful when a widget wants to react (update a draft indicator) but lets an ancestor perform the primary action.
ActionBuilder::on_invoke always reports Handled — use
on_invoke_with_response when you need to propagate.
IntentKind — typed DTO bridge
Use #[derive(IntentKind)] on an enum that catalogs the app's intents.
Each variant declares its name via #[name = "..."]:
#![allow(unused)] fn main() { use teksilo::IntentKind; #[derive(Debug, IntentKind)] enum AppIntent { // Unit variants — no payload fields: #[name = "app.save"] Save, #[name = "app.quit"] Quit, // Tuple variants — whole variant is the payload: #[name = "app.open"] Open(String), #[name = "app.scroll_by"] ScrollBy(i32), // Struct variants work identically: #[name = "app.goto_line"] GoToLine { line: u32 }, // Complex payloads are fine too: #[name = "app.add_item"] AddItem { id: i64, dto: CreateItemDto }, } }
What the derive generates (verbatim):
#![allow(unused)] fn main() { impl IntentKind for AppIntent { fn into_intent(self) -> Intent { let name: &'static str = match &self { Self::Save => "app.save", Self::Open(..) => "app.open", Self::GoToLine { .. } => "app.goto_line", // ... }; Intent::with_payload(name, self) } fn from_intent(intent: &Intent) -> Option<&Self> { intent.payload::<Self>() } } }
A blanket impl<K: IntentKind> From<K> for Intent lets most call sites
skip the explicit .into_intent():
#![allow(unused)] fn main() { ctx.send_intent(AppIntent::Save); // unit ctx.send_intent(AppIntent::Open(path)); // tuple ctx.send_intent(AppIntent::GoToLine { line: 42 }); // struct }
Why the derive is dumb on purpose
The macro never inspects fields. Any variant shape works — unit,
tuple, struct, arbitrary user types — because the whole variant is
stored as the payload. The only requirement: the enum itself is
'static (typically trivially true).
Trade-off this codifies: Teksilo sits between Flutter's fully-typed
Intents (no strings anywhere) and Qt's string-keyed QAction.
Names are the dispatch key; IntentKind layers compile-time checking
on top when the app opts in. Third-party widgets can still declare
intents without knowing the consuming app's enum.
Action
Widget-owned handler for one intent name:
#![allow(unused)] fn main() { use teksilo::core::{Action, IntentResponse}; ctx.register_action( Action::new("app.save") .on_invoke(|_intent, _ctx| { println!("saved"); }), ); }
Key bits:
- One action per intent name per widget. Register multiple for different names on the same widget if needed; at a given level, if two actions match the same name, the first (by declaration order) wins.
intent: &'static str— the dispatch key. Must exactly matchIntent::name. Typo-safety comes fromIntentKind's name attributes, not from the action side.enabled_when: Option<Signal<bool>>— reactive predicate. Whenfalse, the action is skipped during dispatch (the intent propagates past this level as if no match existed here — unless the firing shortcut haspropagate_when_disabled == false, in which case it is consumed dormant).on_invoke(|intent, ctx| …)— handler that always reportsHandled.on_invoke_with_response(|intent, ctx| …) -> IntentResponse— when the handler needs to decideHandledvsPropagatedat runtime.
Scoped vs global actions
ctx.register_action(action) attaches the action to the registering widget's
node — it only fires when that widget is on the intent's source→root walk.
That's right for actions co-located with their UI (a panel handling a command
fired from within itself).
ctx.register_action_global(action) registers an app-global action consulted
as a dispatch fallback — after the source→root walk finds no consuming node
action — so it fires no matter where the intent originated. Use it for app-wide
commands whose handler lives at the app root but whose triggers are scattered
across the tree and the window chrome:
- A menu-bar dropdown renders in an overlay, not under the widget that
built the menu — so a
MenuEntry::intent("app.x")dispatched from it will not reach an action registered withregister_actionon a sibling widget (e.g. the app body). This is the most common footgun: the menu item looks wired but nothing happens. - A global shortcut with no widget focused anchors at the arena root; a scoped action deep in the tree won't be on that walk.
register_action_global is the action-side counterpart to
register_shortcut_global. Ownership applies: the action is torn down when the
registering widget rebuilds or is destroyed. Multiple globals for the same intent
name fire in registration order, honouring IntentResponse (Handled stops,
Propagated continues to the next global).
#![allow(unused)] fn main() { // App root: command reachable from the menu bar, a shortcut, and content alike. ctx.register_shortcut_global( Shortcut::new("view.toggle_sidebar").primary(KeyStroke::ctrl(Key::B)).build(), ); ctx.register_action_global( Action::new("view.toggle_sidebar").on_invoke(|_i, _c| sidebar.toggle()), ); }
Handler patterns: extract only when needed
The framework already name-matches before invoking a handler — an
action's invocation is proof of intent.name == action.intent. You
only call from_intent when you need the typed fields.
#![allow(unused)] fn main() { // Unit intent — no fields to extract, react by name alone. // This also means the handler fires whether the intent came from // a shortcut (name-only) or from `send_intent(AppIntent::Save)`. Action::new("app.save").on_invoke(|_intent, _ctx| { println!("[action] Save"); }); // Data-bearing intent — extract the typed variant: Action::new("app.open").on_invoke(|intent, _ctx| { if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) { open_file(path); } }); }
Dispatch walk
From
widget_tree::dispatch_intent:
- Build the chain
source → parent → … → root. - For each
idin the chain:- Skip if the node is inactive or disabled.
- Find the first action on that node whose
intent == intent.name. If none, continue to the parent. - If the action is disabled: restore it and either
continue(whenpropagate_when_disabled) orreturn(otherwise). - Invoke the handler. On
Handled→ return. OnPropagated→ continue.
- Global fallback. If the chain walk consumed nothing, consult the
window-global actions (registered via
register_action_global) in registration order. First enabled match handles it (Handled→ stop,Propagated→ next global). This is position-independent, so it catches intents from menu-bar overlays and root-anchored shortcuts that the source→root walk would otherwise miss.
Handlers may call ctx.send_intent(...) from inside; those intents
queue and drain after the current one, until the queue empties. FIFO
ordering.
Source anchoring
- Shortcut path: anchor is the focused widget for scoped shortcuts. Global shortcuts use the focused widget when present, otherwise fall back to the first arena root — so global shortcuts fire even before anything has been focused or after the focused widget is destroyed by a rebuild.
ctx.send_intent(...): anchor is the widget whose handler ran. Defaultpropagate_when_disabled = true— programmatic sends have no shortcut to consult and take the least-surprising path. ⚠️ When the handler runs in an overlay (menu dropdown, popover), the anchor is the overlay's content, whose source→root walk does not pass through the widget that opened it — register an app-global action (register_action_global) for commands fired from menus/chrome.tree.dispatch_intent(source, intent, propagate): caller chooses.
Focus invalidation on destroy
WidgetTree::destroy_subtree clears self.focused and self.hovered
when they point at the widget about to be destroyed. Without this, a
rebuild of a currently-focused subtree (classic scenario: hitting
Rebind and editing a chord) would leave focus pointing at a dead id,
making subsequent global shortcuts look dead until the user clicked
elsewhere.
Interaction with on_key_preview
A KeyDown event flows through three stages, in this order:
- Shortcut resolution. The registry is consulted before any
widget dispatch.
ShortcutRegistry::matches_by_keystrokeyields every enabled shortcut bound to the chord; the dispatcher then picks the one whose scope applies to the current focus (see Same-chord precedence below). If an applicable shortcut is found, its intent is activated and the key event is consumed. If no candidate applies — every match is aScopedbinding outside the focused subtree — the event falls through to stage 2. - Ancestor key preview. If no shortcut matched, the framework
walks the focused widget's strict ancestors root → parent-of-target,
firing
on_key_previewon each. ReturningEventResponse::Handledconsumes the event. - Focused widget bubble. If preview returned
Ignoredfor every ancestor, the focused widget's ownon_keyruns, then the event bubbles to ancestors via theiron_keyslots.
Implication: shortcuts always win over on_key_preview. An
ancestor that wants to override a registered shortcut should also
register a shortcut (with enabled_when gating which one fires when
both are eligible) — on_key_preview cannot stop a shortcut because
shortcuts are resolved first. Use on_key_preview for chords not
in the registry: a messenger composer claiming Enter that nobody
registered as a shortcut, a list view consuming arrow keys that no
ancestor declared.
Taking a text chord: Ctrl+Z, Ctrl+C, Ctrl+X, Ctrl+V, Ctrl+A
The same rule has a sharp edge worth naming, because an application that
wants one Undo command — one chord, one menu row, routed to whatever
the user is actually editing — has to register Ctrl+Z globally, and the
moment it does it has taken that key away from every text widget in the
tree. RichTextEditor, TextInputField and CodeEditor all handle those
chords in stage 3, so a global shortcut silently wins over all of them.
Do not try to answer that from the application's own knowledge. It can
recognise the surfaces it built and kept a handle on, and it is blind to
the rest — a rename box in a table cell, a search field, an input inside a
dialog it did not write. Guessing gets it exactly backwards: Ctrl+Z in
the widget it forgot undoes something else entirely, which is worse than
not shipping the feature. A hand-maintained list of text widgets is
correct the day it is written and silently wrong the first time someone
adds one.
Ask the framework instead. Every text widget calls
BuildContext::register_text_surface,
so the tree can answer completely:
#![allow(unused)] fn main() { // Once, during build — the handle shares the tree's focus signal, so it // stays live and can be read from a frame tick. let surfaces = ctx.text_surfaces(); // Later, wherever the routing decision is made: match surfaces.focused() { Some(surface) => surface.undo(), // drive the caret's own widget None => app_undo(), // no text surface: the app's own history } }
TextSurfaces::focused() yields an Rc<dyn TextSurface> — undo/redo,
history_frozen, selection, read-only, clipboard, select-all — for
whichever widget holds the focus, whatever kind it is. Registrations are
owned by the registering widget and torn down on its rebuild or destroy,
exactly like register_action_global.
Pair it with enabled_when. A disabled shortcut is treated as not
registered, so the keystroke falls through to the focused widget's own
handling — which is what you want whenever the router has nothing to
offer. Between the two, the application only ever intercepts a text chord
when it knows what it is doing.
A custom text widget should call register_text_surface too. Failing to
is not a compile error and will not be noticed until an application
routes one of these chords and your widget quietly loses it.
End-to-end skeleton
#![allow(unused)] fn main() { use teksilo::IntentKind; use teksilo::core::{Action, Intent}; use teksilo::core::shortcut::{KeyStroke, Shortcut}; use teksilo::prelude::*; #[derive(Debug, IntentKind)] enum AppIntent { #[name = "app.save"] Save, #[name = "app.open"] Open(String), #[name = "app.scroll_by"] ScrollBy(i32), } impl Widget for Root { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { // --- Shortcuts --- ctx.register_shortcut_global( Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ); ctx.register_shortcut_global( Shortcut::new("app.scroll_by") .name("Scroll by page") .primary(KeyStroke::new(Key::PageUp, Modifiers::NONE)) .secondary(KeyStroke::new(Key::PageDown, Modifiers::NONE)) // Parametric: chord drives the payload. .on_activate(|ks, _ctx| { let delta = if ks.key == Key::PageUp { -1 } else { 1 }; AppIntent::ScrollBy(delta) }) .build(), ); // --- Actions --- ctx.register_action( Action::new("app.save") .on_invoke(|_intent, _ctx| println!("saved")), ); ctx.register_action(Action::new("app.open").on_invoke(|intent, _ctx| { if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) { open_file(path); } })); ctx.register_action(Action::new("app.scroll_by").on_invoke(|intent, _ctx| { if let Some(AppIntent::ScrollBy(delta)) = AppIntent::from_intent(intent) { scroll(*delta); } })); // --- UI — menus, buttons, tooltips all reference shortcuts // by id. Labels refresh when the user rebinds because the // widgets observe `shortcut_registry.version()`. let menu = MenuBar::new().menu(lit!("File"), || { Box::new(MenuList::new().item( MenuItem::new(lit!("Save")) .for_shortcut("app.save") .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)), )) }); let save_button = Button::new(lit!("Save")) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)); let root = ctx.add(VStack::new().child(menu).child(save_button)); self.root_child_id = Some(root); vec![root] } // layout_response delegates to root child… } }
Cheat sheet
| Task | API |
|---|---|
| Declare a keyboard shortcut | Shortcut::new("id").primary(KeyStroke::…).build() |
| Register widget-scoped | ctx.register_shortcut(shortcut) |
| Register app-level | ctx.register_shortcut_global(shortcut) |
| Declare metadata eagerly (lazy-safe) | fn declare_shortcuts(&self) -> Vec<Shortcut> on the Widget impl |
| Parametric payload | .on_activate(|ks, ctx| AppIntent::X(…)) |
| Disable reactively | .enabled_when(signal) |
| Composite predicate (AND/OR/NOT) | a.and(&b.not()), a.or(&b), s.not() on Signal<bool> |
| Tuple multi-source signal | a.zip(&b), a.zip3(&b, &c) |
| Switch to a selected inner signal | selector.flat_map(|t| inner_signal(t)) |
| Consume when disabled | .propagate_when_disabled(false) |
| Declare a handler | Action::new("id").on_invoke(|intent, ctx| …) |
| Propagate after observing | .on_invoke_with_response(|i, c| IntentResponse::Propagated) |
| Register handler on widget | ctx.register_action(action) |
| Register app-global handler (menu/chrome) | ctx.register_action_global(action) |
| Fire programmatically | ctx.send_intent(AppIntent::X) |
| Typed enum bridge | #[derive(IntentKind)] + #[name = "…"] on each variant |
| Recover typed variant | AppIntent::from_intent(intent) |
| Raw payload lookup | intent.payload::<T>() |
| Observe registry changes | ctx.shortcut_registry().version() — Signal<u64> |
| Effective view of a shortcut | ctx.effective_shortcut("id") — merged defaults + overrides |
| Menu label follows rebinds | MenuItem::new(...).for_shortcut("id") |
| Tooltip shows chord + rebinds live | TooltipContent::new(...).for_shortcut("id") |
| Rebind UI out of the box | ShortcutSettings::new() |
| One-shot key capture | ctx.begin_key_capture(|ks, registry, ctx| …) — returns CaptureHandle |
See also
- Working demo:
examples/shortcuts_demo/src/main.rs - Source:
crates/teksilo-core/src/shortcut.rs,intent.rs,action.rs - Derive macro:
crates/teksilo-macros/src/intent_kind.rs - Pre-built settings widget:
crates/teksilo-widgets/src/shortcut_settings.rs - Architecture §11: keyboard & shortcut design rationale
Tooltip Reference
Tooltips are hover-/focus-triggered overlays that surface ancillary information about a control. Teksilo ships three tiers that share one attachment pipeline:
- Plain tooltips — a single localized string in a themed rounded-rect surface. Pure-text, ephemeral, no interaction.
- Rich tooltips — a registry-driven content surface that may carry inline
markup (
*italic*,**bold**,label), a shortcut hint, an Accordion-revealed "more" body, and a sticky-on-dwell promotion to a focusable, click-through Dialog. - Composite tooltips — host an arbitrary widget tree (Crusader Kings 3
style: tabbed sections, charts, progress bars, conditional rows, dynamic
numeric values). Same dwell-to-sticky machinery as rich tooltips. "Primary
only" by construction — has no inline-markup body and no registry key, so
it cannot be the target of a
labelcascade from a rich tooltip. Child widgets inside the composite body keep their own.tooltip(...)/.rich_tooltip(...)setters and cascade normally.
All three ride the same WidgetTree machinery (hover/focus tracking, delay
scheduling, overlay show/dismiss, fade-in animation). The choice of tier is
made per-anchor by which builder method you call on the host widget. The
three setters are mutually exclusive (last-call-wins): each setter clears
the other two.
| Layer | Type | Crate | What it does |
|---|---|---|---|
| Plain content widget | TooltipWidget | teksilo-widgets | Themed rounded-rect with one line of text |
| Rich content widget | RichTooltipWidget | teksilo-widgets | Body + shortcut chip + "more" disclosure + dwell indicator |
| Composite content widget | CompositeTooltipWidget | teksilo-widgets | Surface hosting an arbitrary widget tree (TabWidget, charts, progress bars, conditional rows, dynamic values) with dwell-to-sticky promotion |
| Registry | TooltipRegistry / TooltipContent | teksilo-widgets | Thread-local catalog keyed by short stable ids |
| Attach helpers | attach_rich_tooltip* / attach_composite_tooltip* | teksilo-widgets | Wire a tooltip onto an anchor inside build() |
| Tree machinery | WidgetTree::attach_tooltip* | teksilo-core | Hover/focus tracking, dwell promotion, overlay lifetime |
| Visual progress | DwellIndicator | teksilo-widgets | Pie-wedge / pin glyph for sticky-on-dwell |
| Tokens | TooltipStyle (trait) / constants in recipe_tooltip_style.rs | teksilo-core / teksilo-widgets | TOOLTIP_PADDING_HORIZONTAL, TOOLTIP_PADDING_VERTICAL, TOOLTIP_CORNER_RADIUS, TOOLTIP_MAX_WIDTH; composite variants prefixed COMPOSITE_TOOLTIP_* |
Quick start
Plain tooltip on any widget
#![allow(unused)] fn main() { use teksilo::prelude::*; Button::new(tr!(save())) .tooltip(tr!(save_hint())) // i18n .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)); }
.tooltip(...) accepts impl Into<LocalizedString>. The grep-marker
.tooltip(lit!("...")) exists as a #[doc(hidden)] shim for tests and
scaffolding.
Rich tooltip from the registry
use teksilo::prelude::*; use teksilo_widgets::tooltip::TooltipContent; fn main() { TeksiloAppBuilder::new() .register_tooltips(vec![ TooltipContent::new("save-as", tr!(save_as_tooltip())) .for_shortcut("app.save_as"), TooltipContent::new("autosave", tr!(autosave_tooltip())) .with_more(tr!(autosave_tooltip_more())), ]) .initial_window(WindowConfig::new().root(|tree, _| { tree.add(Button::new(tr!(save_as())).rich_tooltip("save-as")) })) .run(); }
Two attachment paths once registered:
.rich_tooltip("save-as")— registry key lookup at build time..rich_tooltip_content(TooltipContent::new(...))— inline content; bypasses the registry. Useful for one-off tooltips, tests, and per-row tips on data-driven widgets.
Composite tooltip (CK3-style)
For tooltips that need a full widget tree — tabs, charts, progress bars,
conditional rows, dynamic numeric values — use .composite_tooltip(content):
#![allow(unused)] fn main() { use teksilo::prelude::*; Button::new(tr!(province_info())) .composite_tooltip( VStack::new() .spacing(8.0) .child(TextWidget::new(tr!(province_header())).style(TextStyleRole::BodyBold)) .child(ProgressBar::new(prosperity_signal)) .child( Grid::new() .columns(vec![TrackSize::Auto, TrackSize::Auto]) .child(TextWidget::new(tr!(food()))) .child(TextWidget::new(lit!(food_value))) .child(TextWidget::new(tr!(trade()))) .child(TextWidget::new(lit!(trade_value))), ), ); }
The dwell-to-sticky machinery is reused from rich tooltips: at 2 s the role
flips Tooltip → Dialog, dismiss swaps to EscapeOrClickOutside, and the
surface becomes Tab-reachable. Rare interactive descendants (a "Pin"
button, an internal TabWidget) work cleanly post-promotion.
The default delay is theme.motion.tooltip_delay_heavy (700 ms — slower than
the 500 ms tooltip_delay used by plain/rich tooltips, because composite
surfaces are heavier and shouldn't pop on transient hover). Default
max_width × max_height are
both 480 dp (COMPOSITE_TOOLTIP_MAX_WIDTH / COMPOSITE_TOOLTIP_MAX_HEIGHT
constants in teksilo-widgets/src/styles/recipe_tooltip_style.rs),
configurable per-instance with .max_width(f32) / .max_height(f32) on
CompositeTooltipWidget.
No registry, no :key cascade target. Composite tooltips are widget
trees, not data — they don't fit the TooltipRegistry's
Vec<TooltipContent> model and have no stable id to address in markup. The
"primary-only" constraint is structural, not enforced at runtime: there is
simply no key to write in label.
Cascading from a composite tooltip
A child widget inside the composite body (e.g. a stat row's
Button::rich_tooltip("modifier-detail")) keeps working as ordinary widget
composition — its own build() runs the existing rich-attach path, and the
nested overlay opens via OverlayLayer::InTree parented to the composite
tooltip's overlay. Mix tiers freely.
Last-call-wins setter matrix
The three setters are mutually exclusive — every setter clears the other two.
| Setter | Sets | Clears |
|---|---|---|
.tooltip(text) / .tooltip(lit!(text)) | plain text | rich source, composite body |
.rich_tooltip(key) / .rich_tooltip_content(c) | rich source | plain text, composite body |
.composite_tooltip(w) | composite body | plain text, rich source |
This is preserved across every widget that exposes the tooltip flavors — which is now essentially every interactive control:
- Buttons:
Button,IconButton,CommandLinkButton,SplitButton(separate.tooltip(...)and.chevron_tooltip(...)matrices),PopoverWidget/Popover,NotificationCenterButton. - Inputs:
TextInput,PasswordField,SearchField,SpinBox,TextScaleControl,ComboBox,HexColorInput,FilePickerField,DateEdit/TimeEdit/DateTimeEdit/DateRangeEdit,ColorEdit/ColorPicker/ColorSwatch. - Selection controls:
Checkbox,RadioButton,Toggle,Slider,SegmentedControl(per-Segment). - Misc controls & rows:
Avatar,Badge,Breadcrumb,Stepper,StandardListItem/StandardTreeItem,ToolBox,Link,MenuItem. - Presets that forward to an inner control:
ThemeSwitcher,LanguageSwitcher(both forward onto their innerComboBox). - Data / command delegates:
TabInfo/TabDelegatefor tab strips,ToolbarActionforToolbarcommands.
Clone value types (Segment via SegmentedControl, ToolbarAction)
are stored by value in a Vec and cloned, so they cannot hold a
Box<dyn Widget> (which is not Clone). Their .composite_tooltip(...)
therefore takes a factory closure — impl Fn() -> Box<dyn Widget>
(stored as an Rc, invoked once per build to produce a fresh body) —
rather than an impl Widget instance. The plain and rich setters are
unaffected (LocalizedString and RichTooltipSource are both Clone).
Not applicable: Toast is a presentable request builder, not a
Widget — it has no build() or visible root of its own. Its tooltip is
stored as data and rendered by toast/surface.rs; the multi-flavor
setters don't apply to it.
Tooltips and a control's own overlay
A control that opens an overlay (the ComboBox dropdown, a Popover, a
date picker's calendar) keeps that overlay's content as an arena child
of the trigger. A tooltip on the trigger (or any ancestor) is suppressed
while the pointer is over that overlay's content: tooltip_pointer_enter
only fires when the hovered widget is within the anchor's scope and no
active-overlay boundary separates them (WidgetTree::tooltip_hover_targets_anchor).
So opening a dropdown and hovering its rows never re-triggers the combo's
tooltip — while a tooltip attached to a widget inside the overlay (e.g. a
dropdown row's own tooltip) still fires.
Registration: TeksiloAppBuilder::register_tooltips
The application's tooltip catalog is a single Vec<TooltipContent> registered
once at boot. The bundle is frozen into a thread-local TooltipRegistry before
the first frame builds — both run() and build_headless() install it
before invoking the root builder, so tooltip widgets created during the very
first build can resolve their content immediately.
#![allow(unused)] fn main() { TeksiloAppBuilder::new() .register_tooltips(vec![ /* ... */ ]) .run(); }
Calling register_tooltips more than once on the same builder simply replaces
the previous catalog (the registry only sees the final list when run /
build_headless fires). Calling install_tooltip_registry directly twice on
the same thread panics in debug builds; release builds keep the first
installation. Tests reset between cases via the crate-internal
_reset_tooltip_registry helper.
TooltipContent builder
#![allow(unused)] fn main() { pub struct TooltipContent { pub key: String, pub text: LocalizedString, pub more: Option<LocalizedString>, pub shortcut_label: Option<String>, // literal override pub shortcut_id: Option<&'static str>, // ShortcutRegistry binding } }
| Method | Behavior |
|---|---|
TooltipContent::new(key, text) | Construct with body only |
.with_more(LocalizedString) | Long-form body revealed by the Accordion disclosure inside a sticky tooltip |
.with_shortcut_label("Ctrl+Shift+S") | Manual shortcut hint — used verbatim, takes precedence over for_shortcut |
.for_shortcut("app.save_as") | Bind the chip to a registered Shortcut id; the effective primary keystroke is read from the tree's ShortcutRegistry and tracks user rebinds (the registry's version signal triggers a Rebuild-level rebind on the tooltip widget) |
.has_more() / .has_shortcut() | Predicates used by the layout |
The body is a LocalizedString, so production code uses tr!(...); literal
strings only show up in tests and demos via lit!(...).
URL scheme inside the body
Inline links inside body text use the :key prefix to address other tooltip
entries:
#![allow(unused)] fn main() { TooltipContent::new( "autosave", tr!(autosave_with_link()), // "Teksilo autosaves. See `details`…" ) }
TooltipRegistry::parse_url(":autosave-details") returns Some("autosave-details").
Every other URL scheme — http://, https://, mailto:, bare paths — passes
through unchanged and is dispatched to open::that(url) (the OS default
handler) when clicked, so production code spawns a browser / mail client
without extra wiring. The open::that call is suppressed under cfg(test)
so unit tests don't actually launch external apps.
When a body contains label links, the rich tooltip widget
pre-creates dormant RichTooltipWidget children for every registered
target during its build() (matching the menu-submenu pattern in
menu_item.rs). Each child is marked a cascade child, which suppresses
its dwell-to-sticky indicator and reads as a persistent, focusable
Role::Dialog (advertising Focus) straight away — it's opened by an
explicit click and is already persistent, so the hover-to-sticky affordance
and the ephemeral Role::Tooltip don't apply.
Clicking a link activates the matching child and calls ctx.show_overlay(...)
anchored to the parent tooltip's own widget id, positioned 8 px below it,
with dismiss behavior EscapeOrClickOutside.
The request passes parent_overlay: None, but the event dispatcher fills it
in with the containing tooltip's overlay (overlay_ancestor_for_widget), so
the child is linked to its parent: dismissing the parent cascade-closes the
whole subtree (OverlayManager::dismiss_immediate's BFS), while Escape
dismisses the top-most level first. A runaway cascade — e.g. a cyclic
A → B → A :key loop — is bounded by MAX_OVERLAY_NESTING_DEPTH, so the
overlay stack can't grow without limit.
Attaching tooltips inside build()
Most widgets expose .tooltip(...) / .rich_tooltip(...) builder methods
that wire everything internally. When you author a custom anchor you reach
the same machinery through BuildContext:
#![allow(unused)] fn main() { // Plain tooltip — caller-managed delay. let tooltip_id = ctx.add(TooltipWidget::new(tr!(save_hint()))); let delay = ctx.theme().motion.tooltip_delay; ctx.attach_tooltip(anchor_id, tooltip_id, delay); // Rich tooltip from the registry — recommended path. let delay = ctx.theme().motion.tooltip_delay; crate::tooltip::attach_rich_tooltip(ctx, anchor_id, "save-as", delay); // Rich tooltip from inline content (no registry lookup). let delay = ctx.theme().motion.tooltip_delay; crate::tooltip::attach_rich_tooltip_content( ctx, anchor_id, TooltipContent::new("inline", tr!(inline_body())), delay, ); // Source-driven — accepts either a key or an inline content. let delay = ctx.theme().motion.tooltip_delay; crate::tooltip::attach_rich_tooltip_source( ctx, anchor_id, source, // RichTooltipSource delay, ); }
RichTooltipSource is the union type accepted by builder methods that want
to take either form:
#![allow(unused)] fn main() { pub enum RichTooltipSource { Key(String), Content(TooltipContent), } impl<T: Into<String>> From<T> for RichTooltipSource { /* … */ } }
The attach helpers do three things:
- Construct the content widget (
TooltipWidget/RichTooltipWidget). - Insert it into the arena via
ctx.add(...)and immediately mark it dormant — it has no parent on the visible scene; the overlay manager activates it on show. - Register a
TooltipEntryon the tree with the anchor id, content id, delay, and (for rich) the dwell threshold + a sharedshown_atsink.
Default delays
All tooltip dwell delays are theme-defined on MotionTokens
(motion.rs), so apps retune the feel
in one place. Each widget reads the value at build() time via
ctx.theme().motion.*.
| Path | Field | Default |
|---|---|---|
| Plain + rich tooltips (all widgets) | motion.tooltip_delay | 500 ms |
| Composite tooltips + scene-item tips | motion.tooltip_delay_heavy | 700 ms |
| Subsequent tip while a tip is open / just dismissed | motion.tooltip_reshow_delay | 100 ms |
Defaults match desktop OS norms (Windows TTDT_INITIAL / GTK
gtk-tooltip-timeout ≈ 500 ms; Windows TTDT_RESHOW ≈ 100 ms). Themes
(including Material 3) inherit MotionTokens::default() unless they
override motion.
The reshow shortening is proportional, not a floor. Windows derives
TTDT_RESHOW as TTDT_INITIAL / 5, and the two tokens encode exactly that
ratio (100 ms of 500 ms); effective_tooltip_delay applies the ratio rather
than clamping to the absolute value. So on the warm path a 500 ms entry
reshows at 100 ms and a 700 ms heavy entry at 140 ms — a heavier surface
keeps the proportionally longer statement of intent it exists for, instead of
collapsing to the light tier's 100 ms.
Two things deliberately do not keep a session warm: a pinned (sticky) tooltip, which would otherwise hold every other anchor on the 100 ms path for as long as it stays up, and — since the grace is 1 s — any hover that starts more than a second after the last tip closed.
Widgets that need a custom value pass an explicit Duration to
attach_tooltip.
BuildContext surface
| Method | Use |
|---|---|
attach_tooltip(anchor, content, delay) | Plain hover-only tooltip |
attach_tooltip_with_sticky(anchor, content, delay, sticky_after) | Tooltip that auto-promotes after sticky_after of visible time |
attach_tooltip_with_sticky_sink(anchor, content, delay, sticky_after, shown_at_sink) | Same plus a shared Rc<Cell<Option<Instant>>> the tree updates on show/dismiss; the rich widget reads it from paint() to drive its dwell indicator without a paint-gap heuristic |
promote_tooltip_to_sticky(content_id) | Manual promotion — flag the entry sticky and swap the overlay to EscapeOrClickOutside |
Lifecycle: hover, delay, show, fade
The WidgetTree keeps a Vec<TooltipEntry> and visits it once per processed
event batch. The state-machine is:
- Hover enter (
tooltip_pointer_enter). The innermost entry whoseanchor_idcontains the entered widget recordshover_start = nowand the pointer position ashover_origin. No overlay yet. Only one entry arms: a row inside a panel that both carry tooltips would otherwise mature two tips and stack them on top of each other, so the most specific anchor — measured by arena depth, not attach order — wins. - Stationary filter (
tooltip_pointer_moved). While the tip is still pending, moving more than ~4 logical px fromhover_originrestarts the timer (Windows-style hover-tracking slop). Intentional pause, not fly-by, shows the tip. - Delay tick (
process_tooltips/process_tooltips_real). Each entry whose elapsed time sincehover_start≥ the effective delay is shown. Effective delay is the entry'sdelay, scaled by the theme'stooltip_reshow_delay : tooltip_delayratio while a tooltip session is active (any non-sticky tip currently shown, or withinTOOLTIP_SESSION_GRACE= 1 s of the last dismiss). An entry whose content announces no text is skipped rather than opening an empty bubble. On show:arena.activateon the dormant content,show_overlaywith placementNearAnchor { offset: (0, 8) }and dismiss behaviorPointerLeave { delay: 100 ms }. Theshown_at_sim/shown_at_realtimestamps are recorded; the optionalshown_at_sinkis updated. - Fade-in. Tooltips fade in over
MotionTokens::duration_fast(~120 ms). Reduced-motion users get an instant snap (no fade animation), and so does the warm reshow path — a 120 ms fade would cost more than the ~100 ms the shortened delay just saved. - Hover leave (
tooltip_pointer_leave). Pending timers are cancelled. Shown non-sticky tips stay until the overlay stack's 100 ms leave-grace (WCAG 1.4.13 Hoverable). Sticky tooltips (post-promotion) survive — the user dismisses them viaEscapeOrClickOutside.
Suppression and dismissal
Beyond hover-leave, four things retire a tooltip:
| Trigger | Pending dwell | Shown non-sticky | Sticky |
|---|---|---|---|
PointerDown anywhere (tooltip_pointer_press) | cancelled | dismissed | kept |
Drag session active (tooltip_cancel_pending_dwell) | cancelled | — | kept |
Window deactivated (tooltip_window_deactivated) | cancelled | dismissed | kept |
Escape (try_dismiss_top_on_escape) | — | dismissed | dismissed |
A press means the user already knows what the control does, so a tip must not
pop after the click that answered it, nor sit over what was just clicked —
Windows and GTK both behave this way. A drag owns the pointer, and
process_tooltips_real runs from the layout pass the drag keeps driving, so
dwells are cleared each pass rather than left to ripen into a stray overlay.
Escape scans the overlay stack top-down for the first
Escape-dismissible overlay instead of consulting only the top. That satisfies
WCAG 2.2 SC 1.4.13(a) Dismissible for hover content, and stops a tooltip
raised over an open menu from swallowing the keystroke meant for the menu
underneath. Manual overlays remain opaque to the scan.
Waking the event loop
WidgetTree::next_timer_deadline() folds every timing source the tooltip
machinery needs the loop to wake for: the pending-tooltip delay, the per-step
dwell wake-ups, delayed overlays, auto-dismiss, and the PointerLeave
leave-grace. That last one matters as much as the first: the pointer's final
motion event only starts the 100 ms grace, so without a deadline for its end
the loop would sit in ControlFlow::Wait and the tooltip would stay on screen
until some unrelated input redrew the window. See
idle-and-animation.md.
Entry lifetime
attach_tooltip* is called from build(), so it re-runs on every rebuild.
An anchor owns at most one tooltip: attaching retires any previous entry
for that anchor and destroys its content subtree, and destroying a widget
reaps the tooltip it anchored. Without both, the entry table would grow by one
dead row (plus one orphaned arena node, since ctx.add creates a parentless
one that the rebuild teardown never reaches) on every rebuild — and that table
is scanned on every pointer move, four times per layout pass, on every
event-loop wake, and once per widget during the accessibility walk.
Sticky-on-dwell (rich tooltips)
Rich tooltips opt into a 2-second dwell timer that promotes a hover-shown
tooltip into a focusable, click-through Dialog. Promotion advertises focus
but does not steal it: the panel becomes focusable and AT-reachable and
the user Tabs in (the correct non-modal-panel pattern) — whatever the user
was doing keeps keyboard focus. The threshold lives in
DWELL_PROMOTION and is
Duration::from_secs(2); it's split into 4 visible quarters of 500 ms each,
driving the DwellIndicator
in the tooltip's top-right corner.
Visual progression of the indicator:
| Step | Glyph |
|---|---|
| 0 | Empty 14×14 circle outline (just shown) |
| 1 | 25 % pie wedge filled (12 → 3 o'clock) |
| 2 | 50 % wedge |
| 3 | 75 % wedge |
| 4 / sticky | Filled pin icon (head + downward triangle tail) |
The dwell mechanism wires up two reactive signals (Signal<u32> step,
Signal<bool> sticky) inside RichTooltipWidget. On every paint the
widget recomputes both from the authoritative shown_at sink — the tree
writes Some(now) on show and None on dismiss, so the widget never
needs to track its own visibility heuristically.
When the dwell threshold elapses, WidgetTree::process_tooltips_impl
sweeps the active rich tooltips and calls
promote_tooltip_to_sticky(content_id):
- The entry's
is_stickyflag flips totruesotooltip_pointer_leaveno longer auto-dismisses it. - The overlay's dismiss behavior is swapped to
EscapeOrClickOutside. - The tooltip's a11y role flips from
Role::TooltiptoRole::Dialogand the AT node advertisesAction::Focus(the rebind happens atBindingLevel::AccessibilityOnly, so no relayout / repaint cost). - The widget itself is
focusable(true)unconditionally (avoiding a rebuild on every sticky flip), but only the sticky form is meaningfully reachable — ephemeral tooltips dismiss on pointer-leave so users can't realistically Tab into them.
The auto-promote sweep marks the entire tooltip subtree needs_paint on
every dwell-window frame, so the indicator's pie wedge advances visibly as
the user keeps hovering.
Accordion "more" disclosure
When TooltipContent::with_more(...) is set, the rich tooltip's footer row
contains an Accordion whose title is the literal string "More" and
whose content is the long-form body (also markup-aware). The Accordion's
expand state is a ctx.signal(false) owned by the tooltip widget — the
disclosure animates open in place once the user clicks the chevron, which
is only practically reachable after the tooltip has gone sticky (clicks
on a non-sticky tooltip would otherwise dismiss it via PointerLeave).
Keyboard / a11y promotion
Pointer users reach the rich-tooltip interactive surface via the 2-second
dwell. Keyboard and screen-reader users get the same access via focus
promotion — WidgetTree::tooltip_focus_enter is called when a widget
gains keyboard focus and immediately shows + promotes any rich tooltip
whose anchor_id is in the focused subtree (in either direction —
composite widgets like Button attach the tooltip to an inner subtree
root but keep focus on the outer node, so the check accepts an
ancestor-or-descendant relationship).
Plain tooltips are deliberately not auto-shown on focus — their text reaches assistive tech through the described control's accessible description, wired in the AccessKit pass, which is the W3C-recommended pattern for supplementary hints. That is the whole of what makes the asymmetry acceptable, so it has to actually land on the control.
Which node the description lands on
Not necessarily the anchor. A composing control (Button, Toggle, and
the two dozen widgets shaped like them) hangs the tooltip overlay off an
inner chrome node — the thing with the right bounds to open against — while
its role, its name and its focusability stay on its own outer node.
Emitting the description on the anchor put it on an unnamed box beside the
control: present in the tree, attached to nothing anyone reads. Since a
plain tooltip is never auto-shown on focus, that description is the
entire non-pointer path for the tier, and it reached nobody.
So every BuildContext::attach_tooltip* records the widget that was
building as the tooltip's description_owner_id, and the AccessKit pass
honours it — but only where that node is unambiguously the one being
described. Three ways a claim is declined, each falling back to the anchor,
which is where the description sat before any of this and so is always safe:
- Contested. One
build()can attach many tooltips, andself_id()is the same for all of them: a list body pane attaches one per visible row, every one naming the pane. Granting that would put one row's text on the pane and lose every other row's outright. A contested claim is no claim.SplitButton, which tooltips its main region and its chevron separately, declines for the same reason and keeps both on their own regions. - Anonymous. A widget whose own
accessibility()leaves it a content-freeGenericContaineris not a node a description can be read from —TextInputsays so out loud, keeping the real role on an inner field — so it does not get to hold one. - Already spoken for. A description the widget wrote itself
(
MenuItem::trailing_hint) is specific where a tooltip's is supplementary, and both land in the one scalar field. The specific wins.
Overlay placement, hover hit-testing, dwell, focus promotion and retirement
all go on reading anchor_id. Only the accessibility walk reads the owner.
The AccessKit pass emits one of two forms, depending on whether the tooltip is currently on screen:
- Shown —
described_bypointing at the live tooltip content node, the richer relation, since the node is genuinely in the tree and navigable. - Not shown — the content's announced text copied onto the anchor as a
static
description, harvested from the content widget's ownaccessibility()(all three tiers publish their body as the node name).
The second form is what actually carries the majority tier. A described_by
relation can only reference a node that exists in the emitted tree, and a
dormant tooltip's content is excluded from it — so gating the wiring on "is
the overlay shown" left plain tooltips, which are never auto-shown on focus,
with no screen-reader path at all. The text is read at walk time, so a locale
change or a Signal<String> swap is picked up by the same AT re-walk that
already tracks both.
When focus moves away from a focus-promoted tooltip,
tooltip_focus_leave_outside dismisses it unless the new focus is
inside either the anchor's subtree or the tooltip-content subtree (so
Tab-into the tooltip to click an inline link keeps it open). Pointer-
dwelled stickies survive focus changes intact — they're only dismissed
via Escape or click-outside, matching the existing mouse UX.
Accessibility roles
| State | Role | Notes |
|---|---|---|
TooltipWidget (always) | Role::Tooltip with set_name(text) | Plain text, no interaction |
RichTooltipWidget (ephemeral) | Role::Tooltip with set_name(body_text_resolved) | Body and shortcut child TextWidgets are a11y_hidden so the parent owns the announcement |
RichTooltipWidget (sticky) | Role::Dialog + Action::Focus | Tab-reachable, click-through |
DwellIndicator | Role::GenericContainer | Decorative — content meaning lives on the tooltip itself |
Inline shortcut chips are bound to the ShortcutRegistry so user rebinds
re-render the chip on the next pass (the registry's version signal is
bound at BindingLevel::Rebuild).
Theming knobs
Tooltip layout constants are defined in
teksilo-widgets/src/styles/recipe_tooltip_style.rs:
#![allow(unused)] fn main() { pub const TOOLTIP_PADDING_HORIZONTAL: f32 = 10.0; pub const TOOLTIP_PADDING_VERTICAL: f32 = 6.0; pub const TOOLTIP_CORNER_RADIUS: f32 = 8.0; pub const TOOLTIP_MAX_WIDTH: f32 = 320.0; pub const TOOLTIP_SHADOW_DENSITY: f32 = 1.0; // Composite variant: pub const COMPOSITE_TOOLTIP_PADDING_HORIZONTAL: f32 = 12.0; pub const COMPOSITE_TOOLTIP_PADDING_VERTICAL: f32 = 12.0; pub const COMPOSITE_TOOLTIP_CORNER_RADIUS: f32 = 8.0; pub const COMPOSITE_TOOLTIP_MAX_WIDTH: f32 = 480.0; pub const COMPOSITE_TOOLTIP_MAX_HEIGHT: f32 = 480.0; pub const COMPOSITE_TOOLTIP_SHADOW_DENSITY: f32 = 0.7; }
Per-instance overrides on CompositeTooltipWidget: .max_width(f32) /
.max_height(f32). Apps that need different global defaults can install a
custom impl TooltipStyle via theme.style_slots.tooltip.
Color tokens (Theme::colors):
| Token | Role | Default (light + dark — both intentionally dark) |
|---|---|---|
tooltip_bg | SurfaceRole::TooltipBg | #1E1F22 |
tooltip_text | TextRole::TooltipText | #DFE1E5 |
tooltip_border | BorderRole::TooltipBorder | #393B40 |
tooltip_shortcut | TextRole::TooltipShortcut | #9DA0A8 |
Int UI's house style: tooltip surfaces stay dark in both light and dark themes
for high-contrast popups (also reused by Snackbar). The OS-theme bridge
(theme.rs) lets a host OS override
tooltip_bg / tooltip_text if the platform exposes corresponding values.
Motion knobs come from MotionTokens:
| Token | Default | Used for |
|---|---|---|
duration_fast | 120 ms | Tooltip fade-in (and matching fade-out for sticky dismiss) |
The RichTooltipWidget clamps its proposal width to TOOLTIP_MAX_WIDTH in
layout_response so long bodies wrap rather than stretching the surface
horizontally. Layout uses a Grid (Fractional + Auto columns) so the body
text receives a width proposal that excludes the trailing shortcut chip and
the dwell indicator — HStack + Spacer would propose the body's natural
single-line width and the chip / indicator would overflow.
Builder API surface (per-widget)
The canonical surface is the same four methods on every widget listed under Supported widgets above:
#![allow(unused)] fn main() { .tooltip(text) // plain — impl Into<LocalizedString> .rich_tooltip(key) // rich — registry key, impl Into<String> .rich_tooltip_content(content) // rich — inline TooltipContent .composite_tooltip(widget) // composite — impl Widget + 'static }
The last setter wins — calling .tooltip(...) after .rich_tooltip(...)
clears the rich source, and vice versa.
Exceptions and extras worth knowing:
| Widget(s) | Difference |
|---|---|
SplitButton | Mirrors all four onto its chevron with a parallel chevron_tooltip / chevron_rich_tooltip / chevron_rich_tooltip_content / chevron_composite_tooltip matrix. |
SegmentedControl (per-Segment), ToolbarAction | Clone value types: .composite_tooltip(...) takes a factory impl Fn() -> Box<dyn Widget> (not an impl Widget instance), since Box<dyn Widget> isn't Clone. |
TextInput, PasswordField | Also keep a legacy rich_tooltip_key(key) alias predating the canonical rich_tooltip(key); prefer the canonical name. |
TabDelegate | Closure-driven per-tab delegate — rich_tooltip_key / rich_tooltip_content_with / composite_tooltip_with take Fn(&T) -> … closures rather than fixed values. |
ThemeSwitcher, LanguageSwitcher | Thin ComboBox presets; the four setters forward onto the inner ComboBox. |
Toast | Not applicable — a request builder, not a Widget; tooltip is data rendered by toast/surface.rs. |
tooltip_literal is a permanent #[doc(hidden)] shim that wraps a raw
String in LocalizedString::literal — same grep marker as
Button::new_literal, intended for tests and explicitly-untranslated call
sites.
Authoring tip: pre-create dormant content
The plain attach_tooltip API takes a content_id you already inserted
into the arena. The pattern inside an anchor widget's build() is:
#![allow(unused)] fn main() { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let root = ctx.add(/* visible subtree */); if let Some(text) = self.tooltip_text.as_deref() { let tooltip = ctx.add(TooltipWidget::new(lit!(text))); ctx.attach_tooltip(root, tooltip, Duration::from_millis(500)); } self.root_child_id = Some(root); vec![root] } }
attach_tooltip_inner immediately calls arena.set_dormant(content_id),
so callers don't need their own dormant marker — but they must not place
the tooltip widget under a visible parent. The standard pattern is
ctx.add(tooltip_widget) (which inserts at the arena top level) followed
by the attach_* call.
See also
- Overlays in architecture.md — overlay
manager,
OverlayRequest, dismiss behaviors. - reactive-theme.md —
ColorProp, role-driven colors, Signal-bound theme switching. - shortcut-intent-action.md — the
ShortcutRegistrythat backsTooltipContent::for_shortcut. - accessibility-overrides.md — builder-level
AT augmentation, including
.access_described_by(tooltip_content_id)for explicitaria-describedbywiring. - idle-and-animation.md — how the idle event loop
uses
next_timer_deadline()to schedule pending-tooltip wake-ups.
Toast Notification Reference
teksilo_widgets::toast ships the Teksilo notification system: stackable,
action-rich, severity-aware floating notifications backed by a
persistent archive with a log + bell-button + dialog UI.
Mental model in one line:
TeksiloAppBuilder.install_toast_default() → ctx.show_toast(Toast::…) from any handler
Distinct from siblings:
| Widget | Shape | Lifetime | Stackable | Archive |
|---|---|---|---|---|
Snackbar | Bottom-center, single-instance | Auto-dismiss (4s default) | No (calls dismiss_all_except_hosts) | No |
Banner | Inline persistent strip | Until user dismisses | No (inline) | No |
MessageBox | Modal dialog | Until user picks a button | No (one modal at a time) | No |
Toast | Corner-anchored, stackable | Auto-dismiss (10s default) or persistent | Yes | Yes (persistent across restarts) |
End-to-end demo: cargo run -p toast-demo. Source:
examples/toast_demo/src/main.rs.
Quickstart
use teksilo::prelude::*; use teksilo::settings::AppPaths; fn main() { TeksiloAppBuilder::new() .theme(intui::light()) .app_paths(AppPaths::new("eu", "FernTech", "MyApp").unwrap()) .install_toast_default() // ← one-line install .initial_window(WindowConfig::new() .id("main") .root(|tree, _state| tree.add(MyRoot::new()))) .run(); }
Anywhere inside a handler:
#![allow(unused)] fn main() { fn save_handler(ctx: &mut EventContext) { save_to_disk(); ctx.show_toast( Toast::success(tr!(saved())) .body(tr!(save_details(path = path.display()))) ); } }
For errors with replayable actions:
#![allow(unused)] fn main() { ctx.show_toast( Toast::error(tr!(build_failed())) .body(tr!(build_error_count(n = 3))) .action(ToastAction::primary(tr!(retry()), |c| c.send_intent(AppIntent::BuildRetry))) .action(ToastAction::new(tr!(show_log()), |c| c.send_intent(AppIntent::ShowLog))) ); }
For long-running operations that update in place:
#![allow(unused)] fn main() { let h = ctx.show_toast(Toast::loading(tr!(uploading_start())).id("upload")); // later, with the same id: ctx.show_toast(Toast::loading(tr!(uploading_progress(n = 4))).id("upload")); // completion replaces the same entry: ctx.show_toast( Toast::success(tr!(upload_complete())) .id("upload") .auto_dismiss_after(Duration::from_secs(5)) ); }
Installing the toast system
One line at the builder. The TeksiloAppBuilderToastExt extension trait
is re-exported from the umbrella prelude (teksilo::prelude::*) so
install_toast(...) / install_toast_default() are callable without
an extra import:
#![allow(unused)] fn main() { .install_toast_default() // BottomTrailing, persistent .install_toast(ToastInstallOptions { // explicit override corner: Corner::TopTrailing, archive: Some(NotificationArchive::in_memory()), ..ToastInstallOptions::default() }) }
The toast subsystem ships behind the umbrella's toast feature
(default-on). To drop it (and the bell-icon SVG + archive code),
depend on teksilo with default-features = false and re-add only
the features you need.
What install_toast does, internally:
- Opens the
NotificationArchiveModel—InMemoryorPersistent.Persistentrequiresapp_paths(…)to be set on the builder first; the install panics with a helpful message otherwise. - Constructs a shared
ToastRegistrybound to the archive (or naked if no archive is configured). - Registers a
DefaultPostRootclosure that wraps every window's root withZStack { user_root, ToastHost::new(registry, options) }. TheDefaultPostRootfires for every window the app opens — initial AND runtime-opened — so the host installs everywhere automatically. - Registers the
ToastRegistry+Rc<NotificationArchiveModel>intoapp_statesoNotificationLog/NotificationCenterButton/NotificationLogDialogcan look them up.
Install options
#![allow(unused)] fn main() { pub struct ToastInstallOptions { pub corner: Corner, // BottomTrailing (default) pub margin: Vec2, // (24.0, 24.0) pub gap: f32, // 8.0 between stacked toasts pub max_visible: usize, // 5 pub entry_width: f32, // 380.0 (matches IntelliJ) pub pause_on_hover_group: bool, // true (any hover pauses all) pub archive: Option<NotificationArchive>, // Persistent("notifications") (default) } }
| Field | Notes |
|---|---|
corner | RTL-aware: Trailing flips to the physical left edge. Top corners stack downward, bottom corners upward. |
max_visible | Normal-priority overflow drops with cause SlotPoolFull. High / Urgent priority evicts the oldest Normal to make room. |
pause_on_hover_group: true | Hovering ANY live toast pauses every timer. false pauses only the hovered toast (libadwaita behaviour). |
archive: None | Toasts work; just not archived. NotificationLog / bell button still load but show empty state. |
archive: Some(InMemory) | Session-only ring buffer. Cleared on app exit. |
archive: Some(Persistent) | TOML file at <config>/notifications.toml via PersistedListModel. Survives app restarts. |
The Toast request
Toast is NOT a Widget — it's a present-able request, like
MessageBox::present(ctx). Construct with a severity-named
constructor, configure via builder methods, then call
ctx.show_toast(toast) (or toast.present(ctx)).
Constructors
| Constructor | Severity | Default behaviour |
|---|---|---|
Toast::info(title) | Info | Status / confirmation |
Toast::success(title) | Success | "Saved", "Deploy complete" |
Toast::warning(title) | Warning | Non-fatal issue |
Toast::error(title) | Error | Failure (forces Role::Alert + Live::Assertive) |
Toast::loading(title) | Info | Persistent, with a Spinner leading widget |
Each has a _literal #[doc(hidden)] shim (info_literal, etc.)
for untranslated strings. Use during scaffolding; the grep marker
keeps "find me before localizing" findable.
Builder methods
#![allow(unused)] fn main() { Toast::warning(tr!(unsaved())) .body(tr!(close_anyway())) // optional second line .leading(MyAppIcon::new()) // override severity glyph .action(ToastAction::primary(tr!(save()), on_save)) // 0..N actions .primary_action(tr!(retry()), on_retry) // shorthand for ToastAction::primary .auto_dismiss_after(Duration::from_secs(5)) // override the 10s default .persistent() // disable auto-dismiss .priority(ToastPriority::High) // Normal | High | Urgent .id("unique-key") // update-in-place key (see below) .on_click(|ctx| ctx.send_intent(AppIntent::Open)) // click-anywhere callback .on_dismiss(|cause, ctx| log_dismiss(cause)) // fires once .show_close_button(false) // default: true .closable_on_escape(false) // default: true .announcement(tr!(custom_at_text())) // override AT name .archive(false) // opt out of archive mirror .style(MyToastStyle) // per-call style override }
ToastAction
#![allow(unused)] fn main() { ToastAction::new(label, on_invoke) // Link (default) ToastAction::primary(label, on_invoke) // Filled button ToastAction::destructive(label, on_invoke) // Destructive button ToastAction::new(label, on_invoke) .style(ToastActionStyle::Button { variant: ButtonVariant::Plain }) .closes_toast(false) // default: true (IntelliJ "expiring") .shortcut_id("app.save") // for archive replay .tooltip(tr!(save_explainer())) }
Link actions render inline in the body row; Button actions go in a footer row below the body. Mixed within the same toast is allowed.
Update-in-place
Toast::id("…") is the dedup key. When a second toast carrying a
matching id is presented:
- Live side: the existing entry's fields mutate in place (title,
body, severity, actions, …) and the auto-dismiss timer resets.
The original
entry_idis preserved — the first call'sToastHandlekeeps working. - Archive side: the existing archived entry merges (an
UpdateRecordappended to itsupdatesVec); no new row appears inNotificationLog.
Preserved across updates that don't re-specify them:
| Field | Preserved when update omits it |
|---|---|
on_dismiss | Yes (the original callback survives) |
leading | Yes (a Toast::loading spinner survives Toast::info updates) |
| Other fields | No (always overwritten by the update) |
When the update DOES specify on_dismiss / leading, the
replacement installs and the previous values drop silently.
Contract: on_dismiss fires exactly once per entry, with the
most-recently-supplied callback.
Setting archive(false) on an update
The update's archive flag is honored. If the original was
archived but a subsequent update sets archive(false), the existing
archive record stays in place but no further updates are mirrored.
Apps that want continuous archive capture should leave archive at
default true across updates.
ToastHandle
Returned by ctx.show_toast(...) and toast.present(ctx). Cheap
to clone (Rc<Inner>).
#![allow(unused)] fn main() { let h = ctx.show_toast(Toast::loading(lit!("Working…"))); // Some time later, in another handler: if h.is_alive() { h.dismiss(ctx); // ToastDismissCause::Programmatic } }
Dropping the handle does NOT dismiss the toast — toasts have their own lifecycle (timer + manual paths). The handle is OPTIONAL "I want to control this later" wiring.
Severity × priority → AT role × live region
The AT role / live region is computed from severity + priority:
| Severity | Priority | Role | aria-live |
|---|---|---|---|
Info, Success | any | Status | Polite |
Warning | Normal | Status | Polite |
Warning | High / Urgent | Alert | Assertive |
Error | any | Alert | Assertive |
| any | Urgent | (forces) | Assertive |
All toasts call set_live_atomic() so the entire title+body
announces as one unit. The announcement(...) builder overrides
the spoken text without changing the visible title (useful when
the visible title is iconic but the spoken text needs context).
ToastDismissCause
| Cause | When |
|---|---|
Timeout | auto_dismiss_after reached zero (timer expired naturally) |
ActionInvoked | A ToastAction with closes_toast(true) (the default) fired |
CloseClicked | User clicked the X |
EscapePressed | User pressed Escape while focused into the toast |
Programmatic | ToastHandle::dismiss() or ctx.dismiss_toast(handle) |
HostShutdown | Window is being torn down |
SlotPoolFull | Pool full + Normal-priority drop, OR evicted by a higher-priority arrival |
ToastRegistry
Cheap to clone (Rc<RefCell<…>>). Holds the queue + per-entry
state, registered in app-state by install_toast. Apps don't usually
construct one directly — use the install hook.
#![allow(unused)] fn main() { pub struct ToastRegistry { … } impl ToastRegistry { pub fn new(opts: ToastInstallOptions) -> Self // no archive pub fn with_archive(opts, archive: Rc<NotificationArchiveModel>) -> Self pub fn archive(&self) -> Option<Rc<NotificationArchiveModel>> pub fn version_signal(&self) -> &Signal<u64> // bumps on every change pub fn hover_count_signal(&self) -> Signal<usize> // shared pause refcount pub fn live_count(&self) -> usize // test helper } }
The version_signal is what ToastHost binds to at
BindingLevel::Rebuild; bumps on enqueue / in-place merge / timer
expiry / programmatic dismiss / close-click / action-invoked.
hover_count is shared between every ToastSurface: each surface's
outer wrapper has an on_hover handler that increments / decrements
this refcount. The host's frame-tick effect reads count > 0 as
"pause all timers" (when pause_on_hover_group: true).
ToastHost
Invisible sibling widget owning the queue + per-frame timer. The
install hook wraps every window with
ZStack { user_root, ToastHost::new(registry, options) }. Direct
construction is needed only when apps want manual mounting
control:
#![allow(unused)] fn main() { pub fn new(registry: ToastRegistry, options: ToastInstallOptions) -> Self }
Renders zero of its own chrome. Each live entry from the registry
becomes a ToastSurface child placed at the configured corner with
stacked offset. Newer entries are placed closer to the corner
anchor (matches IntelliJ + Windows convention).
Per-frame-tick effect (subscribed via ctx.subscribe_frame_tick())
decrements every entry's time_left by the wall-clock dt — unless
hover-pause is active. Expired entries are dismissed deferred (the
user callback fires on the next pointer event).
NotificationArchiveModel
The persistent layer behind the live toast queue. Two backends:
| Backend | Use case |
|---|---|
InMemory { limit } | Session-only. No disk I/O. Cleared on exit. |
Persistent { file_name, limit } | <config>/<file>.toml via PersistedListModel. Survives restarts. |
Default limit: DEFAULT_ARCHIVE_LIMIT = 200. IntelliJ keeps
hundreds; Teksilo picks a pragmatic cap so persistent files don't
grow unbounded.
NotificationEntry
The persistent shape — closures dropped, intent_name retained for
archive replay:
#![allow(unused)] fn main() { pub struct NotificationEntry { pub id: u64, // per-archive stable id, monotonic pub severity: BannerSeverity, pub priority: ToastPriority, pub title: String, pub body: Option<String>, pub actions: Vec<ArchivedAction>, pub timestamp: jiff::Timestamp, pub group: Option<String>, // optional visual grouping key pub source: Option<String>, // optional originating-feature tag pub read: bool, // flipped by mark_all_read pub dedup_id: Option<String>, // sourced from Toast::id pub updates: Vec<NotificationUpdate>,// appended by in-place merges } pub struct ArchivedAction { pub label: String, pub intent_name: Option<String>, // sourced from ToastAction::shortcut_id pub style: ArchivedActionStyle, // Link | PrimaryButton | SecondaryButton | Destructive pub closes_on_invoke: bool, } pub struct NotificationUpdate { pub timestamp: jiff::Timestamp, pub title: Option<String>, // None if unchanged pub body: Option<String>, pub progress: Option<f32>, } }
Methods
#![allow(unused)] fn main() { pub fn open(archive, paths, debounce) -> Result<Self, NotificationArchiveError> pub fn in_memory() -> Self // convenience pub fn entries(&self) -> &ListModel<NotificationEntry> // observable pub fn unread_count(&self) -> &Signal<usize> // drives the badge pub fn version_signal(&self) -> &Signal<u64> // drives the log rebuild pub fn push(&self, entry: NotificationEntry) // bounded; deduplicates by dedup_id pub fn mark_all_read(&self) // resets unread to 0 pub fn clear(&self) pub fn remove(&self, index: usize) pub fn flush_now(&self) -> Result<(), SettingsFileError> // persist immediately (test helper) }
push semantics:
- New entry → inserts at index 0 (newest first), evicts oldest past
limit, bumpsunread_count+version. - Existing
dedup_id→ updates the matching entry's title/body in place, appends aNotificationUpdateto itsupdates, flips it back to unread, bumpsunread_count+version.
version_signal bumps on every actual mutation; no-ops (mark_all_read
on a fully-read archive, clear on empty) don't bump.
NotificationLog
Widget rendering the archive as a filterable list. Composition:
VStack {
Toolbar { Spacer, "Mark all read" button, "Clear all" button },
ScrollArea {
VStack {
"Today" header,
row, row, …
"Yesterday" header,
row, row, …
"This week" header,
…
"Earlier" header,
…
}
},
// OR empty-state hint when the archive is empty
}
Each row is a StandardListItem:
severity glyph leading, title in BodyBold (unread) / Body
(read), body as subtitle, action buttons trailing. Outer node
carries Role::List + set_name("Notifications").
Title and body are elided, not wrapped, and the full text is
carried on the row's rich tooltip. Notification text is arbitrary
app-supplied prose and the log does not control its own width, so a
wrapping row (StandardListItem's default) would report its full
one-line intrinsic width, over-constrain the row, and push the
trailing action buttons outside it — clipped, with no horizontal
scrollbar to reach them.
Sizing
The log grows into a host that bounds its height (a dialog, a
side panel) and compresses inside one shorter than its natural
height, down to a one-row floor. Only a host that hugs its content —
which is how the overlay layer measures the
NotificationCenterButton popover — falls back to the preferred
size, since rows cannot report a meaningful intrinsic width of their
own:
#![allow(unused)] fn main() { NotificationLog::new(archive) .preferred_width(380.0) // default — width when the host proposes none .preferred_height(320.0) // default — list height when the host proposes none }
Builder
#![allow(unused)] fn main() { NotificationLog::new(archive: Rc<NotificationArchiveModel>) .show_toolbar(true) // default true .empty_state(|| Box::new(my_empty_widget())) // factory: re-run per rebuild .on_entry_invoked(|entry, ctx| open_details(entry, ctx)) // click-row callback .on_action_invoked(|entry, action, ctx| { // archive-replay callback match action.intent_name.as_deref() { Some("app.build.retry") => ctx.send_intent(AppIntent::BuildRetry), Some(name) => log::warn!("unknown archived intent: {name}"), None => {} } }) }
Archive-action replay model
ArchivedAction::intent_name is captured from
ToastAction::shortcut_id. The log uses it to decide whether an
action is clickable:
intent_name | on_action_invoked set | Renders as |
|---|---|---|
| Present | Yes | Clickable Link / Button → fires the callback |
| Present | No | Inert text tag |
None | any | Inert text tag with "(no longer available)" suffix |
The "no longer available" tag matches IntelliJ's event-log
semantics: actions that depended on live closures can't be replayed
once the closure has torn down. Actions that go through the Intent
system survive archival — apps map the archived intent_name to
their typed AppIntent variants in the callback.
Day-bucket section headers
Entries are grouped into four buckets, computed against the user's
local timezone via jiff::Zoned:
| Bucket | Range |
|---|---|
| Today | Same local-calendar date |
| Yesterday | today - 1 day |
| This week | 2..=6 days ago |
| Earlier | 7+ days ago |
Future timestamps (clock skew, peer sync) bucket as Today so they don't silently slip into Earlier.
Buckets recompute on every archive mutation (the log binds to
archive.version_signal() at Rebuild). A log left open across
midnight keeps stale labels until the next mutation OR until the
user reopens — acceptable for the popover-shaped UX.
NotificationCenterButton
Bell-icon trigger + live unread badge + popover containing a
NotificationLog. The de-facto status-bar widget.
#![allow(unused)] fn main() { NotificationCenterButton::new(archive: Rc<NotificationArchiveModel>) .size(IconButtonSize::Toolbar) // default Toolbar (30dp) .show_badge_when_zero(false) // default: hide badge at 0 .max_badge_count(99) // default 99; counts above show "99+" .placement(OverlayPlacement::BelowPreferred) // popover anchor .on_action_invoked(|entry, action, ctx| { … }) // forwarded to the embedded log }
The badge label binds reactively to archive.unread_count(). On
popover open, the archive's mark_all_read runs (the user is
presumed to have seen the toasts now) — the badge resets, the
badge widget disappears.
┌─────┐
│ 🔔3 │ ← bell icon + badge with unread count
└─────┘
↓ click
┌──────────────────────────────────────┐
│ Mark all read │ Clear │
│ Today │
│ • [⚠] Build #42 failed [Retry][Log] │
│ • [✓] File saved │
│ Yesterday │
│ • […] Upload complete │
│ Earlier │
│ … │
└──────────────────────────────────────┘
A custom BellButton example wrapper that pulls the archive from
app-state lives in the demo:
examples/toast_demo/src/main.rs.
NotificationLogDialog
One-liner modal preset wrapping NotificationLog in a
ModalContainer:
#![allow(unused)] fn main() { NotificationLogDialog::show(archive, ctx); NotificationLogDialog::show_with(archive, ctx, |log| { log.on_action_invoked(|entry, action, ctx| { … }) }); }
Default presentation: ModalPresentation::Auto, 720×520 size,
title "Notifications", dismissed via Escape or click-outside.
Apps wire this to a menu item ("Window → Notification Log…") or a shortcut.
Accessibility checklist
| Concern | How it's handled |
|---|---|
| Role mapping | Status / Alert per the severity × priority table above. |
| Live region | Live::Polite / Live::Assertive per the same table; set_live_atomic() so title+body is one announcement. |
| Custom announcement text | Toast::announcement(text) overrides the AT name without changing the visible title. |
| Body description | Toast::body(text) → set_description(body) on the surface node. |
| Severity glyph | set_hidden() — presentational, not in the AT tree (the surface itself carries the role + name). |
| Close button | IconButton with its own Role::Button + tooltip / a11y label. Reachable by Tab once focus is inside the toast. |
| Action buttons | Standard Button / Link a11y. |
| Escape | Dismisses with cause EscapePressed while focus is inside the toast. |
| Bell button | IconButton with the localized "Notifications" tooltip. Badge widget contributes its own Role::Label with the count. |
| Log | Role::List outer + Role::ListItem rows + section headers as plain TextWidget with Secondary color. |
Urgent priority forces Live::Assertive regardless of severity —
the escape hatch for "this Info-level toast is actually
time-critical" cases.
Per the WAI-ARIA spec gotcha: the live-region node must exist in
the AT tree before its content is populated, otherwise ATs miss the
announcement. ToastSurface::build handles this — the surface's AT
node is constructed empty, then bound to the title signal one frame
later (via ctx.subscribe_frame_tick() one-shot).
Reduced motion
prefers-reduced-motion is consulted at the host's enqueue path.
When set: no fade-in / fade-out on the overlay, no slide-in. The
surface appears in place, instantly. The auto-dismiss timer still
runs normally — reduced motion affects only the visual transition.
i18n keys
Built-in keys (en-US source + fr-FR translation shipped):
| Key | Default text |
|---|---|
a11y-builtin-bell | "Notifications" |
notifications-title | "Notifications" |
notifications-empty | "No notifications" |
notifications-mark-all-read | "Mark all read" |
notifications-clear | "Clear all" |
notifications-filter-placeholder | "Search notifications" (reserved) |
notifications-bucket-today | "Today" |
notifications-bucket-yesterday | "Yesterday" |
notifications-bucket-this-week | "This week" |
notifications-bucket-earlier | "Earlier" |
notifications-archive-replay-disabled | "(no longer available)" |
Apps that need more locales register them through the framework's
standard I18nConfig::framework_locales(teksilo_widgets::framework_locales()).
Where the code lives
| Concern | File |
|---|---|
Toast request + ToastAction + ToastDismissCause + ToastHandle | crates/teksilo-widgets/src/toast.rs |
ToastRegistry (queue + archive bridge + in-place merge) | crates/teksilo-widgets/src/toast/registry.rs |
EventContextToastExt (ctx.show_toast / ctx.dismiss_toast) | crates/teksilo-widgets/src/toast/ext.rs |
ToastSurface (chrome + a11y + custom actions) | crates/teksilo-widgets/src/toast/surface.rs |
ToastHost (queue display + timer + hover-pause) + ToastInstallOptions | crates/teksilo-widgets/src/toast/host.rs |
RecipeToastStyle (default chrome) | crates/teksilo-widgets/src/styles/recipe_toast_style.rs |
ToastStyle trait + ToastPriority + ToastStyleConfig | crates/teksilo-core/src/styles/toast_style.rs |
NotificationEntry + ArchivedAction + NotificationUpdate | crates/teksilo-widgets/src/notification.rs |
NotificationArchiveModel (InMemory / Persistent) | crates/teksilo-widgets/src/notification/archive.rs |
NotificationLog (toolbar + buckets + rows) | crates/teksilo-widgets/src/notification/log.rs |
NotificationCenterButton (bell + badge + popover) | crates/teksilo-widgets/src/notification/center_button.rs |
NotificationLogDialog (modal preset) | crates/teksilo-widgets/src/notification/log_dialog.rs |
install_toast extension trait (TeksiloAppBuilderToastExt) | crates/teksilo/src/toast_install.rs |
| Runnable demo | examples/toast_demo/src/main.rs |
Related core API additions
The toast system landed alongside small teksilo-core extensions reusable by other overlay-shaped widgets:
teksilo_tokens::Corner(TopLeading/TopTrailing/BottomLeading/BottomTrailing) with RTL-awareresolve(content, viewport, margin, rtl).OverlayPlacement::ViewportCorner { corner, margin }— anchor-independent corner-snapped overlay placement.OverlayManager::pause_auto_dismiss(id)/resume_auto_dismiss(id)— stashauto_dismiss_after - elapsedand restore with a freshshown_at_*. Available to any overlay caller; ToastHost owns its own timer so doesn't use these, but Snackbar refinements or future hover-pause-able overlays can.EventContext::pause_overlay_auto_dismiss(id)/resume_overlay_auto_dismiss(id)— handler-side queue that forwards toOverlayManagerafter the dispatcher returns.TeksiloAppBuilder::configured_app_paths()— read-side companion to the existingapp_paths(paths)setter. Builder-extension traits use it to open persistent files at install time beforerun.teksilo_core::styles::ToastStyleslot +ComponentStyleSlots::toast.
Limitations & explicit non-goals
- Multi-window: a single shared
ToastRegistryper app. Toasts show up in whichever window's host last bound — apps with multiple windows share one queue. Per-window routing is a planned refinement. - OS-level notifications (libnotify / NSUserNotification / Windows toast): out of scope. Toast is an in-app widget. Apps wanting OS notifications wire that through a separate crate.
- Inline reply / combo / picker inside a toast (Windows toast text-box / combo): non-portable, out of pattern for Teksilo.
- Cross-window deduplication:
Toast::idis per-app (the registry is app-singleton), but theNotificationLogrebuild signal is per-archive — multi-window apps see the SAME log in every bell button. - Sticky promotion via long hover (the tooltip dwell pattern):
not needed — a Toast is either sticky from the start
(
persistent()) or timed; there's no progressive promotion. - Live update-in-place visual transition: the surface mutates
in place (no fade-out + fade-in), which matches the IntelliJ
pattern. Apps that want a visual handoff can dismiss + show
separately instead of using
Toast::id. - SearchField filter in NotificationLog: documented as a future refinement. Apps can compose external filtering with their own toolbar above the log.
- Severity-chip filter in NotificationLog: same — composable
with
SegmentedControl.
Native (OS) Menu Bar
Teksilo can mirror a menu into the platform's native menu surface — the
global menu bar at the top of the screen on macOS (NSApplication.mainMenu). A
serious desktop app is expected to present its menus this way on macOS; an
in-window strip alone reads as non-native.
The design is a single declarative model consumed by two renderers:
- the in-window
MenuBarwidget, and - a platform
NativeMenuBackend(realNSMenuon macOS; a no-op everywhere else).
MenuModel ──┬─► MenuBar::from_model(..) (in-window dropdowns)
(widgets) └─► NativeMenuSnapshot ─► NSMenu (macOS global bar)
(plain data, crosses into teksilo-platform)
Quick start
#![allow(unused)] fn main() { use teksilo::widgets::{MenuBar, MenuModel, MenuEntry, NativeMenuMode}; // 1. Install the native-menu service on the app. TeksiloAppBuilder::new() .install_native_menu() .initial_window(WindowConfig::new().root(|tree, _| tree.add(Root::new()))) .run(); // 2. Build one model, render it both ways. let model = MenuModel::new() .menu(tr!(file()), |m| m .item(MenuEntry::new(tr!(new())).intent("app.new").shortcut("app.new")) .separator() .item(MenuEntry::new(tr!(quit())).intent("app.quit").shortcut("app.quit"))) .menu(tr!(view()), |m| m .item(MenuEntry::new(tr!(show_grid())).checkable(grid_visible.clone()))); let bar = ctx.add( MenuBar::from_model(model).native_on_macos(NativeMenuMode::Suppress), ); }
Item activations route through the usual Intent/Action pipeline (with
IntentSource::Menu), so the same Action::new("app.new") fires whether the
item was chosen from the native menu, the in-window menu, or its keyboard
shortcut.
Demo: cargo run -p native-menu.
The model
MenuModel (in teksilo-widgets) is a cloneable handle (Rc inside) holding a
tree of MenuNodes with a version: Signal<u64>:
MenuModel::menu(title, |m| …)— a top-level menu.MenuModel::standard(role)— a platform-standard menu (StandardMenuRole::App/Window/Help); rendered natively, ignored in-window.- inside a menu:
MenuItems::item(MenuEntry),.separator(),.submenu(title, |m| …).
MenuEntry is the leaf builder:
| method | effect |
|---|---|
.intent("app.x") | fire intent by name on activation |
.on_activate(|ctx| …) | run a closure (after the intent) |
.shortcut("app.x") | display + bind the ShortcutRegistry chord |
.enabled(prop) | static or Signal<bool> — greys out reactively (both surfaces) |
.visible(prop) | static or Signal<bool> — hide reactively in-window (native: omitted at build) |
.checkable(Signal<bool>) | two-state check item |
.tri_checkable(Signal<CheckState>) | tri-state check item |
.radio(value, Signal<usize>) | radio item within a group |
Each MenuEntry is assigned a process-unique
MenuItemId — the token the native
backend round-trips on activation.
NativeMenuMode (the macOS flag)
MenuBar::native_on_macos(mode):
Off(default) — in-window bar only; the native bar is untouched.Suppress— on macOS, mirror to the OS bar and hide the in-window strip (only its leading/trailing slots render). The native-looking choice.Coexist— mirror to the OS bar and keep the in-window strip too.
On non-macOS targets the flag is ignored and the in-window bar always renders
(the native backend is a no-op there). The architecture is platform-neutral, so
a Windows HMENU / Linux DBus app-menu backend can be added later without
touching the model or the widget.
Driving the menu from anywhere in the app
You never reach into the menu widget. Two channels reach it from any handler:
Trigger a command — fire the intent (no handle needed):
#![allow(unused)] fn main() { ctx.send_intent(Intent::new("app.save")); // runs Action "app.save" }
Change per-item state — bind a Signal to the entry and .set() it from
anywhere (keep the signal in app_state or a captured clone):
#![allow(unused)] fn main() { let can_save = Signal::new(false); MenuEntry::new(lit!("&Save")).intent("app.save").enabled(can_save.clone()); // deep in the app: can_save.set(true); // greys in/out live, in-window AND native }
enabled / checkable / tri_checkable / radio / visible are all
signal-driven and update without a rebuild (the native path observes the signal
and calls update_item; the in-window path binds it directly).
A derived signal works here too — enabled(unsaved.and(&backup_mode.not()))
is a normal binding, not a special case: Signal::observe registers on the
mutable roots a derived signal was built from. The one shape it cannot follow is
flat_map, whose inner signal is re-selected as it is read; such a row keeps the
state it was installed with in the global bar and does not follow later
changes. The in-window menu binds it directly and stays live either way.
Dynamic structure (add / remove items at runtime)
MenuModel is a cloneable handle with &self mutators. Each bumps version;
a from_model bar binds version at Rebuild level, so the in-window dropdowns
re-derive and the native menu re-installs automatically.
#![allow(unused)] fn main() { // Pre-allocate an id so you can address a submenu later: let recent = MenuItemId::next(); let model = MenuModel::new() .menu(tr!(file()), |m| m.submenu_with_id(recent, tr!(open_recent()), |s| s)); // ...anywhere later (hold a clone of `model`): let id = model.push_item(recent, MenuEntry::new(lit!("doc.txt")).on_activate(|_| open())); model.remove(id); // remove any item/submenu by id let edit = model.push_menu(tr!(edit()), |m| m.item(...)); // add a top-level menu model.modify(|nodes| { /* full control: reorder, retitle, … */ }); }
| method | effect |
|---|---|
push_item(into, entry) | append an item to the submenu with id into |
push_separator(into) | append a separator |
push_menu(title, |m| …) -> MenuItemId | add a top-level menu |
remove(id) -> bool | remove an item or submenu anywhere |
modify(|&mut Vec<MenuNode>| …) | arbitrary structural edit (escape hatch) |
menu_with_id / submenu_with_id let you assign ids up front so submenus are
addressable.
Reactivity summary
- Per-item
enabled/visible/ check / radio — live, no rebuild (native:update_item; in-window: direct signal binding /item_when). - Structural add/remove —
versionbump → automatic rebuild + native re-install. - Locale / shortcut-rebind of native titles / key equivalents — re-resolved on the next rebuild or window re-focus (the in-window bar reflects them immediately). Trigger a refresh sooner by touching the model (e.g. any mutator) if needed.
Shortcuts and key equivalents
.shortcut("id") resolves the chord from the ShortcutRegistry. On the native
menu it becomes an NSMenuItem key equivalent, so AppKit fires the item
directly — the keystroke never reaches the widget tree, so there is no
double-fire with the in-app shortcut dispatcher.
Modifier mapping follows the cross-platform convention (as in Qt's Qt::CTRL):
a declared Ctrl is the primary accelerator modifier and resolves to ⌘ on
macOS, so KeyStroke::ctrl(Key::S) shows as ⌘S. Alt→⌥, Shift→⇧.
The rewriting happens once, in the registry — see
Shortcuts, Intents and Actions
— so the menu row and the in-app dispatcher advertise and fire the same chord.
A shortcut declared literal_modifiers(), or a chord the user rebound to
physical ⌃, arrives here untouched and gets ⌃ as its key equivalent.
Multi-window
There is one global menu bar on macOS; it follows the focused window. Each
window installs its own snapshot (NativeMenuHandle::set_window_menu), and
teksilo-app calls activate_window on WindowEvent::Focused so the focused
window's menu becomes mainMenu. The menu + its activation map are dropped when
the window closes.
Standard macOS menus
The App / Window / Help menus carry system selectors (About / Hide / Quit, Minimize / Zoom, the live window list) but their labels go through i18n like every other widget — the platform layer never hardcodes English. Declare them with localized strings:
#![allow(unused)] fn main() { use teksilo::widgets::StandardMenu; MenuModel::new() .standard_menu(StandardMenu::app() .title(tr!(app_name())) // bold app-name submenu .about(tr!(about())) .settings(tr!(settings())) // "Settings…" — needs settings_intent too .settings_intent("app.settings") .hide(tr!(hide())) .quit(tr!(quit()))) // e.g. "Quitter" on a French system .menu(tr!(file()), |m| …) .standard_menu(StandardMenu::window()); // Minimize / Zoom + window list }
StandardMenu::{app, window, help}give Englishlit!defaults; passtr!to localize..standard(role)is sugar for the all-default menu.- A default App menu is auto-injected as the leading menu if the model declares none (so ⌘Q always works) — labels resolved through the widget layer, not the platform crate.
- Window adds Minimize (⌘M,
performMiniaturize:) + Zoom (performZoom:) and registers the menu with AppKit so the live window list appears. - Help is a localized titled submenu registered as the help menu. Custom Help
items beyond that are best declared as a normal
.menu(...).
Settings…
macOS keeps app settings in the application menu, under About, on ⌘, — and
neither the placement nor the chord is reachable from an ordinary MenuEntry,
since the App menu is filled in by the platform. StandardMenu::settings_intent
puts the row there:
#![allow(unused)] fn main() { StandardMenu::app() .settings(tr!(settings())) // "Settings…" (macOS 13+; "Preferences…" before) .settings_intent("app.settings") // same intent the in-window command fires .settings_shortcut("app.settings") // …and the same registered chord }
Unlike Quit there is no system fallback — no platform opens an arbitrary app's
settings on its own — so leaving settings_intent unset omits the row rather
than rendering one that does nothing. Route it to the same intent your in-window
Settings command uses and the two stay one command.
⚠ Quit, and apps with something to lose
The App menu's Quit is bound to AppKit's terminate: by default. That is
what makes ⌘Q work with no wiring at all — but terminate: exits the process
directly: it does not run winit's exit path, so no LoopExiting hook, no
close guard, nothing the app registered.
An in-app ⌘Q shortcut does not save you. AppKit dispatches main-menu key equivalents before the responder chain, so the App menu's item wins and the app's own shortcut never sees the keystroke — the app looks wired up and is not. The same is true of a Quit row the app puts in its own File menu.
So an app that must ask before exiting — unsaved work to confirm, a session to flush, a job to stop — routes the item instead:
#![allow(unused)] fn main() { MenuModel::new().standard_menu( StandardMenu::app() .title(tr!(app_name())) .quit(tr!(quit())) .quit_intent("app.quit") // the app's own guarded action .quit_shortcut("app.quit"), // …advertised at the chord the registry holds ); }
Quit then becomes an ordinary routed item — same ⌘Q, same
Intent/Action pipeline as every other menu item, IntentSource::Menu — and
the app owns the exit from that point on: nothing terminates on its behalf.
Leave quit_intent unset and the platform behaviour is unchanged, which is also
what the auto-injected default App menu uses (a model that declares no App menu
has declared no quit handler to route to either).
Chords on the two routed rows
Quit and Settings are the only rows the platform places for you, so they are the
only ones that cannot carry a MenuEntry::shortcut. Name the registered
shortcut with quit_shortcut / settings_shortcut instead, and the chord is
resolved from the ShortcutRegistry exactly as every other row's is — same
primary-accelerator convention, same response to a user's rebind.
Unset, the row falls back to the platform convention (⌘Q, ⌘,) — right for an app that registered no such shortcut, and wrong the moment one exists: a hardcoded ⌘Q stays live after the user moves Quit elsewhere and shadows wherever they moved it to, because the platform dispatches a main-menu key equivalent before the responder chain. Naming a shortcut that currently resolves to nothing leaves the row with no key equivalent rather than resurrecting the convention — the app said where the chord comes from, and it says none right now.
Architecture
| layer | type | crate |
|---|---|---|
| id token | MenuItemId | teksilo-core |
| rich model | MenuModel / MenuEntry / MenuItemState | teksilo-widgets |
| model → native bridge | menu::native::install (+ reactive observers) | teksilo-widgets |
| boundary data | NativeMenuSnapshot / NativeMenuNode / MenuItemDelta | teksilo-platform |
| trait + handle | NativeMenuBackend / NativeMenuHandle | teksilo-platform |
| macOS impl | NSMenu builder + TeksiloMenuTarget | teksilo-platform/native_menu/macos.rs |
| app wiring | install_native_menu, payload router, focus arbitration | teksilo-app |
The platform boundary type is plain, already-resolved data — teksilo-platform
never sees the widgets model. The macOS item callback posts a
NativeMenuEventPayload through AppEventPoster::post_external, routed back into
the originating window's tree exactly like the file-dialog / external-DnD paths.
WebView — Embedded Web Content
Status: prototype. The wry backend (the default) is functional on macOS / Windows / Linux-X11 and, via XWayland, on Linux/Wayland. The Servo backend (the native Wayland path) is work in progress — it constructs a real engine but is not yet frame-driven, so it does not paint a page. See Servo backend: requirements & status.
WebView embeds HTML / web content in a Teksilo window — for documentation
panes, license dialogs, OAuth flows, Markdown previews, help centers,
dashboards, or any HTML/SPA-driven surface. It lives in its own crate,
teksilo-webview, behind the umbrella web-view feature.
Source: crates/teksilo-webview/. Demo:
cargo run -p web-view-demo.
#![allow(unused)] fn main() { use teksilo::prelude::*; // brings TeksiloAppBuilderWebViewExt into scope use teksilo::web_view::WebView; TeksiloAppBuilder::new() .theme(intui::light()) .install_web_view_default() // installs the engine (wry by default) .initial_window(WindowConfig::new().title("Docs").size(1000, 720).root(|tree, _| { tree.add( WebView::new() .url("https://example.com") .title_signal(title_signal.clone()) // window title follows the page .loading_signal(loading_signal.clone()) .on_message(|msg, _ctx| println!("JS said: {msg}")), ) })) .run(); }
The one widget that can't render into wgpu
Every realistic engine — WKWebView (macOS), WebView2 (Windows), WebKitGTK
(Linux/X11), Servo — owns its own rendering and lives as a native OS subview
on top of Teksilo's wgpu surface. WebView accepts that and mirrors the
established platform-backend pattern (FileDialogBackend,
ExternalDndBackend): a swappable WebViewBackend creates an engine-specific
WebViewHandle; a per-app WebViewRegistry (in app_state) routes
JS→Rust / lifecycle events back into the widget tree. The engine is pluggable;
the widget feels native to Teksilo.
Two architectural consequences fall out of "the engine is a native subview":
- Visibility doesn't ride the paint pass — see Dormancy bridge.
- Z-order is above wgpu — see Z-order.
Engines and feature flags
teksilo-webview is engine-agnostic; the engine is chosen by cargo feature on
the umbrella teksilo crate. wry is the default engine.
| Feature | Engine(s) compiled | install_web_view_default() installs |
|---|---|---|
web-view | wry | WryBackend (macOS / Windows / Linux-X11) |
web-view-servo (implies web-view) | wry + Servo | ServoBackend under a Wayland session, WryBackend everywhere else (runtime, via is_wayland) |
web-view-headless | none | NoopWebViewBackend (renders nothing) |
- wry by default. Enabling
web-viewgives a working webview with no extra flag.cargo run -p web-view-demorenders via wry. - Servo is additive, Wayland-only at runtime.
web-view-servoimpliesweb-view, so it ships both engines; Servo is only selected under Wayland (where wry's WebKitGTK can't reparent into a child window). There is no "Servo-everywhere" build by design — Servo renders whole-window via GL, conflicting with wgpu, and is the wrong engine off Wayland. web-view-headlessis the no-engine escape hatch (mirrorsfile-dialog-trait): the widget + event routing, the inert no-op backend. Use for headless tests, or apps that install their own backend withinstall_web_view(custom_backend).- A true Servo-only target (Linux-only / no-GTK) bypasses the umbrella:
depend on
teksilo-webviewdirectly withfeatures = ["servo-backend"]and passServoBackend::new()toinstall_web_view(...).
Pinned versions: wry = 0.55.1, servo = 0.2.0.
Linux build dependencies (wry / WebKitGTK)
wry's Linux backend is WebKitGTK, so building anything that enables web-view
on Linux (including web-view-demo) needs the GTK / WebKit2GTK development
headers. On Debian / Ubuntu:
sudo apt install libpango1.0-dev libgdk-pixbuf-2.0-dev libatk1.0-dev \
libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev
macOS (WKWebView) and Windows (WebView2) need no extra system packages.
wry on Linux needs the GTK loop pumped (and X11)
WebKitGTK runs on the GTK / GLib main loop and embeds only as an X11 child window. A winit app must therefore, on Linux:
- Init GTK — handled automatically;
WryBackend::opencallsgtk::init(). - Pump the GLib loop each turn — winit doesn't, so the page never paints
otherwise. Call [
teksilo_webview::pump_gtk_events] fromTeksiloAppBuilder::on_loop_tick, holding the poll source high while aWebViewis alive:
(#![allow(unused)] fn main() { let poll = std::rc::Rc::new(std::cell::Cell::new(true)); TeksiloAppBuilder::new() .on_loop_tick(poll.clone(), || { teksilo::web_view::pump_gtk_events(); false }) // … }pump_gtk_eventsis a no-op off Linux / without the wry engine, so the call is portable.) - Run under X11 — winit 0.30 picks Wayland whenever
WAYLAND_DISPLAYis set, and hands wry a Wayland handle it can't embed into. On a Wayland session, switch to XWayland before the event loop is built (unsetWAYLAND_DISPLAY, setGDK_BACKEND=x11), or build--features servofor the native Wayland engine.examples/web_view_demodoes this automatically (see itsforce_xwayland_for_wry).
The continuous poll (step 2) keeps the loop awake; that is the cost of hosting a
GTK engine inside a winit app today. A future revision may pump only while a
WebView is mounted.
Servo backend: requirements & status
Servo (servo = 0.2.0) is the intended native Wayland engine — pure Rust,
no GTK reparenting problem. It is work in progress: the backend compiles and
constructs a real Servo webview, but it is not yet frame-driven, so it does
not paint a page. Building --features servo and running on Wayland selects it
(via is_wayland) and you get the
loading wash plus a "constructed but not yet frame-driven" console message — not
web content. For now, use wry + XWayland on Linux.
Build requirements (Linux). Servo pulls a large native toolchain on top of the wry/WebKitGTK deps above. Expect to install (Debian/Ubuntu names; exact set varies with the Servo release):
# LLVM/Clang + media + font/graphics stack Servo links against
sudo apt install llvm clang libclang-dev \
gstreamer1.0-plugins-base libgstreamer-plugins-base1.0-dev \
libgstreamer1.0-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
libfontconfig1-dev libfreetype-dev libxcb1-dev libx11-dev \
libgl1-mesa-dev libegl1-mesa-dev
Servo's own build setup docs
are authoritative; ./mach bootstrap in a Servo checkout lists the current
system packages for your distro. The first build also downloads and compiles the
entire Servo tree — many GB and a long compile.
What remains (Phase 4). To make Servo actually render:
- Wire an
EventLoopWakerto teksilo-app's winit proxy so Servo gets pumped. - Call
servo.spin_event_loop()+webview.paint()+rendering_context.present()from the render loop. - Composite Servo's surface as a positioned region rather than the whole window — its GL/surfman context currently wants the entire window surface, which conflicts with wgpu owning it.
Until then the Servo path is best-effort and documented, not a supported engine.
JS→Rust IPC (window.ipc) is also unsupported on Servo (no built-in channel
like wry's with_ipc_handler).
Installing the subsystem
TeksiloAppBuilderWebViewExt (re-exported through teksilo::prelude) adds two
builder methods:
install_web_view_default()— installs the feature-selected engine (table above).install_web_view(backend)— install an explicitWebViewBackend(a native engine, a custom backend, orMemoryWebViewBackendfor tests).
Both register a WebViewRegistry in app_state; every WebView reaches it
via ctx.app_state::<WebViewRegistry>().
The WebView widget
#![allow(unused)] fn main() { WebView::new() .url("https://example.com") // OR .html("<!doctype html>…") OR .source(WebSource::*) .user_agent("MyApp/1.0") .transparent(true) .devtools(cfg!(debug_assertions)) .url_signal(url_signal) // Signal<String> — TWO-WAY (see below) .title_signal(title_signal) // Signal<String> — updated on title change .loading_signal(loading_signal) // Signal<bool> — true between page-load start/finish .on_message(|msg: String, ctx| { … }) // JS → Rust (window.ipc.postMessage) .on_title_changed(|title, ctx| { … }) .on_navigation(|nav, ctx| { … }) // observer — NavigationInfo (no veto, see below) .on_page_load(|state, ctx| { … }) // PageLoadState::{Started, Finished} .on_download_started(|d, ctx| { … }) // DownloadStart { url, suggested_path } .on_download_finished(|o, ctx| { … }) // DownloadOutcome { path, success } .style(MyWebViewStyle) // Tier-3 overlay chrome override }
Imperative controls (call via ctx.with_widget_mut::<WebView>(id, RepaintOnly, |w| …)):
load_url, eval, post_message (Rust → JS), reload, go_back,
go_forward, stop, open_devtools / close_devtools (runtime toggle; no-op
where unsupported). The stable routing identity is WebView::id() -> WebViewId.
Two-way url_signal. The engine writes the resolved URL into the bound signal
on navigation-finish, and an external url_signal.set("…") drives programmatic
navigation (equivalent to load_url). The engine's own echo is filtered, so the
two directions don't loop. The initial page comes from .url() / .html()
/ .source(); the signal's value at build time is taken as the baseline and
does not trigger a navigation — url_signal governs navigation after the first
load. (Don't bind the same signal directly to an editable TextInput, or every
keystroke navigates — drive navigation from a "Go" button / Enter handler that
sets the signal instead.)
Observers, not vetoes. on_navigation and on_download_* are notification
callbacks. Teksilo delivers backend events on a later event-loop tick (posted,
not delivered inline), so a synchronous decision can't be returned to the
engine: a navigation cannot be cancelled from on_navigation
(NavigationInfo::can_cancel is always false today), and a download's
destination path cannot be redirected from on_download_started. Use them for
URL-bar sync, logging, progress UI, and toasts.
Lifecycle. build() creates the style-driven overlay (loading/error chrome)
and captures the host TeksiloWindowId; the native engine subview is opened
from a post-mount EventContext (BuildContext::run_after_mount) because
that is the only place the OS parent window handle, app_state, and the event
poster are all reachable together. Bounds track via place_children;
visibility via the activation bridge (below); teardown is RAII — dropping the
WebViewHandle tears down the native subview.
Styling. The overlay chrome is a Tier-3 WebViewStyle
(teksilo_core::styles); the default RecipeWebViewStyle paints a state-tinted
wash (loading / error / transparent-when-ready). Override per-call with
.style(...) or theme-wide via theme.style_slots.web_view.
Accessibility. The widget emits a single Role::WebView node named from the
title binding; the page's own AT tree is published to the OS by the engine, so
Teksilo does not duplicate it.
Keyboard: the frame, then the page. The web view is focusable, so Tab
reaches it and the style paints a focus ring around the frame — necessary
because the widget draws no content of its own to show focus on. Landing there
does not hand the keyboard to the engine; Enter or Space does
(WebViewHandle::set_focus), and so does an AT-invoked Click or Focus. Every
other key is declined, so the frame is never a trap: Tab cycles straight off it.
The two-step is deliberate. A WebView has two disjoint focus rings and two
AT trees — AccessKit's and the engine's platform tree — and once the native
subview owns the keyboard, Teksilo stops receiving keys altogether. An automatic
hand-off on Tab would therefore be a one-way door out of the app's own focus
cycle. Getting back out of an entered page is the engine's and the OS's business,
not something the toolkit can guarantee; this is the same reason the web
platform treats an <iframe> as a focus scope you enter rather than fall into.
Apps whose web view is the window content can take the one-step form with
.enter_page_on_focus(true). .focus_page() is the programmatic equivalent of
Enter, and .focused_signal() reports whether the frame holds focus (it can
say nothing about what happens once the page has been entered).
A consequence for anyone assembling a conformance artifact: a WebView-embedding application cannot inherit the toolkit's 2.1.1 or 4.1.2 posture for the page. It must scope the embedded content separately.
The dormancy / visibility bridge
This is the one place WebView breaks a framework invariant, and it is handled
automatically — but worth understanding.
Every ordinary widget composites through the wgpu pass, so "not painted"
means "not on screen." A WebView's engine subview lives outside that
pass, so when a Switcher
/ TabWidget / visible_when gate parks the widget dormant, the framework
merely stops painting it — the native surface keeps floating over the output,
showing stale content over whatever is now visible.
WebView closes the gap with a framework primitive added for exactly this
case: a per-node activation signal (BuildContext::activation_signal),
which the arena flips on every Active↔Dormant transition (batched at the end
of the visibility pass, mirroring focus_within/hover_within). The widget
bridges it to the engine: tab-away → handle.set_visible(false),
tab-back → set_visible(true). A WebView opened while already parked starts
hidden (no flash). This is the only case where a widget must mirror framework
visibility onto an OS resource; any future native-embed widget (video surface,
native map) reuses activation_signal the same way.
JS ↔ Rust messaging
- JS → Rust: the page calls
window.ipc.postMessage("…"); it surfaces ason_message(|msg, ctx| …). (wry built-in; on Servo this is best-effort.) - Rust → JS:
webview.post_message("…")dispatches ateksilo-messageMessageEvent; the page listens withaddEventListener('teksilo-message', e => …).e.datais the opaque string you sent (the app layer decides JSON / MsgPack / plain text).
Z-order with overlays
Native subviews sit above the wgpu surface, so Teksilo overlays (tooltips,
popovers, dropdowns) drawn by the OverlayManager render under a WebView
where they overlap. For overlays that must cover a WebView, open them as a
popup OS window via ctx.open_window(...) (the approach Electron uses for
context menus over webviews).
Multi-window & lifetime
- A
WebViewis bound to theTeksiloWindowIdit was mounted in. WindowManager::close_windowpurges the window'sWebViewRegistryregistrations, so a late backend event can't fire into a torn-down tree.- Moving a
WebViewbetween windows is not supported in v1 (matches Tauri / Electron).
Testing
MemoryWebViewBackend records every backend op (open / set_bounds /
set_visible / load_url / …) into a shared MemoryWebViewRecords, with no
GPU / window / engine. The headless suite
(tests/basic_lifecycle.rs)
covers open/teardown, bounds tracking, the headline dormancy assertion — a
WebView parked in a real Switcher issues set_visible(false) on tab-away
and set_visible(true) on tab-back — plus two-way url_signal navigation,
download-event delivery to the callbacks, and the runtime devtools toggle.
Install it with
install_web_view(MemoryWebViewBackend::new().0) (or the memory_registry()
one-liner) and pump post-mount opens with tree.run_mount_actions(&mut NoopWindowOps).
Known limitations
- Custom-protocol handlers (
app://serving local SPAs) are not yet plumbed throughWebViewAttributes— only scheme names are carried, no dispatch closure. Load local content inline with.html(...)for now. - Servo backend is work in progress (not yet frame-driven, no render). See Servo backend: requirements & status for build deps and the remaining Phase-4 work.
load_htmlbase_urlis ignored on wry (no runtime load-HTML API; emulated viadocument.write).- HiDPI / monitor moves mid-flight: wry handles its native engines; Servo handling is unverified.
- Memory of an open WebView with heavy content is non-trivial (~50–150 MB
for WebView2 / WKWebView); a
WebViewis not a cheap widget. - Leaving an entered page is not under Teksilo's control. Once
set_focus()has handed the keyboard to the engine subview, the toolkit sees no further keystrokes, so it cannot offer an escape chord the way akeyboard_capturesurface can. Whether Tab at the end of the document returns focus to the host window is engine- and platform-dependent and is not verified here. Nor is a click on the page mirrored back onto Teksilo's focus ring — the native subview receives it directly.
Drag and Drop
Companion to: architecture.md §14, events-and-gestures.md
Scope: The full DnD lifecycle — source-side handlers, target-side handlers, preview overlay, coordinate conventions, auto-scroll / spring-loaded folders, keyboard equivalence, and how ListView / TreeView use it.
1. What DnD means here
Three distinct user stories share the same mechanics in Teksilo:
- Intra-widget reordering — drag a row inside a list or a node inside a tree. No serialisation; the payload is a typed Rust value.
- Inter-widget transfer — drag a row from one list into another, or drop a file shortcut onto a bookmarks bar. Also a typed payload, possibly with a MIME-annotated byte representation for adapter layers.
- External (OS) drops — accept files / text / URLs dragged in from a file manager or another app. Built on the same primitives plus a per-OS
ExternalDndBackend. Inbound is shipped on every desktop target (macOS verified; Windows OLE, Waylandwl_data_deviceand X11 XDND cfg-gated) — see §11 and theDropZonewidget. - External (OS) export — drag a file / text / URL out of a Teksilo window into another application. Shipped on every desktop target (macOS and Wayland verified; Windows OLE and X11 XDND cfg-gated) — the source needs no new API: a normal
start_dragwhose payload carries MIME data auto-escalates to a native OS drag when the pointer leaves the window. See §11.5.
All flows reuse the same payload type, the same handler set, and the same gesture recognizer. Only the source of the events differs (in-app gesture vs. OS backend).
2. Payload — DragPayload
Every drag carries a DragPayload. It can hold:
- A typed Rust value — stored via
DragPayload::typed(value), retrieved viapayload.get_typed::<T>()/take_typed::<T>()or probed withhas_typed::<T>(). - Zero or more MIME-annotated byte representations — added via
with_mime(mime_type, bytes), queried viamime_types()/get_mime(mime). Populated for external (OS) drops (e.g.text/uri-list,text/plain) alongside the typedfiles()/text()/uris()accessors; see §11.
Typed payloads are fast-path: no serialisation, sender and receiver just agree on a Rust type. Drop targets check acceptance during hover without touching the bytes:
#![allow(unused)] fn main() { // On the source: let payload = DragPayload::typed(MyRowId(i)); ctx.start_drag_with_preview(source_id, payload, preview_widget); // On the target: handlers = handlers.on_drag_hover(move |payload, pos, _ctx| { if payload.has_typed::<MyRowId>() { DropFeedback::InsertionLine { y: insertion_y, width: w } } else { DropFeedback::NoFeedback } }); }
3. Starting a drag — source side
A widget becomes a drag source by attaching on_drag. The framework auto-wires a DragRecognizer (press → 5 px threshold → recognise) into the widget's gesture arena and fires on_drag with a DragPhase:
#![allow(unused)] fn main() { use teksilo::core::gesture::DragPhase; handlers = handlers.on_drag(move |phase, ctx| { if let DragPhase::Started { .. } = phase { ctx.start_drag_with_preview( self_id, DragPayload::typed(RowRef { index: i }), Box::new(build_preview(i)), ); } }); }
Two source APIs on EventContext:
| Call | Effect |
|---|---|
start_drag(source, payload) | Start a drag with no visible preview. Cursor still turns into Grabbing; target-side feedback still fires. Useful for "abstract" drags where the row itself doesn't move (e.g. colour pickers). |
start_drag_with_preview(source, payload, Box<dyn Widget>) | Same, plus a floating overlay that tracks the pointer. ListView / TreeView use this — they re-invoke their delegate for the dragged row and wrap it in a raised panel (see crates/teksilo-widgets/src/drag_preview.rs). |
Three cursor / capture invariants the framework guarantees for the source:
current_cursorswitches toCursorIcon::Grabbingat drag start and resets toDefaulton drop / cancel / source-destroyed.- Pointer capture is installed on the source's wrapper automatically via the recognizer's
ctx.capture_pointer()call. The source keeps receivingPointerMove/PointerUpeven when the cursor leaves its bounds. Escapecancels: the preview overlay is dismissed andon_drag_leavefires on the current target before the session is cleared.
4. Dropping — target side
A widget becomes a drop target by attaching at least on_drag_hover or on_drop. Target hit-testing uses find_drop_target_at_or_above: hit-test the pointer position, walk up until a node with either handler is found.
Target-side handlers fire in this strict order, each at most once per role per drag:
4.1 on_drag_hover(payload, local_pos, ctx) -> DropFeedback
Fires on every PointerMove while this widget is the current drop target. Two jobs:
- Decide acceptance. Inspect
payloadviahas_typed/mime_types. If this target doesn't want this payload, returnDropFeedback::NoFeedback(and make sure youron_dropalso rejects it — nothing enforces consistency). - Provide visual feedback. Return
DropFeedback::InsertionLine { y, width }(between-items insertion) orDropFeedback::HighlightRect { rect, color }(drop-into container). The framework stores the descriptor on the active session for anything that wants to render it; the widget itself is responsible for actually drawing the feedback in itspaint().
#![allow(unused)] fn main() { handlers = handlers.on_drag_hover(move |payload, pos, _ctx| { if !payload.has_typed::<RowRef>() { feedback_signal.set(None); return DropFeedback::NoFeedback; } let insertion_y = compute_insertion_y(pos, scroll.get(), item_height); feedback_signal.set(Some((insertion_y, content_width))); DropFeedback::InsertionLine { y: insertion_y, width: content_width } }); }
Coordinates are target-local — origin at the target widget's top-left, in logical pixels. Same coordinate system as the target's own bounds / paint, so drop-index math doesn't have to know where the widget sits in the window. (Before we fixed this the indicator was offset by the header height; see the regression test on_drag_hover_and_on_drop_receive_widget_local_coordinates in drag_drop_impl.rs.)
4.2 on_drag_tick(local_pos, ctx)
Fires once per layout pass while this widget is the current drop target. Use it for per-frame behaviours that must keep progressing when the pointer is stationary:
- Viewport-edge auto-scroll. When the pointer dwells inside, say, the top 32 px of a scrollable target, the widget nudges its own scroll signal down a fixed delta each frame.
ListView/TreeViewship this — see theon_drag_tickhandler block in list_view.rs. - Spring-loaded folders.
TreeViewrecords which flat row the pointer sits over inon_drag_hover, together with the time it first saw it.on_drag_tickchecks elapsed time againstSPRING_DELAY_MS = 700; after the dwell, a collapsed branch auto-expands so the user can drop into its children.
#![allow(unused)] fn main() { handlers = handlers.on_drag_tick(move |pos, _ctx| { // edge-scroll (top/bottom 32 px ramp, max 12 px/frame) // spring-open (700 ms dwell → expand(node)) }); }
on_drag_tick is the only DnD hook that isn't event-driven. The framework fires it from WidgetTree::layout itself, right after the animation scheduler tick.
4.3 on_drag_leave(ctx)
Fires exactly once when this widget stops being the current drop target. The framework emits it for all four leave scenarios:
| Scenario | Trigger |
|---|---|
| Pointer moved to a different target | handle_drag_move detects prev_target != drop_target |
| Drop completed on this or another target | handle_drag_drop, before on_drop runs |
| Drag cancelled | Escape key, or EventContext::cancel_drag() |
| Source destroyed mid-drag | revalidate_interaction_state after the arena loses the source widget |
Widgets MUST clear their feedback state in on_drag_leave. The framework owns the session state but never touches widget-owned Signals or Cells. ListView / TreeView clear their drop_feedback signal here; that's what makes the insertion line vanish the instant the pointer exits.
#![allow(unused)] fn main() { let feedback_for_leave = self.drop_feedback.clone(); handlers = handlers.on_drag_leave(move |_ctx| { feedback_for_leave.set(None); }); }
4.4 on_drop(payload, local_pos, ctx) -> bool
Fires on PointerUp only if this widget is the drop target at the release position. Already preceded by on_drag_leave, so by the time on_drop runs the feedback state is cleared. Return true if the drop was accepted.
#![allow(unused)] fn main() { handlers = handlers.on_drop(move |mut payload, pos, _ctx| { if let Some(drag_data) = payload.take_typed::<RowRef>() { apply_reorder(drag_data, pos); true } else { false } }); }
Whether the drop is "accepted" has no framework-observable side effect today — the payload is dropped (Rust Drop) regardless, and no user-visible state hangs off the return. The bool is an extension point for future listener APIs.
4.5 Drop-target bubbling — nested targets
When drop targets nest (a per-row DropTarget inside a reorderable
ListView, a cell target inside a table), a hover doesn't stop at the deepest
one. The framework walks up from the hit target through successive drop
targets, firing each one's on_drag_hover, and stops at the first that
engages — returns a non-NoFeedback response (is_engaged()):
- A target that returns
DropFeedback::NoFeedbackdoes not want this payload, so the drag bubbles to the next drop target above it. This is what lets a reorderable view behind a per-rowDropTargetstill receive a drag the row rejected. - If an ancestor engages, every rejecting target passed on the way up is
cleared (
on_drag_leave) so none leaves a stuck "forbidden" border — the drag is accepted above them. - If nothing engages, the drag is genuinely rejected: the deepest drop target keeps its own reject affordance and becomes the tracked target; ancestors above it are cleared.
A target with an on_drop but no on_drag_hover engages optimistically
(Accept, no visual), so it can still receive the drop — on_drop makes the
final call on release. The same engage-or-bubble walk runs on PointerUp, so
the drop lands on whichever target the hover settled on.
5. The preview overlay
When a drag starts with start_drag_with_preview, the framework:
- Inserts the preview widget as a root via
add_boxed— which runsbuild(), so composite preview widgets actually instantiate their child subtrees. (Plainarena.insertdoesn't run build; using it here leaves the preview rendering an empty widget, which is what "no floating indicator" looked like before we fixed it.) - Creates an overlay with
OverlayLayer::InTree+OverlayPlacement::AtPointer(Point::ZERO). - Marks the preview content
needs_layoutso the next layout pass runsposition_overlaysand actually positions the overlay at the pointer rather than leaving it at(0, 0).
On every PointerMove during drag, handle_drag_move calls overlay_manager.update_placement(AtPointer(position)) and marks the preview content needs_layout again — without the dirty mark, layout() short-circuits (any_needs_layout() is false) and the overlay stays pinned at its previous position.
Cleanup: cleanup_drag_preview() dismisses the overlay and destroys its content subtree. It runs on drop, Escape cancel, and explicit cancel_drag.
The DragPreview wrapper
ListView / TreeView don't hand the raw delegate widget to start_drag_with_preview — they wrap it in a small DragPreview composite that:
- Applies a fixed
(width, height)so aSpacerinside the delegate doesn't collapse under the overlay's unbounded proposal. - Wraps the inner in
Panel::new().background(SurfaceRole::Raised).corner_radius(6.0)so the floating row reads as picked-up against the window.
Custom widgets can use DragPreview too, but it's pub(crate) today — if you need one, either re-implement the pattern locally or open a PR to make it public.
6. Scrolling during a drag
Two related interactions, both handled by the framework:
6.1 Mouse-wheel scroll over a drop target
While active_drag is Some, WidgetEvent::Scroll is routed to the drag session's current_target instead of the normally-hovered widget. The drop target's on_scroll handler (e.g. ListView's internal one) fires, updating its scroll signal. The framework then synthesises a re-hover at the stationary pointer so drop-index math, feedback line, and preview placement all refresh against the new scroll offset. Implementation: the WidgetEvent::Scroll arm of the active_drag.is_some() match in dispatch_event.
6.2 Viewport-edge auto-scroll
See §4.2 — this is a per-widget behaviour, not a framework one. The widget implements it in on_drag_tick.
7. Keyboard equivalence
Every drag operation should have a keyboard-accessible equivalent that emits the same semantic command. ListView / TreeView implement this via Alt+Arrow (in their on_key handler), calling the same ListModel::move_item / TreeModel::move_node that on_drop would call. The semantic operation is decoupled from the input gesture.
This is a contract, not a framework feature — nothing forces a custom drop target to provide a keyboard path. For anything accessible, you have to.
8. Lifecycle summary — one drag, one diagram
┌─────────────────────────────────────────────┐
source widget │ │
─────────── │ ▼
on_drag(Started) → start_drag[_with_preview] → preview overlay created
(AtPointer)
── cursor switches to Grabbing, pointer_captured_by = source widget ──
drop target (whichever widget find_drop_target_at_or_above returns):
PointerMove ─► on_drag_hover(payload, LOCAL pos) ─► DropFeedback
│
├─► on_drag_tick(LOCAL pos) (each layout pass)
│ ↳ edge-scroll, spring-open
│
target changes ─────────────►│ on_drag_leave (prev target)
│
Escape / cancel ─────────────►│ on_drag_leave (current target)
│ cleanup_drag_preview
│ pointer_captured_by = None
│ current_cursor = Default
│
PointerUp on target ─────────► on_drag_leave (current target)
on_drop(payload, LOCAL pos)
cleanup_drag_preview
pointer_captured_by = None
current_cursor = Default
scroll wheel during drag:
Scroll ─► routed to active_drag.current_target.on_scroll
└─► synthesised re-hover so feedback refreshes
9. ListView / TreeView as drop targets — how they wire it up
Both widgets combine every primitive above, but the acceptance decision and
the commit are owned by the backing data source, not the view (see
data-source.md §3). The view supplies geometry and rendering;
the source answers can_accept / accept_drop. Reading
list_view.rs and
tree_view.rs as reference examples:
drop_feedbacksignal (bound atBindingLevel::RepaintOnly) — set byon_drag_hover, cleared byon_drag_leave. Reading it inpaint()is enough; the binding dirties the widget when the signal changes.on_drag(per item wrapper) — fires only when the source'sdrag(key)returnsCanDrag; emits the shared publicRowDragData<T> { source: ViewId, rows: Vec<usize>, items: Option<Vec<T>> }typed payload (one type for all five data views) and aDragPreviewbuilt by re-invoking the delegate for the dragged row.rowsis the selection-aware dragged set;itemsisSomeonly when the view opted into.exportable(..). The identity is a kind-tagged, process-globalViewIdso a foreign drag is never misread as a same-view reorder.on_drag_hover(on the list/tree itself) — computes the geometric(target, position)from local Y + scroll offset, asks the sourcecan_accept, and sets the feedback signal to match the verdict (Accept→ the affordance for the effective position,Reject→ suppress,Redirect→ snap).TreeViewalso records the hovered node + timestamp for spring-load. The row under a givenyis resolved by the samePrefixSumOffsets::row_ata click uses, so the two agree even at a zero-height row boundary — see table-view.md "Which row aycoordinate resolves to" for the degenerate-height tie-breakrow_atapplies.
The two affordances must not look alike
A tree answers two different questions during a drag, and TreeView /
TreeTableView paint them differently on purpose:
| Verdict | Affordance | Recipe |
|---|---|---|
Before / After | an insertion line at the row boundary, indented to the depth the dropped row lands at | ListInsertionRecipe (role, thickness, indent_step) |
Into | a rounded box round the target row, inset on every side | ListDropIntoRecipe (role, fill_alpha, corner_radius, inset, thickness) |
Both properties are load-bearing, and both were once absent:
- The inset. Painted flush to the row, the
Intobox shares its top edge with aBeforeline and its bottom edge with anAfterline, and the drag ghost covers the vertical sides that would have told them apart — so all three hovers read as "an accent bar at a row boundary" and the writer cannot tell a reparent from a reorder. A theme settinginsetto zero re-creates exactly that. - The indent. Un-indented, an
Afterline says nothing about whose sibling the row becomes: "after this scene, still inside the chapter" and "after the chapter" draw the same pixels. The depth travels on the feedback signal (DropViz::{Line, Rect}) sopaintcan multiply it by the step; the hover handler reads it from the source'sFlatEntry, and a foreign drop — which lands at a flat index with no nesting the view can promise — reports depth 0 rather than claiming one.
TreeTableView measures its indent from the tree column's leading edge, not
the body's: .tree_column() and a user column-reorder can move the twist/indent
gutter off the leading slot.
on_drag_tick— edge auto-scroll (linear ramp inside a 32 px zone, max 12 px/frame).TreeViewadditionally checks the spring-load timer and expands the hovered branch after 700 ms.on_drag_leave— clears the feedback signal and the spring-load timer.on_drop— re-queriescan_accept; if notReject, routes the commit to the source'saccept_drop. A same-viewRowDragDatais aDragSource::SameViewthe source applies (aListModelreorders in place — one row viamove_item, a multi-row block viamove_items; aTreeModel-backed sourcemove_nodes with the cycle guard); a cross-view or OS payload arrives asDragSource::Foreign { payload }at the sameaccept_drop, which downcasts it. The same-view reorder only runs when the view isreorderable.on_key— Alt+ArrowUp / Alt+ArrowDown synthesize the sameRowDragDataand route it throughaccept_drop, so the keyboard contract travels the identical path.
10. Testing
Everything is headless. The key harness helpers (on WidgetTree):
| Helper | Purpose |
|---|---|
dispatch_event(WidgetEvent::PointerDown/Move/Up/...) | Feed raw events |
advance_time(Duration) | Advance the sim clock (used by tooltip / long-press delays) |
overlay_manager().len() / .overlay(id) | Inspect active overlays (incl. drag preview) |
widget_as_any(id) | Downcast a widget for test introspection (via the Widget::as_any hook) |
active_drag.is_some() (pub(crate) — in teksilo-core tests only) | Check whether a session is live |
Common patterns from the existing suite:
- Core-level lifecycle tests (drag_drop_impl.rs tests module) — use
FillWidget/InsetWidget/StackWidgetwith handlers attached directly, drive events withtree.dispatch_event(...), assert viaRc<Cell<u32>>counters andtree.active_drag. Theon_drag_leave_*tests are the canonical examples. - Widget-level integration tests (list_view.rs, tree_view.rs tests modules) — build a real
ListView/TreeViewwith aListModel/TreeModel, run the full gesture chain via adrag_itemhelper, assert the model's observable state (with_item,root_count) and the feedback signal via thewidget_as_anydowncast. - Drag across a rebuild —
drag_survives_rebuild_triggered_by_selectioninlist_view.rspins the scenario where clicking a row triggers a selection-driven rebuild betweenPointerDownand the firstPointerMove. The drag must still complete. - External handlers survive rebuild —
external_handlers_survive_rebuildin widget_builder.rs pins the handler-bucket invariant: closures attached viaSomeWidget::new().on_tap(...)must keep firing after the widget rebuilds in place.
See events-and-gestures.md §8 for the general testing patterns.
11. External (OS) drag-and-drop — across the app boundary
Files dragged from the file manager, or text / URLs dragged from another
application, enter through a platform backend and then reuse the entire
in-app pipeline above. There is no separate handler surface: an OS drop is just
a DragPayload with origin() == DragOrigin::External, dispatched through the
same on_drag_hover / on_drag_leave / on_drop. The reverse direction —
dragging out of the app — is covered in §11.5.
11.1 What the payload carries
For external drags, DragPayload exposes typed accessors instead of (or
alongside) the text/uri-list etc. MIME bytes:
#![allow(unused)] fn main() { fn on_drop(payload: DragPayload, _pos, ctx) -> bool { if payload.is_external() { for path in payload.files() { import(path); } // &[PathBuf] if let Some(text) = payload.text() { paste(text); } // Option<&str> for url in payload.uris() { open(url); } // &[String] (non-file) } true } }
EventContext::drag_is_external() is the same query for the on_drag_leave /
on_drag_tick handlers, which don't receive the payload.
11.2 The backend trait
ExternalDndBackend registers
the app as the OS drop target for a window and, for each phase
(Entered { data, position } / Moved / Left / Dropped { data, position }),
posts an ExternalDndEventPayload through AppEventPoster::post_external — the
same channel file dialogs use. teksilo-app routes it to the window's tree and
drives WidgetTree::{begin,update,end,cancel}_external_drag, which construct a
DragSession (with source_widget = None, is_external = true, no pointer
capture, no preview overlay) and run the normal handle_drag_move /
handle_drag_drop path.
Apps opt in with TeksiloAppBuilder::install_external_dnd(). Each window is
registered on creation and revoked on close.
11.3 Per-platform status
| Platform | Inbound backend | Outbound (export) | Notes |
|---|---|---|---|
| macOS | NSDraggingDestination on a transparent overlay NSView | NSDraggingSource on the same overlay | Full position + files + text + URLs. Both directions verified. |
| Windows | OLE IDropTarget (RevokeDragDrop winit's, then RegisterDragDrop ours) | OLE IDropSource + DoDragDrop (deferred off the dispatch that armed it) | Inbound: full position + formats. |
| Wayland | wl_data_device from the seat | wl_data_source + start_drag | No winit conflict (winit leaves Wayland DnD unimplemented). Both directions verified. |
| X11 | XDND v5 via an XdndProxy helper window | XDND source: owns XdndSelection, polls the pointer, serves the selection (incl. INCR) | Full position + arbitrary MIME types. See §11.3.1. |
winit's own DroppedFile / HoveredFile events are not used: they carry no
cursor position, files only, and nothing on Wayland — insufficient for a
drop-zone widget that must hit-test position.
11.3.1 X11: why a proxy window, and why no pointer grab
Two things about X11 shape the backend, and both are worth knowing before
reading external_dnd/x11.rs.
Inbound needs XdndProxy. XDND messages are ClientMessages sent with an
empty event mask, which the X protocol delivers only to the client that
created the destination window. winit created the toplevel and pumps its own
connection, and exposes no hook into its X event stream (WindowExtX11 is an
empty trait) — so a second connection cannot see them, and winit's own built-in
XDND handling (files only, no position) cannot be turned off. The spec's own
answer is XdndProxy: a window may name another window that "should be checked
for XdndAware and should receive all the client messages". Teksilo creates a
1×1 InputOnly helper window on its own connection, marks it XdndAware and
self-pointing XdndProxy (the spec's stale-proxy guard), and points the
toplevel's XdndProxy at it. GTK 3/4, Qt 5/6 and Java/AWT all honour this with
the same validation, covering every mainstream toolkit and file manager.
Outbound needs no pointer grab. An XDND source conventionally grabs the
pointer to keep receiving motion over other applications' windows. Teksilo
cannot: X11 pointer grabs are exclusive per client, and the ButtonPress that
started the drag already gave winit's connection an implicit grab lasting until
release — GrabPointer from the backend would return AlreadyGrabbed every
time, not occasionally. It does not need one: QueryPointer is unaffected by
grabs and reports both position and button state, so the drag is driven by
polling the backend's own connection while the button is held.
Coordinates are converted root-physical → window-logical using the scale factor
pushed down by the app layer (ExternalDndGuard::set_scale_factor), since X11
has no per-window DPI to query the way Win32's GetDpiForWindow does.
Two protocol details are easy to get wrong and are worth stating explicitly.
When a target names an XdndProxy, only the address changes: messages go to
the proxy but must still name the real window in the window field, or a proxy
fronting several windows cannot route the drop (the bug Chromium tracks as
crbug.com/41278320). Conversely, target→source replies (XdndStatus,
XdndFinished) name the source — the recipient — with our own window in
data[0]; GTK routes replies by xclient.window and discards anything else.
Target resolution is cached on the root-child that the QueryPointer each tick
already performs reports, so staying over one window costs a single round trip
rather than the dozens a full tree descent plus per-ancestor property reads
would.
Known limitation. A source that ignores XdndProxy — a hand-rolled XDND
client; no mainstream toolkit does — reaches winit's built-in handler instead,
whose events Teksilo does not consume, so such a drop is ignored rather than
delivered. Raw Xt/Motif clients speak _MOTIF_DRAG_*, not XDND, and were never
reachable.
11.4 The DropZone widget
DropZone is the ready-made
"drop files here" target: hover accept/reject highlight, accept_extensions
filter, allow_multiple policy, on_files_dropped / on_text_dropped /
on_urls_dropped callbacks, and a keyboard-operable Browse… button (the
WCAG 2.1.1 equivalent, since an OS drag can't be keyboard-initiated). It is a
Tier-3 themable widget (DropZoneStyle) and announces hover / success /
rejection through a Live::Polite status line. Demo: cargo run -p file-drop.
Accessibility note. AccessKit models no drag/drop Action, and ARIA's
aria-grabbed / aria-dropeffect are deprecated. The supported pattern is
therefore live-region announcements (the status line) plus the always-present
keyboard fallback (Browse) — not a synthetic drag action.
11.5 Outbound export (app → OS)
Dragging a file / text / URL out of a Teksilo window into another
application. The model is unified, escalate-at-boundary: a drag is not
pre-committed to "internal" or "external" — the destination decides. DragPayload
already carries both representations (a typed Box<dyn Any> fast-path and
mime_data), so the source side needs no new API — a drag becomes
OS-exportable simply by populating mime_data (via DragPayload::with_mime):
#![allow(unused)] fn main() { row.on_drag(|phase, ctx| { if let DragPhase::Started { .. } = phase { let uri_list = format!("file://{path}\r\n"); ctx.start_drag( row_id, DragPayload::typed(item).with_mime("text/uri-list", uri_list.into_bytes()), ); } }); }
Teksilo runs its normal in-app drag (preview overlay, on_drag_hover feedback).
When the pointer leaves the window carrying an OS-exportable payload, the
framework escalates to a native OS drag (WidgetTree::try_escalate_to_os_drag →
WindowOps::begin_os_drag → the backend's ExternalDndGuard::begin_drag). Drops
that never leave the window keep the typed fast-path untouched.
Completion — on_drag_ended. A single source-side hook fires once per drag,
with a DropOutcome:
#![allow(unused)] fn main() { row.on_drag_ended(|outcome, ctx| match outcome { DropOutcome::InApp { accepted } => { /* dropped on an in-app target */ } DropOutcome::OsCopy => { /* exported to another app (copy) */ } DropOutcome::OsMove => { /* exported as a move */ } DropOutcome::Cancelled => { /* Escape / dropped on nothing / OS rejected */ } }); }
The advertised OS operation is Copy only — never Move — so the destination
can't physically relocate a dragged file off disk; move-out would be an explicit
opt-in, not the baseline.
Typed re-entry + cross-window DnD. Once escalated, the OS owns the drag, but
the original typed payload is parked in an app-global stash for the drag's
lifetime. If the OS drag wanders back over any window of the app — the source
window or another one — that window recovers the typed payload and presents it
as a normal internal drag (so get_typed::<T>() works), while also exposing the
files() / text() / uris() view derived from the MIME (so DropZone-style
targets accept it too). This is what enables drag-and-drop between two windows
of the same app. Limitation: an in-app drop that crossed the window boundary
reports OsCopy (the OS's view), not InApp — drops that never left report
InApp.
Per-platform: macOS uses NSDraggingSource + beginDraggingSessionWithItems:event:source:
(triggering event from NSApp.currentEvent); Wayland uses wl_data_source +
wl_data_device.start_drag with a button-press serial captured from a wl_pointer
bound on the DnD thread (the Drop handler skips the pipe-read for a self-drag to
avoid a single-thread deadlock); X11 owns XdndSelection from its proxy window
and polls the pointer rather than grabbing it (§11.3.1). No target declines
(begin_drag returns
false) and the framework keeps the in-app drag alive. Demo: the "Drag OUT" rows
and "Internal drop target" in cargo run -p file-drop.
11.6 The DropTarget widget
DropTarget is the wrapping
counterpart to DropZone: instead of being a standalone "drop here" placeholder,
it turns any existing widget subtree into a drop target without replacing its
look. The wrapped child fills the bounds and stays fully visible; the highlight is
a border stroked over the child (never an opaque fill that would hide it), plus
an optional popup hint card centered in the zone. It reacts to both internal
(typed DragPayload) and external (OS) drops through the same
on_drag_hover / on_drag_leave / on_drop pipeline.
#![allow(unused)] fn main() { // Wrap a panel; accept image files; show a hint while an accepted drag hovers. DropTarget::new() .child(my_panel) .hint(TextWidget::new(lit!("Drop your image here"))) .accept_external_extensions(["png", "jpg", "jpeg"]) .on_drop(|payload, _pos, _ctx| { import(payload.files()); true }); // Typed internal drag — recovers the value even after an OS round-trip or // across windows (§11.5 typed re-entry), since it rides the unchanged // target-side pipeline. DropTarget::new() .child(project_card) .on_drop_typed::<ProjectRef>(|project, _pos, ctx| { ctx.send_intent(AppIntent::Link(project)); true }); }
Accept filtering (last-call-wins; default = accept everything once on_drop
is set): accept_any, accept_external / accept_external_files /
accept_external_text / accept_external_extensions([…]), accept_typed::<T>(),
or accept_when(|payload| …) for full control. The external-extension filters
mirror DropZone's Wayland-aware split — optimistic at hover (file bytes haven't
arrived yet, only advertised formats), real check at drop. on_drop re-checks the
filter before invoking the callback (the hover gate is visual only; the framework
still routes the drop to the target).
Caller-observable state. targeted_signal(Signal<bool>) (SwiftUI's
isTargeted pattern — true only while an accepted drag hovers) and
drag_state_signal(Signal<DropTargetDragState>) (full Idle / HoverAccept /
HoverReject) let the surrounding UI drive its own visuals. on_drop_typed::<T>
implicitly sets accept_typed::<T>() and hands the extracted T to the callback.
Multi-zone drops
Beyond one whole-bounds target, a DropTarget can expose up to five
independently enable-able regions — DropRegion::{Center, Top, Bottom, Leading, Trailing} — each with its own optional hint, and route the drop by
where the pointer released. This is the reusable form of DockingLayout's
hand-computed five-zone drag-to-dock overlay (its compute_drop_zone /
DockDropOverlay); the pure hit-test (region_at) and geometry (region_rect)
live in teksilo-core::styles.
#![allow(unused)] fn main() { DropTarget::new() .child(editor_pane) // The four SIDE zones share one factor (0.1..=1.0): the fraction of the // axis each edge strip occupies. 0.2 = fifth (default), 0.5 = bisect. .zone_size_factor(0.25) .region(DropRegion::Center, |z| z.hint(TextWidget::new(lit!("Add as tab")))) .region(DropRegion::Leading, |z| z.hint(TextWidget::new(lit!("Split left")))) .region(DropRegion::Trailing, |z| z.hint(TextWidget::new(lit!("Split right")))) // Region-aware drop (wins over on_drop); also `.active_region_signal(..)`. .on_region_drop(|region, payload, _pos, ctx| { route(region, payload); true }); }
- Declaring any region switches the target to exactly the declared regions;
declaring none keeps the
Center-only whole-bounds default (.hint(w)is sugar for.region(DropRegion::Center, |z| z.hint(w))). - Each zone takes a reactive
z.enabled(signal)(defaulttrue): a boundSignal<bool>disables the zone live, without a rebuild — it stops hit-testing (its strip falls through to the next-priority enabled zone, orCenter, or rejects), never highlights, and never shows its hint. region_atclassifies the target-local pointer (§4.1) against the currently enabled zones — side zones aresize_factor-thick strips tested in leading→trailing→top→bottom priority; a middle covered by no enabled zone resolves toCenterwhen enabled, else the drop is rejected (the hover never engages there, andon_region_droponly ever receives an enabled region).- The active zone highlights (centre → frame only so the wrapped content shows through; an edge strip → translucent fill + accent frame) and only that zone's hint appears, centered within the zone rect. Accept uses this per-zone overlay; a reject paints a full-bounds error border.
Leading/Trailingmap to left / right — the framework surfaces no writing direction on the layout context yet, so RTL mirroring is a follow-up.
Styling. Tier-3 DropTargetStyle (default RecipeDropTargetStyle); per-call
DropTarget::style(…) or theme-wide theme.style_slots.drop_target.
DropTargetVariant (Default 2 px / Prominent 3 px / Subtle 1 px / None)
sets the highlight-frame weight. Each hint is gated with visible_when on a
derived "is this region the active accepted-hover?" signal, so an inactive
zone's hint is culled from paint and the accessibility tree; Live::Polite
on the card announces it appearing.
Accessibility. Role::Group. Unlike DropZone, Live is not placed on the
group itself (that would announce every change to the wrapped child) — it is scoped
to each hint card. There is no Browse fallback: DropTarget wraps arbitrary content,
which provides its own keyboard affordances.
Demo: the "Internal drop target" panel in cargo run -p file-drop is a single-zone
DropTarget recovering a typed String; cargo run -p drag-and-drop adds a
multi-zone target (leading = play next / centre = add / trailing = favourite, each
with its own hint).
12. Dragging rows OUT of a data view — cross-widget export
Sections 9 and 4 cover a row reordering within its own view and a source
owning can_accept / accept_drop. This section is the other direction:
letting a user drag row(s) out of a ListView / TreeView / TableView /
TreeTableView / GridView and drop them elsewhere — on a
DropTarget, a DropZone,
another data view, or the OS. All five views share one opt-in builder surface.
12.1 The payload — RowDragData<T>
Every data-view row drag carries the public, generic
RowDragData<T>:
#![allow(unused)] fn main() { pub struct RowDragData<T: 'static> { pub source: ViewId, // kind-tagged, process-global identity pub rows: Vec<usize>, // origin's flat visible indices (drag-start) pub items: Option<Vec<T>>, // clones — Some only for an export drag } }
It occupies the single typed slot of the DragPayload
and serves both audiences: the origin's own erased classifier reads
source + rows to recognise a same-view reorder; a foreign receiver reads
items. A plain .reorderable(true) drag carries items == None, so a
reorder-only view is never accidentally consumed elsewhere — a receiver gates on
RowDragData::is_export().
12.2 Send side — opting rows into export
| Builder (on every data view) | Effect |
|---|---|
.exportable(DragTransferMode) (where T: Clone) | Carry items clones so a foreign target gets typed rows; also makes rows a drag source without reorderable. Move removes the origin rows once a foreign target accepts them; Copy keeps them. |
.export_external(|&[T]| -> Vec<(String, Vec<u8>)>) (where T: Clone) | Additionally advertise MIME (text/plain, text/uri-list, an app application/x-…) so a DropZone / the OS can take the drag. Implies .exportable. |
.on_rows_transferred_out(|&[usize], ctx|) | Override the Move removal (rows are delivered descending so index-by-index removal stays valid). Default: the source's on_drag_out. |
The dragged set is selection-aware: pressing an already-selected row keeps
the whole multi-selection (the collapse-to-one is deferred to a release without
a drag), so dragging one member of a selection exports them all. Rows whose item
isn't resident (a lazy Loading row) are dropped from the export so rows and
items stay aligned.
12.3 Receive side
-
A
DropTarget/DropZone/ any widget elsewhere — already works: name the sameTand read it:#![allow(unused)] fn main() { DropTarget::new() .accept_when(|p| p.get_typed::<RowDragData<Chapter>>().is_some_and(|d| d.is_export())) .on_drop(|mut p, _pos, _ctx| { if let Some(items) = p.take_typed::<RowDragData<Chapter>>().and_then(|d| d.items) { trash.extend(items); return true; } false }) .child(trash_bin); } -
Another data view — two ways: (a) a custom
ListDataSource/TreeDataSourcewhosecan_accept/accept_dropinspect theDragSource::Foreign { payload }(§9); or (b) the zero-custom-source sugar.accept_foreign_rows(true)+.on_rows_received(\|Vec<T>, insertion_index, ctx\|)on the receiving view, which inserts the dropped items into your model. -
TreeTableViewis not source-pluggable (it wraps a concreteSortFilterTreeModel<T>), so it exposes a raw escape hatch in addition to the typed sugar:.on_foreign_drop(\|&DragPayload, target: NodeId, DropPosition, ctx\| -> bool).
12.4 Completion (move-vs-copy)
The framework delivers the outcome to the source's on_drag_ended. The view sets
a self_reorder_flag when it handled a same-view reorder, so a Move only
removes rows that a foreign target accepted — never double-removing after an
own reorder. Move caveats: removal fires for an in-app drop in the same
window (DropOutcome::InApp { accepted: true }) or a genuine OS move; shipped
OS backends advertise copy only, so a drag exported to another application or
another window is a copy (the origin row is kept — see §13). For a
ListModel-backed view (key == index) the move-out removes by drag-start
indices; mutate a shared model mid-drag and use .on_rows_transferred_out with
your own stable identity.
12.5 Correctness notes
ViewId is a process-global, kind-tagged id (so a ListView and a TreeView
can never collide and misread a foreign drag as a same-view reorder). Multi-row
same-view reorder lands the block contiguously (ListModel::move_items
emits ItemsMoved so index selection follows; trees re-anchor and drop
descendants of another dragged node, and reject a drop into a dragged
subtree). See the integration tests in
list_view.rs (exportable_*,
accept_foreign_rows_receives_from_another_view,
two_views_over_same_model_do_not_spuriously_reorder).
13. Non-goals — what DnD does NOT do yet
- Cross-window / re-entry move semantics. A drop that crossed the window boundary reports
OsCopy, neverOsMove/InApp— the source can't know to delete its item. True app-internal move across windows would need a private-MIME handshake beyond the current Copy-only export. (A data-view.exportable(Move)drag therefore behaves as a copy across the window boundary — see §12.4.) - A drag icon on X11. XDND has no drag image in the wire protocol; GTK and Qt each create their own override-redirect window and reposition it per motion, which needs an ARGB visual and a running compositor to avoid drawing a black rectangle. Teksilo changes the cursor instead, so
DragImageDatais ignored on X11. - Non-
XdndProxyX11 sources. See §11.3.1 — a source that ignores the proxy reaches winit's built-in handler and its drop is not delivered. No mainstream toolkit is affected. Opacityprimitive for previews. The currentDragPreviewuses a raised surface — no transparency. Opacity is a separate widget-primitive enhancement.- Public
preview_builder(..)on ListView / TreeView. Today the preview is always a delegate-builtDragPreview. Apps that need a differently-styled preview have to re-implement the full reorderable widget or wait for the builder API.
See also
- architecture.md §14 Drag and Drop — the design rationale and the three DnD scenarios.
- events-and-gestures.md §4 Gesture recognizers — how
DragRecognizerfits into the gesture arena. - data-models.md §8 Drag-and-drop integration — how
ListModel::move_item/TreeModel::move_node/DataChange::ItemsMoved/TreeChange::NodeMovedplug into the drop handlers. - shortcut-intent-action.md — when a drop should fire a typed
Intentinstead of mutating a model directly. - crates/teksilo-core/src/drag_payload.rs, drag_state.rs — the framework types.
- crates/teksilo-widgets/src/list_view.rs, tree_view.rs, drag_preview.rs — the canonical widget integrations.
- crates/teksilo-platform/src/external_dnd.rs — the external (OS) drag backend trait (
ExternalDndGuard::begin_dragfor outbound), handle, and macOS / Wayland / no-op / memory backends; external_dnd/macos.rs (NSDraggingSource), external_dnd/wayland.rs (wl_data_source); drop_zone.rs — the standaloneDropZonewidget; drop_target.rs — the wrappingDropTargetwidget (§11.6). - Outbound escalation + typed re-entry live in crates/teksilo-core/src/widget_tree/drag_drop_impl.rs (
try_escalate_to_os_drag,handle_os_drag_ended, the global typed-payload stash);DropOutcome/OutboundDragData/DragImageData/DragPayload::{to_outbound,is_os_exportable,enrich_external_from_mime}in drag_payload.rs;WindowOps::begin_os_dragin window/ops.rs. - examples/drag_and_drop — runnable in-app DnD demo; examples/file_drop — external (OS) drop demo.
Multi-Window Reference
Teksilo's multi-window system is signal-driven and synchronous.
A single WindowConfig describes any window you want to open (initial
or runtime); per-window state lives in a reactive
WindowState that widgets
bind against; handlers open, focus, and close windows through
EventContext methods that
return real ids immediately.
Mental model in one line:
WindowConfig → (WindowManager::create_window OR ctx.open_window) → (WindowState signals, tree, winit window)
Every signal on WindowState is two-way: app writes push to the OS,
OS-initiated changes write back into the same signals (re-entrancy
guarded so observers don't echo).
Full end-to-end example:
examples/multi_window.
Canonical app shape
Every Teksilo app opens exactly one initial window via
TeksiloAppBuilder::initial_window(WindowConfig). Secondary windows are
opened from handler code via EventContext::open_window.
use teksilo::prelude::*; use teksilo::app::TeksiloAppBuilder; fn main() { TeksiloAppBuilder::new() .theme(intui::light()) .initial_window( WindowConfig::new() .title("My App") .size(1200, 800) .min_size(640, 400) .initial_placement(WindowPlacement::Floating) .root(|tree, _state| tree.add(AppRoot::new())), ) .run(); }
Notes:
TeksiloAppBuilderhas no.window_title,.window_size,.root, or.custom_chrome— every window is described byWindowConfig. One conceptual surface, no special-casing for the initial window.root_builderreceives(tree, WindowState)— the state clone is how a widget can bind against its own window's signals at construction time, without going through aBuildContext.
WindowConfig
The single entry point for creating any window. Uniform whether you
pass it to TeksiloAppBuilder::initial_window at startup or to
EventContext::open_window from a handler.
#![allow(unused)] fn main() { pub struct WindowConfig { pub title: String, // also feeds WindowState::title pub string_id: Option<String>, // stable lookup key for find_window pub size: (u32, u32), // restored size; always set pub position: Option<(i32, i32)>, // restored position; None = WM picks pub min_size: Option<(u32, u32)>, pub max_size: Option<(u32, u32)>, pub restore_geometry: bool, // read the saved geometry back? (default true) pub initial_placement: WindowPlacement, pub decorations: DecorationsMode, pub resizable: bool, pub always_on_top: bool, pub skip_taskbar: bool, pub icon: Option<WindowIcon>, pub modal: Option<ModalConfig>, pub root_builder: Option<RootBuilder>, } }
Persisting geometry without restoring it
string_id normally governs both halves of window-state persistence: a window
with an id has its geometry saved on every move/resize, and restored at
creation. restore_geometry splits them.
They need splitting whenever several windows share one geometry slot — a multi-window (or, like Skribisto, multi-process) app that remembers "where the window was" rather than "where this document's window was". Restore the saved geometry into every window and they all land on the same pixel, stacked. What you want is:
- the first window: restore it — reopen where the user left off;
- any window opened alongside it: let the window manager place it (it cascades), but still save its geometry, so whichever window the user moved or closed last is the one that reopens.
That second case is id(..) + restore_geometry(false):
#![allow(unused)] fn main() { WindowConfig::new() .id("main") // still persists into this slot .restore_geometry(peers.is_empty()) // ...but only the first one restores }
With position left None, the WM picks the spot. This is the behaviour of
Word, Firefox and most document apps.
Per-document geometry (Scrivener, Sublime, the JetBrains IDEs) is a different design: it keys the slot on the document, so windows never collide and every one restores. It only works if the document is known before the window is created — i.e. a launcher/welcome window that opens a separate document window, rather than a blank window that later loads a document into itself.
Builder form for the common cases:
#![allow(unused)] fn main() { WindowConfig::new() .title("Inspector") .id("inspector") // ctx.find_window("inspector") → Some(id) .size(420, 640) .min_size(320, 400) .position(120, 80) .initial_placement(WindowPlacement::Floating) .decorations(DecorationsMode::CustomChrome) .resizable(true) .always_on_top(true) // floating tool palette .skip_taskbar(true) // not in the taskbar/dock .icon(WindowIcon::from_rgba(rgba, w, h)) .root(|tree, state| tree.add(Inspector::new(state))) }
WindowPlacement
Unified enum for the four top-level placement states every desktop OS supports:
| Variant | Meaning |
|---|---|
Floating | Regular overlapping window — uses WindowState::size / position as current geometry |
Maximized | Fills the current monitor's work area |
Fullscreen | Exclusive fullscreen (Space-based on macOS) |
Minimized | Hidden to the taskbar / dock |
Size and position are not inside Floating. They live on
WindowState as their own signals and always hold the last-known
restored values — matching macOS frameAutosaveName and Windows
WINDOWPLACEMENT behavior, so "un-maximize" and "un-fullscreen"
restore the window to the right rect without ambiguity.
Transitions between any two variants are legal; the platform layer
preserves the restored rect as you cross through Maximized /
Fullscreen / Minimized.
DecorationsMode
| Variant | Meaning |
|---|---|
Native | OS-provided title bar, borders, resize handles. Default |
CustomChrome | No native title bar; a PlatformTitleBarHost is attached so the app can paint its own. On X11, falls back to Native when the window manager lacks _NET_WM_MOVERESIZE (see title-bar.md) |
None | Borderless, no host — splash screens, popups, fully chrome-less embeds |
ModalConfig
Modal dialogs are an Option<ModalConfig> on WindowConfig, not two
separate flags. The type system enforces that a modal always names its
parent:
#![allow(unused)] fn main() { .modal(ModalConfig { parent: ctx.window().unwrap().id(), focus_target: Some(ok_button_id), // optional explicit initial focus }) }
Short form when you only need the parent:
#![allow(unused)] fn main() { .modal_to(ctx.window().unwrap().id()) }
Modal semantics are preserved from the previous ModalRequest path:
input-blocking on the parent, Z-order child-window attachment
(WM_TRANSIENT_FOR / xdg_toplevel.set_parent / AppKit
addChildWindow:ordered:), refocus-on-stolen-focus.
WindowIcon
Raw RGBA8 buffer + dimensions. width × height × 4 bytes exactly; the
app-level manager validates on creation and logs + drops invalid icons
(the window still opens with the platform default).
#![allow(unused)] fn main() { let rgba: Vec<u8> = /* load from disk, decode PNG, … */; WindowConfig::new().icon(WindowIcon::from_rgba(rgba, 64, 64)) }
WindowState
Per-window reactive state. Cloneable handle to an Rc<WindowStateInner>
so signals and command queue are shared across clones. Widgets get a
clone from ctx.window() (in both BuildContext and EventContext).
#![allow(unused)] fn main() { pub struct WindowState(Rc<WindowStateInner>); impl WindowState { pub fn id(&self) -> TeksiloWindowId; pub fn string_id(&self) -> Option<&str>; // Writable signals. App-side writes queue a `WindowCommand` to the // OS; OS-initiated writes flow back through `*_from_os` setters // with a re-entrancy guard. pub fn placement(&self) -> &Signal<WindowPlacement>; pub fn title(&self) -> &Signal<String>; pub fn size(&self) -> &Signal<(u32, u32)>; pub fn position(&self) -> &Signal<(i32, i32)>; pub fn focused(&self) -> &Signal<bool>; pub fn resizable(&self) -> &Signal<bool>; pub fn always_on_top(&self) -> &Signal<bool>; // Imperative one-shots. Each pushes a single `WindowCommand` on // the next drain. pub fn request_attention(&self, kind: UserAttentionKind); pub fn focus(&self); pub fn close(&self); } }
Binding widgets to window state
At build() time, pick up the state from ctx.window() and build
derived signals you pass to widgets:
#![allow(unused)] fn main() { impl Widget for AppRoot { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let fs = ctx.window() .expect("AppRoot requires a window") .placement() .map(|p| p.is_fullscreen()); let label = fs.map(|f| if f { "Exit fullscreen" } else { "Fullscreen" }); vec![ctx.add( Button::new() .label(label) .on_activate_fn(|ctx| { let Some(w) = ctx.window() else { return }; let next = if w.placement().get().is_fullscreen() { WindowPlacement::Floating } else { WindowPlacement::Fullscreen }; w.placement().set(next); }), )] } // ... } }
The button label re-renders automatically when fullscreen is toggled —
whether the toggle came from the button itself, the F11 shortcut, or
the user pressing the green traffic light on macOS. All three paths
write into the same placement() signal.
Two-way OS sync — how it works
WindowState::new wires an observer to every writable signal. The
observer:
- Checks the
applying_from_osflag onWindowStateInner. - If set (OS-initiated write): does nothing — the OS already knows.
- If unset (app-initiated write): pushes a
WindowCommandonto the sharedpending_os_commandsqueue.
Each event-loop tick:
teksilo-app'shandle_window_event_innertranslates winitResized/Moved/Focusedevents into calls likestate.set_placement_from_os(new)/set_size_from_os(size). These flipapplying_from_ostotruebefore writing the signal, suppressing the observer's outbound echo.- After event dispatch,
WindowManager::drain_window_commandsdrains each live window's command queue and translates each command into the corresponding winit call.
This is the re-entrancy guard from Compose Multiplatform
#1489:
without it, an OS-initiated state change would loop back through the
observer as an app-initiated OS call, desynchronizing OS and app mid-
animation. The guard is the single concrete mechanism that makes
WindowState safe as a shared source of truth.
See state.rs for the
implementation; see state.rs tests
for os_side_write_does_not_enqueue_command and
os_side_write_still_notifies_derived_signals.
EventContext multi-window API
Every handler receives a &mut EventContext that carries an
&mut dyn WindowOps borrowed from the app-level window manager for
the duration of dispatch.
#![allow(unused)] fn main() { impl EventContext<'_> { pub fn window(&self) -> Option<&WindowState>; pub fn open_window(&mut self, config: WindowConfig) -> TeksiloWindowId; pub fn open_modal(&mut self, request: ModalRequest) -> Option<TeksiloWindowId>; pub fn find_window(&self, string_id: &str) -> Option<TeksiloWindowId>; pub fn focus_window(&mut self, id: TeksiloWindowId); pub fn close_window(&mut self); // current window, GUARDED pub fn close_window_forced(&mut self); // current window, bypasses the guard pub fn close_window_by_id(&mut self, id: TeksiloWindowId); pub fn window_state(&self, id: TeksiloWindowId) -> Option<WindowState>; pub fn windows(&self) -> Vec<WindowState>; } }
open_window is synchronous
When you call ctx.open_window(config), the winit-level window is
created before the call returns. The returned TeksiloWindowId is
immediately usable — you can pass it to focus_window, read its
window_state(id), or reference it as a modal parent in a subsequent
open_window call in the same handler.
#![allow(unused)] fn main() { ctx.register_action(Action::new("app.help").on_invoke(|_i, ctx| { if let Some(id) = ctx.find_window("help") { ctx.focus_window(id); // second press → raise existing return; } // First press → create the window; id is valid from this line on. let id = ctx.open_window( WindowConfig::new() .title("Help") .id("help") // stable key for find_window .size(720, 480) .root(|tree, _state| tree.add(HelpRoot)), ); // Could immediately e.g. write an initial state signal on the new // window by looking it up via ctx.window_state(id). let _ = id; })); }
Under the hood: WindowOpsImpl::open_window calls
WindowManager::create_window(config, event_loop) — which builds the
winit window, wires WindowState observers, runs the root builder,
registers ManagedWindow in the windows map, and returns the id.
Nothing is deferred.
Ergonomic patterns
Idempotent open (single-instance preferences, inspector):
#![allow(unused)] fn main() { .on_invoke(|_i, ctx| { match ctx.find_window("preferences") { Some(id) => ctx.focus_window(id), None => { ctx.open_window(/* ... */); } } }) }
Document window (one window per file):
#![allow(unused)] fn main() { .on_invoke(|intent, ctx| { let AppIntent::OpenDocument(path) = AppIntent::from_intent(intent).unwrap() else { return; }; let wid = format!("doc:{}", path.display()); if let Some(id) = ctx.find_window(&wid) { ctx.focus_window(id); return; } let path = path.clone(); ctx.open_window( WindowConfig::new() .title(format!("{} — My App", path.file_name().unwrap().to_string_lossy())) .id(wid) .size(1200, 800) .root(move |tree, _state| tree.add(DocumentRoot::open(path))), ); }) }
Opening a window from a background thread (on_external_with_ctx):
Both recipes above run inside a handler, where an EventContext already
exists. A background thread has none — and neither does
TeksiloAppBuilder::on_app_event, which receives &AppEvent and nothing
else, so ctx.open_window is simply not reachable from there (calling it on
a standalone context panics: "open_window called outside of a dispatch").
on_external_with_ctx is the hook for that case. It is offered every
AppEvent::External payload that no framework router claimed, together with
a live EventContext minted from the focused window (or the primary one),
and returns true when the payload was the app's:
#![allow(unused)] fn main() { // A single-instance app. The second launch forwards its argv over a socket // and exits; this process's listener thread posts it with // `AppEventProxy::send_external`, and the "document window" recipe above // runs against the resulting context. TeksiloAppBuilder::new() .on_ready(spawn_ipc_listener) // background thread → send_external .on_external_with_ctx(move |payload, ctx| { let Some(req) = payload.downcast_ref::<OpenDocument>() else { return false; // not ours — leave it unclaimed }; let wid = format!("doc:{}", req.path.display()); match ctx.find_window(&wid) { Some(id) => ctx.focus_window(id), None => { ctx.open_window(document_window_config(&req.path)); } } true }) }
Notes:
- Framework payload types (file-dialog results, async completions, native-menu
choices,
CloseWindowRequest, title-bar synthetics,RepaintWindowRequest) are handled before this hook and never reach it, so it never has to defend against them. - It is a single slot, like
on_app_event— a second call replaces the first. For fan-out useregister_app_event_observer, which composes (but gets no context). - With no window open there is nowhere to mint a context from, and the call is silently skipped.
- On Wayland, a launcher that hands you an
XDG_ACTIVATION_TOKENshould have it forwarded in the payload and applied viaWindowState::set_activation_tokenbeforefocus(), or the compositor treats the raise as unsolicited.
Cross-window read (dim the inspector when the main window is fullscreen):
#![allow(unused)] fn main() { // In a handler on the inspector window: let main_id = ctx.find_window("main").unwrap(); if let Some(main_state) = ctx.window_state(main_id) { let dim = main_state.placement().map(|p| p.is_fullscreen()); // Use `dim` as a derived signal inside the inspector's UI. } }
Modal dialogs
EventContext::open_modal is a thin wrapper that builds a
WindowConfig with ModalConfig { parent: ctx.window().id(), focus_target }
and calls open_window. Use it when you already have a
ModalRequest in hand:
#![allow(unused)] fn main() { ctx.open_modal(ModalRequest { content: ModalContent::Deferred(Box::new(|tree| tree.add(ConfirmQuit::new()))), presentation: ModalPresentation::NativeWindow, close_behavior: ModalCloseBehavior::EscapeOrClickOutside, title: Some("Confirm quit".into()), size: Some((420, 180)), focus_target: Some(ok_button_id), on_dismiss: None, }); }
For the general case (may land in-tree or in a native window),
ctx.present_modal(request) picks the presentation at dispatch time
based on ModalPresentation::Auto and platform capability.
Intercepting close / quit — confirmation guards
A window can refuse to close. Each WindowConfig carries an optional
close guard that the framework runs — with a real EventContext
for that window's own tree — before any interactive close gesture
tears the window down:
- the OS close button,
Alt+F4,Cmd+W(winitCloseRequested); - a custom-chrome (Teksilo-drawn) title-bar close button;
- a handler calling
ctx.close_window().
The guard returns CloseResponse::Close to let the close proceed, or
CloseResponse::Veto to cancel it. Quitting the app is just the last
window closing, so a guard that vetoes the final window's close also
keeps the app alive.
Guards are strictly per-window — closing one window never consults another's guard — so this is correct for multi-window apps: an editor window with unsaved changes can veto its own close while a tool palette beside it closes freely.
Veto-then-reissue (the async-confirmation pattern)
A confirmation dialog is asynchronous — it waits for a click — so the guard cannot answer "close?" synchronously. The idiomatic shape is to veto now, confirm, then re-issue a forced close:
#![allow(unused)] fn main() { use teksilo::prelude::*; // CloseResponse use teksilo::widgets::{MessageBox, MessageBoxButtons, StandardButton, EventContextMessageBoxExt}; WindowConfig::new() .title("Editor") .on_close_requested(move |ctx| { if !dirty.get() { return CloseResponse::Close; // nothing unsaved → just close } ctx.present_message_box( MessageBox::question(lit!("Close window?")) .text(lit!("The document has unsaved changes.")) .buttons(MessageBoxButtons::SaveDiscardCancel) .on_result(move |r, ctx| match r.button { StandardButton::Save => { save(); ctx.close_window_forced(); } StandardButton::Discard => ctx.close_window_forced(), _ => {} // Cancel → stay open }), ); CloseResponse::Veto // hold the window open for now }); }
ctx.close_window_forced() is the escape hatch: it closes the window
unconditionally, bypassing the guard, so the second close (from the
dialog's button) actually goes through instead of re-prompting.
Reactive sugar: can_close + on_close_blocked
When the gate is a single reactive flag, skip the closure:
#![allow(unused)] fn main() { let may_close = dirty.not(); // Signal<bool> WindowConfig::new() .can_close(may_close) // false → veto .on_close_blocked(move |ctx| { // fired only when blocked ctx.present_message_box(/* confirmation … */); }); }
can_close is evaluated before on_close_requested: a false signal
short-circuits to a veto and fires on_close_blocked; a true signal
(or no signal) falls through to the guard, then to closing.
Which closes are guarded
| Close origin | Guarded? |
|---|---|
OS close button / Alt+F4 / Cmd+W | ✅ yes |
| Custom-chrome title-bar close button | ✅ yes |
ctx.close_window() | ✅ yes |
ctx.close_window_forced() | ❌ bypasses |
ctx.close_window_by_id(id) | ❌ bypasses (explicit programmatic close) |
WindowState::close() | ❌ bypasses |
| Modal-dismissal / framework teardown | ❌ bypasses |
A window with no guard configured always closes immediately — the guard
machinery only runs when on_close_requested or can_close is set.
Working demo: cargo run -p close-confirmation (main window: full
on_close_requested + Save/Discard/Cancel; second window: the
can_close sugar).
TeksiloAppBuilder::run() lifecycle
run()builds aTeksiloAppHandlerand spins up the winit event loop.- On
resumed(), the handler callsWindowManager::create_window(initial_window_config, event_loop)— synchronous winit creation, widget tree built, first paint requested. - On every
winit::WindowEvent:- Event translation →
WidgetEvent. dispatch_in_window(winit_id, evt, event_loop)— temporarily removes the window from the map, constructsWindowOpsImplwith&mut WindowManager+&ActiveEventLoop, callstree.dispatch_event_with_ops(evt, ops), reinserts the window.- Handlers can call
ctx.open_window(...)which synchronously reacheswm.create_window(...)— modal parents attach to either the dispatching window (via the stashed raw handle on the ops object) or to another window that's still in the map.
- Event translation →
- After dispatch,
post_event:- Drains tree-level pending operations (locale, close-window).
- Processes in-tree modal requests.
- Drains every window's
pending_os_commandsand applies them via winit calls. - Drains
pending_closes(from any source —ctx.close_window(),ctx.close_window_by_id(id),state.close(), close requests viaTitleBarHostCallbacks::request_close). Each entry is either guarded (interactive gestures — runs the window's close guard, may be vetoed) or forced (explicit programmatic closes + framework teardown — unconditional). See Intercepting close / quit above.
handle_redraw_requestedrunslayout_with_ops+render_with_ops— both thread ops through, so state-change-triggered handlers (data-driven rebuilds, delayed overlays, drag-tick) can open windows too.
WindowOps and the dispatch re-entry pattern
WindowOps is a trait in teksilo-core; teksilo-app provides
WindowOpsImpl. This is what lets EventContext::open_window route
into WindowManager::create_window synchronously without teksilo-core
depending on teksilo-app.
#![allow(unused)] fn main() { // teksilo-core pub trait WindowOps { fn open_window(&mut self, config: WindowConfig) -> TeksiloWindowId; fn find_window(&self, string_id: &str) -> Option<TeksiloWindowId>; fn window_state(&self, id: TeksiloWindowId) -> Option<WindowState>; fn windows(&self) -> Vec<WindowState>; fn focus_window(&mut self, id: TeksiloWindowId); fn close_window_by_id(&mut self, id: TeksiloWindowId); } }
Temporary-removal re-entry
Inside TeksiloAppHandler::dispatch_in_window:
#![allow(unused)] fn main() { let Some(mut current) = self.wm.take_managed(winit_id) else { return }; // SAFETY: the current window is held in `current`; the map no longer // contains it. WindowOpsImpl holds `&mut self.wm` (minus the current // window) + `&ActiveEventLoop` + the current window's raw handle so // modal parents pointing at it still resolve. { let mut ops = WindowOpsImpl::new(&mut self.wm, event_loop, current.teksilo_id, current_handle); current.tree.dispatch_event_with_ops(evt, &mut ops); } self.wm.reinsert_managed(winit_id, current); }
The dispatching window is removed from the windows map for the
duration of the handler run. That releases the mutable borrow on
self.wm.windows[winit_id] so WindowOpsImpl::open_window can call
self.wm.create_window(...) without borrow conflicts. The stashed
raw window handle lets modal-parent lookups reach back to the
dispatching window.
If you're a handler, none of this is visible — you just call
ctx.open_window(...) and it returns an id.
Integration points
TitleBar widget
The title bar's maximize / restore / close buttons and double-click
handler now write directly to WindowState::placement (through
ctx.window()). The button glyph swap is driven by a derived signal:
#![allow(unused)] fn main() { let is_maximized = ctx .window() .map(|w| w.placement().map(|p| p.is_maximized())) .unwrap_or_else(|| Signal::new(false)); }
The PlatformTitleBarHost trait shrank — it no longer owns minimize,
toggle_maximize, close, is_maximized, is_maximized_signal, or
notify_window_resized. It keeps only what's genuinely chrome-specific
(insets, drag/resize interaction, hit regions, show_window_menu).
Custom chrome now works with DecorationsMode::Native windows too —
the TitleBar widget binds to WindowState::placement either way.
Tests / headless
Standalone WidgetTrees without an attached app use NoopWindowOps:
tree.dispatch_event(evt)— wraps withNoopWindowOpstree.layout(proposal)— wraps withNoopWindowOpstree.render()— wraps withNoopWindowOpstree.tick_gestures(now)— wraps withNoopWindowOpstree.focus(id)/tree.focus_with_origin(id, origin)— wrapstree.dismiss_overlay(id)— wraps
A handler that calls ctx.open_window(...) from any of these paths
panics (by design — the test has no event loop to create a window in).
ctx.find_window, ctx.window_state, ctx.windows return None /
empty.
teksilo-app uses the _with_ops variants internally so real apps get
fully-threaded ops on every code path.
Checklist for common tasks
Add a fullscreen toggle to my app
- Register a shortcut for
F11. - Register an
Actionthat readsctx.window().placement()and writes the oppositeFloating/Fullscreen. - Widgets that want to reflect the state derive from
ctx.window().placement().map(|p| p.is_fullscreen()).
Open a "Preferences" window that's single-instance
#![allow(unused)] fn main() { ctx.register_action(Action::new("app.preferences").on_invoke(|_i, ctx| { match ctx.find_window("preferences") { Some(id) => ctx.focus_window(id), None => { ctx.open_window( WindowConfig::new() .title("Preferences") .id("preferences") .size(640, 480) .root(|tree, _state| tree.add(Preferences::new())), ); } } })); }
Show a confirm-quit native modal
#![allow(unused)] fn main() { ctx.open_modal(ModalRequest::deferred(|tree| tree.add(ConfirmQuit::new())) .presentation(ModalPresentation::NativeWindow) .title("Confirm quit") .size(420, 180)); }
Custom chrome on the initial window
#![allow(unused)] fn main() { WindowConfig::new() .title("My App") .size(1200, 800) .decorations(DecorationsMode::CustomChrome) .root(|tree, _state| tree.add(AppRoot::new())) }
The root widget typically places a TitleBar at the top of its
layout; its maximize / close buttons bind to WindowState automatically.
Read the main window's size from a secondary window
#![allow(unused)] fn main() { // In a handler on any window: if let Some(main_id) = ctx.find_window("main") { if let Some(main_state) = ctx.window_state(main_id) { let (w, h) = main_state.size().get(); // Use w, h ... } } }
Or keep a live subscription by cloning the Signal<(u32, u32)> and
installing an observer through the current window's build context.
Reference
- End-to-end demo:
examples/multi_window. - Implementation:
- Types —
crates/teksilo-core/src/window/ - Dispatch —
crates/teksilo-core/src/widget_tree/event_dispatch_impl.rs - Window manager —
crates/teksilo-app/src/window_manager.rs EventContextmethods —crates/teksilo-core/src/widget.rs
- Types —
- Related docs:
title-bar.md— custom chrome integrationshortcut-intent-action.md— the input pipeline that typically drivesopen_windowcallsreactive-theme.md— the signal systemWindowStateis built on
TitleBar Reference
Teksilo replaces the native OS title bar with a widget-level one when an application opts into custom chrome. The title bar is a single cross-platform widget (TitleBar); the window-manipulation plumbing (drag, zoom, close, inset measurements) lives behind a per-OS trait (PlatformTitleBarHost). The widget never touches NSWindow, HWND, or xdg_toplevel directly.
| Layer | Type | Crate | What it does |
|---|---|---|---|
| Widget | TitleBar | teksilo-widgets | Lays out the bar, dispatches gestures, renders controls |
| Host trait | PlatformTitleBarHost | teksilo-core | Seam between the widget and the OS |
| Backends | WaylandHost / MacOsHost / WindowsHost / X11Host | teksilo-platform | Concrete per-OS implementations |
| Resize frame | WindowFrame | teksilo-widgets | Optional invisible edge-resize overlay for borderless windows |
The backend is constructed by WindowManager when the app opts into custom chrome, and handed to the widget tree. You retrieve it from the root-builder closure and pass it to TitleBar::new.
Quick start
use teksilo::prelude::*; use teksilo::widgets::{Expand, RectWidget, TextWidget, TitleBar, VStack, ZStack}; fn main() { TeksiloAppBuilder::new() .theme(intui::dark()) .initial_window( WindowConfig::new() .title("My App") .size(900, 600) .decorations(DecorationsMode::CustomChrome) // opt in .root(|tree, _state| { let theme = tree.theme().clone(); let title_bar: Box<dyn Widget> = match tree.title_bar_host() { Some(host) => Box::new( TitleBar::new(host) .height(40.0) .background(theme.colors.surface_pressed) .leading(TextWidget::new(lit!(" My App"))) .center(TextWidget::new(lit!("drag · double-click to maximize"))), ), // Some configurations have no backend — e.g. X11 with a window manager // that lacks `_NET_WM_MOVERESIZE`, where a borderless window could not // be moved. Always handle the `None` arm. None => Box::new(TextWidget::new(lit!( "(custom chrome unsupported — native decorations)", ))), }; let body = Expand::new().child( ZStack::new() .child(RectWidget::new().background(theme.colors.surface_main)) .child(TextWidget::new(lit!("body content"))), ); let title_bar_id = tree.add_boxed(title_bar); let body_id = tree.add(body); tree.add(VStack::new().spacing(0.0).add_child(title_bar_id).add_child(body_id)) }), ) .run(); }
Two entry points matter:
WindowConfig::decorations(DecorationsMode::CustomChrome)— opts the window into custom chrome.WidgetTree::title_bar_host()— returnsOption<Rc<dyn PlatformTitleBarHost>>.Nonemeans either the app didn't opt in or the platform has no backend for this window (X11 without a_NET_WM_MOVERESIZE-capable window manager). Always handle both arms; a fallback view keeps the app usable on the unsupported path.
Working demo: examples/title_bar_demo/src/main.rs — cargo run -p title-bar-demo.
Layout model
TitleBar is a horizontal band with five slots:
┌──────────┬───────────┬──────────────────────┬────────────┬──────────────┐
│ leading │ leading │ drag region │ trailing │ window │
│ inset │ slot │ (center / flex) │ slot │ controls │
└──────────┴───────────┴──────────────────────┴────────────┴──────────────┘
host-reserved host-rendered
- Leading inset —
host.reserved_leading_inset(). Blank. Reserved so the OS can draw over it; on macOS this is where the traffic lights land. Windows/Wayland returnSize::ZERO. - Leading slot —
.leading(widget). App icon, menu bar, title text. - Drag region — the center slot is wrapped in a flex
DragRegion: unconsumed presses callhost.begin_drag(), double-clicks toggle maximize viaWindowState::placement, right-clicks callhost.show_window_menu(). - Trailing slot —
.trailing(widget). Search field, action buttons. - Window controls — min/max/close cluster. Rendered only when
host.renders_custom_controls()istrue(Windows + Wayland; never on macOS — the OS traffic lights already cover this).
#![allow(unused)] fn main() { TitleBar::new(host) .height(40.0) // default: 40 logical px .background(theme.colors.surface_pressed) // default: transparent .border(theme.colors.text_secondary, 2.0) // 1px+ bottom rule .leading(leading_widget) // or .leading_id(id) .center(center_widget) // or .center_id(id) .trailing(trailing_widget) // or .trailing_id(id) .close_action(|ctx| ctx.close_window()) // optional override }
Full builder surface in title_bar.rs.
Per-platform behavior
The widget is identical everywhere; the host decides what renders where. TitleBar::build reads host.reserved_leading_inset() + host.renders_custom_controls() on every rebuild.
| Capability | Wayland | macOS | Windows | X11 |
|---|---|---|---|---|
custom_chrome supported | yes | yes | yes | yes, if the WM implements _NET_WM_MOVERESIZE |
reserved_leading_inset() | ZERO | ~78×22 (traffic-light cluster) | ZERO | ZERO |
renders_custom_controls() | true | false | true | true |
needs_custom_resize_handles() | true | false | false (OS handles via WM_NCHITTEST) | true |
begin_drag() | winit drag_window | winit drag_window | winit drag_window | winit drag_window (EWMH _NET_WM_MOVERESIZE) |
begin_resize(edge) | winit drag_resize_window | Unsupported (NSWindow handles edges) | winit drag_resize_window | winit drag_resize_window |
has_window_menu() | true | true | true | false — no OS menu exists; TitleBar builds its own |
show_window_menu(at) | xdg-shell show_window_menu | no-op (Ok(())) | SendMessage(WM_SYSCOMMAND, SC_KEYMENU) | Unsupported (never called) |
update_hit_regions(&HitRegions) | no-op | no-op | snapshot stored for WM_NCHITTEST (logical→physical converted via GetDpiForWindow) | no-op |
| Snap-layout flyout (Win11) | n/a | n/a | yes — proc returns HTMAXBUTTON for the maximize-button rect | n/a |
X11: conditional support, and the window menu
X11 has no protocol for "the client draws its own frame". Decorations are
switched off with _MOTIF_WM_HINTS, after which _NET_WM_MOVERESIZE is the
only way the window can be moved or resized — so a window manager that does
not implement it would leave the window borderless and immovable. Teksilo
therefore probes before committing: it reads _NET_SUPPORTED (after validating
_NET_SUPPORTING_WM_CHECK with the spec's two-step self-pointing handshake) on
a short-lived connection of its own, once per process, before the window is
created — because the decoration flag has to be chosen at WindowAttributes
time. If the probe fails, or no EWMH window manager is running,
title_bar_host() returns None and the app keeps native decorations.
There is also no system window menu on X11: winit's show_window_menu is an
empty stub there and _GTK_SHOW_WINDOW_MENU is not implemented by KWin
(KDE bug 454756). Rather than
leave right-click dead, has_window_menu() reports false and TitleBar
builds an ordinary Teksilo menu (Restore / Maximize / Minimize / Close) driving
the same WindowState::placement signals its buttons use. Any future platform
without an OS menu gets the same fallback for free.
Going borderless does not cost keyboard window management: Alt+F7 / Alt+F8 and the WM's own window-menu shortcut are global window-manager bindings, independent of who draws the frame.
On macOS, because renders_custom_controls() is false, WindowControls never enters the tree — the OS's native traffic lights are what you see.
Window minimize / maximize / close are not trait methods. They flow through WindowState::placement (a Signal<WindowPlacement>) and WindowState::close — WindowControls mutates these and the app-level apply_window_command translates each WindowCommand into the matching winit call. OS-initiated changes flow back via set_placement_from_os (re-entrancy guarded).
PlatformTitleBarHost trait
Full signature in teksilo-core/src/window_chrome.rs. The trait is intentionally !Send + !Sync (it owns platform-handle Rcs) and is passed around as Rc<dyn PlatformTitleBarHost>.
| Method | Purpose |
|---|---|
reserved_leading_inset() -> Size | Blank leading-edge area the OS draws over (macOS traffic lights); ZERO elsewhere |
reserved_trailing_inset() -> Size | Reserved trailing-edge area (always ZERO today) |
renders_custom_controls() -> bool | Whether the widget should draw min/max/close |
needs_custom_resize_handles() -> bool | Whether the app should install a WindowFrame overlay |
begin_drag() -> Result<(), PlatformError> | Start interactive window move |
begin_resize(ResizeEdge) -> Result<(), PlatformError> | Start interactive resize from an edge/corner |
show_window_menu(Point) -> Result<(), PlatformError> | Show the system window menu |
update_hit_regions(&HitRegions) | Per-frame snapshot of drag + button rects; only the Windows backend uses it (WM_NCHITTEST) |
title_bar_widget_id(ControlTarget) -> Option<WidgetId> | Resolve a button target to its widget id; Windows uses this to route synthetic taps from WM_NCLBUTTONUP |
set_button_hover(ControlTarget, bool) | Inject non-client hover from WM_NCMOUSEMOVE (Windows) |
register_hover_signal(ControlTarget, Signal<bool>) | WindowControls registers the per-button hover signal at build time so the host can drive it |
Window-state mutations (minimize, maximize, close) are not trait methods. The widget tree mutates WindowState::placement / WindowState::close directly; the app-level apply_window_command translates them into the matching winit calls. This means a custom close_action override on TitleBar is honoured on every backend including Windows — the ControlButton's on_tap runs the override regardless of how the click arrived (widget tree or synthetic tap from the wndproc).
PlatformError::Unsupported vs PlatformError::Os(String) — Unsupported means the platform has no way to do it (begin_resize on macOS); Os(String) means the OS call failed at runtime. The string is for logs, not programmatic matching.
Widget::after_paint aggregation
TitleBar overrides Widget::after_paint (gated on wants_after_paint() == true) to publish a single complete HitRegions snapshot per frame. The hook receives a read-only WidgetTreeView so the parent can read the resolved bounds of memoised descendants — the drag region and the three ControlButtons registered by WindowControls via a shared layout sink. Wayland and macOS hosts ignore the published payload (their update_hit_regions is a no-op); the Windows host converts logical→physical via GetDpiForWindow(hwnd) and stores the snapshot under a Mutex for WM_NCHITTEST to consume.
This is also why per-button publishing from ControlButton::paint would be wrong: update_hit_regions is replace-semantics, so concurrent publishes by sibling controls would each clobber the previous payload. Aggregation in the parent is the only correct approach.
The close action
The close button on WindowControls has two paths:
- Default — the button's
on_tapcallsEventContext::close_window, which queues aWindowCommand::Closeon the window'sWindowState. The app drains the queue on the next event-loop tick (winit 0.30 has no synchronousWindow::request_close, so we hop through the command queue). - Override —
TitleBar::close_action(|ctx| …)replaces the defaulton_tapentirely. Useful when the app wants to confirm unsaved work first, or to send anIntentfor a root-levelAction:
#![allow(unused)] fn main() { TitleBar::new(host).close_action(|ctx| ctx.close_window()) // or: TitleBar::new(host).close_action(|ctx| ctx.send_intent(AppIntent::RequestQuit)) }
The override fires on every backend including Windows: when the OS reports WM_NCLBUTTONUP over the close-button rect, teksilo-platform posts a TitleBarSyntheticEvent through AppEvent::External, the dispatcher resolves the button's WidgetId via host.title_bar_widget_id(Close), and WidgetTree::synthesise_tap runs the same on_tap handler the override installed.
Reactive maximize
TitleBar derives the maximize signal from the hosting window's WindowState::placement:
#![allow(unused)] fn main() { let is_maximized_signal = ctx .window() .map(|w| w.placement().map(|p| p.is_maximized())) .unwrap_or_else(|| Signal::new(false)); }
End-to-end flow:
user clicks maximize ─► ControlButton on_tap fires:
w.placement().set(Maximized | Floating)
│
▼
observer enqueues WindowCommand::SetPlacement(...)
│
▼
WindowManager::drain_window_commands → apply_window_command
│
▼
winit `set_maximized(true|false)`
│
▼
OS zooms, fires WindowEvent::Resized
│
▼
TeksiloAppHandler::window_event → set_placement_from_os(...)
(re-entrancy guarded — observers don't echo)
│
▼
placement signal flips → Switcher swaps glyph
(currently both children render □ — see below)
OS-initiated maximizes (macOS green-light zoom, Windows drag-to-top snap, Wayland xdg_toplevel.state changes) all flow through the same WindowEvent::Resized arm, so the placement signal is always consistent with the OS. Applications can subscribe to drive their own iconography from the same signal.
macOS caveat.
NSWindow.isZoomedtracks traffic-light zoom only. Native fullscreen (green light + Option, or-[NSWindow toggleFullScreen:]) puts the window on its own Space and leavesisZoomedfalse. The title bar isn't visible during fullscreen anyway, so we don't track that state.
Glyph fallback. Both Switcher children currently use
□(U+25A1, Geometric Shapes). The semantically nicer "two stacked squares" glyphs (❐U+2750 Dingbats,⧉U+29C9 Math Symbols,🗗U+1F5D7 Symbols and Pictographs) and even neighbouring Geometric Shapes glyphs like▭U+25AD all render as missing on Windows because text-typeset's font fallback chain only reliably hits□from Segoe UI's basic geometric coverage (same root cause as the close button using U+00D7 instead of U+2715). State is still distinguished by the OS window itself, the action toggling correctly viaWindowState::placement, and the reactive a11y name (Maximize/Restoreviatr_widget!). A future pass can swap to custom rect-primitive icons to restore the visual delta.
WindowFrame — edge resize for borderless windows
On Wayland and (eventually) Windows, a borderless window has no OS-drawn frame, so nothing catches clicks at the 1-pixel edge for a resize. WindowFrame solves that with an invisible overlay of resize strips along the four edges and four corners.
#![allow(unused)] fn main() { match tree.title_bar_host() { Some(host) if host.needs_custom_resize_handles() => tree.add(WindowFrame::new(host).thickness(6.0).content_id(inner)), _ => inner, // macOS, or no host } }
Gate on needs_custom_resize_handles(); on macOS NSWindow still services edge resize even with titlebarAppearsTransparent + fullSizeContentView, and installing the overlay would fight the OS.
Content fills the whole window — the strips sit on top. Hit-testing walks children in reverse insertion order so strips win clicks within thickness pixels of an edge; interior clicks fall through to the content. Default thickness 6 logical pixels, matching the common Windows 11 / GNOME convention.
Builder: .new(host) → .thickness(f32) → .content(widget) / .content_boxed(Box<dyn Widget>) / .content_id(WidgetId).
Windows backend
The Windows host extends the DWM-drawn frame into the client area with a 1-pixel top inset (the magic value that preserves Win11's rounded corners — 0 gives square corners), then installs a SetWindowSubclass proc on the HWND to intercept the non-client messages that would otherwise hand control back to the OS frame. winit's own wndproc was registered at class-registration time via raw SetWindowLongPtrW and runs first; the comctl32 subclass chain fires after and falls through to DefSubclassProc for messages we don't intercept. AccessKit's WM_GETOBJECT subclass is a separate slot and they coexist.
Messages the proc handles:
WM_NCCALCSIZE— zero non-client insets so the client area covers the full window. WhenIsZoomedis true, restore the systemSM_CXFRAME + SM_CXPADDEDBORDERinsets and clamp to the monitor work area so the maximized window doesn't cover the taskbar.WM_NCHITTEST— returnHTLEFT/HTTOP/ corner codes for the outer N pixels (so the OS handles the resize loop natively, with the right cursor and snap behavior),HTCAPTIONfor the widget's drag region, andHTMINBUTTON/HTMAXBUTTON/HTCLOSEfor the control-button rects. ReturningHTMAXBUTTONis what makes Win11 show the snap-layout flyout on hover.no_dragholes are tested before the button and drag rects and returnHTCLIENT— they carve out both the dead-zoned interactive controls the app placed inside the caption and any overlay floating over it (a revealed hamburger menu bar, a tall modal), which must win over every chrome rect beneath it.WM_NCLBUTTONDOWNover a button hit code — return 0 to preventDefSubclassProcfrom entering its built-in press-tracking modal loop, which would otherwise consume the matchingWM_NCLBUTTONUPitself (user-visible symptom: the button appears to need a double-click).WM_NCLBUTTONUPover a button hit code — post aTitleBarSyntheticEventthroughAppEventProxy::send_external_boxed. The teksilo-app dispatcher resolves the matchingWidgetIdviahost.title_bar_widget_id(target)and callsWidgetTree::synthesise_tapto run the button'son_taphandler.close_actionoverrides fire here.WM_NCMOUSEMOVE/WM_NCMOUSELEAVE— postTitleBarHoverEventfor the same reason. The host writes the matchingSignal<bool>(registered byWindowControlsviahost.register_hover_signal(...)at build time); an effect insideControlButtonmaps the bool to its visualbg_signal, so OS-driven hover renders identically to widget-tree hover.WM_DPICHANGED— re-callDwmExtendFrameIntoClientAreaso rounded corners survive a DPI change (winit handles the resize but doesn't re-extend). Falls through toDefSubclassProcfor the rest.WM_NCPAINT/WM_NCACTIVATE— return early (0andTRUErespectively) so DWM doesn't paint legacy caption-button artwork over our pixels and the frame doesn't flicker on focus changes.
Hit-region snapshot. TitleBar::after_paint publishes a single complete HitRegions per frame. Wayland and macOS backends ignore it; the Windows host converts the logical-pixel rects to physical pixels via GetDpiForWindow(hwnd) and stores under a Mutex<HitRegions> shared with the proc. The proc reads via try_lock — if it's contended (re-entry via SendMessage), it falls through to HTCLIENT rather than blocking the message pump.
#![allow(unused)] fn main() { pub struct HitRegions { pub minimize: Option<Rect>, pub maximize: Option<Rect>, pub close: Option<Rect>, pub minimize_id: Option<WidgetId>, pub maximize_id: Option<WidgetId>, pub close_id: Option<WidgetId>, pub drag: Vec<Rect>, // multiple → non-rectangular drag pub no_drag: Vec<Rect>, // holes: HTCLIENT wins over everything pub resize_borders: ResizeBorders, // per-edge widths } }
The Vec<Rect> for drag lets apps split the drag band around a centered search field or title pill without losing draggability. no_drag collects the holes after_paint carves back out of the published chrome, from two sources: every DeadZone-marked control inside the drag region (clipped to the drag rect), and every interactive overlay's intersection with the title bar strip — an overlay floats above the chrome in widget land, so the wndproc tests these holes before the button rects as well as before drag. The overlay half is what makes a hamburger MenuBar's revealed bar clickable on Windows: it is an overlay anchored outside the drag region, so no DeadZone walk can reach it, and without the hole its menu titles over the caption would return HTCAPTION and drag the window instead of opening. The *_id companions are the routing target for synthetic-tap forwarding. maximize_id specifically points to the Switcher wrapping the two glyph buttons (not to either glyph child): the inactive Switcher child is dormant and reports Rect::ZERO, but the Switcher container itself is always laid out by the parent HStack, so its bounds are stable across the floating ↔ maximized swap. WidgetTree::synthesise_tap dispatches the click at the Switcher's bounds-center, and the normal hit-test routing then delivers it to whichever child is currently visible.
File reference
Widget layer:
- crates/teksilo-widgets/src/title_bar.rs —
TitleBarbuilder + layout - crates/teksilo-widgets/src/title_bar/controls.rs —
WindowControls,ControlButton - crates/teksilo-widgets/src/title_bar/drag_region.rs —
DragRegion - crates/teksilo-widgets/src/title_bar/window_frame.rs —
WindowFrame,ResizeStrip
Core trait:
Backends:
- crates/teksilo-platform/src/title_bar_host.rs — factory
- crates/teksilo-platform/src/title_bar_host/macos.rs
- crates/teksilo-platform/src/title_bar_host/wayland.rs
- crates/teksilo-platform/src/title_bar_host/windows.rs
- crates/teksilo-platform/src/title_bar_host/x11.rs
App integration:
- crates/teksilo-app/src/app.rs —
TeksiloAppBuilder,CloseWindowRequest - crates/teksilo-app/src/window_manager.rs — host construction +
WindowEvent::Resizedhook - crates/teksilo-core/src/widget_tree.rs —
WidgetTree::title_bar_host
Demo:
Toolbar Reference
teksilo_widgets::toolbar ships Toolbar
— a command bar with automatic overflow: excess commands collapse into a
trailing chevron (⌄) that opens a drop-down menu, mirroring Qt's QToolBar
extension button, macOS NSToolbar's overflow menu, and WinUI CommandBar.
Mental model in one line:
Toolbar::new().action(…).item(ToolbarItem::custom(…).overflow_widget(…)) → it fits itself to the available width
A Toolbar fills the width it is offered and decides, every layout pass,
which commands stay inline and which collapse into the chevron menu — so it
never spills outside its container and never truncates an action label (a
truncated action reads poorly; the desktop convention is to overflow excess
commands into a menu instead).
End-to-end demo: cargo run -p over-constraint (section 1). Source:
examples/over_constraint/src/main.rs.
Quickstart
#![allow(unused)] fn main() { use teksilo::prelude::*; use teksilo::widgets::{Toolbar, ToolbarAction}; Toolbar::new() .action( ToolbarAction::new(tr!(new_doc())) .icon(|| IconWidget::doc_add(16.0)) .on_activate(|ctx| ctx.send_intent(AppIntent::NewDocument)), ) .action(ToolbarAction::new(tr!(open())).on_activate(|ctx| ctx.send_intent(AppIntent::Open))) .action(ToolbarAction::new(tr!(save())).on_activate(|ctx| ctx.send_intent(AppIntent::Save))) }
That's the 90% case: a row of ToolbarActions. When the bar is too narrow to
show them all, the lowest-priority ones collapse into the chevron menu and
reappear as it widens.
Items
A toolbar is a sequence of ToolbarItems, added with .item(...) (or the
.action(...) / .child(...) sugar). There are four kinds:
| Item | Constructor | Collapses? | Renders inline as | Renders in the menu as |
|---|---|---|---|---|
| Action | ToolbarItem::action(a) / .action(a) | yes (by priority) | a Button | a MenuItem (with the action's icon) |
| Pinned widget | ToolbarItem::custom(w) / .child(w) | no | the widget itself | — (never collapses) |
| Collapsible widget | ToolbarItem::custom(w).overflow_as(...) / .overflow_widget(...) / ToolbarItem::collapsible(w) | yes | the widget itself | its declared overflow form |
| Separator / flexible space | ToolbarItem::separator() / flexible_space() | no | a Divider / a Spacer | — |
Actions
ToolbarAction is a command with a label and an activation handler, plus
optional refinements:
#![allow(unused)] fn main() { ToolbarAction::new(tr!(bold())) .icon(|| IconWidget::bold(16.0)) // icon FACTORY — reused inline AND in the menu .tooltip(tr!(bold_tooltip())) // also the accessible name in IconOnly mode .enabled(true) .toggle(is_bold) // checkable: pressed inline, checkmark in the menu .priority(10) // higher priority collapses LAST (NSToolbar semantics) .always_overflow() // WinUI secondary command — lives in the menu, never inline .on_activate(|ctx| ctx.send_intent(Editor::ToggleBold)) }
The icon is a factory (Fn() -> IconWidget) because IconWidget isn't Clone
and the toolbar may build it twice — once for the inline Button, once for the
menu MenuItem.
Pinned widgets
ToolbarItem::custom(widget) (sugar: .child(widget)) embeds an arbitrary
widget — a search field, a SegmentedControl, a zoom SpinBox — that never
collapses. Use it for controls that must always stay reachable.
#![allow(unused)] fn main() { Toolbar::new() .child(SearchField::new(query)) // pinned: always visible .action(ToolbarAction::new(tr!(filter())).on_activate(…)) }
Collapsible widgets — overflow_as, overflow_widget, ToolbarOverflow
A custom widget becomes collapsible by declaring its overflow representation
(NSToolbar menuFormRepresentation / Qt QWidgetAction). Pick the form that
reads best in a menu:
1. .overflow_as(action) — a menu row
Best when the control's menu form is a single command. An icon-only inline control reuses its icon as the menu item's leading glyph:
#![allow(unused)] fn main() { ToolbarItem::custom(IconButton::new(IconWidget::checkmark(16.0)).tooltip(tr!(confirm()))) .overflow_as( ToolbarAction::new(tr!(confirm())) .icon(|| IconWidget::checkmark(16.0)) // shown as the MenuItem icon .on_activate(|ctx| ctx.send_intent(App::Confirm)), ) }
2. .overflow_widget(factory) — a live widget in the menu
Best for stateful inputs (a combo box, a slider) that have no meaningful single-command form. The factory rebuilds the control inside the menu, bound to the same signal as the inline instance, so it stays fully usable while collapsed — selecting in the menu copy updates the inline copy and vice-versa:
#![allow(unused)] fn main() { let view_mode = Signal::new(Some("List".to_string())); let menu_mode = view_mode.clone(); ToolbarItem::custom(ComboBox::new(["List", "Grid", "Columns"], view_mode)) .overflow_widget(move || { Box::new(ComboBox::new(["List", "Grid", "Columns"], menu_mode.clone())) }) }
When the bar is too narrow, the inline ComboBox is hidden and an equivalent,
live ComboBox appears in the chevron menu. State is shared through the cloned
Signal, so the two are never out of sync.
A factory (
Fn() -> Box<dyn Widget>) is required rather than a value because widgets aren'tCloneand the menu builds its row lazily.
3. ToolbarOverflow trait — a widget that knows its own menu form
When a reusable widget always overflows the same way, implement
ToolbarOverflow on it and add it with ToolbarItem::collapsible(w) — no
per-call overflow_as:
#![allow(unused)] fn main() { impl ToolbarOverflow for ZoomControl { fn toolbar_menu_form(&self) -> ToolbarAction { ToolbarAction::new(tr!(zoom())).on_activate(/* … */) } } Toolbar::new().item(ToolbarItem::collapsible(ZoomControl::new(zoom))) }
Separators & flexible space
ToolbarItem::separator() draws a Divider between groups.
ToolbarItem::flexible_space() inserts a Spacer that pushes the following
items to the trailing edge (NSToolbar flexibleSpace).
How overflow is computed
Every layout pass, the toolbar measures each item's intrinsic size — even
the currently-collapsed ones, via
LayoutContext::measure_intrinsic
— so a collapsed command reappears at exactly the right width as the bar grows
(no stale-width glitch). It then runs a greedy priority algorithm:
- If everything fits, nothing collapses and no chevron is shown.
- Otherwise the chevron is reserved, and the lowest-priority inline commands collapse into the menu until the rest fit (ties: the later-declared one collapses first).
always_overflowcommands start collapsed regardless of room.
Pinned widgets and separators reduce the room available to collapsible commands but never collapse themselves.
Toolbar::is_overflowing() returns a Signal<bool> that is true whenever any
command is currently collapsed — useful for adaptive UI (WinUI
IsOverflowOpen-adjacent introspection).
The overflow menu
The chevron's drop-down is a real
MenuList, not a bare list, so it:
- sizes compactly to the currently-collapsed rows (size-to-content, standard menu chrome — no fixed width/height);
- takes focus when opened (keyboard or pointer) and supports
arrow /
Home/End/Enternavigation, skipping the rows that aren't currently collapsed; - hosts both ordinary menu rows (from
overflow_asactions) and live embedded widgets (fromoverflow_widget).
It is driven by
MenuList::item_when — a
conditionally-visible menu row that collapses to zero height (no gap) and is
skipped by keyboard navigation while hidden. That is the general primitive any
app can use for a menu whose rows come and go.
Display mode & orientation
#![allow(unused)] fn main() { Toolbar::new() .display_mode(ToolbarDisplayMode::IconOnly) // IconAndText (default) / IconOnly / TextOnly .orientation(ToolbarOrientation::Vertical) // Horizontal (default) / Vertical .spacing(6.0) }
In IconOnly mode the label becomes the control's accessible name + tooltip.
Vertical toolbars collapse along the vertical axis and the roving arrow keys
become Up/Down.
Accessibility — the ARIA toolbar pattern
Toolbar implements the WAI-ARIA toolbar
pattern:
- It emits
Role::Toolbarwith its orientation and name (.label(...)overrides the default localized "Toolbar"). - It is a single Tab stop with roving tab-index: Tab enters the
toolbar (landing on the last-focused control) and leaves it; the
←/→ (or ↑/↓ when vertical) arrow
keys move focus among the visible controls, and Home/End
jump to the ends. The roving suppression reaches composite controls (a
ComboBox, anIconButton) correctly — Tab doesn't get stuck on one. Under RTL the horizontal arrows mirror (← advances, → steps back), resolved live so a locale change flips them. - All localizable strings — the chevron's "More" tooltip and the accessible
name — flow through the framework's Fluent bundle (
en-US+fr-FRshipped), so they translate and update reactively on a locale change. - The chevron announces
HasPopup::Menuand its expanded state. - Collapsed commands are dormant (absent from the accessibility tree) — they are represented by their menu rows instead, so no command is announced twice.
- Toggle actions carry
Toggled.
API surface
Pull the full, current signatures with:
python3 tools/extract_widget_api.py Toolbar
Key types (crates/teksilo-widgets/src/toolbar.rs):
Toolbar—new,item,action,child,add_child,orientation,display_mode,spacing,label,is_overflowing.ToolbarAction—new,icon,tooltip,enabled,on_activate,toggle,priority,always_overflow.ToolbarItem—action,custom,custom_id,collapsible,overflow_as,overflow_widget,separator,flexible_space.ToolbarOverflow—toolbar_menu_form(implement on a widget forToolbarItem::collapsible).ToolbarDisplayMode{IconAndText,IconOnly,TextOnly},ToolbarOrientation{Horizontal,Vertical}.
Reactive Data Models
Companion to: architecture.md, charts.md
Scope: The teksilo-data crate — ListModel, TreeModel, TreeSlice, TreeDataSlice, TreeRowFilter, SelectionModel, CheckedModel, TreeCheckedModel, KeyedTreeCheckedModel, CheckState, ListDataSource, TreeDataSource, ChartModel, ChartWindow, ChartAggregate, ChartSelection, and the change-notification enums that connect them to data-driven widgets (ListView, TreeView, Repeater) and to teksilo-charts (BarChart / LineChart / PieChart).
API reference: the full rustdoc for every type lives at /api/teksilo_data/.
1. Why teksilo-data is its own crate
Data models sit above the widget tree conceptually: a ListModel<String> has no idea whether it is rendered by a ListView, a Repeater, or pretty-printed to stdout. Keeping them in their own crate enforces that separation in the dependency graph.
teksilo-core→ widgets, layout, events — the retained-tree infrastructure.teksilo-data→ reactive collections — depends onteksilo-coreonly forSignal<T>andObserverHandle(utility plumbing).teksilo-widgets→ depends on both and consumes teksilo-data through its widgets.
Application code that wants to share a ListModel<Project> between a Teksilo view and, say, a headless validation pipeline can depend on teksilo-data without pulling in the renderer. The Qleany Clean-Architecture consumer is the main beneficiary: a domain layer holds its entity collections as ListModel<Entity>, a view-model layer observes and transforms, and the view layer binds a ListView to the result. See §6 on MVVM below.
Cloning any teksilo-data handle produces a second handle to the same underlying data. There is no deep-copy semantics and no ownership complication — the models are Rc<RefCell<…>> inside, so clones cost two pointer copies and all see the same items.
2. ListModel<T> — the common case
ListModel<T> is a concrete reactive list: a Vec<T> plus an observer list, behind an Rc<RefCell<…>>. Every mutation method (push, insert, remove, set, move_item, replace_all, clear) drops the mutable borrow before notifying observers, so a callback that reads len() or with_item(...) during the notification does not deadlock the cell.
#![allow(unused)] fn main() { use teksilo_data::{ListModel, DataChange}; let projects: ListModel<Project> = ListModel::new(); projects.push(Project::new("Widget Catalog")); projects.insert(0, Project::new("Onboarding Tutorial")); // Observe changes: let _handle = projects.observe_changes(|change| match change { DataChange::ItemsInserted { range } => println!("{} item(s) inserted at {}", range.len(), range.start), DataChange::ItemsRemoved { range } => println!("{} item(s) removed at {}", range.len(), range.start), DataChange::ItemsMoved { from, to, count } => println!("moved {count} items from {from} to {to}"), DataChange::ItemUpdated { index } => println!("item {index} updated"), DataChange::Reset => println!("list reset"), }); }
with_item(index, f) is the item-access primitive — a callback-taking accessor rather than a reference return, so the RefCell borrow is scoped to the callback lifetime and can't escape. This is the same pattern Signal<T>::with(f) uses.
Observation returns an ObserverHandle — drop the handle to unsubscribe. Widgets that observe a ListModel in their build() typically store the handle on self so it lives as long as the widget. ctx.effect(...) wraps this pattern.
3. ListDataSource — the escape hatch
ListModel<T> holds items in memory. A database-paged view, a filesystem directory listing, or a 10-GB log file doesn't fit that model. ListDataSource is a trait for implementors that own the data in some other form and emit DataChange notifications manually:
#![allow(unused)] fn main() { pub trait ListDataSource: 'static { type Item: 'static; fn len(&self) -> usize; fn with_item<R>(&self, index: usize, f: impl FnOnce(&Self::Item) -> R) -> Option<R>; fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle; } }
ListDataSource is not related to ListModel<T> by inheritance — they are two separate input paths. ListView provides both ListView::new(model, delegate) and ListView::from_source(source, delegate) constructors to consume either.
The trait is not object-safe (associated type + generic methods), which is deliberate: widgets consume it generically. Implementors are free to keep internal locks, LRU caches, or network state across calls; with_item passing the item by callback rather than reference means the implementor controls the borrow lifetime.
Anything that can be presented as "an indexed sequence with change notifications" fits here. Database cursor: implement len() as the cached page count, with_item(i, f) as "fetch if not cached, else look up," observe_changes as "forward the DB replication stream after translating to DataChange." Directory listing: len() is readdir() result length, observe_changes is a file-watcher backed stream.
4. TreeModel<T> + TreeSlice<T> — hierarchies with independent views
The tree side has grown a small family. They layer as source → projection → view: a source holds the tree, a projection turns it into the flat, expandable, per-view row list a TreeView reads (every projection implements TreeDataSource), and TreeRowFilter is a pre-transform that reshapes the rows before a TreeDataSlice. Pick by where the tree lives and what you need on top:
| Type | Layer | Data from | Key | Reach for it when |
|---|---|---|---|---|
TreeModel<T> (§4.1) | source | you build it in memory | NodeId | the tree lives in memory and you own it — the in-memory container |
TreeSlice<T> (§4.2) | projection | wraps a TreeModel | NodeId | you need per-view expand + flatten over a TreeModel (the built-in TreeView source) |
SortFilterTreeModel<T> (§13) | projection | wraps a TreeModel | NodeId | …and sort / tree-aware filter too; it owns its own expand state |
TreeDataSlice<K, T> (§4.4) | projection | Vec<TreeRow> you supply (indent-ordered) | your domain K | the tree lives in an external store (Qleany / DB) as an outline — no TreeModel mirror |
TreeRowFilter<K, T> (§4.5) | pre-transform | Vec<TreeRow> → Vec<TreeRow> | — | sort / filter the rows feeding a TreeDataSlice (wire into set_source; pair with set_all_expanded to reveal matches) |
Two orthogonal companions ride alongside a tree view rather than being sources themselves — pick the NodeId or domain-keyed variant to match your projection: selection — SelectionModel / KeyedSelectionModel<K> (§5); checkboxes — TreeCheckedModel<T> / KeyedTreeCheckedModel<K> (§6, §6.1). Rule of thumb: everything on a TreeModel is NodeId-keyed; everything on a TreeDataSlice/external source is domain-K-keyed.
4.1 TreeModel<T>
The tree equivalent of ListModel<T>. Nodes are stored in a SlotMap<DefaultKey, TreeNode<T>>; each node carries its data, a parent reference, and a Vec<NodeId> of children. NodeId is an opaque handle that is stable across mutations — inserting or removing other nodes does not invalidate existing handles. This is what lets a view (see TreeSlice below) remember an expanded-set of node IDs across a model mutation.
#![allow(unused)] fn main() { use teksilo_data::{TreeModel, NodeId}; let fs: TreeModel<FsEntry> = TreeModel::new(); let docs: NodeId = fs.insert_root(0, FsEntry::dir("docs")); let readme: NodeId = fs.insert_child(docs, 0, FsEntry::file("README.md")); let inner: NodeId = fs.insert_child(docs, 1, FsEntry::dir("inner")); }
Mutations emit TreeChange::{NodeInserted, NodeRemoved, NodeMoved, NodeUpdated, Reset}. NodeRemoved removes the entire subtree; observers see a single event but the subtree is gone.
4.2 TreeSlice<T> — per-view flattening
A TreeModel<T> describes a hierarchy but does not decide what's expanded, what's visible, or how it lays out. That's the TreeView's (or any other consumer's) choice. TreeSlice<T> is the bridge:
- Owns an
expanded: HashSet<NodeId>— the set of nodes whose children are shown. - Maintains a flat
Vec<FlatEntry>of the currently-visible nodes with depth information. - Re-flattens on every
TreeChangefrom the underlying model. - Publishes a
version: Signal<u64>that bumps on each re-flatten — consumers bind to this signal to know when to repaint.
#![allow(unused)] fn main() { pub struct FlatEntry { pub node_id: NodeId, pub depth: usize, // 0 for roots pub has_children: bool, // whether the node has any children in the model pub is_expanded: bool, // whether this slice shows them } }
Two TreeView widgets bound to the same TreeModel each hold their own TreeSlice, so each has its own independent expand state. Opening a folder in one does not open it in the other. This matters for dual-pane file managers, for hierarchical search results shown alongside a full tree, and for any "overview" pane.
Consumers access entries via slice.with_entry(index, |data, entry| …) and get a reactive rebuild signal via slice.version_signal().
4.3 TreeSliceHandle — the consumer API
TreeView doesn't re-implement expand/collapse; it holds a TreeSliceHandle and calls toggle_expand(node_id), expand(node_id), collapse(node_id). The handle is Clone — widgets can share access to the same slice without ambient state. (expand_all() and collapse_all() are available on the owning TreeSlice itself.)
4.4 TreeDataSlice<K, T> — the same, over an external tree
TreeSlice needs a TreeModel to wrap. When the tree's source of truth lives outside teksilo — a Qleany entity store, a database, a virtual filesystem — and you don't want to mirror it into a TreeModel, TreeDataSlice<K, T> gives the same machinery over your own data. It is the tree counterpart of the ListDataSource escape hatch (§3), but ready-made rather than a bare trait — it implements TreeDataSource for you.
You hand it the tree as a flat, indent-ordered row stream — the shape an outline is actually stored in (binders / chapters / scenes, OPML, Markdown headings) — and it derives the hierarchy:
#![allow(unused)] fn main() { use teksilo_data::{TreeDataSlice, TreeRow}; let slice: TreeDataSlice<EntityId, Row> = TreeDataSlice::new(); slice.set_expand_new_nodes(true); // new nodes appear expanded slice.set_source(move || load_rows()); // your `rows::load` -> Vec<TreeRow> slice.reload(); // let view = TreeView::from_source(slice.clone(), delegate); }
Each TreeRow<K, T> is { key, item, depth } in document order. The engine derives every row's parent (the nearest preceding row of strictly smaller depth), its children, the roots (depth-0 rows), and its structural depth — then owns, exactly like TreeSlice:
- a per-view expand set keyed by
K— so expand state (and keyed selection) survive a full re-source, which aTreeModelmirror can't guarantee becauseNodeIds are reassigned on rebuild; - the collapse-aware flatten into visible rows;
- the
version_signal()andfirst_changed_index()side-channel (§13); - the DnD cycle guard.
Identity is your domain key K (an i64 entity id, a tagged enum) — not a positional NodeId. Domain policy is injected as closures, so the mechanism stays in the slice and the meaning stays in your code:
| Setter | Purpose |
|---|---|
set_source(|| …) | re-materialise the rows (called by reload() and after a drop) |
set_reorder(|dragged, target, pos| …) | apply a move through the backend (with undo); returns whether it took |
set_drag_policy(|key| …) | which rows may be dragged |
set_drop_resolver(|dragged, target, target_item, pos| …) | domain drop rules; receives the hovered target's item, so it decides without capturing the slice (no Rc cycle). The cycle guard runs first. |
T: PartialEq is required: the divergence compares item content, so a re-source that changed only a row's text still narrows the height cache to that row.
When to use which: TreeSlice if the data lives in a TreeModel; TreeDataSlice if it lives in an external store as an indent-ordered outline. Implement TreeDataSource by hand only for a source that isn't a resolved indent sequence — a huge/lazy tree that pages children on demand (§14).
4.5 TreeRowFilter<K, T> — sort + filter for the TreeDataSlice pipeline
SortFilterTreeModel (§below, the TreeModel-backed sort/filter projection) owns its own expand state, so stacking it on a TreeDataSlice — which already has one — would give you two projections and two expand states. For an external tree, sort/filter belongs below the slice, on its raw indent-ordered input:
rows::load() → TreeRowFilter::apply → TreeDataSlice::set_source → TreeView
\___ Vec<TreeRow> → Vec<TreeRow> ___/ \___ the one projection ___/
TreeRowFilter is a pure Vec<TreeRow<K, T>> → Vec<TreeRow<K, T>> transform you build once and apply to each freshly-sourced stream (usually inside the set_source closure; re-apply to cached rows on a filter change to avoid re-querying the backend):
#![allow(unused)] fn main() { let sieve = TreeRowFilter::new() .filter_mode(TreeFilterMode::KeepAncestors) .filter(move |item: &Row| item.title.contains(&query)) // outline search .sort(|a: &Row, b: &Row| a.title.cmp(&b.title)); slice.set_source(move || sieve.apply(rows::load())); slice.reload(); slice.set_all_expanded(true); // reveal the whole filtered result (see below) }
TreeRowFilter reshapes the rows but not the slice's per-view expand state, so KeepAncestors keeps the ancestor rows without expanding them — the matches would sit hidden under collapsed ancestors. While a filter is active, call slice.set_all_expanded(true) to reveal the narrowed result, and set_all_expanded(false) when it clears; the user's persistent collapse state is preserved underneath (it's a display override, not a mutation of the expand set).
It reuses the three TreeFilterMode strategies and sorts siblings per parent, then re-emits a valid indent-ordered stream (survivors' depths compact onto their nearest surviving ancestor, which TreeDataSlice re-derives). Two mode details worth knowing: HideNonMatching keeps a node only if it and every ancestor match (children of a hidden parent stay hidden), and KeepDescendants surfaces a matching subtree even when the match's own ancestors don't match — deliberately unlike SortFilterTreeModel's flatten, which drops such a match. KeepAncestors (show the path to each match) is the usual outline-search mode.
5. SelectionModel — one rule set, two widgets
Both ListView and TreeView share selection semantics, so selection lives in its own type in teksilo-data:
#![allow(unused)] fn main() { pub enum SelectionMode { None, Single, Multi } pub struct SelectionModel { mode: SelectionMode, selection: Signal<BTreeSet<usize>>, // indices into the flat view anchor: Rc<Cell<Option<usize>>>, // for Shift+click range extend } }
The selection exposes a Signal<BTreeSet<usize>> (via selection_signal()), so any widget can bind to it and repaint on selection change without manual subscription. Methods:
select(index)— replace selection with a single item; set anchor.toggle(index)— Ctrl+click: add or remove. InSinglemode, degrades toselect.extend_to(index)— Shift+click: select the range from anchor to index, keeping anchor.select_all(range)/clear()— bulk ops.
In None mode every operation is a no-op; widgets can construct a disabled selection model when selection doesn't apply (a toolbar's action list, for instance). select_all is also a no-op in Single mode — every other mutator (select, toggle, extend_to, select_indices) already collapses to one index there, so "select all" has no coherent reading for a model that holds at most one item, and selecting one arbitrary row would be more surprising than doing nothing. ListView's Ctrl+A handler and TableView's select_all helper already gated on Multi before this was enforced in the model itself; GridView's Ctrl+A handler did not, so a single-selection GridView used to select every tile on Ctrl+A.
The selection is stored as flat indices into the view. For a TreeView, those are indices into the TreeSlice's flat list — which means expanding or collapsing a parent changes which NodeIds those indices correspond to. Widgets translate at interaction time (e.g., on Ctrl+click): take the clicked FlatEntry.node_id, find its current flat index via the slice, then call selection.toggle(index). Alternative designs where selection stores NodeIds directly have their own trade-offs (expansion doesn't lose selection, but the signal type changes per-widget); keeping selection index-based keeps the type uniform.
5.1 RowAnchor — surviving the shift
Everything above is about where the cursor is. There is a second, quieter consequence of storing positions: a row's event handlers are built once and then live as long as the row widget does. If they capture the flat index they were built at, expanding a branch above them, applying a filter, or sorting shifts every index below — and a stale handler acts on whatever row moved into that slot. A chevron toggles the wrong branch; a click selects a neighbour; an open cell editor slides onto a different row.
RowAnchor closes over the row's source-owned identity instead and resolves
the row's current position on demand — the captured slot when it still holds
that key, else a lookup by key, else None when the row is gone. The key never
surfaces in the anchor's type: it is captured inside the resolver, so
TreeSource / ListSource keep erasing it and the four views stay
key-agnostic. That erasure is load-bearing — it is what lets TreeTableView
accept any TreeDataSource at all, and a Key type parameter would have had
to unwind it.
All four data views anchor their per-row handlers this way, and the two table
views additionally re-resolve editing_cell on each rebuild, so an open editor
follows its row and closes if that row disappears rather than editing its
replacement.
What a source can offer decides how much this buys:
| Source | Identity | Anchors |
|---|---|---|
TreeSlice, TreeDataSlice, SortFilterTreeModel | real (NodeId / domain key) | track fully |
SortFilterListModel | the row's source index | tracks across any sort/filter reprojection |
ListModel, accessor-backed sources | none — a Vec row is its position | fixed (no worse than a captured index) |
Two limits worth stating plainly. Keys must be unique: resolution falls back
to a lookup returning the first match, so duplicate keys would redirect an
anchor onto another row — the very failure the type exists to prevent. And a
SortFilterListModel's source index is renumbered by an upstream
insert/remove/move, so an anchor can mis-resolve inside the window between that
mutation and the rebuild it schedules; sort/filter-only rebuilds never renumber,
which is the case that actually bites in a UI.
6. CheckedModel and TreeCheckedModel — per-row checkbox state
Selection (where the cursor is) and checkedness (which rows are marked) are orthogonal axes — Outlook / Files-app convention. So checkbox state lives in its own type pair, parallel to SelectionModel:
#![allow(unused)] fn main() { // Flat-list checkbox state. pub struct CheckedModel { checked: Signal<BTreeSet<usize>>, per_index: Rc<RefCell<HashMap<usize, Signal<bool>>>>, } impl CheckedModel { pub fn new() -> Self; pub fn signal_for(&self, index: usize) -> Signal<bool>; // shared per index pub fn checked_indices(&self) -> Vec<usize>; pub fn check(&self, index: usize); pub fn uncheck(&self, index: usize); pub fn toggle(&self, index: usize); pub fn check_all(&self, count: usize); pub fn clear(&self); } // Tree checkbox state with optional descendant→ancestor aggregation. pub enum AggregateMode { None, DescendantsDriveAncestors } pub struct TreeCheckedModel<T: 'static> { /* per-NodeId Signal<CheckState> */ } impl<T: 'static> TreeCheckedModel<T> { pub fn new(tree: TreeModel<T>) -> Self; pub fn with_mode(tree: TreeModel<T>, mode: AggregateMode) -> Self; pub fn signal_for(&self, node: NodeId) -> Signal<CheckState>; // tristate per node pub fn check(&self, node: NodeId); pub fn uncheck(&self, node: NodeId); pub fn toggle(&self, node: NodeId); pub fn checked_nodes(&self) -> Vec<NodeId>; // Checked only — Indeterminate excluded pub fn aggregate_mode(&self) -> AggregateMode; pub fn set_aggregate_mode(&self, mode: AggregateMode); } }
signal_for(...) is cached per key: repeat calls with the same index/NodeId return signals sharing the same root, so widgets bound to it re-render whenever the model is mutated through any other accessor.
TreeCheckedModel defaults to DescendantsDriveAncestors: a parent's CheckState is Checked when all descendants are, Unchecked when none are, Indeterminate otherwise. Setting a parent cascades to all descendants. The None mode disables aggregation when nodes own their state independently.
The CheckState enum (Unchecked | Checked | Indeterminate) lives in teksilo-data (re-exported from teksilo::widgets::CheckState for convenience). The Checkbox widget consumes it via Checkbox::tristate(Signal<CheckState>); StandardListItem.tristate_checkbox(...) and StandardTreeItem.tristate_checkbox(...) accept the same signal.
Wiring with the new row widgets:
#![allow(unused)] fn main() { let checks: CheckedModel = state.app_state(); ListView::new(model, move |idx, item, _sel| { Box::new( StandardListItem::new(lit!(&item.name)) .checkbox(checks.signal_for(idx)) ) }) let tree_checks: TreeCheckedModel<Item> = state.app_state(); TreeView::new_with_context(tree, move |item, entry, _sel, ctx| { Box::new( StandardTreeItem::new(lit!(&item.title)) .from_entry(entry) .tristate_checkbox(tree_checks.signal_for(entry.node_id)) .on_toggle_rc(ctx.toggle_callback()) ) }) }
Path A (ad-hoc Signal<bool> stored on each row's view-model item) remains valid — it's the right answer for fixed dialog lists and small settings panels. Reach for CheckedModel / TreeCheckedModel once item types are domain models you don't want to retrofit a signal field onto.
6.1 KeyedTreeCheckedModel<K> — the same, for an external tree
TreeCheckedModel is bound to a TreeModel and keyed by NodeId. KeyedTreeCheckedModel<K> is its domain-keyed twin — the checkbox counterpart of KeyedSelectionModel (§5) — for the "select scenes to export" tristate over a TreeDataSlice / any TreeDataSource. It takes the tree shape as two injected closures (children + parent), so from_source(slice.clone()) wires it to a slice with no TreeModel to mirror:
#![allow(unused)] fn main() { let checked = KeyedTreeCheckedModel::from_source(outline_slice.clone()); // bind each row's checkbox to `checked.signal_for(key)` / `.bool_signal_for(key)`; // read the result with `checked.checked_keys()`. }
Because state is keyed by your stable domain id, a checked node survives a full re-source. After a reload call prune_missing(|k| source.contains_key(k)) (drops deleted nodes' state and recomputes the ancestors they affected) or reaggregate() (recompute every parent from the new shape) so the tristates stay correct across structural changes. Same cascade / Signal<CheckState> ↔ Signal<bool> bridge / AggregateMode as TreeCheckedModel.
Both methods recompute more than just the keys that already carry a state entry, because a node only gains one once it is explicitly checked/toggled — an untouched leaf never does. prune_missing always calls reaggregate() after removing the stale keys, even when none of the pruned nodes had a state entry of their own: skipping that step whenever stale came back empty would leave a surviving ancestor's cached tristate computed against the child set from before the prune. reaggregate() itself widens its recompute set to every ancestor reachable by walking parent up from each tracked key, not just the tracked keys themselves — otherwise a node that was an untouched leaf before a re-source, and is now a meaningful intermediate branch under the new shape, would read back as the Unchecked default instead of being derived from its new children. Both changes only ever widen what gets recomputed; a key that was already correct cannot come out wrong.
7. MVVM flow
Typical data flow for a list-backed view:
Domain ViewModel View
────── ───────── ────
Entities ─► ListModel<ProjectVM> ─► ListView ─► paint
▲ │ │
│ │ delegate │ on_tap / on_drop
│ │ closure │
│ ▼ ▼
│ Widget subtree intent (enum variant)
│ per item │
│ ▼
└── apply command ◄───── Action::on_invoke
- The domain owns entities (rows in a DB, nodes in a file tree, whatever the app is actually about).
- The view-model layer maps entities to display types (
ProjectVM { title, subtitle, icon, status_color }) and holds them in aListModel<ProjectVM>. The view-model observes the domain; when a domain entity changes, it updates the model, which emitsDataChange. - The view is a
ListViewbound to theListModel<ProjectVM>via a delegate closure. Item interaction fires typed intents (see shortcut-intent-action.md); an ancestorAction::on_invoketranslates the intent into a domain command.
The view never mutates the model directly. The intent/action split means "what the user did" and "what the app does about it" are separable layers — testable independently, replaceable independently, reconfigurable via Action::enabled_when.
8. Repeater vs ListView — when to use which
The widget catalog provides two data-driven collection consumers; pick by the size and scroll behavior of the collection.
Repeater — dynamic, non-virtualized
A Repeater takes a ListModel<T> and a delegate, creates one child subtree per item, and lives inside whatever container the widget tree nests it in. On DataChange::ItemsInserted { range } the delegate fires for each new item; on ItemsRemoved the corresponding subtrees are destroyed; on ItemsMoved children are reordered without recreation. On ItemUpdated { index } the current implementation destroys and recreates that item's subtree; a future optimization path re-uses the existing subtree by pushing updates through reactive bindings on the delegate's Signals (no structural mutation).
Use Repeater for bounded collections where every item should produce a widget:
- Toolbar button lists.
- Tab headers.
- Form fields generated from a schema.
- Chapter lists in a side panel (author has tens of chapters, not thousands).
Repeater does not scroll or clip — it produces siblings. Wrap it in a ScrollArea to get scrolling; every item is still laid out whether visible or not.
ListView — virtualized, scrollable
ListView creates widget subtrees only for the items currently in the viewport plus a small buffer. A ListModel<Row> with 100,000 rows consumes ~20 widgets' worth of tree memory when rendered through a ListView fitting 15 rows on screen. On scroll, newly-visible items get subtrees built and departing items are destroyed.
Use ListView for unbounded or large collections that scroll:
- Database-driven row lists.
- File managers.
- Chat histories.
- Log viewers.
ListView accepts both ListModel<T> and ListDataSource via separate constructors. Selection is driven by a shared SelectionModel. Drag-and-drop (intra-widget reorder) produces insertion-line feedback and emits typed reorder commands; see §8.
9. Drag-and-drop integration
ListView and TreeView are drag sources and drop targets out of the box. Intra-widget reorder routes through ListModel::move_item(from, to) / TreeModel::move_node(node, new_parent, new_index); the widget produces visual feedback (insertion lines, depth-tinted highlight on tree drop targets) and emits typed reorder intents. Cross-widget drag flows through DragPayload; external (OS) drops (files / text / URLs from another app) arrive as a DragPayload with origin() == External through the same handlers — see architecture.md §14 Drag and Drop and drag-and-drop.md §11 for the full picture.
The relevant teksilo-data hook is the DataChange::ItemsMoved { from, to, count } / TreeChange::NodeMoved { node, old_parent, new_parent, new_index } notifications. The source widget emits the mutation on the model; every observer of the model — including other ListViews sharing the data — receives the notification and updates consistently.
10. Qleany and adjacent-app integration
For applications that already have a Clean Architecture split, teksilo-data sits naturally at the ViewModel layer:
- Qleany entities live in the domain crate, no teksilo dependency.
- The view-model crate depends on teksilo-data to publish entity collections as
ListModel<EntityVM>. - The view crate (widgets + windows) depends on teksilo-widgets and binds
ListView/TreeViewto those models.
The architecture doc's EntityListModel example shows the shape: a wrapper that observes a Qleany store, maps its entities through a presentation transform on change, and holds the result in a ListModel<EntityVM>. The widget side is unaware of Qleany.
Nothing in teksilo-data requires Qleany. An application that uses diesel + raw structs, or one that streams events off a Kafka topic, follows the same pattern with whatever domain-layer types it prefers.
11. Testing patterns
teksilo-data is headless. Tests hold models, mutate them, and assert observer callbacks received the right DataChange / TreeChange:
#![allow(unused)] fn main() { let model = ListModel::<String>::new(); let log = Rc::new(RefCell::new(Vec::<DataChange>::new())); let log_c = log.clone(); let _handle = model.observe_changes(move |change| log_c.borrow_mut().push(change.clone())); model.push("alice".to_string()); model.push("bob".to_string()); model.insert(0, "zoe".to_string()); model.remove(1); let events = log.borrow().clone(); assert_eq!(events, vec![ DataChange::ItemsInserted { range: 0..1 }, DataChange::ItemsInserted { range: 1..2 }, DataChange::ItemsInserted { range: 0..1 }, DataChange::ItemsRemoved { range: 1..2 }, ]); }
Widget-tree tests that want a representative model use ListModel::from_vec(vec![...]) and pass the model clone to the ListView under test. Selection tests construct a SelectionModel::new(SelectionMode::Multi) and drive it with select / toggle / extend_to calls, asserting on selection_signal().get().
12. Design rules in one list
- Models are
Rc<RefCell<…>>internally andClone-friendly. Share by cloning; there's no ownership transfer cost. - Every mutation notifies observers after dropping the mutable borrow, so observer callbacks can freely read the model.
- Access items through callbacks (
with_item,with_entry) rather than returning references. TheRefCellborrow stays internal. NodeIdis stable across mutations; index-based addressing is not (indices change when items insert or move). Store IDs, not indices, in long-lived state.- Selection is a separate concern (
SelectionModel) — not part ofListModel/TreeModel. ListModel<T>in memory,ListDataSourcefor external; pick one per view — they are not composable.Repeaterfor bounded non-scrollable collections,ListViewfor scrollable or large ones.- Two
TreeViews sharing aTreeModelget independentTreeSlices; expand state is per-view. - Mutations flow one-way: widget emits typed intent →
Actiontranslates → model mutates → change notification → widgets repaint. The widget never writes directly to the model. ChartModel<T>(§15) follows the sameRc<RefCell<…>>/ mutate-then-notify discipline as every other model here, keyed bySeriesId— a slotmap key that survives series reorder, exactly likeNodeIdsurvives tree mutation.
13. Divergence reporting — first_changed_index()
The four projection layers — TreeSlice, TreeDataSlice,
SortFilterTreeModel, and SortFilterListModel — rebuild their visible
list wholesale on every change, and (for the sort/filter proxies) notify
observers with a blanket Reset. That is the safe notification contract, but it
destroys information a consumer may need: which prefix of the visible
list is actually unchanged. The canonical consumer is variable-row-height
virtualization (ListView / TreeView / TableView / TreeTableView
keep per-visible-row measured heights), but anything caching per-row
derived state can use it.
Each projection therefore computes, during the rebuild it already performs, the first visible index whose content may differ and exposes it as a side-channel:
#![allow(unused)] fn main() { proxy.first_changed_index() // -> Option<usize> }
Some(d)— rows0..dshow the same items, in the same order, at the same depth/expand state as before the rebuild.d == len()means nothing visible changed.None— unknown (no rebuild observed yet); treat as a full change.
Semantics per type:
TreeSlice/SortFilterTreeModel— prefix-compare of the old and new flat lists (NodeIds are stable, so equality means identity). An expand/collapse at flat index k reports k (the toggled row's own entry changed); aNodeUpdatedwith unchanged structure reports that node's flat index.TreeDataSlice— prefix-compare of the old and new visible lists, comparing key, depth, has-children, expand state, and item content (hence theT: PartialEqbound). UnlikeTreeSliceit has noNodeUpdatedevent — a whole re-source (set_rows) replaces the row stream — so folding the item comparison into the prefix check is what catches a pure rename (structure unchanged, one row's text differs) and reports that row's flat index; an expand/collapse reports the toggled row, an append reports the old length. A stable domain keyKis required for equality to mean identity.SortFilterListModel— prefix-compare of the projected source-index map, with an identity floor: upstream inserts/removes/ moves renumber source indices, so equal index values are only trusted below the change point. AnItemUpdatedthat didn't move under the sort reports that row's visible position; an append reports the old length.
The value describes the latest rebuild only and is overwritten by
the next one. Read it synchronously from a change observer
(observe_changes callbacks and version_signal() observers fire
inline on every rebuild, so per-change reads cannot miss a value). The
external DataChange::Reset contract of the proxies is unchanged.
ListDataSource carries a defaulted first_changed_index() (returning
None) so generic consumers reach the side-channel without downcasts.
14. Projecting an external source of truth
§7 and §10 assume the view-model owns its model (the QStandardItemModel
shape). When the domain owns the data and the teksilo model is a projection
over it (a Qleany entity store, a DB, an event stream — the
QAbstractItemModel shape), implement a data source over the domain instead
of mirroring it into a built-in model.
Implement [ListDataSource] / [TreeDataSource] directly over the domain and
feed the view with ListView::from_source / TreeView::from_source. The view
reads through with_item/with_entry and commands through the DnD + lazy
capability methods (drag / can_accept / accept_drop / request_window /
fetch_more), so there is no second in-memory copy to keep in sync — the
domain stays the single source of truth, keyed by its own id (i64, a UUID,
…). Because identity is the domain key (not a positional NodeId), keyed
selection, tree expand state, and scroll follow a domain refresh automatically;
version_signal() + first_changed_index() (§13) narrow row-height caches
across the refresh. This is the path the designer's outline uses, and the one
to reach for whenever the data is a tree, lives behind a query, or doesn't fit
in memory. Full protocol reference: data-source.md.
Trees: reach for TreeDataSlice before hand-rolling TreeDataSource. For
the common case — a tree stored as an indent-ordered row sequence (an outline:
binders/chapters/scenes, OPML, headings) — you rarely implement TreeDataSource
by hand. TreeDataSlice<K, T> (§4.4) is the ready-made engine: hand it
Vec<TreeRow{ key, item, depth }> on
each (re)load via set_source, and it derives the tree from the indent depth and
owns the per-view expand set, the collapse-aware flatten, version_signal(),
first_changed_index() (§13), and the DnD cycle guard — exactly what TreeSlice
gives a TreeModel, but keyed by your domain id and with no TreeModel to
mirror. Inject only the domain policy (set_reorder, set_drag_policy,
set_drop_resolver). Implement TreeDataSource directly only when the source
isn't a resolved indent sequence — a huge/lazy tree that pages children on
demand, where the eager flatten doesn't fit. Add view-layer sort/filter with a
TreeRowFilter on the row stream (§4.5), and tree checkboxes with a
KeyedTreeCheckedModel beside it (§6.1) — both compose without a second
projection.
A bounded, fully-resident, flat list is the one case where a ListModel
projection can still be simpler than a source impl. There is no reconcile
helper for it — keep the projection in sync by emitting the minimal insert /
remove / move_item / set mutations yourself (a Repeater then reorders
subtrees instead of recreating them); accept a Reset only when per-item state
loss is acceptable.
Tables. A TableView's rows are a ListDataSource (or a ListModel for
the bounded-projection case); a TreeTableView's rows are a TreeDataSource. A
cell edit is an in-place value update the source emits. Columns are
configuration, not data.
15. ChartModel, projections, and selection
teksilo-charts (BarChart / LineChart / PieChart) is the other
consumer of teksilo-data besides the widget-catalog row views — it
gets its own model family rather than reusing ListModel<T> because
chart data is two-level (series, then points within a series) and
carries chart-specific concerns (a color per series, a visibility
flag, a distinct paint-only vs. relayout change class) that a flat
list model has no vocabulary for. Full widget-side usage and the
reactivity/binding-level mapping live in
charts.md §3 and
charts.md §8; this section
covers the data-layer mechanism.
15.1 ChartModel<T> — the source
ChartModel<T> is a
concrete reactive multi-series chart data model, Rc<RefCell<…>>
inside like every other model here — cloning shares the same series
and points and all clones see the same ChartChange notifications.
Series live in a flat SlotMap arena keyed by
SeriesId (an opaque,
stable handle — the chart counterpart of NodeId: removing other
series never invalidates an existing SeriesId), plus a separate
order: Vec<SeriesId> giving display order independent of arena
layout. Each series holds a Vec<ChartDatum<T>> ({ category: T, value: f32 }).
#![allow(unused)] fn main() { use teksilo_data::{ChartModel, ChartSeries, ChartDatum}; let model = ChartModel::from_series_vec(vec![ ChartSeries::new("Revenue").data(vec![ ChartDatum::new("Q1".to_string(), 10.0), ChartDatum::new("Q2".to_string(), 20.0), ]), ]); let revenue = model.series_id_at(0).unwrap(); model.push_point(revenue, "Q3".to_string(), 30.0); }
Every mutation method follows the mutate-then-notify discipline
(drop the RefCell borrow, then notify) and does two things:
- Emits a
ChartChangedescribing exactly what changed —SeriesInserted/SeriesRemoved/SeriesMoved/SeriesRenamed/SeriesColorChanged/SeriesPatternChanged/SeriesVisibilityChanged/PointsInserted/PointsRemoved/PointUpdated/SeriesDataReplaced/Reset— to every observer registered viamodel.observe_changes(|change| …) -> ObserverHandle(RAII, same as every other model's observer handle). - Bumps exactly one of two
Signal<u64>version counters:structure_version()for everything that can move the y-domain, tick positions, or bar/point layout (series add/remove/move/rename, and visibility toggles, plus every point mutation), orstyle_version()for the two variants that are paint-only —SeriesColorChanged(fromset_series_color/clear_series_color) andSeriesPatternChanged(fromset_series_pattern/clear_series_pattern). This binary split is deliberately coarse: a consumer that only cares "did anything change" can bind either signal atRebuild; a chart widget that wants to skip a relayout for a pure color change bindsstructure_versionatRelayoutandstyle_versionatRepaintOnlyseparately (see charts.md §8 for the exact wiring).
Construction: ChartModel::new() (empty) + add_series /
insert_series, ChartModel::from_series_vec(vec![ChartSeries...])
(the common multi-series case, no per-item notification — mirrors
ListModel::from_vec), or ChartModel::from_points(vec![ChartDatum...])
(a single anonymous, visible series — the flat, one-dimensional path
PieChart uses).
Each series also carries a
pattern: Option<SeriesPattern>
— the non-colour channel that identifies it (a dash on a line, a
marker shape on a point, a hatch on a filled region). None takes the
pattern the series' position implies, so the channel exists with no
application code; set one when a series' identity must survive a
reorder. This is what keeps a multi-series chart readable in greyscale,
under forced colours, or by a reader who does not see the palette — see
charts.md §5.1 for the rendering table and the policy that
decides when a chart draws it.
ChartSeries<T> (the construction DTO) carries
visible: bool — a plain bool, not a Signal<bool>: it only
describes a series' desired shape at construction time. Once a series
is in the model, mutate it through the model's own methods
(set_series_visible, set_series_color, set_series_pattern,
rename_series, move_series, push_point / insert_point / remove_point /
update_point / replace_series_data), not by reaching back into the
DTO. Read access is callback-scoped like every other model here —
with_series, with_point, with_series_view (one series, metadata
- points slice),
with_all_series(every series as an ordered slice of views) — so theRefCellborrow never escapes.
15.2 ChartWindow<T> — last-N-points streaming projection
ChartWindow<T> wraps a
ChartModel<T> and exposes only the tail window_size points of
every series — the live-scrolling-strip-chart pattern (a sensor feed,
a log-rate graph, a stock ticker). Unlike ChartAggregate below, it
copies no point data: it tracks, per series, the source index of
the window's first visible point and delegates every read straight
through to the source, so it needs no T: Clone bound at all.
#![allow(unused)] fn main() { let window = ChartWindow::new(model.clone(), 10); assert_eq!(window.point_count(revenue), 10.min(model.point_count(revenue))); }
The upstream ChartChange stream is translated, not collapsed to a
blanket Reset — a fixed-size tail window has no sort-key-move hazard
the way SortFilterListModel does, so fine-grained translation is
safe: a tail append into a full window becomes a PointsRemoved +
PointsInserted pair (the window slides), a tail append into a
still-growing window becomes a plain PointsInserted, and anything
that isn't a clean tail append (a mid-series insert, any removal)
falls back to a per-series rebuild reported as SeriesDataReplaced.
set_window_size(n) rebuilds every series and emits Reset.
first_changed_index(series) reports the first window-local index
that may differ since the latest translated change — per-series,
unlike the single flat value the list/tree proxies expose (§13),
because chart data is naturally two-level.
15.3 ChartAggregate<T> — bucket/rollup projection
ChartAggregate<T>
wraps a ChartModel<T> and reduces each series into fixed-size
buckets of bucket_size source points, each collapsed to one
ChartDatum via a ChartAggregateFn
— Mean / Sum / Min / Max / First / Last / Custom(Rc<dyn Fn(&[f32]) -> f32>). The "downsample a long series for display"
pattern — a year of daily sensor readings shown as weekly means, a
tick feed shown as 1-minute bars. Bucket b covers source indices
[b*bucket_size, min((b+1)*bucket_size, n)); a trailing partial
bucket is included; a bucket's category is its first member's.
#![allow(unused)] fn main() { let weekly = ChartAggregate::new(model.clone(), 7, ChartAggregateFn::Mean); }
Unlike ChartWindow, ChartAggregate materializes its buckets —
a bucket's category is a clone of a source point's category, so
building or rebuilding one requires T: Clone (read-only queries
afterward need only T: 'static). Reactivity: a tail append that
doesn't change the bucket count updates the not-yet-full last bucket
in place (PointUpdated); a tail append that starts a new bucket
finalizes the previous last bucket (PointUpdated) and appends the
new one(s) (PointsInserted); a mid-series insert or any removal
falls back to a full per-series rebuild (SeriesDataReplaced).
set_bucket_size(n) / set_aggregate_fn(f) rebuild and emit Reset.
Same per-series first_changed_index() side-channel as ChartWindow.
15.4 ChartSelection — point-level selection
ChartSelection is
the chart counterpart of SelectionModel (§5) / KeyedSelectionModel
— it manages which (SeriesId, usize) pairs are selected across a
ChartModel, share-by-clone like the model itself, with the current
selection exposed as a reactive Signal<HashSet<(SeriesId, usize)>>
via selection_signal(). It uses a HashSet, not the BTreeSet flat
SelectionModel uses, because SeriesId is intentionally not Ord
(an opaque SlotMap key, mirroring NodeId) — there is no natural
ordering across series, only within one series' point indices.
#![allow(unused)] fn main() { let sel = ChartSelection::new(SelectionMode::Multi); sel.select_point(revenue, 1); sel.extend_to(revenue, 3); // (revenue,1), (revenue,2), (revenue,3) sel.toggle_point(revenue, 5); // Ctrl+click }
Same three SelectionModes as SelectionModel (None / Single /
Multi, with anchor-based range extension in Multi).
extend_to(series, target) only extends within the anchor's own
series — a cross-series "range" has no natural order, so it falls
back to a single-point select of (series, target). adjust(&change)
keeps the selection consistent as the source model mutates: a removed
or wholesale-replaced series drops its selected points (and the
anchor, if it pointed there); point insertions/removals shift or drop
indices within their series; series metadata changes (rename / recolor
/ visibility / move / insert) and in-place point updates never affect
which points are selected. prune(exists) drops any selected point
exists rejects — the same shape as KeyedTreeCheckedModel::prune_missing
(§6.1).
ChartWindow and ChartAggregate stay pure teksilo-data building
blocks an app composes on top of a ChartModel (feed a ChartWindow's
or ChartAggregate's output into a fresh ChartModel::from_series_vec
snapshot). ChartSelection is wired in directly, though: all three
chart widgets (BarChart / LineChart / PieChart) accept a shared
handle via .selection(ChartSelection) — the chart reuses its own
hover hit-test to select the tapped mark (Ctrl/Cmd-click toggles it in
Multi mode), clears the selection on a tap that misses every mark,
and paints an accent highlight on every selected mark. See
charts.md §9 for the paint/interaction details and
charts.md §13 for
the current state of the remaining ChartWindow / ChartAggregate
wiring.
See also
- architecture.md §6 UI Construction Patterns —
Repeaterin context, static-vs-dynamic children. - architecture.md §14 Drag and Drop —
DragPayload, cross-widget reorder. - shortcut-intent-action.md — typed intents, ancestor
Actions, how the MVVM command layer lands in Rust. - crates/teksilo-data/src/list_model.rs, tree_model.rs, tree_slice.rs, tree_data_slice.rs, tree_row_filter.rs, selection_model.rs, list_data_source.rs, tree_data_source.rs, keyed_tree_checked_model.rs.
- crates/teksilo-data/src/data_change.rs, tree_change.rs.
- crates/teksilo-data/src/chart_model.rs (§15) —
ChartModel<T>,ChartChange,SeriesId; see also chart_window.rs, chart_aggregate.rs, chart_selection.rs. - examples/data_collections — runnable demonstration of ListView, TreeView, Repeater, SelectionModel, and intra-widget DnD.
- examples/chart_demo —
teksilo-chartsdemo; see charts.md for the chart-widget side ofChartModel.
Data Sources — the read-and-command interface
The four data views — ListView, TreeView, TableView, TreeTableView —
do not own a private store they mutate. They read from, and command, a
data source: a trait the application implements (or reuses a built-in impl
of) that owns the truth. This is Teksilo's answer to Qt's QAbstractItemModel
capability protocol — flags / canDropMimeData / dropMimeData for drag-and-
drop, canFetchMore / fetchMore for lazy loading — but expressed as
defaulted methods on concrete-T source traits, not a type-erased
QVariant/QModelIndex base class. A view reads &T directly; the source
answers "may this drop happen?" and "apply it"; the view only renders the
verdict and routes the commit.
There are two traits, both in teksilo-data:
| Trait | Shape | Built-in impls |
|---|---|---|
ListDataSource | flat list | ListModel<T>, SortFilterListModel<T> |
TreeDataSource | per-view flattened tree | TreeSlice<T>, SortFilterTreeModel<T> |
Neither is object-safe (associated types + generic with_item/with_entry),
so a view consumes it generically via from_source(...) and erases it into an
internal closure bundle — the view type stays ListView<T> / TreeView<T>,
not ListView<T, S>. The Key is captured at the from_source boundary,
so it never leaks into the view's type parameters.
When to implement a source vs. use a built-in model. A bounded, in-memory collection that the view-model owns is a
ListModel/TreeModel(which are sources — see the matrix below); reach for them first. Implement a source trait directly when the truth lives elsewhere (a DB cursor, a Qleany entity store, a paged feed) or doesn't fit in memory. Then there is no second copy to keep in sync. See data-models.md §14.
1. The core read surface
ListDataSource
#![allow(unused)] fn main() { pub trait ListDataSource: 'static { type Item: 'static; type Key: ItemKey; // identity fn len(&self) -> usize; // TOTAL (incl. not-yet-loaded) fn with_item<R>(&self, index: usize, f: impl FnOnce(&Self::Item) -> R) -> Option<R>; fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle; // defaulted ↓ fn is_empty(&self) -> bool { self.len() == 0 } fn key_at(&self, index: usize) -> Option<Self::Key> { None } fn index_of(&self, key: &Self::Key) -> Option<usize> { None } fn first_changed_index(&self) -> Option<usize> { None } // … DnD + lazy capability methods (§3, §4) … } }
A read-only in-memory source implements only len + with_item +
observe_changes. with_item returns None for an out-of-bounds index or
an in-bounds index whose data isn't resident yet (a lazy window miss — §4).
TreeDataSource
A tree source exposes its per-view flattened, currently-visible rows (the
TreeSlice shape — expand state is per view, so two TreeViews over one source
have independent expansion):
#![allow(unused)] fn main() { pub trait TreeDataSource: 'static { type Item: 'static; type Key: ItemKey; fn visible_count(&self) -> usize; fn with_entry<R>(&self, flat_index: usize, f: impl FnOnce(&Self::Item, &FlatEntry<Self::Key>) -> R) -> Option<R>; fn key_at(&self, flat_index: usize) -> Option<Self::Key>; fn flat_index_of(&self, key: &Self::Key) -> Option<usize>; fn parent(&self, key: &Self::Key) -> Option<Self::Key>; // sibling nav + cycle guard fn child_keys(&self, key: &Self::Key) -> Vec<Self::Key>; fn version_signal(&self) -> Signal<u64>; // bound at Rebuild fn is_expanded(&self, key: &Self::Key) -> bool; fn set_expanded(&self, key: &Self::Key, expanded: bool); // defaulted ↓ fn first_changed_index(&self) -> Option<usize> { None } fn contains_key(&self, key: &Self::Key) -> bool { self.flat_index_of(key).is_some() } // … DnD + lazy capability methods (§3, §4) … } }
FlatEntry<K = NodeId> { node_id: K, depth, has_children, is_expanded } carries
the per-row tree metadata the delegate needs. Trees drive the view off
version_signal() (bumps on every structural/projection change) plus
first_changed_index() (the divergence prefix that lets row-height caches and
other per-row state survive a reflatten).
contains_key is visibility-independent: a node collapsed under an ancestor
(or scrolled out of a lazy window) still exists. It's what keyed-selection
pruning consults, so a collapsed-but-present node keeps its selection and only a
deleted node is dropped. The default is visible-only — an external store whose
nodes persist while collapsed should override it.
2. Wiring a view
#![allow(unused)] fn main() { // Flat: let list = ListView::from_source(source, |index, item: &Row, selected| { Box::new(StandardListItem::new(item.title.clone()).selected(selected)) }); // Tree: the delegate gets a TreeRow (depth / has_children / is_expanded + // a one-call chevron `toggle_callback()`): let tree = TreeView::from_source(source, |item: &Node, row, selected| { Box::new(StandardTreeItem::new(item.name.clone()) .from_entry(row) .on_toggle_rc(row.toggle_callback())) }); }
The built-in models implement the trait, so ListView::from_source(list_model, …) / TreeView::from_source(tree_slice, …) work unchanged; from_model sugar
exists where it reads better. TableView::from_source(source) then adds columns
via the builder; TreeTableView fuses a TreeDataSource with columns. See
table-view.md.
3. Capability — drag-and-drop validation
The source owns DnD. The view never paints an "always valid" insertion line; it
asks the source on every hover and refuses a rejected drop. The vocabulary
(dnd_types) is shared by all four
data views (and adapted by TabBar):
#![allow(unused)] fn main() { enum DragEligibility { CanDrag, NoDrag } // the transferable gate enum DragSource<'a, K> { SameView { key: K }, Foreign { payload: &'a DragPayload } } struct DropQuery<'a, K> { source: DragSource<'a, K>, target: K, position: DropPosition } enum DropResponse { Accept, Reject, Redirect(DropPosition) } struct DropCommit<'a, K> { source: DragSource<'a, K>, target: K, position: DropPosition } enum DropPosition { Before, Into, After } // Into = reparent (trees only) }
The four source methods (all default to inert):
#![allow(unused)] fn main() { fn drag(&self, key: &Self::Key) -> DragEligibility; // may this row start a drag? fn can_accept(&self, q: &DropQuery<'_, Self::Key>) -> DropResponse; // hover verdict fn accept_drop(&self, c: DropCommit<'_, Self::Key>) -> bool; // apply fn on_drag_out(&self, key: &Self::Key); // source-side completion }
Flow per drag:
- Start. A row begins a drag only if
drag(key) == CanDrag. The view emits aRowDrag { source_index, source_view_id }typed payload (shared by all four views viadata_views). - Hover. The view computes the geometric
(target_key, position)and callscan_accept.Acceptpaints the insertion line / reparent box;Rejectpaints the no-drop affordance and will refuse;Redirect(pos)snaps the indicator topos(e.g. a container that takes children but not sibling reorder redirectsBefore/After→Into). This is the pre-drop validation the integrated reorder never had. - Drop. The view re-queries
can_accept; if notReject, it callsaccept_drop(commit). A store-backed source mutates itsVec/arena; an external source routes to its command (a Qleanymove_node, an SQLUPDATE). - Cross-view / external. A drag from another view or the OS arrives as
DragSource::Foreign { payload }; the source downcasts the payload itself (payload.get_typed::<MyPaletteDrop>(),payload.files(), …). The samecan_accept/accept_droppath covers intra-view reorder, list→list transfer, palette→outline drop, and OS file drops — one protocol, not a bolt- on. - Source-side completion. After a
Foreigndrop is accepted elsewhere, the framework callson_drag_out(key)on the origin source. A shared/command-backed source no-ops it; an independent model uses it to drop the moved row. (Same-view reorders don't fire it.)
Keyboard reorder. Alt+Arrow synthesizes the same RowDrag against the
sibling target derived from parent/child_keys, then routes through
can_accept → accept_drop. All four data views share this (TableView /
TreeTableView gained it in the redesign).
Tree reorder helpers. Custom TreeDataSource impls building on a
TreeModel can reuse
tree_apply_reorder (applies a
(source, target, position) move with the remove-then-insert index adjustment)
and tree_is_desc_or_self (the cycle guard — you cannot drop a node into its
own subtree). The built-in TreeSlice / SortFilterTreeModel accept_drop
impls are built on them.
4. Capability — lazy / windowed loading
There is no view-level on_near_end hook — incremental loading is a source
capability (all defaulted to fully-resident):
#![allow(unused)] fn main() { enum RowState { Ready, Loading } fn row_state(&self, index: usize) -> RowState; // is this row resident? fn request_window(&self, range: Range<usize>); // load the visible+buffer window fn can_fetch_more(&self) -> bool; // append-only growth available? fn fetch_more(&self); // pull the next page }
Each realize pass the view calls request_window(start..end) for its visible +
buffer range, and when the scroll nears the end consults can_fetch_more() →
fetch_more(). A row whose with_item/with_entry returns None and whose
row_state(i) == Loading is rendered as a placeholder skeleton at the row's
estimated height instead of being skipped — so selection, focus, and scroll math
stay stable while the page loads. A Loading row keeps its PrefixSumOffsets
estimate (it is never set_row_height-ed), so layout doesn't jump.
Two shapes are supported:
- Windowed (total known, sliding resident window — the 1M-row DB):
len()/visible_count()returns the total;row_stateisLoadingoutside the resident window;request_windowslides the window. - Append (total unknown — infinite scroll):
can_fetch_more/fetch_moregrow the source.
When a page lands, a flat source emits DataChange::WindowLoaded { range } — a
variant distinct from ItemsInserted so index-based SelectionModel does
not index-shift (the rows already existed; only their data arrived); the
divergence prefix is range.start. Trees need no new variant — a
version_signal() bump + first_changed_index() cover it. The page fetch itself
runs off-thread / on the app executor and updates the resident buffer on the
main thread (the existing AsyncCompletionHandle machinery); the next realize
swaps placeholders for real rows and place_children re-measures.
5. Keyed selection
Index-based SelectionModel is unstable under windowing and external reorder (a
position means a different row after a slide). KeyedSelectionModel<K> stores a
HashSet<K> keyed by source identity, plus the range anchor as a key, so
selection survives reorders, filters, window-slides, and stays consistent across
two views of one source.
#![allow(unused)] fn main() { let keyed = KeyedSelectionModel::new(SelectionMode::Multi); let list = ListView::from_source_keyed(source, keyed.clone(), delegate); // keyed.select(k) / .toggle(k) / .extend_to(target, &ordered_visible_keys) // .is_selected(&k) / .selected_keys() / .selection_signal() }
The view resolves is_selected(source.key_at(i)) at the realization loop and
select(key) in the click handler. On ItemsRemoved / Reset it calls
prune_missing(|k| source has k) — for trees that consults contains_key, so a
collapsed-but-present node keeps its selection. extend_to ranges over the
current visible key order; an anchor scrolled out gracefully degrades to single
select. from_source_keyed (all four views) opts in; plain from_source keeps
index selection.
6. Built-in implementation matrix
| Type | Trait | Key | DnD | Lazy |
|---|---|---|---|---|
ListModel<T> | ListDataSource | usize | accept_drop = move_item, can_accept = Accept | resident |
SortFilterListModel<T> | ListDataSource | usize | inert (sorted view) | resident |
TreeSlice<T> | TreeDataSource | NodeId | accept_drop = move_node w/ cycle guard | resident |
SortFilterTreeModel<T> | TreeDataSource | NodeId | as TreeSlice | resident |
TreeModel<T> is not itself a TreeDataSource — it carries no per-view
expand state; wrap it in a TreeSlice (independent expansion per view) or a
SortFilterTreeModel. External sources supply their own Key (an i64 entity
id, a Uuid, …) and implement the trait directly — that domain key is exactly
what removes the need for a mirror model.
See also
- data-models.md — the built-in models, projections, the
first_changed_index()divergence side-channel (§13), and projecting an external source of truth (§14). - table-view.md —
TableView/TreeTableViewcolumns, row heights, and source binding. - grid-view.md —
GridViewrides the sameListDataSourcecapabilities (drag routing +fetch_more+ placeholders). - drag-and-drop.md — the framework DnD pipeline the source
protocol routes through,
DropTarget/DropZone, and drop-target bubbling. - Source: list_data_source.rs, tree_data_source.rs, dnd_types.rs, keyed_selection_model.rs.
Settings & Persisted State Reference
Teksilo's persistence layer (teksilo-settings) is reactive end-to-end:
disk values live as Signal<T>s and ListModel<T>s, mutating either
the in-memory handle or the underlying file flows to the other side
automatically. There is no separate "config object" you remember to
write back; the in-memory state is the source of truth, and disk is
a projection of it — debounced for the types that write often, synchronous
for the types that don't.
Cross-process safety is not a mode you opt into — it is the only
behaviour every persisted type in this crate has. Two processes (or two
windows in one process) sharing the same general.toml / recents.toml /
window_state.toml — exactly Skribisto's one-process-per-open-project model
— cannot clobber each other's writes, and neither process has to do
anything to notice the other's change: a peer's write arrives on its own,
live, through the same Signal/ListModel you're already bound to. Unlike
Qt's QSettings, which makes you remember to call sync() at the right
moments, there is nothing to remember here at all.
Mental model in one line:
SettingsBundle → OpenedSettings → app_state registry → SettingsExt accessors → reactive widgets
↓
SettingsRegistry ← SettingsWatcher ← a peer's write landing on disk
Three persistence shapes share one storage backbone:
| Shape | Type | Use for |
|---|---|---|
| Dynamic K/V | SettingsStore → Signal<T> | Scalar prefs (font size, theme name, bools, arrays of scalars) |
| Typed file | SettingsFile<T> | App-shaped structs with their own schema + migrations |
| Reactive collection | PersistedListModel<T> | Recents, palettes, saved searches — anything that drives a Repeater / ListView |
There used to be a fourth shape, PersistedTreeModel<T>, for nested
hierarchies. It had zero consumers anywhere in this workspace or in
Skribisto and carried the exact whole-snapshot-clobber defect this crate
now hardens everything else against, so it was deleted rather than dragged
through that hardening — see "Cross-process safety, by default" below.
Reintroduce it (ops-based, from scratch) if a consumer actually needs a
persisted tree.
Two built-in services are layered on those primitives:
| Service | Backed by | Scope |
|---|---|---|
MruList<T: MruEntry> | PersistedListModel<T> | Generic dedupe + pin + cap recents over an app-defined item type |
WindowStateService | SettingsFile<WindowStateFile> | Per-window labelled geometry; auto-restored and auto-saved by the framework when a WindowConfig carries an id(...) |
End-to-end example:
examples/recent_projects.
Canonical app shape
use teksilo::prelude::*; use teksilo::app::TeksiloAppBuilder; use teksilo::settings::{AppPaths, MruList, SettingsBundle}; fn main() { let paths = AppPaths::new("eu", "FernTech", "Teksilo") .expect("could not resolve OS config directory"); // App-typed MRU list — the framework knows nothing about projects. let recents: MruList<RecentProject> = MruList::open(&paths, "recent_projects", 10).unwrap(); TeksiloAppBuilder::new() .theme(intui::light()) .app_paths(paths) // explicit // or .application("eu", "FernTech", "Teksilo") // shortcut .settings( SettingsBundle::new() .with_window_state(true), // opt-in ) // Live cross-process reload is on by default the moment `.settings(...)` // is configured — nothing else to write here. Opt out with // `.settings_watch(false)` (e.g. a sandboxed test double with no // usable filesystem watcher). .app_state(recents) // register MRU .initial_window( WindowConfig::new() .id("main") // <- enables auto save/restore .title("Teksilo") .size(1200, 800) .min_size(640, 400) .root(|tree, _state| tree.add(AppRoot::new())), ) .run(); }
Notes:
app_pathsorapplicationmust be set andsettingsmust be configured beforerun()/build_headless()is called — the bundle has nowhere to write without a directory, and the runtime panics ifsettings(...)was used but no paths were configured. (The order of the builder calls themselves doesn't matter; the builder just stores fields.)- The bundle only opens the K/V store and (optionally) the
WindowStateService. Apps register their ownMruList<T>,SettingsFile<T>, etc. via.app_state(handle). - Auto-save / auto-restore of window geometry is enabled by
.id("main")onWindowConfigplus.with_window_state(true)on the bundle. No widget-side wiring needed. - The live-reload watcher only starts for
run()(a real event loop to post the reload event through);build_headless()never starts one. Headless callers that still want to notice a peer's write pollReloadable::reload_from_diskthemselves.
Why three shapes, not one
The clean shapes lose information when you collapse them.
SettingsStoreis for scalars. Behind the scenes the file is a TOML map with dotted keys (editor.font_size,ui.theme). Struct values aren't supported because TOML serializes them as tables — indistinguishable on a re-read from "a parent of nested keys." Apps that need struct persistence go throughSettingsFile<T>.SettingsFile<T>owns one struct per file with its own schema andVersionedimpl. The migration story (rawtoml::Valuetransformations registered asfrom → from + 1steps) lives here. Its writes are synchronous — see "Cross-process safety, by default" below for why that's the right trade-off for this shape.PersistedListModel<T>wraps a reactiveListModel<T>and persists by replayable op (upsert / update / remove / clear by key), not by re-serializing the whole collection on every mutation. Mutating either the model's ops or the file goes through one path; widgets bound to the model see incremental UI updates (Repeaterdoes the right thing on inserts/removes/reorderings, and never sees aDataChange::Resetfrom a peer's write landing — see "Reconciling a live collection without losing the user's place" below).
Don't shoehorn a list into Signal<Vec<T>>. A 100-row recents menu
backed by a Vec would full-rebuild every Repeater on every add;
backed by ListModel, only the changed range patches.
AppPaths
Single point of truth for where settings live. Wraps
directories::ProjectDirs so the rest of the crate (and the rest of
this doc) ignores XDG / %APPDATA% / ~/Library/Preferences
differences.
#![allow(unused)] fn main() { pub struct AppPaths { /* private */ } impl AppPaths { pub fn new(qualifier: &str, organization: &str, application: &str) -> Option<Self>; pub fn for_testing(root: &Path) -> Self; pub fn from_dirs(config_dir: PathBuf, data_dir: PathBuf) -> Self; pub fn config_dir(&self) -> &Path; pub fn data_dir(&self) -> &Path; pub fn config_file(&self, name: &str) -> PathBuf; // <config>/<name>.toml pub fn data_file(&self, name: &str) -> PathBuf; // <data>/<name>.toml } }
new(...) returns Option because OS path resolution can fail
(sandboxed CI, missing HOME). TeksiloAppBuilder::application(...)
panics with a clear message in that case; production apps that want
to fall back to a portable directory use the Option directly:
#![allow(unused)] fn main() { let paths = AppPaths::new("eu", "FernTech", "Teksilo") .or_else(|| { let cwd = std::env::current_dir().ok()?; Some(AppPaths::for_testing(&cwd.join(".teksilo-state"))) }) .expect("no usable directory"); }
for_testing is the canonical test path — every test in this crate
calls it against a tempdir(), never against the user's real config
tree. Tests that consult production ProjectDirs would pollute
~/.config and would be non-hermetic on CI.
SettingsBundle and OpenedSettings
Declarative configuration for the framework integration.
#![allow(unused)] fn main() { let bundle = SettingsBundle::new() // store only .with_store_name("general") // → general.toml .with_window_state(true) // → window_state.toml .with_debounce(Duration::from_millis(500)); let opened: OpenedSettings = bundle.open(&paths)?; // opened.store: SettingsStore // opened.window_state: Option<WindowStateService> // opened.registry: SettingsRegistry — every service above is // pre-registered into it (see "Live reload" below). }
OpenedSettings is a cheap-to-clone handle bundle. Each contained
service is Rc<>-shaped internally; cloning produces a second handle
to the same in-memory state and the same shared I/O thread queue.
TeksiloAppBuilder::run keeps one OpenedSettings on the stack while
clones of each service live in the app_state registry — when the
registry is dropped at exit, the Drop impls flush every pending
payload synchronously.
flush_all() is the explicit form for tests and pre-fork scenarios.
with_debounce only actually debounces SettingsStore's writes —
WindowStateService accepts the same parameter (so open can call
both uniformly) but ignores it, because its writes are always
synchronous now (see WindowStateService below).
Cross-process safety, by default
This is the architectural story every other section builds on. It applies
identically to SettingsStore, SettingsFile<T>, and
PersistedListModel<T> — there is no per-type opt-in and no "shared mode"
flag anywhere in this crate's public API.
Why a lock alone is not enough
The obvious fix for "two processes writing the same file" is "wrap the
write in an advisory flock." That is necessary but not sufficient. A
lock only serializes the two writes against each other; it does
nothing about a stale in-memory snapshot. Concretely: process A takes
the lock, writes, releases it; process B then takes the lock and writes
its own pre-loaded snapshot — which predates A's write and doesn't
contain A's change — and B's write, though itself perfectly atomic and
lock-protected, still clobbers A's change. The lock made the write safe;
it did nothing to make the write correct.
The fix has to be a locked read-modify-write: the re-read of the current on-disk state has to happen after the lock is acquired and before the caller's change is applied, so the write that follows is always based on fresh data, not a snapshot that might already be behind a peer's write.
lock -> read current -> apply queued patches -> write atomically -> unlock
Patches, not rendered strings
Every write in this crate is expressed as a Patch: "given the file's
current raw text (or None if it doesn't exist yet), produce its new raw
text." The write path used to carry a pre-rendered String instead — the
caller serialized its whole in-memory document and the writer blindly
wrote those bytes. That is last-write-wins by construction: the writer
has nothing to merge with. A Patch closure instead runs against
whatever is actually on disk, read fresh under the lock, so a peer's
concurrent change to some other part of the document survives:
SettingsStorebuilds a patch from only the dotted keys dirtied since the last schedule — never a full render of the document — so a peer's change to an unrelated key is untouched.PersistedListModel<T>builds a patch from a smallListOp<T>(UpsertFront/UpdateInPlace/Remove/Clear, keyed byKeyed::key— see "MruList<T: MruEntry>" below) — never a re-derivedVec<T>— so a peer's concurrent insert or removal survives.SettingsFile<T>::mutate/replaceapply the caller's closure directly to the freshly re-read, re-migrated value, under the same lock.
Why Fn, not FnOnce
A patch may need to run more than once: if the write fails (disk full,
a transient network mount), the queued patches are retained and replayed
on the next tick against whatever is on disk then. That re-application is
exactly the right merge, and it's only possible if the patch can be called
again — FnOnce would force a choice between dropping the mutation (silent
data loss) or caching a pre-rendered string (which defeats the merge and
reintroduces last-write-wins for exactly the writes that failed once).
Patches are built entirely inside this crate from owned snapshots (a
Vec<(key, value)>, a ListOp<T>), so this never leaks into the public
API: callers keep writing signal.set(v) / mru.add(e) /
file.mutate(|s| ..) and never see a Patch.
The honest performance story
One flock + read + parse per debounce window, not per set.
SettingsStore and PersistedListModel<T> batch every mutation that
happens inside the debounce window (default 500 ms) into one patch queue,
and flush that whole queue as a single locked read-modify-write when the
window elapses (or flush_now() is called). Setting ten Signals in a
tight loop costs one lock/read/write, not ten.
SettingsFile<T> is the deliberate exception: mutate/replace are
always a synchronous locked read-modify-write, on the calling thread,
bypassing the debounce entirely. That's the right trade-off for how this
type is meant to be used — a settings change, one record per backup run
— where writes are rare enough that there's no burst to coalesce, and a
synchronous write keeps the "read fresh, apply, write" window as short as
possible. It becomes the wrong trade-off if a caller wires it to
something that fires every frame — see WindowStateService's record
below for a real instance of exactly that happening today.
Two mechanisms, one guarantee
SettingsStore / PersistedListModel<T> | SettingsFile<T> | |
|---|---|---|
| Merge unit | dotted key / ListOp<T> | whole struct, via caller's closure |
| Timing | debounced (default 500 ms), shared I/O thread | synchronous, calling thread |
| Retry on failure | queue retained, replayed next tick | error propagates immediately |
| Right for | frequent small writes (a Signal::set, a recents add) | rare whole-struct writes |
Both go through the same <path>.lock sidecar (via fs2, cross-platform
flock/LockFileEx) — see crates/teksilo-settings/src/lock.rs — so a
SettingsStore write and a SettingsFile<T> write to two different paths
never contend, and two handles (in this process or a peer's) to the same
path always serialize correctly regardless of which mechanism opened them.
SettingsStore — dynamic K/V scalars
The QSettings analogue. Dotted keys, types chosen at the call site,
backing Signal<T> cached by key.
#![allow(unused)] fn main() { pub const FONT_SIZE: SettingsKey<f32> = SettingsKey::new("editor.font_size", || 14.0); let store = SettingsStore::open(paths.config_file("general"))?; let size = store.signal_for(&FONT_SIZE); // first call seeds + caches size.set(18.0); // schedules debounced flush let same = store.signal_for(&FONT_SIZE); // second call returns the same Signal assert_eq!(same.get(), 18.0); }
Invariants enforced at registration:
- Type stability. Once a key is registered as
f32, callingsignal::<i32>on the same key panics. Settings are programmer- named; type drift is a code bug surfaced immediately. - Path-shape collisions.
"editor.font_size"cannot coexist with"editor"as a leaf value, in either order. Both directions panic at the call site that creates the conflict. - Struct rejection.
signal::<MyStruct>panics — see "Why three shapes" above. UseSettingsFile<MyStruct>.
Registration is idempotent, even against a peer's real edit
Registering a key (the first store.signal("k", default) call for it)
schedules a seed write so a brand-new key hits disk even if nothing
ever calls .set() on it. That seed cannot be an unconditional write,
though: two processes independently registering the same key with the
same hardcoded default, at slightly different times, must not have
whichever one's seed-patch happens to run second stomp a real value the
other process (or a third one) already set there. The dirty-key queue
therefore tags every entry with a DirtyKind:
Set— an explicitSignal::set()— always wins, unconditionally, over whatever is on disk.SeedIfAbsent— a registration-time default — only written if the key is still absent from the document at the moment the patch actually runs.
This is a real bug this design exists to close, not a hypothetical: an unconditional seed write would silently discard a peer's already-set real value under exactly the ordinary "both processes start up and register the same keys" sequence, with no unusual timing required.
Reload and the re-entrancy guard
A peer's write doesn't wait for this process to touch the same key —
Reloadable::reload_from_disk (see "Live reload" below) pushes it
straight into the already-handed-out Signal<T> for that key.
Two independent SettingsStore handles over one file, each setting a
different key with no coordination — the scenario a two-window /
two-process Skribisto session hits on every settings change:
#![allow(unused)] fn main() { let a = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap(); let b = SettingsStore::open_with_delay(path.clone(), Duration::ZERO).unwrap(); let dark_a = a.signal::<bool>("ui.dark", false); let dark_b = b.signal::<bool>("ui.dark", false); let width_b = b.signal::<f32>("editor.column_width", 80.0); assert!(!dark_b.get(), "b hasn't seen a's write yet"); dark_a.set(true); a.flush_now().unwrap(); // b's own write only ever touches its own key — but reload must push // a's concurrent change into b's *already-live* Signal, no restart needed. assert!(Reloadable::reload_from_disk(&b).unwrap()); assert!(dark_b.get(), "b's live signal must reflect a's write"); width_b.set(120.0); b.flush_now().unwrap(); // A third, fresh handle proves both keys are actually on disk together. let c = SettingsStore::open_with_delay(path, Duration::ZERO).unwrap(); assert!(c.signal::<bool>("ui.dark", false).get()); assert_eq!(c.signal::<f32>("editor.column_width", 80.0).get(), 120.0); }
Pushing a reloaded value into a Signal must not itself schedule a write
— that would bounce the peer's value straight back out as if it were a
local edit, and could race the peer's next write. A
StoreInner::applying_external flag, set for the duration of the reload,
makes the write-back observer a no-op while a reload is in progress.
Cycle-free observer. The closure each key installs to write
mutations back into the in-memory toml::Value captures
Weak<RefCell<StoreInner>>, never a strong Rc: a strong capture
would trap the entire store inside its own observer and leak it for
the life of the process. The
weak.upgrade().is_none() early-return also gives correct teardown
semantics — in-flight signal sets after a store drop bail silently.
Built-in: WindowStateService
Per-window labelled geometry, fully framework-driven.
A window participates in auto save / restore when:
- Its
WindowConfigcarries anid(...)(a stable string label), and - A
WindowStateServiceis registered (i.e.,SettingsBundle::with_window_state(true)).
That naturally excludes modal dialogs, popovers, and any transient
surface that doesn't ask for an id. Multi-window apps just give each
window a different id ("main", "log", "inspector"); the service
stores them under their own keys and the manager round-trips each
independently.
What round-trips
#![allow(unused)] fn main() { pub struct PerWindowState { pub label: String, pub x: i32, pub y: i32, pub width: u32, pub height: u32, pub placement: WindowPlacement, // Floating | Maximized | Fullscreen | Minimized } }
- Size: honored on every platform.
- Position: honored on X11, macOS, Windows. Wayland ignores position by design — see "Wayland caveat" below.
- Placement:
Floating,Maximized,Fullscreenround-trip exactly.Minimizedis downgraded toFloatingon restore — a window that comes back invisible looks like the app failed to start.
Restoration: the sanitize step
What happens if the saved coordinate is for a monitor that's no longer
connected? The framework runs every saved entry through
PerWindowState::sanitize(min_size, work_area) before applying it:
- Width / height are clamped to
[min, work_area]. A 4K saved size on a 1080p screen comes back as 1920×1080. - Position is checked per-axis against a 50-pixel intersection
test with the work area. A window saved at
x=2200, y=100on a now-disconnected secondary monitor recenters itsxto the primary's middle while keepingy=100(it was always on-screen vertically). A window withx=-2000, y=-2000recenters both axes. - The original on-disk state is untouched, so re-plugging the second monitor restores the original geometry on the next launch.
The work-area hint is pulled from winit's
ActiveEventLoop::primary_monitor().size().to_logical(scale_factor),
falling back to (1920, 1080) on hosts where no monitor handle is
reachable (headless, wired-only).
record is synchronous, not debounced — and that has a real cost today
WindowStateService is built directly on SettingsFile<WindowStateFile>,
so record/forget are the same synchronous locked read-modify-write
described in "Cross-process safety, by default" above — there is no
debounce window to coalesce a burst of calls, unlike SettingsStore /
PersistedListModel<T>. That's the right trade-off for how this type is
meant to be called (a handful of writes across a window's lifetime), but
teksilo-app's window_persist module currently wires record to fire on
every Signal change of a window's size/position/placement — which on
X11/Windows/macOS means once per reported frame during a live drag or
resize (Wayland mostly spares position, per the caveat below, but size
still updates during a resize). Concretely: a live window drag today does
a synchronous lock-acquire + file read + parse + serialize + atomic write
on every reported geometry change, not a debounced one. This is a known,
current characteristic of that call site's wiring, not a limitation of
WindowStateService itself — a caller that wants to coalesce a drag into
one write should debounce at the call site (e.g. call record only from a
"drag ended" observer or a periodic timer) rather than from every raw
geometry Signal.
Wayland caveat
Wayland's xdg-shell protocol does not let an application choose its own window position. The compositor (Mutter, KWin, sway, Hyprland) is the sole authority — by design, for security and tiling reasons. Concretely:
winit::Window::set_outer_position(...)silently no-ops on Wayland;outer_position()returnsErr(NotSupportedError).- The position observer in
window_persist.rsalmost never fires on Wayland because the compositor doesn't notify apps of their position. WindowState.positionkeeps whatever value we initialized it with.
The framework persists (x, y) regardless because the saved value is
portable storage — useful when the same config roams to an X11
session. On Wayland itself, compositors with per-app placement
memory (KWin's window rules, sway's for_window, GNOME's heuristic
stickiness) match windows by their Wayland app_id (typically
derived from the binary name by winit), not by anything teksilo-app
wires from WindowConfig::id(...) — that string is purely an
internal lookup key for find_window and the persistence service.
The result for users is fine on Wayland: the compositor remembers
placement at its layer, the framework remembers placement at
ours, and on a switch back to X11 / Windows / macOS the saved
coordinates apply.
v1 → v2 migration
PerWindowState originally stored a single maximized: bool. v2
replaces it with the full WindowPlacement enum so Fullscreen
round-trips properly. The migrator
(window_state.rs)
converts each entry's maximized: true to placement = "Maximized",
otherwise "Floating". Files are upgraded transparently on first
read; the new shape is written back on the next record/forget.
MruList<T: MruEntry> — generic recents
Apps define their own item type implementing two small traits: Keyed
(a stable, owned merge identity — shared with every collection this crate
persists) and MruEntry (the pin/touch vocabulary an MRU list specifically
needs on top). The framework provides dedupe-on-add, pin-aware cap
eviction, and cross-process-safe persistence via PersistedListModel<T>.
#![allow(unused)] fn main() { use std::path::{Path, PathBuf}; use teksilo::settings::{Keyed, MruEntry, MruList}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone)] struct RecentProject { path: PathBuf, display_name: String, last_opened: u64, pinned: bool, } impl Keyed for RecentProject { type Key = PathBuf; fn key(&self) -> PathBuf { self.path.clone() } } impl MruEntry for RecentProject { fn is_pinned(&self) -> bool { self.pinned } fn set_pinned(&mut self, p: bool) { self.pinned = p; } fn touch(&mut self) { self.last_opened += 1; } // a real app stamps a wall-clock time } }
Keyed::Key is owned (PathBuf, String, a small Copy id) — not the
old borrowed MruEntry::Key: ?Sized shape — because it must be captured
into a Patch closure that crosses to the shared I/O worker thread; a
borrow into T cannot outlive the mutation call that produced it. remove
/ touch / set_pinned stay ergonomic despite the owned key by being
generic over Q where T::Key: Borrow<Q>, so callers still pass &Path
/ &str without allocating just to look an entry up.
#![allow(unused)] fn main() { let mru: MruList<RecentProject> = MruList::open(&paths, "recent_projects", 10).unwrap(); // Bound to UI: let model = mru.model().clone(); // ListModel<RecentProject> // Mutations — each of these both updates the live model *and* enqueues // the matching replayable op (see "Cross-process safety" above): mru.add(RecentProject { path: "/projects/foo".into(), display_name: "Foo".into(), last_opened: 0, pinned: false, }); // dedupes by key, prepends, caps mru.touch(Path::new("/projects/foo")); // re-marks as freshly used mru.set_pinned(Path::new("/projects/foo"), true); // pin (see "Replayable ops" below) mru.remove(Path::new("/projects/foo")); mru.clear(); }
The cap policy: only unpinned entries count. Pinning a tenth entry in a 10-cap list doesn't evict anything; pinning eight entries in a 5-cap list keeps all eight (they're never evicted) plus up to five unpinned. A re-add of a previously-pinned key preserves the pin even if the new value didn't ask for it.
Mutate through MruList's methods, never through .model().
.model() is for reading and reactive binding (ListView / Repeater) —
every UI observer wants live updates regardless of who mutates. There is
no longer an observer that bridges an arbitrary ListModel mutation to
disk (that observer was the whole-snapshot-clobber bug this crate
exists to fix), so mutating the returned model directly updates what's on
screen but is never persisted.
Bind to a list via Repeater (this is what the demo does):
#![allow(unused)] fn main() { ctx.add(Repeater::new( mru.model().clone(), |_idx, project: &RecentProject| { let path = project.path.clone(); Box::new( Button::new(lit!(project.display_name.clone())) .on_activate_fn(move |ctx| { ctx.send_intent(AppIntent::OpenRecent(path.clone())); }), ) }, )); }
Driving a MenuList from an MruList is also a reasonable pattern,
but MenuList's builder takes MenuItems by value through .item(...)
rather than wrapping a single child widget — adapting a Repeater to
that shape needs an extra "rebuild on ListModel change" indirection
that's outside this doc's scope.
Live reload: Reloadable, the watcher, and self-write suppression
Cross-process safety on the write side (above) is only half the story:
a process that loads its state once and never looks again will not notice
a peer's write until it happens to mutate something itself. Reloadable
is the read side.
#![allow(unused)] fn main() { pub trait Reloadable { fn path(&self) -> &Path; fn reload_from_disk(&self) -> Result<bool, SettingsFileError>; } }
Implemented by SettingsFile<T> (where T: PartialEq), SettingsStore,
PersistedListModel<T> (where T: PartialEq), WindowStateService, and
MruList<T> (delegating to its PersistedListModel). reload_from_disk
returns Ok(true) if in-memory state actually changed, Ok(false) — a
hard guarantee that nothing was touched — otherwise.
The self-write-suppression contract
A naive implementation would feed back into itself: this process writes
general.toml, a watcher notices that very write a few milliseconds
later, and calls reload_from_disk() — which had better be a cheap no-op,
not a full re-parse-and-notify cycle, and must never re-apply our own
value as if it were a peer's newer one (which could bounce a
just-superseded value back into a live Signal between the user's edit
and the debounced write landing). Every implementation layers two checks,
cheapest first:
- Stamp check. Each implementor records the on-disk
(mtime, len)as of the last time it read or wrote the file. If the current stamp matches,reload_from_diskreturnsOk(false)immediately — no read, no parse, nothing touched. This is the common case for a self-write notification. - Content backstop. If the stamp did change (a real write
happened, by us or a peer — mtime resolution can coincide, or the
write path didn't get a chance to update the stamp), the file is read
and parsed, then compared by value against what's already live. Only
a genuine difference is pushed into signals/models;
Ok(false)is returned — again touching nothing — when the content is unchanged. This is the actual correctness guarantee; the stamp check is purely an optimization to skip the common case cheaply.
SettingsWatcher and SettingsRegistry
SettingsWatcher owns a notify::RecommendedWatcher background thread,
mirrored from teksilo-i18n's FtlFileWatcher. It watches directories
(AppPaths::config_dir() / data_dir()), not individual files: every
atomic writer in this crate (and any well-behaved peer) writes a temp file
and renames it over the target, which would invalidate an inode-level
watch on the file itself. SettingsRegistry maps a canonical path to a
Weak<dyn Reloadable>; a changed-path event is dispatched through it to
the one live handle that owns that path — anything else (a .lock
sidecar, a .tmp write-in-progress, an unrelated file a peer dropped in
the same directory) is a harmless no-op.
#![allow(unused)] fn main() { use teksilo_settings::{SettingsRegistry, SettingsFile, Migrator, Versioned}; use serde::{Serialize, Deserialize}; use std::rc::Rc; #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] struct Prefs { version: u32 } impl Versioned for Prefs { const CURRENT_VERSION: u32 = 1; fn version(&self) -> u32 { self.version } fn set_version(&mut self, v: u32) { self.version = v; } } let dir = tempfile::tempdir().unwrap(); let file: SettingsFile<Prefs> = SettingsFile::load(dir.path().join("prefs.toml"), Migrator::new()).unwrap(); let registry = SettingsRegistry::new(); // Keep `handle` alive for as long as reload should keep working — // the registry only ever holds a Weak; nothing is called on a // service that has since been dropped. let handle = registry.register(Rc::new(file.clone())); drop(handle); // dropping it deregisters: no leak, no dangling call. }
TeksiloAppBuilder wires this up automatically the moment .settings(...)
is configured (windowed apps only — run(), not build_headless(),
since there's a real event loop to post the reload event through): every
service SettingsBundle::open opens is pre-registered into
OpenedSettings::registry, and that registry itself is installed into
app_state, so application code opening its own ad hoc
SettingsFile<T> / PersistedListModel<T> / MruList<T> can register it
too via ctx.app_state::<SettingsRegistry>(). Opt out entirely with
.settings_watch(false) on the builder (e.g. a sandboxed test double with
no usable filesystem watcher, or an app that wants to poll
Reloadable::reload_from_disk on its own schedule instead).
Reconciling a live collection without losing the user's place
A PersistedListModel<T>'s reload can't just clear the model and rebuild
it from the freshly-read file — ListModel::replace_all emits a blanket
DataChange::Reset, which unconditionally clears a positional
SelectionModel. If a peer's write lands mid-session while the user has a
row selected (or focused, in a ListView), a Reset-based reload would
yank that selection out from under them for no reason connected to
anything they did.
Instead, a reload diffs the freshly-read Vec<T> against the live model
by key (Keyed::key) and emits only the minimal granular changes
needed to reconcile the two: coalesced removals for keys that vanished,
single-row moves only for entries that are actually out of place (an
append-only or remove-only reload emits zero moves), value updates
(T: PartialEq) for entries whose key survived but whose content changed,
and coalesced insertions for brand-new keys. ListModel::reconcile_by_key
(in teksilo-data) is the general-purpose primitive this reduces to; a
ListView's row selection and focused-index tracking already consume
exactly this kind of event stream (ItemsInserted / ItemsRemoved /
ItemsMoved / ItemUpdated) correctly, because those events are also
what an ordinary user-driven insert/remove/reorder produces — a reload is
just another source of the same event vocabulary, not a special case a
selection has to separately account for.
Replayable ops: why some APIs had to change shape
Every mutation this crate persists is enqueued as a patch now, applied later — at the next debounce tick, possibly after a peer's write has already landed on disk in the meantime (see "Cross-process safety, by default"). That constraint rules out any operation whose meaning depends on transient state that may no longer be true by the time it replays:
toggle_pin→set_pinned(key, bool). A toggle's effect depends on the current pinned state at the moment it runs. If two toggles for the same key are queued and a peer's concurrent write reorders when either actually applies, "toggle" can end up flipped the wrong number of times — the operation isn't idempotent, so replaying it against a document that has moved on since it was enqueued can silently produce the wrong answer.set_pinned(key, pinned)states the desired end state directly: replaying it against any starting document — including one a peer has already mutated — always lands on the same pinned value.NotificationArchiveModel::remove(index)→remove_by_id(id). An index is a position in this process's current view of the list. By the time a queued mutation actually runs — after a debounce window, or after a peer's concurrent insert has already shifted every row after it — that index may no longer name the row it named when the call was made, or may not even be in bounds. An id is stable identity: it names the same row regardless of how many inserts or removals happened to its neighbors in the meantime.
The general principle: an op must be safe to apply against any
document state consistent with "some other writer might have gotten there
first", not just the state the caller happened to observe when it made
the call. ListOp<T>'s own shape follows the same rule — Remove(T::Key)
carries only the key, never the value or a position, because a key is the
only thing a diff of "what's gone" can always produce, even once the
value itself is no longer available to compare against.
SettingsExt accessors
A single extension trait on BuildContext and EventContext.
#![allow(unused)] fn main() { use teksilo::settings::SettingsExt; // Inside any handler / build method: let store = ctx.settings(); // panics if not registered let store_opt = ctx.try_settings(); // Option<&SettingsStore> let recents = ctx.mru::<RecentProject>(); // panics if not registered let recents_opt = ctx.try_mru::<RecentProject>(); let svc = ctx.window_state(); // panics if not registered let svc_opt = ctx.try_window_state(); }
Each accessor wraps the existing app_state::<T>() lookup. Mandatory
forms panic with a clear message that names the missing service and
the call to register it; try_* variants return Option.
Window-geometry persistence is not an extension method. When a
WindowStateService is registered, every WindowConfig carrying an
id(...) is automatically restored on creation and recorded on every
change by teksilo-app's window manager. No ctx.persist_window_state(...)
call needed.
Migrations
Every persisted struct carries a version: u32 (via the Versioned
trait). Migrations operate on raw toml::Value before deserialize,
so a v1 file that no longer matches the v2 type can still be upgraded:
#![allow(unused)] fn main() { use teksilo_settings::{Migrator, Versioned}; #[derive(Serialize, Deserialize, Default)] struct Recents { version: u32, items: Vec<Entry>, } impl Versioned for Recents { const CURRENT_VERSION: u32 = 2; fn version(&self) -> u32 { self.version } fn set_version(&mut self, v: u32) { self.version = v; } } let migrator: Migrator<Recents> = Migrator::new() .step(1, |mut v| { // v1 had no `pinned` field; default to false. if let Some(items) = v.get_mut("items").and_then(|i| i.as_array_mut()) { for item in items { if let Some(t) = item.as_table_mut() { t.insert("pinned".into(), toml::Value::Boolean(false)); } } } Ok(v) }); }
Migrator::run reads the version directly from the raw value's
version field (defaulting to v1 if missing) before any deserialize
attempt, walks registered steps in order, and stamps each intermediate
result with the new version so subsequent steps see a coherent
struct. A file newer than CURRENT_VERSION returns
MigrationError::NewerThanCurrent rather than risk silent
corruption — this lets a downgraded build refuse to read forward
state.
Migrator<T> is cheaply Clone (each step's closure lives behind an
Arc, so cloning is a handful of refcount bumps) and is now taken by
value by SettingsFile::load/load_strict/PersistedListModel::open,
retained for the handle's whole lifetime — not just consulted once at
construction. That matters because cross-process safety means every
locked read-modify-write (not just the initial load) has to be able to
bring a peer's still-older on-disk schema forward, since a peer running
an older build might still be writing the pre-migration shape at any
point during this process's lifetime.
Corrupt files (parse failure, missing migration, post-migration
deserialize failure) are renamed to <path>.broken-<unix_ts> and the
SettingsFile falls back to T::default() so the app keeps running.
Apps that want the strict alternative (errors propagate, no fallback)
use SettingsFile::load_strict.
Atomic write + debounce
Every flush goes through write-temp + rename via tempfile. Debounced
writes (SettingsStore, PersistedListModel<T>) are coalesced through a
single shared I/O thread (one OnceLock<Sender<PoolMsg>> per process).
Each writer holds a WriterId, and Drop synchronously flushes its
pending payload via the Unregister ack so end-of-process state is never
lost. SettingsFile<T> writes synchronously instead (see "Cross-process
safety, by default"), so it never has anything queued on this thread;
its own flush_now() is consequently a harmless no-op, kept only so
callers can flush every service uniformly without special-casing it.
DebouncedWriter::schedule — the method that actually enqueues a
Patch — is pub(crate): application code never calls it directly.
DebouncedWriter is exported only as the public type that
SettingsFile<T>, SettingsStore, and PersistedListModel<T> are built
on and hold internally; the surface an external caller actually gets is
just enough to inspect or force-flush a service that wraps one:
#![allow(unused)] fn main() { use teksilo::settings::DebouncedWriter; use std::time::Duration; let w = DebouncedWriter::new(path.clone(), Duration::from_millis(500)); assert_eq!(w.path(), path.as_path()); w.flush_now().unwrap(); // synchronous force-flush; a harmless no-op // here since nothing has been scheduled }
Duration::ZERO makes every schedule flush on the worker's next
iteration — useful for tests, where flush_now() is the
deterministic anchor.
Application logic stays single-threaded; only the atomic write
happens on the worker. Signal<T>::observe callbacks fire on the UI
thread as ever — the path from signal.set(v) to the worker being
notified is cheap and synchronous.
Threading and source-of-truth
Signal<T> and *Model<T> use Rc<RefCell<>>; the settings store
inherits that. In-memory is the source of truth. Disk is a
projection: seeded once at startup (lock-protected, so a peer mid-write at
startup can't hand this process a torn read), written on every mutation
(debounced or synchronous depending on the type), and re-synced whenever
a peer's write is noticed — either by the live SettingsWatcher ("Live
reload" above) or by an explicit reload_if_stale() /
Reloadable::reload_from_disk() call. Widgets never read from disk
directly.
This is why OpenedSettings: Clone is a shared clone, not a deep
one. Cloning each contained service is an Rc bump; mutations
through any clone are visible to every clone.
This "in-memory is the source of truth" model used to make multi-process sharing last-write-wins by construction: two instances each held their own private snapshot, and each write re-serialized from that increasingly-stale copy with no re-read and no lock, so one process's change was silently discarded by the other's next write. That is no longer true of anything in this crate — see "Cross-process safety, by default" above for the locked read-modify-write that replaced it, and "Live reload" for how a peer's change reaches this process's live state without this process having to touch anything itself.
Checklist for common tasks
| Task | Recipe |
|---|---|
| Add a new scalar pref | Declare a const KEY: SettingsKey<T> = SettingsKey::new(...), call ctx.settings().signal_for(&KEY) from build(), bind with .text(...) / .color(...) etc. |
| Persist a struct | Define struct Foo { version: u32, ... }, impl Versioned for Foo, open with SettingsFile::load(path, Migrator::new()), register via app_state(handle.clone()). |
| Persist a list | Define T: Keyed + MruEntry, MruList::open(&paths, "name", N), register via app_state(handle). |
| Auto-save / restore window geometry | .settings(SettingsBundle::new().with_window_state(true)) and .id("main") on the WindowConfig. Done. |
| Add a v2 schema migration | Bump CURRENT_VERSION, register a Migrator::new().step(1, ...) transformation, plumb the migrator into SettingsFile::load. |
| Force a flush before a child process | opened.flush_all() (or per-service flush_now()). |
Test settings code without touching ~/.config | AppPaths::for_testing(tempdir.path()) and Duration::ZERO for the debounce. |
React to a peer's write outside a running TeksiloAppBuilder app (e.g. a headless tool) | Call Reloadable::reload_from_disk(&handle) (or the cheaper reload_if_stale() on SettingsFile<T>) on your own schedule — there's no watcher without a running event loop. |
| Register an ad hoc persisted type for live reload | ctx.app_state::<SettingsRegistry>().register(Rc::new(my_handle.clone()) as Rc<dyn Reloadable>), keep the returned Rc alive. |
Reference
- Source:
crates/teksilo-settings/src/ - Window persist integration:
crates/teksilo-app/src/window_persist.rs - Live-reload wiring:
crates/teksilo-app/src/app.rs(searchsettings_watch) - End-to-end demo:
examples/recent_projects/src/main.rs - Related architecture topics:
docs/multi-window.md,docs/data-models.md,docs/reactive-theme.md
Out of scope — intentional
- Encryption. Plaintext TOML. Secrets go through a future
teksilo-secretscrate against the OS keychain. - Cloud sync. No.
- Large persisted collections (> ~1k items). Use SQLite via
rusqlite; the persistence bridges intentionally re-serialize whole on every change. - Per-document state. Document state belongs in the document file
or its sidecar, not in app settings. Same primitive
(
SettingsFile<T>) is reusable by app code, but no built-in service. QSettings::sync()-style read-back guarantees.flush_allis the only sync barrier; there is no per-key sync, no read-after-write barrier within a tick, and no cross-handle visibility within the same process for two stores opened on the same file. Apps that need any of these are using the wrong tool.- A persisted tree collection.
PersistedTreeModel<T>was deleted (zero consumers, and it never got the ops-based hardening the rest of this crate did). Reintroduce it ops-based, from scratch, if a real consumer needs one — do not resurrect the deleted whole-snapshot version.
Telemetry & Privacy Reference
Teksilo's telemetry is consent-gated by construction and
privacy-mode-switchable at runtime. There is no path through which
an event can reach a server while the user's ConsentState is
Unknown or Denied — the gate lives in the dispatch tap, before
any adapter sees the event. Apps that ship without telemetry pay
nothing; apps that ship with it inherit a working RGPD-compliant
shape (Art. 13 notice + per-scope toggles + Art. 15/17/20 buttons)
out of the box.
Mental model in one line:
TelemetryBundle → OpenedTelemetry → app_state registry → dispatch tap → consent gate → adapter
The two modes are an architectural choice, not a tunable knob:
| Mode | install_id | Default scopes available | What the user can do |
|---|---|---|---|
| Anonymous | None | anonymous_metrics_only() | Withdraw consent |
| Pseudonymous | UUID, 13-month rotation | ConsentScope::all() | Withdraw + Get my data + Erase |
Apps configure one or both adapters; users (or the framework's mode-switch UI) flip between them.
Three persistence shapes come with the crate:
| Shape | Type | Use for |
|---|---|---|
| Consent state | ConsentStore → Signal<ConsentState> | The user's grant/deny/scope decision, atop SettingsFile<ConsentFile> |
| Pseudonymous identity | InstallId | Per-install UUID with 13-month rotation, atop SettingsFile<InstallIdFile> |
| Event queue | InMemoryEventQueue / PersistentEventQueue | Outbound buffering with retry; redb-backed for cross-restart durability |
Two reference adapters ship in tree:
| Adapter | Crate | Mode(s) | Backend |
|---|---|---|---|
StubReporter | teksilo-telemetry | Anonymous + Pseudonymous | In-memory Vec (testing only) |
PlausibleAdapter | teksilo-analytics-plausible | Anonymous | Plausible Cloud or self-hosted |
TeksiloAdapter | teksilo-analytics-native | Anonymous + Pseudonymous | Self-hosted teksilo-collector gRPC service |
1. Quick start
Wire telemetry through TeksiloAppBuilder alongside settings(...) —
both go through the same builder-time validation pattern:
use teksilo::prelude::*; use teksilo::app::TeksiloAppBuilder; use teksilo::settings::SettingsBundle; use teksilo_analytics_native::TeksiloAdapter; use teksilo_telemetry::{TelemetryBundle, TelemetryMode, UsageReporter}; use std::rc::Rc; const EVENT_SCHEMA_VERSION: u32 = 1; fn main() { let adapter = Rc::new( TeksiloAdapter::builder() .endpoint("https://collector.example.com:50051") .product_id("my.app") .bearer_token(std::env::var("TEKSILO_TOKEN").unwrap()) .build(), ) as Rc<dyn UsageReporter>; let telemetry = TelemetryBundle::new(EVENT_SCHEMA_VERSION) .with_anonymous(adapter) .with_default_mode(TelemetryMode::Anonymous) .with_data_processor_name("MyCo SAS"); TeksiloAppBuilder::new() .application("eu", "MyCo", "my-app") .settings(SettingsBundle::new()) .telemetry(telemetry) // <— telemetry wires in here .initial_window(/* ... */) .run(); }
Three things happen on .run():
- The
TelemetryBundleopens —ConsentStore,InstallId(when in pseudonymous mode), the recent-log ring buffer, and theDynamicReporterare constructed. - The resulting
OpenedTelemetryis registered into theapp_stateregistry — accessible from any widget viaTelemetryExt. - The dispatch tap in
teksilo-corestarts forwarding every dispatched intent throughDynamicReporter::record, but the consent gate silently drops everything until the user grants — the app stays functional in theUnknownstate, no events leave.
Drop the PrivacySettings
widget anywhere in the tree (typically a settings tab or first-run
modal) and the user's grant flow + every Art. 13/15/17/20 obligation
is wired.
2. The pieces
2.1 TelemetryBundle — declarative configuration
Mirror of SettingsBundle. Builder-time validation;
opens at TeksiloAppBuilder::run() time once AppPaths and the
SettingsStore are available.
#![allow(unused)] fn main() { TelemetryBundle::new(event_schema_version) .with_anonymous(adapter) // Rc<dyn UsageReporter> .with_pseudonymous(other_adapter) // optional second adapter .with_default_mode(TelemetryMode::Anonymous) .with_data_processor_name("MyCo SAS") .with_data_residency_region(DataResidencyRegion::EU) .with_recent_log_capacity(200) // ring buffer for "Inspect data sent" .with_debounce(Duration::from_millis(500)) }
Required: at least one of with_anonymous(...) or
with_pseudonymous(...). Both let the user flip between modes via
the widget.
2.2 OpenedTelemetry — runtime handle
The opened-bundle handle is Clone-cheap (every field is Rc/Arc).
Surfaced through TelemetryExt:
#![allow(unused)] fn main() { use teksilo_telemetry::TelemetryExt; fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { if let Some(t) = ctx.try_telemetry() { // Public fields: let _: ConsentStore = t.consent.clone(); let _: Option<InstallId> = t.install_id.clone(); let _: Rc<DynamicReporter> = t.reporter.clone(); let _: Arc<InMemoryEventQueue> = t.recent_log.clone(); let _: PrivacyPolicy = t.policy.clone(); let _: u32 = t.event_schema_version; } // ... } }
recent_log is a ring buffer DynamicReporter::record tees every
consent-gated event into. Independent of the adapter's outbound
queue — events stay in the recent log even after the adapter has
flushed them, until evicted by the ring buffer's capacity (default
200, configurable via with_recent_log_capacity). Read by the
"Inspect data sent" accordion in the widget.
2.3 UsageReporter trait — the adapter surface
#![allow(unused)] fn main() { pub trait UsageReporter { fn record(&self, event: &Event<'_>); fn flush(&self) -> Result<(), TelemetryError>; fn discard_pending(&self) -> Result<(), TelemetryError>; fn supported_scopes(&self) -> ConsentScope; fn install_id(&self) -> Option<&str>; fn endpoint(&self) -> &str; fn adapter_name(&self) -> &'static str; fn fetch_remote_data(&self) -> Result<RemoteDataExport, TelemetryError>; fn erase_remote_data(&self) -> Result<(), TelemetryError>; } }
The trait is single-threaded — adapters are Rc-shared, called
only from the UI-thread dispatch tap. Adapters that need I/O behind
a worker thread (Plausible, Teksilo) own that thread internally and
communicate via mpsc channels; the UsageReporter impl is just
the sync surface.
fetch_remote_data and erase_remote_data return
TelemetryError::FetchUnsupported / ErasureUnsupported for
anonymous-mode adapters; the widget hides the corresponding buttons
in that case.
2.4 ConsentStore — the gate
Wraps SettingsFile<ConsentFile> with a Signal<ConsentState> for
widget reactivity.
#![allow(unused)] fn main() { pub enum ConsentState { Unknown, // first run; no events emitted Granted(ConsentScope), // events flow per the scope Denied, // explicit no; events dropped } pub struct ConsentScope { pub anonymous_metrics: bool, pub crash_reports: bool, pub feature_flags: bool, pub session_recording: bool, // reserved — not implemented yet (PII risk) } }
API:
#![allow(unused)] fn main() { consent.state_signal(); // Signal<ConsentState> consent.is_granted(); // bool — what the dispatch tap checks consent.grant(scope, endpoint); // Granted with full scope consent.deny(); // Denied consent.withdraw(); // shortcut for deny() consent.set_scope(|s| s.crash_reports = false); consent.set_or_grant_scope(endpoint, |s| s.anonymous_metrics = true); // Unknown→Granted-with-one-scope; no-op when Denied consent.reset(); // back to Unknown (used by mode switch) consent.with_settings_mirror(settings_store); // optional one-way mirror to per-scope // SettingsKey<bool> values }
Re-prompt rules (consent goes back to Unknown in any of):
event_schema_versionbumps from one the user previously consented to.- The endpoint string changes (recipient-change rule).
- The user runs
consent.reset()(typically from the mode-switch flow).
with_settings_mirror(SettingsStore) writes the per-scope booleans
into scopes::TELEMETRY_ANONYMOUS_METRICS /
TELEMETRY_CRASH_REPORTS / TELEMETRY_FEATURE_FLAGS keys so power
users editing general.toml directly see the live state. One-way
(consent → settings, not the reverse — the consent file is
authoritative).
2.5 InstallId — pseudonymous identity
Generated lazily on first pseudonymous-mode use; rotated every 13
months to align with the CNIL cookie-consent SLA. Stored in
SettingsFile<InstallIdFile> under AppPaths::config_dir().
#![allow(unused)] fn main() { let install_id: Option<InstallId> = telemetry.install_id.clone(); if let Some(id) = &install_id { let uuid: String = id.get(); id.clear(); // user clicked "Erase my data" } }
reporter.install_id() is the value that ends up on every emitted
Event::install_id field — adapters override anything the event
itself carried, so the per-install identity is consistent with what
the server sees.
2.6 Event queues
| Type | Use case | Backend |
|---|---|---|
InMemoryEventQueue | Tests, the recent-log ring buffer, simple deployments | Mutex<VecDeque<OwnedEvent>> |
PersistentEventQueue | Adapter outbound buffering across process restarts | redb (pure Rust, no C deps) |
Both implement the EventQueue trait (push, drain_batch, len,
peek_recent, discard_all). Adapters typically hold one as their
outbound queue; the OpenedTelemetry::recent_log is always an
InMemoryEventQueue.
PersistentEventQueue opens a redb file at a configured path with
capacity + age caps:
#![allow(unused)] fn main() { let queue = PersistentEventQueue::open_with( &path, 10_000, // capacity (oldest evicted past this) Duration::from_secs(60 * 60 * 24 * 7), // max age — events past this drop )?; }
The Plausible and Teksilo adapters expose a
.persistent_queue_path(path) builder method to opt into
durability. Without it, they fall back to an InMemoryEventQueue
(events lost on hard exit).
OtlpAdapter is the exception: it deliberately has no persistent
queue. The OTel deployment model assumes a collector sits between
the app and the backend, and that collector (file_storage
extension on otelcol-contrib, or its built-in queue) owns
durability. See §3.4 for the full reasoning.
2.7 The dispatch tap
teksilo-core's event_dispatch_impl.rs taps every dispatched intent
through:
intent.fired
→ ctx.try_telemetry_context()
→ ConsentStore::is_granted()? if not → return
→ DynamicReporter::record(&Event)
├── recent_log.push(event.to_owned()) // user-visible
├── recent_log_revision.set(version + 1) // signals widget rebuild
└── active_adapter.record(event) // outbound
The recent_log_revision signal lives on DynamicReporter and is
the binding the PrivacySettings widget watches at
BindingLevel::Rebuild so its "Inspect data sent" accordion stays
in sync without polling.
3. Adapters
3.1 StubReporter (testing)
In-memory Vec<OwnedEvent> collector. The last_recorded_name()
helper makes integration tests trivial:
#![allow(unused)] fn main() { let stub = Rc::new(StubReporter::anonymous()); let bundle = TelemetryBundle::new(1).with_anonymous(stub.clone()); // ... assert_eq!( stub.last_recorded_name().as_deref(), Some("intent.dispatched"), ); }
Both anonymous() and pseudonymous("uuid") constructors exist.
3.2 PlausibleAdapter (anonymous mode → Plausible)
Wire-format: {name, url, domain, props} POSTed to
<endpoint>/api/event. Synthetic app://<domain>/<event-name> URL
since Plausible expects a URL and we don't have one.
#![allow(unused)] fn main() { let adapter = PlausibleAdapter::builder() .endpoint("https://plausible.io/api/event") // or self-hosted .domain("my.app") .max_batch_size(50) .flush_interval(Duration::from_secs(60)) .persistent_queue_path(paths.data_dir().join("plausible-queue.redb")) .endpoint_override( settings.signal_for(&scopes::TELEMETRY_ENDPOINT_OVERRIDE).get(), ) // no-op when empty .build(); }
Anonymous-by-design: no install_id ever, fetch_remote_data /
erase_remote_data always return Unsupported. CNIL audience-
measurement-exemption posture by default.
See crates/teksilo-analytics-plausible/
and examples/telemetry_plausible/.
3.3 TeksiloAdapter (anonymous + pseudonymous → teksilo-collector)
Home-grown gRPC adapter for the Teksilo-operated
teksilo-collector backend. Single adapter
covers both modes; flip via .install_id(uuid) on the builder.
#![allow(unused)] fn main() { let adapter = TeksiloAdapter::builder() .endpoint("https://collector.example.com:50051") .product_id("my.app") .bearer_token("fct_id_secret") // from `teksilo-collector token mint` .tls(TlsClientConfig { // optional — server may run plain ca_pem: Some(std::fs::read("/etc/ssl/ca.pem")?), client_cert_pem: None, // optional mTLS client_key_pem: None, domain_name: Some("collector.example.com".into()), }) .install_id("UUID-STRING") // pseudonymous mode; omit for anonymous .max_batch_size(50) .flush_interval(Duration::from_secs(60)) .persistent_queue_path(paths.data_dir().join("teksilo-queue.redb")) .build(); }
In pseudonymous mode (install_id set):
supported_scopes()returnsConsentScope::all().- Every batch is tagged
mode = Pseudonymous. fetch_remote_data()callsTelemetry.Fetchand rebuilds aRemoteDataExport.erase_remote_data()callsTelemetry.Erase.
Multiple instances of the same Teksilo app, each with its own
install_id, hit the same teksilo-collector endpoint with the same
bearer token; per-product scope is enforced server-side.
See crates/teksilo-analytics-native/
and examples/telemetry_teksilo/.
3.4 OtlpAdapter (anonymous + pseudonymous → OTLP/HTTP logs)
Speaks OTLP/HTTP logs over JSON. Works with any
OTel-compatible collector — otelcol-contrib, Honeycomb,
self-hosted Tempo+Loki via the OTel collector's HTTP receiver.
#![allow(unused)] fn main() { let adapter = OtlpAdapter::builder() .endpoint("http://127.0.0.1:4318/v1/logs") .service_name("my.app") .service_version(env!("CARGO_PKG_VERSION")) .header("x-honeycomb-team", api_key) .max_batch_size(50) .flush_interval(Duration::from_secs(60)) .build(); }
The mapping is:
Teksilo Event OTLP LogRecord
──────────────────────── ────────────────────────────────────
event.name body.stringValue
event.category attributes["teksilo.category"]
event.timestamp timeUnixNano (string, OTLP/JSON)
event.install_id resource.service.instance.id (when set)
event.session_id attributes["teksilo.session_id"]
event.props.<key> attributes["teksilo.<key>"]
Anonymous-mode batches (no install_id) omit
service.instance.id; the OTel collector treats them as aggregate
logs.
Queue durability — intentional asymmetry. Unlike the Plausible
and Teksilo adapters, OtlpAdapter has no .persistent_queue_path(...)
method: pending events live in an in-memory VecDeque and are lost
on hard exit. The OTel deployment model expects a collector
(sidecar, system service, or localhost:4318) to own the durability
layer via its file_storage extension or built-in queue. Layering
redb inside the desktop adapter would duplicate work the collector
already does. Apps that need client-side durability against hard
exits should run a local collector with file_storage.
Fetch + erase. OTLP has no read or delete RPC, so
fetch_remote_data / erase_remote_data return
FetchUnsupportedByBackend / ErasureUnsupportedByBackend. The
PrivacySettings widget hides the "Get my data" / "Erase my data"
controls when these come back.
See crates/teksilo-analytics-otlp/.
3.5 Retry semantics — comparison
The three adapters share the same outline (drain → send → on failure, exponential backoff with jitter) but differ in how a failed batch interacts with the queue. The differences are visible in operations and in stats counters.
| Behavior | Plausible | OTLP | Teksilo |
|---|---|---|---|
| Send unit | Per event (one HTTP POST per event) | Per batch (one OTLP request per drain) | Per batch (one gRPC call per drain) |
| First failure inside a drain | Re-enqueue failed event at the tail, mark hit_retry, re-enqueue remaining events without trying | Push the whole batch back to the front of the buffer in reverse order | Re-enqueue every event, reset channel = None to force re-dial |
| Subsequent events in the same drain | Skipped — re-enqueued unsent | n/a (batched) | n/a (batched) |
| Order preservation across retries | FIFO only among events that succeeded; failed events drift to the tail | Strict — failed batch retries before any newer events drain | Strict — failed batch is the first thing the next attempt sends |
| Effect on flush latency for poison events | Bounded — newer events still ship; bad event keeps cycling at the tail | Head-of-line blocking — buffer stuck behind the bad batch | Head-of-line blocking — drain breaks, retried on next opportunity |
| Backoff reset | Reset on first Accepted | Reset on Accepted | Reset implicitly by re-dial |
When the differences matter:
- Plausible's tail-requeue is the right call for analytics ordering tolerance — losing a few events to the tail beats blocking the queue behind a poison event.
- OTLP's head-requeue preserves strict log order, which OTel consumers (Tempo, Honeycomb) sometimes assume.
- Teksilo's batch-requeue + redial matches the gRPC stream model: a transient stream error is treated as fatal to the current channel; subsequent batches start from a fresh dial.
Apps that need strict ordering across all events should prefer OTLP or Teksilo. Apps that prioritize availability under transient server flakiness should prefer Plausible.
4. The PrivacySettings widget
Drop-in widget that surfaces every consent + RGPD obligation. Lives
in teksilo-widgets:
#![allow(unused)] fn main() { use teksilo::widgets::PrivacySettings; let widget = PrivacySettings::new() .data_processor_name("MyCo SAS") .privacy_policy_url("https://example.com/privacy") .compact(false) // first-run modal mode .show_inspect(true) // "Inspect data sent" accordion .show_mode_switch(true) // anonymous ↔ pseudonymous (when both adapters configured) .show_identity_row(true) // install_id + Get my data + Erase my data .inspect_event_count(50); }
Layout (in order, top-to-bottom):
PrivacySettings
├── Heading
├── Plain-language Art. 13 notice
│ (controller, processor, purposes, lawful basis,
│ retention, withdrawal right, optional policy URL)
├── Per-scope toggles
│ (anonymous_metrics / crash_reports / feature_flags,
│ intersected with reporter.supported_scopes() — toggles
│ for unsupported scopes are HIDDEN, not just disabled)
├── Reject all ←→ Accept all (CNIL parity, GDPR Art. 7)
├── Identity row (pseudonymous mode only:
│ install_id display
│ Get my data → opens a save-as-JSON file dialog
│ Erase my data → confirm → server delete + local discard + withdraw)
├── Inspect data sent (accordion; lists last N events)
├── Privacy mode switch (when both adapters configured:
│ confirm → wipe install_id + queue + reset consent + flip mode)
└── Withdraw consent (footer, equal prominence to Accept)
Confirmation dialogs (MessageBox::question + OkCancel) gate the
destructive actions: erase, withdraw, mode switch. Misclicks survive
a confirm step.
The "Inspect data sent" accordion auto-refreshes as events land —
the recent_log_revision signal triggers a widget rebuild whenever
DynamicReporter::record or discard_pending fires.
i18n: 42 keys under the privacy-* namespace in
crates/teksilo-widgets/locales/en-US.ftl
and fr-FR.ftl. Apps install
the framework bundle via I18nConfig::framework_locales(teksilo_widgets::framework_locales()).
5. Configuration layering
Three places where telemetry behavior is set, in order of precedence:
| Concern | Where set | Mechanism |
|---|---|---|
| Adapter type / wire format / API token | Build-time in the binary | Adapter builder calls in main.rs |
| Default mode, retention policy, processor name | App-builder time | TelemetryBundle::with_* |
| User's per-scope toggles | Runtime, per-user | ConsentStore + per-scope SettingsKey<bool> mirror |
| User's endpoint override | Runtime, per-deployment | scopes::TELEMETRY_ENDPOINT_OVERRIDE — apps feed this into adapter builder via .endpoint_override(...) |
| Active mode | Runtime | DynamicReporter::active, mutated by the widget |
| Install ID | Runtime, automatic | SettingsFile<InstallIdFile>, 13-month rotation |
| Consent decision | Runtime, persistent | SettingsFile<ConsentFile> |
| Pending events | Runtime, persistent (Plausible + Teksilo adapters) | redb at AppPaths::data_dir().join("<adapter>-queue.redb") |
| Pending events | Runtime, in-memory only (OTLP adapter) | VecDeque<OwnedEvent> — durability deferred to the OTel collector |
Endpoint override
Set the telemetry.endpoint_override settings key in
general.toml to redirect all adapters at a different server
without rebuilding:
[telemetry]
endpoint_override = "https://my-other-collector.example.com:50051"
Apps wire it through:
#![allow(unused)] fn main() { let override_url = settings .signal_for(&teksilo_telemetry::scopes::TELEMETRY_ENDPOINT_OVERRIDE) .get(); let adapter = TeksiloAdapter::builder() .endpoint("https://default-collector.example.com:50051") .endpoint_override(override_url) // applies iff non-empty .product_id("my.app") .build(); }
Triggers the recipient-change re-prompt rule in ConsentStore:
when the endpoint stored at consent grant time differs from the
endpoint at app start, consent flips back to Unknown and the
widget re-asks. RGPD Art. 13 transparency.
6. RGPD / GDPR compliance summary
The framework provides the SDK plumbing; the app developer is the data controller and remains responsible for the legal artifacts (Art. 13 controller notice, privacy policy, DPA with processors, etc.). What Teksilo does automate:
| Article | What Teksilo does |
|---|---|
| Art. 6(1)(a) consent | ConsentStore. No event flows in Unknown or Denied. |
| Art. 6(1)(f) legitimate interest (anonymous mode) | Anonymous-mode adapters set supported_scopes() = anonymous_metrics_only() and report install_id() = None. CNIL audience-measurement-exemption posture by default. |
| Art. 7(3) right to withdraw | Withdraw button in the widget, equal prominence to Accept. |
| Art. 13 transparency | Plain-language notice block in the widget — controller, processor, purposes, lawful basis, retention, recipients. |
| Art. 15 right of access | "Get my data" button → fetch_remote_data() → JSON export with file-save dialog. |
| Art. 17 right to erasure | "Erase my data" button → confirm → erase_remote_data() → local queue wipe + consent withdrawal. |
| Art. 20 portability | RemoteDataExport is JSON-serializable, self-describing (includes schema_version, endpoint, adapter). |
Anonymous mode never collects per-user data, so Art. 15 / 17 / 20 buttons hide automatically — there's nothing to fetch or erase.
7. Code references
| File | Purpose |
|---|---|
crates/teksilo-core/src/telemetry/event.rs | Event, OwnedEvent, Prop, RemoteDataExport, serde derives |
crates/teksilo-core/src/telemetry/reporter.rs | UsageReporter trait, TelemetryError |
crates/teksilo-telemetry/src/bundle.rs | TelemetryBundle, OpenedTelemetry, PrivacyPolicy |
crates/teksilo-telemetry/src/consent.rs | ConsentStore, ConsentFile, settings-mirror integration |
crates/teksilo-telemetry/src/install_id.rs | InstallId with 13-month rotation |
crates/teksilo-telemetry/src/dynamic_reporter.rs | DynamicReporter, recent-log tee, revision signal |
crates/teksilo-telemetry/src/queue.rs + queue/ | EventQueue trait (queue.rs), InMemoryEventQueue (queue/mem.rs), PersistentEventQueue (queue/persistent.rs) |
crates/teksilo-telemetry/src/scopes.rs | SettingsKey<bool> constants for per-scope mirror, TELEMETRY_ENDPOINT_OVERRIDE, TELEMETRY_REGION_OVERRIDE |
crates/teksilo-telemetry/src/ext.rs | TelemetryExt accessors on BuildContext / EventContext |
crates/teksilo-widgets/src/privacy_settings.rs | The widget |
crates/teksilo-widgets/locales/en-US.ftl | i18n keys (privacy-*) |
crates/teksilo-analytics-plausible/ | Plausible adapter |
crates/teksilo-analytics-native/ | Home-grown gRPC adapter |
For the home-grown server backend, see the teksilo-collector sibling repo.
8. Worked examples
-
examples/telemetry_plausible/— anonymous mode against Plausible. Three intent buttons + thePrivacySettingswidget. Default endpointhttp://127.0.0.1:8000/api/event; override viaPLAUSIBLE_ENDPOINTenv var. -
examples/telemetry_teksilo/— anonymous OR pseudonymous against a self-hostedteksilo-collector. Env vars:TEKSILO_ENDPOINT,TEKSILO_PRODUCT_ID,TEKSILO_TOKEN,TEKSILO_INSTALL_ID(set to flip to pseudonymous),TEKSILO_TLS_CA,TEKSILO_TLS_DOMAIN. See the example's docstring for the complete run procedure including spinning up the siblingteksilo-collector.
Async Runtime Reference
Scope: the optional, opt-in teksilo-async crate (plus the teksilo-tokio
/ teksilo-async-std reactor adapters) — a main-thread async executor for
imperative async inside UI handlers.
Mental model in one line:
TeksiloAppBuilder::install_async() → ctx.spawn_local(async move { … }) → Signal::set(result)
Teksilo keeps the view layer synchronous: async is the backend's concern.
This crate is the escape hatch for the cases where a handler wants to write
linear async / .await — sequencing or branching several awaits in one place
— instead of restructuring into callbacks. It is off by default; nothing in
teksilo-core or teksilo-app gains an async dependency unless you opt in.
When to use it (and when not to)
| You want… | Use |
|---|---|
| Background work → push a result into the reactive UI | The data path: ctx.subscribe_event(...) + Signal::set (no executor). See architecture.md §9.4. |
A handler that does let a = f().await; let b = g(a).await; sig.set(b); | ctx.spawn_local(...) (this crate). |
| Offload one blocking call and await its result | spawn_blocking(...) (this crate). |
.await a native tokio / async-std future (timer, socket, reqwest) | teksilo-tokio / teksilo-async-std. |
For the common "kick off work, update the UI when it lands" case the reactive
data path is simpler and needs no executor — reach for spawn_local only when
the imperative shape genuinely reads better. (Teksilo apps backed by a data
layer such as Qleany generally keep async in that layer entirely.)
The three crates
| Crate | Adds | Depends on |
|---|---|---|
teksilo-async | the executor, spawn_local / spawn_local_with, spawn_blocking, install_async() | teksilo-app, teksilo-core (+ async-channel, thiserror) |
teksilo-tokio | install_async_tokio() + TokioHandle; awaits native Tokio futures | teksilo-async + tokio |
teksilo-async-std | install_async_async_std(); awaits native async-std futures | teksilo-async + async-std |
teksilo-async alone is runtime-free: spawn_blocking offloads to a plain
std::thread, so you can run blocking work and await its result with no async
runtime at all. The adapter crates only add the ability to .await native
ecosystem futures directly.
Through the umbrella teksilo crate these are the async, tokio, and
async-std features (the latter two imply async). The spawn surface is in
the prelude when enabled.
Quick start
use teksilo::prelude::*; // brings the spawn extension traits when `async` is on fn main() { TeksiloAppBuilder::new() .theme(intui::light()) .install_async() // ← register the executor .initial_window(/* … */) .run(); } // inside an event handler (`&mut EventContext`): let status = self.status.clone(); // Signal<Status> (Rc clone) ctx.spawn_local(async move { status.set(Status::Loading); // spawn_blocking returns Result<T, BlockingError> (Err only if it panics) let report = spawn_blocking(move || expensive_report(&input)).await; status.set(match report { // resume on the UI thread Ok(r) => Status::Ready(r), Err(e) => Status::Failed(e.to_string()), }); }) .detach(); // fire-and-forget; drop the handle instead to cancel
Demo: cargo run -p async-demo.
The owned-handles model
A spawn_local future is single-threaded (!Send) and runs on the UI thread.
It captures Rc-based Signal handles and mutates them on resume — that is
how an async result reaches the UI. There is no EventContext after .await
(it is borrow-transient — it exists only during a synchronous event dispatch),
so UI updates flow through owned handles, exactly matching the reactive model.
spawn_local is fire-and-forget — its future's output is (); surface results
by setting a Signal, or use spawn_local_with (below) for a one-shot callback
that runs with a context.
This is deliberately the same shape as Slint's spawn_local: capture
component/state handles, set them on resume.
spawn_local_with — a fresh context for one-shot ambient ops
When the result needs an ambient op that requires an EventContext
(open_window, send_intent, set_theme, …), use spawn_local_with. The
future body runs handle-only; the result is delivered to a callback with a
fresh EventContext bound to the originating window's tree:
#![allow(unused)] fn main() { ctx.spawn_local_with( async move { fetch_report(url).await }, // body: handle-only move |report, ctx: &mut EventContext| { // completion: real ctx, on the origin window ctx.open_window(WindowConfig::new().title("Report").root(/* report */)); }, ) .detach(); // keep it alive — dropping the handle cancels }
For a multi-step sequence of ambient ops, chain: the completion callback can
itself spawn the next future. There is intentionally no re-entrant
"current context" available mid-future — that would couple the executor to
window internals and reopen the RefCell double-borrow class. (It could be
added later as a separate, additive API if a real need appears.)
spawn_blocking
#![allow(unused)] fn main() { let result = teksilo_async::spawn_blocking(move || expensive_sync_call()).await; // result: Result<T, BlockingError> }
Runs the closure on a dedicated std::thread and resolves to
Result<T, BlockingError> through a one-shot channel. Needs no async runtime —
the channel's waker nudges the executor when the worker finishes. The closure
and its result must be Send; the awaiting task stays on the UI thread. A panic
in the closure is caught on the worker and surfaced as
BlockingError::Panicked — it does not unwind through the UI thread.
Threading & the loop hook (zero idle cost)
The executor is driven once per event-loop turn by an async-agnostic hook
in teksilo-app:
#![allow(unused)] fn main() { TeksiloAppBuilder::on_loop_tick(poll_source: Rc<Cell<bool>>, tick: impl FnMut() -> bool) }
teksilo-app only ever sees FnMut + Rc<Cell<bool>> — it has no async
dependency. Each turn (about_to_wait) the hook polls the executor; a true
return triggers a repaint of the open windows (a task may have mutated a
Signal). While idle the loop sleeps in ControlFlow::Wait (zero CPU) until a
task is woken.
The wake path is the crux of the cross-thread story. Every task's leaf futures
are polled with one shared Waker (Arc<ExecWaker>, Send + Sync). On
wake — possibly from a spawn_blocking worker thread or a runtime's reactor
thread — it sets an atomic flag and nudges the winit event loop through the
(Send + Sync) AppEventPoster. It never touches the !Send task queue; the
main thread re-polls live tasks on the next tick. Tasks are dropped on
completion; dropping a TaskHandle cancels (the future is dropped on the next
tick), and .detach() lets it run independently.
Reactor notes: tokio vs async-std
teksilo-tokioowns a multi-threadtokio::runtime::Runtime(reactor + timer driver on background threads).install_async_tokio()wraps each tick inruntime.enter(), so a Tokio leaf future polled on the UI thread registers with that background driver and registers the executor'sWakeras its wake target. When the timer/socket is ready, the background driver wakes the executor and the loop ticks again.TokioHandle(in app-state) exposes.spawn()forSendtasks and.handle().teksilo-async-stdneeds no per-tick guard — async-std's reactor is global and auto-starting, soinstall_async_async_std()is justinstall_async()plus the async-std dependency.
Both are validated headlessly (a real sleep awaited on the executor resolves)
in each crate's tests/.
Relationship to the subscription data path
The reactive data path (EventSource / ctx.subscribe_event) and this executor
are complementary, not competing:
- A background publisher (a Qleany
LongOperation, a file watcher, a message bus) →subscribe_event→Signal::set. No executor; the result is pushed in. Best for "data arrives, UI reacts." - An imperative flow that sequences/branches awaits in one handler →
spawn_local. Best when the callback shape would fragment the logic.
Both deliver their effects on the UI thread and both update the UI through
Signals.
Limitations
spawn_localfutures cannot hold anEventContextacross.await; ambient ops post-await go throughspawn_local_with's completion callback or aSignalanActionwatches.- A
spawn_blockingclosure panic is caught and returned asBlockingError::Panicked. A panic in aspawn_localbody (your own async code) still propagates on the UI thread — keep those panic-free. - The adapters bring their runtime as a normal dependency; enabling both
tokioandasync-stdin one binary pulls both runtimes (rarely desirable). - Task progress repaints all open windows (not just the one whose
Signalchanged), matching thesubscribe_eventdata path. Negligible for single- window apps; a per-window targeted repaint would need framework-level dirty tracking.
Code reference
| Concern | File |
|---|---|
Executor, AsyncRuntimeHandle, TaskHandle, cross-thread waker | crates/teksilo-async/src/executor.rs |
spawn_blocking | crates/teksilo-async/src/blocking.rs |
EventContextAsyncExt (spawn_local / spawn_local_with) | crates/teksilo-async/src/ext.rs |
install_async() | crates/teksilo-async/src/install.rs |
Completion router (registry + Send payload) | crates/teksilo-core/src/async_completion.rs |
Neutral loop hook (on_loop_tick, poll source) | crates/teksilo-app/src/app.rs |
| Completion routing + window-close purge | crates/teksilo-app/src/app.rs, window_manager.rs |
| Tokio adapter | crates/teksilo-tokio/src/lib.rs |
| async-std adapter | crates/teksilo-async-std/src/lib.rs |
| Demo | examples/async_demo/src/main.rs |
Splitter
Splitter is an N-pane split container with draggable, collapsible
dividers — the Qt QSplitter model. It replaces the old two-pane
SplitView (no backward compatibility) and is the building block for the
future DockingLayout.
- Widget: crates/teksilo-widgets/src/splitter.rs
- Model: crates/teksilo-widgets/src/splitter/model.rs
- Sizing engine: crates/teksilo-widgets/src/splitter/distribute.rs
- Handle: crates/teksilo-widgets/src/splitter/handle.rs
- Tier-3 style: crates/teksilo-core/src/styles/splitter_style.rs + recipe
- Demo:
cargo run -p splitter
Model + widget
All layout state lives in a shared, cloneable SplitterModel
(Rc<RefCell<…>>, the SceneModel/ListModel handle pattern). The app
holds a clone to read / mutate / persist; the widget renders it and reacts
to the model's version signal at BindingLevel::Relayout — so any
external change reflows the panes with no rebuild.
#![allow(unused)] fn main() { use teksilo_widgets::{Splitter, SplitterModel, PaneDescriptor, Orientation}; let model = SplitterModel::from_panes(vec![ PaneDescriptor::new().size(220.0).min_size(160.0).stretch(0.0).collapsible(true), // sidebar PaneDescriptor::new().min_size(320.0).stretch(1.0), // editor PaneDescriptor::new().size(280.0).min_size(200.0).stretch(0.0).collapsible(true), // inspector ], Orientation::Horizontal); Splitter::new(model.clone()) .pane(sidebar).pane(editor).pane(inspector) // N content panes, model order .pane_label(0, tr!(sidebar())); // optional a11y region name }
Splitter builder: new(model), .pane(impl Widget) / .pane_id(WidgetId)
(repeated; count must match model.pane_count()), .child(...) (a teksu!
alias for .pane), .pane_label(i, impl Into<Prop<String>>),
.style(impl SplitterStyle), .enabled(bool).
Orientation, sizes, min/max, stretch, gutter, snap, and collapse all live on
the model (single serializable source of truth, shared with a
DockingLayout). Each content pane is wrapped in an internal clip so
overflow can't bleed into a gutter or sibling.
Sizing
Pixel sizes are the source of truth (Qt). Each layout pass projects the
model's stored sizes onto the current bounds via the pure
distribute
function; a container resize never writes back, so drag positions
survive resizes. Stored sizes change only on drag, programmatic mutation,
or structural insert/remove.
- Stretch (
PaneDescriptor::stretch, QtsetStretchFactor): positive container slack is distributed tostretch > 0panes proportional to weight;stretch = 0panes keep their pixel size. If no pane stretches, the surplus goes to the last pane. - Min/max: a deficit (container smaller than the sum of sizes) shrinks
panes proportional to their room above
min, never below it.maxclamps growth. An unsatisfiable deficit (container smaller thanΣ min) floors every pane at its minimum and is accepted as overflow that the container clips — on that path the returned sizes sum to more thanavailable, not≤ available. - Equal-size panes:
SplitterModel::new(n, orientation)(eachstretch = 1, no initial size) yields equal shares.
Splitter reports its own min as Σ min[i] + (N−1)·gutter, so a
min-respecting parent never forces overflow.
PaneDescriptor::min/max are app-supplied — a minimum derived from a
ratio can come out as NaN (a 0.0 / 0.0), and f32::clamp panics by
contract on a NaN bound. distribute normalises both bounds before
clamping, behind a debug_assert! that still fails loudly in a debug
build. The two bounds are not treated symmetrically: max = INFINITY
is the ordinary way to spell "unbounded" (it's what max_size: None
unwraps to), so it passes through untouched, and only a NaN max
normalises to INFINITY; min has no such "unbounded" reading, so any
non-finite min (NaN or ±INFINITY) normalises to 0.0. A max of
-INFINITY is left for the existing min > max guard, which resolves it
to the (finite) min.
Collapse
Panes marked .collapsible(true) can fold to zero width/height, animated
(reduced-motion aware — snaps under prefers-reduced-motion). A collapsed
pane's divider stays visible and draggable (it's how you restore it). Four
triggers:
- Programmatic —
model.set_collapsed(i, bool)/toggle_collapsed(i)(animated). Ignores thecollapsibleflag (that flag only gates user interaction, like QtchildrenCollapsible). - Double-click a divider — toggles the adjacent collapsible pane.
- Drag-past-min snap — drag a pane below
min − snap_offsetto snap it collapsed; drag the divider back out to restore (instant, the pointer is the motion). - Keyboard — focus a divider (Tab) and press Enter.
Dynamic panes (hide / show, add / remove)
Three distinct mechanisms, by how much they change:
| Pane | Its gutter/handle | Content | Reactive (no rebuild)? | |
|---|---|---|---|---|
| Collapse | size → 0, animated | stays (grab it to restore) | dormant | yes |
| Hide | size → 0, animated | removed — reads as absent | dormant | yes (pane pre-mounted) |
| Add / remove (new content) | created / destroyed | created / destroyed | brand-new | no — rebuild |
Hide / show a whole pane and its gutter via a per-pane visible flag —
the reactive "add / remove a pane from a fixed set" trick (the panes are
pre-mounted; toggling visible makes one appear/disappear with its divider,
animated, no rebuild):
#![allow(unused)] fn main() { let model = SplitterModel::from_panes(vec![ PaneDescriptor::new().size(220.0).collapsible(true), // sidebar PaneDescriptor::new().stretch(1.0), // editor PaneDescriptor::new().size(280.0).visible(false), // inspector — starts hidden ], Orientation::Horizontal); model.set_pane_visible(2, true); // inspector + its gutter grow in (animated) model.set_pane_visible(2, false); // …and vanish; content goes dormant }
A hidden pane's content is parked dormant and its gutter's handle is disabled (Tab-skipped, event-gated) and removed from the AT tree. Use this for toggling a whole sidebar / inspector / terminal, or a fixed-max split. Caveat: two visible panes separated only by hidden panes have no divider between them (you can't resize across a hidden middle pane until you show one) — for that, use add/remove below.
Add / remove with new content (e.g. VS Code drag-a-tab-to-split, arbitrary
content) is a structural change → rebuild the Splitter with the new pane
list (insert_pane/remove_pane carry sizes across the rebuild). The
seamless feel comes from the collapse machinery — insert collapsed then
expand to grow in, or collapse then remove to shrink out:
#![allow(unused)] fn main() { // Grow a new pane in: model.insert_pane(idx, PaneDescriptor::new().collapsed(true).collapsible(true)); // …rebuild the Splitter with the new content list, then: model.set_collapsed(idx, false); // animates 0 → full // Shrink one out, then drop it (on the tween's end): model.set_collapsed(idx, true); // animates full → 0 // …after the tween: model.remove_pane(idx) + rebuild without that content. }
The full drag-tab-to-split orchestration (split tree + drop zones + rebuild)
is the future DockingLayout's job; Splitter is its building block and
provides the animated grow-in / shrink-out.
Accessibility
Each divider is a Role::Splitter node: localized name, numeric_value /
min / max / value ("42%"), numeric_value_step, bar-axis orientation,
set_expanded of the adjacent collapsible pane, and controls relations to
the two panes it resizes. Actions: Focus, Increment, Decrement, and
Collapse/Expand when a neighbor is collapsible. Resize: arrows /
Home / End (and AccessKit Increment/Decrement). The focus indicator
shows on keyboard focus only (FocusOrigin). Labeled panes
(.pane_label) become named Role::Group regions; unlabeled panes stay
transparent (their content represents itself).
Save / restore (persistence)
The model exposes a serde DTO. Only user-controllable values (per-pane
stored_size + collapsed) are serialized; structural config
(min/max/stretch/collapsible) is app-declared and reconstructed each run
(Qt saveState parity).
#![allow(unused)] fn main() { let state: SplitterState = model.export_state(); // serde + Versioned let ok: bool = model.import_state(&state); // false if pane count differs }
SplitterState implements teksilo_settings::Versioned, so it drops into
the framework's persistence layer. Don't use one SettingsFile per
splitter — compose every splitter's state into one app/workspace struct
and persist that as a single file:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Default, Clone)] struct WorkspaceLayout { version: u32, main: SplitterState, bottom: SplitterState } impl Versioned for WorkspaceLayout { /* ... */ } let file: SettingsFile<WorkspaceLayout> = SettingsFile::load(path, debounce, &migrator)?; main_model.import_state(&file.snapshot().main); // restore on launch let f = file.clone(); let m = main_model.clone(); let _obs = main_model.version().observe(move |_| { // debounced auto-save let _ = f.mutate(|w| w.main = m.export_state()); }); }
import_state bumps the model's version, so restoring reflows
immediately; collapsed panes come back collapsed instantly (no open
animation on load). A pane-count mismatch is handled gracefully (restore is
skipped, returns false).
Runtime structure changes
insert_pane / remove_pane / replace_pane_desc mutate the model. Because
changing a container's child set is a rebuild in retained mode, the app
reconstructs the Splitter widget (with the new .pane(...) list) on a
structural change — the model carries the persistent size/collapse state
across that rebuild.
Tier-3 style
SplitterStyle::make_handle(cfg, ctx) paints the divider chrome (line /
hover-dwell / focus indicator); layout dimensions stay on the model.
Install per-call (.style(...)) or theme-wide
(theme.style_slots.splitter = Some(Rc::new(...))). The default
RecipeSplitterStyle ships the IntUI look.
Not implemented (intentional)
Non-opaque / rubber-band deferred resize (Qt setOpaqueResize(false)):
Teksilo resizes live, the modern default.
DockingLayout
DockingLayout is a VS Code-style dockable layout: a fixed centre slot
(the app's main content — the "editor") surrounded by four collapsible,
splittable, draggable side regions — leading / trailing / top / bottom. It
is a layout like any other (not a window shell à la Qt QMainWindow), backed
by a cloneable, serializable [DockingModel]. No floating docks.
- Widget + orchestrator: crates/teksilo-widgets/src/docking.rs
- Geometry engine: crates/teksilo-widgets/src/docking/geometry.rs
- Model + state: model.rs, state.rs
- Panels / drag / rail / handle: panel.rs, drag.rs, activity_bar.rs, resize_handle.rs
- Demo:
cargo run -p docking
The structure — four levels
DockingLayout
└── Centre (one app widget, always present) + 4 Sides
└── Side = [optional always-visible DockActivityBar rail] + collapsible content region
└── content region holds ONE tab stack (in-side strip optional, or
replaced by the rail)
└── Tab → a Splitter of panes, one DockWidget per pane
└── pane = a DockWidget. A **sole** pane renders bare (the
tab / rail is its header); a **split** pane (one of several)
is wrapped in a single-item ToolBox whose draggable header
titles the dock and is its drag handle.
There is no multi-section pane (no QToolBox-style accordion): stacking two DockWidgets side-by-side adds a Splitter pane (each its own single-item ToolBox), separated by the Splitter. A DockWidget can be dragged out to become its own tab, dropped onto a pane's edge to split it, or dropped onto a pane's centre to stack it (append a Splitter pane to that tab). A whole Side is shown/hidden (animated); the activity rail (when on) stays visible and is the reopen affordance.
Quick start
#![allow(unused)] fn main() { use teksilo::widgets::{DockingLayout, DockingModel, DockWidget, DockWidgetId, DockSide, DockOpenLocation}; let model = DockingModel::new(); let explorer = DockWidgetId::fresh(); let terminal = DockWidgetId::fresh(); // Leading side as a VS Code activity rail: model.set_side_rail(DockSide::Leading, 48.0); let layout = DockingLayout::new(model.clone()) .center(editor_widget) .dock(DockWidget::new(explorer, lit!("Explorer"), |_| ExplorerPanel::new()) .default_location(DockOpenLocation::side(DockSide::Leading))) .dock(DockWidget::new(terminal, lit!("Terminal"), |_| TerminalPanel::new()) .default_location(DockOpenLocation::side(DockSide::Bottom))); // Initial layout (panels are registered by `.dock(..)` above, so this is valid): model.open_dock(explorer, DockOpenLocation::side(DockSide::Leading)); model.open_dock(terminal, DockOpenLocation::side(DockSide::Bottom)); }
DockWidget::new(id, title, factory) declares a panel; factory(id) builds its
content lazily. .icon(..), .default_location(..), .header_actions(..),
.show_header(..) configure chrome (see “Dock header & options” below).
DockingLayout::new(model).center(w).dock(dw)… assembles the widget;
.dock(..) registers the panel eagerly, so the initial layout can be set on
the model before mounting.
Sides, corners, and geometry
The five region rectangles are computed directly (a border-layout with
configurable corners — Qt QMainWindow::setCorner). A nested-Splitter tree
genuinely cannot express per-corner ownership (in any splitter nesting the
corners always belong to the outer axis), so DockingLayout runs a small pure
geometry::compute_rects
engine in place_children.
- Each side contributes, along the axis toward the centre: an always-visible rail strip (when in Rail presentation), a resizable/collapsible content rect, and a resize handle.
- Per-corner ownership (
model.set_corner(DockCorner::BottomLeading, DockSide::Bottom | DockSide::Leading)) decides whether the bottom bar spans under the leading column or vice-versa. The default has top/bottom spanning full-width. - Corner degradation: if a corner's owner side is hidden, the corner falls to the other adjacent side, else the centre.
- All extents are clamped non-negative — no container size (down to 0×0 or smaller than the sum of minimums) produces a negative or overlapping rect; the centre shrinks to zero first.
- RTL mirrors leading/trailing; top/bottom never mirror.
Resizing, hide/show
Each side has a DockResizeHandle (Role::Splitter) between its content and the
centre: drag to resize (window-absolute anti-jump math), arrows / Home / End
to resize / hide / show from the keyboard, double-click to hide, or drag past the
minimum to snap it hidden. A side is one shown/hidden concept (the user
equates "collapsible = hideable"); a hidden side keeps its rail and is reopened
from the rail, the keyboard, or the programmatic API (VS Code Cmd+B). Show/hide
is animated (reduced-motion aware) and is a relayout, not a rebuild, so
content is preserved across it.
Tabs, stacking, splitting (within a side)
A side's content is a stack of tabs (each tab sized to its own content,
TabSizing::Independent). A tab's content is a Splitter of panes, one
DockWidget per pane. A sole pane renders bare (the tab / rail is its header)
unless the dock opts into its own header bar with
DockWidget::show_header(true); a split pane is wrapped
in an Accordion whose draggable header titles the dock and collapses on
click — header-only (taps/drags inside the content are absorbed, so clicking
the panel body never collapses or moves it). Collapsing
folds the Splitter pane down to the header (its siblings grow to take the
space) and expanding restores it to the same size — the accordion drives
SplitterModel::set_collapsed, and the pane's collapsed_size is the header
height (a non-zero collapsed_size keeps the collapsed pane's header visible
rather than folding it to nothing). Orientation follows the side: leading/trailing use a vertical Splitter +
vertical Accordion headers; top/bottom use a horizontal Splitter +
horizontal Accordion (rotated-90° vertical header strip). The in-side tab
strip shows for the Strip presentation (a denser 38 dp compact_bar); the
Rail presentation replaces it with the activity rail. The content-vs-centre
resize divider (DockResizeHandle) renders with the active SplitterStyle, so
it looks and behaves exactly like a Splitter divider.
Activity rail (DockRail)
Set model.set_side_rail(side, thickness) to put a side in Rail
presentation: an always-visible DockActivityBar (a Role::TabList) replaces
the in-side strip. Clicking an inactive item selects + shows the side; clicking
the active item hides the side. The rail stays visible while the side is
hidden — it is the reopen affordance.
The rail is a vertical column of one icon per tab, pushed to the top.
It hugs each side's leading edge: for the leading / trailing columns that's
the outer (window) edge; for the top / bottom bands the vertical rail is a
column on the leading cross-edge (left in LTR, right in RTL) with the dock
content inboard to its side — so a top/bottom rail reads like a leading rail
rather than a thin horizontal strip. A hidden leading / trailing side keeps
its rail visible (the reopen affordance); a hidden top / bottom band
collapses completely (rail included — a vertical rail can't stand in a
zero-depth band), so reveal it again from an external control (a toolbar
"toggle panel" button, set_side_visible(side, true), or reveal_dock). Style
it with DockingLayout::rail(DockRail::new(side)…):
.size(IconButtonSize)— one size for every item (Compact … Hero)..top_slot(|| …)/.bottom_slot(|| …)— fixed widgets pinned above the items and at the very bottom (a logo on top, settings/account at the bottom — the VS Code convention). To make a slotted control track the rail's item size, bindmodel.rail_size_mode_signal(side)inside the factory and map it to anIconButton::size; the rail rebuilds its slots whenever the size mode changes, so reading the signal keeps the slot in step (the factory stays a plainFn() -> impl Widget, like every other slot)..overflow_icon(|| IconWidget…)— when the items don't all fit, the surplus are parked dormant and reached through this caller-chosen trigger, which opens a popover list of the overflowed entries..leading_slot(|| …)/.trailing_slot(|| …)— the Strip-presentation counterparts oftop_slot/bottom_slot, pinned at the start / end of the side's in-side tab bar. See "Bar slots on a Strip side" below..action(DockAction::new(…))— a dockless command button in the rail. See "Rail actions" below.
Rail actions (DockAction)
A rail item is normally one activity — a tab with a panel behind it. A
DockAction is the other thing an icon in that column can be: a plain command
that opens no panel.
#![allow(unused)] fn main() { const SETTINGS: DockActionId = DockActionId::named("app.settings"); DockingLayout::new(model).rail( DockRail::new(DockSide::Leading).action( DockAction::new(SETTINGS, tr!(settings()), || IconWidget::from_svg_icon(&GEAR), |ctx| ctx.send_intent(Intent::new("app.settings"))) .placement(DockActionPlacement::Pinned), ), ) }
An action is deliberately more restricted than an activity — it is never
draggable, never hidable, has no "Move to" menu, and is never overflow-parked
(it is reserved space). That matches VS Code's fixed Accounts / Manage cluster
and IntelliJ's stripe, where the only non-tool-window button is IDE-owned
chrome. It is rendered by the framework, so it tracks the rail's Default /
Compact / Icon + Label size mode, gets the same selected-surface highlight an
open activity gets, and places its tooltip to the side (a Below tooltip
would land on the next item down the column).
Placement picks which cluster it joins:
DockActionPlacement | Position |
|---|---|
Start | before the first activity item, flowing with them |
End | after the last activity item and after the overflow trigger, still flowing |
Pinned | past the spacer, anchored to the rail's far edge — VS Code's Accounts / Manage cluster, and where a Settings gear belongs |
DockActionId::named("…") is a const fn, so ids can be module-scope const
items. Ids are not persisted — an action carries no user-mutable state, so
nothing about it is serialized. The id exists so the accessibility tree and the
automation bridge can address the action stably across runs.
.toggled(signal) paints the selected surface while the signal is true. It is
reflect-only: the rail never writes the signal, so a derived signal is
safe here — unlike IconButton::toggle, which flips its signal on click.
on_activate owns every write.
Rail presentation only. A side in TabPresentation::Strip renders no
actions, and set_side_rail can flip presentation at runtime — so a side that
flips Rail → Strip drops its whole action cluster. If that is reachable in your
app, mirror the cluster with trailing_slot, which the same DockRail carries
alongside its actions.
Bar slots on a Strip side
top_slot / bottom_slot are Rail-presentation chrome. Their Strip
counterparts are leading_slot / trailing_slot, pinned at the start / end of
the side's own tab bar (the QTabWidget::setCornerWidget shape). The framework
composes your trailing slot with its own "hidden activities" hamburger, so
neither is dropped when both are present, and both render even on a side that
currently holds no docks.
They carry a weaker visibility contract than the rail slots, and the
difference is worth knowing before choosing one: the activity bar is built
whenever the side has a rail, so top_slot / bottom_slot survive the side
being collapsed; leading_slot / trailing_slot live inside the side's
TabWidget, within the collapsing content region, so they disappear with the
content when the side is hidden. If your content must survive a hidden side, use
Rail presentation — or host it outside the docking system.
The rail width follows the size mode. Switching Default / Compact / Icon +
Label resizes the whole strip (the rail thickness is derived from the effective
item size), not just the items. set_side_rail(side, thickness) enables the
rail; the rendered width tracks the mode. Any external widget can react to the
switch by binding model.rail_size_mode_signal(side) -> Signal<DockRailItemSize>
(the same signal a rail slot reads to resize itself).
The rail is a drop target, like a TabWidget that reorders + accepts
external tabs. While a dock tab (a rail item or a tab-strip header from any
side) or a single dock (a split-pane header) is dragged over the rail, it paints
an insertion line between items, and on drop relocates the activity to that
position: dragging one of the rail's own items reorders the side's tabs
(move_tab, same source/target side), a tab from another side moves here
(move_tab), and a single dock becomes a new activity at the drop position
(promote_to_tab). Dropping on a hidden side's rail reveals it. An empty
Rail-presentation side accepts the first drop this way too (its content area is
otherwise blank).
Context menus
Right-click a rail item or a dock tab for the per-activity menu (wired automatically — no app code):
Hide "<activity>"
──────────────
Move to ▸ <the other sides>
──────────────
☑ <activity> (one checkable row per activity in this side)
☑ <activity>
──────────────
Activity bar size ▸ Default / Compact / Icon + Label (rail item)
– or –
Tab size ▸ Text / Icon / Icon + Text (dock tab)
- Hide drops the activity from the rail / strip but keeps it in the model so it stays listable + restorable — it is not closed. The selected tab hands off to the nearest visible one.
- Move to relocates the whole tab to another side, shows that side, and
selects it (
move_tab). The submenu lists only enabled sides (DockingModel::enabled_move_targets); a side turned off withdisable_side(..)/set_side_enabled(.., false)is never offered (it would be silently rejected). When no enabled target remains the Move to entry is omitted entirely. - The checkable list toggles each activity's visibility (
set_tab_hidden). Each checkmark is bound to the activity's live hidden state, so it tracks an externalset_tab_hidden(e.g. a keyboard shortcut) while the menu is open. - Restoring when every activity is hidden (no tab/rail item to right-click):
in Rail presentation, right-click the empty rail (the
DockActivityBaralways shows) → the list + size submenu; in Strip presentation, the tab bar keeps a trailing hamburger (☰) that opens the same menu. The menu is placed withBelowPreferred, so it flips above / clamps to stay on-screen even for a bottom-docked bar. The same activity menu is reachable from tabs, rail items, theDockActivityBarbackground, and the dock-header⋮options button (see Dock header & options). - Activity bar size (
DockRailItemSize::{Default, Compact, Labeled}) and Tab size (DockTabDisplay::{Text, Icon, IconText}) are per-side, reactive, and persisted. The rail / strip rebind and re-render when they change.
Icons, titles, and tooltips. Every dock declares a title (DockWidget::new)
and, optionally, an icon (DockWidget::icon). Both the rail and the tab strip
use them per the size / display mode:
- Rail —
Default/Compactshow the icon alone (the title is a hover tooltip);Labeledadds a 90°-rotated title beneath the icon (the vertical-accordion look — no tooltip, the title is on screen). A dock with no icon falls back to its title's initial letter as the glyph. - Strip — the side's
DockTabDisplaymaps straight onto theTabWidget'sTabDisplayMode:Iconshows the icon alone (title → tooltip) and the tab sizes to its icon,Textthe title,IconTextboth (the tab grows to fit the icon). An icon-less dock inIconmode falls back to its title's initial letter (the full title stays in the tooltip + the content panel's AT name), so the mode is never a silent no-op.
Drive any of it from outside the menu too: model.set_tab_hidden(tab, ..),
model.set_side_rail_size(side, ..), model.set_side_tab_display(side, ..),
model.select_tab_by_id(side, tab). Per-tab context menus on a TabWidget are
available generally via TabInfo::context_menu(..).
Dock header & options
Every dock can carry a header (the VS Code / IntelliJ "view header" pattern) with two kinds of controls:
-
App actions —
DockWidget::header_actions(|id| …)declares a flatVec<ToolbarAction>("New File", "Collapse All", refresh, filter …) shown inline in the header. The framework hosts them in a compactToolbarand lays it out along the header's axis, so you never pickHStackvsVStack: a horizontal row on leading / trailing sides, a vertical column on the rotated top / bottom strip. Because it is aToolbar, excess actions collapse into a trailing⌄overflow menu when the header is tight (lowestpriorityfirst) and reappear as it widens — in every header shape (bare bar, vertical Accordion header, rotated top/bottom strip), since the header hosts the toolbar in a shrink-forwardingDeadZone(see below). EachToolbarActiontakes a label + icon;.on_activate(..)fires a command, or.menu(|| MenuList…)makes the action a dropdown (PopoverIconButton).#![allow(unused)] fn main() { DockWidget::new(id, lit!("Explorer"), build).header_actions(|_| vec![ ToolbarAction::new(lit!("New File"), || new_icon()).on_activate(..), ToolbarAction::new(lit!("Collapse All"), || collapse_icon()).on_activate(..), ]) } -
The
⋮options menu — an always-visible "More actions" button, the discoverable counterpart to the right-click activity menu. Its contents depend on whether the dock shares its activity:- a pane in a grouped (split) activity →
Move to new activity(promote_to_tab— pull this dock out into its own tab) andMove to side ▸(move just this dock to another enabled side as a new activity). - a sole-pane dock (it is its activity) →
HideandMove to ▸.
There is no "Close": a dock can only be hidden (and restored from the activity checklist or the rail/strip background menu). Closing would leave the user no way to bring the panel back.
- a pane in a grouped (split) activity →
Where the header appears:
- Split panes always have an
Accordionheader, so the actions +⋮show there automatically (in the header's trailing slot, before the chevron — the newAccordion::trailing/trailing_id). - A sole-pane (bare) dock is headerless by default. Opt in with
DockWidget::show_header(true)to give it a VS Code–style header bar ([title] [Spacer] [actions] [⋮]) above its content. (The side tab / rail is otherwise its only header.)
The ⋮ button is omitted when it would open an empty menu (a dock under a
fully-locked DockPolicy). Every "Move to" surface honours side availability —
a disabled side is never offered.
The action toolbar + ⋮ sit in a DeadZone — the
accordion (split-pane) header is a drag handle, so the whole header drags the
dock except the trailing controls: you can click them (even with the few px
of pointer jitter a real click carries) without starting a panel drag. This is
backed by the node-level gesture_dead_zone flag (the framework counterpart of
Electron's -webkit-app-region: no-drag), so it's robust by construction rather
than a gesture-timing race. The DeadZone is layout-transparent for the full
LayoutResponse (it forwards its child's shrink / min), so the wrapped
toolbar still collapses into its ⌄ when the header is tight instead of shoving
the title out of view.
Activity names
An activity's displayed name (rail item / tab label) derives in three steps:
- an explicit title set with
DockingModel::set_tab_title(tab_id, …)(or the sugarset_dock_activity_title(dock_id, …)— apps hold stable dock ids), else - the title of the activity's primary pane — its first non-collapsed dock (so collapsing the lead pane surfaces the next pane's title), else
- the literal
"Panel".
For a single-dock activity this is just the dock's own title. For a grouped
activity (several docks stacked into one tab) set an explicit title so the rail /
tab reads e.g. "Source" rather than silently tracking whichever dock happens to
sit in pane 0. Like dock titles and rail config, activity titles are app-config —
reconstructed each run, not persisted in DockLayoutState.
Drag-to-dock
Drag a split pane's ToolBox header — a five-zone overlay appears on each
pane: drop on the centre to stack (append a Splitter pane to that tab), on an
edge fifth (capped at 48 px so the centre stays reachable) to split before /
after the target pane. Foreign / non-dock payloads are ignored. The drop routes
to model.stack_into_tab / split_into_tab. Drag a tab-strip header — or an
activity-rail item — to move (or reorder) the whole tab; dropping it on a
pane splits/stacks there, on another side's tab bar inserts it at the drop
position (the bar paints the insertion line — this works for a rail item too,
via the tab bar's on_external_drop), on an activity rail inserts it at the
line the rail paints (reordering within the side, or accepting the tab from
another side — see Activity rail above), and on any other non-pane chrome
relocates it to the end of that side. The Splitter re-derives orientation for the
destination side. Programmatic relocation: move_dock / promote_to_tab /
move_tab.
Locking the layout (DockPolicy) + disabling sides
Serious apps ship a fixed chrome the user can't tear apart. A [DockPolicy]
(app-declared, not persisted) gates the end-user affordances — the
programmatic API keeps working, so the app's own "Toggle panel" button,
open_dock, set_tab_hidden, etc. still drive the locked layout.
#![allow(unused)] fn main() { use teksilo::widgets::{DockPolicy, DockSide}; // Fully lock, then disable a side the app doesn't use: model.set_policy(DockPolicy::locked()); // no user drag / collapse / hide model.set_side_enabled(DockSide::Top, false); // Top renders nothing, rejects docks // …or pick individual locks (default = everything allowed): model.set_policy(DockPolicy { allow_side_collapse: false, ..Default::default() }); // Builder sugar on DockingLayout: DockingLayout::new(model).policy(DockPolicy::locked()).disable_side(DockSide::Top) }
Flag (default true) | When false, the user can no longer… |
|---|---|
allow_activity_drag | drag rail items / tab headers to reorder or move activities, nor use the context-menu Move to. |
allow_dock_drag | drag a single dock out of a split pane (its accordion header stops being a drag handle). |
allow_side_collapse | hide/collapse a side — the resize handle still resizes but no longer snaps shut, double-click / Home / Enter / AccessKit-Collapse are inert, and clicking the active rail item no longer hides the side. |
allow_activity_hide | hide an activity (the context-menu Hide item + the checklist are gone). |
Disabling a side (set_side_enabled(side, false), reactive) makes it render
nothing, reserve no space, drop out of the AT tree, and reject placement /
moves to it — open_dock / move_tab / promote_to_tab / split_into_tab /
stack_into_tab targeting it become no-ops. Docks already on it stay in the
model and reappear when you re-enable it. (This single guard is programmatic —
rejecting placement is the point of disabling.)
Policy and side-enable are app-config like rail_thickness / min_size: re-apply
them each run (and after import_state); they aren't in DockLayoutState.
Programmatic open-from-outside
The model is the single source of truth, so panels open from anywhere (a side toolbar, a command, a menu):
#![allow(unused)] fn main() { model.reveal_dock(id); // ensure open + show its side + select its tab model.toggle_dock(id); // open on default location / close model.open_dock(id, DockOpenLocation::side(DockSide::Trailing).new_tab()); model.set_side_visible(DockSide::Bottom, false); // Reactive bindings for an external rail / toolbar: let is_open = model.dock_open_signal(id); // Signal<bool> let active = model.side_selected_tab_signal(side); // Signal<usize> }
Accessibility
- Container
Role::GenericContainer; each side regionRole::Complementarywith a localized landmark name ("Leading panel" …). - Activity rail: the items live in a
Role::TabList>Role::Tab(selected / click) that persists in the AT tree while the side is hidden. The rail's own root is a presentationalRole::GenericContainer, and the slots, the overflow trigger and each action cluster are siblings of the tab list, never inside it — ARIA's Tabs pattern restricts atablisttotabchildren, so a slot or a command button nested there would be an invalid owned element. - Rail actions: one
Role::Toolbarper placement (Role::Buttonchildren, withtoggledwhen the action declares a bistate). Tab list and toolbars are independent composites in the ARIA sense — each is a single Tab stop with its own roving Arrow/Home/End cycle, and Tab / Shift+Tab crosses between them. - In-side tab strip headers
Role::Tab; resize handlesRole::Splitter(value / expanded / Increment / Decrement / Collapse / Expand); split-pane ToolBox headers carry their own roles + the draggable affordance. - Structural mutations call
request_accessibility_update().
Persistence
The model gives you the two halves directly:
#![allow(unused)] fn main() { let state: DockLayoutState = model.export_state(); // serde + Versioned model.import_state(&state); // restore (also reset-to-default) }
Only user-controllable state is serialized (per-side size / visibility /
presentation / selection and the full tab → arrangement tree, plus corner
owners). App-config — rail thickness, minimums, content factories, header
actions — is declared each run and reconstructed (Qt saveState parity). On import, unknown
dock ids are dropped, emptied panes/tabs pruned, selections clamped.
Saving / restoring with teksilo-settings
DockLayoutState is Versioned + Serialize + Deserialize + Default + Clone,
which is exactly what SettingsFile<T> needs — so the disk side
is a debounced, atomic, corrupt-file-quarantining projection of the model.
1. Load once at startup (missing file → default(); corrupt file →
quarantined to <path>.broken-<ts> + default()):
#![allow(unused)] fn main() { use teksilo::settings::{AppPaths, SettingsFile}; use teksilo::widgets::DockLayoutState; use teksilo_settings::Migrator; use std::time::Duration; let paths = AppPaths::new("eu", "FernTech", "Teksilo").expect("config dir"); let dock_file = SettingsFile::<DockLayoutState>::load( paths.config_file("docking.toml"), Duration::from_millis(500), // write debounce &Migrator::new(), // v1: no migration steps yet ).expect("load docking layout"); }
2. Restore after the docks are registered. import_state drops unknown
dock ids, so register the panels first (the .dock(..) builder registers
eagerly), then import:
#![allow(unused)] fn main() { let layout = DockingLayout::new(model.clone()) .center(editor) .dock(DockWidget::new(explorer, lit!("Explorer"), |_| ExplorerPanel::new())) .dock(DockWidget::new(terminal, lit!("Terminal"), |_| TerminalPanel::new())); // docks are now registered → safe to restore: model.import_state(&dock_file.snapshot()); // `import_state(&DockLayoutState::default())` is also the reset-to-default path. }
import_state rebuilds the activity (DockTab) structure from the snapshot, so
any explicit activity title set with set_dock_activity_title /
set_tab_title is cleared (titles, like dock icons and policy, are app-config,
not in DockLayoutState). Re-apply those after importing — a single-dock
activity recovers its name from the re-registered dock title automatically, but a
grouped activity's custom name (e.g. "Source") must be set again:
#![allow(unused)] fn main() { model.import_state(&dock_file.snapshot()); model.set_dock_activity_title(explorer, lit!("Source")); // re-name the group }
3. Auto-save on change. Bind one effect (in the root widget's build()) to
the model's two version signals — version() (structural: open / close / move /
split) and geometry_version() (size / visibility / corners / presentation).
SettingsFile debounces, so bursts coalesce into a single write:
#![allow(unused)] fn main() { let combined = model.version().zip(&model.geometry_version()); let file = dock_file.clone(); let m = model.clone(); ctx.effect(&combined, move |_| { let _ = file.replace(m.export_state()); // schedules a debounced atomic write }); }
A selection-only change (select_tab) bumps neither version — it's captured
on the next structural/geometry change, or call dock_file.flush_now() on window
close. Bind the per-side model.side_selected_tab_signal(side) too if you want
selection persisted live.
Compose, don't sprinkle files. Prefer one workspace file over one per
dock layout / splitter. Since SplitterState and DockLayoutState are both
Versioned serde DTOs, wrap them and restore each piece via its own
import_state:
#![allow(unused)] fn main() { #[derive(Default, Clone, PartialEq, Serialize, Deserialize)] struct WorkspaceLayout { version: u32, docking: DockLayoutState, sidebar_split: SplitterState, } impl Versioned for WorkspaceLayout { const CURRENT_VERSION: u32 = 1; fn version(&self) -> u32 { self.version } fn set_version(&mut self, v: u32) { self.version = v; } } // one SettingsFile<WorkspaceLayout>. }
The dock layout is the content state; window geometry (position / size) is
separate and handled automatically by WindowConfig::id(..) +
SettingsBundle::with_window_state(true) — see settings.md.
Scope & non-goals (v1)
- In: 4 sides + centre, per-corner ownership, Splitter arrangement (one dock per pane, both orientations), draggable DockWidgets (promote / split / stack / move-side), whole-tab drag across sides, hide/show sides, activity rail, programmatic open, serde export/import + reset-to-default, landmark/role a11y.
- Out / known v1 limitations: floating/tear-off docks (explicit constraint);
cross-window dock moves (content factories are per-layout); recursive split
nesting (flat: one Splitter of single-dock panes per tab); "maximize a dock"
and hover-flyout auto-hide. Collapsed-dock a11y: a split-pane dock collapses
to its Accordion header, and the header sliver + its content stay live and
clipped during the fold so the collapse animates smoothly — meaning a fully
collapsed dock's body is still reachable by Tab / screen-reader navigation
(clipped to zero) rather than parked dormant. Parking it dormant only after
the fold completes (so the animation still plays) is a follow-up.
Content preservation: a
structural change (open / close / move / split) rebuilds the open panels'
content from their factories — transient widget state (scroll position, unsaved
edits) is preserved across resize / show-hide / tab-switch (those are relayout/
repaint, not rebuild) but not yet across structural moves; the
version/geometry_versionsplit keeps the common interactions rebuild-free. Drop-routing for the keyboard-only "Move to side" tab menu and the RTL resize- handle direction are likewise follow-ups.
Status. Recommendations A (Strip bar slots) and B (dockless
DockActions), and the ARIA fix in §4.6, are implemented and shipped — seedocs/docking.mdfor the resulting user-facing API and the CHANGELOG entry for the summary. This document is kept for the reasoning: why actions are notDockTabs (§4.1), why they are not spliced into the tab-indexed column (§4.2), why they are view config rather than model state (§4.3), and the ARIA argument behind the tablist/toolbar split (§4.6).§5 is the live backlog item — the Top/Bottom reopen-affordance scar and the horizontal-rail question. §5.1 records a cheap fix that was proposed, accepted, and then found unsound; read it before re-proposing one.
§9's phase list is historical. Phases 2-5 are done; Phase 6 (the
background_menukeyboard trap) and Phase 7 (the horizontal rail) are not.
DockActivityBar Slots + Dockless Actions — Design (Revision 2)
1. What already exists (correcting the mental model)
DockRailalready hastop_slot()/bottom_slot()—Rc<dyn Fn() -> Box<dyn Widget>>factories, rebuilt every rail rebuild, placed above the items / after a trailingSpacer.docking/activity_bar.rs:127-128,149-150,187-203TabWidgetalready hasbar_leading_slot()/bar_trailing_slot()— a different mechanism: a memoize-onceBarSlottaking a builtimpl Widget + 'static, not a factory.tab_widget.rs:911-935docking/panel.rsalready consumesbar_trailing_slotinternally, for the "hidden activities" hamburger shown only when every activity on a Strip side is hidden.panel.rs:524-535- There is no app-facing way to add a slot to a Strip-presentation tab bar today.
DockSidePanelnever sets.orientation(...), so the Strip bar is horizontal for everyDockSideincluding Top/Bottom — "leading/trailing" for that bar is a reading-order axis, unrelated to the Rail's vertical top/bottom axis.panel.rs:369-372,438-535;tab_widget.rs:361 DockActivityBaris vertical-only, end to end — item column is aVStack, overflow capacity is computed frombounds.height,rail_insertionis y-only, the drop indicator draws a horizontal line, a11y hardcodesOrientation::Vertical, Labeled mode uses a 90°-rotated label, tooltips are hardcodedTooltipPlacement::Sidewith an explicit comment explaining why aBelowtooltip would drop onto the next stacked item.activity_bar.rs:236-612,1146-1179- For Top/Bottom sides, the (always-vertical) rail is a column pinned to the leading cross-edge, excluded from
band_depth(), and a hidden Top/Bottom band collapses completely — rail included ("a vertical rail can't stand alone in a zero-depth band"); the app must supply its own external reopen button. This is an admitted, tested design scar (hidden_top_with_rail_fully_collapses).geometry.rs:13-26,194-219,759-776 DockRailis per-view builder config onDockingLayout, not onDockingModel. It's declared fresh perDockingLayout::new(model)call site, the same way.dock(DockWidget)is — and dock/rail metadata registration on the model happens immediately, synchronously, inside the builder chain, specifically so the app can callmodel.import_state(dto)afterward with all ids already known.docking.rs:79-128,156-164,159-164DockingModel::register_meta— the actual metadata-registration mutator — ispub(crate). App code never calls a model-levelregister_*method directly; it only reaches metadata through theDockingLayoutbuilder.model.rs:527-531- Teksilo already has a live, unfixed ARIA "required owned elements" violation.
DockActivityBar::accessibility()setsRole::TabListon the rail's whole root;top_slot/bottom_slotwidgets and the overflow-triggerIconButtonare ordinary children of that sameVStack, so they are already non-Role::Tabdescendants of arole=tablisttoday, before any of this design ships.activity_bar.rs:394-433,601-611 Role::GenericContaineris already this codebase's idiom for a presentational, unnamed grouping wrapper — used byDockingLayout's own root,menu_bar.rs, andsplitter.rs.accessibility_impl.rs's pruning pass only removes such a wrapper when it carries no semantic property at all (no name, no orientation) — there's a standing regression test guarding exactly this (plain_button_is_a_leaf_no_group_node). [docking.rs:474;menu_bar.rs:917;splitter.rs:555;teksilo-core/src/widget_tree/accessibility_impl.rs:683-702;button.rs:1404-1426]- Skribisto has zero live call sites for
DockRail::top_slot/bottom_slot(grep-confirmed) — its only slot consumer isTabWidget::bar_trailing_slot, for the editor pane's own split/close button. [crates/teksilo_ui/src/app.rs:122] - Skribisto's dockless-action need is real but already solved outside the docking system:
SpellcheckToggleButton,ExportSplitButton,ProjectSwitcherButtonare hand-built in the window'sTitleBar(shell/windows.rs:612-660), specifically because they are window-global, not tied to anyDockSide— none of them has a coherent side to attach to.
2. The real gaps, ranked
- No app-facing Strip-side slot.
TabWidget::bar_leading_slot/bar_trailing_slotexist and are production-tested (the hamburger) but Strip sides have no way for an app to inject one — the DockRail-side equivalent (top_slot/bottom_slot) has no Strip-presentation counterpart at all. - No dockless-action concept exists. Every rail item today is 1:1 with a
DockTab(splitter + panes + content). There is no way to put a plain command button in the rail that looks and behaves like an activity button but opens no panel. - A pre-existing ARIA violation (
top_slot/bottom_slot/the overflow trigger as non-tab children ofrole=tablist) must be fixed before adding a second, larger population of non-tab rail content, or the defect compounds. Worth fixing on its own merit even if nothing else here ships. - Top/Bottom sides have no persistent reopen affordance — a hidden band takes its rail with it, so every app with a Top/Bottom rail must hand-wire an external toggle. Skribisto already does, twice (§5.2). Only a horizontal rail fixes this; there is no cheap version (§5.1).
- A pre-existing overflow-capacity approximation ("one stride per slot" regardless of actual slot height). Not fixed here — Part B's action term is exact by construction, but reconciling the two slot terms needs each
DockRailSlotto report a measured extent, which is its own small design. - A pre-existing keyboard trap:
background_menu— the sole restore path once a side is fully hidden — is reachable only by pointer right-click; nothing in an empty rail is a Tab stop. Genuinely pre-existing and not widened by anything in this design (§4.7). Fix separately.
3. Recommendation A — Strip-presentation bar slots
3.1 API
#![allow(unused)] fn main() { // docking/activity_bar.rs — DockRail widened #[derive(Clone)] pub struct DockRail { pub(crate) side: DockSide, pub(crate) size: IconButtonSize, pub(crate) background: Option<ColorProp>, pub(crate) divider: Option<ColorProp>, pub(crate) top_slot: Option<DockRailSlot>, // UNCHANGED — Rail only pub(crate) bottom_slot: Option<DockRailSlot>, // UNCHANGED — Rail only /// Pinned at the start of this side's Strip-presentation tab bar via /// `TabWidget::bar_leading_slot`. Ignored while the side is Rail /// presentation (use `top_slot` there). /// /// NOT the same contract as `top_slot`. `top_slot`/`bottom_slot` sit on /// `DockActivityBar`, which is built unconditionally whenever /// `side_has_rail(side)` is true — it survives the side being fully /// collapsed. `leading_slot`/`trailing_slot` sit inside `TabWidget`, /// which lives *inside* the side's `SideClipPane` and is /// `visible_when(progress > COLLAPSED_EPS)` — it disappears the moment /// the side is hidden, same as the tab content it sits beside. If your /// slot content must survive a hidden side, use Rail presentation with /// `top_slot`/`bottom_slot`, or host it outside the docking system /// entirely (the pattern Skribisto's title-bar trio already uses). pub(crate) leading_slot: Option<DockRailSlot>, pub(crate) trailing_slot: Option<DockRailSlot>, pub(crate) overflow_icon: Option<DockIconFactory>, } impl DockRail { pub fn leading_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self { self.leading_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>)); self } pub fn trailing_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self { self.trailing_slot = Some(Rc::new(move || Box::new(f()) as Box<dyn Widget>)); self } } }
3.2 Wiring
DockingLayout::build() currently reads self.rails.get(&side) only at the DockActivityBar construction site; DockSidePanel::new gets no rail config at all. Fix:
#![allow(unused)] fn main() { // docking.rs — DockingLayout::build() let config = self.rails.get(&side).cloned().unwrap_or_else(|| DockRail::new(side)); let panel = ctx.add(DockSidePanel::new(side, self.model.clone(), self.registry.clone()) .rail_config(config.clone())); let rail = self.model.side_has_rail(side) .then(|| ctx.add(DockActivityBar::new(side, self.model.clone(), config, self.side_panel_ids.clone()))); }
3.3 Hamburger composition — mandatory, exhaustive
TabWidget's BarSlot is last-write-wins single-field; DockSidePanel already privately calls bar_trailing_slot for its hamburger. Compose explicitly, once per edge:
#![allow(unused)] fn main() { // docking/panel.rs — Strip-presentation branch let mut leading = Vec::new(); if let Some(f) = &self.config.leading_slot { leading.push(ctx.add_boxed((f)())); } let mut tab_widget = tab_widget; if !leading.is_empty() { tab_widget = tab_widget.bar_leading_slot_id(ctx.add(HStack::new().children(leading))); } let mut trailing = Vec::new(); if let Some(f) = &self.config.trailing_slot { trailing.push(ctx.add_boxed((f)())); } if needs_hamburger { trailing.push(ctx.add(hamburger_widget)); } if !trailing.is_empty() { tab_widget = tab_widget.bar_trailing_slot_id(ctx.add(HStack::new().children(trailing))); } }
Invariant A1: DockSidePanel is the sole caller of TabWidget::bar_leading_slot/bar_trailing_slot in the crate — verify this by grep as a Phase-1 precondition, not an assumption.
3.4 Zero-tabs fix
DockSidePanel::build() early-returns a bare drop target before the TabWidget (and hence any slot) is ever constructed, when the side has zero open docks:
#![allow(unused)] fn main() { let all_tabs = self.model.side_tabs(self.side); if all_tabs.is_empty() { let drop = empty_side_drop_target(ctx, &self.model, self.side); self.root = Some(drop); return vec![drop]; } }
A side configured with .leading_slot(...)/.trailing_slot(...) but currently zero docks — a reachable state, not a misuse — renders no slot at all, silently. Thread self.config.leading_slot/trailing_slot into this branch too (a minimal HStack/TabWidget around the drop target), so the slot survives an empty-but-configured side. This does not fix the collapse-on-hide case documented in §3.1 — that one stays as a stated, weaker contract, not a bug, because fixing it would mean restructuring docking.rs to move TabWidget outside SideClipPane, a change with no current consumer justifying its cost.
3.5 Overflow-capacity note
top_slot/bottom_slot's "one stride per slot" approximation is a real, pre-existing bug, independent of this feature. Not fixed here — see Phase 5, where Part B needs an exact version of the same math anyway and the two should be reconciled together, not duplicated.
4. Recommendation B — dockless action entries
Revision 2. Cyril's answers to §10 — "do not hide
DockAction", "not hidable" — collapse this part substantially. What follows is the revised design; the deleted machinery is itemised in §4.9 so the reasoning isn't lost.
4.1 Why not a DockTab variant (settled, unchanged)
import_state's pane-survival guard (if !panes.is_empty(),
model.rs) would treat an action's permanently-empty
panes as indistinguishable from a fully-pruned dead tab — modelling actions as zero-pane
DockTabs would silently delete every action on the first app restart. ~10 call sites also
assume tab.panes.first() is meaningful (silent panic/blank-panel risk, not a compile error).
Verdict: actions are a structurally separate concept.
4.2 Why not spliced into the tab-indexed column (settled, unchanged)
rail_insertion's vpos → model_indices[vpos] mapping silently resolves to the wrong tab if a
non-tab entry shares the indexed sequence. DockRailActionGroup is a separate sibling widget that
never registers into RailItemBounds/RailItemIds, so rail_insertion, model_indices,
side_append_index and the drop machinery are untouched. The corruption class is unreachable
by construction, not merely guarded.
4.3 Actions are view config, not model state — the decisive consequence of "never hidable"
With hiding gone, an action has no user-mutable state whatsoever. Everything about it —
label, icon, tooltip, enabled, toggled, handler — is app-declared and reconstructed each run,
which is the exact definition state.rs's module doc gives for what must not be persisted:
"Only user-controllable values are persisted … App-config — rail thickness, minimum sizes, content factories, header actions — is declared each run and reconstructed (Qt
saveStateparity)."
So actions belong on DockRail, beside top_slot/bottom_slot, not on DockingModel.
This is strictly better than Revision 1's builder-registration design and deletes its entire
registration-ordering problem: there is no id to match at import time because nothing is imported.
#![allow(unused)] fn main() { // docking/activity_bar.rs — DockRail gains an ordered action list impl DockRail { /// Append a dockless command button to this side's rail. Declaration /// order is render order within a placement. /// /// **Rail presentation only.** A side in [`TabPresentation::Strip`] /// renders no actions at all — and `set_side_rail` can flip /// presentation at runtime, so a side that flips Rail → Strip drops /// its whole action cluster. If that is reachable in your app, mirror /// the cluster with `trailing_slot`, which the /// same `DockRail` can carry alongside its actions. pub fn action(mut self, action: DockAction) -> Self { self.actions.push(action); self } } }
Nothing changes in state.rs. Nothing changes in DockPolicy. The latter matters: Revision 1's
allow_action_hide was a breaking field addition to a fully-pub, non-#[non_exhaustive] struct.
That breaking change is now gone — Parts A and B together are purely additive.
4.4 Identity
#![allow(unused)] fn main() { /// Stable identity for a rail action. NOT used for persistence — actions /// carry no persisted state (§4.3). It exists for AT naming and for the /// automation bridge, which addresses widgets by stable id; a fresh-per-run /// id would make every automation script that clicks a rail action flaky. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DockActionId(u64); impl DockActionId { /// Stable across runs, processes and machines — derived from a /// caller-chosen name. Prefer this over `from_raw`: it removes the /// hand-picked-`u64`-literal collision hazard entirely. /// /// ```ignore /// const SETTINGS: DockActionId = DockActionId::named("skribisto.settings"); /// ``` pub const fn named(name: &str) -> Self { /* const FNV-1a over name bytes */ } pub const fn from_raw(v: u64) -> Self { Self(v) } pub const fn raw(self) -> u64 { self.0 } } }
named() must be const fn so ids can be const items at module scope, matching how Skribisto
already declares its DockWidgetIds. FNV-1a (not blake3) because it has to run in a const
context; collision risk over a handful of app-chosen names is negligible, and unlike
open_registry's namespacing there is no adversarial input here.
4.5 DockAction
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DockActionPlacement { /// Before the first activity item, in the flowing cluster. Start, /// After the last activity item and the overflow trigger, still in the /// flowing cluster — the group grows downward with the tabs. End, /// Past the `Spacer`, anchored to the rail's far edge regardless of how /// many activities exist. VS Code's Accounts / Manage-gear cluster. /// This is where a Settings gear belongs (§4.8). Pinned, } /// Mirrors `ToolbarAction`'s proven field shape rather than inventing new /// vocabulary. Never draggable, never hidable, never persisted — a rail /// action is app chrome that happens to look like an activity button. pub struct DockAction { pub(crate) id: DockActionId, pub(crate) placement: DockActionPlacement, pub(crate) label: LocalizedString, pub(crate) icon: DockIconFactory, pub(crate) tooltip: Option<LocalizedString>, pub(crate) enabled: Prop<bool>, /// `Some` => renders pressed/checked (mirrors `IconButton::toggled`). /// With `hidden` gone there is no longer a second checkmark-shaped /// concept to confuse this with — Revision 1's open question is void. pub(crate) toggled: Option<Signal<bool>>, pub(crate) on_activate: Rc<dyn Fn(&mut EventContext)>, } impl DockAction { pub fn new( id: DockActionId, label: impl Into<LocalizedString>, icon: impl Fn() -> IconWidget + 'static, on_activate: impl Fn(&mut EventContext) + 'static, ) -> Self { /* placement: End, enabled: true, toggled: None, tooltip: None */ } pub fn placement(mut self, p: DockActionPlacement) -> Self { self.placement = p; self } pub fn tooltip(mut self, t: impl Into<LocalizedString>) -> Self { self.tooltip = Some(t.into()); self } pub fn enabled(mut self, e: impl Into<Prop<bool>>) -> Self { self.enabled = e.into(); self } pub fn toggled(mut self, s: Signal<bool>) -> Self { self.toggled = Some(s); self } } }
No hidable, no not_hidable(), no set_action_hidden/is_action_hidden/action_hidden_signal,
no action_context_menu, no background_menu section. See §4.9.
4.6 A11y structure — the argued decision (unchanged, and now cheaper)
ARIA citation: the APG Tabs pattern's Required Owned Elements normatively restrict
role=tablist children to role=tab; command buttons belong in a role=toolbar, which carries
its own independent roving-tabindex model.
Decision: two sibling composites, neither nested in the other:
DockRailTabList— wraps only theDockRailItems. Must provide real layout (VStack::spacing(RAIL_ITEM_SPACING)), not a bare pass-through, or the column's spacing changes the moment the wrapper lands. CarriesRole::TabList+rail_label(side)+Orientation::Vertical.DockRailActionGroup— one per(side, placement)with ≥1 action; omitted from theVStackentirely when empty (never an emptyRole::Toolbar). CarriesRole::Toolbar+rail_actions_label(side, placement)+Orientation::Vertical, and a localroving: Signal<usize>mirroringToolbar's pattern — notDockRailItem's model-levelselected: Signal<usize>, since an action group has no "currently selected" concept.DockActivityBar's root dropsRole::TabListforRole::GenericContainer— the crate's idiom (docking.rs:474,menu_bar.rs:917,splitter.rs:555). Hard requirement: the wrapper must carry no semantic property at all — noset_name, noset_orientation— oraccessibility_impl.rs's pruning pass will not prune it and a screen reader announces "Leading activity bar, group" then "Leading activity bar, tab list". The crate's ownplain_button_is_a_leaf_no_group_nodetest exists to prevent exactly this.- The overflow-trigger
IconButton(a third stray non-tab child today) moves out as its own sibling.
A rejected shortcut, settled — do not re-litigate. Widget::accessibility_children() -> Option<Vec<WidgetId>>
does exist (teksilo-core/src/widget.rs:439, honoured at
widget_tree/accessibility_impl.rs:413) and its doc says it can "reorder (or restrict)" AT
children — so it looks like a one-line fix: keep Role::TabList on the root, return only the item
ids. It is the wrong fix. Restricting is not re-parenting: the slots and the overflow trigger
would not move to a valid parent, they would be dropped from the AT tree entirely, turning a spec
violation into a WCAG 2.1.1 failure (operable controls with no accessible representation). The
wrapper is correct because it gives non-tab content a valid parent instead of deleting it.
Composition order inside the rail's padded column:
[top_slot?] → [ActionGroup(Start)?] → [DockRailTabList[…DockRailItem]]
→ [overflow trigger?] → [ActionGroup(End)?] → [Spacer]
→ [ActionGroup(Pinned)?] → [bottom_slot?]
Keyboard model: DockRailTabList and each DockRailActionGroup are each a single Tab stop with
their own internal roving Arrow/Home/End cycle. Tab/Shift+Tab crosses between them; arrows never do.
This needs no special implementation — it is the natural consequence of keeping them separate widgets.
Rendering details that are easy to get wrong:
- Tooltip: use
ctx.attach_tooltip_with_placement(root, tip, delay, TooltipPlacement::Side)exactly asDockRailItemdoes — neverIconButton::tooltip's default path, which opensBelowand would drop the tooltip onto the next stacked item. - Labeled mode: read
side_rail_size(side).shows_label()and mirrorDockRailItem's RotatedLabel-vs-tooltip branch. An action showing a hover-only tooltip while the tabs beside it show permanent captions is a hover-only-discovery regression. - Glyph sizing: size the icon to the rail's
item_glyph_size(effective_item_size()), so an action tracks Compact/Default/Labeled like a real item. (AnIconButtoninbottom_slotdoes not get this for free — the app must bindrail_size_mode_signalby hand. That asymmetry is the main reason §4.8 recommendsDockActionover a slot.)
Overflow capacity — this does require a change to DockActivityBar::place_children:
reserve = RAIL_PADDING*2
+ stride * (top_slot.is_some() as f32) // existing, approximate
+ stride * (bottom_slot.is_some() as f32) // existing, approximate
+ actions.len() as f32 * stride; // NEW, exact — count is fixed now
Because actions can no longer be hidden, the count is a build-time constant, so this term is exact and needs no reactive re-evaluation. Actions are always reserved, never overflow-parked — matching VS Code's fixed bottom cluster and avoiding its documented issue #46017 (trailing action icons silently vanishing under space pressure). Keep counts to 1–3 per placement by convention.
4.7 The keyboard trap — demoted back to pre-existing
Revision 1 pulled the background_menu keyboard trap into scope on the argument that Part B
widens it (actions could independently reach zero-visible). With actions never hidable that
argument dies: actions can never disappear, so the set of states reaching an empty rail is
unchanged. The trap is real and still worth fixing — background_menu is wired as a context_menu
handler on the rail root with no accompanying .focusable(true), so a keyboard-only user
cannot reach it via Shift+F10 once every activity is hidden — but it is a pre-existing defect on
its own merit, not part of this feature. File it separately; do not let it gate Part B.
4.8 Verdict on Skribisto's two concrete cases
Settings at the bottom of the leading rail → DockAction with Pinned, not a bottom_slot IconButton.
The two are not equivalent, and four differences all point the same way:
bottom_slot(IconButton) | DockAction + Pinned | |
|---|---|---|
| a11y | non-tab child of Role::TabList — the live violation from §1 | inside the sibling Role::Toolbar — correct |
| Roving focus | none; a lone sequential Tab stop | joins the toolbar's Arrow/Home/End cycle |
| Glyph sizing | fixed dp unless the app hand-binds rail_size_mode_signal | tracks Compact/Default/Labeled automatically |
| Labeled mode | no caption — looks broken next to captioned tabs | gets the rotated caption like a real item |
Your own framing settles it too: idea #2 asked for something "similar to the activity buttons".
A slot is deliberately opaque chrome; an action is framework-rendered to match. Reserve
bottom_slot for what it is genuinely for — non-command chrome (a logo, an avatar, a sync-status
dot, a progress ring) that should not look or behave like a button.
#![allow(unused)] fn main() { // skribisto — app/project_shell.rs const SETTINGS_ACTION: DockActionId = DockActionId::named("skribisto.settings"); const ANALYSIS_ACTION: DockActionId = DockActionId::named("skribisto.analysis"); DockRail::new(DockSide::Leading) .background(SurfaceRole::Main) .divider() .action( DockAction::new( ANALYSIS_ACTION, tr!(rail_analysis()), || IconWidget::chart_bar(), |ctx| ctx.send_intent(Intent::new("analysis.open")), ) .placement(DockActionPlacement::End), ) .action( DockAction::new( SETTINGS_ACTION, tr!(rail_settings()), || IconWidget::settings(), |ctx| ctx.send_intent(Intent::new("app.settings")), ) .placement(DockActionPlacement::Pinned), ) }
Two notes on that call site, both load-bearing:
-
Fire the existing command, don't duplicate it.
app.settingsis already registered viaregister_action_globalwith Ctrl+, bound to it;ctx.send_intent(Intent::new("app.settings"))is the idiom Skribisto already uses (app/commands/file.rs:241,app/commands/go.rs:122). A rail action that re-implements the modal would drift from the menu item and the shortcut. -
"Analysis" is the load-bearing validation of this whole feature. It opens a centre editor tab, not a dock — so it can never be an activity, and there is no
DockWidgetto hang it on. It is precisely the caseDockRail::actionexists for. Wire the rail half as a new unitAppIntentvariant (handler-driven, payload-free).The rail half is the easy half, and this design only solves that half — say so plainly. An earlier draft said to add the placeholder tab "matching how
tabs/already dispatches on(role, sub_role)". That is wrong and would send an implementer down a dead end:tabs.rs::tab_pane()matches on aBinderItem's(role, sub_role)drawn fromskribisto_model::COMBINATIONS, and the per-tab payload is aContentTabover a sharedRc<OpenDoc>— machinery built end to end around a real,uid-bearingBinderItem. Analysis is not aBinderItemand has no(role, sub_role)to key on, so there is no arm to add.Hosting a non-document tab in
EditorsViewModelis therefore a genuine, unsolved architectural gap, not a wiring detail — the open questions are: what identifies such a tab in the tab list (today every tab is keyed byBinderItem.uid, which is also whatworkspace.tomlv2 persists); whether it survives a session restore or is deliberately transient; and whetherEditorsViewModel's save/dirty path must learn that some tabs own no document. Scope that separately before starting — thefeat/analysis-tabbranch is where it belongs, andDockRail::actionwill be waiting for it.
4.9 What Revision 1 had that is now deleted
Recorded so the reasoning isn't re-derived later:
| Deleted | Why |
|---|---|
DockAction.hidable, not_hidable() | actions are never hidable |
DockPolicy.allow_action_hide | nothing left to gate — and this un-breaks the API |
set_action_hidden / is_action_hidden / action_hidden_signal | no user-mutable state |
action_context_menu | its only item was "Hide" |
background_menu's action checklist section | nothing to restore |
DockActionState, DockSideState.actions | nothing to persist — state.rs untouched |
DockingLayout::action() + register_action_meta | actions are DockRail config (§4.3) |
the registration-vs-import_state ordering contract | no import step exists |
| focus re-homing on hide | actions never vanish |
the toggled-vs-hidden visual ambiguity | only toggled remains |
5. Recommendation C — the orientation question
5.1 Correction: the cheap fix I proposed does not exist
Revision 1 floated a narrower alternative (OQ5): keep the existing vertical rail alive at zero
band depth for Top/Bottom by relaxing rail_only()'s region.height > 0.0 guard. Cyril accepted
this ("ok, fix"). It is unsound and must not be built. Closer reading of
geometry.rs:434-467 shows why:
#![allow(unused)] fn main() { let rail = Rect::new(rail_x, region.y, rail_w, region.height); }
For a Top/Bottom band the rail's height is the band's depth, and
band_depth() = content_extent() + gutter_extent() — zero when hidden. The guard is not the cause;
it is a symptom. Keeping a vertical rail visible would require permanently reserving
N × item_extent of band depth (≈ 3 items → 130 dp of always-present bottom band) purely to host a
column of icons. SideLayout models the rail as a single rail_thickness scalar, which for
Leading/Trailing is a width with a free height, and for a Top/Bottom vertical rail is a width with a
constrained height — a genuine model mismatch, not an oversight. geometry.rs:20-23's comment
("a vertical rail can't stand alone in a zero-depth band") is simply correct.
There is no cheap fix. The reopen affordance for Top/Bottom requires a horizontal rail.
5.2 The evidence, corrected
Revision 1 also claimed the scar "is not currently hit by any app in this codebase." Also false:
project_shell.rs:205-206putsDockSide::Bottomin Rail presentation at 36 dp,Compact.project_shell.rs:311then callsset_side_visible_immediate(DockSide::Bottom, false)— the band ships hidden by default, so the rail is invisible in Skribisto's own default state.- Skribisto pays for it with two hand-wired workarounds: the
view.rs:63toggle command and theproject_menus.rs:387menu item bound toside_visible_signal(DockSide::Bottom). That menu item is the "external button"geometry.rstells apps to supply.
(DockSide::Top remains unreferenced in teksilo_ui — that part of the original claim stands.)
5.3 Verdict
Defer, but schedule it. A horizontal DockActivityBar for Top/Bottom is now a justified backlog
item with a named beneficiary, not speculative framework investment. It is still not something to
bundle into Parts A/B: it rewrites geometry.rs's Top/Bottom split_side arm, deletes rather
than extends four regression tests (top_rail_is_a_leading_column_not_a_band,
top_rail_column_mirrors_to_the_right_in_rtl, hidden_top_with_rail_fully_collapses,
bottom_rail_column_keeps_handle_inboard), and changes runtime behaviour for any existing
Top/Bottom-rail consumer. Its payoff is real though: Skribisto would delete a command and a menu
item and get the reopen affordance for free.
Scope notes for when it is taken up, so the next pass starts warm:
resize_handle.rsis already fully orientation-generic (branches onis_horizontal_axis) — no work there.- Arrow-key nav in
activity_bar.rsalready accepts both axes (Up/Left = Prev, Down/Right = Next) — dead flexibility today that becomes correct for free, though it needs an RTL pass for a horizontal rail (reading order reverses). RotatedLabelshould be dropped, not rotated the other way: it exists because a vertical rail has a fixed narrow cross-axis and text runs against the flow. On a horizontal rail the flow axis already matches text, so Labeled mode is just icon-above-caption.TooltipPlacement::Sidemust becomeBelow/Above—teksilo-core's existing two-variant enum already covers it with edge-flip, so no new variant is needed.- The one genuinely open design question is whether a horizontal Top/Bottom rail makes
TabPresentation::Stripredundant for those sides, or whether they stay complementary.
6. Interaction & policy matrix
| Entry kind | Movable | Hidable | Own Tab stop | Overflowable | Drop target | Persisted |
|---|---|---|---|---|---|---|
DockTab (activity item) | yes, if allow_activity_drag | yes, if allow_activity_hide | yes — roving, in DockRailTabList | yes (DockOverflowMenu) | yes | yes (DockTabState) |
top_slot/bottom_slot (Rail) | no — no drag code path exists | no — no hide code path exists | whatever the widget is; sequential, not roving | no — reserved, approximate charge | no | no — app-declared each run |
leading_slot/trailing_slot (Strip) | no | no | sequential, not roving | no — reserved | no | no — app-declared each run |
DockAction (Start/End/Pinned) | no — no drag code path exists | no — by decision | yes — roving, in its own Role::Toolbar | no — reserved, exact charge | no | no — nothing to persist |
7. Persistence & migration
Nothing changes. This is the headline consequence of §4.3: state.rs, DockLayoutState,
DockSideState, DockTabState and the Versioned/Migrator wiring are all untouched, and
DockLayoutState::CURRENT_VERSION stays at 1. Parts A and B together add no persisted field, so
there is no migration to write and no old-layout compatibility question to answer.
One consumer-awareness note worth recording anyway, because it will matter for a future
non-additive change: Skribisto embeds DockLayoutState as a plain field inside its own separately
Versioned WorkspaceLayoutFile, via a lenient_docks deserializer that bypasses
DockLayoutState's own Migrator entirely — only the outer file's version is walked
(models/workspace_layout_file.rs:129-146). A future non-additive DockLayoutState change
would therefore fail-load and silently blackhole every Skribisto user's whole per-project dock
layout, not just the new field. Nothing to do today; do not let a later contributor assume
"the migrator will handle it".
8. Prior-art notes
- Qt
QTabWidget::setCornerWidget— the toolkit precedent for fixed, caller-owned, non-draggable end-slots on a horizontal tab strip. Matches Part A directly, and is the source of a pitfall worth inheriting awareness of: Qt's corner widget only renders while ≥1 tab exists — the exact bug §3.4 fixes. - VS Code's Activity Bar accepts only View-Container contributions; extensions are explicitly forbidden from using an item to open a bare panel-less webview. Validates keeping Part B structurally separate from real activity tabs rather than splicing it in as fake tabs.
- VS Code's fixed bottom cluster (Accounts / Manage-gear) is never draggable and never
individually hideable, and migrates to the title bar on reorientation. Cyril's "do not hide
DockAction" decision lands teksilo exactly on this precedent rather than beside it — Revision 1's optional-hidability stance was the one part of the design with no precedent in any surveyed system, and it is now gone. - IntelliJ's tool-window stripes — every stripe icon in the docs is a tool-window toggle; the
only plain-action stripe button ("More tool windows") is IDE-owned chrome. Reinforces: never let
DockActionacquire draggable/reorderable behaviour. - W3C ARIA APG's tablist/toolbar split — two independent single-Tab-stop composites; arrows navigate within, Tab/Shift+Tab crosses between. The load-bearing citation for §4.6.
- VS Code panel-actions overflow (issue #46017) — trailing action icons silently vanishing under space pressure with no overflow menu, in a mature funded product. Why §4.6 reserves space for actions rather than letting them overflow.
9. Phased implementation order
Phase 1 — Preconditions. No code change. Grep-confirm (a) DockSidePanel is the sole caller of
TabWidget::bar_leading_slot/bar_trailing_slot (Invariant A1); (b) Role::GenericContainer usage
at docking.rs:474, menu_bar.rs:917, splitter.rs:555. Record both in the PR description.
Phase 2 — Part A: Strip slot parity. DockRail::leading_slot/trailing_slot; widen
DockingLayout::build() to feed rail config into DockSidePanel; hamburger composition (§3.3);
zero-tabs fix (§3.4). No model, policy or persistence change.
Tests: strip_leading_slot_renders_with_zero_tabs; strip_trailing_slot_composes_with_hamburger
(both present, neither dropped); strip_slot_hides_with_side_collapse (asserts the documented
weaker contract rather than fighting it); regression pass on Skribisto's Strip-side tests.
Phase 3 — A11y fix. Independent of Part B; fixes a live defect regardless of whether Part B ever
ships. DockRailTabList with real VStack::spacing(RAIL_ITEM_SPACING) layout; DockActivityBar's
root drops to a property-free Role::GenericContainer; overflow trigger moves out as a sibling.
Tests: a11y-tree assertion that top_slot/bottom_slot/overflow-trigger are no longer descendants
of a Role::TabList; update rail_strip_width_follows_the_size_mode to query the rail's outer
bounds by WidgetId rather than by Role::TabList; update dock_drag_lands_on to verify its drop
point still lands in the rail's actual outer rect, not the now-narrower TabList sub-rect.
Phase 4 — Part B, whole. Collapsed from Revision 1's four phases, because §4.3 removed the model,
policy, menu and persistence work. DockActionId (+ const fn named), DockAction,
DockActionPlacement, DockRail::action(), DockRailActionGroup, the place_children
overflow-reserve term, and the composition order in DockActivityBar::build.
Tests: all three placements in one assertion — Start before the tab list, End after the tab
list and after the overflow trigger, Pinned after the Spacer (the End-vs-overflow ordering
is the one most likely to be implemented backwards, since both sit between the tab list and the
Spacer); actions_are_absent_in_strip_presentation (pins the R2-2 no-op deliberately, §10.1);
roving Tab stop stays within the action group and never enters the tab cycle; tooltip placement
asserted Side, not the IconButton default; Labeled mode shows an inline caption, not a hover
tooltip; action glyph size follows a Compact↔Labeled flip; overflow reserve shrinks the shown-item
count with 3 actions present; Role::Toolbar is a sibling of Role::TabList, never a
descendant.
Phase 5 — Skribisto adoption. SETTINGS_ACTION (Pinned) firing Intent::new("app.settings");
ANALYSIS_ACTION (End) firing a new unit AppIntent with a placeholder tab. Both locales for
rail-settings / rail-analysis. Deliberately does not migrate the title-bar trio
(Spellcheck/Export/ProjectSwitcher) — those are window-global, have no DockSide, and stay where
they are.
Phase 6 — Separate, pre-existing. The background_menu keyboard trap (§4.7). Its own small PR;
not gated by anything above.
Phase 7 — Separate proposal. Horizontal DockActivityBar for Top/Bottom (§5). Its own design
doc, its own geometry.rs test rewrite, its own behaviour-change sign-off.
10. Decisions
Every open question is closed. No blocking questions remain; the design is ready to implement as written.
| # | Question | Decision |
|---|---|---|
| R1-1 | Should DockAction be hidable? | No. Never hidable — collapses §4 (see §4.9) and un-breaks the API |
| R1-2 | toggled vs hidden visual disambiguation | Void — hidden no longer exists |
| R1-3 | Real near-term consumer? | Yes — "Analysis" (opens a centre editor tab) and Settings (§4.8) |
| R1-4 | The narrow Top/Bottom reopen fix | Retracted as unsound by §5.1 after the accepting decision — do not build |
| R1-5 | DockActionId::named() stable-hash ctor | Yes, const fn, FNV-1a — justified by automation addressing, not persistence (§4.4) |
| R2-1 | Two placements or three? | Three — Start, End, Pinned ship together |
| R2-2 | Render actions in TabPresentation::Strip? | No — Rail-only |
10.1 Consequences of R2-2 that must be documented, not left implicit
Actions are Rail-only, but DockingModel::set_side_rail can flip a side's presentation at
runtime. A side that flips Rail → Strip therefore drops its whole action cluster silently. That is
accepted behaviour, not a defect — but it must be stated at three points or it will be rediscovered
as a bug report:
DockRail::action()'s doc comment: "Rail presentation only. A side inTabPresentation::Striprenders no actions — mirror them withtrailing_slotif the side may flip presentation at runtime."docs/docking.md's Activity rail section, beside the existingtop_slot/bottom_slotprose.- A test —
actions_are_absent_in_strip_presentation— so the no-op is pinned deliberately rather than becoming true by accident and then silently reversing.
The mitigation is already available to apps and costs nothing: a DockRail carries both action()s
and leading_slot/trailing_slot for the same side, so an app that genuinely flips presentation
declares the cluster twice — once as actions (Rail), once as a slot widget (Strip). No current app
does this; the escape hatch exists so the Rail-only decision is not a dead end.
10.2 Note on shipping all three placements (R2-1)
Start has no Skribisto consumer today — top_slot already covers the above-the-tabs position for
non-button chrome. Shipping it anyway is the right call for a framework: the three variants are one
coherent, symmetric vocabulary (before the activities / after the activities / past the spacer),
and a two-variant enum would make Start a later breaking-ish addition that reads as an
afterthought. Cost is a single extra match arm in the composition order — there is no per-variant
machinery, since an empty DockRailActionGroup is omitted from the VStack entirely.
Test implication: the Phase 4 placement test must cover all three (Start before the tab list,
End after the tab list and the overflow trigger, Pinned after the Spacer) — the ordering
between End and the overflow trigger is the one an implementation is most likely to get backwards,
because both sit between the tab list and the Spacer.
TableView and TreeTableView
Two production-grade tabular widgets for Teksilo: a flat
TableView<T> over any
ListDataSource<Item = T> and a hierarchical
TreeTableView<T> over a
SortFilterTreeModel<T>.
They share the same column model, header strip, drag/resize/reorder,
filter popover, keyboard map, and accessibility wrappers; only the body
pane differs.
This page is the reference for the public surface and the design contracts you can rely on.
At a glance
#![allow(unused)] fn main() { use teksilo::data::{SelectionMode, SelectionModel, SortDirection, SortFilterListModel}; use teksilo::prelude::*; use teksilo::widgets::{ Column, ColumnWidth, GridLines, TableAlignment as Alignment, TableSelectionMode, TableView, TextWidget, }; let model = ListModel::from_vec(rows()); let selection = SelectionModel::new(SelectionMode::Multi); let proxy = SortFilterListModel::new(model) .with_comparator("name", |a, b| a.name.cmp(&b.name)) .with_predicate("name", |t| { let needle = t.to_lowercase(); Box::new(move |row| row.name.to_lowercase().contains(&needle)) }); let table = TableView::from_source(proxy.clone()) .add_column( Column::new("name", "Name", |row, _| { Box::new(TextWidget::new(lit!(row.name.clone()))) }) .width(ColumnWidth::Flex(2.0)) .sortable(true) .filterable(true), ) .row_height(28.0) .alternating_rows(true) .grid_lines(GridLines::Horizontal) .selection_mode(TableSelectionMode::MultiRow) .selection(selection.clone()); // One-shot wiring: the proxy now consumes the table's signals. proxy.sort_signal(table.sort_signal().clone()); proxy.filters_signal(table.filters_signal().clone()); table.set_sort(Some("name"), SortDirection::Ascending); }
TreeTableView is identical in shape but takes a SortFilterTreeModel<T>
and adds a tree_column(id) plus an optional filter_mode(...):
#![allow(unused)] fn main() { let proxy = SortFilterTreeModel::new(model) .filter_mode(TreeFilterMode::KeepAncestors) .with_predicate("name", /* … */); let tree = TreeTableView::from_projection(proxy.clone()) .add_column(/* tree column with the twist arrow */ name_col) .add_column(size_col) .tree_column("name") .selection_mode(TableSelectionMode::MultiRow); }
Row heights
Three mutually exclusive modes (the last builder call wins), identical on
TableView and TreeTableView:
#![allow(unused)] fn main() { .row_height(28.0) // uniform — the default fast path .row_height_fn(|row| { /* … */ }) // exact per-row callback .auto_row_height(30.0) // measured, 30 px estimate seed }
- Uniform (
row_height) — every row is the same height. Pure arithmetic, no allocation; this is the historical behavior and stays the default (28 px from the table style). - Exact (
row_height_fn) — a pure callbackfn(visible_index) -> f32seeds a prefix-sum offset table (O(log n) row↔y lookups). No measurement pass, exact scrollbar, zero jitter. The callback is re-swept from the first changed index on every model change, so it must be deterministic for the data it indexes. - Auto-measure (
auto_row_height(estimate)) — each realized row reports the height of its tallest cell, measured at the cell's column width (height-for-width — wrapped text just works). Unrealized rows assume the estimate. Two consequences:- Scroll anchoring: when a correction shifts content above the
viewport top,
scroll_yis adjusted in the same pass so on-screen content doesn't jump (one-frame latency). - Scrollbar settle: the root computes scrollbar totals before the body pane measures, so the thumb geometry settles one frame after a measurement change. A realization re-check guarantees rows always tile the full viewport even when the estimate was far too large.
- Scroll anchoring: when a correction shifts content above the
viewport top,
PageUp / PageDown page by visual distance (the row one viewport
above/below the current row's top), not by a fixed rows-per-page count.
Invalidation: which heights survive a model change
Measured/seeded heights are keyed by visible index, so the question on
every change is "from which row on are they stale?". The projection
layers answer it: SortFilterListModel, SortFilterTreeModel, and
TreeSlice expose first_changed_index() (see
data-models.md), and the tables consume it
automatically:
- appending rows keeps every measured height (divergence = old length),
even though
SortFilterListModelnotifies with a blanketReset; - expanding/collapsing a
TreeTableViewnode keeps the heights of all rows above the toggle — no scroll jump; - a sort flip invalidates from the first reordered row.
Which row a y coordinate resolves to
row_height_fn / item_height_fn / item_height are public callbacks with
no floor above 0.0, and spacing defaults to 0.0 — a zero-height row is an
ordinary, supported configuration (a filtered-to-nothing group header, a
collapsed detail row), not a corner case to route around. The shared
PrefixSumOffsets
table underlies both the exact and auto-measure modes and its row_at(y) is
the single place that resolves a pixel coordinate to a row index — it's what
both a click and a drag-drop hover call, so it is also the raw drop-target
identity in TreeView/TreeTableView DnD and the hit-tested tile in
GridView (see drag-and-drop.md §9).
A fully degenerate table — every row height and the spacing are
zero, the fully-collapsed-or-filtered-to-nothing case — used to disagree
with RowMetrics::uniform's equivalent geometry: Uniform::row_at
short-circuits on step <= 0.0 and answers row 0, while the offset table
ties every entry and partition_point resolved to the last tied index,
answering the final row instead. PrefixSumOffsets::row_at now checks the
same degeneracy structurally (every offset equal and the last row's own
height is zero) and answers 0, so a click and a drop at the same y
agree regardless of which row-height mode the view uses.
That check is deliberately narrower than "resolve every tie to the first
index." A partially degenerate table — a run of zero-height rows
between two real ones — must keep the last-tied answer: heights
[50, 0, 50] give offsets [0, 50, 50, 100], and at y = 50 the right row
is 2, the real row that actually starts there, not the invisible row 1.
Answering with a zero-height row there would silently retarget a click or a
drop onto a row nothing is drawn for.
Column model
A column is a generic descriptor over the row type:
#![allow(unused)] fn main() { pub struct Column<T: 'static> { /* … */ } Column::new("id", "ID", |row, ctx| Box::new(TextWidget::new(lit!(row.id.to_string())))) .width(ColumnWidth::Fixed(64.0)) // Fixed | Flex(factor) | Auto .min_width(40.0) .max_width(200.0) .alignment(Alignment::Trailing) // Leading | Center | Trailing .sortable(true) .filterable(true) // exposes the filter popover affordance .resizable(true) // default true .reorderable(true) // COLUMN drag-reorder; default true .pinned(PinnedSide::Leading) // Leading | None | Trailing .truncation(TruncationPolicy::Ellipsis); }
Column ids are the persistence key for sort, filter, width, and order signals — keep them stable across releases.
CellContext passed to the cell delegate carries:
| Field | Meaning |
|---|---|
row_index | visible row (post sort/filter) |
col_id | the column's stable id |
col_index | display position (0-based, post pin + reorder) |
is_selected | true when this row (or cell, in cell-mode) is selected |
is_focused | true when this cell carries the keyboard focus |
is_editing | true when editing_cell_signal == Some((row, col_index)) |
depth | Some(level) in TreeTableView, None in TableView |
is_tree_column | true on the column hosting the twist arrow |
Sort / filter / widths / order — the signal contract
Both widgets publish six reactive signals. Mutating any of them triggers the right rebuild level (no full layout when scrolling, no rebuild when only the focus ring moves, etc.).
| Signal | Type | Mutated by | Persistence key |
|---|---|---|---|
sort_signal | Signal<Option<(String, SortDirection)>> | header click cycle, set_sort, clear_sort | table.sort |
filters_signal | Signal<HashMap<String, String>> | filter popover, set_filter, clear_filters | table.filters |
column_widths_signal | Signal<HashMap<String, f32>> | header drag-resize, set_column_width | table.widths |
column_order_signal | Signal<Vec<String>> | header drag-reorder, set_column_order | table.order |
column_pinning_signal | Signal<HashMap<String, PinnedSide>> | drag across pane boundary, set_column_pinning | table.pinning |
focused_cell_signal | Signal<Option<(usize, usize)>> | keyboard nav, set_focused_cell, clear_focused_cell | (transient) |
Persistence
Use teksilo-settings to round-trip the layout. A typical
shape:
#![allow(unused)] fn main() { const TABLE_SORT: SettingsKey<String> = SettingsKey::new("table.sort", String::new); const TABLE_FILTERS: SettingsKey<HashMap<String,String>> = SettingsKey::new("table.filters", HashMap::new); const TABLE_WIDTHS: SettingsKey<HashMap<String,f32>> = SettingsKey::new("table.widths", HashMap::new); const TABLE_ORDER: SettingsKey<Vec<String>> = SettingsKey::new("table.order", Vec::new); let widths = ctx.settings().signal_for(&TABLE_WIDTHS); // Signal<HashMap<String, f32>> // Restore, then keep both directions in sync. Hold the returned // `ObserverHandle`s for as long as the table lives — dropping one // unsubscribes it. table.set_column_widths(widths.get()); let restore = widths.observe({ let table = table.clone(); move |w| table.set_column_widths(w.clone()) }); let persist = table.column_widths_signal().observe({ let widths = widths.clone(); move |w| widths.set(w.clone()) }); // Repeat for sort / filters / order. }
The signal API is the persistence boundary on purpose — the widget
emits, the application persists. There are no on_*_changed hooks; an
observe on the signal is the same thing without the typo surface.
Note the shape: two observers pointing at each other. Signal::set carries
no equality check by design, so such a pair is an unbounded mutual
recursion unless one edge guards its write — and a ColumnResizePolicy::Live
resize writes a width on every pointer move, so the loop would fire on the
first tick of the first drag. set_column_widths (and set_column_width,
set_sort, set_column_order, set_column_pinning) are therefore
equality-guarded: an unchanged value neither writes nor notifies, which
is what makes the round trip settle after one pass. If you route the value
through a transform of your own, guard your own edge the same way.
SortFilterListModel<T> vs raw signals
The minimum the widget needs is the four signals above; you can apply
sort and filter manually inside on_sort_changed / on_filters_changed
observers. Don't. Use the proxy:
#![allow(unused)] fn main() { let proxy = SortFilterListModel::new(model) .with_comparator("name", |a, b| a.name.cmp(&b.name)) .with_predicate("name", |t| { /* … */ }); let table = TableView::from_source(proxy.clone()); proxy.sort_signal(table.sort_signal().clone()); proxy.filters_signal(table.filters_signal().clone()); }
The proxy:
- maintains a single visible-index map shared between sort and filter,
- emits
DataChange::Resetonce per upstream change (one rebuild, not two), - forwards row-level inserts/removes to the table's
SelectionModelvia theobserve_changeschain, soMultiRowselection survives data mutations.
For trees, SortFilterTreeModel<T>
plays the same role, plus a TreeFilterMode switch:
| Mode | Behaviour |
|---|---|
HideNonMatching | rows that don't match are hidden, taking their entire subtree with them |
KeepAncestors | a match keeps every ancestor visible (file-tree convention; the default) |
KeepDescendants | a match keeps its full subtree visible (useful for "find a folder, see what's inside") |
TreeTableView::filter_mode(...) forwards to the proxy in place — calling
it on the builder mutates the shared Rc<RefCell<…>> even though the
method consumes Self.
Incremental updates for a single-row edit
A DataChange::ItemUpdated (list) or TreeChange::NodeUpdated (tree) from
the upstream model doesn't always force the full filter/sort/flatten pass
described above. Both proxies first try a cheap fast path: re-check just the
edited row's filter verdict and its rank against its current visible
neighbours, instead of re-filtering and re-sorting every row. They fall back
to the full rebuild whenever the row enters/leaves the visible set, or moves
past a neighbour — including a neighbour it now ties with. The tie case
matters because the full rebuild sorts with Vec::sort_by, which is
stable, so it always resolves a tie the same way (source index for the list,
original sibling order for the tree); leaving an edited row in its old slot
on a tie would disagree with that reprojection, and the row would visibly
jump the next time an unrelated mutation forced a full rebuild.
The two proxies pay a different price for that correctness.
SortFilterListModel compares source indices directly, so it still takes
the fast path for a tie that's already in stable order. SortFilterTreeModel
would have to walk tree.children(parent) to recover a tied node's sibling
index, so it bails to a full reprojection on any tie rather than pay that
cost on every update. Sorting a large tree on a low-cardinality column (a
status enum, a boolean) therefore falls back to a full reprojection more
often than the equivalent flat list would — worth knowing when picking what
column to sort on.
The filter popover
When Column::filterable(true), the header cell paints a small funnel
glyph at the trailing end (just before the resize zone). Tapping it
opens a Popover anchored to
the glyph; the popover content is a one-line text editor + a Clear
button that mutate the filters_signal[col_id] slot in place.
- The popover dismisses on Escape or click-outside (default
DismissBehavior::EscapeOrClickOutside). - Empty editor text removes the column's entry from the map; a non-empty string inserts/replaces it.
- The glyph tints
TextRole::Accentwhen the column has an active filter andTextRole::Secondaryotherwise.
Callers that already use SortFilterListModel<T> /
SortFilterTreeModel<T> get filtered output for free —
filters_signal re-projects the visible list whenever the popover
mutates the map.
The editor inside the popover is a deliberately minimal text field: printable characters, Backspace, Delete (clear), and ImeCommit. It is self-contained, so the filter UI is available in any TableView/TreeTableView build.
The header's pointer handler reserves a filter zone at the trailing edge (resize handle + filter glyph + a small padding tolerance) so that PointerDown over the popover trigger reaches the trigger instead of being eaten by the sort-cycle handler. Outside that zone, a click on the header label still cycles the sort as before.
Selection
TableSelectionMode picks the model:
| Mode | Backing model | Notes |
|---|---|---|
None | — | clicks just move focus |
SingleRow | teksilo_data::SelectionModel | replaces; modifier keys ignored |
MultiRow (default) | teksilo_data::SelectionModel with SelectionMode::Multi | Ctrl-click toggles, Shift-click extends, Shift+Arrow extends |
SingleCell | CellSelectionModel | Excel-style; one (row,col) at a time |
MultiCell | CellSelectionModel | rectangular extension via Shift+Arrow / Shift+Click |
Both selection models auto-adjust on DataChange::ItemsInserted /
ItemsRemoved / Reset, so visual selection survives sorting,
filtering, and underlying mutation.
TreeTableView accepts both row and cell modes; selection is keyed by the
flat visible index of the TreeSlice. Expanding/collapsing
re-numbers indices, so don't pin a selection across an expand_all()
without a re-mapping step.
Editing
The widget is the keyboard handler; the cell delegate is the editor swap. Wire it in three lines:
#![allow(unused)] fn main() { let table = TableView::from_source(proxy) // ... .edit_trigger(EditTrigger::F2OrTypeOrDoubleClick) // default .on_cell_edit_request(|row, col_id, ctx| { // open your editor: a TextInputField bound to the row's value, // a date picker, a colour picker, … }); let column = Column::new("amount", "Amount", move |row, ctx| { if ctx.is_editing { // Swap in your editor while editing_cell_signal matches. Box::new(TextInputField::new(state_for(row.id))) } else { Box::new(TextWidget::new(lit!(format!("{}", row.amount)))) } }); }
EditTrigger selects which gestures begin an edit:
| Variant | F2 | Type | Double-click |
|---|---|---|---|
F2 | ✔ | ||
F2OrType | ✔ | ✔ | |
DoubleClick | ✔ | ||
F2OrTypeOrDoubleClick | ✔ | ✔ | ✔ |
None |
F2OrTypeOrDoubleClick is the default (Excel-like). editing_cell_signal
is the source of truth for "which cell is in edit mode"; begin_edit
and end_edit give you imperative control.
Escape ends the edit (the framework's keyboard handler reads
editing_cell_signal and clears it before falling back to the focus
clear behaviour).
Keyboard
| Key | Effect |
|---|---|
| Arrow keys | move focused cell within the visible grid |
| Home / End | jump to first / last column of the current row |
| Ctrl-Home / Ctrl-End | jump to first / last cell |
| PgUp / PgDn | scroll one page; focus moves the same number of rows |
| Tab / Shift+Tab | next / previous cell in row order, wrapping rows (configurable via tab_traversal) |
| Shift + Arrow | extend selection in MultiRow / MultiCell modes |
| Space | toggle selection at focus |
| Enter | invoke on_row_activate (or fall back to toggle-select) |
| Ctrl-A | select all rows / cells in multi modes |
| F2 / typing | begin edit (gated by EditTrigger) |
| Escape | end edit if any, else clear focus |
| ArrowLeft on tree column | collapse the row when expanded (TreeTableView) |
| ArrowRight on tree column | expand the row when collapsed and has children (TreeTableView) |
The same handler powers both widgets via the
RowNavigator
trait — FlatNavigator for TableView, TreeNavigator for
TreeTableView.
Drag & drop
Column resize
The grip is centred on the divider: it reaches RESIZE_HANDLE_WIDTH
(default 4 px) into the cell on each side, the same PM_HeaderGripMargin
convention QHeaderView uses. So a header cell owns two grips — the one at
its reading-order trailing edge, which resizes its own column, and the one at
its leading edge, which resizes its predecessor, whose trailing edge that
same divider is. Aiming at the seam and landing a pixel late therefore still
resizes, instead of cycling the sort or starting a reorder drag.
Two exceptions narrow a grip:
- Pane seams. The leading grip is suppressed when the predecessor sits in
a different pinned pane. Once
scroll_xis nonzero the column on the far side of a seam is not the one visually adjacent to it, so that boundary is not a column divider. - Very narrow columns. Each half is capped at a quarter of the cell's
width, so a column dragged down to a small
min_widthkeeps a central band for click-to-sort and reorder-drag instead of becoming all grip.
The header strip paints a separator at every column boundary,
independent of GridLines — in the header the separator
is the affordance (it is the only thing showing where the grip is), which is
why every desktop table draws header separators unconditionally. GridLines
stays a body decoration.
Cursor switches to CursorIcon::ColResize over either grip and is held for
the whole drag (the pointer is captured and can travel far outside the
header). PointerDown captures the pointer and records the target column's
width; PointerMove updates column_widths_signal. Two policies:
#![allow(unused)] fn main() { table.column_resize_policy(ColumnResizePolicy::Live) // commit on every tick (default) table.column_resize_policy(ColumnResizePolicy::OnRelease) // commit on PointerUp }
Under OnRelease nothing moves until the button comes up, so the view paints
a full-height guide line at the prospective divider for the duration of the
drag — the same rubber band Qt and Excel show.
The committed width is clamped to the column's [min_width, max_width]
before it is written, so column_widths_signal — the handle apps read
back and persist — always mirrors what the table actually renders (the
solver re-applies the same clamp when it resolves widths).
The handler converts window-space pointer coordinates into cell-local
coordinates using the cell's window origin, captured in
place_children. Without that translation, the resize zone test would
misfire from anywhere in any column past the first one.
A drag is abandoned if the window goes inactive mid-gesture: the OS delivers no PointerUp to a window that lost focus with the button down, and the stale state would otherwise keep dragging the column on the next bare pointer move.
Accessibility. A resizable column header advertises AccessKit
Increment / Decrement, each stepping the column by COLUMN_RESIZE_STEP
(8 px) with the same clamping as a drag — the non-pointer path for screen
readers, switch access, and the automation MCP. No
numeric value or range is published on the ColumnHeader node: it would be
announced on every ordinary pass over the table, which costs the common case
to serve a rare one.
Column reorder
Drag a header cell from outside the resize zone. The column-reorder
drag emits ColumnReorderDragData { col_id, source_table_id }. The
header strip is the drop target; dropping inside the leading-pinned
pane re-pins the column to Leading, dropping inside the
trailing-pinned pane re-pins to Trailing, otherwise the column joins
the unpinned middle stream. Inter-table drops are rejected by
source_table_id mismatch.
Row reorder
Row drag-and-drop is owned by the backing source, not the view (see
data-source.md §3). The view computes a geometric
(target, position), asks the source can_accept on every hover (an
insertion line shows an accepted landing; a Reject suppresses it),
and commits via the source's accept_drop on release — there is no
on_row_drop callback. target is a row index resolved from the pointer's
y the same way a click resolves one — see "Which row a y coordinate
resolves to" above for the
zero-height-row tie-break that keeps a click and a drop agreeing.
TableView. Set .reorderable(true) on the table (distinct from
Column::reorderable, which reorders columns and defaults to true; the
table-level flag reorders rows and defaults to false); a row drag emits the
shared RowDrag { source_index, source_view_id }. An intra-table
reorder is a DragSource::SameView the source's accept_drop applies
(a ListModel<T> reorders in place); a cross-table or external drop
arrives as DragSource::Foreign { payload } at the same
accept_drop, which downcasts the payload. Keyboard reorder
(Alt+Arrow) routes a synthesized RowDrag through the same
accept_drop.
TreeTableView. Set .reorderable(true); a row drag routes through
the tree source with the cycle guard — tree_apply_reorder refuses
to drop a node into its own subtree, and handles the
insertion-vs-reparent (Before/After sibling vs Into child) index
math. Reorder is suppressed while a sort is active (a sorted projection
has no stable insertion target). Alt+Arrow keyboard reorder is
likewise routed through the source.
Accessibility
TableViewroot:Role::Tablewithrow_count(header inclusive when shown) +column_count.TreeTableViewroot:Role::TreeGrid, same counts.- Each header cell:
Role::ColumnHeaderwithcolumn_indexand, on the active sort column,sort_direction. - Each body row:
Role::Rowwithrow_index(1-based; header is row 1, first body row is row 2). OnTreeTableView, the row also carrieslevel(1-based depth) andexpandedfor non-leaf rows. - Each body cell:
Role::Cellwithrow_indexandcolumn_index, plusselectedreflecting the current selection. - The filter popover's trigger inherits the popover's
set_expandedstate and is named"Filter"— locating it via screen-reader search is the same as locating any popover button.
Virtualization vs accessibility: only rendered rows materialize cell
nodes, but set_row_count(total) keeps screen readers aware of the
full size. Action::ScrollIntoView on an unmaterialized row routes
through ensure_row_visible, which is the same path the keyboard
PgDn handler uses.
Theme tokens
| Surface | Role |
|---|---|
| outer frame border | BorderRole::Default |
| header background | SurfaceRole::Raised |
| header bottom divider | BorderRole::DividerStrong |
| body even-row bg | SurfaceRole::Content |
| body odd-row bg | SurfaceRole::AltRow |
| row selected bg | SurfaceRole::Selected |
| cell focus ring | BorderRole::Focused |
| grid lines | BorderRole::Divider |
| sort indicator (active) | TextRole::Accent |
| filter glyph (inactive) | TextRole::Secondary |
| filter glyph (active) | TextRole::Accent |
| TreeTableView connector lines | BorderRole::Divider |
Static numbers (ROW_HEIGHT, HEADER_HEIGHT, RESIZE_HANDLE_WIDTH,
GRID_LINE_THICKNESS, TREE_INDENT_PER_LEVEL, …) are pub consts in
recipe_table_style
They are snapshot at build time, like every other widget.
What is and isn't shipped
Shipped: virtualized flat + hierarchical bodies, header drag-resize,
header drag-reorder (with cross-pane re-pinning), pinned columns
(Leading / Trailing), sort cycle (None → Asc → Desc → None), filter
popover with reset, MultiRow / MultiCell selection with shift +
ctrl semantics, full keyboard nav with focus ring, edit hooks via
editing_cell_signal + on_cell_edit_request, row drag-drop reorder
on TableView, tree expand/collapse via twist + ArrowLeft/Right,
tree filter modes, Role::Table / TreeGrid accessibility with row
indices and sort direction.
Intentionally not shipped:
- spreadsheet-style cell merging at the layout level (cells expose
AccessKit
row_span/column_spanfor screen readers; the layout doesn't merge), - formula evaluation / computed cells,
- multi-row column-group headers,
- footer / summary rows (compose a
StatusBarbelow the table), - in-table filter chip bar,
- TreeTableView row drag-drop (insertion-vs-reparent UX needs its own design).
Deltas you may notice: Column::header_override is stored on
the column but the default header rendering ignores it for now;
Column::alignment and Column::truncation are likewise persisted on
the descriptor but the user's cell delegate handles its own alignment
and truncation; row_header_column, cell_label, row_label, and
auto_truncation_tooltip builders are not yet wired (their
accessibility slots exist on CellA11y and RowA11y). These are gaps,
not bugs.
Demos
cargo run -p data-grid— 1000-row flatTableViewwithSortFilterListModel,MultiRowselection, alternating rows, and filterable name/email/role columns.cargo run -p tree-table-view— mock filesystemTreeTableViewwithKeepAncestorsfiltering, twist-arrow expand/collapse, and the same drag-resize / drag-reorder behaviour as the flat table.
CodeEditor, PlainTextEditor, and LogView
Three multi-line text surfaces over one core
(crates/teksilo-widgets/src/code_editor/):
CodeEditor— a source editor: a line-number gutter, a current-line band, indentation, bracket handling, multiple carets, and completion.PlainTextEditor— the same core with the code affordances off and wrapping on: a notes field, a commit message, a description box.LogView— a read-only, append-only, tail-following streaming view that scales to 100 000+ lines. Its own page: Log view.
They are one implementation because they differ in configuration, not in kind — all three are a run of lines with a caret in it. A widget per face would triplicate the caret, selection, IME, clipboard, scrolling, and accessibility and let them drift.
Why not RichTextEditor
RichTextEditor already edits
multi-line text, and this deliberately does not build on it. Its command
vocabulary is tables, lists, blockquotes, and bold — reusing it would put
Tab-navigates-a-table-cell and Ctrl+B-emboldens into a source file, where the
first is wrong and the second is meaningless. Its state carries a table-aware
Ctrl+A ladder and a rich clipboard fragment; this one carries an indent policy
and a caret vector. What the two genuinely share — the caret blink clock, the
debounce window, the scroll arithmetic — lives in the crate-internal
common::editor_runtime,
used by both, so the overlap is factored, not copied.
Language-agnostic by construction
There is no Language enum anywhere in this module. Comment tokens, bracket
pairs, indent width, and completion candidates are
CodeConfig values the
application supplies: the editor knows how to toggle a line comment, not that
Rust uses //. Guessing would be worse than not knowing — inserting // into a
Python file corrupts it silently — so the defaults do only what needs no language
knowledge (indent, auto-indent) and leave comment toggling and bracket handling
off until the application says what the tokens are.
#![allow(unused)] fn main() { use teksilo::widgets::{CodeEditor, COMMON_BRACKETS}; use teksilo::text_document::TextDocument; let doc = TextDocument::new(); doc.set_plain_text(source).unwrap(); let editor = CodeEditor::new(doc) .font_family("monospace") // a code editor wants a monospace family .line_comment("//") // enables Ctrl+/ .bracket_pairs(COMMON_BRACKETS.to_vec()) .auto_close_brackets(true) // typing '(' inserts ')' .bracket_matching(true) // the caret's bracket + its match wash .completion_provider(|ctx| complete(ctx.prefix)); let handle = editor.handle(); // drive it from a toolbar / status bar }
CodeEditor::read_only(doc) is the same, minus the caret: navigation, selection,
and copy only, Role::Document.
Builders
Shared by CodeEditor and PlainTextEditor:
| Builder | Effect |
|---|---|
wrap_mode(WrapMode) | CodeEditor defaults to None (a wrapped source line breaks the gutter's one-number-per-line correspondence); PlainTextEditor defaults to Word. |
v_scroll_policy / h_scroll_policy | Auto (default) / AlwaysOn / AlwaysOff. |
min_lines / max_lines | Switch from greedy to intrinsic sizing — grow with content up to max_lines, then scroll (the composer pattern). |
font_family / zoom / follow_text_scale | Typography. follow_text_scale (default on) grows text with the global accessibility scale. |
background / text_color / caret_color / selection_color | Color, a theme role, or a Signal. |
on_change(Fn) | Fired once per drain batch that contained a real edit. |
window_to_clip(bool) | Cull the render to the visible clip band — only for an editor laid out at full document height inside an outer ScrollArea. |
CodeEditor-only:
| Builder | Effect |
|---|---|
gutter(bool) (default on) | The line-number gutter. |
current_line_highlight(bool) (default on) | A full-width band under the caret's line. |
indent_style / tab_width / use_soft_tabs | Spaces of a width, or tabs rendered a width wide. |
auto_indent(bool) (default on) | Enter carries the line's leading whitespace. |
line_comment(token) | Enables Ctrl+/. Unset leaves it a no-op rather than guessing. |
bracket_pairs(pairs) / auto_close_brackets / bracket_matching | Delimiter handling. Empty pairs (the default) disables both. |
completion_provider(Fn) / auto_complete(bool) | See Completion. |
Code semantics
Every command in keyboard.rs
is driven by injected configuration and is a single atomic undo step:
- Auto-indent on Enter carries the previous line's indentation (and splits a
{}pair onto its own indented line when the caret is between them). - Smart Tab / Shift+Tab — soft or hard tabs; with a selection, indent / dedent every touched line.
- Ctrl+/ toggles the configured line comment on the caret's line or selection.
- Ctrl+D duplicates the line; Alt+↑ / Alt+↓ move it.
- Auto-close, type-over, and pair-backspace for configured brackets;
bracket matching publishes the caret's bracket and its partner as a
reactive
Signal<Option<(usize, usize)>>(viahandle.bracket_match()) and washes both cells behind the text. - Multiple carets —
Ctrl+Alt+↑/↓add a caret above/below, Alt-click adds one at the pointer; typing goes to every caret at once, in one undo step. The accessibility tree reports only the primary caret.
Caret motion follows the platform
Word-jump, the line edge and the document edge sit on different modifiers on
macOS than they do elsewhere, and the difference is not a simple substitution —
so the chords are read through
common::text_nav rather
than from an "is the accelerator held?" flag:
| motion | Windows / Linux | macOS |
|---|---|---|
| character | ← → | ← → |
| word | Ctrl+←/→ | ⌥←/→ |
| line edge | Home End | ⌘←/→, Home End |
| document edge | Ctrl+Home/End | ⌘↑/↓, ⌘Home/End |
| delete word | Ctrl+⌫ Ctrl+⌦ | ⌥⌫ ⌥⌦ |
Alt+↑/↓ stays on move-line here on every platform, macOS included — that is
the binding every code editor ships, and it takes precedence over the
paragraph motion the rich-text editor puts there. ⌘⌫ means delete-to-line-start
on macOS, which is not implemented; it falls through to a plain single-character
delete rather than removing more than was asked for.
Shift extends the selection over any of them, and the policy filter is asked
about the motion that actually runs — a MoveWordLeft veto bites on ⌥←
exactly as it bites on Ctrl+←.
Completion
Supply candidates with completion_provider(Fn(&CompletionContext) -> Vec<CompletionItem>);
the editor filters them by the word before the caret, shows a caret-anchored
popup, and replaces the word on accept. Language-agnostic — the app knows the
candidates (keywords, in-scope names, an LSP reply), the editor knows the
mechanics. Without a provider there is no completion.
CompletionContextcarries theprefix,line,column, and documentposition.CompletionItemisnew(label).insert_text(..).detail(..).kind(CompletionKind).- The editor owns the keys while the popup is open (Up/Down/PageUp-Down/Enter/Tab/
Escape) — the popup is a detached overlay, not an ancestor, so keys cannot
bubble to it. The ARIA listbox pattern (
HasPopup::Listbox+AutoComplete::Listactive_descendant) is on the editor node.
auto_complete(false)restricts opening toCtrl+Space.
Accessibility
Both the editor and the log present their text to assistive technology as a tree
— a Role::Paragraph per line, a Role::TextRun per formatting run — built by
the shared walk in
a11y.rs. Each run carries
the per-character byte lengths, word starts, and geometry a screen reader needs
to speak and navigate character by character, plus:
- Same-line run linking (
next_on_line/previous_on_line) so a reader navigating by line does not stop at each syntax-highlight colour boundary. - A trailing newline on each line's last run (AccessKit's line-break contract; the caret can never address it).
- Chunking of runs over 255 characters into linked ≤255-char runs —
word_startsare character indices stored asu8, so a long line would otherwise lose word navigation past character 255. - Per-line
position_in_set/size_of_set("line 42 of 200"), carried on the line rather than announced from the gutter (which is hidden from AT).
Editable surfaces report Role::MultilineTextInput and advertise SetValue /
ReplaceSelectedText / SetTextSelection; read-only ones report Role::Document
(not Role::Code, which accesskit_consumer excludes from text-range support,
so a caret could not be tracked through it) and advertise SetTextSelection only.
An AT-initiated SetTextSelection resolves back to a document cursor position
through a per-run synthetic-node map. The editor walks the whole bounded document
(cached); the log walks only its visible window — see Log view.
Rendering & scale
The body paints via the shared
rich_text::paint::paint_frame
over the RichTextEngine. For a
bounded document the standard full layout is right; for the unbounded streaming
case the LogView uses the windowed layout path — the text-stack additions that
make that possible (windowed layout_window, O(1) append, front-truncate) are
documented in Log view, which also carries the before/after
benchmark table.
Testing
The core is fully headless — no GPU, no display. Tests run against the private
engine's fixed metrics and verify the editor's own logic (viewport adoption,
caret bookkeeping, event classification, policy gating, the a11y walk), not
shaping, which is text-typeset's own suite's job. See
code_editor/tests.rs.
Demos
cargo run -p code_editor # gutter, brackets, comment toggle, multi-caret,
# an injected highlighter and completion
cargo run -p log_view # the streaming face — see docs/log-view.md
LogView — a scalable streaming log
LogView is the code
editor's core turned inside out: instead of a bounded document a person edits,
it is an unbounded one the program appends to and the person only reads,
scrolls, selects, and copies. It reuses
CodeEditorState — so
selection, copy, theming, and accessibility come for free and cannot drift from
the editors' — but owns its own frame step
(log_stream.rs) and
paint body, because content arrives faster than a person types, forever, and
neither the editor's full relayout nor its event handling can carry that load.
#![allow(unused)] fn main() { use teksilo::widgets::{LogView, LogViewHandle}; use teksilo::tokens::Color; let log = LogView::new() .scrollback_limit(50_000) // bound the retained lines .severity_highlighter(|line| { // colour a line by what it is if line.contains(" ERROR ") { Some(Color::new(0.92, 0.36, 0.36, 1.0)) } else if line.contains(" WARN ") { Some(Color::new(0.92, 0.72, 0.28, 1.0)) } else { None } }) .font_family("monospace"); let handle: LogViewHandle = log.handle(); // append from anywhere on the UI thread }
Feed it through the handle: append("line"), append_line, append_lines(iter)
(splitting on \n; a single trailing newline is a terminator, not a blank line),
clear(), and scroll_to_bottom(). handle.line_count() is a reactive
Signal<usize> of the retained count for a status bar.
Two costs, bounded
A naive multi-line view over a growing document has two costs that grow without
bound. LogView answers each; the numbers below are from
text-typeset/docs/streaming-baseline.md
(a 65-char log line, no-wrap, 16 px).
Appending one line
Because a block-count change invalidates a full layout, a consumer with no tail-append entry point is forced to re-lay-out the whole document on every appended line — O(N):
| Lines | Full relayout (per line) | Windowed append (per line) | speedup |
|---|---|---|---|
| 1 000 | 10.9 ms | ~10 µs | 1 046× |
| 10 000 | 113 ms | ~10 µs | 11 655× |
| 100 000 | 1.167 s | ~10 µs | 117 007× |
The LogView never re-lays-out the whole buffer: drain_events, told the state
is streaming, sets a re-window flag instead of forcing a relayout, and a frame's
arrivals are batched into one append_lines.
Holding a large buffer
A resident shaped line costs ≈ 6.5 KB, so laying out the whole document is the real memory sink:
| Lines | Fully resident | Windowed (viewport only) |
|---|---|---|
| 1 000 | 10.8 MB | 3.7 MB |
| 10 000 | 68.1 MB | 3.7 MB |
| 100 000 | 622.9 MB | 3.7 MB (168× less) |
LogView shapes only the rows the viewport can show, via
RichTextEngine::layout_window_from_snapshots,
placing each row arithmetically at y = index × row_height; the scrollbar spans
the whole document even though almost none of it is shaped. Render already culls
to the viewport, so shaping the rest only ever cost memory. The document's raw
text (a rope, ≈ 65 B a line) is cheap by comparison — ~6.5 MB at 100 k.
The text-stack additions
The windowed path is additive to the sibling crates, so RichTextEditor's paths
are byte-for-byte unchanged:
- text-typeset —
DocumentFlow::{layout_window, set_uniform_extent, add_block, remove_leading, block_params_for}. - teksilo-text —
RichTextEngine::{layout_window, layout_window_from_snapshots, set_uniform_extent, append_block, remove_leading, block_visual_info}. - text-document —
TextDocument::{append_line, append_lines, truncate_front}(undoable-false, all-or-nothing), whose events now also reachon_changesubscribers, not only the poll path.
Windowing internals
The visible window (first, count) is computed from the scroll offset and a
learned uniform row height (all three of the render window, the a11y window, and
the a11y-change check share one window_bounds helper so they cannot disagree).
Rows are located by chaining character positions through the rope
(snapshot_block_at_position) — each row is one O(log n) snapshot, not the O(n)
block walk TextBlock::next would be — forward from a cached (row, position)
anchor that the tail-following hot path advances a few rows at a time. Windowing
is therefore O(window · log n) in steady state, with a cold O(n) locate only on a
far scrollbar jump or after an eviction drops the anchor.
The document's block_count stat does not count its initial empty block, so the
view keeps an authoritative line count instead and fills that initial block with
the first line — a fresh log opens on real content, not a blank line.
Following the tail
Following is derived from scroll position, not a mode flag that fights the
user: the view sticks to the bottom only while it is already at the bottom
(scroll_y ≥ max_scroll_y − ε). Scroll up to read history and it pauses; scroll
back (or scroll_to_bottom()) and it resumes — the behaviour of every terminal,
and the one that composes correctly with a stray key or click nudging the
viewport. Set follow_tail(false) to hold position as the buffer grows.
Scrollback
scrollback_limit(n) evicts the oldest lines past n from the front. Eviction
is amortised over a slack band that scales down with the cap (so a small cap is
still honoured tightly), and truncate_front shifts the cursors automatically, so
a live selection stays glued to surviving text. Unset (the default) keeps every
line: memory stays flat in the line count (only the window is shaped), but the
raw text accumulates in the rope and each append stays linear in the document
size — so a genuinely unbounded, sustained high-rate producer should set a cap.
Accessibility
Role::Document (not Role::Log — that role is excluded from accesskit's
text-range support, so a reader could not track a caret through it), read-only,
with the same paragraph/run walk as the editor
(a11y.rs) — but windowed:
only the visible lines are emitted as paragraphs (numbered by global line, "line
41 002 of 128 449"), so an append re-walks O(window), not O(document). The tree
re-walks on the log's own a11y_version, bumped only when the visible window
changes — a scroll crossing a row, a following-tail append, an eviction — not on a
sub-row pixel scroll or a tail append arriving while the reader is scrolled away.
Opt into a Live::Polite region for new lines with announce_appends(true) — off
by default, because a live region is right for a handful of meaningful events and
hostile for a build log at fifty lines a second.
Threading
The handle is UI-thread (Rc). Feeding a log from a background thread (a PTY
reader, a tracing layer) means marshalling the lines to the UI thread first —
through the app's async executor, or a channel drained in a handler. Each append
wakes the view, which otherwise stops asking for frames when idle.
Demo
cargo run -p log_view # a synthetic ~40-lines/frame producer, severity
# colour, follow-tail, a 50k scrollback cap, and a
# "Burst 10k" button to watch the windowing hold
TabWidget and TabBar
Two cooperating widgets for tabbed content in Teksilo: a header-only
TabBar<T> driven by any
ListDataSource<Item = T>
and a TabDelegate<T>,
and an all-in-one TabWidget
that pairs a TabBar with a Switcher of content panes — sharing one
Signal<Option<TabId>> selection.
TabBar<T> is the primitive: use it on its own when the header strip
lives in one panel and the content lives somewhere else (a separate
window, a different splitter pane, or a flat document area below).
TabWidget is the convenience composition for the common "header above
content" pattern.
This page is the reference for the public surface and the contracts you can rely on.
At a glance
#![allow(unused)] fn main() { use teksilo::data::ListModel; use teksilo::prelude::*; use teksilo::widgets::{ TabBarOrientation, TabDisplayMode, TabHandle, TabId, TabInfo, TabSizing, TabWidget, TextWidget, VStack, }; #[derive(Debug)] struct DocState { title: String, edits: Signal<usize>, } let selected: Signal<Option<TabId>> = Signal::new(None); let model: ListModel<TabHandle> = ListModel::from_vec(vec![ TabHandle::dynamic( TabId::fresh(), "doc", TabInfo::new() .title(lit!("Doc 1")) .closable(true), DocState { title: "Doc 1".into(), edits: Signal::new(0) }, ), ]); let tw = TabWidget::new(selected.clone()) .static_tab( TabInfo::new() .title(lit!("Welcome")) .pinned(true), TextWidget::new(lit!("Welcome page")), ) .dynamic_tab::<DocState>("doc", |_handle, state| { Box::new(VStack::new() .child(TextWidget::new(lit!(state.title.clone()))) .child(TextWidget::new(lit!("…")))) as Box<dyn Widget> }) .dynamic_model(model.clone()) .reorderable(true) .tab_sizing(TabSizing::Shared); }
Stand-alone TabBar<T> looks the same minus the content-side machinery.
TabBar is generic over any item type T; supply a TabDelegate<T> that
extracts presentation from your items and an id_of closure that produces
the stable TabId for each item:
#![allow(unused)] fn main() { use teksilo::widgets::{TabBar, TabDelegate}; // Example with a custom item type. struct DocItem { id: TabId, title: String, closable: bool, pinned: bool } let bar = TabBar::horizontal( model, // ListModel<DocItem> TabDelegate::new(|_, item: &DocItem| lit!(item.title.clone())) .closable(|_, item| item.closable) .pinned(|_, item| item.pinned), selected, |_, item: &DocItem| item.id, ) .tab_sizing(TabSizing::Shared) .reorderable(true); }
TabHandle / TabInfo / TabId
A tab's runtime identity is split across three types, each with one job:
TabId— stable identity. ANonZeroU64wrapper. Allocate fresh ids withTabId::fresh()(a monotonic counter), or wrap an external key withTabId::from_raw(NonZeroU64)when the identity comes from app-side storage (document UUID, file-path hash, …) — fresh ids would re-allocate every restart and break session-restore round-trips.TabInfo— presentation metadata:title,icon,tooltip,closable,pinned,enabled. Title and tooltip areLocalizedString(accepttr!(...)); the icon is a factory closure (noIconWidget: Clonerequirement) called each build, so it picks up theme/state changes naturally.TabHandle— the thing that lives in the data source. Carriesid,info, akinddiscriminator, and anRc<dyn Any>payload. Heavy state (the document, the image, the page) lives onpayload— not on the content widget. Reorders, sort/filter rebuilds, and pin-toggle rebuilds destroy and recreate widgets freely; the handle's payload is stable and the registered factory produces a fresh view over it whenever the framework needs one.
TabHandle::clone() is cheap: TabInfo is shallow (the icon is an
Rc<dyn Fn() -> IconWidget> factory) and payload is an Rc<dyn Any>.
Static vs dynamic tabs
TabWidget accepts both shapes side by side. Static tabs always render
first, in declaration order; dynamic tabs follow.
Static tabs are fixed for the widget's lifetime. The content is built
once and memoized — subsequent rebuilds (caused by adjacent
dynamic-model mutations, locale changes, theme flips) reuse the same pane
WidgetId, so per-pane state (focus, scroll, animation progress) is
preserved.
| Builder | Content shape | Notes |
|---|---|---|
static_tab(info, content) | impl Widget + 'static | One-shot ownership; consumed on first build. |
static_tab_factory(info, fn(&TabHandle) -> Box) | factory closure | Called once on first build. |
static_tab_id(info, WidgetId) | pre-registered WidgetId | For the teksu! DSL — wraps the id in an alias on first build. |
static_tab_with_id(id, info, content) | impl Widget + 'static + caller-chosen id | Use when external code (deep links, session restore) flips selection by id. |
static_tab_factory_with_id(id, info, factory) | factory closure + caller-chosen id | Factory variant of the above. |
Dynamic tabs are produced from a ListModel<TabHandle> (or any
ListDataSource<Item = TabHandle>). One factory is registered per
kind:
#![allow(unused)] fn main() { .dynamic_tab::<DocState>("doc", |handle, state: &DocState| { Box::new(DocPane::new(state)) as Box<dyn Widget> }) .dynamic_model(model) }
The <S> type parameter pins the payload type. The framework downcasts
handle.payload to S before calling the factory and panics with a
clear "tab kind X was registered for Y but payload has different type"
message on mismatch — Any never leaks into app code. The kind
"__static__" is reserved for static tabs and panics at registration.
Dynamic panes are also memoized, keyed by TabId. The memo map is
pruned every build to drop entries whose tab is no longer in the model;
their widgets become unreachable and the arena reaps them.
When to use which
- Always-present features that ship with the app (Welcome, Settings, a Console pane in an IDE, the editor's main perspective list) → static.
- User-opened items that come and go at runtime (open documents, open images, open chat threads) → dynamic.
- Session-restored items: dynamic, with
TabId::from_raw(...)rehydrated from storage so deep links keep working.
Cross-boundary reorders (drag a dynamic tab past a static tab in the
unified ordering) are silently rejected by the default reorder
handler — the framework warns once per process and keeps the move from
happening. Install an explicit on_reorder(...) to interleave them.
TabDelegate<T> — the per-item resolver
TabBar<T> is generic over the data source's item type, so the bar
needs a closure-of-closures to extract per-tab presentation. That's
TabDelegate<T>:
#![allow(unused)] fn main() { pub struct TabDelegate<T: 'static> { /* … */ } TabDelegate::new(|i, item: &T| label_for(i, item)) // required .icon(|i, item| item.icon().map(IconWidget::from)) .leading(|i, item| None::<Box<dyn Widget>>) .trailing(|i, item| None::<Box<dyn Widget>>) .context_menu(|i, item| factory_for(i, item)) .closable(|i, item| item.is_closable()) .pinned(|i, item| item.is_pinned()) .enabled(|i, item| !item.is_locked()) .tooltip(|i, item| item.tooltip()); }
Closures run at build time, every build. Mutating an item through
ListModel::set(i, …) fires DataChange::ItemUpdated which rebuilds
the bar — closures re-run, labels and icons re-resolve. Locale changes
propagate the same way because LocalizedString already carries
reactive resolution semantics. There is no eager resolve_now().
TabWidget has its own delegate-free shape (static_tab(...) /
dynamic_tab::<S>(...)) and constructs a TabDelegate<TabHandle>
internally that reads from handle.info. You only touch
TabDelegate<T> directly when you build a stand-alone TabBar<T> over
a custom T.
TabBar vs TabWidget
The split is data flow, not features. TabBar owns:
- the header strip layout (axis-aware: horizontal row / vertical column)
- pinned-tab partition (leading icon-only strip)
- scroll viewport with arrows + wheel remap
- the "show all tabs" overflow dropdown
- per-tab close button (suppressed on pinned tabs)
- drag-to-reorder + insertion-line drop indicator + edge auto-scroll
- cross-bar tab transfer (
accept_external_tabs/on_tab_received/on_transfer_out) and non-tab / OS drops (on_external_drop) - per-tab tooltip via
WidgetBuilder::tooltip - bar-leading and bar-trailing slots
- accessibility for the header tree (
Tabrole +controls()relation)
TabWidget adds:
- the
Switcherof content panes - static + dynamic tab registration
- pane memoization across rebuilds
- the unified ordering (static-then-dynamic) over the bar's index space
- callback translation: bar speaks indices, app callbacks speak
TabId
Either widget works in the teksu! DSL; both publish their selection
through Signal<Option<TabId>>.
Selection — Signal<Option<TabId>>
Selection is id-based. The bar holds a stable TabId per item
(extracted by the id_of closure passed to the constructor) and the
public selected_id signal is the source of truth across reorders /
removals / locale changes. Internal index-based code (keyboard nav,
scroll, click) reads a private selected_index signal that the bar
keeps in bidirectional sync with selected_id at build time.
What this guarantees:
- Reorder preserves selection. Drag a tab from position 2 to position 0 with that tab selected → it is still selected after the move. The id matches; the index re-resolves.
- Out-of-range writes are absorbed.
selected_id.set(Some(id))for an id not in the model leaves the visible state alone (no panic, no blank content). - External code drives it cleanly. A "Go to tab" command sets
selected_id; the bar follows. A toolbar's "open Settings" button setsselected_id.set(Some(self.settings_id))and the framework does the rest.
The framework's stale-id fallback: when the active tab is closed, the bar selects the next neighbour (browser convention) — the index of the tab that took the closed tab's slot, or the new last tab if the closed tab was at the end.
Orientation — reactive
TabBarOrientation
is Horizontal (default) or Vertical. On TabWidget:
#![allow(unused)] fn main() { TabWidget::new(selected) .horizontal() // default .vertical() // sidebar / IDE-perspective convention // or — reactive, driven by an external signal: let orient = Signal::new(TabBarOrientation::Horizontal); TabWidget::new(selected).orientation(orient.clone()); // later: orient.set(TabBarOrientation::Vertical); // bar flips, panes preserved }
TabWidget binds the orientation signal at BindingLevel::Rebuild so
flipping it from a toolbar button rebuilds the outer layout (HStack ↔
VStack) and re-creates the inner TabBar with the new orientation.
Memoized panes survive the rebuild — focus, scroll, and per-document
state are preserved.
TabBar<T> chooses orientation through its constructor only:
TabBar::horizontal(...) / TabBar::vertical(...). Switching at
runtime means rebuilding the bar — which is what the TabWidget
wrapper does for you.
Vertical bars use upright text (single-line, ellipsis-truncated),
not rotated glyphs. Rotated text breaks hit-testing and focus-ring
math, and Teksilo's text-typeset integration doesn't yet support
per-glyph layout rotation. This matches VS Code's activity-bar style.
Tab sizing — Shared vs Independent
#![allow(unused)] fn main() { pub enum TabSizing { /// All non-pinned tabs share the same extent on the layout axis — /// width in horizontal, height in vertical. Available region /// divided equally, clamped to [min_tab_extent, max_tab_extent]. Shared, /// Each tab sizes to its content (icon + label + slots), clamped /// to [min_tab_extent, max_tab_extent]. Truncation via ellipsis /// when content hits max. Independent, } }
| Orientation | "Layout axis" | Default | Meaning |
|---|---|---|---|
Horizontal | width | Shared | Uniform tab widths (Firefox / Chrome convention). |
Vertical | height | Shared | Uniform pill heights — fixed at editor_tab_height. |
Pinned tabs are always fixed-extent (pinned_tab_width) regardless
of TabSizing — that's what "pinned" means visually.
The two orientations apply Shared sizing differently:
-
Horizontal divides the viewport width across tabs (Firefox / Chrome convention) and clamps by the
min_tab_width/max_tab_widthknobs:available = scroll_region_width n = unpinned_count ideal = available / n target = clamp(ideal, min_tab_width, max_tab_width)If
target * n < available, slack is left as trailing empty space inside the scroll region (tabs do not stretch pastmax). Iftarget * n > available, content overflows into scroll (arrows, wheel remap, dropdown engage normally). -
Vertical does NOT divide the viewport. Sidebar pills stay at the intrinsic per-tab height (
TAB_EDITOR_HEIGHT, default 50 dp) regardless of how tall the bar is. A 800 dp bar with 4 tabs gives 4 pills of 50 dp at the top, not 4 × 200 dp bands. This matches VS Code, IntelliJ tool-window tabs, and the user expectation of sidebar tabs being short pills. Themin_tab_width/max_tab_widthknobs are width-defaulted (96 / 240) and intentionally don't apply to vertical's height axis — they'd force pills unreasonably tall.
Reactive: TabWidget::sizing(Signal<TabSizing>) rebinds at
BindingLevel::Rebuild so toggling Shared ↔ Independent is a one-line
operation from a toolbar button.
Tab display mode — icon / text / icon + text
Each tab declares both a title and (optionally) an icon; a bar-level
TabDisplayMode decides what is painted, so an app can offer a "tab size"
toggle (VS Code's panel / activity-bar convention) without rebuilding the tabs
by hand:
#![allow(unused)] fn main() { pub enum TabDisplayMode { Auto, // render each tab as its TabInfo declares (default; back-compat) Text, // title only — icons hidden even when present Icon, // icon only — title promoted to the hover tooltip IconText, // icon + title } }
Set it statically with TabWidget::tab_display(mode) or reactively with
TabWidget::tab_display(Signal<TabDisplayMode>) (bound at
BindingLevel::Rebuild, like sizing).
Mode-specific behaviour:
Iconblanks the visible label so the header sizes to its icon (Independentsizing) instead of padding out to a text width, and promotes the title to the tooltip when the caller set none. A tab with no icon falls back to its title's initial letter, so the mode is never blank.Textdrops the icon;IconTextkeeps both (and so doesAuto, which is the identity transform — they differ only in intent).- The content
TabPanelkeeps its real title as its AT name in every mode, so a screen reader navigating to the panel still hears the full name even when the chrome is icon-only. The tab header also keeps the original title as its AT name (the visible label is a presentation detail). - Icon-only sizing is still floored by
min_tab_width(the bar's row clamps every tab to it). With the default editor-tab minimum an icon-only tab won't shrink much; set a smallmin_tab_width(..)(asDockingLayoutdoes) for a compact, content-sized icon strip.
This is what DockingLayout builds its per-side "Tab size" menu on.
Pinned tabs
Tabs with info.pinned = true render in a leading non-scrolling
strip at fixed pinned_tab_width (default 32 dp), icon-only, with no
close button. This is the Firefox / Chrome convention.
Critical contract: the model does not need to keep pinned items contiguous. At render time the bar partitions the source:
items = source.iter()
pinned_view = items.filter(|(i, it)| delegate.pinned(i, it))
unpinned_view = items.filter(|(i, it)| !delegate.pinned(i, it))
Indices in callbacks (on_close(i), selected.set(i),
on_reorder(from, to)) remain model indices, not view positions.
When the title is None and the tab is pinned, the framework promotes
info.title (if any) to the tooltip — pinned tabs render icon-only and
otherwise have no way for the user to identify them on hover.
DnD across the pinned/unpinned boundary fires
on_pin_toggle(model_index, new_pinned_flag). The app decides whether
to actually mutate info.pinned; the bar reports the desired
transition without applying it itself (pinning is app semantics).
Close, reorder, pin handlers
#![allow(unused)] fn main() { TabWidget::new(selected) // … .on_close(|id: TabId| { // default behavior: remove from dynamic_model. // static tabs are not auto-closable. }) .on_reorder(|moved_id: TabId, dest_index: usize| { // default behavior: ListModel::move_item within the dynamic region only. // implies .reorderable(true). }) .on_pin_toggle(|id: TabId, new_pinned: bool| { // no default — pinning is app semantics. }); }
Note the indirection: TabWidget callbacks speak TabId, but inside,
the bar receives indices. The wrapper translates at the boundary using
the index_to_id table captured at build time. On stand-alone
TabBar<T> the callbacks are Fn(usize) / Fn(usize, usize) — the
caller is closer to the data source and may prefer indices.
on_reorder(...) implicitly sets reorderable(true). The default
reorder handler refuses cross-boundary moves (dynamic past static) and
prints a one-shot stderr warning pointing at the install-explicit-handler
fix; high-frequency drag events do not spam the log.
Middle-click on a closable tab fires on_close (Firefox convention).
Pinned tabs suppress the close button regardless of closable.
Drag & drop
Drag-reorder follows the same pattern ListView uses. Each tab header
is a drag source; the bar is the drop target.
- Payload.
TabBarDragData<T> { source_index, source_bar_id, source_id, item }, generic over the bar's item type.source_bar_iddistinguishes an intra-bar reorder (matches the bar's own id) from a cross-bar transfer (see below); being generic overTmeans aTabBar<T>only ever downcasts a drag started by a peerTabBar<T>, so unrelated drags never match.itemcarries a clone of the dragged item for cross-bar transfer (Nonefor reorder-only / non-transferable tabs). - Insertion math.
on_drag_hovercomputes the insertion boundary from pointer position relative to tab boundaries (per-axis: x for horizontal, y for vertical when wired). The boundary is published through a sharedCell<Option<f32>>that the bar'spaint()reads. - Drop indicator. A 2 dp accent-color line at the insertion boundary. Vertical line for horizontal bar, horizontal line for vertical bar — both the paint and the hover-to-insertion-boundary math are axis-aware.
- Edge auto-scroll.
on_drag_tickramps scroll velocity inside a 32 dp edge zone, capped at 12 dp/frame — same constants asListView. - Pinned/unpinned model index translation. Insertion is computed in
unpinned-view space; the bar maintains an
unpinned_to_modelmap and converts before applying the post-removal-1adjustment (from < to_model → to_model - 1) and callingon_reorder(from_model, adjusted_to). - Cross-pane drops (drop a non-pinned tab into the pinned strip, or
vice versa) fire
on_pin_toggleinstead ofon_reorder.
Drag-reorder is fully wired in both orientations.
Cross-TabWidget transfer — migrating tabs between containers
Opt-in app-internal drag-and-drop between two tabbed containers: drag
a tab out of one TabWidget and drop it between the tabs of another. The
dragged TabHandle moves intact — its Rc<dyn Any> payload (the heavy
per-tab state) is preserved, not rebuilt — so a half-edited document
keeps its scroll position, undo stack, and so on after the move.
#![allow(unused)] fn main() { let model_a: ListModel<TabHandle> = ...; let model_b: ListModel<TabHandle> = ...; let group_a = TabWidget::new(sel_a) .dynamic_model(model_a.clone()) .dynamic_tab::<DocState>("doc", |_h, s| Box::new(doc_pane(s))) .accept_external_tabs(true); // both send and receive let group_b = TabWidget::new(sel_b) .dynamic_model(model_b.clone()) .dynamic_tab::<DocState>("doc", |_h, s| Box::new(doc_pane(s))) .accept_external_tabs(true); }
accept_external_tabs(true) makes a widget both a transfer source
(its dynamic tabs become draggable to other accepting widgets) and a
target (it accepts tabs dragged in, painting the usual insertion-line
indicator). With the defaults above, accepting a tab inserts it into the
receiver's dynamic_model and the source removes it from its own — each
container mutates only its own model.
Override either side:
#![allow(unused)] fn main() { .on_tab_received(|handle: TabHandle, dyn_index: usize, ctx| { // target side: insert `handle` into our model at the dynamic-region index }) .on_transfer_out(|tab_id: TabId, ctx| { // source side: one of our tabs landed elsewhere — remove it }) }
How it works (the "split, each bar owns its model" model):
- The source publishes a payload carrying a clone of the
TabHandle(cheap — the heavy state is behind anRc). - On drop in a different bar, the target's
on_dropcallson_tab_receivedwith the moved handle and the model insertion index (no-1correction — there's no source slot in this model). - The source is notified via the framework's native
on_drag_ended(DropOutcome::InApp { accepted: true })hook, which fireson_transfer_out. A self-reorder flag (set by the source bar's ownon_drop, which runs beforeon_drag_ended) suppresseson_transfer_outon intra-bar reorders so a just-reordered tab is never wrongly removed.
Constraints:
- Static tabs are excluded — they have no content factory on a
receiving widget, so they're never transferable (they still reorder in
place).
TabWidgetinstalls the predicate that enforces this. - Type-safe interop only:
TabWidget↔TabWidget(both areTabBar<TabHandle>underneath). ATabBar<OtherT>never matches. - Same-window only. Cross-window transfer is feasible via the DnD
layer's typed re-entry but needs
mime_dataon the payload to escalate at the window boundary — not wired here. - Requires
T: Clone(TabHandleis). Stand-aloneTabBar<T>exposes the sameaccept_external_tabs/on_tab_received/on_transfer_outmethods, index-based.
Non-tab drops — on_external_drop (open a dropped file as a tab)
Accept payloads that aren't tabs: an in-app foreign drag (a row
dragged from a TreeView / ListView carrying app data) or an OS
file / text / URL drop. This is the "drag a file onto the tab bar to open
it" gesture (VS Code style).
#![allow(unused)] fn main() { TabWidget::new(sel) .dynamic_model(model.clone()) .dynamic_tab::<DocState>("doc", |_h, s| Box::new(doc_pane(s))) .on_external_drop(move |payload, dyn_index, _ctx| { if let Some(node) = payload.get_typed::<TreeFileNode>() { // in-app drag model.insert(dyn_index, open_doc(node)); return true; } if let Some(path) = payload.files().first() { // OS file drop model.insert(dyn_index, open_path(path)); return true; } false // not interested → rejected }); }
- The bar branches its drop handler three ways: tab-payload intra-bar
reorder → tab-payload cross-bar transfer → non-tab payload →
on_external_drop(a failedTabBarDragData<T>downcast leaves the payload intact for inspection). - OS drops reuse the same
on_droppath, so installing the handler makes the bar an OS-drop target automatically — the app must still callTeksiloAppBuilder::install_external_dnd()for the OS pipeline. - Independent of
accept_external_tabs: a bar can do tab-migration, file-opening, both, or neither. - The hover insertion-line is optimistic (shown for any non-tab
payload while the handler is installed); the closure's
boolreturn is authoritative at drop time.
Demo: cargo run -p tab-migration.
Overflow chrome
When the headers row doesn't fit the viewport, three affordances engage (all toggleable):
Scroll arrows
Two IconButtons (chevron-leading, chevron-trailing, embedded mode) flank the
scrollable region. Visibility is dynamic: leading visible iff
scroll_x > 0, trailing visible iff scroll_x < max_scroll_x. Click
animates scroll_x by ~one tab-width via Signal::animate_to with
MotionTokens::duration_normal.
#![allow(unused)] fn main() { .show_scroll_arrows(true) // default }
Mouse wheel mapping
On a horizontal bar, vertical-only wheel deltas remap to horizontal scroll (Firefox / Chrome convention). Shift+wheel always remaps, regardless of orientation — useful on touchpads where two-finger scroll is ambiguous. Diagonal trackpad gestures pass through.
#![allow(unused)] fn main() { .vertical_wheel_scrolls_horizontally(true) // default .shift_wheel_scrolls_horizontally(true) // default }
Wheel "lines" are converted to pixels at 64 dp/line (≈ one tab-width per notch) so a single notch scrolls one full tab into view.
"Show all tabs" overflow dropdown
A single trailing PopoverButton with a chevron icon. Clicking it
opens a Popover containing a ListView of every tab (pinned
included). Activating an item sets selected_id and dismisses the
popover.
#![allow(unused)] fn main() { .overflow_button(TabOverflowButton::Auto) // default }
TabOverflowButton governs when the button appears:
| Mode | Behaviour |
|---|---|
Auto (default) | Shown only when the tab headers overflow the viewport — the same condition that reveals the scroll arrows (visible_when on the ScrollArea's max_scroll signal). Stays out of the way until it is useful. |
Always | Shown whenever the bar has at least one tab, even when everything fits (a persistent fast-jump affordance — the old default). |
Never | Never built. |
show_overflow_dropdown(bool) is a convenience over overflow_button:
true → Always, false → Never. The popover's surface is a Panel
with SurfaceRole::Raised and bounded height (max 320 dp, 28 dp per
row), scrolling internally on long lists.
The dropdown advertises HasPopup::Menu to AccessKit so screen readers
announce it as a popup trigger.
Keyboard ScrollIntoView
Tab keyboard nav into an off-screen tab is handled by the framework's
existing WidgetEvent::ScrollIntoView path on ScrollArea — when a
tab header gains focus and lies outside the viewport, ScrollArea
auto-scrolls to bring it on-screen. No tab-specific code is needed.
Bar slots
Two stable widget positions for app chrome that should travel with the bar:
#![allow(unused)] fn main() { .bar_leading_slot(small_breadcrumb_or_logo) // before the pinned strip .bar_trailing_slot(new_tab_button_toolbar) // after the dropdown }
Both accept impl Widget + 'static. _id variants take a
pre-registered WidgetId for the teksu! DSL. The slot widget is
registered once on first build and memoized — subsequent rebuilds
reuse the same id, so a slot's internal state (button hover, tooltip
visibility, focus) survives bar rebuilds.
Slots scroll with the bar's outer chrome, not with the headers row — a "+" button in the trailing slot stays visible regardless of horizontal scroll position.
Appearance — backgrounds, text colour, dividers, indicator
All of these builders exist on both TabBar and TabWidget (the
TabWidget form forwards to its inner bar). They tune the default
RecipeTabStyle; an app that needs more than colour replaces the whole
chrome with .style(impl TabStyle) or theme.style_slots.tab (see
styling-system.md).
Per-tab backgrounds (selected / hover / idle)
Each tab state can paint its own background. Precedence is
selected > hover > idle; each state resolves to its own override,
else the tab_background shorthand, else transparent:
#![allow(unused)] fn main() { TabWidget::new(selected) .tab_background(SurfaceRole::Sunken) // shorthand: all states .selected_tab_background(SurfaceRole::Raised) // current tab .hover_tab_background(SurfaceRole::Hover) // hovered (non-selected) .idle_tab_background(SurfaceRole::Transparent) // the other tabs }
Each accepts any Color, SurfaceRole, or Signal<Color> (an
impl Into<ColorProp>). Internally the three states are three flush
RectWidgets gated by visible_when — switching state just toggles
which one paints (a repaint, never a rebuild), so selection state and
focus survive.
Tab text colour
Per-state text colour is set with the text-role builders (the label and its icon tint follow the role):
#![allow(unused)] fn main() { .selected_text_role(TextRole::Primary) // default .idle_text_role(TextRole::Secondary) // default; also used on hover }
Disabled tabs always read as TextRole::Disabled. (Full per-state font
style — e.g. bold-when-selected — is not a built-in knob; use a custom
TabStyle if you need it.)
Bar background
The bar's backdrop fill is independent of the per-tab backgrounds:
#![allow(unused)] fn main() { .bar_background(SurfaceRole::Sunken) // behind headers, slots, arrows }
Default is transparent.
Dividers between tabs
#![allow(unused)] fn main() { .tab_dividers() // 1 dp BorderRole::Divider line .tab_divider_color(BorderRole::DividerStrong) // or an explicit colour (implies on) }
A line is drawn between consecutive tabs in both the scrollable row
and the pinned strip. In the scrollable row it is an on-top overlay that
scrolls with the tabs; in the pinned strip it is an interleaved
Divider widget.
Active-tab indicator position
The highlight that marks the selected tab defaults to the outer edge (top for a horizontal bar, leading for a vertical bar). Move it to the inner edge — below the label on a horizontal bar, trailing on a vertical bar — with:
#![allow(unused)] fn main() { use teksilo::widgets::TabIndicatorPosition; .active_indicator(TabIndicatorPosition::InnerEdge) // below the text (horizontal) }
OuterEdge (default) and InnerEdge together cover all four edges
across the two orientations, and the vertical leading/trailing edges are
resolved against the layout direction (RTL-correct). A custom TabStyle
receives the choice on TabStyleConfig::indicator_position and may
interpret it freely.
Keyboard
| Key | Effect |
|---|---|
ArrowLeft / ArrowUp | move selection to previous enabled tab |
ArrowRight / ArrowDown | move selection to next enabled tab |
Home | jump to first enabled tab |
End | jump to last enabled tab |
Enter / Space | activate the tab and move focus into its content panel (first focusable descendant) |
Ctrl+W | close the focused tab if closable |
Middle-click | close the clicked tab if closable (mouse, not keyboard) |
Disabled tabs are skipped by all keyboard navigation. Out-of-range
selection writes are absorbed harmlessly. Focus moves with selection;
ScrollArea scrolls the bar to keep the focused tab visible via the
existing ScrollIntoView event.
Enter and Space behave identically — both let keyboard / screen-reader
users dive from the tab strip straight into the panel without hunting for
the Tab stop. This matches the desktop tab-control convention (Windows /
JAWS: Space or Enter invokes a tab and a well-built control sets focus to
the start of the panel) and the Spacebar/Enter keyboard-parity guidance for
invocable controls. The dive lands on the panel's first focusable control; a
panel that opted into focusability itself (TabInfo::focusable_panel(true))
with no inner controls receives focus directly; a panel with neither leaves
focus on the header (it is never trapped on a non-interactive container).
This is TabWidget-only — a standalone TabBar has no content panel, so
Enter / Space there only activate.
The framework dispatches both ArrowLeft/Up and ArrowRight/Down to the "prev/next" handlers regardless of orientation — the same key map works for horizontal and vertical bars without re-mapping.
Accessibility
- TabBar root:
Role::TabListwithorientation = Horizontal | Vertical. - Each tab header:
Role::Tab, withselected = boolreflecting the active tab. Thecontrols()relation points at the tab's content-panelWidgetIdwhen the bar is composed insideTabWidget. - Each content pane:
Role::TabPanel, named after the tab's resolved title. - Pinned tabs: include
access_description("Pinned tab")so screen readers distinguish them. - Closable tabs: advertise
accesskit::Action::Defaultplus a custom action with i18n name "Close" wired toon_close. - Reorderable tabs: advertise custom actions "Move Left" and "Move Right" (or "Move Up" / "Move Down" on vertical bars), invoking the same reorder path drag-drop uses. AT users can't drag, so this is the supported reorder affordance.
- Overflow dropdown:
HasPopup::Menu+controls(menu_list_id). - Scroll arrows:
Role::Buttonwith i18n labels "Scroll tabs left" / "Scroll tabs right".
The full TabList → Tab → TabPanel hierarchy is what AT software
expects from a tabbed container, and matches what Firefox and Chrome
publish for their own browser tabs.
Theme tokens
| Surface | Role |
|---|---|
| bar backdrop + tab fills | tab_surface_role (settable) |
| label text — selected | selected_text_role (settable) |
| label text — idle | idle_text_role (settable) |
| label text — disabled | TextRole::Disabled (always) |
| accent indicator (selected) | theme.colors.accent |
| bar bottom separator | BorderRole::DividerStrong |
| close button hover | SurfaceRole::Hover |
| drop indicator line | TextRole::Accent |
| overflow popover surface | SurfaceRole::Raised |
| overflow popover border | BorderRole::Default |
tab_surface_role defaults to transparent and accepts any Color,
SurfaceRole, or Signal<Color> (via [ColorProp]). When set, the
bar paints it as a uniform backdrop covering the whole strip — leading
slot, pinned strip, scroll arrows, headers row, overflow dropdown, and
trailing slot all share the surface, so the bar reads as a single
plane regardless of how the chrome is composed.
selected_text_role defaults to TextRole::Primary (the Int UI
editor-strip convention); idle_text_role defaults to
TextRole::Secondary. Override either to e.g. TextRole::Accent /
TextRole::Tertiary when the strip sits over a tinted surface and
the default cascade reads with insufficient contrast. Disabled tabs
always render at TextRole::Disabled.
Static numbers are pub consts in
recipe_tab_style:
TAB_EDITOR_HEIGHT(default 50 dp) — height of horizontal bar tabs.TAB_TOOL_WINDOW_HEIGHT(default 28 dp) — reserved for future tool-window tab variant; not currently consumed by vertical bars.TAB_UNDERLINE_ACTIVE(default 3 dp) — thickness of the selection indicator. The indicator's color comes fromtheme.colors.accent.
The accent indicator paints at the top edge in horizontal bars and
the leading edge in vertical bars. Tabs use a uniform surface
across all states (tab_surface_role); selection is conveyed by the
accent indicator and the label-color shift only — Int UI editor-strip
convention.
#![allow(unused)] fn main() { TabWidget::new(selected) .tab_surface_role(SurfaceRole::Content) // role-driven, theme-aware .selected_text_role(TextRole::Primary) // override the selected label color .idle_text_role(TextRole::Secondary); // override the idle label color }
What is and isn't shipped
Shipped:
- horizontal + vertical orientations, both reactive
- shared / independent sizing, both reactive
- static + dynamic tabs in one widget, with pane memoization across rebuilds (focus, scroll, animation, rich-text editor history all survive)
- closable tabs (button + middle-click), with selection re-anchoring
- pinned tabs (icon-only fixed-width leading strip, no close button, tooltip-promoted title)
- drag-to-reorder with insertion-line indicator, edge auto-scroll, and
pinned/unpinned cross-boundary
on_pin_togglesemantics - horizontal scroll with leading + trailing arrow buttons and dynamic visibility
- mouse-wheel-to-horizontal mapping (configurable: vertical-only, shift-only, both, neither)
- "show all tabs" overflow dropdown via
PopoverButton+ListView - keyboard navigation: arrow keys, Home/End, Enter/Space, Ctrl+W
- accessibility:
TabList/Tab/TabPanelroles; "Move Left/Right" custom actions for AT-driven reorder; named close action;HasPopupon the dropdown Signal<Option<TabId>>selection that survives reorders, removals, locale and theme changes
Intentionally not shipped:
- multi-line / wrapping horizontal bar (was prototyped via
Wrap::max_lines(...); dropped — lots of layout machinery for a feature most desktop apps don't use, and the overflow dropdown covers the same fast-jump need) - touchscreen flick momentum on the scroll viewport (desktop trackpads
hit the existing
ScrollDelta::Pixelspath withEasing::EaseOutanimation; touch flicks would needScrollArea↔SwipeRecognizerwiring, ~150 LOC, separate task) tool_window_tab_height(28 dp) is reserved onTabStylebut not yet consumed by vertical bars — they currently pick upeditor_tab_heightlike horizontal bars
Demos
cargo run -p tab-widget— full showcase: static tabs (pinned, disabled, default), three dynamic tabs from aListModel<TabHandle>, registereddynamic_tab::<DocState>factory, "+ New tab" trailing button, theme / orientation / sizing toggle buttons, drag-reorder, overflow dropdown, pinned-tab tooltip promotion, status bar showing the resolved selection.cargo run -p widget-catalog— TabWidget appears in the catalog for visual regression checks.
SegmentedControl
A row of mutually exclusive segments — view mode, time period, document view. Source: crates/teksilo-widgets/src/segmented_control.rs.
Two things distinguish it from the rest of the radio family
(RadioButton,
RadioTileGroup):
selection is keyed, not positional, and the control has a real
width story — segments that do not fit move into a chevron menu
rather than all of them compressing into ellipsised stubs.
#![allow(unused)] fn main() { const LIST: SegmentId = SegmentId::from_u64(1); const GRID: SegmentId = SegmentId::from_u64(2); const COLUMNS: SegmentId = SegmentId::from_u64(3); let view = ctx.signal(Some(LIST)); SegmentedControl::new(view.clone()) .label(tr!(view_mode())) .segment(Segment::new(tr!(list_view())).id(LIST).icon(|| IconWidget::list(14.0))) .segment(Segment::new(tr!(grid_view())).id(GRID).icon(|| IconWidget::grid(14.0))) .segment(Segment::new(tr!(columns())).id(COLUMNS)) }
Identity
Selection is a Signal<Option<SegmentId>>.
SegmentId
mirrors TabId: a NonZeroU64 newtype with
fresh(), from_raw() / raw(), and a const fn from_u64() so an app
can declare its segments as constants.
Segment::new(label) allocates a fresh id, so a throwaway control needs
none. Declare them explicitly when the selection is persisted, or
when a segment can be contributed by another crate.
Why keyed at all? Because the positional alternative fails silently. Bind
a Signal<usize> to a control and a Switcher, let a plugin insert a
segment at position 1, and every index below it now points at the wrong
pane — with no error, no panic, and nothing in the type system to catch
it. TabWidget learned this already; this is the same fix.
Framework-allocated ids start at 2^48, so a small app constant —
from_u64(1), the first thing anyone writes — can never collide with a
fresh() id.
Pairing with a Switcher
Switcher is index-driven. index_signal is the adapter:
#![allow(unused)] fn main() { Switcher::new(segmented_control::index_signal(&view, &[LIST, GRID, COLUMNS])) .child(list_pane) .child(grid_pane) .child(columns_pane) }
When position really is the meaning
Some state is positional by construction: an enum discriminant over a
fixed ALL array, a settings choice, a preview knob. indexed binds a
Signal<usize> directly, mirrored both ways:
#![allow(unused)] fn main() { SegmentedControl::indexed(bucket_idx.clone()) .segments([lit!("×2"), lit!("×4"), lit!("×8")]) }
Reach for it only when the segment list is closed and local. A
persisted selection, or segments another crate can contribute to, belong
on new — an index stops meaning the same thing the moment a segment is
inserted ahead of it, which is the whole reason selection is keyed.
Positions address the declared list, so hiding a segment does not
renumber the others.
Width
By default ([SegmentOverflow::Menu]) segments that do not fit move into
a trailing chevron menu, and the rest keep a legible width.
Declaration order is stable, with exactly one exception: the selected segment is always visible. If it would have been pushed into the menu it takes the last slot, and it stays there until something else is chosen from the menu — so the strip does not reshuffle under the pointer. The promotion is forgotten once the control is wide enough for everything, so a later unrelated narrowing starts from clean declaration order instead of resurrecting a minutes-old pick.
Declared: [A][B][C][D][E][F][G] fits 4 + chevron
start, A selected [A][B][C][D][v] menu: E F G
pick F from menu [A][B][C][F][v] menu: D E G
click A (F stays) [A][B][C][F][v] menu: D E G
pick D from menu [A][B][C][D][v] menu: E F G
widen to full fit [A][B][C][D][E][F][G]
This is deliberately not MRU. A bar whose items reorder by recency is harder to use than one that does not — adaptive menus in Office are the cautionary case. Only one slot ever moves, and only when you reach into the menu.
SegmentOverflow::Compress opts out: every segment stays on the strip
and labels ellipsize, which is the right call for two or three short
segments that will never realistically overflow.
Knobs
| Method | Effect |
|---|---|
.overflow(SegmentOverflow) | Menu (default) or Compress. |
.sizing(SegmentSizing) | Uniform (default — every visible segment the same width, measured against the widest) or Fit (each its own width, leftover shared). |
.display(SegmentDisplay) | Auto (default) / Text / Icon / IconText. Icon-only fits far more segments, so it is worth reaching for before overflow engages; the label becomes the tooltip, and a segment with no icon falls back to its label so the mode is never a silent no-op. |
.fill_width(bool) | true (default) claims the offered width; false hugs the segments and makes the control shrinkable, so an over-constrained stack compresses it instead of letting it spill. |
is_overflowing() -> Signal<bool> reports whether anything is currently
in the menu — republished from place_children behind an equality guard,
like Toolbar::is_overflowing. Safe for RepaintOnly /
AccessibilityOnly consumers, and for Relayout consumers that do not
feed back into this control's own width (a caption beside it is fine; a
container that resizes the control from it is not).
Widths come from real measurement
(LayoutContext::measure_intrinsic),
including for segments currently in the menu — that is how the control
knows when they fit again. The height follows the measured content with
the 24 dp design constant as a floor, so a raised global text scale
grows the control rather than clipping it.
Reactivity
| Method | Level |
|---|---|
.enabled(impl Into<Prop<bool>>) | whole control |
Segment::disabled(impl Into<Prop<bool>>) | per segment; read at event time, so a bound signal changes keyboard stepping with no rebuild |
Segment::visible(impl Into<Prop<bool>>) | per segment; removes it from the strip, the menu, the keyboard order and the a11y tree |
Hidden and overflowed are different states: an overflowed segment is still reachable from the chevron menu, a hidden one is not there at all. Hiding is structural — it renumbers the live list — so it triggers a rebuild; the keyed selection survives that, which is again why it is keyed.
on_change
#![allow(unused)] fn main() { .on_change(|id, ctx| ctx.set_locale(locale_for(id))) }
Fires for user-driven changes — click, arrow key, assistive technology,
overflow menu — and hands over an EventContext, so the control can do
things a bare Signal write cannot. Programmatic writes to the bound
signal do not fire it: there is no event in flight to carry. Observe the
signal for those.
Keyboard and accessibility
Role::RadioGroup on the control, with active_descendant pointing at
the selected segment and Increment / Decrement AT actions.
Role::RadioButton per segment, carrying "N of M" over the whole
segment list — segments in the overflow menu are still part of the set,
so the count deliberately exceeds the number of rendered radios on a
narrow control. push_to_radio_group lists only the segments actually on
the strip: a segment in the menu publishes no AccessKit node, and
referencing it would dangle.
| Key | Effect |
|---|---|
| ← / → | previous / next selectable segment, wrapping; RTL-swapped, resolved at event time so a locale flip needs no rebuild |
| Home / End | first / last selectable segment |
Disabled segments are skipped. Stepping onto a segment that is in the overflow menu promotes it into view, so the keyboard reaches every segment without opening the menu.
Name the group with .label(...), matching RadioGroup::label /
RadioTileGroup::label. .access_label(...) also works — the control
itself is the semantic node.
Tab stops
One while everything fits. Two while overflowing: the group, then the
chevron. An overflow menu no keyboard can reach is not an overflow menu,
and the chevron cannot join the arrow sequence because here arrows move
selection, not a roving focus (unlike Toolbar).
Segments in the menu are dormant, so they are pruned from the
accessibility tree; their menu rows are their representation there,
rendered as real Role::MenuItemRadio rows. The open menu therefore
forms its own, smaller radio group with its own "N of M".
Styling
Tier-3 SegmentedControlStyle,
via .style(...) per call or theme.style_slots.segmented_control
theme-wide. Default:
RecipeSegmentedControlStyle.
The chrome paints the frame, hover tint, selected-segment surface, overflow divider and focus ring — never text or icons, which stay composed widgets so they remain locale- and theme-reactive.
Because a control can overflow, the chrome cannot derive segment
rectangles by dividing its bounds by n. The widget publishes resolved
geometry each layout pass through SegmentSlots:
#![allow(unused)] fn main() { pub struct SegmentSlotGeometry { pub frame: Rect, pub segments: Vec<Rect>, // one per visible slot, reading order pub order: Vec<usize>, // order[slot] = live segment index pub overflow: Option<Rect>, } }
order is what maps a segment to a slot; the two coincide until a
segment is promoted. overflow is paint-only — the trigger is a real
widget whose bounds come from the layout pass, so never hit-test against
that rect.
Testing
Anything asserting structural state — which segments are active,
node counts, geometry — needs two layout() calls. A Signal::set from
place_children dirties the binding registry, but process_state_changes
only turns that into dormancy transitions at the top of the next
layout. A real app never notices (the window manager re-lays out whenever
needs_reconcile()); a bare WidgetTree does. Toolbar's suite has the
same requirement.
#![allow(unused)] fn main() { fn settle(tree: &mut WidgetTree, width: f32, height: f32) { tree.layout(SizeProposal::exact(width, height)); tree.layout(SizeProposal::exact(width, height)); } }
Note that MockTextBackend ignores the TextStyle it is handed (fixed
8 px per char, 16 px line height), so headless text never changes size —
a text-scale assertion there proves nothing about this widget.
Demo: cargo run -p widget-catalog (Inputs tab). The seven-segment
showcase sits in a slider-driven fixed-width box — the same shape as the
collapsible_menu_bar example's responsive bar — so the overflow
behaviour can be watched without resizing the window, with a caption
bound to is_overflowing() narrating the current state.
FontPicker
A drop-in font-family selector, in the tradition of Qt's QFontComboBox,
GTK's FontChooser, and UIKit's UIFontPickerViewController. It lists every
installed font family, previews each one, and binds the choice to a
Signal<Option<String>> (the family name) that plugs straight into
TextStyle.family / RichTextEditor::set_font_family.
#![allow(unused)] fn main() { use teksilo::prelude::*; use teksilo::widgets::FontPicker; let family: Signal<Option<String>> = Signal::new(None); VStack::new() .child(TextWidget::new(tr!(font())).style(TextStyleRole::BodyBold)) .child( FontPicker::new(family.clone()) .on_select(|name, _ctx| editor.set_font_family(name)), ); }
Source: crates/teksilo-widgets/src/font_picker.rs.
Demo: cargo run -p font-picker. Also on the widget catalog's Rich text tab
(cargo run -p widget-catalog).
What it does
- Self-populates from the app's shared typesetter — no font list is
passed in. (Reads
ctx.app_state::<SharedTypesetter>()at build time, the same pathSpinBoxuses for text measurement.) - Previews each font. By default every row shows the family name in a legible UI font next to a tiny sample rendered in that font; the sample text is chosen for the font's writing system (a Cyrillic font previews Cyrillic, an Arabic font previews Arabic, …). The closed control shows the selected family in its own typeface.
- Searchable (type to filter hundreds of fonts) and filterable by spacing (monospaced / proportional) and by writing system.
- Built on
ComboBox, so it inherits complete keyboard navigation, type-ahead, virtualization (the dropdown never materializes hundreds of rows), and AccessKit wiring (Role::ComboBox+HasPopup::Listbox,Role::ListBox/ListBoxOptionrows,set_value/set_expanded).
API
#![allow(unused)] fn main() { FontPicker::new(selected: Signal<Option<String>>) -> Self // family name = source of truth // Item source .families(impl IntoIterator<Item = impl Into<String>>) // override enumeration (names only) .families_with_meta(Vec<(String, FontMeta)>) // + inject monospaced/writing systems // Filtering (accept a static value or a Signal for a reactive toolbar) .spacing_filter(impl Into<Prop<FontSpacingFilter>>) // Any | Monospaced | Proportional .writing_system(impl Into<Prop<Option<WritingSystem>>>) // restrict to a script // Preview .preview_mode(FontPreviewMode) // NameThenSample (default) | NameInOwnFont | NameInSystemFont .preview_in_own_font(bool) // false ⇒ NameInSystemFont .sample_text(impl Into<String>) // global sample override .sample_text_for(WritingSystem, impl Into<String>) // per-script sample (Qt setSampleTextForSystem) .sample_text_for_family(family, text) // per-font sample (Qt setSampleTextForFont) .show_selected_in_own_font(bool) // trigger in the font's own face (default true) // ComboBox passthrough .placeholder(..) / .label(..) / .enabled(bool) / .variant(ComboBoxVariant) / .style(impl ComboBoxStyle) .max_visible_items(usize) / .searchable(bool) [default true] / .search_query(Signal<String>) .on_select(impl Fn(&str, &mut EventContext)) // apply hook .tooltip(..) / .rich_tooltip(..) / .rich_tooltip_content(..) / .composite_tooltip(..) }
The bound value is the family name string, matching how fonts are named
everywhere in the stack, so it drops straight into TextStyle { family, .. }
or RichTextEditor::set_font_family(name). There is no ambient "app font"
(unlike theme/locale), so on_select is where you apply the choice and the
signal is the source of truth.
Filters are programmatic
Like Qt / GTK / UIKit, the spacing and writing-system filters are set in code,
not exposed as in-widget chrome. Bind them to Signals and drive them from
your own controls next to the picker — the demo pairs the picker with a
"Monospace only" checkbox and a writing-system dropdown. The in-dropdown search
field (type-to-filter) is the one filter that lives inside the widget.
Reactive filtering re-runs even while the dropdown is open: a filter change
recomputes the visible names and pushes them into the picker's backing
ListModel (replace_all), which rebuilds only the dropdown list, not the
whole control. The currently-selected family is always kept in the list so a
filter change never silently clears your choice.
Writing-system detection is off-thread
A font's writing systems (Latin, Cyrillic, CJK, …) are read from its OS/2
table's ulUnicodeRange (script coverage) and ulCodePageRange (the
CJK-language + Vietnamese distinction, which shares codepoints and so can't be
told apart from Unicode coverage alone — the same heuristic Qt uses), with a
cmap sample-codepoint cross-check for fonts whose OS/2 ranges are absent or
wrong. This lives in text-typeset
(text_typeset::font::writing_system::writing_systems_for_face, built on
ttf-parser).
Classifying a font means reading its bytes, so doing it for a whole system is hundreds of file reads — far too much for the UI thread. The picker therefore builds the coverage index on a background thread the first time it mounts and polls readiness on the frame tick. Until the index is ready the writing-system filter shows the unfiltered list and samples fall back to a Latin default; the list narrows (and samples upgrade to their real scripts) once it completes. The UI never blocks. Spacing filtering is instant — it uses only font metadata, no bytes.
WritingSystem mirrors Qt's QFontDatabase::WritingSystem set (Latin, Greek,
Cyrillic, …, Simplified/Traditional Chinese, Japanese, Korean, Vietnamese,
Symbol, Ogham, Runic, N'Ko). It is re-exported at teksilo::text::WritingSystem.
Accessibility
The control inherits ComboBox's complete AccessKit surface: Role::ComboBox
with HasPopup::Listbox, the selected family announced via set_value (the
placeholder via set_placeholder when empty), set_expanded, aria-controls
to the open listbox, and AutoComplete::List in searchable mode; the popup is
Role::ListBox and each row Role::ListBoxOption with
set_selected/position_in_set/size_of_set. The in-font sample on each row
is decorative and hidden from assistive technology — the row's accessible name
is the plain family string, so a screen reader reads "DejaVu Sans", never the
sample text or a tofu glyph.
Testing
The .families(...) / .families_with_meta(...) overrides make the picker
fully testable headlessly (no font backend): the latter injects synthetic
monospaced + writing-system metadata so the spacing and writing-system filter
predicates are exercised deterministically. See the tests in
font_picker.rs.
Scope
Family selection only, exactly like Qt's QFontComboBox. Face / weight / style
/ size selection is a larger control — Qt splits it into QFontDialog — and is
out of scope here. Simplified vs Traditional Chinese is a best-effort heuristic
from OS/2 code-page bits (the codepoints are shared; the difference is
glyph-variant, undecidable from coverage alone).
The text-typeset additions
The picker needed two capabilities surfaced from the external text-typeset
crate (both cheap plumbing over data the font stack already had):
- Enumeration —
TextFontService::families()/family_names()/family_is_monospaced()(fontdb metadata; no bytes loaded). - Writing-system coverage —
TextFontService::writing_system_index_builder()returns aSendsnapshot whosebuild()computes the per-family coverage map off-thread, plus theWritingSystem/WritingSystemSettypes. Reads OS/2 via a newttf-parserdependency (already present transitively through fontdb, so no newly-compiled crate).
Both are re-exported through teksilo-text and reachable from a widget's
build() via ctx.app_state::<SharedTypesetter>().
Charts
Companion to: architecture.md, data-models.md
Scope: The teksilo-charts crate — BarChart, LineChart, PieChart
(pie + donut), the ChartModel<T> data model (teksilo-data) and its
ChartSeries<T> / ChartDatum<T> construction DTOs, the Tier-3
ChartStyle trait, the shared axis / palette / legend infrastructure,
and the rendering and reactivity contracts that connect them to the
widget tree.
1. Why teksilo-charts is its own crate
Charts are widget-shaped — they implement
Widget and live inside the
retained tree like any other view — but the catalog is large enough
that bundling it into teksilo-widgets would
mean every chart-free desktop app drags ~3,000 lines of axis math,
nice-numbers tick generation, polygonal slice paths, and the Okabe-Ito
palette into its binary. So teksilo-charts sits at the same layering
tier as teksilo-widgets, not on top of it:
teksilo-tokens → teksilo-canvas → teksilo-core ── teksilo-data ─┬→ teksilo-widgets
└→ teksilo-charts
teksilo-charts deliberately does not depend on teksilo-widgets. The
hover tooltip, the legend, the donut center placeholder all live inside
teksilo-charts and use only teksilo-core + teksilo-canvas primitives.
Tests reach for teksilo-widgets::TextWidget as a dev-dependency to
populate the donut center slot, but no production code path crosses
the boundary.
What this buys an app: depending on teksilo-charts brings just charts.
Depending on teksilo-widgets brings just widgets. The umbrella
teksilo crate re-exports both, so apps that
want the union pay nothing extra.
The directory layout under crates/teksilo-charts/src/
is module-flat (no mod.rs per coding conventions): one file per
public widget plus shared helpers for axes, palette, legend, and
plot-area carving.
2. The widget catalog
Three widgets, deliberately kept that small — a focused two-chart catalog avoids the tiny-matplotlib trap. Pie/donut joined late because it's the one chart users routinely expect from a desktop GUI toolkit and the implementation reuses 90% of the bar/line infrastructure.
2.1 BarChart
Vertical or horizontal bars, single or grouped series. Value labels, grid lines, axis titles, and an embedded legend are all opt-in flags on the builder.
#![allow(unused)] fn main() { use teksilo_charts::{AxisConfig, BarChart, BarGrouping, ChartModel, ChartSeries, LegendPosition}; let mut revenue = ChartSeries::<String>::new("Revenue"); revenue.push("Q1".into(), 12.5); revenue.push("Q2".into(), 18.3); revenue.push("Q3".into(), 9.8); revenue.push("Q4".into(), 22.1); let model = ChartModel::from_series_vec(vec![revenue]); BarChart::new(model) .grid(true) .value_labels(true) .legend(true) .legend_position(LegendPosition::Bottom) .axis_y( AxisConfig::new() .label("USD (k)") .formatter(|v| format!("${:.0}", v)), ) .axis_x(AxisConfig::new().label("Quarter")) .bar_corner_radius(2.0) }
The y-domain auto-includes zero — bars without a zero baseline aren't
legible, and the bar of a 100→102 series on a [100, 102] axis looks
identical to a 0→2 series on a [0, 100] axis. Override with
AxisConfig::range(min, max) if you really mean it.
2.2 LineChart
Polyline per series with optional area fill, hover tooltips, and embedded legend. PR-3 / PR-4 territory.
#![allow(unused)] fn main() { use teksilo_charts::{AxisConfig, ChartModel, ChartSeries, LineChart}; let mut series = ChartSeries::<String>::new("Latency p99"); series.push("Mon".into(), 142.0); series.push("Tue".into(), 138.5); // ... let model = ChartModel::from_series_vec(vec![series]); LineChart::new(model) .grid(true) .points(true) .area_fill(true) .area_fill_opacity(0.15) .hover_tooltip(true) .axis_y(AxisConfig::new().label("ms")) }
The y-domain pads ±5% so points at the data extremes don't sit on the
axis edge. nice_ticks then snaps ticks outward, which can extend the
range slightly past the padding — that's the standard data-viz
behavior and matches matplotlib / d3.
2.3 PieChart (and donut)
One widget for both shapes. Set inner_radius_ratio == 0.0 (the
default) for a pie; any positive value is a donut. The optional
center widget slot is silently ignored when the ratio is 0.0,
so swapping pie ↔ donut at runtime is safe.
#![allow(unused)] fn main() { use teksilo_charts::{ChartDatum, ChartModel, LegendPosition, PieChart, PieLabelMode}; use teksilo::widgets::{TextWidget, VStack}; let data: Vec<ChartDatum<String>> = /* … */; let total = format!("${:.0}", data.iter().map(|d| d.value).sum::<f32>()); let model = ChartModel::from_points(data); PieChart::new(model) .donut(0.55) .label_mode(PieLabelMode::Outside) .show_percentages(true) .legend(true) .legend_position(LegendPosition::Trailing) .center( VStack::new() .child(TextWidget::new(lit!("Total")).style(TextStyleRole::Tiny)) .child(TextWidget::new(lit!(total))), ) }
The center slot follows the existing Option<PendingChild> pattern
used by Card,
DialogContent, and
GroupBox: two builders
(.center(impl Widget) and .center_id(WidgetId)), resolved in
build() via ctx.add_boxed.
The placement is the largest square inscribed in the donut hole
(side = inner_radius * √2). A TextWidget for the total / a
VStack of label + value / a small IconWidget all fit comfortably;
larger compositions need to be self-clipping.
3. Data model — ChartModel<T>
Series data lives in a ChartModel<T>
— a concrete reactive multi-series chart data model in teksilo-data,
the same tier as ListModel<T> / TreeModel<T>. All three chart
widgets (BarChart::new, LineChart::new, PieChart::new) take a
ChartModel<T> directly; there is no Prop<Vec<ChartSeries<T>>> or
Signal<Vec<ChartDatum<T>>> binding path anymore — mutating the model
is the reactivity. Full mechanism reference:
data-models.md §15.
ChartModel<T> is Rc<RefCell<…>> inside — cloning shares the same
series and points, and every clone receives the same change
notifications. Series live in a flat SlotMap arena keyed by
SeriesId (a stable
handle, like NodeId) plus a separate order: Vec<SeriesId> for
display order. Every mutation method follows the mutate-then-notify
discipline (drop the borrow, then notify) and:
- emits a
ChartChangedescribing exactly what changed (SeriesInserted,SeriesRemoved,SeriesMoved,SeriesRenamed,SeriesColorChanged,SeriesVisibilityChanged,PointsInserted,PointsRemoved,PointUpdated,SeriesDataReplaced,Reset) to every observer registered viamodel.observe_changes(|change| …), and - bumps one of two
Signal<u64>version counters the three chart widgets bind internally — see §8 for the full mapping.
ChartSeries<T> and ChartDatum<T> (the construction DTOs) now live
in teksilo-data alongside the model and are re-exported from
teksilo_charts for convenience:
#![allow(unused)] fn main() { pub struct ChartDatum<T> { pub category: T, // x-axis position: String, enum, date, … pub value: f32, // y-axis value (always f32) } pub struct ChartSeries<T> { pub name: String, pub color: Option<ColorProp>, // None → palette assigns pub visible: bool, // plain bool — see note below pub points: Vec<ChartDatum<T>>, } }
ChartSeries::visible is a plain bool, not a Signal<bool> —
unlike the pre-ChartModel shape, reactivity does not live on the
per-series DTO. ChartSeries only describes the desired shape of
one series at construction time (ChartModel::from_series_vec); once
a series is in the model, its visibility is toggled through
ChartModel::set_series_visible(series, bool), which notifies
observers and bumps structure_version() like every other structural
change (§8).
Construction:
#![allow(unused)] fn main() { use teksilo_charts::{ChartDatum, ChartModel, ChartSeries}; // Multi-series (BarChart / LineChart): let model = ChartModel::from_series_vec(vec![ ChartSeries::new("Revenue").data(vec![ ChartDatum::new("Q1".to_string(), 10.0), ChartDatum::new("Q2".to_string(), 20.0), ]), ChartSeries::new("Costs").data(vec![ ChartDatum::new("Q1".to_string(), 5.0), ]), ]); // Single anonymous series (PieChart's flat, one-dimensional path): let pie_model = ChartModel::from_points(vec![ ChartDatum::new("Storage".to_string(), 42.0), ChartDatum::new("Apps".to_string(), 18.0), ]); }
Live updates mutate the model in place — no .set(), no vec swap:
#![allow(unused)] fn main() { let revenue = model.series_id_at(0).unwrap(); model.push_point(revenue, "Q3".to_string(), 30.0); // structure_version bumps → chart relayouts model.set_series_color(revenue, Color::from_hex("#0072B2")); // style_version bumps → repaint only }
T is the category / x-axis type. Common choices: String for
human-readable labels, an enum for fixed buckets, chrono::DateTime
for time-series (the chart only requires Display). Numeric values
are always f32.
ChartModel<T> also underpins three companion types for the streaming
/ downsampling / selection cases — ChartWindow<T> (last-N-points
projection), ChartAggregate<T> (bucket/rollup projection), and
ChartSelection (point-level selection state). None of the three
chart widgets wire these in directly today; they're building blocks
for apps that need a strip-chart, a downsampled long series, or
click-to-select behavior on top of the same model. See
data-models.md §15
for the full API.
4. Axes — nice_ticks and formatting
crates/teksilo-charts/src/axis.rs
implements the Wilkinson / Heckbert nice-numbers algorithm extended
with the d3 / matplotlib 2.5 step (so 0..100 / target=4 produces
[0, 25, 50, 75, 100] instead of degrading to step 20):
#![allow(unused)] fn main() { pub fn nice_ticks(min: f32, max: f32, target_count: usize) -> Vec<f32>; }
target_count is the maximum number of intervals, not a hard
tick count. The algorithm picks the smallest "nice" step (1, 2, 2.5,
5, or 10 × 10^k) that yields ≤ target_count intervals covering
[min, max], then snaps min down and max up to step-aligned
positions. The result has at most target_count + 1 ticks.
Tick counts auto-derive from the y-axis pixel length unless
AxisConfig::tick_count_hint(n) overrides:
#![allow(unused)] fn main() { pub fn auto_tick_count(axis_pixels: f32) -> usize { ((axis_pixels / 60.0) as usize).clamp(2, 10) } }
That's a 60-pixel-per-tick density target. It works equally well for a 200-pixel-tall sparkline (3 ticks) and a 600-pixel-tall dashboard chart (10 ticks).
AxisConfig::formatter takes any Fn(f32) -> String for currency,
units, locale-aware separators, time strings, etc. The default
formatter trims trailing zeros and caps at 4 decimal places — fine
for most charts; supply your own when you want "$12k" or
"3.5 ms".
Categorical x-axes (BarChart, LineChart over discrete categories) use
one tick per category — nice_ticks is not invoked there. The
x-axis type stays generic over T exactly so a future time-axis
formatter can hook in here without API churn.
5. Palette
ChartPalette is the
mechanism that decides series colors when a series didn't pick its
own:
#![allow(unused)] fn main() { pub enum ChartPalette { FromTheme, // reads theme.colors.chart_palette Custom(Vec<Color>), } }
Default is FromTheme, which reads
ColorTokens::chart_palette.
The built-in light and dark themes ship the Okabe-Ito
colorblind-safe sequence (Okabe & Ito 2008), the same palette used by
ggplot2 and seaborn:
| # | Name | Hex |
|---|---|---|
| 1 | Orange | #E69F00 |
| 2 | Sky blue | #56B4E9 |
| 3 | Bluish green | #009E73 |
| 4 | Yellow | #F0E442 |
| 5 | Blue | #0072B2 |
| 6 | Vermilion | #D55E00 |
| 7 | Reddish purple | #CC79A7 |
| 8 | Black (light) / White (dark) | #000000 / #FFFFFF |
Themes can override the palette field directly to brand-match — the
from_os_colors derivation path inherits the default and lets the
OS colors flow through everything else. Per-chart override:
.palette(ChartPalette::Custom(vec![...])) on the chart builder.
Per-series override: series.color = Some(ColorProp::Static(...)),
which wins over both the chart palette and the theme palette.
Wrap-around is automatic: palette.color_for(index, theme) does
palette[index % palette.len()]. Eight default colors handle every
chart you should reasonably draw without a legend so dense it's
unreadable.
Inactive-window desaturation. Like every other themed control, the chart palette dims when its window loses OS focus (see window-activation.md). The paint walker swaps in
ColorTokens::for_inactive_window, which desaturateschart_palettebyColorTokens::INACTIVE_CHART_DESATURATION(0.35) — deliberately lighter thanINACTIVE_ACCENT_DESATURATION(0.70) used for the accent family. The Okabe-Ito sequence's whole purpose is inter-series hue separation; fully desaturating it like a single accent control would defeat that even in a background window. No per-chart code is needed — this falls out of the same theme-side swap every other control gets.
5.1 The non-colour channel — SeriesPattern
A palette answers "are these colours distinguishable from one another?" Okabe-Ito answers it well. It does not answer WCAG 1.4.1 (Use of Color), which is a different question: colour must not be the only visual means of conveying information. A reader with monochrome vision, a greyscale printout, a screen in direct sunlight, or a forced-colours setting has no colour channel at all — and neither does a ninth series, which used to repeat the first's colour exactly under the modulo wrap described above.
So every series carries a second, orthogonal identity:
SeriesPattern, a single
value that drives all three renderings a chart needs, so a series looks
like itself whether it is drawn as a line, a bar, a slice, or a legend
swatch:
| line | marker | filled area | |
|---|---|---|---|
Solid | solid | circle | plain |
Dashed | long dash | square | 45° hatch |
Dotted | dotted | triangle | back-hatch |
DashDot | dash-dot | diamond | cross-hatch |
ShortDash | short dash | × | horizontal |
WideDash | wide dash | + | vertical |
Six patterns against eight palette colours means the pair
(colour, pattern) does not repeat until the 24th series. A series with
no explicit pattern takes the one its position implies, so the channel
exists with no application code; pin one with
ChartSeries::pattern(..) or ChartModel::set_series_pattern(..) when a
series' identity must survive a reorder.
When it is drawn is
PatternPolicy, a builder on
all three charts (.pattern_policy(..)):
Auto(default) — drawn once colour is actually doing identification work: from the second plotted series onwards forBarChart(soBarGrouping::Single, which draws one series however many the model holds, stays plain) andLineChart; forPieChart, when a legend is shown and there is more than one slice, since a pie's colour-to-category mapping lives in its legend. A chart showing one series has nothing to disambiguate, and hatching it would be decoration carrying no information.Always— draw it regardless. Use for consistency across a small multiple, where each panel holds one series but the set is read together. Note that series 0's pattern isSolid, so a single-series chart needs an explicit.pattern(..)forAlwaysto be visible.Never— a deliberate accessibility regression, named plainly so it reads as one at the call site. Reach for it only where the design already carries the distinction some other way — direct series labels on the plot, or one series per chart.
Legend swatches sample what the plot draws, so there is no second
mapping to learn: LegendSwatch::Block (a hatched chip) for bars,
LegendSwatch::Line (a dashed sample with the marker at its centre) for
lines, LegendSwatch::Marked (a chip stamped with the marker glyph) for
pie slices. The charts set this themselves; a standalone ChartLegend
takes .swatch(..) and .pattern_policy(..) to match its chart.
Why a pie gets a marker and a bar gets a hatch. Hatches are parallel
strokes clipped to the region being filled, and the canvas clips to
rectangles only. A bar and a legend swatch are rectangles; a wedge is
not. Each slice therefore carries its pattern's marker glyph at its
centroid — in a tone derived from the slice's own fill so it contrasts
against any palette — and the matching legend swatch carries the same
glyph. Slices narrower than
style::MIN_MARKED_SLICE_RAD are skipped: a sliver cannot hold a glyph
without it spilling into its neighbours.
6. Legend
Two ways to use it:
Embedded — the chart instantiates ChartLegend internally when
constructed with .legend(true), lays it out at legend_position
(Top / Bottom / Leading / Trailing), and shares the same
ChartModel and palette prop.
Standalone — build a ChartLegend
yourself and place it anywhere in your widget tree, sharing the
same ChartModel the chart binds to:
#![allow(unused)] fn main() { use teksilo_charts::{ChartLegend, ChartModel, LegendOrientation}; let model = ChartModel::from_series_vec(make_series()); let chart = LineChart::new(model.clone()) .legend(false); // chart draws no legend let legend = ChartLegend::new(model.clone()) .orientation(LegendOrientation::Vertical); VStack::new() .child(HStack::new().child(chart).child(legend)) }
Interactive. ChartLegend::interactive(true) (or the chart-level
.legend_interactive(true) on BarChart / LineChart — PieChart
does not expose it) turns every row into a real focusable/clickable
element (Role::CheckBox, click or Space toggles). Toggling a row
calls ChartModel::set_series_visible(series, !visible) directly —
there's no separate wiring; the legend mutates the same model the
chart reads. Default false.
Embedded legend orientation is auto-derived from position: Top and
Bottom get horizontal, Leading and Trailing get vertical.
Override with the standalone widget if you need something different.
7. Layout — proposal-driven plot-area carve
All three charts are proposal-driven: layout_response returns
whatever the parent proposes, with a 320×200 (line / bar) or 320×220
(pie) fallback when the proposal is unbounded. This matches
ProgressBar and
ScrollArea — charts
fit any container.
Inside paint, the bounds are carved into a plot rect by
carve_plot_area, which:
- Reserves the legend band on the requested edge (when shown).
- Reserves a y-axis band on the leading edge: max tick label width + tick length + gap + axis-title height (when applicable).
- Reserves an x-axis band on the bottom edge: tick label height + tick length + gap + axis-title height.
- Insets the inner plot by
plot_padding_*from the dimension constants in crates/teksilo-charts/src/style.rs (not to be confused with the Tier-3ChartStyletrait — §11 below — which carries paint recipes, not dimensions).
Y-tick labels need actual values to measure widths, so the order is:
domain → nice_ticks → measure widest label string → carve y-band →
recompute tick positions to fit the carved plot rect. Single pass —
no iteration on label collisions.
PieChart bypasses axis bands entirely (pie has no axes) and only
carves off the legend band. The disc inscribes into the largest
centered square minus pie_padding.
For PieChart with a center widget, place_children and paint both
go through compute_plot_rect so the inscribed-square slot is
centered on the actually-rendered disc, not on the full bounds —
otherwise the slot drifts when a legend is shown.
8. Reactivity — binding levels
Every chart binds to its ChartModel<T>'s two version signals — see
§3 and data-models.md §15
for what bumps which. The mapping is deliberately coarse: only a
series color change is paint-only — everything else that can mutate
a model (including a visibility toggle, which shifts the auto
y-domain and bar widths) goes through structure_version and is a
full Relayout.
| Change | Model signal | Binding level | Why |
|---|---|---|---|
| Series add/insert/remove/move/rename | structure_version | Relayout + AccessibilityOnly | Y-domain, tick positions, and label widths may all shift; the per-datum AT mark list must also refresh |
Point push/insert/remove/update, replace_series_data, clear | structure_version | Relayout + AccessibilityOnly | Same — any point-shape change can move the domain |
set_series_visible | structure_version | Relayout + AccessibilityOnly | Visible set changes the auto y-domain and bar widths, not just paint |
set_series_color / clear_series_color | style_version | RepaintOnly | Geometry unchanged — this is the only ChartChange variant that doesn't bump structure_version |
Hover state (private Signal<Option<(SeriesId, usize)>>, all three charts) | — | RepaintOnly | Marker + tooltip only |
| Theme change | — | Auto via tree-wide mark_all_dirty | Colors/fonts re-resolved on next paint |
Prop<ChartPalette> change | — | RepaintOnly | Color-only |
PieChart inner_radius_ratio change | — | Relayout | Center-slot inscribed-square size depends on it |
The wiring lives in each chart's build() (BarChart shown; LineChart
/ PieChart follow the same shape):
#![allow(unused)] fn main() { fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { let id = ctx.self_id(); let registry = ctx.binding_registry(); // Data swap → relayout (y-domain might shift) AND the AT mark // list must refresh. self.model.structure_version().bind_to(id, registry, BindingLevel::Relayout); self.model.structure_version().bind_to(id, registry, BindingLevel::AccessibilityOnly); // Color-only swap → repaint. self.model.style_version().bind_to(id, registry, BindingLevel::RepaintOnly); self.palette.register_if_bound(id, registry, BindingLevel::RepaintOnly); self.hover.bind_to(id, registry, BindingLevel::RepaintOnly); // ... } }
For widgets that bind via per-series ColorProp::Bound(signal) (a
series' color field holding a live Signal<Color> rather than a
static value), the chart palette stays untouched and the color signal
triggers a repaint without a full relayout — same effect as
set_series_color, driven from outside the model. This is the right
path for "pulsing" / "highlighted" colors that don't change geometry.
9. Hover tooltips
All three charts — BarChart, LineChart, PieChart — draw their
hover tooltips inline inside their own paint(), clipped to the
plot rect. This is deliberately different from
TooltipWidget:
- Chart tooltips track the cursor across the plot to the nearest
data point, snapping per-pixel.
TooltipWidgetis anchored to a widget bounds box. - Chart tooltips appear instantly.
TooltipWidgetwaits ~700 ms for dwell. - The content depends on which point is nearest, which can change within the same widget without an enter/leave event.
The implementation is straightforward:
- The chart owns a private hover signal —
Signal<Option<(SeriesId, usize)>>, the same(series, point index)shape across all three chart kinds — bound atBindingLevel::RepaintOnly. - An
on_pointer_eventhandler attached viaHandlerSetreads the pointer position, finds the nearest hit in aVec<…Hit>snapshot the chart wrote during paint, and updates the signal. paint()reads the signal — ifSome, it draws a marker (a small ring + filled circle for line charts, the wedge stroke for pie) and a tooltip rect above the marker.- Edge-flip placement: if the tooltip would clip the plot rect's top edge, it flips below the marker; if it would clip leading/trailing, it shifts inward.
The hit snapshot is keyed by paint epoch (replaced, not appended, each paint), so a data change shrinks the index correctly. Hit-test cost is O(N×S) per pointer move for N points across S series — acceptable up to ~10k points without optimization.
For pie/donut, the hit-test is polar: convert pointer position to
(angle, distance) from disc center, accept the hit only if
inner_radius ≤ distance ≤ outer_radius, then locate the slice
whose angular range covers the pointer. The angle conversion has to
subtract start_angle_degrees and flip for non-clockwise charts —
both are easy to forget; the
pie_hit_test_uses_logical_angle_space
test locks this.
Disable with .hover_tooltip(false) if you'd rather the chart not
react to hover at all (e.g. embedded in a tooltip itself, or behind
a busy overlay). A clone of the hover signal is also public via
.hover_signal() -> Signal<Option<(SeriesId, usize)>> on each chart,
for apps that want to observe hover from outside without
re-implementing the hit-test.
ChartSelection (teksilo-data,
keyed by (SeriesId, usize)) is consumed by all three charts the
same way: .selection(ChartSelection) reuses the exact hit-test the
hover handler uses (hit::rect_hit / hit::nearest_point /
hit::slice_hit) to add click-to-select — a tap on a mark selects it
(Ctrl/Cmd-click toggles it in SelectionMode::Multi), a tap on empty
space clears the selection — and every selected mark paints an
accent-colored highlight (a bar's outline, a line point's ring, a
slice's outline) on top of its normal fill; see
data-models.md §15.4.
10. Theming — chart style constants
crates/teksilo-charts/src/style.rs
carries chart-specific dimension constants: padding (PLOT_PADDING_TOP,
PLOT_PADDING_RIGHT, PLOT_PADDING_BOTTOM, PLOT_PADDING_LEADING),
tick lengths, label gaps, gridline width, default line / point sizes,
legend swatch and item gaps, tooltip padding, and the four pie-related
constants (PIE_PADDING, PIE_LABEL_GAP, PIE_LEADER_LENGTH,
PIE_MIN_SLICE_LABEL_DEGREES, DONUT_DEFAULT_INNER_RATIO).
Charts pull their colors from existing roles, not new fields:
- Axis lines →
BorderRole::Default - Grid lines →
BorderRole::Defaultwith reduced alpha (0.4) - Axis tick labels →
TextRole::Secondary,TextStyleRole::Tiny - Axis title →
TextRole::Secondary,TextStyleRole::Tiny - Legend label text →
TextRole::Primary,TextStyleRole::Tiny - Tooltip background / text / border → reuse
tooltip_bg,tooltip_text,tooltip_border
The only chart-specific color is the chart_palette (§5). A theme
overriding the palette doesn't need to touch any other chart token;
a theme tightening density can change the PLOT_PADDING_* constants
in teksilo-charts/src/style.rs without touching colors.
11. Styling — the ChartStyle trait
Charts sit on the same Tier-3 styling ladder as every other themable
widget (see styling-system.md) via
ChartStyle, a
trait in teksilo-core::styles:
#![allow(unused)] fn main() { pub struct ChartFillContext<'a> { pub series_index: usize, pub resolved_color: Color, // palette / per-series color, already resolved pub theme: &'a Theme, } pub trait ChartStyle: 'static { fn bar_fill(&self, cfg: &ChartFillContext) -> FillRecipe; fn area_fill(&self, cfg: &ChartFillContext, opacity: f32) -> FillRecipe; fn donut_fill(&self, cfg: &ChartFillContext) -> FillRecipe; fn gridline(&self, theme: &Theme) -> BorderRecipe; } }
Unlike every other Tier-3 trait, ChartStyle is all-recipe — four
methods returning plain-data FillRecipe / BorderRecipe (Tier 2),
none returning WidgetId. Charts paint via Canvas calls inside their
own paint() rather than composing child widgets, so there's no
make_*(cfg, ctx) -> WidgetId step to hook into; the recipe is
resolved once per fill/stroke and painted directly. This is a
different trait shape from the widget world's make_body traits
and from the multi-method traits (TabStyle, DialogStyle,
TableStyle, CalendarStyle) that still return WidgetIds from
several named slots — ChartStyle returns data from all four.
Resolution chain, same precedence as every other themable widget:
per-call .style(impl ChartStyle) > theme.style_slots.chart > RecipeChartStyle::default()
BarChart / LineChart / PieChart all expose
.style(impl ChartStyle) -> Self. The theme-wide slot is
theme.style_slots.chart: Option<Rc<dyn ChartStyle>>
(SharedChartStyle).
Layering note: RecipeChartStyle, the shipped default, lives in
teksilo-charts itself, not teksilo-widgets/src/styles/* — the
one place this default breaks the convention every other Recipe*Style
follows (see §1 and styling-system.md). The
reason is layering, not oversight: teksilo-charts deliberately does
not depend on teksilo-widgets, so its default style implementation
has to live where its dependencies already reach. teksilo-core only
holds the trait and the Rc<dyn ChartStyle> slot type — it has no
opinion on where the default lives.
RecipeChartStyle reproduces the flat-color chrome charts always
painted before Tier-3 styling landed: bar_fill / donut_fill
resolve to FillRecipe::Solid(cfg.resolved_color), area_fill is the
same solid color at the caller-given opacity, and gridline is a
BorderRole::Default-at-40%-alpha solid BorderRecipe.
Dashed gridlines. gridline()'s returned BorderRecipe carries a
BorderStyle (Solid by default in RecipeChartStyle), so a custom
ChartStyle can theme-wide switch every chart's gridlines to
BorderStyle::Dashed { dash, gap }. For a one-chart override without
writing a whole ChartStyle, AxisConfig::gridline_dash(dash, gap)
sets a per-axis dash pattern that wins over the style's gridline
recipe. Gridlines are drawn via Canvas::stroke_path (Tier 3) rather
than the faster draw_line (Tier 1), because draw_line doesn't
honor dash patterns.
Gradient area / donut fills. area_fill and donut_fill can
return FillRecipe::LinearGradient { .. } / FillRecipe::RadialGradient { .. } instead of Solid — a custom ChartStyle is the only way to
opt in (RecipeChartStyle stays flat). Gradient fills route through
the same two recipe methods plus
Canvas::fill_path(path: &Path, paint: impl Into<Paint>)
(widened from a flat-color-only signature) and a new Tier-3
path-gradient GPU pipeline (path_gradient.wgsl). Radial gradients on
a donut are continuous across wedge boundaries (the gradient is
defined once over the whole disc, not re-evaluated per slice); a
linear gradient across a donut is a documented edge case — it reads
correctly per-wedge but the seam between wedges isn't a straight
gradient line the way a radial one is, so radial is the natural choice
for donut fills.
12. Accessibility
Each chart declares Role::GraphicsDocument with a name that
describes the shape ("Bar chart: 3 series, 4 categories",
"Line chart: 2 series, 12 points", "Pie chart: 5 slices").
Per-datum AT nodes. Every visible bar / line point / pie slice is
also its own synthetic child node — Role::GraphicsObject, name
"{series name}, {category}: {value}", and numeric_value set to the
datum's f32 value — emitted via
hit::emit_mark_node under
SyntheticKind::ChartMark (the same synthetic-child mechanism
teksilo-scene uses for lightweight scene items). Node ids are
deterministic within a process run, derived from (SeriesId, usize)
via DefaultHasher, so a mark keeps the same AT id across repeated
accessibility() walks. Apps that need full data-table semantics
(sortable columns, cell-level navigation) should still mirror the
chart with a TreeView / TableView next to it — the per-datum marks
give a screen reader a way to inspect individual values, not a
substitute for tabular navigation.
13. Limits and explicit follow-ups
Closed since the initial five-PR cycle: BarChart hover tooltips,
interactive legends, per-datum accessibility nodes, the styling
ladder gap (ChartStyle, §11), and ChartSelection click-to-select
are all now implemented — see §5 (inactive-window desaturation), §6
(interactive legend), §9 (BarChart tooltip + selection), §11
(ChartStyle, dashed gridlines, gradient fills), and §12 (per-datum
AT nodes) above. The flat-fill limit is closed as an opt-in:
RecipeChartStyle stays flat by default (visual parity with every
chart drawn before Tier-3 styling landed) — gradients and dashed
gridlines require installing a custom ChartStyle or setting
AxisConfig::gridline_dash.
Still genuinely open:
- No stacked bars. Single + grouped only. Stacked needs its own legend + hit-test pass for the sub-bar; deferred.
- No log axis.
nice_ticksis linear-only. - No time-axis formatters.
T = chrono::DateTimeworks structurally (the chart only needsDisplay), but tick generation doesn't snap to month/quarter/year boundaries. Deferred. - No animation on data change. A model mutation (
push_point,set_series_visible, …) relayouts/repaints instantly — there's noanimate_tointegration on bar height / line position / slice angle transitions yet. - Pie / donut hover for BarChart-style "follow the cursor across multiple slices." The handler exists but the visual treatment matches Excel's "highlight one slice" — no slice-pull-on-hover yet.
- Linear gradient on a donut is a documented edge, not a bug. See §11 — reach for a radial gradient on a donut; a linear gradient reads correctly per-wedge but has a visible seam across wedge boundaries.
- No chart widget wires
ChartWindow/ChartAggregateinternally. Both remainteksilo-databuilding blocks (§3, and data-models.md §15) an app composes on top of aChartModelfor a strip-chart or a downsampled long series.ChartSelectionis the one exception — see §9 — all three charts consume it directly via.selection(ChartSelection).
For each of these, the file pattern in crates/teksilo-charts/src/ is the place to look — the modules are intentionally split so future work lands in one or two files at most.
14. Demo
examples/chart_demo ships all
three charts in one window, built throughout on the current
ChartModel<T> API — ChartModel::from_series_vec /
ChartModel::from_points construction plus in-place mutation
(replace_series_data, push_point, §3) — with no wholesale
Signal<Vec<ChartSeries<T>>> swap anywhere in the demo. Run with:
cargo run -p chart-demo
What it shows, end to end:
- Chart-kind switcher. A
SegmentedControl("Bars" / "Lines" / "Donut") drives aSwitcherbetween the three panels. Bar and Line share one seriesChartModel<String>— constructed once, cloned into both chart widgets, the same sharing patternChartModel::clone()gives for free (§3) — and oneChartSelection, so switching between the two panels keeps the highlighted point selected. The donut consumes a second, single-seriesChartModel<String>. - Default / Gradient theme toggle. A second
SegmentedControldrives aSwitcherbetween the shipped flatRecipeChartStyleand a demo-definedGradientChartStyle(§11): a vertical bar-fill gradient, a top-to-bottom area-fill gradient fading toward the baseline, a continuous radial donut gradient, and dashed gridlines viaChartStyle::gridline. - Interactive legend (§6). Both the Bar and Line panels embed a
.legend_interactive(true)legend — clicking (or pressing Space on a focused) row toggles that series' visibility live. - BarChart hover (§9, §4). Hovering a bar shows the shared tooltip card, snapping to the nearest bar.
- Click-to-select (§9, §2, data-models.md §15.4).
All three charts are wired with
.selection(ChartSelection): clicking a bar, line point, or donut slice paints an accent highlight on it and clicking empty space clears the selection. The donut's center slot reads the pie's ownChartSelection::selection_signal()directly and shows the selected category plus its share of the total, falling back to "Total" plus the full sum when nothing is selected — real slice interaction, no button-chip stand-in. - "Refresh data" button. Re-seeds the pseudo-random series and
calls
ChartModel::replace_series_dataper series (Bar/Line model) and per point (pie model) — an in-place data swap, not a rebuild. - Live strip-chart pane (§3, data-models.md §15).
A
LiveStripPanewidget appends one point every tick (via a periodic frame-tick timer) to an unbounded historyChartModel<u32>, then projects its tail through aChartWindow<u32>("last N points"). Since chart widgets bind to aChartModel, not aChartWindowprojection directly, the window's current tail is materialized each tick into a small render-boundChartModeltheLineChartactually consumes — an honest bridge given that constraint. Reduced-motion builds the (empty) chart but skips the timer.
Useful as a sanity-check after any change to teksilo-charts;
cargo test -p teksilo-charts (88 headless tests, no GPU) is the
faster CI path.
teksilo-scene
A pannable / zoomable scene viewport for Teksilo. Use it for any scene-based application — story corkboards, mind maps, node-graph editors, timeline views, CAD canvases, simple maps — where content is free-positioned at scene coordinates instead of placed by a layout algorithm.
The crate sits at the same tier as teksilo-widgets: it depends on
teksilo-core, teksilo-canvas, and teksilo-tokens, but not on
teksilo-widgets. Apps mixing scene-based and standard-widget UI bring
both crates in.
Two tiers of content
Every scene mixes two tiers under one view transform:
- Heavyweight tier — any
Widget(Button, TextInput, Panel, custom composites) placed at a scene position. Fully interactive, fully accessible — every framework affordance survives the embedding (focus, animation, AT, drag-and- drop, etc.). - Lightweight tier —
SceneItems: paint-only objects with no arena overhead. Cheap to render thousands of them. Used for the background furniture of a scene (connector lines, grids, decorative tiles, status dots).
Apps freely mix the two: heavyweight cards arranged on a lightweight connector-line backdrop, a lightweight grid under a heavyweight toolbar overlay, etc.
#![allow(unused)] fn main() { use teksilo_scene::{RectItem, Scene, SceneView}; use teksilo_canvas::{Point, Rect}; let mut scene = Scene::new(); scene.add_widget(my_card_widget(), Rect::new(0.0, 0.0, 200.0, 120.0)); scene.add_item( RectItem::new(Rect::new(0.0, 0.0, 50.0, 50.0)) .fill(teksilo_tokens::Color::RED), Point::new(220.0, 0.0), ); let view = SceneView::new(scene); tree.add(view); }
Coordinate model
Coordinates are parent-relative, mirroring Qt's
QGraphicsItem:
local_pos: Point— origin of the item's local frame, in its parent's coordinates (or scene coords ifparent == None).local_bounds: Rect— AABB at origin in local coords.transform: Transform2D— rotation / scale / shear applied around the local origin before translating bylocal_pos.
The Scene composes the chain (local → parent → … → scene) on
demand via Scene::scene_transform(id).
Helpers project both ways:
#![allow(unused)] fn main() { scene.scene_pos(id) // Point in scene coords scene.scene_rect(id) // AABB in scene coords (used by the spatial index) scene.scene_transform(id) // local → scene affine scene.map_to_scene(id, pt) // local → scene scene.map_from_scene(id, pt) // scene → local view.map_to_scene(view_pt) // view → scene view.map_from_scene(scene_pt) // scene → view view.map_rect_to_scene(view_rect) view.map_rect_from_scene(scene_rect) }
Per-item rotation / scale composes through ancestors, so rotating a parent rotates every descendant visually and updates their hit-test shapes in lockstep.
SceneItem trait
Custom items implement SceneItem:
#![allow(unused)] fn main() { pub trait SceneItem: Debug + 'static { fn local_bounds(&self) -> Rect; fn set_local_bounds(&mut self, bounds: Rect); fn paint(&self, canvas: &mut Canvas, ctx: &SceneItemPaintContext<'_>); // Optional: fn set_fill(&mut self, fill: Option<ColorProp>) -> bool; // live colour mutation fn set_stroke(&mut self, stroke: Option<(ColorProp, StrokeStyle)>) -> bool; fn shape_contains(&self, local_pt: Point) -> bool; // exact-shape hit-test fn initial_flags(&self) -> ItemFlags; // set on insert fn label(&self) -> Option<String>; // debug + AT default fn cache_mode(&self) -> CacheMode; // None | ItemCoordinate fn access_subtree_mode(&self) -> AccessSubtreeMode; fn register_bindings(&self, ctx: &mut BuildContext, view_id: WidgetId); fn accessibility(&self, b: &mut AccessNodeBuilder, ctx: &SceneItemA11yContext); } }
paint runs in local coordinates — the canvas already has the
item's scene_transform (chain × view) pushed by the SceneView paint
walk, so a RectItem paints with canvas.fill_rect(self.local_bounds, ...).
set_fill / set_stroke default to a no-op (false) and back the live
SceneModel::set_item_fill /
set_item_stroke mutators — see Item colours & theming below.
Five built-ins ship out of the box:
RectItem (optional
corner_radius and styled/dashed strokes via stroke_styled),
PathItem (with per-segment
hit-test for stroke-only paths, also stroke_styled),
ImageItem,
TextItem (static or
signal-bound; horizontal align(TextAlign::{Leading,Center,Trailing}), a
free rotation(radians), and a measure(&mut dyn TextBackend) -> Size
helper for sizing a slot around a label), and
GroupItem (labelled box
or logical-only AT container — also has corner_radius and
stroke_styled).
Item colours & theming
SceneItem::paint receives a
SceneItemPaintContext carrying
everything a colour-bearing item needs to resolve its chrome against the
live theme:
| Field | Meaning |
|---|---|
theme: &Theme | The fully-projected theme for this paint pass — already swapped to the inactive-window / high-contrast variant by the render walker. Read it directly; never call Theme::for_inactive_window yourself. |
window_active: bool | true iff the host window is focused and unoccluded (true in headless tests). For behavioural blur cues the theme swap alone can't cover. |
enabled: bool | The item's effective IS_ENABLED flag, forwarded to ColorProp::resolve so a role colour picks its disabled variant. |
text_scale: f32 | The global accessibility text-scale factor, for TextItem::follow_text_scale opt-ins. |
view_transform / dirty_scene_rect | Unchanged — pan/zoom/rotation and the current repaint region. |
Built-in items' fill / stroke / foreground fields are
ColorProps — accepting a plain
Color, a theme role (SurfaceRole
/ TextRole / BorderRole), a Signal<Color>, or a Signal<Role> — and are
resolved with prop.resolve(ctx.theme, ctx.enabled) inside paint. Because
ctx.theme is already the inactive-window projection, a role fill
auto-desaturates when the window loses focus with zero per-item code:
#![allow(unused)] fn main() { RectItem::new(rect) .fill(SurfaceRole::Sunken) // resolves against ctx.theme at paint .stroke(BorderRole::Default, 1.0) }
Reactive colours
A colour is continuously reactive in two ways:
- Build-time: construct the item with a
Signal<Color>or aSignal<Role>(e.g..fill(my_signal.clone())). Every colour-bearing built-in (RectItem,PathItem,GroupItem,TextItem) registers its boundColorProps atBindingLevel::RepaintOnlyinregister_bindings, so a signal change repaints the owningSceneView— no relayout, no rebuild. - Runtime: mutate a mounted item's colour live through the shared
[
SceneModel] —set_item_fill/clear_item_fill/set_item_stroke/clear_item_stroke. Each emitsItemChange::AppearanceChanged, which the view treats as repaint-only, always (it evicts the item's paint cache and repaints — never a relayout, rebuild, or AccessKit re-walk). These install a snapshot, which is all a static colour ever needs. Passing aSignal/dynamic role here paints its current value immediately and starts tracking it continuously from the view's next rebuild (whenever some other structural change re-runsregister_bindings) — a colour change is deliberately never allowed to cost a rebuild. For a colour that tracks its signal forever, construct the item with it (the build-time path above).
#![allow(unused)] fn main() { let model = view.model(); model.set_item_fill(card_id, SurfaceRole::AccentSubtle); // repaint only model.set_item_stroke(card_id, Color::RED, StrokeStyle::dashed(2.0, 6.0, 4.0)); model.clear_item_fill(card_id); }
set_fill / set_stroke are also the SceneItem trait hooks (default
no-op) a custom item overrides to participate in the same live-mutation
path: RectItem / PathItem / GroupItem accept both; TextItem maps
set_fill onto its foreground colour (a None clear is rejected — text
always has a colour); ImageItem accepts neither. Note that on a GroupItem,
giving a previously logical (chrome-less, click-through) group a fill or
stroke makes it visual — it starts hit-testing and will absorb clicks that
used to fall through to the items it groups.
Minimap caveat.
SceneItem::thumbnail_color(whatSceneMinimaprenders) is theme-free by signature, so an item whose colour is a theme role has no theme to resolve against and falls back to a neutral grey on the minimap. Use a concreteColoror aSignal<Color>for items you want faithfully represented there.
Cache caveat. A custom item that opts into
CacheMode::ItemCoordinatebakes its resolved colours into the cached frame. TheSceneViewinvalidates that cache on a theme swap, a window-active flip, and anIS_ENABLEDchange, so role colours stay correct — but the cache is still keyed by(id, raster_scale), so a custom item whose paint depends on any other ambient state must use the defaultCacheMode::None.
Item flags
Per-item behaviour is a bitset on ItemFlags.
Default is IS_VISIBLE | IS_ENABLED | IS_SELECTABLE.
| Flag | Effect |
|---|---|
IS_VISIBLE | When cleared, the item skips paint and hit-test. Composes through ancestors via Scene::is_effectively_visible. |
IS_ENABLED | Disabled items don't dispatch pointer events and don't take focus. |
IS_DRAGGABLE | The item participates in drag-to-move when the view is in DragMode::RubberBand. |
IS_SELECTABLE | The item can be picked up by marquee-select. |
IS_FOCUSABLE | The item can receive keyboard focus. |
ACCEPTS_HOVER | Reserved — tracks hover entrance / exit. |
CLIPS_TO_SHAPE / CLIPS_CHILDREN_TO_SHAPE | Reserved for clip-region paint. |
IGNORES_TRANSFORMATIONS | Item paints / hit-tests at fixed pixel size regardless of view zoom. Anchor (parent-relative scene point) still follows pan/zoom — so the item tracks the data point underneath, but its size stays constant. Mirrors Qt's ItemIgnoresTransformations. |
HAS_NO_CONTENTS | Logical-only entry, skipped by the paint walk. |
Read / mutate via Scene::flags(id) / Scene::set_flag(id, flag, on) /
Scene::set_flags(id, flags). Convenience: Scene::set_visible(id, v).
Per-item events
Mirrors WidgetBuilder's attached-handler chain for the lightweight
tier. Install via SceneItemHandlerSet:
#![allow(unused)] fn main() { let mut handlers = SceneItemHandlerSet::new(); handlers .on_tap(|pt, ctx| ctx.send_intent(MyIntent::Clicked)) .on_double_tap(|pt, ctx| ctx.send_intent(MyIntent::Open)) .on_hover(|entered, ctx| { /* … */ }) .on_context_menu(|pt, ctx| ctx.send_intent(MyIntent::Menu)) .cursor(CursorIcon::Pointer) .tooltip(tr!(card_tooltip())); // accepts LocalizedString scene.set_item_handlers(item_id, Some(handlers)); }
The view's pointer-dispatch path projects the screen-space pointer to
scene coords, broad-phases via the spatial index, narrow-phases via
SceneItem::shape_contains, then dispatches to the topmost-z hit
item's handlers.
View transform & gestures
SceneView owns four animated
Signal<f32>s:
pan_x,pan_yzoomrotation
The composite view_transform
projects scene → screen and is bound via
BuildContext::set_transform so the renderer pushes it around the
entire subtree.
OS gestures plug in directly:
- Trackpad two-finger pan and mouse-wheel scroll drive
pan_x/pan_y(Ctrl+wheel = zoom-about-pointer). - Pinch drives
zoomandrotationanchored on the gesture center. - Reduced-motion is honoured: pan / zoom snap instead of animating.
Programmatic API:
#![allow(unused)] fn main() { view.set_pan(target); view.pan_to(target, duration); view.set_zoom(target); view.zoom_to(target, duration); view.set_rotation(rad); view.rotate_to(rad, duration); view.ensure_visible(scene_rect, margin); // pan-only fit view.fit_to_content(); view.center_on(scene_pt); }
State persistence:
#![allow(unused)] fn main() { let snap = view.state(); // SceneViewState — Serde-friendly view.restore_state(snap); }
Scene policy: pan / zoom axes
The Scene declares which navigation gestures are permitted. Apps with a fixed-extent diagram, a horizontal-only timeline, or a "as-large-as-the-window" embedded mini-graph set:
#![allow(unused)] fn main() { scene.pan_axes(PanAxes::None | PanAxes::Horizontal | PanAxes::Vertical | PanAxes::Both); scene.zoomable(false); // disables Ctrl+wheel, pinch, +/- }
The View reads these at gesture-handler wiring time. Pan deltas on a restricted axis pass through to ancestor scrollables (correct event propagation).
For inline embeddings of a scene that fills its slot exactly, the view itself sizes to the scene:
#![allow(unused)] fn main() { SceneView::new(scene).adopt_scene_size(true) // view.size = scene_rect_extent }
In adopt mode user pan / zoom are no-ops (the entire scene is on
screen) and the view's layout_response returns the scene's content
extent instead of a default.
scene_rect clamping:
#![allow(unused)] fn main() { scene.set_scene_rect(Some(Rect::new(-500.0, -500.0, 2000.0, 2000.0))); // Programmatic + animated pan now clamp to scene_rect ± viewport. }
Drag mode
#![allow(unused)] fn main() { SceneView::new(scene).drag_mode(DragMode::RubberBand) // default — item drag → move; empty → marquee SceneView::new(scene).drag_mode(DragMode::ScrollHandDrag) // left-drag pans the view SceneView::new(scene).drag_mode(DragMode::NoDrag) }
Middle-click pan is unconditional. Right-click on an item with an
on_context_menu handler fires the handler.
Drag-start hit-test (narrow-phase). In RubberBand mode, a press
decides item drag vs. marquee by hitting only draggable lightweight
items (IS_DRAGGABLE — opt in via .draggable(true)), and it hits them with
the exact-shape test, not just their AABB: a per-item snapshot carries the
item's scene_rect (broad-phase) plus its shape_contains predicate
(narrow-phase) and scene transform, sorted topmost-first. A press lands on an
item only when it falls inside the shape (a thin diagonal PathItem, a
ring, a rotated rect) — a press in the AABB but off the shape, or over a
non-draggable backdrop / heavyweight card, falls through to a marquee. This
is why dragging from on top of a select-only (non-IS_DRAGGABLE) card still
rubber-bands instead of nudging the scene: the card is not in the draggable
snapshot, and the cross-widget tap/drag disambiguation (see
events-and-gestures.md) lets the view's on_drag
start even though the card carries an on_tap.
Reactive observers — item_change_signal
Every Scene mutation fires an ItemChange
event through Scene::item_change_signal(). Apps observe to wire
snap-to-grid, validation, persistence:
#![allow(unused)] fn main() { let _h = scene.item_change_signal().observe(|change| { if let ItemChange::LocalPosChanged { id, new, .. } = change { snap(id, *new); } }); }
ItemChange variants: Added, Removed, LocalPosChanged,
LocalBoundsChanged, TransformChanged, VisibilityChanged,
OpacityChanged, FlagsChanged, ZChanged, ParentChanged.
Collision API
#![allow(unused)] fn main() { scene.item_at(scene_pt) -> Option<ItemId> // topmost-z hit scene.items_at(scene_pt) -> Vec<ItemId> // all hits, sorted by z scene.items_in_rect(scene_rect) -> Vec<ItemId> scene.colliding_items(id) -> Vec<ItemId> // items whose AABB intersects id's scene.items_along_path(&path) -> Vec<ItemId> // items under a connector polyline }
Backed by the spatial index (default GridHashIndex). All query cost
is O(visible × chain-depth), independent of total scene size.
GridHashIndex buckets an item into every grid cell its AABB overlaps.
Cell count grows as (width / cell_size) * (height / cell_size), and
cell_size clamps to a 1.0 minimum, so nothing bounds it on its own — a
single Scene::add_item with a full-document backdrop or canvas rect at a
small cell_size can ask for billions of cells. An item whose AABB would
span more than MAX_CELLS_PER_ITEM (1024) cells is therefore not
bucketed cell-by-cell at all; it is kept in a separate always-scanned
oversized set and checked against every query with an exact AABB
intersection test instead. At the default 256 px cell_size that threshold
is an ~8192 px square item; at the clamped-minimum cell_size of 1.0 it's
~32 px. The query rect itself gets the same treatment — query /
items_in_rect take an arbitrary caller rect, so a "select everything"
query over a huge area hits the identical hazard on the query side; when
the query rect's own span exceeds the cap, the index scans the populated
cell map directly instead of enumerating the rect's cells, bounded by how
many cells are actually occupied rather than by the rect's area. Both
paths preserve GridHashIndex::query's broad-phase invariant: it may
over-report (a cell-granularity false positive) but must never
under-report — miss an item whose bounds genuinely intersect the query
rect. Scene::items_in_rect (and the other collision queries above)
narrow-phase every candidate through their own exact AABB check, so the
over-report never reaches the app; it matters only to a caller that
queries GridHashIndex directly.
Magnetism
Magnetism is typed snap-and-connect between anchor points ("magnets") on scene items. It is general node-graph / diagram machinery: drag an item so its magnets snap to compatible magnets on other items, drag a wire from a magnet handle, or connect two magnets from the keyboard, and on release a connection event carries the magnet payloads to the consumer.
The governing principle is mechanism in scene, policy in the
consumer. Scene owns the geometry, the broad-phase, the snap math, the
feedback rendering, the predicate hook, and the connection event. Scene
does not own which magnet types are compatible, what a connection
means, or whether connections persist. Compatibility is decided by the
predicate the consumer supplies; the meaning of a connection is decided
by the consumer's on_connect.
The magnet model
A Magnet is a local point on an item (in the item's frame, so it
follows the item under any move / rotate / scale), carrying a directional
MagnetRole and an optional type-erased payload ('static,
downcastable):
#![allow(unused)] fn main() { let out = scene.add_magnet( node, Magnet::new(Point::new(node_w, node_h * 0.5)) .role(MagnetRole::Source) // advisory: Source | Target | Bidirectional .payload(PortId { node, kind: Out }) // any 'static value .label(tr!(node_output())), // AT name ); }
MagnetRole is generic diagram vocabulary (every node-graph has output
and input ports). It is advisory — the scene uses it for default
feedback (which end is the source) and to order the keyboard cycle, but
the predicate is always the authority on whether two magnets connect.
Mutators (all &self on SceneModel, &mut self on Scene):
add_magnet / remove_magnet / clear_magnets / set_magnet_local_pos
/ set_magnet_enabled. Reads: magnet_ids_of / magnet (a borrow-free
MagnetRef snapshot) / magnet_scene_pos / magnet_owner /
magnet_enabled. Removing an item drops its magnets automatically.
The predicate and the connection event
The predicate is Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict, where
MagnetVerdict is Reject or Accept(Option<Rc<dyn Any>>) — "both
payloads in, reject or accept-with-payload out". It runs over owned
magnet snapshots while a shared (read-only) scene borrow is held, so it
may read the model but must not mutate it.
on_connect is Fn(&MagnetConnection, &mut EventContext). It fires on
mouse release or keyboard confirm, after every borrow is dropped, so it
may freely mutate the model, declare an AT relation, or fire an intent.
Three input methods, one mechanism
Install per view via SceneView::magnetism(MagnetismConfig):
#![allow(unused)] fn main() { let cfg = MagnetismConfig::new(|a, b| { // policy: accept Source -> Target on different items if a.item != b.item && /* roles compatible */ true { MagnetVerdict::accept() } else { MagnetVerdict::Reject } }) .on_connect(|conn, _ctx| { /* add an edge, fire an intent, … */ }) .capture_px(14.0) // screen-space capture + grab radius .markers(MarkerVisibility::DuringInteraction) .connect_key(Key::Character('m')); // keyboard connect-mode toggle let view = SceneView::with_model(model).magnetism(cfg); }
- Item-drag-snap (mouse): drag a lightweight item; its magnets ride along and snap to the closest accepting magnet within the capture radius; release fires the connection and lands the item snapped.
- Port-drag wire (mouse): press directly on a magnet handle to drag a transient wire that snaps to a compatible target; release connects, the item does not move.
- Keyboard connect (any item kind): focus the view, press the connect key to enter connect mode, arrow-keys / Home / End move a virtual focus through magnets (gated by the predicate once a source is activated), Enter activates the source then forms the connection, Esc cancels.
The capture radius is specified in screen pixels and divided by the live zoom, so snapping feels constant at any zoom.
Feedback
A built-in renderer paints magnet markers (coloured by state, constant
pixel size) plus a connector / ghost wire during an interaction, in the
post-paint pass over the content. Replace it with
MagnetismConfig::feedback(|canvas, ctx, &MagnetFeedback| …) for custom
chrome; MarkerVisibility (Always / DuringInteraction / Never)
controls when markers show.
Persistent vs transient — the consumer chooses
Scene stores no connection state. It fires the event; the consumer
decides. A node-graph keeps connections as persistent edges (an added
PathItem wire, as in the scene-magnetism demo); a structural editor
consumes the event once as a reparent and shows containment by nesting.
Lightweight vs heavyweight
The built-in mouse integration rides the SceneView's lightweight drag /
pointer path (the RectItem::draggable(true) substrate), because that is
the only tier the SceneView drags. The keyboard connect flow works for
magnets on any item (it never touches pointer routing). For heavyweight
items (which the SceneView does not drag), originate the drag inside the
item's own widget and call the reusable snap helpers directly:
SceneModel::compute_item_snap(dragged, drag_delta, capture_radius, &predicate)
and compute_port_snap(source, cursor, capture_radius, &predicate) — the
same mechanism, reachable from any drag origin.
Demo: cargo run -p scene-magnetism. Accessibility shaping for magnets
(synthetic nodes + active_descendant) is covered in
docs/teksilo-scene-a11y.md.
Background / foreground hooks
Closures injected at the SceneView level for app-supplied chrome. Both run with the view-transform scope pushed (paint in scene coords) and receive the visible scene region so geometry off-screen is trivially cullable:
#![allow(unused)] fn main() { SceneView::new(scene) .background(|canvas, _ctx, region| { // Zoom-aware 50-unit grid, only the visible cells. let step = 50.0; let mut x = (region.x / step).floor() * step; while x < region.x + region.width { canvas.draw_line(/* … */); x += step; } }) .foreground(|canvas, _ctx, region| { // Snap-line indicators, ruler chrome, drop-zone hints. }) }
Paint order, bottom to top: background → Under items → heavyweight
children → Over items → marquee → foreground → debug overlay. The
background hook runs in the SceneView's paint (a backdrop, before the
heavyweight children); the foreground hook runs in its post_paint (after the
children), so it paints over the cards. See Z-order and paint bands
for the Under/Over band and the three-pass model.
Cache modes
Items override cache_mode() to opt into per-item paint caching:
#![allow(unused)] fn main() { impl SceneItem for HeavyDecoration { fn cache_mode(&self) -> CacheMode { CacheMode::ItemCoordinate } // ... } }
ItemCoordinate records the item's first paint into a sub-canvas as
a RenderFrame in local coordinates, replays that frame on
subsequent paints. The cache is invalidated automatically on
LocalBoundsChanged / Removed via an observer wired in
SceneView::build(). Items that mutate visual state without going
through a Scene mutator call view.invalidate_item_cache(id)
manually.
Don't use ItemCoordinate for items whose paint reads external
signal state (e.g. a TextItem::with_signal_text — its visual
depends on signal updates that don't dirty the cache). Default is
CacheMode::None.
Dynamic bounds (signal-driven)
Most items snapshot local_bounds at insert time. For items whose
bounds depend on a Signal<Rect> read at paint time:
#![allow(unused)] fn main() { scene.add_item_dynamic(MyDynItem { ... }, Point::ZERO); // SceneView::build() calls scene.refresh_dynamic_bounds() each // rebuild — the spatial index re-buckets on change. }
Selection
#![allow(unused)] fn main() { SceneView::new(scene).selection_mode(SceneSelectionMode::Multi) // Single | Multi | None }
Click-to-select (with Ctrl/Shift modifiers for extend / toggle),
marquee box-select. The selection state is a Signal<HashSet<ItemId>>
exposed via SceneSelection.
Z-order and paint bands
A SceneView paints in three passes — the per-widget paint → children → post_paint model applied at the scene level:
| Pass | What paints | Tier |
|---|---|---|
paint (backdrop) | lightweight Under items, z-sorted | lightweight |
| arena child-walk | heavyweight widgets, z-sorted | heavyweight |
post_paint (foreground) | lightweight Over items, then the selection marquee / app foreground hook / debug overlays | lightweight |
So the stacking order, bottom to top, is Under items → heavyweight cards → Over items.
Within a tier
#![allow(unused)] fn main() { scene.set_z(id, 5.0); // higher z paints later (on top) scene.z(id) -> Option<f32>; scene.bring_to_front(id); // z = current max + 1 scene.send_to_back(id); // z = current min − 1 }
set_z works for both tiers. Lightweight items re-sort within their band on
the next paint. Heavyweight widget entries restack the arena children on the next
rebuild — the SceneView reorders node.children by z without recreating the
widgets, so a dragged card keeps its focus, text-edit cursor and in-flight
animations across the restack. Equal-z falls back to insertion order (stable).
bring_to_front is the drag-to-front primitive: call it on drag-start (via
[SceneView::scene_mut]) so the grabbed card — and its text — render over the
others.
Across the tiers — the Over band
#![allow(unused)] fn main() { scene.set_layer(id, SceneLayer::Over); // raise a lightweight item above the cards scene.layer(id) -> Option<SceneLayer>; // Under (default) | Over }
Lightweight items default to Under (background furniture: connector lines,
grids, decorations). Over raises an item into the foreground pass so it paints
above the heavyweight widgets — selection halos, highlighted connectors,
annotations. Within each band z still orders items among themselves.
This is a binary band, not a continuous z across the tiers, because the render walker offers exactly two lightweight paint positions (before and after the child subtree). The heavyweight tier is one contiguous block in between. To place a lightweight item between two specific cards, promote it to a heavyweight widget and give it a z between theirs.
Nested P-C-AP — a node is one widget
The paint → children → post_paint model is per-widget and nests. A scene's
bands are for furniture (connectors, the nodes-as-units, the lasso); each
node is itself a P-C-AP scope — its paint draws the container, its children
are the text. Keep a node whole: build it as one heavyweight widget; never
split its container into the lightweight tier and its text into the heavyweight
tier. The render walker paints each heavyweight child's entire subtree
atomically, so a node ordered last paints its container and its text on top of
the node beneath — drag-to-front "with text included" is structural, not
something you arrange. Splitting a node across tiers tears it: every container
would sit in one band and every text in the band above, so a raised card's
neighbour would have its text leak on top of it.
Hit-testing irregular nodes
Z-order is paint-only; it does not change hit-test priority between tiers
(heavyweight widgets win heavyweight-vs-lightweight collisions). For a node whose
visible shape isn't its bounding box — an ellipse, a cloud — override
Widget::hit_shape so a click lands on the silhouette you see, not the
rectangle. Returning false for an in-bounds point makes the click fall through
to whatever node is painted underneath; this mirrors the lightweight tier's
SceneItem::shape_contains.
Removal
#![allow(unused)] fn main() { scene.remove(id); // recursive — id + all descendants scene.orphan(id); // promote children to root, leave them alive }
Recursive remove is the Qt removeItem convention: deleting a parent
deletes its children. Apps wanting to drop the parent without losing
the children call orphan(id) first (which detaches children and
re-buckets them in the spatial index), then remove(id).
remove also cleans the logical-AT maps for the removed item(s) — parents,
relations, live, landmarks, categories — and re-roots any still-alive node
that was AT-parented under a removed item, so the separate AccessKit tree
never carries a dangling reference. See Runtime mutation below.
Shared model & multi-view
A Scene lives behind a cloneable [SceneModel] handle — Rc<RefCell<Scene>>,
the same share-by-handle pattern as teksilo-data's ListModel. Clone the
handle into several SceneView::with_model(model.clone()) panes to render one
scene many ways: an overview + a detail pane, the same document in two
windows, or a headless model a tool mutates with no view at all. Mutate the
model once and every attached view reconciles.
#![allow(unused)] fn main() { let model = SceneModel::new(); let id = model.add_widget_item(CardData { /* … */ }, rect); // a typed payload let editor = SceneView::with_model(model.clone()) .delegate_typed::<CardData>(|card, id| build_card(card, id)); let overview = SceneView::with_model(model.clone()) // same model, own camera .delegate_typed::<CardData>(|card, id| build_card(card, id)); // Later, from any handler holding a clone — no `with_widget_mut`: model.set_payload(id, CardData { /* … */ }); // both panes rebuild that card }
Heavyweight content: payload + per-view delegate
A heavyweight Widget instance lives in exactly one arena, so a shared model
can't hand the same Box<dyn Widget> to two views. Two ways to add one:
- Single-view —
model.add_widget(widget, rect)(orScene::add_widget) stores the instance in a one-shot slot, drained by the first view that builds. A second view sharing the model produces no child for it. Use it when the scene has exactly one view. - Multi-view —
model.add_widget_item(payload, rect)stores a type-erasedpayload(any'statictype). Each view supplies a delegate —.delegate_typed::<P>(|&P, ItemId| -> Box<dyn Widget>)(downcasts; debug-asserts on a type mismatch) or the untyped.delegate(|&dyn Any, ItemId| -> Box<dyn Widget>)— and builds its own instance per item.model.set_payload(id, new)replaces the data and re-invokes the delegate for that item in every view (so a card with transient widget state — caret, focus — should bind aSignalfor those fields rather than rely on the rebuild).
Lightweight SceneItems (add_item) are shared automatically — painted
read-only from each view's paint walk, so no per-view instance is needed.
Selection across panes
Selection is per-view by default. To sync panes, build a SceneSelection
and pass a clone to each view via .selection_model(sel.clone()); capture the
same sel.selection_signal() in your delegate so each card derives its
highlight reactively — selecting in one pane repaints the border in every pane,
with no rebuild. (SceneSelection is itself a cheap-clone shared handle.)
Single-view ergonomics
SceneView::new(scene) still takes a Scene by value (it wraps a fresh
SceneModel internally); view.scene() / view.scene_mut() return borrow
guards for ad-hoc single-view access; view.model() hands out the shared handle.
SceneListAdapter — sync items from a ListModel
SceneListAdapter<T>
keeps a run of lightweight SceneItems in lock-step with a
teksilo_data::ListModel<T> (or any ListDataSource<Item = T>), so a
data-driven collection of dots / markers / cards doesn't need hand-rolled
reconciliation against DataChange. It is a plain non-Widget struct —
construct it once, hold onto it, and it does the rest via an internal
ObserverHandle.
#![allow(unused)] fn main() { use teksilo_scene::SceneListAdapter; let model = SceneModel::new(); let adapter = SceneListAdapter::from_model(&list_model, model.clone(), |row, _index| { Box::new(RectItem::new(Rect::new(0.0, 0.0, 12.0, 12.0)).fill(row.color)) as Box<dyn SceneItem> }); let id = adapter.item_id_at(0); // scene ItemId for row 0, if materialised }
The delegate is Fn(&T, usize) -> Box<dyn SceneItem>; the returned item
positions itself via its own local_bounds — the adapter inserts it at the
scene origin through
Scene::add_boxed_item (the
boxed-dyn counterpart of add_item, needed because a trait-object item
can't go through the generic add_item<I: SceneItem>). On construction the
adapter materialises every current row; afterwards it reconciles from the
model's DataChange stream: a structural change (insert / remove / move /
reset) rebuilds every adapter-owned item (simple and always correct), an
ItemUpdated rebuilds just that one row's item in place, and a lazy-loading
source's WindowLoaded rebuilds only the newly-loaded range. item_id_at,
ids, len, is_empty read the current index → ItemId mapping; clear
removes every adapter-owned item from the scene. Dropping the adapter stops
observing the model but does not remove its items — call clear() first
if you want them gone.
Use SceneListAdapter::from_source instead of from_model to drive the
same reconciliation off a custom ListDataSource<Item = T> (the escape
hatch for huge / external sources) rather than an in-memory ListModel<T>.
Runtime mutation (after mount)
The cleanest way to mutate a mounted scene is through the shared [SceneModel]
handle: every mutator is &self, so a handler holding view.model() (a cheap
clone) drives the scene directly and all views reconcile — no
with_widget_mut needed for content:
#![allow(unused)] fn main() { let model = view.model(); // a clone captured in the handler let act = model.add_a11y_group(A11yGroup::builder().label(lit!("Act IV"))); model.set_a11y_live(A11yNode::Group(act), Live::Polite); let card = model.add_widget_item(CardData { /* … */ }, rect); model.set_a11y_parent(A11yNode::Item(card), Some(A11yNode::Group(act))); }
with_widget_mut remains the channel for per-view state a handler can't
otherwise reach — e.g. animating one pane's camera:
#![allow(unused)] fn main() { ctx.with_widget_mut::<SceneView>(view_id, BindingLevel::Relayout, |view| { view.ensure_visible(rect, 40.0); }); }
Each view self-reconciles on every scene mutation — visual and accessibility:
- Add (
add_widget_item/add_widget/add_item) materialises into the arena on the next rebuild; the spatial index already holds it from insertion. - Payload change (
set_payload) re-invokes the delegate for that item in every view, rebuilding its widget with the new data. - Remove (
remove) destroys the orphaned arena widget (no leak), drops it from the materialised maps, and cleans the logical-AT maps. - Move / transform / reparent / visibility / opacity / z / layer — every
ItemChangevariant drives a reconcile pass, so paint and the screen-projected AccessKit bounds follow. - Pure-a11y mutations (
add_a11y_group,set_a11y_parent, relations, live, landmark, categories) don't change item geometry, so they ride a separateScene::a11y_change_signal— the AccessKit tree still re-walks.
A relayout no longer re-walks the AccessKit tree on its own (it's gated on
a11y_dirty), so SceneView::build() calls ctx.request_accessibility_update()
when it reconciles — the lever that keeps assistive tech in lock-step with the
visual scene. The call is gated on a mutation-version delta: build()
re-walks AT only when [Scene::mutation_version] advanced since the last walk
(any add / remove / move / reparent / visibility / a11y change). A build()
driven purely by a per-frame add_item_dynamic animation does not re-walk
AT every frame — re-walking 60×/s for sub-pixel bounds drift is waste a screen
reader can't use — but when that animation settles, the final bounds are
walked into AT exactly once. Discrete mutations always re-walk, even interleaved
with an animation. Demo: cargo run -p scene-corkboard ("Add Act").
App-owned view state
Pan / zoom / rotation default to view-owned signals. Inject app-owned ones with
view_state(pan_x, pan_y, zoom, rotation) so view state survives a
rebuild-from-state, a "Reset View" button can snap it home, and a toolbar can
read it. initial_pan / initial_zoom / initial_rotation seed starting
values without giving up ownership. These builders run pre-mount, like the
others.
i18n
User-visible strings on SceneItem builders (label, tooltip,
access_label, access_description, A11yGroupBuilder::label,
SceneView::a11y_label, TextItem::new) accept impl Into<LocalizedString>.
Pass the result of tr!(...) directly:
#![allow(unused)] fn main() { RectItem::new(rect).access_label(tr!(save_card())) }
Each translated method has an _literal #[doc(hidden)] twin (e.g.
access_label_literal, tooltip_literal, TextItem::new_literal)
that takes impl Into<String>. Use the twin for engine-internal
debug copy or scaffolding where translation is overkill — they're a
grep marker for "intentionally untranslated."
Worked example: corkboard
#![allow(unused)] fn main() { let mut scene = Scene::new(); scene.set_scene_rect(Some(Rect::new(0.0, 0.0, 4000.0, 3000.0))); // Background grid as decoration — lightweight closure, no items. let view = SceneView::new(scene) .selection_mode(SceneSelectionMode::Multi) .background(|canvas, _ctx, region| draw_grid(canvas, region, 50.0)); // Add cards as heavyweight widgets. let card1 = view.scene_mut().add_widget(card("Idea 1"), Rect::new(0.0, 0.0, 200.0, 120.0)); let card2 = view.scene_mut().add_widget(card("Idea 2"), Rect::new(300.0, 200.0, 200.0, 120.0)); // Connector line as a lightweight item beneath the cards. let path = Path::new() .move_to(Point::new(200.0, 60.0)) .line_to(Point::new(300.0, 260.0)); view.scene_mut().add_item( PathItem::new(path, Rect::new(200.0, 60.0, 100.0, 200.0)) .stroke(Color::BLACK, 2.0), Point::ZERO, ); }
These scene_mut() calls run pre-mount — the app still owns view. To
mutate the same scene from a handler after the view is added to the tree, go
through ctx.with_widget_mut::<SceneView>(view_id, …) (see Runtime mutation
above); the live scene-corkboard example does exactly that for its "Add Act"
button.
Worked example: simple node-graph editor
#![allow(unused)] fn main() { // Each node is a draggable RectItem with a child TextItem label. let mut scene = Scene::new(); let node = scene.add_item( RectItem::new(Rect::new(0.0, 0.0, 120.0, 60.0)) .fill(Color::WHITE).stroke(Color::BLACK, 1.0) .draggable(true), Point::new(100.0, 100.0), ); let label = scene.add_item( TextItem::new(tr!(node_name()), Rect::new(8.0, 8.0, 100.0, 24.0)), Point::ZERO, ); scene.set_item_parent(label, Some(node)); // React to drag-end with snap-to-grid. let _h = scene.item_change_signal().observe(|c| { if let ItemChange::LocalPosChanged { id, new, .. } = c { snap_to_grid(*id, *new, 20.0); } }); let view = SceneView::new(scene); }
Reference
- Implementation:
crates/teksilo-scene/src/ - Accessibility-shaping API:
docs/teksilo-scene-a11y.md - Showcase demo:
cargo run -p scene-showcase - Corkboard demo:
cargo run -p scene-corkboard
teksilo-scene accessibility
The user-facing reference for shaping a scene's accessibility tree
without touching the framework AT walker. Pairs with the visual
reference at teksilo-scene.md.
SceneView ships an accessible tree out of the box: every visible
heavyweight widget participates as a normal child, every visible
lightweight item gets a synthetic AT node with role +
screen-projected bounds, Tab cycles in scene-insertion order. This
document covers the levers that override that default when the AT
shape needs to diverge from the visual layout — the typical case
for story corkboards, node-graph editors, CAD canvases, anything
where "what the eye sees" and "what the ears need" aren't the same
tree.
Two layers
The AT machinery has two cooperating layers:
- Visual default.
A11yOffScreenModedecides which off-viewport entries the walker still emits. PickCooperative(default) when the visual layout is a sensible reading order; pickStrictlyParallelwhen AT shape diverges meaningfully from visual layout. - Logical structural API.
A11yGroup,A11yNode, parents, relations, auto-graft, and a focus-order callback let apps declare an AT tree that has no visual counterpart. Cards live in Acts, nodes live in Subgraphs, components live in Layers.
Off-screen mode
#![allow(unused)] fn main() { SceneView::new(scene) .a11y_off_screen_mode(A11yOffScreenMode::ViewportOnly) // ViewportPlusN { n } (default, n=1) | AllItems | ViewportOnly }
Decides which items the AT walker emits when the user pans / zooms.
ViewportPlusN { n: 1 } (the default) emits items in the viewport
plus one viewport-width margin — giving screen-reader users a
one-screen "lookahead" for navigation. AllItems always emits
everything (good for small scenes, < ~500 items). ViewportOnly
strictly limits emission to the current viewport (large scenes where
off-screen enumeration would overwhelm AT clients).
A11y mode
#![allow(unused)] fn main() { SceneView::new(scene).a11y_mode(A11yMode::Cooperative) // Cooperative | StrictlyParallel }
Cooperative (default) — items / widgets without a declared logical
parent appear as direct children of the SceneView in the AT tree.
Pick this for charts, dashboards, simple maps where visual layout
is the reading order.
StrictlyParallel — only entries placed in the logical tree
(set_a11y_parent, add_a11y_group) are emitted. Items without an
explicit declaration are suppressed. Pick this for corkboards /
graph editors where AT shape should ignore visual layout entirely.
Logical groups
A virtual AT container with no visual counterpart. Pure structure: no hit-test, no paint.
#![allow(unused)] fn main() { let act_one = scene.add_a11y_group( A11yGroup::builder() .label(tr!(act_one())) .role(accesskit::Role::Region), ); scene.set_a11y_parent(A11yNode::Item(scene_card), Some(A11yNode::Group(act_one))); }
Groups can themselves nest under other groups via
set_a11y_parent(A11yNode::Group(child_group), Some(A11yNode::Group(parent_group))).
Build arbitrary AT-only trees that have no relationship to the
visual layout.
Reparenting
#![allow(unused)] fn main() { scene.set_a11y_parent(A11yNode::Item(child), Some(A11yNode::Item(parent))); scene.set_a11y_parent(A11yNode::Item(card), None); // back to root scene.a11y_parent_of(A11yNode::Item(card)) -> Option<A11yNode> }
A11yNode addresses any node in the parallel tree:
A11yNode::* | Targets |
|---|---|
Item(ItemId) | Any scene entry — lightweight item or heavyweight widget added via Scene::add_widget |
Group(A11yGroupId) | A logical group declared via add_a11y_group |
Widget(WidgetId) | A real interactive widget addressed by its arena id — typically a descendant of a heavyweight scene item that should logically belong elsewhere |
For widgets you added via Scene::add_widget, prefer
A11yNode::Item(item_id) — the walker handles the heavyweight
auto-graft for you (the real widget's NodeId lands under the
declared parent without you doing anything special).
Relations
Cross-tree relationships independent of parenting.
#![allow(unused)] fn main() { scene.add_a11y_relation(A11yNode::Item(button), A11yRelation::Controls, A11yNode::Item(menu)); scene.add_a11y_relation(A11yNode::Item(field), A11yRelation::DescribedBy, A11yNode::Item(error_msg)); scene.add_a11y_relation(A11yNode::Item(node_a), A11yRelation::FlowTo, A11yNode::Item(node_b)); }
A11yRelation variants:
Controls—fromcontrolsto(button opening a menu).DescribedBy—fromis described byto(cross-item annotation).LabelledBy—fromis labelled byto(cross-item label).FlowTo— logical reading flow fromfromtoto. Many node-graph editors use this so VoiceOver / NVDA "next item" follows data-flow order rather than scene-insertion order.
Live regions
Mark a scene entry as a polite or assertive live region:
#![allow(unused)] fn main() { scene.set_a11y_live(A11yNode::Item(toast), accesskit::Live::Polite); }
Updates to the entry's AT name / value are announced.
Landmark roles
Promote a group to a landmark for screen-reader navigation:
#![allow(unused)] fn main() { scene.set_a11y_landmark(A11yNode::Group(toolbar_group), accesskit::Role::Toolbar); }
Categories (rotor / quick-nav)
App-defined tags surfaced to AT clients that support categorized
navigation (VoiceOver rotor on macOS, NVDA quick-nav on Windows).
Apps coin their own category names — "node", "connector",
"comment" — and bucket items into them:
#![allow(unused)] fn main() { scene.set_a11y_categories(A11yNode::Item(node), &[A11yCategory::new("node")]); scene.set_a11y_categories(A11yNode::Item(edge), &[A11yCategory::new("connector")]); }
Subtree mode for items
Each SceneItem builder carries an
AccessSubtreeMode:
| Mode | Effect |
|---|---|
Inherit (default) | Descendants emit AT nodes normally. |
Exclude | Descendants are pruned from the AT tree. |
Merge | Descendants' label / value / actions concatenate into this item's AT node and they're pruned individually. The subtree reads as one element. |
#![allow(unused)] fn main() { RectItem::new(rect) .label(tr!(card_idea_1())) .access_merge_subtree(); // card with rect + label + indicator dot reads as one }
Merge is the right pattern for a card whose visual subparts
(background rect, label, status dot) are conceptually one element
for the AT user. Exclude is useful for animated decorations whose
emission would be noisy (a pulsing recording dot, a spinner).
Override chain (access_* builders)
Every built-in item's builder, every custom item that invokes the
item_a11y_builders!() macro, and A11yGroupBuilder / SceneView
expose a parallel .access_* chain that mirrors WidgetBuilder on
the widget tier:
#![allow(unused)] fn main() { RectItem::new(rect) .access_label(tr!(save())) .access_description(tr!(save_explanation())) .access_role(accesskit::Role::Button) .access_subtree(AccessSubtreeMode::Merge); }
access_label_literal, access_description_literal (and friends)
are #[doc(hidden)] twins for explicitly-untranslated strings.
Data-bearing items — gauges, value marks, progress dots — additionally carry
access_value / access_numeric_value / access_numeric_range /
access_numeric_step, mirroring the widget-tier numeric-range overrides:
#![allow(unused)] fn main() { RectItem::new(gauge_rect) .access_label(tr!(cpu_load())) .access_value(lit!("42 %")) .access_numeric_value(0.42) .access_numeric_range(0.0, 1.0) .access_numeric_step(0.01); }
access_value announces a formatted string reading (e.g. "42 %");
access_numeric_value / access_numeric_range / access_numeric_step
populate AccessKit's numeric-value fields so a screen reader can describe
magnitude and bounds, not just a label.
Custom focus order
Apps that need a focus traversal that diverges from scene-insertion order install a callback. Common cases:
- Story corkboards — Tab follows Acts → Scene cards in story order.
- Node-graph editors — Tab follows data-flow order via
FlowTorelations. - CAD canvases — Tab follows depth-then-breadth tree order.
- Timelines — Tab follows chronological order.
#![allow(unused)] fn main() { SceneView::new(scene).focus_order(|scene, dir, current| { // dir: FocusDirection::{Forward, Backward} // current: Option<ItemId> — None on initial Tab match dir { FocusDirection::Forward => next_in_my_order(scene, current), FocusDirection::Backward => prev_in_my_order(scene, current), } }); }
The callback is Fn(&Scene, FocusDirection, Option<ItemId>) -> Option<ItemId>.
Returning None ends the cycle (the focus exits the SceneView and
moves to the next focusable in the parent).
When the focused item is off-viewport, the SceneView calls
ensure_visible
automatically so the focus indicator stays on screen.
SceneView own AT name + nesting
#![allow(unused)] fn main() { SceneView::new(scene) .a11y_label(tr!(graph_data_area())) .nested_a11y(true) // emit Role::Region instead of Role::Pane .a11y_bounds_space(A11yBoundsSpace::Scene) // Screen (default, view-projected) | Scene (independent of pan / zoom) }
Pane is the right role for a top-level scene; Region is for an
inner scene inside another (a chart's data area inside a chart's
chrome). Switch via nested_a11y(true).
a11y_bounds_space controls the coordinate frame reported to AT for
items: Screen (view-projected, the framework default) is right for
most cases; Scene is right when AT users should be able to reason
about "where in the design" an item sits, independent of the current
pan / zoom (CAD canvases, blueprint editors).
Runtime mutation — the AT tree follows
The logical AT tree is separate from the visual scene, so it needs its own
notification path when the scene changes after mount. Two channels feed the
SceneView's reconcile pass:
Scene::item_change_signal— every item mutation (add / remove / move / transform / visibility / opacity / z / layer / reparent). The new card materialises, a removed one is destroyed and its AT maps cleaned, a moved one gets fresh screen-projected AT bounds.Scene::a11y_change_signal— pure logical-AT mutations that change no item geometry (add_a11y_group,set_a11y_parent,add_a11y_relation,set_a11y_live,set_a11y_landmark,set_a11y_categories). Without this a runtime group add or reparent would be invisible to assistive tech.
A relayout no longer re-walks the AccessKit tree by itself (the walk is cached,
gated on a11y_dirty). SceneView::build() calls
ctx.request_accessibility_update() when it reconciles, which flips that flag —
so any runtime change to the visual or logical tree reaches a screen reader on
the next frame. The request is gated on a Scene::mutation_version delta:
both channels above advance that counter, so a discrete add / remove / move /
reparent / group / relation / live / landmark change always re-walks AT. What it
won't do is re-walk AT 60×/s while an add_item_dynamic item animates its
bounds — that per-frame churn is suppressed (a screen reader can't use sub-pixel
bounds updates), and the final bounds are walked in once when the animation
settles. Scene::remove additionally re-roots any still-alive node that was
AT-parented under a removed item (its explicit parent mapping is dropped, exactly
like remove_a11y_group). Mark a runtime-added group Live::Polite to have the
addition announced. Demo: the "Add Act" button in cargo run -p scene-corkboard.
Multi-view. When several SceneViews share one SceneModel (see
teksilo-scene.md → Shared model & multi-view), each pane
installs its own observers on these two channels and walks its own
AccessKit subtree — the gate (mutation_version delta) is per-view, and each
pane's synthetic AT nodes carry bounds projected through that pane's view
transform. A mutation on the shared model therefore reaches assistive tech for
every pane independently. A heavyweight item added via add_widget_item is a
type-erased payload, so each pane's delegate builds its own widget — and the
item's accessibility() runs once per pane, under that pane's projected bounds.
Worked example: story corkboard
Acts contain Scene cards. Acts are virtual groups; Scene cards are heavyweight widgets. AT shape ignores visual layout entirely.
#![allow(unused)] fn main() { let mut scene = Scene::new(); let act1 = scene.add_a11y_group(A11yGroup::builder().label(tr!(act_1()))); let act2 = scene.add_a11y_group(A11yGroup::builder().label(tr!(act_2()))); let scene_card_1 = scene.add_widget(card_widget("Opening"), Rect::new(0.0, 0.0, 200.0, 120.0)); let scene_card_2 = scene.add_widget(card_widget("Climax"), Rect::new(220.0, 0.0, 200.0, 120.0)); scene.set_a11y_parent(A11yNode::Item(scene_card_1), Some(A11yNode::Group(act1))); scene.set_a11y_parent(A11yNode::Item(scene_card_2), Some(A11yNode::Group(act2))); let view = SceneView::new(scene) .a11y_mode(A11yMode::StrictlyParallel) // ignore visual layout entirely .focus_order(|scene, dir, current| story_order_traversal(scene, dir, current)); }
Screen-reader output: "Act 1, Region. Opening, Card. Act 2, Region. Climax, Card." Tab cycles in story order regardless of where the cards sit visually.
Magnetism
When a view has magnetism enabled (SceneView::magnetism(...)), each
enabled magnet on a lightweight item is emitted as a synthetic
SyntheticKind::SceneMagnet AT node, a child of the owning item's node,
with Role::Button and the magnet's label as its name (falling back to
a generic name when unset). This makes anchors screen-reader perceivable
and gives the keyboard connect flow a focus target. Adding, removing, or
enabling a magnet bumps the scene's a11y_change_signal, so the AT tree
re-walks with no extra wiring.
The keyboard connect flow uses the roving-active_descendant pattern:
the SceneView keeps real arena focus, and while in connect mode it points
its active_descendant at the focused magnet's synthetic node, so a
screen reader announces the focused anchor as the user arrows through
them. (The grid-cell roving pattern, applied to scene anchors.)
Connections themselves are consumer-owned in AT, exactly as in the
scene model: from your on_connect, declare the connection's meaning on
the relation layer, e.g.
#![allow(unused)] fn main() { scene.add_a11y_relation( A11yNode::Item(source_node), A11yRelation::FlowTo, // or Controls A11yNode::Item(target_node), ); }
Scene provides the relations API; it does not invent connection meaning.
Magnet AT nodes for heavyweight-item magnets are a follow-up; the
keyboard state machine and on_connect still work for them, only the
active_descendant announcement is limited to lightweight-item magnets.
Demo: cargo run -p scene-magnetism (a fully keyboard- and
screen-reader-operable node graph).
Worked example: graph editor
Nodes contain Ports; connector lines declare data flow via FlowTo.
#![allow(unused)] fn main() { let mut scene = Scene::new(); let node_a = scene.add_widget(node_widget("A"), Rect::new(0.0, 0.0, 120.0, 80.0)); let node_b = scene.add_widget(node_widget("B"), Rect::new(300.0, 0.0, 120.0, 80.0)); // Connector lines are lightweight PathItems. let edge = scene.add_item( PathItem::new(connector_path(), edge_aabb()).stroke(Color::BLACK, 2.0), Point::ZERO, ); scene.set_a11y_categories(A11yNode::Item(edge), &[A11yCategory::new("connector")]); scene.set_a11y_categories(A11yNode::Item(node_a), &[A11yCategory::new("node")]); scene.set_a11y_categories(A11yNode::Item(node_b), &[A11yCategory::new("node")]); // Logical flow: data flows A → B. scene.add_a11y_relation( A11yNode::Item(node_a), A11yRelation::FlowTo, A11yNode::Item(node_b), ); let view = SceneView::new(scene) .focus_order(|scene, dir, current| flow_order_traversal(scene, dir, current)); }
VoiceOver rotor offers "Nodes" and "Connectors" categories; "next item" via the rotor follows the user's chosen category.
Worked example: CAD canvas
Components belong to Layers. Layers are virtual groups. AT bounds are reported in scene coordinates so AT users can reason about "the gear is at (150, 200) in the design" regardless of pan / zoom.
#![allow(unused)] fn main() { let mut scene = Scene::new(); let layer_drive = scene.add_a11y_group(A11yGroup::builder().label(tr!(drive_layer()))); let layer_frame = scene.add_a11y_group(A11yGroup::builder().label(tr!(frame_layer()))); let gear = scene.add_widget(gear_widget(), Rect::new(150.0, 200.0, 60.0, 60.0)); scene.set_a11y_parent(A11yNode::Item(gear), Some(A11yNode::Group(layer_drive))); let beam = scene.add_widget(beam_widget(), Rect::new(0.0, 280.0, 400.0, 20.0)); scene.set_a11y_parent(A11yNode::Item(beam), Some(A11yNode::Group(layer_frame))); let view = SceneView::new(scene) .a11y_mode(A11yMode::StrictlyParallel) .a11y_bounds_space(A11yBoundsSpace::Scene) .nested_a11y(true) .a11y_label(tr!(design_canvas())); }
Reference
- Implementation:
crates/teksilo-scene/src/a11y.rs,crates/teksilo-scene/src/scene.rs(theScene::add_a11y_*/set_a11y_*API), and the AT walker incrates/teksilo-scene/src/view.rs. - Widget-tier override surface:
docs/accessibility-overrides.md. - Agent/CI automation over this AT surface:
docs/automation-mcp.md. - AccessKit reference: https://accesskit.dev.
Icons and Resources
Overview
Teksilo supports three icon formats — SVG, PNG, and WebP — embedded at compile time via the res!() macro. Icons are tintable by default: their color follows the theme and interaction state (hover, pressed, disabled) automatically.
Supported Formats
| Format | Use case | Tintable | Notes |
|---|---|---|---|
| SVG | Vector icons (preferred) | Yes | Scales to any size without loss |
| PNG | Raster icons | Yes | Fixed resolution, best at native size |
| WebP | Raster icons (smaller files) | Yes | Use lossless encoding for icons |
| Other | Arbitrary files | N/A | Embedded as raw &'static [u8] |
Usage
Embedding resources with res!()
Place resource files under resources/ in your crate root:
my-app/
Cargo.toml
src/
main.rs
resources/
icons/
save.svg
star.png
clock.webp
Embed and use them:
#![allow(unused)] fn main() { // SVG — returns &'static SvgIcon, compile-time validated let save = teksilo::res!("resources/icons/save.svg"); // PNG — returns &'static RasterIcon, compile-time validated let star = teksilo::res!("resources/icons/star.png"); // WebP — returns &'static RasterIcon (static) or &'static AnimatedIcon (animated) let clock = teksilo::res!("resources/icons/clock.webp"); // Unknown extensions — returns &'static [u8], existence checked only let font = teksilo::res!("resources/fonts/custom.ttf"); }
The macro validates known formats at compile time (XML structure for SVG, magic bytes for PNG/WebP). Unknown extensions are embedded as raw bytes without validation — only file existence is checked.
Using icons in buttons
#![allow(unused)] fn main() { let save = teksilo::res!("resources/icons/save.svg"); // Leading icon — most common Button::new(lit!("Save")) .icon(IconWidget::from_svg_icon(save), IconLocation::Leading) .style(ButtonVariant::Plain) // Icon only — toolbars Button::new(lit!("Save")) .icon(IconWidget::from_svg_icon(save), IconLocation::IconOnly) .style(ButtonVariant::Ghost) // Raster icon let star = teksilo::res!("resources/icons/star.png"); Button::new(lit!("Favorite")) .icon(IconWidget::from_raster(star, 24.0), IconLocation::Leading) }
The button controls the icon's display size via the BUTTON_ICON_SIZE constant (default 16dp) in teksilo-widgets. The icon's color is bound to the button's text color signal — it follows hover, pressed, disabled, and theme changes automatically.
Icon locations
IconLocation | Layout |
|---|---|
None | No icon (default) |
Leading | Icon left of label |
Trailing | Icon right of label |
IconOnly | Icon only, no label |
Top | Icon above label |
Bottom | Icon below label |
Standalone icons (outside buttons)
#![allow(unused)] fn main() { // SVG — size defaults to viewBox, override with icon_size() IconWidget::from_svg_icon(icon).icon_size(32.0).color(Color::RED) // Programmatic — built-in shapes IconWidget::checkmark(24.0) IconWidget::chevron_down(16.0) IconWidget::chevron_right(16.0) // From raw SVG string (no res! macro, parses at runtime) IconWidget::from_svg(include_str!("../resources/icons/save.svg")) }
Tintable vs full-color mode
Icons default to tintable mode: the image is treated as an alpha mask and tinted with the widget's color property. This enables theme-aware coloring.
For icons that should keep their original colors (e.g., app logos, colored emoji):
#![allow(unused)] fn main() { IconWidget::from_raster(logo, 32.0).mode(IconMode::FullColor) }
In full-color mode, the icon's RGB is rendered directly; the widget color only controls opacity.
Creating Icon Assets
SVG icons
Use any SVG editor. Icons should be single-color paths on a transparent background. Fill and stroke colors in the SVG are ignored — the rendering color comes from the theme.
Standard viewBox: 0 0 24 24 (Material Design convention).
PNG icons
Export as white shape on transparent background (RGBA). The luminance of the image becomes the alpha mask for tinting.
- Use 24x24 or 48x48 pixels for standard icons
- Export as RGBA PNG (not indexed/palette)
WebP icons
Use lossless encoding. Lossy WebP with separate alpha planes (VP8X + ALPH chunks) may not decode correctly. Lossless WebP (VP8L) stores RGBA natively and works reliably.
With ImageMagick:
convert -size 24x24 xc:none -fill none -stroke white -strokewidth 2 \
-draw "circle 12,12 12,3" \
-define webp:lossless=true \
icon.webp
With cwebp:
cwebp -lossless input.png -o icon.webp
WebP is ~40-60% smaller than PNG for the same quality, making it a good choice for apps with many icons.
Animated WebP
Animated WebP icons (loading spinners, status indicators) are supported. The res!() macro auto-detects animation and returns &'static AnimatedIcon. Use IconWidget::from_animated() to render.
Frame cycling is automatic and loops continuously. Each frame should use lossless encoding.
Debug Inspector Reference
teksilo-inspector is an in-app debug surface for Teksilo applications.
It compiles to nothing in release builds (everything lives behind
cfg(debug_assertions)) and adds zero overhead when not enabled.
Mental model in one line:
TeksiloAppBuilder.install_inspector_in_debug() → F12 toggles a bottom panel inside every window
The panel hosts nine tabs: live widget tree + properties + accessibility, theme + locale switchers, focus chain, registered shortcuts, active overlays, and registered data models. Plus a toolbar with a picker tool, a bounds-overlay mode selector, and an opacity slider for the overlay strokes.
End-to-end smoke example: cargo run -p simple-button then press F12.
Enabling the inspector
One line at the builder, regardless of release/debug. The
TeksiloAppBuilderInspectorExt extension trait is re-exported from the
umbrella prelude (teksilo::prelude::*) so install_inspector_in_debug()
is callable without an extra import or dependency:
#![allow(unused)] fn main() { use teksilo::prelude::*; TeksiloAppBuilder::new() .theme(intui::light()) .install_inspector_in_debug() // no-op in release .initial_window(WindowConfig::new()…) .run(); }
The inspector ships behind the umbrella's inspector feature
(default-on). To drop it (and the teksilo-inspector dependency it
pulls in), depend on teksilo with default-features = false and
re-add only the features you need.
Apps that drop the feature can still call
install_inspector_in_debug only if they take a direct dependency
on teksilo-inspector themselves.
install_inspector_in_debug is a no-op stub when
!cfg(debug_assertions), so the call site stays clean of #[cfg]
lines. In debug builds it:
- Parses
--teksilo-inspectorfromstd::env::args()andTEKSILO_INSPECTOR=1(or=true) from the environment to seed the inspector's initial visibility. - Stores a shared
InspectorState(toggle / selection / picker mode / overlay mode / opacity / shell ids) intoapp_state. - Registers a default
WindowConfig::post_roothook that wraps every window's user root withInspectorShelland registers the F12 shortcut. - If the app has wired a
SettingsStoreviaTeksiloAppBuilder::settings(...), bridges the inspector's persistent preferences to it (see Persistence below).
Toggling the inspector
| Path | What it does |
|---|---|
| F12 | Global shortcut, owned by the user-root widget per window. Toggles the panel on/off. |
--teksilo-inspector CLI arg | Open the inspector at startup. |
TEKSILO_INSPECTOR=1 env | Same as the CLI arg. |
× toolbar button | Closes the panel (F12 reopens). |
| Persisted state | If the app uses SettingsStore, the toggle remembers its last state across launches. |
The shortcut id is __teksilo_inspector.toggle. The double-underscore
prefix marks it as framework-reserved — do not bind it from app code.
It is also shown dimmed in the inspector's Shortcuts tab.
Panel keyboard shortcuts
Once the panel is open, a handful of single-letter chords speed up
common actions. They are scoped to the panel subtree — they only
fire when focus is on the panel or one of its descendants, so the
single-letter P / B / T chords don't hijack typing in the user
app's text inputs. Click anywhere in the panel (a tab header, a
button) to take focus, then:
| Key | Action |
|---|---|
| Ctrl+P | Toggle the picker tool (same as the toolbar Pick button). |
| Ctrl+B | Cycle bounds overlay: Off → Sel → All → Off. |
| Ctrl+Tab | Switch to the next tab. |
| Ctrl+Shift+Tab | Switch to the previous tab. |
| Esc | If picker mode is active, stop picking. Otherwise close the panel. |
All five share the framework-reserved __teksilo_inspector.* prefix and
appear dimmed in the Shortcuts tab.
Toolbar
[ Pick ] [ Off | Sel | All ] [ ── opacity ── ] [ × ]
- Pick — toggles the picker tool. While picking, a transparent
overlay covers the user-root area. Clicking on a widget opens a
context menu listing the deepest-hit widget plus its ancestors
(up to 10 entries, walking up to the user-root inclusive). Pick
any row to select that level — useful for composites where the
deepest hit is an inner leaf (e.g. a
TextWidgetinside aButton) but you want the wrapping widget. Click outside the menu or press Escape to dismiss; the picker auto-exits after one selection or dismissal. - Bounds overlay —
Off(no overlay),Sel(stroke around the selected widget only),All(stroke every widget; layout primitives in cyan, content widgets in magenta; cursor-following tooltip with type + size — see Bounds overlay color legend). - Opacity slider — dims the bounds-overlay strokes for dense UIs. Range 0.1 .. 1.0.
- Overflow toggle — turns the overflow overlay on/off (see below). A check mark in the label reflects the state.
- × — closes the panel.
Overflow overlay
Independent of the bounds-overlay mode and on by default in debug builds: wherever a distributing container's children spill past its bounds, the inspector paints Flutter-style yellow/black hazard stripes on the overhang plus a bright red border — so over-constrained layouts are impossible to miss. It paints even with the panel closed (any time the inspector is installed).
Detection rules (in highlight.rs,
collect_overflow):
- Only distributing containers are checked —
HStack,VStack,Grid,FormLayout— so intentional overlap (ZStack, scene content, overlays) never false-positives. - Containers that clip their children (
ScrollArea,MaxSize) are skipped — their overflow is expected and clipped away. - An overhang under
0.5 pxis ignored (rounding noise).
After over-constraint handling,
shrinkable content compresses to fit and shows no stripes; stripes mean the
layout genuinely cannot fit (only rigid children, or every shrinkable child
already at its min). Toggle from the toolbar; the choice persists via
__teksilo_inspector.overflow_overlay. Demo: cargo run -p over-constraint.
Tabs
| Tab | What it shows |
|---|---|
| Tree | Live widget hierarchy, indented by depth. Click a row to select. Top text input filters by case-insensitive substring match against each type's last segment. When the picker resolves to a widget that's currently off-screen, the row scrolls into view automatically (skipped when the user clicked the row directly — the row is already on-screen). Excludes every InspectorShell subtree (multi-window safe). |
| Properties | For the selected widget: type, bounds, dirty flags, parent, children count, activation, clips_children, event_pass_through, plus a single-line debug_repr row. Copy button dumps every row plus the full multi-line Debug repr to the clipboard via ClipboardHandle. Right-click any row to open a Copy value context menu that copies just that row's value. |
| Accessibility | Role / name / value / advertised actions / toggled / expanded / selected / hidden, from the widget's accessibility(builder) output. |
| Theme | Preset buttons (Light / Dark) — clicking calls EventContext::set_theme(...). Apply folds every per-row draft back into the active theme; Reset discards drafts and re-syncs from the active theme. Export dumps the current Theme as pretty JSON to the clipboard; Import parses the clipboard JSON back into a Theme and applies it (silently ignores parse errors). Below: a curated list of editable colors (accent, surfaces, text roles, borders, status colors). Each row carries a ColorEdit field — clicking it opens a ColorPicker popover with HSV canvas, hue / alpha strips, RGB spinners, hex input, and preset swatches. The picker writes through to the row's draft on every drag; Apply commits the batch. |
| Locale | Every locale declared in I18nConfig::supported_locales. Click a row to call EventContext::set_locale(...). The active locale is highlighted. |
| Focus | Current focused widget plus its ancestor chain (root → leaf). Leaf shown in primary color, ancestors dimmed. |
| Shortcuts | Every shortcut in the tree's ShortcutRegistry with its effective primary keystroke. Framework-reserved ids (__-prefixed) are dimmed. |
| Overlays | Active overlays from OverlayManager, with their content + anchor labels. |
| Models | Data models registered via .debug_named(...) (see Data models). For each: name, kind (ListModel, TreeModel, SelectionModel), and len. Click a row to select it — its debug_dump output is shown below. With nothing selected, the most recently registered model is dumped (dimmed row highlight). Click the same row again to clear the selection. |
Data models
To make a model show up in the Models tab, call .debug_named("…")
after construction. Available on ListModel<T>, TreeModel<T>, and
SelectionModel:
#![allow(unused)] fn main() { use teksilo_data::{ListModel, SelectionMode, SelectionModel, TreeModel}; let recents: ListModel<RecentProject> = ListModel::from_vec(load_recents()).debug_named("recents"); let outline: TreeModel<OutlineNode> = TreeModel::new().debug_named("outline"); let row_selection: SelectionModel = SelectionModel::new(SelectionMode::Multi).debug_named("rows"); }
ListModel and TreeModel require T: Debug + 'static (used by the
dump). debug_named is always available — in release builds it is a
no-op pass-through, so call sites do not need #[cfg] lines.
Internally, each model registers a Weak<dyn ModelDebug> adapter in
the thread-local teksilo_data::debug_registry:
ListModel/TreeModel— the adapter holds aWeakto the model's innerRc<RefCell<…>>, so it never extends the model's lifetime. When the last model handle drops, the registration becomes dead and is pruned on the nextsnapshot().SelectionModel— has no shared inner; the strong adapterRclives inside anRc<RefCell<Option<…>>>cloned across handles. When the lastSelectionModelclone drops, the holder reaches zero, the adapter is freed, and the registry'sWeakgoes dead.
In all three cases, the inspector never keeps a model alive past its natural lifetime.
Persistence
When TeksiloAppBuilder::settings(SettingsBundle::new()) has been wired,
the inspector bridges five signals to keys under the framework-reserved
__teksilo_inspector.* namespace:
__teksilo_inspector.open(bool) — last toggle state. Read at startup if neither--teksilo-inspectornorTEKSILO_INSPECTORwas given.__teksilo_inspector.bounds_mode("off"/"selection"/"all")__teksilo_inspector.overlay_opacity(f32)__teksilo_inspector.active_tab(i64) — index of the last-used panel tab. Stored asi64because TOML lacks unsigned integers andusizewidth varies by target. Out-of-range values seed at 0.__teksilo_inspector.panel_height(f32) — last user-set panel height. Clamped to[120, 720]on load and on every observer fire so a hand-edited or stale value can't shrink the panel below the toolbar or grow it past the user-root.
Bridging is one-way (state → store), with the persisted value used as
the initial seed. Bridge wiring runs once per process, on the first
window's creation. Apps without SettingsStore configured see no
behavior change.
The panel grows / shrinks via a 6 px top-edge resize handle.
Drag captures the pointer, anchors at the click point in widget-local
coords, and updates state.panel_height on every move so the handle's
top edge tracks the cursor exactly under live layout.
Bounds overlay color legend
When the bounds overlay is set to All:
- Cyan strokes — layout primitives (anything whose
Widget::type_name()contains::primitives::—HStack,VStack,ZStack,Padding,Expand,Spacer,FixedSize,Switcher,Center, …). - Magenta strokes — content widgets (everything else).
- Blue accent — the currently selected widget, drawn 2 px on top.
A small cursor-following tooltip also follows the mouse in
All mode, showing the deepest widget under the pointer and its
laid-out size — for example Button · 96×32. Background tint matches
the bounds-stroke color (cyan for layout primitives, magenta for
content widgets); positioned above the widget by default, flipping
below or shifting left if it would clip the user-root area. Suppressed
when the cursor is over the inspector's own panel. Driven off the
framework's WidgetTree::hovered_signal() (added in slice 6) — no
polling.
All mode also paints spacing bands behind the strokes:
- Yellow fill — the four
Padding-inset bands between aPaddingwidget's outer rect and its child's inner rect (top, bottom, leading, trailing). - Green fill — the gap between consecutive
HStack/VStacksiblings, spanning the parent's cross-axis extent.
The bands are translucent so the underlying widget colors still show through. Use the opacity slider to dim them for dense UIs.
Limitations
- Hit-test ignores
set_transformscopes. Picking under a rotated or scaled subtree returns the widget at its pre-transform bounds. Acceptable for a debug picker. - Bounds-overlay AllBounds mode walks the arena once per layout
pass. Cost is ~O(N) per frame while active. Toggle to
Off/Selwhen not actively inspecting layout. - Theme tab edits a curated subset of
ColorTokens. Sixteen commonly-edited fields are surfaced; the remaining tokens (typography, spacing, etc.) are read-only. The Apply / Reset buttons commit or discard the per-row drafts; Light / Dark / Import switch the active theme and re-sync drafts via the same observer. - Multi-window picker exclusion now tracks every InspectorShell
id in
state.shell_root_ids: Signal<Vec<WidgetId>>. The picker walks every shell id when hit-testing, so opening a second window no longer shadows widgets in older windows.
Where the code lives
- Crate: crates/teksilo-inspector/
- Entry point: crates/teksilo-inspector/src/lib.rs
- Shared state: crates/teksilo-inspector/src/state.rs
- Wrapping shell: crates/teksilo-inspector/src/shell.rs
- Highlight overlay: crates/teksilo-inspector/src/highlight.rs
- Picker tool: crates/teksilo-inspector/src/picker.rs
- Resize handle: crates/teksilo-inspector/src/resize_handle.rs
- Panel keyboard shortcuts: crates/teksilo-inspector/src/keyboard.rs
- Persistence: crates/teksilo-inspector/src/persistence.rs
- Tabs: crates/teksilo-inspector/src/tabs/
- Debug-registry hook: crates/teksilo-data/src/debug_registry.rs
Related core API additions (debug-build only in spirit)
These public APIs were added to teksilo-core to support the inspector
but are not gated by cfg — they are useful for any tooling that
wants to introspect a running tree:
WidgetTree::hovered() -> Option<WidgetId>WidgetTree::hovered_signal() -> Signal<Option<WidgetId>>— reactive mirror updated at every hover change (added in slice 6 to drive the AllBounds tooltip without polling)WidgetTree::focused_signal() -> Signal<Option<WidgetId>>— reactive mirror of focused id, drives the inspector's Focus tab without polling (added in slice 7)OverlayManager::version() -> &Signal<u64>— bumped on every show/dismiss, drives the inspector's Overlays tab without polling (added in slice 7)WidgetTree::hit_test(point)(delegates toWidgetArena::hit_test_at)WidgetArena::hit_test_at(point, exclude)WidgetBuilder::event_pass_through(bool)and the correspondingWidgetNode::event_pass_throughfieldWindowConfig::post_root(F)per-window root-wrapping hookLayoutContext::widget_bounds(id),widget_at_point(point, exclude),arena(),focused(),shortcut_registry(),overlay_manager()teksilo_app::DefaultPostRoottypedapp_stateslot for an app-wide defaultpost_rootwrapper
Automation MCP — Drive a Teksilo App from an Agent
A Teksilo app exposes a rich semantic surface — the AccessKit accessibility
tree — plus an AT-action dispatch path, both of which are queryable and
drivable in-process, without the OS accessibility layer. The
teksilo-automation-mcp server turns that latent capability into a
Model Context Protocol (MCP) server so an
AI agent (or any MCP client) can observe (semantic tree + screenshots) and
drive (AT actions + synthetic input) a Teksilo app.
This is the capability an agent can't get otherwise: the TreeUpdate lives
inside private platform state with no external channel except the OS AT layer;
the AT-action channel is OS-AT-only; headless operation needs no display
server; and a WidgetId-derived node id (stable across in-place changes —
see the tool surface) is steadier to cache than a fragile OS handle. It
complements (does not replace) a real screen-reader OS smoke test — the
live --connect mode (debug builds) drives your actual app, while the headless
mode is the toolkit's CI harness / a build-your-own-harness kit (see below).
Two modes
| Mode | Command | What it drives |
|---|---|---|
| Headless (default) | teksilo-automation-mcp --headless | A built-in demo app owned entirely in-process on a dedicated thread. No display, GPU daemon, or AT layer needed. The right mode for CI and agent test-authoring. |
| Live (connect) | teksilo-automation-mcp --connect <sock> --token <uuid> | A running app that opted into the debug-only in-app bridge. The agent drives the real window the user sees. |
Both speak MCP over stdio.
Headless
teksilo-automation-mcp --headless
A dedicated std::thread owns a HeadlessApp and the async rmcp handlers
marshal Send DTOs to it; the !Send WidgetTree never leaves that thread.
Screenshots render offscreen on the tree thread via pollster::block_on
(reusing teksilo_render::test_support::create_test_renderer — the same
offscreen path the widget previewer's PNG export uses).
What the stock binary drives. teksilo-automation-mcp --headless builds a
small built-in demo (a heading, two buttons, a text field, a checkbox) — it
is the toolkit's own conformance harness and a worked reference, not your
app. To headlessly automate your app there are two paths:
- Build a tiny harness with the GUI-free
teksilo-automationcrate: own your app'sWidgetTreeon one thread (or reuse a headless test tree) and callteksilo_automation::execute(&mut tree, &mut ops, &op, &settle)per request.executeworks against anyWidgetTree, so this is ~a screenful of glue — but it is a kit, not a turnkey "point it at my app" binary. - Use the live
--connectmode below — the turnkey "drive my real app" path today (needs a display and a running debug build).
Live (connect)
A debug build opts in with one line:
#![allow(unused)] fn main() { use teksilo::prelude::*; TeksiloAppBuilder::new() .theme(intui::light()) .install_automation_bridge_in_debug() // debug-only; a no-op in release .initial_window(/* ... */) .run(); }
Once the socket is bound and listening it prints its path and the token to stderr — the announcement follows the bind, so the path is connectable the instant it appears and a client needs no wait-for-socket loop:
teksilo-automation: bridge socket = /run/user/1000/teksilo-automation-12345/sock
TEKSILO_AUTOMATION_TOKEN=8f3c…
teksilo-automation: connect with `teksilo-automation-mcp --connect /run/user/1000/teksilo-automation-12345/sock --token 8f3c…`
Then point the server at it:
teksilo-automation-mcp --connect /run/user/1000/teksilo-automation-12345/sock --token 8f3c…
Each rmcp tool handler writes the op to the Unix socket and reads one reply;
the in-app bridge thread reads the socket and posts an AutomationPayload
(carrying a Send reply channel) through the existing AppEvent::External
path, and the winit main thread runs the op against the real window — the
settle runs synchronously on the main thread, never across a frame boundary.
Install
Add the automation feature to the umbrella crate (debug-only by design):
[dependencies]
teksilo = { version = "0.6", features = ["automation"] }
install_automation_bridge_in_debug() is gated on debug_assertions: a
release build with the feature on still contains no socket, token, or
bridge — the method is the identity. The GUI-free DTO toolkit is available as
teksilo::automation for writing harnesses against the same protocol.
The server binary builds from cargo build -p teksilo-automation-mcp.
Tool surface (27 tools)
Mutating tools accept an optional settle argument (see the settle model
below); query tools don't. Node ids come from snapshot_tree / find_node
and are the raw AccessKit NodeId values — derived deterministically from the
widget id. An id is stable for the lifetime of the widget instance (across
relayout, repaint, theme, and locale changes, which mutate widgets in place),
but a structural rebuild that destroys and recreates the widget — a
data-model change, a Switcher swap, a Rebuild-level binding — allocates a
new id. So caching an id is the payoff over an OS handle for in-place
changes; after a structural change, re-find_node (by role/label, which
carries the usual label fragility) rather than reuse a possibly-stale id.
Query — snapshot_tree, read_node, find_node, assert_node,
list_windows
Layout / geometry — layout_tree, inspect_node
Drive (AT actions) — invoke_action, focus_node, set_value,
expand, collapse, scroll
scroll takes ctrl / shift / alt / meta alongside dx / dy, all
defaulting to false. A modifier-held wheel is a different gesture from a
plain one — WidgetEvent::Scroll carries modifiers precisely so an app can
implement Ctrl-wheel-to-zoom — so a probe for such a feature must be able to
send one, not merely a bare wheel.
Synthetic input — inject_pointer, right_click, inject_key,
type_text, type_ime, drag_node
Introspection — get_overlays, get_shortcuts, list_live_regions,
pull_announcements
Time / settle — advance_clock, settle, wait_for_condition
Visual — screenshot (returns an MCP image content block)
snapshot_tree returns { root, focus, nodes: [SemanticNode…] }, where each
SemanticNode carries id, role, label, value, toggled, expanded,
selected, disabled, focused, live, numeric_value, bounds,
actions, and children.
layout_tree and inspect_node expose the full widget/layout (arena)
tree — the same data the debug inspector's Tree + Properties tabs show, and
strictly richer than the accessibility snapshot: it includes widgets the AT
tree prunes (layout primitives like HStack/Padding/Spacer, dormant
Switcher branches, presentational / access_exclude widgets), so an agent
can debug layout (overlap, clipping, off-screen, wrong size) — not just
semantics. Each LayoutNode carries id, type (the concrete Rust type
name), bounds, active, clips_children, parent, children, and — when
requested (include_debug, or always for inspect_node) — debug, the
widget's Debug repr (its constructor parameters). Layout nodes are keyed by
the same node-id space as the AT tools, so when a widget appears in both,
the two records share an id and can be correlated. (Coordinates are logical
window-relative pixels, identical to the AT bounds.)
Right-click & context menus
Teksilo context menus are attached with the .context_menu(factory) builder
and open on a Secondary PointerDown — a real right-click. To open one from
automation, use the node-based right_click tool:
// 1. Find the row / cell / item you want the menu for.
find_node { "role": "Row", "label": "report.pdf" } // → { "node": 123 }
// 2. Right-click it — injects a secondary press+release at its centre.
right_click { "node": 123 }
// 3. Read the menu that opened, then pick an item.
get_overlays // → { "count": 1, … }
find_node { "role": "MenuItem", "label": "Rename" } // → { "node": 456 }
invoke_action { "node": 456, "action": "click" }
Equivalent alternatives, in order of preference:
right_click(node)— the clearest verb; no coordinate math. Preferred.invoke_action(node, "show_context_menu")— theShowContextMenuAT action. It routes to the same.context_menu(..)factory (unless the widget wires its ownShowContextMenuhandler, which then wins). This is exactly what a screen reader's "show context menu" does.inject_pointer(x, y, button="secondary")— the low-level path. Only reach for it when you need a menu at a specific point rather than a node's centre; you must supply coordinates yourself (from a node'sbounds).
After any of these, settle runs automatically, so the very next
snapshot_tree / get_overlays / find_node sees the mounted menu. Dismiss it
with inject_key { "key": "escape" } or by clicking elsewhere.
Multi-window routing
Every tool accepts an optional window_id (the TeksiloWindowId raw value
from list_windows). window_id: None resolves to the focused window, else
the primary. Headless is single-tree, so routing is always unambiguous there.
The settle model
After a mutating op, the executor settles the tree so the next snapshot
reflects the change, then re-syncs the AT tree. The settle argument (all
fields optional) is:
| Field | Default | Meaning |
|---|---|---|
clock_millis | 0 | Advance the simulation clock first (drives tooltip / overlay timers). |
max_anim_frames | 60 | Cap on 16 ms animation ticks (~1 s). A perpetually-looping animation hits the cap — expected. |
layout_after | true | Run a layout pass after ticking, so height-for-width / reflow settles before the AT re-walk. |
settle_timeout_ms | 500 | Hard wall-clock budget; exceeding it ends the settle (the live bridge reports SETTLE_TIMEOUT). |
The settle loop is simulation-clock-driven (tick_animations doesn't wait
on VSync or OS events), so it can't deadlock — it progresses to quiescence or
the cap. wait_for_condition polls snapshot → predicate on the same clock
until a NodeExists / NodeValue / NodeGone / AtVersionAtLeast condition
holds or settle_timeout_ms elapses (WAIT_TIMEOUT).
Live regions & announcements
Teksilo has no OS AT layer in headless mode, and no in-process way to observe
what the platform spoke. So the WidgetTree diffs the live (Live::Polite /
Live::Assertive) nodes of each freshly-built TreeUpdate and records the
changes into a ring buffer. pull_announcements { since_seq } drains it — a
faithful, in-process model of the live-region stream. list_live_regions
reports the live nodes themselves.
Screenshots
screenshot renders the window (or, with node, that node's bounds) to a PNG
and returns it as an MCP image content block.
- Headless: an offscreen
RENDER_ATTACHMENT | COPY_SRCtexture, rendered via the test renderer, read back, PNG-encoded. If no GPU backend is present (CI without a GPU), the tool returnsGPU_UNAVAILABLE(non-fatal). - Live:
PlatformWindow::capture_offscreenrenders the live frame into an offscreen texture in the window's own surface format (the swapchain texture lacksCOPY_SRC), swizzling BGRA→RGBA as needed; the bridge base64-encodes the PNG over the socket and the--connectclient rehydrates it to an image block.
WebView blind spot: a native WebView subview composites on top of the
wgpu surface and is invisible to the readback (a transparent hole). When the
AT tree contains a WebView node the screenshot reply adds
warnings: ["webview_hole_possible"]. There is no platform-capture workaround
in scope.
Security model
The live bridge is defence-in-depth:
- Feature- and debug-gated. Every item that binds the socket, generates the
token, or spawns the bridge thread is
#[cfg(debug_assertions)]; a release build'sinstall_automation_bridge_in_debug()is the identity. A shipped release binary contains no socket, token, or bridge. - Unix-domain socket only (never TCP), in a
0700per-process directory under$XDG_RUNTIME_DIRwith a0600socket (so it isn't world-connectable even during the bind→chmod window), removed on startup and on bridge-thread exit. - Single connection at a time, single in-flight request, with a 10 s read-timeout on the token handshake and a 16 MiB cap on a request frame.
- Per-process UUID token: the client must send the token (printed to
stderr, or pinned via
TEKSILO_AUTOMATION_TOKEN) as the first line, or the connection is rejected. - Bounded main-thread settle. Because the live settle runs on the winit
main thread, the bridge clamps
max_anim_frames ≤ 120andsettle_timeout_ms ≤ 2000so no op (including a longwait_for_condition) can freeze the UI for more than ~2 s. Headless keeps the caller's values (no UI to freeze).
The threat model is a trusted local user with a non-shared $XDG_RUNTIME_DIR
— the same-uid socket plus the per-process token. The token is printed to
stderr, so anyone who can read the app's stderr (or /proc/<pid>/environ when
it's pinned) can drive the UI; this is a dev-tool stance, kept out of
production by the debug gate above. Regression guard: the debug-only banner
string only exists in the gated spawn_bridge_thread, so a release binary with
the feature on must not contain it — a CI canary:
cargo build --release -p widget-catalog # has the bridge wired + feature
! grep -qa "teksilo-automation: bridge socket" target/release/widget-catalog \
|| { echo "BRIDGE LEAKED INTO RELEASE"; exit 1; }
Headless mode has no socket at all.
Documented limitations
- WebView pixels in screenshots (compositor hole — warned, not captured).
- Windows live bridge: the socket is Unix-only;
install_automation_bridge_in_debug()is a no-op on Windows (headless mode works everywhere — it has no socket). - Release-build automation: debug-gated by design.
- Software-GPU fallback for CI screenshots: a clean
GPU_UNAVAILABLEerror, not a fallback. - This complements, not replaces, a real screen-reader OS round-trip.
Architecture
The wire protocol is serde DTOs, never closures or !Send handles. One
core function does the work, in the GUI-free teksilo-automation crate:
#![allow(unused)] fn main() { teksilo_automation::execute( tree: &mut WidgetTree, ops: &mut dyn WindowOps, op: &AutomationOp, settle: &SettleSpec, ) -> AutomationReply }
WidgetTree is Rc/RefCell-based and therefore !Send, so it lives on
exactly one thread; the async / socket layers marshal Send DTOs to it.
list_windows and screenshot are the only ops execute can't serve (they
need the window manager / a GPU); both return HOST_REQUIRED, and the
headless tree thread and the live bridge serve them with the context they
alone hold.
| Crate | Role |
|---|---|
teksilo-automation | GUI-free toolkit: DTOs, execute, RecordingWindowOps, the tool catalog. Mirrors teksilo-data's core-only-peer design. |
teksilo-automation-mcp | The rmcp server binary (--headless / --connect) + offscreen screenshots. tokio / rmcp are confined here. |
teksilo-app (automation feature) | The debug-only in-app bridge — std::os::unix::net + the existing send_external path; no async runtime in the framework. |
teksilo-platform | PlatformWindow::capture_offscreen — the live-window readback. |
RecordingWindowOps is why an AT action that opens a window (a menu item, a
"New window" button) never crashes the headless server: instead of panicking
on open_window, it records the request and returns a synthetic id.
Run snippets
# Headless MCP server (CI / agent test-authoring):
teksilo-automation-mcp --headless
# Drive a live running app (after `install_automation_bridge_in_debug()`):
teksilo-automation-mcp --connect <sock> --token <uuid>
# Live-bridge smoke test (needs a display):
cargo run -p automation_bridge_smoke
# — or keep it alive for an external client:
cargo run -p automation_bridge_smoke -- --serve
Testing
- Toolkit (
teksilo-automation): unit tests driven throughexecute(), each validating the producedTreeUpdatewith the realaccesskit_consumer. - MCP conformance (
teksilo-automation-mcp): the 24-tool router, the async-handler ⇄ tree-thread marshaling, and a screenshot that decodes to PNG magic bytes (skipped when no GPU). - Golden screenshots behind the
golden-testsfeature (cargo test -p teksilo-automation-mcp --features golden-tests), inline per-channel pixel compare with tolerance ≤ 2,UPDATE_GOLDENS=1to refresh. - Bridge round-trip (
examples/automation_bridge_smoke): a real app +install_automation_bridge_in_debug(), an in-process client runningsnapshot → invoke → re-snapshotand asserting the0600socket.
See also: Accessibility overrides, Scene accessibility.
Widget Catalog
Every public widget in teksilo-widgets, grouped by category. Each page links to its full rustdoc API reference.
Layout primitives
- AspectRatio — AspectRatio — a single-child wrapper that constrains layout to a fixed
- Center — Center — a single-child wrapper that centers its child within the available
- ColumnFlow —
ColumnFlow— flows children into as many columns as the width affords, - DeadZone —
DeadZone— a gesture dead zone wrapper - Divider — Divider — a themed separator line that visually partitions content
- Expand — Expand — a layout modifier that claims slack space in a stack and
- FixedSize — FixedSize — a layout modifier that pins a child to its natural size,
- FocusScope —
FocusScope— a layout-transparent wrapper that declares a **traversal - FormLayout — FormLayout — a two-column settings or preferences form layout
- Grid — Grid — a 2D layout container with explicit row and column tracks
- HStack — HStack — a horizontal layout container that distributes children left-to-right
- MasonryLayout — MasonryLayout — a variable-height grid that packs children into the
- MaxSize — MaxSize — a layout modifier that caps a child to a maximum width and/or height
- MinSize — MinSize — a layout modifier that ensures a child reaches a minimum width and/or height
- Padding — Padding — a single-child layout container that adds insets around its child
- Shrinkable — Shrinkable — a layout modifier that allows its child to compress under an over-constraint
- Spacer — Spacer — an invisible, flexible gap that claims all available space on the
- Switcher — Switcher — a container that shows exactly one child page at a time
- VStack — VStack — a vertical layout container that distributes children top-to-bottom
- Wrap — Wrap — a horizontal flow layout that wraps children to the next line when
- ZStack — ZStack — a layout container that layers children on top of each other
Visual primitives
- IconWidget — IconWidget — a vector or raster icon rendered at a configurable size
- ImageMaskShape — Anti-aliased alpha masking for raster images — circle / rounded-square
- ImageWidget — ImageWidget — displays a raster image (PNG, WebP) with a configurable
- RectWidget — RectWidget — a leaf widget that paints a filled and/or stroked rounded rectangle
- TextInputField —
TextInputField— editable single-line text surface primitive - TextWidget — TextWidget — a leaf widget that renders a localized text string
- TwistArrow — TwistArrow — a small chevron that indicates and toggles a tree node's expansion
- ValidationStrip — ValidationStrip — a small inline message shown below a text field to
Containers and chrome
- Accordion — Accordion — a collapsible section with a clickable header that shows or hides
- Banner — Banner — persistent inline status strip (info / success / warning / error)
- Breadcrumb — Breadcrumb — a navigational trail with automatic overflow into a
…menu - Card — Card — a surface container with optional header, content, and footer slots
- DockingLayout —
DockingLayout— a VS Code-style dockable layout: a fixed centre slot - DropTarget —
DropTarget— a transparent wrapping drop container - DropZone —
DropZone— a "drop files here" target for external (OS) drag-and-drop - GroupBox — GroupBox — titled cluster of controls in Int UI / Jewel style
- GroupHeader — GroupHeader — a horizontal section header: label followed by a trailing
- Panel — Panel — a themed single-child container that provides a background, border,
- ScrollArea — ScrollArea — a clipping viewport that scrolls its content on wheel, touch,
- ScrollBar — ScrollBar — pointer and keyboard affordance for a
ScrollArea - Splitter — N-pane split container with draggable, collapsible dividers
- StatusBar — StatusBar — a horizontal chrome bar at the bottom of a window for status
- Stepper —
Stepper— a modern, embeddable step-flow widget (Material/Ant/Flutter - TabWidget — Tabbed-container widgets
- TitleBar — Custom window title bar widget
- Toolbar —
Toolbar— a command bar with automatic overflow - ToolBox — ToolBox — a vertical stack of collapsible sections, exactly one expanded
- Wizard —
Wizard— a thin modal launcher aroundStepper
Buttons
- Button — Button — a labelled, activatable action trigger
- CommandLinkButton — CommandLinkButton — large two-line button with icon, title, and
- IconButton — IconButton — a square, icon-only, flat-surface button
- SplitButton — SplitButton — a button split into two regions sharing a single frame
Inputs and indicators
- Avatar —
Avatar— circular (or rounded-square / square) user-identity widget - Badge — Badge — a pill-shaped label for tags, status indicators, and counts
- Checkbox — Checkbox — a two-state or tristate checkbox with an optional label
- ComboBox — ComboBox — dropdown selection widget
- FontPicker — FontPicker — a drop-in font-family selector
- Link — Link — a clickable text label rendered as underlined inline text
- ProgressBar — ProgressBar — a bar showing progress from 0.0 to 1.0
- RadioButton — RadioButton — mutually exclusive selection control
- RadioGroup — RadioGroup — invisible layout container that groups
RadioButtons - RadioTile — RadioTile — a "selectable card" radio option
- RadioTileGroup — RadioTileGroup — an N-ary group of
RadioTiles with single selection - SegmentedControl — SegmentedControl — mutually exclusive segments in a horizontal row
- Slider — Slider — a draggable value selector bound to a
Signal<f32> - Spinner —
Spinner— a shader-driven circular-arc loading indicator - Toggle — Toggle — an animated on/off switch bound to a
Signal<bool>
Text input family
- CodeEditor — The public editing surfaces:
CodeEditorandPlainTextEditor - FilePickerField —
FilePickerField— a text-input preset for path entry with a Browse button - InputDialog — InputDialog — a
QInputDialog-style modal that prompts the user for - LogView —
LogView— a read-only, append-only, tail-following streaming view - PasswordField —
PasswordField— secure single-line text entry with a reveal - RichTextEditor — Rich text editor and viewer widget
- SearchField — SearchField — a
TextInputpreset - SpinBox —
SpinBox— numeric input with increment/decrement buttons - TextInput —
TextInput— styled single-line text field composite
Date and time
- Calendar —
Calendar— month-grid date picker, standalone widget - DateEdit —
DateEdit— text input + calendar popover, bound toSignal<Option<Date>> - DateRangeEdit —
DateRangeEdit— single unified control for picking aDateRange - DateTimeEdit —
DateTimeEdit— single unified control for picking aDateTime - TimeEdit —
TimeEdit— text input for time-of-day, bound toSignal<Option<Time>>
Color
- ColorEdit —
ColorEdit— compact field-style color picker trigger that opens - ColorPicker —
ColorPicker— embeddable composite color selector - HexColorInput —
HexColorInput— single-line#RRGGBB[AA]color editor
Menus
- MenuBar — MenuBar — a horizontal application menu bar with keyboard-driven dropdowns
- MenuItem — MenuItem — a single command row in a menu or context menu
- MenuList — MenuList — a themed vertical menu container with keyboard navigation
Overlays and dialogs
- AttachedSide — Layered drop-shadow helper for elevated surfaces
- Dialog — Modal dialogs — a trigger button that presents a centered modal panel
- MessageBox — MessageBox — QMessageBox-style alert dialog
- NotificationCenterButton —
NotificationCenterButton— bell icon with an unread-count badge that - NotificationLog —
NotificationLog— a scrollable, day-bucketed list of archived notifications - Snackbar — Snackbar — a transient, button-triggered floating notification surface
- Toast — Toast notification — stackable, action-rich, severity-aware floating
- ToastHost —
ToastHost— invisible sibling widget that owns the toast queue - TooltipWidget — Tooltip system — hover-triggered overlays with configurable delay
Data-driven widgets
- GridView — Virtualized 2D tile grid bound to a
ListModel<T>/ListDataSource - ListView — ListView — a virtualized, scrollable list backed by a reactive data model
- Repeater — Repeater — non-virtualized dynamic widget list driven by a
ListModel<T> - StandardListItem — Canonical row layout for
ListView/TreeViewdelegates - TableView —
TableView<T>— generic, virtualized, accessible tabular widget - TreeTableView —
TreeTableView<T>— hierarchical multi-column data table with expand/collapse - TreeView — TreeView — a virtualized, expandable/collapsible hierarchical list widget
Animation wrappers
- Blur —
Blur— a wrapper widget that applies a Gaussian-equivalent blur - Collapse —
Collapse— a wrapper widget that animates its child between - Crossfade —
Crossfade— when an externalSignal<K>changes, the - Cycle —
Cycle— show one of N children at a time, advancing on a fixed - Fade —
Fade— a wrapper widget that animates its child between hidden - Pulse —
Pulse— a wrapper widget that pulses its child's opacity between - Rotate —
Rotate— wraps a child and applies a 2D rotation to its entire - Scale —
Scale— wraps a child and animates a uniform 2D scale on its - Shake —
Shake— wraps a child and plays a damped horizontal oscillation - Slide —
Slide— wraps a child and slides it in or out from a chosen - SmoothSize —
SmoothSize— auto-sizes the slot to fit the child's intrinsic - Unroll —
Unroll— the horizontal sibling ofCollapse
Settings widgets
- LanguageSwitcher — LanguageSwitcher — a drop-in UI-language picker for settings screens
- PrivacySettings — PrivacySettings — a user-facing panel for telemetry consent management
- ShortcutSettings — ShortcutSettings — user-facing widget for browsing and rebinding
- TextScaleControl —
TextScaleControl— the settings control that grows all text in the app - ThemeSwitcher — ThemeSwitcher — a drop-in app-theme picker for settings screens & toolbars
ColorPicker (submodule)
- ColorSwatch —
ColorSwatch— single clickable color cell withRole::ColorWell
Other
- ActivateOn — Shared substrate for the data views' source-owned drag-and-drop + lazy
- CodeEditorHandle — Multi-line plain-text and code editing surfaces
- CommandPalette — CommandPalette — type-to-run access to every command an app has registered
- NotificationEntry — Persistent notification archive — the storage and data-model layer
- OverlayTrigger
- PopoverSurface —
PopoverSurface— the themed panel a popover's content sits in - PopoverWidget —
PopoverWidget<T>— a generic trigger that opens a popover when - TreeRowMeta — Type-erased data source adapter for
TreeView
TabWidget (submodule)
- TabBar —
TabBar<T>— header strip driven by a data source
TitleBar (submodule)
- DragRegion —
DragRegion— flexible drag region inside aTitleBar - ResizeStrip — A thin invisible widget that forwards a window resize gesture to the
- WindowControls — The minimize / maximize / close button cluster on the trailing edge of
- WindowFrame — A borderless-window frame: an invisible overlay of resize strips and
Toast (submodule)
- ToastSurface —
ToastSurface— the rendered chrome of one toast
Accordion

Accordion — a collapsible section with a clickable header that shows or hides its content when activated.
In the default vertical mode a horizontally-spanning header row sits above the
content; clicking or pressing Space/Enter toggles visibility with an animated
height disclosure (via Collapse).
A horizontal mode flips the header into a narrow vertical strip with a rotated
label — used by top/bottom sides of a DockingLayout. Fill mode (.fill(true))
is designed for fixed-size slots such as Splitter panes: the content fills all
available space and collapse animation is driven externally by the enclosing
pane rather than by an internal height tween.
Accessibility
The header is announced as Role::Button with aria-expanded reflecting the
current state, and aria-controls pointing at the content region
(Role::Region). Space/Enter toggle the disclosure; AT "click" actions are
also handled. The focus ring appears only on keyboard focus (not on pointer
clicks), matching the IntUI convention.
#![allow(unused)] fn main() { use teksilo_widgets::accordion::Accordion; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let expanded = Signal::new(false); let _accordion = Accordion::new(lit!("Advanced settings"), expanded); }
Builder methods at a glance
orientation, horizontal, fill, on_header_drag, trailing, trailing_id, title_color, title_style, content_id, content
API reference
📖 Full rustdoc API for this module
pub const ACCORDION_HEADER_HEIGHT
Height of the accordion header row in pixels (vertical mode).
#![allow(unused)] fn main() { pub const ACCORDION_HEADER_HEIGHT: f32 = 28.0; }
pub const ACCORDION_HEADER_PADDING_HORIZONTAL
Horizontal padding inside the accordion header on the leading and trailing edges.
#![allow(unused)] fn main() { pub const ACCORDION_HEADER_PADDING_HORIZONTAL: f32 = 8.0; }
pub const ACCORDION_INDICATOR_SIZE
Size of the chevron disclosure indicator icon in pixels.
#![allow(unused)] fn main() { pub const ACCORDION_INDICATOR_SIZE: f32 = 12.0; }
pub const ACCORDION_INDICATOR_GAP
Gap between the disclosure indicator and the title label.
#![allow(unused)] fn main() { pub const ACCORDION_INDICATOR_GAP: f32 = 6.0; }
pub const ACCORDION_CORNER_RADIUS
Corner radius of the keyboard-focus ring painted on the accordion header.
#![allow(unused)] fn main() { pub const ACCORDION_CORNER_RADIUS: f32 = 4.0; }
pub enum AccordionOrientation
Orientation of an [Accordion]: how its header sits relative to its
content. Vertical (the default) is a
horizontal header row above the content; Horizontal
is a narrow vertical header strip (rotated-90° label, left/right
chevron) beside the content — used by top/bottom dock sides.
#![allow(unused)] fn main() { pub enum AccordionOrientation { /* variants */ } }
Variants
Vertical— Header row above the content (default).Horizontal— Vertical header strip beside the content.
pub struct Accordion
A collapsible section widget whose header button shows or hides attached content.
Supply the title and a Signal<bool> for the expanded state, then attach
content via .content(w) or
.content_id(id). The signal can be toggled externally
(e.g. from a "collapse all" button) and the disclosure animation will follow.
#![allow(unused)] fn main() { pub struct Accordion { /* fields */ } }
Methods
pub fn new(title: impl Into<LocalizedString>, expanded: Signal<bool>) -> Self
Create a new accordion with the given title and an external expanded signal.
The accordion starts collapsed or expanded according to the initial value of
expanded. Toggling the signal later drives the disclosure animation.
pub fn orientation(mut self, orientation: AccordionOrientation) -> Self
Set the header orientation (default AccordionOrientation::Vertical).
pub fn horizontal(mut self) -> Self
Shorthand for Accordion::orientation``(``AccordionOrientation::Horizontal``).
pub fn fill(mut self, fill: bool) -> Self
Make the expanded content fill the accordion's allotted space (the
leftover after the header) — instead of the default natural-height
disclosure — while keeping the collapse/expand animated. Use when the
accordion lives in a fixed-size slot such as a Splitter pane (a dock
panel): the content lays out at exactly the available size (no narrow
content, no overflow) and the header tween still plays. Default false.
pub fn on_header_drag(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Make the header a drag source: a drag gesture starting on it fires
f (which should begin a drag, e.g. ctx.start_drag(source, payload)).
Tap-to-toggle is unaffected — the gesture arena tells a tap from a drag.
pub fn trailing(mut self, widget: impl Widget + 'static) -> Self
Place a widget at the trailing end of the header, before the disclosure
chevron — an options (⋮) button, an inline action toolbar, etc. The
slot's own controls capture their gestures (innermost hit wins), so
clicking them does not toggle the accordion. Mirrors
ToolBoxItem::trailing /
TabWidget::bar_trailing_slot.
pub fn trailing_id(mut self, id: WidgetId) -> Self
Like trailing but takes a pre-registered widget
id — for callers that must build the slot in-context (e.g. a slot that
itself adds boxed children). Takes precedence over trailing.
pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self
Override the header foreground color used for the title text and
chevron icon. Defaults to TextRole::Primary. Accepts a literal
Color, a TextRole/SurfaceRole, or a Signal<Color>.
pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self
Override the header title's text style. Use this to make the
disclosure label smaller (e.g. inside a tooltip) or to match a
non-body typography role. Accepts a static
TextStyle or a
TextStyleRole.
pub fn content_id(mut self, id: WidgetId) -> Self
Set the content widget by pre-registered ID.
pub fn content(mut self, widget: impl Widget + 'static) -> Self
Set an inline content widget (deferred insertion).
ActivateOn
Shared substrate for the data views' source-owned drag-and-drop + lazy loading.
Centralizes the vocabulary the four data views (ListView / TreeView /
TableView / TreeTableView) share, so DnD validation (can_accept) and
the lazy placeholder are wired one way everywhere:
RowDragData— the public, generic intra-app drag payload a row (or a whole selected set) emits. The receiving source distinguishes its OWN reorder (matchingViewId) from a foreign drop, and translates the origin'srows→ its own key viakey_at, so the source'sKeytype never leaks into the view. When the origin opted into export it also carriesitems(clones of the draggedT), so a foreignDropTarget, a different data view, or the OS can consume the drag.DropIndicator— whatpaintrenders;allowed == falseis the pre-commit forbidden affordance.flat_insertion_target— maps a flat insertion index to the(target, position)paircan_accept/accept_dropexpect.default_placeholder— the skeleton for aLoadingrow.
Builder methods at a glance
items, into_items, is_export, len, is_empty
API reference
📖 Full rustdoc API for this module
pub enum ActivateOn
How a data-view row/tile is activated (opened/committed) by pointer —
distinct from selection, which also moves on arrow-key navigation. Mirrors
the platform split other toolkits expose (Qt
SH_ItemView_ActivateItemOnSingleClick, GTK activate-on-single-click).
Enter/Space always activates regardless of this mode.
Pass to ListView::activate_on, TreeView::activate_on, etc.
#![allow(unused)] fn main() { pub enum ActivateOn { /* variants */ } }
Variants
SingleClick— One primary click activates the row (KDE / web / Scrivener convention). Selection and activation happen on the same click.DoubleClick— A double primary click activates the row; the first click only selects it (Finder / Explorer / Qt and GTK default). This is theDefault.
pub struct ViewId
Opaque, kind-tagged, process-unique identity of a drag-capable data-view
instance. Used to tell a view's OWN reorder (SameView) from a foreign drop
on the receive side. Apps only ever compare two ViewIds for equality (e.g.
out of a received RowDragData); there is no public constructor, and the
value is stable for a view instance's lifetime, so it is safe to compare
even across windows (each mint is globally unique).
#![allow(unused)] fn main() { pub struct ViewId(ViewKind, usize); }
pub enum DragTransferMode
What the origin view does to its own rows once a drag is accepted by a
foreign target (a different DropTarget / view / the OS). Purely an
origin-side cleanup choice — the receiver is unaffected. A same-view reorder
is never a transfer, so this never applies to it.
#![allow(unused)] fn main() { pub enum DragTransferMode { /* variants */ } }
Variants
Copy— Leave the origin rows in place (the dragged data is duplicated).Move— Remove the dragged rows from the origin once accepted elsewhere (or exported as an OS move). This is theDefault.
pub struct RowDragData
The public, generic drag payload every data-view row (or selected set)
emits. It occupies the single typed slot of a
teksilo_core::drag_payload::DragPayload and serves both audiences:
- the origin view's own erased classifier reads
source+rowsto recognise a same-view reorder; - a foreign consumer (another view's custom
ListDataSource, aDropTarget::accept_typed::<RowDragData<T>>(), oron_rows_received) readsitems.
items is Some only when the origin view opted into export via
.exportable(..) (which requires T: Clone); a plain .reorderable(true)
drag carries items == None (nothing outside the origin could use it
anyway), so a reorder-only view is never accidentally droppable elsewhere.
#![allow(unused)] fn main() { pub struct RowDragData<T: 'static> { /* fields */ } }
Methods
pub fn items(&self) -> Option<&[T]>
The dragged items, if this is an export drag (.exportable(..) was set
on the origin). None for a reorder-only drag.
pub fn into_items(self) -> Option<Vec<T>>
Consume the payload for its items (avoids cloning on the receive side).
pub fn is_export(&self) -> bool
Whether this drag carries exportable items — i.e. the origin opted into
.exportable(..). A foreign receiver should gate on this (a reorder-only
payload has the same Rust type but carries nothing usable).
pub fn len(&self) -> usize
Number of dragged rows.
pub fn is_empty(&self) -> bool
Whether no rows are carried (never true for a real drag).
pub struct RowAnchor
Active drag-drop feedback a tree data view paints itself: a between-rows insertion line (Before/After) or a highlighted row (an into-container drop).
Shared by TreeView and TreeTableView so both render the same affordance
for the same source verdict.
A stable handle to a row in a data view.
Per-row event handlers (a chevron toggle, a click, an activation) are built once and then live as long as the row widget does, so capturing the flat index they were built at is fragile: expanding a branch above, applying a filter, or sorting shifts every index below, and the stale handler would act on whatever row moved into that slot.
A RowAnchor closes over the row's source-owned identity instead and
resolves the row's current position on demand. The key never surfaces in
the anchor's type — it is captured inside the resolver, so views stay
key-agnostic (TreeSource and
ListSource both erase it).
Sources without identity (a bare ListModel, or any source that leaves
key_at at its None default) get a fixed anchor that always reports the
index it was built with — no worse than capturing the index directly.
A bare ListModel has no identity to offer (a Vec row is its position),
so anchors over one are fixed. SortFilterListModel keys rows by their
source index, which no sort/filter reprojection renumbers — so anchors
over a projection do track their row across a filter change, which is the
flat fragility in practice. They can still mis-resolve inside the window
between an upstream insert/remove and the rebuild it schedules, since that
does renumber source indices; no worse than the captured index they replace.
The tree sources all carry real identity.
Precondition: keys must be unique. Resolution falls back to a lookup by key, which returns the first match, so a source handing out duplicate keys would silently redirect an anchor onto a different row — the very failure this type exists to prevent.
#![allow(unused)] fn main() { pub struct RowAnchor { /* fields */ } }
Methods
pub fn index(&self) -> Option<usize>
The row's current flat index, or None if it no longer exists in the
source (it was deleted, or filtered away).
pub fn is_live(&self) -> bool
Whether the row still exists.
AspectRatio

AspectRatio — a single-child wrapper that constrains layout to a fixed width-to-height ratio.
Given a proposal, AspectRatio computes the largest rectangle that fits
within both dimensions while satisfying width / height == ratio. When
only one axis is constrained by the parent, the other is derived from the
ratio. The child is stretched to the resulting rectangle. The widget is
invisible to assistive technology (set_hidden); its child carries all
semantic meaning.
When to use
- Embedding images, thumbnails, or video placeholders that must stay letter-boxed regardless of the available space.
- Ensuring a square avatar or tile layout against an unconstrained parent axis.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{AspectRatio, RectWidget}; // 16:9 video placeholder let _thumbnail = AspectRatio::new(16.0 / 9.0) .child(RectWidget::new()); }
Builder methods at a glance
widescreen, square, child, child_id
API reference
📖 Full rustdoc API for this module
pub struct AspectRatio
A single-child wrapper that maintains a fixed width/height ratio.
#![allow(unused)] fn main() { pub struct AspectRatio { /* fields */ } }
Methods
pub fn new(ratio: f32) -> Self
Create a new aspect ratio wrapper. Ratio is width / height.
pub fn widescreen() -> Self
Convenience for 16:9 aspect ratio.
pub fn square() -> Self
Convenience for 1:1 aspect ratio.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget to constrain; the child is stretched to the computed aspect-ratio rectangle.
pub fn child_id(mut self, id: WidgetId) -> Self
Set a pre-registered child widget by ID.
AttachedSide
Layered drop-shadow helper for elevated surfaces.
Composes two Shadows underneath a rounded rect:
outer— the wide soft halo (typicallytheme.shape.shadow_*).inner— the sharp short-blur rim that gives the surface a clearly "lifted" edge instead of a vague glow (typically the matchingtheme.shape.shadow_inner_*).
The inner token's geometry (offset_y, blur, color.rgb) is used
verbatim. Only color.a is modulated: the painted alpha is
density × inner.color.a(), with density ∈ [0.0, 1.0] provided by
the per-component shadow_density field. This keeps every visual
knob in the theme while letting individual surfaces dial intensity.
Common density presets:
1.0— tooltips (full inner-rim alpha, punchy "lift").~0.5— cards, popovers, menus (moderate).0.0— disable inner rim entirely (single-layer outer only).
Attached side
Popovers, menus and combo-box dropdowns sit attached to the widget
that opened them. On the side that touches the trigger, drawing a
halo would visually cut the surface off from its anchor. Pass an
AttachedSide to suppress shadow on that side.
// Typical usage inside a custom widget's paint() method:
use teksilo_widgets::shadow::{paint_layered_shadow, DENSITY_SURFACE};
paint_layered_shadow(
canvas, bounds, radius,
&ctx.theme.shape.shadow_sm,
&ctx.theme.shape.shadow_inner_sm,
DENSITY_SURFACE,
None,
);
API reference
📖 Full rustdoc API for this module
pub const DENSITY_TOOLTIP
Inner-rim alpha multiplier for tooltips — full intensity for maximum lift.
#![allow(unused)] fn main() { pub const DENSITY_TOOLTIP: f32 = 1.0; }
pub const DENSITY_SURFACE
Inner-rim alpha multiplier for cards, popovers, and menus — moderate lift.
#![allow(unused)] fn main() { pub const DENSITY_SURFACE: f32 = 0.5; }
pub const DENSITY_DIALOG
Inner-rim alpha multiplier for snackbars and dialogs — subtle lift.
#![allow(unused)] fn main() { pub const DENSITY_DIALOG: f32 = 0.3; }
pub enum AttachedSide
Which geometric edge of the surface is attached to its trigger and should have shadow drawing suppressed on that side. Geometric (Top / Bottom / Left / Right), not RTL-aware — callers working in Leading/Trailing terms must resolve to a geometric side using the active layout direction before calling.
#![allow(unused)] fn main() { pub enum AttachedSide { /* variants */ } }
Variants
Top— Suppress the shadow halo on the top edge (e.g. a dropdown opening downward).Bottom— Suppress the shadow halo on the bottom edge (e.g. a popover opening upward).Left— Suppress the shadow halo on the left edge.Right— Suppress the shadow halo on the right edge.
pub fn paint_layered_shadow(...)
Paint a two-layer drop shadow behind a rounded rect.
The outer shadow is drawn unchanged. If density × inner.color.a()
is above the sub-perceptual threshold (1/255), the inner shadow is
drawn on top with its alpha scaled by density. This gives a "lift"
look — a wide soft halo with a sharp close rim.
When attached is Some(side), both shadow draws are clipped so
the penumbra on that side is hidden — matching the visual where
the surface is attached to its anchor (popover under its trigger,
dropdown under its combo box, etc.).
If both layers would be sub-perceptual (e.g. theme has zero alphas
or density of 0), this function returns without emitting any draw
commands.
// In a widget's paint() method:
use teksilo_widgets::shadow::{paint_layered_shadow, AttachedSide, DENSITY_SURFACE};
paint_layered_shadow(
canvas, bounds, radius,
&ctx.theme.shape.shadow_sm, &ctx.theme.shape.shadow_inner_sm,
DENSITY_SURFACE, None,
);
#![allow(unused)] fn main() { pub fn paint_layered_shadow( canvas: &mut Canvas, bounds: Rect, radius: CornerRadius, outer: &Shadow, inner: &Shadow, density: f32, attached: Option<AttachedSide>, ); }
Avatar
![]()
Avatar — circular (or rounded-square / square) user-identity widget.
Displays either a person's image (clipped to the configured shape via a CPU-side anti-aliased alpha mask applied at construction time) or their initials over a hash-derived background colour. Optional presence indicator (Online / Offline / Away / Busy) and outer ring. Can be made activable to serve as a user-menu trigger.
#![allow(unused)] fn main() { use teksilo_widgets::{Avatar, AvatarPresence, AvatarSize}; use teksilo_canvas::raster::RasterIcon; use teksilo_i18n::lit; use teksilo_core::Intent; let face = RasterIcon::from_raw(vec![0u8; 4 * 4 * 4], 4, 4); // Image with a presence dot. let _w = Avatar::with_image(&face) .alt(lit!("Jane Doe")) .presence(AvatarPresence::Online) .size(AvatarSize::Medium); // Hash-tinted initials, auto-derived from a name. let _w = Avatar::with_name(lit!("Jane Doe")).size(AvatarSize::Large); // Click target — opens a user menu via an intent. let _w = Avatar::with_image(&face) .label(lit!("Open user menu")) .alt(lit!("Jane Doe")) .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.open-user-menu"))); }
The widget reuses ImageWidget for the image path and draws bg /
border / presence directly via Canvas. Hash-derived background
tints come from theme.colors.chart_palette (Okabe-Ito), so they
track the active theme automatically.
Builder methods at a glance
with_initials, with_name, with_image, from_raw_image, style, size, shape, fallback_initials, image_visible, background, foreground, seed, border, border_color, presence, presence_corner, label, alt, a11y_hidden, on_activate_fn, has_popup, expanded_when, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, name_signal, image_signal, alt_signal, label_signal, presence_signal
API reference
📖 Full rustdoc API for this module
pub struct Avatar
Circular (or rounded-square / square) user-identity widget showing either a photo or hash-tinted initials, with an optional presence dot.
Static and reactive content fields coexist: each knob (name, image,
alt, label, presence) has a static constructor or setter and a
bind_* counterpart that takes a Signal. When a signal is bound it
wins; the static value acts as a fallback. Signal-bound rebuilds fire at
BindingLevel::Rebuild so inner children are recreated with fresh values —
the canonical pattern for a "logged-out → logged-in" transition.
#![allow(unused)] fn main() { pub struct Avatar { /* fields */ } }
Methods
pub fn with_initials(initials: impl Into<LocalizedString>) -> Self
Create an avatar from an explicit initials string. Uppercases and
truncates to at most 2 chars; empty input yields "?".
pub fn with_name(name: impl Into<LocalizedString>) -> Self
Create an avatar from a display name; initials are derived
automatically ("Jane Doe" → "JD", "jane.doe@x.com" → "JD",
"Cher" → "C", "" → "?"), and the full name is used as the
hash seed for the background tint so users with identical initials
still get distinct colours.
pub fn with_image(icon: &RasterIcon) -> Self
Create an avatar from a decoded RasterIcon. The pixels are
centre-cropped to a square and CPU-masked to the configured shape
at the first build(). Call .alt(...) to provide a
screen-reader name for the image.
pub fn from_raw_image(pixels: Vec<u8>, width: u32, height: u32) -> Self
Create an avatar from raw RGBA pixels (width × height × 4 bytes).
Same pixel-layout convention as ImageWidget::from_raw.
pub fn style(mut self, style: impl teksilo_core::styles::AvatarStyle) -> Self
Per-call style override for the avatar chrome.
pub fn size(mut self, size: AvatarSize) -> Self
Set the avatar's discrete size. Default: AvatarSize::Medium (32 dp).
pub fn shape(mut self, shape: AvatarShape) -> Self
Set the avatar's clip shape. Default: AvatarShape::Circle.
pub fn fallback_initials(mut self, initials: impl Into<LocalizedString>) -> Self
Override the initials shown when the image is hidden via
image_visible(false) or fails to register. Defaults to the
derived initials if with_image was paired with with_name,
otherwise "?".
pub fn image_visible(mut self, visible: impl Into<Prop<bool>>) -> Self
Reactive image visibility. When unbound it's true. When bound
to a Signal<bool> and the value is false, the initials
fallback paints in place of the image — same logical bounds, no
layout shift.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Override the auto hash-derived background. Accepts a Color,
a role, or a Signal<Color>.
pub fn foreground(mut self, color: impl Into<ColorProp>) -> Self
Override the auto-contrast text colour for the initials. Auto (unset) picks white over dark backgrounds and near-black over light ones, computed at paint time from the resolved bg's luminance.
pub fn seed(mut self, seed: impl Into<String>) -> Self
Override the seed string used to pick a hash-derived background
from the theme's chart palette. Defaults to the resolved name
(when constructed via with_name) or the initials.
pub fn border(mut self, width: f32) -> Self
Outer ring thickness. A non-zero value enables the ring (drawn
in BorderRole::Default unless Self::border_color overrides
it). 0.0 disables the ring.
pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self
Override the outer ring colour. Accepts a Color, a theme role,
or a Signal<Color>. Has no effect unless Self::border is also
set to a positive width.
pub fn presence(mut self, presence: AvatarPresence) -> Self
Show a presence indicator dot. Pass AvatarPresence::Online,
Offline, Away, or Busy.
pub fn presence_corner(mut self, corner: AvatarCorner) -> Self
Choose which corner the presence dot occupies. Default:
AvatarCorner::BottomTrailing.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Override the accessible name. When unset:
- image-mode →
altif set, else the initials, else "Avatar" - initials-mode → the initials.
pub fn alt(mut self, alt: impl Into<LocalizedString>) -> Self
Image alt text — distinct from label so a clickable avatar
can have a button label like "Open user menu" while still
describing the image as "Jane Doe".
pub fn a11y_hidden(mut self) -> Self
Hide from the a11y tree entirely. Use only when an adjacent label conveys the avatar's meaning.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Make the avatar activable. Promotes the a11y role to
Role::Button and adds Action::Click / Action::Focus. Tap,
Enter, and Space all fire the closure. Cursor changes to
Pointer on hover.
pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self
Declare that this avatar is a disclosure trigger for a popup
(typically HasPopup::Menu for a user-menu trigger). Surfaces
via set_has_popup in the a11y node so screen readers
announce the avatar as "menu button" / "has popup". Only takes
effect when paired with .on_activate_fn(...) — without an
activation handler the avatar isn't a trigger.
pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self
Bind a signal reporting whether this avatar's popup is
currently visible. The wrapping Popover / overlay manager owns
the signal and flips it on show / dismiss; Avatar reads it in
accessibility() to publish set_expanded. Only meaningful
alongside .has_popup(...).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after the hover delay.
Mutually exclusive with Self::rich_tooltip,
Self::rich_tooltip_content, and Self::composite_tooltip —
this call clears the other three slots.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip identified by a registry key. The tooltip
content is resolved from the application's TooltipRegistry at
hover time. Mutually exclusive with the other tooltip setters —
this call clears the other three slots.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from inline crate::tooltip::TooltipContent
without a registry key. Mutually exclusive with the other tooltip
setters — this call clears the other three slots.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Shown after the longer tooltip_delay_heavy delay. Mutually
exclusive with the other tooltip setters — this call clears the
other three slots.
pub fn name_signal(mut self, signal: Signal<String>) -> Self
Bind the user's display name to a signal. The displayed
initials are auto-derived from the current value
(derive_initials), and the same value is used as the hash
seed for the background tint. Bound at
BindingLevel::Rebuild so the inner children regenerate on
flip — the canonical login-flow pattern:
let user_name: Signal<String> = ctx.signal(String::new());
Avatar::with_initials(lit!("?")) // logged-out fallback
.name_signal(user_name.clone())
.image_signal(user_avatar_signal)
pub fn image_signal(mut self, signal: Signal<Option<Rc<RasterIcon>>>) -> Self
Bind the image source. None ⇒ initials fallback. Each
non-None value is masked to the configured AvatarShape by
the inner ImageWidget. Bound at BindingLevel::Rebuild.
pub fn alt_signal(mut self, signal: Signal<Option<String>>) -> Self
Bind the image alt text. Bound at BindingLevel::AccessibilityOnly
— only the screen-reader projection is affected.
pub fn label_signal(mut self, signal: Signal<Option<String>>) -> Self
Bind the accessible label. Bound at
BindingLevel::AccessibilityOnly.
pub fn presence_signal(mut self, signal: Signal<Option<AvatarPresence>>) -> Self
Bind the presence indicator. None hides the dot. Bound at
BindingLevel::Rebuild — the dot's colour and the a11y
description flip together so a rebuild keeps both layers in
sync.
Badge

Badge — a pill-shaped label for tags, status indicators, and counts.
Badge renders a short piece of text inside a rounded-pill container.
Common uses include tag chips on list items, unread-count bubbles in
navigation rails, and severity labels in alert rows. The pill chrome
(corner radius, padding, surface tint) is driven by the active
BadgeStyle; callers may swap it per-instance (.style(...)) or
theme-wide via theme.style_slots.badge.
When to use
- Inline chip that annotates another widget (version tag, "NEW" label).
- Standalone count indicator; pair with
SeverityBadgefor icon-backed status glyphs.
Accessibility
Announces as Role::Label with its resolved text as the AT name.
The inner TextWidget is hidden from AT to avoid double-announcement.
#![allow(unused)] fn main() { use teksilo_widgets::Badge; use teksilo_i18n::lit; use teksilo_tokens::Color; let _badge = Badge::new(lit!("NEW")) .background(Color::new(0.2, 0.6, 1.0, 1.0)); }
Builder methods at a glance
style, background, text_role, text_style, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct Badge
A pill-shaped label for displaying tags, counts, or status.
#![allow(unused)] fn main() { pub struct Badge { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Construct a badge with the given label text.
pub fn style(mut self, style: impl teksilo_core::styles::BadgeStyle) -> Self
Per-call style override for the badge pill chrome. Replaces the
theme-wide default BadgeStyle for just this instance.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Override the badge background. Accepts Color, a
SurfaceRole / TextRole,
or a Signal<Color>. Default (unset) is SurfaceRole::AccentSubtle.
pub fn text_role(mut self, color: impl Into<ColorProp>) -> Self
Override the badge text color. Accepts Color, a role, or a signal.
Default (unset) is the theme's status_info_fg.
pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self
Override the label's text style (font, size, weight). Accepts a
TextStyleRole, a TextStyle, or a Signal of either. Default
(unset) is TextStyleRole::Tiny.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter called wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip identified by a registry key.
Mutually exclusive with tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter called wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from inline TooltipContent.
Mutually exclusive with tooltip,
rich_tooltip, and
composite_tooltip — the last setter called wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip with an arbitrary widget tree body.
Mutually exclusive with tooltip,
rich_tooltip, and
rich_tooltip_content — the last setter called wins.
Banner

Banner — persistent inline status strip (info / success / warning / error).
A non-transient, full-width callout for app-level conditions: deprecation
notices, "you have unsaved changes", trial-expiry warnings, license
issues, restored-from-cache notices, etc. Distinct from
Snackbar (transient, corner-anchored) and
MessageBox (modal).
Banner::warning(tr!(unsaved_changes()))
.description(tr!(close_loses_changes()))
.action(Button::new(tr!(save_now()))
.on_activate_fn(|ctx| ctx.send_intent(AppIntent::SaveNow)))
.on_dismiss(|ctx| ctx.send_intent(AppIntent::DismissBanner))
Builder methods at a glance
style, info, success, warning, error, description, action, on_dismiss
API reference
📖 Full rustdoc API for this module
pub struct Banner
A persistent inline status strip.
#![allow(unused)] fn main() { pub struct Banner { /* fields */ } }
Methods
pub fn style(mut self, style: impl teksilo_core::styles::BannerStyle) -> Self
Per-call style override for the banner strip chrome. Replaces
the theme-wide default BannerStyle for just this instance.
pub fn info(title: impl Into<LocalizedString>) -> Self
Construct an info-severity banner.
pub fn success(title: impl Into<LocalizedString>) -> Self
Construct a success-severity banner.
pub fn warning(title: impl Into<LocalizedString>) -> Self
Construct a warning-severity banner.
pub fn error(title: impl Into<LocalizedString>) -> Self
Construct an error-severity banner.
pub fn description(mut self, text: impl Into<LocalizedString>) -> Self
Optional secondary line of text rendered below the title.
pub fn action(mut self, widget: impl Widget + 'static) -> Self
Trailing widget — typically a Button or
an HStack of buttons. Placed before the optional dismiss button.
pub fn on_dismiss(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Attach a trailing dismiss (X) button. The closure runs when the
user clicks it; the host is expected to remove the banner from the
tree (typically by toggling a Signal<bool> driving a Switcher).
Blur

Blur — a wrapper widget that applies a Gaussian-equivalent blur
to its child subtree, driven by a Prop<f32> radius (in logical
pixels).
Built on BuildContext::set_blur, a per-node paint scope parallel
to set_opacity and set_transform. The framework's render walker
emits BeginBlurredSubtree { bounds, radius } before this widget's
paint and EndBlurredSubtree afterwards; the renderer redirects
drawing into an intermediate texture, runs a dual-Kawase blur chain
at the requested radius, and composites the blurred result back into
the parent pass.
Sub-perceptual radii (< 0.5 px) skip the Begin/End pair entirely so
animated 0 → target_radius enable patterns have zero per-frame
cost when fully off.
// Static frosted-glass backdrop:
ctx.add(Blur::new(15.0).child(modal_backdrop));
// Click-to-reveal sensitive content:
let visible = ctx.signal(false);
let radius = visible.map(|&v| if v { 0.0 } else { 12.0 });
ctx.add(Blur::new(radius).child(secret_text));
// Animated frosted-glass on modal show:
let radius = ctx.animated_signal(0.0_f32);
ctx.animate().normal().standard().to_or_snap(&radius, 15.0);
ctx.add(Blur::new(radius).child(content));
Layout semantics
Blur does not change layout. The wrapped child reports its full
natural size at all blur radii; only the visual paint output is
affected.
Performance
Blur is the most expensive paint scope in the framework — every
enabled blur scope drives N+M+1 small render passes per frame
(N downsamples, M upsamples, +1 composite). Don't put it on
widgets that animate every frame at full radius. For "fade-blur on
reveal" patterns, animate the radius up to a static value and leave
it there. See docs/animation.md §5.8.
Builder methods at a glance
child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Blur
Wraps a child and applies a Gaussian-equivalent blur to the entire
subtree, driven by an external Prop<f32> radius (logical pixels).
#![allow(unused)] fn main() { pub struct Blur { /* fields */ } }
Methods
pub fn new(radius: impl Into<Prop<f32>>) -> Self
Build a blur wrapper bound to radius (in logical pixels).
Accepts any Prop<f32> source — f32, Signal<f32>, or
Prop<f32>. Sub-perceptual radii (< 0.5) are a no-op.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
Breadcrumb

Breadcrumb — a navigational trail with automatic overflow into a … menu.
Breadcrumb renders a horizontal row of labelled segments separated by
chevron glyphs, representing a hierarchical path (file system, settings
hierarchy, wizard steps, etc.). When the trail is too wide to fit its
container, middle segments are automatically collapsed into a … popover
menu — the root and the current (last) segment always stay visible,
matching Windows Explorer, macOS path bar, and web breadcrumb conventions.
Building a trail
#![allow(unused)] fn main() { use teksilo_widgets::{Breadcrumb, BreadcrumbItem}; use teksilo_core::Intent; use teksilo_i18n::lit; let _bc = Breadcrumb::new() .item(BreadcrumbItem::new(lit!("Home")) .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.nav.home")))) .item(BreadcrumbItem::new(lit!("Projects")) .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.nav.projects")))) .item(BreadcrumbItem::current(lit!("Teksilo"))); }
Accessibility
The container uses Role::Navigation; each segment uses Role::Link.
The current crumb sets aria-current="page". The decorative separator
chevrons are hidden from the AT tree. The … overflow button declares
HasPopup::Menu.
Builder methods at a glance
label, item, item_id, trailing_slot, trailing_slot_id, is_overflowing
API reference
📖 Full rustdoc API for this module
pub const BREADCRUMB_ITEM_HEIGHT
Minimum height of a single breadcrumb segment in logical pixels.
#![allow(unused)] fn main() { pub const BREADCRUMB_ITEM_HEIGHT: f32 = 20.0; }
pub const BREADCRUMB_ITEM_PADDING_HORIZONTAL
Horizontal inner padding of each segment pill in logical pixels.
#![allow(unused)] fn main() { pub const BREADCRUMB_ITEM_PADDING_HORIZONTAL: f32 = 6.0; }
pub const BREADCRUMB_SEPARATOR_GAP
Gap reserved for the chevron separator between adjacent segments.
#![allow(unused)] fn main() { pub const BREADCRUMB_SEPARATOR_GAP: f32 = 4.0; }
pub const BREADCRUMB_CORNER_RADIUS
Corner radius of the interactive segment hover/focus rectangle.
#![allow(unused)] fn main() { pub const BREADCRUMB_CORNER_RADIUS: f32 = 4.0; }
pub struct BreadcrumbItem
A single breadcrumb segment definition.
#![allow(unused)] fn main() { pub struct BreadcrumbItem { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Construct a non-current (navigable) breadcrumb segment.
pub fn current(label: impl Into<LocalizedString>) -> Self
Construct the current (last) breadcrumb segment, announced
with aria-current="page". Current segments are never
collapsed into the overflow … menu.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on activation.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip to this breadcrumb segment, shown after a hover delay. Clears any previously set rich or composite tooltip.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip to this breadcrumb segment, looked up by registry key. Clears any previously set plain or composite tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip to this breadcrumb segment from inline content. Clears any previously set plain or composite tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip (arbitrary widget tree) to this breadcrumb segment. Clears any previously set plain or rich tooltip.
pub struct Breadcrumb
A breadcrumb navigation row with automatic overflow: when the trail is
too wide, the middle crumbs collapse into a trailing-of-root … menu while
the root and the current (last) crumb stay visible — the standard breadcrumb
collapse (Windows Explorer / web breadcrumbs / macOS path bar).
#![allow(unused)] fn main() { pub struct Breadcrumb { /* fields */ } }
Methods
pub fn new() -> Self
Construct an empty breadcrumb trail. Add segments with
item and item_id.
pub fn label(mut self, text: impl Into<LocalizedString>) -> Self
Accessible name for the Navigation landmark — distinguishes
this breadcrumb from other nav landmarks on the page
(e.g. "Files", "Settings"). Screen readers announce it as the
name of the landmark when it gains focus or is summoned.
pub fn item(mut self, item: BreadcrumbItem) -> Self
Append a BreadcrumbItem segment to the trail. Items are rendered
in insertion order, separated by chevron glyphs. Middle items (neither
root nor current) may be collapsed into the … overflow menu.
pub fn item_id(mut self, id: WidgetId) -> Self
Insert a pre-registered widget as a breadcrumb segment slot. The caller is responsible for the segment's visual + interaction. Note: a pre-registered crumb never collapses into the overflow menu (the breadcrumb has no label/action to synthesize a menu row from) — it is treated like the root/current crumbs as always-visible.
pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Append a trailing widget after all segments, pushed to the far edge
by an intervening Spacer. Common uses: a search icon, refresh button,
or current-path copy button. When a trailing slot is set, the breadcrumb
spans the full proposed width.
pub fn trailing_slot_id(mut self, id: WidgetId) -> Self
Same as trailing_slot but accepts a
pre-registered WidgetId instead of an inline widget.
pub fn is_overflowing(&self) -> Signal<bool>
Reactive signal that is true whenever any crumb is collapsed into the
overflow … menu — for adaptive chrome.
Button

Button — a labelled, activatable action trigger.
Button is the primary action surface in Teksilo. It renders a text
label (optionally with a leading, trailing, top, or bottom icon), fires
a closure on click / Space / Enter / AT click, and advertises seven
design-language variants via ButtonVariant. Chrome (fill, border,
focus ring, padding) is delegated to the active ButtonStyle; the
default RecipeButtonStyle implements the Int UI token ladder.
When to use
- Primary action:
.variant(ButtonVariant::Filled)— one per context. - Secondary / cancel: default
ButtonVariant::Plain. - Danger:
ButtonVariant::Destructive(IntUI maps this to Filled). - Text-only link:
ButtonVariant::Link/ButtonVariant::Ghost.
Accessibility
Announces as Role::Button with the resolved label as its AT name.
Keyboard: Space / Enter activate; the lone-KeyUp guard prevents spurious
re-activation when a shortcut consumes the KeyDown and returns focus here.
#![allow(unused)] fn main() { use teksilo_widgets::{Button, ButtonVariant}; use teksilo_i18n::lit; use teksilo_core::Intent; let _btn = Button::new(lit!("Save")) .variant(ButtonVariant::Filled) .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save"))); }
Builder methods at a glance
current_variant, share_interaction, variant, style, label, on_activate_fn, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, enabled, text_role, text_style, icon, icon_keeps_color, has_popup, expanded_when, leading, trailing
API reference
📖 Full rustdoc API for this module
pub enum InteractionState
Internal interaction state.
#![allow(unused)] fn main() { pub enum InteractionState { /* variants */ } }
Variants
IdleHoveredPressedFocusedDisabled
pub enum IconLocation
Where an optional icon is placed relative to the button label.
#![allow(unused)] fn main() { pub enum IconLocation { /* variants */ } }
Variants
None— No icon (default).IconOnly— Icon only, no label.Leading— Icon to the left of the label (default).Trailing— Icon to the right of the label.Top— Icon above the label.Bottom— Icon below the label.
pub struct Button
A labelled action trigger; use Button::new and chain builder methods.
#![allow(unused)] fn main() { pub struct Button { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Construct a button from a LocalizedString label. The label may
come from tr!(...) (translated) or lit!(...)
(explicit non-translated). When an I18nManager is installed, a
tr!(...) label becomes a Prop::Bound that observes the locale
version signal, so the inner TextWidget re-renders on a locale
switch without rebuilding the Button — matching TextWidget::new.
lit!(...) and the no-manager case resolve to a static String.
pub fn current_variant(&self) -> ButtonVariant
Returns the configured visual variant. Used by wrappers like
PopoverButton that
derive their own chrome colors from the same recipe-resolution
path the inner Button uses.
pub fn share_interaction(mut self, signal: Signal<InteractionState>) -> Self
Bind the button's internal interaction state to a caller-owned
Signal<InteractionState> instead of letting build() allocate
its own. Used by wrapper widgets like
PopoverButton whose
disclosure caret needs to match the label's color across hover
/ press / focus / disabled states.
The provided signal is reset to Disabled when enabled == false
during build() so the shared signal honors the button's
enabled state without the caller having to seed it.
pub fn variant(mut self, variant: ButtonVariant) -> Self
Set the Tier-1 design-language variant. The active
ButtonStyle decides whether to honour or remap it (the IntUI
default RecipeButtonStyle collapses Destructive → Filled,
Tinted/Outlined → Plain, Link → Ghost).
pub fn style(mut self, style: impl ButtonStyle) -> Self
Override the active ButtonStyle for this widget instance
only. Useful for one-off custom-painted buttons (glassmorphism
CTA, Material-3 ripple, etc.) without forking the Button.
pub fn label(mut self, label: impl Into<teksilo_core::signal::Prop<String>>) -> Self
Bind the button's label to a reactive source — replaces the
static label captured at new(...). Accepts any
impl Into<Prop<String>>: a Signal<String> for live
updates, or a plain String (which is the same as constructing
the button with that string). Mirrors
TextWidget::text.
The inner label TextWidget is built with the bound prop, so
the visible text refreshes without rebuilding the Button. The
AT node's set_name reads the current value via Prop::get.
Translation note: derive the signal with
state.map(|s| tr!(status_label(value = s)).resolve_now()) for translated
reactive labels — Button only sees the resolved String.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on activation. Use ctx.send_intent(...) to
route activation through the Action/Intent system, or inline
the behavior directly.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a tooltip that appears after a hover delay.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip registry.
The key is looked up via
TooltipRegistry at build
time; the resolved body text supports inline markup
(label, *italic*, **bold**) and the entry's
shortcut / long-form "more" fields are rendered automatically.
Overrides any previously set plain .tooltip(...) text.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline
TooltipContent — for
one-off tooltips that aren't worth registering in the central
catalog. Overrides any previously set plain .tooltip(...).
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree (Crusader Kings 3 style: tabbed sections, charts,
progress bars, conditional rows). Promotes to a focusable
Role::Dialog after the user dwells for the standard
promotion threshold. Overrides any plain or rich tooltip
previously set on this button.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Disabled buttons
ignore input and dim their content (the framework's
PaintContext::effective_enabled propagates through to the
label/icon leaves). Forwarded into the arena via
ctx.enabled_when(self_id, self.enabled.clone()) at build time —
a bound signal updates live as it changes.
pub fn text_role(mut self, role: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the label and icon's tint with a static ColorProp.
When set, the button ignores its style and the auto-derived
idle/hover/press text-role cascade — both the label text and
any icon are bound directly to this prop instead. Use for chrome
whose host enforces a single text role across all of its
sub-widgets (e.g. tab-bar overflow-dropdown triggers that must
match the strip's idle_text_role regardless of hover state).
Accepts Color, TextRole, Signal<Color>, or Signal<TextRole>.
pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self
Override the label's text style (font, size, weight). By default the
label uses the inner TextWidget's default style; pass a
TextStyleRole (e.g. TextStyleRole::BodyBold), a TextStyle, or a
Signal of either to change it — e.g. to make the label bold.
Orthogonal to Button::text_role, which only sets the color.
pub fn icon(mut self, icon: IconWidget, location: IconLocation) -> Self
Add an icon to the button at the specified location.
pub fn icon_keeps_color(mut self) -> Self
Keep the icon's own colour instead of tinting it to the label's.
The mirror of MenuItem::icon_keeps_color,
and it exists for the same reason: an icon whose colour is the information.
A filter chip carrying a user-chosen tag colour, a legend swatch, a status
disc — tinting those to the label's foreground destroys the one thing they
carry, while tinting is exactly right for a glyph that merely repeats the
label.
Two consequences worth knowing, both inherited from
ColorProp's own rules rather than
special-cased here:
- The colour must clear contrast against every fill the button takes — an accent-filled selected state as well as the resting surface.
- A literal colour does not dim when the button is disabled. An icon that should dim wants a role instead, and then it does not need this.
pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self
Declare that this button is a disclosure trigger for a
popup (menu, dialog, listbox, tree, grid). Surfaced via
set_has_popup in the a11y node so screen readers announce
it as leading into the named popup kind.
pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self
Bind a signal reporting whether this button's popup is
currently visible. The Popover / Dialog wrapper owns the
signal and flips it on show / dismiss; Button reads it in
accessibility() to publish set_expanded. Only
meaningful alongside .has_popup(...).
pub fn leading(mut self, widget: impl Widget + 'static) -> Self
Insert a widget at the leading edge of the button's content
(left in LTR, right in RTL). Composes with .icon(...): the
final order is [leading_slot, icon+label, trailing_slot],
separated by btn::BUTTON_ICON_LABEL_GAP. Single-slot —
calling .leading(...) again replaces the previous slot.
Stack multiple widgets with an explicit HStack.
The slot widget paints itself and emits its own a11y. Button
does not retint it (so e.g. a ColorSwatch keeps its own
color through every interaction state). If the slot widget
declares an AT role of its own — ColorSwatch is the canonical
case (Role::ColorWell) — pass widget.access_hidden(true)
so the trigger reads as a single Button node instead of a
Button containing a redundant ColorWell child.
pub fn trailing(mut self, widget: impl Widget + 'static) -> Self
Same as leading but at the trailing edge
(right in LTR, left in RTL). Common uses: chevron-down hint
on disclosure triggers, clear-X on search fields, status
badges on segmented control segments.
Calendar

Calendar — month-grid date picker, standalone widget.
A self-contained calendar with month/year navigation, a 6×7 day grid,
keyboard navigation matching the WAI-ARIA grid pattern, and full
AccessKit instrumentation (Role::Grid + per-cell Role::GridCell).
Used standalone for event apps and scheduling, and embedded in
DateEdit's popover.
Selection modes
Calendar::single— pick one day. Bound toSignal<Option<Date>>.Calendar::range— pick a start + end day. Bound toSignal<Option<DateRange>>. Click first day → click second day to commit. Escape mid-selection cancels the in-progress anchor.
Behaviour
- Visible month is independent of the selection — navigating past the selected month doesn't lose the selection.
- Today highlight draws a ring around today's cell whenever it's
in the visible month. Color comes from
TextRole::Accent. - Out-of-month cells (the leading days from the previous month
and trailing days from the next month that fill the 6×7 grid) are
rendered with
TextRole::Disabledand remain selectable (matching macOS / Material). To prevent selection usedisabled_date_filter. - Keyboard (matches the WAI-ARIA
gridpattern):- Arrow keys: move focus by one day.
- Home / End: first / last day of week.
- Ctrl+Home / Ctrl+End: first / last day of month.
- PageUp / PageDown: previous / next month.
- Shift+PageUp / Shift+PageDown: previous / next year.
- Enter / Space: commit focused day to selection.
- Escape: in range mode mid-selection, cancel anchor; otherwise bubble (popover hosts close).
T: jump focus to today.
Accessibility
- Container —
Role::Gridwithset_label("Calendar, May 2026")(localized). Single-mode also setsset_valueto the current ISO selection or empty; range mode sets"start – end". - Header arrow buttons —
Role::Buttonwith localized labels ("Previous month", "Next month") andAction::Clickadvertised. - Header month/year label —
Role::Button(clickable to open the month picker) withset_has_popup(HasPopup::Grid)andset_expanded(open). - Weekday header row —
Role::RowofRole::ColumnHeadercells, each labelled with the long weekday name (e.g. "Monday"). - Day cells —
Role::GridCellwith localized long-form labels ("May 2, 2026"),set_selected,set_focused,set_disabledfor filter rejections, andAction::Clickadvertised.
Example
use teksilo::widgets::{Calendar, common::datetime::Date};
let date = ctx.signal(Some(Date::constant(2026, 5, 2)));
ctx.add(
Calendar::single(date.clone())
.show_today_button(true)
.on_selection_changed(|d, ctx| ctx.send_intent(MyIntent::DateChanged(d))),
);
Builder methods at a glance
single, range, first_day_of_week, week_numbers, show_today_button, show_navigation, min_date, max_date, disabled_date_filter, label, enabled, on_selection_changed, on_range_changed, on_month_changed, on_activate, visible_month_signal, focused_date_signal, mode_signal
API reference
📖 Full rustdoc API for this module
pub struct DateRange
Inclusive range of two dates, with start <= end enforced at
construction. Used by Calendar::range.
#![allow(unused)] fn main() { pub struct DateRange { /* fields */ } }
Methods
pub fn new(a: Date, b: Date) -> Self
Construct a range; swaps start and end if needed so the
invariant start <= end always holds.
pub fn contains(&self, d: Date) -> bool
true iff d is between start and end inclusive.
pub enum CalendarMode
What the calendar body is showing — drives the WPF/Avalonia
"header-zoom" UX where clicking the title cycles to a coarser
grid, letting the user reach any year in 2-3 clicks instead of
many chevron presses. Default CalendarMode::Days.
#![allow(unused)] fn main() { pub enum CalendarMode { /* variants */ } }
Variants
Days— 6×7 day grid for the visible month. Title shows "May 2026". Header chevrons step by ±1 month and ±1 year.Months— 4×3 grid of months. Title shows "2026". Header chevrons step by ±1 year. Picking a cell zooms back intoSelf::Days.Years— 4×3 grid of years (current decade). Title shows "2020 — 2029". Header chevrons step by ±10 years (one decade). Picking a cell zooms back intoSelf::Months.
Methods
pub fn demote(self) -> Self
Mode after demoting one level (clicking the header title).
Years is the coarsest level — no further demotion.
pub enum WeekNumberDisplay
Whether and how week numbers are displayed in the leading column of the day grid.
#![allow(unused)] fn main() { pub enum WeekNumberDisplay { /* variants */ } }
Variants
None— No week-number column (default).Iso8601— ISO 8601 week number — week 1 is the week containing the first Thursday of the year. Adds a narrow column to the left of the day grid.
pub struct Calendar
Standalone month-grid date picker. See the module docs for
the full feature list and a usage example.
#![allow(unused)] fn main() { pub struct Calendar { /* fields */ } }
Methods
pub fn single(value: Signal<Option<Date>>) -> Self
Construct a calendar in single-selection mode bound to a nullable date signal.
pub fn range(value: Signal<Option<DateRange>>) -> Self
Construct a calendar in range-selection mode bound to a nullable date-range signal.
pub fn first_day_of_week(mut self, w: Weekday) -> Self
Override the locale-derived first day of the week.
pub fn week_numbers(mut self, mode: WeekNumberDisplay) -> Self
Show or hide the leading week-number column.
pub fn show_today_button(mut self, show: bool) -> Self
Show a "Today" button in the footer that jumps focus and selection (in single mode) to today.
pub fn show_navigation(mut self, show: bool) -> Self
Show or hide the prev/next month navigation arrows.
pub fn min_date(mut self, d: Date) -> Self
Earliest allowed date; days before this read as disabled.
pub fn max_date(mut self, d: Date) -> Self
Latest allowed date; days after this read as disabled.
pub fn disabled_date_filter(mut self, f: impl Fn(Date) -> bool + 'static) -> Self
Per-cell predicate. true ⇒ cell is disabled (no click, no
keyboard commit, AT marks disabled).
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Override the AT label. Default: "Calendar, May 2026" (localized, derived from the visible month).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the
arena at build time — a bound Signal<bool> updates live.
pub fn on_selection_changed( mut self, f: impl Fn(Option<Date>, &mut EventContext) + 'static, ) -> Self
Fired when the selection changes. In range mode use
on_range_changed instead — this
callback fires on every committed-day change in range mode too,
passing the just-committed endpoint.
pub fn on_range_changed( mut self, f: impl Fn(Option<DateRange>, &mut EventContext) + 'static, ) -> Self
Fired in range mode whenever a range is committed (second click
of the pair). None fires when the user resets via Escape or
when the bound value is externally cleared.
pub fn on_month_changed(mut self, f: impl Fn(YearMonth, &mut EventContext) + 'static) -> Self
Fired when the visible month changes (navigation arrows, keyboard PageUp/Down, today jump).
pub fn on_activate(mut self, f: impl Fn(Date, &mut EventContext) + 'static) -> Self
Fired in single mode on Enter or click (i.e. when the user "double commits"). Distinct from selection change; popover hosts use this to dismiss themselves only on a real click, not on keyboard navigation.
pub fn visible_month_signal(&self) -> Signal<YearMonth>
Reactive accessor for the currently-visible month.
pub fn focused_date_signal(&self) -> Signal<Date>
Reactive accessor for the focused-cell date.
pub fn mode_signal(&self) -> Signal<CalendarMode>
Reactive accessor for the body mode (Days / Months / Years). Drives the header-zoom UX. Apps can read this to react to mode changes, or write to it to programmatically zoom in/out.
Card

Card — a surface container with optional header, content, and footer slots.
Card renders an opaque or tinted rounded-rectangle backdrop, an optional
drop shadow, and up to three stacked content slots (header / content /
footer). It is the standard building block for list-item cards, dashboard
tiles, onboarding panels, and any widget that needs a visually distinct
raised or outlined surface. Chrome (shadow, background, corner radius,
padding) is delegated to the active CardStyle
so the visual language can be changed per-call (.style(...)) or
theme-wide via theme.style_slots.card.
When to use
CardVariant::Elevated— a dashboard tile or list card that should "float" above the page surface.CardVariant::Outlined— a bordered grouping box without shadow.CardVariant::Plain— the content sits on the default surface; no visible chrome (useful for spacing only).
Accessibility
Announces as Role::Group. The slots' own accessibility nodes are
included in the subtree; the card itself carries no additional AT name.
#![allow(unused)] fn main() { use teksilo_widgets::Card; use teksilo_core::styles::CardVariant; use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; let _card = Card::new() .variant(CardVariant::Elevated) .content(TextWidget::new(lit!("Hello, card!"))); }
Builder methods at a glance
header, header_id, content, content_id, footer, footer_id, shadow, background, corner_radius, padding, variant, style
API reference
📖 Full rustdoc API for this module
pub struct Card
A card container with shadow, background, and optional header/content/footer.
#![allow(unused)] fn main() { pub struct Card { /* fields */ } }
Methods
pub fn new() -> Self
Construct an empty card with no slots and the default CardVariant::Plain.
pub fn header(mut self, widget: impl Widget + 'static) -> Self
Set the header slot (topmost section) to an inline widget.
pub fn header_id(mut self, id: WidgetId) -> Self
Set the header slot to a pre-registered WidgetId.
pub fn content(mut self, widget: impl Widget + 'static) -> Self
Set the main content slot (middle section) to an inline widget.
pub fn content_id(mut self, id: WidgetId) -> Self
Set the main content slot to a pre-registered WidgetId.
pub fn footer(mut self, widget: impl Widget + 'static) -> Self
Set the footer slot (bottommost section) to an inline widget.
pub fn footer_id(mut self, id: WidgetId) -> Self
Set the footer slot to a pre-registered WidgetId.
pub fn shadow(mut self, shadow: Shadow) -> Self
Override the drop shadow. Accepts a Shadow token (see
teksilo_tokens::Shadow). The default shadow comes from the active
CardStyle for the chosen CardVariant.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Override the background. Default (unset) is the variant's default
(SurfaceRole::Main for Plain/Outlined/Elevated, SurfaceRole::Raised
for Filled). Accepts Color, a role, or Signal<Color>.
pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self
Override the corner radius (default: theme components.card.corner_radius).
Accepts a static f32 or a reactive Signal<f32>.
pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self
Override the padding (default: theme components.card.padding).
Accepts a static f32 or a reactive Signal<f32>.
pub fn variant(mut self, variant: CardVariant) -> Self
Pick the design-language variant. Default Plain. The active
CardStyle decides what each variant means visually (the IntUI
default maps Plain → no shadow + surface_main, Elevated →
shadow_md + surface_main, Outlined → border + surface_main,
Filled → shadow_md + surface_raised).
pub fn style(mut self, style: impl teksilo_core::styles::CardStyle) -> Self
Per-call style override. Replaces the theme-wide default
CardStyle for just this Card instance.
Center

Center — a single-child wrapper that centers its child within the available space.
On a bounded axis (the parent proposes an exact size), Center fills
that dimension and places the child in the middle. On an unbounded axis
(the parent leaves it open, as a stack does on its main axis), Center
shrink-wraps to the child's natural size rather than collapsing to zero —
this prevents the child from overflowing a prior sibling. Center always
reports flex = 0, so it never claims slack from a stack's distribution
pass; to center content within leftover space, wrap it in an Expand:
Expand::horizontal().child(Center::new().child(w)).
The child is measured under the constraint Center received (a
loose-but-bounded proposal, like Flutter's Center): rigid children keep
their natural size and are centered, while adaptive children respond to
the bound — an ellipsis TextWidget truncates at the slot width instead
of overflowing symmetrically, and wrapping text reports its real wrapped
height.
When to use
- Center a small widget inside a bounded slot (e.g., an icon in a fixed square cell).
- Shrink-wrap and center an element inside a layout that provides an exact proposal in both axes.
For claiming all remaining stack space and then centering within it, use
Expand wrapping Center instead.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{Center, RectWidget}; // Center a rect in the full slot provided by its parent let _centered = Center::new().child(RectWidget::new()); }
Builder methods at a glance
child_id, child
API reference
📖 Full rustdoc API for this module
pub struct Center
Centers a single child within the space this widget is given.
Sizing follows the incoming constraint, per axis: Center fills a
bounded axis (the tree root, or inside an Expand / wrapper that
proposes exact bounds) and shrink-wraps to the child on an unbounded
axis. So a bare Center does not claim slack inside an HStack /
VStack — those leave their main axis open, and Center sizes to its
child there (like Flutter's Center / Align, or Compose's Box),
rather than collapsing to zero and letting the child overflow.
Centering and expanding are separate concerns: Center reports
flex = 0 and is a pure alignment wrapper, never a space-claiming one. To
center a child within the leftover space of a stack, give it flex with
Expand — Expand::horizontal { Center { child } } (the analogue of
Flutter's Expanded(child: Center(...))).
#![allow(unused)] fn main() { pub struct Center { /* fields */ } }
Methods
pub fn new() -> Self
Create a new Center with no child attached.
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
Checkbox

Checkbox — a two-state or tristate checkbox with an optional label.
Checkbox renders a square (or rounded-square / circle) toggle box
alongside an optional label and caption. Two modes are supported:
- Two-state (
Checkbox::new): toggles aSignal<bool>betweentrue(checked) andfalse(unchecked) on click or Space. - Tristate (
Checkbox::tristate): cycles aSignal<CheckState>betweenCheckedandUncheckedon user interaction; theIndeterminatestate is set only by external sources such asTreeCheckedModelaggregation — clicking fromIndeterminategoes toChecked, not a further third state.
Chrome (box shape, fill, focus ring) is driven by the active
CheckboxStyle; three visual variants are available via
CheckboxVariant.
Accessibility
Announces as Role::CheckBox. A label is required in debug builds
unless .labels_hidden(true) is set (for embedding inside a composite
row that owns the AT name). Keyboard: Space toggles; lone-KeyUp guard
prevents spurious toggle when focus is restored after a shortcut.
#![allow(unused)] fn main() { use teksilo_widgets::Checkbox; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let checked = Signal::new(false); let _cb = Checkbox::new(checked) .label(lit!("Accept terms and conditions")); }
Builder methods at a glance
tristate, labels_hidden, label, caption, enabled, variant, style, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct Checkbox
A checkbox that toggles a Signal<bool> or cycles a Signal<CheckState>.
#![allow(unused)] fn main() { pub struct Checkbox { /* fields */ } }
Methods
pub fn new(checked: Signal<bool>) -> Self
Create a two-state checkbox bound to a Signal<bool>.
pub fn tristate(state: Signal<CheckState>) -> Self
Create a tristate checkbox bound to a Signal<CheckState>.
User clicks toggle Checked ↔ Unchecked (clicking from Indeterminate
checks the whole). The Indeterminate state is reserved for external
sources — TreeCheckedModel aggregation when descendants are mixed,
"select all" indicators, etc. Matches the Outlook / Files-app
folder-checkbox semantic. Useful for parent checkboxes in tree views.
pub fn labels_hidden(mut self, hidden: bool) -> Self
Suppress the visual label/caption AND the debug-time
"missing accessible label" assertion. Use this only when
the checkbox is embedded inside a composite that owns the
row's accessible name (e.g. StandardListItem /
StandardTreeItem, where the row's accessibility(builder)
calls set_name(...) with the row label).
A11y contract: when labels_hidden(true) is set, the
caller MUST guarantee that an addressable AT ancestor
provides the name — either via that ancestor's own
accessibility() impl or a builder-level
.access_label* override. Without it the AT tree exposes a
Role::CheckBox node with no name; screen readers announce
"checkbox, checked" with no context. The Outlook /
Files-app row pattern (where the row label covers the
embedded checkbox) is the supported use case.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the visible label rendered to the right of the checkbox box,
also used as the AT name. Required unless .labels_hidden(true) is set.
pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self
Secondary explanatory text rendered below the label, left-aligned
with the label (not the box). Uses the small / text_secondary
style. Has no effect unless label(...) is also set.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the
arena via ctx.enabled_when(self_id, self.enabled.clone()) at
build time — a bound Signal<bool> updates live.
pub fn variant(mut self, variant: CheckboxVariant) -> Self
Pick the design-language variant. Default Square. The active
CheckboxStyle impl decides what the variant means visually
(the IntUI RecipeCheckboxStyle honours all three variants
directly via corner-shape changes).
pub fn style(mut self, style: impl teksilo_core::styles::CheckboxStyle) -> Self
Per-call style override. Replaces the theme-wide default
CheckboxStyle for just this Checkbox instance — same role as
Button::style(...).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip shown after a hover delay. Clears any previously set rich or composite tooltip (last-call wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip
registry. See Button::rich_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. See Button::composite_tooltip.
CodeEditor
The public editing surfaces: CodeEditor and PlainTextEditor.
The wrapper is the focus + event target; it owns the gutter (optional), the
paint-only body, and the overlay scrollbars, joined to them only through the
shared CodeEditorState. This mirrors
RichTextEditor exactly — the wrapper carries focus so a future style may
place the body anywhere in its chrome without the focus semantics moving —
and adds the two things a source editor needs on top: a line-number gutter to
the left, and a paint pass that draws the current-line band (across gutter and
body) and the matched-bracket cells behind the text.
PlainTextEditor is the same machinery with the code affordances off and
wrapping on — a notes field, a commit message — so the two never drift.
Builder methods at a glance
read_only, wrap_mode, v_scroll_policy, h_scroll_policy, overscroll_behavior, window_to_clip, min_lines, max_lines, font_family, font_size_scale, follow_text_scale, on_change, background, text_color, caret_color, selection_color, gutter, current_line_highlight, indent_style, tab_width, use_soft_tabs, auto_indent, bracket_pairs, auto_close_brackets, bracket_matching, line_comment, completion_provider, auto_complete, handle
API reference
📖 Full rustdoc API for this module
pub struct CodeEditor
A multi-line source-code editing surface: gutter, current-line highlight, indentation, bracket handling, and multiple carets.
Construct with CodeEditor::new (editable) or CodeEditor::read_only
(view + select + copy). Every code affordance is injected configuration, not
a built-in language — see CodeConfig.
#![allow(unused)] fn main() { pub struct CodeEditor { /* fields */ } }
Methods
pub fn new(document: TextDocument) -> Self
An editable code editor bound to document: gutter on, current-line
highlight on, no wrapping. Code affordances (comment token, bracket
pairs) stay off until the application supplies them — the editor never
guesses a language.
pub fn read_only(document: TextDocument) -> Self
A read-only code viewer bound to document: no caret, navigation and
copy only, Role::Document. Still gets the gutter and syntax colours.
pub fn wrap_mode(self, mode: WrapMode) -> Self
Set the line-wrap mode. CodeEditor defaults to WrapMode::None (source
lines must not fold, or the gutter's one-number-per-line correspondence
breaks); pair with .h_scroll_policy(Auto) to scroll wide lines.
pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self
Vertical scrollbar policy (default Auto).
pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self
Horizontal scrollbar policy (default Auto).
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Wheel scroll-chaining at the editor's scroll boundary. Chain (default)
hands leftover scroll to an enclosing scrollable; Contain absorbs it.
pub fn window_to_clip(self, on: bool) -> Self
Cull the render to the visible clip band (default false). Turn on only
for an editor deliberately laid out at full document height inside an
outer ScrollArea (v_scroll_policy(AlwaysOff) + min_lines(1)): the
body's bounds then span the whole document, and this renders only the
on-screen slice instead of every line. A normally-scrolling editor already
renders just a viewport's worth, so it needs nothing.
pub fn min_lines(mut self, lines: u32) -> Self
Minimum visible height in lines — switches the editor from greedy (fill
the proposal) to intrinsic sizing (grow with content up to max_lines,
then scroll). The composer pattern.
pub fn max_lines(mut self, lines: u32) -> Self
Maximum visible height in lines — caps intrinsic growth.
pub fn font_family(self, family: impl Into<String>) -> Self
Fallback font family for the document's text. None (the default) keeps
the typesetter's registry default; a code editor should pass a monospace
family so columns line up.
pub fn font_size_scale(self, scale: f32) -> Self
Per-editor logical font-size multiplier (1.0 = 100 %), composed with
the accessibility text scale when follow_text_scale
is on. Sharp — shapes at a larger ppem.
pub fn follow_text_scale(self, follow: bool) -> Self
Whether the editor grows text with the global accessibility text scale
(default true). Turn off for a WYSIWYG surface whose font sizes are
document content. Composed with font_size_scale.
pub fn on_change(self, callback: impl Fn() + 'static) -> Self
A callback fired once per drain batch that contained a real content edit.
pub fn background(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the editor background colour (accepts Color, a theme role, or a
Signal). None-equivalent default tracks the theme's editor_bg.
pub fn text_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the text colour. Default tracks the theme's editor_fg.
pub fn caret_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the caret colour. Default tracks the theme's editor_caret.
pub fn selection_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the selection colour. A pinned colour opts out of the window-inactive desaturation.
pub fn gutter(mut self, show: bool) -> Self
Whether the line-number gutter is shown (default true).
pub fn current_line_highlight(self, on: bool) -> Self
Whether the caret's line gets a full-width background wash (default
true for CodeEditor).
pub fn indent_style(self, style: IndentStyle) -> Self
Set the indentation style directly (spaces of a width, or tabs rendered a width wide).
pub fn tab_width(self, width: u8) -> Self
Set the indent width, keeping the current spaces-vs-tabs kind.
pub fn use_soft_tabs(self, soft: bool) -> Self
Whether indentation is written with spaces (true, the default) or a tab
character (false), keeping the current width.
pub fn auto_indent(self, on: bool) -> Self
Whether Enter carries the current line's indentation onto the new line
(default true).
pub fn bracket_pairs(self, pairs: impl Into<Vec<BracketPair>>) -> Self
The delimiter pairs the editor auto-closes and match-highlights. Empty (the default) disables both.
pub fn auto_close_brackets(self, on: bool) -> Self
Whether typing an opener inserts its closing partner (default false;
needs configured bracket_pairs).
pub fn bracket_matching(self, on: bool) -> Self
Whether the delimiter matching the caret's is highlighted (default
false; needs configured bracket_pairs).
pub fn line_comment(self, token: impl Into<String>) -> Self
The token that starts a line comment ("//", "#", "--"). Enables
Ctrl+/ comment toggling; unset (the default) leaves it a no-op rather
than guessing.
pub fn completion_provider( self, provider: impl Fn(&CompletionContext) -> Vec<CompletionItem> + 'static, ) -> Self
Supply the completion candidates. The provider is called for the word
being completed and given a CompletionContext; the editor filters its
result by the live prefix, shows the popup, and replaces the word on
accept. Language-agnostic — the app knows the candidates, the editor knows
the mechanics. Without a provider there is no completion.
pub fn auto_complete(self, auto: bool) -> Self
Whether typing an identifier character opens the completion popup
automatically (default true). When off, only Ctrl+Space opens it.
pub fn handle(&self) -> CodeEditorHandle
A cloneable handle to drive the editor from a toolbar, shortcut, or test.
pub struct PlainTextEditor
A multi-line plain-text editing surface — the code editor with its code affordances off and wrapping on. A notes field, a commit message, a description box.
It shares CodeEditor's machinery (caret, selection, IME, clipboard,
scrolling, accessibility); the difference is configuration, so the two never
drift. Construct with PlainTextEditor::new / PlainTextEditor::read_only.
#![allow(unused)] fn main() { pub struct PlainTextEditor { /* fields */ } }
Methods
pub fn new(document: TextDocument) -> Self
An editable plain-text editor bound to document: no gutter, no
current-line highlight, word wrapping, and no code affordances.
pub fn read_only(document: TextDocument) -> Self
A read-only plain-text viewer bound to document.
pub fn min_lines(mut self, lines: u32) -> Self
Restrict growth to [min, max] lines (intrinsic sizing — the composer
pattern).
pub fn max_lines(mut self, lines: u32) -> Self
Cap intrinsic growth at lines.
pub fn wrap_mode(mut self, mode: WrapMode) -> Self
Set the line-wrap mode (default Word).
pub fn font_family(mut self, family: impl Into<String>) -> Self
Fallback font family.
pub fn follow_text_scale(mut self, follow: bool) -> Self
Whether the editor follows the global accessibility text scale.
pub fn font_size_scale(mut self, scale: f32) -> Self
Per-editor logical font-size multiplier (1.0 = 100 %).
pub fn on_change(mut self, callback: impl Fn() + 'static) -> Self
A callback fired on each content-changing edit batch.
pub fn background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the background colour.
pub fn handle(&self) -> CodeEditorHandle
A cloneable handle to drive the editor.
CodeEditorHandle

Multi-line plain-text and code editing surfaces.
Three faces over one core:
CodeEditor— a source editor: gutter, current-line highlight, indentation, bracket handling, multiple carets.PlainTextEditor— the same core with the code affordances off and wrapping on: a notes field, a commit message, a description box.LogView— read-only, append-only, tail-following.
They are one implementation because they differ in configuration, not in kind. All three are a monospaced-or-not run of lines with a caret in it; a separate widget per face would triplicate the caret, selection, IME, clipboard, scrolling, and accessibility and let them drift.
Why not RichTextEditor
RichTextEditor already edits multi-line text, and this deliberately does
not build on it. Its command vocabulary is tables, lists, blockquotes, and
bold — reusing it would put Tab-navigates-a-table-cell and
Ctrl+B-emboldens into a source file, where the first is wrong and the second
is meaningless. Its state carries a table-aware Ctrl+A ladder and a rich
clipboard fragment; this one carries an indent policy and a caret vector.
The overlap is real but it is the clock — the caret blink, the debounce
window, the scroll arithmetic — and that lives in the crate-internal
common::editor_runtime, shared by both.
Language-agnostic by construction
There is no Language enum here. Comment tokens, bracket pairs, indent
width, and highlighting are CodeConfig values the application supplies:
the editor knows how to toggle a line comment, not that Rust uses //.
Guessing would be worse than not knowing — inserting // into a Python file
corrupts it silently.
Builder methods at a glance
cursor_position, cursor_position_signal, caret_count, bracket_match, has_selection, can_undo, undo, redo, copy, cut, paste, select_all, is_read_only, can_redo, document_version, scroll_y
API reference
📖 Full rustdoc API for this module
pub struct CodeEditorHandle
A handle onto a live editor, cloneable and detachable from the widget.
The EditorHandle pattern: an app keeps one to drive the editor from a
toolbar, a shortcut, or a test without holding the widget itself.
#![allow(unused)] fn main() { pub struct CodeEditorHandle { /* fields */ } }
Methods
pub fn cursor_position(&self) -> usize
The caret's document position.
pub fn cursor_position_signal(&self) -> teksilo_core::Signal<usize>
The primary caret's document position — a character offset into the whole document, not a line or column — as a reactive signal. Bind it in a status bar to show a caret position that tracks every caret move, not only edits.
pub fn caret_count(&self) -> teksilo_core::Signal<usize>
Live caret count — 1 unless multi-caret editing is active.
pub fn bracket_match(&self) -> teksilo_core::Signal<Option<(usize, usize)>>
The bracket next to the caret and its match, as document positions, or
None. Populated only when the editor was configured with
match_brackets and bracket pairs; a status surface can bind it, or an
app can read it to drive its own overlay.
pub fn has_selection(&self) -> teksilo_core::Signal<bool>
pub fn can_undo(&self) -> teksilo_core::Signal<bool>
pub fn undo(&self)
Undo this editor's last edit.
The handle could report can_undo long before it could
act on it, which left a host able to light an Undo button here and
unable to make it do anything. Ctrl+Z inside the widget always worked;
this is the same command from outside.
pub fn redo(&self)
Redo this editor's last undone edit.
pub fn copy(&self, ctx: &teksilo_core::widget::EventContext<'_>)
Copy the selection to the clipboard.
pub fn cut(&self, ctx: &teksilo_core::widget::EventContext<'_>)
Cut the selection to the clipboard.
pub fn paste(&self, ctx: &teksilo_core::widget::EventContext<'_>)
Paste over the selection.
pub fn select_all(&self)
Select the whole document.
pub fn is_read_only(&self) -> bool
Is this editor refusing edits?
pub fn can_redo(&self) -> teksilo_core::Signal<bool>
pub fn document_version(&self) -> teksilo_core::Signal<u64>
Bumps on every content or format change.
pub fn scroll_y(&self) -> teksilo_core::Signal<f32>
pub struct CompletionItem
A completion candidate. Build with CompletionItem::new and the fluent
setters; insert_text defaults to label.
#![allow(unused)] fn main() { pub struct CompletionItem { /* fields */ } }
Methods
pub fn new(label: impl Into<String>) -> Self
A candidate whose inserted text is its label.
pub fn insert_text(mut self, text: impl Into<String>) -> Self
Override the text inserted on accept (when it differs from the label).
pub fn detail(mut self, detail: impl Into<String>) -> Self
Trailing dimmed detail (a type or signature).
pub fn kind(mut self, kind: CompletionKind) -> Self
The leading badge category.
pub enum CompletionKind
The category of a completion candidate — drives a small leading badge only. Deliberately a fixed, language-neutral set: the editor renders a glyph, the application decides which candidate is which kind.
#![allow(unused)] fn main() { pub enum CompletionKind { /* variants */ } }
Variants
TextKeywordFunctionMethodVariableFieldTypeModuleConstantSnippet
pub struct CompletionContext
What a completion provider is told about the caret when asked for candidates.
#![allow(unused)] fn main() { pub struct CompletionContext<'a> { /* fields */ } }
pub enum IndentStyle
How a line's leading indentation is written.
#![allow(unused)] fn main() { pub enum IndentStyle { /* variants */ } }
Variants
Spaces—widthspaces per indent level.Tabs— One tab character per level, renderedwidthcolumns wide.
Methods
pub fn unit(&self) -> String
The text one indent level inserts.
pub fn width(&self) -> u8
How many columns one level occupies on screen. Both styles need this: spaces to know how many to strip on dedent, tabs to render the stop.
pub struct BracketPair
A pair of characters the editor treats as opening and closing delimiters.
Used for auto-closing and for match highlighting. The application declares
the set, because the same character means different things per language:
< is a bracket in a generic parameter list and a less-than sign in
arithmetic, and only the caller knows which document this is.
#![allow(unused)] fn main() { pub struct BracketPair { /* fields */ } }
Methods
pub const fn new(open: char, close: char) -> Self
pub const COMMON_BRACKETS
The three pairs that are structural in essentially every bracketed language. A convenience starting point, not a default — an editor with no configured pairs simply does no bracket handling, which is correct for prose or a log.
#![allow(unused)] fn main() { pub const COMMON_BRACKETS: &[BracketPair] = &[ BracketPair::new('(', ')'), BracketPair::new('[', ']'), BracketPair::new('{', '}'), ]; }
pub struct CodeConfig
Editing behaviour the code editor applies, all supplied by the application.
#![allow(unused)] fn main() { pub struct CodeConfig { /* fields */ } }
Methods
pub fn closing_for(&self, open: char) -> Option<char>
The closing partner for open, if it is a configured opening delimiter.
pub fn opening_for(&self, close: char) -> Option<char>
The opening partner for close, if it is a configured closing delimiter.
Collapse
Collapse — a wrapper widget that animates its child between
hidden and natural size when an external Signal<bool> toggles.
Drives a progress: Signal<f32> ∈ [0, 1] (0 = collapsed,
1 = expanded) and reports its own size as (natural_w, natural_h * progress) while the child lays out at full natural size — the
framework's clip pass crops the overflow. This keeps the animation
visible across the whole duration, instead of compressing the
visible portion into the final few milliseconds (which is what
happened when an animated MaxSize::max_height slid against a
10000-px sentinel that vastly overshot the child's natural height).
let expanded = ctx.signal(false);
ctx.add(Collapse::new(expanded.clone()).child(advanced_settings));
// ...elsewhere:
expanded.set(true); // animates open over `motion.duration_collapse`
Honors prefers-reduced-motion: under reduced motion, progress
snaps to its end value instead of tweening.
Builder methods at a glance
child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Collapse
Wraps a child and animates it between hidden (progress=0) and
natural size (progress=1), driven by an external Signal<bool>.
#![allow(unused)] fn main() { pub struct Collapse { /* fields */ } }
Methods
pub fn new(expanded: Signal<bool>) -> Self
Build a collapse wrapper bound to expanded. Initially
collapsed iff expanded.get() is false at the first
build().
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
ColorEdit

ColorEdit — compact field-style color picker trigger that opens
a popover containing a ColorPicker.
Direct analog of DateEdit. The
trigger is a Button with a reactive ColorSwatch in its
leading slot, the current hex as the label, and an optional
chevron in its trailing slot. Click, Enter, Space, or Alt+Down
opens the popover; Escape or click-outside closes it. The inner
picker writes through the same bound Signal<Color>, so external
observers see live updates as the user drags within the popover
(no commit step).
Built on [PopoverButton]:
the overlay wiring (dormant content + show / dismiss + AT
has_popup + expanded) lives there. This file is just the
ColorEdit-specific assembly — picker config pass-through, the
reactive trigger, and the nullable-binding bridge.
Accessibility
The trigger declares Role::Button
(via Button), HasPopup::Dialog
(via PopoverButton), and tracks the popover open state through
set_expanded. The label binds reactively to the hex value so
AT name updates as the picker mutates the bound color.
Example
use teksilo_core::signal::Signal;
use teksilo_tokens::Color;
let color = ctx.signal(Color::new(0.21, 0.52, 0.89, 1.0));
let _edit = ColorEdit::new(color)
.alpha_enabled(true)
.show_chevron(true);
Builder methods at a glance
nullable, alpha_enabled, swatches, swatch_columns, picker_layout, show_rgb_spinners, show_hsv_spinners, show_hex_input, show_hex_in_trigger, show_chevron, trigger_swatch_size, placement, dismiss_behavior, label, enabled, on_open, on_close, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct ColorEdit
Compact color cell that opens a full ColorPicker in a popover when activated.
#![allow(unused)] fn main() { pub struct ColorEdit { /* fields */ } }
Methods
pub fn new(value: Signal<Color>) -> Self
Bind to a non-nullable color signal. The trigger and the picker both read from and write to the same signal.
pub fn nullable(value: Signal<Option<Color>>) -> Self
Bind to a nullable color signal. None is treated as transparent
black for picker math; any user interaction produces a concrete
Some(color). To clear back to None, compose a separate
Clear button alongside the ColorEdit.
pub fn alpha_enabled(mut self, enabled: bool) -> Self
Enable or disable the alpha channel in the picker and the hex trigger label.
pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self
Provide a palette of preset swatches shown in the popover —
statically, or reactively via a bound Signal<Vec<Color>> so the
palette updates without reopening the popover.
pub fn swatch_columns(mut self, n: usize) -> Self
Number of columns in the preset swatch grid. Defaults to 6; clamped to at least 1.
pub fn picker_layout(mut self, l: ColorPickerLayout) -> Self
Select a popover layout variant — ColorPickerLayout::Compact
(default, minimal height) or Standard / Wide for richer controls.
pub fn show_rgb_spinners(mut self, s: bool) -> Self
Show or hide the RGB (0–255) component spinners in the popover.
pub fn show_hsv_spinners(mut self, s: bool) -> Self
Show or hide the HSV (hue/saturation/value) component spinners in the popover.
pub fn show_hex_input(mut self, s: bool) -> Self
Show or hide the hex string input in the popover.
pub fn show_hex_in_trigger(mut self, s: bool) -> Self
Show or hide the formatted hex value as the trigger button label.
pub fn show_chevron(mut self, s: bool) -> Self
Show or hide the trailing chevron glyph on the trigger button.
pub fn trigger_swatch_size(mut self, size: f32) -> Self
Override the size of the color swatch thumbnail in the trigger button (logical pixels).
pub fn placement(mut self, p: OverlayPlacement) -> Self
Override where the popover appears relative to the trigger.
Default is OverlayPlacement::BelowPreferred.
pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self
Override how the popover is dismissed. Default is
DismissBehavior::EscapeOrClickOutside.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Replace the trigger button's visible label with a static localized
string. When set, the hex value is no longer displayed in the trigger
(combine with .show_hex_in_trigger(false) if needed).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn on_open(mut self, f: impl Fn() + 'static) -> Self
Install a callback fired when the color-picker popover opens.
The signature is Fn() (no EventContext)
because on_close is invoked from the overlay-dismiss path,
which has no ctx in scope. To keep the open/close pair
symmetric, on_open matches. If you need ctx in a
color-editing-mode callback, attach an on_tap on a sibling
trigger that wakes the editor explicitly.
pub fn on_close(mut self, f: impl Fn() + 'static) -> Self
Install a callback fired when the color-picker popover closes.
See on_open for why this is Fn() and not
Fn(&mut EventContext).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — calling this
clears the other slots (last setter wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip identified by a registry key.
Mutually exclusive with tooltip,
rich_tooltip_content, and
composite_tooltip — calling this
clears the other slots (last setter wins).
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from an inline TooltipContent value.
Mutually exclusive with tooltip,
rich_tooltip, and
composite_tooltip — calling this
clears the other slots (last setter wins).
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with tooltip,
rich_tooltip, and
rich_tooltip_content — calling
this clears the other slots (last setter wins).
ColorPicker

ColorPicker — embeddable composite color selector.
Combines a 2D HSV canvas, 1D hue and alpha strips, RGB and HSV
component spinners, a hex input, a current-color preview, and an
optional preset swatch grid into a single bound widget. Driven by a
Signal<Color> (or Signal<Option<Color>>) source of truth — every
subcomponent reads from / writes to the same signal so the various
representations stay in lockstep.
Layouts
ColorPickerLayout::Compact— HSV canvas + hue strip + hex input. Minimal vertical footprint, suitable for popovers.ColorPickerLayout::Standard(default) — HSV canvas + hue strip + alpha strip (when enabled), with RGB spinners, hex input, and preset swatches stacked beneath. The everything-on layout for inspector panes and settings dialogs.ColorPickerLayout::Wide— HSV canvas with strips on the right, spinners stacked vertically alongside the swatch grid. For wide property pages.
Accessibility
Root: Role::Group with a localized
label and Live::Polite so screen readers announce committed color
changes. The HSV canvas's subtree is excluded from the AT tree
(no ARIA precedent for 2D pointer gestures); the hue strip, alpha
strip, RGB / HSV spinners, hex input, current-color preview, and
swatch grid each carry their own appropriate role and value.
Builder methods at a glance
nullable, style, alpha_enabled, show_hsv_canvas, show_hue_strip, show_alpha_strip, show_rgb_spinners, show_hsv_spinners, show_hex_input, show_preview, show_swatches, show_footer, on_done, on_cancel, swatches, swatch_columns, layout, label, enabled, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, current
API reference
📖 Full rustdoc API for this module
pub const DEFAULT_SWATCHES
Default 12-color preset palette (Int UI–flavored). Apps can use
this verbatim or pass their own via ColorPicker::swatches.
#![allow(unused)] fn main() { pub const DEFAULT_SWATCHES: `Color; }
pub struct ColorPicker
Embeddable HSV+RGB+hex+alpha+swatches color picker.
See the [module docsfor layout options, accessibility, and integration patterns. UseColorEdit`
to wrap this in a compact trigger + popover pattern.
use teksilo_core::signal::Signal;
use teksilo_tokens::Color;
use teksilo_widgets::color_picker::{ColorPicker, ColorPickerLayout};
let color = ctx.signal(Color::new(0.42, 0.70, 0.35, 1.0));
let _picker = ColorPicker::new(color)
.layout(ColorPickerLayout::Compact)
.alpha_enabled(false);
#![allow(unused)] fn main() { pub struct ColorPicker { /* fields */ } }
Methods
pub fn new(value: Signal<Color>) -> Self
Bind to a non-nullable color signal.
pub fn nullable(value: Signal<Option<Color>>) -> Self
Bind to a nullable color signal. None is treated as
transparent black for picker math; any commit produces a
concrete Some(color). Apps that want a "clear to None"
affordance should expose a separate Clear button alongside
the picker.
pub fn style(mut self, style: impl teksilo_core::styles::ColorPickerStyle) -> Self
Per-call style override. Higher precedence than the theme-wide
style_slots.color_picker slot.
pub fn alpha_enabled(mut self, e: bool) -> Self
Enable or disable the alpha channel (hue-strip alpha strip + a spinner + hex digit pair).
pub fn show_hsv_canvas(mut self, s: bool) -> Self
Show or hide the 2D HSV gradient canvas. Hidden in headless or accessibility-only contexts where the pointer-drag surface is not useful.
pub fn show_hue_strip(mut self, s: bool) -> Self
Show or hide the vertical hue selection strip.
pub fn show_alpha_strip(mut self, s: bool) -> Self
Show or hide the vertical alpha strip. Defaults to the value of
alpha_enabled; call this to decouple them (e.g. show the strip
without enabling the alpha spinner).
pub fn show_rgb_spinners(mut self, s: bool) -> Self
Show or hide the RGB (0–255) component spinners row.
pub fn show_hsv_spinners(mut self, s: bool) -> Self
Show or hide the HSV (hue 0–359°, saturation 0–100%, value 0–100%) spinners row.
pub fn show_hex_input(mut self, s: bool) -> Self
Show or hide the hex string input field.
pub fn show_preview(mut self, s: bool) -> Self
Show or hide the current-color preview swatch (Standard / Wide layouts).
pub fn show_swatches(mut self, s: bool) -> Self
Show or hide the preset swatch grid (Standard / Wide layouts only).
pub fn show_footer(mut self, s: bool) -> Self
Show a Done / Cancel footer at the bottom of the picker.
Default false for embedded use (the bound signal is the
commit channel — there is no "uncommitted" state). Wrappers
that present the picker as a popover (e.g. ColorEdit)
flip this to true so the user has explicit accept / dismiss
affordances; the buttons fire Self::on_done /
Self::on_cancel respectively.
pub fn on_done(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Callback fired when the user activates the footer's Done
button. The picker has already been writing through to the
bound signal as the user dragged / typed, so Done's job is
purely to dismiss the surrounding surface (popover, sheet,
dialog). Only meaningful when show_footer(true).
pub fn on_cancel(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Callback fired when the user activates the footer's Cancel
button. The picker itself does not restore any value —
that's the caller's responsibility (e.g. ColorEdit captures a
snapshot at popover-open time and writes it back here). The
callback's typical implementation is
value.set(snapshot.get()); ctx.dismiss_self_overlay_chain();.
Only meaningful when show_footer(true).
pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self
Replace the default 12-color DEFAULT_SWATCHES with a custom
palette — statically, or reactively via a bound Signal<Vec<Color>>
that updates live without rebuilding the picker.
pub fn swatch_columns(mut self, n: usize) -> Self
Number of columns in the preset swatch grid. Defaults to 6; clamped to at least 1.
pub fn layout(mut self, l: ColorPickerLayout) -> Self
Select the overall layout variant. Defaults to ColorPickerLayout::Standard.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the accessible group label for the picker root node. Defaults to the localized "Color picker" string.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with Self::rich_tooltip, Self::rich_tooltip_content,
and Self::composite_tooltip — the last setter called wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up from the registry by key.
Mutually exclusive with the other tooltip setters — the last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach an inline rich tooltip from an already-constructed crate::tooltip::TooltipContent.
Mutually exclusive with the other tooltip setters — the last call wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with the other tooltip setters — the last call wins.
pub fn current(&self) -> Color
Read the current bound color. Convenience for tests / apps that
hold a ColorPicker reference; otherwise prefer reading the
Signal<Color> you passed in.
ColorSwatch

ColorSwatch — single clickable color cell with Role::ColorWell.
Public widget so apps can compose their own swatch rows or palettes
outside of the bundled SwatchGrid. Renders an optional checkerboard
underlay when color.a() < 1.0 so transparent swatches read correctly.
The displayed color is a Prop<Color> — pass a static Color for a
fixed palette entry or a Signal<Color> for a live preview that
re-paints whenever the bound value changes (used by ColorPicker's
current-color preview and ColorEdit's trigger swatch).
Accessibility
Declares Role::ColorWell; set_color_value carries the RGBA value
and set_value carries the formatted hex string so braille and
voice output both have a human-readable form. Selected swatches
append a localized "selected" suffix to their announced name.
#![allow(unused)] fn main() { use teksilo_widgets::color_picker::ColorSwatch; use teksilo_tokens::Color; let _swatch = ColorSwatch::new(Color::new(0.21, 0.52, 0.89, 1.0)) .size(24.0) .corner_radius(4.0); }
Builder methods at a glance
selected, label, size, corner_radius, enabled, on_activate_fn, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct ColorSwatch
Single-cell color swatch.
The displayed color is a Prop<Color> — pass a Color for a
static palette entry (the common case in SwatchGrid) or a
Signal<Color> for a live preview that re-paints when the bound
value changes (used by ColorPicker's current-color preview and
ColorEdit's trigger).
#![allow(unused)] fn main() { pub struct ColorSwatch { /* fields */ } }
Methods
pub fn new(color: impl Into<teksilo_core::signal::Prop<Color>>) -> Self
Create a swatch displaying color. Accepts a static Color or a
Signal<Color> (via impl Into<Prop<Color>>); a reactive value
re-paints the cell whenever the signal changes.
pub fn selected(mut self, selected: bool) -> Self
Mark the swatch as currently selected, which paints an accent border and appends a localized "selected" suffix to the AT name.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Override the accessible label. Default is a localized "Color: #RRGGBB" string derived from the displayed color's hex value.
pub fn size(mut self, size: f32) -> Self
Set the swatch cell size in logical pixels (square). Defaults to
the theme's recipe_color_picker_style::SWATCH_SIZE.
pub fn corner_radius(mut self, r: f32) -> Self
Set the corner radius of the swatch cell in logical pixels.
Defaults to recipe_color_picker_style::SWATCH_CORNER_RADIUS.
pub fn enabled(mut self, enabled: impl Into<teksilo_core::signal::Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Register an activation callback invoked on tap, Enter, Space, or
the Action::Click accessibility action.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with Self::rich_tooltip, Self::rich_tooltip_content,
and Self::composite_tooltip — this call clears the other slots.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up from the tooltip registry by key.
Mutually exclusive with Self::tooltip, Self::rich_tooltip_content,
and Self::composite_tooltip — this call clears the other slots.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip with inline content (no registry lookup required).
Mutually exclusive with Self::tooltip, Self::rich_tooltip,
and Self::composite_tooltip — this call clears the other slots.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with Self::tooltip, Self::rich_tooltip,
and Self::rich_tooltip_content — this call clears the other slots.
ColumnFlow

ColumnFlow — flows children into as many columns as the width affords,
re-partitioning every child when a column is gained or lost.
The newspaper / CSS multi-column model: content runs down column 0, then
down column 1, and so on. The column count is derived from the available
width and min_column_width — when the
width no longer affords N columns the layout drops to N−1 and all
children are re-partitioned across the survivors. Children are atomic: one
child never straddles a column boundary.
Pair it with a ScrollArea for vertical
overflow — ColumnFlow reports its true content height (the tallest
column), so the scroll extent is correct.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::column_flow::ColumnFlow; use teksilo_widgets::primitives::TextWidget; use teksilo_widgets::scroll_area::ScrollArea; use teksilo_i18n::lit; let _view = ScrollArea::new().child( ColumnFlow::new() .min_column_width(240.0) .max_columns(4) .column_spacing(16.0) .item_spacing(12.0) .child(TextWidget::new(lit!("First"))) .child(TextWidget::new(lit!("Second"))) .child(TextWidget::new(lit!("Third"))), ); }
Reading order
Children are distributed as contiguous runs in source order — column 0
takes children 0..i, column 1 takes i..j. So source order, visual
reading order, and focus order are the same thing, at every column count.
This is why ColumnFlow does not reuse
MasonryLayout's shortest-column
packing, which interleaves children and would divorce the visual order from
the source order.
Accessibility
By default ColumnFlow emits a bare Role::GenericContainer carrying no
properties, which the accessibility walker prunes, promoting the children
to its parent in source order. That is the correct outcome for a layout
primitive: it contributes geometry, not semantics, and the reading order is
already right. Add semantics from the outside with .access_role(..) /
.access_label(..), or opt into list semantics with
semantic_list.
Relationship to CSS multi-column
Close, but not identical. CSS column-fill: balance balances content within
a column height it computes from a bounded block size; ColumnFlow derives
the column count from the width and lets the height run free (a
ScrollArea absorbs it). No CSS column-fill mode does that, so don't read
this as a CSS multicol port.
Builder methods at a glance
min_column_width, max_column_width, max_columns, column_spacing, item_spacing, alignment, column_rule, semantic_list, add_child, child, children, child_opt, column_count_signal
API reference
📖 Full rustdoc API for this module
pub struct ColumnFlow
A layout that flows its children into as many columns as the available width affords, re-partitioning every child when a column is gained or lost.
wide narrower
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
│ 1 │ │ 3 │ │ 5 │ │ 1 │ │ 4 │
├────┤ ├────┤ ├────┤ ├────┤ ├────┤
│ 2 │ │ 4 │ │ 6 │ ───► │ 2 │ │ 5 │
└────┘ └────┘ └────┘ ├────┤ ├────┤
│ 3 │ │ 6 │
└────┘ └────┘
Reading order is 1..6 at both widths. See the module docs.
#![allow(unused)] fn main() { pub struct ColumnFlow { /* fields */ } }
Methods
pub fn new() -> Self
Create a ColumnFlow with a 240 dp minimum column width, no maximum
column width, and no column-count cap.
pub fn min_column_width(mut self, width: f32) -> Self
The narrowest a column may be. The column count is the largest N whose
columns are all at least this wide — CSS column-width / SwiftUI
GridItem(.adaptive(minimum:)) / Compose GridCells.Adaptive(minSize).
A value of zero or less pins the layout to a single column.
pub fn max_column_width(mut self, width: f32) -> Self
The widest a column may be. Unset by default, so columns stretch to share the full width evenly.
Set it to stop columns becoming unreadably wide when few of them fit a
large display — the reason KDE's Kirigami.CardsLayout pairs
minimumColumnWidth with maximumColumnWidth. When it bites, the
columns no longer fill the width and
alignment decides where the block sits.
pub fn max_columns(mut self, max: usize) -> Self
Never use more than max columns however wide the layout gets.
Also decides the count when the width is unconstrained (inside a
size-to-content parent such as a popover): unset, that case reports one
column, matching CSS column-count: auto in a shrink-to-fit context.
Clamped to at least 1.
pub fn column_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self
Horizontal gap between columns. Accepts an f32 or a Signal<f32>.
pub fn item_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self
Vertical gap between items within a column. Accepts an f32 or a
Signal<f32>.
Named for items rather than rows because there are no rows here: a column's items are independent of its neighbours'.
pub fn alignment(mut self, alignment: HAlignment) -> Self
Where the column block sits when it does not fill the available width.
Only observable once max_column_width clamps
the columns narrower than their even share — otherwise the columns
consume the whole width and there is nothing to align. Defaults to
HAlignment::Leading; RTL-aware.
pub fn column_rule(mut self, width: f32, color: impl Into<ColorProp>) -> Self
Draw a rule of width dp, centred in every inter-column gap — CSS
column-rule.
Purely decorative: it emits no accessibility node. Accepts a Color, a
theme role, or a Signal. Pass BorderRole::Divider to track the
theme's divider colour.
pub fn semantic_list(mut self, enabled: bool) -> Self
Expose the children to assistive technology as a list.
The container becomes Role::List and every child is wrapped in a
layout-transparent node reporting Role::ListItem with its position and
the set size, so a screen reader announces "list, 30 items" and
"item 5 of 30" rather than reading 30 unrelated widgets.
Off by default: a layout primitive should not invent semantics its content may not have. Turn it on when the children genuinely are a list of peers. Costs one extra node per child.
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child by ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple inline children from an iterator.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Conditionally add a child. No-op if None.
pub fn column_count_signal(&self) -> Signal<usize>
The live column count, as a reactive signal.
Lets an app follow the reflow — swapping to a compact header at one column, say. Written from the layout pass behind an equality guard, so it only fires when the count actually changes.
Binding contract. Safe for RepaintOnly / AccessibilityOnly
consumers, and for Relayout consumers that do not feed back into this
widget's own width. The count is a pure function of the width
ColumnFlow is given — it never changes its own width, so it cannot
oscillate on its own. But a Relayout consumer that resizes something
which in turn resizes this ColumnFlow closes a feedback loop through
the layout pass, which is exactly what
Widget::place_children's own documentation warns against.
ComboBox

ComboBox — dropdown selection widget.
Generic over the item type T: Clone + PartialEq + 'static. Selection is
value-based: the bound Signal<Option<T>> survives reorder and insertion
of the backing model. Items come from one of four input paths:
ComboBox::new— static list of localizable strings (the 90% case).ComboBox::from_items— static list of typed values.ComboBox::from_model— reactiveListModel<T>.ComboBox::from_source— externalListDataSource<Item = T>.
The dropdown panel is pre-created during build() and kept dormant until
opened via click, Enter, Space, or ArrowDown/ArrowUp.
The widget is split across four internal modules:
stateholds the interaction-state enum, theItemSourceaccessor, and color/index helpers.itemholds the single-rowDropdownItemwidget.panelholds theDropdownPaneloverlay content and theFilteredItemListinner widget.testsholds the headless unit tests.
Builder methods at a glance
from_items, from_model, from_source, item_label, render_item, render_selected, on_select, max_visible_items, type_ahead_timeout, placeholder, label, enabled, variant, style, text_style, text_role, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, searchable, search_query, filter
API reference
📖 Full rustdoc API for this module
pub struct ComboBox
A dropdown selection widget.
// Simple: list of strings.
let selected = ctx.signal(None::<String>);
ComboBox::new(["Apple", "Banana", "Cherry"], selected)
.placeholder(lit!("Select a fruit..."))
// Typed items: any T: Clone + PartialEq, plus a label extractor.
#[derive(Clone, PartialEq)] struct Fruit { name: String, emoji: &'static str }
let selected = ctx.signal(None::<Fruit>);
ComboBox::from_items(fruits, selected)
.item_label(|f: &Fruit| lit!(format!("{} {}", f.emoji, f.name)))
// Model-backed: reactive.
let model = ListModel::from_vec(fruits);
ComboBox::from_model(model, selected)
.item_label(|f: &Fruit| lit!(f.name.clone()))
.max_visible_items(6)
#![allow(unused)] fn main() { pub struct ComboBox<T: Clone + PartialEq + 'static> { /* fields */ } }
Methods
pub fn new( items: impl IntoIterator<Item = impl Into<String>>, selected: Signal<Option<String>>, ) -> Self
Create a ComboBox from a list of strings.
Accepts any impl Into<String> — string literals (&str),
owned Strings, resolved LocalizedStrings, etc. For
translated items, resolve translations before passing in,
e.g. vec![tr!(apple()).resolve_now(), ...].
pub fn from_items<F>( items: impl IntoIterator<Item = T>, selected: Signal<Option<T>>, item_label: F, ) -> Self where F: Fn(&T) -> LocalizedString + 'static,
Static list of typed items. item_label is the display extractor —
it's required at construction so the compiler enforces it rather
than a runtime check. For T = String, use ComboBox::new which
defaults to the identity label.
pub fn from_model<F>(model: ListModel<T>, selected: Signal<Option<T>>, item_label: F) -> Self where F: Fn(&T) -> LocalizedString + 'static,
Backed by a reactive ListModel<T>. Inserts, removes, and reorders
propagate into the dropdown automatically. If the currently-selected
value disappears from the model, selected becomes None.
pub fn from_source<S, F>(source: S, selected: Signal<Option<T>>, item_label: F) -> Self where S: ListDataSource<Item = T> + 'static, F: Fn(&T) -> LocalizedString + 'static,
Backed by a custom ListDataSource — for external or paged data.
pub fn item_label(mut self, f: impl Fn(&T) -> LocalizedString + 'static) -> Self
Override the display-label extractor. Rarely needed — prefer passing
item_label to the constructor. Useful for the ComboBox<String>
path when you want a non-identity projection.
pub fn render_item(mut self, f: impl Fn(&T, bool) -> Box<dyn Widget> + 'static) -> Self
Custom cell rendering. The closure receives the item and a flag indicating whether it is the currently-selected value.
The framework wraps the returned widget with the correct
Role::ListBoxOption accessibility and tap handler, so callers
do not need to manage a11y or selection dispatch themselves.
Reactivity. The bool argument is a snapshot at build time.
If the selection flips after the dropdown is open, the user's
subtree is not automatically re-rendered; the framework-managed
highlight background (behind the custom widget) does update, and
closing and re-opening the dropdown picks up the new state. If
you need a reactive appearance that tracks selection, close over
a Signal<Option<T>> in your closure and compare against the
item value inside a .map() / bind_* on primitives.
Accessibility. The wrapper's set_name(label) (from
item_label) is what screen readers announce. If the returned
widget includes its own text nodes (e.g. a bare TextWidget), the
label may be announced twice — one from the wrapper, one from the
inner text. Wrap primary text nodes in .a11y_hidden() to avoid
duplication, and reserve visible widgets for presentation only.
pub fn render_selected(mut self, f: impl Fn(&T) -> Box<dyn Widget> + 'static) -> Self
Custom renderer for the trigger's selected value — the widget shown
when the combo is closed. The parallel of render_item
for the trigger rather than the dropdown rows.
When set, the closed combo shows f(&value) for the current
selection instead of the plain text label (item_label). The
canonical use is a FontPicker rendering the selected family name in
its own typeface. The subtree is rebuilt whenever the selection
changes and whenever the locale changes (so a None-state
placeholder re-translates), without rebuilding the whole ComboBox.
Accessibility. The rendered subtree is excluded from the
accessibility tree — the ComboBox's own accessibility(builder)
already announces the selected value via set_value, so the custom
visual can never double-announce. When nothing is selected the
trigger shows the placeholder text.
pub fn on_select(mut self, f: impl Fn(&T, &mut EventContext) + 'static) -> Self
Register a callback fired when the user commits a selection — by
tapping a dropdown row or picking one with the keyboard (arrows /
type-ahead / Home / End). The callback receives the chosen value
and a live EventContext, so it can run context-bearing actions
that observing the bound selected signal cannot — e.g.
ctx.set_locale(...), navigation, or opening another overlay.
It fires only on user-driven commits, not on external writes
to the selected signal (those are observed via ctx.effect).
The selected signal is updated before the callback runs.
pub fn max_visible_items(mut self, n: usize) -> Self
Maximum number of items shown before the dropdown becomes scrollable. Defaults to 8. Clamped to at least 1.
pub fn type_ahead_timeout(mut self, d: Duration) -> Self
Reset window for keyboard type-ahead. Keystrokes more than d apart
begin a fresh prefix; within d they extend it. Defaults to 500 ms,
matching MenuList::type_ahead_timeout. Pass Duration::ZERO to
treat each keystroke independently.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder text shown in the trigger when selected is None.
Accepts a tr!(...) directly (resolved at build); use
placeholder_literal for an
untranslated string.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible label describing what this combo box is for (e.g. "Fruit", "Font family"). Independent of the visible placeholder and of the current selection — screen readers announce this as the name of the control.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn variant(mut self, variant: ComboBoxVariant) -> Self
Pick a Tier-1 design-language variant
(ComboBoxVariant::Outlined / Filled / Underline / Plain).
The active ComboBoxStyle decides what to do with the hint —
IntUI's default impl honours Outlined (default) and Plain;
a custom impl (Material 3, macOS, etc.) might paint differently.
pub fn style(mut self, style: impl ComboBoxStyle) -> Self
Override the active ComboBoxStyle for this widget instance
only. The default IntUI chrome (crate::styles::RecipeComboBoxStyle)
reads its tokens from theme.components.combo_box; custom impls
can paint anything they want around the selected-label slot.
pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self
Override the selected-value text style (font, size, weight).
Accepts a TextStyleRole, a TextStyle, or a Signal of either.
Default (unset) is TextStyleRole::Body.
pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the selected-value text color. Accepts Color, a role, or
a Signal of either. Default (unset) is enabled-derived
(Primary / Disabled); setting this replaces that cascade.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip that appears after a hover delay. The tooltip is anchored to the trigger only — with the framework's overlay-boundary gate it does not re-trigger while the pointer is over the open dropdown's option rows.
Mutually exclusive with rich_tooltip /
rich_tooltip_content /
composite_tooltip — last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip registry.
The key is looked up via
TooltipRegistry at build
time; the resolved body supports inline markup, a shortcut chip,
and a "more" disclosure. Overrides any previously set tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline
TooltipContent — for one-off
tooltips that aren't worth registering centrally. Overrides any
previously set tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree (tabbed sections, charts, conditional rows). Promotes
to a focusable Role::Dialog after the standard dwell. Overrides
any plain or rich tooltip previously set.
pub fn searchable(mut self, enabled: bool) -> Self
Show a search field at the top of the dropdown panel and filter
the list live against the user's query. When true, items are
matched by the closure passed to filter, or —
if no filter is set — by a case-insensitive substring match on
the item_label.
The search input becomes a child of the dropdown panel only, not of the trigger: the closed combo box looks identical whether searchable or not.
The query signal is created internally. Use
search_query to supply your own if you
want to observe or drive the query externally.
pub fn search_query(mut self, query: Signal<String>) -> Self
Bind the search field to an external Signal<String>. Implies
searchable(true). Useful for observing or
programmatically setting the query from outside the widget
(e.g. a "Clear" button, persistence across sessions).
pub fn filter(mut self, f: impl Fn(&str, &T) -> bool + 'static) -> Self
Custom match predicate for searchable mode. Called on every
visible-item pass with the current query string (as typed, not
normalized) and a reference to the item; return true to keep
the item in the filtered list. Only consulted when
searchable is true. Ignored otherwise.
CommandLinkButton

CommandLinkButton — large two-line button with icon, title, and subtitle. Used for wizard landing screens, onboarding choices, and any "card-shaped CTA" pattern.
Modeled on Qt's QCommandLinkButton. Distinct from a regular
Button by its layout (HStack(icon + VStack(title + subtitle))) and default visual variant (Flat —
Int UI convention — with an interactive surface tint on hover).
CommandLinkButton::new(tr!(create_new_project()))
.description(tr!(create_new_project_subtitle()))
.icon(IconWidget::from_svg(NEW_PROJECT_ICON))
.on_activate_fn(|ctx| ctx.send_intent(AppIntent::NewProject))
Builder methods at a glance
description, icon, enabled, on_activate_fn, title_style, description_style, title_color, description_color, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub const COMMAND_LINK_BUTTON_ICON_SIZE
CommandLinkButton design tokens. The widget is a group-4 composite with no dedicated recipe module.
#![allow(unused)] fn main() { pub const COMMAND_LINK_BUTTON_ICON_SIZE: f32 = 28.0; }
pub const COMMAND_LINK_BUTTON_ICON_TEXT_GAP
#![allow(unused)] fn main() { pub const COMMAND_LINK_BUTTON_ICON_TEXT_GAP: f32 = 14.0; }
pub const COMMAND_LINK_BUTTON_TITLE_DESCRIPTION_GAP
#![allow(unused)] fn main() { pub const COMMAND_LINK_BUTTON_TITLE_DESCRIPTION_GAP: f32 = 4.0; }
pub const COMMAND_LINK_BUTTON_PADDING_HORIZONTAL
#![allow(unused)] fn main() { pub const COMMAND_LINK_BUTTON_PADDING_HORIZONTAL: f32 = 16.0; }
pub const COMMAND_LINK_BUTTON_PADDING_VERTICAL
#![allow(unused)] fn main() { pub const COMMAND_LINK_BUTTON_PADDING_VERTICAL: f32 = 14.0; }
pub const COMMAND_LINK_BUTTON_MIN_HEIGHT
#![allow(unused)] fn main() { pub const COMMAND_LINK_BUTTON_MIN_HEIGHT: f32 = 64.0; }
pub struct CommandLinkButton
A large two-line CTA button: icon + title + subtitle.
#![allow(unused)] fn main() { pub struct CommandLinkButton { /* fields */ } }
Methods
pub fn new(title: impl Into<LocalizedString>) -> Self
Create a CommandLinkButton with the given title text.
Chain .description(...) and .icon(...) to complete the card layout.
pub fn description(mut self, text: impl Into<LocalizedString>) -> Self
Optional descriptive subtitle rendered below the title.
pub fn icon(mut self, icon: IconWidget) -> Self
Leading icon — large enough to anchor the card visually (rendered at 28 dp).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on activation. Use ctx.send_intent(...) to
route through the Action / Intent system.
pub fn title_style( mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>, ) -> Self
Override the title's text style (font, size, weight). Accepts a
TextStyleRole, a TextStyle, or a Signal of either. Default
(unset) is TextStyleRole::BodyBold.
pub fn description_style( mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>, ) -> Self
Override the description's text style. Default is TextStyleRole::Body.
pub fn title_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the title's text color. Accepts Color, a role, or a
Signal of either. Default (unset) is TextRole::Primary.
pub fn description_color( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Override the description's text color. Default is TextRole::Secondary.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay. Clears any previously set rich or composite tooltip.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up by registry key. Clears any previously set plain or composite tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip with inline content (no registry lookup). Clears any previously set plain or composite tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip hosting an arbitrary widget tree body. Clears any previously set plain or rich tooltip.
CommandPalette
CommandPalette — type-to-run access to every command an app has registered.
The palette is application-agnostic: it holds no list of its own and knows
nothing about any particular app. Its content is the tree's
ShortcutRegistry, which already
carries everything a palette row needs — a localized
name, an optional category to group by, an
optional description, the effective keystroke (user rebinds merged in), and a
live enabled verdict. Activating a row sends the command's intent, which is the
same path a menu row or the chord itself takes.
That has a consequence worth stating plainly, because it is the whole design:
a command does not need a keystroke to appear here. iter_effective() yields
every registered entry, bound or not, so an app makes a command searchable by
registering it with a name and no chord:
// Reachable from the palette, and rebindable by the user later, without
// occupying a keystroke today.
ctx.register_shortcut_global(
Shortcut::new("document.export")
.name("Export…")
.category("File")
.build(),
);
Presenting it
CommandPalette::present shows it centered, dismissed by Escape or a click
outside:
ctx.register_action_global(Action::new("app.command_palette").on_invoke(|_, ctx| {
CommandPalette::new().present(ctx);
}));
Presenting it as a window-level modal is deliberate, not incidental: a palette is routinely opened from a menu, and a menu is itself a transient overlay. Anchoring to the invoking widget would render the palette inside the menu that opened it, positioned against a surface that is about to disappear.
Matching
Typing filters by subsequence, not substring, so ndw finds "New Window" and
expdoc finds "Export document". Matches score higher when the typed letters land
consecutively and on word starts, so the most literal reading of a query sorts
first. An empty query lists everything in the registry's own deterministic
(category, id) order. The category takes part in matching, so file new finds
the New command filed under File.
Keyboard
Focus stays in the search field throughout — that is what makes a palette feel like one. Arrow keys are not editing keys for the field, so they bubble to the palette's own handler, which moves the highlight and scrolls it into view. Enter runs the highlighted command; Escape dismisses.
Builder methods at a glance
placeholder, empty_text, include, on_dismiss, show_disabled, query_signal, present
API reference
📖 Full rustdoc API for this module
pub struct PaletteCommand
One command as the palette sees it.
A read-only projection of a registered shortcut, handed to
CommandPalette::include so an app can decide what belongs in its palette
without the widget growing knowledge of any app's command names. Deliberately
not the Shortcut itself: that type carries the activation closure and the
rebinding machinery, neither of which a filter predicate has any business
reaching.
#![allow(unused)] fn main() { pub struct PaletteCommand { /* fields */ } }
pub struct CommandPalette
Type-to-run access to every registered command. See the module docs.
#![allow(unused)] fn main() { pub struct CommandPalette { /* fields */ } }
Methods
pub fn new() -> Self
A palette over every command in the tree's registry.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Replace the search field's placeholder text.
pub fn empty_text(mut self, text: impl Into<LocalizedString>) -> Self
Replace the text shown when nothing matches the query.
pub fn include(mut self, f: impl Fn(&PaletteCommand) -> bool + 'static) -> Self
Keep only the commands this predicate accepts.
The usual reasons are to hide the command that opens the palette itself, and to drop registry entries that are key bindings rather than commands a person would look for by name.
pub fn on_dismiss(self, f: impl Fn(&mut EventContext) + 'static) -> Self
Run this after a command is activated, and when Escape is pressed.
present installs its own, so this is for callers embedding
the palette in a surface they manage themselves.
pub fn show_disabled(mut self, show: bool) -> Self
Also list commands whose enabled_when predicate currently says no, greyed
out and inert. Off by default: a palette answers "what can I do now", and a
row that cannot run is a row that has to be explained.
pub fn query_signal(&self) -> Signal<String>
The query signal, so a caller can seed or observe what was typed.
pub fn present(self, ctx: &mut EventContext)
Show the palette centered in the window, dismissed by Escape or a click
outside. See the module docs on why this is window-level.
Crossfade
Crossfade — when an external Signal<K> changes, the
previous content fades out while the new content fades in over
the same window. Like Switcher,
but animated.
let tab = Signal::new(Tab::Overview);
ctx.add(
Crossfade::new(tab.clone(), |t| match t {
Tab::Overview => Box::new(overview_panel()),
Tab::Details => Box::new(details_panel()),
}),
);
Behavior
On each key change, both the previous-key widget and the
current-key widget are rebuilt (via the supplied builder) and
mounted side-by-side in a ZStack. The previous fades 1→0 while
the current fades 0→1 over the configured duration. On the next
key change, the previously-outgoing widget is torn down and the
cycle repeats.
Builders should be cheap — they may run more than once per lifetime as the user navigates through several keys. For data- heavy panels, hoist expensive state out of the builder closure.
Reduced motion
Honours prefers-reduced-motion: snaps the opacity changes
instead of tweening (instant swap).
Builder methods at a glance
duration
API reference
📖 Full rustdoc API for this module
pub struct Crossfade
Animated swap between widgets keyed by an external signal.
#![allow(unused)] fn main() { pub struct Crossfade<K: Eq + Clone + 'static> { /* fields */ } }
Methods
pub fn new(key_signal: Signal<K>, builder: impl Fn(&K) -> Box<dyn Widget> + 'static) -> Self
New Crossfade driven by key_signal. The builder closure
constructs the widget for a given key value. Builders can be
invoked multiple times across the widget's lifetime as the
user transitions through keys.
pub fn duration(mut self, duration: Duration) -> Self
Override the crossfade duration. Default: MotionTokens::duration_normal.
Cycle
Cycle — show one of N children at a time, advancing on a fixed
period. The "rotating loading tip" / status display pattern.
ctx.add(
Cycle::new()
.period(Duration::from_secs(3))
.child(TextWidget::new(lit!("Tip: press Cmd-K to search")))
.child(TextWidget::new(lit!("Tip: hold Shift to multi-select")))
.child(TextWidget::new(lit!("Tip: drag the divider to resize"))),
);
Internally a Switcher whose
Signal<usize> index is incremented by a per-frame effect.
Children share a ZStack slot — at any given moment only the
selected child is visible (others are dormant).
Reduced motion
Honours prefers-reduced-motion: pins on the first child and
does not install the timer driver. Subsequent children are still
built (so widget construction is identical) but are never shown.
Builder methods at a glance
period, child, child_boxed, children
API reference
📖 Full rustdoc API for this module
pub struct Cycle
A wrapper that cycles through its children on a fixed period.
#![allow(unused)] fn main() { pub struct Cycle { /* fields */ } }
Methods
pub fn new() -> Self
New cycle with default 3 s period.
pub fn period(mut self, period: Duration) -> Self
Step interval — how long each child is visible before advancing to the next. Default 3 s.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Append a child to the rotation.
pub fn child_boxed(mut self, widget: Box<dyn Widget>) -> Self
Append a pre-boxed child to the rotation.
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Append children from an iterator.
DateEdit

DateEdit — text input + calendar popover, bound to Signal<Option<Date>>.
A single-line editable date field. The underlying surface is a
TextInputField displaying the formatted date; commit on Enter or
blur parses the input against the active pattern, clamps to
[min_date, max_date], and writes the result back. A trailing
calendar-icon button opens a Calendar
popover anchored below the field for graphical date selection.
Behaviour
- Value binding:
Signal<Option<Date>>is the source of truth. External writes re-format the text.Noneshows the placeholder. - Pattern: locale-derived strftime-subset (
%Y-%m-%d,%m/%d/%Y, …); override viaformat_pattern. - Step keys (preview-pass on the field):
- Arrow Up / Down → ±1 day; Shift+ → ±7 days.
- Page Up / Page Down → ±1 month; Shift+ → ±1 year.
Alt+ArrowDown(or click the calendar icon) → opens calendar popover.
- Calendar popover: dismisses on click-outside or Escape,
commits on cell click, animates with
motion.duration_fastfade. - Min / Max: clamps on commit and on step. Out-of-range values in the popover cell are disabled.
Accessibility
- Container —
Role::DateInput,set_valueto ISO selection,set_labelfrom.label()builder,set_placeholderwhen value isNone. - Calendar trigger button —
Role::Buttonwithset_has_popup(HasPopup::Grid)andset_expanded(open). - Internally the editing surface remains a
Role::TextInputfor AT discoverability (so screen readers know it accepts text); the wrapper carries the DateInput role on the outer node.
Example
use teksilo::widgets::{DateEdit, common::datetime::Date};
let date = ctx.signal(Some(Date::constant(2026, 5, 2)));
ctx.add(
DateEdit::new(date.clone())
.min_date(Date::constant(2020, 1, 1))
.max_date(Date::constant(2030, 12, 31))
.label("Birth date"),
);
Builder methods at a glance
style, required, min_date, max_date, format_pattern, placeholder, first_day_of_week, show_calendar_button, calendar_popover_placement, enabled, read_only, validation_behavior, width_policy, validation_feedback_signal, label, on_value_changed, value, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub enum WidthPolicy
How a datetime widget claims horizontal space.
Shared across DateEdit, TimeEdit, DateRangeEdit, and
DateTimeEdit. For the two-half widgets the policy applies to
the trailing half only — the leading half always sizes to its
mask-derived natural width so the date never reflows when only
the time half changes.
#![allow(unused)] fn main() { pub enum WidthPolicy { /* variants */ } }
Variants
Default— Default. The widget claims its natural width: the mask-derived empty template (__/__/____for ISO date,__:__for 24h time) measured in the theme body font plus surrounding chrome. The footprint stays fixed as the user types — Int UI form-density convention. This is theDefault.Fill— The widget expands to fill the horizontal space its parent offers, instead of capping at the natural mask width. Use inside toolbars, inspector panels, or anExpand::horizontalcolumn that should stretch with the surrounding layout.
pub enum ValidationBehavior
How the date editor reacts to out-of-range or partially invalid input.
#![allow(unused)] fn main() { pub enum ValidationBehavior { /* variants */ } }
Variants
AutoCorrect— Out-of-range inputs are clamped to the nearest valid value (e.g.12/50/2026→12/31/2026) and announced viaLive::Polite. Matches macOS Calendar and iOS DatePicker. This is theDefault.Reject— Out-of-range inputs are rejected with an inline error message; the field's text is left as-typed so the user can correct it. The bound value is unchanged until a valid date is committed. Matches Excel / Material strict-validation patterns. Use for high-precision contexts where silently rounding is unacceptable.
pub struct DateEdit
Single-line date input with optional calendar popover. See the
module docs for the full feature list.
#![allow(unused)] fn main() { pub struct DateEdit { /* fields */ } }
Methods
pub fn new(value: Signal<Option<Date>>) -> Self
Construct a date editor bound to a nullable date signal.
pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self
Per-call style override for the date-edit chrome.
pub fn required(value: Signal<Date>) -> Self
Construct from a non-nullable date signal. Internally backed by
a Signal<Option<Date>> proxy that mirrors the source in both
directions. The placeholder is unused — the proxy is always
initialized to Some(value.get()) and the mirror keeps it
non-empty.
pub fn min_date(mut self, d: Date) -> Self
Clamp the selectable range from below. Dates earlier than d
are rejected on commit and are shown as disabled in the calendar popover.
pub fn max_date(mut self, d: Date) -> Self
Clamp the selectable range from above. Dates later than d
are rejected on commit and are shown as disabled in the calendar popover.
pub fn format_pattern(mut self, pat: impl Into<String>) -> Self
Override the locale-derived format pattern (strftime subset, see
crate::common::datetime::pattern).
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Text displayed when the bound value is None. Defaults to empty
(no placeholder rendered).
pub fn first_day_of_week(mut self, w: Weekday) -> Self
Override which weekday heads the calendar's column grid. Defaults to the locale's convention if not set.
pub fn show_calendar_button(mut self, show: bool) -> Self
Show or hide the trailing calendar-icon trigger button that opens
the calendar popover. Default true.
pub fn calendar_popover_placement(mut self, p: OverlayPlacement) -> Self
Override where the calendar popover appears relative to the field.
Default is OverlayPlacement::BelowPreferred.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Make the field read-only: text is selectable and copyable but not editable, and step keys are suppressed.
pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self
How parse failures are surfaced. Default
ValidationBehavior::AutoCorrect (clamp + announce); switch
to ValidationBehavior::Reject for strict-validation form
contexts.
pub fn width_policy(mut self, policy: WidthPolicy) -> Self
How the widget claims horizontal space. Default
WidthPolicy::Default — the field sizes to its natural
mask-derived width. Switch to WidthPolicy::Fill to make
the field stretch to fill the parent's offered width
(toolbar / inspector pattern).
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive handle on the live validation feedback (mirrored from
the inner field). Composites that want to render their own
feedback UI elsewhere can bind to this; the default
ValidationStrip slot below the field uses it internally.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the accessible label for the field (also shown by any paired
FormLayout label slot). Defaults to the localized "Date" string.
pub fn on_value_changed( mut self, f: impl Fn(Option<Date>, &mut EventContext) + 'static, ) -> Self
Register a callback fired on every committed value change with the
new Option<Date> and a live EventContext. Fires only on
user-driven commits (typing + blur, Enter, calendar selection),
not on external writes to the bound signal.
pub fn value(&self) -> Signal<Option<Date>>
Return a clone of the bound value signal for external observation.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with Self::rich_tooltip,
Self::rich_tooltip_content, and Self::composite_tooltip —
this call clears those slots.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up by registry key. Mutually exclusive
with Self::tooltip, Self::rich_tooltip_content, and
Self::composite_tooltip — this call clears those slots.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from inline content. Mutually exclusive with
Self::tooltip, Self::rich_tooltip, and
Self::composite_tooltip — this call clears those slots.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with Self::tooltip, Self::rich_tooltip,
and Self::rich_tooltip_content — this call clears those slots.
DateRangeEdit

DateRangeEdit — single unified control for picking a DateRange.
Visually one widget: a single bordered frame containing two
TextInputField halves separated by a painted arrow glyph, with
a trailing built-in calendar button that opens a shared
Calendar::range popover. Backed by Signal<Option<DateRange>>.
┌──────────────────────────────────────┐
│ 05/12/2026 → 05/19/2026 │ 📅 │
└──────────────────────────────────────┘
Why one frame?
Two adjacent DateEdits (one frame each) visually read as two
separate fields that happen to be next to each other. A single
frame says "this is one range". Same affordance the user is used
to from booking sites and analytics dashboards.
Behaviour
- Two text halves — each masked from the resolved date pattern,
each with its own validator + segment-stepping (Up/Down on the
focused segment matches
DateEdit). - Painted arrow separator — a thin chevron-right glyph, no text.
Visual only; AT users see the wrapper's
Role::DateInput. - One trailing calendar button — Int UI
IconButton::embedded()with the calendar glyph. Opens a single popover hostingCalendar::rangebound to the outer signal. The two-anchor click model (start-then-end) commits the range and closes the popover. No per-half calendar buttons — there's only one calendar, anchored to the wrapper. - One frame — focus-aware border (
BorderRole::Focusedwhile any half holds focus, otherwiseDefault), validation-aware border (ErrorforInvalid,FocusedforCorrected). - One validation strip below the frame — composed feedback from both halves (worse of the two wins).
Accessibility
- Container —
Role::DateInputwithset_valueformatted asYYYY-MM-DD/YYYY-MM-DD(ISO range). - Each
TextInputFieldkeeps its ownRole::TextInputAT node; the wrapper'sRole::DateInputprovides the range semantics.
// Requires ctx.signal() — shown as ignore per convention.
use teksilo_widgets::date_range_edit::DateRangeEdit;
use jiff::civil::Weekday;
let range = ctx.signal(None);
let _w = DateRangeEdit::new(range.clone())
.first_day_of_week(Weekday::Monday)
.on_value_changed(|r, _ctx| println!("{r:?}"));
Builder methods at a glance
style, min_date, max_date, format_pattern, placeholder_start, placeholder_end, first_day_of_week, enabled, read_only, label, validation_behavior, end_width_policy, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, validation_feedback_signal, on_value_changed, value
API reference
📖 Full rustdoc API for this module
pub struct DateRangeEdit
Two-handle date picker over Signal<Option<DateRange>>. See the
module docs for the visual layout and behaviour.
#![allow(unused)] fn main() { pub struct DateRangeEdit { /* fields */ } }
Methods
pub fn new(value: Signal<Option<DateRange>>) -> Self
Create a date-range picker bound to value.
pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self
Per-call DateEditStyle override (shared with DateEdit family).
pub fn min_date(mut self, d: Date) -> Self
Restrict the selectable start and end dates to those on or after d.
pub fn max_date(mut self, d: Date) -> Self
Restrict the selectable start and end dates to those on or before d.
pub fn format_pattern(mut self, p: impl Into<String>) -> Self
Override the strftime-subset format pattern for both halves
(e.g. "%d/%m/%Y"). Defaults to the locale-derived pattern.
pub fn placeholder_start(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder shown in the start half when no date is set.
pub fn placeholder_end(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder shown in the end half when no date is set.
pub fn first_day_of_week(mut self, w: Weekday) -> Self
Override which weekday appears in the first column of the calendar popup.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Make both halves read-only; the calendar button is also disabled.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible label for the wrapper Role::DateInput node. When not set,
falls back to the localized date-range-edit-name message.
pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self
How both halves handle invalid or out-of-range text on blur / Enter.
Defaults to ValidationBehavior::AutoCorrect.
pub fn end_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self
How the trailing (end) half claims horizontal space. The
leading (start) half always sizes to its natural mask width;
the end half follows this policy. Default
WidthPolicy::Default (natural width); pass
WidthPolicy::Fill to make the end half absorb extra
space the parent offers.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Show a plain single-line tooltip on hover. Mutually exclusive with the rich / composite tooltip slots — this setter clears the other two so the last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Show a rich tooltip sourced from the registry by key. Mutually
exclusive with the plain / composite tooltip slots — this setter clears
the other two so the last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Show a rich tooltip from an inline TooltipContent value. Mutually
exclusive with the plain / registry-key tooltip slots — this setter
clears the other two so the last call wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Show a composite tooltip whose body is an arbitrary widget tree. Mutually exclusive with the plain / rich tooltip slots — this setter clears the other two so the last call wins.
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive handle on the composed validation feedback (worse of the two
halves — Invalid > Corrected > Valid > Pristine).
pub fn on_value_changed( mut self, f: impl Fn(Option<DateRange>, &mut EventContext) + 'static, ) -> Self
Callback invoked whenever the range changes (including when one half
clears its value). Receives the new Option<DateRange> and an
EventContext for dispatching intents or side effects.
pub fn value(&self) -> Signal<Option<DateRange>>
Clone the underlying Signal<Option<DateRange>> for external binding.
DateTimeEdit

DateTimeEdit — single unified control for picking a DateTime.
Visually one widget: a single bordered frame containing a date
TextInputField half, a small painted separator, a time
TextInputField half, and a trailing built-in calendar button that
opens a Calendar popover anchored below the wrapper. Backed by
Signal<Option<DateTime>>.
┌──────────────────────────────────────┐
│ 05/02/2026 · 14:35 │ 📅 │
└──────────────────────────────────────┘
Why one frame?
Two adjacent DateEdit + TimeEdit (one frame each) visually read
as two separate fields that happen to be next to each other. A single
frame says "this is one moment in time" — same affordance the user
is used to from booking sites, calendar apps, and form builders.
Behaviour
- Two text halves — date pattern on the left (locale-derived strftime subset), time pattern on the right (24h or 12h, with or without seconds). Each half carries its own input mask, validator, and segment-stepping (Up/Down on the focused segment).
- Painted separator — a thin middle-dot glyph (
·), no text. Visual only; AT users see the wrapper'sRole::DateTimeInput. The separator can be replaced with a custom string viaseparator(rendered as styled secondary text). - One trailing calendar button — Int UI
IconButton::embedded()with the calendar glyph. Opens a single popover hostingCalendar::singlebound to the date half. Picking a cell commits the date and closes the popover; the time half retains whatever the user typed. - One frame — focus-aware border (
BorderRole::Focusedwhile any half holds focus, otherwiseDefault), validation-aware border (ErrorforInvalid,FocusedforCorrected). - One validation strip below the frame — composed feedback from both halves (worse of the two wins).
Accessibility
- Container —
Role::DateTimeInputwithset_valueformatted asYYYY-MM-DDTHH:MM:SS(ISO 8601 datetime). - Each
TextInputFieldkeeps its ownRole::TextInputAT node; the wrapper'sRole::DateTimeInputprovides the datetime semantics.
// Requires ctx.signal() — shown as ignore per convention.
use teksilo_widgets::date_time_edit::{DateTimeEdit, SecondsMode};
let datetime = ctx.signal(None);
let _w = DateTimeEdit::new(datetime.clone())
.seconds(SecondsMode::Hidden)
.on_value_changed(|dt, _ctx| println!("{dt:?}"));
Builder methods at a glance
style, required, date_format_pattern, time_format, seconds, min, max, step_minutes, first_day_of_week, show_calendar_button, separator, placeholder, enabled, read_only, label, validation_behavior, time_width_policy, validation_feedback_signal, on_value_changed, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, value
API reference
📖 Full rustdoc API for this module
pub struct DateTimeEdit
Single unified datetime picker over Signal<Option<DateTime>>. See
the module docs for the visual layout and behaviour.
#![allow(unused)] fn main() { pub struct DateTimeEdit { /* fields */ } }
Methods
pub fn new(value: Signal<Option<DateTime>>) -> Self
Create a datetime picker backed by the optional value signal.
pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self
Per-call DateEditStyle override (shared with DateEdit family).
pub fn required(value: Signal<DateTime>) -> Self
Create a datetime picker backed by a required (non-optional) signal.
The widget wraps it in an Option proxy internally and keeps the two
in sync via ctx.effect — the outer signal is never set to None.
pub fn date_format_pattern(mut self, p: impl Into<String>) -> Self
Override the strftime-subset format pattern for the date half
(e.g. "%d/%m/%Y"). Defaults to the locale-derived pattern.
pub fn time_format(mut self, f: TimeFormat) -> Self
Lock the time half to a specific clock (12h or 24h). When this
builder is not called, the time half defaults to the user's
current locale via prefers_12_hour_clock — same rule as
standalone TimeEdit.
pub fn seconds(mut self, mode: SecondsMode) -> Self
Whether the time half includes a seconds field. Defaults to SecondsMode::Hidden.
pub fn min(mut self, dt: DateTime) -> Self
Earliest selectable datetime (inclusive). Both the calendar cell and the text validator enforce this floor.
pub fn max(mut self, dt: DateTime) -> Self
Latest selectable datetime (inclusive). Both the calendar cell and the text validator enforce this ceiling.
pub fn step_minutes(mut self, n: u32) -> Self
Minute increment for Up/Down segment stepping on the minute field.
Defaults to 1; values below 1 are clamped to 1.
pub fn first_day_of_week(mut self, w: Weekday) -> Self
Override which weekday appears in the first column of the calendar popup.
pub fn show_calendar_button(mut self, show: bool) -> Self
Show or hide the trailing calendar button. Default true.
pub fn separator(mut self, s: impl Into<String>) -> Self
Override the painted middle-dot separator with a custom string (rendered as styled secondary text between the two halves). Pass an empty string to suppress the separator entirely.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder shown when the datetime is None.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Make both halves read-only; the calendar button is also disabled.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible label for the wrapper Role::DateTimeInput node. When not
set, falls back to the localized date-time-edit-name message.
pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self
How parse failures are surfaced. Forwarded to both halves — each half uses the same behaviour.
pub fn time_width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self
How the trailing (time) half claims horizontal space. The
leading (date) half always sizes to its natural mask width;
the time half follows this policy. Default
WidthPolicy::Default (natural width); pass
WidthPolicy::Fill to make the time half absorb extra
space the parent offers.
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive handle on the composed validation feedback. Reflects
whichever half is more severe (Invalid > Corrected > Valid > Pristine).
pub fn on_value_changed( mut self, f: impl Fn(Option<DateTime>, &mut EventContext) + 'static, ) -> Self
Callback invoked whenever the datetime changes. Receives the new
Option<DateTime> and an EventContext for dispatching intents.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Show a plain single-line tooltip after a hover delay. Mutually
exclusive with rich_tooltip / rich_tooltip_content /
composite_tooltip — each setter clears the other three so the
last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Show a rich tooltip identified by a registry key. Mutually
exclusive with tooltip / rich_tooltip_content /
composite_tooltip — each setter clears the other three so the
last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Show a rich tooltip with inline content. Mutually exclusive with
tooltip / rich_tooltip / composite_tooltip — each setter
clears the other three so the last call wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Show a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with tooltip / rich_tooltip /
rich_tooltip_content — each setter clears the other three so
the last call wins.
pub fn value(&self) -> Signal<Option<DateTime>>
Clone the underlying Signal<Option<DateTime>> for external binding.
DeadZone
DeadZone — a gesture dead zone wrapper.
Builder methods at a glance
child, child_id
API reference
📖 Full rustdoc API for this module
pub struct DeadZone
A layout-transparent wrapper whose subtree is a gesture dead zone: a pointer press inside it never arms a drag/swipe recognizer on any ancestor.
Wrap interactive controls (buttons, a ⋮ options menu, a slider) that sit
inside a draggable / swipeable container — a dock-panel header, a card, a
list row, a scene item — so clicking them, even with the few pixels of
pointer jitter a real click carries, can never start the ancestor's drag.
The container's own drag still works everywhere outside the dead zone. This
is the framework counterpart of Electron's -webkit-app-region: no-drag.
It is robust structurally, not by a timing-dependent gesture race: it
sets the node-level gesture_dead_zone
flag, which the framework's drag-arming honours by refusing to arm any
ancestor above this node. (It also carries a no-op tap/drag so a press on the
dead zone's own bare area — a gap between controls — is absorbed too.)
// A draggable dock header whose action buttons don't drag the panel:
HStack::new()
.child(title)
.child(DeadZone::new().child(
HStack::new()
.child(IconButton::new(new_icon).on_activate_fn(..))
.child(options_button),
))
Layout-transparent: it reports its child's size and fills the child to its own bounds, so dropping it in is size-neutral.
#![allow(unused)] fn main() { pub struct DeadZone { /* fields */ } }
Methods
pub fn new() -> Self
A new, empty dead zone. Attach content with child or
child_id.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Wrap an inline widget.
pub fn child_id(mut self, id: WidgetId) -> Self
Wrap a pre-registered widget by id.
Dialog

Modal dialogs — a trigger button that presents a centered modal panel.
Three cooperating types cover the common dialog use-case. Dialog is the
high-level entry point: a Button (or custom trigger) that, on activation,
presents a ModalContainer above a full-viewport dimming ModalScrim.
DialogContent is the convenience body layout — a VStack with an
optional title, supporting text, scrollable body slot, and a footer slot
separated by a Divider.
When to use
Dialog::new(label).content(|| …)for the common "button opens dialog" pattern.Dialog::new(label).trigger(my_icon_button).content(|| …)to use a custom widget as the trigger instead of the defaultButton.ModalContainer::new(content)directly when you need to present a modal from handler code viactx.present_modal(ModalRequest::…)rather than a persistent trigger.
Accessibility
ModalContainer is a Role::Dialog node and announces set_modal().
Its accessible name defaults to the DialogContent title (via
Widget::accessible_title_hint) or falls back to the localized
a11y_dialog_name message; pass .title(tr!(…)) to the container for an
explicit override. The trigger button advertises HasPopup::Dialog and
set_expanded tracks whether the modal is currently open.
use teksilo_widgets::dialog::{Dialog, DialogContent};
use teksilo_i18n::lit;
let _d = Dialog::new(lit!("Open settings"))
.content(|| {
DialogContent::new()
.title(lit!("Settings"))
.supporting_text(lit!("Adjust your preferences below."))
});
Builder methods at a glance
content, variant, enabled, presentation, close_behavior, trigger, trigger_id
API reference
📖 Full rustdoc API for this module
pub struct ModalContainer
Rounded panel chrome that wraps a modal dialog's content widget.
All visual dimensions (padding, corner radius, min-width, shadow) are owned
by the active DialogStyle; per-instance
overrides are available via Self::padding and Self::min_width.
#![allow(unused)] fn main() { pub struct ModalContainer { /* fields */ } }
Methods
pub fn new(content: impl Widget + 'static) -> Self
Wrap content inside a modal panel with default chrome.
pub fn padding(mut self, padding: f32) -> Self
Override the content padding (logical pixels) from the theme default.
pub fn min_width(mut self, min_width: f32) -> Self
Override the minimum panel width (logical pixels) from the theme default.
pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self
Per-call style override for the modal panel chrome. Replaces the
theme-wide default DialogStyle for just this container.
pub fn title(mut self, title: impl Into<LocalizedString>) -> Self
Accessible title for the dialog. Screen readers announce this
as the dialog's name. Should match the inner DialogContent's
visible title string.
pub struct ModalScrim
Full-viewport dimming scrim painted behind a ModalContainer.
Mounted by the modal-presentation pipeline (teksilo-app) as a separate
OverlayPlacement::FullViewport overlay pushed BEFORE the centered
modal overlay so it z-orders below the panel. The chrome itself is
delegated to the active DialogStyle::make_scrim; clicking the
scrim dismisses the linked modal when the modal's
ModalCloseBehavior permits click-outside dismissal.
The dismissal cascade is wired via
OverlayManager::set_parent_overlay AFTER both overlays are
pushed — the scrim's parent_overlay is set to the modal's id, so
any dismiss of the modal cascades through dismiss_immediate and
also dismisses the scrim. The scrim's own dismiss behavior is
Manual — it never dismisses itself directly.
#![allow(unused)] fn main() { pub struct ModalScrim { /* fields */ } }
Methods
pub fn new() -> Self
Build a new scrim; wire it with Self::dismiss_target and
Self::click_to_dismiss after construction.
pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self
Per-call style override for the scrim chrome. Replaces the
theme-wide default DialogStyle for just this scrim.
pub fn dismiss_target(mut self, target: Rc<Cell<Option<OverlayId>>>) -> Self
Handle to the modal-overlay id the scrim dismisses on click.
The framework fills this AFTER the modal is pushed (see the
in-tree modal pipeline in teksilo-app).
pub fn click_to_dismiss(mut self, enabled: bool) -> Self
Enable click-to-dismiss on the scrim. Should mirror whether the
modal's ModalCloseBehavior permits click-outside dismissal.
pub struct DialogContent
Convenience body layout for a modal dialog: optional title, supporting text,
scrollable body slot, and a Divider-separated footer row.
#![allow(unused)] fn main() { pub struct DialogContent { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty dialog body with no sections set.
pub fn title(mut self, title: impl Into<LocalizedString>) -> Self
Bold title shown at the top of the content area. Also propagated to
the enclosing ModalContainer via accessible_title_hint.
pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self
Secondary description text shown below the title.
pub fn body(mut self, body: impl Widget + 'static) -> Self
Main scrollable content slot (any widget).
pub fn body_id(mut self, id: WidgetId) -> Self
Main content slot by pre-registered WidgetId.
pub fn footer(mut self, footer: impl Widget + 'static) -> Self
Footer slot separated from the body by a Divider (typically action
buttons like "OK" / "Cancel").
pub fn footer_id(mut self, id: WidgetId) -> Self
Footer slot by pre-registered WidgetId.
pub struct Dialog
A trigger button that presents a modal dialog when activated.
Renders as a Button by default; call .trigger(w) to replace it with any
widget. The content is lazily constructed by a factory closure each time the
dialog opens — no persistent widget subtree is kept while the dialog is closed.
#![allow(unused)] fn main() { pub struct Dialog { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Build a dialog trigger with label as the button text and accessible name.
pub fn content<W, F>(mut self, factory: F) -> Self where W: Widget + 'static, F: Fn() -> W + 'static,
Factory closure that builds the dialog's content each time it opens. Required — the dialog panics at build time if no factory is set.
pub fn variant(mut self, variant: ButtonVariant) -> Self
Visual style of the default trigger button. Has no effect when
.trigger(…) replaces the button with a custom widget.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the trigger button, statically or reactively
(default true).
pub fn presentation(mut self, presentation: ModalPresentation) -> Self
Override the modal presentation mode (default ModalPresentation::Auto).
pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self
Override how the dialog may be closed (default EscapeOrClickOutside).
pub fn trigger(mut self, trigger: impl Widget + 'static) -> Self
Replace the default Button trigger with a custom widget. The widget
receives the same tap / key / AT-action handlers as the button would.
pub fn trigger_id(mut self, id: WidgetId) -> Self
Custom trigger by pre-registered WidgetId.
Divider

Divider — a themed separator line that visually partitions content.
Divider renders a single hairline stroke (DIVIDER_THICKNESS = 1 dp by
default) using the theme's divider color. It comes in two orientations:
horizontal (the default, spans the proposed width and has a fixed 1 dp
height) and vertical (spans the proposed height, 1 dp wide). Both the
thickness and the color can be overridden per-instance without a custom
style.
Accessibility
The widget emits Role::Splitter, which matches the ARIA separator pattern
and signals a structural boundary to screen readers.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::Divider; // Horizontal rule between two content sections let _rule = Divider::new(); // Vertical rule inside a toolbar let _vbar = Divider::vertical(); }
Builder methods at a glance
horizontal, vertical, thickness, color
API reference
📖 Full rustdoc API for this module
pub struct Divider
A themed separator line. Thickness defaults to DividerStyle::thickness
and the color defaults to BorderRole::Divider; both can be overridden.
#![allow(unused)] fn main() { pub struct Divider { /* fields */ } }
Methods
pub fn new() -> Self
Create a horizontal Divider with default theme thickness and color.
pub fn horizontal() -> Self
Create a horizontal Divider — alias for Divider::new().
pub fn vertical() -> Self
Create a vertical Divider that spans the proposed height.
pub fn thickness(mut self, thickness: f32) -> Self
Override the stroke thickness in logical pixels; defaults to
DIVIDER_THICKNESS (1 dp).
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Override the line color. Accepts Color, a role (typically
BorderRole), or a Signal<Color>.
pub const DIVIDER_THICKNESS
Default visual thickness of a Divider stroke. Divider has no
per-widget Recipe*Style module, so the constant lives alongside
the widget that reads it.
#![allow(unused)] fn main() { pub const DIVIDER_THICKNESS: f32 = 1.0; }
DockingLayout

DockingLayout — a VS Code-style dockable layout: a fixed centre slot
(the app's main content) surrounded by four collapsible, splittable,
draggable side regions (leading / trailing / top / bottom), backed by a
cloneable, serializable DockingModel.
See docs/docking.md for the full reference. The structure is four
levels deep:
DockingLayout
└── Centre + 4 Sides
└── Side = [optional DockActivityBar rail] + collapsible content region
└── content region holds ONE TabWidget (strip optional / replaced
by the rail)
└── Tab → DockArrangement (a Splitter of panes, each a single
DockWidget or a ToolBox of DockWidgets)
└── DockWidget — the atomic dockable unit
Builder methods at a glance
rail, center, policy, disable_side, center_id, dock
API reference
📖 Full rustdoc API for this module
pub struct DockingLayout
The docking layout widget. See the module docs and docs/docking.md.
let model = DockingModel::new();
// …declare panels + an initial layout on `model`…
DockingLayout::new(model.clone())
.center(editor)
.dock(DockWidget::new(EXPLORER, lit!("Explorer"), |_| Explorer::new()))
#![allow(unused)] fn main() { pub struct DockingLayout { /* fields */ } }
Methods
pub fn new(model: DockingModel) -> Self
Create a docking layout over a model.
pub fn rail(mut self, rail: DockRail) -> Self
Configure a side's activity rail (item size, top/bottom slots, overflow
trigger). The side still needs DockingModel::set_side_rail to put it
in Rail presentation; this only styles the rail. See DockRail.
pub fn center(mut self, widget: impl Widget + 'static) -> Self
Set the always-present centre content (the app's main area).
pub fn policy(self, policy: DockPolicy) -> Self
Lock down end-user layout edits (sugar for DockingModel::set_policy).
See DockPolicy.
pub fn disable_side(self, side: DockSide) -> Self
Disable a side (sugar for DockingModel::set_side_enabled``(side, false)):
it renders nothing, reserves no space, and rejects docks.
pub fn center_id(mut self, id: WidgetId) -> Self
Set the centre content by a pre-registered id.
pub fn dock(self, dock: DockWidget) -> Self
Declare a dock widget (its content factory + chrome metadata). The
dock is registered immediately, so the app may set the initial layout
on the model (open_dock / import_state) before mounting.
pub type DockRailSlot
Factory for a rail slot widget (rebuilt on each rail rebuild).
A slot that wants to match the rail's current item size binds
DockingModel::rail_size_mode_signal
— the rail rebuilds its slots whenever the size mode changes, so reading the
signal in the factory is enough to keep the slot in step.
#![allow(unused)] fn main() { pub type DockRailSlot = Rc<dyn Fn() -> Box<dyn Widget>>; }
pub struct DockActionId
Stable identity for a DockAction.
Not used for persistence — a rail action carries no user-mutable state,
so nothing about it is serialized (see DockLayoutState's
"app-config is reconstructed each run" rule). It exists so the accessibility
tree and the automation bridge can address a given action stably across
runs; a fresh-per-run id would make every script that clicks a rail action
flaky.
#![allow(unused)] fn main() { pub struct DockActionId(u64); }
Methods
pub const fn named(name: &str) -> Self
Derive a stable id from a caller-chosen name — identical across runs,
processes and machines. Prefer this over from_raw:
it removes the hand-picked-u64-literal collision hazard entirely.
const so ids can be declared as module-scope const items, the same
way apps already declare their DockWidgetIds.
# use teksilo_widgets::docking::DockActionId;
const SETTINGS: DockActionId = DockActionId::named("app.settings");
assert_eq!(SETTINGS, DockActionId::named("app.settings"));
assert_ne!(SETTINGS, DockActionId::named("app.about"));
pub const fn from_raw(v: u64) -> Self
Wrap a raw value. Prefer named.
pub const fn raw(self) -> u64
pub enum DockActionPlacement
Where a DockAction sits along the rail's column.
#![allow(unused)] fn main() { pub enum DockActionPlacement { /* variants */ } }
Variants
Start— Before the first activity item, in the flowing cluster.End— After the last activity item and after the overflow trigger, still in the flowing cluster — the group grows downward with the tabs.Pinned— Past the flexible spacer, anchored to the rail's far edge regardless of how many activities exist — VS Code's Accounts / Manage-gear cluster. Where a Settings gear belongs.
pub struct DockAction
A dockless command button in the activity rail: it looks and behaves like an activity item, but opens no panel — activating it just runs a closure.
Declared on DockRail::action, so (like the rail's slots) it is per-view
app config, reconstructed each run. A rail action is deliberately more
restricted than a real activity: it is never draggable, never hidable, has
no "Move to" menu, and is never overflow-parked — it is reserved space. That
matches every surveyed precedent (VS Code's fixed Accounts / Manage cluster;
IntelliJ's stripe, whose only non-tool-window button is IDE-owned chrome).
DockRail::new(DockSide::Leading).action(
DockAction::new(
DockActionId::named("app.settings"),
lit!("Settings"),
|| IconWidget::gear(),
|ctx| ctx.send_intent(Intent::new("app.settings")),
)
.placement(DockActionPlacement::Pinned),
)
#![allow(unused)] fn main() { pub struct DockAction { /* fields */ } }
Methods
pub fn new( id: DockActionId, label: impl Into<LocalizedString>, icon: impl Fn() -> IconWidget + 'static, on_activate: impl Fn(&mut EventContext) + 'static, ) -> Self
Declare a rail action. Defaults to DockActionPlacement::End,
enabled, untoggled, with the label as its hover tooltip.
pub fn placement(mut self, placement: DockActionPlacement) -> Self
Where the action sits along the rail. See DockActionPlacement.
pub fn tooltip(mut self, tooltip: impl Into<LocalizedString>) -> Self
Override the hover tooltip (defaults to the label). Ignored in
Icon + Label rail mode, which paints the label inline instead.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable / disable the action. Accepts a bool or a Signal<bool>.
pub fn toggled(mut self, state: Signal<bool>) -> Self
Paint the selected surface while state is true — the same
highlight an open activity gets. Reflect-only: activating the
action does not write state; on_activate must.
pub fn id(&self) -> DockActionId
The action's id.
pub struct DockRail
App-facing configuration for a side's activity rail (Rail presentation).
Pass to DockingLayout::rail. All knobs are
optional; an unconfigured rail uses IconButtonSize::Large items, no
slots, and no overflow affordance (items just clip if the side is too
short).
#![allow(unused)] fn main() { pub struct DockRail { /* fields */ } }
Methods
pub fn new(side: DockSide) -> Self
Configure the rail for side.
pub fn size(mut self, size: IconButtonSize) -> Self
Pick one size for every rail item (IconButtonSize::Compact …
Hero). Default IconButtonSize::Large.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Override the rail strip's background. Accepts Color, a
SurfaceRole, or a Signal<Color>.
Default (unset) is SurfaceRole::Sunken.
pub fn divider(mut self) -> Self
Draw a 1 dp divider line between the rail and the side's content, on
the rail's content-facing edge (RTL-aware). Uses BorderRole::Divider.
Off by default. See divider_color for a custom
colour.
pub fn divider_color(mut self, color: impl Into<ColorProp>) -> Self
Like divider, but with an explicit colour. Accepts
Color, a BorderRole, or a
Signal<Color>.
pub fn top_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self
Widget pinned above the items (e.g. a logo / hamburger). To track the
rail's item size, bind
DockingModel::rail_size_mode_signal
inside the factory.
pub fn bottom_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self
Widget pinned at the bottom of the rail (e.g. settings / account). To
track the rail's item size, bind
DockingModel::rail_size_mode_signal
inside the factory.
pub fn leading_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self
Widget pinned at the start of this side's Strip-presentation tab
bar (via TabWidget::bar_leading_slot).
The Rail-presentation counterpart is top_slot.
Weaker contract than top_slot. top_slot/bottom_slot sit on the
DockActivityBar, which is built whenever the side has a rail — they
survive the side being collapsed. leading_slot/trailing_slot sit
inside the side's TabWidget, which lives within the collapsing
SideClipPane, so they disappear with the content when the side is
hidden. If your content must survive a hidden side, use Rail
presentation, or host it outside the docking system.
pub fn trailing_slot<W: Widget + 'static>(mut self, f: impl Fn() -> W + 'static) -> Self
Widget pinned at the end of this side's Strip-presentation tab
bar. Composed before the side's own "hidden activities" hamburger when
both are present, so neither is dropped. See
leading_slot for the visibility contract.
pub fn action(mut self, action: DockAction) -> Self
Append a dockless command button to this side's rail. Declaration
order is render order within a placement. See DockAction.
Rail presentation only. A side in
TabPresentation::Strip renders no
actions at all — and set_side_rail
can flip presentation at runtime, so a side that flips Rail → Strip drops
its whole action cluster. If that is reachable in your app, mirror the
cluster with trailing_slot, which the same
DockRail can carry alongside its actions.
pub fn overflow_icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self
Choose the glyph for the overflow trigger — the item shown (in place of the surplus items) when they don't all fit. Tapping it opens a popover list of the overflowed entries.
pub enum DockSide
One of the four dockable sides. Leading/Trailing are
writing-direction-relative (mirrored under RTL by the caller); Top/
Bottom never mirror.
#![allow(unused)] fn main() { pub enum DockSide { /* variants */ } }
Variants
Leading— Left in LTR, right in RTL.Trailing— Right in LTR, left in RTL.TopBottom
Methods
pub const ALL: [DockSide;
All four sides, in a stable order.
pub fn is_horizontal_axis(self) -> bool
True for the vertical columns (leading / trailing), whose long axis is vertical — they stack their dock content top-to-bottom.
pub enum DockCorner
One of the four corners of the container. Each corner is owned by exactly
one of its two adjacent sides (Qt setCorner).
#![allow(unused)] fn main() { pub enum DockCorner { /* variants */ } }
Variants
TopLeadingTopTrailingBottomLeadingBottomTrailing
Methods
pub const ALL: [DockCorner;
All four corners.
pub fn adjacent_sides(self) -> (DockSide, DockSide)
The two sides adjacent to this corner: (horizontal side, vertical side) — i.e. (Leading|Trailing, Top|Bottom).
pub struct CornerOwners
Which side owns each corner. Default = the classic IDE shell where the top and bottom bars span the full width and the leading / trailing columns occupy only the middle band.
#![allow(unused)] fn main() { pub struct CornerOwners { /* fields */ } }
Methods
pub fn owner(&self, corner: DockCorner) -> DockSide
pub fn set(&mut self, corner: DockCorner, owner: DockSide)
pub struct DockWidgetId
Process-unique identity for a registered dock widget (the atomic unit).
#![allow(unused)] fn main() { pub struct DockWidgetId(pub u64); }
Methods
pub fn fresh() -> Self
Mint a fresh, process-unique id.
pub fn from_raw(v: u64) -> Self
pub fn raw(self) -> u64
pub struct DockTabId
Process-unique identity for a dock tab (a tab of a side's TabWidget).
#![allow(unused)] fn main() { pub struct DockTabId(pub u64); }
Methods
pub fn fresh() -> Self
pub fn from_raw(v: u64) -> Self
pub fn raw(self) -> u64
pub enum TabPresentation
How a side surfaces its tabs: an in-side strip (the TabWidget's own bar) or an always-visible activity rail outboard of the collapsible content.
#![allow(unused)] fn main() { pub enum TabPresentation { /* variants */ } }
Variants
Strip— In-side tab strip (hidden when a single tab is present).Rail— External always-visible activity rail; the in-side strip is suppressed.
pub enum DockOpenMode
Placement mode for a programmatically-opened dock.
#![allow(unused)] fn main() { pub enum DockOpenMode { /* variants */ } }
Variants
Stack— Stack into the side's currently-selected tab (as a ToolBox section).NewTab— Create a brand-new tab holding just this dock.
pub struct DockOpenLocation
Target for DockingModel::open_dock / DockingModel::move_dock.
#![allow(unused)] fn main() { pub struct DockOpenLocation { /* fields */ } }
Methods
pub fn side(side: DockSide) -> Self
Default placement on a side (stack into the active tab).
pub fn stack(mut self) -> Self
Stack into the side's active tab.
pub fn new_tab(mut self) -> Self
Open as a fresh tab.
pub enum DockRailItemSize
Activity-bar item size for a side's rail (context-menu "Activity bar size").
#![allow(unused)] fn main() { pub enum DockRailItemSize { /* variants */ } }
Variants
Default— The rail's configured size (DockRail::size); icon only, title on hover.Compact— Compact items — the standardIconButtonSize::Defaultregardless of the rail's configured (larger) size; icon only, title on hover. Not the extra-smallCompactbutton: a rail glyph is the activity's identifier and must stay legible.Labeled— Icon at the configured size plus a 90°-rotated title beneath it (the vertical-accordion look). The title shows inline, so no hover tooltip.
Methods
pub fn shows_label(self) -> bool
Whether this mode paints the title inline (rotated) rather than only as a hover tooltip.
pub enum DockTabDisplay
How a side's dock tabs render (context-menu "Tab size").
#![allow(unused)] fn main() { pub enum DockTabDisplay { /* variants */ } }
Variants
Text— Title text only (the default).Icon— The dock's icon only (falls back to the title initial if it has none).IconText— Icon + title.
Methods
pub fn shows_icon(self) -> bool
Whether this mode shows the icon glyph.
pub fn shows_text(self) -> bool
Whether this mode shows the title text.
pub struct DockPolicy
App-declared policy that locks down end-user layout edits on a
DockingLayout. Each flag removes a user
affordance only — the programmatic DockingModel API (a "Toggle panel"
button, open_dock, set_tab_hidden, …) keeps working regardless, so the
app can still drive the layout it has locked for the user.
App-declared each run (like rail_thickness / min_size / DockRail)
— not persisted in DockLayoutState. Set it with
DockingModel::set_policy. Default = everything allowed; DockPolicy::locked
= everything forbidden.
#![allow(unused)] fn main() { pub struct DockPolicy { /* fields */ } }
Methods
pub fn locked() -> Self
A fully locked layout — no user drag, no collapse, no activity hide. The app's programmatic API still drives it.
pub struct DockingModel
The shared docking-layout model. Clone = share-by-handle.
#![allow(unused)] fn main() { pub struct DockingModel(Rc<RefCell<Inner>>); }
Methods
pub fn new() -> Self
A fresh model: four empty, hidden sides and the default corner owners.
pub fn version(&self) -> Signal<u64>
Structural version — bump on tab / pane / section / side add-remove.
The widget binds this at BindingLevel::Rebuild.
pub fn geometry_version(&self) -> Signal<u64>
Geometry version — bump on side size / visibility / corner change.
The widget binds this at BindingLevel::Relayout.
pub fn consume_animate_flag(&self) -> bool
Read-and-reset the "animate the next side show/hide" latch.
pub fn is_registered(&self, id: DockWidgetId) -> bool
Whether a dock id is known (its content factory + meta are registered).
pub fn set_side_rail(&self, side: DockSide, thickness: f32)
Set a side's activity-rail thickness and presentation. A non-zero rail
switches the side to TabPresentation::Rail; the in-side strip is
then suppressed.
pub fn set_side_size(&self, side: DockSide, size: f32)
Set a side's stored content size (px). Relayout only (no rebuild).
pub fn set_side_min_size(&self, side: DockSide, min: f32)
Set a side's minimum content size (px).
pub fn set_policy(&self, policy: DockPolicy)
Set the app's DockPolicy — locks down end-user layout edits (the
programmatic API keeps working). Structural → rebuild.
pub fn policy(&self) -> DockPolicy
The app's current DockPolicy (cheap Copy; read by the widgets in
build() to gate their user affordances).
pub fn set_side_enabled(&self, side: DockSide, enabled: bool)
Enable / disable a whole side. A disabled side renders nothing, reserves no space, is not a drop target, and rejects placement / moves to it; its docks stay in the model and reappear when re-enabled. Structural → rebuild.
pub fn is_side_enabled(&self, side: DockSide) -> bool
Whether a side is enabled (default true).
pub fn set_side_visible(&self, side: DockSide, visible: bool)
Show / hide a whole side (animated).
pub fn set_side_visible_immediate(&self, side: DockSide, visible: bool)
Show / hide a side immediately (no animation — drag-driven).
pub fn toggle_side_visible(&self, side: DockSide)
Toggle a side's visibility (animated).
pub fn select_tab(&self, side: DockSide, tab_idx: usize)
Select the active tab of a side. Repaint only (the Switcher swaps via
its bound selected_tab signal — no rebuild, no relayout).
pub fn select_tab_by_id(&self, side: DockSide, tab_id: DockTabId)
Select a side's active tab by id (position-independent — used by the rail / strip, whose visible order may skip hidden tabs).
pub fn set_tab_hidden(&self, tab_id: DockTabId, hidden: bool)
Hide / show one activity (tab). A hidden activity stays registered (so it remains listable + restorable) but is dropped from the rail and tab strip. Hiding the selected tab moves the selection to the nearest still- visible tab. Structural → rebuild.
pub fn is_tab_hidden(&self, tab_id: DockTabId) -> bool
Whether an activity (tab) is currently hidden.
pub fn side_rail_size(&self, side: DockSide) -> DockRailItemSize
Current activity-bar item size for a side.
pub fn set_side_rail_size(&self, side: DockSide, size: DockRailItemSize)
Set a side's activity-bar item size (reactive → the rail rebuilds).
pub fn rail_size_mode_signal(&self, side: DockSide) -> Signal<DockRailItemSize>
Reactive activity-bar size mode for a side — fires whenever the user
switches Default / Compact / Icon + Label (via the context menu or
set_side_rail_size). Bind it to adapt any
external widget — a rail's slotted controls, an app toolbar — to the
rail's current item size. (The rail rebuilds its slots on every change,
so a slot factory that reads this signal stays in step.)
pub fn side_tab_display(&self, side: DockSide) -> DockTabDisplay
Current dock-tab display mode for a side.
pub fn set_side_tab_display(&self, side: DockSide, display: DockTabDisplay)
Set a side's dock-tab display mode (reactive → the strip rebuilds).
pub fn set_corner(&self, corner: DockCorner, owner: DockSide)
Set the owner of a corner (must be one of its two adjacent sides).
pub fn open_dock(&self, id: DockWidgetId, loc: DockOpenLocation)
Open (or move) a dock onto a side. Already-open docks are relocated (never duplicated).
pub fn promote_to_tab(&self, id: DockWidgetId, side: DockSide, at_tab: usize)
Drag a dock out into its own new tab on side, inserted at at_tab.
pub fn split_into_tab( &self, id: DockWidgetId, side: DockSide, tab_idx: usize, pane_idx: usize, before: bool, )
Drop a dock into an existing tab's Splitter as a new Single pane,
before (before = true) or after the pane at pane_idx.
pub fn stack_into_tab(&self, id: DockWidgetId, side: DockSide, tab_idx: usize)
Drop a dock into a tab as a new Splitter pane appended after its existing panes (the "centre" drop — join this group without choosing a split direction). Each pane is its own single-item ToolBox.
pub fn move_dock(&self, id: DockWidgetId, loc: DockOpenLocation)
Move a dock to another location (close + open in one notify).
pub fn close_tab(&self, tab_id: DockTabId)
Close a whole tab (and every dock it holds).
pub fn move_tab(&self, tab_id: DockTabId, target_side: DockSide, at_tab: usize)
Move a whole tab (its arrangement + every dock + selection) to another
side, re-deriving the Splitter orientation. Inserted at at_tab.
pub fn close_dock(&self, id: DockWidgetId)
Close (remove) a dock from the layout.
pub fn toggle_dock(&self, id: DockWidgetId)
Toggle a dock: close it if open, else open it on its default location.
pub fn reveal_dock(&self, id: DockWidgetId)
Reveal a dock: ensure it is open, show + select its side / tab.
pub fn is_dock_open(&self, id: DockWidgetId) -> bool
pub fn dock_location(&self, id: DockWidgetId) -> Option<DockLoc>
pub fn dock_open_signal(&self, id: DockWidgetId) -> Signal<bool>
A reactive true-while-open signal for an external rail / toolbar.
pub fn is_side_visible(&self, side: DockSide) -> bool
pub fn side_visible_signal(&self, side: DockSide) -> Signal<bool>
pub fn side_selected_tab_signal(&self, side: DockSide) -> Signal<usize>
pub fn side_selected_tab(&self, side: DockSide) -> usize
pub fn side_presentation(&self, side: DockSide) -> TabPresentation
pub fn side_size(&self, side: DockSide) -> f32
pub fn side_min_size(&self, side: DockSide) -> f32
pub fn side_rail_thickness(&self, side: DockSide) -> f32
pub fn side_has_rail(&self, side: DockSide) -> bool
pub fn corner_owner(&self, corner: DockCorner) -> DockSide
pub fn tab_count(&self, side: DockSide) -> usize
pub fn tab_id_at(&self, side: DockSide, idx: usize) -> Option<DockTabId>
The id of the tab at idx in a side's full tab list. The live inverse
of select_tab_by_id — the strip's
index → id selection sync uses it so both directions resolve against the
current order and agree across a reorder (a build-time snapshot would
disagree and feed back unboundedly).
pub fn set_tab_title(&self, tab_id: DockTabId, title: Option<LocalizedString>)
Give an activity (tab) a stable, explicit name, independent of which dock
occupies pane 0 (e.g. a grouped "Source Control" activity holding a file
tree and a git pane). Pass None to clear it (the label then derives from
the primary dock again). App-config — reconstructed each run, like dock
titles; not persisted. Structural → rebuild.
pub fn tab_title(&self, tab_id: DockTabId) -> Option<LocalizedString>
The explicit title set on an activity (None when it derives from its
primary dock).
pub fn activity_of(&self, dock_id: DockWidgetId) -> Option<DockTabId>
The activity (tab) currently holding a dock — apps hold stable
DockWidgetIds, so this is the bridge to address the enclosing tab.
pub fn set_dock_activity_title( &self, dock_id: DockWidgetId, title: impl Into<LocalizedString>, )
Sugar: name the activity that currently holds dock_id. The natural way
to title a grouped activity from app code that holds the dock id.
pub fn enabled_move_targets(&self, from: DockSide) -> Vec<DockSide>
The enabled sides a tab / dock on from can be relocated to (every side
except from, keeping only is_side_enabled).
The "Move to" menus iterate this so a disabled side is never offered as a
silently-rejected target.
pub fn export_state(&self) -> super::state::DockLayoutState
Serialize the user-controllable layout state (sizes / visibility / selections / arrangement structure / corners). App-config (rail thickness, mins, content factories) is reconstructed each run.
pub fn import_state(&self, state: &super::state::DockLayoutState)
Restore a previously-exported state. Unknown dock ids are dropped,
emptied panes / tabs pruned, selections clamped. Bumps version.
pub struct DockWidget
App-facing declaration of a dock widget: identity, chrome metadata, and a
lazy content factory. Collect these on DockingLayout::dock.
#![allow(unused)] fn main() { pub struct DockWidget { /* fields */ } }
Methods
pub fn new<W: Widget + 'static>( id: DockWidgetId, title: impl Into<LocalizedString>, factory: impl Fn(DockWidgetId) -> W + 'static, ) -> Self
Declare a dock widget. factory builds its content the first time the
dock appears (and after it is closed and re-opened).
pub fn icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self
Set the dock's tab / rail icon.
pub fn header_actions( mut self, f: impl Fn(DockWidgetId) -> Vec<ToolbarItem> + 'static, ) -> Self
Attach a factory for the dock's inline header actions — a flat list
of ToolbarActions shown before the ⋮ options button, the VS Code
"view actions" pattern ("New File", "Collapse All", …). Built on demand
each time the dock is placed into a header. The framework hosts them in a
Toolbar, so the actions gain overflow (when the header is tight,
the lowest-priority actions collapse into a
⌄ menu) and the correct axis for free — a horizontal row on leading
/ trailing sides, a vertical column on the rotated top / bottom strip. The
actions appear in any header the dock has: the multi-pane Accordion
header always, and the sole-pane (bare) header when
show_header(true) is set.
Each item is a ToolbarItem — a collapsible
ToolbarAction via
ToolbarItem::action, or a pinned arbitrary widget (a SplitButton, a
search field, …) via ToolbarItem::custom.
DockWidget::new(id, lit!("Explorer"), build).header_actions(|_| vec![
ToolbarItem::action(ToolbarAction::new(lit!("New File"), new_icon).on_activate(..)),
ToolbarItem::custom(CreateSplitButton::new(..)),
])
pub fn show_header(mut self, show: bool) -> Self
Give a sole-pane (bare) dock its own header bar (title + actions +
⋮ options). Default false. The multi-pane Accordion header is always
present regardless; this only governs the bare case. Turn it on to get a
discoverable options button (and inline header_actions) on a dock that
is the only one on its side.
pub fn default_location(mut self, loc: DockOpenLocation) -> Self
The location used when the dock is opened via toggle / reveal
without an explicit target.
pub struct DockLayoutState
The full serializable snapshot of a DockingModel.
#![allow(unused)] fn main() { pub struct DockLayoutState { /* fields */ } }
DragRegion
DragRegion — flexible drag region inside a TitleBar.
Captures pointer events that are not consumed by inner content and
forwards them to the platform host: drag gestures begin a window move,
double taps toggle maximize, and right-clicks open the system window
menu (Wayland only). On Windows the drag rect is published into
HitRegions::drag so the wndproc subclass returns HTCAPTION for
the same area — but the actual publish happens from
crate::title_bar::TitleBar::after_paint, which aggregates this
drag region and the three control buttons into one snapshot per
frame. This widget no longer publishes from paint().
The region grows via flex = 1.0 to claim all remaining horizontal
space in the parent HStack, so it naturally sits between any leading
widgets (app icon, document title) and the trailing WindowControls
cluster. An optional child widget — typically a centered title — is
placed at the full region bounds and passes pointer events upward to
the drag handler when it does not consume them.
// Used internally by TitleBar; the snippet shows the construction pattern.
let region = DragRegion::with_child(host.clone(), TextWidget::new(lit!("My App")));
Builder methods at a glance
with_child, with_child_id, close_action
API reference
📖 Full rustdoc API for this module
pub struct DragRegion
Flexible, hit-transparent region inside a title bar that routes pointer events to the platform host for window dragging, maximize-toggle, and the system window menu.
#![allow(unused)] fn main() { pub struct DragRegion { /* fields */ } }
Methods
pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self
Create a drag region with no inner content — the entire region is a pure drag handle.
pub fn with_child(host: Rc<dyn PlatformTitleBarHost>, child: Box<dyn Widget>) -> Self
Create a drag region wrapping an arbitrary boxed child widget (typically a centered title). Pointer events not consumed by the child bubble up to the drag handler.
pub fn with_child_id(host: Rc<dyn PlatformTitleBarHost>, id: WidgetId) -> Self
Create a drag region with an already-registered child identified by id.
Use this when the child widget was added to the tree before constructing the
region (e.g. when you need the child's WidgetId for another reference).
pub fn close_action( mut self, action: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>, ) -> Self
Forward the title bar's close-action override, so the fallback window menu's Close entry matches the close button. No effect on platforms that provide their own window menu.
DropTarget

DropTarget — a transparent wrapping drop container.
Where DropZone is a standalone "drop files
here" placeholder with its own label / icon / Browse button, DropTarget is
a wrapping container: it turns any existing widget subtree into a drop
target without replacing its visual identity. The wrapped child fills the
bounds and is always visible; the widget adds a reactive highlight border +
tint while a drag hovers and, if a hint slot is set, fades in a centered
popup card ("Drop your image here").
It reacts to both internal drags (typed DragPayload) and external
(OS) drops (files / text / URIs), through the framework's normal drag
pipeline (on_drag_hover / on_drag_leave / on_drop).
// Wrap a panel; accept image files; show a hint while hovering.
DropTarget::new()
.child(my_panel)
.hint(TextWidget::new(lit!("Drop your image here")))
.accept_external_extensions(["png", "jpg", "jpeg"])
.on_drop(|payload, _pos, _ctx| { import(payload.files()); true });
// Typed internal drag — recovers the value even after an OS round-trip
// or across windows (the framework's typed re-entry).
DropTarget::new()
.child(project_card)
.on_drop_typed::<ProjectRef>(|project, _pos, ctx| {
ctx.send_intent(AppIntent::Link(project));
true
});
Multi-zone drops
Beyond the single whole-bounds target, a DropTarget can expose up to five
independently enable-able DropRegions — Center / Top / Bottom /
Leading / Trailing — each with its own optional hint, and route the drop
by which zone the pointer released over. This is the VS Code-style
"drop on the centre to add, drop on an edge to split" affordance
(DockingLayout computes the same five zones by hand). Declare regions with
DropTarget::region; the side zones share one DropTarget::zone_size_factor
(0.1..=1.0, the fraction of the axis each edge strip occupies — 0.2 is the
default fifth, 0.5 bisects) so you size them to the context. Route with
DropTarget::on_region_drop (or observe DropTarget::active_region_signal).
DropTarget::new()
.child(editor_pane)
.zone_size_factor(0.25)
.region(DropRegion::Center, |z| z.hint(TextWidget::new(lit!("Add as tab"))))
.region(DropRegion::Leading, |z| z.hint(TextWidget::new(lit!("Split left"))))
.region(DropRegion::Trailing, |z| z.hint(TextWidget::new(lit!("Split right"))))
.on_region_drop(|region, payload, _pos, ctx| { route(region, payload); true });
Declaring any region switches the target to exactly the declared regions;
declaring none keeps the Center-only whole-bounds default (.hint(w) is
sugar for .region(DropRegion::Center, |z| z.hint(w))). Leading / Trailing
map to left / right — the framework surfaces no writing direction on the
layout context yet, so RTL mirroring is a follow-up.
Each zone can be reactively enabled with z.enabled(signal) (default
true): a bound Signal<bool> disables the zone live — no rebuild — and its
strip then falls through to the next-priority enabled zone (or Center, or
rejects). A drop landing in a middle covered by no enabled zone is rejected;
on_region_drop therefore only ever receives an enabled region.
Styling
The per-zone highlight overlay + hint chrome is a Tier-3 DropTargetStyle;
the default RecipeDropTargetStyle
paints the active zone (centre → frame only, so the wrapped content shows
through; an edge strip → translucent fill + accent frame) and a full-bounds
error border on reject. Override per-call with DropTarget::style or
theme-wide via theme.style_slots.drop_target.
Accessibility
The wrapper is a Role::Group. Live is intentionally not set on the
group (that would announce every change to the wrapped child); instead the
recipe scopes Live::Polite to each hint card so a screen reader announces
the active zone's hint appearing. Each hint is gated by visible_when, so a
non-active zone's hint leaves the AT tree entirely.
Keyboard accessibility is the caller's responsibility
An OS drag cannot be initiated from the keyboard, and — unlike
DropZone, which ships a keyboard-operable
Browse… button as its WCAG 2.1.1 equivalent — DropTarget adds no
keyboard affordance of its own. That is by design: DropTarget wraps
existing content that is expected to already offer a keyboard path to the
same outcome (e.g. a card you can drop a project onto or open with a
context-menu "Link…" command). The drop is an enhancement, not the sole
path.
If you use DropTarget for an action that has no other affordance, you
must add a keyboard equivalent yourself (a button, menu item, or shortcut) —
otherwise the action is unreachable for keyboard-only users, and entirely
unavailable on platforms with no external-DnD backend (e.g. X11, where OS
drag-and-drop is a no-op). DropZone is the better choice when the drop
is the primary action.
Builder methods at a glance
child, child_id, region, zone_size_factor, hint, hint_id, accept_any, accept_external, accept_external_files, accept_external_text, accept_external_extensions, accept_typed, accept_when, targeted_signal, drag_state_signal, active_region_signal, on_drop, on_drop_typed, on_region_drop, on_drag_leave, variant, style
API reference
📖 Full rustdoc API for this module
pub struct DropRegionSpec
Per-region configuration for a multi-zone [DropTarget]: an optional hint
plus a reactive enabled flag. Kept as a struct so more per-zone knobs can
land without a signature churn.
#![allow(unused)] fn main() { pub struct DropRegionSpec { /* fields */ } }
Methods
pub fn new() -> Self
An enabled spec with no hint.
pub fn hint(mut self, widget: impl Widget + 'static) -> Self
Widget shown (centered in this region's rect, inside a popup card) while a drag with an accepted payload hovers this region.
pub fn hint_id(mut self, id: WidgetId) -> Self
This region's hint content by pre-registered WidgetId.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Whether this zone is active — static or signal-bound (default true). A
bound Signal<bool> enables/disables the zone live, without a rebuild:
while disabled the zone stops hit-testing (its area falls through to the
next-priority enabled zone, or Center, or rejects), never highlights,
and never shows its hint. The enabled state is resolved on every drag
tick, so a .set(false) mid-drag takes effect on the next hover.
pub struct DropTarget
A transparent container that turns its child into a drop target. See the module docs.
#![allow(unused)] fn main() { pub struct DropTarget { /* fields */ } }
Methods
pub fn new() -> Self
A drop target with no child yet — call Self::child (required).
pub fn child(mut self, widget: impl Widget + 'static) -> Self
The wrapped content — fills the bounds and is always visible.
pub fn child_id(mut self, id: WidgetId) -> Self
The wrapped content by pre-registered WidgetId.
pub fn region( mut self, region: DropRegion, f: impl FnOnce(DropRegionSpec) -> DropRegionSpec, ) -> Self
Enable and configure a drop DropRegion. Declaring any region
switches the target to exactly the declared regions; declaring none
leaves the implicit Center-only whole-bounds default. The spec closure
configures the region (currently: an optional hint).
DropTarget::new()
.child(editor)
.zone_size_factor(0.25)
.region(DropRegion::Center, |z| z.hint(TextWidget::new(lit!("Add tab"))))
.region(DropRegion::Leading, |z| z.hint(TextWidget::new(lit!("Split left"))))
.region(DropRegion::Trailing, |z| z.hint(TextWidget::new(lit!("Split right"))))
.on_region_drop(|region, payload, _pos, ctx| { route(region, payload); true });
pub fn zone_size_factor(mut self, factor: f32) -> Self
The fraction of the axis each side zone occupies (clamped to
0.1..=1.0). 0.2 is the default fifth; 0.5 bisects. Applies to all
four edge zones in common; Center takes the leftover middle.
pub fn hint(mut self, widget: impl Widget + 'static) -> Self
Widget shown centered inside a popup card while a drag with an accepted
payload hovers. Sugar for .region(DropRegion::Center, |z| z.hint(w)) —
the classic whole-bounds single-zone case.
pub fn hint_id(mut self, id: WidgetId) -> Self
Hint content by pre-registered WidgetId (Center region).
pub fn accept_any(mut self) -> Self
Accept any payload (internal or external). Explicit form of the default.
pub fn accept_external(mut self) -> Self
Accept any external (OS) drop, regardless of content.
pub fn accept_external_files(mut self) -> Self
Accept external drops that carry at least one file. Optimistic at hover
on Wayland (where the file bytes only arrive at drop) if the source
advertises a text/uri-list.
pub fn accept_external_text(mut self) -> Self
Accept external text drops. Optimistic at hover on Wayland if the source advertises a text format.
pub fn accept_external_extensions<I, S>(mut self, extensions: I) -> Self where I: IntoIterator<Item = S>, S: AsRef<str>,
Accept external file drops whose extension is in extensions
(case-insensitive). At hover on Wayland the real check is deferred to
drop (no file bytes yet); it is optimistic if a text/uri-list is
advertised.
pub fn accept_typed<T: 'static>(mut self) -> Self
Accept internal drags whose payload carries a value of type T.
Ergonomic companion to Self::on_drop_typed.
pub fn accept_when(mut self, f: impl Fn(&DragPayload) -> bool + 'static) -> Self
Custom predicate — full control over payload inspection.
pub fn targeted_signal(mut self, signal: Signal<bool>) -> Self
The widget writes true while a drag with an accepted payload is over
the target, false otherwise — SwiftUI's isTargeted pattern. Drive
custom visuals off this signal.
pub fn drag_state_signal(mut self, signal: Signal<DropTargetDragState>) -> Self
Full three-state version of Self::targeted_signal.
pub fn active_region_signal(mut self, signal: Signal<Option<DropRegion>>) -> Self
The widget writes which DropRegion an accepted drag is currently
over (None when idle, rejecting, or over a disabled middle). Drive
custom per-zone visuals off this.
pub fn on_drop( mut self, f: impl FnMut(DragPayload, Point, &mut EventContext) -> bool + 'static, ) -> Self
Handle a drop. Return true to accept, false to reject. Invoked only
when the accept filter passes.
pub fn on_drop_typed<T: 'static>( mut self, mut f: impl FnMut(T, Point, &mut EventContext) -> bool + 'static, ) -> Self
Ergonomic typed drop: implicitly sets accept_typed::<T>() and extracts
the typed value before invoking f. Last-call-wins with Self::on_drop.
pub fn on_region_drop( mut self, f: impl FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool + 'static, ) -> Self
Region-aware drop: receives which DropRegion the pointer released
over, plus the payload. Last-call-wins with Self::on_drop — when set,
it is used instead of the plain on_drop. Invoked only when the accept
filter passes; return true to accept.
pub fn on_drag_leave(mut self, f: impl FnMut(&mut EventContext) + 'static) -> Self
Called when a drag leaves the target (pointer exit, drop completion, or cancel).
pub fn variant(mut self, variant: DropTargetVariant) -> Self
Visual prominence of the hover indicator.
pub fn style(mut self, style: impl DropTargetStyle) -> Self
Per-call style override (Tier-3). Wins over the theme slot and the default recipe.
DropZone

DropZone — a "drop files here" target for external (OS) drag-and-drop.
A bordered, tinted region that accepts files / text / URLs dragged in from the operating system (Finder, Explorer, Nautilus) or another application. It reacts to hover (accept / reject highlight) and fires typed callbacks on drop. Because an OS drag cannot be initiated from the keyboard, the zone also offers a keyboard-operable Browse… button (opening the native file dialog) as the WCAG 2.1.1 equivalent.
DropZone::new(tr!("drop_images_here"))
.subtitle(tr!("png_or_jpeg"))
.accept_extensions(["png", "jpg", "jpeg"])
.allow_multiple(true)
.on_files_dropped(|paths, _ctx| { /* import paths */ });
External drops are delivered through the framework's normal drag pipeline
(on_drag_hover / on_drag_leave / on_drop) once
install_external_dnd is wired and a backend
is available; on platforms with no backend (e.g. X11) the Browse button
keeps the zone fully usable.
Styling
The bordered, tinted chrome is a Tier-3 DropZoneStyle; the default
RecipeDropZoneStyle tracks the
interaction state. Override per-call with DropZone::style or theme-wide
via theme.style_slots.drop_zone.
Accessibility
The zone is a Role::Group labelled by its prompt, with a Live::Polite
status line that announces hover ("Drop to add 3 files"), success
("3 files added"), and rejection. AccessKit models no drag/drop action and
ARIA's aria-grabbed / aria-dropeffect are deprecated, so live-region
announcements plus the Browse fallback are the supported pattern.
Builder methods at a glance
subtitle, accept_extensions, allow_multiple, show_browse_button, starting_dir, browse_label, icon, style, on_files_dropped, on_text_dropped, on_urls_dropped
API reference
📖 Full rustdoc API for this module
pub struct DropZone
A drop target for external (OS) drag-and-drop. See the module docs.
#![allow(unused)] fn main() { pub struct DropZone { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Build a drop zone with the given prompt (e.g. tr!("drop_files_here")).
The label may come from tr!(...) (translated) or
lit!(...); it is resolved eagerly at construction
and stored as a String. Locale changes rebuild the composite parent,
which re-creates the DropZone with a fresh translation — the same
model as Button::new.
pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self
Secondary line under the prompt (e.g. tr!("png_or_jpeg")).
pub fn accept_extensions<I, S>(mut self, extensions: I) -> Self where I: IntoIterator<Item = S>, S: Into<String>,
Restrict accepted files to these extensions (without leading dots, case-insensitive). Empty (the default) accepts any file. Text and URL drops are unaffected.
pub fn allow_multiple(mut self, allow: bool) -> Self
Whether more than one file may be dropped at once. Default true.
When false, a multi-file drop is rejected.
pub fn show_browse_button(mut self, show: bool) -> Self
Show or hide the keyboard-operable Browse button. Default true.
Keeping it visible is strongly recommended — it is the only
keyboard-accessible path to the zone's action.
pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self
Override the Browse button's label (e.g. tr!("browse")).
Directory the Browse button's dialog opens in. If unset, the OS default is
used.
The same builder FilePickerField::starting_dir
offers, and for the same reason: an app that remembers where its writer last
picked files has no way to say so otherwise, because this widget builds its own
FileDialogRequest internally rather than taking one.
pub fn browse_label(mut self, label: impl Into<LocalizedString>) -> Self
pub fn icon(mut self, icon: impl Widget + 'static) -> Self
An icon widget shown above the prompt (any widget — typically an
IconWidget).
pub fn style(mut self, style: impl DropZoneStyle) -> Self
Override the Tier-3 DropZoneStyle for this instance only.
pub fn on_files_dropped( mut self, f: impl FnMut(Vec<PathBuf>, &mut EventContext) + 'static, ) -> Self
Called with the dropped (or browsed) file paths. Files are only accepted when this is set.
pub fn on_text_dropped(mut self, f: impl FnMut(String, &mut EventContext) + 'static) -> Self
Called with dropped plain text. Text drops are only accepted when set.
pub fn on_urls_dropped( mut self, f: impl FnMut(Vec<String>, &mut EventContext) + 'static, ) -> Self
Called with dropped non-file URLs. URL drops are only accepted when set.
Expand

Expand — a layout modifier that claims slack space in a stack and stretches its child to fill the allocated bounds.
Inside an HStack or
VStack, Expand participates in the flex
distribution pass by reporting a non-zero flex weight (default 1.0).
The parent stack distributes leftover space proportionally to each child's
flex weight. Expand::new() competes on both axes;
Expand::horizontal() and Expand::vertical() restrict competition to
the named axis so they do not accidentally steal slack from orthogonal
siblings. By default the wrapped child is stretched to the full allocated
rectangle; call .align_child(alignment) to keep the child at its natural
size and align it within the slot instead.
The default flex basis is zero (CSS flex-basis: 0), giving exact
proportional ratios. Call .respect_intrinsic() to switch to auto
basis where the child's natural size acts as a floor before flex slack is
added.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{HStack, Expand, RectWidget}; // Two panels sharing horizontal space in a 1:2 ratio let _row = HStack::new() .child(Expand::new().flex(1.0).child(RectWidget::new())) .child(Expand::new().flex(2.0).child(RectWidget::new())); }
Builder methods at a glance
horizontal, vertical, flex, align_child, respect_intrinsic, child_id, child
API reference
📖 Full rustdoc API for this module
pub struct Expand
Layout modifier that claims space along one or both axes from its parent and stretches its child to fill it.
In an HStack / VStack, Expand participates in flex slack
distribution: it returns a LayoutResponse with flex (default 1.0),
so the parent stack hands it a share of the leftover space proportional
to flex. Default basis is zero — the wrapped child's natural size
does NOT count in the rigid pool, which gives clean ratio layouts. Call
Expand::respect_intrinsic to switch to auto basis (CSS
flex-basis: auto), where the child's natural size acts as a floor and
flex adds slack on top.
Expand::new() is the common case: claim space, fill the child.
Use .flex(n) to change the ratio (e.g. 1:2 by pairing flex(1) with
flex(2)). Use .align_child(...) to opt out of fill and align the
child at its natural size within the claimed bounds.
horizontal() / vertical() semantics. The named axis is the one
the wrapper competes for slack on. Both sizing and flex behavior
follow from that:
-
Sizing: when the parent binds an axis (
proposal.{axis} = Some), the wrapper claims that axis regardless of its name. SoExpand::vertical(child)inside aVStack(which binds width and leaves height open) fills the VStack's full width AND distributes vertical slack via flex. Cross-axis collapse to child intrinsic only happens when the parent left that axis open too. -
Flex contribution: the wrapper reports its
flexweight only on axes the parent is distributing (i.e. left open).Expand::horizontal()inside aVStackreportsflex = 0on the open vertical axis, so it does NOT compete for vertical slack with siblings — it just claims the cross-axis width and sits at its child's intrinsic height. Symmetric forExpand::vertical()inside anHStack.
#![allow(unused)] fn main() { pub struct Expand { /* fields */ } }
Methods
pub fn new() -> Self
Expand on both axes. Default flex(1), child fills bounds.
pub fn horizontal() -> Self
Compete for slack on the horizontal axis only. Inside an HStack,
distributes flex on width while claiming bound height as-is. Inside
a VStack (which binds width and distributes height), claims the
VStack's full width but reports flex = 0 so it doesn't steal
vertical slack from siblings — height stays at child intrinsic.
pub fn vertical() -> Self
Compete for slack on the vertical axis only. Inside a VStack,
distributes flex on height while claiming bound width as-is. Inside
an HStack (which binds height and distributes width), claims the
HStack's full height but reports flex = 0 so it doesn't steal
horizontal slack from siblings — width stays at child intrinsic.
pub fn flex(mut self, flex: f32) -> Self
Override the flex weight reported to a parent stack. flex(0) opts
out of slack distribution (the wrapper still claims any offered
proposal, useful inside non-stack containers). Default: 1.0.
pub fn align_child(mut self, alignment: Alignment) -> Self
Opt out of stretching the child. The child is laid out at its
natural size and positioned within the Expand's bounds according
to alignment.
pub fn respect_intrinsic(mut self) -> Self
Switch to auto flex basis — the wrapped child's natural size
acts as a floor on each flex axis, and the parent stack adds slack
on top via the flex weight. Useful when the wrapper sits inside an
unconstrained parent (e.g. an outer VStack with height = None),
where the default zero-basis would let the child overflow because
the parent has no bound to share.
Trade-off: with respect_intrinsic, exact ratios bend by content
width — [Expand::flex(1).child(60), Expand::flex(2).child(40)] in
300 px gives 60 + 66 = 126 and 40 + 133 = 173 rather than
100 / 200. Without it (the default), the same layout splits
exactly 100 / 200.
Do not use this inside a bounded parent
The floor is a hard one: Expand reports shrink = 0, so if the
child's natural size exceeds what the parent can offer, the resulting
over-constraint deficit cannot be absorbed and later siblings are
pushed outside the bounds.
This bites hardest with children whose natural size is large and
content-driven. A vertical TabBar
answers an unbounded height query with its stacked height — every tab,
one below another. So:
// 21 tabs => the bar's natural height is ~1050 dp.
VStack::new()
.child(Expand::vertical().respect_intrinsic().child(tab_widget))
.child(status_bar)
makes the VStack want 1050 + status_bar, at every window size. The
status bar is placed at y=1050 and stays below the fold until the window
is grown past it — the bar never scrolls, because it was never asked to
fit. Dropping respect_intrinsic() fixes it: the bar takes the slack
left after the status bar and scrolls its tabs internally.
Rule of thumb: reach for this only when the parent genuinely has no
bound to share (height = None). When the parent is bounded — a window
root, a sized pane — the default zero basis is what you want.
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
Fade
Fade — a wrapper widget that animates its child between hidden
(opacity 0) and visible (opacity 1) when an external
Signal<bool> toggles.
Drives an opacity: Signal<f32> ∈ [0, 1] and applies it to its
own subtree via BuildContext::set_opacity. The framework's
render walker emits SetOpacity(value) before this widget's
paint and RestoreOpacity afterwards, so the multiplier composes
correctly with ancestor opacity scopes via the canvas's stacked
opacity model.
let visible = ctx.signal(false);
ctx.add(Fade::new(visible.clone()).child(tooltip_content));
// ...elsewhere:
visible.set(true); // fades in over `motion.duration_fast`
Layout semantics
Fade does not change layout. The wrapped child reports its full
natural size at all opacity values, so reserving space for a
to-be-faded-in widget works the same whether the widget is fully
visible or fully hidden.
For overlays where the dismiss should be deferred until the
fade-out completes (tooltip / popover / snackbar / dialog),
prefer OverlayRequest::with_fade
instead — that path coordinates the dismiss with the tween so the
overlay survives until the opacity reaches zero.
Reduced motion
Honours prefers-reduced-motion: under reduced motion the
opacity snaps to its end value instead of tweening.
Builder methods at a glance
child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Fade
Wraps a child and animates the entire subtree's opacity between
0 and 1, driven by an external Signal<bool>.
#![allow(unused)] fn main() { pub struct Fade { /* fields */ } }
Methods
pub fn new(visible: impl Into<Prop<bool>>) -> Self
Build a fade wrapper bound to visible. Initially hidden iff
visible.get() is false at the first build().
Accepts any Prop<bool> source — Signal<bool>, Prop<bool>,
or a plain bool (for static "always visible" / "always
hidden" cases without a tween).
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
FilePickerField

FilePickerField — a text-input preset for path entry with a Browse button.
Combines a TextInput with a trailing IconButton (the folder/browse glyph)
that opens a native file dialog and writes the chosen path back into the bound
Signal<String>. The three FilePickerKind variants map to the three
single-result dialog modes: open a file, pick a folder, or save a file.
Multi-file selection does not fit the "one editable line" pattern; use the
file-dialog API directly for that.
// Requires ctx.signal() — shown as ignore per convention.
let path = ctx.signal(String::new());
let _f = FilePickerField::new(path.clone())
.kind(FilePickerKind::OpenFile)
.add_filter("Images", &["png", "jpg"])
.placeholder(lit!("Choose a file…"));
Builder methods at a glance
kind, dialog_title, starting_dir, default_file_name, add_filter, on_pick, placeholder, label, validation, enabled, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub enum FilePickerKind
Which file-dialog kind the trailing button opens.
#![allow(unused)] fn main() { pub enum FilePickerKind { /* variants */ } }
Variants
OpenFile— Open an existing file. Default.PickFolder— Pick an existing folder.SaveFile— Pick a new or existing file location for saving.
pub struct FilePickerField
A single-line path entry field with a trailing Browse button that invokes the
native file dialog and writes the chosen path back into the bound Signal<String>.
#![allow(unused)] fn main() { pub struct FilePickerField { /* fields */ } }
Methods
pub fn new(text: Signal<String>) -> Self
Construct a FilePickerField bound to text. The visible string
is updated on a successful pick; existing content is shown as-is.
pub fn kind(mut self, kind: FilePickerKind) -> Self
Pick the dialog kind opened by the Browse button.
pub fn dialog_title(mut self, title: impl Into<LocalizedString>) -> Self
Title shown in the file-dialog window caption.
pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self
Directory the dialog opens in. If not set, the OS default is used.
pub fn default_file_name(mut self, name: impl Into<String>) -> Self
Pre-filled file name for the FilePickerKind::SaveFile dialog.
No-op for OpenFile / PickFolder.
pub fn add_filter(mut self, label: impl Into<String>, extensions: &[&str]) -> Self
Append an extension filter (label + extensions without leading dots). Repeat to add multiple rows.
pub fn on_pick(mut self, f: impl Fn(&FileDialogResult, &mut EventContext) + 'static) -> Self
Hook invoked with the raw FileDialogResult after the dialog
closes — useful when the caller needs to react to cancellation
or backend errors. The bound text signal is already updated by
the time this fires (on success).
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder text shown when the field is empty.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible name for the path field.
pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self
Bind an external ValidationState signal — shown as the same inline
error/warning strip and border tint the inner TextInput renders (e.g.
"the chosen folder does not exist / is not writable").
pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self
Set the initial enabled state for the text field and Browse button. Forwarded to the arena at build time.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after the hover delay. Clears any previously set rich or composite tooltip (last call wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip by registry key. Clears any previously set plain or composite tooltip (last call wins).
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from inline crate::tooltip::TooltipContent.
Clears any previously set plain or composite tooltip (last call wins).
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree. Clears any previously set plain or rich tooltip (last call wins).
FixedSize
FixedSize — a layout modifier that pins a child to its natural size, optionally overriding one or both dimensions with a reactive value.
Without bindings, FixedSize ignores the parent's size proposal and
always reports the child's intrinsic size. This is useful for widgets
that must not be stretched or compressed by their containing stack —
icons, chips, or thumbnails that must stay at their designed size
regardless of the surrounding layout.
With width or
height, the corresponding dimension is
locked to a reactive Signal<f32> value; the signal change triggers a
relayout automatically. Unbound dimensions still fall back to the child's
natural size.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{FixedSize, RectWidget}; use teksilo_core::signal::Signal; let sidebar_width = Signal::new(240.0_f32); // Pin the sidebar width to a reactive signal let _sidebar = FixedSize::new() .width(sidebar_width) .child(RectWidget::new()); }
Builder methods at a glance
child_id, child, width, height
API reference
📖 Full rustdoc API for this module
pub struct FixedSize
Layout modifier that prevents a widget from expanding beyond its natural size, or constrains it to specific reactive dimensions.
Without bindings, reports the child's natural size (ignoring parent proposal).
With width/height, constrains to the bound values.
#![allow(unused)] fn main() { pub struct FixedSize { /* fields */ } }
Methods
pub fn new() -> Self
Create a FixedSize with no child and no dimension bindings; the child's
natural size will be used for both axes.
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
pub fn width(mut self, state: impl Into<Prop<f32>>) -> Self
Bind width to a reactive state. When the state changes, relayout is triggered.
pub fn height(mut self, state: impl Into<Prop<f32>>) -> Self
Bind height to a reactive state. When the state changes, relayout is triggered.
FocusScope
FocusScope — a layout-transparent wrapper that declares a traversal
boundary for Tab / Shift+Tab focus cycling.
Descendants' tab_index values are scoped to the nearest enclosing
FocusScope: two sibling scopes that both number their children 1, 2, 3
never interleave — each scope is an independent, ordered unit within its
parent. The TraversalScopePolicy controls what Tab does at the scope's
ends:
Continue— Tab flows out of the scope into the enclosing scope's next member (grouping only). Use for logical regions in a continuous Tab order, e.g. dock panels.Cycle— Tab wraps within the scope and never leaves via keyboard. Use for modal dialogs.
// A modal dialog whose Tab order is confined to its own content:
FocusScope::new(TraversalScopePolicy::Cycle).child(dialog_body)
Do not Cycle-wrap a popover, menu or dropdown panel. Those are
non-modal, and the framework dismisses a non-modal overlay when keyboard
focus leaves it — which is what their ARIA patterns (Disclosure, Menu) ask
for, and what keeps an open panel from sitting over the focus ring that
left it. Trapping focus inside one prevents that dismissal from ever
firing. A centered modal needs no wrapper at all: cycle_focus already
roots traversal at the topmost centered overlay's content.
Layout & accessibility
FocusScope imposes no layout — it reports its child's natural size and
places the child at its own bounds (like Fade). It is a
structural boundary, not an AT element: the wrapped child owns its own
accessibility semantics. The scope node is never itself a Tab stop
(BuildContext::set_traversal_scope forces it non-focusable).
Builder methods at a glance
child, child_id
API reference
📖 Full rustdoc API for this module
pub struct FocusScope
Wraps a child subtree and declares it a Tab traversal scope. See the
module documentation for semantics.
#![allow(unused)] fn main() { pub struct FocusScope { /* fields */ } }
Methods
pub fn new(policy: TraversalScopePolicy) -> Self
Create a traversal scope with the given boundary policy.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion — the form teksu! lowers to).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
FontPicker

FontPicker — a drop-in font-family selector.
A ComboBox preset that lists every installed font family and lets
the user pick one, in the tradition of Qt's QFontComboBox, GTK's
FontChooser, and UIKit's UIFontPickerViewController. It
- self-populates from the app's shared typesetter
(
ctx.app_state::<SharedTypesetter>()→families()), so no font list is passed in; - previews each font: every row shows the family name in a legible
system font next to a tiny sample rendered in that font
(
FontPreviewMode::NameThenSample, the default), and the closed trigger shows the selected family in its own typeface; - is searchable (type to filter hundreds of fonts) and
filterable by spacing (
FontSpacingFilter) and by writing system (WritingSystem); - binds the choice to a
Signal<Option<String>>(the family name), which plugs straight intoTextStyle.family/RichTextEditor::set_font_family.
let family: Signal<Option<String>> = Signal::new(None);
VStack::new()
.child(TextWidget::new(tr!(font())).style(TextStyleRole::BodyBold))
.child(FontPicker::new(family.clone())
.on_select(|name, _ctx| editor.set_font_family(name)));
Writing-system detection is off-thread
Classifying which scripts a font covers parses its OS/2 table, i.e. reads the font file — hundreds of reads for a full system. The picker therefore builds the coverage index on a background thread the first time it mounts and polls readiness on the frame tick; until the index is ready the writing-system filter shows the unfiltered list and samples fall back to a Latin default. Spacing (monospaced / proportional) filtering is instant (it uses only font metadata, no bytes).
Only family selection is offered, matching Qt's QFontComboBox. Face /
weight / size selection belongs to a larger font dialog and is out of
scope.
Builder methods at a glance
families, families_with_meta, spacing_filter, writing_system, preview_mode, preview_in_own_font, sample_text, sample_text_for, sample_text_for_family, show_selected_in_own_font, placeholder, label, enabled, variant, style, max_visible_items, searchable, search_query, on_select, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub enum FontSpacingFilter
Spacing filter, mirroring the monospaced / proportional axis of Qt's
QFontComboBox::FontFilters. Cheap — it reads only font metadata.
#![allow(unused)] fn main() { pub enum FontSpacingFilter { /* variants */ } }
Variants
Any— Show all fonts (default).Monospaced— Only monospaced fonts.Proportional— Only proportional (non-monospaced) fonts.
pub enum FontPreviewMode
How each row — and the closed trigger — previews a font.
#![allow(unused)] fn main() { pub enum FontPreviewMode { /* variants */ } }
Variants
NameThenSample— Family name in a legible system font, then a tiny sample rendered in the font itself (the default). The sample text is chosen for the font's writing system.NameInOwnFont— Family name rendered in its own typeface (the Qt / UIKit default).NameInSystemFont— Family name in the system font, no in-font sample (UIKitdisplayUsingSystemFont). Maximum legibility.
pub struct FontMeta
Per-family metadata for headless testing / restricted font sets via
FontPicker::families_with_meta. In a real app this data comes from
the shared typesetter instead.
#![allow(unused)] fn main() { pub struct FontMeta { /* fields */ } }
pub struct FontPicker
A font-family selector built on ComboBox. See the module docs.
#![allow(unused)] fn main() { pub struct FontPicker { /* fields */ } }
Methods
pub fn new(selected: Signal<Option<String>>) -> Self
Create a picker bound to selected (the chosen family name). The
list is enumerated from the app's shared typesetter at build time.
pub fn families(mut self, families: impl IntoIterator<Item = impl Into<String>>) -> Self
Override the family list instead of enumerating from the typesetter.
Family names only — spacing is treated as proportional and
writing-system coverage is unknown (the writing-system filter shows
all). For deterministic filter tests, prefer
families_with_meta.
pub fn families_with_meta(mut self, families: Vec<(String, FontMeta)>) -> Self
Override the family list and its metadata (monospaced + writing systems). Enables headless testing of the spacing / writing-system filters and the script-aware sample without a font backend.
pub fn spacing_filter(mut self, filter: impl Into<Prop<FontSpacingFilter>>) -> Self
Restrict the list by spacing (monospaced / proportional). Accepts a
static value or a Signal for a reactive filter toolbar.
pub fn writing_system(mut self, ws: impl Into<Prop<Option<WritingSystem>>>) -> Self
Restrict the list to fonts covering a writing system. None shows
all. Accepts a static value or a Signal. The first time a
non-None value is applied, the coverage index is built off-thread;
until it is ready the list is unfiltered.
pub fn preview_mode(mut self, mode: FontPreviewMode) -> Self
Choose how rows (and the trigger) preview each font. Default
FontPreviewMode::NameThenSample.
pub fn preview_in_own_font(mut self, on: bool) -> Self
Convenience: true keeps the default preview; false switches to
FontPreviewMode::NameInSystemFont (UIKit displayUsingSystemFont).
pub fn sample_text(mut self, text: impl Into<String>) -> Self
Global sample text override (used when the font's writing system has no more specific sample). Mirrors GTK's preview text.
pub fn sample_text_for(mut self, ws: WritingSystem, text: impl Into<String>) -> Self
Per-writing-system sample override (Qt setSampleTextForSystem).
pub fn sample_text_for_family( mut self, family: impl Into<String>, text: impl Into<String>, ) -> Self
Per-family sample override (Qt setSampleTextForFont) — for fonts
whose script the generic sample doesn't suit (icon fonts, etc.).
pub fn show_selected_in_own_font(mut self, on: bool) -> Self
Whether the closed trigger renders the selected family in its own
typeface (default true; Qt behaviour). No effect in
FontPreviewMode::NameInSystemFont.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder shown when nothing is selected. Defaults to a localized "Select a font…".
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible / control label. Defaults to a localized "Font".
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable / disable the control, statically or reactively.
pub fn variant(mut self, variant: ComboBoxVariant) -> Self
Design-language variant, forwarded to the inner ComboBox.
pub fn style(mut self, style: impl ComboBoxStyle) -> Self
Per-call ComboBoxStyle override, forwarded to the inner combo.
pub fn max_visible_items(mut self, n: usize) -> Self
Maximum rows shown before the dropdown scrolls (default 8).
pub fn searchable(mut self, on: bool) -> Self
Enable / disable the in-dropdown search field (default true).
pub fn search_query(mut self, query: Signal<String>) -> Self
Drive the search field from an external query signal (implies
searchable).
pub fn on_select(mut self, f: impl Fn(&str, &mut EventContext) + 'static) -> Self
React to a commit with a live EventContext — the place to apply
the chosen font (e.g. editor.set_font_family(name)).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip, forwarded to the inner ComboBox.
Mutually exclusive with the rich / composite variants — last-call-wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a registry-keyed rich tooltip, forwarded to the inner combo.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach an inline rich tooltip, forwarded to the inner combo.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip hosting an arbitrary widget tree, forwarded to the inner combo.
FormLayout

FormLayout — a two-column settings or preferences form layout.
Children are added as label/field pairs via FormLayout::line (inline
widgets) or FormLayout::line_ids (pre-registered IDs). Full-width rows
that span both columns — section headers, Dividers, or banners — are
added via FormLayout::full_width / FormLayout::full_width_id. The
label column auto-sizes to the widest label across all pairs so all field
inputs are left-aligned. RTL layouts are handled automatically: the label
column migrates to the trailing side and the field column moves to the
leading side. Dormant rows are excluded from both measurement and
placement.
When an accessible name is provided via FormLayout::label, the widget
emits Role::Form so screen-reader users can navigate directly to the
form. Without a name it demotes to a presentational GenericContainer.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{FormLayout, TextWidget, RectWidget}; use teksilo_i18n::lit; let _form = FormLayout::new() .label_gap(8.0) .row_spacing(6.0) .line(TextWidget::new(lit!("Name:")), RectWidget::new()) .line(TextWidget::new(lit!("Email:")), RectWidget::new()); }
Builder methods at a glance
label_gap, row_spacing, label, line, line_ids, full_width, full_width_id
API reference
📖 Full rustdoc API for this module
pub struct FormLayout
A two-column form layout with auto-sized label column.
Children are added as label/field pairs via line() or as
full-width rows via full_width(). The label column
auto-sizes to the widest label; the field column takes the remaining
space.
┌─ label col ─┐ gap ┌── field col ──────────────┐
│ Name: │ │ [___________________] │
│ Email: │ │ [___________________] │
├─────────────┴─────┴────────────────────────────┤
│ ── Advanced ────────────────────────────────── │ ← full_width
├─ label col ─┐ gap ┌── field col ──────────────┐
│ Port: │ │ [____] │
└─────────────┘ └────────────────────────────┘
#![allow(unused)] fn main() { pub struct FormLayout { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty FormLayout with zero label gap and zero row spacing.
pub fn label_gap(mut self, gap: f32) -> Self
Horizontal gap between the label column and the field column.
pub fn row_spacing(mut self, spacing: f32) -> Self
Vertical gap between rows.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set an accessible name for this form. When set, the widget emits
the Role::Form landmark so assistive-technology users can
navigate directly to it and distinguish it from other forms on
the page. When unset, the widget demotes to a presentational
GenericContainer — an unnamed landmark is worse than no
landmark for AT users.
pub fn line(mut self, label: impl Widget + 'static, field: impl Widget + 'static) -> Self
Add a label/field pair row.
pub fn line_ids(mut self, label_id: WidgetId, field_id: WidgetId) -> Self
Add a label/field pair row with pre-registered widget IDs.
pub fn full_width(mut self, widget: impl Widget + 'static) -> Self
Add a full-width row spanning both columns.
pub fn full_width_id(mut self, id: WidgetId) -> Self
Add a full-width row with a pre-registered widget ID.
Grid

Grid — a 2D layout container with explicit row and column tracks.
Columns and rows are declared as TrackSize slices supporting three
sizing modes: Fixed(px) (exact logical pixels), Auto (sized to the
largest child in that track), and Fractional(fr) (share of the remaining
space after fixed and auto tracks are allocated — the CSS fr unit).
Children are placed in row-major order: child 0 occupies cell
(row=0, col=0), child 1 (row=0, col=1), and so on. Dormant children
are excluded from placement while keeping their siblings at their original
cell positions, so toggling a cell visible/dormant does not shift other
cells.
Fractional columns fall back to the child's natural width when the parent provides no width constraint (intrinsic-measurement pass), preventing wrap-aware children from reporting inflated heights.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{Grid, TrackSize, RectWidget}; // Two equal columns with a fixed 40 dp row, separated by an 8 dp gap let _grid = Grid::new() .columns(vec![TrackSize::Fractional(1.0), TrackSize::Fractional(1.0)]) .rows(vec![TrackSize::Fixed(40.0)]) .column_gap(8.0) .child(RectWidget::new()) .child(RectWidget::new()); }
Builder methods at a glance
columns, rows, column_gap, row_gap, add_child, child, children, child_opt
API reference
📖 Full rustdoc API for this module
pub enum TrackSize
Sizing mode for a single row or column track in a Grid.
#![allow(unused)] fn main() { pub enum TrackSize { /* variants */ } }
Variants
Fixed— Fixed size in logical pixels regardless of available space.Fractional— Share of the remaining space afterFixedandAutotracks are resolved; equivalent to the CSSfrunit. MultipleFractionaltracks divide the remainder proportionally to their weights.Auto— Sized to the largest intrinsic dimension among all children in the track; expands to fill content, never clips.
pub struct Grid
A 2D grid layout container with explicit track declarations.
#![allow(unused)] fn main() { pub struct Grid { /* fields */ } }
Methods
pub fn new() -> Self
Create a new Grid with a single Auto column and a single Auto
row; configure track definitions with columns and
rows.
pub fn columns(mut self, columns: Vec<TrackSize>) -> Self
Set the column track definitions; each entry describes one column's sizing mode.
pub fn rows(mut self, rows: Vec<TrackSize>) -> Self
Set the row track definitions; each entry describes one row's sizing mode.
pub fn column_gap(mut self, gap: impl Into<Prop<f32>>) -> Self
Set the inter-column gap. Accepts static f32 or Signal<f32>.
pub fn row_gap(mut self, gap: impl Into<Prop<f32>>) -> Self
Set the inter-row gap. Accepts static f32 or Signal<f32>.
pub fn add_child(mut self, id: WidgetId) -> Self
Append a pre-registered child by ID; children are placed in row-major
order starting at (row=0, col=0).
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Append an inline child widget in the next cell (row-major order).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Append multiple inline children from an iterator, each occupying the next cell in row-major order.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Append an optional inline child; a None value is a no-op, keeping
subsequent children at their original cell positions.
GridView

Virtualized 2D tile grid bound to a ListModel<T> / ListDataSource.
GridView is the photo-gallery / icon-view / file-manager-grid /
collection-view widget — the 2D sibling of ListView
and TableView. It realizes only the
tiles currently visible (plus a buffer), reflows on resize, supports
single / multi selection with 2D keyboard navigation, and is fully
accessible (Role::Grid → Role::GridCell).
The layout is pluggable via GridLayoutStrategy;
the stock UniformGrid gives fixed tile size /
fixed column count / adaptive min-width grids. (Variable-row-height and
waterfall strategies, plus marquee selection, drag-reorder, sections and
sticky headers, are layered on in later phases.)
GridView::new(model, |tc| {
Box::new(Card::new().child(TextWidget::new(lit!(&tc.item.name))))
})
.sizing(GridSizing::Adaptive { min_width: 120.0, max_width: None, height: 140.0 })
.spacing(8.0)
.selection(selection_model)
Builder methods at a glance
from_source, enabled, sizing, tile_size, column_count, variable_row_heights, item_height, waterfall, column_spacing, row_spacing, spacing, content_inset, selection, on_selection_changed, marquee_selection, wrap_navigation, tab_traversal, show_scrollbar, overscroll_behavior, smooth_scrolling, smooth_scroll_duration, scroll_bar_style, scroll_y_signal, max_scroll_y_signal, viewport_ratio_y_signal, ensure_index_visible, scroll_to_index, sections, section_header_delegate, section_header_height, pinned_section_headers, a11y_label, style, empty_view, loading_view, is_loading, reorderable, exportable, export_external, on_rows_transferred_out, accept_foreign_rows, on_rows_received, on_item_drop, on_tile_activate, activate_on, tile_context_menu, type_ahead_label, tile_a11y_label, type_ahead_timeout
API reference
📖 Full rustdoc API for this module
pub struct TileContext
Context passed to the tile delegate for each realized tile.
Richer than ListView's (index, &item, selected) — carries the 2D
grid coordinates and focus state (mirrors TableView's CellContext).
There is intentionally no is_hovered: hover changes on every
mouse-move and is handled per-tile inside the delegate's own widget
(its interaction signal), never by rebuilding the grid.
#![allow(unused)] fn main() { pub struct TileContext<'a, T: 'static> { /* fields */ } }
pub struct GridView
A virtualized 2D tile grid backed by a ListModel<T>.
#![allow(unused)] fn main() { pub struct GridView<T: 'static> { /* fields */ } }
Methods
pub fn new( model: ListModel<T>, delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static, ) -> Self
Create a grid backed by a ListModel<T>. The delegate builds the
widget for each tile from a TileContext.
pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>( source: S, delegate: impl Fn(&TileContext<'_, T>) -> Box<dyn Widget> + 'static, ) -> Self
Create a grid backed by any ListDataSource (large / external data).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the whole view. A disabled view greys out and stops accepting focus / selection / keyboard input (arena-gated).
pub fn sizing(mut self, sizing: impl Into<Prop<GridSizing>>) -> Self
Set the tile sizing / column-count policy.
Accepts a plain GridSizing (static) or a Signal<GridSizing>
(reactive). A bound signal is observed at [BindingLevel::Rebuild]: when
it changes, build() rebuilds the cached layout strategy and reflows —
the internal scroll_y / focused_index / selection are field signals on
the same widget instance, so they survive the rebuild (no scroll jump).
This is the card-size-slider path; mirrors
TabWidget::sizing.
pub fn tile_size(mut self, width: f32, height: f32) -> Self
Sugar for GridSizing::Fixed — every tile is exactly width × height.
pub fn column_count(mut self, count: usize, tile_height: f32) -> Self
Sugar for GridSizing::FixedColumnCount — exactly count columns.
pub fn variable_row_heights(mut self, estimated: f32) -> Self
Switch to variable row heights: each row is sized to its tallest
tile (SwiftUI LazyVGrid semantics). estimated seeds rows that
haven't been measured yet; the scroll position is anchored when an
estimate is later corrected. Combine with
item_height for exact heights.
pub fn item_height(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self
Supply an exact per-item natural height. Width-independent, so it
doesn't depend on the runtime column count: VariableRowGrid sizes
each row to max(item_height(i)) over its items. Implies variable row
heights, gives an exact scrollbar, and removes anchoring jitter.
pub fn waterfall(mut self, estimated: f32) -> Self
Switch to a Pinterest-style waterfall: per-item variable heights flow
into the currently-shortest column. Column count comes from the
configured sizing; heights are auto-measured (or
exact via item_height). estimated seeds
unmeasured items.
pub fn column_spacing(mut self, spacing: f32) -> Self
Horizontal gap between tiles (default 8).
pub fn row_spacing(mut self, spacing: f32) -> Self
Vertical gap between tile rows (default 8).
pub fn spacing(mut self, spacing: f32) -> Self
Set both column and row spacing.
pub fn content_inset(mut self, inset: EdgeInsets) -> Self
Inset from the scroll-content edge to the tiles.
pub fn selection(mut self, sel: SelectionModel) -> Self
Set the selection model (modes None / Single / Multi).
pub fn on_selection_changed(mut self, f: impl Fn(&BTreeSet<usize>) + 'static) -> Self
Called whenever the selection set changes — including programmatic changes — with the new set of selected indices.
pub fn marquee_selection(mut self, enabled: bool) -> Self
Enable / disable rubber-band marquee selection (default enabled; only
active when the selection model is in Multi mode).
pub fn wrap_navigation(mut self, enabled: bool) -> Self
Whether arrow navigation wraps across row/grid edges (default false).
pub fn tab_traversal(mut self, traversal: GridTabTraversal) -> Self
How Tab moves out of (or within) the grid (default OutOfGrid).
pub fn show_scrollbar(mut self, show: bool) -> Self
Suppress the internal scrollbar (mount your own via the signal accessors so it survives rebuilds).
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Scroll-chaining behavior at the boundary (default Chain).
pub fn smooth_scrolling(mut self, enabled: bool) -> Self
Enable or disable animated wheel scrolling (enabled by default).
pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self
Duration of the smooth scroll animation (default 150 ms).
pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self
How the scroll bar is displayed (default Permanent). Overlay
and Thin float the bar over the content instead of reserving a
layout column, mirroring ScrollArea::scroll_bar_style.
pub fn scroll_y_signal(&self) -> &Signal<f32>
The vertical scroll offset signal.
pub fn max_scroll_y_signal(&self) -> &Signal<f32>
The maximum scroll offset signal (content_height - viewport_height).
pub fn viewport_ratio_y_signal(&self) -> &Signal<f32>
The vertical viewport-to-content ratio signal (drives the thumb size).
pub fn ensure_index_visible(&self, index: usize, anchor: ScrollAnchor)
Scroll the minimum distance to bring index into view per anchor.
pub fn scroll_to_index(&self, index: usize, anchor: ScrollAnchor)
Scroll to index, forcing the viewport position per anchor
(Auto behaves like ensure_index_visible).
pub fn sections<P: SectionProvider>(mut self, provider: P) -> Self
Group the flat model into sections, rendering a header above each section's tile band. Sections compose with the uniform tile layout.
pub fn section_header_delegate( mut self, f: impl Fn(usize, &str) -> Box<dyn Widget> + 'static, ) -> Self
Custom section-header widget builder (section_index, title). Without
it a default bold-text header is used.
pub fn section_header_height(mut self, height: f32) -> Self
Height of each section header row (default 28).
pub fn pinned_section_headers(mut self, enabled: bool) -> Self
Keep the current section's header pinned to the top while scrolling
through it (SwiftUI pinnedViews:[.sectionHeaders]).
pub fn a11y_label(mut self, label: impl Into<String>) -> Self
Accessible label for the grid container.
pub fn style(mut self, style: impl GridViewStyle) -> Self
Per-call Tier-3 decoration style override (focus ring, marquee,
insertion bar, pinned-header surface). Precedence: this override →
theme.style_slots.grid_view → the stock RecipeGridViewStyle.
pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Widget shown when the model is empty.
pub fn loading_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Widget overlaid while is_loading reads true.
pub fn is_loading(mut self, flag: impl Into<Prop<bool>>) -> Self
Reactive loading flag; when true the loading_view
is shown above the grid.
pub fn reorderable(mut self, enabled: bool) -> Self
Enable intra-grid drag reordering (and keyboard Alt+Arrow). The move is
routed through the source's accept_drop (a built-in ListModel
reorders via move_item; an external source applies its own command).
pub fn exportable(mut self, mode: DragTransferMode) -> Self where T: Clone,
Make tiles droppable outside this view — on a
DropTarget, another data view, or the OS.
A dragged tile (or the whole selection, when the pressed tile is part of
a multi-selection) carries clones of its items in a public
RowDragData<T>, so a foreign receiver can pull
them out with payload.get_typed::<RowDragData<T>>() /
DropTarget::on_drop_typed::<RowDragData<T>>() — no serialization. This
also makes tiles a drag source even without reorderable.
mode chooses what happens to the origin rows once a foreign target
accepts them: DragTransferMode::Move removes them (via the source's
on_drag_out, or on_rows_transferred_out),
DragTransferMode::Copy leaves them. A same-view reorder is never a
transfer, so mode never affects it. Requires T: Clone.
pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self where T: Clone,
Additionally advertise the dragged tiles as MIME data so they can be
dropped on a DropZone or exported to another
application / window via the OS. f maps the dragged items to
(mime_type, bytes) pairs (e.g. text/plain, text/uri-list, an
app-specific application/x-…). Implies exportable
(defaulting to DragTransferMode::Move if not already set). Requires
T: Clone.
pub fn on_rows_transferred_out( mut self, f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Override how rows moved out to a foreign target are removed from this
view. Receives the dragged rows' indices (descending-safe) and the live
context. Without this, an exportable
Move drag removes them through the source's
on_drag_out (works out of the box for a ListModel).
pub fn accept_foreign_rows(mut self, accept: bool) -> Self
Accept exported rows dropped from a different view or source without
writing a custom ListDataSource. Pair with
on_rows_received, which is handed the dropped
items and the insertion index. (Same-view reorder is
reorderable; a custom ListDataSource can still
accept foreign drops through its can_accept/accept_drop instead.)
pub fn on_rows_received( mut self, f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Handler for rows accepted via accept_foreign_rows:
(items, insertion_index, ctx). Insert them into your model at the
index.
pub fn on_item_drop( mut self, f: impl Fn( teksilo_core::drag_payload::DragPayload, usize, &mut teksilo_core::widget::EventContext, ) -> bool + 'static, ) -> Self
Accept external drops at a flat insertion index. Returns true when
the drop is accepted.
pub fn on_tile_activate( mut self, f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Called when a tile is activated (a click per activate_on,
or Enter on the focused tile) — the "open / default action", distinct
from selection.
pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self
Choose single- vs double-click tile activation (default
ActivateOn::DoubleClick). Enter activates in either
mode.
pub fn tile_context_menu( mut self, f: impl Fn(usize, Point, &mut teksilo_core::widget::EventContext) -> Option<Box<dyn Widget>> + 'static, ) -> Self
Per-tile context-menu factory: (index, pointer_position, ctx) →
optional menu widget.
pub fn type_ahead_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self
Supply a per-item label for type-ahead navigation (typing letters jumps to the first matching item). Required to enable type-ahead.
pub fn tile_a11y_label(mut self, f: impl Fn(usize) -> String + 'static) -> Self
Supply a per-item accessible name applied to each tile's GridCell
(Node::label), so a screen reader announces a concise item name in
addition to the row/column position. Without it, the cell's name is left
to its contents.
pub fn type_ahead_timeout(mut self, timeout: std::time::Duration) -> Self
Type-ahead reset timeout (default 500 ms; ZERO disables).
pub enum GridTabTraversal
How Tab moves out of (or within) the grid.
#![allow(unused)] fn main() { pub enum GridTabTraversal { /* variants */ } }
Variants
OutOfGrid— Tab releases focus to the next focusable widget in the window.WithinGrid— Tab advances to the next tile (wrapping rows); Shift+Tab the previous.
pub struct GroupingSections
A SectionProvider built by partitioning consecutive equal-key runs of
an (already ordered) model. The titles come from each run's key.
#![allow(unused)] fn main() { pub struct GroupingSections { /* fields */ } }
GroupBox

GroupBox — titled cluster of controls in Int UI / Jewel style.
A bold title (optionally preceded by a checkbox) sits above an indented content area. No border, no frame — pure composition. The standard use is grouping related settings controls on a preferences sheet or form — the IntelliJ "group" pattern.
In checkable mode, unchecking disables event dispatch to every descendant
of the content area (via ctx.enabled_when with ancestor propagation) AND
paints a translucent surface overlay over the content so it reads as
greyed-out. The title checkbox itself stays interactive.
When to use
- GroupBox — logical cluster with a title; optional enable/disable toggle for the whole cluster. Use for settings sections.
GroupHeader— lighter-weight "soft divider + caption" without a content slot; use to label regions that are not collapsed or disabled as a unit.
Accessibility
The box node carries Role::Group and its name is set to the title
string. When checkable and unchecked, set_disabled() is set on the
group node so assistive technology announces the cluster as unavailable.
#![allow(unused)] fn main() { use teksilo_widgets::GroupBox; use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; let _w = GroupBox::new(lit!("Indentation")) .child(TextWidget::new(lit!("Tab width: 4"))); }
Builder methods at a glance
checkable, child, child_id
API reference
📖 Full rustdoc API for this module
pub const GROUP_BOX_CONTENT_INDENT
Horizontal indent of the content area below the title (dp).
#![allow(unused)] fn main() { pub const GROUP_BOX_CONTENT_INDENT: f32 = 24.0; }
pub const GROUP_BOX_TITLE_CONTENT_SPACING
Vertical gap between the title row and the content area (dp).
#![allow(unused)] fn main() { pub const GROUP_BOX_TITLE_CONTENT_SPACING: f32 = 8.0; }
pub const GROUP_BOX_CHECKBOX_GAP
Gap between the checkbox and the adjacent title label in checkable mode (dp).
#![allow(unused)] fn main() { pub const GROUP_BOX_CHECKBOX_GAP: f32 = 6.0; }
pub struct GroupBox
A titled cluster of controls with optional enable/disable toggle.
See the module documentation for the checkable-mode details and
the GroupHeader sibling.
#![allow(unused)] fn main() { pub struct GroupBox { /* fields */ } }
Methods
pub fn new(title: impl Into<LocalizedString>) -> Self
Create a non-checkable group box with the given title.
pub fn checkable(mut self, checked: Signal<bool>) -> Self
Turn this into a checkable GroupBox. When the signal is false, events
to descendants of the content area are blocked via effective-enabled
ancestor propagation. The title checkbox itself stays interactive.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set the content widget inline (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Set the content widget by pre-registered ID.
GroupHeader

GroupHeader — a horizontal section header: label followed by a trailing rule line that fills the remaining width.
Used to segment settings pages, preference sheets, and forms into labelled
regions without the heavier chrome of a GroupBox.
Int UI and Jewel use this pattern as a lightweight "soft divider with a
caption" between groups of related controls.
#![allow(unused)] fn main() { use teksilo_widgets::GroupHeader; use teksilo_i18n::lit; let _w = GroupHeader::new(lit!("Appearance")); }
Trivially composed from existing primitives:
HStack → TextWidget + Expand(Divider).
Builder methods at a glance
style, color, gap
API reference
📖 Full rustdoc API for this module
pub struct GroupHeader
A labelled section header with a trailing rule line.
#![allow(unused)] fn main() { pub struct GroupHeader { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Create a section header with the given label.
pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self
Override the label's text style (font, size, weight, …). Accepts a
static TextStyle or a
TextStyleRole.
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Override the label's color. Useful when a consumer wants to
emphasise a header with an accent. Accepts a literal Color, a
TextRole/SurfaceRole, or a Signal<Color>.
pub fn gap(mut self, gap: f32) -> Self
Horizontal gap between the label and the rule line. Defaults to 8 dp.
HexColorInput

HexColorInput — single-line #RRGGBB[AA] color editor.
A specialization of TextInput that wires an input mask, a
hex-digit character filter, and a strict commit-time validator on top
of the standard text-editing surface. Bound to a Signal<Color>
(required) or Signal<Option<Color>> (nullable). External writes to
the bound signal reformat the field text — but only when the field
is unfocused, so a user typing "FF" in the middle of a long color
code isn't clobbered by a sibling widget tweaking the value.
Behaviour
- Parsing:
#RRGGBB(case-insensitive);#RRGGBBAAifalpha_enabled;#RGBshort-form expands to#RRGGBBifshort_form_enabled. Each accepted form may be normalized to uppercase on commit (configurable). - Char filter: only
[0-9a-fA-F#]admitted while typing. - Mask:
\\#hhhhhh(or\\#hhhhhhhhwith alpha) — theTextInputFieldmask grammar (h= hex digit slot,\\literal escape). - Validation: commits on Enter / Tab-out / blur. Returns
ValidationOutcome::Valid/ValidationOutcome::Corrected/ValidationOutcome::Invalidwhich the inner field maps to a visible inline strip via the standardvalidation_feedbackbridge. - Nullable: empty (after trim) commits
None; non-empty parses normally and commitsSome(color).
Example
let color = ctx.signal(Color::from_hex("#3584E4"));
ctx.add(
HexColorInput::new(color)
.alpha_enabled(true)
.label("Background"),
);
Builder methods at a glance
nullable, alpha_enabled, short_form_enabled, require_hash, uppercase, label, placeholder, enabled, read_only, width, on_value_changed, on_invalid, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, validation_feedback_signal
API reference
📖 Full rustdoc API for this module
pub struct HexColorInput
Single-line hex color editor.
#![allow(unused)] fn main() { pub struct HexColorInput { /* fields */ } }
Methods
pub fn new(value: Signal<Color>) -> Self
Bind to a non-nullable color signal. Empty / invalid input surfaces an error and keeps the previous value. Commits on Enter or blur.
pub fn nullable(value: Signal<Option<Color>>) -> Self
Bind to a nullable color signal. Empty input commits None;
invalid input surfaces an error and keeps the previous value.
Commits on Enter or blur.
pub fn alpha_enabled(mut self, enabled: bool) -> Self
Enable or disable the alpha channel (#RRGGBBAA form). Default false
(#RRGGBB only). When enabled, the input mask and parser both switch
to the 8-digit form; existing values are immediately reformatted.
pub fn short_form_enabled(mut self, enabled: bool) -> Self
Allow CSS #RGB short-form input (each digit doubles: #F0A →
#FF00AA). Default true. When committed, the short form is expanded
and a Corrected feedback is shown to the user.
pub fn require_hash(mut self, required: bool) -> Self
Require the # prefix during input. Default true. Set to false
to accept bare RRGGBB hex digits (e.g. CSS custom property editors).
pub fn uppercase(mut self, upper: bool) -> Self
Normalize committed values to uppercase hex digits. Default true
(#FF0000). Set to false for lowercase (#ff0000). Existing
values are reformatted immediately.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Attach a visible label above the field and use it as the AT name.
pub fn placeholder(mut self, placeholder: impl Into<LocalizedString>) -> Self
Placeholder text shown when the field is empty. Defaults to the
framework's locale-specific #RRGGBB / #RRGGBBAA hint.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Put the field in read-only mode; the value is displayed but cannot be
edited. Forwarded to the inner TextInput.
pub fn width(mut self, width: f32) -> Self
Set a minimum intrinsic width for the field in logical pixels.
pub fn on_value_changed( mut self, f: impl Fn(Option<Color>, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Called after a successful commit with the new color value (None on a
nullable binding when the field is cleared). Not called when the previous
and new values are identical.
pub fn on_invalid( mut self, f: impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Called after a commit attempt when the input is invalid, with the raw typed string. The field is left as-is so the user can correct the value.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after the standard hover delay.
Mutually exclusive with Self::rich_tooltip, Self::rich_tooltip_content,
and Self::composite_tooltip — each setter clears the other three so
the last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip driven by a registry key.
Mutually exclusive with Self::tooltip, Self::rich_tooltip_content,
and Self::composite_tooltip — each setter clears the other three so
the last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from inline crate::tooltip::TooltipContent.
Mutually exclusive with Self::tooltip, Self::rich_tooltip,
and Self::composite_tooltip — each setter clears the other three so
the last call wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with Self::tooltip, Self::rich_tooltip,
and Self::rich_tooltip_content — each setter clears the other three so
the last call wins.
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive handle on the inner TextInput's published validation
feedback. Mirrors the inner field's signal after build().
HStack

HStack — a horizontal layout container that distributes children left-to-right.
Children are given their intrinsic width and the stack's cross-axis height.
Positive slack (leftover space) is distributed among children that carry a
non-zero flex weight (e.g. Spacer, Expand); negative slack (over-constraint)
is absorbed by children with a non-zero shrink weight (e.g. a single-line
TextWidget). Vertical alignment defaults to VAlignment::Center and can be
overridden per-container or per-child.
For a vertical counterpart see VStack.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{HStack, TextWidget, Spacer}; use teksilo_i18n::lit; let _row = HStack::new() .spacing(8.0) .child(TextWidget::new(lit!("Label"))) .child(Spacer::new()) .child(TextWidget::new(lit!("Value"))); }
Builder methods at a glance
spacing, alignment, add_child, child, children, child_opt
API reference
📖 Full rustdoc API for this module
pub struct HStack
Horizontal layout container that distributes children left-to-right.
Cross-axis (vertical) alignment defaults to VAlignment::Center and may be
overridden globally via alignment or per-child via
WidgetTree::set_alignment.
#![allow(unused)] fn main() { pub struct HStack { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty HStack with no spacing and VAlignment::Center.
pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self
Set inter-child spacing. Accepts a static f32 or a reactive
Signal<f32> — use a signal derived from
ctx.theme_signal() to track theme-driven spacing changes.
pub fn alignment(mut self, alignment: VAlignment) -> Self
Set the vertical alignment for children that are shorter than the stack's height.
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child by ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple inline children from an iterator.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Conditionally add a child. No-op if None.
IconButton
![]()
IconButton — a square, icon-only, flat-surface button.
Five sizes covering both embedded use (inside another widget's
trailing slot — TextInput's clear-X, ComboBox's chevron, SearchField's
magnifier) and stand-alone use (toolbars, rich menus, hero CTAs).
The .embedded() flag opts into the JetBrains "built-in" look —
dimmer icon at rest (Secondary), brightening on hover (Primary),
flashing accent on press — so an IconButton living inside a TextInput
doesn't compete visually with the field's text. Without the flag the
icon stays at full visual weight (Primary at rest), the right default
for stand-alone toolbar / menu rows.
#![allow(unused)] fn main() { use teksilo_widgets::{IconButton}; use teksilo_widgets::primitives::IconWidget; use teksilo_i18n::lit; use teksilo_core::Intent; const MY_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>"; // Stand-alone toolbar use — full-weight icon. let _w = IconButton::new(IconWidget::from_svg(MY_SVG)) .toolbar() .tooltip(lit!("Save")) .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save"))); // Embedded inside a TextInput's trailing slot — dim until hover. let _w = IconButton::clear() .embedded() .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.clear"))); }
Predefined constructors
Common roles ship with the appropriate icon and an i18n tooltip
(which doubles as the AT name). They are size- and mode-agnostic —
call .embedded(), .toolbar(), .large(), etc. to configure:
#![allow(unused)] fn main() { use teksilo_widgets::IconButton; use teksilo_core::signal::Signal; let visible = Signal::new(false); let _w = IconButton::browse().embedded(); // 24 dp, dim — TextInput trailing let _w = IconButton::clear().embedded(); // 24 dp, dim — clear-X let _w = IconButton::search().toolbar(); // 40 dp, full weight — toolbar let _w = IconButton::visibility_toggle(visible); // password-field eye toggle }
Bistate
Two distinct toggle modes:
IconButton::toggle— surface-tint bistate: clicking flips the boundSignal<bool>; whiletrue, the background reads asSurfaceRole::Selected("on"). Same icon throughout. The pin-this-row / select-this-tool pattern.IconButton::toggle_with_icon— surface-tint and icon-swap bistate: same surface flip plus the icon glyph swaps to a second icon. The visibility-toggle pattern (eye ↔ eye-off).
Slot convention
Host widgets that accept icon buttons follow the trailing_slot
convention established by TabWidget:
#![allow(unused)] fn main() { use teksilo_widgets::{IconButton, TextInput}; use teksilo_widgets::primitives::HStack; use teksilo_core::signal::Signal; use teksilo_core::Intent; let value = Signal::new(String::new()); let _w = TextInput::new(value) .trailing_slot(HStack::new().spacing(0.0) .child(IconButton::clear().embedded().on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.clear")))) .child(IconButton::browse().embedded().on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.browse")))) ); }
Builder methods at a glance
style, style_shared, size_variant, is_embedded, share_interaction, embedded, icon_role, focusable, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, composite_tooltip_boxed, enabled, size, toolbar, large, hero, on_activate_fn, toggle, toggle_with_icon, has_popup, expanded_when, browse, expand, search, copy, clear, add, bell, menu, more, visibility_toggle
API reference
📖 Full rustdoc API for this module
pub struct IconButton
A square, icon-only, flat-surface button. See module docs for embedded vs stand-alone modes, the five sizes, and the two bistate toggle modes.
#![allow(unused)] fn main() { pub struct IconButton { /* fields */ } }
Methods
pub fn new(icon: IconWidget) -> Self
Create an icon button from a custom icon. Defaults to
IconButtonSize::Default (24 dp) and stand-alone visual mode.
Apply .embedded() for the JetBrains "built-in" dim look,
and one of the size methods (.large() / .toolbar() /
.hero()) or .size(...) to pick a different size.
pub fn style(mut self, style: impl teksilo_core::styles::IconButtonStyle) -> Self
Per-call style override. Replaces the theme-wide default
IconButtonStyle for just this IconButton instance — same role
as Button::style(...). The override fully owns the background +
border + size composition; icon coloring stays on the widget.
pub fn style_shared(mut self, style: SharedIconButtonStyle) -> Self
Per-call style override from an already-shared
SharedIconButtonStyle (Rc<dyn IconButtonStyle>). Same effect as
style but takes the erased handle directly, so a host
(e.g. a Toolbar applying one style to all its icon buttons) can share a
single Rc instead of cloning a concrete style per button.
pub fn size_variant(&self) -> IconButtonSize
Returns the configured size variant. Used by wrappers like
PopoverIconButton
that need to reason about the trigger's footprint at build time
(e.g. to skip a corner decoration that wouldn't fit at Compact).
pub fn is_embedded(&self) -> bool
Returns whether the button is in the JetBrains "built-in" /
embedded color profile (Secondary at rest). Mirror getter to
size_variant for wrappers that want to
derive their own chrome colors from the same icon role.
pub fn share_interaction(mut self, signal: Signal<InteractionState>) -> Self
Bind the button's internal interaction state to a caller-owned
Signal<InteractionState> instead of letting build() allocate
its own. Used by wrapper widgets like
PopoverIconButton
whose disclosure caret needs to match the icon's color across
hover / press / focus / disabled states.
The provided signal is reset to Disabled when enabled == false
during build() so the shared signal honors the button's
enabled state without the caller having to seed it.
pub fn embedded(mut self) -> Self
Opt into the embedded visual treatment — the JetBrains
"built-in button" look. Icon dims to Secondary at rest,
brightens to Primary on hover, flashes Accent on press —
designed to live inside another widget's trailing slot
(TextInput's clear-X, ComboBox's chevron) without competing
visually with the host's content. Default mode is stand-alone
(icon at full visual weight, Primary always).
pub fn icon_role(mut self, role: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the icon's tint with a static ColorProp. When set,
the icon ignores embedded and the auto-derived idle/hover/press
role cascade — its color is bound directly to this prop instead.
Use for chrome whose host enforces a single text role across all
of its sub-widgets (e.g. tab-bar scroll arrows that must match
the tab strip's idle_text_role regardless of hover state).
Accepts Color, TextRole, Signal<Color>, or Signal<TextRole>.
It replaces the interaction cascade (idle / hover / press / focus),
not the disabled substitution: a role passed here still resolves to
TextRole::Disabled in a disabled subtree, like every other
role-derived color (see ColorProp::resolve).
That is what a disabled
control should look like. When the tint is semantic state that stays
true even though the button can't be pressed — a save/sync indicator, a
validation badge — wrap it: .icon_role(ColorProp::undimmed(role)).
pub fn focusable(mut self, on: bool) -> Self
Whether the button takes keyboard focus. Default true —
the button is focusable when enabled. Set to false for
embedded-control patterns where the parent owns focus and
keyboard interaction goes through the parent (e.g. the
close button inside a tab header — Tab moves between tabs,
not onto their close buttons).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a tooltip that appears after a hover delay. Required — the tooltip text doubles as the AT name for icon-only buttons.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip
registry. See Button::rich_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. See Button::composite_tooltip.
pub fn composite_tooltip_boxed( mut self, content: Box<dyn teksilo_core::widget::Widget>, ) -> Self
Attach a composite tooltip from an already-boxed widget — the boxed twin
of composite_tooltip, for hosts that build
the body via a Fn() -> Box<dyn Widget> factory (e.g. a ToolbarAction).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Disabled
buttons ignore input and dim their icon (handled by the
framework's PaintContext::effective_enabled). Forwarded into
the arena via ctx.enabled_when(self_id, self.enabled.clone())
at build time — a bound signal updates live as it changes.
For a reactive enabled state — e.g. a toolbar button that
enables only when the caret is inside a table — pass a
Signal<bool> here, or call ctx.enabled_when(button_id, my_signal) from the composing widget's build() instead of
(or in addition to) this builder. Both routes write to the
same arena enabled_state; an external enabled_when
registered after this builder runs wins (last-write semantics)
and updates reactively from the signal.
pub fn size(mut self, size: IconButtonSize) -> Self
Set the size variant. Most callers prefer the named shortcuts
large / toolbar /
hero; use .size(...) for Compact or for
programmatic size selection.
pub fn toolbar(mut self) -> Self
Shortcut for .size(IconButtonSize::Toolbar) (30 dp) — the
IntelliJ side-toolbar density (left / right / top window edges).
pub fn large(mut self) -> Self
Shortcut for .size(IconButtonSize::Large) (40 dp) —
emphasized stand-alone buttons in rich menus and detail panes.
pub fn hero(mut self) -> Self
Shortcut for .size(IconButtonSize::Hero) (50 dp) — hero /
landing-screen CTAs.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on activation. Fires after the toggle signal (if any) is flipped, so apps observing the closure see the post-flip state.
pub fn toggle(mut self, state: Signal<bool>) -> Self
Enable surface-tint bistate: clicking flips state and the
background reads as SurfaceRole::Selected while state == true.
The icon glyph is unchanged. Pin / select / lock-toggle pattern.
on_activate_fn, if any, still fires after the flip.
For the eye / eye-off pattern where the icon glyph also changes,
use toggle_with_icon instead.
pub fn toggle_with_icon(mut self, state: Signal<bool>, toggled_icon: IconWidget) -> Self
Enable surface-tint plus icon-swap bistate: clicking flips
state, the background flips to Selected, and the icon
swaps to toggled_icon. The visibility-toggle pattern (eye ↔
eye-off). For surface-only bistate (icon stays the same), use
toggle.
pub fn has_popup(mut self, kind: teksilo_core::accesskit::HasPopup) -> Self
Declare that this button is a disclosure trigger for a popup
(menu, dialog, listbox, …). Surfaced via set_has_popup in
the a11y node so screen readers announce it as opening the
named popup kind. Wired automatically by
PopoverIconButton.
pub fn expanded_when(mut self, signal: impl Into<Prop<bool>>) -> Self
Bind a signal reporting whether this button's popup is
currently visible. The popover wrapper owns the signal and
flips it on show / dismiss; IconButton reads it in
accessibility() to publish set_expanded. Only meaningful
alongside has_popup.
pub fn browse() -> Self
Browse button (ellipsis icon). Opens a file/directory chooser.
pub fn expand() -> Self
Expand button (diagonal resize arrows). Enlarges a constrained field.
pub fn search() -> Self
Search button (magnifier icon). Triggers a search.
pub fn copy() -> Self
Copy button (clipboard icon). Copies the field content.
pub fn clear() -> Self
Clear button (X icon). Clears the field content.
pub fn add() -> Self
Add button (plus icon). Adds a new entry.
pub fn bell() -> Self
Notification bell. Used by
NotificationCenterButton
— the bell-icon trigger that opens the notification log popover.
pub fn menu() -> Self
Menu / hamburger button (three horizontal bars). Used by the
collapsible MenuBar as the
collapsed representation that reveals the bar when activated.
Advertises HasPopup::Menu for assistive technology.
pub fn more() -> Self
"More actions" / overflow button — three vertical dots (the kebab
⋮). The conventional trigger for a per-item options menu (view-header
…, list-row overflow). Advertises HasPopup::Menu for assistive
technology. Pair with a PopoverIconButton + MenuList (use .bare()
so the menu isn't wrapped in a second popover surface).
pub fn visibility_toggle(visible: Signal<bool>) -> Self
Visibility toggle (eye / eye-off). Toggles password visibility.
Uses the icon-swap bistate mode internally — the icon advertises
the expected action, matching the prevailing password-field
convention (1Password, Bitwarden, KeePass, Chrome, GitHub):
eye (open) while the value is hidden, suggesting "click to
reveal"; eye_off (closed) once revealed, suggesting "click to
hide". set_toggled still reports the literal current state, so
AT readers are not misled.
For a current-state-instead semantics (icon shows what IS),
build your own with toggle_with_icon
and the eye glyphs in the opposite order.
The visible signal is flipped on each click. The host widget reads
it to decide whether to mask or show the text.
pub struct BuiltInIcons
Icon factory set for predefined built-in buttons.
Each field is a function pointer that creates an IconWidget.
The default implementation uses SVG icons embedded in teksilo-widgets.
Overriding
Call BuiltInIcons::set_global at app startup (before creating any
built-in buttons) to replace the default icon set:
#![allow(unused)] fn main() { use teksilo_widgets::{BuiltInIcons}; use teksilo_widgets::primitives::IconWidget; const MY_BROWSE_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>"; const MY_CLEAR_SVG: &str = "<svg xmlns='http://www.w3.org/2000/svg'/>"; BuiltInIcons::set_global(BuiltInIcons { browse: || IconWidget::from_svg(MY_BROWSE_SVG), clear: || IconWidget::from_svg(MY_CLEAR_SVG), ..BuiltInIcons::defaults() }); }
#![allow(unused)] fn main() { pub struct BuiltInIcons { /* fields */ } }
Methods
pub fn defaults() -> Self
Return the default icon set (SVGs embedded in teksilo-widgets).
pub fn set_global(icons: Self)
Set the global icon set. Call at app startup before creating any
built-in buttons. Can only be set once: the global is a
process-wide OnceLock, so the first set wins and any later
call is ignored (and warns). It is also locked in the first time
global() reads it, so set it before any built-in
button is created. Use defaults() with struct
update syntax to override only specific icons.
IconWidget
![]()
IconWidget — a vector or raster icon rendered at a configurable size.
Supports multiple source formats: programmatic Path (checkmarks,
chevrons, dots), SVG strings, PNG, static WebP, and animated WebP. Icons
default to tintable mode — the pixels are treated as an alpha mask
and multiplied by the widget's color property (defaults to
TextRole::Primary) so they follow theme switches automatically.
IconMode::FullColor preserves original pixel colors and is appropriate
for emoji-style graphics or brand logos.
For arbitrary-aspect-ratio photos or artwork see
ImageWidget.
Accessibility
Icons are decorative by default — they set no accessibility role and
announce nothing. The parent widget (e.g. Button, IconButton) is
responsible for the accessible label.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::icon_widget::{IconWidget, IconMode}; use teksilo_tokens::TextRole; let _check = IconWidget::checkmark(20.0); let _chevron = IconWidget::chevron_down(16.0) .color(TextRole::Primary) .follow_text_scale(false); }
Builder methods at a glance
from_path, checkmark, dash, radio_dot, chevron_down, chevron_right, chevron_left, chevron_up, from_svg, from_svg_icon, from_png, from_webp, from_raster, from_animated, mode, color, icon_size, follow_text_scale
API reference
📖 Full rustdoc API for this module
pub enum IconMode
Whether an icon is rendered as a theme-tinted mask or in its original colors.
Applies to every source an IconWidget can hold — raster and SVG. For an
SVG the two modes select between the two representations the parser builds
(see teksilo_canvas::svg): Tintable draws the merged
silhouette in the widget's color, FullColor walks the
document-ordered ops and honours each shape's own fill / stroke / gradient.
The default is Tintable, which is what a UI glyph wants —
it follows the theme into dark mode. Reach for
FullColor for artwork whose colors are the content: a
brand mark, a flag, a colored file-type badge. A currentColor shape inside
full-color artwork still takes the widget's color, so the two are mixable.
#![allow(unused)] fn main() { pub enum IconMode { /* variants */ } }
Variants
Tintable— Treat as an alpha mask: tint the whole icon with the widget's color.FullColor— Render the icon's own colors; the widget color suppliescurrentColorand its alpha attenuates the result.
pub struct IconWidget
A leaf widget that renders an icon from a path, SVG string, PNG, or WebP source.
#![allow(unused)] fn main() { pub struct IconWidget { /* fields */ } }
Methods
pub fn from_path(path: Path, size: f32) -> Self
Create an icon from a custom path. The path should be defined in coordinates matching the given size (e.g., 0..24 for size=24).
pub fn checkmark(size: f32) -> Self
A checkmark icon (✓) at the given size.
pub fn dash(size: f32) -> Self
A short horizontal dash at the given size — used as the indeterminate-state glyph for tristate menu items (mirrors the Windows "mixed-state" convention).
pub fn radio_dot(size: f32) -> Self
A small filled disc centered in the given size — used as the selected-state glyph for radio menu items.
pub fn chevron_down(size: f32) -> Self
A downward-pointing chevron (▼) at the given size.
pub fn chevron_right(size: f32) -> Self
A right-pointing chevron (▶) at the given size.
pub fn chevron_left(size: f32) -> Self
A left-pointing chevron (◀) at the given size.
pub fn chevron_up(size: f32) -> Self
An upward-pointing chevron (▲) at the given size.
pub fn from_svg(svg_str: &str) -> Self
Create an icon from an SVG string. Parses the SVG and extracts
geometry, ignoring any colors in the SVG. Display size defaults
to the SVG's viewBox dimensions; use icon_size
to override.
If parsing fails, logs the error in debug mode and produces an empty icon.
pub fn from_svg_icon(icon: &SvgIcon) -> Self
Create an icon from a pre-parsed SvgIcon. Display size
defaults to the SVG's viewBox; use icon_size
to override. Scaling is deferred to paint time.
pub fn from_png(data: &'static [u8], size: f32) -> Self
Create an icon from PNG data.
If decoding fails, logs the error in debug mode and produces an empty icon.
pub fn from_webp(data: &'static [u8], size: f32) -> Self
Create an icon from WebP data. Auto-detects static vs animated.
If decoding fails, logs the error in debug mode and produces an empty icon.
pub fn from_raster(icon: &RasterIcon, size: f32) -> Self
Create an icon from a pre-decoded RasterIcon.
Accepts a reference — pixel data is copied internally.
pub fn from_animated(icon: &AnimatedIcon, size: f32) -> Self
Create an icon from a pre-decoded AnimatedIcon.
Accepts a reference — frame data is copied internally.
pub fn mode(mut self, mode: IconMode) -> Self
Set the icon rendering mode (tintable or full-color). Re-computes cached pixel data for raster/animated icons.
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Set the tint. Accepts any impl Into<ColorProp>:
- A raw
Color— a frozen literal. - A
TextRole/SurfaceRole/BorderRole— resolved against the theme at paint time (reactive across theme switches). - A
Signal<Color>— reactive state (usually interaction-driven).
pub fn icon_size(mut self, size: f32) -> Self
Set the display size of the icon. The path/image is scaled to fit this size during rendering. This does not affect the design-time coordinate space — SVG paths scale correctly.
pub fn follow_text_scale(mut self, follow: bool) -> Self
Make this icon grow with the global accessibility text scale
(ctx.text_scale). Off by default. Enable for icons that sit inline
with text and should scale together — e.g. status glyphs in a
SeverityBadge. The reported (and rendered) size becomes
display_size × text_scale.
ImageMaskShape
Anti-aliased alpha masking for raster images — circle / rounded-square / square coverage applied in-place to RGBA8 pixel buffers.
The retained renderer's Canvas::set_clip is rectangular-only, so to
crop a photo into a circle (avatar, contact icon, channel thumbnail,
etc.) we modulate the source image's alpha channel with a per-pixel
coverage value computed analytically. 4×4 super-sampling (16
sub-samples per pixel) gives a smooth edge at the small sizes these
masks are typically used at (≤96 logical pixels).
Used directly by ImageWidget::mask and
by Avatar. Other widgets that want a non-rectangular image silhouette
can call apply_alpha_mask and center_crop_square directly.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::image_mask::{ImageMaskShape, apply_alpha_mask}; let mut pixels = vec![255u8; 32 * 32 * 4]; // opaque white 32×32 apply_alpha_mask(&mut pixels, 32, 32, ImageMaskShape::Circle); // Corner pixels are now transparent; the center is still opaque. assert_eq!(pixels[3], 0); // top-left alpha assert_eq!(pixels[(16 * 32 + 16) * 4 + 3], 255); // center alpha }
API reference
📖 Full rustdoc API for this module
pub enum ImageMaskShape
Shape of the alpha mask applied to an image.
RoundedSquare carries the corner radius as a fraction of the
shorter side (0.0 ⇒ square, 0.5 ⇒ circle), matching the convention
Avatar and ImageWidget::mask accept on their public APIs. The
apply_alpha_mask helper expects a radius in pixels — convert
before calling.
#![allow(unused)] fn main() { pub enum ImageMaskShape { /* variants */ } }
Variants
None— No mask. The pixels pass through unchanged.Circle— Inscribed circle in the image's bounding square (after a centred crop to the shorter side).RoundedSquare— Rounded rectangle. The carriedf32is the corner radius as a fraction ofmin(width, height), clamped to0.0..=0.5.
pub fn center_crop_square(...)
Crop the source RGBA buffer to a centered square of edge min(w, h).
The returned buffer is side * side * 4 bytes. If the input is
already square, a copy of the original is returned.
#![allow(unused)] fn main() { pub fn center_crop_square(pixels: &[u8], width: u32, height: u32) -> (Vec<u8>, u32); }
pub fn apply_alpha_mask(...)
Apply an alpha mask in-place to an RGBA8 buffer. RGB channels are
preserved; only alpha is modulated by the coverage value, so a
pre-multiplied source remains pre-multiplied (the alpha-channel-only
transformation matches RasterIcon::to_alpha_mask).
The shape accepts the public ImageMaskShape surface; the
RoundedSquare radius is interpreted as a fraction of
min(width, height), clamped to 0.0..=0.5. None is a no-op.
#![allow(unused)] fn main() { pub fn apply_alpha_mask(pixels: &mut [u8], width: u32, height: u32, shape: ImageMaskShape); }
ImageWidget
ImageWidget — displays a raster image (PNG, WebP) with a configurable sizing policy, content-fit mode, and intra-box alignment.
Unlike IconWidget which is designed
for small square tintable icons, ImageWidget handles arbitrary aspect
ratios and defaults to full-color rendering.
Sizing model
Two independent concerns, mirroring Qt's QLabel/QPixmap, SwiftUI's
Image, and CSS's replaced-element model:
- Box size — how big the widget's layout rectangle is.
width/height/sizepin a fixed logical extent. A pinned axis is rigid: it is reported as-is and is never scaled up to a parent's proposal (this is the SwiftUI.frame(width:height:)/ Qt fixed-size behaviour). Pinning only one axis derives the other from the image's aspect ratio (CSSwidth: Npx; height: auto).- With no axis pinned the widget reports its natural pixel size.
By default (
resizable= true) a constraining proposal scales that natural size down/up while preserving aspect ratio;resizable(false)locks it to the raw pixel dimensions (SwiftUI's default non-.resizable()image).
- Content fit — how the image pixels map into that box, via
ImageFit(Contain/Cover/Fill/ScaleDown/None, the CSSobject-fitset) plusalignment(the CSSobject-positionequivalent) for where slack/overflow lands. Modes that overflow the box (Cover, andNoneon an oversized image) are clipped to the box so the image never bleeds past its layout rectangle.
For a fixed 32×32 logo: ImageWidget::new(icon).size(32.0, 32.0) — the
box is exactly 32×32 and the artwork is letterboxed inside it
(Contain, the default).
#![allow(unused)] fn main() { use teksilo_canvas::RasterIcon; use teksilo_widgets::primitives::image_widget::{ImageWidget, ImageFit}; use teksilo_widgets::primitives::image_mask::ImageMaskShape; // A 64×64 image shown at natural size with no masking. let icon = RasterIcon::from_raw(vec![255; 64 * 64 * 4], 64, 64); let _logo = ImageWidget::new(&icon).size(32.0, 32.0); // Cover a square avatar slot and crop to a circle. let _avatar = ImageWidget::new(&icon) .mask(ImageMaskShape::Circle) .fit(ImageFit::Cover) .alt("User avatar") .size(48.0, 48.0); }
Builder methods at a glance
from_raw, mask, fit, alignment, width, height, size, resizable, alt, a11y_hidden
API reference
📖 Full rustdoc API for this module
pub enum ImageFit
How the image is fitted within its layout bounds.
#![allow(unused)] fn main() { pub enum ImageFit { /* variants */ } }
Variants
Contain— Scale to fit entirely within bounds, preserving aspect ratio. May leave empty space (letterboxing).Cover— Scale to cover the entire bounds, preserving aspect ratio. May crop the image.Fill— Stretch to fill bounds exactly, ignoring aspect ratio.ScaleDown— Like Contain but never upscales — if the image is smaller than bounds, it is centered at its natural size.None— Draw the image at its natural pixel size, neither scaling up nor down. If the image is larger than the box it is cropped to the box (positioned byalignment); if smaller it sits inside with empty space. CSSobject-fit: none.
pub struct ImageWidget
A widget that displays a raster image (PNG, WebP, or raw RGBA pixels) with configurable fit and alignment.
#![allow(unused)] fn main() { pub struct ImageWidget { /* fields */ } }
Methods
pub fn new(icon: &RasterIcon) -> Self
Create from a decoded RasterIcon (e.g., from res!()).
pub fn from_raw(pixels: Vec<u8>, width: u32, height: u32) -> Self
Create from raw RGBA pixel data.
Each call gets a unique texture-atlas key (via a process-local
atomic counter), so two from_raw widgets with the same
dimensions but different bytes don't alias in the renderer's
pending-image cache. Without this, the first writer per frame
would silently win and subsequent ones would render the wrong
pixels — a latent bug fixed alongside the dynamic-image use
cases that need many short-lived from_raw widgets.
pub fn mask(mut self, shape: ImageMaskShape) -> Self
Apply an anti-aliased alpha mask to the image at construction time. The pixels are first centre-cropped to the shorter side (so the mask shape is geometrically consistent regardless of the source aspect ratio), then their alpha channel is modulated by the mask coverage. RGB is preserved.
Cover fit is the natural pairing — the masked square fills
the avatar/thumbnail bounds and the masked-out corners stay
transparent. Contain works but may letterbox. The default
fit (Contain) is left unchanged so callers explicitly pick
a fit when they apply a mask.
ImageMaskShape::None is a no-op. Re-uploading is keyed off a
fresh per-mask name so the un-masked version of the same
source doesn't shadow the masked one in the texture atlas.
pub fn fit(mut self, fit: ImageFit) -> Self
Set the content-fit mode — how the image pixels map into the box.
See ImageFit.
pub fn alignment(mut self, alignment: Alignment) -> Self
Set where the fitted image sits within the box when the active fit
leaves slack or crops (the CSS object-position analogue). Defaults
to Alignment::CENTER. Leading/Trailing resolve against the
active layout direction (RTL-aware).
pub fn width(mut self, w: f32) -> Self
Pin a fixed display width (in logical pixels). The width axis
becomes rigid — reported as-is and never scaled to a parent
proposal. With no height pinned, the height derives from the
image's aspect ratio (CSS width: Npx; height: auto).
pub fn height(mut self, h: f32) -> Self
Pin a fixed display height (in logical pixels). The height axis becomes rigid. With no width pinned, the width derives from the image's aspect ratio.
pub fn size(mut self, w: f32, h: f32) -> Self
Pin both display width and height (in logical pixels). The box is
exactly this size, rigid on both axes; the image content is fitted
inside it via the fit mode. This is the
fixed-size-logo case — .size(32.0, 32.0).
pub fn resizable(mut self, resizable: bool) -> Self
Control whether, with no axis pinned, a constraining parent
proposal scales the natural pixel size (true, the default) or the
box stays locked to the raw pixel dimensions (false). Equivalent
to opting out of SwiftUI's .resizable(). No effect once a
dimension is pinned via width /
height / size.
pub fn alt(mut self, text: impl Into<String>) -> Self
Set the accessibility alt text.
pub fn a11y_hidden(mut self) -> Self
Mark this image as decorative — hidden from the accessibility
tree. Use when the image's semantic content is already conveyed
by adjacent text (e.g. a hero image next to its caption). ARIA
equivalent of alt="" / role="presentation".
InputDialog
InputDialog — a QInputDialog-style modal that prompts the user for
a single string. Built on the same present_modal infrastructure as
MessageBox, with a TextInput
body between the prompt and the Ok / Cancel buttons.
Use MessageBox when the dialog
conveys information without requiring data; use InputDialog when
the modal needs to capture exactly one short string. Forms longer
than a single field belong in a custom Dialog.
InputDialog::new(tr!(rename_title()))
.prompt(tr!(rename_prompt()))
.default_text(current_name)
.placeholder("New name")
.on_result(|result, _ctx| {
if let Some(name) = result {
rename(name);
}
})
.present(ctx);
Live validation
validate runs on every keystroke and both disables OK
and shows its message under the field, so a value the caller cannot accept can never
be submitted:
InputDialog::new(tr!(save_as_template_title()))
.validate(move |name| {
if name.trim().is_empty() {
Err(None) // block, say nothing
} else if let Some(clash) = taken(name) {
Err(Some(tr!(duplicate(name = clash)))) // block, and explain
} else {
Ok(())
}
})
.on_result(|result, _| { /* only ever called with a valid value */ })
.present(ctx);
Err(None) is the "not yet" case — it disables OK without printing anything, which
is what an untouched empty field wants: shouting at someone before they have typed
is noise, and the greyed button already says the dialog is not ready. A message is
withheld until the field has been edited for the same reason, so a caller can return
Err(Some(..)) for the empty case without it flashing on open.
Builder methods at a glance
prompt, placeholder, default_text, ok_label, cancel_label, on_result, validate, present
API reference
📖 Full rustdoc API for this module
pub type ValidateResult
Verdict from an InputDialog::validate callback.
Ok(()) accepts. Err(None) blocks silently; Err(Some(msg)) blocks and shows
msg beneath the field once it has been edited.
#![allow(unused)] fn main() { pub type ValidateResult = Result<(), Option<LocalizedString>>; }
pub struct InputDialog
A single-field input modal.
#![allow(unused)] fn main() { pub struct InputDialog { /* fields */ } }
Methods
pub fn new(title: impl Into<LocalizedString>) -> Self
Construct a new input dialog with the given title.
pub fn prompt(mut self, text: impl Into<LocalizedString>) -> Self
Prompt rendered above the input field. Optional but recommended.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder shown when the field is empty.
pub fn default_text(mut self, text: impl Into<String>) -> Self
Initial value pre-filled into the field.
pub fn ok_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the OK button label (defaults to the framework's translated "OK" string).
pub fn cancel_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the Cancel button label (defaults to the framework's translated "Cancel" string).
pub fn on_result(mut self, f: impl Fn(Option<String>, &mut EventContext) + 'static) -> Self
Result callback. Invoked exactly once when the user accepts
(Some(value)) or cancels (None).
pub fn validate(mut self, f: impl Fn(&str) -> ValidateResult + 'static) -> Self
Install a live validator, run on every keystroke.
While it returns Err, the OK button is disabled and Enter does nothing, so
on_result is only ever called with a value the validator
accepted (or with None, for Cancel). Err(Some(msg)) shows msg under the
field; Err(None) blocks without saying anything.
The message is withheld until the field has been edited, so a validator that rejects the empty string does not greet the writer with an error on a dialog they have not yet typed into. The disabled OK is what communicates "not yet" there.
Distinct from TextInput::validator,
which fires on commit and cannot gate a dialog's accept path.
pub fn present(self, ctx: &mut EventContext)
Present the dialog as a modal on top of ctx's tree. Consumes
self.
LanguageSwitcher

LanguageSwitcher — a drop-in UI-language picker for settings screens.
A thin ComboBox preset that lists the application's supported
locales and switches the active locale on selection. Each entry is
shown as its endonym — the language's own name — followed by the
BCP-47 tag, e.g. français (fr-FR), Deutsch (de-DE),
العربية (ar-SA). Showing endonyms (not "French", "German", "Arabic")
means a speaker of each language can always find their own in the list.
Zero-config: drop it into a settings panel and it
- self-populates from the installed
I18nManager(teksilo_i18n::current_supported_locales()), - shows the active locale as the current selection
(
teksilo_i18n::current_locale()), - switches the app locale on selection via
EventContext::set_locale, which the window manager fans out to every window (re-translating text and flipping layout direction for RTL locales like Arabic), - and keeps its selection in sync if the locale is changed elsewhere.
// In a settings panel's build():
VStack::new()
.child(TextWidget::new(tr!(ui_language())).style(TextStyleRole::BodyBold))
.child(LanguageSwitcher::new())
Endonyms come from ICU4X CLDR data via
teksilo_i18n::language_endonym; an unknown tag falls back to the
raw BCP-47 tag. When no I18nManager is configured the switcher
renders an empty, placeholder ComboBox.
Builder methods at a glance
variant, label, locales, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct LanguageSwitcher
A UI-language picker built on ComboBox. See the module docs.
#![allow(unused)] fn main() { pub struct LanguageSwitcher { /* fields */ } }
Methods
pub fn new() -> Self
Create a switcher that auto-discovers the supported locales from
the active I18nManager.
pub fn variant(mut self, variant: ComboBoxVariant) -> Self
Pick the inner ComboBox's design-language variant.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the accessible / control label (defaults to "Language").
Pass a tr!(...) to localize it.
pub fn locales(mut self, locales: Vec<LanguageIdentifier>) -> Self
Override the locale list instead of auto-discovering it from the
active I18nManager. Useful in previews / tests, or to restrict
the offered set.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip, forwarded to the inner ComboBox.
Mutually exclusive with the rich / composite variants — last
call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide registry,
forwarded to the inner ComboBox. Overrides any previously
set tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline
TooltipContent, forwarded to
the inner ComboBox. Overrides any previously set tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip hosting an arbitrary widget tree,
forwarded to the inner ComboBox. Overrides any previously
set tooltip.
Link

Link — a clickable text label rendered as underlined inline text.
Link is Teksilo's hyperlink control: it responds to tap, Enter, and
Space like a Button, but renders as styled underlined text rather than a
bordered box. It supports an optional url field (informational — the app
decides whether and how to open it), a reactive visited state that shifts
the text colour, and all three tooltip tiers (plain / rich / composite).
Keyboard behaviour follows the platform link convention: Space and Enter
activate; a bare KeyUp with no preceding KeyDown is ignored (lone-KeyUp
guard). The focus ring appears only after keyboard navigation
(focus_visible), not after a mouse click.
Accessibility
Role::Link with the label as the AT name. When url is set it is
forwarded to set_url so screen readers can announce the destination.
Exposes Action::Click and Action::Focus.
#![allow(unused)] fn main() { use teksilo_widgets::Link; use teksilo_i18n::lit; let _w = Link::new(lit!("Open documentation")) .url("https://example.com/docs"); }
Builder methods at a glance
visited, style, on_activate_fn, url, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, get_url, enabled
API reference
📖 Full rustdoc API for this module
pub struct Link
A clickable text link that renders as underlined inline text.
#![allow(unused)] fn main() { pub struct Link { /* fields */ } }
Methods
pub fn new(text: impl Into<LocalizedString>) -> Self
Create a link with the given display text.
pub fn visited(mut self, visited: impl Into<Prop<bool>>) -> Self
Mark the link's target as visited. Drives TextRole::LinkVisited
when no transient interaction (hover / press) is active. Visited
is overridden by hover/press, following the web convention. The
app owns the signal (typically backed by URL-history state).
pub fn style(mut self, style: impl teksilo_core::styles::LinkStyle) -> Self
Per-call style override for the link chrome.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on activation.
pub fn url(mut self, url: impl Into<String>) -> Self
Set a URL for the link (informational — not automatically opened).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip / composite_tooltip — last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip
registry. See Button::rich_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. See Button::composite_tooltip.
pub fn get_url(&self) -> Option<&str>
Return the URL previously set via url, if any.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the
arena at build time — a bound Signal<bool> updates live as it
changes.
ListView

ListView — a virtualized, scrollable list backed by a reactive data model.
ListView<T> materializes widget subtrees only for the rows currently
visible in its viewport (plus a configurable buffer). Scrolling and model
changes trigger a localized rebuild that touches only the newly-visible
slice, leaving the rest of the tree untouched. The data source is a
ListModel<T> (in-memory, reactive) or any ListDataSource<Item = T>
(lazy / external). A delegate closure (index, &T, selected) -> Box<dyn Widget>
produces each row widget on demand.
Row heights come in three modes: uniform (item_height, the 32 dp
default and fastest path), exact callback (item_height_fn — pure,
deterministic per-row sizes), and auto-measured (auto_item_height —
height-for-width measurement with scroll anchoring so content above the
viewport stays put while estimates converge).
When to use
- Large or dynamically-loaded lists (thousands of rows) — use
ListView. - Small, always-all-visible collections — use
Repeaterinstead. - Hierarchical data — use
TreeView. - Multi-column tabular data — use
TableView.
Accessibility
The widget is Role::List; each row is wrapped in Role::ListItem with
set_selected state. Full keyboard navigation: arrows, Home, End, PageUp,
PageDown, Space (select/toggle), Enter (activate), Ctrl+A (select all),
Shift+Arrow (range), type-ahead (opt-in via type_ahead_label).
#![allow(unused)] fn main() { use teksilo_widgets::ListView; use teksilo_widgets::primitives::TextWidget; use teksilo_data::{ListModel, SelectionMode, SelectionModel}; use teksilo_i18n::lit; struct Item { name: String } let model: ListModel<Item> = ListModel::from_vec(vec![Item { name: "Alpha".into() }]); let sel = SelectionModel::new(SelectionMode::Single); let _w = ListView::new(model, |_i, item, _selected| { Box::new(TextWidget::new(lit!(&item.name))) }) .item_height(32.0) .selection(sel); }
Builder methods at a glance
from_source, from_source_keyed, enabled, overscroll_behavior, smooth_scrolling, smooth_scroll_duration, scroll_bar_style, item_height, item_height_fn, auto_item_height, spacing, selection, realized_row_ids, reorderable, exportable, export_external, on_rows_transferred_out, accept_foreign_rows, on_rows_received, on_activate, activate_on, row_tooltip_sticky, row_tooltip, row_rich_tooltip, row_composite_tooltip, type_ahead_label, type_ahead_timeout, show_scrollbar, scroll_y_signal, max_scroll_y_signal, viewport_ratio_y_signal, scroll_to_index, ensure_index_visible
API reference
📖 Full rustdoc API for this module
pub struct ListView
A virtualized scrollable list backed by a ListModel<T> or ListDataSource.
See the module-level documentation for the full feature overview.
#![allow(unused)] fn main() { pub struct ListView<T: 'static> { /* fields */ } }
Methods
pub fn new( model: ListModel<T>, delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static, ) -> Self
Create a new ListView backed by a ListModel<T>.
The delegate closure receives (index, &item, selected) and returns
a boxed widget for that item.
pub fn from_source<S: teksilo_data::ListDataSource<Item = T>>( source: S, delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static, ) -> Self
Create a ListView backed by a custom ListDataSource.
Use this for large or external datasets that cannot fit in memory.
The source must implement ListDataSource<Item = T>.
pub fn from_source_keyed<S: teksilo_data::ListDataSource<Item = T>>( source: S, keyed: KeyedSelectionModel<S::Key>, delegate: impl Fn(usize, &T, bool) -> Box<dyn Widget> + 'static, ) -> Self where S::Key: ItemKey,
Create a ListView backed by a custom ListDataSource with keyed
selection. The KeyedSelectionModel<S::Key> tracks selection by source
identity, so it survives reorders, filters, lazy window-slides, and
stays consistent across two views of the same source. The view stays
key-less (ListView<T>) — the index↔key mapping is captured from the
concrete source here. Mutually exclusive with
selection (the last one set wins).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the whole view. A disabled view greys out and stops accepting focus / selection / keyboard input (arena-gated).
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Set the scroll-chaining behavior at the boundary (default
OverscrollBehavior::Chain; Contain
disables chaining to an ancestor scrollable).
pub fn smooth_scrolling(mut self, enabled: bool) -> Self
Enable or disable animated wheel scrolling (enabled by default).
pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self
Duration of the smooth scroll animation (default 150 ms).
pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self
How the scroll bar is displayed (default Permanent). Overlay
and Thin float the bar over the content instead of reserving a
layout column, mirroring ScrollArea::scroll_bar_style.
pub fn item_height(mut self, height: f32) -> Self
Set the fixed height per item (default 32.0) — the uniform fast
path. Mutually exclusive with item_height_fn
and auto_item_height; the last mode
setter wins.
pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self
Per-item heights from a callback. The callback must be pure (same index + same data → same height); it is re-swept from the first changed index on every model change. No measurement pass runs — this is the deterministic variable-height path.
pub fn auto_item_height(mut self, estimated: f32) -> Self
Auto-measured item heights: each realized row is measured at the
list's content width (height-for-width), unrealized rows assume
estimated. Scroll anchoring keeps content above the viewport
stationary as estimates are corrected. estimated should be a
typical row height — a wrong estimate only costs realization
churn while measurements settle, never incorrect layout.
pub fn spacing(mut self, spacing: f32) -> Self
Set spacing between items (default 0.0).
pub fn selection(mut self, sel: SelectionModel) -> Self
Set the index-based selection model (positions). For identity-based
selection that survives reorder / filter / window-slide, build the view
with from_source_keyed instead.
pub fn realized_row_ids(&self) -> Rc<RefCell<Vec<(usize, WidgetId)>>>
A shared handle to the live (model index → row node id) map of the
realized rows, rewritten at the end of every build.
The id is the row's Role::ListItem wrapper — the node an
active_descendant has to point at. Take the handle before moving the
view into the tree; it is populated on the first build.
This exists for the ARIA combobox / listbox pattern, where keyboard
focus stays on a text field while the arrow keys move a highlight
through this list (a command palette, a type-ahead picker). The field's
AT node publishes active_descendant pointing here, so a screen reader
announces each row as the highlight moves without focus ever leaving
the input. A ListView that holds focus itself does not need this.
Only realized rows are present — a row scrolled outside the
virtualization window has no widget, so look-ups for it return None.
Callers should scroll_to_index the row they intend to announce.
pub fn reorderable(mut self, enabled: bool) -> Self
Enable intra-widget drag reordering.
When enabled, rows can be dragged within this ListView to reorder them.
The move is routed through the source's accept_drop — a ListModel
reorders in place, an external source routes the move to its store. The
hover indicator reflects the source's can_accept verdict, so a
forbidden drop shows no insertion line. Keyboard equivalent:
Alt+ArrowUp/Down.
pub fn exportable(mut self, mode: DragTransferMode) -> Self where T: Clone,
Make rows droppable outside this view — on a
DropTarget, another data view, or the OS.
A dragged row (or the whole selection, when the pressed row is part of a
multi-selection) carries clones of its items in a public
RowDragData<T>, so a foreign receiver can pull
them out with payload.get_typed::<RowDragData<T>>() /
DropTarget::on_drop_typed::<RowDragData<T>>() — no serialization. This
also makes rows a drag source even without reorderable.
mode chooses what happens to the origin rows once a foreign target
accepts them: DragTransferMode::Move removes them (via the source's
on_drag_out, or on_rows_transferred_out),
DragTransferMode::Copy leaves them. A same-view reorder is never a
transfer, so mode never affects it. Requires T: Clone.
Move caveats. The row is removed only when the drop is accepted by an
in-app target in the same window (DropOutcome::InApp { accepted: true })
or the OS reports a genuine move. Shipped OS backends advertise copy
only, so a drag exported to another application — or to another window
of the same app — is treated as a copy: the origin row is kept and the
receiver must own its own copy semantics. Also, for a ListModel-backed
view (whose key is the row index) the move-out removes by the indices
captured at drag-start; if a shared handle to the same model is mutated
while the drag is in flight, those indices can point at different rows —
use a keyed source, or on_rows_transferred_out
with your own stable identity, for models that change mid-drag.
pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self where T: Clone,
Additionally advertise the dragged rows as MIME data so they can be
dropped on a DropZone or exported to another
application / window via the OS. f maps the dragged items to
(mime_type, bytes) pairs (e.g. text/plain, text/uri-list, an
app-specific application/x-…). Implies exportable
(defaulting to DragTransferMode::Move if not already set). Requires
T: Clone.
pub fn on_rows_transferred_out( mut self, f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Override how rows moved out to a foreign target are removed from this
view. Receives the dragged rows' indices (descending-safe) and the live
context. Without this, an exportable
Move drag removes them through the source's
on_drag_out (works out of the box for a ListModel).
pub fn accept_foreign_rows(mut self, accept: bool) -> Self
Accept exported rows dropped from a different view or source without
writing a custom ListDataSource. Pair with
on_rows_received, which is handed the dropped
items and the insertion index. (Same-view reorder is
reorderable; a custom ListDataSource can still
accept foreign drops through its can_accept/accept_drop instead.)
pub fn on_rows_received( mut self, f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Handler for rows accepted via accept_foreign_rows:
(items, insertion_index, ctx). Insert them into your model at the
index.
pub fn on_activate( mut self, f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Set the row-activation handler — invoked with the flat row index and
the live EventContext on a click
(per activate_on) or Enter on the focused row.
The context lets the handler open a modal, toast, or dispatch an intent —
matching TableView::on_row_activate
/ GridView::on_tile_activate.
Distinct from selection: arrow-key navigation and Space move /
toggle the selection but do not activate.
pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self
Choose single- vs double-click activation (default
ActivateOn::DoubleClick). Enter activates in
either mode.
pub fn row_tooltip_sticky(mut self, on: bool) -> Self
Enable type-ahead ("type to jump"): with this set, typing a
printable character while the list has keyboard focus jumps the
selection to the next row whose label starts with the accumulated
search term, wrapping around (Qt keyboardSearch / macOS &
Windows type-select). label(&item) yields the searchable text for
a row; matching is ASCII-case-insensitive. A pause longer than the
type_ahead_timeout starts a fresh term.
Whether a composite row tooltip offers dwell-to-sticky promotion.
Default true.
Turn it off for a read-only row card: with nothing to reach into there is nothing to pin, so the countdown indicator would promise an interaction that does not exist and the surface would outlive the pointer for no reason.
pub fn row_tooltip( mut self, f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static, ) -> Self
Per-row plain tooltip: one line of text for the row under the pointer.
The resolver receives the row's flat index and its item; returning
None leaves that row without a tip. Mutually exclusive with
row_rich_tooltip and
row_composite_tooltip — last setter
wins, matching the per-widget tooltip matrix.
Opens to the row's trailing side, never below it: rows stack vertically, so a tip below would cover the next row.
pub fn row_rich_tooltip( mut self, f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static, ) -> Self
Per-row rich tooltip — a registry key or inline
TooltipContent. See
row_tooltip for the shared semantics.
pub fn row_composite_tooltip( mut self, f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static, ) -> Self
Per-row composite tooltip — an arbitrary widget tree describing the row.
The body is built for every realized row (the virtualization window)
and rebuilt with it, so keep the resolver cheap and defer anything
costly to the body's own first paint, which only runs if the tip is
actually shown. See row_tooltip for the rest.
pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self
pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self
Reset window between keystrokes before the type-ahead search term clears (default 500 ms). A zero duration disables type-ahead.
pub fn show_scrollbar(mut self, show: bool) -> Self
Suppress the internal scroll bar. Use when the caller wants to
mount its own ScrollBar outside the ListView (keeping it alive
across rebuilds so a thumb drag isn't torn down when the visible
range shifts past the buffer). The caller is expected to wire
the external bar up to the signals returned by
scroll_y_signal,
max_scroll_y_signal and
viewport_ratio_y_signal.
pub fn scroll_y_signal(&self) -> &Signal<f32>
The current vertical scroll offset, in logical pixels. Drives the
viewport position and the scroll bar thumb. Exposed so external
logic (e.g. a parent widget implementing custom scroll-into-view)
can read or drive the scroll directly — prefer
scroll_to_index /
ensure_index_visible when possible.
pub fn max_scroll_y_signal(&self) -> &Signal<f32>
The maximum scroll offset, content_height - viewport_height.
Updated during layout. Exposed for callers that mount their own
external scrollbar via show_scrollbar(false).
pub fn viewport_ratio_y_signal(&self) -> &Signal<f32>
The vertical viewport-to-content ratio (0.0..1.0). Drives the thumb size on any external scrollbar.
pub fn scroll_to_index(&self, index: usize)
Scroll so the given model index is aligned to the top of the viewport. Clamped to the valid scroll range. Safe to call before the ListView has been laid out — the clamp will kick in on the first layout pass.
pub fn ensure_index_visible(&self, index: usize)
Scroll the minimum distance needed to bring the given model index fully into the viewport. No-op if already visible.
LogView

LogView — a read-only, append-only, tail-following streaming view.
The third face of the editor core, and the one that is not an editor. A
program writes to it, forever, faster than a person types; a person only
reads, scrolls, selects, and copies. That inversion is why it does not share
the editor's frame step — the details are in log_stream
— but it is the same CodeEditorState, so
selection, copy, scrolling, theming, and accessibility come for free and
cannot drift from the editors'.
What it adds over the read-only code viewer:
- Scale. Only the visible rows are ever laid out, so a 100 000-line
buffer costs a viewport's worth of memory, not the document's. Feed it a
scrollback_limitto bound the raw text too. - Following the tail. New lines stick the view to the bottom while it is already at the bottom; scroll up to read history and it pauses, scroll back and it resumes — derived from position, never a fight.
- Severity colour. An injected classifier paints a line by what it is (an error line red). Language-agnostic: the view colours a line, the application decides what an error looks like.
Builder methods at a glance
follow_tail, scrollback_limit, severity_highlighter, announce_appends, font_family, follow_text_scale, v_scroll_policy, h_scroll_policy, background, text_color, selection_color, handle
API reference
📖 Full rustdoc API for this module
pub struct LogView
A read-only, append-only, tail-following log / console view.
Construct with LogView::new, feed it with a LogViewHandle from
handle, and add it to the tree. It owns an internal
document; the application never touches one directly, it only appends lines.
#![allow(unused)] fn main() { pub struct LogView { /* fields */ } }
Methods
pub fn new() -> Self
A fresh, empty log view: read-only, no caret, no wrapping, following the
tail, unbounded. Attach a handle and append to it.
pub fn follow_tail(self, follow: bool) -> Self
Whether new lines stick the view to the bottom when it is already there
(default true). Off makes the view hold position while it grows.
pub fn scrollback_limit(self, limit: usize) -> Self
Cap the retained lines: older lines beyond limit are evicted from the
front. Unset (the default) keeps every line — memory stays flat in the
line count, since only the visible window is ever shaped, but the raw text
accumulates in the document and each append stays linear in the document's
size. A genuinely unbounded, sustained high-rate producer should therefore
set a limit; a bounded or bursty one need not. The cap is soft: eviction
is batched, so the count can briefly exceed limit (by a band that scales
down with the cap).
pub fn severity_highlighter(self, classify: impl Fn(&str) -> Option<Color> + 'static) -> Self
Colour each line by what it is: the classifier maps a line's text to a
colour, or None to leave it in the default colour. The view knows how
to colour a line; the application knows what an error line looks like.
pub fn announce_appends(self, announce: bool) -> Self
Whether appended lines are announced to assistive technology (default
false). Off is the right default: a live region is correct for a
handful of meaningful events and hostile for a build log at fifty lines a
second. The application says which it is.
pub fn font_family(self, family: impl Into<String>) -> Self
Fallback font family. A log reads best monospaced, so columns align; pass a monospace family here.
pub fn follow_text_scale(self, follow: bool) -> Self
Whether the view grows text with the global accessibility text scale
(default true).
pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self
Vertical scrollbar policy (default Auto).
pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self
Horizontal scrollbar policy (default Auto).
pub fn background(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the background colour (accepts a Color, theme role, or
Signal). Default tracks the theme's editor_bg.
pub fn text_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the default text colour. Per-line severity colours (from
severity_highlighter) still win.
pub fn selection_color(self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the selection colour.
pub fn handle(&self) -> LogViewHandle
A cloneable handle to append to the view and drive it from anywhere.
pub struct LogViewHandle
A cloneable handle to append to a LogView and drive it.
Use it on the UI thread — from an event handler, a timer, or an async
completion. It holds an Rc, so it is not Send; feeding a log from a
background thread (a PTY reader, a tracing layer) means marshalling the lines
to the UI thread first — through the app's async executor, or a channel whose
receiver is drained in a handler. Each append wakes the view, which otherwise
stops asking for frames when idle.
#![allow(unused)] fn main() { pub struct LogViewHandle { /* fields */ } }
Methods
pub fn append(&self, text: &str)
Append text, split into lines on \n. A single trailing newline is a
terminator, not a blank line, so it is dropped; embedded blank lines are
kept. Enqueues for the next frame and wakes the view.
pub fn append_line(&self, line: &str)
Append one line. \n is still split defensively — the document rejects a
block containing one — so a value that turns out to be multi-line becomes
several lines rather than an error.
pub fn append_lines<I, S>(&self, lines: I) where I: IntoIterator<Item = S>, S: AsRef<str>,
Append many lines.
pub fn clear(&self)
Empty the view, resetting it to its pristine state. UI-thread only.
pub fn scroll_to_bottom(&self)
Scroll to the bottom, resuming tail-following. UI-thread only.
pub fn line_count(&self) -> teksilo_core::Signal<usize>
The live line count — a status bar can bind it.
pub fn document_version(&self) -> teksilo_core::Signal<u64>
Bumps on every content change.
pub fn scroll_y(&self) -> teksilo_core::Signal<f32>
The vertical scroll offset — a follow-state indicator can read it against
max_scroll_y.
pub fn max_scroll_y(&self) -> teksilo_core::Signal<f32>
The maximum vertical scroll offset.
MasonryLayout

MasonryLayout — a variable-height grid that packs children into the shortest column (Pinterest-style).
Each child is measured at the shared column width and placed into whichever column currently has the lowest accumulated height. Ties between equal-height columns are broken by column index (leftmost wins). All columns share the same width; column and item spacing are independently configurable. RTL layout mirrors the column order so the first logical child still goes to the leading edge.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::masonry::MasonryLayout; use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; let _grid = MasonryLayout::new(3) .column_spacing(8.0) .item_spacing(8.0) .child(TextWidget::new(lit!("Tall card"))) .child(TextWidget::new(lit!("Short card"))) .child(TextWidget::new(lit!("Another card"))); }
Builder methods at a glance
column_spacing, item_spacing, add_child, child, children, child_opt
API reference
📖 Full rustdoc API for this module
pub struct MasonryLayout
A masonry (Pinterest-style) layout that packs children into the shortest column.
Children are placed left-to-right into whichever column is currently shortest. All children receive the same column width; their heights are determined by each child's intrinsic size at that width.
┌──────┐ ┌──────┐ ┌──────┐
│ A │ │ B │ │ C │
│ │ │ │ └──────┘
│ │ └──────┘ ┌──────┐
└──────┘ ┌──────┐ │ F │
┌──────┐ │ E │ │ │
│ D │ └──────┘ └──────┘
└──────┘
#![allow(unused)] fn main() { pub struct MasonryLayout { /* fields */ } }
Methods
pub fn new(column_count: usize) -> Self
Create a masonry layout with the given number of columns.
The count is clamped to a minimum of 1.
pub fn column_spacing(mut self, spacing: f32) -> Self
Horizontal gap between columns.
pub fn item_spacing(mut self, spacing: f32) -> Self
Vertical gap between items within the same column.
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child by ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple inline children from an iterator.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Conditionally add a child. No-op if None.
MaxSize
MaxSize — a layout modifier that caps a child to a maximum width and/or height.
The child is proposed the lesser of the parent's proposal and the configured maximum on each axis; the reported size is then clamped again so a child that intrinsically overshoots the cap is always contained. Axes with no maximum set are passed through unchanged.
MaxSize clips its child when a maximum is active (clips_children() == true)
so content that still overflows after layout does not bleed into adjacent widgets.
Maximum values can be static or bound to a reactive Signal<f32>
for animated or data-driven constraints.
For the inverse operation (ensuring a minimum size) see MinSize.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{MaxSize, TextWidget}; use teksilo_i18n::lit; // Cap a text widget to 240 logical pixels wide. let _w = MaxSize::width(240.0) .child(TextWidget::new(lit!("This text will not exceed 240 dp."))); }
Builder methods at a glance
width, height, max_width, max_height, child_id, child
API reference
📖 Full rustdoc API for this module
pub struct MaxSize
Layout modifier that enforces a maximum width and/or height on a single child widget.
Constraints can be static or bound to a reactive Signal<f32> for dynamic resizing.
#![allow(unused)] fn main() { pub struct MaxSize { /* fields */ } }
Methods
pub fn new(width: f32, height: f32) -> Self
Cap both axes: the child's width will not exceed width and its height will not exceed height.
pub fn width(width: f32) -> Self
Cap only the width axis; the height axis is unconstrained by this modifier.
pub fn height(height: f32) -> Self
Cap only the height axis; the width axis is unconstrained by this modifier.
pub fn max_width(mut self, state: impl Into<Prop<f32>>) -> Self
Bind max width to a reactive state.
pub fn max_height(mut self, state: impl Into<Prop<f32>>) -> Self
Bind max height to a reactive state.
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
MenuBar

MenuBar — a horizontal application menu bar with keyboard-driven dropdowns.
MenuBar renders a row of labelled trigger buttons; activating one opens a
dropdown MenuList as an overlay. Menus can be added via the fluent
.menu(label, factory) API or built from a declarative MenuModel
(the single source of truth shared with the native macOS menu bar via
from_model + native_on_macos). Leading and trailing slots accept
arbitrary widget content (an app icon or a search field, for example).
Keyboard. F10 and bare-Alt-tap focus the first trigger without opening
a menu; Alt+letter opens the menu whose label carries a matching mnemonic
marker (&File → Alt+F). On macOS the Alt+letter branch is suppressed
because the OS rewrites Option+letter for accented character composition —
F10 and bare-Alt-tap continue to work. Once a dropdown is open, ArrowLeft
and ArrowRight cycle between top-level menus, and Escape closes the active
one and returns focus to the trigger.
Hamburger / collapsible mode. Call .collapsible() to let the bar
collapse to a single hamburger IconButton when its intrinsic width
exceeds the allotted space (CollapsePolicy::Responsive). .collapse_policy(Always)
forces the hamburger regardless of width.
Accessibility
The bar carries Role::MenuBar; each trigger is Role::MenuItem with
set_has_popup(Menu) and set_expanded tracking the open dropdown.
Mnemonic letters are announced via set_access_key for Windows Narrator.
#![allow(unused)] fn main() { use teksilo_widgets::{MenuBar, MenuList, MenuItem}; use teksilo_i18n::lit; use teksilo_core::Intent; let _w = MenuBar::new() .menu(lit!("File"), || Box::new( MenuList::new() .item(MenuItem::new(lit!("New")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.new")))) .separator() .item(MenuItem::new(lit!("Quit")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.quit")))) )) .menu(lit!("Edit"), || Box::new( MenuList::new() .item(MenuItem::new(lit!("Cut")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.cut")))) )); }
Builder methods at a glance
from_model, native_on_macos, collapsible, collapsed_signal, collapse_policy, hamburger_size, is_collapsed, no_dispatcher_install, menu, leading_slot, trailing_slot
API reference
📖 Full rustdoc API for this module
pub enum CollapsePolicy
Controls when a collapsible MenuBar switches from the full inline bar
to the hamburger IconButton representation.
#![allow(unused)] fn main() { pub enum CollapsePolicy { /* variants */ } }
Variants
Responsive— Collapse to a hamburger only when the bar's intrinsic width exceeds the width it is allotted; otherwise show the full inline bar. Mirrors the responsiveToolbaroverflow behaviour.Always— Always show the hamburger, regardless of available width. The "force hamburger" / compact mode.
pub struct MenuBar
A horizontal application menu bar with labelled trigger buttons and dropdown menus.
Each top-level entry becomes a focusable trigger; activating it opens a
floating MenuList overlay. See the module documentation for the full
keyboard, mnemonic, and collapsible-mode details.
#![allow(unused)] fn main() { pub struct MenuBar { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty menu bar with no menus, slots, or collapse policy.
pub fn from_model(model: crate::menu::MenuModel) -> Self
Build a menu bar from a declarative MenuModel
— the single source of truth shared with the native OS menu bar. Each
top-level menu in the model becomes an in-window dropdown; combine with
native_on_macos to also mirror it into the
macOS system menu bar.
pub fn native_on_macos(mut self, mode: crate::menu::NativeMenuMode) -> Self
Choose how this bar behaves on macOS, where the convention is a global
menu bar at the top of the screen. Requires the bar to have been built
with from_model and the app to have called
install_native_menu(). No effect on other platforms (the in-window bar
renders there regardless).
pub fn collapsible(mut self) -> Self
Enable the optional hamburger representation. When there
isn't room for the full inline bar, it collapses to a single
hamburger (☰) IconButton; activating it (click, Alt+
mnemonic, F10, or bare-Alt-tap) reveals the full bar as a
floating overlay over content. Clicking outside the bar or
pressing Escape hides it again.
Uses CollapsePolicy::Responsive. Observe the collapsed state
via is_collapsed, or bind your own signal
with collapsed_signal.
pub fn collapsed_signal(mut self, collapsed: Signal<bool>) -> Self
Like collapsible, but uses the supplied
signal as the collapsed-state source so the application can
observe (and react to) collapse transitions. The responsive
decision writes this signal (it is not a plain read-only
input) — kept as a Signal<bool> rather than Prop<bool> since a
static value would have nowhere to receive those writes.
pub fn collapse_policy(mut self, policy: CollapsePolicy) -> Self
Set the collapse policy (and enable collapsible mode).
CollapsePolicy::Always forces the hamburger regardless of
available width — i.e. collapsed by default.
pub fn hamburger_size(mut self, size: IconButtonSize) -> Self
Set the size variant of the collapsed-mode hamburger
IconButton. Mirrors IconButton::size — pick
IconButtonSize::Toolbar, IconButtonSize::Large,
IconButtonSize::Hero, etc. so the hamburger matches the
surrounding chrome. Defaults to IconButtonSize::Default.
pub fn is_collapsed(&self) -> Signal<bool>
A clone of the collapsed-state signal (true while the
hamburger is shown). Call after collapsible.
pub fn no_dispatcher_install(mut self) -> Self
Skip the window-state dispatcher install. The MenuBar still
renders, intercepts mouse clicks, and supports keyboard
navigation when its triggers have focus — only F10 /
Alt+letter / Alt-tap routing through the window-level slot is
disabled. Use this for demo / showcase MenuBars that share a
window with a primary functional MenuBar — the slot is
single-occupancy and a second install would debug_assert!.
pub fn menu( mut self, label: impl Into<LocalizedString>, factory: impl Fn() -> Box<dyn Widget> + 'static, ) -> Self
Add a top-level menu entry. label is the trigger text (supports &
mnemonic markers, e.g. "&File"); factory is called each build to
produce the dropdown content — typically a MenuList.
pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self
Add content before the menu buttons (e.g. an app icon). Call more than once to stack several.
Takes the widget by value, like every other widget's slot. MenuBar
builds it once and reuses it across rebuilds (it
preserves_children_on_rebuild),
so the slot — and any state it holds — survives a theme / locale /
model-version rebuild.
pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Add content after the menu buttons (e.g. a search box or avatar).
Like leading_slot, taken by value and preserved
across rebuilds.
MenuItem

MenuItem — a single command row in a menu or context menu.
Each item consists of an optional leading icon, a label, an optional
trailing shortcut label, and an activation closure. MenuItem is
non-generic: actions are type-erased closures identical to Button's
on_activate_fn model. Submenus are declared with MenuItem::submenu
— the factory builds the nested MenuList lazily at hover time.
Every item operates in one of three modes selected by builder methods:
| Builder | AT Role | Leading glyph |
|---|---|---|
| (default) | Role::MenuItem | icon or blank |
.checked(signal) | Role::MenuItemCheckBox | checkmark / blank |
.check_state(signal) | Role::MenuItemCheckBox | check / dash / blank |
.reflect_checked(signal) | Role::MenuItemCheckBox | checkmark (read-only) |
.radio(value, selected) | Role::MenuItemRadio | filled dot / blank |
Check and radio modes are mutually exclusive with .icon(...) — the
Windows convention reserves the leading slot for state glyphs on
checkable items; a debug_assert! fires when both are set.
An icon that keeps its own colour
.icon(...) recolours whatever it is handed with the row's text role, so the
glyph follows hover, press and disabled alongside the label. That is right for
an icon that says the same thing as the label, and wrong for one whose colour
is the content — a tag's swatch, a status light, a colour a person chose.
.icon_keeps_color() leaves it alone. Two costs come with it: the icon no
longer follows the highlight (on a style whose highlighted row is a solid
accent fill, it has to carry its own contrast against that fill), and a
literal colour does not dim in a disabled row — ColorProp::Static and
Bound ignore the enabled state, while every role variant substitutes its
disabled counterpart. An icon that should dim wants a role, and then it does
not want this at all.
#![allow(unused)] fn main() { use teksilo_widgets::{MenuItem, primitives::IconWidget}; use teksilo_canvas::{Path, Point}; use teksilo_i18n::lit; use teksilo_tokens::Color; let swatch = IconWidget::from_path(Path::circle(Point::new(5.0, 5.0), 4.5), 10.0) .color(Color::from_hex("#e91e63")); let _w = MenuItem::new(lit!("Characters")) .icon(swatch) .icon_keeps_color(); }
Mnemonic markers use the in-string & convention (&Save →
underline 'S' when Alt is held; && → literal &). The enclosing
MenuList wires bare-letter in-menu activation automatically.
#![allow(unused)] fn main() { use teksilo_widgets::MenuItem; use teksilo_i18n::lit; use teksilo_core::Intent; let _w = MenuItem::new(lit!("&Save")) .on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.save"))); }
Builder methods at a glance
on_activate_fn, label, label_localized, action, icon, icon_keeps_color, shortcut_label, trailing_hint, for_shortcut, enabled, style, text_style, text_role, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, submenu, submenu_delay, is_submenu, checked, reflect_checked, check_state, radio
API reference
📖 Full rustdoc API for this module
pub struct MenuItem
A single command row in a MenuList or context menu.
See the module documentation for the full mode table, mnemonic syntax, and submenu construction pattern.
#![allow(unused)] fn main() { pub struct MenuItem { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Create a plain menu item with the given label and no action yet.
pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on activation. Note: shortcut label auto-lookup is not available with this variant since there is no typed command to look up.
pub fn label(&self) -> String
Read the item's display label. Exposed so SplitButton (and any other compound widget that embeds a MenuItem) can mirror the label in its own chrome.
pub fn label_localized(&self) -> LocalizedString
Like label but returns the unresolved
LocalizedString, so embedders can mirror the label reactively
(re-resolving on a locale switch) instead of freezing a snapshot.
pub fn action(&self) -> Option<Rc<dyn Fn(&mut EventContext)>>
Clone out a shared handle to the activation closure. Returns None
when this MenuItem has no action (e.g. it's a submenu trigger). The
returned Rc aliases MenuItem's own internal handle — invoking it
has the same effect as the user clicking this menu item (minus the
overlay dismissal that the tap handler also performs).
pub fn icon(mut self, icon: IconWidget) -> Self
Set a leading icon.
pub fn icon_keeps_color(mut self) -> Self
Keep the icon's own colour rather than tinting it with the row's.
A menu icon normally says the same thing as the label beside it, so it takes
the row's text role and follows it through hover, press and disabled — which
is why icon recolours whatever it is handed. Some icons are
not that. A tag's swatch, a status light, a colour a person chose: there the
colour is the content, and tinting it to the menu's foreground deletes the
only thing the icon was there to say.
Opt-in, because the default is right for nearly every row, and keeping a colour has two costs the caller takes on:
- It does not follow the highlight. On a style whose highlighted row is a solid accent fill (the macOS recipe), the icon has to carry its own contrast against that fill as well as against the menu's surface.
- It does not dim when the row is disabled — if it is a literal colour.
That is
ColorProp's own rule everywhere, not a special case here:StaticandBoundignore the enabled state, while every role variant substitutes its disabled counterpart. An icon that should dim should be given a role instead, and then it does not need this at all.
Ignored in the check and radio modes, which draw an indicator glyph of the framework's own rather than the caller's icon.
pub fn shortcut_label(mut self, label: impl Into<String>) -> Self
Set a trailing shortcut label (e.g., "Ctrl+X"). Shortcut labels are typically not translated (they're the key combination literal), so this accepts a plain string.
pub fn trailing_hint(mut self, text: impl Into<LocalizedString>) -> Self
Set a trailing descriptive hint (e.g. "inside", "after parent") — a secondary phrase explaining what the item will do, rendered in the same trailing slot as an accelerator but semantically unrelated to one.
Prefer this over shortcut_label for any
trailing text that is not a key combination. It differs in two ways
that matter:
- it takes a
LocalizedString, so atr!(...)hint re-resolves on a live locale change instead of being frozen at build time; - it is announced as the item's accessible description, not as
keyboard_shortcut— a screen reader would otherwise read the phrase out as if it were a chord to press.
Independent of the accelerator: an item may carry both, in which case the chord renders first and the hint follows it.
pub fn for_shortcut(mut self, id: &'static str) -> Self
Bind the trailing shortcut label to a registered
Shortcut by its stable id.
At build time the effective primary keystroke is rendered;
rebinds performed through
ShortcutRegistry
rebuild this item automatically via the registry's version
signal.
A manual shortcut_label takes
precedence when both are set.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state — static or signal-bound. A bound Signal<bool>
enables/disables the item reactively (paint and AT follow), so
MenuItem::new(...).enabled(can_save_signal) greys out live without a
rebuild. Cursor is always Pointer (see build); disabled items are
gated by the arena before hover runs, so a NotAllowed cursor cannot
be applied from a build-time snapshot of this prop either.
pub fn style(mut self, style: impl teksilo_core::styles::MenuItemStyle) -> Self
Per-call style override. Replaces the theme-wide default
MenuItemStyle for just this MenuItem instance.
pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self
Override the label's text style (font, size, weight). Accepts a
TextStyleRole, a TextStyle, or a Signal of either. Default
(unset) is TextStyleRole::Body.
pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the label text color. Accepts Color, a role, or a
Signal of either. Default (unset) is the interaction/enabled
cascade; setting this replaces that cascade (the hover / disabled
tint no longer applies), so reserve it for chrome that enforces a
fixed text role.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a tooltip that appears after a hover delay, same mechanism
as Button::tooltip.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip
registry. Body text supports inline markup
(label, *italic*, **bold**); the entry's shortcut
and long-form "more" fields are rendered automatically.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. See Button::composite_tooltip.
pub fn submenu( label: impl Into<LocalizedString>, factory: impl Fn() -> Box<dyn Widget> + 'static, ) -> Self
Create a submenu trigger item. The factory is invoked during build() to
pre-create the submenu content (typically a MenuList), which is kept
dormant until the hover delay elapses.
pub fn submenu_delay(mut self, delay: Duration) -> Self
Set a custom submenu open delay (default: 200ms).
pub fn is_submenu(&self) -> bool
Whether this is a submenu trigger.
pub fn checked(mut self, state: Signal<bool>) -> Self
Bind this item to a two-state Signal<bool>. The item renders
Role::MenuItemCheckBox; activation flips the signal. By
Windows convention, the leading icon slot becomes a checkmark
when the signal is true, blank otherwise.
Mutually exclusive with check_state
and radio — last call wins.
pub fn reflect_checked(mut self, state: impl Into<Prop<bool>>) -> Self
Render Role::MenuItemCheckBox whose checkmark reflects state
read-only: activation does NOT write the signal — the truth lives
elsewhere (a model / method), and this item's on_activate/intent is
responsible for the change, after which state updates the checkmark
reactively. Use for "View ▸ Sidebar / Full Screen"-style commands that
mirror externally-owned state (e.g. DockingModel::dock_open_signal),
where two-way checked would fight the model.
Mutually exclusive with the other check / radio binders — last call wins.
pub fn check_state(mut self, state: Signal<CheckState>) -> Self
Bind this item to a tri-state Signal<CheckState>. The item
renders Role::MenuItemCheckBox; activation cycles
Unchecked ↔ Checked (per Windows / Checkbox
convention: Indeterminate is reserved for external sources
like TreeCheckedModel; clicking from Indeterminate
promotes to Checked).
The leading-slot glyph is checkmark for Checked, dash
for Indeterminate, blank for Unchecked — matching the
Windows mixed-state convention.
Mutually exclusive with checked
and radio — last call wins.
pub fn radio(mut self, value: usize, selected: Signal<usize>) -> Self
Bind this item to a radio group via a shared Signal<usize>.
Activation writes value into selected; all radio items
sharing the same selected signal observe the change and
update their leading-slot dot accordingly. The item renders
Role::MenuItemRadio.
For "2 of 3"-style AT announcement, the enclosing
MenuList groups radio items
by selection-signal identity and emits push_to_radio_group
relationships automatically — no app-side wiring required.
Mutually exclusive with checked
and check_state — last call
wins.
MenuList

MenuList — a themed vertical menu container with keyboard navigation.
MenuList is the dropdown panel used by MenuBar, MenuContext, and
popover-style menus. It provides a themed surface (background, rounded
border, drop shadow) and owns the full keyboard navigation stack:
ArrowUp/Down moves focus, Enter activates, Escape bubbles to the
enclosing overlay host, Home and End jump to the first/last enabled item.
Type-ahead search jumps to the next item whose stripped label starts with
the accumulated keystrokes (500 ms reset window by default).
Items are added with .item(widget) (any impl Widget, but typically a
MenuItem); separators with .separator(). Conditional rows use
.item_when(widget, visible_prop) — a hidden row collapses to zero height
and is skipped by keyboard navigation. For very long lists (recent files,
etc.) call .max_visible_items(n) to cap the panel height and wrap the
content in a ScrollArea.
Safe-triangle hover gate. When a submenu item opens its child overlay,
MenuList stamps a shared anchor so sibling items can skip their
hover-switch while the cursor travels diagonally toward the submenu.
Accessibility
Role::Menu; each row is Role::MenuItem / Role::MenuItemCheckBox /
Role::MenuItemRadio as declared by the item. Radio items in the same
list auto-group via push_to_radio_group so AT announces "2 of 3".
#![allow(unused)] fn main() { use teksilo_widgets::{MenuList, MenuItem}; use teksilo_i18n::lit; use teksilo_core::Intent; let _w = MenuList::new() .item(MenuItem::new(lit!("Cut")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.cut")))) .item(MenuItem::new(lit!("Copy")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.copy")))) .separator() .item(MenuItem::new(lit!("Paste")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.paste")))); }
Builder methods at a glance
type_ahead_timeout, attached_side, item, item_when, item_boxed_when, separator, header, max_visible_items
API reference
📖 Full rustdoc API for this module
pub struct MenuSeparator
A 1 dp horizontal divider line between groups of menu items.
#![allow(unused)] fn main() { pub struct MenuSeparator; }
pub struct MenuList
A themed vertical dropdown menu panel with keyboard navigation and type-ahead.
See the module documentation for the full feature description.
#![allow(unused)] fn main() { pub struct MenuList { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty menu list with no items, no height cap, and the default 500 ms type-ahead reset window.
pub fn type_ahead_timeout(mut self, d: Duration) -> Self
Override the type-ahead buffer reset window. Defaults to 500ms
to match Windows' menubar convention. Tests use
Duration::ZERO to force every keypress to start a fresh
search.
pub fn attached_side(mut self, side: crate::shadow::AttachedSide) -> Self
Suppress drop-shadow drawing on the side that visually merges
with the menu's trigger. See crate::shadow::AttachedSide
for the available edges.
pub fn item(mut self, widget: impl Widget + 'static) -> Self
Add a menu item (typically a MenuItem).
pub fn item_when( self, widget: impl Widget + 'static, visible: impl Into<teksilo_core::signal::Prop<bool>>, ) -> Self
Add a menu item that is shown only while visible is true. When the
gate is false the row collapses to zero height (no gap) and keyboard
navigation skips it — arrows, Home/End, Enter, type-ahead, and
mnemonic activation all ignore it. Used e.g. by a Toolbar's overflow
menu, where each row is present only while its inline twin is collapsed.
Because a hidden row never claims its mnemonic letter, two gated rows that are mutually exclusive may share one — the letter resolves to whichever is visible when it is pressed.
pub fn item_boxed_when( mut self, widget: Box<dyn Widget>, visible: impl Into<teksilo_core::signal::Prop<bool>>, ) -> Self
item_when for an already-boxed widget — used when
the row type is decided at runtime (e.g. a menu row that is either a
MenuItem or an embedded control).
pub fn separator(mut self) -> Self
Add a separator line.
pub fn header(mut self, widget: impl Widget + 'static) -> Self
Add a non-interactive section caption (typically a crate::GroupHeader).
Skipped by Arrow/Home/End navigation and type-ahead, exactly like
separator. The caller passes any impl Widget, but it
must expose its own accessible name/role via accessibility() (as
GroupHeader does) or it is silently pruned from the AT tree as a
content-free container.
pub fn max_visible_items(mut self, n: usize) -> Self
Cap the panel height to roughly n * item_height and make the
content scrollable when that height is exceeded. Clamped to at
least 1. Useful for long menus (e.g. a "Recent files" list) —
without this, a very long menu grows to exceed the window.
Note: items are still materialized eagerly; this is a viewport cap, not virtualization. See the module-level note.
MessageBox

MessageBox — QMessageBox-style alert dialog.
A higher-level surface built on top of ModalContainer
for the classic "tell the user something and ask for a response"
pattern: unsaved-changes prompts, error surfaces, confirmation
dialogs, and informational notices. Mirrors QMessageBox (Qt),
NSAlert (AppKit), and SwiftUI's .alert(...) while staying inside
Teksilo's idioms — closure result handlers, Signal/Prop
reactivity, Intent/Action/Shortcut routing for keyboard
defaults, and AccessKit Role::AlertDialog accessibility.
Quick tour
use teksilo::prelude::*;
use teksilo::widgets::{MessageBox, MessageBoxButtons, StandardButton};
fn on_close(ctx: &mut EventContext) {
MessageBox::question(lit!("Save changes?"))
.text(lit!("You have unsaved changes in report.skrib."))
.informative_text(lit!("Your changes will be lost if you don't save them."))
.buttons(MessageBoxButtons::SaveDiscardCancel)
.default_button(StandardButton::Save)
.escape_button(StandardButton::Cancel)
.on_result(|r, ctx| match r.button {
StandardButton::Save => save_and_close(ctx),
StandardButton::Discard => close(ctx),
_ => {}
})
.present(ctx);
}
# fn save_and_close(_: &mut EventContext) {}
# fn close(_: &mut EventContext) {}
Severity
MessageBoxSeverity controls the icon drawn beside the title and
its tint:
Information— info glyph,status_info_fgtint.Question— question mark glyph,accenttint.Warning— exclamation triangle,status_warning_fgtint.Critical— X-mark circle,status_error_fgtint. Also disables click-outside dismissal (Qt convention).None— no icon, no tint.
Severity is conveyed through the icon + title + text. Per Teksilo's
Int UI baseline, buttons are never colored as "destructive":
destructive intent lives in the dialog's severity and wording, not
in the button. See crate::button for details.
Default & escape buttons
default_button— activated by Enter (widget-scoped shortcut) and receives initial focus on open (viaModalRequest::focus_targetplusWidget::initial_focus_hint). Styled withButtonVariant::Filled.escape_button— activated by Escape. The fallback logic (for presets with no explicitescape_button) picks: explicitescape_button→ firstReject-role button →Cancel→ last button.
Result reporting
MessageBox::on_result takes impl Fn(MessageBoxResult, &mut EventContext) + 'static. The callback fires exactly once — on
button activation or Escape dismissal — then the modal is closed by
the framework.
Accessibility
The widget exposes Role::AlertDialog (distinct from
ModalContainer's Role::Dialog), with set_modal(),
set_live(Live::Assertive), set_name(title), and
set_description(text + informative_text) so screen readers
announce the dialog and its body on open.
Builder methods at a glance
information, warning, critical, question, plain, text, informative_text, detailed_text, buttons, add_button, default_button, escape_button, show_again_checkbox, show_again_checkbox_state, on_result, present
API reference
📖 Full rustdoc API for this module
pub enum MessageBoxSeverity
Alert severity level. Drives the icon glyph + tint shown beside the
title, and (for Critical) whether click-outside dismiss is enabled.
#![allow(unused)] fn main() { pub enum MessageBoxSeverity { /* variants */ } }
Variants
None— No icon. Use for plain notices where an icon would be noise.Information— Informational notice — blue circle with "i" glyph.Question— Confirmation prompt — accent-tinted circle with "?" glyph.Warning— Non-fatal warning — amber triangle with "!" glyph.Critical— Critical error — red circle with an "X" glyph. Click-outside dismissal is disabled (Escape still works).
pub enum ButtonRole
Semantic role of a message-box button. Used for fallback escape
resolution (Reject wins when no explicit escape button is set).
Teksilo deliberately does not render Destructive buttons with
a red fill — the dialog's severity icon and wording carry that
signal. See crate::button for the framework-level rationale.
#![allow(unused)] fn main() { pub enum ButtonRole { /* variants */ } }
Variants
Accept— Confirms / proceeds. Ok, Yes, Save, Open, Apply, Retry.Reject— Bails out. Cancel, Close, No, Abort.Destructive— Data-loss action. Discard. (Same visuals as Regular — the severity of the surrounding MessageBox carries the warning.)Action— Side action. Help, Reset, RestoreDefaults, Ignore, and the "to all" variants.
pub enum StandardButton
The Qt-modeled catalog of standard buttons. Each variant resolves
to a localized label, a semantic ButtonRole, and a stable
intent-name string used internally for shortcut/action routing.
#![allow(unused)] fn main() { pub enum StandardButton { /* variants */ } }
Variants
Ok— Accept / confirm.ButtonRole::Accept.Cancel— Cancel the operation.ButtonRole::Reject.Close— Close the dialog.ButtonRole::Reject.Yes— Confirm with "Yes".ButtonRole::Accept.No— Decline with "No".ButtonRole::Reject.YesToAll— Confirm all remaining items.ButtonRole::Accept.NoToAll— Decline all remaining items.ButtonRole::Reject.Save— Save changes.ButtonRole::Accept.SaveAll— Save all open items.ButtonRole::Accept.Discard— Discard changes without saving.ButtonRole::Destructive.Apply— Apply changes without closing.ButtonRole::Accept.Reset— Reset to defaults.ButtonRole::Action.RestoreDefaults— Restore factory defaults.ButtonRole::Action.Abort— Abort the current operation.ButtonRole::Reject.Retry— Retry the failed operation.ButtonRole::Accept.Ignore— Ignore the error and continue.ButtonRole::Action.Open— Open a file or resource.ButtonRole::Accept.Help— Show help.ButtonRole::Action.
Methods
pub fn role(self) -> ButtonRole
The button's semantic role — used internally by MessageBox's
escape-button fallback resolution, and available to callers that
want to inspect a MessageBoxButton's role.
pub fn intent_name(self) -> &'static str
Stable string id used as both the shortcut id and the intent name for routing default/escape key activations. Scoped to a MessageBox instance via widget-scoped shortcut registration, so the same id is safe to reuse across instances.
pub fn default_label(self) -> LocalizedString
Default label for the button. Resolved through the Fluent
catalog via tr_widget! so apps can override per-locale.
pub struct MessageBoxButton
A single button placement inside a MessageBox, including an optional
per-instance label override. Callers usually build these via
From<StandardButton> (StandardButton::Ok.into()), or
construct them manually when Custom is needed.
#![allow(unused)] fn main() { pub struct MessageBoxButton { /* fields */ } }
Methods
pub fn standard(kind: StandardButton) -> Self
Build a button from a StandardButton with the default label.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Override the default translated label.
pub enum MessageBoxButtons
Pre-built button bundles covering the common MessageBox shapes.
Custom combinations go through MessageBox::add_button or
MessageBoxButtons::Custom.
#![allow(unused)] fn main() { pub enum MessageBoxButtons { /* variants */ } }
Variants
Ok— Just Ok.OkCancel— Ok + Cancel, Ok default, Cancel escape.YesNo— Yes + No, Yes default, No escape.YesNoCancel— Yes + No + Cancel, Yes default, Cancel escape.SaveDiscardCancel— The unsaved-changes triad: Save + Discard + Cancel.RetryIgnoreAbort— The error-recovery triad: Retry + Ignore + Abort.Custom— Explicit list. MessageBox preserves the order as the visual button order (leading Spacer pushes all buttons to the trailing edge; default button may appear anywhere).
pub struct MessageBoxResult
Report passed to MessageBox::on_result when the dialog closes.
#![allow(unused)] fn main() { pub struct MessageBoxResult { /* fields */ } }
pub struct MessageBox
A modal alert dialog that displays a severity icon, title, body text, and one or more buttons.
Constructed via severity-named constructors (MessageBox::information,
MessageBox::warning, MessageBox::critical, MessageBox::question,
MessageBox::plain), configured fluently, and presented with
MessageBox::present. See the module documentation for the full guide.
#![allow(unused)] fn main() { pub struct MessageBox { /* fields */ } }
Methods
pub fn information(title: impl Into<LocalizedString>) -> Self
Construct an informational MessageBox (Information severity).
pub fn warning(title: impl Into<LocalizedString>) -> Self
Construct a warning MessageBox (Warning severity).
pub fn critical(title: impl Into<LocalizedString>) -> Self
Construct a critical-error MessageBox (Critical severity).
Click-outside dismissal is disabled; use an explicit button or
Escape to close.
pub fn question(title: impl Into<LocalizedString>) -> Self
Construct a confirmation / question MessageBox (Question
severity).
pub fn plain(title: impl Into<LocalizedString>) -> Self
Construct a plain MessageBox with no severity icon.
pub fn text(mut self, text: impl Into<LocalizedString>) -> Self
Primary message line, rendered in typography.body with
text_primary. Prefer a short, self-contained sentence —
details belong in informative_text.
pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self
Secondary, explanatory text rendered below the primary text in
typography.body with text_secondary. Matches Qt's
setInformativeText.
pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self
Detailed text hidden behind a "Show details" Accordion —
for technical diagnostics (stack traces, error codes). Matches
Qt's setDetailedText.
pub fn buttons(mut self, preset: MessageBoxButtons) -> Self
Apply a preset button bundle. Implicitly sets default and
escape buttons for the preset (both can be overridden via
MessageBox::default_button and
MessageBox::escape_button).
pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self
Append a single button. Use to augment a preset (rare) or to
build a bespoke button row without going through
MessageBoxButtons::Custom.
pub fn default_button(mut self, which: StandardButton) -> Self
Mark which button activates on Enter and receives initial
focus. Must refer to one of the buttons configured via
buttons / add_button.
pub fn escape_button(mut self, which: StandardButton) -> Self
Mark which button activates on Escape (and scrim-click, when allowed). Must refer to one of the configured buttons.
pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self
Attach a "Don't show again"-style checkbox below the body.
Internally creates a Signal<bool> initialized to false and
reports its state in MessageBoxResult::checkbox_checked.
For external observation, use
MessageBox::show_again_checkbox_state instead.
pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self
Like MessageBox::show_again_checkbox, but with a
caller-owned Signal<bool> so the checkbox state survives the
dialog lifetime (useful for "remember my choice" persistence).
pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self
Register the result callback, invoked exactly once when a button fires (either by click or by Enter/Escape shortcut).
pub fn present(self, ctx: &mut EventContext)
Present the MessageBox as a modal on top of ctx's current
tree. Consumes self; callers who need to present multiple
dialogs with shared config should build a factory closure.
MinSize
MinSize — a layout modifier that ensures a child reaches a minimum width and/or height.
The child's reported size is clamped upward so it never falls below the
configured minimum on each constrained axis. The minimum is also forwarded
as part of the clamped proposal so that wrap-aware children (e.g. a
multi-line TextWidget) measure against the constraint they will actually
be placed into. Axes with no minimum set are passed through unchanged.
MinSize propagates the child's flex and shrink weights so that a
Spacer or Expand inside MinSize still participates in stack
slack-distribution; the child's own compression floor is composed with
the MinSize floor.
For the inverse operation (capping a maximum size) see MaxSize.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{MinSize, icon_widget::IconWidget}; // Guarantee a 44×44 dp tap target around a 20 dp icon. let _tap_target = MinSize::new(44.0, 44.0) .child(IconWidget::checkmark(20.0)); }
Builder methods at a glance
width, height, min_width, min_height, child_id, child
API reference
📖 Full rustdoc API for this module
pub struct MinSize
Layout modifier that enforces a minimum width and/or height on a single child widget.
Constraints can be static or bound to a reactive Signal<f32> for dynamic resizing.
#![allow(unused)] fn main() { pub struct MinSize { /* fields */ } }
Methods
pub fn new(width: f32, height: f32) -> Self
Enforce a minimum on both axes: the child's width will be at least width and its height at least height.
pub fn width(width: f32) -> Self
Enforce a minimum only on the width axis; the height axis is unconstrained by this modifier.
pub fn height(height: f32) -> Self
Enforce a minimum only on the height axis; the width axis is unconstrained by this modifier.
pub fn min_width(mut self, state: impl Into<Prop<f32>>) -> Self
Bind min width to a reactive state.
pub fn min_height(mut self, state: impl Into<Prop<f32>>) -> Self
Bind min height to a reactive state.
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
NotificationCenterButton

NotificationCenterButton — bell icon with an unread-count badge that
opens a NotificationLog popover when clicked.
Composed as a ZStack { PopoverIconButton(bell), Badge }. The badge
shows the current unread count and is hit-transparent so clicks always
reach the bell beneath. On popover close the archive's mark_all_read
is called and the badge resets — matching the GitHub / Slack / JetBrains
convention. Most apps mount this in a StatusBar or TitleBar trailing
slot; all popover behaviour is self-managed with no further wiring.
Accessibility
The inner IconButton carries the bell Role::Button label; the outer
container is set_hidden (presentational). The badge count is not
separately announced — the button label and badge label together convey
the state to sighted users; AT users interact through the button itself.
// Typical setup — archive comes from install_toast_default():
let archive: Rc<NotificationArchiveModel> = ctx.app_state().unwrap();
let bell = NotificationCenterButton::new(archive)
.on_action_invoked(|_entry, action, ctx| {
if let Some(name) = &action.intent_name {
ctx.send_intent(teksilo_core::Intent::new(name));
}
});
Builder methods at a glance
for_window, for_audience, size, show_badge_when_zero, max_badge_count, placement, on_action_invoked, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct NotificationCenterButton
Bell-icon trigger + unread-count badge + popover that contains a
NotificationLog. On popover open the archive's mark_all_read
runs (the user is presumed to have seen the toasts now).
#![allow(unused)] fn main() { pub struct NotificationCenterButton { /* fields */ } }
Methods
pub fn new(archive: Rc<NotificationArchiveModel>) -> Self
Construct bound to a shared archive. The archive is typically
held in app_state and cloned to every consumer.
pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self
Scope this bell to window window_id: its badge counts unread
among entries routed to that window (plus any Broadcast
entry), and its popover shows only those. Overrides any
previous for_window / for_audience call.
pub fn for_audience(mut self, audience: ToastAudience) -> Self
Scope this bell to audience: its badge counts unread among
entries routed to that audience (plus any Broadcast entry),
and its popover shows only those. Overrides any previous
for_window / for_audience call.
pub fn size(mut self, size: IconButtonSize) -> Self
Bell-icon size. Default IconButtonSize::Toolbar (30 dp) —
matches the JetBrains status-bar density.
pub fn show_badge_when_zero(mut self, show: bool) -> Self
Whether to keep the badge visible when the unread count is
zero. Default false (badge hidden when no unread). Apps
that want a persistent "0" indicator pass true.
pub fn max_badge_count(mut self, max: u32) -> Self
Cap the displayed badge count. Default 99 — counts above
the cap display as "99+". Set to u32::MAX to disable the
cap.
pub fn placement(mut self, p: OverlayPlacement) -> Self
Popover placement relative to the bell. Default
BelowPreferred — flips above when the button is near the
viewport bottom edge.
pub fn on_action_invoked( mut self, f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static, ) -> Self
Threaded into the embedded NotificationLog —
see NotificationLog::on_action_invoked for the contract.
Wire this to dispatch archived actions; without it the
action buttons in the log are inert.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter
called wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip identified by a registry key.
Mutually exclusive with tooltip,
rich_tooltip_content, and
composite_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from inline crate::tooltip::TooltipContent.
Mutually exclusive with tooltip,
rich_tooltip, and
composite_tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip containing an arbitrary widget tree.
Mutually exclusive with tooltip,
rich_tooltip, and
rich_tooltip_content.
NotificationEntry
Persistent notification archive — the storage and data-model layer
backing NotificationLog, NotificationCenterButton, and
NotificationLogDialog.
Every toast presented through the toast registry is mirrored into a
NotificationArchiveModel when archiving is enabled via
ToastInstallOptions::archive. The model is a
ListModel<NotificationEntry> plus an
unread-count signal — shaped for one-line binding to the notification
UI family. Two storage variants are available: an in-memory session-only
ring buffer (NotificationArchive::InMemory) and a file-backed
persistent store (NotificationArchive::Persistent) that survives app
restarts. Action callbacks attached via raw closures are lost on
archival; actions that should remain re-invokable from the log carry an
intent_name that the log replays through ctx.send_intent(...).
When to use
- Pair with
TeksiloAppBuilder::install_toast_default()to get the full bell-button + log + persistence stack for free. - Construct
NotificationArchiveModel::in_memorydirectly in tests or custom toast setups.
// In app boot, after install_toast:
let archive = ctx.app_state::<Rc<RefCell<NotificationArchiveModel>>>().unwrap();
let log = NotificationLog::new(archive.clone());
Builder methods at a glance
in_memory, in_memory_with_limit, persistent, persistent_with_limit, limit
API reference
📖 Full rustdoc API for this module
pub struct NotificationEntry
A single archived notification entry rendered by NotificationLog and
persisted under NotificationArchive::Persistent. Carries plain owned
fields only — no closures, no Rc<dyn Fn> — so it is Serialize-friendly.
#![allow(unused)] fn main() { pub struct NotificationEntry { /* fields */ } }
pub struct NotificationUpdate
One in-place mutation applied when a Toast with the same id as an
existing entry is presented again. The archive merges these onto the
existing row — the "Uploading 3 of 7 → Upload complete" pattern.
#![allow(unused)] fn main() { pub struct NotificationUpdate { /* fields */ } }
pub enum ArchivedActionStyle
Visual presentation of an archived action button. Maps one-to-one to
ToastActionStyle; re-declared as a self-contained Serialize-friendly
enum so the archive type does not depend on ButtonVariant.
#![allow(unused)] fn main() { pub enum ArchivedActionStyle { /* variants */ } }
Variants
Link— JetBrains-style hyperlink in the body row.PrimaryButton— Filled (primary CTA).SecondaryButton— Plain (secondary).Destructive— Destructive (red-tinted).
pub struct ArchivedAction
A single action stored alongside an archived notification entry. Only
re-invokable from NotificationLog when intent_name is set — actions
whose live closure has torn down render as inert descriptive labels.
#![allow(unused)] fn main() { pub struct ArchivedAction { /* fields */ } }
pub const DEFAULT_ARCHIVE_LIMIT
Default per-archive entry cap. IntelliJ's notification log keeps hundreds of entries with no cap visible to the user; we pick a pragmatic limit so persistent files don't grow unbounded.
#![allow(unused)] fn main() { pub const DEFAULT_ARCHIVE_LIMIT: usize = 200; }
pub const ARCHIVE_FILE_NAME
File-name (without extension) used for the persistent archive.
Resolved through AppPaths::config_file into
<config_dir>/<app>/notifications.toml.
#![allow(unused)] fn main() { pub const ARCHIVE_FILE_NAME: &str = "notifications"; }
pub enum NotificationArchive
Storage mode for the notification archive. Passed inside
ToastInstallOptions::archive to the install helper.
#![allow(unused)] fn main() { pub enum NotificationArchive { /* variants */ } }
Variants
InMemory— Session-only — entries live in aListModelfor the running session. Cheap, no disk I/O. Default for apps that don't install aSettingsBundle.Persistent— File-backed viaPersistedListModel. The path is built at install time fromAppPaths::config_fileusing the configuredfile_name.
Methods
pub fn in_memory() -> Self
In-memory archive with the default 200-entry cap.
pub fn in_memory_with_limit(limit: usize) -> Self
In-memory archive with a custom cap.
pub fn persistent(file_name: impl Into<String>) -> Self
File-backed archive resolved through AppPaths::config_file
at install time. The default file name ("notifications")
yields <config_dir>/<app>/notifications.toml. Apps that
want a different name pass it here; tests pass an arbitrary
name and use AppPaths::for_testing(tmpdir).
pub fn persistent_with_limit(file_name: impl Into<String>, limit: usize) -> Self
pub fn limit(&self) -> usize
pub struct NotificationArchiveModel
Shared model — clones share state. Constructed by the install
helper from NotificationArchive + AppPaths; apps reach it
via ctx.app_state::<Rc<RefCell<NotificationArchiveModel>>>().
NotificationLog and NotificationCenterButton
consume this model directly.
#![allow(unused)] fn main() { pub struct NotificationArchiveModel { /* fields */ } }
Methods
pub fn open( archive: &NotificationArchive, paths: &AppPaths, debounce: Duration, ) -> Result<Self, NotificationArchiveError>
Construct from a NotificationArchive config. For
Persistent mode, resolves the path through AppPaths.
Tests use AppPaths::for_testing(tmpdir) + Duration::ZERO
debounce.
pub fn in_memory() -> Self
Convenience: construct an NotificationArchive::InMemory
archive with the default cap, without going through paths.
Mostly useful for tests and apps that explicitly want no
persistence.
pub fn entries(&self) -> &ListModel<NotificationEntry>
Reactive handle on the entries. Bind to a ListView /
Repeater for live UI.
pub fn unread_count(&self) -> &Signal<usize>
Signal of the unread count. Drives the bell-button badge.
pub fn version_signal(&self) -> &Signal<u64>
Reactive handle on the archive's mutation version. Widgets
that render the archive (NotificationLog,
NotificationCenterButton) bind to this at
BindingLevel::Rebuild, in every window — one signal is enough
for N of them, see
ToastRegistry::version_signal
for the history of why that had to be said out loud.
pub fn limit(&self) -> usize
pub fn flush_now(&self) -> Result<(), SettingsFileError>
Force the persistent backing file to disk synchronously.
No-op for InMemory. Tests call this between mutations and
re-opening the file to verify persistence.
pub fn push(&self, mut entry: NotificationEntry)
Push a new entry. Inserts at index 0 (newest first), evicts
the oldest if the resulting length exceeds limit. Stamps
the entry's id field from next_id. Bumps unread_count
when the entry is unread (which is the typical case from a
toast push).
If entry.dedup_id matches an existing entry, the existing
entry is updated in place (title / body / progress collapsed
into a NotificationUpdate appended to updates) and no
new row is inserted. Unread count increments either way (an
in-place update IS new information for the user).
pub fn mark_read_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool)
Mark every UNREAD entry matching predicate as read,
decrementing unread_count by exactly how many were flipped.
This is the scoped counterpart of mark_all_read:
a bell scoped to one window/audience must only mark ITS
entries read on close — calling the unscoped mark_all_read
from a scoped bell would incorrectly clear every OTHER
window's/audience's unread state too.
pub fn mark_all_read(&self)
Mark every archived entry as read; reset unread_count to 0.
Called by NotificationCenterButton when its popover opens.
pub fn clear(&self)
Clear the entire archive (resets unread_count to 0).
pub fn clear_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool)
Remove every entry matching predicate, decrementing
unread_count for each removed entry that was unread. The
scoped counterpart of clear: a bell scoped to
one window/audience must only clear ITS entries — the unscoped
clear() wipes the ENTIRE shared archive (every window's
history), which would be wrong for a scoped "Clear" button.
pub fn remove_by_id(&self, id: u64)
Remove the entry with the given stable id (see
NotificationEntry::id — "assigned by the archive on first
push; never reused"). Updates unread_count if the removed entry
was unread. No-op (no version bump) when no entry has that id.
Deliberately id-based rather than index-based: an index is a
snapshot of the list's shape at the moment it was read, and is
meaningless once anything else — a concurrent peer-process reload
merged in via the live archive, another push, another remove —
has shifted rows out from under it. A caller that captured "the row
I want to dismiss" as an index earlier and replays it later against
a since-mutated list can silently remove the wrong entry; keying
off id instead re-resolves the row's current position at the
moment of removal, so it always removes the entry the caller meant.
pub struct NotificationLogDialog
One-liner modal preset around NotificationLog. Apps usually
wire this to a menu item or shortcut (e.g. "Window → Notification
Log…").
#![allow(unused)] fn main() { pub struct NotificationLogDialog; }
Methods
pub fn show(archive: Rc<NotificationArchiveModel>, ctx: &mut EventContext)
Present the dialog with the standard chrome (title + 720x520 default size, escape-or-click-outside dismissal).
pub fn show_with( archive: Rc<NotificationArchiveModel>, ctx: &mut EventContext, configure: impl FnOnce(NotificationLog) -> NotificationLog + 'static, )
Same as show, but lets the caller configure the embedded
NotificationLog (e.g. attach an on_action_invoked hook
for archive replay).
NotificationLog

NotificationLog — a scrollable, day-bucketed list of archived notifications.
Renders a NotificationArchiveModel as a scrollable column of
StandardListItem rows grouped under section headers (Today /
Yesterday / This week / Earlier), computed against the user's local
timezone on every archive mutation. An optional toolbar row provides
mark-all-read and clear buttons. Unread rows show the title in
BodyBold; read rows use Body. An empty-state hint is shown when the
archive is empty.
Sizing
The log grows into a host that bounds its height and compresses
inside one shorter than its natural height (floored at one row);
only a host that hugs its content — which is how the overlay layer
measures the NotificationCenterButton
popover — falls back to preferred_width /
preferred_height. Row text is
elided, not wrapped, with the full text on the row's rich
tooltip: notification prose is arbitrary and the log does not
control its own width, so a wrapping row would over-constrain
itself and push its trailing action buttons out of view.
When to use
- Embed directly inside a side panel or settings page for an in-app notification centre.
- Wrap in
NotificationCenterButtonfor the standard bell-icon-with-popover pattern. - Call
NotificationLogDialog::showfor a one-line modal presentation.
let archive: Rc<NotificationArchiveModel> = ctx.app_state().unwrap();
let log = NotificationLog::new(archive)
.on_action_invoked(|_entry, action, ctx| {
if let Some(name) = &action.intent_name {
ctx.send_intent(teksilo_core::Intent::new(name));
}
});
Builder methods at a glance
for_window, for_audience, show_toolbar, empty_state, preferred_width, preferred_height, on_entry_invoked, on_action_invoked
API reference
📖 Full rustdoc API for this module
pub struct NotificationLog
Configurable archive log. Shipped chrome:
- mark-all-read + clear buttons in a toolbar row;
- empty-state hint when the archive is empty;
- day-bucket section headers (Today / Yesterday / This week / Earlier) above the rows for each bucket — computed against the user's local timezone, recomputed on every archive mutation;
StandardListItemrows with unread-as-bold differentiation.
A SearchField filter and a severity-chip filter can be composed by apps using the existing widget toolkit.
#![allow(unused)] fn main() { pub struct NotificationLog { /* fields */ } }
Methods
pub fn new(archive: Rc<NotificationArchiveModel>) -> Self
Construct a log bound to the shared archive. The archive is
expected to outlive the log (typically held in app_state).
pub fn for_window(mut self, window_id: TeksiloWindowId) -> Self
Scope this log to entries routed to window window_id (plus
any Broadcast entry) — the shape a NotificationCenterButton
mounted in that window wants for its popover body. Overrides
any previous for_window / for_audience call.
pub fn for_audience(mut self, audience: ToastAudience) -> Self
Scope this log to entries routed to audience (plus any
Broadcast entry). Overrides any previous for_window /
for_audience call.
pub fn show_toolbar(mut self, show: bool) -> Self
Whether to render the toolbar row (mark-all-read + clear).
Default true. Apps that want a chrome-less log (e.g. inside
a custom panel that supplies its own toolbar) pass false.
pub fn empty_state(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Override the empty-state hint. Default: a centered "No notifications" text. Pass a factory returning any widget for a custom empty view (illustration, call-to-action, …).
A factory rather than a widget because the log rebuilds on every archive mutation: the view has to be re-creatable each time the archive goes empty again, not just the first time.
log.empty_state(|| Box::new(TextWidget::new(tr!(inbox_zero()))))
pub fn preferred_width(mut self, width: f32) -> Self
Width the log reports when the host proposes an unbounded one.
Default 380 dp.
This is load-bearing for the popover presentation
(NotificationCenterButton):
the overlay layer measures its content with a fully unbounded
proposal, and a StandardListItem asked for an intrinsic
width reports only its chrome, so without a preferred width the
popover would size itself to whatever the two toolbar buttons
happen to measure (~248 dp with the stock labels) and elide
every title to a stub. Hosts that DO bound the width (a dialog,
a side panel) ignore this value.
pub fn preferred_height(mut self, height: f32) -> Self
Height of the scrolling list area when the host proposes an
unbounded height. Default 320 dp.
The log always grows into a host that bounds its height (it reports a flex weight), so this only sets the natural height a content-hugging host — again, the popover — sizes itself to.
pub fn on_entry_invoked( mut self, f: impl Fn(&NotificationEntry, &mut EventContext) + 'static, ) -> Self
Called when the user clicks anywhere on an archived entry's row body (outside any specific action button). The default behaviour is no-op — the log is read-only display unless callers wire this hook.
pub fn on_action_invoked( mut self, f: impl Fn(&NotificationEntry, &ArchivedAction, &mut EventContext) + 'static, ) -> Self
Called when an archived action button is clicked. Apps wire
this hook to replay the action — typically by mapping the
ArchivedAction::intent_name to one of the app's registered
Actions via ctx.send_intent(...). Without this hook
configured the action buttons are inert (the log keeps them
visible for archival context).
Actions without an intent_name render as non-clickable
past-action tags regardless of this hook — there's nothing
for the framework to dispatch against once the live closure
has torn down.
log.on_action_invoked(|_entry, action, ctx| {
// Bridge the dynamic intent_name to one of the app's
// typed AppIntent variants:
match action.intent_name.as_deref() {
Some("app.build.retry") => ctx.send_intent(AppIntent::BuildRetry),
Some(name) => log::warn!("unknown archived intent: {name}"),
None => {}
}
})
OverlayTrigger
Builder methods at a glance
around, around_id, named, has_on_activate, on_activate
API reference
📖 Full rustdoc API for this module
pub struct OverlayTrigger
Wraps an arbitrary widget so it can drive a popover.
PopoverButton and PopoverIconButton cover the two stock triggers; this
is the third case — a trigger that is not a button, such as a table
header's filter glyph or a tag chip. It supplies what those two get from
Button/IconButton: an activate route (pointer, Enter/Space, and the
AT Click action), the has_popup / expanded disclosure annotations, and
the arena-level enabled gate.
PopoverWidget::new(OverlayTrigger::around(my_glyph))
.content(my_panel)
.placement(OverlayPlacement::BelowPreferred)
#![allow(unused)] fn main() { pub struct OverlayTrigger { /* fields */ } }
Methods
pub fn around(widget: impl Widget + 'static) -> Self
Wrap any widget as a popover trigger.
pub fn around_id(id: WidgetId) -> Self
around for a widget already inserted by id.
pub fn named(self, name: impl Into<String>) -> Self
Set the trigger's accessible name.
pub fn has_on_activate(&self) -> bool
Whether an activate handler is already installed.
pub fn on_activate( mut self, f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Install the popover's open/close handler. Routed onto the wrapped widget
as pointer-tap, Enter/Space and the AT Click action.
Padding

Padding — a single-child layout container that adds insets around its child.
Padding shrink-wraps a child widget and enlarges it by configurable insets
on each of the four sides. Horizontal insets are leading/trailing
(logical), not left/right (physical), so they flip automatically in RTL
locales. Each inset accepts a static f32 or a reactive Signal<f32>; a
bound inset schedules a relayout whenever the signal fires, so theme-derived
spacing values take effect without rebuilding the widget tree.
The grow weight, shrink weight, and compression floor reported by the child
are forwarded through the padding so a flexible or shrinkable child inside a
Padding stays flexible or shrinkable from the parent's perspective.
When to use
- Adding whitespace around a widget without wrapping it in a stack.
- Applying asymmetric insets (e.g. extra leading inset for a list item).
- Reacting to a
Signal-driven spacing token.
Use Padding::uniform when all four sides are equal, and
Padding::symmetric when horizontal and vertical insets differ.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{Padding, TextWidget}; use teksilo_i18n::lit; // 12 dp padding on every side: let _w = Padding::uniform(12.0) .child(TextWidget::new(lit!("Hello"))); }
Builder methods at a glance
uniform, symmetric, child_id, child
API reference
📖 Full rustdoc API for this module
pub struct Padding
A layout container that adds padding (insets) around a single child.
See the module documentation for the full feature description and
an example. Construct with Padding::new, Padding::uniform, or
Padding::symmetric; attach a child with .child(widget) or
.child_id(id).
#![allow(unused)] fn main() { pub struct Padding { /* fields */ } }
Methods
pub fn new( top: impl Into<Prop<f32>>, trailing: impl Into<Prop<f32>>, bottom: impl Into<Prop<f32>>, leading: impl Into<Prop<f32>>, ) -> Self
Create a padding with explicit per-side insets.
Argument order mirrors CSS shorthand: (top, trailing, bottom, leading).
trailing and leading are logical — they map to physical right and
left in LTR and are swapped in RTL.
pub fn uniform(amount: impl Into<Prop<f32>>) -> Self
Create a padding with the same inset on all four sides.
pub fn symmetric(vertical: impl Into<Prop<f32>>, horizontal: impl Into<Prop<f32>>) -> Self
Create a padding with equal top/bottom insets and equal leading/trailing insets.
vertical applies to both top and bottom; horizontal applies to both
leading and trailing sides (logical, RTL-aware).
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
Panel

Panel — a themed single-child container that provides a background, border, corner radius, and padding.
The equivalent of Qt's QFrame: a visual wrapper whose chrome comes from
the active PanelStyle trait
implementation. The IntUI default (RecipePanelStyle) honours four
PanelVariant presets (Plain /
Sunken / Raised / Highlighted) while still accepting per-call overrides
for background, border colour/width, corner radius, and padding. Apps
requiring a custom surface (frosted glass, brutalist frame) supply their
own impl PanelStyle per-call (.style(...)) or theme-wide via
theme.style_slots.panel.
Accessibility
Emits Role::Group by default. Call .a11y_presentational() to suppress
the group node when the panel is purely decorative (e.g. a toolbar
background that should not introduce a spurious container in the AT tree).
#![allow(unused)] fn main() { use teksilo_widgets::Panel; use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; let _w = Panel::new() .padding(12.0) .child(TextWidget::new(lit!("Content"))); }
Builder methods at a glance
variant, style, a11y_presentational, child_id, child, background, border_color, border_width, corner_radius, padding
API reference
📖 Full rustdoc API for this module
pub struct Panel
A themed container with background, border, corner radius, and padding.
#![allow(unused)] fn main() { pub struct Panel { /* fields */ } }
Methods
pub fn new() -> Self
Construct a panel with default theme values (Plain variant, no manual overrides).
pub fn variant(mut self, variant: PanelVariant) -> Self
Pick the design-language variant. Default Plain. The active
PanelStyle decides what each variant means visually (the
IntUI default maps Plain → surface_main, Sunken →
surface_sunken, Raised → surface_raised, Highlighted →
accent_subtle_bg, with matching border defaults).
pub fn style(mut self, style: impl teksilo_core::styles::PanelStyle) -> Self
Per-call style override. Replaces the theme-wide default
PanelStyle for just this Panel instance — same role as
Button::style(...). Manual overrides (background,
border_color, etc.) are still passed to the style via
PanelStyleConfig; custom styles are free to honour or ignore
them.
pub fn a11y_presentational(mut self) -> Self
Mark the panel as presentational for assistive tech: the panel's
own a11y node is hidden so its wrapping chrome (background,
border, padding) doesn't introduce a spurious Group node
between an outer widget (Toolbar, StatusBar, etc.) and the
real content. Children remain visible in the a11y tree.
pub fn child_id(mut self, id: WidgetId) -> Self
Set child by pre-registered ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Set an inline child widget (deferred insertion).
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Override the background. Accepts Color, a SurfaceRole,
or a Signal<Color>. Default (unset) is SurfaceRole::Main.
pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self
Override the border color. Accepts Color, a BorderRole,
or a Signal<Color>. Default (unset) is BorderRole::Default.
pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self
Override the border width (default: 0 — no border).
Accepts a static f32 or a reactive Signal<f32>.
pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self
Override the corner radius (default: theme radius_popup).
Accepts a static f32 or a reactive Signal<f32>.
pub fn padding(mut self, padding: impl Into<Prop<f32>>) -> Self
Override the padding (default: theme components.panel.padding).
Accepts a static f32 or a reactive Signal<f32>.
PasswordField

PasswordField — secure single-line text entry with a reveal
toggle, masking, Caps Lock warning, and clipboard protection.
A thin, ergonomic preset over a secure
TextInputField composed
SpinBox-style: the field + an embedded reveal button live inside
one bordered frame with a unified focus halo. Masking happens at the
text-engine layer (one echo glyph per source char), so the
plaintext never reaches the shaper or glyph atlas while masked, and
caret / selection / hit-test stay correct.
Feature parity target: Qt QLineEdit echo modes, SwiftUI
SecureField, WinUI PasswordBox / PasswordRevealMode, and the
Android password_toggle.
Example
let password = ctx.signal(String::new());
PasswordField::new(password.clone())
.label(tr!(password())) // or .label(lit!("Password"))
.placeholder(tr!(password_hint())) // i18n-first; `_literal` twins bypass i18n
.validator(|s| if s.len() >= 8 {
ValidationOutcome::Valid
} else {
ValidationOutcome::Invalid { message: "Too short".into() }
})
Builder methods at a glance
placeholder, label, enabled, read_only, max_length, char_filter, validator, on_submit_fn, on_blur_fn, min_width, variant, style, echo_char, echo_mode, reveal_mode, revealed, allow_copy, caps_lock_warning, at_reveal_policy, tooltip, rich_tooltip_key, rich_tooltip_content, rich_tooltip, composite_tooltip, revealed_signal, text
API reference
📖 Full rustdoc API for this module
pub enum RevealMode
How the reveal affordance behaves. Mirrors WinUI's
PasswordRevealMode.
#![allow(unused)] fn main() { pub enum RevealMode { /* variants */ } }
Variants
Toggle— A click (or Space / Enter while focused) flips between masked and revealed. Backed byIconButton::visibility_toggle; fully keyboard- and screen-reader-accessible. (Default.)Hold— Press-and-hold to reveal, release to re-mask (WinUI "Peek"). Pointer-oriented; preferTogglefor keyboard accessibility.None— No reveal button — the field is always masked per itsEchoMode.
pub struct PasswordField
Secure single-line text entry. See the module docs.
#![allow(unused)] fn main() { pub struct PasswordField { /* fields */ } }
Methods
pub fn new(password: Signal<String>) -> Self
Construct a secure field bound to password.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Placeholder shown when empty. Never masked.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible name, applied to the Role::PasswordInput field node.
Strongly recommended for screen-reader users.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the
arena at build time — a bound Signal<bool> updates live as it
changes.
pub fn read_only(mut self, read_only: bool) -> Self
Read-only: selection works, edits don't.
pub fn max_length(mut self, max_length: usize) -> Self
Hard cap on length in chars.
pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self
Per-character input filter (applied to keystrokes, IME commits, and paste).
pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self
Commit-time validator (Enter / blur). Drives the inline
validation strip and aria-invalid.
pub fn on_submit_fn( mut self, f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Fired on Enter (focus stays put).
pub fn on_blur_fn( mut self, f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Fired once per focus-loss.
pub fn min_width(mut self, width: f32) -> Self
Minimum frame width (logical px). Default 65.
pub fn variant(mut self, variant: TextInputVariant) -> Self
Frame variant (Outlined / Filled / Underline / Bare).
pub fn style(mut self, style: impl TextInputStyle) -> Self
Per-instance style override.
pub fn echo_char(mut self, c: char) -> Self
Override the masking glyph (default '•').
pub fn echo_mode(mut self, mode: EchoMode) -> Self
Set the EchoMode (default EchoMode::Masked).
pub fn reveal_mode(mut self, mode: RevealMode) -> Self
Set the RevealMode (default RevealMode::Toggle).
pub fn revealed(mut self, revealed: Signal<bool>) -> Self
Bind an external reveal signal (shared with other UI, observed
for analytics, or driven programmatically). Defaults to an
internal signal exposed via revealed_signal.
pub fn allow_copy(mut self, allow: bool) -> Self
Permit copy / cut even while masked (default false). Copy is
always allowed while revealed regardless of this flag.
pub fn caps_lock_warning(mut self, on: bool) -> Self
Show a Caps Lock warning when focused with Caps Lock on (default
true). The warning is announced to screen readers via a polite
live region.
pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self
How a revealed field reports to assistive tech (default
AtRevealPolicy::SwapRole).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Plain single-line tooltip shown on hover.
Mutually exclusive with rich_tooltip_key,
rich_tooltip,
rich_tooltip_content, and
composite_tooltip — calling any of them
clears the others.
pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self
Registry-keyed rich tooltip.
Mutually exclusive with the other tooltip setters.
pub fn rich_tooltip_content(mut self, content: tooltip::TooltipContent) -> Self
Inline rich tooltip (canonical name: accepts a
TooltipContent directly without a
registry key).
Mutually exclusive with the other tooltip setters.
pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self
Inline rich tooltip.
Mutually exclusive with the other tooltip setters.
Prefer rich_tooltip_content for the
canonical API.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Composite (arbitrary-widget) tooltip.
Mutually exclusive with the other tooltip setters.
pub fn revealed_signal(&self) -> Signal<bool>
The reveal-state signal (true = plaintext shown). Useful to
observe or drive reveal programmatically.
pub fn text(&self) -> Signal<String>
The bound password signal.
PopoverSurface
PopoverSurface — the themed panel a popover's content sits in.
Style infrastructure, not a widget an app mounts: RecipePopoverStyle (and
any PopoverStyle replacing it) constructs one in make_body, and
PopoverWidget shows the result as its overlay. It lived in popover.rs
beside the standalone Popover widget until that type was removed; the two
were never related beyond sharing a file.
API reference
📖 Full rustdoc API for this module
pub struct PopoverSurface
#![allow(unused)] fn main() { pub struct PopoverSurface { /* fields */ } }
Methods
pub fn new( content: PendingChild, placement: OverlayPlacement, show_caret: bool, caret_size: f32, name: String, content_padding: EdgeInsets, background: SurfaceRole, corner_radius: f32, presentational: bool, ) -> Self
PopoverWidget
PopoverWidget<T> — a generic trigger that opens a popover when
activated, plus the PopoverButton / PopoverIconButton aliases.
Wraps a caller-built trigger (T: PopoverTrigger) with overlay
wiring: owns a popover_open: Signal<bool> toggled on activate /
dismiss, sets has_popup and expanded_when on the inner trigger so
AT announces the disclosure state, pre-builds the popover content as a
dormant subtree, and shows / hides it via OverlayRequest. The
set_dormant + activate + show_overlay sequence and the
dismiss-callback shape match DateEdit
so behavior across the disclosure family stays consistent.
#![allow(unused)] fn main() { use teksilo_widgets::{Button, ButtonVariant, IconButton, MenuList, MenuItem, PopoverButton, PopoverIconButton}; use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; // Text trigger (HasPopup::Dialog by default, no caret): let _w = PopoverButton::new(Button::new(lit!("Choose…")).variant(ButtonVariant::Plain)) .content(TextWidget::new(lit!("Pick"))); // Icon trigger (HasPopup::Menu by default, corner caret on): let _w = PopoverIconButton::new(IconButton::add().toolbar()) .content(MenuList::new().item(MenuItem::new(lit!("New file")))); }
Trigger configuration overrides
build() configures the inner trigger by calling has_popup,
expanded_when, and on_activate_fn (and share_interaction when a
caret is shown). These replace any previous values the caller set
— in particular any on_activate_fn set before ::new is discarded,
because the activate slot is owned by the popover wiring. Use
on_open / on_close, or observe open_signal, for side effects.
Per-trigger differences (the PopoverTrigger trait)
Button and IconButton differ only in: the default has_popup
kind, whether the disclosure caret shows by default, whether the
caret is suppressed (IconButton at Compact), and how the caret's
color is derived. Those four points live behind PopoverTrigger;
everything else is shared by the generic.
Builder methods at a glance
content, placement, dismiss_behavior, fade_duration, has_popup_kind, show_disclosure_caret, on_open, on_close, open_signal, open_action, surface, bare, surface_style, surface_name, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub type PopoverCustom
A popover whose trigger is an arbitrary widget, wrapped in
OverlayTrigger.
The third stock shape beside PopoverButton and PopoverIconButton,
and what replaced the standalone Popover widget: that type existed only
because this generic could not take a non-button trigger.
#![allow(unused)] fn main() { pub type PopoverCustom = PopoverWidget<OverlayTrigger>; }
pub struct PopoverWidget
A trigger paired with a popover surface. See the module docs for the
contract on which trigger properties get overridden during build().
Use the PopoverButton / PopoverIconButton aliases for the
concrete trigger types.
#![allow(unused)] fn main() { pub struct PopoverWidget<T: PopoverTrigger> { /* fields */ } }
Methods
pub fn new(trigger: T) -> Self
Wrap a pre-configured trigger. The popover content is set
separately via Self::content (required).
pub fn content(mut self, content: impl Widget + 'static) -> Self
Set the popover content — added to the tree as a dormant subtree
during build(), woken via
EventContext::activate
when the trigger fires. Required.
pub fn placement(mut self, p: OverlayPlacement) -> Self
Override the popover's placement relative to the trigger.
Default: OverlayPlacement::BelowPreferred.
pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self
Override the dismiss behavior. Default:
DismissBehavior::EscapeOrClickOutside.
pub fn fade_duration(mut self, d: Duration) -> Self
Animate the overlay in / out over the given duration. Default:
no fade. See OverlayRequest::with_fade for the mechanism.
pub fn has_popup_kind(mut self, k: HasPopup) -> Self
Override the has_popup kind announced by AT. Defaults to the
trigger type's PopoverTrigger::default_has_popup.
pub fn show_disclosure_caret(mut self, on: bool) -> Self
Whether to paint the disclosure triangle in the trigger's
bottom-right corner. Defaults to the trigger type's
PopoverTrigger::default_show_caret. The caret is
suppressed automatically when
PopoverTrigger::suppress_caret returns true (e.g.
IconButton at Compact) regardless of this flag. AT-hidden —
the popup is announced via set_has_popup + set_expanded.
pub fn on_open(mut self, f: impl Fn() + 'static) -> Self
Notification fired on the rising edge of the popover (after the
overlay show request is dispatched). No EventContext — observe
Self::open_signal from your build() if you need
frame / dispatch context.
pub fn on_close(mut self, f: impl Fn() + 'static) -> Self
Notification fired on the falling edge of the popover (when the overlay's dismiss callback runs).
pub fn open_signal(&self) -> Signal<bool>
Observe-only handle to the popover-open state.
Read-back only — writing this does not open the popover. Presenting
an overlay needs an EventContext (show_overlay + request_focus),
which no signal observer has; this field is the mirror the trigger writes
after it has done that work. To open the popover from somewhere other
than its trigger, use open_action.
pub fn open_action(mut self, intent: &'static str) -> Self
Register a named global action that toggles this popover, so a menu
entry, a global shortcut or ctx.send_intent(...) can open it — not only
a click on its own trigger.
Without this a popover is reachable by pointer alone. on_open /
on_close are notification-only and open_signal is a read-back mirror
(see its doc), so an app that wanted "Go to… ⌘G" next to its button had
no way to wire the second half. Action handlers are the one place that
does get an EventContext, which is exactly what presenting an overlay
requires — so the action runs the identical toggle the trigger runs, and
the two can never drift.
Registered with register_action_global, deliberately: intents walk
source-widget → root, and a menu renders in an overlay that is a
sibling of the popover's own subtree, so a plain register_action would
never be reached from a menu item. Pair it with
register_shortcut_global in the app for the keystroke.
PopoverButton::new(Button::new(tr!(go_to())))
.content(palette)
.open_action("go.to")
// elsewhere: MenuEntry::new(tr!(go_to())).intent("go.to").shortcut("go.to")
pub fn surface(mut self, variant: PopoverVariant) -> Self
Choose which themed PopoverVariant surface wraps the content.
Default is PopoverVariant::Default (elevated panel with
padding + shadow). The surface is resolved from the active
PopoverStyle (theme.style_slots.popover), so it themes
app-wide.
pub fn bare(mut self) -> Self
Opt OUT of the themed surface: the content is added raw, with no
background / border / padding. Use when the content already
supplies its own chrome — a MenuList (which
routes through the Menu PopoverStyle itself) or a hand-rolled
surface Panel. Without this, such content would be
double-chromed.
pub fn surface_style(mut self, style: impl PopoverStyle) -> Self
Per-call PopoverStyle override for the surface (highest
precedence over the theme slot and the built-in default). Mirrors
the per-call override the standalone Popover used to offer. No effect under
bare.
pub fn surface_name(mut self, name: impl Into<String>) -> Self
Accessible name for the surface's Role::Dialog node. Defaults
to empty (the wrapped content usually carries its own role and
name). No effect under bare or for the Menu
variant (which is presentational).
pub fn tooltip(mut self, text: impl Into<teksilo_i18n::LocalizedString>) -> Self
Show a plain single-line tooltip on the trigger after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — each setter clears
the other three so the last call wins. The tooltip anchors on the
trigger, not on the popover content.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Show a rich tooltip (looked up by registry key) on the trigger after a hover delay. Mutually exclusive with the other tooltip setters — the last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Show an inline rich tooltip (pre-built TooltipContent) on the
trigger after a hover delay. Mutually exclusive with the other tooltip
setters — the last call wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Show a composite tooltip (arbitrary widget tree) on the trigger after a longer hover delay. Mutually exclusive with the other tooltip setters — the last call wins.
pub type PopoverButton
A Button that opens a popover when activated. Alias for
PopoverWidget<Button> — HasPopup::Dialog, no caret by default.
#![allow(unused)] fn main() { pub type PopoverButton = PopoverWidget<Button>; }
pub type PopoverIconButton
An IconButton that opens a popover when activated. Alias for
PopoverWidget<IconButton> — HasPopup::Menu, corner caret on by
default (skipped at Compact).
#![allow(unused)] fn main() { pub type PopoverIconButton = PopoverWidget<IconButton>; }
PrivacySettings
Available under:
#[cfg(feature = "telemetry")]
PrivacySettings — a user-facing panel for telemetry consent management.
Embeddable in any container — typically a Dialog for first-run consent
or a dedicated tab in the app's settings UI. Reads from
OpenedTelemetry and writes to ConsentStore; the UI rebuilds
whenever the consent state signal changes. When no telemetry is registered
in app_state the widget renders a graceful placeholder so apps without
analytics pay nothing.
Sections (top-to-bottom)
- Plain-language Art. 13 notice — controller, processor name,
purposes, lawful basis, retention, recipients, withdrawal right.
All strings flow through
tr_widget!against keys defined incrates/teksilo-widgets/locales/en-US.ftlandfr-FR.ftlunder theprivacy-*namespace. Apps install the framework bundle viaI18nConfig::framework_locales(teksilo_widgets::framework_locales()). - Per-scope toggles — one per
ConsentScopefield, intersected withreporter.supported_scopes()so toggles for unsupported scopes are hidden, not just disabled. Toggles work fromUnknown(auto-transition toGrantedwith the toggled scope) andGrantedstates; they're disabled when state isDenieduntil the user clicks Withdraw → Accept. - Accept all / Reject all — equal-prominence buttons (CNIL parity rule, GDPR Art. 7).
- Identity row (pseudonymous mode only) — install_id display, Get-my-data button (Art. 15 + 20), Erase-my-data button (Art. 17).
- Inspect data sent — accordion listing the most-recent events from the bundle's recent-log ring buffer.
- Mode switch (when both adapters configured) — confirm-button pair to flip anonymous ↔ pseudonymous.
- Footer — Withdraw consent (equal prominence to Accept, GDPR Art. 7(3)).
When no OpenedTelemetry is registered in app_state, the
widget renders a "Telemetry not configured" placeholder. Apps that
ship without analytics pay nothing.
// Embed in a Dialog for first-run consent (compact mode).
let panel = PrivacySettings::new()
.compact(true)
.data_processor_name("Acme Corp")
.privacy_policy_url("https://example.com/privacy");
Builder methods at a glance
compact, show_identity_row, show_mode_switch, show_inspect, inspect_event_count, privacy_policy_url, data_processor_name
API reference
📖 Full rustdoc API for this module
pub struct PrivacySettings
Settings widget for telemetry consent. Construct with
PrivacySettings::new and embed in any container.
#![allow(unused)] fn main() { pub struct PrivacySettings { /* fields */ } }
Methods
pub fn new() -> Self
Create a PrivacySettings widget with full layout and all sections shown.
pub fn compact(mut self, compact: bool) -> Self
Use a compact layout suited for first-run modals: hides the mode-switch
section and tightens spacing. Defaults to false (full settings panel).
pub fn show_identity_row(mut self, show: bool) -> Self
Show or hide the install-id / GDPR Art. 15 + 17 identity row in
pseudonymous mode. Set to false when the host app supplies its own
equivalent UI. Defaults to true.
pub fn show_mode_switch(mut self, show: bool) -> Self
Show or hide the anonymous ↔ pseudonymous mode-switch section when both
adapters are configured. Has no effect if only one mode is available.
Defaults to true.
pub fn show_inspect(mut self, show: bool) -> Self
Show or hide the "Inspect data sent" accordion that lists recent events
from the telemetry ring buffer. Defaults to true.
pub fn inspect_event_count(mut self, n: usize) -> Self
Maximum number of recent events shown in the inspect accordion. Clamped to at least 1. Defaults to 50.
pub fn privacy_policy_url(mut self, url: impl Into<String>) -> Self
Surface a "Read full privacy policy" link in the Art. 13 notice. When not set the link is hidden — the controller is responsible for hosting their own policy page.
pub fn data_processor_name(mut self, name: impl Into<String>) -> Self
Plain-text controller name used in the Art. 13 notice ("Data is
processed by <name>"). Defaults to "the application".
ProgressBar

ProgressBar — a bar showing progress from 0.0 to 1.0.
Supports determinate (fixed or reactive value), indeterminate (animated
sweep), horizontal, and vertical orientations. The stationary chrome (track
and determinate fill) is delegated to ProgressBarStyle; the indeterminate
sweep is widget-owned (motion infrastructure is not chrome). Three paint
paths exist internally:
- Horizontal indeterminate uses the shader-driven animated-quad
pipeline.
ProgressBar::buildregisters anAnimatedQuadHandleand mounts a singleIndeterminateSweepLeafwhosepaint()issues onedraw_animated_quadper frame; the shader composes the track + moving fill in a procedural draw. The recipe frame is NOT mounted in this case (the shader self-paints both). - Vertical indeterminate keeps the signal-based path. The
recipe frame paints the track; an
IndeterminateSweepLeafin signal mode paints a moving fill rect on top driven by aSignal<f32>::animate_looping. - Determinate mounts the recipe frame only; the frame paints the track plus a proportional fill rect.
#![allow(unused)] fn main() { use teksilo_widgets::ProgressBar; use teksilo_core::signal::Signal; // Static determinate bar at 70 %: let _bar = ProgressBar::new(0.7).thickness(6.0); // Reactive determinate bar: let progress = Signal::new(0.0_f32); let _bar = ProgressBar::new(0.0).value(progress); // Indeterminate (animated sweep): let _spinner_bar = ProgressBar::indeterminate(); }
Builder methods at a glance
indeterminate, value, orientation, thickness, track_color, fill_color, style, label
API reference
📖 Full rustdoc API for this module
pub struct ProgressBar
A progress bar — determinate or indeterminate, horizontal or vertical.
#![allow(unused)] fn main() { pub struct ProgressBar { /* fields */ } }
Methods
pub fn new(value: f32) -> Self
Create a determinate progress bar with a static value (0.0–1.0).
pub fn indeterminate() -> Self
Create an indeterminate progress bar (animated sweep).
pub fn value(mut self, state: impl Into<Prop<f32>>) -> Self
Bind the progress value to a reactive state.
pub fn orientation(mut self, orientation: Orientation) -> Self
Set the bar's orientation. Default is Orientation::Horizontal.
Vertical bars use the shader-driven animation path only for horizontal;
vertical indeterminate bars use the signal-driven path instead.
pub fn thickness(mut self, thickness: f32) -> Self
Set the bar's narrow dimension in logical pixels. For horizontal bars this is the height; for vertical bars this is the width. Default is 4.0.
pub fn track_color(mut self, color: impl Into<ColorProp>) -> Self
Override the track background. Default (unset) is SurfaceRole::Sunken.
Accepts Color, roles, or Signal<Color>.
pub fn fill_color(mut self, color: impl Into<ColorProp>) -> Self
Override the fill / sweep color. Default (unset) is SurfaceRole::Accent.
Accepts Color, roles, or Signal<Color>.
pub fn style(mut self, style: impl teksilo_core::styles::ProgressBarStyle) -> Self
Per-call style override for the stationary chrome (track +
determinate fill). The indeterminate sweep is widget-owned and
always uses the shader-quad / signal-driven path described in
the module doc; the style supplies the sweep's colour
recipe via fill_color_override / track_color_override.
pub fn label(mut self, text: impl Into<LocalizedString>) -> Self
Accessible name for the progress bar.
Pulse
Pulse — a wrapper widget that pulses its child's opacity between
a min and max value on a fixed period, sine-shaped.
The classic "blinking red light" / recording-indicator / attention beacon pattern. The wrapped subtree pulses smoothly (sine interpolation), giving a breathing-light feel rather than a hard on/off blink.
ctx.add(
Pulse::opacity(0.3, 1.0)
.period(Duration::from_millis(1200))
.child(RectWidget::new().background(Color::RED)),
);
Layout semantics
Layout-transparent — the child reports its full natural size at
all opacity values. Identical layout footprint to Fade.
Reduced motion
Honours prefers-reduced-motion: skips the per-frame driver and
pins opacity at the midpoint (min + max) / 2. The subtree stays
visible at a steady, non-distracting brightness so the indicator
still communicates "active" without animating.
Builder methods at a glance
opacity, period, child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Pulse
Wraps a child and pulses its opacity smoothly between min and
max on a fixed period. Useful for recording indicators,
notification beacons, and attention-grabbing status icons.
#![allow(unused)] fn main() { pub struct Pulse { /* fields */ } }
Methods
pub fn opacity(min: f32, max: f32) -> Self
Wrap a subtree in an opacity pulse between min and max
(both clamped to 0..=1). Uses a sine wave so the transitions
at both extremes are smooth, not abrupt.
pub fn period(mut self, period: Duration) -> Self
Override the pulse period (full cycle min → max → min).
Default: MotionTokens::duration_indeterminate_sweep (~900 ms),
the same continuous-loop budget the indeterminate progress bar
and spinner use — so a re-themed motion stack stays consistent.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
RadioButton

RadioButton — mutually exclusive selection control.
Multiple RadioButtons share a Signal<usize>; selecting one writes its
value to the signal, which automatically deselects every sibling that
observes the same signal. The widget is non-generic: values are usize
indices into the caller's choice list. Wrap related buttons in a
RadioGroup to provide the AT "2 of 3"
positional announcement required by ARIA.
Accessibility
Reports Role::RadioButton with set_toggled mirroring the selected
state. Responds to Action::Click from assistive technology. The focus
ring is keyboard-only (:focus-visible gated by the input-modality
signal). When wrapped in RadioGroup, each button emits
push_to_radio_group([sibling_ids]) so screen readers can announce
positional membership.
#![allow(unused)] fn main() { use teksilo_widgets::RadioButton; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let selected = Signal::new(0_usize); let _r0 = RadioButton::new(0, selected.clone()).label(lit!("Light")); let _r1 = RadioButton::new(1, selected.clone()).label(lit!("Dark")); let _r2 = RadioButton::new(2, selected.clone()).label(lit!("System")); }
Builder methods at a glance
label, caption, enabled, variant, style, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct RadioButton
A single radio button option that writes value into a shared Signal<usize> on selection.
#![allow(unused)] fn main() { pub struct RadioButton { /* fields */ } }
Methods
pub fn new(value: usize, selected: Signal<usize>) -> Self
Create a radio button with the given value and shared selection signal.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the visible label text displayed to the right of the radio circle.
pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self
Secondary explanatory text rendered below the label, left-aligned
with the label (not the radio circle). Uses the small /
text_secondary style. Has no effect unless label(...) is also set.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to
the arena via ctx.enabled_when(self_id, self.enabled.clone())
at build time.
pub fn variant(mut self, variant: RadioVariant) -> Self
Pick the design-language variant. Default Circle. The active
RadioStyle impl decides what the variant means visually.
pub fn style(mut self, style: impl teksilo_core::styles::RadioStyle) -> Self
Per-call style override. Replaces the theme-wide default
RadioStyle for just this RadioButton instance.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown on hover.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip
registry. See Button::rich_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. See Button::composite_tooltip.
RadioGroup

RadioGroup — invisible layout container that groups RadioButtons
and wires their accessibility metadata.
Radios are a fundamentally group-based control: screen readers need
to announce "2 of 3" positional info, which AccessKit models via
push_to_radio_group([sibling_ids]) on each radio button. Loose
RadioButtons scattered in an HStack can't self-assemble this
relation because they have no knowledge of their siblings.
RadioGroup solves this by owning a shared Rc<RefCell<Vec<WidgetId>>>
buffer, injecting it into each RadioButton child before adding
them to the arena, and populating the buffer with each radio's
WidgetId as it's created. RadioButton::accessibility() reads
the buffer and emits the push_to_radio_group calls.
The widget is a pure layout wrapper — it delegates actual
rendering to an HStack or VStack under the hood. Its own
accessibility node carries Role::RadioGroup + an optional
accessible name.
let selected = ctx.signal(0_usize);
RadioGroup::new()
.label(lit!("Theme"))
.radio(RadioButton::new(0, selected.clone()).label(lit!("Light")))
.radio(RadioButton::new(1, selected.clone()).label(lit!("Dark")))
.radio(RadioButton::new(2, selected.clone()).label(lit!("System")))
Builder methods at a glance
orientation, spacing, label, radio, child
API reference
📖 Full rustdoc API for this module
pub struct RadioGroup
Invisible layout container that groups RadioButtons for
accessibility. Arranges children in an HStack or VStack
and carries Role::RadioGroup on its own a11y node.
#![allow(unused)] fn main() { pub struct RadioGroup { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty radio group with vertical orientation and 8 dp spacing.
pub fn orientation(mut self, orientation: Orientation) -> Self
Layout orientation. Defaults to Vertical — most radio groups
read top-to-bottom.
pub fn spacing(mut self, spacing: f32) -> Self
Gap between children.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible name for the group — e.g. "Theme", "Font family". Screen readers announce this before individual radio labels.
pub fn radio(mut self, button: RadioButton) -> Self
Add a radio button. The group's shared sibling-id buffer is
injected into the radio at build time so its accessibility
impl can publish group membership via push_to_radio_group.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add a non-radio child (divider, caption label, etc.). Passed straight through to the internal stack without a11y wiring.
RadioTile

RadioTile — a "selectable card" radio option.
A RadioTile behaves as a single radio button (Role::RadioButton,
set_toggled) rendered as a bordered, rounded card: a leading icon, a
bold title, an inline radio indicator, and a muted, wrapping description.
Multiple tiles share a Signal<usize> — selecting one writes its value,
which deselects every sibling observing the same signal (the RadioButton
model). Group them with
RadioTileGroup for layout,
roving keyboard navigation, and the AT "N of M" positional announcement.
Content model
Typed slots cover the common case (matching the reference design):
.icon(..), .title(..), .description(..). For arbitrary content, the
.body(..) slot replaces the description column with any widget subtree.
Accessibility
Reports Role::RadioButton with set_toggled mirroring selection, the
title as the accessible name, and the description as the accessible
description. When grouped, each tile emits
push_to_radio_group([sibling_ids]) plus set_position_in_set /
set_size_of_set for "N of M". Inside a RadioTileGroup the tile is not
individually focusable — focus roves on the group (WAI-ARIA radiogroup),
and the group publishes active_descendant. A standalone tile is
focusable and responds to Space / Action::Click.
let selected = ctx.signal(0_usize);
RadioTileGroup::new(selected)
.tile(RadioTile::new().icon(icon).title(tr!(single_file())).description(tr!(single_file_desc())))
.tile(RadioTile::new().icon(icon2).title(tr!(bundle())).description(tr!(bundle_desc())))
Builder methods at a glance
selection, icon, icon_boxed, title, description, body, body_boxed, trailing, trailing_slot, compact, title_style, title_color, description_style, description_color, enabled, variant, show_indicator, indicator_side, style, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub enum RadioTileIndicatorSide
Which side of the top row the radio indicator sits on. Defaults to
Trailing (top-right in LTR), matching the reference design.
#![allow(unused)] fn main() { pub enum RadioTileIndicatorSide { /* variants */ } }
Variants
Trailing— Trailing edge of the row — top-right in LTR, top-left in RTL.Leading— Leading edge of the row — top-left in LTR, top-right in RTL.
pub struct RadioTile
A single selectable-card radio option. See the module docs.
#![allow(unused)] fn main() { pub struct RadioTile { /* fields */ } }
Methods
pub fn new() -> Self
Create a tile with no selection binding. The enclosing
RadioTileGroup assigns
this tile's value (its position) and shared selection signal. Use
selection for a standalone tile.
pub fn selection(mut self, value: usize, selected: Signal<usize>) -> Self
Bind this tile to an explicit value + shared Signal<usize> for use
outside a RadioTileGroup. Inside a group this is set automatically.
pub fn icon(mut self, widget: impl Widget + 'static) -> Self
Leading icon slot (top-left of the tile). Any widget — typically an
IconWidget.
pub fn icon_boxed(mut self, widget: Box<dyn Widget>) -> Self
Leading icon slot, pre-boxed.
pub fn title(mut self, title: impl Into<LocalizedString>) -> Self
Bold title text (the tile's accessible name).
pub fn description(mut self, text: impl Into<LocalizedString>) -> Self
Muted, multi-line description (the tile's accessible description).
Ignored when a body is set.
pub fn body(mut self, widget: impl Widget + 'static) -> Self
Replace the description column with an arbitrary widget subtree. Takes
precedence over description. Note: a body's own
content is exposed to assistive technology as-is (unlike the typed
description, which is folded into the tile's accessible description).
pub fn body_boxed(mut self, widget: Box<dyn Widget>) -> Self
Custom body slot, pre-boxed.
pub fn trailing(mut self, text: impl Into<LocalizedString>) -> Self
Right-aligned trailing meta text (e.g. "20 chapters", "free-form
notes"). Tints to the accent color when the tile is selected. Most
useful with the compact vertical arrangement. Ignored when a
trailing_slot is set.
pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Arbitrary right-aligned trailing widget (badge, count, chevron, …).
Takes precedence over trailing.
pub fn compact(mut self, compact: bool) -> Self
Compact single-line arrangement: [indicator] [icon] [title] [Spacer] [trailing] with no description row — the vertical settings-list look.
RadioTileGroup::layout(TileLayout::Vertical) sets this automatically
(and moves the indicator to the leading edge).
pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self
Override the title text style (default TextStyleRole::BodyBold).
pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self
Override the title text color (default TextRole::Primary).
pub fn description_style(mut self, style: impl Into<TextStyleProp>) -> Self
Override the description text style (default TextStyleRole::Small).
pub fn description_color(mut self, color: impl Into<ColorProp>) -> Self
Override the description text color (default TextRole::Secondary).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. A disabled tile is skipped by the group's keyboard navigation and cannot be selected.
pub fn variant(mut self, variant: RadioTileVariant) -> Self
Pick the card variant (default Outlined).
pub fn show_indicator(mut self, show: bool) -> Self
Whether to render the inline radio indicator (default true). When
false, the selection cue is the card highlight alone.
pub fn indicator_side(mut self, side: RadioTileIndicatorSide) -> Self
Which side of the top row the radio indicator sits on (default Trailing).
pub fn style(mut self, style: impl RadioTileStyle) -> Self
Per-call style override — replaces the theme-wide RadioTileStyle
for just this tile.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown on hover.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip registry.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip hosting an arbitrary widget tree.
RadioTileGroup

RadioTileGroup — an N-ary group of RadioTiles with single selection.
Like SegmentedControl, the
tile count is not fixed: add any number of tiles, all sharing one
Signal<usize>. The group owns:
- Layout — an equal-size
TileLayout::Row, an adaptive wrappingTileLayout::Grid, a full-widthTileLayout::Column, or a compact fixed-heightTileLayout::Verticalsettings list. Row and Grid equalize tile size (uniform width + the tallest tile's height) via a customplace_childrenmeasuring each tile height-for-width — stacks have no cross-axis stretch, so the group does the sizing. - Keyboard — the WAI-ARIA roving radiogroup pattern: the group is a
single Tab stop; Arrow keys move selection (selection follows focus),
Home/End jump, disabled tiles are skipped.
Increment/DecrementAT actions mirror the arrows for switch access. - Accessibility —
Role::RadioGroupwithactive_descendantpointing at the selected tile; each tile isRole::RadioButtonand declares its siblings viapush_to_radio_group(for "N of M").
let selected = ctx.signal(0_usize);
RadioTileGroup::new(selected)
.label(tr!(project_format()))
.tile(RadioTile::new().icon(a).title(tr!(single_file())).description(tr!(single_file_desc())))
.tile(RadioTile::new().icon(b).title(tr!(bundle())).description(tr!(bundle_desc())))
.layout(TileLayout::Row)
Builder methods at a glance
label, tile, tiles, layout, spacing, line_spacing, row_height, enabled, style
API reference
📖 Full rustdoc API for this module
pub enum TileLayout
How a RadioTileGroup arranges its tiles.
#![allow(unused)] fn main() { pub enum TileLayout { /* variants */ } }
Variants
Row— A single horizontal row of equal-width, equal-height tiles (the tiles stretch to the tallest). The reference "two cards side-by-side" layout.Grid— A wrapping grid whose column count adapts to the available width:cols = floor((width + spacing) / (min_tile_width + spacing)), at least one. All cells share the same width and the tallest tile's height.Column— A vertical column of full-width tiles, each its natural height. Tiles keep their full card content (icon + title + description).Vertical— A vertical list of compact fixed-height full-width rows:[radio] [icon] [title] [Spacer] [trailing], no description — the settings-list look. Every row is a fixed height taken from the activeRadioTileStyle(the theme'sRadioTileRecipe::vertical_row_height, 44 dp by default; override per-group withRadioTileGroup::row_height), and the group switches each tile to the compact arrangement (leading radio) automatically.
pub struct RadioTileGroup
An N-ary, single-selection group of selectable-card radios. See the
module docs.
#![allow(unused)] fn main() { pub struct RadioTileGroup { /* fields */ } }
Methods
pub fn new(selected: Signal<usize>) -> Self
Create a group bound to the shared selection signal. Add tiles with
tile / tiles.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible name for the group (announced before individual tiles).
pub fn tile(mut self, tile: RadioTile) -> Self
Add a tile. Its value (position) and shared selection signal are
assigned automatically.
pub fn tiles(mut self, tiles: impl IntoIterator<Item = RadioTile>) -> Self
Add several tiles from an iterator.
pub fn layout(mut self, layout: TileLayout) -> Self
Choose the layout (default TileLayout::Row).
pub fn spacing(mut self, spacing: f32) -> Self
Override the gap between tiles along the main axis (and grid columns).
Defaults to 6 dp for TileLayout::Vertical, 12 dp otherwise.
pub fn line_spacing(mut self, spacing: f32) -> Self
Gap between rows in TileLayout::Grid.
pub fn row_height(mut self, height: f32) -> Self
Override the fixed row height for TileLayout::Vertical compact rows.
Takes precedence over the theme value
(RadioTileRecipe::vertical_row_height, 44 dp by default). No effect on
other layouts.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state for the whole group, statically or reactively.
pub fn style(mut self, style: impl teksilo_core::styles::RadioTileStyle) -> Self
Forward a RadioTileStyle to every tile that doesn't set its own
.style(...).
RectWidget

RectWidget — a leaf widget that paints a filled and/or stroked rounded rectangle.
RectWidget has no intrinsic content: it fills whatever space its parent
proposes (or reports 0×0 when unconstrained) and draws a fill (solid color
or gradient), an optional border (a uniform stroke positioned inside / center
/ outside, or per-side edge fills for an underline), and an optional corner
radius. It is the low-level building block for card backgrounds, focus rings,
dividers, underlined fields, and highlight overlays.
The fill accepts impl Into<PaintProp> — anything Into<ColorProp> (a raw
Color, a theme role such as SurfaceRole::Hover, or a Signal<Color>) for
a solid, plus PaintProp::Linear / Radial for a gradient. Border color
accepts impl Into<ColorProp>, so reactive interaction-driven colors require
no extra wiring.
#![allow(unused)] fn main() { use teksilo_tokens::{Color, CornerRadius}; use teksilo_widgets::primitives::RectWidget; // A pill-shaped accent badge background: let _w = RectWidget::new() .background(Color::from_rgba(0.2, 0.5, 1.0, 1.0)) .corner_radius(CornerRadius::uniform(12.0)); }
Builder methods at a glance
background, border_sides, border_position, border_color, border_width, corner_radius
API reference
📖 Full rustdoc API for this module
pub struct RectWidget
A leaf widget that paints a filled and/or stroked rounded rectangle.
See the module documentation for the full feature description.
All visual properties accept impl Into<ColorProp> (colors/roles/signals) or
impl Into<Prop<f32>> / impl Into<Prop<CornerRadius>> (static or reactive)
— so the common "fill with theme surface, border with theme border" setup is
just .background(SurfaceRole::Main).border_color(BorderRole::Default).
#![allow(unused)] fn main() { pub struct RectWidget { /* fields */ } }
Methods
pub fn new() -> Self
Create a fully transparent, zero-border rectangle with no corner radius.
pub fn background(mut self, paint: impl Into<PaintProp>) -> Self
Fill. Accepts Color, a theme role (SurfaceRole, etc.), a
Signal<Color>, or a PaintProp (e.g. a gradient).
pub fn border_sides(mut self, sides: impl Into<Prop<Option<BorderSides>>>) -> Self
Per-side border widths (e.g. BorderSides::bottom for an
underline). When set, overrides the uniform stroke; sides are
drawn as edge fills in border_color.
pub fn border_position(mut self, position: BorderPosition) -> Self
Where a uniform stroke sits relative to the rect edge
(inside / center / outside). Ignored when border_sides is set.
pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self
Border color. Accepts Color, a theme role (BorderRole, etc.),
or a Signal<Color>.
pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self
Stroke width, in logical pixels. Accepts a static value or a reactive Signal<f32>.
pub fn corner_radius(mut self, radius: impl Into<Prop<CornerRadius>>) -> Self
Corner radius for the fill and stroke. Accepts a CornerRadius (per-corner
control) or a reactive Signal<CornerRadius>.
Repeater

Repeater — non-virtualized dynamic widget list driven by a ListModel<T>.
Repeater creates one child widget per item in a ListModel<T>
using a caller-supplied factory closure, arranging them along one axis
(RepeaterLayout::Vertical by default) or as a wrapping flow
(RepeaterLayout::Wrap). It is not virtualized: every item has a live
widget at all times. That is a deliberate trade — it is what lets the
children keep real, stateful widgets (text editors, forms) mounted, which a
virtualizing ListView cannot do because it recycles
off-screen rows.
Repeater::new — reconciling (the default)
The factory takes &item and each child widget is reused across model
changes. When the model mutates, Repeater reads the
DataChange it emits and applies the minimal edit to its child set: an
insert builds one new widget, a remove reaps one, a move reorders, an
in-place update rebuilds only that item — every other child keeps its
existing widget, and with it its focus, selection, caret, scroll offset,
in-flight text edit, and undo history.
This makes Repeater a fit for a stack of editors — e.g. a document
rendered as a column of RichTextEditors,
one per scene/block:
Repeater::new(scenes, |scene| {
Box::new(RichTextEditor::editor(scene.document()))
})
Inserting, deleting, or reordering a scene costs one widget's worth of work
instead of reshaping every editor in the document, and the editor the user is
typing in keeps its caret. Because the factory has no index, position shifts
are safe by construction: reuse can never leave a widget showing content
derived from a stale position. The one requirement is that an item's
content only changes through the model (via set/replace_all), which is
always true for a ListModel.
#![allow(unused)] fn main() { use teksilo_widgets::Repeater; use teksilo_widgets::primitives::TextWidget; use teksilo_data::ListModel; use teksilo_i18n::lit; let model: ListModel<u32> = ListModel::from_vec(vec![1, 2, 3]); let _w = Repeater::new(model, |item| { Box::new(TextWidget::new(lit!(format!("item {item}")))) }) .spacing(4.0); }
Repeater::indexed — full rebuild (position-in-content)
When the content genuinely depends on position — a numbered list, "N of M",
a ranking that must renumber on reorder — use indexed.
Its factory takes (index, &item), and on any model change the whole
child subtree is torn down and rebuilt, so the index every widget shows is
always current. This is the right pick for cheap, stateless, position-derived
rows; it does not preserve per-child state across changes (that is the
reason to prefer new whenever the index isn't content).
Accessibility
Repeater imposes no accessibility semantics of its own — it is a
transparent layout wrapper, so its children surface directly into the
surrounding AT subtree and their own roles decide how they read. When the
children genuinely form a named list, menu, or toolbar, opt in with the
standard builder overrides that every widget supports — these stay
locale-reactive:
use teksilo_core::accesskit::Role;
Repeater::new(tags, factory)
.access_role(Role::List)
.access_label(tr!(tags()))
Builder methods at a glance
indexed, layout, horizontal, wrap, spacing, line_spacing
API reference
📖 Full rustdoc API for this module
pub enum RepeaterLayout
How a Repeater arranges its item widgets.
#![allow(unused)] fn main() { pub enum RepeaterLayout { /* variants */ } }
Variants
Vertical— A vertical column, top to bottom (default). Gap =Repeater::spacing.Horizontal— A horizontal row, leading to trailing (RTL-aware viaHStack). Gap =Repeater::spacing.Wrap— A horizontal flow that wraps to the next line when items exceed the available width — chip rows, badge lists.Repeater::spacingis the inter-item gap,Repeater::line_spacingthe inter-line gap.
pub struct Repeater
A non-virtualized dynamic collection that creates one child widget per item in a ListModel<T>.
See the module-level docs for the two build modes, layout options,
and accessibility guidance.
#![allow(unused)] fn main() { pub struct Repeater<T: 'static> { /* fields */ } }
Methods
pub fn new(model: ListModel<T>, factory: impl Fn(&T) -> Box<dyn Widget> + 'static) -> Self
Create a Repeater in reconciling mode (the default): item widgets are reused across model changes, so each child keeps its state (focus, caret, selection, scroll, undo history) when siblings are inserted, removed, or reordered.
The factory receives &item only — it must not depend on the item's
position, which is what makes reuse safe when items shift. This is the
mode for a stack of stateful widgets such as RichTextEditors. If the
content genuinely depends on position (a numbered list), use
Repeater::indexed instead. See the module-level docs for the
full rationale.
pub fn indexed( model: ListModel<T>, factory: impl Fn(usize, &T) -> Box<dyn Widget> + 'static, ) -> Self
Create a Repeater in full-rebuild mode: the factory receives
(index, &item) and the entire child subtree is rebuilt on any model
change, so position-derived content stays current.
Use this only when the content depends on the item's position (row
numbers, "N of M", a ranking that renumbers on reorder). It does not
preserve per-child state across changes — prefer Repeater::new
whenever the index isn't part of what each item renders.
pub fn layout(mut self, layout: RepeaterLayout) -> Self
Choose how items are arranged (default RepeaterLayout::Vertical).
pub fn horizontal(self) -> Self
Arrange items horizontally — shorthand for .layout(RepeaterLayout::Horizontal).
pub fn wrap(self) -> Self
Arrange items as a wrapping flow — shorthand for .layout(RepeaterLayout::Wrap).
pub fn spacing(mut self, spacing: f32) -> Self
Set the gap between items along the main axis (default 0.0). For
RepeaterLayout::Wrap this is the inter-item (horizontal) gap.
pub fn line_spacing(mut self, line_spacing: f32) -> Self
Set the gap between lines for RepeaterLayout::Wrap (default 0.0).
Ignored by the single-axis layouts.
ResizeStrip
A thin invisible widget that forwards a window resize gesture to the platform host when the user presses the primary button inside it. Used to build a 6-px resize frame around a borderless window on Wayland.
This is the frame complement to [crate::title_bar::DragRegion]: drag
moves the window, resize strips drag the window edges. On platforms
that don't expose Window::drag_resize_window (notably winit's macOS
backend), PlatformTitleBarHost::begin_resize returns
PlatformError::Unsupported and the strip becomes a silent no-op —
macOS handles edge resize via its own native chrome.
Builder methods at a glance
horizontal, vertical, corner
API reference
📖 Full rustdoc API for this module
pub struct ResizeStrip
A single edge of a resize frame. Construct one per side and lay them out around your content (HStack of left + content + right inside a VStack of top + middle + bottom is the conventional shape — see the title bar demo for an example).
#![allow(unused)] fn main() { pub struct ResizeStrip { /* fields */ } }
Methods
pub fn horizontal( host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, thickness: f32, ) -> Self
Build a horizontal (top / bottom) strip of the given height. The width is unconstrained — the strip claims whatever its parent container offers, so it can stretch across the full window width.
pub fn vertical(host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, thickness: f32) -> Self
Build a vertical (left / right) strip of the given width. The height is unconstrained.
pub fn corner(host: Rc<dyn PlatformTitleBarHost>, edge: ResizeEdge, size: f32) -> Self
Build a square corner cell of the given size. The corner handles a
diagonal resize gesture (e.g. TopLeft does NW/SE resize). Should
be placed on top of the edge strips at the four corners so the
framework's hit-test routes the click to the corner rather than
the adjacent edge.
RichTextEditor

Rich text editor and viewer widget.
Two construction presets share the same implementation: RichTextEditor::editor
provides a full editing surface (blinking caret, keyboard commands, clipboard,
undo/redo, Role::MultilineTextInput) and RichTextEditor::read_only is a
view-only surface (hidden caret, mutations rejected, Role::Document). Both
bind to an external TextDocument
via on_change subscriptions, so any number of editors and viewers can share
one document and observe each other's edits live.
The widget owns a per-widget RichTextEngine (typesetter), and drives its own
scroll bars independently of ScrollArea to avoid the wrap/scrollbar circular
measurement dependency. Use RichTextEditor::min_lines /
RichTextEditor::max_lines to switch from greedy sizing to intrinsic
(messenger-composer) sizing. A detachable EditorHandle lets toolbars and
palette panels issue formatting commands from closures that cannot borrow the
editor directly.
use teksilo_text::text_document::TextDocument;
let doc = TextDocument::new();
let editor = RichTextEditor::editor(doc)
.min_lines(3)
.max_lines(8)
.wrap_mode(WrapMode::Word);
Builder methods at a glance
read_only, editor, style, content_padding, content_padding_symmetric, content_padding_each, content_padding_top, content_padding_right, content_padding_bottom, content_padding_left, wrap_mode, show_highlights, annotation_spans, set_highlight_mask, typography_defaults, background, selection_color, caret_color, text_color, v_scroll_policy, h_scroll_policy, estimate_height_before_layout, window_to_clip, scroll_policy, follow_caret_in_page, typewriter, overscroll_behavior, min_lines, max_lines, follow_text_scale, font_size_scale, context_menu, default_context_menu, font_registrar, on_change, on_text_inserted, document_version, cursor_position, cursor_anchor, is_composing, cursor_position_signal, cursor_anchor_signal, has_selection, can_undo, can_redo, caret_char_format, scroll_y, scroll_x, context_target_at, selected_text, select_all, deselect, insert_text, insert_html, insert_djot, insert_block, insert_image, delete_selection, select_word, select_line, set_caret_position, focused_signal, select_range, reveal_range, set_bold, set_italic, set_underline, set_strikethrough, set_font_size, set_font_family, toggle_bold, toggle_italic, toggle_underline, toggle_strikethrough, set_superscript, set_subscript, set_vertical_alignment, get_vertical_alignment, is_superscript, is_subscript, toggle_superscript, toggle_subscript, apply_block_format, apply_text_format, set_alignment, clear_direction, set_direction, set_heading_level, insert_list, create_list, indent, outdent, remove_from_list, is_in_blockquote, selection_spans_multiple_frames, toggle_blockquote, increase_blockquote_depth, decrease_blockquote_depth, insert_table, remove_current_table, insert_row_above, insert_row_below, insert_column_before, insert_column_after, remove_current_row, remove_current_column, is_in_table, is_bold, is_italic, set_link, clear_link, link_at_caret, is_link, is_underline, is_strikethrough, get_heading_level, get_alignment, get_direction, undo, break_undo_merge, redo, begin_edit_block, end_edit_block, edit_block, set_default_language, default_language, handle, copy, cut, paste, paste_unformatted, can_paste, set_font_size_scale, get_font_size_scale, set_typography_defaults, get_typography_defaults, set_typewriter, get_typewriter, set_command_filter, command_filter, set_caret_highlight, get_caret_highlight, caret_window_rect, format_version, document_loaded_count, on_link_activated, on_image_missing, on_files_dropped, on_image_resized, on_image_activated
API reference
📖 Full rustdoc API for this module
pub enum ScrollPolicy
Scroll bar visibility policy for RichTextEditor, applied independently per axis.
#![allow(unused)] fn main() { pub enum ScrollPolicy { /* variants */ } }
Variants
Auto— Show the scroll bar only when content overflows the visible area (default).AlwaysOn— Always show the scroll bar, reserving gutter space even when content fits.AlwaysOff— Never show the scroll bar; useful when embedding the editor inside an outerScrollAreaor in headless tests.
pub enum EditSource
How a piece of text reached the document — the channel, not the author.
Deliberately framework-generic, and deliberately small. These are the routes a toolkit can actually observe: which input path the characters came down. What that means is the application's to decide, and every application will decide differently — a writing tool cares that dictation is not typing, a code editor cares that a snippet is not either, and a form cares about none of it. Teksilo says what it saw; it does not interpret.
⚠ Not evidence of who wrote anything. Text typed one character at a time was typed one character at a time, and that is the entire claim. Anything further — who, or whether a person at all — is an inference this cannot make and no consumer of it should pretend to.
#![allow(unused)] fn main() { pub enum EditSource { /* variants */ } }
Variants
Keyboard— Typed, one key at a time.Ime— The settled result of an IME composition — CJK/Kana candidate selection, a dead-key accent. Separate fromSelf::Keyboardbecause the characters that land are not the keys that were pressed.Clipboard— Pasted, as plain text or as HTML.Accessibility— Arrived through an assistive technology: AccessKit'sSetValueorReplaceSelectedText, which is how dictation and a braille display write. Never folded intoSelf::Keyboard. For some people this is typing, and a toolkit that reported it as something else — or as nothing — would be quietly erasing how they work.Programmatic— Inserted by the application itself rather than by anything the person at the keyboard did: a template, a substitution, a completion.
pub struct RichTextEditor
#![allow(unused)] fn main() { pub struct RichTextEditor { /* fields */ } }
Methods
pub fn read_only(document: TextDocument) -> Self
Construct a read-only rich text viewer bound to document. The
document can also back an editable RichTextEditor::editor in
another part of the UI — both widgets receive document events
independently via on_change subscriptions.
pub fn editor(document: TextDocument) -> Self
Construct an editable rich text editor bound to document.
Uses the full editor preset: every command accepted, caret
blinks, MultilineTextInput accessibility role, full clipboard
support. Multiple editors on the same document share live edits
via per-widget on_change subscriptions.
pub fn style(mut self, style: impl RichTextEditorStyle) -> Self
Per-call style override for the editor chrome (border, padding,
focus ring). Replaces the theme-wide
style_slots.rich_text_editor and the IntUI default
RecipeRichTextEditorStyle for just this editor.
pub fn content_padding(mut self, amount: f32) -> Self
Set a uniform padding (logical pixels) between the text content
and the editor's chrome. Replaces the style's default insets
(TextInput-style for editable, none for read-only). Use
content_padding_symmetric or
content_padding_each for
per-axis / per-edge control.
pub fn content_padding_symmetric(mut self, vertical: f32, horizontal: f32) -> Self
Set vertical and horizontal padding (logical pixels) between the text content and the editor's chrome. Replaces the style's default insets.
pub fn content_padding_each(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self
Set per-edge padding (top, right, bottom, left) between the
text content and the editor's chrome. Replaces the style's
default insets.
pub fn content_padding_top(mut self, top: f32) -> Self
Set just the top inset between the text and the chrome. Leaves
the other edges at their previously-set values, defaulting to
0.0 for any edge never touched.
pub fn content_padding_right(mut self, right: f32) -> Self
Set just the right inset between the text and the chrome.
pub fn content_padding_bottom(mut self, bottom: f32) -> Self
Set just the bottom inset between the text and the chrome.
pub fn content_padding_left(mut self, left: f32) -> Self
Set just the left inset between the text and the chrome.
pub fn wrap_mode(self, mode: WrapMode) -> Self
Set the line-wrap mode. WrapMode::Word (the default) wraps at word
boundaries; WrapMode::None allows horizontal overflow — pair with
.h_scroll_policy(ScrollPolicy::Auto) to expose a scroll bar.
pub fn show_highlights(self, show: bool) -> Self
Whether this view applies the document's syntax / search / spell
highlighting. editor defaults to true; read_only defaults to
false (a bare preview). A highlights-off view pulls a clean
snapshot (no highlights at all, even metric ones like keyword bold) and
ignores paint-only highlight events entirely, so it does zero work when
the shared document's search/spell highlights change.
pub fn annotation_spans(self, spans: Vec<TextAnnotationSpan>) -> Self
Declare the annotations (comment threads) covering ranges of this document, for the accessibility tree only.
Each span becomes a Role::Comment node, and every Role::TextRun it
covers points at it through AccessKit's details relation — the W3C
annotations pattern, and the reason a screen reader can say "has comment"
and let the user navigate in rather than reciting the thread every time the
caret crosses the span.
Painting is a separate concern: a highlight session draws the underline. A highlight carries no text and this carries no colour, so neither is derivable from the other and both are supplied independently.
pub fn set_highlight_mask(&self, mask: teksilo_text::text_document::HighlightMask)
Set which highlight sessions this view renders, at runtime.
HighlightMask::all shows every
session on the document (the default);
HighlightMask::only shows a
chosen set — which is how a per-editor find banner
keeps one pane's find highlighting out of another pane over the same document.
show_highlights(false) still overrides this to nothing.
Forces a re-pull on the next tick so the change is visible immediately.
pub fn typography_defaults(self, defaults: EditorTypographyDefaults) -> Self
Set the initial non-destructive default typography (font family / line
height / first-line indent) applied to runs and blocks that carry no
explicit override. Applied before the first layout. These are display
defaults — they never mutate the bound document (no undo entry, no
modified); use set_typography_defaults
or EditorHandle::set_typography_defaults to change them after mount.
Preferred text size is font_size_scale.
pub fn background(self, color: impl Into<ColorProp>) -> Self
Override the editor background fill. Accepts a Color, a theme role
(SurfaceRole::Content, …), or a Signal. Threaded into the active
RichTextEditorStyle's make_body, so the common case ("give the
editor a surface") needs no custom style. None uses the style's
default surface.
pub fn selection_color(self, color: impl Into<ColorProp>) -> Self
Override the selection-highlight color. Accepts a Color, theme role,
or Signal. Resolved against the active theme on every paint; None
uses the engine/theme default.
pub fn caret_color(self, color: impl Into<ColorProp>) -> Self
Override the caret / insertion-point color. Accepts a Color, theme
role, or Signal. Resolved against the active theme on every paint;
None tracks the theme's editor_caret role.
pub fn text_color(self, color: impl Into<ColorProp>) -> Self
Override the default text color. Accepts a Color, theme role, or
Signal. Resolved against the active theme on every paint; None
tracks the theme's editor_fg role (so dark / light swaps follow
automatically). A role or Signal stays reactive; a bare Color pins
it.
pub fn v_scroll_policy(mut self, policy: ScrollPolicy) -> Self
Set the vertical scroll-bar visibility policy.
pub fn h_scroll_policy(mut self, policy: ScrollPolicy) -> Self
Set the horizontal scroll-bar visibility policy.
pub fn estimate_height_before_layout(self, on: bool) -> Self
Window paint-time culling to the accumulated ancestor clip rather than this editor's own bounds.
Enable this only for an editor deliberately laid out at its full
document height inside an outer ScrollArea
(v_scroll_policy(ScrollPolicy::AlwaysOff), no max_lines) — "dubious
mode". Such an editor's own viewport spans the whole document, so the
viewport-derived render cull keeps nothing; this makes it cull to the
visible clip band instead, so a huge document only rasterizes the rows on
screen. Correct under nested ScrollAreas (the clip is the intersection of
all clipping ancestors), and positioning / hit-testing are unaffected.
A normal self-scrolling editor already culls correctly from its own scroll offset and doesn't need this — leave it off (the default). (The window is computed relative to the editor's own scroll offset as well, so enabling it on a self-scroller degrades to a correct-but-redundant cull rather than rendering the wrong rows.) Guess this editor's height from its text until something has laid it out.
content_height() is 0 until layout_full has run, and that waits for the
editor to have been through a frame on screen. The zero falls through to the
min_lines floor, so an editor that has never been shown claims the same few
lines whatever it holds.
For an editor that is on screen that is invisible — it lays out on the first frame and the floor never shows. Turn this on for one that may not be: a row of a long column, most of which is below the fold. There the page's height is the sum of its rows' claims, so the scroll extent starts wrong by an order of magnitude and settles a row at a time as the reader arrives — and anything drawing that extent draws the settling.
Off by default, deliberately. The estimate is crude by construction, and an editor that lays out immediately gains nothing from it while every consumer of its first-frame size pays for the guess — including the windowed-render path, whose culling is derived from the editor's own bounds.
Never a floor: it goes through the same clamp a real height does, so
max_lines still caps it and an over-estimate corrects downwards when the
layout lands.
pub fn window_to_clip(self, on: bool) -> Self
pub fn scroll_policy(mut self, policy: ScrollPolicy) -> Self
Set the same scroll-bar visibility policy on both axes.
pub fn follow_caret_in_page(self, follow: bool) -> Self
Whether moving the caret also scrolls any enclosing scroll area to keep the caret on screen — the standard editor "caret stays visible as you type / navigate" behaviour. On by default.
It fires only on a caret move, never on a plain wheel / scrollbar
scroll, so the reader can still scroll freely away from the caret and the
view holds until the caret next moves. This is what makes an editor that
grows to its content with its own scroll suppressed (a flowing page
inside an outer ScrollArea) track the caret at all — there the editor's
internal caret-visibility is a no-op, so the enclosing-page follow is the
only mechanism that reveals the caret. Pass false for the rare layout
where a caret change must never move the surrounding page.
pub fn typewriter(self, anchor: Option<f32>) -> Self
Typewriter scrolling: pin the caret's line at fraction of the way
down the enclosing scroll area — 0.0 at the top, 0.5 centred, 1.0
at the bottom — and let the document scroll under it. None (the
default) leaves the ordinary minimal-reveal follow in charge.
Unlike that follow, which only acts once the caret would leave the viewport, a pin re-asserts on every caret move, so the line being written holds a constant height on screen. The classic writing-app feature.
Three behaviours come with it, each of them the consensus answer among the editors that ship this well:
- The pointer stands the pin down. A click places the caret without scrolling, and that position becomes the new resting place; a drag-selection is never interrupted. The next keystroke resumes pinning. Editors that re-centre on pointer input instead have open bugs about the view fighting the mouse and about drag-selection becoming unusable.
- The rendered row is pinned, not the paragraph. Under soft wrap a long paragraph spans several visual rows; pinning the logical line would leave the caret far from the mark.
- Typing snaps, page jumps glide. Animating a pin that updates on every keystroke is what produces the "screen bouncing" complaint other implementations attract.
Requires follow_caret_in_page (on by
default). fraction is clamped to 0.0..=1.0.
Near the start of the document the pin gives way to the scroll range —
the caret rides above its line until there is room — and near the end it
would do the same, which is usually not what you want: pair this with
ScrollArea::scroll_past_end(1.0 - fraction) so the last line can still
reach the pin.
Takes a plain value, like typography_defaults;
to follow a setting live, push changes onto the handle with
EditorHandle::set_typewriter.
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Set the wheel scroll-chaining behavior at the editor's boundary
(default OverscrollBehavior::Chain). With Chain, a wheel event the
editor can no longer absorb (already at the top/bottom, or content that
fits so there is nothing to scroll) is declined so it bubbles to an
ancestor scrollable — an editor embedded in a scrolling form/page lets
the page scroll once the editor reaches its edge.
OverscrollBehavior::Contain keeps the event at the editor instead.
Mirrors the identical knob on ScrollArea / ListView / TableView /
GridView.
pub fn min_lines(mut self, n: u32) -> Self
Set a minimum height (in lines of text) for the editor's intrinsic size.
Setting either min_lines or max_lines
switches the editor from greedy sizing (consume the
proposal) to intrinsic sizing: size_that_fits returns
clamp(content_height, min_lines × line_height, max_lines × line_height)
for the dimension the parent leaves unspecified. A parent
like VStack proposes unbounded height to non-Expand
children, so the editor lands at its intrinsic height —
exactly the messenger-composer / chat-input pattern.
A parent that forces the height (e.g. FixedSize) wins
regardless. This is intentional and matches Teksilo's
general layout discipline: parents always have the final
say on the dimensions they pin.
min_lines measures the visible text area, not the outer
widget — min_lines(1) reports a height equal to one line
of text at the typesetter's default font + size, even
before the document has any content.
pub fn max_lines(mut self, n: u32) -> Self
Set a maximum height (in lines of text) for the editor's intrinsic size. Past this cap the vertical scroll bar absorbs further content growth.
See min_lines for the intrinsic-mode
switch and the parent-proposal interaction. max_lines
measures the visible text area, not the outer widget.
pub fn follow_text_scale(self, follow: bool) -> Self
Whether this editor's text grows with the global accessibility text
scale (ctx.text_scale). Defaults to true — like every other text
surface, the editor magnifies when the user raises the app-wide text
size. Pass false for an editor whose font sizes are document
content (a WYSIWYG / print-layout editor) that must stay at its true
point size regardless of the reader's UI accessibility setting.
Composed with font_size_scale:
engine.font_scale = (follow ? text_scale : 1.0) × font_size_scale.
pub fn font_size_scale(self, scale: f32) -> Self
Per-editor logical font-size multiplier (1.0 = 100 %). Applied
before shaping (same channel as accessibility text scale), so text
grows, re-wraps, and stays sharp — the knob for a "Text size"
preference. Composed as
(follow_text_scale ? ctx.text_scale : 1.0) × font_size_scale.
Clamped to [0.1, 10.0]. Use set_font_size_scale
after mount.
pub fn context_menu( mut self, factory: impl Fn( teksilo_canvas::Point, &mut teksilo_core::widget::EventContext, ) -> Option<Box<dyn teksilo_core::widget::Widget>> + 'static, ) -> Self
Replace the built-in right-click context menu with a
user-provided factory. Same shape as the framework's
closure receives the click position (widget-local) and a full
EventContext, and returns
Some(menu_widget) to mount or None to decline (falling
through to the next ancestor with a factory).
Taking this branch disables the default menu unconditionally.
The framework's
show_context_menu_for handles
the overlay lifecycle (open at pointer, dismiss on
click-outside / Escape, focus-restore on dismiss), so the
factory only needs to build the menu content.
This is an inherent method: it shadows the blanket
WidgetBuilder::context_menu
trait method so the user can chain it directly on the editor.
Internally, the factory is installed on the editor's arena
node via the same HandlerSet::context_menu plumbing.
pub fn default_context_menu(mut self, enabled: bool) -> Self
Enable (default) or disable the widget's built-in right-click
context menu (Cut / Copy / Paste / Paste Unformatted / Select
All). When disabled, right-click bubbles past the widget
unhandled and
context_target_at stays
available for applications that render their own menu.
Note: if a user factory is installed via
context_menu, that factory wins
regardless of this flag — this setter only governs the
default menu.
pub fn font_registrar(self, registrar: &dyn FontRegistrar) -> Self
Install a custom font registrar for the fallback private
engine. Only has effect when the editor is built outside a
windowed teksilo-app — once build() sees a SharedTypesetter
in app_state, the private engine is replaced with one that
shares the app's typesetter and this registrar is ignored.
pub fn on_change(self, f: impl Fn() + 'static) -> Self
Install a callback fired once per batch of genuine user content
edits (typing, paste, cut, delete) — and not on a programmatic
set_djot / set_markdown / set_html load or a document reset, and
not while an IME composition (CJK/Kana candidate preview, dead-key
accent) is still in progress — only the settled result of a commit
fires it. The callback runs on the UI thread during the editor's frame
drain, so it may touch Signals directly — e.g. flip a "dirty" flag or
kick a debounced autosave. Replaces any prior change callback on this
editor.
For a reactive change token (which also bumps on loads/format-only
changes, and on intermediate IME composition steps), observe
document_version instead.
pub fn on_text_inserted(self, f: impl Fn(EditSource, usize) + 'static) -> Self
Install a callback fired at each insertion, with the
EditSource the text came through and how many characters it was.
Additive to on_change rather than a replacement for
it, because they answer different questions. on_change fires once per
drain batch and says that the document changed — the right shape for a
dirty flag and a debounced autosave, and the wrong one for counting: a
batch can carry a typed run and a paste, and after the fact nothing can
tell them apart.
Reported where the text is, not derived afterwards. Every site below
holds the literal &str about to be inserted, so the count is what was
actually written rather than a position delta — which is a different
number the moment an insertion replaces a selection.
Fires for text arriving through:
- the keyboard, once per batched run of typed characters;
- an IME commit, once for the settled result and never for the intermediate composition states;
- a paste, of plain text or HTML;
- an assistive technology, through AccessKit's
SetValueandReplaceSelectedText.
It does not fire for a programmatic set_djot / set_markdown /
set_html load, for undo or redo, or for a format-only change: none of
those is text arriving.
Replaces any prior callback on this editor. Runs on the UI thread.
pub fn document_version(&self) -> Signal<u64>
Reactive counter that bumps on every document change (content edits,
format changes, load events). Starts at 0. Use as a change token to
invalidate external caches.
pub fn cursor_position(&self) -> usize
Current cursor position in the document, in character units. Exposed for tests and for applications that need to mirror the caret position externally (status bar, outline panel, etc.).
pub fn cursor_anchor(&self) -> usize
Current selection anchor (equal to cursor_position when there
is no selection).
pub fn is_composing(&self) -> bool
true while an IME composition (CJK/Kana candidate preview, dead-key
accent) is actively in progress — i.e. on_change
is currently suppressed for this editor. Exposed so a caller doing its
own while-typing scanning (e.g. an autocorrect feature) can gate its
own trigger logic the same way, as defense-in-depth alongside
on_change's own gate.
pub fn cursor_position_signal(&self) -> Signal<usize>
Reactive cursor position signal. Observers fire whenever the cursor moves (arrow keys, click, Home/End, …). Useful for status bars and tests.
pub fn cursor_anchor_signal(&self) -> Signal<usize>
Reactive selection anchor signal.
pub fn has_selection(&self) -> Signal<bool>
Reactive signal — true whenever the editor has a non-empty
selection. Updates synchronously after every cursor mutation.
pub fn can_undo(&self) -> Signal<bool>
Reactive undo-availability signal, suitable for toolbar button enable-state. Updated through the frame loop's debounce drain so toolbars don't flicker during rapid editing.
pub fn can_redo(&self) -> Signal<bool>
Reactive redo-availability signal.
pub fn caret_char_format(&self) -> TextFormat
Read the current character format at the widget's caret — the right source for toolbars that mirror bold/italic/underline state.
When a selection is active, the format is read from
selection_start()
rather than position().
Rationale (matches godot-rich-text's query_char_format):
position() lands at the end of the selection and may fall
on a run with different formatting (or past the last character,
on an empty virtual element) — a toolbar observing that value
would flicker or lie. selection_start() always points at the
first character of the selected range, so the reading is
stable and matches what a user would expect from "tell me the
format of what I have selected."
pub fn scroll_y(&self) -> Signal<f32>
Reactive vertical scroll offset in logical pixels. Bind to a scroll bar or observe for scroll-position persistence.
pub fn scroll_x(&self) -> Signal<f32>
Reactive horizontal scroll offset in logical pixels. Non-zero
only when wrap_mode is WrapMode::None.
pub fn context_target_at(&self, point: Point) -> Option<hit_test::ContextTarget>
Classify what is under point in the widget's local coordinates
(origin at the widget's top-left, scroll offset handled
internally by the typesetter), for applications building an
external context menu. Returns None if the point does not
land on any hit region.
pub fn selected_text(&self) -> String
Currently selected text, or an empty string if nothing is selected.
pub fn select_all(&self)
Select the entire document programmatically. Equivalent to the final step of the Ctrl+A ladder; resets the ladder state so a subsequent Ctrl+A starts fresh at level 1.
pub fn deselect(&self)
Clear any current selection.
pub fn insert_text(&self, text: &str)
Insert plain text at the widget's caret. Replaces any selection.
pub fn insert_html(&self, html: &str)
Insert a fragment parsed from HTML at the widget's caret.
Replaces any selection. Uses text-document's
TextCursor::insert_html,
which parses the HTML into a DocumentFragment and inserts it.
pub fn insert_djot(&self, djot: &str)
Insert a fragment parsed from djot at the widget's caret.
Replaces any selection. Uses text-document's
TextCursor::insert_djot,
which parses the djot into a DocumentFragment and inserts it — so
unlike insert_text, block-level source really
does produce new blocks rather than literal newlines in one paragraph.
pub fn insert_block(&self)
Split the current block at the widget's caret, as pressing Enter does.
pub fn insert_image(&self, name: &str, alt: &str, width: u32, height: u32)
Insert an inline image by logical resource name. width and
height are in logical pixels.
alt is the image's accessible description and its export representation. It is
passed straight through rather than defaulted here: the caller is the only layer
that knows what the picture shows, and an empty string chosen on its behalf would
be an accessibility decision made silently by a widget wrapper.
pub fn delete_selection(&self)
Delete the current selection. No-op when nothing is selected.
pub fn select_word(&self)
Select the word under the widget's caret.
pub fn select_line(&self)
Select the paragraph / block under the widget's caret.
pub fn set_caret_position(&self, position: usize)
Move the caret to an absolute character position. Collapses any
existing selection (passes MoveMode::MoveAnchor). Resets
CursorAffinity to Downstream — programmatic placement
can't know whether the caller wanted the upstream side of a
wrap boundary, so we default to the same placement that
existed before affinity was introduced.
pub fn focused_signal(&self) -> Signal<bool>
Reactive signal — true while this editor holds keyboard focus.
A per-editor find banner (Ctrl+F) targets whichever editor is focused, and the split
view has two of them; focused_side only names the Primary/Secondary pane, not which
editor. This is the per-editor answer, mirroring has_selection.
pub fn select_range(&self, start: usize, end: usize)
Select the character range start, end)`, **without** collapsing — unlike [`set_caret_position, which always moves both ends together.
The anchor lands at start and the caret (focus) at end, so the standard selection
highlight marks the range and a subsequent replace acts on it. Used to select a search
match. (The non-collapsing two-call shape is the same one the AccessKit
SetTextSelection handler uses.)
pub fn reveal_range( &self, ctx: &mut teksilo_core::widget::EventContext, start: usize, end: usize, ) -> bool
Scroll the character range ``start, end)` into view within the enclosing scroll area.
Reveals an arbitrary offset range — the current search match — rather than the live caret the follow-into-view path tracks, and works whether or not the editor is focused.
Returns whether it could. false means this editor has no layout to locate the
range in — never laid out, or parked dormant in a tab that is not on screen — and
nothing was requested. A caller holding several editors over one document (two split
panes; a stream row and that row's own tab) must try the next rather than take the
first as the answer: revealing through a dormant one silently does nothing, which
reads as "the viewport does not follow".
Under [`typewriter`` scrolling the range is pinned to the anchor rather than merely revealed, so a search walks matches to the same height the caret writes at instead of leaving them wherever they happened to fall. Because a search jump is a deliberate, screen-sized move, it glides.
pub fn set_bold(&self, enabled: bool)
Apply bold to the current selection (or set the typing bold
state when no selection is active). Pairs with
is_bold and toggle_bold.
pub fn set_italic(&self, enabled: bool)
Apply italic to the current selection.
pub fn set_underline(&self, enabled: bool)
Apply underline to the current selection.
pub fn set_strikethrough(&self, enabled: bool)
Apply strikethrough to the current selection.
pub fn set_font_size(&self, size: u32)
Set the font size (in points) for the current selection.
pub fn set_font_family(&self, family: impl Into<String>)
Set the font family for the current selection. family must be
a name resolvable by the shared typesetter's font registrar.
pub fn toggle_bold(&self)
Toggle bold on the current selection, reading the current state
via caret_char_format. Matches the
Ctrl+B keyboard shortcut's behaviour.
pub fn toggle_italic(&self)
Toggle italic; see toggle_bold.
pub fn toggle_underline(&self)
Toggle underline; see toggle_bold.
pub fn toggle_strikethrough(&self)
Toggle strikethrough; see toggle_bold.
pub fn set_superscript(&self, enabled: bool)
Raise the selection to superscript, or drop it back to the baseline.
pub fn set_subscript(&self, enabled: bool)
Lower the selection to subscript, or drop it back to the baseline.
pub fn set_vertical_alignment(&self, alignment: CharVerticalAlignment)
Set the selection's vertical alignment directly. Normal is the
baseline; Middle exists in the model but has no toolbar affordance.
pub fn get_vertical_alignment(&self) -> CharVerticalAlignment
The caret's vertical alignment, Normal when unset.
pub fn is_superscript(&self) -> bool
True while the caret sits in superscript text.
pub fn is_subscript(&self) -> bool
True while the caret sits in subscript text.
pub fn toggle_superscript(&self)
Flip superscript on the selection. Turning it on replaces subscript.
pub fn toggle_subscript(&self)
Flip subscript on the selection. Turning it on replaces superscript.
pub fn apply_block_format(&self, fmt: BlockFormat)
Set an arbitrary BlockFormat on the caret's current block.
The higher-level helpers set_alignment
and set_heading_level go through
this method. Exposed so apps that need less common fields
(indent, left_margin, line_height, …) don't have to
reach through TextDocument::cursor() and lose the widget's
caret continuity.
pub fn apply_text_format(&self, fmt: TextFormat)
Set an arbitrary TextFormat on the current selection.
Public counterpart of the private apply_char_format helper,
for apps that need fields beyond the dedicated
set_bold / set_italic / … setters (e.g. letter_spacing,
foreground_color).
pub fn set_alignment(&self, alignment: Alignment)
Set the paragraph alignment for the current block (or the block containing the selection anchor).
pub fn clear_direction(&self)
Unset the block's direction, handing the paragraph back to automatic detection.
Not the same as setting left-to-right. An explicit direction
pins the paragraph and overrides the bidi algorithm, so
"clearing" a direction by writing LeftToRight would force
Arabic and Hebrew prose to lay out backwards. Only an unset
direction lets the text speak for itself.
pub fn set_direction(&self, direction: TextDirection)
Set the base reading direction of the current block.
This is the paragraph direction, not a character property: it decides which edge unaligned text sits against and, more importantly, overrides the bidi algorithm's first-strong-character guess — which misreads an Arabic paragraph opening with a Latin acronym as left-to-right.
pub fn set_heading_level(&self, level: u8)
Set the heading level of the current block. 0 = plain
paragraph; 1..=6 follow the HTML <h1>..<h6> convention.
pub fn insert_list(&self, ordered: bool)
Create a list at the current selection. ordered = true uses
decimal numbering; ordered = false uses a bullet disc.
Choose a specific style with create_list.
pub fn create_list(&self, style: ListStyle)
Create a list with an explicit ListStyle. Exposed for
applications that want e.g. lowercase Roman numerals or circle
bullets.
pub fn indent(&self)
Increase the nesting depth of the caret's current list item by
one. No-op when the caret is not inside a list. Equivalent to
pressing Tab while the caret is on a list item — same behaviour,
same nest_current_list_item codepath, exposed for toolbar
buttons that do not want to synthesise key events.
pub fn outdent(&self)
Decrease the nesting depth of the caret's current list item by
one. No-op at depth 0 (use Backspace at block-start to exit
the list entirely). Toolbar counterpart of Shift+Tab.
pub fn remove_from_list(&self)
Take the caret's block out of its list entirely, leaving a plain paragraph. No-op when the caret is not inside a list.
outdent deliberately stops at depth 0 — Shift+Tab
should not silently destroy the list — so a toolbar that offers
"remove list formatting" needs this instead. Backspace at block-start
reaches the same codepath from the keyboard.
pub fn is_in_blockquote(&self) -> bool
True iff the caret currently sits inside a blockquote frame at any nesting depth. Used by the toolbar to drive the toggle button's pressed state and the context menu's label.
pub fn selection_spans_multiple_frames(&self) -> bool
True iff the current selection spans more than one frame. The "Toggle blockquote" affordance is disabled in this case because wrapping a cross-frame range has no well-defined semantics (different blocks already belong to different containers).
pub fn toggle_blockquote(&self)
Wrap the current block (or selection) in a blockquote, or unwrap the innermost enclosing blockquote if already inside one. No-op (returns silently) when the selection spans multiple frames.
pub fn increase_blockquote_depth(&self)
Equivalent to pressing Tab inside a blockquote — wraps the current block in a deeper nested quote. No-op when the caret is not in a quote.
pub fn decrease_blockquote_depth(&self)
Equivalent to pressing Shift+Tab inside a blockquote — pops one nesting level. At depth 1 unwraps the block to a plain paragraph. No-op when the caret is not in a quote.
pub fn insert_table(&self, rows: usize, columns: usize)
Insert a fresh rows × columns table at the caret. Any
existing selection is replaced.
pub fn remove_current_table(&self)
Remove the table containing the caret (if any). No-op when the caret is not inside a table.
pub fn insert_row_above(&self)
Insert a row above the caret's current table row. No-op when outside a table.
pub fn insert_row_below(&self)
Insert a row below the caret's current table row.
pub fn insert_column_before(&self)
Insert a column before the caret's current table column.
pub fn insert_column_after(&self)
Insert a column after the caret's current table column.
pub fn remove_current_row(&self)
Remove the caret's current table row.
pub fn remove_current_column(&self)
Remove the caret's current table column.
pub fn is_in_table(&self) -> bool
Whether the caret is currently inside a table cell.
pub fn is_bold(&self) -> bool
Whether the current selection / typing position is bold.
pub fn is_italic(&self) -> bool
Whether italic.
pub fn set_link(&self, href: &str)
Point the selection at href.
Merges, so formatting already on the range is kept. A collapsed
selection formats nothing (as everywhere else), so a caller linking
existing text should select it first — see
link_at_caret for the range of a link already
there.
pub fn clear_link(&self)
Take the link off the selection, leaving its text.
pub fn link_at_caret(&self) -> Option<LinkExtent>
The link the caret is in, and how far it reaches.
Coalesced across the runs an inner mark splits a link into, so the
range covers the whole link rather than the piece under the caret.
None when the caret is not on a link.
pub fn is_link(&self) -> bool
Whether the caret / selection sits on a link.
pub fn is_underline(&self) -> bool
Whether underline.
pub fn is_strikethrough(&self) -> bool
Whether strikethrough.
pub fn get_heading_level(&self) -> u8
Current heading level (0 = plain paragraph). Reads the caret's current block format.
pub fn get_alignment(&self) -> Alignment
Current block alignment.
pub fn get_direction(&self) -> Option<TextDirection>
The block's explicitly-set reading direction, if it has one.
None means the bidi algorithm decides from the text.
pub fn undo(&self)
Undo the most recent edit. Mirrors Ctrl+Z. No-op when the undo stack is empty.
pub fn break_undo_merge(&self)
Close the current undo entry, so the next edit starts a new one.
Typing coalesces into word-sized undo steps by looking only at the shape of two edits — adjacent, moments apart. It cannot see that the user did something else in between, somewhere else in the application, that they would remember as a dividing line. A host that knows one was crossed says so here, and the burst before it stops merging with the burst after.
pub fn redo(&self)
Redo the most recently undone edit. Mirrors Ctrl+Y / Ctrl+Shift+Z. No-op when the redo stack is empty.
pub fn begin_edit_block(&self)
Begin grouping subsequent edits into a single undo entry.
Must be paired with end_edit_block. Prefer
edit_block, which pairs them for you.
pub fn end_edit_block(&self)
Close the group opened by begin_edit_block.
pub fn edit_block<R>(&self, edits: impl FnOnce() -> R) -> R
Run edits as one undo entry.
The scoped form of begin_edit_block — the
block is closed even if edits returns early, which hand-pairing gets
wrong eventually.
pub fn set_default_language(&self, language: &str)
Set the document-wide default language (ISO 639-1 code, e.g. "en", "fr", "de"). Blocks that don't set their own language inherit it for hyphenation. Forces a full re-layout so the change takes effect on the next frame. No-op-safe if the document rejects the update.
pub fn default_language(&self) -> String
The document-wide default language (ISO 639-1 code). Defaults to
"en" when never set.
pub fn handle(&self) -> EditorHandle
Cheap clone-able handle for external toolbars / palettes — see
EditorHandle. The handle shares the editor's internal
state (same Rc<RefCell<…>>), so mutations through the handle
are immediately observable through the editor's reactive
signals (and vice versa).
Use this when the caller needs to invoke editor commands from
on_activate_fn / ctx.effect closures that outlive the
borrow of &editor: RichTextEditor itself is move-only
(the optional context-menu factory holds a Box<dyn Fn>,
which prevents Clone).
pub fn copy(&self, ctx: &teksilo_core::widget::EventContext)
Copy the current selection to the system clipboard (plain + HTML payloads). No-op when there is no selection.
All clipboard methods take &EventContext because they only
need read access — the clipboard handle is looked up via
ctx.app_state::<ClipboardHandle>(). A call site that holds
&mut EventContext can pass &ctx directly; Rust reborrows
automatically.
pub fn cut(&self, ctx: &teksilo_core::widget::EventContext)
Cut the current selection: copy first, then remove.
pub fn paste(&self, ctx: &teksilo_core::widget::EventContext)
Paste from the system clipboard. Prefers an in-process fragment
over HTML over plain text — see
rich_text/clipboard.rs.
pub fn paste_unformatted(&self, ctx: &teksilo_core::widget::EventContext)
Paste plain text only, stripping any rich payload.
pub fn can_paste(&self, ctx: &teksilo_core::widget::EventContext) -> bool
Whether a paste would insert anything — true iff the system
clipboard carries text or an HTML payload (the shapes
paste can consume; an HTML-only clipboard pastes
fine, so probing plain text alone would under-report).
Clipboard contents are not reactively observable, so this is a
point-in-time query rather than a Signal: pass the active
EventContext. It probes
the clipboard (an X11 HTML probe can round-trip to the selection
owner), so a menu / toolbar builder should re-query when the menu
opens, not per frame. Returns false when no clipboard backend
is installed (headless or feature-off builds) — the same
"silently no-op" degradation the paste path itself uses.
pub fn set_font_size_scale(&self, scale: f32)
Set the per-editor logical font-size multiplier (1.0 = 100 %).
Composed with accessibility text scale at paint; forces relayout.
See font_size_scale.
pub fn get_font_size_scale(&self) -> f32
Current per-editor font-size scale (1.0 = 100 %).
pub fn set_typography_defaults(&self, defaults: EditorTypographyDefaults)
Set the non-destructive default typography at runtime. Re-lays out and schedules a repaint. Never mutates the document.
pub fn get_typography_defaults(&self) -> EditorTypographyDefaults
Current default typography (see typography_defaults).
pub fn set_typewriter(&self, anchor: Option<f32>)
Set the typewriter-scrolling anchor at runtime — see
typewriter. None turns pinning off.
Takes effect on the next caret move rather than scrolling immediately: a pin is a follow rule, and re-anchoring the page the instant a setting changes would jump the view under a reader who is not even typing.
pub fn get_typewriter(&self) -> Option<f32>
Current typewriter anchor (see typewriter).
pub fn set_command_filter(&self, filter: policy::CommandFilter)
Narrow (or restore) what the keyboard may do on this mounted editor.
The other three policy dimensions — caret, accessibility role, clipboard
surface — describe what kind of surface this is and are fixed at
construction; only the command filter is a mode the host can change
while the writer is looking at it. Swapping in
CommandFilter::ForwardOnly gives a forward-only drafting mode;
CommandFilter::All restores ordinary editing.
Every gate reads the filter live — the keyboard dispatch, the default context menu, and drag-and-drop — so this takes effect on the next event without rebuilding the widget.
pub fn command_filter(&self) -> policy::CommandFilter
The filter currently in force (see
set_command_filter).
pub fn set_caret_highlight(&self, highlight: Option<caret_highlight::CaretHighlight>)
Draw an ambient band behind the sentence — or paragraph — the caret is in.
None (the default) draws nothing and registers no session on the document. The band
shows only while this editor has focus, so two panes over one document never band
twice, and it disappears when focus leaves the editor entirely.
The band is registered below every other highlight layer, so a find match or a spell
squiggle always paints over it. Give it a paint-only format — a background colour —
or it will force a reshape on every caret move.
pub fn get_caret_highlight(&self) -> Option<caret_highlight::CaretHighlight>
What this editor's caret band is currently configured to draw.
pub fn caret_window_rect(&self) -> Option<teksilo_canvas::Rect>
The caret's rectangle in absolute window (tree) coordinates, or
None when the editor is unfocused or has not been laid out yet.
The same rect the OS-IME reporting and the caret follow use, exposed for hosts that need to position something against the caret (and for tests that need to assert where a pin actually put it).
pub fn format_version(&self) -> Signal<u64>
Signal that bumps on every format-only document event (bold /
italic / heading / alignment / list style changes …).
Distinct from document_version,
which also bumps on content changes. Useful for toolbar
observers that want to refresh button state on format changes
without flickering during plain typing.
pub fn document_loaded_count(&self) -> Signal<u64>
Signal that bumps once per document-loaded event (fires when
an async set_html / set_markdown import completes). Starts
at 0; observers see a new value each time a long import
finishes.
pub fn on_link_activated( self, handler: impl Fn(&str, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Install a callback fired when the user Primary-clicks a link
(an element with an anchor href). The callback receives the
href string and the active EventContext.
The callback replaces any prior link-click callback on this builder chain. To stop observing, reconstruct the editor without the setter.
pub fn on_image_missing( self, resolve: impl Fn(&str) -> Option<(String, Vec<u8>)> + 'static, ) -> Self
Supply an image's bytes on demand, when the document has no resource under that name.
An inline image references its pixels by name, and those pixels live on the document. So a name that arrives without them — which is exactly what pasting an image into a second editor is, since the interchange format carries the reference and not the bytes — lays out at its full size and paints nothing.
Rather than make every host re-scan its document after every edit for names that have appeared, the editor asks for what it is missing, once, at the moment it needs it. The bytes are written onto the document, so the answer is permanent and every later reader (a save, an export, a second view of the same document) sees them too.
One hook serves paste, drag-and-drop, and an undo that re-inserts a deleted image, without any of them knowing it exists.
pub fn on_files_dropped( self, handler: impl Fn(&[std::path::PathBuf], &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Install a callback fired when files are dropped on the editor.
The editor places the caret at the drop point and then hands the paths over: what a dropped file means — a picture to embed, a link to write, a document to include — is the host's policy, and a text editor that guessed would be wrong for every host but one.
Without this, file drops are declined, and the drag bubbles to whatever ancestor claims it.
pub fn on_image_resized( self, handler: impl Fn(&ImageResize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Install a callback fired when the reader finishes dragging one of a selected image's corner grips.
The widget does not resize the picture itself. It cannot: an image's
display size lives in the host's own document format (an attribute, a
style, a column of a table), and only the host knows how to write it
there so it survives a save. So the drag reports a size and the host
decides what that means — the same division of labour as
on_image_activated.
Fired once, on release. During the drag the widget shows an outline at the proposed size, which costs no relayout and keeps one gesture to one entry on the host's undo stack.
pub fn on_image_activated( self, handler: impl Fn(&ImageActivation, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Install a callback fired when the user Primary-clicks an inline
image. The callback receives the activation (see
ImageActivation) and the active EventContext.
pub struct EditorHandle
A clone-able, 'static handle to a RichTextEditor's shared
state.
Use this when a toolbar, palette, command panel, or other external
widget needs to invoke editor commands from on_activate_fn /
ctx.effect closures that outlive the borrow of &editor.
RichTextEditor itself is move-only (the optional
custom_context_menu factory holds a Box<dyn Fn>, which prevents
Clone), so a closure cannot just capture editor.clone().
Obtain a handle via RichTextEditor::handle() and clone it into
each closure that needs to issue commands.
EditorHandle mirrors the toolbar-relevant subset of the editor's
public API:
- Inline character formatting —
set_bold/toggle_bold/is_boldand the italic / underline / strikethrough variants. - Block-level formatting —
set_alignment,set_heading_level,apply_block_format,insert_list,indent/outdent. - Tables —
insert_tableand the per-row / per-column / remove operations, plusis_in_tablefor contextual UI enable state. - History —
undo/redo. - Clipboard —
copy/cut/paste/paste_unformatted, pluscan_pastefor Paste enable-state — so a context-menu factory (which can only capture a handle, never the editor that owns it) can rebuild Cut / Copy / Paste / Paste-Unformatted. - Selection —
select_all/delete_selection. - Reactive signal accessors —
format_version,cursor_position_signal,cursor_anchor_signal,has_selection,can_undo/can_redo— so callers that hold only anEditorHandlecan derive bound signals without keeping a separateRichTextEditorreference.
Cloning is cheap (an Rc clone). All clones share the same
underlying state — mutations through any clone, through other
clones, or through the originating RichTextEditor are all
immediately observable through the same signals.
#![allow(unused)] fn main() { pub struct EditorHandle { /* fields */ } }
Methods
pub fn to_djot(&self) -> String
This editor's content as Djot.
The counterpart to insert_djot: a toolbar or command that can
write into an editor it did not build should be able to read it back the same way.
Without this the only route to the text is the host's own document bookkeeping,
which knows about the editors it mounted and not about the ones a list or a card
grid created — so a command ends up working on some surfaces and silently doing
nothing on others.
Empty string on a serialisation error, matching TextDocument::to_djot's own
callers: a command reading an editor has no better answer than "nothing there", and
propagating a Result here would push that decision onto every call site.
pub fn to_plain_text(&self) -> String
This editor's content as the addressable plain text — the view whose character offsets are the document's own.
The counterpart to to_djot for a caller that has an
offset (a caret, a selection, a click) and needs to know what is there.
An inline image appears as its U+FFFC, so offsets into this string are
offsets into the document, character for character — which the .txt
export's view deliberately is not.
Empty string on error, for the same reason to_djot returns one.
pub fn is_empty(&self) -> bool
Whether this editor holds no text at all.
character_count() == 0, so a document of one empty paragraph is empty but one
holding only spaces is not — the distinction a caller usually wants is
to_djot().trim().is_empty(), and this is the cheap O(1) pre-check.
pub fn focused_signal(&self) -> Signal<bool>
Reactive signal — true while this editor holds keyboard focus.
See RichTextEditor::focused_signal.
pub fn select_range(&self, start: usize, end: usize)
Select the character range [start, end) without collapsing (anchor at
start, caret at end). See RichTextEditor::select_range.
pub fn replace_range(&self, start: usize, end: usize, text: &str)
Replace the character range ``start, end)withtext`, leaving the caret
after the inserted text.
The counterpart to [`select_range`` for callers that must rewrite a span rather than merely reveal it — a spell-check correction picked from a context menu, an autocorrect, a replace-this-occurrence action. It goes through the widget's internal cursor, so the edit behaves exactly like typed text: it lands on the editor's undo stack as one entry (the replacement is a single insert-over-selection), fires the document's change notifications, and leaves the caret where the user would expect it.
Offsets are character positions, the same space
cursor_position and select_range use. The
inserted text inherits the character format at start, so correcting a
word inside italic prose stays italic.
Reaching through TextDocument::cursor
instead would mutate the document behind the widget's back, leaving the
caret decoupled from the edit — use this.
pub fn replace_range_from(&self, start: usize, end: usize, text: &str, source: EditSource)
As replace_range, saying which channel the text
came through for on_text_inserted.
replace_range itself reports EditSource::Programmatic, which is
what a handle-driven edit is by default: a toolbar, a menu command, a
substitution the application made. An application that knows better
should say so here rather than let the default stand. The distinction
that matters most is an edit which merely puts back what the person
typed — undoing an autocorrect, say. Those characters were typed, they
are being typed again, and reporting them as the application's own work
would credit the application with the writer's words.
One call rather than an insert plus a separate report, so the two cannot drift apart at a call site that later grows a second early return.
pub fn insert_text(&self, text: &str)
Insert plain text at the caret, replacing any selection. The
EditorHandle counterpart of
RichTextEditor::insert_text, for callers
that hold only a handle — a toolbar button or a global menu command.
pub fn add_image_resource(&self, name: &str, mime_type: &str, bytes: &[u8]) -> bool
Register an image's bytes on this editor's document, under name.
An inline image stores only a name; the paint pass resolves it to pixels through the document's resource table. So an image inserted without this lays out and stays blank — and the name is also what a reload resolves against, which is why a host restoring a document has to register its images before the first paint rather than at insertion time only.
On the handle rather than only on the widget because commands operate on whichever editor has focus, including ones a list or card grid built that the host never mounted itself.
pub fn image_resource_size(&self, name: &str) -> Option<(u32, u32)>
The natural pixel size of a registered image, decoded from its bytes.
What the file actually is, not what the document asks it to be shown at — so a host offering "reset to the original size" restores the picture's own dimensions rather than a number remembered from when it was inserted, which is wrong the moment the file behind the name is replaced.
Decodes on call. That is deliberate: this answers an explicit, rare request, and caching it would mean holding a second copy of every image in the document for a question almost nobody asks.
pub fn has_image_resource(&self, name: &str) -> bool
Whether this editor's document already has an image under name.
Registering the same name twice appends a second resource row, so a host re-registering on every paint would grow the document without bound.
pub fn insert_djot(&self, djot: &str)
Insert a fragment parsed from djot at the caret, replacing any selection.
Unlike insert_text, which drops its bytes into the
current block verbatim (a \n becomes literal content, not a new
paragraph), this parses block-level djot into a DocumentFragment, so
inserting a standalone paragraph really does create one.
pub fn insert_block(&self)
Split the current block at the caret, as pressing Enter does.
pub fn insert_paragraph(&self, text: &str) -> bool
Insert text as a paragraph of its own at the caret: split here, fill
the new block, split again, so whatever followed the caret continues in a
third block.
Deliberately one call rather than three. Composing
insert_block + insert_text + insert_block from outside re-enters the
widget three times, and an application that rebuilds its editor in
response to the first change notification is left driving a handle that
no longer points at the mounted widget — the split lands and the text
silently does not. Doing the whole edit under a single borrow, with one
signal sync at the end, makes it atomic from the caller's side.
Returns false if any step failed, leaving the document as far as it
got. Steps are not attempted after a failure: filling and re-splitting
on top of a split that did not happen produces a mangled paragraph rather
than a partial one, and the caller has no way to tell.
pub fn selection(&self) -> (usize, usize)
The live selection as (anchor, position), unordered — anchor is where the
selection started, position is where the caret is, so a backwards drag
reports anchor > position. Equal values mean no selection.
Both ends are read under a single borrow, so the pair cannot tear. That is
the reason to prefer this over pairing cursor_position
with cursor_anchor_signal: the former is a live
read of the cursor while the latter is a mirror refreshed on sync, so combining
them mixes two different moments in time and can invent — or miss — a selection
if the mirror lags. A caller deciding "is there a selection, and over what"
wants one consistent answer.
pub fn selected_text(&self) -> String
The selected text, or an empty string when nothing is selected.
O(selection), not O(document). Pairs with selection
for a caller that needs the range and what is in it — a link dialog
pre-filling its display name from what the writer highlighted, say.
pub fn range_rect(&self, start: usize, end: usize) -> Option<Rect>
The window-space rectangle enclosing the character range ``start, end)`.
The inverse of [`offset_at_point``: that maps a point to an offset, this maps offsets back to a point. It is what a decoration drawn outside the editor — a margin annotation, a connector leader, a bracket spanning a paragraph — needs in order to line itself up with the text it refers to.
Coordinates match what the arena stores (viewport_origin + engine-local −
scroll), so the result can be compared with any other widget's bounds
directly, and it tracks scrolling for free.
None before the first full layout. Focus is not required — a margin
annotation must stay aligned whether or not the writer is typing.
pub fn offset_rect(&self, offset: usize) -> Option<Rect>
The window-space caret rectangle at one offset — a zero-width
range_rect, and the anchor point for a marker drawn at
one end of a span (the triangle at a comment's tail).
pub fn range_content_rect(&self, start: usize, end: usize) -> Option<Rect>
The content-space rectangle enclosing ``start, end)` — y = 0 at the top of the laid-out text, unaffected by scrolling and by where the editor sits in the window.
The scroll-free counterpart to [range_rect``, and the one to reach for when the question is *what proportion of the document is this* rather than *where is this on screen*. Divided by content_height` it gives a fraction an overview
strip can draw against, for offsets the writer has long scrolled past —
which window space cannot express at all, since it reports those relative to
a viewport they are nowhere near.
None before the first full layout. Focus is not required.
pub fn offset_content_rect(&self, offset: usize) -> Option<Rect>
The content-space caret rectangle at one offset — a zero-width
range_content_rect.
pub fn document_version(&self) -> Signal<u64>
Reactive counter that bumps on every document change — the handle mirror of
RichTextEditor::document_version.
The change token a decoration drawn outside the editor binds, so it re-derives when the text moves under it. Without it such a widget has only the scroll metrics to go on, and those move on a reflow but not on an edit that leaves the height alone — which is most edits, and exactly the ones that shift the offsets a mark is anchored to.
pub fn content_height(&self) -> Option<f32>
Height of the laid-out text, in the same space
range_content_rect reports.
The denominator that turns a content rect into a fraction of the document.
None before the first full layout — the same gate the rect queries use, so
a caller that has one has the other and the division is never against a
stale height.
This is the text's height, not the widget's: an editor laid out taller than its content (a short scene in a tall pane) reports the text.
pub fn offset_at_point(&self, window_point: Point) -> Option<usize>
Hit-test a point — in window coordinates, as a
context_menu factory receives it — to a
document character offset. None when the point resolves to no text
(past the last glyph on an empty line, outside the body, etc.).
Lets a custom context-menu factory resolve "the word under the pointer" from the right-click position, since a bare right-click does not move the caret on its own.
pub fn reposition_caret_for_context_menu(&self, window_point: Point)
Reposition the caret to a right-click point (window coordinates)
unless the click lands inside the current selection (then the selection
is preserved). Call this at the top of a custom
context_menu factory so the menu's Paste
— and any caret-relative action — operates where the user clicked, exactly
as the built-in menu and the single-line field do.
pub fn reveal_range( &self, ctx: &mut teksilo_core::widget::EventContext, start: usize, end: usize, ) -> bool
Scroll the character range [start, end) into view, reporting whether this editor
could — it has a layout to locate the range in, and is on screen rather than parked
dormant. See RichTextEditor::reveal_range.
When it answers false because there is no layout yet, the coarser
reveal_widget is the way to get one.
pub fn reveal_widget(&self, ctx: &mut teksilo_core::widget::EventContext) -> bool
Scroll the editor itself into view — the coarse fallback for the one case
reveal_range cannot serve at all. Reports whether this
editor could: it has been built, so the arena knows a widget to scroll to, and
it is on screen rather than parked dormant.
A row of a stream that has never been painted has no full layout, so there is
no rect to locate an offset in and reveal_range answers false — for ever,
because the row only gets a layout when it is painted and it is only painted
when it comes on screen. That is a deadlock a range reveal has no way out of:
a match found in row 31 of a Book leaves the page exactly where it was, with
the counter cheerfully reading 1 of 40.
Revealing by widget breaks it, because the arena knows where row 31 is laid
out whether or not its text has been shaped. The row comes on screen, the next
paint gives it a layout, and a later reveal_range can then put the match
itself where the caller wants it. Coarser on purpose: this reveals the row,
not the offset inside it.
pub fn focus(&self, ctx: &mut teksilo_core::widget::EventContext)
Move keyboard focus onto the editor. Lets a control built above the editor — a find banner returning focus to the prose on Escape — put the caret back where the user expects. A no-op until the editor has built at least once (its wrapper id is stashed then).
pub fn caret_char_format(&self) -> TextFormat
Read the current character format at the caret. When a selection
is active, reads from selection_start() rather than
position() so toolbar bistate stays stable across selection
extension (same rule as
RichTextEditor::caret_char_format).
pub fn set_bold(&self, enabled: bool)
Apply bold to the current selection.
pub fn set_italic(&self, enabled: bool)
Apply italic to the current selection.
pub fn set_underline(&self, enabled: bool)
Apply underline to the current selection.
pub fn set_strikethrough(&self, enabled: bool)
Apply strikethrough to the current selection.
pub fn set_font_family(&self, family: impl Into<String>)
Set the font family for the current selection (a character-format
change applied over the selected range). Like the other char-format
setters (set_bold, …), this is a no-op when there is no
selection — the document model has no typing/pending format, so a
bare caret has no range to format. family must be a name resolvable
by the shared typesetter's font registrar — e.g. a value chosen from
a FontPicker.
pub fn set_font_size(&self, size: u32)
Set the font size (in points) for the current selection.
pub fn set_typography_defaults(&self, defaults: EditorTypographyDefaults)
Set the non-destructive default typography (font family / line height /
first-line indent) filled onto runs and blocks with no explicit
override. Unlike set_font_family /
set_font_size — which mutate the selected text —
this is a display-time default: it never touches the document, undo
stack, or modified flag. Schedules a relayout + repaint.
pub fn get_typography_defaults(&self) -> EditorTypographyDefaults
Current default typography.
pub fn set_font_size_scale(&self, scale: f32)
Set the per-editor logical font-size multiplier. See
RichTextEditor::set_font_size_scale.
pub fn get_font_size_scale(&self) -> f32
Current per-editor font-size scale (1.0 = 100 %).
pub fn set_typewriter(&self, anchor: Option<f32>)
Set the typewriter-scrolling anchor — the EditorHandle counterpart of
RichTextEditor::set_typewriter. None turns pinning off.
This is the door a host uses to keep the pin following a live setting,
the same way set_typography_defaults
keeps typography following one.
pub fn get_typewriter(&self) -> Option<f32>
Current typewriter anchor.
pub fn set_command_filter(&self, filter: policy::CommandFilter)
Narrow (or restore) what the keyboard may do — the EditorHandle
counterpart of RichTextEditor::set_command_filter, for hosts that
drive a drafting mode from a settings or session effect after the editor
is mounted.
pub fn command_filter(&self) -> policy::CommandFilter
The filter currently in force on this editor.
pub fn set_caret_highlight(&self, highlight: Option<caret_highlight::CaretHighlight>)
Draw an ambient band behind the caret's sentence or paragraph — the EditorHandle
counterpart of RichTextEditor::set_caret_highlight, for hosts that re-push it from a
settings or theme effect after the editor is mounted.
pub fn get_caret_highlight(&self) -> Option<caret_highlight::CaretHighlight>
What this editor's caret band is currently configured to draw.
pub fn caret_window_rect(&self) -> Option<teksilo_canvas::Rect>
The caret's rectangle in absolute window (tree) coordinates — the
EditorHandle counterpart of RichTextEditor::caret_window_rect.
None when unfocused or not yet laid out.
pub fn apply_text_format(&self, fmt: TextFormat)
Apply an arbitrary TextFormat (escape hatch for fields not
covered by the dedicated setters: letter_spacing,
foreground_color, …).
pub fn toggle_bold(&self)
Toggle bold on the current selection.
pub fn toggle_italic(&self)
Toggle italic on the current selection.
pub fn toggle_underline(&self)
Toggle underline on the current selection.
pub fn toggle_strikethrough(&self)
Toggle strikethrough on the current selection.
pub fn is_bold(&self) -> bool
Whether the selection / typing position is bold.
pub fn is_italic(&self) -> bool
Whether italic.
pub fn set_link(&self, href: &str)
Point the selection at href.
Merges, so formatting already on the range is kept. A collapsed
selection formats nothing (as everywhere else), so a caller linking
existing text should select it first — see
link_at_caret for the range of a link already
there.
pub fn clear_link(&self)
Take the link off the selection, leaving its text.
pub fn link_at_caret(&self) -> Option<LinkExtent>
The link the caret is in, and how far it reaches.
Coalesced across the runs an inner mark splits a link into, so the
range covers the whole link rather than the piece under the caret.
None when the caret is not on a link.
pub fn is_link(&self) -> bool
Whether the caret / selection sits on a link.
pub fn is_underline(&self) -> bool
Whether underline.
pub fn is_strikethrough(&self) -> bool
Whether strikethrough.
pub fn set_superscript(&self, enabled: bool)
Raise the selection to superscript, or return it to the baseline.
pub fn set_subscript(&self, enabled: bool)
Lower the selection to subscript, or return it to the baseline.
pub fn set_vertical_alignment(&self, alignment: CharVerticalAlignment)
Set the selection's vertical alignment directly.
pub fn get_vertical_alignment(&self) -> CharVerticalAlignment
The caret's vertical alignment, Normal when unset.
pub fn is_superscript(&self) -> bool
True while the caret sits in superscript text.
pub fn is_subscript(&self) -> bool
True while the caret sits in subscript text.
pub fn toggle_superscript(&self)
Flip superscript on the selection. Turning it on replaces subscript.
pub fn toggle_subscript(&self)
Flip subscript on the selection. Turning it on replaces superscript.
pub fn apply_block_format(&self, fmt: BlockFormat)
Apply an arbitrary BlockFormat to the caret's block.
pub fn set_alignment(&self, alignment: Alignment)
Set paragraph alignment for the caret's block.
pub fn clear_direction(&self)
Unset the block's direction, handing the paragraph back to automatic detection.
Not the same as setting left-to-right. An explicit direction
pins the paragraph and overrides the bidi algorithm, so
"clearing" a direction by writing LeftToRight would force
Arabic and Hebrew prose to lay out backwards. Only an unset
direction lets the text speak for itself.
pub fn set_direction(&self, direction: TextDirection)
Set the base reading direction of the caret's block. See
RichTextEditor::set_direction.
pub fn set_heading_level(&self, level: u8)
Set heading level for the caret's block. 0 = plain paragraph,
1..=6 follow the HTML <h1>..<h6> convention.
pub fn get_alignment(&self) -> Alignment
Current block alignment.
pub fn get_direction(&self) -> Option<TextDirection>
The block's explicitly-set reading direction, if it has one.
None means the writer never chose — the bidi algorithm decides
from the text. That is a genuinely different state from an
explicit left-to-right, so it is reported rather than defaulted:
a toggle needs to show "auto" as its own setting.
pub fn get_heading_level(&self) -> u8
Current heading level (0 = plain paragraph).
pub fn insert_list(&self, ordered: bool)
Wrap the caret's block in a list. ordered = true uses decimal
numbering, false uses bullet discs.
pub fn create_list(&self, style: ListStyle)
Wrap the caret's block in a list with an explicit
ListStyle.
pub fn indent(&self)
Indent the caret's current list item by one nesting level. No-op when the caret is not inside a list. Equivalent to Tab.
pub fn outdent(&self)
Outdent the caret's current list item by one nesting level. No-op at depth 0. Equivalent to Shift+Tab.
pub fn remove_from_list(&self)
Take the caret's block out of its list entirely, leaving a plain paragraph. No-op when the caret is not inside a list.
See RichTextEditor::remove_from_list for why this is separate from
outdent, which stops at depth 0 by design.
pub fn is_in_blockquote(&self) -> bool
True iff the caret currently sits inside a blockquote frame at any nesting depth.
pub fn selection_spans_multiple_frames(&self) -> bool
True iff the selection spans more than one frame — the "Toggle blockquote" affordance should be disabled in this case.
pub fn toggle_blockquote(&self)
Wrap the current block/selection in a blockquote, or unwrap the innermost enclosing blockquote if already inside one. Toolbar counterpart for a Ctrl+Shift+Q-style toggle.
pub fn increase_blockquote_depth(&self)
Wrap the current block in a deeper nested quote. Equivalent to Tab inside a blockquote.
pub fn decrease_blockquote_depth(&self)
Pop the caret out of one blockquote nesting level. Equivalent to Shift+Tab inside a blockquote.
pub fn insert_table(&self, rows: usize, columns: usize)
Insert a fresh rows × columns table at the caret.
pub fn remove_current_table(&self)
Remove the table containing the caret. No-op outside a table.
pub fn insert_row_above(&self)
Insert a row above the caret's current table row.
pub fn insert_row_below(&self)
Insert a row below the caret's current table row.
pub fn insert_column_before(&self)
Insert a column before the caret's current table column.
pub fn insert_column_after(&self)
Insert a column after the caret's current table column.
pub fn remove_current_row(&self)
Remove the caret's current table row.
pub fn remove_current_column(&self)
Remove the caret's current table column.
pub fn is_in_table(&self) -> bool
Whether the caret is currently inside a table cell.
pub fn undo(&self)
Undo the most recent edit. No-op when the undo stack is empty.
pub fn break_undo_merge(&self)
Close the current undo entry, so the next edit starts a new one.
Typing coalesces into word-sized undo steps by looking only at the shape of two edits — adjacent, moments apart. It cannot see that the user did something else in between, somewhere else in the application, that they would remember as a dividing line. A host that knows one was crossed says so here, and the burst before it stops merging with the burst after.
pub fn redo(&self)
Redo the most recently undone edit. No-op when the redo stack is empty.
pub fn begin_edit_block(&self)
Begin grouping subsequent edits into a single undo entry. Pair with
end_edit_block, or prefer the scoped
edit_block.
pub fn end_edit_block(&self)
Close the group opened by begin_edit_block.
pub fn edit_block<R>(&self, edits: impl FnOnce() -> R) -> R
Run edits as one undo entry — the pairing-safe form.
pub fn copy(&self, ctx: &teksilo_core::widget::EventContext)
Copy the current selection to the system clipboard (plain + HTML
payloads). No-op when there is no selection. See
RichTextEditor::copy.
pub fn cut(&self, ctx: &teksilo_core::widget::EventContext)
Cut the current selection: copy first, then remove. See
RichTextEditor::cut.
pub fn paste(&self, ctx: &teksilo_core::widget::EventContext)
Paste from the system clipboard. Prefers an in-process fragment
over HTML over plain text. See RichTextEditor::paste.
pub fn paste_unformatted(&self, ctx: &teksilo_core::widget::EventContext)
Paste plain text only, stripping any rich payload. See
RichTextEditor::paste_unformatted.
pub fn can_paste(&self, ctx: &teksilo_core::widget::EventContext) -> bool
Whether a paste would insert anything — true iff the system
clipboard carries text or an HTML payload. A point-in-time
query (clipboard contents are not reactively observable), taking
the active EventContext.
Use it to drive a context-menu / toolbar Paste enable-state,
re-querying on menu-open. Mirrors RichTextEditor::can_paste.
pub fn select_all(&self)
Select the entire document programmatically. Resets the Ctrl+A
ladder so a subsequent Ctrl+A starts fresh at level 1. Mirrors
RichTextEditor::select_all.
pub fn delete_selection(&self)
Delete the current selection. No-op when nothing is selected.
Mirrors RichTextEditor::delete_selection.
pub fn format_version(&self) -> Signal<u64>
Bumps on every format-only document event (bold / italic /
heading / alignment / list-style changes). See
RichTextEditor::format_version.
pub fn cursor_position(&self) -> usize
The live caret offset — reads cursor.position() directly, unbatched. Unlike
cursor_position_signal, whose stored value lags one frame
behind a just-typed printable character (the insert is deferred to the frame loop and the
signal is only re-synced on the next caret event), this always reflects the true caret —
what a host that recomputes highlights on a frame tick must read. Mirrors
RichTextEditor::cursor_position.
pub fn is_composing(&self) -> bool
true while an IME composition is actively in progress. Mirrors
RichTextEditor::is_composing.
pub fn cursor_position_signal(&self) -> Signal<usize>
Reactive caret position signal.
pub fn cursor_anchor_signal(&self) -> Signal<usize>
Reactive selection anchor signal.
pub fn has_selection(&self) -> Signal<bool>
Reactive selection-non-empty signal.
pub fn can_undo(&self) -> Signal<bool>
Reactive undo-availability signal (toolbar enable-state source).
pub fn can_redo(&self) -> Signal<bool>
Reactive redo-availability signal.
pub struct ImageActivation
An inline image the user clicked.
Carries the offset as well as the name because a document may hold the same
picture more than once — a name alone cannot say which one was clicked, so
a host acting on the click (selecting it, editing its size, replacing it)
would be guessing. The offset addresses the image's single U+FFFC, so
select_range(offset, offset + 1) selects exactly it.
#![allow(unused)] fn main() { pub struct ImageActivation { /* fields */ } }
pub struct EditorTextDrag
Rich text being dragged out of an editor.
The typed fast path for editor-to-editor drags: it carries the
DocumentFragment itself, so formatting, tables and inline images survive a
move the way they survive a copy/paste — where the text/plain MIME
alternative the drag also advertises (for other applications) could only
carry the words.
source and range are what let the drop tell a move from a copy:
dropped back into the editor it came from, the original has to be removed,
and only the source editor can say which range that was.
#![allow(unused)] fn main() { pub struct EditorTextDrag { /* fields */ } }
pub struct ImageResize
A resize the reader finished dragging.
Reported once, on release, rather than continuously: the document is the durable record and rewriting it on every pointer move would put a hundred entries on the undo stack for one gesture.
#![allow(unused)] fn main() { pub struct ImageResize { /* fields */ } }
Rotate

Rotate — wraps a child and applies a 2D rotation to its entire
subtree, driven by an external Prop<f32> of radians. Layout-
stable: the wrapper reports the child's natural size at all
angles; only the visual content rotates within the slot.
let angle = ctx.animated_signal(0.0);
ctx.add(Rotate::new(angle.clone()).child(chevron));
// Animate to 90° on expand:
angle.animate_to(std::f32::consts::FRAC_PI_2, Duration::from_millis(150), Easing::EaseOut);
No internal animation — the caller owns the angle signal and pairs
it with Signal::animate_to (or ctx.animate()) for animated
rotations. This keeps the widget composable: bind it to interaction
state for hover-on rotation, to an animated signal for spinning
loaders, to a constant for static decorative rotation.
Use cases: animated chevrons (the disclosure-state pattern, today
faked by visibility-toggling two static chevron icons), spinning
loaders not covered by Spinner, "shake your
head no" rotation feedback, dial controls.
Reduced motion
Rotate doesn't introduce motion — it just applies whatever the
caller's angle signal currently holds. Reduced-motion handling
belongs at the caller's animate_to site (use to_or_snap or
gate the animation behind prefers_reduced_motion).
Builder methods at a glance
origin, child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Rotate
Wraps a child widget and rotates its entire subtree by an externally-driven angle in radians.
#![allow(unused)] fn main() { pub struct Rotate { /* fields */ } }
Methods
pub fn new(angle: impl Into<Prop<f32>>) -> Self
Create a rotate wrapper bound to angle (radians); accepts a
static f32 or a reactive Signal<f32>. Default pivot: Center.
pub fn origin(mut self, origin: ScaleOrigin) -> Self
Pivot point for the rotation. Default Center.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
Scale
Scale — wraps a child and animates a uniform 2D scale on its
entire subtree when an external Prop<bool> toggles. Drives a
progress: Signal<f32> ∈ [0, 1] (0 = invisible, 1 = at rest) and
applies it as a centered (or origin-pivoted) scale transform via
BuildContext::set_transform — the renderer's transform stack
composes it onto the subtree.
let visible = ctx.signal(false);
ctx.add(Scale::new(visible.clone()).child(card));
visible.set(true); // scale-in around the slot center
Two layout modes
- Visual-only (default) —
reflow=false. The slot stays at the child's natural size at all scale values; only the visual content shrinks/grows around the chosen origin. Use for: overlay enter/exit, "boop" feedback on a Card, focus emphasis. Pair withCenterorigin (the default). - Reflow —
.reflow(true). The wrapper'slayout_responsereturnschild_size * progress, so siblings reflow as the child shrinks to nothing. The visual content scales by the same factor, fitting exactly within the shrunken slot. Use for: a Card that disappears by shrinking with surrounding cards filling the gap. Pair withTopLeadingorigin (so the visual stays anchored at the slot's top-left as it shrinks — otherwise the visual drifts while the slot shrinks).
Why this isn't just Collapse
Collapse animates only one axis (height by default) and "wipes"
content via clipping — text inside stays at full size, only the
visible portion shrinks. Scale shrinks uniformly on both axes,
and text/icons visually get smaller. Different visual vocabulary,
different use cases.
Reduced motion
Honours prefers-reduced-motion: snaps progress to its end value
(visible / hidden) instead of tweening.
Builder methods at a glance
reflow, origin, duration, easing, child, child_id
API reference
📖 Full rustdoc API for this module
pub enum ScaleOrigin
Pivot point for the scale matrix, expressed relative to the wrapper's slot rectangle.
#![allow(unused)] fn main() { pub enum ScaleOrigin { /* variants */ } }
Variants
Center— Scale around the centre of the slot. Default for visual-only mode.TopLeading— Pin the top-leading corner; content grows/shrinks toward the bottom-trailing.TopTrailing— Pin the top-trailing corner; content grows/shrinks toward the bottom-leading.BottomLeading— Pin the bottom-leading corner; content grows/shrinks toward the top-trailing.BottomTrailing— Pin the bottom-trailing corner; content grows/shrinks toward the top-leading.
pub struct Scale
Wraps a child widget and animates a uniform 2D visual scale on its
subtree when an external Prop<bool> toggles between visible and hidden.
#![allow(unused)] fn main() { pub struct Scale { /* fields */ } }
Methods
pub fn new(visible: impl Into<Prop<bool>>) -> Self
Create a scale wrapper bound to visible; accepts a static bool
or a reactive Signal<bool>. Defaults: visual-only (no layout
reflow), Center origin, MotionTokens::duration_normal +
easing_standard.
pub fn reflow(mut self, reflow: bool) -> Self
When true, the wrapper's reported size shrinks with progress
(siblings reflow). Pair with .origin(ScaleOrigin::TopLeading)
for the "card removal" pattern. Default: false (visual-only).
pub fn origin(mut self, origin: ScaleOrigin) -> Self
Pivot point for the scale matrix. Default Center for visual-
only mode; consider TopLeading when reflow=true.
pub fn duration(mut self, duration: Duration) -> Self
Override the tween duration. Default: MotionTokens::duration_normal.
pub fn easing(mut self, easing: Easing) -> Self
Override the easing. Default: MotionTokens::easing_standard.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
ScrollArea

ScrollArea — a clipping viewport that scrolls its content on wheel, touch, and assistive-technology actions.
Wrap any widget in ScrollArea to make it scrollable. The scroll position
is stored in reactive Signal<f32> signals (one per axis), shared with the
built-in ScrollBar children. Two display
modes cover most use cases: Overlay (the default, macOS-style thin-at-rest
indicator that expands on hover) and Permanent (a layout-consuming gutter
always on screen). Use ScrollBarPolicy to control when each axis shows.
Accessibility
Reports Role::ScrollView with per-axis scroll_y / scroll_x position
and limit fields. Advertises ScrollUp / ScrollDown / ScrollLeft /
ScrollRight actions only for the axes that actually overflow, so AT clients
(NVDA, JAWS, VoiceOver) know which directions are reachable.
#![allow(unused)] fn main() { use teksilo_widgets::scroll_area::{ScrollArea, ScrollBarMode}; use teksilo_widgets::primitives::MinSize; let _w = ScrollArea::new() .child(MinSize::new(0.0, 2000.0)) .scroll_bar_style(ScrollBarMode::Permanent) .smooth_scrolling(true); }
Builder methods at a glance
child, from_id, scroll_bar_style, scroll_bar_thumb_color, vertical_scroll_bar_policy, horizontal_scroll_bar_policy, line_height, scroll_bar_thickness, widget_resizable, smooth_scrolling, smooth_scroll_duration, scroll_past_end, preferred_size, preferred_height, overscroll_behavior, restore_scroll_y, scroll_y_signal, scroll_x_signal, max_scroll_y_signal, viewport_ratio_y_signal, max_scroll_x_signal
API reference
📖 Full rustdoc API for this module
pub enum ScrollBarMode
How the scroll bar is presented relative to the viewport content.
#![allow(unused)] fn main() { pub enum ScrollBarMode { /* variants */ } }
Variants
Overlay— Scroll bar overlays the content (macOS-style): a thin passive indicator is painted while scrolling; the full interactive track expands on pointer proximity. Does not reduce the viewport width.Permanent— Scroll bar is a permanent layout sibling of the viewport, reserving its full thickness and always remaining interactive — the classic Windows/Linux gutter style.Thin— Floats over the content likeOverlaybut only ever shows the thin resting indicator, never the full track. A passive scroll-position display for minimal UIs; drag, track-click, and keyboard still work against the full slot bounds.
pub enum ScrollBarPolicy
Controls when the scroll bar appears for a given axis.
#![allow(unused)] fn main() { pub enum ScrollBarPolicy { /* variants */ } }
Variants
AsNeeded— Show the scroll bar only when content exceeds the viewport size (default).AlwaysOn— Always show the scroll bar, even when content fits without scrolling.AlwaysOff— Never show the scroll bar; content is still scrollable via wheel and touch.
pub struct ScrollArea
A clipping viewport that makes any child widget scrollable.
The scroll offset per axis is stored in a reactive Signal<f32>, shared
with the built-in ScrollBar children. See ScrollBarMode for display
options and ScrollBarPolicy for per-axis visibility control.
#![allow(unused)] fn main() { pub struct ScrollArea { /* fields */ } }
Methods
pub fn new() -> Self
Create a new ScrollArea with overlay scroll bars, smooth scrolling, and no content yet.
pub fn child(mut self, child: impl Widget + 'static) -> Self
Set the scrollable content widget.
pub fn from_id(child: WidgetId) -> Self
Construct from an already-registered child WidgetId.
pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self
Set the scroll bar display mode (Overlay, Permanent, or Thin).
pub fn scroll_bar_thumb_color(mut self, color: impl Into<ColorProp>) -> Self
Tint the built-in scroll bars' thumb with an explicit colour instead of
the theme's scrollbar_thumb* tokens. Accepts anything
impl Into<ColorProp> — a Color, a theme role, or a Signal —
resolved against the live theme at paint, so roles/signals stay
reactive. Forwarded to both scroll bars via
ScrollBar::thumb_color.
Use when the area sits on a surface the surface-relative tokens don't
suit — e.g. a tooltip's inverse chip (TextRole::TooltipText).
pub fn vertical_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self
Set the vertical scroll bar visibility policy.
pub fn horizontal_scroll_bar_policy(mut self, policy: ScrollBarPolicy) -> Self
Set the horizontal scroll bar visibility policy.
pub fn line_height(mut self, lh: f32) -> Self
Set the pixels-per-line used when translating line-based wheel events.
pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self
Set the scroll bar thickness in logical pixels (applies to both axes).
pub fn widget_resizable(mut self, resizable: bool) -> Self
When true, content smaller than the viewport is stretched to fill it.
Similar to Qt's QScrollArea::setWidgetResizable(true).
pub fn smooth_scrolling(mut self, enabled: bool) -> Self
Enable or disable smooth animated scrolling for wheel events.
Enabled by default. Applies to both line-based (ScrollDelta::Lines)
and pixel-based (ScrollDelta::Pixels) wheel events — on Wayland and
other platforms with high-resolution scroll axes, mouse wheel notches
are delivered as pixel deltas, so animating both paths is required for
a fast flick to feel smooth instead of jumping.
pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self
Set the duration of the smooth scroll animation (default: 150ms).
pub fn scroll_past_end(mut self, fraction: impl Into<Prop<f32>>) -> Self
Allow scrolling past the end of the content by fraction of the
viewport height (default 0.0 — the last pixel of content stops flush
with the bottom of the viewport).
This extends the scroll range only. It adds no widget, no padding and no layout, so it cannot interfere with the content's own padding — a distinction worth keeping, since padding-based implementations of this idea in other toolkits are a recurring source of "single-line content is scrollable" bugs.
The motivating case is typewriter scrolling: to pin the caret's line at
the middle of the viewport, the view must be able to scroll half a
viewport past the last line, or the pin quietly stops working over the
final page — exactly where a writer spends their time. Pair with
EventContext::ensure_visible_aligned, passing 1.0 - fraction here
for a pin at fraction.
Accepts a literal or a Signal<f32>, so it can follow a setting live.
Negative values are treated as 0.0.
pub fn preferred_size(mut self, width: f32, height: f32) -> Self
Set a preferred size returned when the parent proposes unconstrained dimensions. If not set, falls back to cached content size or 300×200.
This overrides both axes. If you only want to cap the height and let
the width follow the content — the usual case for a menu or popover, which
must be as wide as its widest row — use preferred_height instead.
Passing a width of 0.0 here does not mean "no preference": it means
zero, and the scroll area will collapse.
pub fn preferred_height(mut self, height: f32) -> Self
Cap the height when the parent proposes an unconstrained one, while letting the width continue to follow the content.
This is what a scrolling menu/popover wants: it must not grow taller than
its viewport, but it must still be as wide as its widest item. Using
preferred_size with a 0.0 width for this
collapses the panel to its minimum width and clips every row — the parent
proposes an unconstrained width (it is hugging its content), so the 0.0
is taken literally.
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Set the scroll-chaining behavior at the boundary. Default
OverscrollBehavior::Chain (a boundary scroll bubbles to an ancestor
scrollable); OverscrollBehavior::Contain absorbs it instead.
pub fn restore_scroll_y(self, offset: f32) -> Self
Land offset on the first layout pass at which this area has a real
scrollable range, then forget it.
max_scroll_y is 0.0 until the content has been measured, so an
offset a host writes before that first measurement is clamped away to
zero and the page paints at the top for a frame before jumping to
where it should have started. This stores the offset instead and
applies it itself, inside layout, as soon as max_scroll_y becomes
nonzero, before the ordinary clamp would otherwise discard it, so the
very first frame the content is measured on is already laid out at
the restored position, with no visible jump.
It is a one-shot: once applied, it is dropped, so a later reflow (a wider window, an edit that lengthens the document) never yanks the reader back to where they came in. The offset is still clamped to the real range when it lands: past the end it lands at the end, negative it lands at zero.
offset <= 0.0 is a no-op: there is nothing to restore, and it clears
any previously armed offset rather than leaving it pending.
An area that never calls this behaves exactly as it always has.
pub fn scroll_y_signal(&self) -> &Signal<f32>
Get the vertical scroll position signal (for external observation).
pub fn scroll_x_signal(&self) -> &Signal<f32>
Get the horizontal scroll position signal (for external observation).
pub fn max_scroll_y_signal(&self) -> &Signal<f32>
Maximum vertical scroll offset for the current content
(content_height − viewport_height, or 0 when content fits), plus any
range bought with scroll_past_end.
External callers bind to this for "is there more to scroll?"
chrome (e.g. trailing scroll-arrow visibility).
pub fn viewport_ratio_y_signal(&self) -> &Signal<f32>
Fraction of the scrollable height currently visible (1.0 when
everything fits) — what sizes the vertical scroll bar's thumb. Accounts
for scroll_past_end, so the thumb stays
proportional to the range the user can actually travel.
pub fn max_scroll_x_signal(&self) -> &Signal<f32>
Maximum horizontal scroll offset for the current content. External callers bind to this for "is there more to scroll?" chrome (e.g. trailing scroll-arrow visibility on a tab bar).
ScrollBar

ScrollBar — pointer and keyboard affordance for a ScrollArea.
ScrollBar reads and writes a shared Signal<f32> scroll position and a
Signal<f32> viewport/content ratio, both supplied by its owning ScrollArea.
Interaction (thumb drag, track click, keyboard Up/Down/Home/End, hover) is
handled here; all painting is delegated to the active ScrollBarStyle impl so
the look is fully theme-overridable.
Most applications do not need to construct a ScrollBar directly — ScrollArea
creates and manages the bars automatically. Use this type when building a custom
scroll host (e.g. the RichTextEditor manages its own bars to avoid the
wrap/scrollbar circular dependency).
Accessibility
Hidden from AT via set_hidden(). Scroll actions (Up/Down/Left/Right) are
advertised on the parent ScrollView node, not on the bar, so screen readers
navigate the content region directly without stopping on the thumb.
#![allow(unused)] fn main() { use teksilo_widgets::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVariant}; use teksilo_core::signal::Signal; let position = Signal::new(0.0_f32); let max_scroll = Signal::new(500.0_f32); let viewport_ratio = Signal::new(0.4_f32); let _bar = ScrollBar::new( ScrollBarOrientation::Vertical, position, max_scroll, viewport_ratio, ) .thickness(8.0) .variant(ScrollBarVariant::Overlay); }
Builder methods at a glance
thickness, min_thumb_length, step_size, visual, variant, style, thumb_color
API reference
📖 Full rustdoc API for this module
pub struct ScrollBar
A scroll bar that shares reactive scroll-position state with a ScrollArea.
Supports thumb drag, track-click page scroll, and keyboard Up/Down/Left/Right/Home/End navigation. Hidden from AT — see module docs.
#![allow(unused)] fn main() { pub struct ScrollBar { /* fields */ } }
Methods
pub fn new( orientation: ScrollBarOrientation, scroll_position: Signal<f32>, max_scroll: Signal<f32>, viewport_ratio: Signal<f32>, ) -> Self
Create a new ScrollBar with shared state.
scroll_position: sharedSignal<f32>for current scroll offsetmax_scroll: sharedSignal<f32>for maximum scroll offsetviewport_ratio: sharedSignal<f32>for viewport/content ratio (0.0..1.0)
pub fn thickness(mut self, thickness: f32) -> Self
Set the bar thickness (width for vertical, height for horizontal).
pub fn min_thumb_length(mut self, len: f32) -> Self
Set the minimum thumb length in pixels.
pub fn step_size(mut self, step: f32) -> Self
Set the scroll step for keyboard navigation.
pub fn visual(mut self, variant: ScrollBarVariant) -> Self
Set the visual variant. The active ScrollBarStyle picks how
to paint each variant; the IntUI default ships Permanent /
Overlay / Thin out of the box.
pub fn variant(mut self, variant: ScrollBarVariant) -> Self
Alias for visual using the new variant naming.
pub fn style(mut self, style: impl ScrollBarStyle) -> Self
Override the active ScrollBarStyle for this widget instance only.
pub fn thumb_color(mut self, color: impl Into<ColorProp>) -> Self
Tint the thumb with an explicit colour instead of the theme's
scrollbar_thumb* tokens. Accepts anything impl Into<ColorProp> —
a Color, a theme role (TextRole/SurfaceRole/…), or a Signal;
resolved against the live theme at paint, so roles and signals stay
reactive. The active ScrollBarStyle derives the idle/hover/pressed
states from this tint. Use when the bar sits on a surface the
surface-relative tokens don't suit — a tooltip's inverse chip, a
branded panel. Mirrors Button::text_role.
SearchField

SearchField — a TextInput preset
configured for search workflows: leading magnifier glyph, default-on
clear-X, and an optional anchored suggestions popover with keyboard
navigation and the ARIA combobox-with-listbox accessibility pattern.
The popover is shown via OverlayRequest so it floats above sibling
content and escapes ancestor clipping (same pattern as ComboBox).
let query = ctx.signal(String::new());
SearchField::new(query.clone())
.placeholder("Search documents")
.with_suggestions(|prefix| {
FRUITS.iter()
.filter(|f| f.to_lowercase().starts_with(&prefix.to_lowercase()))
.map(|s| s.to_string())
.collect()
})
.on_select(|value, _ctx| println!("picked: {value}"))
.on_submit_fn(|ctx| ctx.send_intent(AppIntent::Search))
Design — comparison with searchable ComboBox
A searchable ComboBox and a SearchField are visually similar
but semantically different:
- ComboBox is a value picker — the bound state is the selected item from a known list. The text input is a transient filter, embedded inside the dropdown popup; the closed combo shows the selected value, not the user's query.
- SearchField is a query input — the bound state is the
query string itself. The text input is always visible at the
top level; suggestions are completion hints, not the source of
truth. The bound
Signal<String>keeps whatever the user typed, even if no suggestion matches.
The two share the same dropdown-of-options machinery in spirit;
a future refactor could lift a common OverlayList<T> primitive
out of both. For now they're separate so each can keep a small
API surface tuned to its semantics.
Accessibility
The field is Role::SearchInput with HasPopup::Listbox and
AutoComplete::List. When the popup is open it advertises
set_expanded(true) and set_controls(listbox_id) (mapped to
accesskit::NodeId via widget_id_to_node_id). Each row is
Role::ListBoxOption with set_selected(is_highlighted),
set_position_in_set(idx + 1), and set_size_of_set(total) so
screen readers can announce "Apple, 1 of 5".
Builder methods at a glance
style, placeholder, label, drives_listbox, enabled, on_submit_fn, with_suggestions, max_suggestions, min_chars, on_select, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct SearchField
A search input with optional inline suggestions popup.
#![allow(unused)] fn main() { pub struct SearchField { /* fields */ } }
Methods
pub fn new(text: Signal<String>) -> Self
Create a search field bound to text, the reactive query string.
pub fn style(mut self, style: impl teksilo_core::styles::SearchFieldStyle) -> Self
Per-call SearchFieldStyle override.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Set the placeholder text shown when the query is empty.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set an accessible label for the field (announced by screen readers, not visually shown).
pub fn drives_listbox( mut self, listbox: Signal<Option<WidgetId>>, active: Signal<Option<WidgetId>>, ) -> Self
Wire this field to a listbox the caller owns, so arrow keys that
move a highlight through that list are announced while focus stays here
(the ARIA combobox pattern). listbox is the list's node, active the
currently-highlighted row's node; both are forwarded to the inner
TextInputField, which is the node that actually holds focus and
therefore the only one whose active_descendant assistive technology
follows.
This is for a search field driving a list built by its host — a
command palette, a filter box above a results view. The built-in
suggestion popup (suggestions) wires itself and needs none of this.
pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self
Set the initial enabled state. Forwarded to the arena at build time.
pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Install a callback invoked when the user presses Enter (or activates the search action).
pub fn with_suggestions(mut self, f: impl Fn(&str) -> Vec<String> + 'static) -> Self
Provider that returns suggestions for the current query string.
When set, the popup appears below the field as soon as the
user types at least Self::min_chars characters and the
provider returns a non-empty list.
pub fn max_suggestions(mut self, n: usize) -> Self
Cap the number of suggestions shown in the popup (default 8, minimum 1).
pub fn min_chars(mut self, n: usize) -> Self
Minimum number of characters the user must type before suggestions appear (default 1).
pub fn on_select(mut self, f: impl Fn(&str, &mut EventContext) + 'static) -> Self
Install a callback invoked when the user picks a suggestion (tap, Enter, or Space).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Show a plain one-line tooltip after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — calling this
clears the other slots (last call wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Show a registry-driven rich tooltip keyed by key.
Mutually exclusive with the other tooltip setters — last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Show an inline rich tooltip with the given TooltipContent.
Mutually exclusive with the other tooltip setters — last call wins.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Show a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with the other tooltip setters — last call wins.
SegmentedControl

SegmentedControl — mutually exclusive segments in a horizontal row.
Each segment is a real composed widget — a centered icon + label with
a reactive tint — built from a Segment descriptor. Selection is
bound to a Signal<Option<SegmentId>>: keyed, not positional, so
inserting or removing a segment never silently re-points the
selection at a different one. The chrome (rounded frame, hover tint,
selected-segment surface) is delegated to the active
SegmentedControlStyle.
const LIST: SegmentId = SegmentId::from_u64(1);
const GRID: SegmentId = SegmentId::from_u64(2);
let view = ctx.signal(Some(LIST));
SegmentedControl::new(view.clone())
.segment(Segment::new(tr!(list_view())).id(LIST).icon(|| IconWidget::list(14.0)))
.segment(Segment::new(tr!(grid_view())).id(GRID).icon(|| IconWidget::grid(14.0)))
// Pairing with a Switcher:
Switcher::new(segmented_control::index_signal(&view, &[LIST, GRID]))
When to use
- Use a
SegmentedControlfor mutually exclusive modes that read well as a compact horizontal strip (view mode, time period). - Prefer a
ComboBoxwhen the options are many and the strip form buys nothing — though a segmented control no longer breaks down at seven segments, because it overflows (below). - Prefer
RadioButton/RadioTileGroupwhen the options need vertical space or descriptions.
Width: overflow, not squeeze
When the segments do not fit, the ones that do not fit move into a
trailing chevron menu rather than all of them compressing into
ellipsised stubs (SegmentOverflow::Menu, the default; opt out with
SegmentOverflow::Compress).
Declaration order is stable, with exactly one exception: the selected segment is always visible. If it would have been pushed into the menu it takes the last slot, and it stays there until another segment is chosen from the menu — so the strip does not reshuffle under the pointer, and the promotion is forgotten once the control is wide enough to show everything again.
Declared: [A][B][C][D][E][F][G] fits 4 + chevron
start, A selected [A][B][C][D][v] menu: E F G
pick F from menu [A][B][C][F][v] menu: D E G
click A (F stays) [A][B][C][F][v] menu: D E G
widen to full fit [A][B][C][D][E][F][G]
Accessibility
Role::RadioGroup on the control with active_descendant pointing at
the selected segment; Role::RadioButton per segment, carrying
"N of M" over the whole segment list — including segments currently in
the overflow menu, which are still reachable. Arrow keys cycle
selection (RTL-aware, resolved at event time) and Home/End jump to the
ends, both skipping disabled segments; stepping onto an overflowed
segment promotes it into view. Increment/Decrement AT actions
mirror the arrows.
The strip is one tab stop. While the control is overflowing the chevron adds a second, because an overflow menu that no keyboard can reach is not an overflow menu; it cannot join the arrow sequence, since here arrows move selection rather than a roving focus.
Builder methods at a glance
indexed, segment, segments, segment_ids, enabled, label, on_change, style, text_style, display, sizing, overflow, is_overflowing, fill_width
API reference
📖 Full rustdoc API for this module
pub enum SegmentDisplay
What a segment paints: its icon, its label, or both.
Set on the control with
SegmentedControl::display; it
applies to every segment. Mirrors TabWidget's TabDisplayMode.
Icon-only is the classic compact fallback before overflow kicks in:
a bar of icon-only segments fits far more of them, so switching to
Icon can be the difference between a
complete strip and a chevron menu.
#![allow(unused)] fn main() { pub enum SegmentDisplay { /* variants */ } }
Variants
Auto— Paint whatever the segment declares — icon and label when both are present, label alone otherwise. The default, and the behaviour of everySegmentedControlbefore this mode existed.Text— Label only. A declared icon is suppressed.Icon— Icon only; the label is promoted to the hover tooltip (unless the segment already declares one). A segment with no icon falls back to its label, so the mode is never a silent no-op.IconText— Icon and label. Identical toAutofor a segment that declares both; kept for parity withTabDisplayModeso a caller can be explicit.
pub enum SegmentSizing
How the visible segments divide the control's width.
#![allow(unused)] fn main() { pub enum SegmentSizing { /* variants */ } }
Variants
Uniform— Every visible segment gets the same width — the Apple / IntUI look, and the behaviour of everySegmentedControlbefore this knob existed. The fit calculation uses the widest segment's natural width as the unit, so segments never look ragged.Fit— Every visible segment gets its own natural width, and leftover space (when the control fills a wider slot) is shared equally. Fits more short segments before overflowing, at the cost of an uneven strip.
pub enum SegmentOverflow
What the control does when its segments do not fit.
#![allow(unused)] fn main() { pub enum SegmentOverflow { /* variants */ } }
Variants
Menu— Move the segments that do not fit into a trailing chevron menu, keeping the rest at a legible width. The selected segment is always among the visible ones. This is the default.Compress— Keep every segment on the strip and let them compress, truncating labels with an ellipsis. The behaviour of everySegmentedControlbefore overflow existed — appropriate for two or three short segments that will never realistically overflow.
pub struct Segment
One segment descriptor: a localized label with a stable
SegmentId, an optional leading icon, a hover tooltip, and
reactive disabled / visible flags.
#![allow(unused)] fn main() { pub struct Segment { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
A text segment with a freshly allocated SegmentId. The label
may come from tr!(...) (translated — follows a live locale
switch) or lit!(...) (untranslated).
Call id when the segment needs a stable identity —
one that survives a restart, or that another crate can name.
pub fn id(mut self, id: SegmentId) -> Self
Give this segment an app-chosen stable identity, replacing the
fresh id new allocated. Use this whenever the
selection is persisted or the segment is contributed by another
crate.
pub fn segment_id(&self) -> SegmentId
This segment's identity.
pub fn icon(mut self, factory: impl Fn() -> IconWidget + 'static) -> Self
Add a leading icon. The factory is invoked at build time (and on rebuild); the icon's tint is bound reactively to the segment's selected / focus / enabled state so it matches the label.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Hover tooltip — most useful for icon-only segments.
Mutually exclusive with rich_tooltip /
rich_tooltip_content /
composite_tooltip — last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Rich hover tooltip resolved from the app-wide registry by key.
Mutually exclusive with tooltip /
rich_tooltip_content /
composite_tooltip — last call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Rich hover tooltip driven by an inline
TooltipContent entry
(no registry key needed).
Mutually exclusive with tooltip /
rich_tooltip /
composite_tooltip — last call wins.
pub fn composite_tooltip(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Composite hover tooltip built by a factory closure at attach time.
The factory is called once per build() to produce the tooltip
body widget. It is stored as an Rc<dyn Fn> so that Segment
remains Clone.
Mutually exclusive with tooltip /
rich_tooltip /
rich_tooltip_content — last call wins.
pub fn disabled(mut self, disabled: impl Into<Prop<bool>>) -> Self
Disable this segment: not selectable via click or keyboard, dimmed, and announced disabled to assistive tech.
Accepts a bool or a Signal<bool> — a bound signal flips the
segment live, with no rebuild, and keyboard stepping honours
the new value immediately (the flags are read at event time, not
snapshotted at build time).
pub fn visible(mut self, visible: impl Into<Prop<bool>>) -> Self
Hide this segment entirely: it leaves the strip, the overflow menu, the keyboard order, and the accessibility tree, and it is excluded from the overflow calculation.
Distinct from overflowed — an overflowed segment is still
reachable from the chevron menu, a hidden one is not there at all.
Accepts a bool or a Signal<bool>; a bound signal re-runs the
overflow plan with no rebuild.
pub fn index_signal(...)
Derive a Switcher-compatible index from a keyed selection.
SegmentedControl is keyed precisely so that a contributed segment
cannot silently re-point the selection, but Switcher is index-driven
— this is the adapter between the two. Unknown or absent ids resolve
to 0, matching Switcher's own out-of-range behaviour.
Switcher::new(segmented_control::index_signal(&view, &[LIST, GRID, COLUMNS]))
.child(list_pane)
.child(grid_pane)
.child(columns_pane)
#![allow(unused)] fn main() { pub fn index_signal(selected: &Signal<Option<SegmentId>>, ids: &[SegmentId]) -> Signal<usize>; }
pub struct SegmentedControl
A segmented control binding a Signal<Option<SegmentId>> to a row of
mutually exclusive segments. Build the segment list with
segment or segments.
#![allow(unused)] fn main() { pub struct SegmentedControl { /* fields */ } }
Methods
pub fn new(selected: Signal<Option<SegmentId>>) -> Self
Create an empty segmented control bound to selected. Add segments
with segment or segments.
pub fn indexed(index: Signal<usize>) -> Self
Bind a positional Signal<usize> instead of a keyed
selection, mirrored in both directions.
Use this only when position is the meaning and the segment list
is closed and local — an enum discriminant over a fixed ALL
array, a Switcher index, a settings choice. For anything else
prefer new: an index silently stops meaning the
same thing the moment a segment is inserted ahead of it, which is
the entire reason selection is keyed. A persisted selection, or
segments contributed by another crate, are both firmly in
"anything else".
Positions address the declared list, so a segment hidden with
Segment::visible does not renumber the others.
// `bucket_idx` already drives the rollup maths and a Switcher.
SegmentedControl::indexed(bucket_idx.clone())
.segments([lit!("×2"), lit!("×4"), lit!("×8")])
pub fn segment(mut self, segment: impl Into<Segment>) -> Self
Append one segment. Accepts a Segment or, via
From<LocalizedString>, a bare tr!(...) / lit!(...) label
(which gets a freshly allocated SegmentId).
pub fn segments(mut self, segments: impl IntoIterator<Item = impl Into<Segment>>) -> Self
Append several segments. Label-only:
.segments([tr!(day()), tr!(week())]); rich:
.segments([Segment::new(...).id(DAY).icon(...), ...]).
pub fn segment_ids(&self) -> Vec<SegmentId>
The ids of the segments added so far, in declaration order.
Convenient for feeding index_signal without repeating the list.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to
the arena at build time via
ctx.enabled_when(segmented_control_id, self.enabled.clone()).
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible name for the group — e.g. "View mode". Screen readers
announce it before the selected segment. Matches
RadioGroup::label and
RadioTileGroup::label.
pub fn on_change(mut self, f: impl Fn(SegmentId, &mut EventContext) + 'static) -> Self
Called whenever the user changes the selection — by click, arrow
key, assistive technology, or the overflow menu. Receives the
newly selected SegmentId and an EventContext, so it can do
things a bare Signal write cannot (ctx.set_locale(...),
ctx.send_intent(...), opening a window).
Does not fire for programmatic writes to the bound signal — there is no event in flight to carry. Observe the signal for that.
pub fn style(mut self, style: impl teksilo_core::styles::SegmentedControlStyle) -> Self
Per-call override for the segmented-control chrome.
pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self
Override every segment's label text style (font, size, weight).
Accepts a TextStyleRole, a TextStyle, or a Signal of either.
Default (unset) is TextStyleRole::Small. Text color stays
state-driven and is intentionally not overridable here.
pub fn display(mut self, display: SegmentDisplay) -> Self
What each segment paints: its icon, its label, or both. See
SegmentDisplay. Icon-only fits far more segments, so it is
worth reaching for before the control starts overflowing.
pub fn sizing(mut self, sizing: SegmentSizing) -> Self
How the visible segments divide the width. See SegmentSizing.
pub fn overflow(mut self, mode: SegmentOverflow) -> Self
What to do when the segments do not fit. See SegmentOverflow.
pub fn is_overflowing(&self) -> Signal<bool>
Reactive "some segments are in the overflow menu right now".
Republished from place_children behind an equality guard, so it
is safe for RepaintOnly / AccessibilityOnly consumers and for
Relayout consumers that do not feed back into this control's own
width. Mirrors Toolbar::is_overflowing.
pub fn fill_width(mut self, fill: bool) -> Self
Whether the control claims all the width offered to it (the default, and the behaviour before this knob existed) or hugs its segments.
false also makes the control shrinkable: in an over-constrained
stack it compresses — and overflows — instead of spilling past its
bounds.
pub struct SegmentId
Stable identity of a segment. Cheap to copy; survives rebuilds, locale changes, and segments being inserted around it.
#![allow(unused)] fn main() { pub struct SegmentId(NonZeroU64); }
Methods
pub fn fresh() -> Self
Allocate a new, never-before-seen id. Backed by a monotonic global counter — overflow is theoretically possible after 2^64 calls, at which point the universe has had bigger problems.
Segment::new calls this for you, so a
control that never persists its selection needs no explicit ids.
Allocations start at 2^48, so they can never collide with a small
constant an app declared through from_u64.
pub const fn from_raw(value: NonZeroU64) -> Self
Wrap an externally-allocated key. Use this when the segment's
identity comes from an app-side store (a view-mode enum
discriminant, a plugin key hash, …) — calling SegmentId::fresh
would allocate a new id every restart, breaking a persisted
selection.
pub const fn from_u64(value: u64) -> Self
const convenience over from_raw, so an app
can declare its segments as constants:
# use teksilo_widgets::SegmentId;
const SYNOPSIS: SegmentId = SegmentId::from_u64(1);
const CHAPTER: SegmentId = SegmentId::from_u64(2);
Panics
If value is zero. Because this is a const fn, a literal zero
is caught at compile time rather than at run time.
pub const fn raw(self) -> NonZeroU64
The underlying non-zero u64. Serialize this to persist a
selection across sessions; restore via from_raw
or from_u64.
pub const fn get(self) -> u64
The underlying value as a plain u64.
Shake
Shake — wraps a child and plays a damped horizontal oscillation
whenever an external trigger Signal<u32> is bumped. The classic
invalid-input feedback: wrong password, failed form validation,
"no more results" wall.
let shake_trigger = ctx.signal(0_u32);
ctx.add(
Shake::new(shake_trigger.clone())
.child(text_input_field),
);
// ...elsewhere, on validation failure:
shake_trigger.set(shake_trigger.get() + 1);
Layout semantics
Layout-stable: the wrapper reports the child's full natural size and clips the oscillating-out-of-bounds excursions on each side. Siblings don't reflow. The shake is a pure visual offset.
Reduced motion
Honours prefers-reduced-motion: the trigger no-ops. The widget
is still focusable / interactive — the visual feedback just
doesn't play. Pair with another a11y-friendly cue (red border,
error text) when error state must be communicated.
Builder methods at a glance
amplitude, duration, cycles, child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Shake
Wraps a child and plays a damped horizontal-oscillation shake each time the trigger signal value changes.
#![allow(unused)] fn main() { pub struct Shake { /* fields */ } }
Methods
pub fn new(trigger: Signal<u32>) -> Self
Build a shake wrapper. Bumping trigger (any new value) plays
one shake cycle.
pub fn amplitude(mut self, px: f32) -> Self
Peak horizontal offset in logical pixels. Default 8 px.
pub fn duration(mut self, duration: Duration) -> Self
Override the total shake duration. Default:
MotionTokens::duration_slow (~300 ms) — the same one-shot
"this should feel deliberate" budget dialogs use.
pub fn cycles(mut self, cycles: f32) -> Self
Number of full back-and-forth oscillations within duration.
Default 4 cycles. Higher = jitterier; lower = wobblier.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
ShortcutSettings

ShortcutSettings — user-facing widget for browsing and rebinding application shortcuts.
Reads every shortcut registered in the tree's
ShortcutRegistry and
renders one row per entry, grouped by category, with both primary
and secondary keystrokes independently rebindable. Supports:
- Rebind (primary or secondary) via one-shot key capture.
- Unbind a slot explicitly (sets the override to
None), or pressDelete/Backspaceduring capture. - Reset clears the user override entirely, restoring the declared defaults. Disabled when no override exists.
- Conflict auto-resolution: rebinding to a keystroke already bound elsewhere silently unbinds the conflicting shortcut so there's always exactly one binding per chord.
- Escape during capture cancels without committing.
- Platform-aware keystroke labels via
format_keystroke.
The widget owns the currently-armed CaptureHandle; dropping
the widget cancels the capture, so navigating away mid-rebind
cannot leak a stray rebind onto the next keystroke pressed
somewhere else in the app.
// Inside a settings Dialog build():
let filter = ctx.signal(String::new());
ctx.add(
ShortcutSettings::new()
.with_filter(filter)
.confirm_conflicts(true)
.on_conflict(|c| println!("displaced: {}", c.displaced_name)),
);
Builder methods at a glance
with_filter, confirm_conflicts, on_conflict
API reference
📖 Full rustdoc API for this module
pub struct ShortcutConflict
Describes a rebind that collides with an existing binding.
Passed to the ShortcutSettings::on_conflict callback so the app
can surface a toast ("Save lost its Ctrl+S binding"); also used
internally to drive the optional inline confirm prompt.
#![allow(unused)] fn main() { pub struct ShortcutConflict { /* fields */ } }
pub struct ShortcutSettings
A settings panel for browsing and rebinding application shortcuts.
Reads every Shortcut in the tree's ShortcutRegistry, groups rows
by category, and renders primary + secondary keystroke slots with
Rebind, Unbind, and Reset controls. See the module-level docs for the
full feature list.
#![allow(unused)] fn main() { pub struct ShortcutSettings { /* fields */ } }
Methods
pub fn new() -> Self
Create a settings panel that lists every shortcut currently
registered in the tree's ShortcutRegistry, without a filter.
pub fn with_filter(mut self, filter: Signal<String>) -> Self
Bind the visible row set to a filter signal. The widget
shows only shortcuts whose name, id, or category
contains the filter text (case-insensitive). Empty string =
show everything.
Apps typically drive this from a TextInput elsewhere in
their settings UI; keeping the filter external keeps this
widget's own surface minimal rather than embedding a search box.
pub fn confirm_conflicts(mut self, yes: bool) -> Self
Require explicit confirmation before a rebind unbinds a conflicting shortcut. Off by default — the chord is reassigned immediately (the historical behavior). When on, a colliding rebind shows an inline "already assigned to X — Reassign / Cancel" prompt on the row, and the registry is left untouched until the user confirms.
pub fn on_conflict(mut self, f: impl Fn(&ShortcutConflict) + 'static) -> Self
Register a callback fired whenever a rebind collides with an
existing binding — regardless of confirm_conflicts. The
callback receives the displaced shortcut's id, name, and the
chord, so the app can surface a toast ("Save lost its Ctrl+S
binding"). It fires before the displaced binding is removed (in
confirm mode, before the user has confirmed).
Shrinkable
Shrinkable — a layout modifier that allows its child to compress under an over-constraint.
By default every widget is rigid: when a stack runs out of main-axis room, rigid
children keep their wanted size and overflow the bounds. Shrinkable opts a child
into the over-constraint distribution: the stack divides any deficit across all
shrinkable children proportional to their shrink weight,
never below the min_width / min_height
floor set here.
Shrinkable is the shrink counterpart to
Expand: while Expand claims leftover slack
(grow), Shrinkable absorbs excess pressure (shrink). The two are independent
— a child can both grow on surplus and shrink on deficit by wrapping with
Shrinkable and setting a non-zero flex on the inner widget.
When to use
- A long text label that should ellipsize before a rigid icon/badge loses space.
- A thumbnail image column that may compress while a fixed sidebar stays at full width.
- "Compress A before B": give A
Shrinkable, leave B rigid (shrink = 0).
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{HStack, Shrinkable, TextWidget}; use teksilo_i18n::lit; // The label shrinks as far as 48 dp; the button stays rigid. let _row = HStack::new() .child(Shrinkable::new().min_width(48.0) .child(TextWidget::new(lit!("A long label that may compress")).single_line())) .child(TextWidget::new(lit!("Rigid"))); }
Builder methods at a glance
shrink, min_width, min_height, child, child_id
API reference
📖 Full rustdoc API for this module
pub struct Shrinkable
Layout modifier that lets its child be compressed when a stack is
over-constrained — the shrink counterpart to Expand.
By default widgets do not shrink: when an HStack/VStack runs out of
room, rigid children keep their wanted size and overflow. Wrap a child in
Shrinkable to opt it into compression: the parent distributes any deficit
across shrinkable children proportional to their shrink weight, never below
the floor set here.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{HStack, Shrinkable, TextWidget, IconWidget}; use teksilo_i18n::lit; let long_label = TextWidget::new(lit!("A very long label that may need to shrink")); let icon = IconWidget::chevron_right(16.0); // The label gives up space before the (rigid) icon when the row is narrow: let _w = HStack::new() .child(Shrinkable::new().min_width(40.0).child(long_label)) .child(icon); // rigid — never shrinks }
Shrinkable preserves its child's grow weight (flex) and cross size, so a
child can both grow on surplus and shrink on a deficit. It forwards the
parent's proposal to the child unchanged; when the stack compresses it, the
child is re-laid-out at the smaller size (so e.g. a wrapped-text child
re-wraps and reports its taller height via the height-for-width pass).
Floor caveat. The default floor is 0 on both axes, which lets the
child shrink to nothing. Set min_width /
min_height to a sensible minimum — the caller owns
this choice (unlike the stock height-stable widgets, which report their own
natural floor).
#![allow(unused)] fn main() { pub struct Shrinkable { /* fields */ } }
Methods
pub fn new() -> Self
A shrinkable wrapper with shrink weight 1.0 and a zero floor.
pub fn shrink(mut self, weight: f32) -> Self
Set the shrink weight (relative share of an over-constraint deficit this
child absorbs). Clamped to >= 0; 0 makes the child rigid again.
pub fn min_width(mut self, min: f32) -> Self
Set the minimum width the child may be compressed to.
pub fn min_height(mut self, min: f32) -> Self
Set the minimum height the child may be compressed to.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Wrap an inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Wrap a pre-registered child by id.
Slide
Slide — wraps a child and slides it in or out from a chosen
edge when an external Signal<bool> toggles. Common patterns:
drawers, snackbars, side panels, banner notifications.
let visible = ctx.signal(false);
ctx.add(
Slide::new(visible.clone())
.from(SlideEdge::Bottom)
.child(snackbar_content),
);
// ...elsewhere:
visible.set(true); // slides in from below
Layout semantics
Slide's own slot stays in its laid-out position; the child is
translated within the slot via place_children. The wrapper
clips so a sliding-in child doesn't bleed past the slot edges.
The wrapper reports the child's full natural size at all
progress values — siblings don't reflow as the child slides.
For a "slide + fade" effect (notification snackbar), wrap the
child in Fade before passing it to Slide:
#![allow(unused)] fn main() { use teksilo_widgets::animations::{Slide, Fade, SlideEdge}; use teksilo_widgets::primitives::TextWidget; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let visible = Signal::new(false); let snackbar_content = TextWidget::new(lit!("Changes saved")); let _w = Slide::new(visible.clone()) .from(SlideEdge::Bottom) .child(Fade::new(visible).child(snackbar_content)); }
Reduced motion
Honours prefers-reduced-motion: snaps the child instantly into
or out of position instead of tweening.
Builder methods at a glance
from, child, child_id
API reference
📖 Full rustdoc API for this module
pub enum SlideEdge
Which edge the child slides in from / out to.
Leading and Trailing honour layout direction (RTL flips them);
the resolution happens in place_children via the layout context.
#![allow(unused)] fn main() { pub enum SlideEdge { /* variants */ } }
Variants
Leading— Slide from the leading edge (left in LTR, right in RTL). Suits drawers and side panels.Trailing— Slide from the trailing edge (right in LTR, left in RTL).Top— Slide from the top edge. Suits drop-down banners or navigation bars.Bottom— Slide from the bottom edge. Suits snackbars and bottom sheets.
pub struct Slide
Wraps a child widget and translates it in or out from one edge of
its slot whenever visible flips.
#![allow(unused)] fn main() { pub struct Slide { /* fields */ } }
Methods
pub fn new(visible: impl Into<Prop<bool>>) -> Self
Create a slide wrapper bound to visible; accepts a static bool
or a reactive Signal<bool>. Defaults to SlideEdge::Bottom —
override with .from(...).
pub fn from(mut self, edge: SlideEdge) -> Self
Edge the child slides in from (and out to).
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
Slider

Slider — a draggable value selector bound to a Signal<f32>.
The widget owns all input handling: pointer drag (click-to-jump and
thumb-drag), keyboard arrows (ArrowRight/ArrowLeft/Up/Down,
Home, End), and Increment/Decrement accessibility actions.
All visual chrome is delegated to a
SliderStyle implementation; the
IntUI default ships out of the box and is also the theme-wide slot
override target (theme.style_slots.slider).
Accessibility
Exposes Role::Slider with numeric value, min, max, step, and
orientation. Screen readers announce the current value on every
change. The focus ring follows the :focus-visible heuristic —
visible after keyboard interaction, invisible after a pointer tap.
#![allow(unused)] fn main() { use teksilo_core::signal::Signal; use teksilo_widgets::Slider; let volume = Signal::new(0.5_f32); let _w = Slider::new(volume, 0.0, 1.0).step(0.05); }
Builder methods at a glance
step, orientation, enabled, variant, tick_count, style, label, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct Slider
A draggable value selector bound to a Signal<f32> in a continuous
or discrete range. Visual chrome is fully delegated to a
SliderStyle implementation.
#![allow(unused)] fn main() { pub struct Slider { /* fields */ } }
Methods
pub fn new(value: Signal<f32>, min: f32, max: f32) -> Self
Create a horizontal slider bound to value with the given inclusive
range. Use orientation to switch to vertical.
pub fn step(mut self, step: f32) -> Self
Set the discrete step size for keyboard arrows and accessibility Increment/Decrement actions. When unset, defaults to 1 % of the range.
pub fn orientation(mut self, orientation: Orientation) -> Self
Set the slider orientation (Horizontal by default). Vertical
sliders map Up/Down arrow keys to increase/decrease.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to
the arena at build time via
ctx.enabled_when(slider_id, self.enabled.clone()).
pub fn variant(mut self, variant: SliderVariant) -> Self
Pick a Tier-1 design-language variant
(SliderVariant::Continuous / Discrete / Range). The
active SliderStyle decides what to do with the hint —
IntUI's default impl paints ticks for Discrete and ignores
Range (the widget itself doesn't yet wire dual-thumb
behaviour).
pub fn tick_count(mut self, count: u32) -> Self
Configure the tick count for a Discrete slider. The
IntUI default paints n evenly spaced tick marks above the
track (or to the leading side for vertical orientation).
pub fn style(mut self, style: impl SliderStyle) -> Self
Override the active SliderStyle for this widget instance
only.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set an accessible name for the slider, announced by screen readers. ARIA requires sliders to have a label; when none is set here the caller is responsible for labelling via a wrapping element.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter
wins and clears the others.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip driven by a registry key. The registry entry supplies title, body markup, optional shortcut chip and cascade links. Mutually exclusive with the other tooltip setters.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from an inline TooltipContent
value, bypassing the registry lookup. Mutually exclusive with the
other tooltip setters.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Uses the heavier tooltip_delay_heavy delay. Mutually exclusive
with the other tooltip setters.
SmoothSize
SmoothSize — auto-sizes the slot to fit the child's intrinsic
size, but tweens the change instead of jumping. The "empty panel
that suddenly must grow gracefully to accept new content" pattern.
ctx.add(
SmoothSize::new()
.axes(SmoothSizeAxes::Both)
.child(Panel::new().child(content_signal)),
);
For explicit size animation (target is a numeric signal you
already drive, e.g. a sidebar width), use the existing
FixedSize::new().width(animated_signal) + Signal::animate_to
pattern instead — that path doesn't need to measure the child every
frame.
Layout semantics
- The wrapper measures the child's natural size at the proposal each layout pass.
- When the natural size differs from the current animation target (above 0.5 px), kicks off a new tween.
size_that_fitsreturns the current animated value — what the wrapper actually occupies right now, not the target.- The child is always laid out at its full natural size and clipped
to the wrapper's smaller animated bounds. Same trick as
Collapse— the child's own internal layout doesn't reflow each frame, only the clip rect changes.
Reduced motion
Honours prefers-reduced-motion: snaps to the natural size each
layout pass instead of tweening.
Builder methods at a glance
axes, duration, easing, child, child_id
API reference
📖 Full rustdoc API for this module
pub enum SmoothSizeAxes
Which axes participate in the size tween. Use Width or Height
to leave the other axis tracking the child's natural size
instantly.
#![allow(unused)] fn main() { pub enum SmoothSizeAxes { /* variants */ } }
Variants
Width— Animate width changes only; height snaps to natural immediately.Height— Animate height changes only; width snaps to natural immediately.Both— Animate both width and height changes. Default.
pub struct SmoothSize
Wraps a child widget and animates the wrapper's reported size toward the child's current natural size whenever that size changes.
#![allow(unused)] fn main() { pub struct SmoothSize { /* fields */ } }
Methods
pub fn new() -> Self
New wrapper. Both axes animate by default.
pub fn axes(mut self, axes: SmoothSizeAxes) -> Self
Restrict the tween to one axis (the other tracks the child's natural size instantly).
pub fn duration(mut self, duration: Duration) -> Self
Override the tween duration. Default: MotionTokens::duration_normal.
pub fn easing(mut self, easing: Easing) -> Self
Override the easing curve. Default: MotionTokens::easing_standard.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
Snackbar

Snackbar — a transient, button-triggered floating notification surface.
A Snackbar pairs a trigger (a Button by default, or any custom
widget via .trigger(...)) with a dormant content surface. Activating
the trigger presents the surface as an OverlayPlacement::BottomCenter
overlay and dismisses it automatically after a configurable timeout
(default: 4 s). The surface stays until dismissed when .persistent()
is set. Only one snackbar can be shown at a time — presenting a second
one dismisses the first.
For richer, stackable, severity-aware notifications see the
Toast system, which also maintains a
persistent NotificationArchiveModel.
Accessibility
The content surface exposes Role::Alert with Live::Polite so
screen readers announce the notification without interrupting the user.
Supply .announcement(...) to give the alert a descriptive name
instead of the generic "notification" fallback.
use teksilo_widgets::{Snackbar};
use teksilo_i18n::lit;
use teksilo_widgets::primitives::TextWidget;
use teksilo_tokens::TextRole;
// In build():
ctx.add(
Snackbar::new(lit!("Undo"))
.content(TextWidget::new(lit!("File deleted.")).color(TextRole::TooltipText))
.announcement(lit!("File deleted."))
.auto_dismiss_after(std::time::Duration::from_secs(5)),
);
Builder methods at a glance
style, content, content_id, variant, enabled, dismiss_behavior, auto_dismiss_after, persistent, trigger, trigger_id, announcement
API reference
📖 Full rustdoc API for this module
pub struct Snackbar
A button-triggered transient notification surface.
Call .content(...) to supply the notification body, then add the
widget to the tree. The trigger label is shown as a Button (or a
custom widget via .trigger(...)); activating it presents the
content surface at the bottom center of the window.
#![allow(unused)] fn main() { pub struct Snackbar { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Create a snackbar whose default trigger button shows label.
pub fn style(mut self, style: impl teksilo_core::styles::SnackbarStyle) -> Self
Per-call style override for the snackbar surface chrome.
Replaces the theme-wide default SnackbarStyle for just this
instance.
pub fn content(mut self, content: impl Widget + 'static) -> Self
The snackbar body — the message (and optional inline action) shown on the floating surface.
The default surface is the high-contrast (dark) tooltip_bg,
the same one tooltips use, and it stays dark in light theme.
So any TextWidget you pass here must set
.color(TextRole::TooltipText) (and actions can use
TooltipText / TooltipShortcut) — the default TextRole::Primary
is dark and renders nearly invisible on the dark surface in light
theme. If you install a light-surface SnackbarStyle, color the
content to match that instead.
pub fn content_id(mut self, id: WidgetId) -> Self
Supply the notification body by WidgetId (already added to the
tree). Mutually exclusive with .content(...).
pub fn variant(mut self, variant: ButtonVariant) -> Self
Override the default trigger ButtonVariant (default: Plain).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state of the trigger, statically or reactively.
pub fn dismiss_behavior(mut self, dismiss: DismissBehavior) -> Self
Override the overlay dismiss behavior (default: ClickOutside).
pub fn auto_dismiss_after(mut self, duration: Duration) -> Self
Set the auto-dismiss timeout. The overlay is removed after this duration without user interaction (default: 4 s).
pub fn persistent(mut self) -> Self
Keep the snackbar visible until explicitly dismissed; disables the auto-dismiss timeout.
pub fn trigger(mut self, trigger: impl Widget + 'static) -> Self
Replace the default Button trigger with a custom widget. The
widget is wired for tap, keyboard (Enter/Space), and AT Click
activation automatically.
pub fn trigger_id(mut self, id: WidgetId) -> Self
Supply the custom trigger by WidgetId (already added to the tree).
pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self
Screen-reader announcement string — used as the Alert's
accessible name when the snackbar appears. Without this
the surface falls back to the generic a11y_snackbar_name
i18n string, which says "notification" but can't describe
the specific message. Set this whenever the snackbar
conveys information the user needs to hear (errors,
confirmations, status changes).
Spacer

Spacer — an invisible, flexible gap that claims all available space on the container's main axis.
Place a Spacer inside an HStack or
VStack to push adjacent siblings to opposite
edges; flank a child with two spacers to centre it. A spacer carries flex
weight 1.0 and zero wanted size, so it soaks up leftover slack without
imposing a cross-axis floor. min_length sets a hard
minimum so the gap never collapses below a fixed amount under tight layout.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{HStack, Spacer, TextWidget}; use teksilo_i18n::lit; // Title hugs the leading edge, badge is pushed to the trailing edge. let _row = HStack::new() .child(TextWidget::new(lit!("Title"))) .child(Spacer::new()) .child(TextWidget::new(lit!("NEW"))); }
Builder methods at a glance
min_length
API reference
📖 Full rustdoc API for this module
pub struct Spacer
An invisible, flexible gap that claims a container's leftover main-axis space.
#![allow(unused)] fn main() { pub struct Spacer { /* fields */ } }
Methods
pub fn new() -> Self
Create a spacer with no minimum length (collapses fully when the container has no slack to give).
pub fn min_length(mut self, min: f32) -> Self
Set a hard floor, in logical pixels, on the spacer's main-axis size.
The container still adds its slack share on top; the floor only matters when the container is too cramped to grant any slack. The cross axis is unaffected, so a horizontal spacer never inflates its stack's height.
SpinBox

SpinBox — numeric input with increment/decrement buttons.
A generic composite over SpinValue
(integer and floating-point primitives), pairing the
TextInputField editing
primitive with a stacked pair of up/down step buttons. Semantics
are a synthesis of Qt's QSpinBox / QDoubleSpinBox, WinUI 3's
NumberBox, GTK's GtkSpinButton, and the W3C ARIA
spinbutton role.
Behaviour
- Value binding: a
Signal<T>is the single source of truth. Typing and stepping update it; external writes re-format the editable text. - Commit model: the user can type freely (subject to the
per-character input filter). The value is committed on
Enteror on focus loss — at commit time the text is parsed, clamped into[min, max](or wrapped, perWrapMode), and reformatted. Invalid input reverts to the last known good value. - Keyboard:
Up/Down→ ±single_stepPageUp/PageDown→ ±page_step(default:10 × single_step)Enter→ commit (stays focused)Home/Endstay bound to the text cursor (Qt-compatible).
- Mouse wheel: adjusts by
single_step— wheel down decreases, wheel up increases, matchingQAbstractSpinBox,GtkSpinButtonand WinUI'sNumberBox. Gated bywheel_mode(default: only when focused, to avoid accidental scroll changes). - Buttons: up/down buttons stack to the right of the field
by default; can be hidden with
button_layout. - Special value text: when the current value equals
minandspecial_value_textis set, the field shows that string instead of the formatted number — Qt's "Auto" / "None" / "Unlimited" affordance. - Adaptive step: with
StepType::Adaptive, the effective step tracks the decimal magnitude of the current value (Qt'sAdaptiveDecimalStepType). Useful for values that span many orders of magnitude in the same control. - Locale: the number follows the active locale's decimal
separator, digits and minus sign
(
localized, on by default); thousands separators are opt-in (use_grouping, off by default, as in Qt). Display, commit parse and the per-character input filter all resolve from oneNumberPresentation, so they cannot disagree about which separator the field is using — a French user sees12,5, types12,5, and the numeric keypad's.still works. Rendering is a string transform over the value's ownDisplay, never anf64round-trip, so aSpinBox<i64>stays exact past 2^53. Turn it off for a number that is an identifier rather than a quantity (port, version component, database id). With noI18nManagerinstalled the active locale is the C locale and this is a no-op. - Custom formatter / parser: full override via
text_from_valueandvalue_from_text; together they let you implement currency, percentages with stored fraction, hex, duration, anything. A custom formatter/parser owns the whole convention — it is not re-punctuated by the locale layer.
Accessibility
The composite exposes itself as
Role::SpinButton
with numeric value, min, max, step, and jump properties set on
the AccessKit node; the AT receives
Increment,
Decrement,
SetValue, and
Focus actions. The
step buttons are structurally part of the SpinBox and publish
no separate a11y nodes.
Example
use teksilo::widgets::{SpinBox, WrapMode};
let font_size = ctx.signal(12_i32);
ctx.add(
SpinBox::new(font_size, 4, 72)
.single_step(1)
.page_step(10)
.suffix(" pt"),
);
let gain_db = ctx.signal(0.0_f32);
ctx.add(
SpinBox::new(gain_db, -60.0, 12.0)
.single_step(0.5)
.decimals(1)
.suffix(" dB")
.wrap_mode(WrapMode::Clamp),
);
Builder methods at a glance
style, single_step, page_step, decimals, localized, use_grouping, suffix, special_value_text, wrap_mode, step_type, button_layout, show_buttons, wheel_mode, width, width_chars, fill_width, label, placeholder, enabled, read_only, text_from_value, value_from_text, on_value_changed, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, value
API reference
📖 Full rustdoc API for this module
pub enum WrapMode
Out-of-range behavior when stepping past min or max.
Set via SpinBox::wrap_mode.
#![allow(unused)] fn main() { pub enum WrapMode { /* variants */ } }
Variants
Clamp— Clamp tomin/max(default).Wrap— Wrap around: pastmaxjumps tomin, pastminjumps tomax. Matches Qt'sQAbstractSpinBox::wrapping.
pub enum StepType
Step-size policy for each key/button press.
Set via SpinBox::step_type.
#![allow(unused)] fn main() { pub enum StepType { /* variants */ } }
Variants
Fixed— Always step bysingle_step(default).Adaptive— Step by the decimal power-of-ten immediately below the current value's magnitude — e.g. values 1–9 step by 1, 10–99 by 10, 100–999 by 100. Matches Qt'sAdaptiveDecimalStepType. Integer types honor the same rule using the magnitude of the absolute value.
pub enum WheelMode
When the mouse wheel is allowed to adjust the value.
Set via SpinBox::wheel_mode.
#![allow(unused)] fn main() { pub enum WheelMode { /* variants */ } }
Variants
Focused— Wheel adjusts only when the field is focused. Default — prevents accidental changes when the user is scrolling a larger surrounding view.Hover— Wheel adjusts whenever the pointer is over the widget.Disabled— Wheel never adjusts the value; events bubble to the surrounding scroll container.
pub enum WidthPolicy
How the SpinBox decides its horizontal size envelope.
Chosen via the width,
width_chars, and
fill_width builder methods — the enum
itself is the storage, not a separate public configuration
API.
#![allow(unused)] fn main() { pub enum WidthPolicy { /* variants */ } }
Variants
Pixels— Cap the widget at a fixed logical-pixel width. Default isDEFAULT_PREFERRED_WIDTH(120 dp), matching Qt'sQSpinBoxsizeHint.Chars— Size the widget to fit this many reference digits ('0') plus the configured suffix, padding, and step buttons. Measurement uses the theme font at build time.Fill— Let the widget expand horizontally to fill whatever space the parent offers. Equivalent to an infinite pixel cap.
pub struct SpinBox
Numeric input with step buttons. Generic over
SpinValue — pre-implemented for i32, i64, u32, u64,
usize, f32, and f64.
#![allow(unused)] fn main() { pub struct SpinBox<T: SpinValue> { /* fields */ } }
Methods
pub fn new(value: Signal<T>, min: T, max: T) -> Self
Construct a new SpinBox bound to value with the given
inclusive range. min must be ≤ max.
pub fn style(mut self, style: impl teksilo_core::styles::SpinBoxStyle) -> Self
Per-call style override. Higher precedence than the theme-wide
style_slots.spin_box slot.
pub fn single_step(mut self, step: T) -> Self
Set the step size for Up / Down / single wheel tick /
button tap.
pub fn page_step(mut self, step: T) -> Self
Set the step size for PageUp / PageDown. When unset,
defaults to 10 × single_step at build time.
pub fn decimals(mut self, decimals: u8) -> Self
Number of decimal places shown for floating-point types. Ignored for integer types.
pub fn localized(mut self, on: bool) -> Self
Whether the number follows the active locale's conventions — decimal separator, digits, and minus sign. On by default.
A French user sees 12,5, not 12.5, and can type either: the
commit path de-localizes before parsing, and the input filter
accepts both the locale's separator and the ASCII one, so a
numeric keypad still works.
Turn it off for a number that is an identifier rather than a quantity — a port number, a version component, a database id, a pixel offset in a file format. Those read wrong grouped or re-punctuated, and their conventional form is the C-locale one.
Localization is a string transform over the value's own
Display, not a round-trip through f64, so a SpinBox<i64>
keeps full precision past 2^53.
With no I18nManager installed the active locale resolves to the
C locale, so this is a no-op in tests and in apps that have not
opted into i18n.
pub fn use_grouping(mut self, on: bool) -> Self
Whether the displayed number carries thousands separators.
Off by default, matching Qt (QAbstractSpinBox:: isGroupSeparatorShown is false unless asked for).
Separators help a large read-only quantity and get in the way of
a field being typed into, so this is opt-in per SpinBox rather
than a locale-wide default. Grouping follows the locale's own
group sizes, including the Indic lakh system (12,34,567).
Has no effect when localized is off.
pub fn suffix(mut self, text: impl Into<String>) -> Self
Qt-style non-editable trailing unit (e.g. " %", " px",
" dB"). Rendered flush-right inside the field's border;
the caret cannot enter it.
pub fn special_value_text(mut self, text: impl Into<LocalizedString>) -> Self
Text shown in place of the formatted value when the current
value equals min. Use for "Auto", "None", "Off",
"Unlimited" affordances where the minimum has special
semantics. When the field is focused the real number is
shown instead so the user can type.
pub fn wrap_mode(mut self, mode: WrapMode) -> Self
Set the out-of-range behavior when stepping past min or max
(default: Clamp).
pub fn step_type(mut self, step_type: StepType) -> Self
Set the step-size policy (default: Fixed). Use
StepType::Adaptive for values that span many orders of magnitude.
pub fn button_layout(mut self, layout: ButtonLayout) -> Self
Override the step-button layout (default: Stacked — stacked
up/down buttons to the right of the field).
pub fn show_buttons(mut self, show: bool) -> Self
Convenience wrapper over button_layout:
true → ButtonLayout::Stacked, false → ButtonLayout::Hidden.
Matches the Int UI guideline that SpinBoxes in dense forms
often hide the step buttons to reduce visual noise and let
keyboard / wheel carry the affordance — pass
.show_buttons(false) on those call sites.
pub fn wheel_mode(mut self, mode: WheelMode) -> Self
Set when the mouse wheel adjusts the value (default: Focused —
only when the inner field holds focus).
pub fn width(mut self, width: f32) -> Self
Cap the widget's horizontal size at a fixed logical-pixel
width. If the parent offers less, the SpinBox shrinks (down
to the internal 72 dp / 48 dp floor that keeps the buttons
and field from overlapping). Default: 120 dp, matching Qt
QSpinBox sizeHint and Int UI form density.
#![allow(unused)] fn main() { use teksilo_widgets::SpinBox; use teksilo_core::signal::Signal; let v = Signal::new(0_i32); let _w = SpinBox::new(v.clone(), 0, 9999).width(80.0); // narrow let _w = SpinBox::new(v.clone(), 0, 9999).width(200.0); // wider let _w = SpinBox::new(v.clone(), 0, 9999).fill_width(); // stretch to parent let _w = SpinBox::new(v.clone(), 0, 9999).width_chars(5); // "fits 5 digits" }
pub fn width_chars(mut self, chars: u32) -> Self
Size the widget to fit exactly chars reference digits plus
the configured suffix, padding, and step buttons. The
measurement uses the actual theme font at build time (same
SharedTypesetter the field draws with), so values stay
right under runtime theme switches and HiDPI scale changes.
#![allow(unused)] fn main() { use teksilo_widgets::SpinBox; use teksilo_core::signal::Signal; let port = Signal::new(8080_i32); let pct = Signal::new(0_i32); let _w = SpinBox::new(port, 0, 65_535).width_chars(5); // 5 digits let _w = SpinBox::new(pct, 0, 100).suffix(" %").width_chars(3); // 3 + " %" }
pub fn fill_width(mut self) -> Self
Let the widget expand to fill the horizontal space offered
by its parent, instead of capping at width.
Use inside toolbars, inspector panels, or an
Expand::horizontal column that should stretch with the
surrounding layout.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the accessible name announced by screen readers as the
control's label. ARIA requires spin buttons to have a label;
when none is set here the caller is responsible for labelling
via a wrapping element or access_label.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Set the placeholder text shown in the field when it is empty.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to
the arena at build time via
ctx.enabled_when(spinbox_id, self.enabled.clone()).
pub fn read_only(mut self, read_only: bool) -> Self
Prevent the user from typing in the field while still allowing keyboard and button stepping.
pub fn text_from_value(mut self, f: impl Fn(T) -> LocalizedString + 'static) -> Self
Override the value → display-string conversion. Receives the
raw value; returns whatever string should appear in the
field. Suffix and special_value_text still apply on top of
the returned string.
pub fn value_from_text(mut self, f: impl Fn(&str) -> Option<T> + 'static) -> Self
Override the parse step. Receives the field's raw text
(without the suffix, which is never part of the editable
content); returns Some(value) to accept or None to
reject. Invalid input reverts to the last good value on
commit.
pub fn on_value_changed(mut self, f: impl Fn(T, &mut EventContext) + 'static) -> Self
Closure fired each time the value is committed (keyboard
step, button tap, wheel tick, Enter, blur). Bound observers
on the value signal also see every change; use this hook
when the caller needs an EventContext (e.g. to fire an
intent).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — each setter
clears the other two so the last call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up by registry key.
The key must match a TooltipContent
registered in the application's tooltip registry. Mutually
exclusive with tooltip,
rich_tooltip_content, and
composite_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip with inline content (no registry key
required). Mutually exclusive with tooltip,
rich_tooltip, and
composite_tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget
tree. Mutually exclusive with tooltip,
rich_tooltip, and
rich_tooltip_content.
pub fn value(&self) -> Signal<T>
The bound numeric value signal.
Spinner

Spinner — a shader-driven circular-arc loading indicator.
Uses the same per-slot uniform-buffer pipeline as
ProgressBar::indeterminate (an
AnimatedQuadKind variant), so per-frame cost is one
queue.write_buffer(64 B) + draw_indexed — the widget's paint()
does not re-run between frames and there's no signal-dirty-mark
cascade.
#![allow(unused)] fn main() { use teksilo_widgets::Spinner; use teksilo_tokens::TextRole; use teksilo_i18n::lit; let _s = Spinner::new(24.0) .color(TextRole::Secondary) .label(lit!("Loading")); }
Defaults match the typical CSS spinner: a quarter-circle (90°) arc rotating clockwise from the top, completing one full rotation every 900 ms.
Honours prefers-reduced-motion: registers no animated quad and
falls back to a static three-quarter arc — the indicator is still
visible (so the user can tell the surface is busy) but doesn't
rotate.
Builder methods at a glance
period, arc_fraction, stroke_fraction, color, label
API reference
📖 Full rustdoc API for this module
pub struct Spinner
A circular-arc loading indicator driven by a GPU shader quad.
Decorative — pair with .label to give screen readers
context. Honours prefers-reduced-motion by falling back to a static
three-quarter arc.
#![allow(unused)] fn main() { pub struct Spinner { /* fields */ } }
Methods
pub fn new(size: f32) -> Self
Construct a spinner of the given square edge length (logical pixels). Use small sizes (16–24) for inline spinners and larger (32–64) for full-content placeholders.
pub fn period(mut self, period: Duration) -> Self
Override the rotation period. Default: 900 ms (one full rotation per period).
pub fn arc_fraction(mut self, arc_fraction: f32) -> Self
Override the arc length as a fraction of the full circle. Default: 0.25 (a quarter-circle "comet tail" arc).
pub fn stroke_fraction(mut self, stroke_fraction: f32) -> Self
Override the stroke thickness as a fraction of the spinner's edge length. Default: 0.12 (so a 24-px spinner has a ~3-px stroke).
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Override the arc colour. Default: TextRole::Secondary so the
spinner picks up theme-aware text-tier styling.
pub fn label(mut self, text: impl Into<LocalizedString>) -> Self
Accessible name (e.g. "Loading", "Uploading file"). Without this, screen readers announce a bare "progress indicator" with no context.
SplitButton

SplitButton — a button split into two regions sharing a single frame.
The left region is the default action: it shows the label of the
currently-selected item and, on click, fires that item's command
(behaving like a regular Button). The right
region is a narrow chevron zone that, on click, opens a
MenuList of related actions. Picking an
action from the dropdown fires it and promotes its index to become the
new default for the session (IntelliJ's "remember last used"
convention).
SplitButton reuses MenuItem verbatim
for the dropdown rows — the caller passes real MenuItem values via
.item(...), so icons, shortcut labels, enabled flags, and separators
all come for free.
#![allow(unused)] fn main() { use teksilo_widgets::{SplitButton, MenuItem, ButtonVariant}; use teksilo_i18n::lit; use teksilo_core::Intent; let _w = SplitButton::new() .item(MenuItem::new(lit!("Run")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.run")))) .item(MenuItem::new(lit!("Run Tests")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.run-tests")))) .separator() .item(MenuItem::new(lit!("Debug")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.debug")))) .variant(ButtonVariant::Plain); }
Builder methods at a glance
new_static, item, separator, variant, icon, style, text_style, text_role, enabled, initial_selected, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, chevron_tooltip, chevron_rich_tooltip, chevron_rich_tooltip_content, chevron_composite_tooltip
API reference
📖 Full rustdoc API for this module
pub const SPLIT_BUTTON_HEIGHT
SplitButton design tokens.
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_HEIGHT: f32 = 24.0; }
pub const SPLIT_BUTTON_MIN_WIDTH
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_MIN_WIDTH: f32 = 72.0; }
pub const SPLIT_BUTTON_PADDING_HORIZONTAL
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_PADDING_HORIZONTAL: f32 = 14.0; }
pub const SPLIT_BUTTON_PADDING_VERTICAL
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_PADDING_VERTICAL: f32 = 0.0; }
pub const SPLIT_BUTTON_CORNER_RADIUS
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_CORNER_RADIUS: f32 = 4.0; }
pub const SPLIT_BUTTON_BORDER_WIDTH
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_BORDER_WIDTH: f32 = 1.0; }
pub const SPLIT_BUTTON_CHEVRON_WIDTH
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_CHEVRON_WIDTH: f32 = 22.0; }
pub const SPLIT_BUTTON_DIVIDER_WIDTH
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_DIVIDER_WIDTH: f32 = 1.0; }
pub const SPLIT_BUTTON_CHEVRON_ICON_SIZE
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_CHEVRON_ICON_SIZE: f32 = 12.0; }
pub const SPLIT_BUTTON_ICON_LABEL_GAP
Gap between an optional main-region leading icon and the label.
#![allow(unused)] fn main() { pub const SPLIT_BUTTON_ICON_LABEL_GAP: f32 = 6.0; }
pub struct SplitButton
A button split into a default-action region and a chevron dropdown region.
See the module-level documentation for a usage overview.
#![allow(unused)] fn main() { pub struct SplitButton { /* fields */ } }
Methods
pub fn new() -> Self
Standard SplitButton: picking an item from the dropdown both fires the item's action and promotes it to become the new default for the session. The main region's label and click action update to match the most recently picked item.
pub fn new_static() -> Self
Static-default SplitButton: the main region is pinned to
initial_selected (default 0) and never changes after the
user picks something from the dropdown. Picking an item still
fires that item's action — only the promotion is skipped.
Use this when the main region represents a semantically fixed primary action (e.g. "Commit") and the dropdown offers related variants ("Commit and Push", "Commit and Push to…") that should not displace the primary.
pub fn item(mut self, item: MenuItem) -> Self
Add a menu item. The item is reused verbatim as a row of the dropdown, and its label + action are also used to drive the main region (when its index is the current default).
pub fn separator(mut self) -> Self
Add a separator row in the dropdown. Separators are skipped when
computing item indices for initial_selected.
pub fn variant(mut self, variant: ButtonVariant) -> Self
Set the visual style variant (filled, plain, ghost, …) for the entire
button frame. Mirrors the same variants as
Button::variant.
pub fn icon(mut self, icon: IconWidget) -> Self
Set a leading icon for the main (default-action) region, rendered before
the label (mirrors Button::icon with
IconLocation::Leading). Unlike the per-row MenuItem::icons, this glyph
is fixed regardless of which item is the current default — use it for a
stable action affordance (e.g. a "+" add glyph).
The icon's tint follows the main-region label (the variant/interaction
cascade, or text_role when overridden), so any
colour set on the passed IconWidget is replaced — same contract as
Button. Its size is left alone, so .icon_size(..) on the caller's
widget is honoured.
pub fn style(mut self, style: impl SplitButtonStyle) -> Self
Override the Tier-3 frame chrome for this instance. Takes precedence
over theme.style_slots.split_button and the built-in
RecipeSplitButtonStyle.
pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self
Override the main-region label text style (font, size, weight).
Accepts a TextStyleRole, a TextStyle, or a Signal of either.
Default (unset) is the inner TextWidget default — e.g. pass
TextStyleRole::BodyBold for a bold default action.
pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the control's text colour — the main-region label, its
leading icon, and the chevron, which the
variant/interaction cascade tints together. Accepts Color, a role,
or a Signal of either. Default (unset) is that cascade; setting this
replaces it wholesale (loses hover/disabled tint).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn initial_selected(mut self, index: usize) -> Self
Which item index (counting only items, not separators) should be the initial default. Defaults to 0.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a tooltip to the main (default-action) region. Same hover
delay as Button::tooltip.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip to the main region.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip to the main region driven by inline TooltipContent.
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip to the main region.
pub fn chevron_tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Override the tooltip shown on hover over the trailing chevron region. When unset, the chevron gets a default "Show dropdown menu" tooltip so its affordance isn't silent.
pub fn chevron_rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip to the chevron region.
pub fn chevron_rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip to the chevron region driven by inline TooltipContent.
pub fn chevron_composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip to the chevron region.
Splitter

N-pane split container with draggable, collapsible dividers.
Splitter arranges N ≥ 2 panes along one axis (per Orientation)
with N − 1 grabbable handles between them — the Qt QSplitter
model. All layout state (per-pane size / min / max / stretch /
collapsed) lives in a shared, cloneable SplitterModel; the app
holds a clone to read, mutate, persist, and import/export, while the
widget renders it and reacts to the model's version signal.
Strengths carried over from the old two-pane SplitView: anti-jump
drag, keyboard resize, Role::Splitter accessibility, per-pane content
clipping, RTL-correct horizontal layout. New: N panes, per-pane
stretch (container-resize policy), animated collapse with four triggers
(programmatic / double-click / drag-past-min snap / keyboard), a Tier-3
SplitterStyle, and serializable import/export. Intended as the
building block for a future DockingLayout.
let model = SplitterModel::from_panes(vec![
PaneDescriptor::new().size(220.0).min_size(160.0).stretch(0.0).collapsible(true),
PaneDescriptor::new().stretch(1.0).min_size(320.0),
PaneDescriptor::new().size(280.0).stretch(0.0).collapsible(true),
], Orientation::Horizontal);
Splitter::new(model.clone())
.pane(sidebar).pane(editor).pane(inspector)
.pane_label(0, tr!(sidebar()));
Builder methods at a glance
pane, pane_id, child, pane_label, style, enabled
API reference
📖 Full rustdoc API for this module
pub struct Splitter
An N-pane resizable split container driven by a SplitterModel.
See the module-level documentation for a usage overview and
constructor patterns.
#![allow(unused)] fn main() { pub struct Splitter { /* fields */ } }
Methods
pub fn new(model: SplitterModel) -> Self
Create a Splitter bound to the given model. Panes must be appended
with pane in model order.
pub fn pane(mut self, widget: impl Widget + 'static) -> Self
Append a content pane (model order). Call once per pane; the count
must match model.pane_count().
pub fn pane_id(mut self, id: WidgetId) -> Self
Append a pre-registered content pane by id.
pub fn child(self, widget: impl Widget + 'static) -> Self
teksu! ergonomic alias for pane: a bare child in a
Splitter { ... } block lowers to .child(...).
pub fn pane_label(mut self, index: usize, label: impl Into<Prop<String>>) -> Self
Set an accessible region name for pane index (locale-reactive).
Labeled panes become a named Role::Group; unlabeled panes stay
AT-transparent (their content represents itself).
pub fn style(mut self, style: impl SplitterStyle) -> Self
Override the active SplitterStyle for this instance only.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable handle dragging, statically or reactively. When
false, divider handles are rendered inert — the pane layout is
still valid but the user cannot resize panes.
pub struct PaneDescriptor
Per-pane configuration passed to SplitterModel::from_panes /
SplitterModel::insert_pane. Public fields + Default so it can
be built with struct-literal ..Default::default() syntax, or via the
fluent setters.
#![allow(unused)] fn main() { pub struct PaneDescriptor { /* fields */ } }
Methods
pub fn new() -> Self
pub fn size(mut self, size: f32) -> Self
pub fn min_size(mut self, min: f32) -> Self
pub fn max_size(mut self, max: f32) -> Self
pub fn stretch(mut self, stretch: f32) -> Self
pub fn collapsible(mut self, collapsible: bool) -> Self
pub fn collapsed(mut self, collapsed: bool) -> Self
pub fn collapsed_size(mut self, px: f32) -> Self
Size a collapsed pane folds down to (default 0). See
collapsed_size.
pub fn visible(mut self, visible: bool) -> Self
pub struct PaneSnapshot
Immutable per-pane view handed to the pure distribute sizing
function (the internal splitter::distribute engine).
#![allow(unused)] fn main() { pub struct PaneSnapshot { /* fields */ } }
pub struct PaneState
Persistable per-pane layout state. Captures the user-controllable
values (size + collapsed); structural config (min/max/stretch/
collapsible) is app-declared and not serialized — Qt saveState
parity.
#![allow(unused)] fn main() { pub struct PaneState { /* fields */ } }
pub struct SplitterState
Full serializable snapshot of a SplitterModel's sizes + collapsed
flags. Round-trips through SplitterModel::export_state /
import_state and implements
Versioned so apps persist it through
SettingsFile<SplitterState> + Migrator (TOML).
#![allow(unused)] fn main() { pub struct SplitterState { /* fields */ } }
pub struct SplitterModel
A shared, cloneable handle to a splitter's layout state. Clone =
share-by-handle (cheap Rc bump).
#![allow(unused)] fn main() { pub struct SplitterModel(Rc<RefCell<SplitterModelInner>>); }
Methods
pub fn new(n: usize, orientation: Orientation) -> Self
n equal-share panes (each stretch = 1, min = SPLITTER_MIN_PANE_SIZE).
pub fn from_panes(panes: Vec<PaneDescriptor>, orientation: Orientation) -> Self
Build from explicit per-pane descriptors.
pub fn handle_count(&self) -> usize
Number of distinct handles to this model (1 = unshared).
pub fn set_stored_size(&self, index: usize, size: f32)
pub fn set_stored_size_silent(&self, index: usize, size: f32)
Like set_stored_size but without a version
bump — for writes made from inside a layout/effect pass that is already
relaying out (e.g. capturing the displayed size as the collapse
reference), where a bump would re-enter the effect.
pub fn set_pair_sizes(&self, index: usize, size_a: f32, size_b: f32)
Set both sides of handle index (panes index and index+1) in
one mutation — a single version bump, so a drag produces exactly
one relayout per move.
pub fn set_min_size(&self, index: usize, min: f32)
pub fn set_max_size(&self, index: usize, max: Option<f32>)
pub fn set_stretch(&self, index: usize, stretch: f32)
pub fn set_collapsible(&self, index: usize, collapsible: bool)
pub fn set_collapsed(&self, index: usize, collapsed: bool)
Programmatically collapse/expand pane index, animated. Ignores
the collapsible flag (that flag only gates interactive triggers).
pub fn set_collapsed_immediate(&self, index: usize, collapsed: bool)
Collapse/expand pane index instantly (no tween). Used by the
drag handlers — the pointer is already the motion.
pub fn toggle_collapsed(&self, index: usize)
Toggle pane index's collapsed state, animated.
pub fn set_collapsed_size(&self, index: usize, px: f32)
Set the size pane index folds down to when collapsed (default 0).
See PaneDescriptor::collapsed_size. No version bump on its own — it
only affects the next collapse.
pub fn set_pane_visible(&self, index: usize, visible: bool)
Show or hide pane index (animated). A hidden pane removes both the
pane and an adjacent gutter from the layout — it reads as absent,
unlike a collapsed pane (which keeps its grabbable gutter). The pane
must be pre-mounted in the Splitter; this is the reactive "add /
remove a pane from a fixed set" trick (no rebuild).
pub fn is_pane_visible(&self, index: usize) -> bool
pub fn consume_animate_flag(&self) -> bool
Read-and-reset the "animate the next collapse change?" latch. The
widget's collapse effect calls this once per version bump; it
resets to true so the default (programmatic) path animates.
pub fn insert_pane(&self, index: usize, desc: PaneDescriptor)
Insert a pane at index (clamped to [0, len]). A None
initial_size takes the average of the existing panes' sizes; the
next layout rebalances. The app must rebuild the Splitter widget
to supply the new pane's content (retained-mode: changing a
container's child set is a rebuild; the model keeps the
persistent size/collapse state across it).
pub fn remove_pane(&self, index: usize)
Remove the pane at index (no-op if out of range). The app must
rebuild the Splitter widget to drop the corresponding content.
pub fn replace_pane_desc(&self, index: usize, desc: PaneDescriptor)
Replace the metadata of pane index (keeps its current size unless
the descriptor specifies one).
pub fn set_gutter_thickness(&self, thickness: f32)
pub fn set_snap_offset(&self, offset: f32)
pub fn set_keyboard_step_px(&self, step: f32)
pub fn set_orientation(&self, orientation: Orientation)
pub fn pane_count(&self) -> usize
pub fn stored_size(&self, index: usize) -> f32
pub fn min_size(&self, index: usize) -> f32
pub fn max_size(&self, index: usize) -> Option<f32>
pub fn stretch(&self, index: usize) -> f32
pub fn is_collapsible(&self, index: usize) -> bool
pub fn collapsed_size(&self, index: usize) -> f32
The size pane index folds to when collapsed (default 0). See
PaneDescriptor::collapsed_size.
pub fn is_collapsed(&self, index: usize) -> bool
pub fn orientation(&self) -> Orientation
pub fn gutter_thickness(&self) -> f32
pub fn snap_offset(&self) -> f32
pub fn keyboard_step_px(&self) -> f32
pub fn version(&self) -> Signal<u64>
The reactive version signal. The Splitter widget binds this at
BindingLevel::Relayout.
pub fn pane_snapshots(&self) -> Vec<PaneSnapshot>
Immutable per-pane snapshot for the pure sizing engine.
pub fn export_state(&self) -> SplitterState
Snapshot the per-pane sizes + collapsed flags into a serializable
SplitterState.
pub fn import_state(&self, state: &SplitterState) -> bool
Restore sizes + collapsed flags from a SplitterState. Returns
false (and changes nothing) if the pane count doesn't match — the
structural config must be reconstructed first. Restoration is
instant (collapsed panes don't animate open on load).
StandardListItem

Canonical row layout for ListView / TreeView delegates.
Two widgets:
StandardListItem— primary line[checkbox?] [leading_slot?] [center_slot?] [label] [Spacer] [trailing_slot?]with optional subtitle line[subtitle_leading_slot?] [subtitle] [Spacer] [subtitle_trailing_slot?].StandardTreeItem— same plus depth-driven indent + chevron column (always reserved, even for leaves, so labels at the same depth align).
Selection / hover / pressed background mirrors MenuItem /
ComboBox: rounded RectWidget (item_corner_radius: 8.0),
horizontally inset so corners are visible, theme-driven via
SurfaceRole so light/dark/custom themes propagate without
rebuild.
Canonical TreeView wiring
use teksilo::data::{TreeCheckedModel, TreeModel};
use teksilo::widgets::{StandardTreeItem, TreeView};
let tree: TreeModel<Item> = ...;
let checks = TreeCheckedModel::new(tree.clone());
TreeView::new_with_context(tree, move |item, entry, selected, ctx| {
let mut row = StandardTreeItem::new(lit!(item.title.clone()))
.from_entry(entry)
.selected(selected)
.leading_slot(IconWidget::from_svg(FOLDER_ICON).icon_size(16.0))
.on_toggle_rc(ctx.toggle_callback());
if entry.has_children {
row = row.tristate_checkbox(checks.signal_for(entry.node_id));
} else {
row = row.checkbox(checks.bool_signal_for(entry.node_id));
}
Box::new(row)
})
.row_click_expands(false) // chevron is the only toggle target
Wiring rules:
TreeView::new_with_contextexposes aTreeRowContextthat yieldstoggle_callback()for chevron clicks. Pair with.row_click_expands(false)so body clicks don't also toggle.- For tristate parent rows, bind to
signal_for(node). For leaves, preferbool_signal_for(node)— the model's bool ↔ tristate bridge runs ancestor recompute on writes either way. from_entry(&FlatEntry)is shorthand for.depth(entry.depth).has_children(entry.has_children) .is_expanded(entry.is_expanded).
Accessibility
StandardListItem.accessibility() sets the row's name (label
only) and description (subtitle, if any) — structural role +
position/level/expanded/selected come from the parent's
ListItemA11y / TreeRowA11y wrapper. The embedded Checkbox
receives an access_label* override carrying the row label so
screen readers announce "checkbox, checked, [label]" rather than
a nameless Role::CheckBox. The chevron's TwistArrow is
decorative (set_hidden); the row's expanded state is owned by
the wrapper.
Builder methods at a glance
style, subtitle, leading_slot, leading_slot_boxed, center_slot, center_slot_boxed, trailing_slot, trailing_slot_boxed, subtitle_leading_slot, subtitle_leading_slot_boxed, subtitle_trailing_slot, subtitle_trailing_slot_boxed, checkbox, tristate_checkbox, selected, enabled, label_style, subtitle_style, label_color, subtitle_color, interaction_signal, label_slot, label_overflow, subtitle_overflow, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct StandardListItem
Canonical single-line or two-line row layout for use in a ListView.
See the module-level documentation for the full slot layout and
wiring rules.
#![allow(unused)] fn main() { pub struct StandardListItem { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Create a list item with the given primary label.
pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self
Per-call style override. Replaces the theme-wide default
StandardItemStyle for just this row instance.
pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self
Set an optional secondary line below the primary label.
pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self
Leading slot — placed AFTER the optional checkbox, BEFORE the
center slot. Typical: IconWidget, avatar, color swatch.
pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of leading_slot.
pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self
Center slot — placed BETWEEN the leading slot and the label.
Typical: status dot, colored category bar, drag-handle gripper,
key-binding chip. Distinct from leading_slot: leading is the
row's icon identity, center is label-adjacent decoration.
pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of center_slot.
pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Trailing slot — placed AFTER the flex Spacer on the primary line. Typical: badge, count, status pill, secondary IconButton.
pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of trailing_slot.
pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self
Leading slot for the subtitle line. No-op without subtitle(...).
pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of subtitle_leading_slot.
pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Trailing slot for the subtitle line. No-op without subtitle(...).
pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of subtitle_trailing_slot.
pub fn checkbox(mut self, checked: Signal<bool>) -> Self
Optional two-state checkbox at the start of the row.
Mutually exclusive with tristate_checkbox — last call wins.
pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self
Optional tri-state checkbox bound to Signal<CheckState>.
Cycles Unchecked → Checked → Indeterminate. Mutually
exclusive with checkbox — last call wins.
pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self
Set the selection state, statically or reactively via a bound
Signal<bool>.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively via a bound
Signal<bool> / Prop<bool>.
pub fn label_style( mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>, ) -> Self
Override the label's text style (font, size, weight). Accepts a
TextStyleRole, a TextStyle, or a Signal of either. Default is
TextStyleRole::Body.
pub fn subtitle_style( mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>, ) -> Self
Override the subtitle's text style. Default is TextStyleRole::Small.
pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the label's text color. Accepts Color, a role, or a
Signal of either. Default (unset) is enabled-derived
(Primary / Disabled); setting this replaces that cascade.
pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the subtitle's text color. Default (unset) is
TextRole::Secondary.
pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self
Truncate the primary label instead of wrapping it. Default (unset) is
TextOverflow::Wrap.
A wrapping label reports its full intrinsic width, so on a row too
narrow to hold it the primary HStack is over-constrained and the
trailing_slot is pushed past the row's edge.
Set TextOverflow::Ellipsis(..) on rows whose trailing actions must
stay reachable: the label then shrinks and truncates within the row.
Share the row's interaction state, so a caller can reveal controls on
hover.
A row that shows its actions only while the pointer is over it is a standard pattern — a search result offering replace and dismiss, a list offering remove — and it cannot be built from outside without knowing when the row is hovered. The row already tracks that; this is the handle on it.
The signal is written by the row, not read: pass one in, watch it, and gate a trailing slot on it. Reserve the space the controls will take, or the row reflows under the pointer that is trying to hit them.
pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self
Draw this instead of the label's text, keeping the label as the row's accessible name.
For a row whose label is not plain text: a search result with the matched
run picked out of its excerpt, a diff line, anything built from runs rather
than from a string. The label passed to new is still what
accessibility reports, so the row keeps a name a screen reader can read —
which is the whole reason this is a replacement for the drawing and not a
replacement for the label.
The widget is laid out where the text would have been, so it inherits the
row's spacing and its place beside the leading and trailing slots.
label_style, label_color and
label_overflow do not reach it: it draws itself.
pub fn label_overflow(mut self, overflow: TextOverflow) -> Self
pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self
Truncate the subtitle instead of wrapping it. Default (unset) is
TextOverflow::Wrap.
Same rationale as label_overflow — and the
usual culprit, since subtitles carry long secondary text (file paths,
URLs). TextOverflow::Ellipsis(EllipsisMode::Middle) suits a path: it
keeps both the root and the file name legible.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip shown after the standard hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter called
wins and clears the other slots.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up from the global tooltip registry by key.
Mutually exclusive with tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter called
wins and clears the other slots.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from an inline TooltipContent
value (no registry lookup required).
Mutually exclusive with tooltip,
rich_tooltip, and
composite_tooltip — the last setter called
wins and clears the other slots.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with tooltip,
rich_tooltip, and
rich_tooltip_content — the last setter
called wins and clears the other slots.
pub struct StandardTreeItem
Canonical row layout for a TreeView — StandardListItem plus
a depth-driven indent column and an always-reserved chevron column.
See the module-level documentation for the canonical TreeView
wiring pattern and wiring rules.
#![allow(unused)] fn main() { pub struct StandardTreeItem { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Create a tree item with the given primary label.
pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self
Forwarded to the inner StandardListItem — see its
subtitle.
See [StandardListItem::interaction_signal]: the row's own hover/press
state, for a caller revealing controls on hover.
pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self
See [StandardListItem::label_slot]: draw this instead of the label's
text, keeping the label as the row's accessible name.
pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self
pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self
Forwarded to the inner StandardListItem — see its
leading_slot.
pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of leading_slot.
pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self
Forwarded to the inner StandardListItem — see its
center_slot.
pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of center_slot.
pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Forwarded to the inner StandardListItem — see its
trailing_slot.
pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of trailing_slot.
pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self
Forwarded to the inner StandardListItem — see its
subtitle_leading_slot.
pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of
subtitle_leading_slot.
pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Forwarded to the inner StandardListItem — see its
subtitle_trailing_slot.
pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self
Box<dyn Widget> variant of
subtitle_trailing_slot.
pub fn checkbox(mut self, checked: Signal<bool>) -> Self
Forwarded to the inner StandardListItem — see its
checkbox.
pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self
Forwarded to the inner StandardListItem — see its
tristate_checkbox.
pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self
Set the selection state, statically or reactively via a bound
Signal<bool>. Forwarded to the inner StandardListItem — see
its selected.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively via a bound
Signal<bool> / Prop<bool>. Forwarded to the inner
StandardListItem.
pub fn label_style( mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>, ) -> Self
Override the label's text style. Forwarded to the inner
StandardListItem — see its
label_style.
pub fn subtitle_style( mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>, ) -> Self
Override the subtitle's text style. Forwarded to the inner
StandardListItem — see its
subtitle_style.
pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the label's text color. Forwarded to the inner
StandardListItem — see its label_color(...).
pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Override the subtitle's text color. Forwarded to the inner
StandardListItem — see its subtitle_color(...).
pub fn label_overflow(mut self, overflow: TextOverflow) -> Self
Truncate the primary label instead of wrapping it. Forwarded to the
inner StandardListItem — see its
label_overflow.
pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self
Truncate the subtitle instead of wrapping it. Forwarded to the inner
StandardListItem — see its
subtitle_overflow.
pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self
Per-call style override for the row chrome. Forwarded to the
inner StandardListItem — see its style(...) for the
precedence rules (per-call > theme.style_slots.standard_item >
RecipeStandardItemStyle).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip shown after the standard hover delay.
Forwarded to the inner StandardListItem — see its
tooltip.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up from the global tooltip registry by key.
Forwarded to the inner StandardListItem — see its
rich_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from an inline
TooltipContent value.
Forwarded to the inner StandardListItem — see its
rich_tooltip_content.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Forwarded to the inner StandardListItem — see its
composite_tooltip.
pub fn depth(mut self, depth: usize) -> Self
Set the indent depth (0 = root level). Each level adds one
STANDARD_ITEM_TREE_INDENT_STEP of leading whitespace.
pub fn has_children(mut self, has: bool) -> Self
Declare whether the node has children, which determines whether the chevron column is interactive or decorative-only.
pub fn is_expanded(mut self, expanded: impl Into<Prop<bool>>) -> Self
Set the expanded state, statically or reactively via a bound
Signal<bool>.
pub fn from_entry(self, entry: &FlatEntry) -> Self
Convenience for the TreeView delegate path:
.from_entry(entry) sets depth + has_children + is_expanded.
pub fn on_toggle( mut self, f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Click handler for the chevron. Wired only when has_children
is true. Typical use: .on_toggle(ctx.toggle_callback()) from
a TreeRowContext (see TreeView::new_with_context).
The callback receives the firing EventContext so apps can
dispatch an intent (e.g. lazy-load children on expand), open
a dialog, or otherwise route the toggle through the framework
before mutating model state.
pub fn on_toggle_rc(mut self, f: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>) -> Self
Variant accepting an already-Rc'd callback. Useful when the
same callback is shared across multiple call sites without an
extra clone — e.g. TreeRowContext::toggle_callback() returns
this shape directly.
StatusBar

StatusBar — a horizontal chrome bar at the bottom of a window for status information.
The bar publishes Role::Status so assistive technology can discover it as
a status landmark. It is not a live region by default — use
announce_changes(true) only for bars that
surface transient messages worth reading aloud (e.g. "Saved"), not for bars
showing continuous data like cursor position or zoom level that would flood
the screen reader. Visual chrome (background, border, corner radius) is
delegated to an inner Panel.
#![allow(unused)] fn main() { use teksilo_widgets::StatusBar; use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; let _bar = StatusBar::new() .child(TextWidget::new(lit!("Ln 1, Col 1"))) .announce_changes(false); }
Builder methods at a glance
child, add_child, background, corner_radius, border_color, border_width, name, announce_changes
API reference
📖 Full rustdoc API for this module
pub const STATUS_BAR_HEIGHT
StatusBar design tokens.
#![allow(unused)] fn main() { pub const STATUS_BAR_HEIGHT: f32 = 22.0; }
pub const STATUS_BAR_PADDING_HORIZONTAL
#![allow(unused)] fn main() { pub const STATUS_BAR_PADDING_HORIZONTAL: f32 = 8.0; }
pub const STATUS_BAR_ITEM_GAP
#![allow(unused)] fn main() { pub const STATUS_BAR_ITEM_GAP: f32 = 2.0; }
pub struct StatusBar
A status bar for displaying information at the bottom of a window.
Visual chrome is delegated to an inner Panel. By default the bar
uses the SurfaceRole::Sunken surface with square corners (a bar
spanning the window edge shouldn't be rounded); override the surface
with background, the corners with
corner_radius, or add a frame with
border_color / border_width.
Accessibility: the bar publishes Role::Status (→ AT-SPI StatusBar,
macOS AXApplicationStatus, Windows UIA_StatusBarControlTypeId) so
it is discoverable as a status landmark. It is not a live region
by default — a status bar showing continuously-changing data (cursor
position, zoom level, word count) would otherwise flood the screen
reader. Call announce_changes(true) for a
bar that surfaces transient messages worth reading aloud ("Saved").
#![allow(unused)] fn main() { pub struct StatusBar { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty status bar with default styling (SurfaceRole::Sunken,
square corners, no live region).
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child widget by ID.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Override the background surface. Accepts Color, a
SurfaceRole, or a Signal<Color>.
Default (unset) is SurfaceRole::Sunken.
pub fn corner_radius(mut self, radius: impl Into<Prop<f32>>) -> Self
Override the corner radius. Accepts a static f32 or a reactive
Signal<f32>. Default (unset) is 0.0 — square corners.
pub fn border_color(mut self, color: impl Into<ColorProp>) -> Self
Override the border color. Accepts Color, a
BorderRole, or a Signal<Color>.
Only painted when border_width > 0.
pub fn border_width(mut self, width: impl Into<Prop<f32>>) -> Self
Override the border width. Accepts a static f32 or a reactive
Signal<f32>. Default (unset) is 0.0 — no border.
pub fn name(mut self, name: impl Into<Prop<String>>) -> Self
Override the accessible name announced for the bar. Accepts a
static string, a Signal<String>, or a tr!(...)
LocalizedString (locale-reactive).
Default (unset) is the localized "Status".
pub fn announce_changes(mut self, announce: bool) -> Self
Control whether content changes are announced by assistive tech.
Default false: the Role::Status landmark is published (still
navigable) but the bar is not a live region, so continuously-changing
data (cursor position, zoom, word count) doesn't flood the screen
reader. Set true to make it a Live::Polite region for bars that
surface transient messages worth reading aloud ("Saved").
Stepper

Stepper — a modern, embeddable step-flow widget (Material/Ant/Flutter
"stepper"), and Wizard, a thin modal launcher built on it.
A stepper shows a visible step-indicator strip above (or beside) a
content area driven by a Switcher, with a
footer of Back / Skip / Help / Next / Finish controls. It supports linear
and non-linear (clickable) navigation, optional + skippable steps, per
step validation gating, a generic chrome slot, and a
StepperController handle for programmatic reset / jump / introspection.
Data flow
The application owns its form state as Signals. A step's content factory
captures clones of those signals (write side); Step::complete_when
derives the Next gate from the same signals; and
Stepper::on_finish reads them back — plus the StepperController for
per-step introspection (visited / skipped) — to branch on the choices
made. There is no QVariant field registry: plain shared signals are the
cross-step channel.
#[derive(Clone)]
struct Form { name: Signal<String>, plan: Signal<Plan> }
let form = Form { name: Signal::new(String::new()), plan: Signal::new(Plan::Free) };
Stepper::new()
.step(Step::new(lit!("Account"))
.content({ let f = form.clone(); move || TextInput::new().text(f.name.clone()) })
.complete_when(form.name.map(|n| !n.is_empty())))
.step(Step::new(lit!("Plan"))
.content({ let f = form.clone(); move || plan_picker(f.plan.clone()) }))
.on_finish({ let f = form.clone(); move |_ctx, ctrl| {
match f.plan.get() { Plan::Free => {/* … */} Plan::Pro => {/* … */} }
let _ = ctrl.skipped(1);
}});
Builder methods at a glance
step, steps, controller, orientation, vertical, non_linear, circle_size, chrome, chrome_position, back_label, next_label, finish_label, skip_label, help, cancel, on_finish, enter_advances, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub enum StepperOrientation
Indicator-strip orientation for a Stepper.
#![allow(unused)] fn main() { pub enum StepperOrientation { /* variants */ } }
Variants
Horizontal— Markers in a row, content below (default).Vertical— Markers in a column on the leading side, content beside.
pub enum ChromePosition
Where the optional chrome slot (banner / sidebar) sits relative to the stepper body.
#![allow(unused)] fn main() { pub enum ChromePosition { /* variants */ } }
Variants
Leading— Leading column (left in LTR). Forced toTopin vertical orientation.Top— Banner above the stepper body.
pub struct Stepper
An embeddable multi-step flow widget. See the module docs for the
data-flow pattern and a usage example.
#![allow(unused)] fn main() { pub struct Stepper { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty Stepper. Append steps with step or
steps and provide a finish callback with
on_finish.
pub fn step(mut self, step: Step) -> Self
Append a single Step definition.
pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self
Append multiple Step definitions from an iterator.
pub fn controller(mut self, controller: StepperController) -> Self
Drive the stepper with an externally-held controller (for programmatic reset / jump / introspection). If omitted, the stepper creates its own.
pub fn orientation(mut self, orientation: StepperOrientation) -> Self
Set the indicator-strip orientation (horizontal or vertical).
pub fn vertical(mut self) -> Self
Shorthand for .orientation(StepperOrientation::Vertical).
pub fn non_linear(mut self, non_linear: bool) -> Self
Allow jumping between steps by clicking their indicators (the markers
become Role::Tab). Linear (default) markers are Role::ListItem.
pub fn circle_size(mut self, size: f32) -> Self
Override the marker circle diameter (logical px).
pub fn chrome(mut self, chrome: impl Widget + 'static) -> Self
A generic chrome widget (banner / sidebar) — the modern replacement for QWizard's watermark pixmap.
It lands in the leading column by default
(ChromePosition::Leading, QWizard's watermark slot), i.e. a full
height sidebar. For a title banner pair it with
.chrome_position(ChromePosition::Top), or the chrome renders as a
wide sidebar holding a few words.
pub fn chrome_position(mut self, position: ChromePosition) -> Self
Choose where the optional chrome widget sits relative to the stepper
body. Forced to ChromePosition::Top when
orientation is Vertical.
pub fn back_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Back" button label. Default: "Back".
pub fn next_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Next" button label. Default: "Next".
pub fn finish_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Finish" button label. Default: "Finish".
pub fn skip_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Skip" button label. Default: "Skip".
pub fn help( mut self, label: impl Into<LocalizedString>, action: impl Fn(&mut EventContext, &StepperController) + 'static, ) -> Self
Add a Help button + callback to the footer.
pub fn cancel( mut self, label: impl Into<LocalizedString>, action: impl Fn(&mut EventContext, &StepperController) + 'static, ) -> Self
Add a Cancel button + callback to the footer.
pub fn on_finish<R: IntoFinishOutcome>( mut self, action: impl Fn(&mut EventContext, &StepperController) -> R + 'static, ) -> Self
Called when Finish is activated on the last step. Receives the event
context and the controller (for skipped / visited introspection);
read collected values from the form signals your steps wrote.
The callback may refuse. Its return value goes through the
IntoFinishOutcome bridge — () always succeeds, while false,
Err(_), or FinishOutcome::Rejected keep the stepper on the last
step and mark it StepStatus::Error (a Wizard modal stays
open). This is the
Finish counterpart of Step::validate_on_next — for the case where
the commit itself can fail (disk full, name taken, server refused):
.on_finish(move |ctx, _ctrl| match create_project(&name.get()) {
Ok(()) => true,
Err(e) => { status.set(e.to_string()); false }
})
pub fn enter_advances(mut self, enter_advances: bool) -> Self
Whether pressing Enter activates the footer's primary button
(Next, or Finish on the last step). Default: true.
The key is handled on the bubble pass at the stepper root, so a
focused control that wants Enter for itself — a Button, a multi-line
editor, a list row — consumes it first and the stepper never sees it.
A single-line form field lets it through, which is where the "Enter
means Next" contract is expected. Gates apply exactly as they do to a
click: a blocked complete_when / validate_on_next refuses the same
way.
Turn it off for a step whose body treats Enter as content in a way the framework cannot see.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip to this stepper. Clears any previously set rich or composite tooltip.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip identified by a registry key. Clears any previously set plain or composite tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip with inline content. Clears any previously set plain or composite tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip (arbitrary widget body). Clears any previously set plain or rich tooltip.
pub struct StepperController
Shared handle controlling a Stepper.
#![allow(unused)] fn main() { pub struct StepperController { /* fields */ } }
Methods
pub fn new(step_count: usize) -> Self
A controller for a stepper with step_count steps, starting at step 0.
pub fn next(&self)
Advance to the next reachable step, recording the current one on
the back-stack. Invisible (Step::visible_when)
and StepStatus::Disabled steps are stepped over; a no-op when none
remains.
pub fn skip(&self)
Mark the current (optional) step skipped, then advance like
next.
pub fn back(&self)
Return to the most recently visited reachable step (the back-stack top). Entries that became unreachable meanwhile are popped and skipped. No-op on an empty stack.
pub fn go_to(&self, idx: usize)
Jump to step idx (non-linear), recording the current step on the
back-stack so back returns here. A no-op when idx is
out of range or not reachable.
pub fn reset(&self)
Reset to the first reachable step: clears the back-stack, restores the
statuses the stepper was declared with (so a Disabled / Optional
step keeps its character), and clears visited/skipped flags. Per-step
visibility is app-owned and left untouched.
pub fn set_status(&self, idx: usize, status: StepStatus)
Override a step's StepStatus (e.g. mark it Error after async
validation). Setting StepStatus::Disabled takes the step out of the
flow — next / go_to skip it — but does
not move off it if it is the active step.
pub fn set_visible(&self, idx: usize, visible: bool)
Show or hide step idx. A hidden step is skipped by
next / back / go_to
and drops out of the indicator strip — the branching-wizard shape
("this step only if you chose X") without maintaining two step lists.
Usually driven declaratively by
Step::visible_when; this is the
imperative twin. Hiding the active step does not navigate away from
it — hide steps the user has not reached yet.
pub fn current(&self) -> usize
pub fn status(&self, idx: usize) -> StepStatus
pub fn visited(&self, idx: usize) -> bool
true if step idx has ever been the active step.
pub fn skipped(&self, idx: usize) -> bool
true if step idx was skipped via skip.
pub fn is_visible(&self, idx: usize) -> bool
true if step idx is visible (see set_visible).
pub fn is_reachable(&self, idx: usize) -> bool
true if step idx participates in the flow — visible and not
StepStatus::Disabled.
pub fn next_reachable(&self, from: usize) -> Option<usize>
The next reachable step after from, if any.
pub fn has_next(&self) -> bool
true if next would move — i.e. the active step is not
the last reachable one. The footer shows Next when this holds and
Finish when it does not.
pub fn step_count(&self) -> usize
pub fn can_back(&self) -> bool
true if there is a previously-visited, still-reachable step to
return to.
pub fn current_step_signal(&self) -> Signal<usize>
The active-step signal — the stepper's Switcher and indicators bind
to it.
pub fn version_signal(&self) -> Signal<u64>
Bumped on every structural mutation; bind at BindingLevel::Rebuild.
pub enum StepStatus
Lifecycle state of a single step, surfaced in the indicator strip and
(for the active step) as aria-current="step".
Mirrors the modern stepper status model (Ant wait/process/finish/error,
Flutter StepState): Upcoming = not yet reached, Active = currently
shown, Complete = validated, Error = failed validation, Disabled =
unreachable, Optional = reachable but skippable, Skipped = an optional
step the user bypassed.
#![allow(unused)] fn main() { pub enum StepStatus { /* variants */ } }
Variants
UpcomingActiveCompleteErrorDisabledOptionalSkipped
Methods
pub fn is_optional(self) -> bool
true for Optional — the only status that surfaces a Skip button.
pub struct Step
One page in a Stepper.
A step carries a localized title, optional supporting_text, a content
factory (the body shown when the step is active), and an optional
completion gate. The recommended data-flow pattern: the application owns
its form state as Signals, the content factory binds widgets to those
signals (write side), and complete_when derives
the Next gate from the same signals.
#![allow(unused)] fn main() { pub struct Step { /* fields */ } }
Methods
pub fn new(title: impl Into<LocalizedString>) -> Self
pub fn content<W, F>(mut self, factory: F) -> Self where W: Widget + 'static, F: Fn() -> W + 'static,
The body shown while this step is active. The factory may capture
clones of the application's form Signals to read/write step input.
pub fn content_boxed(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self
The body shown while this step is active, as a boxed widget — the escape hatch for a body whose concrete type varies at runtime.
content is generic over one W: Widget, and
Box<dyn Widget> does not itself implement Widget, so a step whose
body branches on app state cannot be expressed as a single content
factory. Box each branch instead of duplicating the surrounding
builder:
Step::new(lit!("Details")).content_boxed({
let purpose = purpose.clone();
move || -> Box<dyn Widget> {
match purpose.get() {
Purpose::Novel => Box::new(novel_form()),
Purpose::Import => Box::new(import_form()),
}
}
})
pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self
Secondary line under the title in the header / indicator.
pub fn status(mut self, status: StepStatus) -> Self
Set the step's initial StepStatus.
pub fn optional(mut self, optional: bool) -> Self
Mark the step optional (reachable but skippable — surfaces a Skip
button while active). Equivalent to .status(StepStatus::Optional).
pub fn complete_when(mut self, signal: impl Into<teksilo_core::signal::Prop<bool>>) -> Self
Reactive Next gate: while this step is active, Next is enabled iff
signal is true. Derive it from the same form signals the step's
content writes — e.g. name.map(|n| !n.is_empty()).
pub fn validate_on_next(mut self, f: impl Fn() -> bool + 'static) -> Self
Imperative validation fallback: checked on the Next click. Returning
false blocks navigation. Prefer complete_when
where a reactive signal is available.
pub fn visible_when(mut self, visible: impl Into<teksilo_core::signal::Prop<bool>>) -> Self
Reactive visibility: while visible is false this step drops out of
the flow — Next / Back / indicator clicks skip it, and its marker is
hidden from the indicator strip (and from AT).
This is how a branching wizard is expressed: declare every step once and gate the conditional ones on the choice that selects them, instead of maintaining one step list per branch.
let purpose = Signal::new(Purpose::Novel);
Stepper::new()
.step(Step::new(lit!("Purpose")).content(|| purpose_picker()))
.step(Step::new(lit!("Import source"))
.visible_when(purpose.map(|p| *p == Purpose::Import))
.content(|| import_form()))
Hiding the step the user is currently on does not navigate away from it — gate steps ahead of the choice, not the one making it.
Switcher
Switcher — a container that shows exactly one child page at a time.
Switcher is the fundamental tab/wizard/step primitive: it owns N child
pages and exposes only the one whose index matches the Signal<usize> it
was constructed with. Switching is a signal write — the framework responds
with a relayout that shows the new page and dormantizes all others (excluded
from focus traversal, accessibility tree, hit-test, and paint).
Lazy mount. Pages added via child /
children / child_boxed
stay unconstructed until their index is selected for the first time. Once
mounted, the page's subtree persists for the Switcher's lifetime — switching
away then back finds it in the exact state the user left it (focus, scroll
offsets, text-input contents, signal subscriptions). Pages added via
child_id are pre-mounted by the caller and treated
eagerly.
The Switcher itself reports the maximum natural size across every
currently-mounted page and stretches each placed page to its own bounds —
all pages share the same slot, so the container size never jumps on a switch.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{Switcher, TextWidget}; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let page = Signal::new(0_usize); let _w = Switcher::new(page.clone()) .child(TextWidget::new(lit!("Step 1"))) // built at startup (index 0 is default) .child(TextWidget::new(lit!("Step 2"))) // built on first page.set(1) .child(TextWidget::new(lit!("Step 3"))); // built on first page.set(2) }
Builder methods at a glance
capture_child_ids_into, child, child_boxed, child_id, children
API reference
📖 Full rustdoc API for this module
pub struct Switcher
A container that shows exactly one child at a time, driven by a
Signal<usize> index.
Lazy mount. A page added via Self::child / Self::child_boxed
/ Self::children stays unconstructed until its index is first
selected. Once mounted, the page's subtree persists for the
Switcher's lifetime — switching away then back finds it in the
state the user left it (focus, scroll, text-input contents, …).
Pages added via Self::child_id are pre-mounted by the caller
and treated eagerly: no lazy benefit, no semantic change.
The Switcher itself reports the maximum natural size across every
currently-mounted page and stretches each placed child to its own
bounds (top-leading, RTL-aware). Hidden pages keep their subtree
laid out but invisible via per-page visible_when bindings.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{Switcher, TextWidget}; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let page = Signal::new(0_usize); let _w = Switcher::new(page.clone()) .child(TextWidget::new(lit!("Page 0"))) // built at startup .child(TextWidget::new(lit!("Page 1"))) // built when page.set(1) .child(TextWidget::new(lit!("Page 2"))); // built when page.set(2) }
#![allow(unused)] fn main() { pub struct Switcher { /* fields */ } }
Methods
pub fn new(selected: Signal<usize>) -> Self
Create a Switcher driven by selected. The initially selected index
is selected.get() at build time; page 0 is mounted immediately if that
is the starting value (the most common case).
pub fn capture_child_ids_into(mut self, out: Rc<RefCell<Vec<WidgetId>>>) -> Self
Capture each mounted page's WidgetId into an externally owned
buffer during build(). Use when the caller needs to reference
pages after they're added to the arena — e.g. for accessibility
relations like Tab → TabPanel.
The buffer reflects the currently-mounted set, not every
declared page. With lazy mount, a page added via child(...)
only appears in the buffer once it has been selected for the
first time. Callers that need every id up front should pass
pre-mounted ids via Self::child_id instead — those are
eagerly recorded.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add a child page. The widget stays Boxed until its index is selected for the first time, then is mounted into the arena and kept alive across selection changes.
pub fn child_boxed(mut self, widget: Box<dyn Widget>) -> Self
Add a pre-boxed child page (lazy, same as Self::child).
pub fn child_id(mut self, id: WidgetId) -> Self
Add a child page by its already-allocated WidgetId. Pre-mounted
pages are wired eagerly — the lazy path doesn't apply because
the caller has already paid the construction cost.
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple child pages from an iterator (lazy, same as
Self::child).
TabBar
TabBar<T> — header strip driven by a data source.
Horizontal and vertical orientations, with shared / independent
sizing. Bar-leading and bar-trailing slots are wired. Overflow is
handled by a ScrollArea around the headers row, plus optional
scroll arrows and a "show all tabs" overflow dropdown (both on by
default); whichever tab is activated is scrolled back into view (see
RevealState). Closable tabs (with middle-click close),
drag-to-reorder with edge auto-scroll, and a leading icon-only
pinned-tab strip are all supported. Multi-line (multi-row) wrapping
is the one layout mode not yet implemented.
The data source is consumed via the pub(crate) ListSource
abstraction so callers can pass either a ListModel<T> (clonable,
mutable) or any external ListDataSource<Item = T> (a database
cursor, a virtual list, …) without TabBar having to carry a generic
source parameter.
Accessibility
The bar emits Role::TabList with an aria-orientation
reflecting whether it was built with TabBar::horizontal or
TabBar::vertical. When a page hosts more than one tab list,
give each one an accessible name via
.access_label(tr!(tab_list_name()))
so screen readers can distinguish them (ARIA APG recommendation).
use teksilo_widgets::tab_widget::{TabBar, TabDelegate, TabId};
use teksilo_data::ListModel;
use teksilo_core::signal::Signal;
#[derive(Clone)]
struct Tab { id: TabId, title: String }
let model: ListModel<Tab> = ListModel::new();
let selected: Signal<Option<TabId>> = Signal::new(None);
let delegate = TabDelegate::new(|_i, t: &Tab| teksilo_i18n::lit!(t.title.clone()));
let _bar = TabBar::horizontal(model, delegate, selected, |_i, t| t.id)
.reorderable(true)
.tab_dividers();
Builder methods at a glance
horizontal, horizontal_from_source, vertical, vertical_from_source, tab_sizing, tab_display, min_tab_width, tab_bar_height, max_tab_width, tab_spacing, pinned_tab_width, tab_background, selected_tab_background, hover_tab_background, idle_tab_background, bar_background, tab_dividers, tab_divider_color, active_indicator, selected_text_role, idle_text_role, style, on_pin_toggle, bar_leading_slot, bar_leading_slot_id, bar_trailing_slot, bar_trailing_slot_id, separator, show_scroll_arrows, overflow_button, show_overflow_dropdown, vertical_wheel_scrolls_horizontally, shift_wheel_scrolls_horizontally, on_close, reorderable, on_reorder, accept_external_tabs, on_tab_received, on_transfer_out, on_external_drop
API reference
📖 Full rustdoc API for this module
pub const DEFAULT_MIN_TAB_WIDTH
Default min width for an unpinned tab.
#![allow(unused)] fn main() { pub const DEFAULT_MIN_TAB_WIDTH: f32 = 96.0; }
pub const DEFAULT_MAX_TAB_WIDTH
Default max width for an unpinned tab.
#![allow(unused)] fn main() { pub const DEFAULT_MAX_TAB_WIDTH: f32 = 240.0; }
pub const DEFAULT_TAB_SPACING
Default spacing between tab headers in the row. 0.0 so tabs sit
flush against each other (Firefox / Chrome convention) — adjacent
tab boundaries are visually separated by the per-tab borders, not
by an empty gap.
#![allow(unused)] fn main() { pub const DEFAULT_TAB_SPACING: f32 = 0.0; }
pub const DEFAULT_BAR_SLOT_SPACING
Default spacing between the bar's leading slot, scroll area, and trailing slot.
#![allow(unused)] fn main() { pub const DEFAULT_BAR_SLOT_SPACING: f32 = 8.0; }
pub const DEFAULT_PINNED_TAB_WIDTH
Default width (in dp) of a pinned tab — icon-only squares.
#![allow(unused)] fn main() { pub const DEFAULT_PINNED_TAB_WIDTH: f32 = 32.0; }
pub struct TabBarDragData
Drag payload published by a tab header when the user starts dragging it.
Generic over the bar's item type T so a TabBar<T> only ever
downcasts (get_typed::<TabBarDragData<T>>()) a drag started by
another TabBar<T> — a drag from a TabBar<OtherT> simply never
matches, giving cross-bar transfer type-safety for free.
Two consumers:
- Intra-bar reorder: the bar's own
on_dropmatchessource_bar_id == self_idand usessource_indexto drivemove_item.itemis unused on this path (and may beNone). - Cross-bar transfer: a different bar that opted in via
accept_external_tabstakesitemby value and hands it to itson_tab_receivedcallback.itemisSomeonly when the source bar opted in and the per-tab transferable predicate allows it (static tabs are excluded).
#![allow(unused)] fn main() { pub struct TabBarDragData<T: 'static> { /* fields */ } }
pub struct TabBar
A reactive header strip that pulls its tab list from a data source
and writes the active tab into a shared Signal<Option<TabId>>.
Selection is id-based: the bar holds a stable TabId per
item (extracted via the id_of closure passed to the constructor)
and the public selected_id signal is the source of truth across
reorders / removals / locale changes. Internal index-based work
(keyboard nav, scroll-to-active, click activation) reads a
private selected_index signal that the bar keeps in
bidirectional sync with selected_id at build time.
#![allow(unused)] fn main() { pub struct TabBar<T: 'static> { /* fields */ } }
Methods
pub fn horizontal( model: ListModel<T>, delegate: TabDelegate<T>, selected_id: Signal<Option<TabId>>, id_of: impl Fn(usize, &T) -> TabId + 'static, ) -> Self
Construct a horizontal tab bar from a ListModel<T>.
Default sizing is TabSizing::Shared.
selected_id is the id-based selection signal — written by
the bar on click / keyboard / drag-drop and observable by
callers. id_of(index, &item) extracts the stable TabId
from each model item.
pub fn horizontal_from_source<S: ListDataSource<Item = T>>( source: S, delegate: TabDelegate<T>, selected_id: Signal<Option<TabId>>, id_of: impl Fn(usize, &T) -> TabId + 'static, ) -> Self
Construct a horizontal tab bar from any ListDataSource.
Default sizing is TabSizing::Shared.
pub fn vertical( model: ListModel<T>, delegate: TabDelegate<T>, selected_id: Signal<Option<TabId>>, id_of: impl Fn(usize, &T) -> TabId + 'static, ) -> Self
Construct a vertical tab bar from a ListModel<T>. Tabs
stack top-to-bottom as horizontal pills (icon + label + close
button arranged left-to-right within each pill). Default
sizing is TabSizing::Shared — uniform pill heights.
pub fn vertical_from_source<S: ListDataSource<Item = T>>( source: S, delegate: TabDelegate<T>, selected_id: Signal<Option<TabId>>, id_of: impl Fn(usize, &T) -> TabId + 'static, ) -> Self
Construct a vertical tab bar from any ListDataSource.
pub fn tab_sizing(mut self, mode: TabSizing) -> Self
Override the per-tab sizing strategy. See TabSizing.
pub fn tab_display(mut self, mode: TabDisplayMode) -> Self
Choose what every tab shows — icon, label, or both. See
TabDisplayMode. Default TabDisplayMode::Auto (render each tab as
its TabInfo declares).
pub fn min_tab_width(mut self, dp: f32) -> Self
Minimum width (in dp) any unpinned tab will be drawn at.
Default: DEFAULT_MIN_TAB_WIDTH.
In horizontal orientation this clamps the per-tab width.
In vertical orientation every tab is forced to the bar's
cross-axis width, so the same knob defines the bar's minimum
width — the sidebar adapts to the widest piece of bar content
(tab labels or a slot widget) and never shrinks below this floor.
Vertical pill heights stay at theme.components.tab.editor_tab_height
regardless of this knob.
Under TabSizing::Fill a vertical bar takes the width it is
offered outright, so this floor no longer applies to it; in a
horizontal Fill bar it still does (the tabs overflow into
scroll rather than squeeze below it).
pub fn tab_bar_height(mut self, dp: f32) -> Self
Override the tab-strip cross-axis extent (the strip height for a
horizontal bar; the per-tab pill height for a vertical one). None
keeps the style's editor_tab_height. Use for a compact bar.
pub fn max_tab_width(mut self, dp: f32) -> Self
Maximum width (in dp) any unpinned tab will be drawn at — long
labels truncate with an ellipsis at this width.
Default: DEFAULT_MAX_TAB_WIDTH.
In horizontal orientation this clamps the per-tab width.
In vertical orientation it caps the whole sidebar's width —
see min_tab_width for the symmetric
adapt-to-content rule.
TabSizing::Fill ignores this cap in both orientations — filling
the bar is the point, and a cap would leave exactly the slack the
mode exists to remove.
pub fn tab_spacing(mut self, dp: f32) -> Self
Override the spacing (in dp) between adjacent tab headers in
the row. Default: DEFAULT_TAB_SPACING.
pub fn pinned_tab_width(mut self, dp: f32) -> Self
Width (in dp) of an icon-only pinned tab.
Default: DEFAULT_PINNED_TAB_WIDTH.
pub fn tab_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
All-states shorthand for the per-tab background — every tab
(selected, idle, hovered) paints this unless a per-state override
below is set. Accepts any Color, SurfaceRole, or Signal<Color>
(via ColorProp).
Default None = transparent. To tint the bar's backdrop instead,
use bar_background.
pub fn selected_tab_background( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Background for the selected tab. Falls back to
tab_background, then transparent.
pub fn hover_tab_background( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Background for the hovered (non-selected) tab. Falls back to
tab_background, then transparent.
pub fn idle_tab_background( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Background for idle tabs (not selected, not hovered). Falls back
to tab_background, then transparent.
pub fn bar_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Set the backdrop fill spanning the whole bar strip (behind the
headers, slots, and scroll arrows). Independent of the per-tab
backgrounds. Accepts any Color, SurfaceRole, or Signal<Color>.
Default None = transparent.
pub fn tab_dividers(mut self) -> Self
Draw a 1 dp divider between consecutive tabs (scrollable and pinned
strips). Off by default. See tab_divider_color.
pub fn tab_divider_color( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Like tab_dividers, but with an explicit
colour. Accepts any Color, BorderRole,
or Signal<Color>. Implies tab_dividers().
pub fn active_indicator( mut self, position: teksilo_core::styles::TabIndicatorPosition, ) -> Self
Choose which edge the active-tab highlight indicator hugs. Default
TabIndicatorPosition::OuterEdge
(top for horizontal / leading for vertical);
InnerEdge
puts it below the label (horizontal) / on the trailing edge (vertical).
Honoured by the default RecipeTabStyle; a custom
TabStyle may interpret it freely.
pub fn selected_text_role(mut self, role: TextRole) -> Self
Set the text role used for the label (and matching icon tint)
on the selected tab. Default: TextRole::Primary — the
Int UI editor-strip convention. Override to e.g.
TextRole::Accent when the strip sits over a tinted surface.
pub fn idle_text_role(mut self, role: TextRole) -> Self
Set the text role used for the label (and matching icon tint)
on idle tabs (not selected, not disabled). Default:
TextRole::Secondary. Disabled tabs always read as
TextRole::Disabled regardless of this setting.
pub fn style(mut self, style: impl teksilo_core::styles::TabStyle) -> Self
Override the active TabStyle
for every header in this bar. The widget keeps responsibility
for the label / icon / close button composition, the
optional per-state tab backgrounds, and all input handling;
the style only paints the accent indicator and focus ring
chrome via make_body. Per-call override > theme slot >
built-in RecipeTabStyle default.
pub fn on_pin_toggle(mut self, f: impl Fn(usize, bool, &mut EventContext) + 'static) -> Self
Install a pin-toggle handler called whenever the user crosses
a pinned tab over the unpinned region or vice-versa during a
drag. Receives (model_index, new_pinned_flag, ctx). The
firing EventContext lets the handler confirm the
transition via a dialog or route it through an intent before
mutating the item; apps decide whether to actually flip the
pinned state.
pub fn bar_leading_slot(mut self, w: impl Widget + 'static) -> Self
Bar-level leading slot — a widget rendered before the headers row (and before any pinned region in later phases).
pub fn bar_leading_slot_id(mut self, id: WidgetId) -> Self
Bar-level leading slot accepting a pre-registered widget id.
pub fn bar_trailing_slot(mut self, w: impl Widget + 'static) -> Self
Bar-level trailing slot — a widget rendered after the headers row (and after any overflow dropdown in later phases).
pub fn bar_trailing_slot_id(mut self, id: WidgetId) -> Self
Bar-level trailing slot accepting a pre-registered widget id.
pub fn separator(mut self, on: bool) -> Self
Toggle the 1 dp bottom separator the bar paints under the headers. Default: on.
pub fn show_scroll_arrows(mut self, on: bool) -> Self
Toggle the leading + trailing scroll-arrow buttons. They auto-show when the headers row overflows the bar's viewport, and click animates the scroll position by one tab-width. Default: on.
pub fn overflow_button(mut self, mode: TabOverflowButton) -> Self
When the trailing "show all tabs" overflow dropdown appears — a
Popover with a MenuList of every tab. Default:
TabOverflowButton::Auto (shown only when the headers overflow the
viewport). See TabOverflowButton for Always / Never.
pub fn show_overflow_dropdown(mut self, on: bool) -> Self
Convenience over overflow_button: true maps
to TabOverflowButton::Always, false to TabOverflowButton::Never.
Prefer overflow_button(TabOverflowButton::Auto) for the default
"only when overflowing" behaviour.
pub fn vertical_wheel_scrolls_horizontally(mut self, on: bool) -> Self
On a horizontal bar, treat a plain vertical-wheel event as a horizontal scroll (Firefox / Chrome convention). Has no effect on vertical or multi-line bars (those still scroll vertically). Default: on.
pub fn shift_wheel_scrolls_horizontally(mut self, on: bool) -> Self
Shift + vertical wheel forces a horizontal scroll regardless
of orientation. Default: on.
pub fn on_close(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self
Install a close-tab handler called whenever the user clicks a
closable tab's close button, middle-clicks the tab header, or
presses Delete on a focused tab. The handler receives the
firing EventContext so it can open a confirmation dialog
(ctx.present_modal(MessageBox::confirm(...))), dispatch an
intent, or otherwise route the close request through the
framework. To veto the close, do nothing in the handler; to
confirm-then-close, run the confirmation flow and only mutate
the underlying model on accept.
If unset and the bar is backed by a ListModel<T>, the
default behavior is to remove the item at the given index
from the model (no confirmation, no ctx needed for that path).
pub fn reorderable(mut self, on: bool) -> Self
Enable drag-to-reorder. Each tab header becomes a drag source
and the bar accepts drops anywhere along the headers row,
painting an insertion-line indicator at the would-be
position. On drop the bar calls on_reorder
— falling back to ListModel::move_item when the bar is
backed by a ListModel<T> and no explicit handler is set.
Default: off.
pub fn on_reorder(mut self, f: impl Fn(usize, usize, &mut EventContext) + 'static) -> Self
Install a reorder handler called whenever the user drag-drops
a tab to a new position. Receives (from, to, ctx) —
from/to are model indices and ctx is the firing
EventContext so the handler can open a confirmation
dialog or dispatch an intent before persisting the move.
Implies reorderable(true).
pub fn accept_external_tabs(mut self, on: bool) -> Self where T: Clone,
Opt into cross-bar tab transfer. When enabled, this bar's
headers become transfer drag sources (their drag payload
carries a clone of the dragged item) and the bar accepts
tabs dragged from other TabBar<T>s, painting the same
insertion-line indicator as an intra-bar reorder.
Requires T: Clone — the dragged item is cloned into the
payload (cheap for handle-like T whose heavy state lives
behind an Rc). Default: off.
Pair with on_tab_received (this bar,
as a drop target — insert the item into your model) and
on_transfer_out (the source bar —
remove the tab from your model).
pub fn on_tab_received(mut self, f: impl Fn(T, usize, &mut EventContext) + 'static) -> Self where T: Clone,
Install the target-side callback fired when a foreign tab is
dropped onto this bar. Receives (item, insertion_index, ctx)
— the moved item (taken by value from the drag payload), the
model index in this bar where it should land, and the firing
context. The app inserts the item into its own model. Implies
accept_external_tabs(true).
pub fn on_transfer_out(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self where T: Clone,
Install the source-side callback fired after one of this bar's
tabs has been accepted by a different bar. Receives the
transferred tab's TabId; the app removes it from its own
model. Not fired for intra-bar reorders (those go through
on_reorder) or rejected / cancelled
drags. Implies accept_external_tabs(true).
pub fn on_external_drop( mut self, f: impl Fn(&DragPayload, usize, &mut EventContext) -> bool + 'static, ) -> Self
Accept non-tab drops onto the bar — an in-app foreign drag
(e.g. a file dragged from a TreeView, carrying app data) or an
OS file/text/URL drop. The bar paints the same insertion-line
indicator while such a payload hovers, and on drop calls f
with the raw DragPayload, the model insertion index, and the
firing context. Return true if accepted — the app inspects the
payload (get_typed::<T>() / files() / text() / uris())
and mints whatever it needs (e.g. opens a tab).
Independent of accept_external_tabs:
a bar can accept foreign tabs, non-tab payloads, both, or
neither. OS drops additionally require the app to have called
TeksiloAppBuilder::install_external_dnd().
Note: the hover indicator is optimistic — it shows for any
non-tab payload while this handler is installed; f's return
value is authoritative at drop time.
TableView

TableView<T> — generic, virtualized, accessible tabular widget.
Built atop the ListModel<T> /
ListDataSource data layer in
teksilo-data and the teksilo-tokens TableStyle. Mirrors Qt's
QTableView, SwiftUI's Table, and JavaFX's TableView.
The core skeleton: single body pane, row-virtualized with alternating
backgrounds, grid lines, Role::Table > Role::Row > Role::Cell
accessibility, multi-row selection, and an empty-state slot. Headers,
sort, filter, resize, reorder, pinning, cell selection, and editing are
also included. Row heights come in three modes: uniform (row_height,
the default fast path), exact per-row callback (row_height_fn), and
auto-measured (auto_row_height — rows grow to their tallest cell,
height-for-width). See docs/table-view.md "Row heights".
use teksilo_data::ListModel;
use teksilo_widgets::table_view::{Column, ColumnWidth, TableView};
use teksilo_i18n::lit;
struct Person { name: String, age: u32 }
let model: ListModel<Person> = ListModel::new();
let _table = TableView::new(model)
.add_column(Column::new("name", ColumnWidth::Flex(1.0))
.label(lit!("Name"))
.cell(|p: &Person, _cx| Box::new(
teksilo_widgets::primitives::TextWidget::new(
teksilo_i18n::lit!(p.name.clone())
)
)))
.add_column(Column::new("age", ColumnWidth::Fixed(60.0))
.label(lit!("Age"))
.cell(|p: &Person, _cx| Box::new(
teksilo_widgets::primitives::TextWidget::new(
teksilo_i18n::lit!(p.age.to_string())
)
)))
.alternating_rows(true)
.row_height(32.0);
Builder methods at a glance
from_source, from_source_keyed, enabled, overscroll_behavior, smooth_scrolling, type_ahead_label, type_ahead_timeout, smooth_scroll_duration, scroll_bar_style, add_column, columns, row_height, row_height_fn, auto_row_height, header_height, show_header, column_resize_policy, tab_traversal, edit_triggers, on_cell_edit_request, on_cell_edit_dismissed, on_row_activate, reorderable, reorderable_rows, exportable, export_external, on_rows_transferred_out, accept_foreign_rows, on_rows_received, activate_on, selection_mode, selection, cell_selection, alternating_rows, grid_lines, a11y_label, show_internal_scrollbars, empty_view, scroll_y_signal, max_scroll_y_signal, viewport_ratio_y_signal, scroll_x_signal, max_scroll_x_signal, viewport_ratio_x_signal, sort_signal, column_widths_signal, column_order_signal, column_pinning_signal, focused_cell_signal, set_focused_cell, clear_focused_cell, editing_cell_signal, begin_edit, end_edit, filters_signal, set_filter, clear_filters, scroll_to_row, set_sort, clear_sort, set_column_width, set_column_widths, set_column_order, set_column_pinning, ensure_row_visible
API reference
📖 Full rustdoc API for this module
pub struct TableView
Generic, virtualized, accessible table with sortable / filterable / resizable columns.
Construct with TableView::new (from a ListModel<T>)
or TableView::from_source (any ListDataSource), then chain builder methods
to configure columns, row heights, selection, and so on. See module docs for the full
feature list and row-height modes.
#![allow(unused)] fn main() { pub struct TableView<T: 'static> { /* fields */ } }
Methods
pub fn new(model: ListModel<T>) -> Self
Wrap a ListModel<T>.
pub fn from_source<S: ListDataSource<Item = T>>(source: S) -> Self
Wrap any ListDataSource<Item = T> (e.g. a
SortFilterListModel<T>).
The source owns DnD validation (can_accept / accept_drop) and
lazy windowing (row_state / request_window / fetch_more); a
read-only source leaves the defaults inert.
pub fn from_source_keyed<S: ListDataSource<Item = T>>( source: S, keyed: KeyedSelectionModel<S::Key>, ) -> Self where S::Key: ItemKey,
Wrap any ListDataSource<Item = T> with keyed row selection. The
KeyedSelectionModel<S::Key> tracks selection by source identity, so it
survives reorders / filters / lazy window-slides and stays consistent
across two views of the same source. The view stays TableView<T> — the
index↔key mapping is captured from the concrete source here. Equivalent
to from_source(..) plus an identity-based replacement for
selection.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the whole view. A disabled view greys out and stops accepting focus / selection / keyboard input (arena-gated).
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Set the scroll-chaining behavior at the boundary (default
OverscrollBehavior::Chain; Contain
disables chaining to an ancestor scrollable).
pub fn smooth_scrolling(mut self, enabled: bool) -> Self
Enable or disable animated wheel scrolling (enabled by default). When disabled, wheel events snap immediately to the new offset.
pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self
Enable type-ahead ("type to jump"): typing a printable character
while the table has keyboard focus jumps the focused row to the next
row whose label starts with the accumulated search term, wrapping
around (Qt keyboardSearch / macOS & Windows type-select).
label(&item) yields the searchable text for a row; matching is
ASCII-case-insensitive. A pause longer than the
type_ahead_timeout starts a fresh term.
On an editable column whose EditTriggers is type-to-edit, typing
starts an edit instead — type-ahead applies on non-editable columns
(or when no type-to-edit trigger is configured).
pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self
Reset window between keystrokes before the type-ahead search term clears (default 500 ms). A zero duration disables type-ahead.
pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self
Duration of the smooth scroll animation (default 150 ms).
pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self
How the scroll bar is displayed (default Permanent). Overlay
and Thin float the bar over the content instead of reserving a
layout column for it, mirroring ScrollArea::scroll_bar_style.
pub fn add_column(mut self, col: Column<T>) -> Self
Append a single Column<T> definition to the table.
pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self
Append multiple Column<T> definitions from an iterator.
pub fn row_height(mut self, height: f32) -> Self
Fixed row height (default: the table style's 28 px) — the
uniform fast path. Mutually exclusive with
row_height_fn and
auto_row_height; the last mode setter
wins.
pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self
Per-row heights from a callback over the visible row index. The
callback must be pure (same index + same data → same height); it
is re-swept from the first changed index on every model change
(a SortFilterListModel source reports that index through
first_changed_index, so sort/filter/append keep the valid
prefix). No measurement pass runs.
pub fn auto_row_height(mut self, estimated: f32) -> Self
Auto-measured row heights: each realized row reports the height
of its tallest cell measured at the cell's column width
(height-for-width), unrealized rows assume estimated. Scroll
anchoring keeps content above the viewport stationary as
estimates are corrected; the scrollbar settles one frame after a
measurement change.
pub fn header_height(mut self, height: f32) -> Self
Override the column header row height in logical pixels. Default: the table style's HEADER_HEIGHT.
pub fn show_header(mut self, visible: bool) -> Self
Show or hide the column header row. Default: visible.
pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self
Set how column widths are redistributed when columns are
added, resized, or the table's own width changes. See
ColumnResizePolicy.
pub fn tab_traversal(mut self, mode: TabTraversal) -> Self
Control how Tab / Shift+Tab navigate between cells. See
TabTraversal.
pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self
Set which user action opens a cell editor. See EditTriggers.
pub fn on_cell_edit_request( mut self, f: impl Fn(usize, &str, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Hook fired by the keyboard handler when an edit trigger fires
on the focused cell. Receives (row_index, col_id, ctx).
pub fn on_cell_edit_dismissed( mut self, f: impl Fn(usize, &str, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Callback invoked when an open cell editor should end because the pointer went somewhere else: a press that lands on any cell other than the one being edited. Receives the editing cell's flat row index and column id, so the owner can commit (or discard) whatever is in its buffer, then clear its own editing state.
The counterpart of on_cell_edit_request,
and the view cannot do it alone: the framework owns which cell is being
edited, but only the owner knows what an ended edit means — commit,
discard, or refuse a value that will not parse.
Why a press and not a focus change. "The editor lost focus" is the obvious signal and it cannot be used: a body pane rebuilds constantly — selection, filtering, scroll, a reload from elsewhere — and every rebuild destroys and re-creates the open editor, so focus leaves it many times during an edit the writer never interrupted. A press on another cell is unambiguous and happens exactly once.
pub fn on_row_activate( mut self, f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Hook fired when the user presses Enter on the focused row.
pub fn reorderable(mut self, enabled: bool) -> Self
Enable drag-to-reorder of rows (pointer drag + keyboard
Alt+ArrowUp/Down). Distinct from
Column::reorderable, which reorders
columns and defaults to true; this defaults to false.
The move is routed through the backing source's accept_drop: a
ListModel reorders in place, an external source routes the move to
its store. Per-hover the source's can_accept decides whether the
drop is allowed — a forbidden position shows no insertion line and
the drop is refused. A row may also be forbidden from dragging at
all (the source's drag gate). Cross-table / external drops arrive
at accept_drop as DragSource::Foreign; a bare ListModel
rejects them, an external source decides.
pub fn reorderable_rows(self, enabled: bool) -> Self
Renamed to reorderable, matching ListView,
GridView, TreeView and TreeTableView — this was the only view in
the family spelling it differently.
pub fn exportable(mut self, mode: DragTransferMode) -> Self where T: Clone,
Make rows droppable outside this view — on a
DropTarget, another data view, or the OS.
A dragged row (or the whole selection, when the pressed row is part of a
multi-selection) carries clones of its items in a public
RowDragData<T>, so a foreign receiver can pull
them out with payload.get_typed::<RowDragData<T>>() /
DropTarget::on_drop_typed::<RowDragData<T>>() — no serialization. This
also makes rows a drag source even without reorderable.
mode chooses what happens to the origin rows once a foreign target
accepts them: DragTransferMode::Move removes them (via the source's
on_drag_out, or on_rows_transferred_out),
DragTransferMode::Copy leaves them. A same-view reorder is never a
transfer, so mode never affects it. Requires T: Clone.
pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self where T: Clone,
Additionally advertise the dragged rows as MIME data so they can be
dropped on a DropZone or exported to another
application / window via the OS. f maps the dragged items to
(mime_type, bytes) pairs (e.g. text/plain, text/uri-list, an
app-specific application/x-…). Implies exportable
(defaulting to DragTransferMode::Move if not already set). Requires
T: Clone.
pub fn on_rows_transferred_out( mut self, f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Override how rows moved out to a foreign target are removed from this
view. Receives the dragged rows' indices (descending-safe) and the live
context. Without this, an exportable
Move drag removes them through the source's
on_drag_out (works out of the box for a ListModel).
pub fn accept_foreign_rows(mut self, accept: bool) -> Self
Accept exported rows dropped from a different view or source without
writing a custom ListDataSource. Pair with
on_rows_received, which is handed the dropped
items and the insertion index. (Same-view reorder is
reorderable; a custom ListDataSource can still
accept foreign drops through its can_accept/accept_drop instead.)
pub fn on_rows_received( mut self, f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static, ) -> Self
Handler for rows accepted via accept_foreign_rows:
(items, insertion_index, ctx). Insert them into your model at the
index.
pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self
Choose single- vs double-click activation for on_row_activate (default
ActivateOn::DoubleClick). Enter/Space activates in
either mode.
pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self
Choose the row-selection granularity (None / Single / Multi).
See TableSelectionMode.
pub fn selection(mut self, sel: SelectionModel) -> Self
Set the index-based row selection model (positions). For identity-based
selection that survives reorder / filter / window-slide, build the view
with from_source_keyed instead.
pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self
Install an independent cell-selection model on top of row selection.
See CellSelectionModel.
pub fn alternating_rows(mut self, enabled: bool) -> Self
Paint every other row with a tinted background. Default: off.
pub fn grid_lines(mut self, kind: GridLines) -> Self
Draw horizontal and/or vertical grid lines between cells.
See GridLines.
pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self
Provide an accessible label for the table (aria-label). Required
when the page hosts more than one table so screen readers can
distinguish them.
pub fn show_internal_scrollbars(mut self, show: bool) -> Self
Show or hide the built-in vertical scroll bar. Default: visible. Set to
false when an external scroll bar is wired to scroll_y_signal.
pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Widget shown when the source is empty.
pub fn scroll_y_signal(&self) -> &Signal<f32>
Current vertical scroll offset in logical pixels.
pub fn max_scroll_y_signal(&self) -> &Signal<f32>
Maximum vertical scroll offset — total_content_height − viewport_height.
pub fn viewport_ratio_y_signal(&self) -> &Signal<f32>
Viewport-to-content height ratio, used by external scroll bar thumbs.
pub fn scroll_x_signal(&self) -> &Signal<f32>
Current horizontal scroll offset of the Middle (unpinned) pane, in
logical pixels. Leading/Trailing-pinned columns are unaffected —
see Column::pinned.
pub fn max_scroll_x_signal(&self) -> &Signal<f32>
Maximum horizontal scroll offset — middle_content_width − middle_viewport_width.
pub fn viewport_ratio_x_signal(&self) -> &Signal<f32>
Middle-pane viewport-to-content width ratio, used by external horizontal scroll bar thumbs.
pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>>
Active sort: Some((col_id, dir)) or None when unsorted.
Mutated by header clicks (cycle: None → Asc → Desc → None) and by
set_sort / clear_sort.
Bind a SortFilterListModel to
drive a re-sort of the underlying data:
let proxy = SortFilterListModel::new(model)
.with_comparator("name", |a, b| a.name.cmp(&b.name));
proxy.sort_signal(table.sort_signal().clone());
pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>>
Map of column id → user-overridden width. A column id appears in this map only after the user resizes that column; missing keys mean "use the declared width policy".
pub fn column_order_signal(&self) -> &Signal<Vec<String>>
Column ids in display order. Updated when the user drags a
header to reorder, or imperatively via
set_column_order. When empty, the
declared order applies. Pinned-side groups (Leading / None /
Trailing) are always honored — the entries inside this signal
only re-sort within each group.
pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>>
Per-id pinning override map. A key here pins the column to that
side; missing keys fall back to the declared Column::pinned.
Updated when the user drags a column across a pane boundary.
pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>>
Currently keyboard-focused cell, as (row_index, display_col),
or None when no cell is focused. Mutated by the keyboard
handler (Arrow keys / Tab / Home / End / PgUp / PgDn /
Ctrl-Home / Ctrl-End / Escape) and by direct
set_focused_cell /
clear_focused_cell calls.
pub fn set_focused_cell(&self, row: usize, col: usize)
Move the focused cell. Out-of-range values are silently clamped when the next layout runs.
pub fn clear_focused_cell(&self)
Remove keyboard focus from any cell (equivalent to pressing Escape).
pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>>
Cell currently in edit mode, or None when no editor is open.
Cell delegates inspect this via CellContext::is_editing and
swap in an editor widget when matched.
pub fn begin_edit(&self, row: usize, col_id: &str)
Begin editing the cell (row, col_id). Silently no-ops if col_id
isn't a currently-displayed column, or if row is outside the visible
range — an out-of-range target would otherwise strand editing_cell on
a row nothing can match.
Callable before the view is mounted, which is the only point at
which a consumer can seed a freshly constructed view with an edit
target it already holds. display_indices is a cache build() fills,
so a pre-mount call finds it empty; the order is recomputed on demand
in that case rather than resolving against nothing and no-opping for a
third, undocumented reason.
pub fn end_edit(&self)
Close the active cell editor without committing (the field's on_blur still fires).
pub fn filters_signal(&self) -> &Signal<HashMap<String, String>>
Per-column filter text. Updated by filter affordances in
header cells and by
set_filter / clear_filters.
Bind a SortFilterListModel<T> to drive the upstream data:
let proxy = SortFilterListModel::new(model)
.with_predicate("name", |t| {
let needle = t.to_string();
Box::new(move |r: &Row| r.name.contains(&needle))
});
proxy.filters_signal(table.filters_signal().clone());
pub fn set_filter(&self, col_id: &str, text: &str)
Set or clear the filter text for a single column. An empty text removes
the entry for col_id (same as clearing the filter for that column).
pub fn clear_filters(&self)
Remove all active column filters.
pub fn scroll_to_row(&self, row: usize)
Scroll so that row is aligned to the top of the viewport. A no-op
before the first layout pass.
pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection)
Set the active sort imperatively. Equivalent to writing to
sort_signal directly, except that an unchanged
value neither writes nor notifies — see
set_column_widths.
pub fn clear_sort(&self)
Clear the active sort.
pub fn set_column_width(&self, col_id: &str, width: f32)
Set or remove a single column's user-resized width override.
A non-positive width removes the entry (the column reverts to
its declared width policy).
pub fn set_column_widths(&self, widths: HashMap<String, f32>)
Replace the full width-override map (typically used to restore a persisted layout).
A no-op when the map is unchanged, so the documented
settings-round-trip wiring (see docs/table-view.md, "Persistence")
terminates instead of recursing: Signal::set has no equality check of
its own, and a live resize writes a width on every pointer move.
pub fn set_column_order(&self, order: Vec<String>)
Replace the column-order list. Ids not declared on this table are silently dropped on the next layout pass.
pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide)
Pin or unpin a single column.
pub fn ensure_row_visible(&self, row: usize)
Scroll the minimum distance needed to make row visible. A no-op
before the first layout pass, when the viewport height is not yet known.
pub enum ColumnWidth
How a column's width is determined during layout.
#![allow(unused)] fn main() { pub enum ColumnWidth { /* variants */ } }
Variants
Fixed— Exact pixel width. Clamped bymin_width/max_width.Flex— Share of the leftover space proportional to the flex factor — behaves like CSSflex-grow. The factor must be> 0.0.Auto— Intrinsic content width (currently approximated by the table'smin_column_width_defaulttoken; refined to probe the header label and visible cells).
pub enum PinnedSide
Whether a column is pinned to one side of the table.
#![allow(unused)] fn main() { pub enum PinnedSide { /* variants */ } }
Variants
Leading— Pinned against the leading edge — stays visible during horizontal scroll.None— Not pinned — scrolls horizontally with the body.Trailing— Pinned against the trailing edge.
pub enum Alignment
Horizontal alignment of a cell's content within its column.
#![allow(unused)] fn main() { pub enum Alignment { /* variants */ } }
Variants
LeadingCenterTrailing
pub enum TruncationPolicy
Strategy when a cell's text overflows its column.
#![allow(unused)] fn main() { pub enum TruncationPolicy { /* variants */ } }
Variants
Ellipsis—…-elide the trailing portion. Default.None— Don't truncate; let the cell content draw beyond the column edge (the body pane's clip will hide it).Fade— Fade the trailing portion — gradient mask.
pub enum GridLines
Whether the table draws grid lines between rows / columns.
#![allow(unused)] fn main() { pub enum GridLines { /* variants */ } }
Variants
NoneHorizontalVerticalBoth
pub enum ColumnResizePolicy
Whether column resize commits the new width on every drag tick (Live)
or only on Ended (OnRelease).
#![allow(unused)] fn main() { pub enum ColumnResizePolicy { /* variants */ } }
Variants
LiveOnRelease
pub struct EditTriggers
Which gestures open a cell editor — a set, composed with |, after
Qt's QAbstractItemView::EditTriggers.
A set rather than an enum of named combinations, because the combinations
are the caller's to choose: "one click" and "F2 or one click" are ordinary
requests that a closed enum of F2 / F2OrType / F2OrTypeOrDoubleClick / DoubleClick / None could not express at all.
Set table-wide with TableView::edit_triggers
/ TreeTableView::edit_triggers, and
per column with Column::edit_triggers — the column wins where it sets
one. Only cells of an editable column ever open an
editor, whatever the triggers say; the two are the same split Qt makes
between a view's editTriggers and an item's ItemIsEditable.
SINGLE_CLICK claims the press. A cell that edits on one click does not
also select its row — the same trade any interactive cell content already
makes, and the reason it is per column: put it on the columns that are
nothing but a value, and leave the row's own column alone.
#![allow(unused)] fn main() { pub struct EditTriggers(u8); }
Methods
pub const NONE: Self = Self(0);
Editing is never opened by the view. Cells of an editable column still
render normally; nothing reaches on_cell_edit_request.
pub const F2: Self = Self(1 << 0);
F2 on the focused cell.
pub const ANY_KEY: Self = Self(1 << 1);
Any printable character typed on the focused cell. Note that the keystroke that opens the editor is not delivered into it — the editor does not exist until the next build — so this reads as "F2 with an extra key", and it shadows type-ahead on every editable column.
pub const SINGLE_CLICK: Self = Self(1 << 2);
A single click on the cell. Claims the press, so that cell no longer selects its row.
pub const DOUBLE_CLICK: Self = Self(1 << 3);
A double click on the cell. It takes the gesture from row activation on this column — a column that edits on double-click must not also open its row on the same click — while every other column still activates.
pub const ALL: Self = Self(0b0000_1111);
Every trigger at once.
pub const fn contains(self, other: Self) -> bool
true when every trigger in other is present.
pub const fn is_empty(self) -> bool
true when nothing opens an editor.
pub const fn union(self, other: Self) -> Self
pub const fn intersection(self, other: Self) -> Self
pub enum TabTraversal
Tab / Shift-Tab traversal policy across cells of a row.
Regardless of the policy, Ctrl+Tab / Ctrl+Shift+Tab always move focus
out of the table to the next / previous focusable widget — the reliable
escape from CellsThenRows, so keyboard focus is never trapped.
#![allow(unused)] fn main() { pub enum TabTraversal { /* variants */ } }
Variants
CellsThenRows— Tab moves to the next cell within the row, then wraps to the first cell of the next row. Default. (Ctrl+Tab leaves the table.)OutOfTable— Tab leaves the table once the focused cell is reached at the row boundary; the focus owner is whatever follows the table in tab order.
pub struct CellContext
Per-cell context handed to a column's cell delegate during build.
#![allow(unused)] fn main() { pub struct CellContext { /* fields */ } }
pub struct ColumnContext
Per-column-header context handed to a column's header delegate.
#![allow(unused)] fn main() { pub struct ColumnContext { /* fields */ } }
pub struct Column
Single column declaration. Column ids must be stable, unique strings — they're the persistence key for sort, filter, width, and ordering.
#![allow(unused)] fn main() { pub struct Column<T: 'static> { /* fields */ } }
Methods
pub fn new( id: impl Into<String>, header: impl Into<LocalizedString>, cell: impl Fn(&T, &CellContext) -> Box<dyn Widget> + 'static, ) -> Self
Create a column with a stable id, a localized header label, and a
cell builder that takes &T plus a CellContext and returns a
boxed widget.
pub fn width(mut self, w: ColumnWidth) -> Self
pub fn min_width(mut self, px: f32) -> Self
pub fn max_width(mut self, px: f32) -> Self
pub fn alignment(mut self, a: Alignment) -> Self
pub fn resizable(mut self, b: bool) -> Self
pub fn reorderable(mut self, b: bool) -> Self
pub fn sortable(mut self, b: bool) -> Self
pub fn filterable(mut self, b: bool) -> Self
pub fn editable(mut self, b: bool) -> Self
Mark the column as editable. Default false. F2 / type-to-edit
only enter edit mode on cells of editable columns; the
on_cell_edit_request hook also fires only for these. Cells of
non-editable columns continue to render their static delegate
regardless of editing_cell.
pub fn edit_triggers(mut self, triggers: EditTriggers) -> Self
Override the view's EditTriggers for this column alone.
The reason the set is not only table-wide: a table's columns rarely want the same gesture. A tree column has to keep click-to-select and double-click-to-open, while the plain value columns beside it are exactly where one click to edit belongs. Unset columns inherit the view's set.
pub fn effective_edit_triggers(&self, view: EditTriggers) -> EditTriggers
The triggers in force for this column, given the view's set — the question the body pane and the key handler both ask, and the one an application's own tests want to ask about their column set.
A non-editable column never opens an editor, whatever either says.
pub fn pinned(mut self, side: PinnedSide) -> Self
pub fn truncation(mut self, p: TruncationPolicy) -> Self
pub fn header_override( mut self, f: impl Fn(&ColumnContext) -> Box<dyn Widget> + 'static, ) -> Self
Override the default header rendering (label + sort/filter
indicators). The closure receives a ColumnContext reflecting
the current sort/filter state.
pub fn id(&self) -> &str
Stable column id (the persistence key for sort, filter, width, and ordering signals).
pub enum TableSelectionMode
Selection mode for a TableView or TreeTableView.
#![allow(unused)] fn main() { pub enum TableSelectionMode { /* variants */ } }
Variants
None— No selection allowed.SingleRow— At most one row selected at a time.MultiRow— Multiple rows selectable; Ctrl-click toggles, Shift-click extends. Default.SingleCell— Excel-style: at most one cell selected at a time.MultiCell— Excel-style: rectangular cell selection.
Methods
pub fn is_cell_mode(self) -> bool
Whether the mode operates on cells rather than entire rows.
pub fn is_multi(self) -> bool
Whether the mode allows more than one entry to be selected.
pub struct CellSelectionModel
Cell-level selection state for TableSelectionMode::SingleCell /
MultiCell. Tracks (row, col) pairs in visible-index space.
Mirrors teksilo_data::SelectionModel's API surface (signal-backed,
auto-adjustable on data mutations) but keyed by (row, col) instead of
row alone.
#![allow(unused)] fn main() { pub struct CellSelectionModel { /* fields */ } }
Methods
pub fn new(mode: TableSelectionMode) -> Self
Construct a model. Panics if mode is not a cell mode —
callers in row mode should use teksilo_data::SelectionModel.
pub fn mode(&self) -> TableSelectionMode
pub fn selection_signal(&self) -> Signal<BTreeSet<(usize, usize)>>
pub fn is_selected(&self, row: usize, col: usize) -> bool
pub fn count(&self) -> usize
pub fn select(&self, row: usize, col: usize)
Replace the selection with the single cell (row, col) and set
the anchor.
pub fn toggle(&self, row: usize, col: usize)
Toggle the cell (row, col) (Ctrl-click). In SingleCell mode
this behaves like select.
pub fn extend_to(&self, row: usize, col: usize)
Extend the selection to include the rectangular range from the
anchor to (row, col). In SingleCell mode this falls back to
select.
pub fn select_all(&self, row_count: usize, col_count: usize)
Select every cell in 0..row_count × 0..col_count.
pub fn clear(&self)
pub fn adjust_for_row_insert(&self, at_row: usize, count: usize)
Adjust selection after count rows are inserted starting at
at_row. Existing selections at indices >= at_row shift up.
pub fn adjust_for_row_remove(&self, at_row: usize, count: usize)
Adjust selection after count rows starting at at_row are
removed. Selections within the removed range are dropped; later
rows shift down.
pub fn adjust_for_row_move(&self, from: usize, to: usize, count: usize)
Adjust selection after a block of count rows moved from from to
to (a post-removal index, matching ListModel::move_item). Selected
cells follow their rows; columns are untouched.
pub fn adjust_for_column_insert(&self, at_col: usize, count: usize)
Adjust selection after count columns are inserted at at_col.
Reserved for future dynamic-column support. TableView/TreeTableView
columns are declared once via .add_column()/.columns() and are
static for the widget's lifetime — there is no runtime insert/remove
API today, so nothing calls this. A column reorder or pin-toggle
permutes positions instead (see remap_columns),
which is what the current views actually use. Kept (not removed) as
public API in case a future dynamic-column feature needs the
offset-shift semantics this and adjust_for_column_remove
already implement and test.
pub fn adjust_for_column_remove(&self, at_col: usize, count: usize)
Adjust selection after count columns starting at at_col are
removed.
Reserved for future dynamic-column support — see the doc comment on
adjust_for_column_insert; nothing
calls this today for the same reason.
TabWidget

Tabbed-container widgets.
Two public entry points:
-
TabBar<T>— a header strip driven by aListModel<T>/ListDataSourceand aTabDelegate<T>. Use it stand-alone when you want only the tab strip (e.g., a document tab strip whose content lives in a different panel or window). -
TabWidget— the all-in-one composition: bar above, contentSwitcherbelow, sharing one selection signal. Two construction flavors:static_tab(info, content)— fixed tabs accumulated at construction.dynamic_tab::<S>(kind, factory)+dynamic_model(model)— apps register a content factory per tabkind("plain-text-doc","image", …); the live tab list is a mutableListModel<TabHandle>mutated at runtime (open / close / reorder).
Static tabs always render first, in declaration order; dynamic
tabs follow. Selection is by stable TabId — drag-reorder and
model mutations never silently send the active selection to a
different tab.
Activating a tab scrolls it into view
When more tabs are open than the strip can show, activating one always reveals it — including when the activation is programmatic (writing the selection signal, the "show all tabs" overflow dropdown, an assistive-technology click). Pointer and keyboard activation move focus and would be revealed by the framework's focus follow anyway; the other paths move no focus, so the bar scrolls the header in itself, by the minimum needed to bring it fully inside the viewport.
The reveal is edge-triggered on the selection changing, not an invariant re-asserted every layout pass: once the reader has scrolled away from the active tab by hand, a rebuild for an unrelated reason — a retitled tab, a locale change, a tab opened elsewhere in the strip — leaves the viewport where they left it.
Accessibility
Both TabWidget and TabBar emit Role::TabList on the bar
and Role::Tab on each header. ARIA APG (tabs
pattern)
recommends providing an accessible name for the tab list
whenever a page hosts more than one — call
.access_label(tr!(editor_tabs()))
on the widget so screen readers can distinguish "editor tabs"
from "tool tabs":
TabWidget::new(selected)
.static_tab(TabInfo::new().title(tr!(welcome())), welcome_panel)
// ...
.access_label(tr!(editor_tabs()))
Panels with no focusable descendants (a static text-only "About"
tab, a chart-only metrics tab) are unreachable by Tab key unless
opted in via TabInfo::focusable_panel(true).
Builder methods at a glance
enabled, bar_visibility, tab_bar_height, compact_bar, vertical, horizontal, orientation, static_tab, tab, tab_id, static_tab_factory, static_tab_id, static_tab_with_id, static_tab_factory_with_id, dynamic_tab, dynamic_model, tab_sizing, sizing, tab_display, tab_background, selected_tab_background, hover_tab_background, idle_tab_background, bar_background, tab_dividers, tab_divider_color, active_indicator, selected_text_role, idle_text_role, min_tab_width, max_tab_width, pinned_tab_width, show_scroll_arrows, overflow_button, show_overflow_dropdown, reorderable, on_close, on_reorder, on_pin_toggle, accept_external_tabs, on_tab_received, on_transfer_out, on_external_drop, bar_leading_slot, bar_trailing_slot, bar_leading_slot_id, bar_trailing_slot_id
API reference
📖 Full rustdoc API for this module
pub type StaticContentFactory
Closure that builds a static tab's content widget. Called once
per static tab — on the TabWidget's first build that includes
it. The resulting pane is then memoized: rebuilds caused by
adjacent dynamic-model mutations reuse the same pane WidgetId, so
internal state (focus, scroll, animation progress, …) survives.
#![allow(unused)] fn main() { pub type StaticContentFactory = Rc<dyn Fn(&TabHandle) -> Box<dyn Widget>>; }
pub struct TabWidget
All-in-one tabbed container. Builds a TabBar above a
Switcher of content panes, sharing one selection signal.
#![allow(unused)] fn main() { pub struct TabWidget { /* fields */ } }
Methods
pub fn new(selected: Signal<Option<TabId>>) -> Self
Construct an empty TabWidget. Selection is None until
the first static_tab(...) / dynamic_model(...) adds a
tab and the framework activates it.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the whole widget. A disabled TabWidget greys out
and stops accepting focus / selection / keyboard input
(arena-gated). Distinct from per-tab TabInfo::enabled.
pub fn bar_visibility(mut self, visibility: impl Into<Prop<TabBarVisibility>>) -> Self
Set the tab-strip visibility policy (default
TabBarVisibility::Always). Use TabBarVisibility::WhenMultiple
to hide the strip while a single tab is present, or
TabBarVisibility::Never when an external selector (e.g. a
docking activity rail) drives selection.
Accepts a plain TabBarVisibility or a Signal<TabBarVisibility>.
Bound reactively, the strip appears and disappears in place — the
TabWidget itself is never torn down, so per-tab content state
(caret, scroll offset, focus) survives the flip. That is the point
of binding rather than swapping two TabWidgets in a Switcher:
an app-level "hide the chrome" mode must not cost the user their
place in the document.
A derived signal (.map(..) / .zip(..)) is fine here: binding
resolves through to the mutable roots and never calls observe.
pub fn tab_bar_height(mut self, dp: f32) -> Self
Override the tab-strip height (its cross-axis extent). None /
unset keeps the style's editor_tab_height (50 dp). Use for a denser
strip — e.g. dock side panels.
pub fn compact_bar(self) -> Self
Shorthand for a compact (38 dp) tab strip — denser than the standard
50 dp editor strip. Equivalent to self.tab_bar_height(38.0).
pub fn vertical(self) -> Self
Configure the bar to render vertically — pills stacked
top-to-bottom on the leading edge, content fills the trailing
area (sidebar / IDE-perspective convention). Equivalent to
self.orientation(TabBarOrientation::Vertical).
pub fn horizontal(self) -> Self
Configure the bar to render horizontally — pills laid out left-to-right above the content (browser tab convention). This is the default.
pub fn orientation(mut self, orientation: impl Into<Prop<TabBarOrientation>>) -> Self
Set the bar orientation, statically or reactively. Passing a
Signal<TabBarOrientation> replaces the internal orientation
signal with the external one — lets a parent widget toggle
orientation reactively (e.g. a "View → Vertical Tabs" toolbar
button) without recreating the TabWidget.
pub fn static_tab(mut self, info: TabInfo, content: impl Widget + 'static) -> Self
Add a static tab — fixed for the widget's lifetime, with a
pre-built content widget. The content is registered in the
arena on the TabWidget's first build and memoized —
subsequent rebuilds (caused by adjacent dynamic-model
mutations) reuse the same pane WidgetId, preserving any
internal state the content owns.
pub fn tab(self, label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self
Ergonomic shorthand for a title-only static tab:
tab(label, content) is static_tab(TabInfo::new().title(label), content). label accepts tr!(...) (translated) or lit!(...).
This is the method the teksu! tab: slot lowers to
(tab: lit!("Overview"), Card { … }).
pub fn tab_id(self, label: impl Into<LocalizedString>, id: WidgetId) -> Self
WidgetId twin of tab — tab_id(label, id) is
static_tab_id(TabInfo::new().title(label), id). This is what the
teksu! tab: slot lowers to when its content is an id binding
(#{…} / name = Element).
pub fn static_tab_factory( mut self, info: TabInfo, factory: impl Fn(&TabHandle) -> Box<dyn Widget> + 'static, ) -> Self
Add a static tab whose content is constructed by a factory
closure. The factory is called once — on the slot's first
build — and the resulting pane is memoized just like
static_tab.
pub fn static_tab_id(mut self, info: TabInfo, content_id: WidgetId) -> Self
Element-valued slot variant for the teksu! DSL — accepts a
pre-registered widget id rather than a Box<dyn Widget>.
Equivalent to static_tab with an
already-built child; the id is wrapped in a tab pane on
first build and the pane id is memoized thereafter.
pub fn static_tab_with_id( mut self, id: TabId, info: TabInfo, content: impl Widget + 'static, ) -> Self
Add a static tab with a caller-provided TabId — useful
when external code (an app-event handler, a session-restore
path, a deep link) needs to flip selection to this tab by id.
The pane is memoized like static_tab.
pub fn static_tab_factory_with_id( mut self, id: TabId, info: TabInfo, factory: impl Fn(&TabHandle) -> Box<dyn Widget> + 'static, ) -> Self
Factory variant of static_tab_with_id.
pub fn dynamic_tab<S: Any + 'static>( mut self, kind: &'static str, factory: impl Fn(&TabHandle, &S) -> Box<dyn Widget> + 'static, ) -> Self
Register a dynamic-tab content factory keyed by kind. The
<S> type parameter pins the payload type — the framework
downcasts handle.payload to S before calling the
factory and panics with a clear message on kind/payload
mismatch, so Any never leaks into app code.
pub fn dynamic_model(mut self, model: ListModel<TabHandle>) -> Self
Connect the dynamic-tab data source. Mutations rebuild the dynamic-tab subtree; static tabs are unaffected.
pub fn tab_sizing(mut self, mode: TabSizing) -> Self
Set the per-tab sizing strategy as a static value. Internally
stores it as a Signal<TabSizing> so the widget can be
retrofitted to reactive control via Self::sizing
without breaking existing call sites.
pub fn sizing(mut self, sizing: impl Into<Prop<TabSizing>>) -> Self
Bind the per-tab sizing strategy, statically or reactively —
flipping a bound signal swaps between Shared / Independent / Fill
live, with no rebuild on the parent's part. The signal is bound at
BindingLevel::Rebuild inside build;
memoized panes survive the rebuild so per-tab state is
preserved.
pub fn tab_display(mut self, mode: impl Into<Prop<TabDisplayMode>>) -> Self
Choose what every tab shows — icon, label, or both
(TabDisplayMode), statically or reactively. A bound signal can be
flipped to swap icon / text / icon+text live (the bar rebuilds,
memoized panes survive), with no rebuild on the parent's part. Bound
at BindingLevel::Rebuild.
pub fn tab_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
All-states shorthand for the per-tab background — every tab
(selected, idle, hovered) paints this unless a per-state override
is set. Accepts any Color, SurfaceRole, or Signal<Color> (via
ColorProp). Default is
transparent. To tint the bar's backdrop instead, use
bar_background.
pub fn selected_tab_background( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Background for the selected tab. Falls back to
tab_background, then transparent.
pub fn hover_tab_background( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Background for the hovered (non-selected) tab. Falls back to
tab_background, then transparent.
pub fn idle_tab_background( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Background for idle tabs (not selected, not hovered). Falls back
to tab_background, then transparent.
pub fn bar_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self
Set the bar-strip backdrop fill (behind headers, slots, arrows), independent of the per-tab backgrounds. Default transparent.
pub fn tab_dividers(mut self) -> Self
Draw a 1 dp divider between consecutive tabs. Off by default.
pub fn tab_divider_color( mut self, color: impl Into<teksilo_core::color_prop::ColorProp>, ) -> Self
Like tab_dividers with an explicit colour
(Color, BorderRole, or
Signal<Color>). Implies tab_dividers().
pub fn active_indicator( mut self, position: teksilo_core::styles::TabIndicatorPosition, ) -> Self
Choose which edge the active-tab highlight indicator hugs. Default
TabIndicatorPosition::OuterEdge;
InnerEdge
puts it below the label (horizontal) / trailing edge (vertical).
pub fn selected_text_role(mut self, role: teksilo_tokens::TextRole) -> Self
Set the text role used for the label (and matching icon tint)
on the selected tab. Default: teksilo_tokens::TextRole::Primary
— the Int UI editor-strip convention. Override to e.g.
teksilo_tokens::TextRole::Accent when the strip sits over a
tinted surface.
pub fn idle_text_role(mut self, role: teksilo_tokens::TextRole) -> Self
Set the text role used for the label (and matching icon tint)
on idle tabs (not selected, not disabled). Default:
teksilo_tokens::TextRole::Secondary. Disabled tabs always read
as teksilo_tokens::TextRole::Disabled regardless of this
setting.
pub fn min_tab_width(mut self, dp: f32) -> Self
Minimum scrollable-tab width in logical pixels. Default
DEFAULT_MIN_TAB_WIDTH.
pub fn max_tab_width(mut self, dp: f32) -> Self
Maximum scrollable-tab width in logical pixels. Default
DEFAULT_MAX_TAB_WIDTH.
pub fn pinned_tab_width(mut self, dp: f32) -> Self
Fixed width for pinned (icon-only) tabs in logical pixels. Default
DEFAULT_PINNED_TAB_WIDTH.
pub fn show_scroll_arrows(mut self, on: bool) -> Self
Show or hide the leading/trailing scroll-arrow buttons when tabs overflow. Default (unset) uses the style's preference.
pub fn overflow_button(mut self, mode: TabOverflowButton) -> Self
When the trailing "show all tabs" overflow dropdown appears. Default
(unset) is TabOverflowButton::Auto — shown only when the tab headers
overflow the bar's viewport. See TabOverflowButton for
Always / Never.
pub fn show_overflow_dropdown(mut self, on: bool) -> Self
Convenience over overflow_button: true maps
to TabOverflowButton::Always, false to TabOverflowButton::Never.
pub fn reorderable(mut self, on: bool) -> Self
Allow drag-to-reorder of tabs within the bar. Default false.
Setting on_reorder implies reorderable(true).
pub fn on_close(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self
Install a close-tab handler. Receives the TabId of the
closed tab (not its index — indices are presentation-only)
and the firing EventContext. The latter lets the handler
open a confirmation dialog
(ctx.present_modal(MessageBox::confirm(...))), dispatch an
intent, or otherwise route the close request before mutating
the underlying model. To veto, do nothing in the handler; to
confirm-then-close, only call the model mutator on accept.
If unset, the default behavior is to remove the tab from
dynamic_model without a prompt
(static tabs cannot be closed by default).
pub fn on_reorder(mut self, f: impl Fn(TabId, usize, &mut EventContext) + 'static) -> Self
Install a reorder handler. Receives (moved_tab_id, destination_index, ctx) in the unified static-then-dynamic
ordering. The firing EventContext lets the handler
confirm or dispatch the reorder via a dialog / intent
before mutating the model. If unset, the default behavior
is to reorder within the dynamic region of
dynamic_model. Implies
reorderable(true).
pub fn on_pin_toggle(mut self, f: impl Fn(TabId, bool, &mut EventContext) + 'static) -> Self
Install a pin-toggle handler — receives (tab_id, new_pinned_flag, ctx) when the user drags a tab across the
pinned ↔ unpinned boundary. The firing EventContext
lets the handler confirm or dispatch the transition via a
dialog / intent. Apps decide whether to actually mutate the
tab's info.pinned.
pub fn accept_external_tabs(mut self, on: bool) -> Self
Opt into cross-TabWidget tab transfer (app-internal
drag-and-drop between two tabbed containers). When enabled,
this widget's dynamic tabs can be dragged out to any other
accepting TabWidget, and it accepts tabs dragged in from one,
painting an insertion-line indicator between its tabs.
The dragged TabHandle moves intact — its Rc<dyn Any>
payload (the heavy per-tab state) is preserved, not rebuilt —
so the receiving widget must register a content factory for the
tab's kind via dynamic_tab.
Static tabs are excluded: they have no factory on a
receiving widget, so they can never be transferred out (they
still reorder in place if reorderable).
By default, accepting a tab inserts it into this widget's
dynamic_model and transferring one out
removes it from this widget's model. Override either side with
on_tab_received /
on_transfer_out. Default: off.
pub fn on_tab_received( mut self, f: impl Fn(TabHandle, usize, &mut EventContext) + 'static, ) -> Self
Override the target-side behaviour when a foreign tab is
dropped onto this widget. Receives (handle, insertion_index, ctx) where insertion_index is within the dynamic tab
region. The app inserts the handle into its own model. Implies
accept_external_tabs(true).
If unset, the default inserts the handle into
dynamic_model at the drop position.
pub fn on_transfer_out(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self
Override the source-side behaviour after one of this widget's
tabs has been accepted by another TabWidget. Receives the
transferred TabId; the app removes it from its own model.
Implies accept_external_tabs(true).
If unset, the default removes the tab from
dynamic_model.
pub fn on_external_drop( mut self, f: impl Fn(&DragPayload, usize, &mut EventContext) -> bool + 'static, ) -> Self
Accept non-tab drops onto the tab bar — an in-app foreign
drag (e.g. a file dragged from a TreeView, carrying app data)
or an OS file/text/URL drop. The bar shows an insertion-line
indicator while such a payload hovers; on drop, f runs with
the raw DragPayload, the insertion index within the dynamic
region, and the firing context. Inspect the payload
(get_typed::<T>() / files() / text() / uris()) and, e.g.,
push a new TabHandle into your dynamic_model;
return true if accepted.
This is the "open a dropped file as a tab" hook (VS Code style).
Independent of accept_external_tabs.
OS drops also require TeksiloAppBuilder::install_external_dnd().
pub fn bar_leading_slot(mut self, w: impl Widget + 'static) -> Self
Place a widget on the leading edge of the tab strip (before the first tab). Memoized: registered once on first build, reused on rebuilds.
pub fn bar_trailing_slot(mut self, w: impl Widget + 'static) -> Self
Place a widget on the trailing edge of the tab strip (after the last
tab and overflow button). Memoized like
bar_leading_slot.
pub fn bar_leading_slot_id(mut self, id: WidgetId) -> Self
Element-valued variant of
bar_leading_slot accepting a
pre-registered WidgetId (for the teksu! DSL).
pub fn bar_trailing_slot_id(mut self, id: WidgetId) -> Self
Element-valued variant of
bar_trailing_slot.
pub enum TabBarVisibility
Controls whether a TabWidget's tab strip is shown.
The default is Always — fully
back-compatible with the historical behaviour. WhenMultiple hides the strip while a single tab
is present (the content fills the whole area) and shows it again
once a second tab appears; the evaluation is reactive because a
dynamic-model mutation already rebuilds the TabWidget.
Never always hides the strip (the
selector lives elsewhere — e.g. a docking activity rail).
#![allow(unused)] fn main() { pub enum TabBarVisibility { /* variants */ } }
Variants
Always— Always render the tab strip (historical default).WhenMultiple— Show the strip only when two or more tabs are present.Never— Never render the strip; the content fills the whole area.
pub type ContextMenuFactory
A reusable widget factory the framework calls every time a context menu opens. Returns a fresh widget instance each call (the framework can't reuse a single widget across multiple openings).
Same shape as the framework's
teksilo_core::widget_builder::ContextMenuFactory — receives the
click position (in tab-local coords) and a full
EventContext, and returns Some(menu) to mount or None to
decline. The Rc wrapping is a tab-widget convenience: the
delegate clones the factory per-tab without reallocating.
#![allow(unused)] fn main() { pub type ContextMenuFactory = Rc<dyn Fn(Point, &mut EventContext) -> Option<Box<dyn Widget>>>; }
pub enum TabBarOrientation
Bar orientation. Selects between a horizontal row of tabs (default for browser-style document tabs) and a vertical column of pills (sidebar / IDE perspective convention).
#![allow(unused)] fn main() { pub enum TabBarOrientation { /* variants */ } }
Variants
Horizontal— Tabs flow left-to-right in a horizontal row. Scroll axis is horizontal; a vertical wheel maps to horizontal scroll (Firefox / Chrome convention) whenvertical_wheel_scrolls_horizontallyis on.Vertical— Tabs flow top-to-bottom in a vertical column. Scroll axis is vertical; vertical wheel scrolls vertically. Pinned tabs render in a non-scrolling strip at the top of the column.
pub enum TabSizing
How wide each tab is: shared across all unpinned tabs, chosen per-tab from content, or stretched to fill the bar.
Shared and Independent size the layout axis (width in
horizontal bars, height in vertical bars); Fill sizes the tab's
width in both orientations — see each variant. See the module
docs of crate::tab_widget for how this is applied per
orientation. In wrap (multi-line horizontal) mode Independent is
forced regardless of this setting — equal-width tabs in a wrapping
row look like a tile grid and lose the bookmark-bar / pill-strip
aesthetic.
#![allow(unused)] fn main() { pub enum TabSizing { /* variants */ } }
Variants
Shared— All non-pinned tabs share the same extent on the layout axis. The available region is divided equally across the unpinned count, then clamped to[min_tab_extent, max_tab_extent]. Below the min, content overflows into scroll. Above the max, slack is left as empty space at the trailing edge. In a vertical bar the layout axis is the pill height, so this yields uniform pills whose width fits the widest label (clamped to[min_tab_width, max_tab_width]).Independent— Each tab sizes to its content (icon + label + slots), clamped to[min_tab_extent, max_tab_extent]. Truncation via ellipsis when content hitsmax.Fill— Tabs stretch to the full width the bar is offered — no slack left over, no fit-to-content shrinking. The nav-rail / segmented-control look (VS Code's settings sidebar, a full-bleed tab strip). - Horizontal: the viewport width is divided equally across the unpinned tabs andmax_tab_widthis not applied, so the strip is filled edge to edge instead of leaving trailing slack.min_tab_widthstill holds — below it the headers overflow into scroll rather than squeezing to nothing. - Vertical: every pill takes the bar's full proposed width (the widest-label clamp is bypassed), so the tabs span the sidebar. Pill height is unchanged (the intrinsiceditor_tab_height, or thetab_bar_heightoverride). With no width proposed at all (an unbounded measure — aCenter, anHStackasking for the natural size), there is nothing to fill: a vertical bar falls back to theSharedfit-to-widest-label width. Give the bar a bounded width (aFixedSize, anExpandin a sized parent) forFillto have any effect.
pub enum TabDisplayMode
Bar-level control over what each tab shows — its icon, its label, or both.
Each tab still declares both a title and (optionally) an icon; this mode decides which are painted, so a caller can offer a "tab size" toggle (VS Code's activity-bar / panel convention) without rebuilding the tabs by hand. Icon-only tabs size to their icon (they don't pad out to a text width), and the full title is promoted to the hover tooltip.
#![allow(unused)] fn main() { pub enum TabDisplayMode { /* variants */ } }
Variants
Auto— Render each tab exactly as itsTabInfodeclares — the title if set, the icon if set. The default; preserves per-tabno_title()control.Text— Title only — icons are hidden even when present.Icon— Icon only — the title becomes the hover tooltip. A tab with no icon falls back to its title's initial letter so the mode is never blank.IconText— Icon + title.
pub enum TabOverflowButton
When the trailing "show all tabs" overflow dropdown button appears.
The dropdown is a chevron-down PopoverIconButton whose popover lists every
tab (a jump-to menu for tabs scrolled out of view). This mode governs when
the button itself is shown — independent of whether the tabs actually
overflow the viewport (which is what drives the scroll arrows).
#![allow(unused)] fn main() { pub enum TabOverflowButton { /* variants */ } }
Variants
Auto— Show the button only when the tab headers overflow the bar's viewport — i.e. exactly when there is something scrolled out of view, the same condition that auto-reveals the scroll arrows. The default: the button stays out of the way until it is useful.Always— Always show the button whenever the bar has at least one tab, even when every tab is already visible (a persistent jump-to affordance).Never— Never show the button.
pub struct TabDelegate
Resolves per-tab UI from a model item.
Required: a label callback. Everything else is optional and
defaults to "no leading icon, no slots, no tooltip, not closable,
not pinned, enabled".
#![allow(unused)] fn main() { pub struct TabDelegate<T: 'static> { /* fields */ } }
Methods
pub fn new(label: impl Fn(usize, &T) -> LocalizedString + 'static) -> Self
Construct from the label callback. Every other field defaults to its identity behavior.
pub fn icon(mut self, f: impl Fn(usize, &T) -> Option<IconWidget> + 'static) -> Self
Per-tab leading icon (rendered before the label).
pub fn leading(mut self, f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static) -> Self
Per-tab leading slot (between the icon and label, or before the label when no icon is present).
pub fn trailing(mut self, f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static) -> Self
Per-tab trailing slot (between the label and the close button, or at the trailing edge when no close button is present).
pub fn context_menu( mut self, f: impl Fn(usize, &T) -> Option<ContextMenuFactory> + 'static, ) -> Self
Per-tab context menu factory. Activated by right-click /
long-press / accesskit::Action::ShowContextMenu.
The closure runs once per build and returns an optional
ContextMenuFactory. The factory itself is called every
time the menu opens, returning a fresh menu widget each call —
the framework cannot reuse a single widget instance across
multiple openings.
pub fn closable(mut self, f: impl Fn(usize, &T) -> bool + 'static) -> Self
Per-tab closable flag. When true, the tab gets a trailing
close button and middle-click / Ctrl+W close affordances.
Pinned tabs suppress the close button regardless of this flag
(pinned tabs only close via the context menu — Firefox
convention).
pub fn pinned(mut self, f: impl Fn(usize, &T) -> bool + 'static) -> Self
Per-tab pinned flag. Pinned tabs render in a leading non-scrolling region with a fixed icon-only width.
pub fn enabled(mut self, f: impl Fn(usize, &T) -> bool + 'static) -> Self
Per-tab enabled flag. Disabled tabs are visible but not activatable, skipped by keyboard navigation, and excluded from the close / pin / context-menu affordances.
pub fn tooltip(mut self, f: impl Fn(usize, &T) -> Option<LocalizedString> + 'static) -> Self
Per-tab tooltip text. Shown on hover via the existing
WidgetBuilder::tooltip mechanism.
pub fn rich_tooltip_key(mut self, f: impl Fn(usize, &T) -> Option<String> + 'static) -> Self
Per-tab rich-tooltip registry key. Returning Some(key) makes
the tab show a rich tooltip resolved against
TooltipRegistry.
pub fn rich_tooltip_content_with( mut self, f: impl Fn(usize, &T) -> Option<TooltipContent> + 'static, ) -> Self
Per-tab inline rich-tooltip content. Skips the registry — useful
for tooltips whose body depends on T's state.
pub fn composite_tooltip_with( mut self, f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static, ) -> Self
Per-tab composite-tooltip body factory. Returning
Some(boxed_widget) makes the tab show a composite tooltip
containing that subtree. The closure runs at tab-header build
time (and on every rebuild after data changes), so the body
can carry per-tab dynamic state.
pub const STATIC_KIND
Sentinel kind reserved for static tabs accumulated via
TabWidget::static_tab.
Application-level kind strings must not collide with this
value — the framework panics with a clear message at registration
if dynamic_tab is
called with this name.
#![allow(unused)] fn main() { pub const STATIC_KIND: &str = "__static__"; }
pub struct TabHandle
One tab's identity, presentation, and state pointer.
Clone is cheap: TabInfo is shallow (the icon is an
Rc<dyn Fn() -> IconWidget> factory) and payload is an
Rc<dyn Any>.
#![allow(unused)] fn main() { pub struct TabHandle { /* fields */ } }
Methods
pub fn dynamic<S: Any + 'static>( id: TabId, kind: &'static str, info: TabInfo, state: S, ) -> Self
Construct a handle for the dynamic-tab path. The kind
must match a
dynamic_tab::<S>
registration on the TabWidget
where this handle lands; the framework downcasts
payload to S before calling the registered factory and
panics with a clear message on type mismatch.
pub fn dynamic_shared( id: TabId, kind: &'static str, info: TabInfo, payload: Rc<dyn Any>, ) -> Self
Construct a handle for the dynamic-tab path with a
pre-built Rc<dyn Any> payload — useful when several
handles share the same underlying state object.
pub struct TabId
Stable identity of a tab. Cheap to copy; persists across model reorders, rebuilds, and reorders triggered by drag-and-drop.
#![allow(unused)] fn main() { pub struct TabId(NonZeroU64); }
Methods
pub fn fresh() -> Self
Allocate a new, never-before-seen id. Backed by a monotonic global counter — overflow is theoretically possible after 2^64 calls, at which point the universe has had bigger problems.
pub fn from_raw(value: NonZeroU64) -> Self
Wrap an externally-allocated key. Use this when the tab's
identity comes from an existing app-side store (document
UUID, file path hash, etc.) — calling TabId::fresh would
allocate a new id every restart, breaking session restore.
pub fn raw(self) -> NonZeroU64
The underlying non-zero u64. Useful when persisting tabs
across sessions: serialize this, restore via from_raw.
pub type IconFactory
Reusable factory for an IconWidget. Boxed in Rc so
TabInfo is Clone without forcing IconWidget: Clone.
#![allow(unused)] fn main() { pub type IconFactory = Rc<dyn Fn() -> IconWidget>; }
pub struct TabInfo
Per-tab presentation metadata. Build with TabInfo::new and
fluent setters.
#![allow(unused)] fn main() { use teksilo_widgets::tab_widget::TabInfo; use teksilo_widgets::primitives::IconWidget; use teksilo_i18n::lit; let _info = TabInfo::new() .title(lit!("Welcome")) .icon(|| IconWidget::checkmark(16.0)) .closable(true); }
#![allow(unused)] fn main() { pub struct TabInfo { /* fields */ } }
Methods
pub fn new() -> Self
Empty defaults: no title, no icon, no tooltip, not closable, not pinned, enabled.
pub fn context_menu( mut self, f: impl Fn(Point, &mut EventContext) -> Option<Box<dyn Widget>> + 'static, ) -> Self
Attach a per-tab context menu (right-click the tab header). The
factory receives the click position (tab-local) and a full
EventContext, and returns Some(menu) to mount or None to
decline (falling through to an ancestor). Cloned per header build.
pub fn title(mut self, t: impl Into<LocalizedString>) -> Self
Set the tab's title. Accepts tr!(...), a literal string,
or any value implementing Into<LocalizedString>.
None means icon-only (the pinned-tab presentation).
pub fn no_title(mut self) -> Self
Untitled — useful for icon-only tabs even when not pinned.
pub fn icon(mut self, factory: impl Fn() -> IconWidget + 'static) -> Self
Set the leading icon via a factory closure. The closure is
called each time the TabHeader
is built — typically once per tab lifetime, plus any rebuild
triggered by data-source mutations.
pub fn tooltip(mut self, t: impl Into<LocalizedString>) -> Self
Tooltip text shown on hover. If unset and the tab is
pinned, the framework promotes title
to the tooltip — pinned tabs render icon-only and otherwise
have no way for the user to identify them.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip
registry. See Button::rich_tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent.
pub fn composite_tooltip<W>(mut self, factory: impl Fn() -> W + 'static) -> Self where W: Widget + 'static,
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. The factory closure is called each time the
tab's header rebuilds, so the body picks up theme / locale
changes naturally without retaining state across rebuilds.
pub fn closable(mut self, b: bool) -> Self
Whether the tab shows a close button + responds to
middle-click. Default: false.
pub fn pinned(mut self, b: bool) -> Self
Whether the tab renders in the leading pinned strip
(icon-only, fixed-width, no close button — Firefox / Chrome
convention). Default: false.
pub fn enabled(mut self, enabled: impl Into<teksilo_core::signal::Prop<bool>>) -> Self
Whether the tab can be activated. Disabled tabs render but
are skipped by keyboard navigation, can't be clicked, and
don't get the close button. Default: true.
Forwarded to the arena via ctx.enabled_when(header_id, false)
at build time when false. Ancestor-driven disable (e.g. a
disabled TabBar) ANDs with this flag automatically.
pub fn focusable_panel(mut self, b: bool) -> Self
Make the tab's content pane itself focusable, so keyboard users
can press Tab from the selected tab header and land inside
the panel.
Opt in for panels you know contain no focusable descendants —
a static text-only "About" tab, a chart-only metrics tab.
Panels that already host a Button, TextInput, ListView,
or any other interactive widget don't need this: focus will
flow naturally into the descendant.
ARIA: this implements the tabindex="0" requirement that an
empty tabpanel must be focusable so its content can be read
by screen readers in browse mode. AccessKit has no tabindex
field; the framework advertises Action::Focus on the panel
node to signal focusability to AT. Default: false.
TextInput

TextInput — styled single-line text field composite.
Wraps the TextInputField
editing primitive in a bordered, padded frame with placeholder
overlay, validation, optional clear button, and leading/trailing
slots. All actual text editing is delegated to the field: every
configuration method here has a direct counterpart on the
primitive.
Most applications want TextInput. Choose
TextInputField directly
when you're building a composite of your own that already
supplies its frame — SpinBox is the canonical in-tree example.
Example
let search = ctx.signal(String::new());
TextInput::new(search.clone())
.placeholder("Search...")
.show_clear_button(true)
.leading_slot(IconWidget::from_svg(SEARCH_ICON))
.on_submit_fn(|ctx| ctx.send_intent(AppIntent::Search))
Builder methods at a glance
variant, style, placeholder, label, enabled, read_only, max_length, show_clear_button, min_width, leading_slot, trailing_slot, on_submit_fn, on_blur_fn, char_filter, suffix, input_mask, input_purpose, active_descendant, controls, validator, caret_position, handle, caret_setter, validation_feedback_signal, validation, validation_feedback, tooltip, rich_tooltip_key, rich_tooltip, rich_tooltip_content, composite_tooltip, text
API reference
📖 Full rustdoc API for this module
pub enum ValidationState
Validation state for the text input field.
Drives the inline feedback strip and border tint of TextInput.
#![allow(unused)] fn main() { pub enum ValidationState { /* variants */ } }
Variants
None— No validation message — the field is pristine or valid.Error— The committed value is invalid;LocalizedStringis shown in red below the field.Warning— The committed value is suspicious but accepted;LocalizedStringis shown as a warning.Corrected— Last commit was auto-corrected; the field's value has already been replaced with the normalized form. The composite renders the message in secondary text and tints the border accent briefly (decay-managed by the framework's frame loop, not a concern of this enum).
pub struct TextInput
Styled single-line text input composite.
See the module-level documentation for usage examples.
#![allow(unused)] fn main() { pub struct TextInput { /* fields */ } }
Methods
pub fn new(text: Signal<String>) -> Self
Construct a new text input bound to text.
pub fn variant(mut self, variant: TextInputVariant) -> Self
Pick a Tier-1 design-language variant
(TextInputVariant::Outlined / Filled / Underline / Bare).
The IntUI default (crate::styles::RecipeTextInputStyle) honours
Outlined, Filled, and Bare; Underline falls back to
Outlined until per-side stroke recipes land.
pub fn style(mut self, style: impl TextInputStyle) -> Self
Override the active TextInputStyle for this widget instance
only. The widget keeps responsibility for caret blinking, IME
composition, the placeholder layering, the leading / trailing
slots and the validation strip — the style only paints the
frame (border / fill / corner radius).
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Set the placeholder text shown when the field is empty.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible name for the composite. Propagated to the outer
container's a11y node; the inner TextInputField still
carries Role::TextInput with the document's value.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the
arena and the inner TextInputField at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Set the field read-only: text is selectable and copyable but not editable.
pub fn max_length(mut self, max_length: usize) -> Self
Limit the number of Unicode scalar values the field will accept.
pub fn show_clear_button(mut self, show: bool) -> Self
Show or hide the trailing ✕ button that clears the field text. Default: hidden.
pub fn min_width(mut self, w: f32) -> Self
Override the frame's intrinsic minimum width (default 65 dp). Use to express a design width for date / time / phone-number fields whose content is well-known and whose collapse to the generic 65 dp floor would look out of place.
pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self
Set an arbitrary widget in the leading slot (before the text area).
Typically an IconButton or IconWidget.
pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self
Set an arbitrary widget in the trailing slot (after the text area).
Typically an IconButton or IconWidget.
pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on Enter. Forwarded to TextInputField.
pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure invoked on focus loss. Forwarded to TextInputField.
pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self
Per-character input-filter predicate. Forwarded to
TextInputField.
pub fn suffix(mut self, text: impl Into<String>) -> Self
Non-editable trailing string (Qt's QSpinBox::suffix).
Forwarded to TextInputField.
pub fn input_mask(mut self, mask: impl Into<String>) -> Self
Install an input mask (Qt grammar). Forwarded 1:1 to
TextInputField::input_mask. Composing widgets like
DateEdit use this to project the date format pattern
onto the editing surface.
pub fn input_purpose( mut self, purpose: crate::primitives::text_input_field::InputPurpose, ) -> Self
Declare the field's semantic InputPurpose
(WCAG 1.3.5), forwarded to the inner TextInputField to select a
specialised AT role (e.g. Role::EmailInput).
pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self
Publish active_descendant on the inner field, pointing at the row a
separate listbox is currently highlighting (the ARIA combobox pattern).
Forwarded 1:1 to TextInputField::active_descendant, which is where
it has to land: AT follows the focused node's active descendant, and
the inner field is the focusable one.
pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self
Publish a controls relation to the listbox this input drives.
Forwarded 1:1 to TextInputField::controls.
pub fn validator( mut self, f: impl Fn(&str) -> crate::primitives::text_input_field::ValidationOutcome + 'static, ) -> Self
Install a commit-time validator. Forwarded 1:1 to
TextInputField::validator. Pair with
Self::validation_feedback_signal (or
Self::validation_feedback) to surface the outcome
in the inline strip.
pub fn caret_position(&self) -> Signal<usize>
Reactive caret position. Mirrors the inner field's
TextInputField::caret_position after build. Capture
before ctx.add(text_input) — used by composing widgets
(DateEdit segment-stepping) that need to know which
segment Up/Down should step.
pub fn handle(&self) -> crate::primitives::TextFieldHandle
A live handle on the inner field — its text-editing commands, for a host outside the widget.
Mirrors TextInputField::handle, and exists for the same reason: an
application that routes Undo, Cut, Copy, Paste and Select All to
"whichever text surface holds the caret" must be able to reach every
such surface. A TextInput that could not be reached would silently
lose its own Ctrl+Z to whatever the host routed the chord at instead.
Like caret_setter, safe to take before build:
the handle reaches the field through a slot the widget fills in.
pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)>
Programmatic caret setter. Mirrors the inner field's
TextInputField::caret_setter. Returns a closure that
is a no-op until build runs; afterwards it walks the
inner field's state and moves the document cursor. Capture
before ctx.add(text_input).
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive published validation feedback. Mirrors the inner
field's TextInputField::validation_feedback_signal
after build. Composing widgets observe this to compose
feedback across multiple fields (range editor's
worse-of-two ladder, etc.).
pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self
Bind an external ValidationState signal directly (e.g. when
validation runs server-side), or set a fixed initial value. Use
validation_feedback
when wiring a local validator's output.
A bound Signal becomes the shared write target used internally
(by the validator-feedback bridge) and externally by the caller —
preserving the two-way channel this method has always offered. A
static value seeds a fresh, unshared signal.
pub fn validation_feedback(mut self, feedback: Signal<ValidationFeedback>) -> Self
Bridge a Signal<ValidationFeedback> (typically from a
validator-equipped widget like DateEdit::validation_feedback_signal
or a custom TextInputField) into this composite's
ValidationState. The feedback is mirrored on every change,
translating outcomes into the composite's display vocabulary:
Pristine/Valid→ValidationState::NoneCorrected { message, .. }→ValidationState::Corrected(message)Invalid { message }→ValidationState::Error(message)
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip. Accepts tr!(...) or lit!(...).
pub fn rich_tooltip_key(mut self, key: impl Into<String>) -> Self
Attach a registry-driven rich tooltip by key. Mutually exclusive with
tooltip and composite_tooltip (last call wins).
pub fn rich_tooltip(mut self, content: tooltip::TooltipContent) -> Self
Attach an inline rich tooltip from a pre-built tooltip::TooltipContent.
Mutually exclusive with tooltip and composite_tooltip (last call wins).
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach an inline rich tooltip from a pre-built tooltip::TooltipContent.
Canonical alias for Self::rich_tooltip — matches the name used by
Button, ComboBox, and other widgets. Mutually exclusive with
tooltip and composite_tooltip (last call wins).
pub fn composite_tooltip( mut self, content: impl teksilo_core::widget::Widget + 'static, ) -> Self
Attach a composite tooltip — third tier, hosting an arbitrary
widget tree. See Button::composite_tooltip.
pub fn text(&self) -> Signal<String>
The reactive text content signal.
TextInputField
TextInputField — editable single-line text surface primitive.
This is the raw editing primitive that powers the styled
TextInput composite and any
other widget that needs inline editable text — SpinBox being
the primary second consumer.
Unlike TextInput, TextInputField paints no frame, no
placeholder overlay, no validation border, and hosts no trailing
slots: it is the focusable text area only. Compose it yourself
with RectWidget, Padding, icons, clear buttons, etc. to
build a styled control. Focus indication is the composite's
responsibility — the Int UI convention is to thicken the
enclosing frame's border to focus_ring_width and recolor it
to the accent focus-ring color.
Features:
- Bound
Signal<String>for two-way text binding. - Full keyboard editing (arrow keys, Home/End, Backspace/Delete, Ctrl+X/C/V, Ctrl+A, Ctrl+Z/Y), IME commit, and pointer caret positioning and drag-select.
- Optional per-character input filter
(
TextInputField::char_filter), max-length cap (TextInputField::max_length), and read-only mode (TextInputField::read_only). - Commit hooks: Enter fires
on_submit_fnand focus loss fireson_blur_fn. - Non-editable trailing
suffix, rendered flush-right inside the field's bounds (Qt'sQSpinBox::suffix). Caret cannot enter it; clicks past the text end clamp to the last character. - Right-click context menu (Cut / Copy / Paste / Select All).
- AccessKit
Role::TextInputwith value, selection, and character/word boundary metadata.
Example
let text = ctx.signal(String::new());
ctx.add(
TextInputField::new(text.clone())
.placeholder("Enter a name…")
.char_filter(|c| !c.is_ascii_digit())
.on_submit_fn(|ctx| ctx.send_intent(MyIntent::Save)),
);
Builder methods at a glance
placeholder, enabled, read_only, max_length, on_submit_fn, on_blur_fn, char_filter, suffix, text_height, interaction_signal, input_mask, mask_placeholder, validator, secure, input_purpose, active_descendant, controls, echo_char, revealed, at_reveal_policy, allow_copy, validation_feedback_signal, text, share_handle, handle, interaction, caret_position, caret_setter
API reference
📖 Full rustdoc API for this module
pub enum InputPurpose
The semantic purpose of a text field, surfaced to assistive technology as a specialised AccessKit role (WCAG 1.3.5 Identify Input Purpose / EN 301 549).
This is the in-framework-achievable part of SC 1.3.5: a screen reader
announces "email, edit text" instead of a generic "edit text". The FULL
HTML autocomplete-token vocabulary (given-name, postal-code,
cc-number, …) that drives OS/browser autofill has no representation in
AccessKit 0.24 and therefore cannot be exposed from Teksilo — see
docs/a11y/a11y_issues.md. Password entry is configured via
TextInputField::secure, not here.
#![allow(unused)] fn main() { pub enum InputPurpose { /* variants */ } }
Variants
Normal— Ordinary free text (Role::TextInput).Email— Email address (Role::EmailInput).Phone— Telephone number (Role::PhoneNumberInput).Url— URL (Role::UrlInput).Number— Numeric entry — e.g. a quantity or code (Role::NumberInput).Search— Search query (Role::SearchInput).
pub enum EchoMode
How a secure (TextInputField::secure) field echoes typed
characters. Mirrors Qt's QLineEdit::EchoMode.
#![allow(unused)] fn main() { pub enum EchoMode { /* variants */ } }
Variants
Masked— Replace every character with the echo glyph (default'•'). The plaintext stays in the boundSignal<String>but never reaches the text engine while masked.NoEcho— Show nothing at all — not even the length. The caret stays at the start. Qt'sNoEcho.RevealWhileTyping— Show plaintext while the field is focused (being edited) and re-mask on blur. Qt'sPasswordEchoOnEdit.
pub enum AtRevealPolicy
How a revealed secure field reports to assistive technology.
#![allow(unused)] fn main() { pub enum AtRevealPolicy { /* variants */ } }
Variants
SwapRole— When revealed, expose the field as a normalRole::TextInputcarrying the plaintext value — matching what is visibly on screen and the webtype=password ↔ type=textswap. When masked, it reverts toRole::PasswordInput. (Default.)AlwaysProtected— Always reportRole::PasswordInputand never expose plaintext to assistive tech, even while visually revealed. Higher confidentiality at the cost of consistency with the screen.
pub struct TextInputField
Editable single-line text surface primitive.
See the module docs for the full feature list and a
compositional example.
#![allow(unused)] fn main() { pub struct TextInputField { /* fields */ } }
Methods
pub fn new(text: Signal<String>) -> Self
Construct a new field bound to text.
pub fn placeholder(mut self, text: impl Into<String>) -> Self
Declarative placeholder string. The field itself paints
nothing for placeholder — that visual is the composite
parent's responsibility (TextInput overlays a
TextWidget). The string is still stored here and published
via AccessKit's placeholder property so screen readers
announce it.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Disabled blocks input and AccessKit interaction. Forwarded to the arena at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Mark the field read-only. Caret and selection still work; inserts, deletes, paste, undo/redo, and cut are all no-ops.
pub fn max_length(mut self, max_length: usize) -> Self
Hard cap on document length in chars (grapheme count is
approximated — each char counts as one unit, matching
String::chars().count()).
pub fn on_submit_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure fired on Enter. Unlike on_blur_fn, this does
not move focus — the field stays focused and the caret
stays where it was.
pub fn on_blur_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Closure fired once per focus-loss, after selection/scroll have been reset. SpinBox-style callers parse and reformat here; validators revalidate here.
pub fn char_filter(mut self, f: impl Fn(char) -> bool + 'static) -> Self
Per-character input-filter predicate. Applied uniformly to
keyboard input, IME commits, and clipboard paste so a filtered
field cannot receive disallowed characters through any path.
Composes with max_length and the built-in control/newline
strip (filter runs after the strip). Whole-string validity
(e.g. "at most one decimal point") is a commit-time concern
for on_blur / on_submit.
pub fn suffix(mut self, text: impl Into<Prop<String>>) -> Self
Static non-editable trailing string rendered flush-right
inside the field's bounds (Qt's QSpinBox::suffix). The
caret cannot enter the suffix; clicks past the text end
position the caret at the last editable character.
Accepts a static String/&str or a reactive Signal<String> /
Prop<String>; when bound, the field re-measures the suffix glyphs
and relayouts the editable text viewport each time the signal fires.
Typical use: a SpinBox with special_value_text binds an empty
string to the suffix whenever the value equals min, and the
configured unit string otherwise.
pub fn text_height(mut self, height: f32) -> Self
Override the intrinsic text-area height. The field is a
pure leaf with no theme lookup of its own; by default it
reports DEFAULT_TEXT_HEIGHT. A wrapping composite like
TextInput passes its theme's text_field.height minus
border + padding here so the visuals line up with the
rest of the form.
pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self
Bind an externally-owned InteractionState signal. The
field writes Focused on focus gain and Idle on loss;
other states (Hovered, Pressed, Disabled) are the
composite's responsibility. When unset, the field owns a
private signal that observers can still read via
interaction, but composites
that drive a focus ring or border color usually want to
push their own.
pub fn input_mask(mut self, mask: impl AsRef<str>) -> Self
Set an input mask (Qt grammar). Constrains accepted characters
per position, auto-derives the empty-state template
(__/__/____ for 99/99/9999), and routes typed chars
through the mask's class filter.
Composes with char_filter: a char must
pass both the mask's per-position class AND the user's
char_filter to be accepted.
On parse error (only the trailing-backslash case in practice), the mask is silently dropped — the field falls back to its no-mask behaviour rather than panicking.
pub fn mask_placeholder(mut self, c: char) -> Self
Override the visible character used for unfilled editable mask
positions. Default: the theme's
text_field.mask_placeholder_char (typically _).
pub fn validator(mut self, f: impl Fn(&str) -> ValidationOutcome + 'static) -> Self
Install a validator. The closure runs on every commit (Enter,
Tab-out, focus loss) and returns a ValidationOutcome that
drives validation_feedback_signal.
Does not run per-keystroke — that's char_filter's
job. Mixing per-keystroke text rewriting with validation
produces caret-jump bugs and is explicitly out of scope.
pub fn secure(mut self, echo_mode: EchoMode) -> Self
Turn this into a secure (password) field with the given
EchoMode. Masking happens at the text-engine layer (one echo
glyph per source char), so the plaintext never reaches the
shaper or glyph atlas while masked, and caret / selection /
hit-test stay correct. Also defaults allow_copy to false and
opts the focused node out of OS IME composition. Pair with
revealed for a reveal toggle.
pub fn input_purpose(mut self, purpose: InputPurpose) -> Self
Declare the field's semantic InputPurpose (WCAG 1.3.5), which
selects a specialised AccessKit role (EmailInput, PhoneNumberInput,
…) so screen readers announce the field's kind. Ignored while secure
(the password role wins). Does not change IME behaviour — winit's
ImePurpose has no email/number/url variants — nor drive OS autofill,
which AccessKit cannot express (see docs/a11y/a11y_issues.md).
pub fn active_descendant(mut self, active: Signal<Option<WidgetId>>) -> Self
Publish active_descendant pointing at the row a separate list is
currently highlighting — the ARIA combobox pattern.
Keyboard focus stays in this field while arrow keys move a highlight through a listbox elsewhere in the tree (a command palette, a type-ahead picker, a suggestion popup). Assistive technology follows the focused node's active descendant, so the announcement has to be published here, on the node that actually holds focus — not on the composite ancestor that owns the list. Without it the arrow keys move a highlight that is announced to nobody.
Bound at AccessibilityOnly, so moving the highlight re-walks the AT
tree without a rebuild or a repaint. Pair with controls.
pub fn controls(mut self, listbox: Signal<Option<WidgetId>>) -> Self
Publish a controls relation to the listbox this field drives, so an
AT client can navigate from the input to the list it is filtering.
The companion of active_descendant.
pub fn echo_char(mut self, c: char) -> Self
Override the masking glyph (default '•', U+2022). Any
uniform-width character works; the engine emits exactly one per
source char.
pub fn revealed(mut self, revealed: Signal<bool>) -> Self
Bind the reveal toggle. When the signal is true the field
shows plaintext regardless of EchoMode; when false it
masks. Shared with the eye IconButton::visibility_toggle.
pub fn at_reveal_policy(mut self, policy: AtRevealPolicy) -> Self
How a revealed secure field reports to assistive tech. Default
AtRevealPolicy::SwapRole.
pub fn allow_copy(mut self, allow: bool) -> Self
Permit (or forbid) copy / cut. Plain fields default true;
secure flips the default to false. Even when
false, copy is allowed while the field is revealed.
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive handle on the published ValidationFeedback state.
Composites bind to this to render the inline feedback strip
below the field. Always present; reads Pristine until the
first commit (or forever if no validator is installed).
pub fn text(&self) -> Signal<String>
The Signal<String> this field is bound to.
pub fn share_handle(mut self, handle: &TextFieldHandle) -> Self
Adopt an existing handle instead of minting one.
For a composing widget — TextInput wraps this field — that must hand
out a handle of its own before it builds the field it will delegate
to. Sharing the slot and the focus signal makes the wrapper's handle and
the field's the same handle, rather than two that agree by accident.
pub fn handle(&self) -> TextFieldHandle
A live handle on this field, valid before and after build.
The counterpart of RichTextEditor::handle, and the reason it exists:
an application that routes Undo, Cut, Copy, Paste and Select All to
"whichever text surface holds the caret" has to be able to drive every
such surface, not only the rich editors. Without this, a menu built for
those commands can only grey them out over a rename field or a search
box while the field's own key handling still works — a menu that lies
about what the keyboard can do.
Like caret_setter, the handle reaches its state through the slot the
widget late-populates, so it may be taken while the tree is being
described and used once it is live.
pub fn interaction(&self) -> Signal<InteractionState>
The interaction signal this field writes on focus changes. Call before inserting the field into the tree.
pub fn caret_position(&self) -> Signal<usize>
Reactive caret position in the field's text (in usize char
offsets). Updates after every keyboard or pointer action that
moves the cursor. Used by composing widgets that need to know
where the caret is — e.g. DateEdit reads this to figure out
which date segment Up/Down should step.
pub fn caret_setter(&self) -> std::rc::Rc<dyn Fn(usize)>
Returns a callable that programmatically sets the caret
position (in char offsets) on the field. Capture this on the
builder BEFORE ctx.add(...) consumes the field; call it
after a programmatic text rewrite to restore the caret to the
right column instead of leaving it at the document end (the
default behaviour of cursor.insert_text).
The returned closure becomes a no-op until build() runs;
after build it walks the field's inner state and moves the
document cursor to position, clamped to the document
length. Used by DateEdit / TimeEdit segment-stepping to
keep the caret within its current segment after Up/Down.
pub struct TextFieldHandle
A live handle on a TextInputField — its text-editing commands, for a
caller outside the widget.
Every method is a no-op before the field is built (and after it is destroyed), which is the honest answer rather than a panic: a menu row bound to a field that is no longer on screen should do nothing, not crash.
#![allow(unused)] fn main() { pub struct TextFieldHandle { /* fields */ } }
Methods
pub fn detached() -> Self
A handle not yet attached to any field — for a composing widget that
hands one out before building the field it will delegate to. Every
method answers "nothing" until TextInputField::share_handle binds it.
pub fn focused_signal(&self) -> Signal<bool>
true while this field holds the keyboard focus. Observable, so a
router can follow the caret without polling.
pub fn is_live(&self) -> bool
Is the widget built and still alive?
pub fn text(&self) -> String
The field's current text.
pub fn has_selection(&self) -> bool
Is any text selected right now?
pub fn allows_copy(&self) -> bool
May this field's content be copied at all? A password field says no —
see TextInputField::allow_copy.
pub fn is_read_only(&self) -> bool
Is the field refusing edits? Cut and Paste are meaningless when it is.
pub fn select_all(&self)
Select the whole field.
pub fn copy(&self, ctx: &EventContext)
Copy the selection to the clipboard.
pub fn cut(&self, ctx: &EventContext)
Cut the selection to the clipboard.
pub fn paste(&self, ctx: &EventContext)
Paste over the selection.
pub fn undo(&self)
Undo this field's own last edit.
pub fn redo(&self)
Redo this field's own last undone edit.
pub fn can_undo(&self) -> Signal<bool>
Is there anything to undo? Debounced like the editor's twin.
pub fn can_redo(&self) -> Signal<bool>
Is there anything to redo?
pub enum ValidationOutcome
What a validator returns for a given commit attempt.
#![allow(unused)] fn main() { pub enum ValidationOutcome { /* variants */ } }
Variants
Valid— Input is valid as typed. The field commits unchanged and the feedback signal flips toValidationFeedback::Valid.Corrected— Input was accepted after normalization. The field replaces its text withcorrected, the boundSignal<String>observes the new value, and the feedback signal carriesmessagefor composites to surface as a polite announcement. Use for clamping, completion, and reformat. Examples:"12/50/2026"→Corrected { corrected: "12/31/2026", … }for "day clamped to month length";"2026"→Corrected { corrected: "2026-01-01", … }for "year-only completed to start of year".Invalid— Input is rejected. The field reverts its text to the pre-edit value and the feedback signal carriesmessagefor composites to surface as an assertive error.
pub enum ValidationFeedback
What composites render. Distinct from [ValidationOutcome]: the
outcome is the validator's return value (no time concept); the
feedback adds a since instant so the visual layer can decay an
auto-correction announcement after a window without re-running the
validator.
#![allow(unused)] fn main() { pub enum ValidationFeedback { /* variants */ } }
Variants
Pristine— No commit has happened yet, or the user is editing again after a previous outcome (typing always clears prior feedback).Valid— Last commit returnedValidationOutcome::Valid. Composites typically render this identically toPristine— the distinction matters for tests and for callers that want to signal "yes, it's confirmed valid" with a checkmark.Corrected— Last commit returnedValidationOutcome::Corrected.sinceis the wall-clock instant the correction was applied; composites use it to decay the visual aftercorrected_pulse_duration_msfrom the theme.Invalid— Last commit returnedValidationOutcome::Invalid. Persists until the user edits again or an externalPristinereset.
Methods
pub fn is_invalid(&self) -> bool
Convenience: is this state currently signalling an error?
pub fn is_corrected(&self) -> bool
Convenience: was the last commit auto-corrected?
pub fn message(&self) -> Option<String>
Human-readable message, if any.
TextScaleControl

TextScaleControl — the settings control that grows all text in the app.
Drop this into a preferences/settings window to let low-vision users scale
every piece of text uniformly (the framework multiplies the active theme's
typography by the chosen factor — see
WidgetTree::set_user_text_scale).
It is a thin specialization of SpinBox that displays a percent
(80 %–200 %, step 10 %) and, on each edit, both persists the value and
applies it app-wide — so the developer only has to place the widget.
Bind it to the persisted factor signal, typically the settings-backed
teksilo_settings::TEXT_SCALE_KEY:
use teksilo::prelude::*;
use teksilo::widgets::TextScaleControl;
// inside build():
let scale = ctx.settings().signal_for(&teksilo_settings::TEXT_SCALE_KEY);
ctx.add(TextScaleControl::new(scale).label(tr!(text_size())));
Writing the bound signal triggers the SettingsStore's debounced auto-save
(persistence), and the widget's on_value_changed calls
EventContext::set_text_scale
(immediate app-wide application). At startup teksilo-app reads the saved
key and seeds every window, so the chosen size is restored automatically.
Builder methods at a glance
label, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct TextScaleControl
A specialized SpinBox for the global user text-scale setting.
See the module docs for the persistence + app-wide application
contract. Construct with TextScaleControl::new, optionally attach a
visible label, and place it in a settings view.
#![allow(unused)] fn main() { pub struct TextScaleControl { /* fields */ } }
Methods
pub fn new(factor_signal: Signal<f32>) -> Self
Construct bound to factor_signal (a scale factor where 1.0 = 100 %).
Pass ctx.settings().signal_for(&teksilo_settings::TEXT_SCALE_KEY) to get
automatic persistence; any Signal<f32> works for ad-hoc / preview use.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Attach a visible label placed to the leading side of the spinbox
(e.g. tr!(text_size())). Also used as the control's accessible name.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip that appears after a hover delay.
Clears any previously set rich or composite tooltip (last-call wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip registry.
key is looked up in the
TooltipRegistry at build time.
Clears any previously set plain or composite tooltip (last-call wins).
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline
TooltipContent.
Clears any previously set plain or composite tooltip (last-call wins).
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip that hosts an arbitrary widget body.
The content widget is rendered inside the tooltip overlay after the
heavy hover delay. Clears any previously set plain or rich tooltip
(last-call wins).
TextWidget

TextWidget — a leaf widget that renders a localized text string.
TextWidget is the building block for every visible label in the framework.
It delegates measurement and rasterization to the TextBackend and supports
three overflow modes: TextOverflow::Wrap (default — grows vertically),
TextOverflow::Ellipsis with trailing, middle, or leading truncation, and
a minimal markup subset (label, *italic*, **bold**) with
per-link click/hover dispatch.
Text and color accept either static values or reactive Signal/Prop bindings.
The default color role is TextRole::Primary, resolved against the active
theme at paint time, so theme switches update text color without any explicit
binding or rebuild.
Single-line / ellipsis text opts into shrink by default: an over-constrained
stack compresses the label down to the ellipsis-glyph width before the label
overflows. Call no_shrink to restore rigid behavior,
or min_shrink_width to set a custom floor.
Wrap-mode text is height-variable and therefore always rigid; opt it into
compression with Shrinkable.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::TextWidget; use teksilo_i18n::lit; // Single-line label that truncates with a trailing ellipsis if too narrow: let _w = TextWidget::new(lit!("Save document")).single_line(); }
Builder methods at a glance
color, style, overflow, single_line, min_shrink_width, no_shrink, max_lines, text_backend, text, resolved_text, markup, on_link_click, on_link_hover, a11y_hidden
API reference
📖 Full rustdoc API for this module
pub struct TextWidget
#![allow(unused)] fn main() { pub struct TextWidget { /* fields */ } }
Methods
pub fn new(text: impl Into<LocalizedString>) -> Self
Construct a text widget whose content is a LocalizedString. The
text may come from tr!(...) (reactive, re-resolves on locale
change) or from lit!("…") for genuinely
non-translated strings.
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Set the text color. Accepts any impl Into<ColorProp>:
- A raw
Color— a frozen literal. - A
TextRole— resolved against the theme at paint time (reactive across theme switches). - A
Signal<Color>— reactive state (typically interaction-driven).
The default role is TextRole::Primary, so .color(...) is only
needed when a label wants a non-default theme role (Secondary,
Error, Accent, ...) or a custom color.
pub fn style(mut self, style: impl Into<TextStyleProp>) -> Self
Set the text style. Accepts a raw TextStyle, a
TextStyleRole, or any value implementing
Into<TextStyleProp>. Using a role resolves at paint/layout time, so
theme typography changes take effect without a rebuild.
pub fn overflow(mut self, overflow: TextOverflow) -> Self
Set how the widget handles text that doesn't fit in the proposed
width. Default is TextOverflow::Wrap.
pub fn single_line(self) -> Self
Shorthand for .overflow(TextOverflow::Ellipsis(EllipsisMode::Trailing)).
Use this on labels inside single-line containers (buttons, menu items,
tab headers, badges, status bar cells, etc.) so long text truncates
with a trailing "…" instead of wrapping onto multiple lines.
pub fn min_shrink_width(mut self, min: f32) -> Self
Override the compression floor for single-line / ellipsis text — the narrowest width an over-constrained stack may shrink this label to before truncating stops. Defaults to the ellipsis-glyph width.
pub fn no_shrink(mut self) -> Self
Opt this label out of native shrink: it reports a rigid size and overflows (rather than truncating) when its stack is over-constrained.
pub fn max_lines(mut self, n: usize) -> Self
Cap the paragraph at n lines when wrapping. Only meaningful
in TextOverflow::Wrap mode — ignored for ellipsis modes.
Lines beyond the cap are silently dropped.
pub fn text_backend(mut self, backend: Rc<RefCell<dyn teksilo_canvas::TextBackend>>) -> Self
Override the text backend used for measurement and rasterization.
In normal app code the framework provides the backend automatically;
this method is used by headless tests that inject a MockTextBackend.
pub fn text(mut self, state: impl Into<Prop<String>>) -> Self
Set the text content. Accepts a static String/&str or a reactive
Signal<String> / Prop<String> (resolved and re-rendered on change).
pub fn resolved_text(&self) -> String
Get the current text value (resolves from state if bound).
pub fn markup(mut self, enabled: bool) -> Self
Enable inline markup parsing. When enabled, the text is parsed as a minimal markdown subset:
label— inline link*italic*— italic run**bold**— bold run
Links are dispatched via on_link_click
and colored using theme.colors.text_link.
pub fn on_link_click<F>(mut self, handler: F) -> Self where F: Fn(&str, &mut EventContext) + 'static,
Called when an inline link is tapped. Enables markup automatically.
pub fn on_link_hover<F>(mut self, handler: F) -> Self where F: Fn(&str, bool, Rect, &mut EventContext) + 'static,
Called when an inline link is hovered (enter/leave). Receives
the URL, a bool indicating whether the pointer entered (true)
or left (false), and the widget-local rect of the link span
(so anchoring popups next to the link is cheap). Enables markup
automatically.
pub fn a11y_hidden(mut self) -> Self
Hide this text from the accessibility tree. Use this when the
TextWidget is a visual label fragment inside another control
that already owns its accessible name via set_name —
otherwise screen readers announce the same string twice
(once for the control, once for the embedded Label node).
Standalone body text (dialog descriptions, form instructions,
read-only display values) should NOT set this — it stays as a
Role::Label node.
ThemeSwitcher

ThemeSwitcher — a drop-in app-theme picker for settings screens & toolbars.
A thin ComboBox preset that switches the application theme. By default
it offers three entries — Light, Dark, and System — where
System follows the native OS theme live: it adopts the OS's actual colours
(GNOME / KDE / Cinnamon on Linux) and tracks OS light/dark changes at
runtime, falling back to the built-in light/dark presets on platforms
without OS-colour support.
Zero-config: drop ThemeSwitcher::new() into a settings panel or toolbar and
it
- shows the active theme as the current selection (matched by the theme's
stable
ThemeId), - switches the app theme on selection via
EventContext::set_theme(fixed themes) orEventContext::follow_system_theme(System), - and stays in sync if the theme changes elsewhere (a menu, the inspector, or an OS light/dark toggle).
// In a settings panel or toolbar:
Toolbar::new().child(HStack::new().child(Spacer::new()).child(ThemeSwitcher::new()))
Labels are translated via the framework Fluent bundle (tr_widget!),
with an English literal fallback so a host app that hasn't installed an
I18nManager still reads "Light / Dark / System" rather than raw keys.
Custom themes: .themes([(label, theme), …]) replaces Light/Dark with an
app-supplied set (e.g. the teksilo-theme-{fluent,macos,material3} presets);
.system(false) drops the System entry.
Builder methods at a glance
variant, label, themes, system, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct ThemeSwitcher
A drop-in app-theme picker built on ComboBox. See the module docs.
#![allow(unused)] fn main() { pub struct ThemeSwitcher { /* fields */ } }
Methods
pub fn new() -> Self
Create a switcher offering Light / Dark / System (the System entry follows the OS theme live).
pub fn variant(mut self, variant: ComboBoxVariant) -> Self
Pick the inner ComboBox's design-language variant.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the accessible / control label (defaults to the translated "Theme").
pub fn themes( mut self, themes: impl IntoIterator<Item = (impl Into<LocalizedString>, Theme)>, ) -> Self
Replace the default Light/Dark fixed-theme list with an app-supplied set
of (label, theme) pairs — e.g. the teksilo-theme-* presets. The
System (follow-OS) entry is still appended unless system
is false.
pub fn system(mut self, include: bool) -> Self
Whether to offer the "System" (follow-OS) entry. Default true.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain tooltip, forwarded to the inner ComboBox.
Mutually exclusive with the rich / composite variants — last
call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide registry,
forwarded to the inner ComboBox. Overrides any previously
set tooltip.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline
TooltipContent, forwarded to
the inner ComboBox. Overrides any previously set tooltip.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip hosting an arbitrary widget tree,
forwarded to the inner ComboBox. Overrides any previously
set tooltip.
TimeEdit

TimeEdit — text input for time-of-day, bound to Signal<Option<Time>>.
Single-line editable time field with strftime-pattern parse/format
and optional 12h/24h mode + AM/PM. Same compositional pattern as
DateEdit (TextInputField + commit on
Enter/blur + step keys), without a popover (desktop convention is no
graphical time picker).
Behaviour
- Value binding:
Signal<Option<Time>>—Noneshows the placeholder. - Pattern: 24h default
%H:%M; 12h is%I:%M %p. Override viaformat_pattern. Add seconds withseconds(SecondsMode::Editable). - Keyboard (preview-pass on the wrapper):
- Arrow Up / Down → ±
step_minutes - PageUp / PageDown → ±60 minutes
- Shift+ on either → ×10 multiplier (×600 max so values stay sane)
- Arrow Up / Down → ±
Accessibility
- Container —
Role::TimeInputwithset_valueformatted asHH:MM:SSandset_labelfrom.label(). - Underlying TextInputField keeps
Role::TextInputso AT knows it's editable.
use teksilo_core::signal::Signal;
use teksilo_widgets::time_edit::{TimeEdit, TimeFormat, SecondsMode};
let value = Signal::new(None);
let _field = TimeEdit::new(value)
.format(TimeFormat::Hour24)
.seconds(SecondsMode::Hidden);
Builder methods at a glance
style, required, format, seconds, format_pattern, min_time, max_time, step_minutes, placeholder, enabled, read_only, validation_behavior, width_policy, validation_feedback_signal, label, on_value_changed, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip, value
API reference
📖 Full rustdoc API for this module
pub enum TimeFormat
12h vs 24h time formatting.
Used with TimeEdit::format to lock the clock style independently of the locale default.
#![allow(unused)] fn main() { pub enum TimeFormat { /* variants */ } }
Variants
Hour24— 24-hour clock (default —%H:%M).Hour12— 12-hour clock with AM/PM segment (%I:%M %p).
pub enum SecondsMode
Whether the seconds segment is shown in TimeEdit.
#![allow(unused)] fn main() { pub enum SecondsMode { /* variants */ } }
Variants
Hidden— Hide the seconds segment (default).Editable— Show and edit the seconds segment.
pub struct TimeEdit
Single-line editable time-of-day field.
See the module documentation for full behaviour, pattern,
and keyboard details.
#![allow(unused)] fn main() { pub struct TimeEdit { /* fields */ } }
Methods
pub fn new(value: Signal<Option<Time>>) -> Self
Construct bound to value (None = empty field; Some(t) = pre-filled time).
pub fn style(mut self, style: impl teksilo_core::styles::DateEditStyle) -> Self
Per-call DateEditStyle override (shared with DateEdit family).
pub fn required(value: Signal<Time>) -> Self
Construct with a required (non-nullable) Signal<Time>. The field
never shows None; the signal and the internal Option are kept in sync.
pub fn format(mut self, f: TimeFormat) -> Self
Lock the field to a specific clock (12h or 24h). When this
builder is not called, the field defaults to the user's
current locale via prefers_12_hour_clock (12h for en-US /
en-CA / en-AU / en-NZ / en-PH / en-IN / en-PK; 24h elsewhere).
pub fn seconds(mut self, mode: SecondsMode) -> Self
Show or hide the seconds segment. Default: SecondsMode::Hidden.
pub fn format_pattern(mut self, p: impl Into<String>) -> Self
Override the strftime-subset format pattern (e.g. "%H:%M:%S").
Bypasses the locale-derived and format-derived defaults entirely.
pub fn min_time(mut self, t: Time) -> Self
Clamp the accepted value to at or after t (inclusive).
pub fn max_time(mut self, t: Time) -> Self
Clamp the accepted value to at or before t (inclusive).
pub fn step_minutes(mut self, n: u32) -> Self
Set the ArrowUp / ArrowDown step in minutes. Default: 1. Must be ≥ 1.
pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self
Text shown when the field is empty (value is None).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the arena at build time.
pub fn read_only(mut self, read_only: bool) -> Self
Allow display-only mode: text is selectable but not editable.
pub fn validation_behavior(mut self, behavior: ValidationBehavior) -> Self
How parse failures are surfaced. See
ValidationBehavior.
pub fn width_policy(mut self, policy: crate::date_edit::WidthPolicy) -> Self
How the widget claims horizontal space. See
WidthPolicy. Default
Default (natural mask-derived width).
pub fn validation_feedback_signal(&self) -> Signal<ValidationFeedback>
Reactive handle on the live validation feedback.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Set the accessible label for the field (announced by screen readers).
pub fn on_value_changed( mut self, f: impl Fn(Option<Time>, &mut EventContext) + 'static, ) -> Self
Callback invoked on every committed value change with the new
Option<Time> and a live EventContext.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay. Clears any previously set rich or composite tooltip (last call wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip identified by a registry key. Clears any previously set plain or composite tooltip (last call wins).
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach an inline rich tooltip from a crate::tooltip::TooltipContent
value. Clears any previously set plain or composite tooltip (last call wins).
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree. Clears any previously set plain or rich tooltip (last call wins).
pub fn value(&self) -> Signal<Option<Time>>
The bound value signal — the same Signal passed to Self::new.
TitleBar
Custom window title bar widget.
TitleBar replaces a window's native chrome with a horizontal bar that
can host menus, tools, and the standard window controls (minimize /
maximize / close). The platform plumbing — beginning a window drag,
returning the right WM_NCHITTEST codes on Windows, repositioning the
macOS traffic lights — lives behind the
PlatformTitleBarHost trait in
teksilo-platform. The widget itself is platform-agnostic.
Construct a TitleBar from inside the root-builder closure, fetching
the host from the widget tree:
.root(|tree| {
let host = tree.title_bar_host().expect("custom_chrome enabled");
tree.add(
VStack::new()
.child(TitleBar::new(host)
.background(theme.colors.surface_raised)
.border(theme.colors.border, 1.0)
.leading(TextWidget::new(lit!("My App"))))
.child(Expand::new().child(/* body */)))
})
Builder methods at a glance
controls_visible, height, background, border, leading, leading_id, center, center_id, trailing, trailing_id, close_action
API reference
📖 Full rustdoc API for this module
pub type CloseAction
Type alias for the user-supplied close action that overrides
host.close() (which on Wayland is currently a no-op due to winit 0.30
lacking Window::request_close). Set via TitleBar::close_action.
#![allow(unused)] fn main() { pub type CloseAction = Rc<dyn Fn(&mut EventContext)>; }
pub struct TitleBar
A custom window title bar.
Layout (left to right):
[leading inset] [leading slot] [drag region (flexible)] [trailing slot] [trailing inset] [window controls]
The leading inset reserves space for the OS-drawn traffic lights on
macOS. The drag region is a Spacer-style flex
child that absorbs all leftover horizontal space and forwards
pointer / drag / double-tap gestures to the platform host. The window
controls (minimize / maximize / close) are rendered only when the host
advertises PlatformTitleBarHost::renders_custom_controls — i.e. on
Windows and Wayland but not on macOS.
This widget builds exactly once
build consumes the leading / center / trailing slots with take(), so a
second pass finds them all None and produces a bar containing nothing but
window controls — no menu, no title, no tools. Nothing here may therefore
carry a BindingLevel::Rebuild
binding. Reactive state on this widget is expressed either as a
RepaintOnly colour prop or, for structure, as dormancy via
teksilo_core::BuildContext::visible_when on an always-built child — which is how
controls_visible works. Memoising the
resolved slot ids is not a workaround: a rebuild replaces the inner row
and prunes its subtree, so the cached ids dangle and re-adding them yields
an empty bar just the same.
#![allow(unused)] fn main() { pub struct TitleBar { /* fields */ } }
Methods
pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self
Construct a TitleBar bound to the given platform host.
The maximize/restore glyph follows WindowState::placement via
ctx.window() at build time — the host no longer owns the
maximize signal.
pub fn controls_visible(mut self, visible: impl Into<Prop<bool>>) -> Self
Show or hide the minimize / maximize / close cluster. Default true.
Accepts a plain bool or a Signal<bool>. Applied through the
framework's own dormancy (teksilo_core::BuildContext::visible_when), so a flip
costs a relayout and never a rebuild of the bar: a dormant node is
skipped by layout, hit-test, focus and paint, so a hidden cluster takes
no space and receives no input. A derived (.map) signal is fine —
binding resolves through to the mutable roots and never calls observe.
The case this exists for is fullscreen.
WindowPlacement::Fullscreen
is documented as "covers the entire display, title bar and all chrome
hidden", and every desktop convention agrees: macOS hides the traffic
lights, Windows fullscreen has no caption buttons, browsers and editors
hide their chrome outright. Minimize and maximize are meaningless for a
window with no frame. An app drawing custom chrome
(DecorationsMode::CustomChrome) owns
that decision itself, because the framework cannot hide a title bar the
app composed — so it gates it here.
An app that hides these must keep some other visible way out of fullscreen: a menu item, an on-screen button, or a documented shortcut.
pub fn height(mut self, height: f32) -> Self
Set the title bar's logical-pixel height. Default: 40.
pub fn background(mut self, color: impl Into<ColorProp>) -> Self
Fill the title bar with a solid background color. Default: transparent (the window's clear color shows through).
Accepts a Color, a Signal<Color>, or any of the role types
(SurfaceRole, TextRole, BorderRole, or their Signal<…>
variants). Role values resolve at paint time, so the title bar
retints live across ctx.set_theme(...) switches.
pub fn border(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Draw a 1px-or-thicker bottom border separating the title bar from the body.
Color accepts the same range as Self::background; pair with
BorderRole::Default for a theme-tracking divider.
pub fn leading(mut self, widget: impl Widget + 'static) -> Self
Set the leading-edge content (e.g. app icon, menus). Rendered to the right of the macOS traffic-light inset.
pub fn leading_id(mut self, id: WidgetId) -> Self
Set the leading-edge content by pre-registered ID.
pub fn center(mut self, widget: impl Widget + 'static) -> Self
Set the center content (e.g. search box, breadcrumbs). Wrapped in a flexible drag region: clicks that are not consumed by the child initiate a window drag.
pub fn center_id(mut self, id: WidgetId) -> Self
Set the center content by pre-registered ID.
pub fn trailing(mut self, widget: impl Widget + 'static) -> Self
Set the trailing-edge content (e.g. user avatar, notification bell). Rendered before the window controls.
pub fn trailing_id(mut self, id: WidgetId) -> Self
Set the trailing-edge content by pre-registered ID.
pub fn close_action(mut self, action: impl Fn(&mut EventContext) + 'static) -> Self
Override the close-button action. When set, the close button calls
this closure instead of host.close(). Required on Wayland where
the host's close() is a no-op (winit 0.30 has no
Window::request_close); the application typically wires this to
call EventContext::close_window directly, or to send an
Intent whose root-level Action handler calls it.
Toast
Toast notification — stackable, action-rich, severity-aware floating
notification (the "upgrade path" from Snackbar).
Distinct from siblings:
Snackbar— single-instance, message-only. Callingpresent_snackbardismisses all other overlays first.Banner— persistent inline strip, not a floating overlay.MessageBox— modal dialog. Blocks interaction with the rest of the UI.
A Toast is built with one of the four severity constructors
(info / success / warning / error) plus a loading variant,
configured via builder methods, and presented with
ctx.show_toast(toast) (see
toast::ext::EventContextToastExt)
or toast.present(ctx). A ToastHost
installed via TeksiloAppBuilder.install_toast(opts) from the teksilo
umbrella accepts the request, picks a free slot from its pool, and
mounts a ToastSurface at the
configured viewport corner using the
OverlayPlacement::ViewportCorner
variant.
ctx.show_toast(
Toast::warning(tr!(unsaved_changes()))
.body(tr!(close_anyway_question()))
.action(ToastAction::primary(tr!(save()), |c| c.send_intent(AppIntent::Save)))
.action(ToastAction::new(tr!(discard()), |c| c.send_intent(AppIntent::Discard)))
);
Builder methods at a glance
info, success, warning, error, loading, body, leading, action, primary_action, auto_dismiss_after, persistent, priority, id, on_click, on_dismiss, show_close_button, closable_on_escape, announcement, archive, style, target, broadcast, present
API reference
📖 Full rustdoc API for this module
pub const DEFAULT_TOAST_AUTO_DISMISS
Default auto-dismiss duration when the caller does not override
it (matches IntelliJ BALLOON and Material Snackbar maximum).
#![allow(unused)] fn main() { pub const DEFAULT_TOAST_AUTO_DISMISS: Duration = Duration::from_secs(10); }
pub enum ToastDismissCause
Why a toast was dismissed — delivered to the on_dismiss callback.
#![allow(unused)] fn main() { pub enum ToastDismissCause { /* variants */ } }
Variants
Timeout—auto_dismiss_afterreached zero (timer expired naturally).ActionInvoked— AToastActionwithcloses_toast(true)(the default) fired.CloseClicked— The user clicked the close (X) button.EscapePressed— The user pressed Escape while focus was inside the toast.Programmatic—ToastHandle::dismisswas called from app code.HostShutdown— The host's window is being torn down.SlotPoolFull— The host's slot pool was atmax_visibleand this toast was dropped (Normal priority overflow) or was evicted by a higher-priority arrival. Reported synthetically soon_dismissalways fires once per toast — apps that track outstanding toasts via the callback don't leak.
pub struct ToastAudience
Opaque per-app routing token. teksilo has no notion of what an
"audience" means to the host app (a document, a project, a user
session, …) — it only ever compares and hashes this value. Apps
mint their own tokens (typically one per open document/window
group) via ToastAudience::new and pass the same value to
Toast::target(...) and ToastRegistry::set_window_audience(...)
to link the two sides of the routing decision.
#![allow(unused)] fn main() { pub struct ToastAudience(u64); }
Methods
pub fn new(id: u64) -> Self
Construct a token from an app-chosen u64. The app owns the
meaning entirely — teksilo never inspects the value beyond
equality/hash.
pub fn raw(&self) -> u64
The raw numeric value, for debugging/serialization by the app.
pub enum ToastRoute
Resolved delivery target for a toast (and, mirrored, its archived
NotificationEntry).
Three levels, from narrowest to widest:
Window— exactly the window that presented the toast. This is the default when aToastcarries no explicit.target()/.broadcast()and was presented through a realEventContext(i.e.ctx.show_toast(...)/toast.present(ctx)from an actual input handler) — seeEventContextToastExt::show_toast.Audience— every window currently assigned the givenToastAudienceviaToastRegistry::set_window_audience.Broadcast— every window, unconditionally. Also the fallback when a toast is enqueued with no window AND no explicit target (e.g.ToastRegistry::show_settings_write_failed, which fires from a backgroundAppEventobserver with noEventContextat all) — an app-wide message with nothing narrower to route by.
#![allow(unused)] fn main() { pub enum ToastRoute { /* variants */ } }
Variants
Window— Delivered only to the window with this id. Never publicly constructible from aToastbuilder — only the framework stamps this, from a realEventContext::window()at present time — so an app can't accidentally fabricate a route to a window it doesn't own.Audience— Delivered to every window currently assigned this audience.Broadcast— Delivered to every window, unconditionally.
pub enum ToastActionStyle
How a ToastAction should be rendered inside the toast surface.
#![allow(unused)] fn main() { pub enum ToastActionStyle { /* variants */ } }
Variants
Link— JetBrains-style hyperlink. Rendered inline with the body row. Default — minimal visual weight, scales to many actions.Button— Material / Windows-style button. Rendered in a dedicated row below the body. Use for primary calls-to-action ("Retry", "Save", "Discard").
pub type ToastActionCallback
Type-erased callback for a ToastAction. Fn (not FnMut) so
the same callback can be wrapped in an Rc and dispatched from
multiple paths (tap, keyboard, AT custom action).
#![allow(unused)] fn main() { pub type ToastActionCallback = Rc<dyn Fn(&mut EventContext)>; }
pub struct ToastAction
One actionable element inside a Toast — a button or hyperlink
the user can click to drive a domain action.
#![allow(unused)] fn main() { pub struct ToastAction { /* fields */ } }
Methods
pub fn new( label: impl Into<LocalizedString>, on_invoke: impl Fn(&mut EventContext) + 'static, ) -> Self
Build an action with the default Link style and
closes_toast = true (IntelliJ "expiring action" semantics).
pub fn primary( label: impl Into<LocalizedString>, on_invoke: impl Fn(&mut EventContext) + 'static, ) -> Self
Shorthand for ToastAction::new(label, on_invoke).style(Button { Filled }).
The visual-weight default for primary calls-to-action.
pub fn destructive( label: impl Into<LocalizedString>, on_invoke: impl Fn(&mut EventContext) + 'static, ) -> Self
Shorthand for the destructive button variant — red-tinted for confirm-style "Delete" / "Discard" actions.
pub fn style(mut self, style: ToastActionStyle) -> Self
Override the action's visual style. Default is Link.
pub fn closes_toast(mut self, closes: bool) -> Self
Whether invoking this action also dismisses the toast. Default
is true — matches IntelliJ's "expiring action" semantics.
Set to false for actions that toggle state without closing
(e.g. "Show details" disclosure inside a sticky toast).
pub fn shortcut_id(mut self, id: impl Into<String>) -> Self
Associate the action with a registered Shortcut id. Two
effects: the keystroke label is shown as a chip on the action,
and the archived form of this action (in
NotificationLog)
is re-invokable by name through the existing Intent
dispatcher.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Optional tooltip text shown when the pointer hovers the action.
pub fn label(&self) -> String
Resolve the action label to a plain string using the current locale.
pub fn style_ref(&self) -> &ToastActionStyle
Return the action's rendering style (link vs button variant).
pub fn closes_toast_flag(&self) -> bool
Return true when invoking this action also dismisses the toast.
pub fn shortcut_id_ref(&self) -> Option<&str>
Return the associated Shortcut id, if any.
pub fn tooltip_ref(&self) -> Option<&LocalizedString>
Return the optional tooltip text, if one was set via tooltip.
pub fn callback(&self) -> ToastActionCallback
Clone the invocation callback — cheap because the underlying closure is Rc-wrapped.
pub struct ToastHandle
Returned by Toast::present (and ctx.show_toast(toast)). Cheap
to clone (Rc<Inner>). Lets app code dismiss the toast
programmatically or check whether it is still alive.
Dropping the handle does NOT dismiss the toast — toasts have their own lifecycle managed by the host (timer + manual paths). The handle is the OPTIONAL "I want to control this toast later" hook.
#![allow(unused)] fn main() { pub struct ToastHandle { /* fields */ } }
Methods
pub fn entry_id(&self) -> u64
Stable per-toast id. Two ToastHandles pointing at the same
underlying toast share the same entry_id. The id is unique
per ToastRegistry (per app) — it doesn't survive across app
restarts.
pub fn is_alive(&self) -> bool
Whether the toast is still in the registry's live set (timer
hasn't expired, user hasn't dismissed, host hasn't shut down).
Always false for overflow-dropped toasts.
pub fn dismiss(&self, ctx: &mut EventContext)
Programmatically dismiss the toast with cause
ToastDismissCause::Programmatic. No-op if the toast is
already dismissed (timer, user, host shutdown).
pub type ToastDismissCallback
Type-erased on_dismiss callback receiving the cause + context.
#![allow(unused)] fn main() { pub type ToastDismissCallback = Rc<dyn Fn(ToastDismissCause, &mut EventContext)>; }
pub struct Toast
Toast — a present-able request (NOT a Widget). Construct with one
of the severity-named constructors, configure via builder methods,
then call .present(ctx) or ctx.show_toast(self). Internally the
builder is consumed and its data is moved into a slot on the
installed ToastHost.
See the module docs for the full conceptual overview.
#![allow(unused)] fn main() { pub struct Toast { /* fields */ } }
Methods
pub fn info(title: impl Into<LocalizedString>) -> Self
Info-severity toast (status confirmation, neutral notice).
pub fn success(title: impl Into<LocalizedString>) -> Self
Success-severity toast ("Saved", "Connected", "Build finished").
pub fn warning(title: impl Into<LocalizedString>) -> Self
Warning-severity toast.
pub fn error(title: impl Into<LocalizedString>) -> Self
Error-severity toast. Defaults to Live::Assertive.
pub fn loading(title: impl Into<LocalizedString>) -> Self
Loading-style toast — Info severity with a
Spinner as the leading widget.
Persistent by default; the app calls
ToastHandle::dismiss (typically from the operation's
completion callback) or replaces it with a success/error toast.
pub fn body(mut self, text: impl Into<LocalizedString>) -> Self
Optional secondary line below the title.
pub fn leading(mut self, widget: impl Widget + 'static) -> Self
Replace the default severity glyph with a custom leading widget (spinner, app icon, avatar). Boxes the widget so the toast remains object-safe.
pub fn action(mut self, action: ToastAction) -> Self
Append a ToastAction (link or button) to the toast.
pub fn primary_action( self, label: impl Into<LocalizedString>, on_invoke: impl Fn(&mut EventContext) + 'static, ) -> Self
Shorthand for appending a filled-button primary action — equivalent to
.action(ToastAction::primary(label, on_invoke)).
pub fn auto_dismiss_after(mut self, duration: Duration) -> Self
Override the auto-dismiss countdown. Pass Duration::ZERO for immediate dismissal
on the next timer tick; call persistent to disable the timer entirely.
pub fn persistent(mut self) -> Self
Disable auto-dismiss — the toast persists until the user
clicks the close X, invokes a closes_toast action, or the
app calls ToastHandle::dismiss.
pub fn priority(mut self, priority: ToastPriority) -> Self
Set the queue priority. High / Urgent entries evict the oldest Normal entry
when the slot pool is full; Urgent also forces Live::Assertive regardless of severity.
pub fn id(mut self, id: impl Into<String>) -> Self
Stable identity for the "progress toast updates in place"
pattern. A subsequent enqueue whose Toast carries the same
id as a still-live entry mutates that entry's fields
(severity, title/body, route, …) in place instead of appending
a new toast — see ToastRegistry::enqueue's update-in-place
merge for the exact behaviour.
Hazard: this id must be unique per logical operation, not just per call site
The merge matches on id ALONE — no route/window/audience
check — and then OVERWRITES the existing entry's route with
the new toast's resolved target. That's intentional: it's what
lets a progress toast whose audience becomes known partway
through retarget itself in place. But it also means that if
TWO DIFFERENT windows (or two different audiences) each
present a toast using the SAME id for what are, to the app,
two DIFFERENT operations, the second enqueue finds the
first window's still-live entry, mutates its text/severity to
the second operation's, and steals its route out from under
it — the first window's toast is not dismissed, not
callback'd, just silently overwritten and gone, while the
second window's operation ends up displayed under the wrong
route besides.
teksilo deliberately does NOT make the dedup key route-aware
(matching on (id, route) together) — that would break the
intentional retargeting case above. So in a multi-window /
multi-document app, do not reuse one static string id across
windows for what is conceptually a per-document (or otherwise
per-audience) operation — export, delete, save, etc. Fold the
document/audience identity into the id yourself, e.g.
format!("export-{work_id}") rather than a bare "export"
constant, so two windows running the same kind of operation
on two different documents never collide on one entry.
pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Treat a click on the toast body as a meaningful action — the
callback fires on tap. Cursor changes to Pointer over the body.
pub fn on_dismiss( mut self, f: impl Fn(ToastDismissCause, &mut EventContext) + 'static, ) -> Self
Notification of dismissal. Fires exactly once per toast on any dismiss path (timer, action invocation, close click, escape, programmatic, host shutdown, slot-pool overflow).
pub fn show_close_button(mut self, show: bool) -> Self
Show or hide the trailing close (×) button. Default true.
pub fn closable_on_escape(mut self, allow: bool) -> Self
Whether pressing Escape while the toast is focused dismisses it. Default true. Set to false in apps that have a custom Escape-handling story (focus trap, modal-style toast).
pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self
Override the screen-reader announcement text without changing the visible title. Useful when the visible title is iconic ("3") but the spoken text needs context ("3 unread messages").
pub fn archive(mut self, archive: bool) -> Self
Whether this toast is added to the persistent archive that
drives NotificationLog.
Default true. Set false for noise-suppressing
transient notifications like quick "Copied!" feedback.
pub fn style(mut self, style: impl teksilo_core::styles::ToastStyle) -> Self
Override the visual chrome for this toast instance. Takes precedence over the
theme-wide style_slots.toast slot and the built-in RecipeToastStyle default.
pub fn target(mut self, audience: ToastAudience) -> Self
Route this toast to every window currently assigned audience
(via ToastRegistry::set_window_audience), instead of the
default origin-window. Overrides any previous .target() /
.broadcast() call — last setter wins.
pub fn broadcast(mut self) -> Self
Route this toast to every window, unconditionally — for
genuinely app-wide messages (a data-loss warning, an update
available notice) rather than one window's concern. Overrides
any previous .target() call — last setter wins.
pub fn present(self, ctx: &mut EventContext) -> ToastHandle
Submit the toast through the installed
ToastHost. Equivalent to
ctx.show_toast(self). Returns a ToastHandle for
programmatic control. If install_toast was not called the
returned handle is in the "dropped" state (is_alive returns
false) and a one-shot stderr warning fires explaining the omission.
pub struct ToastRegistry
Cheap to clone (Rc<RefCell<…>>). All public methods take &self
and use interior mutability.
#![allow(unused)] fn main() { pub struct ToastRegistry { /* fields */ } }
Methods
pub fn new(options: super::host::ToastInstallOptions) -> Self
Construct a registry with the given options and no archive.
Used by tests and by apps that don't want notification
persistence. The install helper in teksilo calls
with_archive instead.
pub fn with_archive( options: super::host::ToastInstallOptions, archive: Rc<NotificationArchiveModel>, ) -> Self
Construct a registry that mirrors every archived-eligible
toast push into archive. Toasts presented with
archive(false) are NOT mirrored (used for transient
"Copied!" feedback that shouldn't pollute the log).
pub fn archive(&self) -> Option<Rc<NotificationArchiveModel>>
Access the underlying notification archive (if configured).
NotificationLog and NotificationCenterButton read from
this directly.
pub fn version_signal(&self) -> &Signal<u64>
Reactive signal bumped on every queue mutation. Every
ToastHost binds this at BindingLevel::Rebuild, in every
window, and app code may also poll it directly to assert "did
something change" without going through a widget tree at all.
One signal is enough for N windows. It was not always: dirty
tracking used to be a bool living on the signal that each
WidgetTree's reconcile pass read and cleared, so whichever
window reconciled first consumed the flag and every other
window's ToastHost silently — and permanently — skipped its
rebuild. Toast routing was the first feature to need
shared-state-fanned-out-to-every-window, so it was the first to
hit that, and it carried a HashMap<TeksiloWindowId, Signal<u64>>
of per-window duplicates plus a fan-out on every bump to work
around it. Signal now tracks a monotone generation and each
BindingRegistry remembers what it last acted on
(teksilo_core::binding::BindingGroup::last_seen), so consumers
no longer contend and the duplicates are gone.
pub fn hover_count_signal(&self) -> Signal<usize>
Shared hover-pause refcount. Surfaces increment / decrement on hover-enter / leave; the host's frame-tick effect reads it.
pub fn window_audience_signal( &self, window_id: TeksiloWindowId, ) -> Signal<Option<ToastAudience>>
Get-or-create the audience signal for window_id. The first
call for a given window allocates a fresh Signal::new(None);
every later call (from that window's ToastHost, or from app
code) returns the SAME signal, so binding to it once and
mutating it later both work through this one accessor.
pub fn set_window_audience(&self, window_id: TeksiloWindowId, audience: Option<ToastAudience>)
Assign (or clear, with None) the audience for window_id.
Retargets that window's toast host + bell immediately — both
are bound to this signal at BindingLevel::Rebuild. Reached
exactly like the registry itself: ctx.app_state::<ToastRegistry>().
Typical call site: a window-activation / active-document-changed
handler that keeps a window's audience in sync with what it's
currently showing.
pub fn forget_window(&self, window_id: TeksiloWindowId)
Drop window_id's entry from window_audiences.
Call this from the app's window-teardown hook — the same place
that tears down the ToastHost mounted in that window.
set_window_audience(window_id, None) is NOT a substitute.
That call only overwrites the signal's value; the map entry
(and the Signal's backing Rc<RefCell<..>> allocation) stays
alive. Without a call to forget_window, every window ever
opened for the life of the process leaves one live Signal in
the map behind forever — an unbounded leak in exactly the
shape a long-running, multi-window app has (open/close windows
repeatedly across a session).
Safe even if some other code still holds a clone of the
removed Signal: a Signal is Rc<RefCell<..>> under the
hood, so dropping the registry's map entry only drops this
reference to it — any clone a still-alive holder kept keeps
reading/writing exactly as before, unaffected by the map
removal (Rc content doesn't disappear just because one owner
let go of it). The only real hazard is calling this too early:
Self::window_audience_signal is get-or-create, so if the
torn-down window's own ToastHost (or any other live widget)
calls it again AFTER forget_window, it transparently
allocates a brand-new Signal::new(_) under the same key
rather than erroring — fine for a window that is genuinely gone
(nothing is bound to the discarded signal any more, so no
rebuild is missed), but it means this must be called from
teardown itself, not from a handler the window's own event loop
might still reach afterwards.
Idempotent: forgetting a window id that was never registered
(or was already forgotten) is a safe no-op — HashMap::remove
on a missing key does nothing.
pub fn show_settings_write_failed(
Enqueue the framework's toast for a permanently-discarded
teksilo-settings write — the write-side counterpart of
AppEvent::SettingsWriteFailed (a DebouncedWriter gave up
after MAX_WRITE_ATTEMPTS retries, or was force-flushed still
failing at process teardown, and its queued patches were
dropped). This is data loss, not a status blip: Error severity
and persistent (no auto-dismiss), naming the file that failed.
Framework-level and crate-internal to the join point: the
locale-validated strings can only live in teksilo-widgets
(tr_widget! resolves against this crate's own
locales/*.ftl), so the toast is built here rather than at the
call site. teksilo::install_toast (the umbrella crate — the
one place that sees both teksilo-app's AppEvent and this
ToastRegistry) calls this from a
TeksiloAppBuilder::register_app_event_observer closure, so
every app with toast installed surfaces the loss automatically,
with no per-app wiring.
No EventContext is available at the call site — this fires
from a background AppEvent observer, not a widget event
handler — so this goes straight to enqueue rather than
through EventContextToastExt::show_toast. The only situation
enqueue needs a context for is invoking the slot-pool-overflow
on_dismiss callback; this toast never sets one, so if the pool
is already full and this arrival evicts/drops an entry, there is
nothing behind that callback to lose — the overflow result is
dropped here deliberately, not silently.
pub fn live_count(&self) -> usize {
Test-only: how many entries are currently live.
ToastHost
ToastHost — invisible sibling widget that owns the toast queue.
Installed by install_toast(opts) in the teksilo umbrella. The
umbrella's TeksiloAppBuilderToastExt::install_toast registers a
DefaultPostRoot closure that wraps
every window's root with a ZStack of [user_root, ToastHost].
The host renders its toast surfaces as direct children, positioned
absolutely at the configured viewport corner. The wrapping ZStack
ensures toasts paint above the user content; the host itself fills
the viewport (so its children — the toasts — have absolute screen
coordinates to anchor against) and is event_pass_through outside
the toast bounds so the user can still interact with content below.
No overlay system involvement — toasts are regular widgets in the arena. The host owns the per-frame timer + hover-pause; expired entries are removed from the registry's queue, the version signal is bumped, the host rebuilds, the surface widgets are destroyed.
Routing: each host filters live_entry_ids() down to entries whose
ToastRoute matches its own window id / assigned audience, or that
are Broadcast. Every host binds the SAME
ToastRegistry::version_signal at BindingLevel::Rebuild — one
signal reaches N windows, because each window's WidgetTree owns
its own BindingRegistry and that registry remembers the
generation it last reconciled (see
teksilo_core::binding::BindingRegistry). A host that matches
nothing in a given rebuild just produces zero new surfaces, which
is cheap and lets one shared queue serve every window without a
per-window registry.
Builder methods at a glance
wrapping
API reference
📖 Full rustdoc API for this module
pub struct ToastInstallOptions
Configuration for the installed ToastHost. Passed to
install_toast in the teksilo umbrella crate.
#![allow(unused)] fn main() { pub struct ToastInstallOptions { /* fields */ } }
pub struct ToastHost
Invisible sibling widget that owns the toast queue. Installed once
per window by the install_toast extension trait via a
DefaultPostRoot closure (see teksilo::toast_install).
Renders its toast surfaces as direct children positioned at the
configured corner. Use ZStack::new().child(user_root).child(host)
to put the host above the user content.
#![allow(unused)] fn main() { pub struct ToastHost { /* fields */ } }
Methods
pub fn new(registry: ToastRegistry, options: ToastInstallOptions) -> Self
Construct a host bound to the given registry. Add to the tree
alongside the user root inside a ZStack.
pub fn wrapping( _user_root: WidgetId, registry: ToastRegistry, options: ToastInstallOptions, ) -> Self
Backwards-compatibility alias for ergonomic post-root
installation: an app that already has a wrapping ZStack can
construct a host via the standalone new(...). This helper
returns a fresh wrapper that uses ZStack internally — but
since the wrapping is owned by install_toast itself, this is
rarely called by user code.
ToastSurface
ToastSurface — the rendered chrome of one toast.
Built by ToastHost for each live entry. Owns the severity
glyph, title + body column, action row, close button, and the
Role::Alert / Role::Status AccessKit node mapping. The visual
chrome (background, padding, layout) is delegated to the active
ToastStyle via make_body.
API reference
📖 Full rustdoc API for this module
pub struct ToastSurfaceData
Snapshot data passed to a ToastSurface for one live entry. Owned
by the host's LiveEntry and cloned into the surface at build
time. Rc<...> fields keep callbacks cheap to copy.
#![allow(unused)] fn main() { pub struct ToastSurfaceData { /* fields */ } }
pub struct ToastSurface
One rendered toast — chrome owned by ToastStyle::make_body,
functional pieces (glyph, body, action row, close button) owned
by this widget. Built fresh for each entry — there is no internal
Signal<Option<…>> slot binding (the host rebuilds on changes).
#![allow(unused)] fn main() { pub struct ToastSurface { /* fields */ } }
Methods
pub fn new( data: ToastSurfaceData, leading_widget: Option<Box<dyn Widget>>, registry: ToastRegistry, closable_on_escape: bool, ) -> Self
Build a surface for a single live toast entry. Called by ToastHost
once per live registry entry during each rebuild pass. leading_widget is Some for
Toast::loading (a spinner) and None for severity-glyph entries (a SeverityBadge
is synthesised in build). closable_on_escape mirrors the matching Toast field.
pub fn _default_dismiss(...) (hidden)
Convert milliseconds to a Duration — used for default tests.
#![allow(unused)] fn main() { pub fn _default_dismiss() -> std::time::Duration; }
Toggle

Toggle — an animated on/off switch bound to a Signal<bool>.
Renders as a sliding-knob switch (IntUI default) or one of the alternate
ToggleVariant shapes. All visual chrome is delegated to a ToggleStyle
impl; the widget itself owns only event handling (tap, Space, AccessKit
Click). The IntUI recipe
(crate::styles::RecipeToggleStyle) ships out of the box; apps install a
custom look per-call with .style(impl ToggleStyle) or theme-wide via
theme.style_slots.toggle = Some(Rc::new(…)).
Accessibility
Emits Role::Switch with toggled reflecting the signal value. Always pair
with .label(…) — the debug build asserts that a label is present, and
screen readers will announce "switch" with no context if it is absent.
Example
#![allow(unused)] fn main() { use teksilo_widgets::Toggle; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let dark_mode = Signal::new(false); let _w = Toggle::new(dark_mode) .label(lit!("Dark mode")); }
Builder methods at a glance
label, labelled_externally, enabled, variant, style, tooltip, rich_tooltip, rich_tooltip_content, composite_tooltip
API reference
📖 Full rustdoc API for this module
pub struct Toggle
An animated toggle switch bound to a Signal<bool>.
#![allow(unused)] fn main() { pub struct Toggle { /* fields */ } }
Methods
pub fn new(on: Signal<bool>) -> Self
Create a toggle bound to on. The signal is both read (to paint the
current state) and written (flipped on each activation).
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible label announced by AT and optionally displayed beside the switch.
pub fn labelled_externally(mut self) -> Self
Declare that this toggle's accessible name comes from a sibling label
widget, wired by a container after mount (FormLayout::line does this
via access_labelled_by).
Without it the debug assertion below fires even though the toggle is
properly labelled: the labelled_by relation is pushed post-mount, so
accessibility() cannot see it and every form-hosted toggle looks
nameless. Setting .label(..) instead would satisfy the assert but
render the text a second time, beside a label column that already has it.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively. Forwarded to the
arena via ctx.enabled_when(self_id, self.enabled.clone()) at
build time.
pub fn variant(mut self, variant: ToggleVariant) -> Self
Pick a Tier-1 design-language variant
(ToggleVariant::Switch / Pill / Square / Inset). The
active ToggleStyle decides what to do with the hint —
IntUI's default impl honours all four; a custom impl might
ignore the variant entirely.
pub fn style(mut self, style: impl ToggleStyle) -> Self
Override the active ToggleStyle for this widget instance
only. Useful for one-off custom-painted toggles in a single
view.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain single-line tooltip shown after a hover delay.
Mutually exclusive with rich_tooltip,
rich_tooltip_content, and
composite_tooltip — the last setter
called wins and clears the others.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip looked up by registry key.
Mutually exclusive with the other tooltip setters — the last setter called wins and clears the others.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip from an inline crate::tooltip::TooltipContent
value rather than a registry key.
Mutually exclusive with the other tooltip setters — the last setter called wins and clears the others.
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip whose body is an arbitrary widget tree.
Mutually exclusive with the other tooltip setters — the last setter called wins and clears the others.
Toolbar

Toolbar — a command bar with automatic overflow.
Excess actions collapse into a trailing chevron (⌄) that opens a popover
menu, mirroring Qt's QToolBar extension button, macOS NSToolbar's
overflow menu, and WinUI CommandBar. Synthesized API:
- Actions (
ToolbarAction) — a command with a label + icon (both required), an optional tooltip, enabled state, optional toggle (checkable) or dropdownmenu, an overflow priority (NSToolbar: lowest priority collapses first), and analways_overflowflag (WinUI secondary commands). Each action has a toolbar form (anIconButton, or aPopoverIconButtonwhen it carries a menu) and a menu form (aMenuItem, or a submenu), so it renders correctly whether inline or in the overflow menu. - Pinned widgets (
ToolbarItem::custom) — arbitrary widgets (a search field, aSegmentedControl) that never collapse. - Collapsible widgets — an arbitrary widget that does overflow, by
supplying an overflow representation (NSToolbar
menuFormRepresentation/ QtQWidgetAction): a menu row (ToolbarAction) viaToolbarItem::custom(w).overflow_as(action)(orToolbarOverflow+ToolbarItem::collapsible; an icon-only control reuses its icon as the menu glyph), or a live embedded widget viaToolbarItem::custom(w).overflow_widget(f)(the factory rebuilds the control — e.g. aComboBoxbound to the same signal — inside the menu so it stays usable while collapsed). When the bar is tight the inline widget is hidden and its overflow form appears in the menu. - Separators and flexible space (NSToolbar
flexibleSpace). - Toolbar-wide
button_size(defaultCompact),button_style(a sharedIconButtonStylefor every action), and orientation.
Overflow is computed every layout pass from each item's intrinsic size
(measured even while collapsed, via
LayoutContext::measure_intrinsic),
so items reappear correctly as the bar widens — no stale-width glitches.
The chevron's drop-down is a real MenuList whose rows are gated by
MenuList::item_when,
so it sizes compactly to the currently-collapsed rows, carries standard
menu chrome, takes focus when opened, and supports arrow / Home / End /
Enter keyboard navigation (skipping the hidden rows).
Accessibility (ARIA toolbar pattern). The bar emits Role::Toolbar
with its orientation and name. It is a single Tab stop with roving
tab-index: arrow keys move focus among the visible controls (and the
chevron), Home/End jump to the ends. The chevron announces
HasPopup::Menu and its expanded state; overflowed actions are dormant
(absent from the AT tree), represented instead by their menu items — so no
action is announced twice. Toggle actions carry Toggled.
// on_activate requires an EventContext — use ignore.
use teksilo_widgets::toolbar::{Toolbar, ToolbarAction, ToolbarItem};
use teksilo_i18n::lit;
let _bar = Toolbar::new()
.action(ToolbarAction::new(lit!("Save"), save_icon).on_activate(|ctx| { /* ... */ }))
.action(ToolbarAction::new(lit!("Undo"), undo_icon).priority(-1))
.item(ToolbarItem::flexible_space());
Builder methods at a glance
item, action, child, add_child, orientation, button_size, button_style, spacing, label, compact, is_overflowing
API reference
📖 Full rustdoc API for this module
pub const TOOLBAR_HEIGHT_DEFAULT
Toolbar design tokens.
#![allow(unused)] fn main() { pub const TOOLBAR_HEIGHT_DEFAULT: f32 = 40.0; }
pub const TOOLBAR_SPACING
#![allow(unused)] fn main() { pub const TOOLBAR_SPACING: f32 = 4.0; }
pub enum ToolbarOrientation
Layout axis of the toolbar.
#![allow(unused)] fn main() { pub enum ToolbarOrientation { /* variants */ } }
Variants
Horizontal— Items flow left-to-right (default).Vertical— Items flow top-to-bottom.
pub struct ToolbarAction
A toolbar command: a label + an icon (both required), plus optional
tooltip/toggle, an activation handler, an overflow priority, and an
always_overflow flag. Renders as an icon-only IconButton inline (the
label is its tooltip + accessible name) and as a labelled MenuItem in the
overflow menu.
#![allow(unused)] fn main() { pub struct ToolbarAction { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>, icon: impl Fn() -> IconWidget + 'static) -> Self
A new action with the given (translatable) label and icon factory,
and a no-op handler. The label is the inline button's tooltip +
accessible name (the button is icon-only); the icon factory builds the
glyph for both the inline IconButton and the overflow menu row
(IconWidget isn't Clone, so it is a factory).
pub fn menu(mut self, factory: impl Fn() -> MenuList + 'static) -> Self
Turn this action into a dropdown: its inline control becomes a
PopoverIconButton that opens the MenuList built by factory
(instead of a plain button that runs on_activate), and in the overflow
it becomes a submenu. MenuList isn't Clone, so pass a factory that
builds a fresh one. Mutually exclusive with on_activate / toggle
(the menu owns the interaction).
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Plain-text tooltip shown after a hover delay (also the AT name
supplement in IconOnly mode). Overrides any previously set rich
tooltip — every setter clears the other so last-call wins.
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide tooltip registry.
The key is looked up via
TooltipRegistry at build
time; the resolved body text supports inline markup
(label, *italic*, **bold**) and the entry's
shortcut / "more" fields are rendered automatically.
Overrides any previously set plain .tooltip(...) — every setter
clears the other so last-call wins.
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self
Attach a rich tooltip driven by inline
TooltipContent — for
one-off tooltips that aren't worth registering in the central
catalog. Overrides any previously set plain .tooltip(...).
pub fn composite_tooltip(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Attach a composite tooltip whose body is built by factory — an
arbitrary widget tree (tabbed sections, charts, conditional rows).
Because ToolbarAction is Clone, the body is supplied as a
factory closure (not a Box<dyn Widget> instance, which is not
Clone); the closure is invoked to produce a fresh body for the
inline button. Overrides any previously set tooltip — every setter
clears the others so last-call wins.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enabled state, static or reactive.
pub fn on_activate(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Activation handler (tap / Enter / Space / AT click / menu activate).
pub fn toggle(mut self, state: Signal<bool>) -> Self
Make this a checkable (toggle) action bound to state. Inline it reads
as a pressed toggle button; in overflow as a checkmark menu item.
pub fn priority(mut self, priority: i32) -> Self
Overflow priority — actions with the lowest priority collapse into
the menu first (NSToolbar semantics). Default 0.
pub fn always_overflow(mut self) -> Self
Always live in the overflow menu, never inline (WinUI secondary command).
pub struct ToolbarItem
One slot in a Toolbar.
#![allow(unused)] fn main() { pub struct ToolbarItem { /* fields */ } }
Methods
pub fn action(action: ToolbarAction) -> Self
A collapsible command.
pub fn custom(widget: impl Widget + 'static) -> Self
A pinned arbitrary widget (never collapses) — e.g. a search field. Make
it collapsible with overflow_as.
pub fn custom_id(id: WidgetId) -> Self
A pinned arbitrary widget by pre-registered id.
pub fn collapsible(widget: impl Widget + ToolbarOverflow + 'static) -> Self
A collapsible widget that supplies its own menu form via
ToolbarOverflow. When the bar is too narrow, the widget is hidden
and its toolbar_menu_form() appears in the overflow menu.
pub fn overflow_as(mut self, menu_form: ToolbarAction) -> Self
Make a custom widget collapsible with an explicit menu
row — the ToolbarAction shown when it overflows (NSToolbar
menuFormRepresentation). Best for controls whose menu form is a
single command; an icon-only inline control reuses its icon here as the
menu item's leading glyph (pass it to ToolbarAction::new).
pub fn overflow_widget(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Make a custom widget collapsible by embedding a live
widget in the overflow menu — the factory rebuilds the control (e.g.
a ComboBox bound to the same signal) so it stays fully interactive
while collapsed, instead of degrading to a one-shot menu row. Best for
stateful inputs (combo boxes, sliders) that have no meaningful single
"command" representation.
pub fn separator() -> Self
A separator line between groups.
pub fn flexible_space() -> Self
Flexible space that pushes the following items to the trailing edge
(NSToolbar flexibleSpace). Collapses to nothing when over-constrained.
pub struct Toolbar
A command bar with automatic overflow. See the module docs.
#![allow(unused)] fn main() { pub struct Toolbar { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty toolbar with the default orientation (horizontal) and
Compact, ghost icon buttons. Add commands with action
or layout items with item.
pub fn item(mut self, item: ToolbarItem) -> Self
Add an item (action, pinned widget, separator, flexible space).
pub fn action(self, action: ToolbarAction) -> Self
Sugar for .item(ToolbarItem::action(a)).
pub fn child(self, widget: impl Widget + 'static) -> Self
Add a pinned inline child widget (sugar for
.item(ToolbarItem::custom(widget))). Pinned widgets never collapse
into the overflow menu — use action for collapsible
commands.
pub fn add_child(self, id: WidgetId) -> Self
Add a pinned inline child by pre-registered id (sugar for
.item(ToolbarItem::custom_id(id))).
pub fn orientation(mut self, orientation: ToolbarOrientation) -> Self
Set the layout axis (default ToolbarOrientation::Horizontal).
pub fn button_size(mut self, size: IconButtonSize) -> Self
Size variant applied to every action's inline IconButton and the
overflow chevron (default IconButtonSize::Compact).
pub fn button_style(mut self, style: impl IconButtonStyle) -> Self
A toolbar-wide IconButtonStyle applied to every action button and the
overflow chevron — one shared style for the whole bar (the icon-button
analogue of theme.style_slots). Default: the theme's flat / ghost
icon-button style.
pub fn spacing(mut self, spacing: f32) -> Self
Gap between consecutive toolbar items in logical pixels (default
TOOLBAR_SPACING).
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Override the accessible name (default: the localized "Toolbar").
pub fn compact(mut self, compact: bool) -> Self
Compact (shrink-to-fit) sizing. By default a toolbar fills the main
extent it is offered (it is meant to span a full command bar). In compact
mode it instead reports its natural content extent as the wanted size
and is shrinkable down to its collapsed minimum (the pinned items plus
the overflow chevron) — so it sits as a tight cluster when there is room,
composes next to other widgets (e.g. a title and a Spacer) without
claiming their space, and still collapses excess actions into the ⌄
menu when the slot is genuinely too narrow. Use it to embed a toolbar in a
constrained header rather than a full-width bar.
pub fn is_overflowing(&self) -> Signal<bool>
Reactive signal that is true whenever any action is collapsed into the
overflow menu (WinUI IsOverflowOpen-adjacent introspection).
ToolBox

ToolBox — a vertical stack of collapsible sections, exactly one expanded at a time.
Semantic cousin of Qt's QToolBox and the collapsible groups in
IntelliJ's Settings dialog. Differs from Accordion
(single-item independent disclosure) and TabWidget
(horizontal tab bar with dormant panes) by combining vertical layout,
always-visible headers, and exclusive expansion in one widget.
Int UI visual language:
- flat, borderless headers (no corner radius)
- 1 dp accent indicator bar on the leading edge of the active header
- color-only emphasis (selected / hover / pressed surface roles)
- border IS the focus ring: 1 dp accent border appears on the focused header, no separate ring primitive
- content swaps are instant — Int UI's house rule is to avoid
decorative animation for inline transitions; see
MotionTokens. Matches the existingTabWidgetprecedent where pane swaps have no transition.
let selected = ctx.signal(0_usize);
ToolBox::new(selected.clone())
.item("Outline", outline_widget)
.item("Properties", properties_widget)
.add(ToolBoxItem::new("Build", build_widget).enabled(false))
Builder methods at a glance
orientation, fill, collapsible, horizontal, on_header_drag, item, item_id, add, items, show_dividers
API reference
📖 Full rustdoc API for this module
pub enum ToolBoxOrientation
Orientation of a [ToolBox]: how its collapsible sections are arranged.
Vertical (the default) stacks sections
top-to-bottom with horizontal headers and an up/down chevron — the
classic QToolBox. Horizontal lays
sections left-to-right; each header becomes a narrow vertical strip
with its label rotated 90° and a left/right chevron. The horizontal form
is used by side-docks anchored to the top/bottom edges (where the wide,
short region calls for vertical header strips).
#![allow(unused)] fn main() { pub enum ToolBoxOrientation { /* variants */ } }
Variants
Vertical— Sections stacked top-to-bottom; horizontal headers (default).Horizontal— Sections arranged left-to-right; vertical header strips with rotated labels and left/right chevrons.
pub struct ToolBoxItem
One section of a ToolBox. Construct with ToolBoxItem::new and pass
to ToolBox::add, or use the convenience ToolBox::item /
ToolBox::item_id builders directly when leading / trailing slots
and tooltip are not needed.
Layout of the header row:
[indicator] [leading?] [label] [spacer] [trailing?] [chevron]
Both leading and trailing accept any impl Widget — typical uses
are a small IconWidget, a Checkbox (checkable section), a
Badge (count), or a Button (per-row action).
#![allow(unused)] fn main() { pub struct ToolBoxItem { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self
Build an item with an inline content widget. The label may come from
tr!(...) (translated) or lit!(...).
pub fn new_id(label: impl Into<LocalizedString>, content_id: WidgetId) -> Self
Build an item whose content is a pre-registered widget id.
pub fn leading(mut self, widget: impl Widget + 'static) -> Self
Attach a leading-slot widget rendered before the label (after
the selection indicator bar). Use for a small IconWidget, a
Checkbox for checkable sections, a Badge, or any other
label-sized widget. The slot widget owns its own events — a
Checkbox inside the leading slot toggles independently of
the header's own tap.
pub fn trailing(mut self, widget: impl Widget + 'static) -> Self
Attach a trailing-slot widget rendered between the row's flexible
spacer and the chevron. Use for per-row actions — a dismiss
button, a badge, a secondary Toggle. The slot widget owns its
own events: tapping a Button inside the trailing slot fires the
button's action; gesture recognisers on the trailing widget stop
the header's own tap from firing, so a close-button click does
not also select the section.
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self
Attach a plain-text tooltip shown after a hover delay on the header
row. The text may come from tr!(...) (translated, locale-reactive)
or lit!(...). Mirrors .tooltip(...) on Button / IconButton /
MenuItem. Clears any previously set rich or composite tooltip (the
last tooltip setter called wins).
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self
Attach a rich tooltip resolved from the app-wide
TooltipRegistry by key.
Clears any previously set plain or composite tooltip (the last
tooltip setter called wins).
pub fn rich_tooltip_content(mut self, content: TooltipContent) -> Self
Attach a rich tooltip driven by inline TooltipContent — for
one-offs that don't belong in the registry. Clears any previously
set plain or composite tooltip (the last tooltip setter called wins).
pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self
Attach a composite tooltip — an arbitrary impl Widget body shown
in a larger, scrollable overlay after a longer hover delay. Use for
rich on-demand previews: charts, property tables, image thumbnails.
Clears any previously set plain or rich tooltip (the last tooltip
setter called wins).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Disable the item: its header renders in the disabled text role,
click and keyboard activation are ignored, and arrow navigation
skips it. Accepts a static bool or a reactive Signal<bool>.
Forwarded to the arena via
ctx.enabled_when(header_id, self.enabled.clone()) at build time;
the arena is then the single source of truth and ANDs with
ancestors — disabling the surrounding ToolBox (or any ancestor)
disables every item regardless of this flag.
pub const TOOL_BOX_HEADER_MIN_HEIGHT
ToolBox design tokens.
#![allow(unused)] fn main() { pub const TOOL_BOX_HEADER_MIN_HEIGHT: f32 = 28.0; }
pub const TOOL_BOX_HEADER_PADDING_HORIZONTAL
#![allow(unused)] fn main() { pub const TOOL_BOX_HEADER_PADDING_HORIZONTAL: f32 = 12.0; }
pub const TOOL_BOX_ICON_TEXT_SPACING
#![allow(unused)] fn main() { pub const TOOL_BOX_ICON_TEXT_SPACING: f32 = 8.0; }
pub const TOOL_BOX_CHEVRON_SIZE
#![allow(unused)] fn main() { pub const TOOL_BOX_CHEVRON_SIZE: f32 = 12.0; }
pub const TOOL_BOX_INDICATOR_THICKNESS
#![allow(unused)] fn main() { pub const TOOL_BOX_INDICATOR_THICKNESS: f32 = 1.0; }
pub struct ToolBox
A vertical container of collapsible sections with exactly one expanded
at a time — the Int UI / QToolBox pattern.
The active section is driven by a caller-owned Signal<usize>; mirrors
TabWidget::new so persistence, synchronised
windows, and programmatic activation work identically.
#![allow(unused)] fn main() { pub struct ToolBox { /* fields */ } }
Methods
pub fn new(selected: Signal<usize>) -> Self
Create a ToolBox driven by selected (visible section index). Set the
signal to 0 to open the first section by default; modify it
programmatically or share it across windows for synchronized state.
pub fn orientation(mut self, orientation: ToolBoxOrientation) -> Self
Set the section arrangement orientation (default
ToolBoxOrientation::Vertical).
pub fn fill(mut self, fill: bool) -> Self
Make the active section's panel fill the ToolBox's allotted space rather than size to its content's natural extent.
With fill on, the active panel stretches to the full cross axis and
flexes / shrinks (and clips) along the main axis, so a ToolBox placed
in a bounded region lays its content out at exactly the available
size — the QToolBox convention. A panel whose content carries a
trailing Spacer therefore pins a bottom toolbar to the visible
bottom edge instead of overflowing past it.
Default false (the panel keeps its content's natural size — the
historical behaviour, appropriate when the ToolBox itself lives inside
a scroll area).
pub fn collapsible(mut self, collapsible: bool) -> Self
Allow collapsing the active section: clicking (or Enter/Space on, or
the AT Collapse action of) the already-expanded header closes it, so
all sections can be collapsed at once. A subsequent click re-expands.
Default false — the classic "exactly one section open" behaviour. This
is what makes a single-section ToolBox a plain collapsible panel
(header toggles its content), e.g. a dock panel.
pub fn horizontal(mut self) -> Self
Shorthand for ToolBox::orientation``(``ToolBoxOrientation::Horizontal``).
pub fn on_header_drag(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self
Make each section header a drag source. f is invoked (with the
section index) when a drag gesture starts on a header; it should
begin a drag (e.g. ctx.start_drag(source, payload)). Tapping a
header still selects it — the gesture arena tells a tap from a drag.
pub fn item(self, label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self
Append an item with an inline content widget. Convenience wrapper
around ToolBox::add that skips the ToolBoxItem builder for
the common label-plus-content case.
pub fn item_id(self, label: impl Into<LocalizedString>, content_id: WidgetId) -> Self
Append an item whose content is a pre-registered widget id.
pub fn add(mut self, item: ToolBoxItem) -> Self
Append a fully-built ToolBoxItem — required when an icon,
tooltip, or disabled flag is needed.
pub fn items<I>(mut self, items: I) -> Self where I: IntoIterator<Item = ToolBoxItem>,
Append multiple items from an iterator.
pub fn show_dividers(mut self, show: bool) -> Self
Show a 1 dp BorderRole::Divider line between consecutive header /
panel rows. Default: false — IntelliJ Settings-style collapsibles
stack without explicit dividers, letting the flat background roles
delineate the rows.
TooltipWidget

Tooltip system — hover-triggered overlays with configurable delay.
Three tiers, increasing in expressive power:
TooltipWidget— single line of localized text in a themed rounded rect. Attached via the per-widget.tooltip(...)setter.RichTooltipWidget—TooltipContent-driven (body + optional long-form "more" disclosure + shortcut chip), inline-markup body solabelcascade links resolve againstTooltipRegistry. Attached via.rich_tooltip(key)/.rich_tooltip_content(content). On dwell it flips its AT role toRole::Dialogand advertises aFocusaction — keyboard focus is not auto-transferred; the user Tabs in (the correct non-modal-panel a11y pattern).composite::CompositeTooltipWidget— hosts an arbitraryimpl Widget + 'staticbody inside the same chrome with a larger surface budget. Crusader Kings 3-style: tabbed sections, charts, progress bars, conditional rows. Attached via.composite_tooltip(content). "Primary-only" by construction — has no inline-markup body and no registry key, so it cannot be the target of alabelcascade. Child widgets inside the body keep their own tooltip setters and cascade normally.
All three tiers share the same overlay machinery, hover/focus
tracking, and dwell-promotion timer in teksilo-core. The per-widget
setters (.tooltip / .rich_tooltip / .composite_tooltip) are
mutually exclusive (last-one-wins): each setter clears the others.
Example — plain tooltip
#![allow(unused)] fn main() { use teksilo_widgets::tooltip::TooltipWidget; use teksilo_i18n::lit; let _tip = TooltipWidget::new(lit!("Save the current file")); }
Builder methods at a glance
bound, style
API reference
📖 Full rustdoc API for this module
pub struct TooltipWidget
A tooltip content widget — a themed rounded rect with text.
Composes a TextWidget with Small typography in tooltip_text color,
then delegates the chrome (shadow, dark background, corner radius,
padding) to the active TooltipStyle (default
crate::styles::RecipeTooltipStyle). Apps install per-call
(TooltipWidget::new(...).style(impl TooltipStyle)) or theme-wide
via theme.style_slots.tooltip = Some(Rc::new(MyTooltip)).
#![allow(unused)] fn main() { pub struct TooltipWidget { /* fields */ } }
Methods
pub fn new(text: impl Into<LocalizedString>) -> Self
Construct a tooltip from a localized string. With an I18nManager
installed the body stays locale-reactive (re-resolves on locale
change); otherwise it's a static snapshot.
pub fn bound(text: impl Into<Prop<String>>) -> Self
Construct a tooltip whose body is driven by a Signal<String>
(or any Prop<String>). Mutating the signal re-renders the
tooltip in place — used when a single dormant tooltip surface is
reused across many anchors and its text is set just before each
show. Callers wanting locale reactivity should resolve their
LocalizedString against the active locale when setting the
signal.
pub fn style(mut self, style: impl teksilo_core::styles::TooltipStyle) -> Self
Per-call style override. Replaces the theme-wide default
TooltipStyle for just this TooltipWidget instance.
TreeRowMeta
Type-erased data source adapter for TreeView.
Wraps any TreeDataSource behind a uniform set of Rc<dyn Fn(..)> closures
keyed on the visible flat index, so TreeView<T> requires no extra type
parameter for the source's Key. Each closure resolves index → Key (via
key_at) before forwarding to the source's parent, set_expanded,
can_accept, etc. The Key type is fully captured here and never surfaces
in the view.
Both built-in and external backings flow through
Rc<TreeSlice<T>> (which implements TreeDataSource<Key = NodeId>), while
TreeView::from_source wraps an external TreeDataSource with its own Key.
The only built-in-vs-external difference — the NodeId-typed TreeRowContext
handed to the legacy delegate — lives in tree_view.rs, not here.
Builder methods at a glance
toggle_callback
API reference
📖 Full rustdoc API for this module
pub struct TreeRowMeta
Key-erased per-row flat metadata, derived from the source's FlatEntry.
#![allow(unused)] fn main() { pub struct TreeRowMeta { /* fields */ } }
pub struct TreeRow
Per-row context handed to a TreeView::from_source
delegate — the key-erased counterpart of the built-in
TreeRowContext. Carries the row's flat metadata
plus a one-call chevron toggle that flips the row's expansion through the
source (by index → key → set_expanded).
#![allow(unused)] fn main() { pub struct TreeRow { /* fields */ } }
Methods
pub fn toggle_callback(&self) -> Rc<dyn Fn(&mut EventContext)>
Toggle callback for this row's chevron. Wires in one line:
.on_toggle_rc(row.toggle_callback()).
TreeTableView

TreeTableView<T> — hierarchical multi-column data table with expand/collapse.
Sibling of TableView for tree-shaped data. Each row carries
a depth level; one designated column (the tree column, defaulting to the first)
shows a twist (chevron) and an indent gutter that toggles the row's children.
Backed by a SortFilterTreeModel<T> so sort, filter, and expand state compose
without extra bookkeeping. Shares the header, column, keyboard, and selection
modules with TableView.
Rows live in a TreeBodyPane — a sibling of the scrollbar — so buffer-exit /
selection / expand rebuilds are never deferred mid-thumb-drag. Three row-height
modes: uniform (row_height, fast path), exact per-flat-index callback
(row_height_fn), and auto-measured (auto_row_height — grows to tallest cell).
Common patterns
A checkbox column. Selection and "checked" are different things — a
checkbox column wants its own state, with parent/child propagation. Build it
from TreeCheckedModel over the same tree
the view projects.
A cell delegate receives (&T, &CellContext) and CellContext carries no
node identity — only row_index. So
capture the projection and resolve the row's NodeId through it:
let proxy = SortFilterTreeModel::new(tree);
let checks = TreeCheckedModel::new(proxy.tree());
let for_cells = proxy.clone();
let col = Column::new("done", lit!("Done"), move |_item, cx: &CellContext| {
match for_cells.visible_node_id(cx.row_index) {
Some(node) => Box::new(Checkbox::new(checks.check_state(node))) as Box<dyn Widget>,
None => Box::new(Spacer::new()),
}
});
For a tree whose identity is a domain key rather than a NodeId, use
KeyedTreeCheckedModel instead — it
survives a full re-source, which a NodeId-keyed set cannot.
Accessibility
Root emits Role::TreeGrid; rows carry set_level + set_expanded.
ArrowLeft / ArrowRight on the tree column collapse / expand.
// Column delegates capture closures — use ignore.
use teksilo_widgets::TreeTableView;
use teksilo_data::TreeModel;
# struct File { name: String }
# let model: TreeModel<File> = TreeModel::new();
let _view = TreeTableView::new(model).row_height(28.0);
Builder methods at a glance
from_projection, from_source, from_source_keyed, enabled, overscroll_behavior, smooth_scrolling, type_ahead_label, type_ahead_timeout, smooth_scroll_duration, scroll_bar_style, add_column, reorderable, exportable, export_external, on_rows_transferred_out, accept_foreign_rows, on_rows_received, on_foreign_drop, activate_on, columns, tree_column, indent_per_level, row_height, row_height_fn, auto_row_height, header_height, show_header, selection_mode, selection, keyed_selection, cell_selection, alternating_rows, grid_lines, a11y_label, show_internal_scrollbars, column_resize_policy, tab_traversal, edit_triggers, on_cell_edit_request, on_cell_edit_dismissed, on_row_activate, filter_mode, scroll_y_signal, max_scroll_y_signal, viewport_ratio_y_signal, scroll_x_signal, max_scroll_x_signal, viewport_ratio_x_signal, sort_signal, filters_signal, column_widths_signal, column_order_signal, focused_cell_signal, editing_cell_signal, projection, expand, collapse, toggle, expand_all, collapse_all, set_focused_cell, clear_focused_cell, set_sort, set_filter, clear_filters, empty_view, clear_sort, scroll_to_row, ensure_row_visible, set_column_width, set_column_widths, set_column_order, column_pinning_signal, set_column_pinning, begin_edit, end_edit
API reference
📖 Full rustdoc API for this module
pub struct TreeTableView
Hierarchical multi-column widget. See module documentation.
#![allow(unused)] fn main() { pub struct TreeTableView<T: 'static> { /* fields */ } }
Methods
pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self
Wrap a SortFilterTreeModel<T>.
Wrap a SortFilterTreeModel<T>.
pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self
Build a tree table over any TreeDataSource — an external source of
truth (a Qleany entity store, a database, a virtual filesystem) carrying
its own Key, so it needs no TreeModel mirror.
This is the tree-table sibling of
TreeView::from_source. Because the
source owns identity, its expand state (and a keyed selection) survive a
full re-source — which a TreeModel mirror cannot guarantee, since
NodeIds are reassigned on rebuild.
The NodeId-typed methods (expand,
projection, keyed_selection)
do not apply here and no-op; drive expansion through the source itself.
Row drag-reorder is wired on this path: a drop routes through the source's
own drag / can_accept / accept_drop, exactly as
TreeView does — so the
source owns both the cycle guard and the commit. Note that
TreeDataSlice::drag defaults to NoDrag: an
external source must opt its rows in before anything can be dragged.
pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>( source: S, keyed: KeyedSelectionModel<S::Key>, ) -> Self where S::Key: teksilo_data::ItemKey,
Like from_source but with keyed selection:
the KeyedSelectionModel<S::Key> tracks rows by source identity, so it
survives expand / collapse, sort / filter and a full re-source. Pruning
consults the source's contains_key, so a collapsed-but-present row
keeps its selection. The view stays TreeTableView<T> — the Key is
captured here.
pub fn new(model: TreeModel<T>) -> Self
Wrap a raw TreeModel<T> — convenience for callers that don't
need sort/filter. Internally builds an identity
SortFilterTreeModel.
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the whole view. A disabled view greys out and stops accepting focus / selection / keyboard input (arena-gated).
pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self
Set the scroll-chaining behavior at the boundary (default
OverscrollBehavior::Chain; Contain
disables chaining to an ancestor scrollable).
pub fn smooth_scrolling(mut self, enabled: bool) -> Self
Enable or disable animated wheel scrolling (enabled by default). When disabled, wheel events snap immediately to the new offset.
pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self
Enable type-ahead ("type to jump"): typing a printable character
while the tree-table has keyboard focus jumps the focused row to the
next visible row whose label starts with the accumulated search term,
wrapping around (Qt keyboardSearch / macOS & Windows type-select).
label(&item) yields the searchable text; matching is
ASCII-case-insensitive. A pause longer than the
type_ahead_timeout starts a fresh term.
pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self
Reset window between keystrokes before the type-ahead search term clears (default 500 ms). A zero duration disables type-ahead.
pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self
Duration of the smooth scroll animation (default 150 ms).
pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self
How the scroll bar is displayed (default Permanent). Overlay
and Thin float the bar over the content instead of reserving a
layout column for it, mirroring ScrollArea::scroll_bar_style.
pub fn add_column(mut self, col: Column<T>) -> Self
Append a column definition. Columns are displayed in declaration order unless reordered by the user.
pub fn reorderable(mut self, enabled: bool) -> Self
Enable drag-to-reorder of rows (pointer drag + keyboard
Alt+ArrowUp/Down). Distinct from
Column::reorderable, which reorders
columns and defaults to true; this defaults to false.
A drop reparents/reorders the dragged node in the underlying
TreeModel (top third of a row = Before, middle = Into / make-child,
bottom = After). The move is cycle-guarded — dropping a node onto
itself or into its own subtree is refused (no insertion line). Reorder
is suppressed while a sort is active: with the visible order driven
by the sort, a manual reorder would have no visible effect.
pub fn exportable(mut self, mode: DragTransferMode) -> Self where T: Clone,
Make rows droppable outside this view — on a
DropTarget, another data view, or the OS.
A dragged row (or the whole selection, when the pressed row is part of a
multi-selection) carries clones of its items in a public
RowDragData<T>, so a foreign receiver can pull
them out with payload.get_typed::<RowDragData<T>>() /
DropTarget::on_drop_typed::<RowDragData<T>>() — no serialization. This
also makes rows a drag source even without reorderable.
mode chooses what happens to the origin rows once a foreign target
accepts them: DragTransferMode::Move removes them — by default,
directly from the underlying TreeModel (any dragged node that is a
descendant of another dragged node is skipped, since removing the
ancestor already removes it); override via
on_rows_transferred_out.
DragTransferMode::Copy leaves them. A same-view reorder is never a
transfer, so mode never affects it. Requires T: Clone.
pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self where T: Clone,
Additionally advertise the dragged rows as MIME data so they can be
dropped on a DropZone or exported to another
application / window via the OS. f maps the dragged items to
(mime_type, bytes) pairs (e.g. text/plain, text/uri-list, an
app-specific application/x-…). Implies exportable
(defaulting to DragTransferMode::Move if not already set). Requires
T: Clone.
pub fn on_rows_transferred_out( mut self, f: impl Fn(&[usize], &mut EventContext) + 'static, ) -> Self
Override how rows moved out to a foreign target are removed from this
view. Receives the dragged rows' flat visible indices (as captured at
drag-start) and the live context. Without this, an
exportable Move drag
removes the dragged nodes directly from the underlying TreeModel
(leaf-first / descending — a dragged node that is a descendant of
another dragged node is skipped, since removing the ancestor already
removes its whole subtree).
pub fn accept_foreign_rows(mut self, accept: bool) -> Self
Accept exported rows dropped from a different view or source
without writing a custom source. Pair with
on_rows_received, which is handed the
dropped items and the target flat row index. (Same-view reorder is
reorderable.)
pub fn on_rows_received( mut self, f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static, ) -> Self
Handler for rows accepted via
accept_foreign_rows: (items, target flat row index, ctx). Insert them into your tree at/near the index.
pub fn on_foreign_drop( mut self, f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static, ) -> Self
Raw escape hatch for a foreign drop.
Projection path only. This hook is NodeId-typed and predates
from_source; over an external source there is no
NodeId to hand it, so it never fires. Prefer
accept_foreign_rows +
on_rows_received, which are source-agnostic. Unlike ListView / TableView,
TreeTableView is backed by a concrete SortFilterTreeModel<T> rather
than a pluggable source, so it cannot express foreign-accept purely
through source capability closures (can_accept / accept_drop).
This fires for any payload NOT recognized as this view's own row
drag — a different view's RowDragData<T>, or a
completely different payload type — dropped on a node: (payload, target node, drop position, ctx) -> accepted. Tried after
on_rows_received, so the typed sugar wins
when both are set and the payload happens to carry an exportable
RowDragData<T>.
pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self
Choose single- vs double-click activation for on_row_activate (default
ActivateOn::DoubleClick). Enter/Space activates in
either mode.
pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self
Append multiple columns from an iterator.
pub fn tree_column(mut self, col_id: impl Into<String>) -> Self
Designate which column hosts the twist + indent. Default: the first column.
pub fn indent_per_level(mut self, px: f32) -> Self
Override the per-depth indent in the tree column in logical pixels (default
comes from the active TableStyle).
pub fn row_height(mut self, height: f32) -> Self
Fixed row height (default: the table style's 28 px) — the
uniform fast path. Mutually exclusive with
row_height_fn and
auto_row_height; the last mode setter
wins.
pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self
Per-row heights from a callback over the flat (visible) row index. The callback must be pure (same index + same data → same height); it is re-swept from the first changed flat index on every projection rebuild (expand/collapse/sort/filter/mutation). No measurement pass runs.
pub fn auto_row_height(mut self, estimated: f32) -> Self
Auto-measured row heights: each realized row reports the height
of its tallest cell measured at the cell's column width
(height-for-width), unrealized rows assume estimated. Scroll
anchoring keeps content above the viewport stationary; measured
heights above a toggled row survive expand/collapse
(divergence-driven invalidation). The scrollbar settles one
frame after a measurement change.
pub fn header_height(mut self, height: f32) -> Self
Override the header row height in logical pixels.
pub fn show_header(mut self, visible: bool) -> Self
Show or hide the column header row (default true).
pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self
Set the row/cell selection mode (default
TableSelectionMode::MultiRow).
pub fn selection(mut self, sel: SelectionModel) -> Self
Set the index-based row selection model (visible positions). For
identity-based selection that survives expand / collapse / sort /
filter / structural edits, use keyed_selection
instead.
pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self
Set a keyed row selection model (by NodeId). Selection is tracked by
node identity, so it survives expand / collapse, sort / filter, and node
moves — and stays consistent if two views share the projection. Pruned
of deleted nodes on each projection change. Mutually exclusive with
selection (last one set wins).
Only meaningful on the from_projection /
new paths, whose identity is NodeId; a no-op over an
external source, which carries its own key — use
from_source_keyed there.
pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self
Attach a cell-level selection model (row and column axes tracked independently).
pub fn alternating_rows(mut self, enabled: bool) -> Self
Paint odd-indexed rows with the SurfaceRole::AlternatingRow tint
(default false).
pub fn grid_lines(mut self, kind: GridLines) -> Self
Paint horizontal and/or vertical dividers between cells.
pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self
Accessible label for the whole tree table, announced by AT as the table's name.
pub fn show_internal_scrollbars(mut self, show: bool) -> Self
Show or hide the widget's internal vertical and horizontal scroll bars
(default true). Set to false when the table lives inside an external
ScrollArea.
pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self
Control how column widths are distributed when the table is resized
(default Proportional).
pub fn tab_traversal(mut self, mode: TabTraversal) -> Self
Set the keyboard Tab traversal direction inside the table (default Cells).
pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self
Set which user gesture starts an in-place cell edit (default
DoubleClick).
pub fn on_cell_edit_request( mut self, f: impl Fn(usize, &str, &mut EventContext) + 'static, ) -> Self
Callback invoked when the user requests an in-place cell edit (e.g.
double-click when edit_triggers is DoubleClick). Receives the flat row
index, the column id, and a mutable EventContext.
pub fn on_cell_edit_dismissed( mut self, f: impl Fn(usize, &str, &mut EventContext) + 'static, ) -> Self
Callback invoked when an open cell editor should end because the pointer went somewhere else: a press that lands on any cell other than the one being edited. Receives the editing cell's flat row index and column id, so the owner can commit (or discard) whatever is in its buffer, then clear its own editing state.
The counterpart of on_cell_edit_request,
and the view cannot do it alone: the framework owns which cell is being
edited, but only the owner knows what an ended edit means — commit,
discard, or refuse a value that will not parse.
Why a press and not a focus change. "The editor lost focus" is the obvious signal and it cannot be used: a body pane rebuilds constantly — selection, filtering, scroll, a reload from elsewhere — and every rebuild destroys and re-creates the open editor, so focus leaves it many times during an edit the writer never interrupted. A press on another cell is unambiguous and happens exactly once.
pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self
Callback invoked when a row is activated (double-click or Enter, per
activate_on). Receives the flat row index.
pub fn filter_mode(self, mode: TreeFilterMode) -> Self
Forward mode to the underlying projection. The proxy holds its
state behind Rc<RefCell>, so calling .filter_mode() on a
clone mutates the shared inner — effectively persisting the
choice on self.proxy.
pub fn scroll_y_signal(&self) -> &Signal<f32>
Current vertical scroll offset in logical pixels.
pub fn max_scroll_y_signal(&self) -> &Signal<f32>
Maximum vertical scroll offset (content height − viewport height).
pub fn viewport_ratio_y_signal(&self) -> &Signal<f32>
Viewport-to-content height ratio — drives the scrollbar thumb size.
pub fn scroll_x_signal(&self) -> &Signal<f32>
Current horizontal scroll offset of the Middle (unpinned) pane, in logical pixels. Leading/Trailing-pinned columns are unaffected.
pub fn max_scroll_x_signal(&self) -> &Signal<f32>
Maximum horizontal scroll offset — middle_content_width − middle_viewport_width.
pub fn viewport_ratio_x_signal(&self) -> &Signal<f32>
Middle-pane viewport-to-content width ratio.
pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>>
Active sort state: Some((col_id, direction)) or None for unsorted.
This is the header's state, not the data's. Clicking a sort header writes here; nothing reorders rows until you bind this onto the backing projection yourself:
let proxy = SortFilterTreeModel::new(tree)
.with_comparator("name", |a: &Row, b: &Row| a.name.cmp(&b.name));
proxy.sort_signal(view.sort_signal().clone());
The binding is deliberately not automatic: a projection may already carry preset comparators, predicates, and a filter mode, and adopting the view's empty signal at construction would clobber them.
pub fn filters_signal(&self) -> &Signal<HashMap<String, String>>
Active per-column filters keyed by column id.
Like sort_signal, this holds the header's state
only — bind it onto the projection to actually filter rows:
let proxy = SortFilterTreeModel::new(tree)
.with_predicate("name", |t| {
let needle = t.to_string();
Box::new(move |r: &Row| r.name.contains(&needle))
});
proxy.filters_signal(view.filters_signal().clone());
pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>>
Current column widths in logical pixels, keyed by column id.
pub fn column_order_signal(&self) -> &Signal<Vec<String>>
Current column display order as a list of column ids.
pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>>
Keyboard-focused cell as (row, display_column_index), or None.
pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>>
Cell currently being edited as (row, display_column_index), or None.
pub fn projection(&self) -> Option<&SortFilterTreeModel<T>>
Access the underlying SortFilterTreeModel (for programmatic sort /
filter / expand outside of the builder API).
None when the view was built from an external
teksilo_data::TreeDataSource via
from_source — there is no TreeModel-backed
projection to hand back in that case.
pub fn expand(&self, node: NodeId)
Expand the subtree rooted at node.
pub fn collapse(&self, node: NodeId)
Collapse the subtree rooted at node.
pub fn toggle(&self, node: NodeId)
Toggle the expand/collapse state of node.
pub fn expand_all(&self)
Expand all nodes in the tree.
pub fn collapse_all(&self)
Collapse all nodes in the tree.
pub fn set_focused_cell(&self, row: usize, col: usize)
Move keyboard focus to the cell at (row, col).
pub fn clear_focused_cell(&self)
Clear the keyboard-focused cell.
pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection)
Programmatically sort by col_id (pass None to clear the sort).
Equality-guarded, like every persisted-layout setter here — see
set_column_widths.
pub fn set_filter(&self, col_id: &str, text: &str)
Set or clear the filter text for a single column.
pub fn clear_filters(&self)
pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self
Widget shown when no rows are visible — an empty tree, or a filter that matched nothing. Without one, the body region is simply blank.
pub fn clear_sort(&self)
Clear the active sort.
pub fn scroll_to_row(&self, row: usize)
Scroll so that row is aligned to the top of the viewport. A no-op
before the first layout pass.
pub fn ensure_row_visible(&self, row: usize)
Scroll the minimum distance needed to make row visible. A no-op
before the first layout pass, when the viewport height is not yet known.
pub fn set_column_width(&self, col_id: &str, width: f32)
Set or remove a single column's user-resized width override.
A non-positive width removes the entry (the column reverts to
its declared width policy).
pub fn set_column_widths(&self, widths: HashMap<String, f32>)
Replace the full width-override map (typically used to restore a persisted layout).
Equality-guarded for the same reason as
TableView::set_column_widths:
the documented settings round-trip would otherwise recurse without
bound on the first tick of a live resize drag.
pub fn set_column_order(&self, order: Vec<String>)
Replace the column-order list. Ids not declared on this table are silently dropped on the next layout pass.
pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>>
Current column pinning overrides, keyed by column id. Wins over
each column's declared Column::pinned.
pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide)
Pin or unpin a single column. PinnedSide::None removes the
override, reverting the column to its declared pinning.
pub fn begin_edit(&self, row: usize, col_id: &str)
Begin editing the cell (row, col_id). Silently no-ops if col_id
isn't a currently-displayed column, or if row is outside the visible
range — an out-of-range target would otherwise strand editing_cell on
a row nothing can match.
Callable before the view is mounted, which is the only point at
which a consumer can seed a freshly constructed view with an edit
target it already holds. display_indices is a cache build() fills,
so a pre-mount call finds it empty; the order is recomputed on demand
in that case rather than resolving against nothing and no-opping for a
third, undocumented reason.
pub fn end_edit(&self)
Close the active cell editor without committing (the field's on_blur still fires).
TreeView

TreeView — a virtualized, expandable/collapsible hierarchical list widget.
Displays a TreeModel<T> as an indented tree.
Internally each view owns a TreeSlice for independent
expand state, so two TreeViews on the same model can be open at different
depths simultaneously. Only rows in the visible viewport + a small buffer have
live widgets — rows outside the buffer are dormant, matching ListView's
virtualization model. An external TreeDataSource
is also accepted via TreeView::from_source when the data lives outside a
TreeModel.
Row heights come in three modes: uniform (item_height, default fast path),
exact per-flat-index callback (item_height_fn), and auto-measured
(auto_item_height — height-for-width per row, scroll-anchored).
Example
#![allow(unused)] fn main() { use teksilo_widgets::TreeView; use teksilo_widgets::primitives::{HStack, Padding, TextWidget}; use teksilo_data::TreeModel; use teksilo_i18n::lit; struct Item { title: String } let tree_model: TreeModel<Item> = TreeModel::new(); let _w = TreeView::new(tree_model, |item, entry, _selected| { let indent = entry.depth as f32 * 20.0; Box::new(HStack::new() .child(Padding::new(0.0, 0.0, 0.0, indent)) .child(TextWidget::new(lit!(&item.title)))) }) .item_height(28.0); }
Builder methods at a glance
toggle_callback, slice_handle, node_id
API reference
📖 Full rustdoc API for this module
pub struct TreeRowContext
Per-row context passed to a 4-arg TreeView delegate. Carries a
reference to the slice handle and the row's NodeId so the
delegate can wire chevron toggles and other tree-aware behavior
without manually cloning state outside the closure.
Created internally by TreeView::new_with_context. Not
constructed directly by user code.
#![allow(unused)] fn main() { pub struct TreeRowContext<'a, T: 'static> { /* fields */ } }
Methods
pub fn toggle_callback(&self) -> std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>
Toggle callback for this row's chevron. Wires in one line:
.on_toggle_rc(ctx.toggle_callback()).
pub fn slice_handle(&self) -> TreeSliceHandle<T>
Cloned handle to the slice — call .toggle_expand(node),
.expand(node), .collapse(node) directly.
pub fn node_id(&self) -> teksilo_data::NodeId
The NodeId of this row in the backing TreeModel.
pub struct TreeView
#![allow(unused)] fn main() { pub struct TreeView<T: 'static> { /* fields */ } }
TwistArrow

TwistArrow — a small chevron that indicates and toggles a tree node's expansion.
Renders a right-pointing arrow when collapsed and a down-pointing arrow when
expanded; a leaf node (where has_children is false) paints nothing but
reserves its slot so the indent column stays aligned across all rows.
The glyph flips direction under right-to-left layout.
Accessibility-decorative: the chevron hides itself from the AT tree and
the parent row's node owns set_expanded.
// TwistArrow is typically instantiated by TreeView row delegates and requires
// an EventContext to wire the tap callback. The snippet below shows the
// construction pattern used inside a custom tree-row build().
let arrow = TwistArrow::new(16.0, true, false)
.on_click(|ctx| ctx.send_intent(teksilo_core::Intent::new("tree.toggle")));
Builder methods at a glance
color, on_click
API reference
📖 Full rustdoc API for this module
pub struct TwistArrow
Small interactive chevron rendered in the leading indent column of a tree row.
#![allow(unused)] fn main() { pub struct TwistArrow { /* fields */ } }
Methods
pub fn new(size: f32, has_children: bool, expanded: bool) -> Self
Construct a chevron. size is the square side length in logical pixels;
has_children determines whether the glyph is painted; expanded
determines the glyph direction (down = expanded, right/left = collapsed).
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Override the glyph colour. Accepts a Color, a TextRole, or a
Signal of either.
The default TextRole::Secondary is a muted grey, which is right on
every row that is not filled. It is not right on one that is: a
design language whose selected row is a solid accent capsule flips
its label to TextRole::OnAccent through
StandardItemStyle::selected_label_role, and a chevron left behind
at Secondary then sits on that capsule at roughly 2.5:1 — under
WCAG SC 1.4.11's 3:1 floor, and visibly wrong beside a white label.
StandardTreeItem passes the row's own label role here so the two
always move together.
pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self
Install a tap handler. Receives the firing EventContext
so consumers can dispatch intents (e.g. lazy-load children on
expand) or open dialogs from the chevron toggle.
Unroll
Unroll — the horizontal sibling of Collapse.
Animates a child's width between zero and natural while the child
keeps its full natural layout — the framework's clip pass crops the
overflow, so the visible reveal tracks progress linearly across the
whole duration and the child never reflows mid-animation. This is the
same "lay out full, clip the shrinking axis" trick the docking
Splitter uses for its side expand/collapse (ClipPane).
Two drivers:
Unroll::new(expanded)— self-animated, likeCollapse. Flips between 0 and natural width overMotionTokens::duration_collapsewheneverexpandedtoggles.Unroll::from_progress(progress)— driven by an external animatedSignal<f32>∈ [0, 1]. Use when something else owns the tween — e.g. an overlay whose deferred dismissal rolls the width back into its anchor before going dormant.
The reveal edge is chosen with reveal_from:
UnrollFrom::Leading (default) keeps the leading edge pinned and
grows trailing-ward — the "slide out from a button on the left"
shape; UnrollFrom::Trailing mirrors it.
Honors prefers-reduced-motion: the self-animated driver snaps to
its end value instead of tweening (the external driver's owner is
responsible for its own reduced-motion policy).
#![allow(unused)] fn main() { use teksilo_widgets::animations::{Unroll, UnrollFrom}; use teksilo_widgets::primitives::TextWidget; use teksilo_core::signal::Signal; use teksilo_i18n::lit; let expanded = Signal::new(false); let _w = Unroll::new(expanded) .reveal_from(UnrollFrom::Leading) .child(TextWidget::new(lit!("Reveal me"))); }
Builder methods at a glance
from_progress, child, child_id, reveal_from, progress_signal
API reference
📖 Full rustdoc API for this module
pub enum UnrollFrom
Which edge stays anchored as the child unrolls.
#![allow(unused)] fn main() { pub enum UnrollFrom { /* variants */ } }
Variants
Leading— Pin the leading edge; reveal trailing-ward (default).Trailing— Pin the trailing edge; reveal leading-ward.
pub struct Unroll
Wraps a child widget and reveals or hides it along the horizontal axis by animating the wrapper's reported width between zero and the child's natural width. See the module docs for the two available drivers.
#![allow(unused)] fn main() { pub struct Unroll { /* fields */ } }
Methods
pub fn new(expanded: Signal<bool>) -> Self
Self-animated wrapper bound to expanded. Initially rolled up
iff expanded.get() is false at the first build().
pub fn from_progress(progress: Signal<f32>) -> Self
Externally-driven wrapper. progress (an animated 0..1 signal)
is read every layout; the caller owns the tween. Use when an
overlay or other coordinator drives the reveal lifecycle.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Inline child widget (deferred insertion).
pub fn child_id(mut self, id: WidgetId) -> Self
Pre-registered child by WidgetId.
pub fn reveal_from(mut self, from: UnrollFrom) -> Self
Set the edge that stays anchored as the child unrolls. Defaults
to UnrollFrom::Leading.
pub fn progress_signal(&self) -> Option<Signal<f32>>
Return the live progress signal (0.0 = rolled up, 1.0 = fully
unrolled). Returns None before the first build(). Useful for
tests and external coordinators that need to observe or gate on
the current animated progress.
ValidationStrip

ValidationStrip — a small inline message shown below a text field to surface a validation outcome.
Bound to a Signal<ValidationFeedback> produced by a
TextInputField. The strip
renders nothing when the feedback is Pristine or Valid, and shows a
single-line message in the appropriate role when Invalid (error colour,
Live::Assertive) or Corrected (secondary text, Live::Polite).
The strip is layout-stable: in the hidden state it reports zero height so
the surrounding layout does not reflow on every commit.
It carries Role::Status so screen readers announce the message through
the appropriate live region without any composite-side wiring.
// ValidationStrip is constructed with a `Signal<ValidationFeedback>`
// obtained from a live `TextInputField` — it needs BuildContext to wire up.
// Typical usage inside a composing widget's build():
let (field_id, fb_signal) = build_text_input_field(ctx, ...);
let strip = ctx.add(ValidationStrip::new(fb_signal));
API reference
📖 Full rustdoc API for this module
pub struct ValidationStrip
Inline validation-feedback strip. See module docs.
#![allow(unused)] fn main() { pub struct ValidationStrip { /* fields */ } }
Methods
pub fn new(feedback: Signal<ValidationFeedback>) -> Self
Construct a strip bound to a feedback signal — typically
field.validation_feedback_signal() from the same widget.
VStack

VStack — a vertical layout container that distributes children top-to-bottom.
Each child is offered the full container width and its intrinsic preferred
height. Positive slack (container height minus the sum of children heights
minus spacing) is distributed among children that declare a non-zero flex
weight (e.g. Expand). Over-constraint
deficits are absorbed by children with a non-zero shrink weight.
Cross-axis (horizontal) alignment defaults to Leading and can be
overridden per container with VStack::alignment or per child via
WidgetTree::set_alignment.
Use VStack when children should be stacked vertically with a configurable
gap; use HStack for the horizontal
counterpart.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{VStack, TextWidget}; use teksilo_i18n::lit; let _col = VStack::new() .spacing(8.0) .child(TextWidget::new(lit!("Heading"))) .child(TextWidget::new(lit!("Body text"))); }
Builder methods at a glance
spacing, alignment, add_child, child, children, child_opt
API reference
📖 Full rustdoc API for this module
pub struct VStack
Vertical layout container that distributes children top-to-bottom
based on their intrinsic sizes. Cross-axis alignment is controlled
by HAlignment (default: Leading).
#![allow(unused)] fn main() { pub struct VStack { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty vertical stack with Leading alignment and zero spacing.
pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self
Set inter-child spacing. Accepts a static f32 or a reactive
Signal<f32>.
pub fn alignment(mut self, alignment: HAlignment) -> Self
Set the cross-axis (horizontal) alignment applied to every child that
does not have a per-child override set via WidgetTree::set_alignment.
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child by ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple inline children from an iterator.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Conditionally add a child. No-op if None.
WindowControls
The minimize / maximize / close button cluster on the trailing edge of
a TitleBar. Rendered only when
PlatformTitleBarHost::renders_custom_controls is true
(Windows + Wayland; never on macOS).
These are deliberately NOT built on top of the regular Button widget:
Button carries a 72 dp minimum width, themed padding, focus ring and
border, none of which are appropriate for a flush-fitting Win11-style
window control. Instead, each control is a small composing widget
ControlButton built from primitives (FixedSize + ZStack +
RectWidget + Center + TextWidget) so we inherit centering, theming and
reactive hover for free.
For M2 the maximize/restore swap is not implemented — the maximize
button always shows the □ glyph. M3+ will add a Signal<bool>-driven
glyph swap once the host can update it from WindowEvent::Resized.
API reference
📖 Full rustdoc API for this module
pub struct WindowControlsLayout
Layout snapshot that WindowControls exports to its parent TitleBar
so the after_paint aggregator can read the per-button WidgetIds.
Populated during WindowControls::build.
The maximize slot is the Switcher that wraps the two glyph
buttons (□ / ❐), not either child directly: the inactive
Switcher child is dormant and has Rect::ZERO bounds, but the
Switcher container itself is always laid out by the parent
HStack and has valid bounds. A synthetic tap dispatched at the
Switcher's bounds-center routes through hit-testing to whichever
child is currently visible.
#![allow(unused)] fn main() { pub struct WindowControlsLayout { /* fields */ } }
pub type ControlAction
Action invoked when a ControlButton is tapped.
#![allow(unused)] fn main() { pub type ControlAction = Rc<dyn Fn(&mut EventContext)>; }
pub struct ControlButton
A compact, flush-fitting window-control button.
Composes existing primitives — a FixedSize cell wrapping a ZStack
of (hover background, centred glyph). Hover state is tracked in a
Signal<bool> that drives a derived Signal<SurfaceRole> background,
so a hover change repaints with no relayout. Both the glyph color
(fg) and the hover surface are stored as roles (ColorProp /
SurfaceRole) that resolve against the current theme at paint time —
so the cluster retints live across ctx.set_theme(...) without a
rebuild.
#![allow(unused)] fn main() { pub struct ControlButton { /* fields */ } }
Methods
pub fn new(glyph: &'static str, width: f32, height: f32, fg: impl Into<ColorProp>) -> Self
Create a control button with the given Unicode glyph, fixed cell dimensions, and
foreground color role. The hover background defaults to transparent until overridden
via hover_background.
pub fn hover_background(mut self, role: SurfaceRole) -> Self
Set the surface role painted over the title bar background while the pointer is inside
the button cell. The default is SurfaceRole::Transparent (flat).
pub fn on_tap(mut self, action: impl Fn(&mut EventContext) + 'static) -> Self
Register the callback invoked when the user taps this button.
pub struct WindowControls
The minimize / maximize / close cluster, laid out as an HStack of
ControlButtons. Each cell forwards taps to the supplied host.
#![allow(unused)] fn main() { pub struct WindowControls { /* fields */ } }
Methods
pub fn new( host: Rc<dyn PlatformTitleBarHost>, show_restore: Signal<bool>, close_action: Option<CloseAction>, ) -> Self
Build the minimize / maximize / close cluster for the given platform host.
show_restore drives the maximize ↔ restore swap: true renders the
Restore affordance (a11y name and action), false the Maximize
one. It is deliberately not called is_maximized: a window is also
restorable — and must not offer "maximize" — while it is
WindowPlacement::Fullscreen,
which WindowPlacement::is_maximized reports as false. See
crate::title_bar::TitleBar's own derivation.
close_action overrides the default ctx.close_window() behaviour (e.g.
to show a "save before closing?" dialog).
WindowFrame
A borderless-window frame: an invisible overlay of resize strips and corner cells along the four edges of a single content widget.
WindowFrame is the canonical way to wrap a TitleBar + body for an
undecorated Wayland window. The content child fills the entire window
bounds — there is no visible padding — and the resize strips +
corners sit on top of the content along the edges. teksilo-core's
hit_test_recursive walks children in reverse insertion order, so
the strips and corners (added after content) get first crack at any
click that lands within thickness pixels of an edge; clicks
anywhere else fall through to the content.
Layout (with thickness = t):
┌─top─edge───────────────────────┐ ← top strip overlays content (0, 0, w, t)
│TL│ │TR│ ← corners overlay the strip ends
│──│ │──│
│L │ content (full) │R │ ← content fills (0, 0, w, h)
│──│ │──│
│BL│ │BR│
└─bottom─edge────────────────────┘
t defaults to 6 logical pixels but is configurable via
WindowFrame::thickness. With a small thickness the frame is
visually undetectable; the cursor only changes (and the resize
gesture only triggers) when the pointer is within t pixels of the
window boundary.
Builder methods at a glance
thickness, content, content_boxed, content_id
API reference
📖 Full rustdoc API for this module
pub struct WindowFrame
Invisible overlay of resize strips and corner cells that gives a borderless window
draggable edges. The content child fills the full client area with no visible inset;
the strips are hit-test-only overlays along the outer thickness pixels.
#![allow(unused)] fn main() { pub struct WindowFrame { /* fields */ } }
Methods
pub fn new(host: Rc<dyn PlatformTitleBarHost>) -> Self
Create a frame bound to the given platform host. Use thickness
and content to configure it before adding to the tree.
pub fn thickness(mut self, t: f32) -> Self
Logical-pixel thickness of each resize strip. Default: 6.
pub fn content(mut self, w: impl Widget + 'static) -> Self
Set the inner content widget — typically a VStack containing a
TitleBar and the application body.
pub fn content_boxed(mut self, w: Box<dyn Widget>) -> Self
Set the inner content widget from an already-boxed value. Prefer content
for unboxed widgets; use this variant when the concrete type is not known at the call site.
pub fn content_id(mut self, id: WidgetId) -> Self
Set the inner content widget by its already-registered WidgetId. Use when the content
was added to the tree before the frame was constructed and you need to retain its id.
Wizard
Wizard — a thin modal launcher around Stepper.
Renders as a button (or a custom .trigger(...) widget) that opens a modal
containing a Stepper built from the same Steps. The modal's Cancel and
a wrapped Finish both dismiss it.
Builder methods at a glance
step, steps, variant, enabled, non_linear, presentation, close_behavior, size, back_label, next_label, finish_label, skip_label, cancel_label, on_finish, trigger
API reference
📖 Full rustdoc API for this module
pub struct Wizard
A button (or custom trigger) that opens a modal Stepper.
Wizard::new(label) renders as a Filled Button whose tap opens a
full-screen modal containing a Stepper built from the same Steps.
The modal's auto-injected Cancel button and the wrapped Finish both dismiss
it. Override the trigger with trigger to use any widget
instead of the default button.
#![allow(unused)] fn main() { pub struct Wizard { /* fields */ } }
Methods
pub fn new(label: impl Into<LocalizedString>) -> Self
Create a wizard trigger button with the given label. The label is also used as the modal title.
pub fn step(mut self, step: Step) -> Self
Append a single Step to the wizard.
pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self
Append multiple Steps from an iterator.
pub fn variant(mut self, variant: ButtonVariant) -> Self
Set the visual variant of the trigger button (default Filled).
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self
Enable or disable the trigger button, statically or reactively. When disabled, tapping or pressing the trigger is a no-op.
pub fn non_linear(mut self, non_linear: bool) -> Self
Allow jumping between steps by clicking their indicators (the
markers become Role::Tab). Default: linear.
pub fn presentation(mut self, presentation: ModalPresentation) -> Self
Control how the modal is presented (auto, sheet, full-screen, …).
pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self
Control how the modal is dismissed (manual, click-outside, …).
pub fn size(mut self, width: u32, height: u32) -> Self
Set the preferred modal size in logical pixels. Default 640 × 460.
pub fn back_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Back" button label inside the modal. Default: "Back".
pub fn next_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Next" button label inside the modal. Default: "Next".
pub fn finish_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Finish" button label inside the modal. Default: "Finish".
pub fn skip_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Skip" button label inside the modal. Default: "Skip".
pub fn cancel_label(mut self, label: impl Into<LocalizedString>) -> Self
Override the "Cancel" button label inside the modal. Default: "Cancel".
pub fn on_finish<R: IntoFinishOutcome>( mut self, action: impl Fn(&mut EventContext, &StepperController) -> R + 'static, ) -> Self
Called when Finish is activated on the last step.
The callback may refuse: its return value goes through
IntoFinishOutcome (() always succeeds; false / Err(_) /
FinishOutcome::Rejected do not). A rejected finish leaves the modal
open on the last step and marks it
StepStatus::Error — the right response to
a commit that failed.
pub fn trigger(mut self, trigger: impl Widget + 'static) -> Self
Wrap

Wrap — a horizontal flow layout that wraps children to the next line when they exceed the available width.
Children are placed left-to-right (or right-to-left under RTL layout) and
wrapped to the next line when the next item would exceed the container
width. Each line's height is the tallest child on that line. Use
spacing for the horizontal gap between items and
line_spacing for the vertical gap between lines.
Wrap is the right choice for chip rows, badge lists, and any collection
whose items vary in width and should reflow as the container resizes. For
a fixed grid use crate::primitives::Grid instead.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{Wrap, TextWidget}; use teksilo_i18n::lit; let _chips = Wrap::new() .spacing(8.0) .line_spacing(6.0) .child(TextWidget::new(lit!("Rust"))) .child(TextWidget::new(lit!("GUI"))) .child(TextWidget::new(lit!("Desktop"))); }
Builder methods at a glance
spacing, line_spacing, add_child, child, children, child_opt
API reference
📖 Full rustdoc API for this module
pub struct Wrap
A horizontal flow layout that wraps children to the next line.
#![allow(unused)] fn main() { pub struct Wrap { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty Wrap container with zero spacing.
pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self
Horizontal spacing between items on the same line. Accepts a static
f32 or a reactive Signal<f32>.
pub fn line_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self
Vertical spacing between lines. Accepts a static f32 or a
reactive Signal<f32>.
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child by ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple inline children from an iterator.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Conditionally add a child. No-op if None.
ZStack

ZStack — a layout container that layers children on top of each other.
The container sizes itself to the maximum width and maximum height across
all children, measured at an unspecified proposal so background rects do not
inflate the size. Height additionally takes a width-bounded query when the
parent bound the width, so a wrapping child reports the height it will really
occupy rather than a single line; see layout_response for why that query is
width-only. Each child is then offered the full container bounds and
positioned according to the container-level Alignment (default: CENTER);
individual children can override alignment via WidgetTree::set_alignment.
The primary use-cases are layered UIs — a background RectWidget beneath
a TextWidget, a floating badge over a button icon — and card-like
compositions where a paint layer and a content layer share the same bounds.
Children that expand to fill their proposal (e.g. RectWidget) fill the
full ZStack area; children with fixed intrinsic sizes are positioned by
alignment.
Propagates shrink weight and minimum size when any child opts in, so
wrapping a shrinkable single-line label in a ZStack stays shrinkable.
#![allow(unused)] fn main() { use teksilo_widgets::primitives::{ZStack, TextWidget}; use teksilo_widgets::RectWidget; use teksilo_i18n::lit; use teksilo_tokens::SurfaceRole; let _card = ZStack::new() .child(RectWidget::new().background(SurfaceRole::Raised)) .child(TextWidget::new(lit!("Hello"))); }
Builder methods at a glance
alignment, add_child, child, children, child_opt
API reference
📖 Full rustdoc API for this module
pub struct ZStack
A layout container that stacks children on top of each other.
Size = max of children sizes. Children are positioned according to
the container's Alignment (default: center), with per-child overrides.
#![allow(unused)] fn main() { pub struct ZStack { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty ZStack with center alignment.
pub fn alignment(mut self, alignment: Alignment) -> Self
Set the alignment applied to every child that does not have a
per-child override set via WidgetTree::set_alignment.
pub fn add_child(mut self, id: WidgetId) -> Self
Add a pre-registered child by ID.
pub fn child(mut self, widget: impl Widget + 'static) -> Self
Add an inline child widget (deferred insertion).
pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self
Add multiple inline children from an iterator.
pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self
Conditionally add a child. No-op if None.
Data Collections
Every public type in teksilo-data, grouped by category. Each page links to its full rustdoc API reference.
Models
- ChartAggregate —
ChartAggregate<T>— a bucket/rollup projection over acrate::ChartModel - ChartChange — ChartChange — change notifications and stable series identifiers for chart collections
- ChartModel —
ChartModel<T>— concrete reactive multi-series chart data model - ChartSelection —
ChartSelection— point-level selection state for chart widgets - ChartWindow —
ChartWindow<T>— a "last N points per series" streaming projection over - CheckedModel —
CheckedModel— per-row checkbox state for flat collection widgets - CheckState —
CheckState— tri-state checkbox value shared by the data layer and widgets - DataChange —
DataChange— change notifications for flat collections - ItemKey — Shared capability types for the data-source drag-and-drop + lazy protocol
- KeyedSelectionModel —
KeyedSelectionModel<K>— identity-based selection for collection widgets - KeyedTreeCheckedModel —
KeyedTreeCheckedModel<K>— per-node checkbox state for a tree **keyed by a - ListDataSource —
ListDataSource— read-and-command interface for a flat collection behind aListView/ - ListModel —
ListModel<T>— concrete reactive list backed by aVec<T> - SelectionModel — SelectionModel — index-based selection state for collection widgets
- SeriesPattern —
SeriesPattern— the non-colour channel that identifies a chart series - SortFilterListModel — Composable sort + filter projection over a flat list source
- SortFilterTreeModel — Composable sort + filter projection over a hierarchical tree
- TreeChange — TreeChange — change notifications and stable node identifiers for tree collections
- TreeCheckedModel —
TreeCheckedModel— per-node checkbox state for a tree, with optional - TreeDataSlice —
TreeDataSlice— the reusableTreeDataSourceengine for an **external, - TreeDataSource —
TreeDataSource— read-and-command interface for hierarchical data behind a - TreeModel —
TreeModel— concrete reactive tree with shared, cloneable handles - TreeRowFilter —
TreeRowFilter— sort + tree-aware filter over aTreeRowstream - TreeSlice —
TreeSlice— per-view flattened projection of aTreeModel
ChartAggregate
ChartAggregate<T> — a bucket/rollup projection over a crate::ChartModel.
Wraps a ChartModel<T> and exposes each series
reduced into fixed-size buckets of bucket_size source points, each
bucket collapsed to one crate::ChartDatum via a ChartAggregateFn
(Mean / Sum / Min / Max / First / Last / Custom) — the
"downsample a long series for display" pattern (a year of daily
sensor readings shown as weekly means, a tick feed shown as 1-minute
bars). Bucket b covers source indices [b*bucket_size, min((b+1)*bucket_size, n)); a trailing partial bucket is included. A
bucket's category is its first member's category.
Unlike crate::ChartWindow (which reads straight through to the
source), ChartAggregate materializes its buckets — a bucket's
category is a clone of a source point's category, so constructing or
rebuilding a ChartAggregate<T> requires T: Clone. Once built,
read-only queries (point_count, with_point, …) need only T: 'static.
Reactivity
A tail append that doesn't change the bucket count updates the
now-not-yet-full last bucket in place (PointUpdated); a tail append
that starts a new bucket finalizes the previous last bucket
(PointUpdated) and appends the new one(s) (PointsInserted).
Symmetrically, a tail removal that doesn't eliminate the last bucket
recomputes it in place (PointUpdated, since it lost some of its
points); one that eliminates one or more trailing buckets recomputes the
new last bucket the same way and then drops the buckets beyond it
(PointsRemoved). A mid-series insert or removal (front or interior)
falls back to a full per-series rebuild reported as
SeriesDataReplaced. A PointUpdated recomputes just its own bucket.
use teksilo_data::{ChartModel, ChartAggregate, ChartAggregateFn};
let model: ChartModel<i32> = ChartModel::new();
let s = model.add_series("daily");
for i in 0..70 {
model.push_point(s, i, i as f32);
}
let weekly = ChartAggregate::new(model, 7, ChartAggregateFn::Mean);
assert_eq!(weekly.point_count(s), 10); // 70 / 7
Builder methods at a glance
set_bucket_size, set_aggregate_fn, bucket_size, series_count, series_ids, point_count, with_series, with_point, observe_changes, first_changed_index
API reference
📖 Full rustdoc API for this module
pub enum ChartAggregateFn
A reduction applied to the numeric values within one bucket.
#![allow(unused)] fn main() { pub enum ChartAggregateFn { /* variants */ } }
Variants
Mean— Arithmetic mean of the bucket's values (0.0for an empty bucket).Sum— Sum of the bucket's values.Min— Smallest value in the bucket.Max— Largest value in the bucket.First— The bucket's first value.Last— The bucket's last value.Custom— A caller-supplied reduction.
Methods
pub fn apply(&self, values: &[f32]) -> f32
Apply the reduction to a bucket's values.
On an empty slice, every built-in variant returns 0.0
(Mean/Min/Max/First/Last) or the empty sum (Sum, also
0.0) — a uniform, unsurprising convention rather than Min/Max
leaking their fold seed (±INFINITY) into a chart value. Custom
returns whatever the supplied closure computes for &[]. No internal
caller actually passes an empty slice — compute_bucket_datum bails
out before calling apply for an empty bucket — so this only bites a
direct caller.
pub struct ChartAggregate
A bucket/rollup projection over a ChartModel<T>.
See the module documentation for semantics.
#![allow(unused)] fn main() { pub struct ChartAggregate<T: 'static> { /* fields */ } }
Methods
pub fn new(source: ChartModel<T>, bucket_size: usize, aggregate_fn: ChartAggregateFn) -> Self
Wrap source, bucketing every series into groups of bucket_size
source points reduced via aggregate_fn. bucket_size is clamped
to a minimum of 1.
pub fn set_bucket_size(&self, bucket_size: usize)
Change the bucket size, rebuilding every series and emitting
ChartChange::Reset. Clamped to a minimum of 1.
pub fn set_aggregate_fn(&self, aggregate_fn: ChartAggregateFn)
Change the aggregate reduction, rebuilding every series and emitting
ChartChange::Reset.
pub fn bucket_size(&self) -> usize
The configured bucket size.
pub fn series_count(&self) -> usize
Number of series (same set as the source).
pub fn series_ids(&self) -> Vec<SeriesId>
The series ids, in the source's display order.
pub fn point_count(&self, series: SeriesId) -> usize
Number of buckets currently materialized for series.
pub fn with_series<R>( &self, series: SeriesId, f: impl FnOnce(&str, Option<&ColorProp>, bool) -> R, ) -> Option<R>
Access a series' metadata (delegates straight through to the
source). Returns None if series is unknown.
pub fn with_point<R>( &self, series: SeriesId, index: usize, f: impl FnOnce(&ChartDatum<T>) -> R, ) -> Option<R>
Access the bucket at index within series. Returns None if
series or index is unknown.
pub fn observe_changes(&self, f: impl Fn(&ChartChange) + 'static) -> ObserverHandle
Register an observer for translated bucket changes. Returns an
ObserverHandle — dropping it removes the callback.
pub fn first_changed_index(&self, series: SeriesId) -> Option<usize>
First bucket index of series whose content may differ since the
latest translated change. None if series is unknown or
unaffected yet.
ChartChange
ChartChange — change notifications and stable series identifiers for chart collections.
SeriesId is an opaque, stable handle for a series in a crate::ChartModel.
Because ChartModel is backed by a slotmap, SeriesId values survive arbitrary
series insertions, removals, and reorders — only removing the series itself
invalidates it. ChartChange describes exactly what mutated (at the series
level or the point level within a series) so that projections
(ChartWindow, ChartAggregate) and consumers (ChartSelection) can refresh
or adjust incrementally instead of rebuilding from scratch.
Consumers typically receive ChartChange values through an observer
registered via crate::ChartModel::observe_changes, which fires
synchronously (before the registering call returns) after each mutation.
// ChartModel::observe_changes returns an ObserverHandle whose drop
// unregisters the callback — keep it alive for the observer's lifetime.
use teksilo_data::{ChartModel, ChartChange};
let model: ChartModel<String> = ChartModel::new();
let _handle = model.observe_changes(|change| {
println!("{change:?}");
});
model.add_series("Revenue");
// prints: SeriesInserted { index: 0, series: SeriesId(...) }
API reference
📖 Full rustdoc API for this module
pub struct SeriesId
Opaque identifier for a series in a ChartModel.
SeriesId values are stable across mutations — inserting or removing
other series does not invalidate existing SeriesId handles (they are
SlotMap keys).
#![allow(unused)] fn main() { pub struct SeriesId(slotmap::DefaultKey); }
pub enum ChartChange
Describes a mutation to a chart's series or point data. Emitted by
ChartModel<T> automatically.
#![allow(unused)] fn main() { pub enum ChartChange { /* variants */ } }
Variants
SeriesInserted— A series was inserted at the given index.SeriesRemoved— A series (and all of its points) was removed.SeriesMoved— A series was moved to a new position among its siblings.SeriesRenamed— A series' display name changed.SeriesColorChanged— A series' explicit color changed (set or cleared). The only variant that bumpscrate::ChartModel::style_versionrather thancrate::ChartModel::structure_version.SeriesPatternChanged— A series' explicitSeriesPatternchanged (set or cleared). Paint-only, likeSeriesColorChanged: it bumpscrate::ChartModel::style_version, notstructure_version.SeriesVisibilityChanged— A series' visibility flag changed.PointsInserted— Points were inserted;rangeholds the indices of the newly inserted points withinseries.PointsRemoved— Points were removed;rangeholds the indices they occupied before removal withinseries.PointUpdated— A single point's data changed in place without any structural shift.SeriesDataReplaced— A series' entire point list was replaced; consumers must discard cached state for that series and rebuild it.Reset— The entire chart was replaced. Consumers should discard all state and rebuild.
ChartModel
ChartModel<T> — concrete reactive multi-series chart data model.
ChartModel<T> owns an ordered collection of named series, each holding a
Vec<ChartDatum<T>> (a category: T paired with a numeric value: f32),
in a flat SlotMap arena — the same shape as crate::TreeModel. Every
mutation (series add/remove/move/rename/recolor/show-hide, point
push/insert/remove/update/replace) emits a ChartChange to all
registered observers and bumps one of two reactive version signals:
ChartModel::style_version (color changes only — a paint-only signal a
chart can bind at BindingLevel::RepaintOnly) or
ChartModel::structure_version (everything else — series/point shape,
bound at BindingLevel::Relayout/Rebuild). Series identity is a stable,
versioned SeriesId (a SlotMap key) that is never reused after removal.
Cloning produces a second handle to the same data — all handles share
series/points and receive the same change notifications. Register
observers via observe_changes; the
returned ObserverHandle is RAII — dropping it unregisters the callback.
For a bounded "last N points" streaming view use
ChartWindow. For bucketed/rolled-up display use
ChartAggregate. For point-level selection use
ChartSelection.
#![allow(unused)] fn main() { use teksilo_data::{ChartModel, ChartSeries, ChartDatum}; let model = ChartModel::from_series_vec(vec![ ChartSeries::new("Revenue").data(vec![ ChartDatum::new("Q1".to_string(), 10.0), ChartDatum::new("Q2".to_string(), 20.0), ]), ]); assert_eq!(model.series_count(), 1); let s = model.series_id_at(0).unwrap(); assert_eq!(model.point_count(s), 2); model.push_point(s, "Q3".to_string(), 30.0); assert_eq!(model.point_count(s), 3); }
Builder methods at a glance
from_series_vec, from_points, only_series, add_series, insert_series, remove_series, rename_series, set_series_color, clear_series_color, set_series_pattern, clear_series_pattern, set_series_visible, move_series, clear, push_point, insert_point, remove_point, update_point, replace_series_data, series_count, series_ids, series_id_at, series_index_of, point_count, with_series, with_point, with_series_view, with_all_series, structure_version, style_version, observe_changes, debug_named
API reference
📖 Full rustdoc API for this module
pub struct ChartDatum
One numeric data point at a category/x-axis position, with an optional per-point color that overrides the series color (bar charts only).
#![allow(unused)] fn main() { pub struct ChartDatum<T> { /* fields */ } }
Methods
pub fn new(category: T, value: f32) -> Self
pub fn with_color(mut self, color: impl Into<ColorProp>) -> Self
Override this point's color (a bar's fill). Ignored by line/pie charts, which color by series.
pub struct ChartSeries
A named series of data points with an optional explicit color and a
visibility flag, used to construct a ChartModel (via
ChartModel::from_series_vec) or to describe one series' desired
shape. Unlike the model, visible here is a plain bool — reactivity
lives in the model's ChartModel::structure_version /
ChartModel::style_version signals, not in this construction DTO.
#![allow(unused)] fn main() { pub struct ChartSeries<T> { /* fields */ } }
Methods
pub fn new(name: impl Into<String>) -> Self
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
pub fn pattern(mut self, pattern: SeriesPattern) -> Self
Pin this series' non-colour channel instead of taking the one its
position implies. See SeriesPattern.
pub fn visibility(mut self, visible: bool) -> Self
pub fn push(&mut self, category: T, value: f32)
pub fn data(mut self, points: Vec<ChartDatum<T>>) -> Self
pub struct SeriesView
A read-only, borrowed view over one series — returned by
ChartModel::with_series_view / ChartModel::with_all_series.
#![allow(unused)] fn main() { pub struct SeriesView<'a, T> { /* fields */ } }
pub struct ChartModel
A concrete reactive multi-series chart data model.
ChartModel<T> is Clone — cloning produces a second handle to the same
data. Multiple charts can hold clones and all see the same series and
points, and receive the same ChartChange notifications.
#![allow(unused)] fn main() { pub struct ChartModel<T: 'static> { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty chart model with no series.
pub fn from_series_vec(series: Vec<ChartSeries<T>>) -> Self
Build a model directly from a vector of ChartSeries DTOs — the
primary constructor. Populates the arena in one pass with no
per-item notification (mirrors crate::ListModel::from_vec).
pub fn from_points(points: Vec<ChartDatum<T>>) -> Self
Build a model with a single anonymous, visible series holding
points — the flat/pie-chart path where series structure doesn't
matter.
pub fn only_series(&self) -> Option<SeriesId>
The model's sole series id, iff it has exactly one series.
pub fn add_series(&self, name: impl Into<String>) -> SeriesId
Append a new, empty, visible series named name.
pub fn insert_series(&self, index: usize, name: impl Into<String>) -> SeriesId
Insert a new, empty, visible series named name at index.
Panics
Panics if index > series_count().
pub fn remove_series(&self, series: SeriesId)
Remove a series and all of its points.
Panics
Panics if series is unknown.
pub fn rename_series(&self, series: SeriesId, name: impl Into<String>)
Rename a series. A no-op (no notify, no version bump) if name
already matches the current value.
Panics
Panics if series is unknown.
pub fn set_series_color(&self, series: SeriesId, color: impl Into<ColorProp>)
Set a series' explicit color. Bumps Self::style_version (not
Self::structure_version) — this is a paint-only change. A no-op
(no notify, no version bump) if color already matches the current
value.
Panics
Panics if series is unknown.
pub fn clear_series_color(&self, series: SeriesId)
Clear a series' explicit color (falls back to the chart's palette).
Bumps Self::style_version. A no-op (no notify, no version bump)
if the series already has no explicit color.
Panics
Panics if series is unknown.
pub fn set_series_pattern(&self, series: SeriesId, pattern: SeriesPattern)
Set a series' explicit SeriesPattern — the non-colour channel that
identifies it. Bumps Self::style_version (paint-only), like
set_series_color. A no-op if unchanged.
Panics
Panics if series is unknown.
pub fn clear_series_pattern(&self, series: SeriesId)
Clear a series' explicit pattern, falling back to the one its position
implies. Bumps Self::style_version. A no-op if already unset.
Panics
Panics if series is unknown.
pub fn set_series_visible(&self, series: SeriesId, visible: bool)
Show or hide a series. A no-op (no notify, no version bump) if
visible already matches the current value.
Panics
Panics if series is unknown.
pub fn move_series(&self, series: SeriesId, to: usize)
Move a series to a new position among its siblings. A no-op (no
notify, no version bump) if to is already the series' position.
Panics
Panics if series is unknown or to is out of bounds.
pub fn clear(&self)
Remove every series.
pub fn push_point(&self, series: SeriesId, category: T, value: f32)
Append a point to the end of series.
Panics
Panics if series is unknown.
pub fn insert_point(&self, series: SeriesId, index: usize, category: T, value: f32)
Insert a point at index within series.
Panics
Panics if series is unknown or index > point_count(series).
pub fn remove_point(&self, series: SeriesId, index: usize) -> ChartDatum<T>
Remove and return the point at index within series.
Panics
Panics if series is unknown or index >= point_count(series).
pub fn update_point(&self, series: SeriesId, index: usize, category: T, value: f32)
Replace the point at index within series.
Panics
Panics if series is unknown or index >= point_count(series).
pub fn replace_series_data(&self, series: SeriesId, points: Vec<ChartDatum<T>>)
Replace series' entire point list.
Panics
Panics if series is unknown.
pub fn series_count(&self) -> usize
Number of series.
pub fn series_ids(&self) -> Vec<SeriesId>
The series ids, in display order.
pub fn series_id_at(&self, index: usize) -> Option<SeriesId>
The series id at index, if any.
pub fn series_index_of(&self, series: SeriesId) -> Option<usize>
The display index of series, if it exists.
pub fn point_count(&self, series: SeriesId) -> usize
Number of points in series (0 if unknown).
pub fn with_series<R>( &self, series: SeriesId, f: impl FnOnce(&str, Option<&ColorProp>, bool) -> R, ) -> Option<R>
Access a series' metadata (name, color, visibility) via a callback.
Returns None if series is unknown.
pub fn with_point<R>( &self, series: SeriesId, index: usize, f: impl FnOnce(&ChartDatum<T>) -> R, ) -> Option<R>
Access a point within series via a callback. Returns None if the
series or index is unknown.
pub fn with_series_view<R>( &self, series: SeriesId, f: impl FnOnce(SeriesView<'_, T>) -> R, ) -> Option<R>
Access a whole-series view (metadata + points slice) via a callback.
Returns None if series is unknown.
pub fn with_all_series<R>(&self, f: impl FnOnce(&[SeriesView<'_, T>]) -> R) -> R
Access every series as an ordered slice of views via a callback.
pub fn structure_version(&self) -> Signal<u64>
Structural version signal — bumped by every mutation except a color
change (series add/remove/move/rename/show-hide, all point ops).
Bind at BindingLevel::Relayout or Rebuild.
Ordering: every mutator notifies the ChartChange observers
registered via Self::observe_changes before bumping this
signal — see the note on observe_changes for what that means for a
callback that reads the signal back synchronously.
pub fn style_version(&self) -> Signal<u64>
Style version signal — bumped only by a series color change. Bind at
BindingLevel::RepaintOnly. Same notify-before-bump ordering as
Self::structure_version — see Self::observe_changes.
pub fn observe_changes(&self, f: impl Fn(&ChartChange) + 'static) -> ObserverHandle
Register an observer that is called on every mutation.
Returns an ObserverHandle — dropping it removes the callback.
Ordering contract: every mutator calls this observer before
bumping Self::structure_version / Self::style_version (see
e.g. rename_series, push_point) — notify, then bump. This is
intentional, not an implementation accident: it lets a ChartChange
callback distinguish "did I get here via the change I'm reacting to"
from "did something else bump the version already", by comparing the
version signal's value inside the callback against a value captured
before the mutation. The flip side: a callback that reads
structure_version()/style_version() synchronously inside
itself always observes the pre-bump value for the mutation
currently being notified — the bump hasn't happened yet. Don't use
the version signal from inside a ChartChange observer as a proxy
for "has this specific mutation been applied" — the ChartChange
argument already tells you that; use the signal for external
bind-and-rerun consumers (widgets), not from within the notify path
itself.
pub fn debug_named(self, _name: impl Into<String>) -> Self
Register this model with the debug inspector under name. In
release builds (!cfg(debug_assertions)) this is a no-op
pass-through so call sites stay free of #[cfg] lines.
Idempotent on repeated calls — the latest registration wins.
The registration drops automatically when the last ChartModel
handle is freed (the adapter the registry holds is Weak).
ChartSelection
ChartSelection — point-level selection state for chart widgets.
ChartSelection manages which (series, point index) pairs are
selected across a crate::ChartModel — the chart counterpart of
crate::SelectionModel (flat lists) and
crate::KeyedSelectionModel (keyed collections). It is a
share-by-clone handle: pass a clone to each chart that should share
selection state. The current selection is exposed as a reactive
Signal<HashSet<(SeriesId, usize)>> so widgets can bind to it without
polling.
HashSet (not BTreeSet) is used because SeriesId is intentionally
not Ord (it's an opaque SlotMap key, mirroring crate::NodeId) —
there is no natural ordering across series, only within one series'
point indices. This is the same rationale as
crate::KeyedSelectionModel, which uses HashSet<K> for the same
reason.
Three selection behaviours are available via
(toggle + anchor-based range extension). ChartSelection::extend_to
only extends within the anchor's own series — a cross-series "range" has
no natural order, so it falls back to a single-point select.
ChartSelection::adjust keeps selected points consistent as the
source model mutates (series removed, points inserted/removed) — call it
from your own model observer, or skip the wiring entirely with
ChartSelection::attached (equivalently, ChartSelection::attach on
an existing selection), which subscribes internally and calls adjust
for you, the same way crate::ChartWindow/crate::ChartAggregate
self-wire in their own constructors. Forgetting to wire adjust up
manually otherwise leaves the selection silently stale after a mutation.
#![allow(unused)] fn main() { use teksilo_data::{ChartModel, ChartSelection, SelectionMode}; let model: ChartModel<i32> = ChartModel::new(); let s = model.add_series("s"); for i in 0..5 { model.push_point(s, i, i as f32); } let sel = ChartSelection::attached(SelectionMode::Multi, &model); sel.select_point(s, 1); sel.extend_to(s, 3); assert_eq!(sel.count(), 3); // (s,1), (s,2), (s,3) model.remove_point(s, 0); // upstream mutation — no manual adjust() call assert_eq!(sel.count(), 3); // (s,0), (s,1), (s,2) — shifted down sel.clear(); assert_eq!(sel.count(), 0); }
Builder methods at a glance
attached, attach, mode, selection_signal, is_selected, selected_points, count, select_point, toggle_point, extend_to, select_points, clear, adjust, prune, debug_named
API reference
📖 Full rustdoc API for this module
pub struct ChartSelection
Point-level selection state for a chart, keyed by (series, point index). See module documentation for semantics.
#![allow(unused)] fn main() { pub struct ChartSelection { /* fields */ } }
Methods
pub fn new(mode: SelectionMode) -> Self
Create a new chart selection with the given mode.
pub fn attached<T: 'static>(mode: SelectionMode, model: &ChartModel<T>) -> Self
Create a selection that self-wires to model: every ChartChange
the model emits is automatically routed through Self::adjust, so
a point removed or shifted upstream never leaves a stale selected
index behind. Equivalent to ChartSelection::new(mode) plus
model.observe_changes(|c| sel.adjust(c)), minus the easy-to-forget
wiring — mirrors how crate::ChartWindow and
crate::ChartAggregate self-wire in their own constructors. The
manual Self::adjust path still works — call it yourself instead
if you'd rather relay through a custom change pipeline.
pub fn attach<T: 'static>(&self, model: &ChartModel<T>)
Subscribe this selection to model's changes, applying
Self::adjust on every ChartChange. The subscription is held
internally (shared across clones — see Clone), so it stays alive
as long as any handle to this selection does; calling attach
again (on this handle or any clone) drops the previous subscription
and installs the new one.
The subscription closure captures only selection + anchor, not a
full Self — capturing Self would pull in attach_handle too,
which holds this very ObserverHandle, forming an Rc cycle that
would leak the subscription instead of tearing down when every
ChartSelection handle drops.
pub fn mode(&self) -> SelectionMode
The selection mode.
pub fn selection_signal(&self) -> Signal<HashSet<(SeriesId, usize)>>
A clone of the selection signal for reactive binding.
pub fn is_selected(&self, series: SeriesId, index: usize) -> bool
Whether (series, index) is currently selected.
pub fn selected_points(&self) -> Vec<(SeriesId, usize)>
The currently selected points (unordered snapshot).
pub fn count(&self) -> usize
Number of selected points.
pub fn select_point(&self, series: SeriesId, index: usize)
Select a single point, clearing the previous selection and setting the anchor.
pub fn toggle_point(&self, series: SeriesId, index: usize)
Toggle a point (Ctrl+click in Multi mode; acts as select_point in
Single mode).
pub fn extend_to(&self, series: SeriesId, target: usize)
Extend the selection from the anchor to (series, target) (for
Shift+click). Only extends within the anchor's own series — if
the anchor is unset or belongs to a different series, falls back to
a single-point select of (series, target).
pub fn select_points( &self, points: impl IntoIterator<Item = (SeriesId, usize)>, additive: bool, )
Replace the selection with points (or, when additive, union
them into the current selection). Used by rubber-band / marquee
selection. In Single mode an arbitrary one wins; None mode is a
no-op.
pub fn clear(&self)
Clear the selection and anchor.
pub fn adjust(&self, change: &ChartChange)
React to an upstream ChartChange, keeping selection consistent
with the model: a removed or wholesale-replaced series drops its
selected points (and the anchor, if it pointed there); point
insertions/removals shift or drop indices within their series.
Series metadata changes (rename/recolor/visibility/move/insert) and
in-place point updates never affect which points are selected.
pub fn prune(&self, exists: impl Fn(SeriesId, usize) -> bool)
Drop any selected point for which exists returns false.
pub fn debug_named(self, _name: impl Into<String>) -> Self
Register this selection with the debug inspector under name. In
release builds (!cfg(debug_assertions)) this is a no-op
pass-through so call sites stay free of #[cfg] lines.
Idempotent on repeated calls — the latest registration wins. The
registration drops automatically when the last ChartSelection
handle is freed (the strong adapter Rc lives inside a shared
holder; the registry holds only a Weak).
ChartWindow
ChartWindow<T> — a "last N points per series" streaming projection over
a crate::ChartModel.
Wraps a ChartModel<T> and exposes the tail
window_size points of every series — the live-scrolling-strip-chart
pattern (a sensor feed, a log-rate graph, a stock ticker). Unlike
crate::ChartAggregate, ChartWindow copies no point data: it
tracks, per series, the source index of the window's first visible point
(starts) and delegates every read straight through to the source. That
means a ChartWindow<T> needs no T: Clone bound at all.
Reactivity
The upstream ChartChange stream is translated, not collapsed to a
blanket Reset (unlike crate::SortFilterListModel, where an
arbitrary sort-key move makes fine-grained translation unsafe — a
fixed-size tail window has no such hazard): a tail append into a full
window becomes a PointsRemoved + PointsInserted pair (the window
slides), a tail append into a still-growing window becomes a plain
PointsInserted, and symmetrically a tail removal (trimming the
series' own end — e.g. discarding a bad trailing reading) becomes the
mirror-image PointsRemoved + PointsInserted pair: points beyond the
new total drop out of the window, and if the window slid backward to
stay full, the newly-uncovered prefix is revealed as an insertion.
Anything that isn't a clean tail append/removal (a mid-series insert or
removal) falls back to a per-series rebuild reported as
SeriesDataReplaced.
use teksilo_data::{ChartModel, ChartWindow};
let model: ChartModel<i32> = ChartModel::new();
let s = model.add_series("sensor");
for i in 0..100 {
model.push_point(s, i, i as f32);
}
let window = ChartWindow::new(model.clone(), 10);
assert_eq!(window.point_count(s), 10); // last 10 points only
Builder methods at a glance
window_size, set_window_size, series_count, series_ids, point_count, with_series, with_point, observe_changes, first_changed_index
API reference
📖 Full rustdoc API for this module
pub struct ChartWindow
A "last N points per series" streaming projection over a ChartModel<T>.
See the module documentation for semantics.
#![allow(unused)] fn main() { pub struct ChartWindow<T: 'static> { /* fields */ } }
Methods
pub fn new(source: ChartModel<T>, window_size: usize) -> Self
Wrap source, showing only the last window_size points of every
series.
pub fn window_size(&self) -> usize
The configured window size.
pub fn set_window_size(&self, window_size: usize)
Change the window size, rebuilding every series and emitting
ChartChange::Reset.
pub fn series_count(&self) -> usize
Number of series (same set as the source).
pub fn series_ids(&self) -> Vec<SeriesId>
The series ids, in the source's display order.
pub fn point_count(&self, series: SeriesId) -> usize
Number of points currently visible in the window for series.
pub fn with_series<R>( &self, series: SeriesId, f: impl FnOnce(&str, Option<&ColorProp>, bool) -> R, ) -> Option<R>
Access a series' metadata (delegates straight through to the
source). Returns None if series is unknown.
pub fn with_point<R>( &self, series: SeriesId, index: usize, f: impl FnOnce(&ChartDatum<T>) -> R, ) -> Option<R>
Access the point at window-local index within series. Returns
None if series is unknown or index is outside the window.
pub fn observe_changes(&self, f: impl Fn(&ChartChange) + 'static) -> ObserverHandle
Register an observer for translated window changes. Returns an
ObserverHandle — dropping it removes the callback.
pub fn first_changed_index(&self, series: SeriesId) -> Option<usize>
First window-local index of series whose content may differ since
the latest translated change. Per-series (chart data is 2-level:
series, then points), unlike
SortFilterListModel::first_changed_index's
single flat value. None if series is unknown or unaffected yet.
CheckedModel
CheckedModel — per-row checkbox state for flat collection widgets.
Tracks which rows in a list view are marked (checked), independently of which row is selected. Selection (cursor position) and checked-ness (persistent marks) are orthogonal axes — the Outlook / Files-app convention where you can check many items and then act on them all.
The model issues one writable Signal<bool> per row index via
CheckedModel::signal_for; repeated calls for the same index return the
same cached handle. A Checkbox widget writes to that signal on click;
the model observes every per-index signal and keeps a central
Signal<BTreeSet<usize>> in sync so consumers can react to the complete
checked set without subscribing to each row individually.
CheckedModel is a share-by-clone handle (Rc<RefCell<…>> internally);
cloning produces a second handle to the same state. When rows are inserted,
removed, or reordered, call the corresponding adjust_for_* method so that
checked state follows the moved items rather than sticking to stale indices.
For hierarchical lists with descendant→ancestor tristate aggregation, see
crate::TreeCheckedModel instead.
#![allow(unused)] fn main() { use teksilo_data::CheckedModel; let model = CheckedModel::new(); model.check(1); model.check(3); assert!(model.is_checked(1)); assert_eq!(model.checked_count(), 2); model.toggle(1); assert!(!model.is_checked(1)); }
Builder methods at a glance
checked_signal, signal_for, adjust_for_insert, adjust_for_remove, adjust_for_move, is_checked, checked_indices, checked_count, check, uncheck, toggle, check_all, clear
API reference
📖 Full rustdoc API for this module
pub struct CheckedModel
Per-row checkbox state for a flat list, with a reactive aggregate checked-set.
#![allow(unused)] fn main() { pub struct CheckedModel { /* fields */ } }
Methods
pub fn new() -> Self
Creates a new, empty CheckedModel with no rows checked.
pub fn checked_signal(&self) -> Signal<BTreeSet<usize>>
Reactive view of the full checked-set.
pub fn signal_for(&self, index: usize) -> Signal<bool>
Writable per-index signal. Repeat calls cache the same handle —
any consumer (the model itself, the Checkbox widget, an external
observer) writing through it propagates to the central
checked_signal().
pub fn adjust_for_insert(&self, start: usize, count: usize)
Shift checked-state after count rows are inserted at start.
Indices >= start move up by count.
pub fn adjust_for_remove(&self, start: usize, count: usize)
Shift checked-state after count rows starting at start are removed.
Checked rows in start..start+count are dropped; later rows shift down.
pub fn adjust_for_move(&self, from: usize, to: usize, count: usize)
Shift checked-state after a block of count rows moved from from to
to (a post-removal index, matching ListModel::move_item). Checked
rows follow their items.
pub fn is_checked(&self, index: usize) -> bool
Returns true if the row at index is currently checked.
pub fn checked_indices(&self) -> Vec<usize>
Returns a sorted Vec of every currently checked row index.
pub fn checked_count(&self) -> usize
Returns the number of currently checked rows.
pub fn check(&self, index: usize)
Marks the row at index as checked; notifies observers if the state changed.
pub fn uncheck(&self, index: usize)
Marks the row at index as unchecked; notifies observers if the state changed.
pub fn toggle(&self, index: usize)
Flips the checked state of the row at index; notifies observers.
pub fn check_all(&self, count: usize)
Checks every row in 0..count; notifies observers for each row that was unchecked.
pub fn clear(&self)
Unchecks every currently checked row; notifies observers for each change.
CheckState
CheckState — tri-state checkbox value shared by the data layer and widgets.
Represents the three visual states of a checkbox: unchecked, checked, and
indeterminate (partial — some but not all descendants are checked). Lives in
teksilo-data rather than teksilo-widgets so that crate::TreeCheckedModel
can produce Signal<CheckState> values without inverting the dependency graph.
From<bool> converts a plain two-state boolean (e.g. from a filter predicate)
into Unchecked or Checked, making it easy to bridge non-tristate sources.
#![allow(unused)] fn main() { use teksilo_data::CheckState; let state = CheckState::Indeterminate; assert!(state.is_filled()); assert_eq!(state.next_tristate(), CheckState::Unchecked); assert_eq!(CheckState::from(true), CheckState::Checked); }
Builder methods at a glance
is_filled, next_tristate
API reference
📖 Full rustdoc API for this module
pub enum CheckState
#![allow(unused)] fn main() { pub enum CheckState { /* variants */ } }
Variants
Unchecked— The checkbox is unchecked (no fill, no mark).Checked— The checkbox is fully checked (filled with a check mark).Indeterminate— Some but not all descendants are checked; shown as a dash or partial fill.
Methods
pub fn is_filled(self) -> bool
Whether the box shows a filled background (checked or indeterminate).
pub fn next_tristate(self) -> Self
Cycle to the next state: Unchecked → Checked → Indeterminate → Unchecked.
DataChange
DataChange — change notifications for flat collections.
Describes the mutations that crate::ListModel (and crate::ListDataSource
implementors) emit to their subscribers. Consumers such as ListView,
TableView, and SortFilterListModel receive a DataChange through their
observer and update their internal state (measured row heights, selection
indices, sort projections) incrementally rather than rebuilding from scratch.
Most variants carry index ranges so that observers can perform O(affected)
work. Reset is the fallback when the change cannot be expressed
incrementally; consumers must discard all cached state and re-query the source.
Also provided: map_index_after_move, a pure function that maps a single
index through an ItemsMoved operation — used by crate::CheckedModel and
crate::SelectionModel to keep index-based state in sync after reorders.
#![allow(unused)] fn main() { use teksilo_data::data_change::{DataChange, map_index_after_move}; // An insertion at row 2 shifts index 5 to 6. let change = DataChange::ItemsInserted { range: 2..3 }; // map_index_after_move: move row 0 to position 2 (post-removal index). let new_idx = map_index_after_move(0, 0, 2, 1); assert_eq!(new_idx, 2); }
API reference
📖 Full rustdoc API for this module
pub enum DataChange
Describes a mutation to a flat list. Emitted by crate::ListModel automatically
and by crate::ListDataSource implementors manually.
#![allow(unused)] fn main() { pub enum DataChange { /* variants */ } }
Variants
ItemsInserted— Rows were inserted;rangeholds the indices of the newly inserted items.ItemsRemoved— Rows were removed;rangeholds the indices they occupied before removal.ItemsMoved— A contiguous block ofcountrows moved fromfromtoto(post-removal index).ItemUpdated— A single row's data changed in place without any structural shift.WindowLoaded— A window of previously-Loadingrows becameReady(lazy / windowed sources). Semantically likeItemsInsertedfor a row-height cache (divergence =range.start), but no rows were added — the count was already declared — so aSelectionModelmust NOT index-shift for it.Reset— The entire list was replaced; consumers must discard all cached state and rebuild.
pub fn map_index_after_move(...)
Map an index through a DataChange::ItemsMoved { from, to, count }.
Mirrors ListModel::move_item: the contiguous block from..from+count is
removed, then reinserted so its first item lands at to (a post-removal
index). Returns where idx ends up after the move. Used by index-based
state (selection, checked-set) to follow items across a reorder.
#![allow(unused)] fn main() { pub fn map_index_after_move(idx: usize, from: usize, to: usize, count: usize) -> usize; }
pub fn adjust_single_index_for_change(...)
Map a single index anchor (not a selection set) through a
DataChange, or None if the row the anchor pointed at no longer
exists (it was removed, or the whole list was reset).
This is the same shift semantics as map_index_after_move /
SelectionModel::adjust_for_* / CheckedModel::adjust_for_*, specialized
for a bare Option<usize> anchor that has no "membership" to prune —
e.g. ListView's keyboard-focus index. Used so a single-anchor consumer
doesn't have to re-derive insert/remove/move shift logic by hand.
ItemsInserted: the anchor shifts up by the inserted count if it sat at or after the insertion point, otherwise it's untouched.ItemsRemoved: the anchor shifts down past the removed range; if the anchor itself pointed into the removed range, it is dropped (None) — the row it followed is gone.ItemsMoved: delegates tomap_index_after_move(the anchor follows its row, or shifts around the moved block like everyone else).ItemUpdated/WindowLoaded: no structural shift — the anchor is unchanged.Reset: the anchor is dropped (None) — nothing about the old indexing survives a wholesale replacement.
#![allow(unused)] fn main() { pub fn adjust_single_index_for_change(idx: usize, change: &DataChange) -> Option<usize>; }
ItemKey
Shared capability types for the data-source drag-and-drop + lazy protocol.
These types are the Teksilo-shaped equivalent of Qt's
flags/canDropMimeData/dropMimeData (DnD validation) and
canFetchMore/fetchMore (lazy loading), expressed as defaulted methods on
ListDataSource and
TreeDataSource. A source owns the answer to
"may this drop happen?" (can_accept) and "apply the move" (accept_drop);
the view merely renders the source's verdict and routes the commit. This is
what lets an external source of truth (e.g. a Qleany entity store) drive a
view without the view ever mutating a mirror model.
Key types
ItemKey— blanket identity trait for anyClone + Eq + Hash + Debug + 'statictype.RowState— whether a lazy row's data is resident (Ready) or still loading (Loading).DragEligibility— per-row drag gate returned byListDataSource::drag.DropPosition— where a drop lands relative to the target row.DragSource— who is dragging: the same view (intra-view reorder) or a foreign view/OS drop.DropQuery/DropResponse— hover-time can-I-drop? query and verdict.DropCommit— the committed drop handed toaccept_drop.
// Example: implementing can_accept for a custom ListDataSource
fn can_accept(&self, query: &teksilo_data::DropQuery<'_, usize>) -> teksilo_data::DropResponse {
match &query.source {
teksilo_data::DragSource::SameView { .. } => teksilo_data::DropResponse::Accept,
teksilo_data::DragSource::Foreign { .. } => teksilo_data::DropResponse::Reject,
}
}
API reference
📖 Full rustdoc API for this module
pub enum RowState
Whether a realized row's data is resident yet. A windowed/lazy source returns
Loading for indices outside its resident window; the view renders a
placeholder skeleton for those and calls request_window to pull them.
#![allow(unused)] fn main() { pub enum RowState { /* variants */ } }
Variants
Ready— Item data is resident;with_item/with_entryreturnsSome.Loading— The row exists (counts againstlen/visible_count) but its data is not yet loaded;with_item/with_entryreturnsNone.
pub enum DropPosition
Where, relative to a target row, a drop lands. Into (reparent) is only
meaningful for trees; flat lists reject it.
#![allow(unused)] fn main() { pub enum DropPosition { /* variants */ } }
Variants
Before— Immediately before the target (sibling, same level).Into— As a child of the target (reparent — trees only).After— Immediately after the target (sibling, same level).
pub enum DragEligibility
Whether a row may begin a drag at all (the per-item transferable gate, Qt's
Qt::ItemIsDragEnabled / TabBar's with_transferable_predicate).
#![allow(unused)] fn main() { pub enum DragEligibility { /* variants */ } }
Variants
CanDrag— The row can be dragged.NoDrag— The row cannot be dragged (the gesture is suppressed).
pub enum DragSource
Who is dragging, from the receiving source's point of view.
SameView is an intra-view reorder identified by the dragged row's key.
Foreign is everything else — an in-app drag from another view or an OS
drop — carried as a type-erased DragPayload the source downcasts itself
(e.g. a designer source downcasts to its palette-drop type, a list source to
its item type, an OS drop to files). This single distinction is exactly what
TabBar already encodes via its source_bar_id.
#![allow(unused)] fn main() { pub enum DragSource<'a, K> { /* variants */ } }
Variants
SameView— An intra-view reorder;keyidentifies the dragged row.Foreign— A drag from another view or the OS; downcastpayloadto interpret it.
pub struct DropQuery
A hover-time question posed to a source: "may source drop at position
relative to target?" The source answers with a DropResponse.
#![allow(unused)] fn main() { pub struct DropQuery<'a, K> { /* fields */ } }
pub enum DropResponse
A source's verdict on a DropQuery. Drives the hover affordance and gates
the commit.
#![allow(unused)] fn main() { pub enum DropResponse { /* variants */ } }
Variants
Accept— Allowed: paint the insertion line / reparent box at this position.Reject— Forbidden: paint the no-drop affordance; the drop will be refused.Redirect— Allowed, but only at a different position — the view snaps its indicator to.0(e.g. a container that accepts children but not sibling reorder redirectsBefore/After→Into).
pub struct DropCommit
A drop the user actually committed, handed to accept_drop to apply.
#![allow(unused)] fn main() { pub struct DropCommit<'a, K> { /* fields */ } }
KeyedSelectionModel
KeyedSelectionModel<K> — identity-based selection for collection widgets.
KeyedSelectionModel<K> stores selection as a set of
source-defined keys rather than visible indices. This is what
SelectionModel cannot do: survive lazy
window-slides and external reorders, and stay consistent across two views of
the same source that scroll/sort/filter independently (selection is a set of
identities, not positions). It coexists with the index-based
SelectionModel — views opt into one or the other.
Shift+click range extension is index-ordered by nature, so extend_to takes
the current visible key order from the caller (the projection) at click
time; the anchor is stored as a key so it survives scrolling out of the
resident window. The selection is exposed as a reactive
Signal<HashSet<K>> via selection_signal().
When to use
Use KeyedSelectionModel when rows are identified by a stable domain key
(entity id, file path, UUID) that survives reorders, sorts, and lazy-loading
evictions. Use SelectionModel when rows are
identified by their current visible index (simple in-memory lists).
#![allow(unused)] fn main() { use teksilo_data::KeyedSelectionModel; use teksilo_data::SelectionMode; let sel: KeyedSelectionModel<u64> = KeyedSelectionModel::new(SelectionMode::Multi); sel.select(10); sel.toggle(20); sel.toggle(30); assert_eq!(sel.count(), 3); sel.toggle(10); // deselect assert!(!sel.is_selected(&10)); sel.clear(); assert_eq!(sel.count(), 0); }
Builder methods at a glance
mode, selection_signal, is_selected, selected_keys, count, select, toggle, extend_to, select_keys, clear, prune_missing, debug_named
API reference
📖 Full rustdoc API for this module
pub struct KeyedSelectionModel
Selection state keyed by source-defined identity rather than visible index.
The selection set is exposed as a Signal<HashSet<K>> (via
selection_signal) so widgets
observe it reactively without polling. Cloning the model shares the same
selection and anchor across all handles. The Shift+click anchor is stored as
a K so it survives lazy-window evictions and visible-order changes.
#![allow(unused)] fn main() { pub struct KeyedSelectionModel<K: ItemKey> { /* fields */ } }
Methods
pub fn new(mode: SelectionMode) -> Self
Create a new keyed selection model with the given mode.
pub fn mode(&self) -> SelectionMode
The selection mode.
pub fn selection_signal(&self) -> Signal<HashSet<K>>
A clone of the selection signal for reactive binding.
pub fn is_selected(&self, key: &K) -> bool
Whether key is currently selected (O(1)).
pub fn selected_keys(&self) -> Vec<K>
The currently selected keys (unordered snapshot).
pub fn count(&self) -> usize
Number of selected items.
pub fn select(&self, key: K)
Select a single key, clearing previous selection and setting the anchor.
pub fn toggle(&self, key: K)
Toggle a key (Ctrl+click in Multi mode; acts as select in Single).
pub fn extend_to(&self, target: K, ordered_keys: &[K])
Extend the selection from the anchor to target over the current visible
key order (Shift+click). ordered_keys is the projection's visible order
at click time. If the anchor isn't currently visible (scrolled out /
evicted), falls back to a single-key select.
pub fn select_keys(&self, keys: impl IntoIterator<Item = K>, additive: bool)
Replace the selection with keys (or, when additive, union them in).
Used by rubber-band selection. In Single mode an arbitrary one wins.
pub fn clear(&self)
Clear the selection and anchor.
pub fn prune_missing(&self, exists: impl Fn(&K) -> bool)
Drop any selected key (and the anchor) for which exists returns false.
Call after a removal/reset to prune deleted rows — the index-based
adjust_for_insert/adjust_for_remove are unnecessary here because keys
are stable across inserts, moves, sorts and filters.
pub fn debug_named(self, _name: impl Into<String>) -> Self
Register this model with the debug inspector under name; no-op in
release builds (!cfg(debug_assertions)). Returns self for chaining.
KeyedTreeCheckedModel
KeyedTreeCheckedModel<K> — per-node checkbox state for a tree keyed by a
stable domain id, with optional descendant→ancestor tristate aggregation.
The keyed counterpart of TreeCheckedModel — the
checkbox twin of KeyedSelectionModel. Where
TreeCheckedModel is bound to a TreeModel<T> and keyed by NodeId, this
model is keyed by your domain key K (an entity id, a tagged enum) and
takes the tree shape as two injected closures (children + parent), so
it composes over a TreeDataSlice or any
TreeDataSource — the "select scenes to export"
tristate over an external outline, without mirroring into a TreeModel.
Because identity is the domain key (stable across a full re-source), a
node's check state survives the tree reloading — a checked scene stays
checked after the backend refreshes. Use prune_missing
after a reload to drop the state of nodes that no longer exist.
Semantics, cascade behaviour, the Signal<CheckState> / Signal<bool>
bridge, and the re-entry guard are identical to TreeCheckedModel — see its
module docs for the detail. This model is a
share-by-clone handle (Rc<RefCell<…>> internally).
Example
use teksilo_data::{KeyedTreeCheckedModel, CheckState, TreeDataSlice, TreeRow};
// An outline: Binder(1) → { Chapter(2) → Scene(3), Scene(4) }
let slice: TreeDataSlice<u64, &str> = TreeDataSlice::from_rows(vec![
TreeRow::new(1, "Binder", 0),
TreeRow::new(2, "Chapter", 1),
TreeRow::new(3, "Scene A", 2),
TreeRow::new(4, "Scene B", 1),
]);
let checked = KeyedTreeCheckedModel::from_source(slice.clone());
let _ = (checked.signal_for(1), checked.signal_for(2), checked.signal_for(3), checked.signal_for(4));
checked.check(3); // one scene under the chapter
assert_eq!(checked.check_state(&2), CheckState::Checked); // chapter has only Scene A → Checked
assert_eq!(checked.check_state(&1), CheckState::Indeterminate); // Binder: 2 of {chapter, Scene B}
Builder methods at a glance
from_source, with_mode, aggregate_mode, set_aggregate_mode, signal_for, bool_signal_for, check_state, check, uncheck, toggle, checked_keys, clear, prune_missing, reaggregate
API reference
📖 Full rustdoc API for this module
pub struct KeyedTreeCheckedModel
Per-node checkbox state for a domain-keyed tree, with optional
descendant→ancestor tristate aggregation. See the module docs.
#![allow(unused)] fn main() { pub struct KeyedTreeCheckedModel<K: ItemKey> { /* fields */ } }
Methods
pub fn new( children: impl Fn(&K) -> Vec<K> + 'static, parent: impl Fn(&K) -> Option<K> + 'static, ) -> Self
Create a model over a tree whose shape is given by two closures:
children(key) -> Vec<K> and parent(key) -> Option<K>. Uses the
default AggregateMode::DescendantsDriveAncestors.
pub fn from_source<S>(source: S) -> Self where S: TreeDataSource<Key = K> + Clone + 'static,
Create a model whose tree shape is read from a cloneable
TreeDataSource (e.g. a TreeDataSlice). The
source is cloned into the shape closures, so the model reflects the live
tree — call prune_missing after the source
reloads to drop state for removed nodes.
pub fn with_mode(self, mode: AggregateMode) -> Self
Set the AggregateMode at construction.
pub fn aggregate_mode(&self) -> AggregateMode
The current AggregateMode.
pub fn set_aggregate_mode(&self, mode: AggregateMode)
Change the cascade behaviour; takes effect on the next write.
pub fn signal_for(&self, key: K) -> Signal<CheckState>
Writable Signal<CheckState> for key (cached). External writes trigger
the configured aggregation pass. The cascade observer is wired
idempotently — including for a signal first materialised by a cascade
(write_state) before its own signal_for was ever called — so binding a
lazily-realised (e.g. virtualized) row still cascades on write.
pub fn bool_signal_for(&self, key: K) -> Signal<bool>
Two-state Signal<bool> projection of signal_for
(cached, writable). Checked → true; anything else → false. See
crate::TreeCheckedModel::bool_signal_for.
pub fn check_state(&self, key: &K) -> CheckState
The current CheckState for key (Unchecked if never touched).
pub fn check(&self, key: K)
Set key to CheckState::Checked (triggers cascade + ancestor recompute).
pub fn uncheck(&self, key: K)
Set key to CheckState::Unchecked (triggers cascade + ancestor recompute).
pub fn toggle(&self, key: K)
Toggle key: a leaf under DescendantsDriveAncestors cycles two-state;
a branch or AggregateMode::None cycles the full tristate sequence.
pub fn checked_keys(&self) -> Vec<K>
All keys whose current state is exactly CheckState::Checked. May
include stale keys after a tree mutation — call prune_missing
or filter against the current tree yourself.
pub fn clear(&self)
Reset all known nodes to CheckState::Unchecked.
Writes every tracked key directly via the internal write_state
helper (per-key cascade-suppressed) instead of signal_for(..).set(..)'s normal
path, which would, for every currently-checked key, cascade the
write down its whole descendant subtree and recompute every
ancestor up to the root — redundant here, since every tracked key
ends up Unchecked and "all children unchecked" is already the
correct parent aggregate. See TreeCheckedModel::clear
for the non-keyed twin of this same optimization.
pub fn prune_missing(&self, exists: impl Fn(&K) -> bool)
Drop cached check state (and its signals/observers) for every key for
which exists(&key) returns false, then reaggregate
surviving parents against the current tree. Call after a reload so a
deleted node's state doesn't linger in checked_keys() and the
ancestors it used to affect show the correct tristate. Mirrors
crate::KeyedSelectionModel::prune_missing.
pub fn reaggregate(&self)
Recompute every surviving parent's aggregate from the current tree
shape + leaf states, deepest first. Call after the backing tree's
structure changed (a reload that added/removed/moved nodes) so parent
tristates reflect the new children; prune_missing
does this for you. A no-op under AggregateMode::None.
ListDataSource
ListDataSource — read-and-command interface for a flat collection behind a ListView /
TableView.
ListDataSource is the flat-list peer of
TreeDataSource: a positional read API plus the
capability protocol (identity, DnD validation, lazy loading). It is the
input every flat data view reads through. The built-in ListModel<T> and
SortFilterListModel<T> implement it; an external/huge source
(a paged database cursor, a 1M-row windowed feed) implements it directly and owns its
own paging behind row_state/request_window/fetch_more.
Not object-safe (associated types + generic with_item); ListView
consumes it generically via ListView::from_source and erases it into a
closure bundle. The DnD and lazy methods default to inert / fully-resident,
so a read-only in-memory source implements only len + with_item +
observe_changes.
When to use
Prefer ListModel<T> when your data fits in memory and you want
automatic DataChange notifications with no extra work. Implement ListDataSource
directly when the source is external, huge, or requires lazy window-based loading —
the view calls request_window each build pass and fetch_more near the end.
#![allow(unused)] fn main() { use teksilo_data::{ListModel, ListDataSource}; // ListModel<T> implements ListDataSource — pass it directly to any flat view. let model = ListModel::from_vec(vec!["alpha", "beta", "gamma"]); // Access via the ListDataSource interface: let _len = model.len(); let _first = model.with_item(0, |s| *s); assert_eq!(_len, 3); assert_eq!(_first, Some("alpha")); }
API reference
📖 Full rustdoc API for this module
ListModel
ListModel<T> — concrete reactive list backed by a Vec<T>.
ListModel<T> stores items in a heap-allocated Vec<T> behind
Rc<RefCell<…>>. Cloning a handle shares the same underlying data — there
is no deep copy. Every mutation method (push, insert, remove, set,
move_item, replace_all, clear) drops the internal borrow before
notifying observers, so observer callbacks may safely call read methods
(len, with_item) without a re-entrant borrow.
ListModel<T> implements ListDataSource directly, so it can be handed
to any ListView / TableView without adaption. For lists too large to
hold in memory, implement ListDataSource directly on your own type
(paged database cursor, windowed feed, etc.).
When to use
Use ListModel<T> when the full list fits in memory and you want automatic
change notifications with no extra setup. Use a custom ListDataSource
when the source is external, huge, or lazy-loaded.
Notifications
Observers registered via ListModel::observe_changes receive a
DataChange describing the minimal change: ItemsInserted,
ItemsRemoved, ItemUpdated, ItemsMoved, or Reset. The
ObserverHandle returned is RAII — dropping
it unregisters the callback immediately.
#![allow(unused)] fn main() { use teksilo_data::ListModel; let model: ListModel<&str> = ListModel::new(); model.push("alpha"); model.push("beta"); model.push("gamma"); assert_eq!(model.len(), 3); let second = model.with_item(1, |s| *s); assert_eq!(second, Some("beta")); model.set(0, "ALPHA"); model.remove(2); assert_eq!(model.len(), 2); }
Builder methods at a glance
from_vec, len, is_empty, with_item, push, insert, remove, set, move_item, move_items, replace_all, clear, observe_changes, reconcile_by_key, debug_named
API reference
📖 Full rustdoc API for this module
pub struct ListModel
A concrete reactive list that stores items in a Vec<T>.
ListModel<T> is Clone — cloning produces a second handle to the same
data. Multiple widgets can hold clones and all see the same items.
Every mutation method modifies the internal Vec, drops the mutable borrow,
then notifies observers. By the time any observer runs, the borrow is
released and shared borrows (len(), with_item()) are safe.
#![allow(unused)] fn main() { pub struct ListModel<T: 'static> { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty list model.
pub fn from_vec(items: Vec<T>) -> Self
Create a list model from an existing vector.
pub fn len(&self) -> usize
Number of items in the list.
pub fn is_empty(&self) -> bool
Whether the list is empty.
pub fn with_item<R>(&self, index: usize, f: impl FnOnce(&T) -> R) -> Option<R>
Access an item by index via a callback. Returns None if out of bounds.
The callback pattern avoids returning a reference that would need to
outlive the RefCell borrow guard.
pub fn push(&self, item: T)
Append an item to the end of the list.
pub fn insert(&self, index: usize, item: T)
Insert an item at the given index.
Panics
Panics if index > len().
pub fn remove(&self, index: usize) -> T
Remove and return the item at the given index.
Panics
Panics if index >= len().
pub fn set(&self, index: usize, item: T)
Replace the item at the given index.
Panics
Panics if index >= len().
pub fn move_item(&self, from: usize, to: usize)
Move an item from one index to another.
The item at from is removed, then inserted at to (post-removal index).
Panics
Panics if either index is out of bounds.
pub fn move_items(&self, indices: &[usize], insert_gap: usize) -> bool
Move a set of items so they land contiguously at a drop gap,
preserving their relative order — the multi-row same-view reorder
commit. indices are the items' current positions (any order;
out-of-range entries are ignored); insert_gap is the destination in
0..=len expressed in the pre-move indexing (i.e. "land before the item
currently at insert_gap"; len = at the end).
Returns whether anything moved (false if indices held no in-range
entry). A contiguous source block emits a single
DataChange::ItemsMoved — so index-based selection follows the moved
rows; a non-contiguous set emits DataChange::Reset (that permutation
is not expressible as one ItemsMoved, and selection is dropped). For a
single index prefer move_item.
pub fn replace_all(&self, items: Vec<T>)
Replace the entire list contents.
pub fn clear(&self)
Remove all items from the list.
pub fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle
Register an observer that is called on every mutation.
Returns an ObserverHandle — dropping it removes the callback.
pub fn reconcile_by_key<K: Eq + Hash>(&self, new_items: Vec<T>, key_fn: impl Fn(&T) -> K)
Reconcile the list's contents with new_items, matching old and new
rows by key (key_fn) instead of wholesale-replacing them, and
emitting the minimal set of granular DataChanges needed to reach
that state — never DataChange::Reset.
This is the primitive a live view needs when a peer process (or any
other out-of-band writer) reloads a backing file and the merged
result must land in a ListModel that a ListView is currently
displaying, without wiping the user's selection or keyboard focus
mid-interaction. replace_all/clear always emit Reset, and a
Reset unconditionally clears a positional SelectionModel
(RowSelection::from_index) — reconcile_by_key is how a caller
avoids that.
Emits, in this order, coalescing contiguous runs into a single event each:
DataChange::ItemsRemovedfor keys present in the old list but absent fromnew_items;DataChange::ItemsMoved(single-row blocks) to re-order the surviving rows intonew_items's relative order — skipped entirely for rows already in the right place, so an append-only or remove-only reload emits no moves at all;DataChange::ItemsInsertedfor keys present innew_itemsbut not in the old list;DataChange::ItemUpdatedfor a row whose key is unchanged but whose content differs (T: PartialEq) — the row's stored value is replaced with the incoming one.
If new_items is identical (same keys, same order, same content, by
PartialEq) to the current contents, no change is emitted and no
observer runs — reconciling with unchanged data is silent.
Preconditions
key_fn must be a pure, stable function of an item's identity (not
its content) and keys must be unique within both the current list
and new_items. See # Panics below — violating either is a caller
bug, not a silently-tolerated edge case.
Panics
Panics (via an internal .expect) if key_fn is not stable — it
returns a different key for the same item across the two calls this
method makes to it (once while snapshotting the current list's keys,
once while re-deriving a key during the write pass) — or if a key is
duplicated within the current list or within new_items. Both
break the same invariant the write pass relies on: "the item that
was accounted for under this key is still findable at or after the
write cursor." A duplicate key means two different items raced to
claim one key slot, so by the time the second one is processed the
slot the accounting expected is already gone. This is this crate's
usual documented-panic-on-contract-violation style (see e.g.
TreeModel::remove) — a caller-side bug
surfaced immediately as a panic, not silently wrong data.
Complexity
Re-ordering is a straightforward left-to-right pass that moves each
out-of-place survivor into its target slot; it is correct and always
granular, but is not guaranteed to emit the mathematically fewest
possible ItemsMoved events for an adversarial permutation (an
LIS-based scheme could do slightly better there). For the common
case this primitive targets — a peer append/remove/edit merged back
in — the existing relative order of untouched rows is preserved
as-is, so no moves are emitted at all.
pub fn debug_named(self, _name: impl Into<String>) -> Self
Register this model with the debug inspector under name. In
release builds (!cfg(debug_assertions)) this is a no-op
pass-through so call sites stay free of #[cfg] lines.
Idempotent on repeated calls — the latest registration wins.
The registration drops automatically when the last ListModel
handle is freed (the adapter the registry holds is Weak).
SelectionModel
SelectionModel — index-based selection state for collection widgets.
SelectionModel manages which flat indices are selected in a
ListView, TreeView, TableView, or GridView. It is a
share-by-clone handle (Rc<RefCell<…>> internally): pass a clone to
each view that should share selection state. The current selection is
exposed as a reactive Signal<BTreeSet<usize>> so widgets can bind to
it without polling.
Three selection behaviours are available via [SelectionMode]: None
(read-only / no interaction), Single (at most one item), and Multi
(Ctrl+click toggle + Shift+click range extension via an internal anchor).
Mutators automatically notify all Signal observers after every change,
and the helper methods adjust_for_insert / adjust_for_remove /
adjust_for_move keep selected indices consistent when the underlying
source mutates.
When to use SelectionModel vs KeyedSelectionModel
Use SelectionModel (this type) for views that are backed by a plain
ListModel<T> or a SortFilterListModel<T> where position is the
natural identity. Use crate::KeyedSelectionModel when items carry a
stable app-defined key (e.g. a NodeId or a UUID) and selection must
survive sort/filter rebuilds or window slides that renumber visible indices.
#![allow(unused)] fn main() { use teksilo_data::{SelectionModel, SelectionMode}; let sel = SelectionModel::new(SelectionMode::Multi); sel.select(2); // clear-and-select index 2, anchor = 2 sel.toggle(5); // add index 5 (Ctrl+click behaviour) sel.extend_to(8); // extend from anchor 5 to 8 (Shift+click behaviour) assert!(sel.is_selected(2)); assert_eq!(sel.count(), 5); // 2, 5, 6, 7, 8 sel.clear(); assert_eq!(sel.count(), 0); }
Builder methods at a glance
mode, selection_signal, is_selected, selected_indices, count, select, toggle, extend_to, select_indices, select_all, clear, adjust_for_insert, adjust_for_remove, adjust_for_move, debug_named
API reference
📖 Full rustdoc API for this module
pub enum SelectionMode
Selection behavior mode.
#![allow(unused)] fn main() { pub enum SelectionMode { /* variants */ } }
Variants
None— No selection allowed.Single— At most one item selected at a time.Multi— Multiple items can be selected (Ctrl+click toggles, Shift+click extends).
pub struct SelectionModel
Manages selection state for a collection widget.
The selection is exposed as a Signal<BTreeSet<usize>> so widgets can
observe changes reactively.
#![allow(unused)] fn main() { pub struct SelectionModel { /* fields */ } }
Methods
pub fn new(mode: SelectionMode) -> Self
Create a new selection model with the given mode.
pub fn mode(&self) -> SelectionMode
The selection mode.
pub fn selection_signal(&self) -> Signal<BTreeSet<usize>>
Get a clone of the selection signal for reactive binding.
pub fn is_selected(&self, index: usize) -> bool
Whether the given index is currently selected.
pub fn selected_indices(&self) -> Vec<usize>
The currently selected indices, sorted.
pub fn count(&self) -> usize
Number of selected items.
pub fn select(&self, index: usize)
Select a single index. In Single mode, clears previous selection.
In Multi mode, clears previous and selects just this one (use toggle
for Ctrl+click behavior). Sets the anchor for subsequent Shift+click.
pub fn toggle(&self, index: usize)
Toggle selection of a single index (for Ctrl+click in Multi mode).
In Single mode, behaves like select().
pub fn extend_to(&self, index: usize)
Extend the selection from the anchor to the given index (for Shift+click).
In Single mode, behaves like select().
pub fn select_indices(&self, indices: impl IntoIterator<Item = usize>, additive: bool)
Replace the selection with indices (or, when additive, union them
into the current selection). Used by rubber-band / marquee selection,
where the selected set is an arbitrary subset rather than a range. In
Single mode the highest index wins; None mode is a no-op.
pub fn select_all(&self, count: usize)
Select all indices from 0 to count-1.
A no-op in None mode, and also in Single mode — "select all" has
no coherent meaning for a control that holds at most one item, and
silently selecting one arbitrary row would be worse than doing
nothing. This mirrors what the gated call sites already do
(ListView's Ctrl+A handler, which documents it as "Multi selection
only — a no-op for Single / None, matching every list control", and
TableView's select_all helper, which matches only the Multi
modes). Enforcing it here too keeps an ungated caller — GridView's
Ctrl+A handler is one — from breaking the Single invariant that
every other mutator on this type upholds.
pub fn clear(&self)
Clear the selection.
pub fn adjust_for_insert(&self, start: usize, count: usize)
Adjust selection indices after items are inserted.
Indices >= start are shifted up by count.
pub fn adjust_for_remove(&self, start: usize, count: usize)
Adjust selection indices after items are removed.
Indices in start..start+count are deselected; indices above are shifted down.
pub fn adjust_for_move(&self, from: usize, to: usize, count: usize)
Adjust selection indices after a block of count items moved from
from to to (a post-removal index, matching ListModel::move_item).
Selected indices follow their items, so a dragged row stays selected.
pub fn debug_named(self, _name: impl Into<String>) -> Self
Register this selection model with the debug inspector under
name. In release builds (!cfg(debug_assertions)) this is a
no-op pass-through so call sites stay free of #[cfg] lines.
Idempotent on repeated calls — the latest registration wins.
The registration drops automatically when the last
SelectionModel handle is freed (the strong adapter Rc lives
inside a shared holder; the registry holds only a Weak).
SeriesPattern
SeriesPattern — the non-colour channel that identifies a chart series.
A chart that tells its series apart by colour and nothing else fails WCAG 1.4.1 (Use of Color), whatever palette it uses. A CVD-safe palette like Okabe–Ito answers a different question — whether the colours are distinguishable from one another — and does not answer this one: a reader with monochrome vision, a monochrome printout, a display in bright sun, or a forced-colours setting has no colour channel at all. It also does not answer the wrap-around problem, where a ninth series repeats the first's colour exactly.
So every series carries a second, orthogonal identity: a pattern. One value drives all three renderings a chart needs, so a series looks like itself whether it is drawn as a line, a bar, a slice, or a legend swatch:
| line | marker | filled area | |
|---|---|---|---|
Solid | solid | circle | plain |
Dashed | long dash | square | 45° hatch |
Dotted | dotted | triangle | back-hatch |
DashDot | dash-dot | diamond | cross-hatch |
ShortDash | short dash | cross | horizontal |
WideDash | wide dash | plus | vertical |
Six patterns against the theme palette's eight colours means the pair
(colour, pattern) does not repeat until the 24th series — where colour
alone repeated at the 9th.
A series with no explicit pattern is assigned one from its position by
SeriesPattern::for_index, so the channel exists without any application
code. Whether a chart draws it is the chart's decision (the stock charts
draw it once more than one series is visible, since a single-series chart
has nothing to disambiguate).
Builder methods at a glance
ALL, for_index, dash, marker, hatch
API reference
📖 Full rustdoc API for this module
pub enum SeriesPattern
The non-colour visual channel identifying one chart series.
See the module docs for the rendering table and the reasoning.
#![allow(unused)] fn main() { pub enum SeriesPattern { /* variants */ } }
Variants
Solid— Unbroken line, round marker, plain fill.Dashed— Long dash, square marker, forward (45°) hatch.Dotted— Dotted line, triangular marker, back (135°) hatch.DashDot— Dash-dot line, diamond marker, cross-hatch.ShortDash— Short dash, ×-shaped marker, horizontal hatch.WideDash— Wide-spaced dash, +-shaped marker, vertical hatch.
Methods
pub const ALL: SeriesPattern;`
Every pattern, in assignment order. The order is the cycle [`for_index`` walks.
pub fn for_index(index: usize) -> Self
The pattern a series at index gets when it declares none.
Wraps, like ChartPalette::color_for does
with colours — but at a different period (6 against the theme palette's
8), so the wrap points do not coincide and (colour, pattern) stays
unique far longer than either channel alone.
pub fn dash(self, line_width: f32) -> Option<(f32, f32)>
The dash pattern for a stroked line, as (dash, gap) in logical
pixels, or None for an unbroken line.
Scaled by line_width so a 1 dp line and a 4 dp line read as the same
pattern rather than the thick one looking almost solid.
pub fn marker(self) -> SeriesMarker
The marker glyph for this pattern.
pub fn hatch(self) -> SeriesHatch
The hatch for a filled region carrying this pattern.
pub enum SeriesMarker
The marker glyph drawn at a line chart's data points, and next to a series in a legend. Shape, not colour — that is the whole point.
#![allow(unused)] fn main() { pub enum SeriesMarker { /* variants */ } }
Variants
CircleSquareTriangleDiamondCrossPlus
pub enum SeriesHatch
How a filled region (a bar, an area, a pie slice) carries its series'
pattern. None is a plain fill; the rest are line hatches at the named
angle, drawn in a contrasting tone over the fill.
#![allow(unused)] fn main() { pub enum SeriesHatch { /* variants */ } }
Variants
None— No hatch — a plain fill.Forward— Parallel lines rising to the right (45°).Backward— Parallel lines falling to the right (135°).Cross— Both diagonals.Horizontal— Parallel horizontal lines.Vertical— Parallel vertical lines.
SortFilterListModel
Composable sort + filter projection over a flat list source.
SortFilterListModel<T> wraps a ListModel<T> or any
ListDataSource<Item = T> and exposes a ListDataSource<Item = T>
whose visible item order is determined by:
- Filtering: each column may register a predicate factory; rows that fail any non-empty filter are hidden.
- Sorting: at most one column may carry an active sort direction; rows are reordered by the column's registered comparator.
Filter is applied first, sort second. The result is a flat reactive view
that drops directly into TableView, ListView, or Repeater via
from_source(...).
Reactivity
Three independent change vectors trigger a rebuild of the visible-index map:
- The upstream source emits any
DataChange. Most changes collapse to a singleDataChange::Resetfor the proxy's own observers — translating fine-grained inserts / removes / moves through a sort projection is correctness-fragile (an item's sort key can move it to a different visible row), soResetis the safe default contract. The one exception is [DataChange::ItemUpdated]: the proxy re-evaluates just that row's filter verdict and its position against its current visible neighbours (not the whole list), and if neither changed, forwards a scopedItemUpdatedat the mapped visible index instead of paying for a full re-filter + re-sort +Reseton every edit to a live-updating source. Any verdict change (entering/leaving the visible set, or needing to move past a neighbour) still falls back to the full rebuild. - A bound sort signal updates: rebuild and emit
Reset. - A bound filters signal updates: rebuild and emit
Reset.
Selection semantics
Selection on a sorted/filtered view is naturally tracked by visible
index, not by item identity. After a projection rebuild, a downstream
SelectionModel keeps the same numerical
indices selected — meaning the visual selection stays in place even
though it now points at different underlying rows. Apps that want
identity-based selection should observe their model directly and rewrite
the selection from source identifiers on each rebuild.
#![allow(unused)] fn main() { use teksilo_data::{ListModel, SortFilterListModel, SortDirection}; use teksilo_data::ListDataSource; // brings `len()` into scope #[derive(Clone, Debug)] struct Person { name: String, age: u32 } let model: ListModel<Person> = ListModel::new(); model.push(Person { name: "Carol".into(), age: 30 }); model.push(Person { name: "Alice".into(), age: 25 }); model.push(Person { name: "Bob".into(), age: 28 }); let proxy = SortFilterListModel::new(model) .with_comparator("name", |a: &Person, b| a.name.cmp(&b.name)) .with_predicate("name", |text| { let t = text.to_lowercase(); Box::new(move |p: &Person| p.name.to_lowercase().contains(&t)) }); proxy.set_sort(Some("name"), SortDirection::Ascending); assert_eq!(proxy.len(), 3); // Alice, Bob, Carol proxy.set_filter("name", "a"); assert_eq!(proxy.len(), 2); // Alice, Carol }
Builder methods at a glance
from_source, with_comparator, with_predicate, sort_signal, filters_signal, set_sort, clear_sort, set_filter, clear_filters, first_changed_index, source_index_of, visible_index_of
API reference
📖 Full rustdoc API for this module
pub enum SortDirection
Sort direction emitted by TableView / TreeTableView headers and consumed by sort projections.
#![allow(unused)] fn main() { pub enum SortDirection { /* variants */ } }
Variants
Ascending— Sort from smallest to largest (A → Z, 0 → 9).Descending— Sort from largest to smallest (Z → A, 9 → 0).
pub struct SortFilterListModel
Flat list source projecting an upstream ListModel<T> /
ListDataSource<Item = T> through sort + filter.
See module-level documentation for semantics.
#![allow(unused)] fn main() { pub struct SortFilterListModel<T: 'static> { /* fields */ } }
Methods
pub fn new(model: ListModel<T>) -> Self
Wrap a ListModel<T>.
pub fn from_source<S: ListDataSource<Item = T>>(source: S) -> Self
Wrap any ListDataSource<Item = T>.
pub fn with_comparator( self, col_id: impl Into<String>, cmp: impl Fn(&T, &T) -> Ordering + 'static, ) -> Self
Register a comparator for a column id. Chainable.
pub fn with_predicate( self, col_id: impl Into<String>, factory: impl Fn(&str) -> Box<dyn Fn(&T) -> bool> + 'static, ) -> Self
Register a predicate factory for a column id. The factory receives the current filter text (empty = no filter, never invoked) and returns a boxed predicate evaluated against each row. Chainable.
pub fn sort_signal(&self, signal: Signal<Option<(String, SortDirection)>>)
Bind a sort signal — typically TableView::sort_signal(). Updates
re-project the view. The current value is read once at bind time.
pub fn filters_signal(&self, signal: Signal<HashMap<String, String>>)
Bind a filters signal — typically TableView::filters_signal().
Updates re-project the view. The current value is read once at bind
time.
pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection)
Set the active sort imperatively. If a sort signal is bound this
writes through the signal; otherwise it mutates internal state and
emits DataChange::Reset directly.
pub fn clear_sort(&self)
Clear the active sort.
pub fn set_filter(&self, col_id: &str, text: &str)
Set or clear a single column's filter. An empty text removes the
entry. If a filters signal is bound this writes through the signal.
pub fn clear_filters(&self)
Clear every column's filter.
pub fn first_changed_index(&self) -> Option<usize>
First visible index whose content may differ from before the
latest projection rebuild — rows 0..index show the same items in
the same order as before, so per-row derived state (e.g. a
measured row height) remains valid for them. Equal to len() when
the visible list is unchanged. Renumbering from upstream
inserts/removes/moves is accounted for (equal source-index values
above the change point are not trusted).
None means unknown (no rebuild observed yet) — treat as a full
change. The value describes the latest rebuild only; read it
synchronously from a DataChange observer (callbacks fire inline
on every rebuild, so per-change reads cannot miss a value). The
DataChange::Reset contract for observers is unchanged — this is
a side-channel for consumers that can exploit a valid prefix.
pub fn source_index_of(&self, visible: usize) -> Option<usize>
Map a visible (post sort+filter) index to its source index.
pub fn visible_index_of(&self, source: usize) -> Option<usize>
Map an underlying source index to its visible position, if shown. Builds a reverse-index lazily; subsequent calls in the same projection epoch are O(1).
SortFilterTreeModel
Composable sort + filter projection over a hierarchical tree.
SortFilterTreeModel<T> wraps a TreeModel<T> and exposes a
TreeSlice-shaped API whose visible nodes are determined by:
- Filtering with one of three
TreeFilterModestrategies:HideNonMatching— strict per-node match (hides ancestors of matches if they don't themselves match).KeepAncestors— file-tree convention: an ancestor stays visible whenever any descendant matches. Default.KeepDescendants— once a node matches, its entire subtree stays visible (useful for "show me this branch").
- Sorting applied per-parent: comparators reorder siblings but never cross levels.
The proxy owns its own expand/collapse state (independent of any
TreeSlice over the same TreeModel) and bumps version_signal on
every projection rebuild — TreeTableView binds to that to know when to
rebuild its row tree.
A single-node TreeChange::NodeUpdated with no filter active skips
the full filter/sort/flatten recompute: the node's rank among its
siblings is checked against its immediate neighbours (tree sort never
crosses levels) and, if stable, only first_changed_index() and
version_signal() advance. Any active filter falls back to the full
rebuild (a node's own match verdict can cascade to ancestors and/or
descendants depending on TreeFilterMode, so cheaply proving no
cascade isn't possible without re-deriving visibility). See
try_incremental_node_update for the full reasoning.
Selection semantics
Selection on a sorted/filtered tree view is tracked by flat (visible)
index, mirroring SortFilterListModel. After a projection rebuild a
downstream SelectionModel keeps the same
numerical indices selected even though they may now point at different
nodes. Apps that want identity-based selection should observe
version_signal() and rewrite the selection from NodeIds after each
bump.
#![allow(unused)] fn main() { use teksilo_data::{TreeModel, SortFilterTreeModel, SortDirection, TreeFilterMode}; let tree: TreeModel<&'static str> = TreeModel::new(); let src = tree.insert_root(0, "src"); let docs = tree.insert_root(1, "docs"); tree.insert_child(src, 0, "main.rs"); tree.insert_child(docs, 0, "readme.md"); let proxy = SortFilterTreeModel::new(tree) .filter_mode(TreeFilterMode::KeepAncestors) .with_comparator("name", |a: &&str, b: &&str| a.cmp(b)) .with_predicate("name", |text| { let needle = text.to_string(); Box::new(move |row: &&str| row.contains(&needle)) }); // Only roots visible initially (collapsed). assert_eq!(proxy.visible_count(), 2); proxy.set_filter("name", ".rs"); // KeepAncestors: src (parent of main.rs) stays visible even though it // doesn't match itself. assert!(proxy.visible_count() >= 1); proxy.clear_filters(); proxy.expand(src); assert_eq!(proxy.visible_count(), 3); // src + main.rs + docs }
Builder methods at a glance
with_comparator, with_predicate, filter_mode, sort_signal, filters_signal, set_sort, clear_sort, set_filter, clear_filters, visible_count, with_entry, visible_node_id, entry_at, flat_index_of, is_expanded, expand, collapse, toggle, expand_all, collapse_all, version_signal, first_changed_index, tree
API reference
📖 Full rustdoc API for this module
pub enum TreeFilterMode
Filter strategy used by SortFilterTreeModel.
#![allow(unused)] fn main() { pub enum TreeFilterMode { /* variants */ } }
Variants
HideNonMatching— Hide rows that don't match. Children of hidden parents stay hidden too.KeepAncestors— Keep ancestors of matching descendants visible (file-tree convention).KeepDescendants— Keep matching rows AND their entire subtree.
pub struct SortFilterTreeModel
Hierarchical projection over a TreeModel<T> driven by sort + filter
signals. Exposes a TreeSlice-shaped read API consumed by TreeTableView.
#![allow(unused)] fn main() { pub struct SortFilterTreeModel<T: 'static> { /* fields */ } }
Methods
pub fn new(tree: TreeModel<T>) -> Self
Wrap a TreeModel<T>. The projection starts as the identity
(everything visible, no sort, all roots collapsed).
pub fn with_comparator( self, col_id: impl Into<String>, cmp: impl Fn(&T, &T) -> Ordering + 'static, ) -> Self
Register a comparator for a column id. Chainable.
pub fn with_predicate( self, col_id: impl Into<String>, factory: impl Fn(&str) -> Box<dyn Fn(&T) -> bool> + 'static, ) -> Self
Register a predicate factory for a column id. Chainable.
pub fn filter_mode(self, mode: TreeFilterMode) -> Self
Set the filter mode (default KeepAncestors). Chainable.
pub fn sort_signal(&self, signal: Signal<Option<(String, SortDirection)>>)
Bind a sort signal — typically TreeTableView::sort_signal().
pub fn filters_signal(&self, signal: Signal<HashMap<String, String>>)
Bind a filters signal — typically TreeTableView::filters_signal().
pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection)
Set the active sort imperatively. Routes through the bound signal when present.
pub fn clear_sort(&self)
Clear the active sort.
pub fn set_filter(&self, col_id: &str, text: &str)
Set or clear a single column's filter.
pub fn clear_filters(&self)
Clear every column's filter.
pub fn visible_count(&self) -> usize
Number of currently visible (non-filtered, non-collapsed) nodes in the flat list.
pub fn with_entry<R>( &self, flat_index: usize, f: impl FnOnce(&T, &FlatEntry) -> R, ) -> Option<R>
Call f with the item and FlatEntry metadata at flat_index, returning f's result.
pub fn visible_node_id(&self, flat_index: usize) -> Option<NodeId>
Return the NodeId of the node at flat_index, or None if the index is out of range.
pub fn entry_at(&self, flat_index: usize) -> Option<FlatEntry>
Return a clone of the FlatEntry at flat_index, or None if out of range.
pub fn flat_index_of(&self, node: NodeId) -> Option<usize>
Return the flat index of node in the current visible list, or None
if it is not visible. O(1) — backed by a position map rebuilt on
every projection rebuild.
pub fn is_expanded(&self, node: NodeId) -> bool
Whether node is currently expanded in this projection.
pub fn expand(&self, node: NodeId)
Expand node, revealing its children in the flat list. Rebuilds and bumps the version signal.
pub fn collapse(&self, node: NodeId)
Collapse node, hiding its children. Rebuilds and bumps the version signal.
pub fn toggle(&self, node: NodeId)
Toggle the expanded state of node. Always rebuilds and bumps the version signal.
pub fn expand_all(&self)
Expand every node that has children, making the full tree visible.
pub fn collapse_all(&self)
Collapse every node, leaving only roots visible.
pub fn version_signal(&self) -> Signal<u64>
Bumps on every projection rebuild — bind in TreeTableView::build
at BindingLevel::Rebuild.
pub fn first_changed_index(&self) -> Option<usize>
First flat index whose content may differ from before the latest
projection rebuild — rows 0..index are the same nodes, at the
same depths, with the same expand state as before, so per-row
derived state (e.g. a measured row height) remains valid for them.
Equal to visible_count() when the visible list is unchanged.
None means unknown (no rebuild observed yet) — treat as a full
change. The value describes the latest rebuild only; read it
synchronously from a version_signal() observer (observers fire
inline on every bump, so per-change reads cannot miss a value).
pub fn tree(&self) -> TreeModel<T>
Return the underlying TreeModel handle for direct mutation outside the projection.
TreeChange
TreeChange — change notifications and stable node identifiers for tree collections.
NodeId is an opaque, stable handle for a node in a crate::TreeModel.
Because TreeModel is backed by a slotmap, NodeId values survive arbitrary
insertions, removals, and moves — only deleting the node itself invalidates it.
TreeChange describes exactly what mutated in the tree so that projections
(SortFilterTreeModel, TreeSlice) can refresh efficiently and emit
fine-grained divergence hints.
Consumers typically receive TreeChange values through an observer registered
via crate::TreeModel::observe_changes, which fires synchronously (before
the registering call returns) after each mutation. The projections listed above
subscribe internally; app code rarely needs to subscribe directly.
// TreeModel::observe_changes returns an ObserverHandle whose drop
// unregisters the callback — keep it alive for the observer's lifetime.
use teksilo_data::{TreeModel, TreeChange};
let tree: TreeModel<String> = TreeModel::new();
let _handle = tree.observe_changes(|change| {
println!("{change:?}");
});
tree.insert_root(0, "root".to_string());
// prints: NodeInserted { parent: None, index: 0, node: NodeId(...) }
API reference
📖 Full rustdoc API for this module
pub struct NodeId
Opaque identifier for a node in a TreeModel.
NodeId values are stable across mutations — inserting or removing other
nodes does not invalidate existing NodeId handles (they are SlotMap keys).
#![allow(unused)] fn main() { pub struct NodeId(slotmap::DefaultKey); }
pub enum TreeChange
Describes a mutation to a tree structure. Emitted by TreeModel<T> automatically.
#![allow(unused)] fn main() { pub enum TreeChange { /* variants */ } }
Variants
NodeInserted— A node was inserted as a child ofparentat the given index.parentisNonefor root-level insertions.NodeRemoved— A node (and its entire subtree) was removed.parentisNoneif it was a root-level node.NodeMoved— A node was moved to a new parent at the given index.NodeUpdated— A node's data was updated in place.Reset— The entire tree was replaced. Consumers should discard all state and rebuild.
TreeCheckedModel
TreeCheckedModel — per-node checkbox state for a tree, with optional
descendant→ancestor tristate aggregation.
Companion to crate::CheckedModel for trees. Defaults to the standard
"Outlook folder selection" semantic: a parent's state is Checked if all
descendants are checked, Unchecked if none, Indeterminate otherwise;
toggling a parent cascades Checked/Unchecked down to all descendants.
Set the mode to AggregateMode::None to give every node independent
state instead. The model is a share-by-clone handle (Rc<RefCell<…>>
internally) — cloning produces a second view onto the same checkbox state.
External writes (e.g. a Checkbox widget bound to
signal_for(node) setting it directly) trigger the same
cascade-and-recompute pass as the model's own
check/uncheck/toggle methods, via per-node observers. A
re-entry guard prevents the cascade pass from re-firing
observers it triggers itself.
Example
#![allow(unused)] fn main() { use teksilo_data::{TreeModel, TreeCheckedModel, CheckState}; let tree = TreeModel::new(); let root = tree.insert_root(0, "root"); let child_a = tree.insert_child(root, 0, "a"); let child_b = tree.insert_child(root, 1, "b"); let model = TreeCheckedModel::new(tree); // Pre-register signal chains before mutating. let _ = (model.signal_for(root), model.signal_for(child_a), model.signal_for(child_b)); model.check(child_a); assert_eq!(model.check_state(root), CheckState::Indeterminate); model.check(child_b); assert_eq!(model.check_state(root), CheckState::Checked); }
Limitation: tree-mutation desync
signal_for(node) and bool_signal_for(node) cache signals keyed
by NodeId. The cache is never invalidated. If the underlying
TreeModel<T> mutates (remove, move_node, etc.) the cached
entry for a removed NodeId lingers indefinitely:
checked_nodes()may include a staleNodeIdwhose underlying tree node no longer exists. Callers that consume this list should validate each id against the current tree state before acting on it.bool_signal_for/signal_forfor a removed node still return their cached signal handle. Setting it has no observable effect on the tree (the cascade walkstree.children(node)which is empty for a freed node).
This is an acceptable trade-off because NodeIds are not reused
by TreeModel (slotmap keys are versioned), so a stale id can
never alias a fresh node. If a future use case needs strict
invalidation on removal, subscribe to TreeModel's change events
and clear the relevant entries. Tracked as out-of-scope for V1.
Builder methods at a glance
with_mode, aggregate_mode, set_aggregate_mode, signal_for, bool_signal_for, check_state, check, uncheck, toggle, checked_nodes, clear
API reference
📖 Full rustdoc API for this module
pub enum AggregateMode
How a parent's CheckState relates to its descendants.
#![allow(unused)] fn main() { pub enum AggregateMode { /* variants */ } }
Variants
None— Each node owns its state independently; parent states do not reflect their descendants and cascades do not occur.DescendantsDriveAncestors— All-checked →Checked; all-unchecked →Unchecked; mixed →Indeterminate. Toggling a parent cascadesChecked/Uncheckedto all descendants and recomputes every ancestor. This is the default and corresponds to the "Outlook folder selection" tristate pattern.
pub struct TreeCheckedModel
Per-node checkbox state for a TreeModel<T>, with optional
descendant→ancestor tristate aggregation.
See the module documentation for the full semantics and limitations.
Clone to share the same checkbox state between multiple call sites.
#![allow(unused)] fn main() { pub struct TreeCheckedModel<T: 'static> { /* fields */ } }
Methods
pub fn new(tree: TreeModel<T>) -> Self
Create a new model wrapping tree with the default
AggregateMode::DescendantsDriveAncestors cascade behaviour.
pub fn with_mode(tree: TreeModel<T>, mode: AggregateMode) -> Self
Create a new model wrapping tree with an explicit AggregateMode.
pub fn aggregate_mode(&self) -> AggregateMode
Returns the current AggregateMode controlling cascade behaviour.
pub fn set_aggregate_mode(&self, mode: AggregateMode)
Change the cascade behaviour; takes effect on the next write to any node's signal.
pub fn signal_for(&self, node: NodeId) -> Signal<CheckState>
Writable Signal<CheckState> for node. Cached: repeat calls
return the same root. External writes (e.g. from a Checkbox)
trigger the configured aggregation pass. The cascade observer is
wired idempotently — including for a signal first materialised by
a cascade (write_state) before its own signal_for was ever called
(a lazily/virtualized-realised row) — so a later external write to it
still cascades.
pub fn bool_signal_for(&self, node: NodeId) -> Signal<bool>
Two-state projection of signal_for for callers that want
to bind a leaf's check state to a Signal<bool>-shaped widget
(e.g. a non-tristate Checkbox). The returned signal is
writable: setting it to true calls check(node) (which
runs the configured cascade), false calls uncheck(node).
Writes from the model side propagate back into the bool signal
(Checked → true, anything else → false). Cached: repeat
calls return the same handle.
For leaves under AggregateMode::DescendantsDriveAncestors
this is the right pairing — a leaf's state is two-state by
nature, and the model's ancestor recompute still runs. For
branches you typically want the tristate signal_for so
Indeterminate is visible.
pub fn check_state(&self, node: NodeId) -> CheckState
Returns the current CheckState for node (defaults to Unchecked
if the node's signal has never been written or read).
pub fn check(&self, node: NodeId)
Set node to CheckState::Checked, triggering the configured cascade and
ancestor recompute; notifies observers of every affected node's signal.
pub fn uncheck(&self, node: NodeId)
Set node to CheckState::Unchecked, triggering the configured cascade and
ancestor recompute; notifies observers of every affected node's signal.
pub fn toggle(&self, node: NodeId)
Toggle node's check state: under DescendantsDriveAncestors a leaf
cycles two-state (Unchecked ↔ Checked); a branch or AggregateMode::None
cycles the full tristate sequence via CheckState::next_tristate.
pub fn checked_nodes(&self) -> Vec<NodeId>
Returns all NodeIds whose current state is exactly CheckState::Checked.
Note: may include stale ids if the underlying tree has been mutated since the signals were first registered — see the module-level limitation note.
pub fn clear(&self)
Reset all known nodes to CheckState::Unchecked and notify observers.
Writes every tracked node directly via the internal write_state
helper (per-node cascade-suppressed, like the recompute pass) instead of going
through check/uncheck's normal signal_for(..).set(..) path —
the latter would, for every currently-checked node, cascade the
write down its entire descendant subtree and recompute every
ancestor up to the root, all before the outer loop even reaches
those same nodes. Since every tracked node ends up Unchecked here,
there is nothing left to aggregate: "all children unchecked" is
already the correct parent state, so skipping the cascade and
ancestor recompute entirely still leaves every node's state
consistent — one direct write per tracked node instead of a
cascade+recompute pass per checked one.
TreeDataSlice
TreeDataSlice — the reusable TreeDataSource engine for an external,
indent-ordered tree (a Qleany entity store, a database, a virtual
filesystem) that is NOT mirrored into a TreeModel.
TreeSlice gives per-view expand state + flattening +
divergence to a TreeModel. TreeDataSlice gives the same machinery to
a source whose identity is a domain key (K = i64 entity id, a tagged enum,
…) and whose natural shape is a flat, pre-order, indent-annotated row stream
— the shape an outline is genuinely stored in (Scrivener-class binders /
chapters / scenes, OPML, Markdown headings). The app hands over
Vec<``TreeRow``<K, T>> ({ key, item, depth }, document order) on every
(re)load; the engine owns everything else:
- tree derivation — parent links + child index + roots + structural depth, derived from the indent sequence (an item's parent is the nearest preceding row of strictly smaller depth; depth-0 rows are roots);
- per-view expand state — a
K-keyed set, so two slices over the same source expand independently and expand survives a full re-source; - collapse-aware flattening into the visible row list;
- divergence (
first_changed_index) — the common-prefix of the old vs new visible rows, comparing key + depth + has-children + expand and item content (hence theT: PartialEqbound), so a consumer caching per-row state (a measured row height) keeps its valid prefix across reloads and expand toggles; - DnD mechanism — the cycle guard +
can_accept/accept_dropplumbing; domain policy is injected as closures (TreeDataSlice::set_drag_policy,TreeDataSlice::set_drop_resolver,TreeDataSlice::set_reorder).
It is a cheap Rc-handle (clone = share, like ListModel / SceneModel):
pass one clone to TreeView::from_source and keep another to drive
reload / set_rows from
the app.
Wiring an external source
use teksilo_data::{TreeDataSlice, TreeRow};
use teksilo_data::dnd_types::{DragEligibility, DropPosition};
// key = entity id, item = the row's display data
let slice: TreeDataSlice<u64, String> = TreeDataSlice::new();
slice.set_expand_new_nodes(true); // new nodes appear expanded
slice.set_source(|| vec![ // your `rows::load`
TreeRow::new(1, "Binder".to_string(), 0),
TreeRow::new(2, "Chapter".to_string(), 1),
TreeRow::new(3, "Scene".to_string(), 2),
]);
slice.set_drag_policy(|key| if *key == 1 { DragEligibility::NoDrag } else { DragEligibility::CanDrag });
slice.set_reorder(|_dragged, _target, _pos: DropPosition| { /* backend move + undo */ true });
slice.reload();
assert_eq!(slice.visible_count(), 3); // all expanded
// let view = TreeView::from_source(slice.clone(), delegate);
Builder methods at a glance
set_source, set_reorder, set_drag_policy, set_drop_resolver, set_expand_new_nodes, from_rows, reload, set_rows, visible_count, with_entry, with_key, key_at, entry_at, depth_at, flat_index_of, contains_key, parent_of, child_keys_of, is_expanded, expand, collapse, toggle, expand_all, collapse_all, expanded_keys, set_expanded_keys, version_signal, first_changed_index, set_all_expanded, all_expanded
API reference
📖 Full rustdoc API for this module
pub struct TreeRow
One row the app hands to a TreeDataSlice, in document (pre-)order.
depth is the indent level (0 = a root). The engine derives each row's
parent, children, and structural depth from the depth sequence: a row's
parent is the nearest preceding row with a strictly smaller depth. The row
stream must be well-formed pre-order (a parent precedes its subtree) — the
shape any indent-stored outline already has.
#![allow(unused)] fn main() { pub struct TreeRow<K, T> { /* fields */ } }
Methods
pub fn new(key: K, item: T, depth: usize) -> Self
Convenience constructor, leaving has_children to be derived from the
stream — see the field, and with_children for the
case where it cannot be.
pub fn with_children(mut self, has_children: bool) -> Self
Declare whether this row has children, rather than letting the stream say.
For a source that materialises a branch on expand. See
has_children for why a lazy source cannot work
without it.
pub struct TreeDataSlice
Per-view flattened projection of an external, indent-ordered tree source.
See the module documentation.
#![allow(unused)] fn main() { pub struct TreeDataSlice<K: ItemKey, T> { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty slice. Configure it (set_source / set_reorder /
policies / set_expand_new_nodes) then populate with
reload or set_rows.
pub fn set_source(&self, f: impl Fn() -> Vec<TreeRow<K, T>> + 'static)
Install the row source (rows::load). reload and a
committed drop call it to re-materialise the tree.
pub fn set_reorder(&self, f: impl Fn(K, K, DropPosition) -> bool + 'static)
Install the reorder command (dragged, target, position -> applied).
Without one, drops are refused.
pub fn set_drag_policy(&self, f: impl Fn(&K) -> DragEligibility + 'static)
Install the per-row drag gate. Without one, no row is draggable.
pub fn set_drop_resolver( &self, f: impl Fn(&K, &K, &T, DropPosition) -> Option<DropPosition> + 'static, )
Install the domain drop resolver. The engine's cycle guard (no drop into
your own subtree, no self-drop) runs first, then hands the resolver
(dragged, target, target_item, position); return Some(pos) to accept
at pos (a different pos snaps the indicator, i.e.
DropResponse::Redirect) or None to forbid. Without one, any
non-cyclic drop is accepted at the requested position.
pub fn set_expand_new_nodes(&self, expand: bool)
Whether nodes appearing for the first time start expanded (true) or
collapsed (false, the default, matching TreeSlice). Set this before
the first populate to affect the initial rows.
pub fn from_rows(rows: Vec<TreeRow<K, T>>) -> Self
Build a slice directly from an initial row stream (no version bump / no divergence — construction is not a change).
pub fn reload(&self) where T: PartialEq,
Re-source the rows via the set_source closure and
reproject. No-op if no source is installed.
pub fn set_rows(&self, rows: Vec<TreeRow<K, T>>) where T: PartialEq,
Replace the rows with a freshly-sourced stream, preserving per-view
expand state by key, computing first_changed_index,
and bumping the version signal.
pub fn visible_count(&self) -> usize
Number of currently-visible (flattened) rows.
pub fn with_entry<R>( &self, flat_index: usize, f: impl FnOnce(&T, &FlatEntry<K>) -> R, ) -> Option<R>
Access the item + flat metadata at a visible index via callback.
pub fn with_key<R>(&self, key: &K, f: impl FnOnce(&T) -> R) -> Option<R>
Access a node's item by key via callback, regardless of visibility (a
node hidden under a collapsed ancestor is still reachable). Returns None
if the key is absent from the source. The by-key counterpart of
with_entry (which is by visible index) — use it to
resolve a key to its domain payload.
pub fn key_at(&self, flat_index: usize) -> Option<K>
The key of the row at a visible index.
pub fn entry_at(&self, flat_index: usize) -> Option<FlatEntry<K>>
The FlatEntry at a visible index (cloned).
pub fn depth_at(&self, flat_index: usize) -> usize
Structural depth at a visible index (0 for a root).
pub fn flat_index_of(&self, key: &K) -> Option<usize>
The visible index of a key, if currently visible.
pub fn contains_key(&self, key: &K) -> bool
Whether key still exists in the source, independent of visibility (a
node hidden under a collapsed ancestor still exists).
pub fn parent_of(&self, key: &K) -> Option<K>
The parent of a node (None for a root or an absent key).
pub fn child_keys_of(&self, key: &K) -> Vec<K>
The children of a node, in order (empty for a leaf / absent key). O(children).
pub fn is_expanded(&self, key: &K) -> bool
Whether the node is effectively expanded (its children shown) — true
for every branch while the set_all_expanded
reveal override is on, otherwise its per-view expand state. Use
expanded_keys for the persistent set.
pub fn expand(&self, key: &K) where T: PartialEq,
Expand a node (make its children visible).
pub fn collapse(&self, key: &K) where T: PartialEq,
Collapse a node (hide its children).
pub fn toggle(&self, key: &K) where T: PartialEq,
Toggle a node's expand state.
pub fn expand_all(&self) where T: PartialEq,
Expand every node that has children.
pub fn collapse_all(&self) where T: PartialEq,
Collapse every node (only roots remain visible).
pub fn expanded_keys(&self) -> Vec<K>
The currently-expanded keys (for persistence).
pub fn set_expanded_keys(&self, keys: &[K]) where T: PartialEq,
Restore expanded state (for persistence). Keys absent from the source are ignored on the next reflatten.
pub fn version_signal(&self) -> Signal<u64>
Version signal — bind at BindingLevel::Rebuild. Bumps on every
set_rows / expand / collapse.
pub fn first_changed_index(&self) -> Option<usize>
First visible index whose content may differ after the latest change —
rows 0..index are unchanged (same key, depth, has-children, expand, and
item content), so per-row derived state remains valid for them. Equal to
visible_count() when nothing visible changed; None before the first
change (construction is not a change).
pub fn set_all_expanded(&self, on: bool) where T: PartialEq,
Reveal override for a filtered view: when on, the flatten treats every
node as expanded, so all rows in the (already sort/filter-narrowed) stream
are visible — the ancestors TreeRowFilter::KeepAncestors keeps no longer
hide their matching descendants. The per-view expand set is preserved
underneath, so turning it off restores the user's real collapse state.
Flip it on with the filter and off when it clears. No-op if unchanged.
pub fn all_expanded(&self) -> bool
Whether the reveal-all override is on (see set_all_expanded).
TreeDataSource
TreeDataSource — read-and-command interface for hierarchical data behind a
TreeView / TreeTableView.
TreeDataSource is to trees what ListDataSource
is to flat lists: a projected, per-view, flattened read API plus the
capability protocol for identity, DnD validation, and lazy loading.
The built-in TreeSlice and
SortFilterTreeModel implement it over an
in-memory TreeModel; an external source of truth
(e.g. a Qleany entity store) implements it directly with its own Key type
and so never needs to mirror itself into a TreeModel.
When to use
Implement TreeDataSource directly when your data already lives outside an
in-memory tree (a database, a virtual filesystem, a remote store) and you
do not want to mirror it into a TreeModel. Use TreeSlice
when you have a TreeModel<T> and want per-view expand state.
Example
use teksilo_data::{TreeDataSource, FlatEntry, NodeId};
use teksilo_data::dnd_types::{DragEligibility, DropQuery, DropResponse, DropCommit, RowState};
use teksilo_core::signal::Signal;
struct MySource { version: Signal<u64> }
impl TreeDataSource for MySource {
type Item = String;
type Key = NodeId;
fn visible_count(&self) -> usize { 0 }
fn with_entry<R>(&self, _i: usize, _f: impl FnOnce(&String, &FlatEntry<NodeId>) -> R) -> Option<R> { None }
fn key_at(&self, _i: usize) -> Option<NodeId> { None }
fn flat_index_of(&self, _k: &NodeId) -> Option<usize> { None }
fn parent(&self, _k: &NodeId) -> Option<NodeId> { None }
fn child_keys(&self, _k: &NodeId) -> Vec<NodeId> { vec![] }
fn version_signal(&self) -> Signal<u64> { self.version.clone() }
fn is_expanded(&self, _k: &NodeId) -> bool { false }
fn set_expanded(&self, _k: &NodeId, _expanded: bool) {}
}
API reference
📖 Full rustdoc API for this module
pub struct FlatEntry
A single entry in a tree's flattened, currently-visible row list.
Generic over the key type so external sources carry their own identity
(K = NodeId for TreeModel-backed sources, K = i64 for an entity-id
store, …). The default K = NodeId keeps every in-tree FlatEntry mention
and entry.node_id read compiling unchanged.
#![allow(unused)] fn main() { pub struct FlatEntry<K: ItemKey = NodeId> { /* fields */ } }
pub fn tree_is_desc_or_self(...)
Whether node is ancestor or one of its descendants — the move cycle
guard (you cannot drop a node into its own subtree).
#![allow(unused)] fn main() { pub fn tree_is_desc_or_self<T: 'static>( tree: &TreeModel<T>, node: NodeId, ancestor: NodeId, ) -> bool; }
pub fn tree_apply_reorder(...)
Apply a tree reorder by NodeId, with the cycle guard and the
remove-then-insert index adjustment TreeModel::move_node requires. Shared
by the TreeSlice / SortFilterTreeModel accept_drop impls. Returns
whether the move was applied (false = rejected, e.g. cycle or self-drop).
#![allow(unused)] fn main() { pub fn tree_apply_reorder<T: 'static>( tree: &TreeModel<T>, source: NodeId, target: NodeId, position: DropPosition, ) -> bool; }
TreeModel
TreeModel — concrete reactive tree with shared, cloneable handles.
TreeModel<T> owns a hierarchy of T items in a flat SlotMap arena with
parent-child links. Every structural mutation (insert_root, insert_child,
remove, move_node, update) emits a TreeChange to all registered
observers before returning. Node identity is a stable, versioned NodeId
(a SlotMap key) that is never reused after removal.
Cloning produces a second handle to the same data — all handles see the
same hierarchy and receive the same change notifications. Register observers
via observe_changes; the returned
ObserverHandle is RAII — dropping it
unregisters the callback.
For per-view expand/collapse state wrap the model in a
TreeSlice. For sort/filter projections use
SortFilterTreeModel.
Example
#![allow(unused)] fn main() { use teksilo_data::{TreeModel, TreeChange}; let tree = TreeModel::new(); let root = tree.insert_root(0, "root"); let child = tree.insert_child(root, 0, "child"); assert_eq!(tree.root_count(), 1); assert_eq!(tree.child_count(root), 1); assert_eq!(tree.parent(child), Some(root)); let clone = tree.clone(); clone.insert_root(1, "root2"); assert_eq!(tree.root_count(), 2); // both handles share the same data }
Builder methods at a glance
root_count, root, child_count, child, parent, depth, has_children, children, with_item, find_by, insert_root, insert_child, remove, move_node, move_to_root, update, observe_changes, debug_named
API reference
📖 Full rustdoc API for this module
pub struct TreeModel
A concrete reactive tree that stores a hierarchy of T items in a flat arena.
TreeModel<T> is Clone — cloning produces a second handle to the same
underlying data. All handles see the same hierarchy and receive the same
TreeChange notifications from observe_changes.
Nodes are identified by opaque NodeId handles that are stable and
non-reusable across mutations (versioned SlotMap keys).
#![allow(unused)] fn main() { pub struct TreeModel<T: 'static> { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty tree model with no roots and no observers.
pub fn root_count(&self) -> usize
Number of root-level nodes.
pub fn root(&self, index: usize) -> NodeId
Get the NodeId of a root-level node by index.
Panics
Panics if index >= root_count().
pub fn child_count(&self, parent: NodeId) -> usize
Number of children of the given node.
pub fn child(&self, parent: NodeId, index: usize) -> NodeId
Get the NodeId of a child by parent and index.
Panics
Panics if the parent or index is invalid.
pub fn parent(&self, node: NodeId) -> Option<NodeId>
Get the parent of a node, or None if it is a root.
pub fn depth(&self, node: NodeId) -> usize
Compute the depth of a node (0 for roots).
pub fn has_children(&self, node: NodeId) -> bool
Whether the given node has any children.
pub fn children(&self, node: NodeId) -> Vec<NodeId>
Get the children of a node as a vector of NodeId.
pub fn with_item<R>(&self, node: NodeId, f: impl FnOnce(&T) -> R) -> Option<R>
Access a node's data via a callback. Returns None if the node doesn't exist.
pub fn find_by(&self, predicate: impl Fn(&T) -> bool) -> Option<NodeId>
Find the first node matching a predicate (depth-first from roots).
pub fn insert_root(&self, index: usize, item: T) -> NodeId
Insert a new root-level node at the given index.
Panics
Panics if index > root_count().
pub fn insert_child(&self, parent: NodeId, index: usize, item: T) -> NodeId
Insert a new child node under the given parent at the given index.
Panics
Panics if the parent is invalid or index > child_count(parent).
pub fn remove(&self, node: NodeId)
Remove a node and its entire subtree.
Panics
Panics if the node is invalid.
pub fn move_node(&self, node: NodeId, new_parent: NodeId, new_index: usize)
Move a node (and its subtree) to a new parent at the given index.
Panics
Panics if any of the nodes are invalid, or if the target is a descendant of the source (would create a cycle).
pub fn move_to_root(&self, node: NodeId, new_index: usize)
Move a node to the root level at the given index.
pub fn update(&self, node: NodeId, item: T)
Update a node's data in place.
Panics
Panics if the node is invalid.
pub fn observe_changes(&self, f: impl Fn(&TreeChange) + 'static) -> ObserverHandle
Register an observer for tree change notifications.
Returns an ObserverHandle — dropping it removes the callback.
pub fn debug_named(self, _name: impl Into<String>) -> Self
Register this tree with the debug inspector under name. In
release builds (!cfg(debug_assertions)) this is a no-op
pass-through so call sites stay free of #[cfg] lines.
Idempotent on repeated calls — the latest registration wins.
The registration drops automatically when the last TreeModel
handle is freed (the adapter the registry holds is Weak).
TreeRowFilter
TreeRowFilter — sort + tree-aware filter over a TreeRow stream.
The composable sort/filter stage for the TreeDataSlice
pipeline. Where SortFilterTreeModel is a full
projection over an in-memory TreeModel (it owns its own expand state), an
external tree already has its expand/flatten projection — the
TreeDataSlice. Stacking a second projection on top would mean two expand
states. So for external trees, sort/filter belongs below the slice, as a
transform of its raw indent-ordered input:
rows::load() → TreeRowFilter::apply → TreeDataSlice::set_source → TreeView
\___ Vec<TreeRow> → Vec<TreeRow> ___/ \___ the one projection ___/
It uses the same three TreeFilterMode strategies and sorts siblings per
parent, then re-emits a valid indent-ordered stream (surviving nodes' depths
are compacted onto their nearest surviving ancestor, which TreeDataSlice
re-derives into a clean tree):
KeepAncestors— a node stays if it matches or any descendant matches (the outline-search behaviour; equivalent toSortFilterTreeModel).HideNonMatching— a node stays only if it and every ancestor match (children of a hidden parent stay hidden; equivalent toSortFilterTreeModel).KeepDescendants— a match keeps its whole subtree, surfaced even when the match's own ancestors don't match (the subtree compacts onto a root). This deliberately differs fromSortFilterTreeModel, whose flatten drops a match unless its full ancestor path is visible — which defeats the mode's "keep the match and its subtree" intent.
Revealing the matches
TreeRowFilter reshapes the rows; it does not touch the slice's per-view
expand state. So KeepAncestors keeps the ancestor rows, but a
freshly-collapsed TreeDataSlice still hides the matches under them. While a
filter is active, flip the slice's reveal override so the whole narrowed
result shows; turn it off when the filter clears (the user's real collapse
state is preserved underneath):
let filtered = !query.is_empty();
slice.set_source(move || if filtered { sieve.apply(load()) } else { load() });
slice.reload();
slice.set_all_expanded(filtered); // reveal while searching, restore after
Example
use teksilo_data::{TreeRowFilter, TreeRow, TreeFilterMode};
let rows = vec![
TreeRow::new(1u64, "Book One", 0),
TreeRow::new(2, "Opening", 1),
TreeRow::new(3, "The Dawn Raid", 1),
TreeRow::new(4, "Notes", 0),
];
// Outline search: keep matches and the folders that lead to them.
let sieve = TreeRowFilter::new()
.filter_mode(TreeFilterMode::KeepAncestors)
.filter(|title: &&str| title.contains("Dawn"));
let out = sieve.apply(rows);
// "Book One" (ancestor of the match) + "The Dawn Raid".
assert_eq!(out.iter().map(|r| r.item).collect::<Vec<_>>(), vec!["Book One", "The Dawn Raid"]);
Builder methods at a glance
filter_mode, filter, sort, sort_desc, apply
API reference
📖 Full rustdoc API for this module
pub struct TreeRowFilter
A reusable sort + tree-aware filter over a Vec<``TreeRow``<K, T>>. Build
it once, apply it to each freshly-sourced row stream (e.g.
inside a TreeDataSlice::set_source closure). See the module docs.
#![allow(unused)] fn main() { pub struct TreeRowFilter<K: ItemKey, T> { /* fields */ } }
Methods
pub fn new() -> Self
An identity transform (no filter, no sort). Chain filter
/ sort to configure it.
pub fn filter_mode(mut self, mode: TreeFilterMode) -> Self
Set the filter strategy (how ancestors/descendants of a match are kept).
Defaults to TreeFilterMode::default().
pub fn filter(mut self, pred: impl Fn(&T) -> bool + 'static) -> Self
Set the match predicate over the row item. A row "matches" when pred
returns true; the filter_mode decides what else
stays visible. With no predicate every row is kept.
pub fn sort(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self
Sort siblings (ascending) by a comparator on the row item. Parent/child structure is preserved — only the order within each parent changes.
pub fn sort_desc(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self
Sort siblings (descending) by a comparator on the row item.
pub fn apply(&self, rows: Vec<TreeRow<K, T>>) -> Vec<TreeRow<K, T>>
Apply the filter + sort to an indent-ordered row stream, returning a new
indent-ordered stream. O(n log n) for the sort, O(n) otherwise.
TreeSlice
TreeSlice — per-view flattened projection of a TreeModel.
TreeSlice<T> wraps a TreeModel<T> and maintains an independent
expand/collapse set so two TreeView widgets sharing the same model have
independent visible rows — dual-pane file managers, overview/detail splits,
and search results panels are each one TreeSlice::new(model.clone()). The
slice re-flattens automatically whenever the underlying model emits a
TreeChange, and bumps a version_signal
Signal<u64> that views bind at BindingLevel::Rebuild.
A lightweight TreeSliceHandle (created via TreeSlice::handle) shares
all Rc-based internals and is usable in closures without keeping the
tree-change observer alive.
TreeSlice implements TreeDataSource and is the
built-in source for TreeView / TreeTableView.
Example
#![allow(unused)] fn main() { use teksilo_data::{TreeModel, TreeSlice}; let tree = TreeModel::new(); let root = tree.insert_root(0, "root"); let child = tree.insert_child(root, 0, "child"); let slice1 = TreeSlice::new(tree.clone()); let slice2 = TreeSlice::new(tree.clone()); slice1.expand(root); assert_eq!(slice1.visible_count(), 2); // root + child visible assert_eq!(slice2.visible_count(), 1); // still collapsed in slice2 // Inserting into the model notifies both slices. tree.insert_child(root, 1, "child2"); assert_eq!(slice1.visible_count(), 3); // child2 also visible in the expanded slice }
Builder methods at a glance
visible_count, with_entry, visible_node_id, entry_at, depth_at, flat_index_of, is_expanded, expand, collapse, toggle, expand_all, collapse_all, expanded_nodes, set_expanded_nodes, version_signal, first_changed_index, tree, handle
API reference
📖 Full rustdoc API for this module
pub struct TreeSlice
Per-view flattened projection of a TreeModel<T>.
Owns an independent expand/collapse set and re-flattens automatically on
every TreeChange from the underlying model. Two slices
over the same model have completely independent expand state. See the
module documentation for the full picture.
#![allow(unused)] fn main() { pub struct TreeSlice<T: 'static> { /* fields */ } }
Methods
pub fn new(tree: TreeModel<T>) -> Self
Create a new TreeSlice for the given TreeModel.
All nodes start collapsed (only roots are visible).
pub fn visible_count(&self) -> usize
Number of currently visible (flattened) rows.
pub fn with_entry<R>( &self, flat_index: usize, f: impl FnOnce(&T, &FlatEntry) -> R, ) -> Option<R>
Access a flat entry by index via callback.
The callback receives (&T, &FlatEntry).
pub fn visible_node_id(&self, flat_index: usize) -> Option<NodeId>
Get the NodeId at the given flat index.
pub fn entry_at(&self, flat_index: usize) -> Option<FlatEntry>
Get the FlatEntry at the given flat index (cloned).
pub fn depth_at(&self, flat_index: usize) -> usize
Get the depth at the given flat index.
pub fn flat_index_of(&self, node: NodeId) -> Option<usize>
Find the flat index for a given NodeId, or None if not visible.
O(1) — backed by a position map rebuilt on every reflatten.
pub fn is_expanded(&self, node: NodeId) -> bool
Whether the given node is expanded.
pub fn expand(&self, node: NodeId)
Expand a node (make its children visible).
pub fn collapse(&self, node: NodeId)
Collapse a node (hide its children).
pub fn toggle(&self, node: NodeId)
Toggle expand/collapse state of a node.
pub fn expand_all(&self)
Expand all nodes in the tree.
pub fn collapse_all(&self)
Collapse all nodes in the tree.
pub fn expanded_nodes(&self) -> Vec<NodeId>
Get all expanded node IDs (for persistence).
pub fn set_expanded_nodes(&self, nodes: &[NodeId])
Restore expanded state (for persistence).
pub fn version_signal(&self) -> Signal<u64>
Get the version signal for binding to BindingLevel::Rebuild.
pub fn first_changed_index(&self) -> Option<usize>
First flat index whose content may differ from before the latest
reflatten — the rows 0..index are the same nodes, at the same
depths, with the same expand state as before, so any per-row
derived state (e.g. a measured row height) remains valid for them.
Equal to visible_count() when the visible list is unchanged.
None means unknown (no reflatten observed yet) — treat as a full
change. The value describes the latest reflatten only; read it
synchronously from a version_signal() observer (observers fire
inline on every bump, so per-change reads cannot miss a value).
pub fn tree(&self) -> &TreeModel<T>
Access the underlying TreeModel.
pub fn handle(&self) -> TreeSliceHandle<T>
Create a lightweight handle for use in closures. Shares all Rc-based internals but does not keep the observer alive.
pub struct TreeSliceHandle
Lightweight handle to a TreeSlice's shared state, usable in closures.
Created via TreeSlice::handle. Shares all Rc-based internals with its
parent TreeSlice but does not keep the tree-change observer alive —
the TreeSlice that owns the observer must outlive all handles that rely on
automatic re-flattening on model changes.
#![allow(unused)] fn main() { pub struct TreeSliceHandle<T: 'static> { /* fields */ } }
Methods
pub fn visible_count(&self) -> usize
Number of currently-visible (flattened) rows.
pub fn entry_at(&self, flat_index: usize) -> Option<FlatEntry>
Get the FlatEntry at flat_index (cloned), or None if out of bounds.
pub fn visible_node_id(&self, flat_index: usize) -> Option<NodeId>
Get the NodeId at flat_index, or None if out of bounds.
pub fn expand(&self, node: NodeId)
Expand node (make its children visible) and bump the version signal.
No-op if already expanded.
pub fn collapse(&self, node: NodeId)
Collapse node (hide its children) and bump the version signal.
No-op if already collapsed.
pub fn is_expanded(&self, node: NodeId) -> bool
Returns true if node is currently expanded.
pub fn toggle_expand(&self, node: NodeId)
Toggle node's expand/collapse state and bump the version signal.
pub fn tree(&self) -> &TreeModel<T>
Access the underlying TreeModel.
pub fn expand_all(&self)
Expand every node with children — see TreeSlice::expand_all. Useful
after a model rebuild reassigns NodeIds (the old expand set no longer
matches), to keep the view fully expanded.
pub fn first_changed_index(&self) -> Option<usize>
See TreeSlice::first_changed_index.
Settings
Every public type in teksilo-settings, grouped by category. Each page links to its full rustdoc API reference.
Collection
- Keyed —
PersistedListModel<T>— bridge between a reactive
Stores & services
- AppPaths — OS-correct path resolution for application config and data directories
- FlushError — Debounced, cross-process-safe atomic file writer
- MruEntry — Most-recently-used list — a generic, persisted reactive collection
- PerWindowState — Per-window geometry persistence via
WindowStateService - Reloadable —
Reloadable— the contract a (separately-built) file watcher uses to - SettingsBundleError —
SettingsBundle— declarative configuration for the teksilo-app - SettingsExt — Extension traits exposing settings services on
BuildContextand - SettingsFileError —
SettingsFile<T>— typed single-struct persistence - SettingsReloadSink — Live cross-process settings sync: a
notify-based directory watcher - SettingsStoreError — Dynamic, dotted-key K/V store backed by TOML
- Versioned — Schema migrations for persisted files
AppPaths
OS-correct path resolution for application config and data directories.
AppPaths wraps etcetera's native AppStrategy so the rest of the
crate has a single point of truth for where settings files live. In
production, AppPaths::new queries the OS (XDG on Linux,
%APPDATA% on Windows, ~/Library/Application Support on macOS); in
tests, AppPaths::for_testing roots everything inside a tempdir so no
test ever touches the user's real config tree.
Usage
Pass an AppPaths instance to SettingsBundle,
SettingsStore, or MruList; the
individual files within it are addressed by name via
config_file and
data_file.
use teksilo_settings::AppPaths;
// Production: returns None when no home directory is detectable.
if let Some(paths) = AppPaths::new("eu", "FernTech", "MyApp") {
let general_toml = paths.config_file("general");
let cache_toml = paths.data_file("cache");
}
// Tests: deterministic, tempdir-rooted, never touches user files.
let tmp = tempfile::tempdir().unwrap();
let paths = AppPaths::for_testing(tmp.path());
assert_eq!(paths.config_file("settings"), tmp.path().join("settings.toml"));
Builder methods at a glance
for_testing, from_dirs, config_dir, data_dir, config_file, data_file
API reference
📖 Full rustdoc API for this module
pub struct AppPaths
Resolved OS-correct application directories (config and data).
Construct with AppPaths::new for production code, or
AppPaths::for_testing in tests and headless CI environments.
Use AppPaths::from_dirs when the application manages its own
directory layout (e.g. portable mode).
#![allow(unused)] fn main() { pub struct AppPaths { /* fields */ } }
Methods
pub fn new(qualifier: &str, organization: &str, application: &str) -> Option<Self>
Resolve directories from the OS. The (qualifier, organization, application) triple feeds etcetera::AppStrategyArgs as
(top_level_domain, author, app_name) — same fields, different
names — and selects the platform-native strategy (XDG on Linux,
%APPDATA%-based on Windows, ~/Library/Application Support
on macOS).
Returns None when no usable home directory could be detected
(a sandboxed or unconfigured environment). Callers who want to
degrade gracefully should fall back to AppPaths::for_testing
with an in-process directory.
pub fn for_testing(root: &Path) -> Self
Construct an AppPaths rooted at an arbitrary directory. Used by
tests so that no test ever touches the user's real config tree.
Both config_dir and data_dir resolve to root.
pub fn from_dirs(config_dir: PathBuf, data_dir: PathBuf) -> Self
Construct from explicit config and data directories. Useful when an application wants to override one or both (e.g. portable mode).
pub fn config_dir(&self) -> &Path
The platform-correct config directory (XDG_CONFIG_HOME, %APPDATA%,
~/Library/Preferences, etc.).
pub fn data_dir(&self) -> &Path
The platform-correct data directory. Used for caches and per-window state — anything larger than a configuration file.
pub fn config_file(&self, name: &str) -> PathBuf
Resolve a per-concern config file by name (without extension).
name = "general" yields <config_dir>/general.toml.
pub fn data_file(&self, name: &str) -> PathBuf
Resolve a per-concern data file by name (without extension).
FlushError
Debounced, cross-process-safe atomic file writer.
DebouncedWriter accepts Patches — replayable mutations — via
schedule, batches rapid bursts inside a
debounce window, and then applies the whole batch to the document read from
disk under an exclusive advisory lock, writing the result atomically
(write-temp + fsync + rename).
Why a patch and not a rendered string
This writer used to carry a pre-rendered String: the caller serialised its
entire in-memory document and the worker blindly wrote those bytes. That is
last-write-wins by construction — the worker had nothing to merge with,
so any concurrent change a peer process made to another part of the file was
silently destroyed. (A lock alone does not fix this: it serialises the two
writes but does nothing about the stale snapshot one of them was rendered
from.)
A Patch instead says "given the file's current text, produce its new text",
so the merge happens against reality:
lock -> read current -> apply queued patches -> write atomically -> unlock
Patches are built inside this crate from owned snapshots of each type's
pending mutations (a Vec<(key, value)>, a list of ops), so they capture no
Rc and can cross to the worker thread. Callers never see one: they keep
writing signal.set(v), mru.add(e), file.mutate(|s| ..).
Single shared I/O thread
All DebouncedWriters in a process share one background I/O thread
(lazily started on first use). Each writer registers under a unique
WriterId. The shared thread:
- keeps a per-id
(deadline, patch queue), - blocks on the next-due deadline (or waits for a message if nothing is pending),
- coalesces rapid
Schedulebursts by appending to the queue, resetting the failure streak (a just-queued patch has never itself failed to write) and moving the deadline forward, never backward — so debouncing collapses writes, never mutations, and a liveRETRY_BACKOFFdeadline installed after a failed attempt can't be clobbered back to "now" by an unrelated new patch on a zero-delay writer. (The old design could overwrite the pending payload precisely because each payload was a complete, self-superseding rendering.)
A failed write retains the queue and retries with backoff, up to
MAX_WRITE_ATTEMPTS — the patches replay cleanly against whatever is on
disk then, which is the correct merge rather than a stale overwrite. Once
the cap is reached (or a writer is dropped mid-failure at process
teardown), the queue is discarded for good and reported through the
process-wide WriteFailureSink (registered via
set_write_failure_sink) in addition to the existing log — the write
side's analogue of crate::reload::Reloadable's read-side contract.
Conversely, every writer may also register a WriteLandedSink (via
DebouncedWriter::set_landed_sink) to learn the real on-disk stamp
the instant its queued patches land successfully — useful to a caller
whose own apply()-style API schedules a write and returns before it's
actually on disk.
The locked read-merge-write (apply_and_write) acquires its advisory
lock non-blocking: because every writer in the process shares this
one thread, a lock held by a peer process must never stall it — a
contended lock is just another transient FlushError::Io, retried
with the same backoff as any other write failure.
Application logic stays single-threaded — SettingsStore and friends never
block on I/O. Drop sends an Unregister that synchronously flushes the
queue before returning, so end-of-process state is never lost (unless the
flush itself is still failing, in which case the discard is reported
through WriteFailureSink exactly as above).
Why one thread, not one-per-writer
An app that opens the K/V store + recents + window state already has 3 writers; a richer app might have 5–10, each idle ~99% of the time. One shared worker is leaner and has identical semantics from the caller's point of view.
Builder methods at a glance
flush_now, set_landed_sink, path, delay
API reference
📖 Full rustdoc API for this module
pub enum FlushError
Errors surfaced by DebouncedWriter::flush_now.
#![allow(unused)] fn main() { pub enum FlushError { /* variants */ } }
Variants
Disconnected— The shared I/O worker thread has panicked or shut down; writes can no longer be delivered.Io— The atomic write (temp-file + rename) failed at the OS level.Merge— APatchcould not be applied to the document currently on disk — e.g. a peer wrote something this process cannot parse or migrate.
pub type WriteFailureSink
Invoked (off the caller's thread — on the shared worker thread) when a
DebouncedWriter's queued patches are permanently discarded: either
flush_writer gave up after MAX_WRITE_ATTEMPTS, or the writer was
dropped (Unregister) while its final flush was still failing. This is
the write-side analogue of crate::reload::Reloadable's read-side
contract — the previous behaviour was a bare eprintln! that never left
the worker thread, so a permanently unwritable settings file (read-only
mount, revoked permissions, disk full) silently ate every change for the
rest of the session with zero signal to the application. Registered
process-wide via set_write_failure_sink.
#![allow(unused)] fn main() { pub type WriteFailureSink = Arc<dyn Fn(PathBuf, u32, usize, String) + Send + Sync + 'static>; }
pub fn set_write_failure_sink(...)
Register a process-wide sink invoked whenever any DebouncedWriter
permanently discards a queued write (see WriteFailureSink). There is
only one slot: a later call replaces an earlier one. teksilo-app uses
this to forward the failure to the UI thread as a typed AppEvent.
#![allow(unused)] fn main() { pub fn set_write_failure_sink(sink: WriteFailureSink); }
pub type LandedStamp
The (mtime, len) stamp disk_stamp computes for a settings file —
named so every Arc<Mutex<...>> wrapping it (here and in
WindowStateService) reads as one term instead of clippy's
type_complexity-tripping nested-generics spelling.
#![allow(unused)] fn main() { pub type LandedStamp = (Option<SystemTime>, Option<u64>); }
pub type WriteLandedSink
Invoked on the shared worker thread the instant a DebouncedWriter's
queued patches land successfully, with the fresh on-disk (mtime, len)
stamp (one extra fs::metadata, computed once, right after the write —
negligible cost). The write-side analogue of WriteFailureSink. Send + Sync because it runs off the caller's thread — a consumer that needs to
update !Send state (an Rc<Cell<_>>) must copy the value out on its
own thread the next time it looks (see
WindowStateService::reload_from_disk).
#![allow(unused)] fn main() { pub type WriteLandedSink = Arc<dyn Fn(LandedStamp) + Send + Sync + 'static>; }
pub struct DebouncedWriter
Atomic, debounced single-file writer.
All writers in a process share one background I/O thread (see
module docs). Each writer is identified by an opaque WriterId;
dropping a writer synchronously flushes its pending payload before
returning.
#![allow(unused)] fn main() { pub struct DebouncedWriter { /* fields */ } }
Methods
pub fn new(path: PathBuf, delay: Duration) -> Self
Create a writer that will atomically write to path, coalescing
rapid schedule bursts inside delay.
delay = Duration::ZERO makes every schedule flush on the
worker's very next iteration — useful for tests.
pub fn flush_now(&self) -> Result<(), FlushError>
Force any queued patches to disk synchronously. Returns Ok(()) if
there was nothing queued.
pub fn set_landed_sink(&self, sink: WriteLandedSink)
Register a sink for this writer's successful-flush stamp (opt-in; a writer with none behaves exactly as today). May be called any time after construction — including after the writer has already flushed once, since the sink is only ever consulted on a future successful flush.
This is how a caller learns the real on-disk stamp resulting from
its own debounced write, without guessing: apply() schedules a
patch and returns before it lands, so only the worker thread — right
after the write actually succeeds — knows the resulting (mtime, len). See WindowStateService::reload_from_disk for the consumer
side (F11).
pub fn path(&self) -> &Path
The destination path this writer flushes to.
pub fn delay(&self) -> Duration
The debounce window configured at construction; Duration::ZERO
means every scheduled payload is written on the worker's next
iteration (useful in tests).
Keyed
PersistedListModel<T> — bridge between a reactive
ListModel<T> and a single TOML file,
merging by op, not by whole-document snapshot.
Why ops, not snapshots
The previous design re-derived the entire Vec<T> from the live model
on every mutation and scheduled a debounced write of that whole
snapshot. That is last-write-wins by construction: if a peer process
added an entry to the same file in the meantime, this process's next
flush would overwrite the peer's row right off the disk — the exact
"a newly-opened project vanishes from Recents" bug this crate exists to
fix.
Instead, every mutation records a small, replayable ListOp<T>
and hands it to the shared debounced writer as a [crate::flush::Patch]:
"given the file's current text, apply this one op to it." The patch is
applied against the document read fresh off disk, under a lock, at
flush time — so it replays cleanly on top of whatever a peer wrote in
the meantime, key by key, instead of overwriting the whole thing.
Identity
Every item needs a stable identity to merge by — see Keyed. Ops are
keyed, not indexed: Remove only needs to carry a key, never a value,
which is exactly what a diff of "what's gone" can always produce even
though the value itself is no longer available once removed.
Mutating through this type, not through .model()
.model() is for reading and for reactive binding (ListView /
Repeater) — every UI observer wants live updates regardless of who
mutates. Writing must go through upsert_front,
update_in_place,
remove and
clear: those are the only places that both
mutate the live model and enqueue the matching op. Mutating the
ListModel returned by .model() directly updates what's on screen but
is never persisted — there is no observer bridging arbitrary model
mutations to disk any more (that observer was the whole-snapshot
overwrite bug).
Example
use teksilo_settings::{Keyed, Migrator, PersistedListModel};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Serialize, Deserialize, Clone)]
struct Tag { name: String }
impl Keyed for Tag {
type Key = String;
fn key(&self) -> String { self.name.clone() }
}
let path = std::env::temp_dir().join("tags-list-doctest.toml");
let plm: PersistedListModel<Tag> =
PersistedListModel::open(path, Duration::ZERO, Migrator::new())
.expect("open failed");
plm.upsert_front(Tag { name: "rust".into() });
plm.flush_now().expect("flush");
Builder methods at a glance
open, model, upsert_front, update_in_place, remove, clear, flush_now, path
API reference
📖 Full rustdoc API for this module
pub enum ListOp
A replayable mutation of a PersistedListModel's backing list,
expressed by key so it can be applied to any starting Vec<T> —
in particular, the fresh one read off disk at flush time, which may
already include a peer process's concurrent changes.
#![allow(unused)] fn main() { pub enum ListOp<T: Keyed> { /* variants */ } }
Variants
UpsertFront— Remove any existing entry with this item's key, then insertTat the front. This is the "most recently used" operation: re-running it against any starting vector — including one a peer has already mutated — reproduces the same dedupe-and-promote-to-front invariantMruList::addrelies on.UpdateInPlace— Replace the entry with this item's key in place (no reordering). A no-op if the key is no longer present — e.g. a peer concurrently removed it, in which case that removal wins.Remove— Remove the entry with this key, if present. No-op otherwise.Clear— Drop every entry.
pub struct ListFile
On-disk shape for a persisted list: a versioned wrapper around
Vec<T>. Apps write migrations against this type, not the bare Vec.
#![allow(unused)] fn main() { pub struct ListFile<T> { /* fields */ } }
pub struct PersistedListModel
A reactive, Keyed-item list whose mutations persist to a single
TOML file by merging ops, not by overwriting a whole-document
snapshot.
#![allow(unused)] fn main() { pub struct PersistedListModel<T> where T: Keyed + Clone + Serialize + DeserializeOwned + Send + 'static, { /* fields */ } }
Methods
pub fn open( path: PathBuf, delay: Duration, migrator: Migrator<ListFile<T>>, ) -> Result<Self, SettingsFileError>
Open the file at path (running migrator, under an exclusive
lock so a peer mid-write can't hand us a torn read), seed the model
from its contents, and retain everything needed to enqueue op
patches on every mutation.
delay is the debounce window for writes — unlike
crate::SettingsFile, this type's writes are expected to be
frequent (every add/touch/remove on a live MRU list), so the
debounce is real and load-bearing here, not vestigial.
pub fn model(&self) -> &ListModel<T>
The underlying reactive list handle. Clone it to share with
Repeater / ListView widgets for reading. See the module
docs: mutating the returned handle directly does not persist —
use this type's own mutation methods instead.
pub fn upsert_front(&self, item: T)
Insert item at the front, deduping by item.key() (removing any
existing entry with the same key first). Updates the live model
immediately and enqueues the matching ListOp::UpsertFront.
pub fn update_in_place(&self, item: T) -> bool
Replace the entry with item.key() in place (no reordering).
Returns false (and does nothing) if no entry with that key
exists locally. Enqueues ListOp::UpdateInPlace on success.
pub fn remove(&self, key: &T::Key) -> bool
Remove the entry with this key, if present locally. Returns
whether anything was removed. Enqueues ListOp::Remove on
success.
pub fn clear(&self)
Drop every entry, locally and on disk.
pub fn flush_now(&self) -> Result<(), SettingsFileError>
Flush any pending op(s) to disk immediately, bypassing the debounce window. Flushes the op queue — never a re-derived snapshot of the in-memory list, which is exactly the mechanism that used to let a cleanly-exiting process erase a peer's newly-added entry.
pub fn path(&self) -> &Path
The absolute path of the TOML file being written to.
MruEntry
Most-recently-used list — a generic, persisted reactive collection with dedupe, pinning, and LRU-style cap eviction.
Apps define their own item type by implementing Keyed (a stable
identity) and MruEntry (pin / touch semantics). The framework
handles dedupe-on-add, pin-aware cap eviction, and cross-process-safe
persistence via PersistedListModel; the app owns the item schema.
When to use
Use MruList for any "recently opened / recently used" feature:
recent files, recent projects, recently visited locations, recently used
palette entries, etc. The backing ListModel<T> is the same reactive
handle you bind to a ListView or iterate in
a menu — no separate notification plumbing is required.
Persistence
MruList::open reads <config_dir>/<name>.toml on first access
(cross-process safe: the read is lock-protected, and every subsequent
mutation merges by key against the document on disk, never overwriting
the whole thing). Pass Duration::ZERO in tests to flush
synchronously, or call MruList::flush_now explicitly.
use std::path::PathBuf;
use std::time::Duration;
use teksilo_settings::{AppPaths, Keyed, MruEntry, MruList};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
struct RecentProject {
path: PathBuf,
display_name: String,
last_opened: u64,
pinned: bool,
}
impl Keyed for RecentProject {
type Key = PathBuf;
fn key(&self) -> PathBuf { self.path.clone() }
}
impl MruEntry for RecentProject {
fn is_pinned(&self) -> bool { self.pinned }
fn set_pinned(&mut self, p: bool) { self.pinned = p; }
fn touch(&mut self) { self.last_opened += 1; }
}
// In tests: AppPaths::for_testing(tmp.path()) + Duration::ZERO.
// In production: AppPaths::new(qualifier, org, app).
let tmp = tempfile::tempdir().unwrap();
let paths = AppPaths::for_testing(tmp.path());
let recents: MruList<RecentProject> =
MruList::open_with_delay(&paths, "recents", 10, Duration::ZERO).unwrap();
recents.add(RecentProject {
path: "/projects/foo".into(),
display_name: "Foo".into(),
last_opened: 0,
pinned: false,
});
assert_eq!(recents.model().len(), 1);
Builder methods at a glance
open, open_with_delay, open_at, model, max_items, add, remove, touch, set_pinned, is_pinned, clear, flush_now, path
API reference
📖 Full rustdoc API for this module
pub struct MruList
A persisted MRU list backed by PersistedListModel<T>.
Cheap to clone (Rc-shared internally). The reactive
ListModel<T> returned by model() is the same
handle the persistence bridge observes.
#![allow(unused)] fn main() { pub struct MruList<T: MruEntry> { /* fields */ } }
Methods
pub fn open(paths: &AppPaths, name: &str, max_items: usize) -> Result<Self, SettingsFileError>
Open at <paths.config_dir()>/<name>.toml with the default debounce window.
Creates the file (and any missing parent directories) if it does not
yet exist. Use open_with_delay to override
the debounce in tests.
pub fn open_with_delay( paths: &AppPaths, name: &str, max_items: usize, delay: Duration, ) -> Result<Self, SettingsFileError>
Open at <paths.config_dir()>/<name>.toml with a custom debounce window.
Pass Duration::ZERO in tests to flush
every mutation synchronously.
pub fn open_at( path: PathBuf, max_items: usize, delay: Duration, ) -> Result<Self, SettingsFileError>
Open at an explicit path with the given debounce window.
Lower-level alternative to open when the caller
already has a resolved PathBuf (e.g. from a custom directory layout).
pub fn model(&self) -> &ListModel<T>
The underlying reactive list; bind to UI widgets via clones of this handle.
Read-only for mutation purposes. Use add,
remove, touch,
set_pinned, clear to mutate —
those are what enqueue the matching persisted op. Mutating the
returned ListModel directly updates what's on screen but is
never written to disk.
pub fn max_items(&self) -> usize
Returns the maximum number of unpinned entries kept in the list.
Pinned entries do not count toward this cap and are never evicted automatically.
pub fn add(&self, mut entry: T)
Insert entry at the front, deduping by entry.key().
T::touch is invoked before insertion, so the freshly-added
entry reflects "now". If a previously-pinned entry is re-added
without pinned, the pin state is preserved.
pub fn remove<Q>(&self, key: &Q) where T::Key: Borrow<Q>, Q: Eq + ?Sized,
Remove the entry whose key matches, then schedule a debounced flush.
No-op when no entry with that key is present. Generic over Q so
callers can pass a borrowed form of the key (e.g. &Path when
T::Key = PathBuf, &str when T::Key = String) without having
to allocate an owned key just to look one up.
pub fn touch<Q>(&self, key: &Q) where T::Key: Borrow<Q>, Q: Eq + ?Sized,
Mark the entry whose key matches as freshly used by calling
MruEntry::touch on a clone of it, then write it back and
schedule a debounced flush. No-op when no entry matches.
pub fn set_pinned<Q>(&self, key: &Q, pinned: bool) where T::Key: Borrow<Q>, Q: Eq + ?Sized,
Set the pin flag of the entry whose key matches to exactly
pinned (idempotent — unlike a toggle, replaying this against an
already-applied peer change does not flip it back). No-op when no
entry matches.
pub fn is_pinned<Q>(&self, key: &Q) -> bool where T::Key: Borrow<Q>, Q: Eq + ?Sized,
Is the entry with this key currently pinned? false when no entry
matches.
The counterpart set_pinned deliberately takes the
desired value rather than toggling, because a toggle is not idempotent:
replayed against a peer process's already-applied toggle it would flip
the value straight back, inverting their change. A pin button still
needs to toggle, though — so read the current value here and pass its
negation:
let pinned = mru.is_pinned(path);
mru.set_pinned(path, !pinned);
pub fn clear(&self)
Drop every entry (pinned or not) and schedule a debounced flush.
pub fn flush_now(&self) -> Result<(), SettingsFileError>
Write the list to disk synchronously, bypassing the debounce window.
Useful at app shutdown or at the end of a test to guarantee the file reflects the in-memory state before the process exits.
pub fn path(&self) -> &Path
The TOML file path this list reads from and writes to.
PerWindowState
Per-window geometry persistence via WindowStateService.
Each named window — identified by a stable string label such as
"main" or "inspector" — can have its position, size, and
placement (Floating / Maximized / Fullscreen) saved across
sessions. In-memory is the source of truth: state_for reads
directly from memory without touching disk. On load, the file is
migrated through Migrator steps (currently v1 → v2: maximized: bool → placement: WindowPlacement) before deserializing, and
corrupt files are quarantined automatically by SettingsFile.
record is debounced, not synchronous
See WindowStateService's "Why this is debounced, unlike
SettingsFile" doc below for the full rationale: record/forget
update the in-memory state instantly and schedule a coalesced, locked
read-merge-write via a DebouncedWriter — a live window drag (which
calls record once per reported geometry frame) costs one disk
write per debounce window, not one per frame.
In a typical Teksilo app, WindowStateService is managed by the
framework's SettingsBundle and wired automatically when the
WindowConfig carries a stable id(...) — no widget-side plumbing
needed. The service is only used directly when building custom window
management or embedding it outside the standard TeksiloAppBuilder
path.
Wayland caveat
Wayland does not let applications choose their window position;
the compositor places windows. Position fields (x, y) are still
recorded and persisted (so the config roams across an X11/Wayland
switch), but a Wayland host must ignore them when restoring.
Width, height, and WindowPlacement are honored on every platform.
Example
use std::time::Duration;
use teksilo_settings::{AppPaths, WindowStateService, PerWindowState};
use teksilo_core::WindowPlacement;
// In tests use AppPaths::for_testing(tmp_dir); in production use AppPaths::new(...).
let paths = AppPaths::for_testing(std::path::Path::new("/tmp/my-app"));
let svc = WindowStateService::open_with_delay(&paths, Duration::ZERO).unwrap();
// On window move / resize, record the new geometry.
svc.record(PerWindowState {
label: "main".into(),
x: 100, y: 80,
width: 1280, height: 800,
placement: WindowPlacement::Floating,
}).unwrap();
// On next launch, restore if available.
if let Some(saved) = svc.state_for("main") {
let ready = saved.sanitize((400, 300), (1920, 1080));
println!("restore to {}x{} at ({},{})", ready.width, ready.height, ready.x, ready.y);
}
Builder methods at a glance
sanitize
API reference
📖 Full rustdoc API for this module
pub struct PerWindowState
Persisted geometry for one labeled window.
placement captures the full WindowPlacement enum (Floating /
Maximized / Fullscreen / Minimized). On restore, Minimized is
downgraded to Floating so the app doesn't appear to fail to
start; every other variant is honored if the OS supports it
(Wayland will ignore position regardless — see sanitize's
docs).
#![allow(unused)] fn main() { pub struct PerWindowState { /* fields */ } }
Methods
pub fn sanitize(&self, min_size: (u32, u32), work_area: (u32, u32)) -> PerWindowState
Validate this state against a (width, height) work area
(typically the size of the largest available monitor's usable
region) and return a sanitized copy:
width/heightare clamped to[min, work_area]. If the minimum is larger than the work area the work area wins — this should not happen with sensible mins (e.g. 320x240).- The position is checked: if the top-left point lies
outside
[0, work_area_w) x [0, work_area_h)and the window would not have at least 50 logical-pixel intersection with the work area, the position is recentered on the monitor so the window comes back on screen instead of spawning at coordinates from a missing monitor. maximizedandlabelare preserved.
Use this on app startup with (work_area_w, work_area_h)
pulled from the OS (e.g. winit's MonitorHandle::size() minus
known taskbars). Without an OS hint, pass conservative
fallbacks like (1920, 1080) — the result still improves on
re-using stale coordinates from a monitor that's no longer
connected.
pub struct WindowStateService
Persistent, in-memory-backed store for per-window geometry.
Entries are PerWindowState, keyed by a stable string label. state_for
reads straight from memory with no I/O.
Why this is debounced, unlike SettingsFile
SettingsFile's mutate is a synchronous locked read-modify-write, which
is right for a document written rarely (a settings change; one record per
backup). Window geometry is the opposite: teksilo-app's window_persist
observes the size / position / placement signals and calls record
on every change — i.e. once per frame while the user drags a window. A
synchronous flock + read + parse + serialize + fsync per frame would make
dragging visibly janky.
So this service owns its own DebouncedWriter and schedules a
WindowOp patch per record, exactly like [crate::PersistedListModel]:
in-memory state updates instantly (so state_for is always current), and
the burst collapses into one locked read-merge-write at the debounce
deadline. Frequent writes ⇒ debounced patch; rare writes ⇒ synchronous
locked RMW. Both are cross-process correct; they differ only in when the
disk write happens.
#![allow(unused)] fn main() { pub struct WindowStateService { /* fields */ } }
Methods
pub fn open(paths: &AppPaths) -> Result<Self, SettingsFileError>
Open the window-state file at the standard location inside paths.
pub fn open_with_delay(paths: &AppPaths, delay: Duration) -> Result<Self, SettingsFileError>
Open at the standard location with an explicit debounce window.
pub fn open_at(path: PathBuf, delay: Duration) -> Result<Self, SettingsFileError>
Open the window-state file at an explicit path.
delay is the debounce window: geometry changes arriving inside it
coalesce into a single disk write. Duration::ZERO writes on the
worker's next tick (used by tests).
pub fn state_for(&self, label: &str) -> Option<PerWindowState>
Saved state for the window with label, or None if there's no entry.
pub fn record(&self, state: PerWindowState) -> Result<(), SettingsFileError>
Record the current geometry for label, replacing any prior entry.
Updates memory immediately and schedules a debounced, locked read-merge-write — so a drag costs one write, not one per frame.
pub fn forget(&self, label: &str) -> Result<(), SettingsFileError>
Forget the entry for label.
pub fn labels(&self) -> Vec<String>
All recorded labels. Useful for "restore last session" features.
pub fn flush_now(&self) -> Result<(), SettingsFileError>
Flush any pending geometry to disk immediately, bypassing the debounce.
Flushes the op queue, never a re-derived snapshot of the in-memory document — dumping the snapshot is exactly how a cleanly-exiting process would erase a peer's window entry.
pub fn path(&self) -> &Path
Absolute path of the underlying TOML file managed by this service.
Reloadable
Reloadable — the contract a (separately-built) file watcher uses to
push a peer process's write into live state.
Every persisted type in this crate is cross-process safe on the write
side (see flush.rs's Patch design): a write always merges against
whatever is on disk, under a lock. That alone is not enough — a process
that loaded its state once and never looks again will not notice a peer's
write until it happens to mutate something itself. Reloadable is the
read side of the same story: a way for an external watcher (inotify /
FSEvents / ReadDirectoryChangesW, wired up outside this crate) to say
"the file changed, go look," without needing to know anything about the
concrete type it's reloading.
The self-write-suppression contract
A naive implementation would feed back into itself: this process writes
general.toml, the watcher notices that very write a few milliseconds
later, and calls reload_from_disk() — which had better be a cheap no-op,
not a full re-parse-and-notify cycle (and, worse, must never re-apply our
own value as if it were a peer's newer one, which could bounce a
just-superseded value back into a live Signal between the user's edit
and the debounced write landing).
Every implementation therefore layers two checks, cheapest first:
- Stamp check. Each implementor records the
(mtime, len)of the file as of the last time it either wrote to it or read it. If the file's current stamp matches,reload_from_diskreturnsOk(false)immediately — no read, no parse, nothing touched. This is the common case for a self-write notification. - Content backstop. If the stamp did change (a real write happened,
by us or a peer, since a filesystem's mtime resolution can coincide,
or the write path didn't get a chance to update the stamp), the file
is read and parsed, then compared by value against what's already
live. Only a genuine difference is pushed into signals / models;
Ok(false)is returned — again touching nothing — when the content is unchanged. This is the actual correctness guarantee; the stamp check above is purely an optimization to skip the common case cheaply.
Implementors: crate::SettingsFile, crate::SettingsStore,
crate::PersistedListModel, crate::WindowStateService.
API reference
📖 Full rustdoc API for this module
SettingsBundleError
SettingsBundle — declarative configuration for the teksilo-app
integration.
TeksiloAppBuilder::settings(bundle) consumes a SettingsBundle,
opens the requested services against the app's AppPaths, and
registers each one in the application's app_state registry so it
is reachable from any handler via the SettingsExt trait
(use teksilo_settings::SettingsExt;).
What's in the bundle
Only services the framework can construct without app-level type information:
SettingsStore— the dynamic K/V store for scalar settings.WindowStateService— per-window geometry persistence (opt-in viawith_window_state).
Anything that needs an app-defined item type (recently-opened
projects/files, color palettes, saved searches) is not in the
bundle. Apps construct an MruList<T> for each
such collection and register it themselves via
TeksiloAppBuilder::app_state(handle).
Example
use teksilo_settings::{AppPaths, SettingsBundle};
use std::time::Duration;
let paths = AppPaths::for_testing(std::env::temp_dir());
let opened = SettingsBundle::new()
.with_window_state(true)
.with_debounce(Duration::ZERO)
.open(&paths)
.expect("bundle open failed");
// opened.store and opened.window_state are now ready to register.
Builder methods at a glance
with_store_name, with_window_state, with_debounce, store_name, debounce, open
API reference
📖 Full rustdoc API for this module
pub enum SettingsBundleError
Errors surfaced by SettingsBundle::open.
#![allow(unused)] fn main() { pub enum SettingsBundleError { /* variants */ } }
Variants
Store— The K/V store could not be opened or flushed.File— A settings file (e.g. the window-state file) could not be opened or flushed.
pub struct SettingsBundle
Declarative configuration for the persistence services an app wants installed.
use teksilo_settings::SettingsBundle;
use std::time::Duration;
let bundle = SettingsBundle::new()
.with_window_state(true)
.with_debounce(Duration::from_millis(250));
#![allow(unused)] fn main() { pub struct SettingsBundle { /* fields */ } }
Methods
pub fn new() -> Self
Default bundle: opens the K/V store under general.toml,
no window-state persistence.
pub fn with_store_name(mut self, name: impl Into<String>) -> Self
Override the K/V store filename (without .toml). Default: general.
pub fn with_window_state(mut self, enabled: bool) -> Self
Enable the window-state service. The service stores
per-label entries, so a multi-window app records each
window's geometry under its own label (e.g. "main",
"log", "inspector").
pub fn with_debounce(mut self, delay: Duration) -> Self
Override the debounce window passed to every service this bundle opens.
Only SettingsStore actually debounces on it — its writes are
frequent enough (every Signal::set) that coalescing matters.
WindowStateService accepts the same parameter (so open can
call both uniformly) but ignores it: SettingsFile's writes are
always a synchronous locked read-modify-write now, so there is
nothing left to debounce (see file.rs's and window_state.rs's
module docs).
pub fn store_name(&self) -> &str
The filename stem (without .toml) used for the K/V store.
pub fn debounce(&self) -> Duration
The debounce window passed to every service this bundle opens
(see with_debounce for which services
actually honor it).
pub fn open(self, paths: &AppPaths) -> Result<OpenedSettings, SettingsBundleError>
Open every requested service against paths.
Every opened service is also registered into a fresh
SettingsRegistry (exposed as OpenedSettings::registry) under
its canonical path, so a crate::SettingsWatcher event naming
that path can be dispatched straight to it. The registration
handles are retained internally by OpenedSettings — see its
field docs — so they stay alive (and thus dispatchable) for as
long as the returned OpenedSettings (or any clone of it) is.
pub struct OpenedSettings
The outcome of [SettingsBundle::open]: ready-to-register handles.
Clone is cheap and shared, not deep. Each contained service
is internally Rc<>-shaped (matching ListModel<T> / TreeModel<T>
/ Signal<T>); cloning produces a second handle to the same
in-memory state and the same shared I/O thread queue. Mutations
through any clone are visible to every clone, and flush_all /
Drop semantics are unchanged.
#![allow(unused)] fn main() { pub struct OpenedSettings { /* fields */ } }
Methods
pub fn flush_all(&self) -> Result<(), SettingsBundleError>
Synchronously flush every active service.
SettingsExt
Extension traits exposing settings services on BuildContext and
EventContext.
teksilo-settings cannot live below teksilo-core (it depends on
teksilo-core for Signal, ObserverHandle, etc.), so the
convenience accessors ctx.settings() / ctx.window_state() /
ctx.mru::<T>() ship as an extension trait that apps use
explicitly:
use teksilo_settings::SettingsExt;
// inside any handler / build method:
let store = ctx.settings();
let recents = ctx.mru::<RecentProject>();
Each accessor wraps the existing app_state::<T>() lookup. The
mandatory accessors panic with a clear message if the service has
not been registered; the try_* variants return Option.
Window-geometry persistence is not an extension method: when a
WindowStateService is registered via TeksiloAppBuilder::settings,
every WindowConfig carrying an id(...) is automatically
restored on creation and recorded on every change by teksilo-app's
window manager. No widget-side wiring needed.
API reference
📖 Full rustdoc API for this module
SettingsFileError
SettingsFile<T> — typed single-struct persistence.
Used when the persisted shape is a known struct (recents, window
state) rather than a dynamic K/V map. The current value lives in a
RefCell<T> inside an Rc<>-shared inner so that multiple handles
can observe and mutate the same projection.
Cross-process safety is the only mode
Every read and every write goes through the exclusive advisory lock on
<path>.lock (see crate::lock):
loadacquires the lock, reads + migrates the file fresh, and retains theMigratorfor the handle's whole lifetime — not just for this one read — becausemutate,replace,reload_if_staleandreload_from_diskall need to re-migrate a peer's still-older on-disk schema on demand, not just once at construction.mutateandreplaceperform a locked read-modify-write: acquire the lock, re-read + re-migrate the file from disk under the lock, apply the caller's change to that fresh value, write it back atomically, refresh the in-memory snapshot, then release the lock. A lock alone would only stop the two writes from interleaving on disk — it does nothing to stop a stale in-memory snapshot from clobbering a peer's newer data, so the re-read has to happen under the same lock that guards the write. These writes are synchronous, on the calling thread, bypassing the shared debounced I/O worker entirely — deliberately:SettingsFile<T>is for rare writes (a settings change, one record per backup), so there is no burst to coalesce. Contrastcrate::SettingsStoreandcrate::PersistedListModel, which write far more often and keep the debounce.reload_if_staleandreload_from_diskare how reads pick up a peer's change — a cheap mtime/len check, escalating to a full re-read only when something actually moved.
use teksilo_settings::{SettingsFile, Migrator, Versioned};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
struct AppPrefs { version: u32, font_size: f32 }
impl Versioned for AppPrefs {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 { self.version }
fn set_version(&mut self, v: u32) { self.version = v; }
}
let path = dirs::config_dir().unwrap().join("myapp/prefs.toml");
let file: SettingsFile<AppPrefs> =
SettingsFile::load(path, Migrator::new()).unwrap();
file.mutate(|p| p.font_size = 16.0).unwrap();
Builder methods at a glance
load, load_strict, borrow, snapshot, replace, mutate, reload_if_stale, flush_now, path
API reference
📖 Full rustdoc API for this module
pub enum SettingsFileError
Errors surfaced by SettingsFile operations (and, by extension, every
other persisted type in this crate — they all share this error type).
#![allow(unused)] fn main() { pub enum SettingsFileError { /* variants */ } }
Variants
Io— An OS-level file I/O error (read, write, or rename).Parse— The file's TOML could not be parsed.Migrate— A migration step failed; the file version could not be brought up toT::CURRENT_VERSION.Serialize— The in-memory value could not be serialized to TOML before writing.Flush— The debounced background write failed.
pub struct SettingsFile
A reactive handle to a single typed file on disk.
Clone is cheap (an Rc bump). All clones share one in-memory
projection and one I/O thread.
#![allow(unused)] fn main() { pub struct SettingsFile<T: Versioned + DeserializeOwned> { /* fields */ } }
Methods
pub fn load(path: PathBuf, migrator: Migrator<T>) -> Result<Self, SettingsFileError>
Load the file from disk (running migrations) or initialize with
T::default() if the file does not exist.
The initial read is lock-protected, exactly like every subsequent
mutate / replace: a peer that is mid-write when this process
starts up cannot hand us a torn read.
migrator is taken by value and retained for the lifetime of
the handle: every later locked read re-runs it, since a peer might
still be on an older on-disk schema at any point, not just at
startup.
On a genuine parse failure (the bytes are not valid TOML at all,
surviving MAX_READ_ATTEMPTS retries) the offending file is
renamed to <path>.broken-<ts> and the returned SettingsFile
starts from T::default() — the file really is corrupt, and the
quarantine lets the next launch start clean instead of repeatedly
failing to load it.
A SettingsFileError::Migrate or SettingsFileError::Io
failure, by contrast, is not quarantined:
Migratemeans the TOML parsed fine, but this build's ownMigratorchain doesn't know how to bring it up toT::CURRENT_VERSION— the classic symptom of an older build opening a file a newer peer process already wrote in a newer schema. The file is not corrupt; renaming it would destroy that peer's live, legitimate, still-in-use data.Iomeans we couldn't even read the file (permissions, a transient failure) — we never saw its content, so there is no basis at all for deciding it's corrupt, and renaming (itself another I/O operation, on a path we just failed to read) would be reckless.
In both of those cases the handle falls back to T::default() for
this session only, but the file on disk is left completely
untouched. Use load_strict in tests that
want to assert on the specific failure instead.
pub fn load_strict(path: PathBuf, migrator: Migrator<T>) -> Result<Self, SettingsFileError>
Like load, but returns parse / migration errors
instead of quarantining the file. Intended for tests that want
to assert on a specific failure mode.
pub fn borrow(&self) -> Ref<'_, T>
Borrow the current value. The returned Ref holds a RefCell
guard; do not call any mutating method on this SettingsFile
while a Ref is alive.
pub fn snapshot(&self) -> T
Clone the current value out. Convenient when you don't want to juggle a borrow.
pub fn replace(&self, new: T) -> Result<(), SettingsFileError>
Replace the current value and persist it via a locked
read-modify-write. The disk read is discarded — replace always
wins over whatever was on disk — but the lock still serializes it
against a concurrent peer write, and the fresh disk stamp is
recorded so a subsequent reload doesn't re-read our own write back
in as if it were new. T::set_version(T::CURRENT_VERSION) is called
so the version stamp is always coherent, even if the caller forgot.
pub fn mutate<F: FnOnce(&mut T)>(&self, f: F) -> Result<(), SettingsFileError>
Mutate the current value in place and persist it via a locked
read-modify-write: the file is re-read and re-migrated from disk
under an exclusive lock before f is applied, so f always sees
a fresh value — not this handle's possibly-stale in-memory snapshot
— and the result is written back atomically before the lock is
released.
Takes f as FnOnce (not Fn) and imposes no Send bound on T:
this write is synchronous on the calling thread, never replayed on a
background worker, so there is no reason to tax every call site with
a Send/Fn requirement it doesn't need.
pub fn reload_if_stale(&self) -> Result<bool, SettingsFileError>
Pick up a peer's change: if the on-disk (mtime, len) differs from
the last one this handle observed, re-read and re-migrate the file
and refresh current. Returns whether a reload happened.
This is the cheap public probe — a stat, safe to call
speculatively (e.g. on every focus-in, or on a timer). It does not
perform the content-equality backstop that
Reloadable::reload_from_disk adds on top (which additionally
requires T: PartialEq); use that when a value-level "did anything
actually change" guarantee is needed (e.g. driven by a file
watcher, where a coincident stamp match must never be relied on
alone).
pub fn flush_now(&self) -> Result<(), SettingsFileError>
Synchronously write any pending payload to disk. A genuine no-op:
mutate / replace already write synchronously on the calling
thread, so nothing is ever pending — this type never registers
with the shared debounced-write worker pool at all, so there is
nothing to flush and nothing that can fail. Kept so callers that
hold a SettingsFile alongside debounced types (SettingsStore,
PersistedListModel) can flush everything uniformly without
special-casing this type.
pub fn path(&self) -> &Path
The path being written to.
SettingsReloadSink
Live cross-process settings sync: a notify-based directory watcher
plus the registry that lets a changed path be dispatched to the
in-memory Reloadable handle that owns it.
This is the read-side counterpart to the write-side cross-process
safety documented in flush.rs / reload.rs: every write in this
crate already merges safely against a peer's concurrent write, but a
process that never looks again will not notice a peer's write until
it happens to touch the same key itself. SettingsWatcher is what
makes it look again, automatically, the moment a peer's write lands
on disk.
Shape, mirrored from teksilo-i18n's FtlFileWatcher
SettingsWatcher owns a notify::RecommendedWatcher background
thread and a type-erased sink Arc<dyn Fn(PathBuf) + Send + Sync>.
Exactly like FtlFileWatcher, it watches directories, not files:
atomic writers (this crate's own write_atomic included) write a
temp file and rename it over the target, which invalidates an
inode-level watch on the file itself. Unlike FtlFileWatcher — which
watches a fixed, already-existing set of .ftl files and derives
their parents — SettingsWatcher watches the settings directories
(AppPaths::config_dir() / AppPaths::data_dir()) directly, because
the set of settings files living there is open-ended and some of
them (e.g. window_state.toml) may not exist yet at watch-construction
time.
The sink receives the changed path (not yet filtered against anything
this process cares about); SettingsRegistry::dispatch is what
decides whether the path names something registered and, if so,
calls its Reloadable::reload_from_disk. A path with no registered
owner (a .lock sidecar, a .tmp write-in-progress, an unrelated
file a peer dropped in the same directory) is a harmless no-op.
The registry
SettingsRegistry maps a canonical path to a Weak<dyn Reloadable>.
It never holds a strong reference itself: whoever opens a persisted
service (SettingsBundle::open, or application code opening its own
ad hoc SettingsFile<T> / PersistedListModel<T> / MruList<T>)
wraps it in an Rc<dyn Reloadable>, registers a weak clone via
SettingsRegistry::register, and keeps the returned Rc alive for
as long as it wants peer writes to be picked up. When that Rc (and
every clone of it) is dropped, the registry's entry can no longer be
upgraded — SettingsRegistry::dispatch then quietly prunes it and
reports nothing happened. Nothing leaks and nothing is ever called on
a service that no longer exists.
API reference
📖 Full rustdoc API for this module
pub type SettingsReloadSink
Sink type invoked on the notify worker thread whenever a watched
settings directory reports a create/modify event. Implementations
must be thread-safe; teksilo-app's implementation posts the path
through the winit EventLoopProxy as AppEvent::SettingsReload,
which hops back onto the UI thread where the (single-threaded,
Rc-based) SettingsRegistry actually lives.
#![allow(unused)] fn main() { pub type SettingsReloadSink = Arc<dyn Fn(PathBuf) + Send + Sync + 'static>; }
pub struct SettingsWatcher
Active directory watcher over one or more settings directories. One
per TeksiloAppBuilder::run invocation (when a settings bundle with
watching enabled is configured).
Owns the notify::RecommendedWatcher background thread for its whole
lifetime; dropping the SettingsWatcher stops the watcher and cleans
up. Kept alive by the caller for as long as live reload is wanted —
teksilo-app stores it on its window-loop handler, exactly like
teksilo-i18n's FtlFileWatcher.
#![allow(unused)] fn main() { pub struct SettingsWatcher { /* fields */ } }
Methods
pub fn new(dirs: Vec<PathBuf>, sink: SettingsReloadSink) -> Result<Self, notify::Error>
Build a watcher over dirs (deduplicated by canonical path, so
passing the same directory twice — e.g. AppPaths::for_testing,
whose config_dir() and data_dir() are the same tempdir — never
double-watches or double-fires) and a sink callback.
A directory that does not exist (or can't be canonicalized for
any other reason) is logged and skipped — not fatal — since a
freshly-installed app may not have created its data directory yet
when this is called. As long as at least the config directory
exists (which AppPaths implies by the time SettingsBundle has
successfully opened anything in it), watching still works for the
files that matter.
pub struct SettingsRegistry
Registry mapping a canonical settings path to the live Reloadable
handle that owns it, so a file-watcher event naming that path can be
dispatched to the right in-memory state.
Clone is cheap (an Rc bump) — every clone shares the same
underlying map, matching the rest of this crate's handle types.
Holds only Weak references: see the module docs' "the registry"
section for the full ownership contract.
#![allow(unused)] fn main() { pub struct SettingsRegistry { /* fields */ } }
Methods
pub fn new() -> Self
A fresh, empty registry.
pub fn register(&self, reloadable: Rc<dyn Reloadable>) -> Rc<dyn Reloadable>
Register reloadable under its canonical path and return it back
unchanged, so a caller can register and retain in one expression:
use teksilo_settings::{SettingsRegistry, SettingsFile, Migrator, Versioned};
use serde::{Serialize, Deserialize};
use std::rc::Rc;
#[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
struct Prefs { version: u32 }
impl Versioned for Prefs {
const CURRENT_VERSION: u32 = 1;
fn version(&self) -> u32 { self.version }
fn set_version(&mut self, v: u32) { self.version = v; }
}
let dir = tempfile::tempdir().unwrap();
let file: SettingsFile<Prefs> =
SettingsFile::load(dir.path().join("prefs.toml"), Migrator::new()).unwrap();
let registry = SettingsRegistry::new();
// Keep `handle` alive for as long as reload should keep working.
let handle = registry.register(Rc::new(file.clone()));
drop(handle); // dropping it deregisters: no leak, no dangling call.
The caller is responsible for keeping the returned Rc alive —
only a Weak is retained internally, by design (see the module
docs). Registering a second Reloadable under the same canonical
path replaces the first entry.
pub fn dispatch(&self, changed_path: &Path) -> Result<bool, SettingsFileError>
Look up changed_path's registered owner and call
Reloadable::reload_from_disk on it.
Returns Ok(true) if the owner's in-memory state actually
changed, Ok(false) if nothing needed to change (including: the
path names nothing registered, or its owner has been dropped —
in the latter case the dead entry is pruned from the map so it
doesn't accumulate forever).
pub fn registered_paths(&self) -> Vec<PathBuf>
The canonical paths currently registered (including entries whose
owner has since been dropped but not yet pruned by a dispatch
call). Exposed for tests and diagnostics.
pub fn live_count(&self) -> usize
Number of live (upgradeable) entries. Exposed for tests.
SettingsStoreError
Dynamic, dotted-key K/V store backed by TOML.
SettingsStore is the QSettings analogue: callers ask for any
dotted key with a type; the store returns a cached Signal<T> whose
mutations write back into an in-memory toml::Value and schedule a
debounced flush to disk.
Keys carry static names via SettingsKey<T>, or are passed as
ad-hoc strings via SettingsStore::signal. Same key, same type,
across any number of call sites returns clones of the same Signal.
When to use
Use SettingsStore for scalar and array-of-scalar preferences
(numbers, strings, booleans, Vec<String>). It is the right choice
for the majority of user-facing prefs that have a flat, well-known key
name. For rich structs with migrations, use
SettingsFile<T> instead — struct values
serialize as TOML tables and collide with the dotted-key model.
Invariants enforced at registration
- Type stability — once a key has been registered with type
T, callingsignal::<U>on the same key panics. Settings are programmer-named; type drift is a code bug, surfaced immediately. - No path-shape collisions —
"editor.font_size"cannot coexist with"editor"as a leaf value, in either order. Both directions panic at the call site that creates the conflict.
Merging by dirty key, not by whole-document overwrite
Every Signal<T>::set schedules a crate::flush::Patch that carries
only the keys dirtied since the last schedule — never a full render of
raw. The patch, applied at flush time against the document read fresh
off disk under a lock, write_nesteds just those keys onto it — so a
peer process's change to some other key survives. This is the fix for
Skribisto's general.toml: today, changing any one of its 26 keys
reverts every other key a peer process changed, because the whole
document gets re-serialized from an increasingly stale in-memory copy.
Reload and the re-entrancy guard
Reloadable::reload_from_disk
pushes a peer's on-disk change straight into the already-handed-out
Signal<T> for that key — see SignalCell::apply_external's doc
comment for why that requires capturing the concrete T at
registration time. Setting a signal from a reload would otherwise
re-trigger this same write-back observer and bounce the value straight
back out to disk as if it were a local edit; StoreInner::applying_external
is the flag the observer checks to short-circuit that.
Cycle-free observer wiring
The cell each key owns includes an ObserverHandle returned by
signal.observe(|new_val| …). The observer's closure captures a
Weak<RefCell<StoreInner>> — never a strong Rc — and bails when
the store has already been dropped. This avoids a reference cycle: a
strong capture would trap the entire store inside its own observer,
leaking for the life of the process.
Example
use teksilo_settings::{SettingsKey, SettingsStore};
use std::time::Duration;
// Declare a typed, statically-named key once — typically at the module level.
const FONT_SIZE: SettingsKey<f32> = SettingsKey::new("editor.font_size", || 14.0);
// Open the store (uses `tempfile` in tests, a real path in production).
let store = SettingsStore::open_with_delay(
"settings.toml".into(),
Duration::from_millis(500),
)?;
// Each call for the same key returns a clone of the same Signal<T>.
let font_size = store.signal_for(&FONT_SIZE); // Signal<f32>, seeded from disk
font_size.set(18.0); // writes back to TOML on next flush
store.flush_now()?; // force sync (useful in tests)
# Ok::<(), teksilo_settings::SettingsStoreError>(())
API reference
📖 Full rustdoc API for this module
pub const DEFAULT_DEBOUNCE
Default debounce window for store flushes.
#![allow(unused)] fn main() { pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500); }
pub enum SettingsStoreError
Errors surfaced by SettingsStore::open.
#![allow(unused)] fn main() { pub enum SettingsStoreError { /* variants */ } }
Variants
Io— The settings file could not be read or written (missing directory, permission denied, etc.).Parse— The settings file exists but its contents are not valid TOML.Flush— An attempt to flush the in-memory state to disk failed.
pub struct SettingsKey
A statically-named setting. Centralizes the dotted key, the value
type, and the default factory. Construct as a const:
use teksilo_settings::SettingsKey;
const FONT_SIZE: SettingsKey<f32> =
SettingsKey::new("editor.font_size", || 14.0);
#![allow(unused)] fn main() { pub struct SettingsKey<T: 'static> { /* fields */ } }
Methods
pub const fn new(key: &'static str, default: fn() -> T) -> Self
Create a new key descriptor; intended for use in const declarations.
pub const TEXT_SCALE_KEY
Persisted user-controlled global text-scale factor (1.0 = 100 %).
Read at startup by teksilo-app to seed every window's text scale, and
bound by the TextScaleControl widget so edits persist. The key accepts
any f32; the UI control restricts the user-facing range to 80 %–200 %.
The effective rendered scale is this value multiplied by the OS
accessibility text-scale preference.
#![allow(unused)] fn main() { pub const TEXT_SCALE_KEY: SettingsKey<f32> = SettingsKey::new("accessibility.text_scale", || 1.0_f32); }
pub struct SettingsStore
A dynamic dotted-key reactive settings store.
Clone is cheap (an Rc bump). All clones share one cache and one
I/O thread.
#![allow(unused)] fn main() { pub struct SettingsStore { /* fields */ } }
Methods
pub fn open(path: PathBuf) -> Result<Self, SettingsStoreError>
Open a store at path with the default debounce window.
pub fn open_with_delay(path: PathBuf, delay: Duration) -> Result<Self, SettingsStoreError>
Open a store at path with a custom debounce window. delay = Duration::ZERO is useful for tests — every set writes through
on the next worker iteration, and flush_now() is fully
deterministic.
pub fn path(&self) -> &Path
Path of the underlying file.
pub fn flush_now(&self) -> Result<(), SettingsStoreError>
Force any pending payload to disk synchronously.
pub fn has(&self, key: &str) -> bool
Whether the given key has already been registered.
pub fn registered_keys(&self) -> Vec<String>
All keys registered so far. Order is unspecified.
pub fn signal<T>(&self, key: &str, default: T) -> Signal<T> where T: Clone + Serialize + DeserializeOwned + 'static,
Get-or-create a Signal<T> for key, seeded from disk or
default if absent. Subsequent calls for the same key return
clones of the same signal.
Panics
- If the key was previously registered with a different type.
- If the key's path conflicts with an existing leaf-value /
table shape (e.g.
"editor"is a string and now you ask for"editor.font_size").
pub fn signal_for<T>(&self, key: &SettingsKey<T>) -> Signal<T> where T: Clone + Serialize + DeserializeOwned + 'static,
Like signal, but driven by a strongly-named
SettingsKey<T> constant.
pub fn open_path(path: &Path) -> Result<Self, SettingsStoreError>
Convenience constructor accepting &Path.
Versioned
Schema migrations for persisted files.
Every persisted struct carries a version: u32 (via Versioned).
Migrator<T> holds an ordered set of from_version → from_version + 1
transformations expressed on raw toml::Value — pre-deserialization,
so a v1 file that no longer matches the v2 type can still be upgraded.
use teksilo_settings::{Versioned, Migrator};
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
struct Recents {
version: u32,
items: Vec<Entry>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Entry { path: String, pinned: bool }
impl Versioned for Recents {
const CURRENT_VERSION: u32 = 2;
fn version(&self) -> u32 { self.version }
fn set_version(&mut self, v: u32) { self.version = v; }
}
// v1 didn't have `pinned`; supply false.
let migrator: Migrator<Recents> = Migrator::new()
.step(1, |mut v| {
if let Some(items) = v.get_mut("items").and_then(|i| i.as_array_mut()) {
for item in items {
if let Some(t) = item.as_table_mut() {
t.insert("pinned".into(), toml::Value::Boolean(false));
}
}
}
Ok(v)
});
Builder methods at a glance
step, run
API reference
📖 Full rustdoc API for this module
pub enum MigrationError
Errors surfaced by Migrator::run.
#![allow(unused)] fn main() { pub enum MigrationError { /* variants */ } }
Variants
NewerThanCurrent— The on-disk version number exceedsT::CURRENT_VERSION; the file was written by a newer build and cannot be read safely.NoStepFor— The chain is missing a step for the encountered version, making it impossible to reachT::CURRENT_VERSION.Step— A migration step closure returnedErr(message).Deserialize— The migratedtoml::Valuedid not deserialize asT.
pub struct Migrator
Schema migration pipeline for a Versioned type.
Add from → from + 1 steps with Migrator::step; the order in which
they're added does not matter — Migrator::run walks them in
version order.
Migrator<T> is cheaply Clone (each step's closure lives behind an
Arc, so cloning is a handful of refcount bumps, not a deep copy) and
Send + Sync whenever T is — which is what lets a Patch
(crate::flush::Patch) closure retain its own copy of the migrator and re-run it against the
document read fresh on the shared I/O worker thread, instead of the
stale, possibly-out-of-date value this handle loaded at construction.
#![allow(unused)] fn main() { pub struct Migrator<T: Versioned + DeserializeOwned> { /* fields */ } }
Methods
pub fn new() -> Self
Create an empty migrator with no steps registered.
If T::CURRENT_VERSION is 1 (the initial schema) or the file
is already at the current version, no steps are needed and
run will succeed immediately.
pub fn step<F>(mut self, from: u32, func: F) -> Self where F: Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync + 'static,
Register a step that promotes a value from from to from + 1.
Steps may be registered in any order; run finds
the right one for the current version on demand.
pub fn run(&self, mut raw: toml::Value) -> Result<T, MigrationError>
Migrate raw from its on-disk version up to
T::CURRENT_VERSION, then deserialize.
Reads the version directly from the version field of the raw
toml::Value — never deserializes-then-checks, because a v1
payload typically fails to deserialize as the v2 type.
Files missing the version field are treated as v1 (legacy).
Scene
Every public type in teksilo-scene, grouped by category. Each page links to its full rustdoc API reference.
Items
- GroupItem —
GroupItem— labelled box / logical AT container - ImageItem —
ImageItem— a raster image at a local-coord rectangle - PathItem —
PathItem— vector path with optional fill and stroke - RectItem —
RectItem— filled / stroked rectangle in local item coords - TextAlign —
TextItem— text in a local-coord rectangle, with alignment + rotation
Scene
- A11yGroupId — Accessibility policies for
SceneView - AccessSubtreeMode — Built-in
SceneItemimplementations - CacheMode — Item-coordinate paint caching
- DebugOverlay —
SceneView— the viewport widget that hosts aSceneand - ItemFlags — Per-item behavior flags
- ItemId — The
SceneItemtrait and its supporting context types - Magnet — Magnetism: typed snap-and-connect between anchor points on scene items
- Scene — The
Scenedata model — the owner of all items in a pannable/zoomable - SceneListAdapter —
SceneListAdapter— keep lightweight scene items in sync with a - SceneMinimap —
SceneMinimap— a small thumbnail of aScene - SceneModel —
SceneModel— a shared, cloneable handle to aScene - SceneScrollView —
SceneScrollView— a thin composite that gives aSceneViewdraggable - SceneSelectionMode — Selection model for
Sceneitems - SceneTapEvent — Per-item event handlers, cursor and tooltip overrides
- SceneViewState —
SceneViewState— a snapshot of aSceneView's - SpatialIndex — Spatial index for
Sceneitems
A11yGroupId
Accessibility policies for SceneView.
Two layers cooperate. The visual-default path emits AT nodes
for every visible heavyweight widget and every visible lightweight
item with role + screen-projected bounds, gated by an
A11yOffScreenMode policy that decides which off-viewport items
are still announced. The logical-structural API (groups,
parents, relations, auto-graft, custom focus callbacks) layers
over the top — see docs/teksilo-scene-a11y.md
for the full picture.
Defaults are chosen so a quick prototype is accessible out of the
box: heavyweight widgets emit normally, lightweight items get
synthetic nodes, Tab cycles in reading order. Apps shape the
reading experience by declaring A11yGroups, reparenting nodes,
and installing a focus-order callback.
Builder methods at a glance
as_u64
API reference
📖 Full rustdoc API for this module
pub struct A11yGroupId
Opaque identifier for a logical AT group declared via
Scene::add_a11y_group. Stable
across the lifetime of the process; safe to hash, compare, store.
#![allow(unused)] fn main() { pub struct A11yGroupId(pub(crate) u64); }
Methods
pub fn as_u64(self) -> u64
Raw numeric value. Used by the AT walker to derive a synthetic
NodeId via synthetic_node_id(scene_view_id, id.as_u64(), SyntheticKind::SceneGroup).
pub enum A11yNode
Address of a node in the parallel logical AT tree. Lets apps uniformly target scene entries, virtual groups, and ad-hoc widgets when declaring relationships, parents, or rotor categories.
#![allow(unused)] fn main() { pub enum A11yNode { /* variants */ } }
Variants
Item— Any entry in the scene — lightweightSceneItemor heavyweightWidgetadded viaScene::add_widget. The walker discriminates by entry kind: lightweight items get a syntheticSyntheticKind::SceneItemAT node; heavyweight items get auto-grafted via the framework redirect hook, landing the real widget'sNodeIdunder the declared parent.Group— A virtualA11yGroupdeclared viaScene::add_a11y_group.Widget— A real interactive widget addressed by its arenaWidgetId. Use this to relocate widgets that aren'tScene::add_widget-managed — typically a descendant of a heavyweight scene item that should logically belong elsewhere (a globalComboBoxnested visually inside a Scene card but logically under a top-level "Tools" group). For widgets you added viaScene::add_widget, preferA11yNode::Item(item_id)— the walker handles the heavyweight-item auto-graft for you.
pub enum A11yRelation
AT relationship kind, applied via
Scene::add_a11y_relation.
Maps to AccessKit's relationship arrays.
#![allow(unused)] fn main() { pub enum A11yRelation { /* variants */ } }
Variants
Controls—fromcontrolsto(e.g. a button that opens a menu).DescribedBy—fromis described byto(cross-item annotation).LabelledBy—fromis labelled byto(cross-item label).FlowTo— Logical flow direction — many node-graph editors use this so VoiceOver / NVDA "next item" follows data-flow order rather than reading order.
pub struct A11yCategory
App-defined category tag for AT rotor / quick-nav navigation.
Surfaced to AT clients that support categorized navigation
(VoiceOver rotor on macOS, NVDA quick-nav). Apps coin their own
tag values like "node", "connector", "comment".
#![allow(unused)] fn main() { pub struct A11yCategory(pub std::borrow::Cow<'static, str>); }
Methods
pub fn new(name: impl Into<std::borrow::Cow<'static, str>>) -> Self
Create a new category tag from a string or &'static str.
Accepts "node", "connector", String, or any Cow<'static, str>.
pub struct A11yGroupBuilder
Builder for an A11yGroup. Returned by
A11yGroup::builder; consumed by
Scene::add_a11y_group.
let act_one = scene.add_a11y_group(
A11yGroup::builder()
.label("Act 1")
.role(accesskit::Role::Group)
);
scene.set_a11y_parent(A11yNode::Item(scene_card), Some(A11yNode::Group(act_one)));
#![allow(unused)] fn main() { pub struct A11yGroupBuilder { /* fields */ } }
Methods
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Human-readable label for the group, announced when AT clients
land on the group node. Accepts anything convertible into
LocalizedString.
pub fn role(mut self, role: accesskit::Role) -> Self
Override the AccessKit role. Default: Role::Group. Apps
commonly use Role::Region for landmark-style groups.
pub struct A11yGroup
A logical AT group. Pure structure — no visual counterpart, no hit-test, no paint. Declares AT-shape that diverges from visual scene layout (Acts containing Scene cards, Subgraphs containing Nodes, Layers containing Components).
#![allow(unused)] fn main() { pub struct A11yGroup { /* fields */ } }
Methods
pub fn builder() -> A11yGroupBuilder
A fresh builder for a logical group. Default role is
Role::Group; override with A11yGroupBuilder::role.
pub fn id(&self) -> A11yGroupId
The group's id. Stable for the lifetime of the process.
pub fn label(&self) -> Option<String>
The label set on the builder, if any.
pub fn role(&self) -> accesskit::Role
The role set on the builder. Default Role::Group.
pub enum A11yMode
AT-emission strategy for SceneView. Decides whether items /
widgets that have not been placed in the app-declared logical
tree appear in the AT tree by default, or are suppressed.
Pick Cooperative when the visual scene layout is a sensible
AT structure for your app (charts, dashboards, simple maps).
Pick StrictlyParallel when AT shape diverges meaningfully
from visual layout — story corkboards (Acts → Scene cards),
node-graph editors (Subgraphs → Nodes → Ports), CAD canvases
(Layers → Components). Apps in this category typically declare
every AT edge anyway, so the default visual-emission becomes
noise.
#![allow(unused)] fn main() { pub enum A11yMode { /* variants */ } }
Variants
Cooperative— Default. Visual is the AT structure unless overridden. Items inside the off-screen-mode policy emit as direct AT children ofSceneView(or their declared logical parent ifset_a11y_parentplaced them). Heavyweight widgets emit through the arena walker as natural descendants ofSceneView. The logical-tree machinery layers on top.StrictlyParallel— AT structure is purely declared. Items are emitted only if the app placed them in the logical tree viaScene::set_a11y_parent. Heavyweight widgets still emit (they own focus / interaction state the AT layer can't suppress) but their parent in the AT tree is the declared logical parent if any, elseSceneViewitself. Use this when your app's AT shape is fundamentally different from its visual layout — declaring every node once is cheaper than overriding the visual default for every node.
pub enum A11yBoundsSpace
Coordinate space the AT walker reports SceneItem bounds in.
The framework convention is screen-projected bounds — the rectangle a sighted user would see on the physical monitor, after pan/zoom/rotation has been applied. Screen readers consume this for spatial nav (Apple's "explore by touch", touch-screen navi- gation, magnifier follow-focus). 99% of apps want this default.
Scene bounds are the raw scene-coord rectangle stored on the item, with no view-transform applied. Use this only for the rare AT clients that reason about scene topology rather than viewport position — typically when a SceneView's contents have a logical, fixed coordinate system that the user thinks in (a CAD canvas where "the bracket is at (240, 180)" means a fixed physical machine position regardless of zoom level).
Picking the wrong one makes "go to the next item" navigation
either a) ignore the user's current pan (Screen mode in a
scene-coord-aware app) or b) report bounds that drift under
pan/zoom (Scene mode in a viewport-aware app). Default is
Screen — change only when you've confirmed your AT users
genuinely want the alternative.
#![allow(unused)] fn main() { pub enum A11yBoundsSpace { /* variants */ } }
Variants
Screen— Screen-projected bounds —view_transform * bounds_in_scene. The framework default; matches the convention used by every other widget in the framework.Scene— Raw scene-coordinate bounds, with no view-transform applied. Apps with a logical fixed coordinate system (CAD canvases, blueprint editors) may want this so AT users can reason about "where in the design" an item sits, independent of the current pan/zoom.
pub enum A11yOffScreenMode
Off-screen visibility policy for the AT walker. Decides which scene items get emitted as synthetic AT nodes per AT-rebuild.
ViewportPlusN { n: 1 } is the default: an item appears in the
AT tree if its bounds_in_scene intersects viewport ∪ (1× viewport-grown-rect). That keeps the tree close to "what the
user can interact with right now" while letting screen-reader
users discover items just outside the visible region by jumping
to the next/prev — at which point SceneView::ensure_visible
pans the view to bring the focused item into view.
#![allow(unused)] fn main() { pub enum A11yOffScreenMode { /* variants */ } }
Variants
AllItems— Emit every item in the scene as a synthetic AT node. Heaviest mode — appropriate for small scenes (< ~500 items) where AT users want a complete table of contents.ViewportPlusN— Emit items inside the viewport plus ann × viewport-grown margin around it.n = 0collapses to "viewport only" with the same allocation pattern asViewportOnly.n = 1is the default — gives screen-reader users a one-screen "lookahead" to navigate withoutensure_visibleround-tripping through pan animation.ViewportOnly— Strict: only items intersecting the current viewport. Pairs with apps that have very large scenes where listing off-screen content would overwhelm AT clients.
Methods
pub fn at_visible_region(&self, visible_scene_region: Rect) -> Option<Rect>
Compute the scene-coord rectangle a given mode considers
"AT-visible" given the current visible scene region. Used by
SceneView::accessibility as the spatial-index query rect.
AllItems returns None so the caller knows to bypass the
query and emit every item.
AccessSubtreeMode
Built-in SceneItem implementations.
Five lightweight items cover the common decoration cases:
RectItem— filled / stroked rectangle. Backgrounds, tiles, simple decorations.PathItem— arbitrary vector path with optional fill and stroke. The "connector lines between cards" workhorse, with per-segment hit-test for stroke-only paths.ImageItem— a raster image at a local-coord rectangle.TextItem— unstyled text in a local-coord rectangle, static string or signal-bound.GroupItem— a group container with optional fill / stroke / inline label. Visually a labelled box; non-visual groups serve as logical AT containers (Scene::add_a11y_group).
All built-ins store their geometry in local item coordinates
anchored at the origin. Apps construct an item with its size at
origin (RectItem::new(Rect::new(0.0, 0.0, w, h))) and place it
in the scene with Scene::add_item(item, local_pos).
Builder methods at a glance
subtree_mode
API reference
📖 Full rustdoc API for this module
pub enum AccessSubtreeMode
How the AT walker treats descendants of an item.
Mirrors the widget-tier AccessSubtreeMode: Inherit is the
default (descendants emit normally); Exclude prunes them from
the AT tree; Merge collapses them into the parent so the
subtree reads as a single AT element. Used for "card with rect +
label + indicator dot reads as one card" patterns.
#![allow(unused)] fn main() { pub enum AccessSubtreeMode { /* variants */ } }
Variants
Inherit— Descendants emit their own AT nodes normally. Default.Exclude— Descendants are pruned from the AT tree; the parent item emits as a single AT node with no children.Merge— Descendants' label / description / actions are folded into the parent AT node; descendants are then pruned. The subtree reads as one AT element — useful for "card with icon + label + badge = one selectable card" patterns.
pub struct ItemA11yOverrides
Builder-level accessibility overrides shared by every built-in
SceneItem. Mirrors the widget-level .access_* chain — names
match so muscle memory carries over.
#![allow(unused)] fn main() { pub struct ItemA11yOverrides { /* fields */ } }
Methods
pub fn subtree_mode(&self) -> AccessSubtreeMode
Read access for the AT walker.
pub fn access_label(...)
Override the AT name announced for this item. Accepts
anything convertible into LocalizedString — most
commonly tr!(...) for translated labels, or any plain
string (which auto-converts via From<String>).
#![allow(unused)] fn main() { pub fn access_label(mut self, label: impl Into<LocalizedString>) -> Self; }
pub fn access_description(...)
Long-form context appended to the item's announcement.
#![allow(unused)] fn main() { pub fn access_description(mut self, description: impl Into<LocalizedString>) -> Self; }
pub fn access_role(...)
Override the AccessKit role for this item.
#![allow(unused)] fn main() { pub fn access_role(mut self, role: accesskit::Role) -> Self; }
pub fn access_hidden(...)
Hide this item from the AT tree.
#![allow(unused)] fn main() { pub fn access_hidden(mut self, hidden: bool) -> Self; }
pub fn access_subtree(...)
Set the AT subtree mode. Merge collapses descendants
into this item's AT node; Exclude prunes them; the
default Inherit lets them emit normally.
#![allow(unused)] fn main() { pub fn access_subtree(mut self, mode: $crate::items::AccessSubtreeMode) -> Self; }
pub fn access_merge_subtree(...)
Convenience: collapse all descendants into this item's AT node so the subtree reads as one element.
#![allow(unused)] fn main() { pub fn access_merge_subtree(mut self) -> Self; }
pub fn access_exclude_subtree(...)
Convenience: prune all descendants from the AT tree.
#![allow(unused)] fn main() { pub fn access_exclude_subtree(mut self) -> Self; }
pub fn access_value(...)
Announce a string value for this item (e.g. a formatted data
reading like "42 %"). Mirrors the widget-tier .access_value.
#![allow(unused)] fn main() { pub fn access_value(mut self, value: impl Into<LocalizedString>) -> Self; }
pub fn access_numeric_value(...)
Announce a numeric value for this item, for slider/gauge-like data
marks whose magnitude assistive tech should describe. Pair with
access_numeric_range /
access_numeric_step for full
range semantics.
#![allow(unused)] fn main() { pub fn access_numeric_value(mut self, value: f64) -> Self; }
pub fn access_numeric_range(...)
Announce the numeric min/max bounds for this item.
#![allow(unused)] fn main() { pub fn access_numeric_range(mut self, min: f64, max: f64) -> Self; }
pub fn access_numeric_step(...)
Announce the numeric step (per-arrow increment) for this item.
#![allow(unused)] fn main() { pub fn access_numeric_step(mut self, step: f64) -> Self; }
CacheMode
Item-coordinate paint caching.
When a SceneItem returns
CacheMode::ItemCoordinate from SceneItem::cache_mode,
the SceneView caches the item's paint
output as a RenderFrame in local item coordinates. On
subsequent paint passes the cached frame is replayed via
Canvas::draw_render_frame instead of re-running
item.paint. Cache validity is keyed by
Scene::item_change_signal:
a LocalBoundsChanged event for an id evicts that id's entry.
Items whose visual depends on signal state outside of their
local_bounds (e.g. TextItem with with_signal_text) should
NOT use ItemCoordinate — the cache won't see signal-driven
repaint dirties. The default for every SceneItem is
CacheMode::None.
Builder methods at a glance
get, insert, evict, clear, sync_glyph_epoch, len
API reference
📖 Full rustdoc API for this module
pub enum CacheMode
Per-item paint caching strategy.
#![allow(unused)] fn main() { pub enum CacheMode { /* variants */ } }
Variants
None— Re-runitem.paintevery frame. Default for every item.ItemCoordinate— Cache the paint output as aRenderFramekeyed by the item'slocal_bounds. Cheap when the item's geometry is stable and its content doesn't depend on external signal state. The cache is dropped onLocalBoundsChangedfor the id.
pub struct ItemCoordinateCache
SceneView's per-item paint cache. Owned by the SceneView, shared
via Rc<RefCell<>> so the paint walk and the item-change
observer can both touch it.
#![allow(unused)] fn main() { pub struct ItemCoordinateCache { /* fields */ } }
Methods
pub fn new() -> Self
An empty cache.
pub fn get(&self, id: ItemId, raster_scale: f32) -> Option<&RenderFrame>
Borrow the cached frame for id, if any — provided it was
recorded at raster_scale. A scale mismatch reads as a miss:
the caller re-records and insert replaces the
stale entry.
pub fn insert(&mut self, id: ItemId, frame: RenderFrame, raster_scale: f32)
Insert (or replace) a cached frame for id, recorded at
raster_scale.
pub fn evict(&mut self, id: ItemId)
Evict id's entry. Called on ItemChange::LocalBoundsChanged
or any other invalidation.
pub fn clear(&mut self)
Drop every entry. Called when the glyph epoch moves (see
sync_glyph_epoch).
pub fn sync_glyph_epoch(&mut self, current_epoch: u64) -> bool
Compare the text backend's current glyph epoch against the one
recorded on the last paint pass; on a change, drop every cached
frame (their baked atlas UVs may reference recycled slots) and
record the new epoch. Returns true when the cache was cleared.
pub fn len(&self) -> usize
Number of cached entries (diagnostics / tests).
DebugOverlay
SceneView — the viewport widget that hosts a Scene and
places its items at scene coordinates.
SceneView is the bridge between the model layer (Scene /
SceneModel) and the render/event pipeline. It
manages a pan/zoom/rotation camera, materialises heavyweight widgets
for delegated items, dispatches pointer events to lightweight item
handlers, and feeds synthetic AT nodes to AccessKit for every visible
lightweight item. Multiple SceneViews can share one SceneModel and
reconcile independently on every mutation.
Composition
- Placement.
place_childrenplants each materialised heavyweight widget at its scene-space rect (composed from the item'slocal_pos,transform, and parent chain). - Paint bands. Three passes:
paintdraws theUnderlightweight items (backdrop), the arena child-walk draws the heavyweight widgets, thenpost_paintdraws theOverlightweight items + marquee / foreground / debug overlays.zorders within each tier; the Under/Over band (Scene::set_layer) chooses the side. Seedocs/teksilo-scene.md§"Z-order and paint bands". - View transform. Pan / zoom / rotation are four animated
Signal<f32>s onSceneView, composed into a derivedSignal<Transform2D>bound viaBuildContext::set_content_transformon the view itself. The render walker pushes that scope around the entire subtree, so every materialised widget is visually transformed; transform-aware hit-test routes pointer events through the same scope. - Spatial index.
place_childrenand the paint walk consultScene::items_in_rect(visible_region)to skip off-screen items. - Idle gating. Pan / zoom that's reached its terminal tick
stops scheduling frames via the engine's per-node
paint_epoch.
Input wiring
on_scroll— trackpad two-finger pan (ScrollDelta::Pixels) and mouse wheel (ScrollDelta::Lines) animate the pan signals viaEasing::EaseOut. Trackpad momentum events from winit arrive as furtherPixelsdeltas; the existing animation pipeline turns this into smooth inertial fling without a custom recognizer.on_pinch— OS trackpad pinch (PinchPhase::Changed) feedsscaleinto the zoom signal androtationinto the rotation signal, anchored around the gesture center so the scene point under the user's fingers stays put.- Reduced-motion — at build time, captures
BuildContext::prefers_reduced_motion. When set, scroll handlerssetthe signals directly instead ofanimate_to-ing them; pinch is already instantaneous. - Drag-to-move for items carrying
IS_DRAGGABLE; marquee selection on the empty viewport surface (or underDragMode::ScrollHandDrag, pan-on-drag).
Example
#![allow(unused)] fn main() { use teksilo_scene::{Scene, SceneModel, SceneView, SceneSelectionMode, RectItem}; use teksilo_canvas::{Point, Rect}; use teksilo_tokens::Color; // Build a shared model and add a lightweight rect item. let model = SceneModel::new(); let local_bounds = Rect::new(0.0, 0.0, 120.0, 80.0); let item_id = model.add_item( RectItem::new(local_bounds).fill(Color::from_rgb(0.2, 0.5, 0.8)), Point::new(50.0, 50.0), // local_pos in scene coords ); // Create viewports backed by that model; each has its own camera. let _view_a = SceneView::with_model(model.clone()) .selection_mode(SceneSelectionMode::Single) .default_size(800.0, 600.0) .initial_zoom(1.5); let _view_b = SceneView::with_model(model.clone()) .interactive(false); // axis-chrome / overview pane // Both views see the item; the model remembers its local_pos. assert!(model.local_pos(item_id).is_some()); }
Builder methods at a glance
ALL, is_active
API reference
📖 Full rustdoc API for this module
pub struct DebugOverlay
Visual debug overlays painted on top of normal scene rendering.
Every flag defaults to false. Use this to verify that culling /
hit-test / spatial-index / dragging are doing what you expect
while developing a scene-based feature; turn off before shipping.
Each flag adds a thin overlay paint with a distinct color so multiple flags can be combined without visual confusion:
item_bounds: green outline around every visible scene item'sbounds_in_scene.content_bounds: blue outline around the scene's overall content extent (the union of all item bounds).viewport: red outline around the visible scene region (the cull rect — the inverse-projected viewport).selection_bounds: orange outline around every currently-selected item.
#![allow(unused)] fn main() { pub struct DebugOverlay { /* fields */ } }
Methods
pub const ALL: DebugOverlay = DebugOverlay { item_bounds: true, content_bounds: true, viewport: true, selection_bounds: true, };
All overlays enabled. Useful to catch any anomaly visually.
pub fn is_active(&self) -> bool
Whether at least one debug overlay is enabled.
pub enum FocusDirection
Direction passed to a SceneView::focus_order callback when the
app wants to override the default Tab cycle.
Forward corresponds to Tab; Backward to Shift+Tab. The default
SceneView focus traversal is scene insertion order — apps that
need data-flow order (graph editor), story-order (corkboard with
Acts), chronological order (timeline), etc. install a callback
that receives the current focus and returns the next id.
#![allow(unused)] fn main() { pub enum FocusDirection { /* variants */ } }
Variants
Forward— Advance to the next item — corresponds to the Tab key.Backward— Retreat to the previous item — corresponds to Shift+Tab.
pub struct SceneView
A pannable/zoomable viewport that renders a Scene's items at scene
coordinates and routes user input (scroll, pinch, drag, keyboard) back into
the camera signals.
Construct with SceneView::new (single-view sugar: wraps a Scene in a
fresh SceneModel) or SceneView::with_model (multi-view: several
viewports share one SceneModel and each reconcile independently on every
mutation). Install a heavyweight builder for delegated items via
delegate_typed. Add to a WidgetTree
like any other widget; gestures and camera animations are wired automatically
during build.
See the module-level documentation for the full composition model
and docs/teksilo-scene.md for an end-to-end guide.
#![allow(unused)] fn main() { pub struct SceneView { /* fields */ } }
GroupItem
GroupItem — labelled box / logical AT container.
Visually a labelled rectangle with optional fill, stroke, and
inline label. Without any chrome it's a logical-only container
that announces itself to AT but draws nothing — the lightweight
analogue of an A11yGroup.
When to use
Use GroupItem when you need to:
- Draw a visible boundary box around a cluster of related items (e.g. a lane in a Kanban board, an "Act 1" region on a corkboard).
- Provide a named AT group that screen readers announce without
any visible chrome — call
GroupItem::labelbut omitfillandstroke, leavingis_visual()false.
Example
use teksilo_scene::{Scene, GroupItem};
use teksilo_canvas::{Point, Rect};
use teksilo_tokens::Color;
use teksilo_i18n::lit;
let mut scene = Scene::new();
// A visible "Act 1" box with a rounded border.
let group = GroupItem::new(Rect::new(0.0, 0.0, 400.0, 600.0))
.label(lit!("Act 1"))
.show_label(true)
.stroke(Color::new(0.6, 0.6, 0.6, 1.0), 1.5)
.corner_radius(8.0);
let _id = scene.add_item(group, Point::new(20.0, 20.0));
Builder methods at a glance
label, show_label, label_inset, label_color, fill, stroke, stroke_cosmetic, stroke_styled, corner_radius, is_visual
API reference
📖 Full rustdoc API for this module
pub struct GroupItem
A group container with optional fill / stroke / inline label, in local item coordinates.
Visually, GroupItem renders a labelled box around its members. Logically, it's the AT-grouping primitive: with no chrome and a label set, it announces itself to AT but draws nothing.
#![allow(unused)] fn main() { pub struct GroupItem { /* fields */ } }
Methods
pub fn new(local_bounds: Rect) -> Self
A group covering local_bounds in local coordinates. No
chrome by default — call fill / stroke / show_label to
give it visible outline / background / inline label.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Human-readable label, used as the default AT group name and
(when show_label is enabled) rendered inline at top-leading.
pub fn show_label(mut self, show: bool) -> Self
Render the label inline at paint time.
pub fn label_inset(mut self, dx: f32, dy: f32) -> Self
Override the inset of the inline label from the local origin.
pub fn label_color(mut self, color: impl Into<ColorProp>) -> Self
Override the inline label colour. Defaults to the stroke colour if set,
else Color::BLACK. Accepts a plain Color, a theme role, or a
reactive signal.
pub fn fill(mut self, color: impl Into<ColorProp>) -> Self
Background fill colour. Accepts a plain Color, a theme role, a
Signal<Color>, or a Signal<Role> — resolved against the active theme
at paint time.
pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Border stroke (colour + scene-coord pixel width) — scales with zoom.
pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Cosmetic border stroke: holds a constant device-pixel width at any
zoom. With corner_radius > 0 the rounded outline goes through the SDF
cosmetic path; otherwise stroke_rect emits four CosmeticLine edges
(one per side), which are hard-edged and crisp at any zoom.
pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self
Border stroke with an explicit StrokeStyle — dashed / dotted /
custom caps. E.g. .stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))
for a dashed lane boundary.
pub fn corner_radius(mut self, radius: f32) -> Self
Rounded corners for fill and stroke. Default 0.0.
pub fn is_visual(&self) -> bool
Whether the group has any visual chrome configured.
ImageItem
ImageItem — a raster image at a local-coord rectangle.
ImageItem renders a raster image registered in the Canvas image registry
at a caller-specified rectangle in local item coordinates. The image
reference is a string key into that registry, not a path — apps pre-load
images and then name them here.
When to use
Use ImageItem when you need a static or swappable raster graphic in
the lightweight tier (no arena overhead). For interactive images that need
focus, drag-and-drop, or rich accessibility, embed a full ImageWidget
as a heavyweight scene widget instead.
Example
use teksilo_scene::{SceneModel, ImageItem};
use teksilo_canvas::Rect;
use teksilo_i18n::lit;
let model = SceneModel::new();
let item = ImageItem::new(Rect::new(0.0, 0.0, 64.0, 64.0), "avatar")
.label(lit!("User avatar"))
.draggable(true);
model.add_item(item, teksilo_canvas::Point::new(100.0, 50.0));
Builder methods at a glance
label, draggable
API reference
📖 Full rustdoc API for this module
pub struct ImageItem
A raster image in a local-coord rectangle.
The image is referenced by a string key into the Canvas image registry.
Place the item in the scene via Scene::add_item; the key must resolve
to a registered image at paint time.
#![allow(unused)] fn main() { pub struct ImageItem { /* fields */ } }
Methods
pub fn new(local_bounds: Rect, name: impl Into<String>) -> Self
An image item of the given size in local coordinates,
referencing the image registered under name. The name is
the Canvas-image-registry identifier — not a user-visible
string, so it is not localized.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Human-readable label.
pub fn draggable(mut self, draggable: bool) -> Self
Opt the image into drag-to-move.
ItemFlags
Per-item behavior flags.
ItemFlags is a bitset packed into a u32. Each flag opts an
item into a behavior — drag-to-move participation, hit-test
response, rendering visibility, transform inheritance — that
the Scene and SceneView consult at the relevant pipeline stage.
Defaults: IS_VISIBLE | IS_ENABLED | IS_SELECTABLE. An item
constructed via the standard built-in builders gets these
defaults; setters layer additional flags on top.
Builder methods at a glance
NONE, IS_VISIBLE, IS_ENABLED, IS_DRAGGABLE, IS_SELECTABLE, IS_FOCUSABLE, ACCEPTS_HOVER, CLIPS_TO_SHAPE, CLIPS_CHILDREN_TO_SHAPE, IGNORES_TRANSFORMATIONS, HAS_NO_CONTENTS, NEGATIVE_Z_BEHIND_PARENT, contains, intersects, set, with, without, bits, from_bits
API reference
📖 Full rustdoc API for this module
pub struct ItemFlags
A bitset of per-item behavior flags.
Use ItemFlags::default for the standard "interactive,
visible, selectable" baseline. Compose flags with | and toggle
them with ItemFlags::set / ItemFlags::contains.
#![allow(unused)] fn main() { pub struct ItemFlags(u32); }
Methods
pub const NONE: Self = Self(0);
Empty bitset — no flags set.
pub const IS_VISIBLE: Self = Self(1 << 0);
Item paints and is hit-tested. Default on. Clearing this is
the equivalent of Qt's setVisible(false) — the item is
neither painted nor hit-tested. Children of an invisible
item are also effectively invisible.
pub const IS_ENABLED: Self = Self(1 << 1);
Item dispatches pointer events. Default on. Disabled items are still painted but pass clicks through to items beneath.
pub const IS_DRAGGABLE: Self = Self(1 << 2);
Item participates in drag-to-move. Default off.
pub const IS_SELECTABLE: Self = Self(1 << 3);
Item is included in marquee box-select results. Default on.
pub const IS_FOCUSABLE: Self = Self(1 << 4);
Item can take keyboard focus. Default off; the focus_order callback considers only items with this flag set.
pub const ACCEPTS_HOVER: Self = Self(1 << 5);
Item dispatches hover events (Qt setAcceptHoverEvents).
Default off; hover handlers wired via ItemBuilder::on_hover
flip this on automatically.
pub const CLIPS_TO_SHAPE: Self = Self(1 << 6);
Item's paint output is clipped to its local_bounds.
Default off.
pub const CLIPS_CHILDREN_TO_SHAPE: Self = Self(1 << 7);
Children are clipped to this item's local_bounds. Default
off; mirrors Qt's ItemClipsChildrenToShape.
pub const IGNORES_TRANSFORMATIONS: Self = Self(1 << 8);
Item paints and hit-tests at a fixed pixel size, independent
of the view's zoom and rotation. Its anchor (the item's
parent-relative scene point) is projected through the view
transform like any other point, so the visible position
follows pan/zoom and tracks the underlying scene data —
but the item itself does not grow with zoom or rotate with
the view. Mirrors Qt's ItemIgnoresTransformations.
Annotation pins for graph editors, fixed-pixel-size badges
over moving content, chart axis labels. Default off.
pub const HAS_NO_CONTENTS: Self = Self(1 << 9);
Item has nothing to paint — the paint walk skips it entirely. Pure logical-only containers (used for AT grouping or hit-test routing) set this. Default off.
pub const NEGATIVE_Z_BEHIND_PARENT: Self = Self(1 << 10);
Children with z < 0 paint behind this item rather
than in front. Mirrors Qt's ItemNegativeZStacksBehindParent.
Default off.
pub const fn contains(&self, other: Self) -> bool
Whether the bitset contains every flag in other.
pub const fn intersects(&self, other: Self) -> bool
Whether the bitset shares any flags with other.
pub fn set(&mut self, flag: Self, on: bool)
Set (when on) or clear (when !on) the bits in flag.
pub const fn with(self, flag: Self) -> Self
Set the bits in flag, returning the new bitset.
pub const fn without(self, flag: Self) -> Self
Clear the bits in flag, returning the new bitset.
pub const fn bits(self) -> u32
Raw u32 bits (debug / serialization).
pub const fn from_bits(bits: u32) -> Self
Construct from raw bits.
ItemId
The SceneItem trait and its supporting context types.
Lightweight items live in a Scene without arena
overhead. Each carries its own bounds (in local item coordinates,
origin at the item's anchor) and paints itself via
SceneView's paint walk. Apps implement this
trait directly for custom items; built-ins live in
crate::items.
Coordinate model
An item is positioned in its parent's coordinate space by a
local_pos: Point plus an optional transform: Transform2D
(rotation/scale, applied around the local origin). The Scene
composes those per-item transforms up the parent chain to produce
a scene_transform (local→scene). Hit-test inverse-transforms a
scene-coord point into local coords before calling
SceneItem::shape_contains; paint pushes the scene transform
onto the canvas before calling SceneItem::paint.
When to use
Implement SceneItem when you need a lightweight, paint-only
decoration or connector that isn't interactive enough to warrant a
full widget (no keyboard focus, no complex event handling). For
anything that needs focus, animations, drag-and-drop, or AT by
default, prefer the heavyweight tier (Scene::add_widget).
Custom item example
use teksilo_scene::{SceneItem, SceneItemPaintContext};
use teksilo_canvas::{Canvas, Point, Rect};
use teksilo_tokens::Color;
#[derive(Debug)]
struct DotItem { bounds: Rect }
impl SceneItem for DotItem {
fn local_bounds(&self) -> Rect { self.bounds }
fn set_local_bounds(&mut self, b: Rect) { self.bounds = b; }
fn paint(&self, canvas: &mut Canvas, _ctx: &SceneItemPaintContext<'_>) {
canvas.fill_rect(self.bounds, Color::RED);
}
}
Builder methods at a glance
as_u64
API reference
📖 Full rustdoc API for this module
pub struct ItemId
Opaque identifier for a SceneItem inside a Scene.
Globally unique within a process, generated by ItemId::next.
ItemIds are stable across the item's lifetime in a scene; removing
an item retires its id permanently (Scene::remove does not reuse).
#![allow(unused)] fn main() { pub struct ItemId(pub(crate) u64); }
Methods
pub fn as_u64(self) -> u64
Raw numeric value, used by AccessKit's synthetic-NodeId derivation.
pub struct SceneItemPaintContext
Context handed to SceneItem::paint.
view_transform is the composed pan/zoom/rotation of the SceneView
that's painting this item; the canvas already has the item's
scene_transform pushed, so paint methods work in local coords
without further matrix math.
theme, window_active, and enabled mirror the widget-tier
PaintContext so lightweight items
can resolve theme-aware colours exactly like widgets do — call
some_color_prop.resolve(ctx.theme, ctx.enabled) in paint. theme is
already the fully-projected theme for this pass (the render walker swaps in
the inactive-window / high-contrast variant before handing it here), so
items never call Theme::for_inactive_window themselves; reading
ctx.theme grants automatic window-blur desaturation of accent roles.
#![allow(unused)] fn main() { pub struct SceneItemPaintContext<'a> { /* fields */ } }
Methods
pub fn new( view_transform: Transform2D, dirty_scene_rect: Option<Rect>, theme: &'a Theme, ) -> Self
Construct a paint context with the given view transform, optional dirty
region, and the active theme. text_scale defaults to 1.0,
window_active and enabled to true; use the with_* builders to
carry the accessibility scale, window-active state, and per-item enabled
state from the widget paint pass.
pub fn with_text_scale(mut self, text_scale: f32) -> Self
Set the global accessibility text-scale factor carried to opted-in items.
pub fn with_window_active(mut self, window_active: bool) -> Self
Set whether the host window is currently active (focused and unoccluded).
pub fn with_enabled(mut self, enabled: bool) -> Self
Set the effective enabled state of the item being painted.
pub struct SceneItemA11yContext
Context handed to SceneItem::accessibility.
Carries the item's screen-projected bounds (so items wanting to
emit AT-relative coordinates can read them) and its ItemId so
implementations can derive synthetic AT NodeIds for sub-elements.
#![allow(unused)] fn main() { pub struct SceneItemA11yContext { /* fields */ } }
Magnet
Magnetism: typed snap-and-connect between anchor points on scene items.
A magnet is a local point on an item (relative to the item's
anchor, like a child point), carrying a type-erased payload
('static, downcastable) and a directional MagnetRole. An item
can carry several. During an interaction the scene broad-phases
nearby magnets, runs an accept/reject predicate per
candidate pair, snaps so the closest accepting pair aligns, and on
release a connection event carries the payloads to the consumer.
Mechanism in scene, policy in the consumer
This module and Scene own the mechanism: magnet
geometry, broad-phase, snap math, and the connection result. They do
not own policy — which magnet types are compatible, what a
connection means, or whether a connection persists. Compatibility is
decided entirely by the predicate the consumer supplies to
Scene::compute_item_snap /
Scene::compute_port_snap; the
meaning of a formed connection is decided by the consumer's
on_connect handler. No widget-tree or designer concept (slot,
category, insertion index) leaks into this API; those live in the
payloads and the predicate.
MagnetRole is generic node-graph / diagram vocabulary used by the
scene only for default feedback (which end is the source) and for
ordering the keyboard connect flow. It is advisory: the predicate is
always authoritative on whether two magnets may connect.
Example — two items connected by a typed magnet pair
#![allow(unused)] fn main() { use teksilo_scene::{Scene, RectItem, Magnet, MagnetRole, MagnetRef, MagnetVerdict}; use teksilo_canvas::{Point, Rect, Vec2}; // A predicate that accepts Source → Target pairs on different items. fn source_to_target(a: &MagnetRef, b: &MagnetRef) -> MagnetVerdict { if a.item == b.item { return MagnetVerdict::Reject; } match (a.role, b.role) { (MagnetRole::Source, MagnetRole::Target) | (MagnetRole::Target, MagnetRole::Source) => MagnetVerdict::accept(), _ => MagnetVerdict::Reject, } } let mut scene = Scene::new(); // Dragged item with a Source magnet at its local origin. let dragged = scene.add_item( RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::ZERO, ); scene.add_magnet(dragged, Magnet::new(Point::ZERO).role(MagnetRole::Source)); // Target item 100 px to the right with a Target magnet at its local origin. let target = scene.add_item( RectItem::new(Rect::new(0.0, 0.0, 10.0, 10.0)), Point::new(100.0, 0.0), ); scene.add_magnet(target, Magnet::new(Point::ZERO).role(MagnetRole::Target)); // The dragged item is 5 px away from snapping; capture radius 20 px. if let Some(snap) = scene.compute_item_snap(dragged, Vec2::new(95.0, 0.0), 20.0, &source_to_target) { // snap_vector carries the dragged item exactly onto the target magnet. assert!((snap.snap_vector.x - 5.0).abs() < 1e-3); } }
Builder methods at a glance
role, payload, payload_rc, label, enabled
API reference
📖 Full rustdoc API for this module
pub struct MagnetId
Opaque identifier for a Magnet inside a Scene.
Globally unique within a process, minted by MagnetId::next. Stable
across the magnet's lifetime; removing a magnet (or its owning item)
retires its id permanently — ids are never reused.
#![allow(unused)] fn main() { pub struct MagnetId(pub(crate) u64); }
Methods
pub fn as_u64(self) -> u64
Raw numeric value, used by AccessKit's synthetic-NodeId derivation.
pub enum MagnetRole
The direction a magnet faces in a connection.
Advisory only — the scene uses it for default feedback (arrow
direction, which end starts the keyboard flow), but the
accept/reject predicate is always the authority on compatibility. A
node-graph output port is a Source, an input
port is a Target; a snap point that can be
either end is Bidirectional.
#![allow(unused)] fn main() { pub enum MagnetRole { /* variants */ } }
Variants
Source— Originates a connection (e.g. a node-graph output port).Target— Receives a connection (e.g. a node-graph input port).Bidirectional— Can be either end of a connection.
pub struct Magnet
A magnetism anchor attached to a scene item.
Built fluently and handed to
SceneModel::add_magnet. Carries a
local-frame position, a MagnetRole, an optional type-erased
payload, an enabled flag, and an optional accessibility label.
#![allow(unused)] fn main() { pub struct Magnet { /* fields */ } }
Methods
pub fn new(local_pos: Point) -> Self
A magnet at local_pos in the owning item's local frame, role
Bidirectional, no payload, enabled.
pub fn role(mut self, role: MagnetRole) -> Self
Set the connection direction (advisory — see MagnetRole).
pub fn payload<P: 'static>(mut self, payload: P) -> Self
Attach a type-erased payload the predicate and the connection
event can downcast. Cheap to carry around (held in an Rc).
pub fn payload_rc(mut self, payload: Rc<dyn Any>) -> Self
Attach an already-Rc-wrapped payload (use when several magnets
share one payload object).
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
The accessibility name announced for this magnet's synthetic AT node. Defaults to a generic role-based label when unset.
pub fn enabled(mut self, on: bool) -> Self
Disabled magnets are skipped by broad-phase, feedback, the keyboard cycle, and AT emission. Enabled by default.
pub struct MagnetRef
An owned, borrow-free snapshot of one magnet, handed to the
accept/reject predicate and carried in a MagnetConnection.
The payload is an Rc clone, so a snapshot can outlive the borrow
taken to collect candidates. The predicate inspects these snapshots
while a shared (read-only) scene borrow is held — it may read the
model but must not mutate it. The on_connect handler, by contrast,
runs after every borrow is dropped and may freely mutate the model
(add an edge item, reparent, fire an intent).
#![allow(unused)] fn main() { pub struct MagnetRef { /* fields */ } }
Methods
pub fn payload_as<P: 'static>(&self) -> Option<&P>
Borrow the payload downcast to P, or None if absent or a
different type. The ergonomic way to read a typed payload inside
a predicate.
pub enum MagnetVerdict
The result of running the accept/reject predicate on a candidate magnet pair. "Both payloads in, reject or accept-with-payload out."
#![allow(unused)] fn main() { pub enum MagnetVerdict { /* variants */ } }
Variants
Reject— The pair may not connect; the scene skips it.Accept— The pair may connect. The optional payload is attached to the resultingMagnetConnection(e.g. a derived edge descriptor).
Methods
pub fn accept() -> Self
Accept with no extra connection payload.
pub fn accept_with<P: 'static>(payload: P) -> Self
Accept and attach a typed connection payload.
pub fn is_accept(&self) -> bool
Whether this verdict accepts the pair.
pub struct MagnetConnection
A formed connection between two magnets, delivered to the consumer's
on_connect handler on release (mouse) or confirm (keyboard).
from is the magnet that initiated the connection (the dragged
item's magnet, the grabbed port, or the keyboard-activated source);
to is the magnet it connected onto. payload is whatever the
predicate's MagnetVerdict::Accept carried.
#![allow(unused)] fn main() { pub struct MagnetConnection { /* fields */ } }
Methods
pub fn payload_as<P: 'static>(&self) -> Option<&P>
Borrow the connection payload downcast to P.
pub struct MagnetSnap
The chosen snap when a dragged item's magnet aligns onto another
item's magnet. Returned by
Scene::compute_item_snap.
A heavyweight consumer that drives its own drag uses snap_vector to
place the item so from lands on to, and resolves from / to
via Scene::magnet to build the connection
for its own on_connect.
#![allow(unused)] fn main() { pub struct MagnetSnap { /* fields */ } }
pub enum MarkerVisibility
When the SceneView paints magnet markers.
#![allow(unused)] fn main() { pub enum MarkerVisibility { /* variants */ } }
Variants
Always— Always draw a marker for every enabled magnet (busy, but the clearest discoverability — good for a dedicated editor).DuringInteraction— Draw markers only while an interaction is in progress (an item drag, a port drag, or keyboard connect mode). The default — keeps an idle scene clean.Never— Never draw markers (the consumer paints its own via the feedback hook, or wants no visual at all).
pub enum MagnetVisualState
The visual state of a magnet as the feedback renderer sees it.
#![allow(unused)] fn main() { pub enum MagnetVisualState { /* variants */ } }
Variants
Idle— A normal, idle magnet.Candidate— A magnet the current interaction could connect to (it passes the predicate against the active source).Snapped— The magnet the active interaction is currently snapped onto.Focused— The keyboard-focused magnet (connect mode).PendingSource— The keyboard-activated source magnet awaiting a target.
pub struct MagnetMarker
One magnet's render data, handed to the feedback renderer.
#![allow(unused)] fn main() { pub struct MagnetMarker { /* fields */ } }
pub struct MagnetFeedback
Everything the magnetism feedback renderer needs for one frame, in
scene coordinates (the canvas is already in the view-transform
scope). The built-in renderer draws markers plus a connector; a
custom MagnetismConfig::feedback closure receives the same data.
#![allow(unused)] fn main() { pub struct MagnetFeedback { /* fields */ } }
pub struct MagnetismConfig
Per-view magnetism configuration, installed via
SceneView::magnetism.
Holds the consumer's policy — the accept/reject predicate and the
on_connect handler — plus presentation knobs. The scene supplies
the mechanism (snap math, broad-phase, feedback rendering, the
connection event); this config is where the consumer plugs its
policy in.
#![allow(unused)] fn main() { pub struct MagnetismConfig { /* fields */ } }
Methods
pub fn new(predicate: impl Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict + 'static) -> Self
A config with the given accept/reject predicate and defaults:
14 px capture radius, markers during interaction, the built-in
feedback renderer, m to toggle keyboard connect mode, enabled.
Install an on_connect handler to actually do something on
connect.
pub fn on_connect( mut self, f: impl Fn(&MagnetConnection, &mut EventContext) + 'static, ) -> Self
The handler invoked when a connection is formed (mouse release or
keyboard confirm). Runs with a live EventContext and no scene
borrow held, so it may mutate the model (add an edge item,
reparent), call scene.add_a11y_relation, or fire an intent.
pub fn capture_px(mut self, px: f32) -> Self
Capture and grab radius in screen pixels (converted to scene units by dividing by the live zoom, so snapping feels consistent at any zoom). Default 14.
pub fn markers(mut self, markers: MarkerVisibility) -> Self
When magnet markers are painted. Default
MarkerVisibility::DuringInteraction.
pub fn feedback( mut self, f: impl Fn(&mut Canvas, &PaintContext, &MagnetFeedback) + 'static, ) -> Self
Replace the built-in feedback renderer with a custom one. The closure paints in scene coordinates (the canvas already has the view transform pushed).
pub fn connect_key(mut self, key: Key) -> Self
The key that toggles keyboard connect mode while the SceneView is
focused. Default m.
pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self
Set the enabled state, statically or reactively (an app-owned signal drives enabled/disabled from e.g. a toolbar toggle).
pub fn enabled_signal(&self) -> Signal<bool>
The reactive enabled signal, for a toolbar to read or bind.
pub fn is_enabled(&self) -> bool
Whether magnetism is currently enabled.
PathItem
PathItem — vector path with optional fill and stroke.
PathItem renders an arbitrary vector path in local item coordinates.
The path can be filled, stroked, or both. Stroke-only paths use a
per-segment distance hit-test so users can click precisely along the
stroke even when the axis-aligned bounding box is huge — making this
the natural workhorse for connector lines between cards in a node graph
or story corkboard.
Strokes come in two flavours: a logical stroke (.stroke) scales
with the view zoom, making thick scene-space edges; a cosmetic stroke
(.stroke_cosmetic) holds a constant device-pixel width at any zoom,
ideal for hairline connector wires that should stay crisp and thin.
When to use
Use PathItem for connector lines, polygon overlays, freehand shapes,
or any vector decoration that needs exact-shape click detection along its
stroke. For solid rectangular regions, prefer the cheaper RectItem.
Example
use teksilo_scene::{SceneModel, PathItem};
use teksilo_canvas::{Path, Point, Rect};
use teksilo_tokens::Color;
let model = SceneModel::new();
let mut path = Path::new();
path.move_to(Point::new(0.0, 0.0))
.line_to(Point::new(200.0, 0.0))
.line_to(Point::new(200.0, 100.0));
let item = PathItem::new(path, Rect::new(0.0, 0.0, 200.0, 100.0))
.stroke_cosmetic(Color::new(0.3, 0.3, 0.3, 1.0), 1.5);
model.add_item(item, Point::new(50.0, 50.0));
Builder methods at a glance
fill, stroke, stroke_cosmetic, stroke_styled, label, draggable
API reference
📖 Full rustdoc API for this module
pub struct PathItem
An arbitrary vector path with optional fill and stroke, in local item coordinates.
The path's commands are evaluated in local space. A logical stroke scales
with the view zoom; a stroke_cosmetic stroke
holds a constant device-pixel width at any zoom (crisp connectors). The
caller-provided local_bounds AABB is what the spatial index buckets on;
it must enclose the path's strokes (including stroke half-width on each
side).
#![allow(unused)] fn main() { pub struct PathItem { /* fields */ } }
Methods
pub fn new(path: Path, local_bounds: Rect) -> Self
A path with a caller-provided AABB in local coordinates. The
path's points are interpreted as local — (0, 0) is the
item's anchor.
pub fn fill(mut self, color: impl Into<ColorProp>) -> Self
Fill colour. Accepts a plain Color, a theme role, a
Signal<Color>, or a Signal<Role> — resolved against the active
theme at paint time.
pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Stroke colour and width in scene-coordinate pixels — the stroke scales with the view zoom.
pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Cosmetic stroke: the connector holds a constant device-pixel width at any zoom (it never thins out or thickens). The renderer keeps the path body sharp at the current zoom, so joins/caps stay correct.
pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self
Stroke with an explicit StrokeStyle — dashed, dotted, or custom caps
/ joins. E.g. .stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))
distinguishes a pending connector from a solid confirmed one. The style
is stored verbatim (dash pattern/offset, Logical vs Device space).
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Human-readable label.
pub fn draggable(mut self, draggable: bool) -> Self
Opt the path into drag-to-move.
RectItem
RectItem — filled / stroked rectangle in local item coords.
RectItem is the simplest and cheapest lightweight scene item: a rectangle
in local item coordinates with an optional fill and/or stroke. It uses the
default AABB hit-test (exact for a rectangle) and has zero arena overhead.
Like all lightweight items, RectItem is constructed with its geometry
relative to a local origin (Rect::new(0.0, 0.0, w, h)) and placed in
the scene by Scene::add_item(item, scene_pos), where scene_pos becomes
the item's anchor in scene coordinates.
Fill and stroke colours are ColorProps, so they accept a plain
Color, a theme role
(SurfaceRole / TextRole / BorderRole),
a reactive Signal<Color>, or a Signal<Role> — resolved against the
active theme at paint time (so role-based fills desaturate automatically in
an inactive window). Change a colour live via
SceneModel::set_item_fill /
set_item_stroke.
When to use
Use RectItem for background tiles, card backgrounds, selection highlights,
grid cells, or any rectangular decoration in the lightweight tier. For
arbitrary shapes, use PathItem; for interactive content needing focus
or event handlers, embed a full widget with Scene::add_widget.
Example
use teksilo_scene::{SceneModel, RectItem};
use teksilo_canvas::{Point, Rect};
use teksilo_tokens::Color;
use teksilo_i18n::lit;
let model = SceneModel::new();
let item = RectItem::new(Rect::new(0.0, 0.0, 120.0, 80.0))
.fill(Color::new(0.9, 0.95, 1.0, 1.0))
.corner_radius(8.0)
.stroke_cosmetic(Color::new(0.6, 0.7, 0.85, 1.0), 1.0)
.label(lit!("Card background"))
.draggable(true);
model.add_item(item, Point::new(40.0, 40.0));
Builder methods at a glance
fill, stroke, stroke_cosmetic, stroke_styled, corner_radius, label, draggable
API reference
📖 Full rustdoc API for this module
pub struct RectItem
A rectangle with optional fill and stroke, in local item coordinates.
Construct with RectItem::new(Rect::new(0.0, 0.0, w, h)) and place
in the scene via Scene::add_item(rect, local_pos).
#![allow(unused)] fn main() { pub struct RectItem { /* fields */ } }
Methods
pub fn new(local_bounds: Rect) -> Self
A rectangle of the given size in local item coordinates. The
passed local_bounds is stored verbatim — typically
Rect::new(0.0, 0.0, w, h). No fill, no stroke — set at least
one or the item is invisible.
pub fn fill(mut self, color: impl Into<ColorProp>) -> Self
Fill colour. Accepts a plain Color, a theme
role, a Signal<Color>, or a Signal<Role> — resolved against the
active theme at paint time.
pub fn stroke(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Stroke colour and width in scene-coordinate pixels — the border scales with the view zoom (a 1px border becomes 2px at 2× zoom).
pub fn stroke_cosmetic(mut self, color: impl Into<ColorProp>, width: f32) -> Self
Cosmetic stroke: the border holds a constant device-pixel width at any zoom (a hairline that never thins out or thickens). Ideal for grid cells and card outlines in a pannable/zoomable scene.
pub fn stroke_styled(mut self, color: impl Into<ColorProp>, style: StrokeStyle) -> Self
Stroke with an explicit StrokeStyle — dashed, dotted, or custom caps
/ joins. E.g. .stroke_styled(color, StrokeStyle::dashed(2.0, 6.0, 4.0))
for a dashed outline, or StrokeStyle::dotted(1.5, 3.0) for a dotted
guide. The style is stored verbatim, so all of StrokeStyle's knobs
(dash pattern/offset, Logical vs Device space) apply.
pub fn corner_radius(mut self, radius: f32) -> Self
Rounded corners for fill and stroke, in scene-coordinate pixels.
Default 0.0 (square corners). A positive radius routes fill/stroke
through the SDF rounded-rect path.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Human-readable label used for debug and the default AT name.
Accepts anything convertible into LocalizedString — most
commonly tr!(...). Plain strings auto-convert.
pub fn draggable(mut self, draggable: bool) -> Self
Opt the rectangle into drag-to-move.
Scene
The Scene data model — the owner of all items in a pannable/zoomable
scene.
Scene holds a flat list of entries in a parent-relative scene-graph, plus
a pluggable SpatialIndex for rectangular queries. Items are positioned
by local_pos (in their parent's coordinate frame, or scene-root if they
have none) and an optional transform (rotation/scale around the local
origin); the Scene composes those up the parent chain to derive each item's
scene_transform and axis-aligned bounding box for hit-test, paint, and
culling. Two content tiers coexist in one Scene: heavyweight Widgets
(full focus/animation/DnD/AT — placed at scene coordinates) and lightweight
SceneItems (paint-only, no arena overhead, thousands
cheap). All mutations update the SpatialIndex in lockstep, so
Scene::items_in_rect and Scene::item_at stay O(visible).
Scene is rarely used directly. The normal entry point is
SceneModel, a cloneable Rc<RefCell<Scene>> handle
with &self mutators (the ListModel pattern) that lets multiple handlers
and multiple SceneViews share one model.
When to use
Use Scene (via SceneModel) when you need a pannable/zoomable canvas —
story corkboards, node-graph editors, mind maps, timeline views, CAD
canvases, or simple spatial maps. Prefer a plain ListView or TreeView
when the content is linear or tree-shaped without spatial relationships.
Example
#![allow(unused)] fn main() { use teksilo_scene::{Scene, ItemChange, SceneLayer}; use teksilo_scene::{RectItem, ItemId}; use teksilo_canvas::{Point, Rect}; use teksilo_tokens::Color; let mut scene = Scene::new(); // Add a lightweight rectangle item at scene coordinates (50, 50). let id: ItemId = scene.add_item( RectItem::new(Rect::new(0.0, 0.0, 80.0, 40.0)).fill(Color::BLUE), Point::new(50.0, 50.0), ); // Observe every mutation — fires after the change is already applied. let _guard = scene.item_change_signal().observe(|change| { if let ItemChange::LocalPosChanged { id: _, old: _, new } = change { let _ = new; // react to the new position } }); // Move the item; the observer fires and the spatial index updates. scene.set_local_pos(id, Point::new(100.0, 100.0)); assert_eq!(scene.scene_pos(id), Some(Point::new(100.0, 100.0))); }
Builder methods at a glance
with_index, add_widget, add_item, add_item_dynamic, refresh_dynamic_bounds, item_change_signal, a11y_change_signal, mutation_version, local_pos, set_local_pos, local_bounds, set_local_bounds, transform, set_transform, scene_transform, scene_pos, scene_rect, map_to_scene, map_from_scene, flags, set_flags, set_flag, set_visible, is_effectively_visible, opacity, set_opacity, set_item_fill, clear_item_fill, set_item_stroke, clear_item_stroke, add_boxed_item, set_item_handlers, handlers_mut, handlers, effective_opacity, set_scene_rect, scene_rect_extent, pan_axes, current_pan_axes, zoomable, is_zoomable, set_pan_bounds, current_pan_bounds, set_zoom_range, current_zoom_range, pan_axes_signal, pan_bounds_signal, zoom_range_signal, zoomable_signal, constraints, set_z, bring_to_front, send_to_back, z, set_layer, layer, set_item_parent, parent_of, is_descendant_of, collect_descendants, item, remove, orphan, items_in_rect, item_thumbnails, item_at, colliding_items, items_along_path, items_at, len, is_empty, ids, index, add_magnet, remove_magnet, clear_magnets, set_magnet_local_pos, set_magnet_enabled, magnet_ids_of, magnet_owner, magnet_enabled, magnet_scene_pos, magnet, compute_item_snap, compute_port_snap, nearest_magnet, add_a11y_group, remove_a11y_group, a11y_group, set_a11y_parent, a11y_parent_of, add_a11y_relation, a11y_relations, set_a11y_live, set_a11y_landmark, set_a11y_categories, a11y_categories_of
API reference
📖 Full rustdoc API for this module
pub enum ItemChange
A change to an item's state, fired through
Scene::item_change_signal for every mutation. Apps observe
to wire snap-to-grid, validation, side effects, etc. The model
is "fire after the change has been applied" — by the time the
observer sees the event, the Scene already reflects it.
#![allow(unused)] fn main() { pub enum ItemChange { /* variants */ } }
Variants
LocalPosChanged—set_local_pos: position in parent coords moved.LocalBoundsChanged—set_local_bounds: AABB in local coords changed.TransformChanged—set_transform: local→parent transform changed.VisibilityChanged—set_visibleflipped IS_VISIBLE.FlagsChanged—set_flags/set_flagchanged the bitset.OpacityChanged—set_opacity: local opacity multiplier changed.ZChanged—set_z: paint z-order changed.LayerChanged—set_layer: the Under/Over paint band changed.ParentChanged—set_item_parent: logical parent changed.Removed—remove: item is gone.Added—add_item/add_widget: item was inserted.PayloadChanged—set_payload: the type-erased payload of aDelegatedheavyweight entry was replaced. ASceneViewrebuilds that entry's widget (re-invokes its delegate) on the next build. Routed throughemit_item_change, somutation_seqadvances and the AT-walk gate notices.AppearanceChanged—set_item_fill/set_item_stroke/clear_item_*: a lightweight item's paint-only appearance (fill / stroke colour or style) changed. Never moves geometry, so the observingSceneViewevicts the item's cached frame and repaints without relayout or rebuild.
pub enum SceneLayer
Which paint band a lightweight SceneItem sits in, relative to
the heavyweight widget tier.
A SceneView paints in three passes: lightweight Under items
(its paint, a backdrop), then the heavyweight widget children
(the arena child-walk), then lightweight Over items (its
post_paint, a foreground). Within each band, z still orders
items among themselves.
This is a binary band, not a continuous z across the tiers, because
the render walker offers exactly two lightweight paint positions
(before and after the child subtree). The heavyweight tier is one
contiguous block in between — to interleave a lightweight item
between two specific heavyweight nodes you must promote it to a
heavyweight widget. Under is the default (background furniture:
connectors, grids, decorations); Over is for foreground overlays
that must sit above the cards (selection halos, highlighted edges).
#![allow(unused)] fn main() { pub enum SceneLayer { /* variants */ } }
Variants
Under— Painted under the heavyweight widget children (the default).Over— Painted over the heavyweight widget children.
pub enum PanAxes
Which axes a SceneView is allowed to pan
along. Set on the Scene (not the View) because a given scene
model often makes sense at one orientation only — a horizontal
timeline, a vertical timeline, a fixed-extent diagram. All views
of the same scene inherit the constraint.
#![allow(unused)] fn main() { pub enum PanAxes { /* variants */ } }
Variants
None— No user-driven pan in either axis. ProgrammaticSceneView::set_pan/pan_tobecome no-ops too.Horizontal— Pan only along X. Vertical scroll deltas pass through to ancestor scrollables.Vertical— Pan only along Y. Horizontal scroll deltas pass through to ancestor scrollables.Both— Default: pan freely in both axes.
pub struct SceneConstraints
Reactive interaction-policy bundle owned by Scene. Apps
configure pan/zoom behaviour by writing to these signals; gesture
closures in SceneView read them live, so
runtime mode switches (e.g. a toolbar toggling pan locks) take
effect on the next event without rebuilding the view.
All four signals are exposed individually via Scene accessors
(pan_axes_signal, pan_bounds_signal, zoom_range_signal,
zoomable_signal). Per-(sub-)scene independence falls out of the
model: each nested SceneView carries its own Scene with its
own SceneConstraints.
View-level tightening overrides (pan_bounds_override,
zoom_range_override) layer on top per-SceneView — the
effective constraint is the intersection. Two views over the
same Scene can lock down independently; neither can loosen
what the Scene declares.
#![allow(unused)] fn main() { pub struct SceneConstraints { /* fields */ } }
Methods
pub fn pan_axes_signal(&self) -> Signal<PanAxes>
Reactive pan-axes signal. Gesture handlers read live.
pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>>
Reactive pan-bounds signal. None = unconstrained.
pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>>
Reactive zoom-range signal. None = unconstrained from
the Scene side.
pub fn zoomable_signal(&self) -> Signal<bool>
Reactive zoomable-on/off signal. Equivalent to a zero-width zoom_range — kept as a separate boolean for clarity and efficient short-circuit at gesture time.
pub struct Scene
The data model behind a SceneView: a flat list of entries in a
parent-relative scene-graph plus a SpatialIndex for rectangular
queries.
The Scene itself does no rendering — it's a passive container the view
reads from at build / place / paint time. Mutations (add_widget,
add_item, set_local_pos, set_transform, set_local_bounds, remove)
update the spatial index in lockstep, so items_in_rect, item_at, and
SceneView's viewport-cull path are all O(visible) instead of O(N). When
a parent's local_pos or transform changes, every descendant's
scene-AABB shifts; the Scene re-buckets the entire subtree.
In practice most callers operate on a SceneModel
handle (Rc<RefCell<Scene>> with &self mutators) rather than a bare
Scene. Prefer SceneModel for any widget or handler that needs to share
the scene across multiple owners.
#![allow(unused)] fn main() { pub struct Scene { /* fields */ } }
Methods
pub fn new() -> Self
An empty scene with the default GridHashIndex.
pub fn with_index(index: Box<dyn SpatialIndex>) -> Self
An empty scene with a custom SpatialIndex.
pub fn add_widget<W: Widget + 'static>(&mut self, widget: W, local_rect: Rect) -> ItemId
Place a heavyweight Widget at local_rect's origin, sized
local_rect.size. The rect is interpreted as
(local_pos = local_rect.origin, local_bounds = (0, 0, w, h)).
Returns the ItemId for later mutation. The widget is
consumed at SceneView build time and added to the arena.
pub fn add_item<I: SceneItem + 'static>(&mut self, item: I, local_pos: Point) -> ItemId
Place a lightweight SceneItem at local_pos. The item's
local_bounds and initial_flags are read once at insert
time. The item is not added to the arena — it's painted
directly from SceneView::paint.
pub fn add_item_dynamic<I: SceneItem + 'static>( &mut self, item: I, local_pos: Point, ) -> ItemId
Like add_item but flags the entry as
having signal-driven local_bounds. The Scene re-reads
item.local_bounds() each rebuild via
refresh_dynamic_bounds — the
SceneView calls that at the start of every build pass. The
spatial index gets re-bucketed when the read-back differs
from the cached value, so items_in_rect / hit-test stay
correct without app-side set_local_bounds plumbing.
Use only when the bounds genuinely depend on a Signal<T>
the item reads in local_bounds. Static items pay an
unnecessary per-rebuild bounds read otherwise; prefer
add_item for the common case.
pub fn refresh_dynamic_bounds(&mut self) -> bool
Re-read every dynamic item's current local_bounds, applying
set_local_bounds (and re-bucketing the spatial index) for
any entry whose value has changed. No-op for static entries.
Called by SceneView at the start of each
build() so signal-driven bounds propagate to bucketing
without explicit app-side calls.
Returns true if at least one dynamic entry's bounds changed this call.
SceneView uses the true → false transition (an animation settling) as
the one moment to walk the final animated bounds into the AccessKit tree,
since it otherwise suppresses per-frame AT re-walks during the animation.
pub fn item_change_signal(&self) -> Signal<ItemChange>
Reactive notification stream for every Scene mutation. Apps
observe via signal.observe(|change| …) to wire snap-to-grid,
clamping, validation, and side effects without having to
poll the Scene each frame. The signal fires after the
mutation has been applied — by the time the observer runs
the Scene already reflects the new state.
pub fn a11y_change_signal(&self) -> Signal<u64>
Reactive notification for logical-AT-structure mutations
(add_a11y_group / remove_a11y_group / set_a11y_parent /
add_a11y_relation / set_a11y_live / set_a11y_landmark /
set_a11y_categories). A monotonic counter bumped after each such
mutation. SceneView observes this to re-walk the AccessKit tree —
these changes don't flow through item_change_signal
because they aren't item geometry, and the AT tree is separate from the
visual scene.
pub fn mutation_version(&self) -> u64
Monotonic counter of every model mutation applied so far — item geometry
/ visibility / structure (each ItemChange) and logical-AT
structure (groups, parents, relations, live, landmarks, categories).
SceneView snapshots this each build() and only
re-walks the (separate, expensive) AccessKit tree when it has advanced
since the previous walk — so an actively-animating
add_item_dynamic item, which rebuilds every
frame, does not issue an AT re-walk per frame. The counter wraps; compare
for equality, not ordering.
pub fn local_pos(&self, id: ItemId) -> Option<Point>
Read an item's local_pos (its anchor in parent coords).
pub fn set_local_pos(&mut self, id: ItemId, local_pos: Point)
Move an item to a new local_pos in its parent's coordinate
frame. Re-buckets the item and every descendant in the
spatial index since the descendants' scene-AABBs shift along.
No-op if the id is unknown.
pub fn local_bounds(&self, id: ItemId) -> Option<Rect>
Read an item's local_bounds (its AABB in local coords).
pub fn set_local_bounds(&mut self, id: ItemId, local_bounds: Rect)
Update an item's local_bounds. For lightweight items this
also calls SceneItem::set_local_bounds on the item so its
next paint reflects the new geometry. The spatial index is
re-bucketed; only this item moves (descendants' local frames
are unchanged). No-op if the id is unknown.
pub fn transform(&self, id: ItemId) -> Option<Transform2D>
Read an item's local→parent transform (rotation/scale around the local origin). Identity by default.
pub fn set_transform(&mut self, id: ItemId, transform: Transform2D)
Set an item's local→parent transform. Re-buckets the item's subtree in the spatial index. No-op if the id is unknown.
pub fn scene_transform(&self, id: ItemId) -> Transform2D
The composed local→scene transform for this item, walking up the parent chain. Identity for an item that doesn't exist.
pub fn scene_pos(&self, id: ItemId) -> Option<Point>
The item's anchor in scene coords (its local origin transformed through the parent chain).
pub fn scene_rect(&self, id: ItemId) -> Option<Rect>
The AABB enclosing the item's local_bounds after composing
through the parent chain — i.e. the rectangle the spatial
index buckets on. None if the id is unknown.
pub fn map_to_scene(&self, id: ItemId, local_pt: Point) -> Option<Point>
Map a point in the item's local frame to scene coords.
pub fn map_from_scene(&self, id: ItemId, scene_pt: Point) -> Option<Point>
Map a point in scene coords to the item's local frame.
Returns None if the item is unknown or its scene transform
is degenerate (zero scale).
pub fn flags(&self, id: ItemId) -> Option<ItemFlags>
Read an item's ItemFlags bitset.
pub fn set_flags(&mut self, id: ItemId, flags: ItemFlags)
Replace an item's flags wholesale. No-op if unknown.
pub fn set_flag(&mut self, id: ItemId, flag: ItemFlags, on: bool)
Set or clear a single flag on an item. No-op if unknown.
pub fn set_visible(&mut self, id: ItemId, visible: bool)
Toggle the ItemFlags::IS_VISIBLE bit. Convenience for
the common "hide this item" operation.
pub fn is_effectively_visible(&self, id: ItemId) -> bool
Whether the item is visible AND every ancestor in its chain
is visible. Returns true when nothing in the chain has
IS_VISIBLE cleared. false for unknown ids.
pub fn opacity(&self, id: ItemId) -> Option<f32>
Read an item's local opacity multiplier (1.0 by default).
pub fn set_opacity(&mut self, id: ItemId, opacity: f32)
Set an item's local opacity, clamped to [0.0, 1.0].
pub fn set_item_fill(&mut self, id: ItemId, fill: impl Into<ColorProp>)
Replace a lightweight item's fill colour live, emitting
ItemChange::AppearanceChanged — always repaint-only, never a
relayout, rebuild, or AccessKit re-walk. The colour is a ColorProp,
so it accepts a plain Color, a theme role, a
Signal<Color>, or a Signal<Role>. No-op for item kinds without a fill
(e.g. ImageItem).
Reactivity contract
A colour becomes continuously reactive by being registered at build
time (SceneItem::register_bindings). So:
- Construct the item with a
Signal/role colour (.fill(my_signal)) for a colour that tracks its signal forever. This is the recommended path and needs no mutator at all. - This mutator installs a snapshot: it repaints immediately, which
is all a static colour ever needs. If you pass a
Signal/dynamic role here, it paints the signal's current value now and starts tracking it continuously from the owning view's next rebuild (whenever some other structural change re-runsregister_bindings). Deliberately not forced: a colour change must never cost a rebuild + AT re-walk.
pub fn clear_item_fill(&mut self, id: ItemId)
Clear a lightweight item's fill (Rect/Path/Group become fill-less),
emitting ItemChange::AppearanceChanged (repaint-only). No-op for items
whose fill can't be cleared (e.g. TextItem, which always has a
foreground colour).
pub fn set_item_stroke(&mut self, id: ItemId, color: impl Into<ColorProp>, style: StrokeStyle)
Replace a lightweight item's stroke (colour + StrokeStyle) live,
emitting ItemChange::AppearanceChanged (repaint-only). No-op for item
kinds without a stroke slot (TextItem / ImageItem). See
set_item_fill for the reactivity contract.
pub fn clear_item_stroke(&mut self, id: ItemId)
Clear a lightweight item's stroke, emitting
ItemChange::AppearanceChanged (repaint-only). No-op for item kinds
without a stroke.
pub fn add_boxed_item(&mut self, item: Box<dyn SceneItem>, local_pos: Point) -> ItemId
Insert an already-boxed lightweight item at local_pos, returning its
id. The boxed-dyn counterpart of add_item — used by
SceneListAdapter whose delegate yields
Box<dyn SceneItem>.
pub fn set_item_handlers(&mut self, id: ItemId, handlers: Option<SceneItemHandlerSet>)
Replace an item's handler set. Pass None to clear.
pub fn handlers_mut(&mut self, id: ItemId) -> Option<&mut SceneItemHandlerSet>
Mutably borrow an item's handler set, lazily creating an
empty one if none exists. Returns None for unknown ids.
Allows fluent chains: scene.handlers_mut(id).unwrap().on_tap(…).cursor(…);.
pub fn handlers(&self, id: ItemId) -> Option<&SceneItemHandlerSet>
Read-only access to an item's handler set, if one is set.
pub fn effective_opacity(&self, id: ItemId) -> f32
Effective opacity composed up the parent chain — the product
of every ancestor's opacity and this item's. 1.0 for an
unknown id (so callers don't end up multiplying by a stale
value).
pub fn set_scene_rect(&mut self, rect: Option<Rect>)
Declare the scene's logical extent. None (the default)
means "auto-compute from items each query"; Some(rect)
fixes the extent regardless of item placement. Used by
SceneView for pan clamping and fit_to_content.
pub fn scene_rect_extent(&self) -> Option<Rect>
The resolved scene extent — user-declared via
Scene::set_scene_rect if set, otherwise the AABB
enclosing every item's scene rect. None when neither is
available (the user didn't declare and the scene is empty).
pub fn pan_axes(&mut self, axes: PanAxes)
Set the axes the view may pan along. Default
PanAxes::Both. Writes to the reactive signal; gesture
closures pick the change up on the next event.
pub fn current_pan_axes(&self) -> PanAxes
The currently-declared pan axes. Live read of the signal.
pub fn zoomable(&mut self, on: bool)
Set whether the view honors zoom gestures. Default true.
Writes to the reactive signal.
pub fn is_zoomable(&self) -> bool
Whether the scene currently allows zoom. Live read.
pub fn set_pan_bounds(&mut self, bounds: Option<Rect>)
Clamp the visible viewport to this scene-coord rect. None
(default) leaves pan unconstrained. When Some(r), the
SceneView's pan is clamped so the
visible scene region overlaps r. When r is smaller than
the visible viewport, the rect is centered.
Distinct from set_scene_rect:
scene_rect declares the scene's logical extent (used by
adopt_scene_size); pan_bounds controls what region the
user can scroll to. A doc-style app typically sets both to
the same rect.
pub fn current_pan_bounds(&self) -> Option<Rect>
The currently-declared pan-bounds rect. Live read.
pub fn set_zoom_range(&mut self, range: Option<std::ops::RangeInclusive<f32>>)
Inclusive [min, max] zoom-factor clamp. None (default)
is unconstrained from the Scene side — the SceneView
may still impose its own override.
The effective range applied by the SceneView is the
intersection of Scene + view-level override, so apps
cannot loosen a Scene-declared range by setting a wider
override on the view.
pub fn current_zoom_range(&self) -> Option<std::ops::RangeInclusive<f32>>
The currently-declared zoom range. Live read.
pub fn pan_axes_signal(&self) -> Signal<PanAxes>
Reactive accessors for live observation.
pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>>
Reactive pan-bounds signal.
pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>>
Reactive zoom-range signal.
pub fn zoomable_signal(&self) -> Signal<bool>
Reactive zoomable on/off signal.
pub fn constraints(&self) -> &SceneConstraints
Read-only view of the full constraint bundle. Useful when passing all four signals to a custom view implementation.
pub fn set_z(&mut self, id: ItemId, z: f32)
Set paint z-order for an entry. Higher z paints later (on top); equal-z falls back to insertion order. Default 0.0.
Works for both tiers: lightweight items re-sort within their
band on the next paint, and heavyweight widget entries restack the
arena children on the next rebuild (the SceneView reorders
node.children by z without recreating the widgets, so focus /
text-edit / animation state survives the restack). No-op for
unknown ids.
pub fn bring_to_front(&mut self, id: ItemId)
Raise an entry above all current entries by giving it a z one
greater than the current maximum. The drag-to-front primitive —
call it on drag-start so the grabbed card (and its text) renders
over the others. Works for both tiers (see set_z).
pub fn send_to_back(&mut self, id: ItemId)
Lower an entry below all current entries by giving it a z one less
than the current minimum. Works for both tiers (see
set_z).
pub fn z(&self, id: ItemId) -> Option<f32>
Read an entry's z-order.
pub fn set_layer(&mut self, id: ItemId, layer: SceneLayer)
Set the Under/Over paint band for a lightweight entry. Over
items paint after the heavyweight widget children (in the
SceneView's post_paint), so they sit on top of the cards;
Under items (the default) paint before them. Within a band,
set_z still orders items among themselves.
No-op for unknown ids.
pub fn layer(&self, id: ItemId) -> Option<SceneLayer>
Read an entry's Under/Over paint band. None for unknown ids.
pub fn set_item_parent(&mut self, child: ItemId, parent: Option<ItemId>)
Declare a parent/child relationship. child's local_pos
and transform are reinterpreted as relative to the new
parent's local frame — the visual position changes unless
the caller compensates. Re-buckets child's subtree.
Pass parent = None to detach (child's local frame becomes
scene-rooted again).
Cycle guard: if the proposed parent is child itself
or a descendant of child, the call is a no-op (no parent
change, no rebucket, no signal fire). Without this guard
the downstream rebucket_subtree walk loops indefinitely.
pub fn parent_of(&self, id: ItemId) -> Option<ItemId>
Parent of id, if any.
pub fn is_descendant_of(&self, id: ItemId, ancestor: ItemId) -> bool
Whether id's ancestor chain contains ancestor.
pub fn collect_descendants(&self, id: ItemId, out: &mut Vec<ItemId>)
Append every direct + transitive descendant of id into
out, breadth-first across declaration order. The id
itself is not included.
pub fn item(&self, id: ItemId) -> Option<&dyn SceneItem>
Borrow a lightweight SceneItem by id. None for unknown
ids and for heavyweight widget entries.
pub fn remove(&mut self, id: ItemId)
Remove an item by id, recursively dropping every descendant.
Mirrors Qt's QGraphicsScene::removeItem semantics: deleting
a parent deletes its children too. No-op if id is unknown.
Fires one ItemChange::Removed per id, descendants first
then the named parent — observers see a consistent
"leaves-then-root" order.
To remove id without deleting its children, call
Scene::orphan first to promote them to root-level, then
remove(id).
pub fn orphan(&mut self, id: ItemId)
Promote id's direct children to root-level (clear their
parent field). Used when an app wants to remove id without
dropping its children — call orphan(id) then remove(id).
No-op when id is unknown or has no children.
Fires one ItemChange::ParentChanged per detached child and
re-buckets every detached subtree in the spatial index — the
children's scene_transform shifts (no longer composes
id's) so their scene-space AABBs change. Without re-bucketing
the index, items_in_rect and
item_at would return stale results.
Apps wanting visual stability across the orphan call should
first bake id's scene_transform into each child's
local_pos + transform; otherwise children visibly jump.
pub fn items_in_rect(&self, scene_rect: Rect) -> Vec<ItemId>
All items whose scene-AABB intersects scene_rect.
Broad phase: the spatial index returns every id bucketed in
any cell touched by scene_rect. Narrow phase: each candidate
goes through scene_rect, which itself
dispatches via entry_index (an HashMap<ItemId, usize>),
so the per-candidate cost is O(parent-chain-depth) — not
O(N). Total query is O(visible × chain) instead of O(N).
pub fn item_thumbnails(&self) -> Vec<(Rect, teksilo_tokens::Color)>
Snapshot every visible item — both tiers — as a (scene_rect, color) pair suitable for a minimap thumbnail. Filters out items with
HAS_NO_CONTENTS (logical-only) and items hidden by IS_VISIBLE / a
hidden ancestor — the visible-effective set matches what the SceneView's
paint walk renders.
Ordered by insertion (low z first). A lightweight item's color comes
from SceneItem::thumbnail_color (its fill / stroke / a neutral grey);
a heavyweight widget entry has no SceneItem, so it's shown in a neutral
tint — a minimap that omitted the heavyweight tier would misrepresent a
widget-heavy scene (cards, nodes), so both tiers are included.
pub fn item_at(&self, scene_pt: Point) -> Option<ItemId>
Topmost lightweight item whose shape_contains fires for
scene_pt. Iterates items_in_rect for a tiny rect around
the point, sorts by z descending, and returns the first hit.
Heavyweight widget entries are skipped (their hit-testing is
handled by the arena event dispatch).
Limitation: items flagged
IGNORES_TRANSFORMATIONS
hit-test in screen space, not scene space — so this scene-only
query may incorrectly hit them or miss them depending on the
current view transform. Apps that route pointer events through
SceneView's dispatch get screen-space hit-test for IGNORES
items automatically; only use item_at directly for normal
items, or pair with the view transform to filter.
pub fn colliding_items(&self, id: ItemId) -> Vec<ItemId>
Items whose scene-AABB intersects the AABB of id. Excludes
id itself. Apps use this for "which other items overlap
this card?" queries — graph editors checking node-on-node
overlap, CAD canvases finding adjacent geometry. Backed by
the spatial index, so the cost is O(visible) not O(N).
pub fn items_along_path(&self, path: &Path) -> Vec<ItemId>
Items whose scene-AABB intersects path's bounding rect.
Apps use this for "which items lie under this connector?"
queries — graph editors highlighting hovered connectors,
CAD canvases doing point-in-polygon style picking. The
narrow phase is AABB-vs-AABB; per-segment-distance precision
is left to the app.
pub fn items_at(&self, scene_pt: Point) -> Vec<ItemId>
All lightweight items whose shape_contains fires for
scene_pt, sorted topmost-first by z.
pub fn len(&self) -> usize
Number of entries in the scene.
pub fn is_empty(&self) -> bool
Whether the scene is empty.
pub fn ids(&self) -> Vec<ItemId>
All ids in insertion order.
pub fn index(&self) -> &dyn SpatialIndex
Borrow the spatial index (diagnostics / tests).
pub fn add_magnet(&mut self, item: ItemId, magnet: Magnet) -> MagnetId
Attach a Magnet to item and return its MagnetId.
Magnets are local to their item (their local_pos is in the
item's frame), so they follow the item under any move / rotate /
scale via the same scene_transform the item uses. No-op
returning a fresh-but-unowned id if item is unknown — callers
add magnets to items they just created.
Bumps the AT-structure change counter (magnets are AT structure)
so a SceneView with magnetism enabled re-walks its synthetic
magnet nodes.
pub fn remove_magnet(&mut self, magnet: MagnetId)
Remove a magnet by id. No-op if the id is unknown.
pub fn clear_magnets(&mut self, item: ItemId)
Remove every magnet attached to item. No-op if none.
pub fn set_magnet_local_pos(&mut self, magnet: MagnetId, local_pos: Point)
Move a magnet to a new position in its owning item's local frame. No-op if the id is unknown.
pub fn set_magnet_enabled(&mut self, magnet: MagnetId, enabled: bool)
Enable or disable a magnet. Disabled magnets are skipped by broad-phase, feedback, the keyboard cycle, and AT emission. No-op if the id is unknown.
pub fn magnet_ids_of(&self, item: ItemId) -> Vec<MagnetId>
The ids of every magnet attached to item, in insertion order
(enabled and disabled alike). Empty if item is unknown or has
no magnets.
pub fn magnet_owner(&self, magnet: MagnetId) -> Option<ItemId>
The owning item of a magnet, or None if the id is unknown.
pub fn magnet_enabled(&self, magnet: MagnetId) -> bool
Whether a magnet is enabled. false for an unknown id.
pub fn magnet_scene_pos(&self, magnet: MagnetId) -> Option<Point>
A magnet's position in scene coordinates (its local position
projected through its owning item's scene_transform). None
for an unknown id or a degenerate item transform.
pub fn magnet(&self, magnet: MagnetId) -> Option<MagnetRef>
Resolve a magnet to a borrow-free MagnetRef snapshot (id,
owning item, role, payload clone, current scene position).
None for an unknown id or a degenerate item transform.
pub fn compute_item_snap( &self, dragged: ItemId, drag_delta: Vec2, capture_radius: f32, predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict, ) -> Option<MagnetSnap>
Compute the best item-drag snap: the dragged item is visually
offset by drag_delta, and each of its enabled magnets seeks the
nearest accepting magnet on another item within capture_radius
(in scene units). Returns the globally closest accepting pair, or
None if nothing accepts within range.
Pure mechanism: it collects candidates under a brief read, then
runs the consumer predicate with no scene borrow held, so the
predicate may inspect payloads freely. snap_vector added to
drag_delta aligns the dragged magnet onto its target.
pub fn compute_port_snap( &self, source: MagnetId, cursor_scene: Point, capture_radius: f32, predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict, ) -> Option<(MagnetRef, Option<Rc<dyn std::any::Any>>)>
Compute the best port-drag snap: a single source magnet is
dragging a transient wire whose free end is at cursor_scene.
Finds the nearest accepting target magnet within
capture_radius (scene units), excluding the source's own
magnet. Returns the target MagnetRef and the accepting
verdict's payload, or None.
pub fn nearest_magnet(&self, scene_pt: Point, radius: f32) -> Option<MagnetId>
The nearest enabled magnet to scene_pt within radius (scene
units), or None. Used by the view to start a port-drag from a
grabbed magnet handle (the handle's grab area is a screen-pixel
disc, converted to scene units by the caller).
pub fn add_a11y_group(&mut self, builder: A11yGroupBuilder) -> A11yGroupId
Declare a virtual AT group. The group has no visual counterpart — it exists so the AT walker can emit an AT node under which items / other groups / widgets can be reparented.
pub fn remove_a11y_group(&mut self, id: A11yGroupId)
Remove a logical group; orphaned references fall back to SceneView root. Relations / live / landmarks / categories targeting this group are cleaned up too.
pub fn a11y_group(&self, id: A11yGroupId) -> Option<&A11yGroup>
Borrow a logical group by id.
pub fn set_a11y_parent(&mut self, child: A11yNode, parent: Option<A11yNode>)
Declare a logical-parent relationship for AT (independent of visual placement).
pub fn a11y_parent_of(&self, child: A11yNode) -> Option<A11yNode>
The currently-declared logical parent of a node.
pub fn add_a11y_relation(&mut self, from: A11yNode, kind: A11yRelation, to: A11yNode)
Declare an AT relationship between two nodes.
pub fn a11y_relations(&self) -> &[(A11yNode, A11yRelation, A11yNode)]
All declared AT relations.
pub fn set_a11y_live(&mut self, node: A11yNode, live: accesskit::Live)
Mark a node as a live region. Pass Live::Off to clear.
pub fn set_a11y_landmark(&mut self, node: A11yNode, role: accesskit::Role)
Mark a node as a landmark by overriding its role. Pass
Role::Unknown to clear.
pub fn set_a11y_categories(&mut self, node: A11yNode, categories: &[A11yCategory])
Tag a node with rotor / quick-nav categories.
pub fn a11y_categories_of(&self, node: A11yNode) -> Option<&[A11yCategory]>
Read declared categories for a node.
SceneListAdapter
SceneListAdapter — keep lightweight scene items in sync with a
teksilo_data list model or data source.
A scene's lightweight tier (SceneItem) has no arena-backed identity
and no built-in notion of "one item per row of some data collection" —
unlike ListView/TableView, which rebuild their child widgets from a
ListModel<T> / ListDataSource<Item = T> automatically. SceneListAdapter
is the scene-tier equivalent: give it a data source and a delegate
(Fn(&T, usize) -> Box<dyn SceneItem>), and it materialises one scene
item per row, then reconciles them whenever the source changes.
SceneListAdapter is a plain struct, not a Widget — it owns no
arena node. Build one from a handler or build(), keep it alive for as
long as you want the items tracked (typically stashed in the owning
widget), and it does its work purely through SceneModel mutations and
a teksilo_data change observer.
Delegate contract
The delegate's return value — a Box<dyn SceneItem> — carries its own
absolute scene position via SceneItem::local_bounds (exactly like
any other item constructed with RectItem::new(Rect::new(x, y, w, h))
and typically placed at Point::ZERO). SceneListAdapter always inserts
the delegate's item at Point::ZERO via
SceneModel::add_boxed_item — it never re-positions the item. If rows
should be laid out (grid, list, freeform), the delegate itself computes
each row's local_bounds from its index (or from data on T) before
returning the boxed item.
The delegate must not mutate the source model. It is invoked inside
the source's row-read (ListModel::with_item, which holds the model's
RefCell borrow across the callback), so calling push / set / remove
/ clear on the same model from within the delegate panics with
RefCell already borrowed. Treat the delegate as a pure
(&T, index) -> item projection; drive data changes from outside it. (This
is the same contract ListView's delegate has, for the same reason.)
Reconciliation policy
A lightweight item has no inherent identity beyond the id the Scene
mints for it — there is nothing to "patch" in place, only remove-and-add.
SceneListAdapter picks the simplest policy that is always correct:
- Structural changes (insert / remove / move / reset — and a windowed
source's
WindowLoaded, see below) rebuild every item: every adapter-owned id is removed from the scene, then the current source is re-read start to finish and one item is built per row. This is O(n) but never leaks an item and never desyncs data-index → item mapping, even when the delegate's output depends onindex(which shifts on insert/remove/move). Incremental insert/remove that spares unaffected rows is a possible future optimisation, not implemented here. ItemUpdated { index }(single-row content change, no structural shift) rebuilds only that one row: the old scene item is removed and a fresh one built from the current data atindexreplaces it.WindowLoaded { range }is treated as a structural change (full rebuild), not a per-row patch. A row for whichListDataSource::with_itemreturnsNone(not yet loaded) has no scene item at all — there is no adapter-agnostic placeholder item to substitute — so a partially-loaded window can only be positionally correct if data-index → adapter-slot alignment is rederived from scratch. SinceWindowLoadedfires rarely (after a batch fetch, not per frame), the O(n) cost is a non-issue; internally the id table tracks unloaded rows asNoneslots so a later full rebuild always lands loaded rows back at their correct index.
Borrow discipline
Every reconciliation reads the source data (via the erased
with_item_fn, which takes its own short-lived borrow per row) and
builds every Box<dyn SceneItem> into a local Vec first, then
mutates the SceneModel (remove / add_boxed_item) only after all
reads are done. SceneModel's mutators internally borrow_mut the
shared RefCell<Scene>; interleaving a read and a scene mutation inside
the same borrow would panic (or, worse, silently reenter) if the reader
and the mutator ever aliased the same RefCell. Mirrors ListView's
"collect owned data, drop the borrow, then mutate" contract.
Example
use teksilo_data::ListModel;
use teksilo_scene::{RectItem, SceneListAdapter, SceneModel};
use teksilo_canvas::Rect;
use teksilo_tokens::Color;
struct Card { x: f32, y: f32, color: Color }
let scene = SceneModel::new();
let cards = ListModel::from_vec(vec![
Card { x: 0.0, y: 0.0, color: Color::RED },
Card { x: 140.0, y: 0.0, color: Color::BLUE },
]);
// Kept alive by the caller for as long as the sync should run.
let adapter = SceneListAdapter::from_model(&cards, scene.clone(), |card, _index| {
Box::new(
RectItem::new(Rect::new(card.x, card.y, 120.0, 80.0)).fill(card.color),
)
});
assert_eq!(adapter.len(), 2);
cards.push(Card { x: 280.0, y: 0.0, color: Color::GREEN });
assert_eq!(adapter.len(), 3);
Builder methods at a glance
from_model, from_source, item_id_at, ids, len, is_empty, clear
API reference
📖 Full rustdoc API for this module
pub struct SceneListAdapter
Keeps a set of lightweight SceneItems in sync with a
teksilo_data::ListModel<T> / ListDataSource<Item = T>.
Not a Widget — a plain handle you construct once (typically from a
composing widget's build() or app setup code) and keep alive for as
long as the sync should run. See the module docs for the delegate
contract, reconciliation policy, and borrow discipline.
Dropping
Dropping a SceneListAdapter drops its ObserverHandle, which stops
the adapter from reacting to further source changes. It deliberately
does not remove the adapter's items from the scene — running scene
mutations from inside a Drop impl risks a re-entrant borrow of the
shared RefCell<Scene> if the drop happens while some other code
already holds a borrow (e.g. mid-notification). Call
clear first if you want the items gone before dropping.
#![allow(unused)] fn main() { pub struct SceneListAdapter<T: 'static> { /* fields */ } }
Methods
pub fn from_model( model: &ListModel<T>, scene: SceneModel, delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static, ) -> Self
Track model's rows as scene items in scene, built by delegate.
Materialises every current row immediately (as if a DataChange::Reset
had just fired), then keeps the scene in sync via
ListModel::observe_changes for as long as the returned adapter is
alive. See the module docs for the delegate contract and
reconciliation policy.
pub fn from_source<S: ListDataSource<Item = T> + 'static>( source: Rc<S>, scene: SceneModel, delegate: impl Fn(&T, usize) -> Box<dyn SceneItem> + 'static, ) -> Self
Track an external ListDataSource's rows as scene items in scene,
built by delegate.
Takes source as an Rc<S> (rather than by value) so the caller can
keep its own handle to the same source alongside the adapter — the
same convention as ListView::from_source / TableView's erasure.
See Self::from_model for the materialisation + reconciliation
behaviour, which is identical for both constructors.
pub fn item_id_at(&self, index: usize) -> Option<ItemId>
The scene item id materialised for data row index, or None if
index is out of range or the row has no materialised item (an
unloaded row of a windowed source).
pub fn ids(&self) -> Vec<ItemId>
All ids currently materialised by this adapter, in data order (rows with no materialised item are omitted, so this may be shorter than the source's row count).
pub fn len(&self) -> usize
Number of scene items this adapter currently owns.
pub fn is_empty(&self) -> bool
Whether this adapter currently owns no scene items.
pub fn clear(&self)
Remove every scene item this adapter owns from the scene and forget them. The adapter keeps observing the source afterward — a later source change re-materialises rows as usual.
SceneMinimap
SceneMinimap — a small thumbnail of a Scene
showing all items as dots / rects scaled down, with an overlay
highlighting the currently visible viewport rectangle.
Use
use teksilo_scene::{Scene, SceneView, SceneMinimap};
use teksilo_canvas::Rect;
# use teksilo_widgets::VStack;
let mut scene = Scene::new();
/* …populate scene… */
// Build the SceneView FIRST so we can read its reactive
// viewport signal and its scene's snapshot of items.
let view = SceneView::new(scene);
let content = view
.scene_content_bounds()
.unwrap_or(Rect::new(0.0, 0.0, 1000.0, 1000.0));
let viewport_signal = view.viewport_in_scene_signal();
let item_thumbs = view.scene().item_thumbnails(); // Vec<(Rect, Color)>
let _w = VStack::new()
.child(view)
.child(
SceneMinimap::new(content, viewport_signal)
.items(item_thumbs)
.size(200.0, 150.0),
);
For a live "items as they move" minimap, re-call
Scene::item_thumbnails on
scene mutations and rebuild the widget tree (or wire a
Signal<Vec<(Rect, Color)>> if your app needs per-frame
reactivity).
Design
Deliberately decoupled from SceneView: it doesn't reach into
the scene model. Instead it consumes a content extent (the rect
that maps to "the entire minimap area"), a static Vec<(Rect, Color)>
of item thumbnails (refreshed by the app whenever items move),
and a Signal<Rect> for the live viewport rectangle.
Apps that want a live "items as they move" minimap rebuild their
widget tree on scene mutations or wire a Signal<Vec<...>>. The
viewport overlay is reactive on its own — the minimap re-paints
whenever the SceneView's pan / zoom changes, with no manual
plumbing.
Builder methods at a glance
size, items, background, border, viewport_color, content_outline, on_click
API reference
📖 Full rustdoc API for this module
pub struct SceneMinimap
A small thumbnail rendering of a Scene's
content, with the live viewport rectangle highlighted.
Paint order: background fill → optional content-bounds outline → item thumbnails (dots / rects) → viewport overlay rect.
#![allow(unused)] fn main() { pub struct SceneMinimap { /* fields */ } }
Methods
pub fn new(content_bounds: Rect, viewport: Signal<Rect>) -> Self
Construct a minimap covering content_bounds (the scene-coord
extent that maps to the full minimap area), with viewport
driving the live overlay rectangle.
pub fn size(mut self, width: f32, height: f32) -> Self
Override the minimap size. Default 200×150.
pub fn items(mut self, items: Vec<(Rect, Color)>) -> Self
Static list of item thumbnails: (scene_rect, color). The
minimap projects each rect onto its drawing area and fills it
with color. Apps refresh by rebuilding the widget tree
when items move.
pub fn background(mut self, color: Color) -> Self
Background fill color. Default semi-transparent white.
pub fn border(mut self, border: Option<(Color, f32)>) -> Self
Border around the minimap drawing area. Pass None for no
border. Default 1px @ 50% black.
pub fn viewport_color(mut self, color: Color) -> Self
Color of the viewport overlay rectangle. Default solid blue.
pub fn content_outline(mut self, outline: Option<(Color, f32)>) -> Self
Outline the content extent inside the minimap (gives users a
"you're somewhere inside this much scene" cue when the
minimap is taller / wider than its content). Default None.
pub fn on_click<F>(mut self, callback: F) -> Self where F: Fn(Point, &mut EventContext) + 'static,
Click handler: fires with the scene-coord corresponding to
the click, plus the standard EventContext. Apps wire this
to e.g. SceneView::pan_to_center for click-to-recenter.
SceneModel
SceneModel — a shared, cloneable handle to a Scene.
Mirrors the ListModel = Rc<RefCell<ListModelInner>> pattern from
teksilo-data: cloning a SceneModel produces a second handle to the
same scene, so multiple SceneViews can render one
scene (overview + detail panes, same-document multi-window, headless model
reuse). Mutate the model once and every attached view reconciles.
Heavyweight content across views
A heavyweight Widget instance can live in only one arena, so a shared
model cannot hand the same Box<dyn Widget> to two views. Two paths:
- Single-view —
add_widgetstores the widget in a one-shot slot drained by the first view that builds. A second view sharing the model produces no child for it. - Multi-view —
add_widget_itemstores a type-erasedpayload; each view's delegate (SceneView::delegate_typed) builds its own instance from the payload.set_payloadreplaces the data and every view rebuilds that item.
Borrow / observer contract
Every mutator takes &self, borrows the inner RefCell<Scene> mutably,
mutates, and the borrow drops at the end of the statement. The change
signal fires inside that borrow (via Scene::emit_item_change), but
Signal::try_set snapshots its observers and releases the signal's own
cell before invoking them — so the only rule is: an observer registered
on item_change_signal /
a11y_change_signal must not re-borrow
the SceneModel in its callback. A SceneView observer only bumps its
own per-view signals, so it is safe. Likewise a view delegate must not
synchronously mutate the model during a build-time call (the view drops all
model borrows before invoking it; the delegate's handlers may mutate
later).
Builder methods at a glance
with_index, from_scene, handle_count, add_widget, add_widget_item, set_payload, payload, add_item, add_item_dynamic, add_boxed_item, set_local_pos, set_local_bounds, set_transform, set_flags, set_flag, set_visible, set_opacity, set_item_fill, clear_item_fill, set_item_stroke, clear_item_stroke, set_z, bring_to_front, send_to_back, set_layer, set_item_parent, remove, orphan, set_item_handlers, with_handlers_mut, add_magnet, remove_magnet, clear_magnets, set_magnet_local_pos, set_magnet_enabled, magnet_ids_of, magnet_owner, magnet_scene_pos, magnet, compute_item_snap, compute_port_snap, nearest_magnet, set_scene_rect, pan_axes, zoomable, set_pan_bounds, set_zoom_range, add_a11y_group, remove_a11y_group, set_a11y_parent, add_a11y_relation, set_a11y_live, set_a11y_landmark, set_a11y_categories, refresh_dynamic_bounds, item_change_signal, a11y_change_signal, mutation_version, pan_axes_signal, pan_bounds_signal, zoom_range_signal, zoomable_signal, len, is_empty, ids, local_pos, local_bounds, transform, scene_transform, scene_pos, scene_rect, flags, is_effectively_visible, opacity, effective_opacity, z, layer, parent_of, is_descendant_of, scene_rect_extent, current_pan_axes, is_zoomable, current_pan_bounds, current_zoom_range, items_in_rect, item_at, items_at, colliding_items, a11y_parent_of
API reference
📖 Full rustdoc API for this module
pub struct SceneModel
A shared, cloneable handle to a Scene.
#![allow(unused)] fn main() { pub struct SceneModel(pub(crate) Rc<RefCell<Scene>>); }
Methods
pub fn new() -> Self
A handle to a fresh empty scene with the default spatial index.
pub fn with_index(index: Box<dyn SpatialIndex>) -> Self
A handle to a fresh scene with a custom SpatialIndex.
pub fn from_scene(scene: Scene) -> Self
Wrap an existing Scene in a handle. Used by
SceneView::new for the single-view path.
pub fn handle_count(&self) -> usize
Number of distinct handles to this scene (1 = unshared).
pub fn add_widget<W: Widget + 'static>(&self, widget: W, rect: Rect) -> ItemId
Single-view heavyweight widget (the one-shot Once path). The first
view to build drains it; a second view sharing this model produces no
child for it. For multi-view, use add_widget_item.
pub fn add_widget_item<P: 'static>(&self, payload: P, rect: Rect) -> ItemId
Multi-view heavyweight item: store a typed payload; each view builds
its own widget instance from it via its delegate. Returns the ItemId.
pub fn set_payload<P: 'static>(&self, id: ItemId, payload: P)
Replace the payload of a Delegated heavyweight item; every view
rebuilds that item's widget on the next pass.
Panics
Panics if id is unknown, refers to a single-view add_widget (Once)
entry, or refers to a lightweight item.
pub fn payload(&self, id: ItemId) -> Option<Rc<dyn std::any::Any>>
The current type-erased payload of a Delegated item, if any.
pub fn add_item<I: SceneItem + 'static>(&self, item: I, local_pos: Point) -> ItemId
Add a lightweight SceneItem at local_pos.
pub fn add_item_dynamic<I: SceneItem + 'static>(&self, item: I, local_pos: Point) -> ItemId
Add a lightweight item with signal-driven (dynamic) bounds.
pub fn add_boxed_item(&self, item: Box<dyn SceneItem>, local_pos: Point) -> ItemId
Add an already-boxed lightweight item at local_pos. The boxed-dyn
counterpart of add_item, used by
SceneListAdapter.
pub fn set_local_pos(&self, id: ItemId, local_pos: Point)
Move id to local_pos in its parent's coordinate space; notifies all views.
pub fn set_local_bounds(&self, id: ItemId, local_bounds: Rect)
Replace the local bounding rect of id; notifies all views.
pub fn set_transform(&self, id: ItemId, transform: Transform2D)
Set an additional local-to-parent transform (rotation, scale) on id; notifies all views.
pub fn set_flags(&self, id: ItemId, flags: ItemFlags)
Replace the complete ItemFlags bitset for id; notifies all views.
pub fn set_flag(&self, id: ItemId, flag: ItemFlags, on: bool)
Set or clear a single ItemFlags bit on id; notifies all views.
pub fn set_visible(&self, id: ItemId, visible: bool)
Show or hide id (also hides its descendants); notifies all views.
pub fn set_opacity(&self, id: ItemId, opacity: f32)
Set the paint opacity of id (0.0 = transparent, 1.0 = opaque); notifies all views.
pub fn set_item_fill(&self, id: ItemId, fill: impl Into<ColorProp>)
Replace a lightweight item's fill colour live; every view repaints
(no relayout/rebuild). Accepts a plain Color,
a theme role, a Signal<Color>, or a Signal<Role>. See
Scene::set_item_fill for the reactive-colour contract.
pub fn clear_item_fill(&self, id: ItemId)
Clear a lightweight item's fill; every view repaints.
pub fn set_item_stroke(&self, id: ItemId, color: impl Into<ColorProp>, style: StrokeStyle)
Replace a lightweight item's stroke (colour + StrokeStyle) live;
every view repaints (no relayout/rebuild).
pub fn clear_item_stroke(&self, id: ItemId)
Clear a lightweight item's stroke; every view repaints.
pub fn set_z(&self, id: ItemId, z: f32)
Set the z-order of id within its layer; higher values paint on top.
pub fn bring_to_front(&self, id: ItemId)
Give id the highest z-value in its layer so it paints on top of all siblings.
pub fn send_to_back(&self, id: ItemId)
Give id the lowest z-value in its layer so it paints beneath all siblings.
pub fn set_layer(&self, id: ItemId, layer: SceneLayer)
Move id to a different SceneLayer (background, default, foreground); notifies all views.
pub fn set_item_parent(&self, child: ItemId, parent: Option<ItemId>)
Re-parent child under parent (or under the scene root when None); notifies all views.
pub fn remove(&self, id: ItemId)
Remove an item and its descendants. Drops any Delegated payload Rc
and cleans the item's a11y mappings; alive logical children re-root.
pub fn orphan(&self, id: ItemId)
Promote an item's children to the scene root.
pub fn set_item_handlers(&self, id: ItemId, handlers: Option<SceneItemHandlerSet>)
Replace the SceneItemHandlerSet of id, or clear it with None.
pub fn with_handlers_mut<R>( &self, id: ItemId, f: impl FnOnce(&mut SceneItemHandlerSet) -> R, ) -> Option<R>
Mutate an item's handler set through a closure (avoids returning a
borrow guard tied to the RefMut).
pub fn add_magnet(&self, item: ItemId, magnet: Magnet) -> MagnetId
Attach a Magnet to item; see Scene::add_magnet.
pub fn remove_magnet(&self, magnet: MagnetId)
Remove a magnet by id; see Scene::remove_magnet.
pub fn clear_magnets(&self, item: ItemId)
Remove every magnet on item; see Scene::clear_magnets.
pub fn set_magnet_local_pos(&self, magnet: MagnetId, local_pos: Point)
Move a magnet in its item's local frame; see Scene::set_magnet_local_pos.
pub fn set_magnet_enabled(&self, magnet: MagnetId, enabled: bool)
Enable or disable a magnet; see Scene::set_magnet_enabled.
pub fn magnet_ids_of(&self, item: ItemId) -> Vec<MagnetId>
Ids of every magnet on item; see Scene::magnet_ids_of.
pub fn magnet_owner(&self, magnet: MagnetId) -> Option<ItemId>
The owning item of a magnet; see Scene::magnet_owner.
pub fn magnet_scene_pos(&self, magnet: MagnetId) -> Option<Point>
A magnet's scene position; see Scene::magnet_scene_pos.
pub fn magnet(&self, magnet: MagnetId) -> Option<MagnetRef>
Resolve a magnet to a MagnetRef snapshot; see Scene::magnet.
pub fn compute_item_snap( &self, dragged: ItemId, drag_delta: Vec2, capture_radius: f32, predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict, ) -> Option<MagnetSnap>
Best item-drag snap; see Scene::compute_item_snap. A shared
(read-only) borrow is held while the predicate runs over owned
candidate snapshots, so the predicate may read but must not mutate
the model.
pub fn compute_port_snap( &self, source: MagnetId, cursor_scene: Point, capture_radius: f32, predicate: &dyn Fn(&MagnetRef, &MagnetRef) -> MagnetVerdict, ) -> Option<(MagnetRef, Option<std::rc::Rc<dyn std::any::Any>>)>
Best port-drag snap; see Scene::compute_port_snap.
pub fn nearest_magnet(&self, scene_pt: Point, radius: f32) -> Option<MagnetId>
Nearest enabled magnet within radius; see Scene::nearest_magnet.
pub fn set_scene_rect(&self, rect: Option<Rect>)
Set the logical extent of the scene (used for scroll-bar sizing); None = unbounded.
pub fn pan_axes(&self, axes: PanAxes)
Restrict panning to horizontal, vertical, or both axes; updates pan_axes_signal.
pub fn zoomable(&self, on: bool)
Enable or disable pinch/scroll zoom; updates zoomable_signal.
pub fn set_pan_bounds(&self, bounds: Option<Rect>)
Clamp the camera pan to bounds (scene coordinates); None = no limit; updates pan_bounds_signal.
pub fn set_zoom_range(&self, range: Option<std::ops::RangeInclusive<f32>>)
Restrict the zoom factor to range; None = no limit; updates zoom_range_signal.
pub fn add_a11y_group(&self, builder: A11yGroupBuilder) -> A11yGroupId
Register a logical AT group (landmark / rotor category container); returns its stable A11yGroupId.
pub fn remove_a11y_group(&self, id: A11yGroupId)
Remove a previously registered AT group; triggers an a11y_change_signal bump.
pub fn set_a11y_parent(&self, child: A11yNode, parent: Option<A11yNode>)
Re-parent child in the AT tree, overriding the default visual parent; None re-attaches under the scene root.
pub fn add_a11y_relation(&self, from: A11yNode, kind: A11yRelation, to: A11yNode)
Declare a cross-node AT relationship (controls, describes, labels) from from to to.
pub fn set_a11y_live(&self, node: A11yNode, live: accesskit::Live)
Mark node as a live region (Polite or Assertive) so assistive tech announces changes to it.
pub fn set_a11y_landmark(&self, node: A11yNode, role: accesskit::Role)
Assign a landmark role to node (e.g. Role::Region, Role::Main) for rotor navigation.
pub fn set_a11y_categories(&self, node: A11yNode, categories: &[A11yCategory])
Register node under the given rotor A11yCategory slices so it appears in category-filtered navigation.
pub fn refresh_dynamic_bounds(&self) -> bool
Re-read signal-driven bounds for add_item_dynamic entries; returns
true if any changed.
pub fn item_change_signal(&self) -> Signal<ItemChange>
Reactive signal fired on every structural scene change; all views observe this to reconcile.
pub fn a11y_change_signal(&self) -> Signal<u64>
Reactive monotonic counter bumped on every AT-structure change; views re-walk accessibility on any increment.
pub fn mutation_version(&self) -> u64
Monotonic counter incremented on every mutation; useful for cache invalidation without observing a signal.
pub fn pan_axes_signal(&self) -> Signal<PanAxes>
Reactive current PanAxes restriction; updated by pan_axes.
pub fn pan_bounds_signal(&self) -> Signal<Option<Rect>>
Reactive camera-pan clamp bounds; updated by set_pan_bounds.
pub fn zoom_range_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>>
Reactive zoom-factor clamp range; updated by set_zoom_range.
pub fn zoomable_signal(&self) -> Signal<bool>
Reactive zoom-enabled flag; updated by zoomable.
pub fn len(&self) -> usize
Total number of items in the scene (lightweight + heavyweight).
pub fn is_empty(&self) -> bool
Returns true when the scene contains no items.
pub fn ids(&self) -> Vec<ItemId>
All ItemIds currently in the scene, in insertion order.
pub fn local_pos(&self, id: ItemId) -> Option<Point>
The local position of id in its parent's coordinate space; None if id is unknown.
pub fn local_bounds(&self, id: ItemId) -> Option<Rect>
The local bounding rect of id; None if id is unknown.
pub fn transform(&self, id: ItemId) -> Option<Transform2D>
The additional local-to-parent transform of id (beyond position); None if none is set.
pub fn scene_transform(&self, id: ItemId) -> Transform2D
The full local-to-scene transform for id (parent chain composed); identity if id is unknown.
pub fn scene_pos(&self, id: ItemId) -> Option<Point>
The origin of id mapped into scene coordinates; None if id is unknown.
pub fn scene_rect(&self, id: ItemId) -> Option<Rect>
The bounding rect of id in scene coordinates (local bounds transformed by the parent chain); None if unknown.
pub fn flags(&self, id: ItemId) -> Option<ItemFlags>
The ItemFlags bitset of id; None if id is unknown.
pub fn is_effectively_visible(&self, id: ItemId) -> bool
Returns true if id and all of its ancestors are visible.
pub fn opacity(&self, id: ItemId) -> Option<f32>
The own opacity of id (ignoring ancestors); None if id is unknown.
pub fn effective_opacity(&self, id: ItemId) -> f32
Accumulated opacity for id (own × each ancestor's opacity).
pub fn z(&self, id: ItemId) -> Option<f32>
The z-order value of id within its layer; None if id is unknown.
pub fn layer(&self, id: ItemId) -> Option<SceneLayer>
The SceneLayer of id; None if id is unknown.
pub fn parent_of(&self, id: ItemId) -> Option<ItemId>
The direct parent of id, or None if it is a root item (or unknown).
pub fn is_descendant_of(&self, id: ItemId, ancestor: ItemId) -> bool
Returns true if id is anywhere in ancestor's subtree.
pub fn scene_rect_extent(&self) -> Option<Rect>
The logical extent set via set_scene_rect; None = unbounded.
pub fn current_pan_axes(&self) -> PanAxes
The current pan-axis restriction without subscribing to its signal.
pub fn is_zoomable(&self) -> bool
Returns true if zoom is currently enabled (snapshot; use zoomable_signal for reactivity).
pub fn current_pan_bounds(&self) -> Option<Rect>
Current pan-clamp bounds without subscribing to its signal.
pub fn current_zoom_range(&self) -> Option<std::ops::RangeInclusive<f32>>
Current zoom-factor clamp range without subscribing to its signal.
pub fn items_in_rect(&self, scene_rect: Rect) -> Vec<ItemId>
All items whose bounding rects overlap scene_rect (spatial-index query).
pub fn item_at(&self, scene_pt: Point) -> Option<ItemId>
The topmost item under scene_pt using exact-shape hit-testing; None if no item is hit.
pub fn items_at(&self, scene_pt: Point) -> Vec<ItemId>
All items under scene_pt (exact-shape hit-test), ordered front-to-back.
pub fn colliding_items(&self, id: ItemId) -> Vec<ItemId>
All items whose bounding rects intersect id's bounding rect.
pub fn a11y_parent_of(&self, child: A11yNode) -> Option<A11yNode>
The AT-tree parent of child as set by set_a11y_parent; None = visual default.
SceneScrollView
SceneScrollView — a thin composite that gives a SceneView draggable
scroll bars, mirroring the widget-tier
ScrollArea's options: the same
ScrollBarMode (Overlay / Permanent / Thin, with its Tier-3
ScrollBarStyle), per-axis ScrollBarPolicy (AsNeeded / AlwaysOn /
AlwaysOff), and thickness. Smooth wheel / keyboard panning and the
overscroll policy stay configured on the wrapped SceneView itself (it
already animates pan and honours reduced-motion); the scroll bars simply
track that motion.
Why a wrapper
A SceneView wraps its entire child subtree in the pan/zoom view
transform (via set_content_transform), so scroll bars added as its own
children would pan and zoom along with the content. Instead — exactly like
ScrollArea wraps arbitrary content and SceneMinimap is a sibling overlay
— this widget hosts the SceneView as content plus two reusable
ScrollBar children outside the transform,
and bridges the bars' scroll signals to the view's pan_x/pan_y.
How the bridge works
The scene's scrollable extent is its effective pan bounds (the
Scene-declared pan_bounds intersected with any view-level
pan_bounds_override), falling back to the union of item bounds. With the
standard view transform screen = zoom*scene + pan + bounds_origin and the
SceneView placed flush at this widget's origin (so bounds_origin cancels
the viewport's screen offset), the per-axis mapping in screen-pixel
units is:
scroll_pos_x = -pan_x - extent.x * zoom
max_scroll_x = (extent.width * zoom - viewport_width).max(0)
viewport_ratio = viewport_width / (extent.width * zoom)
and the inverse, when a bar writes a new scroll_pos_x:
pan_x = -extent.x * zoom - scroll_pos_x
The display direction (camera → bar metrics) is recomputed each
place_children — the same place ScrollArea computes its metrics — so it
never lags a layout pass. The interaction direction (bar drag → pan) is a
pair of guarded effects, one per axis, that snap the pan immediately so
the thumb tracks the cursor 1:1 (the desktop scroll-bar convention). Both
use an epsilon equality guard (the color_picker bidirectional-bridge
idiom) so a write arriving from the opposite direction is a no-op and the
loop closes — in particular the bars track the SceneView's own smooth
wheel / keyboard pan animation without fighting it.
Rotation is supported but approximate: the mapping is exact only when
rotation == 0; while rotated the thumbs track the camera using the
axis-aligned formula above.
Builder methods at a glance
scroll_bar_mode, vertical_policy, horizontal_policy, scroll_bar_thickness, scroll_pos_x_signal, scroll_pos_y_signal, max_scroll_x_signal, max_scroll_y_signal, viewport_ratio_x_signal, viewport_ratio_y_signal
API reference
📖 Full rustdoc API for this module
pub struct SceneScrollView
A SceneView with draggable scroll bars.
Construct directly from a configured view, or via the
SceneView::with_scroll_bars convenience method:
#![allow(unused)] fn main() { use teksilo_scene::{Scene, SceneView, SceneScrollView, ScrollBarMode}; let scrollable = SceneView::new(Scene::new()) .with_scroll_bars() .scroll_bar_mode(ScrollBarMode::Overlay); let _ = scrollable; }
#![allow(unused)] fn main() { pub struct SceneScrollView { /* fields */ } }
Methods
pub fn new(view: SceneView) -> Self
Wrap a configured SceneView in a scroll-bar host. Captures the
view's pan/zoom/model signals before moving it into the arena.
pub fn scroll_bar_mode(mut self, mode: ScrollBarMode) -> Self
Set the scroll-bar display mode (Overlay / Permanent / Thin).
pub fn vertical_policy(mut self, policy: ScrollBarPolicy) -> Self
Set the vertical scroll-bar visibility policy.
pub fn horizontal_policy(mut self, policy: ScrollBarPolicy) -> Self
Set the horizontal scroll-bar visibility policy.
pub fn scroll_bar_thickness(mut self, thickness: f32) -> Self
Set the scroll-bar thickness (and the gutter width in Permanent mode).
pub fn scroll_pos_x_signal(&self) -> &Signal<f32>
Horizontal scroll position signal (screen-pixel units), for external
observation. 0 = content's leading edge flush with the viewport.
pub fn scroll_pos_y_signal(&self) -> &Signal<f32>
Vertical scroll position signal (screen-pixel units).
pub fn max_scroll_x_signal(&self) -> &Signal<f32>
Maximum horizontal scroll offset (extent.width*zoom - viewport_width,
or 0 when the content fits). Bind for "is there more to scroll?" chrome.
pub fn max_scroll_y_signal(&self) -> &Signal<f32>
Maximum vertical scroll offset.
pub fn viewport_ratio_x_signal(&self) -> &Signal<f32>
Horizontal viewport/content ratio (0.0..1.0) — the relative thumb size.
pub fn viewport_ratio_y_signal(&self) -> &Signal<f32>
Vertical viewport/content ratio (0.0..1.0).
SceneSelectionMode
Selection model for Scene items.
Mirrors the API of teksilo_data::SelectionModel but keyed by
ItemId instead of usize — the natural address for scene
entries. Click-to-select, Ctrl+click toggle, Shift+click range,
and marquee box-select all flow through this single model;
SceneView paints a marquee overlay during the drag and
commits the result via Scene::items_in_rect.
The selection set is exposed as a Signal<BTreeSet<ItemId>>
so SceneItem paint code can render selected items differently
by binding their colors / strokes to a derived signal:
# use teksilo_scene::{SceneModel, SceneSelection, SceneSelectionMode};
# use teksilo_canvas::{Point, Rect};
# use teksilo_tokens::Color;
# let model = SceneModel::new();
# let card_id = model.add_item(teksilo_scene::RectItem::new(Rect::new(0.0, 0.0, 100.0, 80.0)), Point::ZERO);
let selection = SceneSelection::new(SceneSelectionMode::Multi);
let selected = selection.selection_signal();
let stroke_color = selected.map(move |s| {
if s.contains(&card_id) { Color::BLUE } else { Color::TRANSPARENT }
});
Builder methods at a glance
mode, selection_signal, is_selected, selected, count, clear, select_one, toggle, replace, extend, commit_marquee
API reference
📖 Full rustdoc API for this module
pub enum SceneSelectionMode
Selection-mode discriminator. Mirrors teksilo_data::SelectionMode.
#![allow(unused)] fn main() { pub enum SceneSelectionMode { /* variants */ } }
Variants
None— Selection disabled. Click does nothing, marquee does nothing.Single— At most one item selected at a time.Multi— Multiple items can be selected; Ctrl+click toggles, Shift+click extends a range from the anchor.
pub struct SceneSelection
Reactive selection state for a Scene.
Cheap-to-clone via Rc internals — all clones share the same
underlying signal. Pass clones into widget closures or item
register_bindings impls without worrying about ownership.
#![allow(unused)] fn main() { pub struct SceneSelection { /* fields */ } }
Methods
pub fn new(mode: SceneSelectionMode) -> Self
New selection model with the given mode. Initially empty, no anchor.
pub fn mode(&self) -> SceneSelectionMode
The configured selection mode.
pub fn selection_signal(&self) -> Signal<BTreeSet<ItemId>>
Live selection signal. Bind reactive consumers (item paint, status-bar item-count labels) to this.
pub fn is_selected(&self, id: ItemId) -> bool
Whether the given item id is currently selected.
pub fn selected(&self) -> Vec<ItemId>
Selected item ids in sorted order.
pub fn count(&self) -> usize
Number of selected items.
pub fn clear(&self)
Clear the selection. The anchor is also cleared so a subsequent Shift+click extends from a fresh starting point.
pub fn select_one(&self, id: ItemId)
Replace the selection with a single item; sets the anchor
for subsequent range extension. No-op in None mode.
pub fn toggle(&self, id: ItemId)
Toggle membership for the given id (Ctrl+click semantic).
Sets the anchor on toggle-on; leaves it unchanged on
toggle-off. No-op in None mode; in Single mode behaves
like select_one if the item is currently unselected, or
clear if it is.
pub fn replace(&self, ids: impl IntoIterator<Item = ItemId>)
Replace the selection with the given set of ids. Used by
marquee on commit. Anchor is cleared. No-op in None
mode; in Single mode keeps at most one (the first id in
ids).
pub fn extend(&self, ids: impl IntoIterator<Item = ItemId>)
Add ids to the existing selection (marquee with
Ctrl-modifier — additive box-select). No-op in None mode;
in Single mode reduces to select_one(last).
pub fn commit_marquee(&self, scene: &Scene, marquee_rect: Rect, additive: bool)
Marquee commit helper: replace (or extend, if additive)
the selection with every scene item whose AABB intersects
marquee_rect_in_scene. Lightweight items and heavyweight
widget entries are both candidates — the spatial index
returns ids regardless of kind.
SceneTapEvent
Per-item event handlers, cursor and tooltip overrides.
SceneItemHandlerSet is the lightweight-tier counterpart to
widget-level HandlerSet.
It carries optional closures the SceneView
invokes when pointer / hover / context-menu events land on the
item, plus per-item cursor and tooltip overrides.
Apps attach handlers via Scene::set_item_handlers /
Scene::handlers_mut after add_item:
let id = scene.add_item(rect, Point::ZERO);
scene.handlers_mut(id).unwrap()
.on_tap(|_pt, ctx| ctx.send_intent(AppIntent::OpenCard))
.cursor(CursorIcon::Pointer)
.tooltip("Open card");
API reference
📖 Full rustdoc API for this module
pub struct SceneTapEvent
Click-style gesture envelope for scene items. Mirrors the
widget-tier teksilo_core::gesture::TapEvent but with the
position in scene coordinates instead of widget-local. Used
by the tap / double-tap / triple-tap / long-press / context-menu
handlers on SceneItemHandlerSet.
#[non_exhaustive] so future additions (e.g. tap count,
stylus pressure) can land without breaking match patterns.
#![allow(unused)] fn main() { pub struct SceneTapEvent { /* fields */ } }
Methods
pub fn new(position_scene: Point, button: PointerButton, modifiers: Modifiers) -> Self
Construct one by hand. Useful for tests; dispatch builds
these from the live pointer event in SceneView.
pub enum DragMode
What a SceneView's on-canvas pointer drag
does in empty space.
DragMode::NoDrag— nothing happens. Useful for embedded read-only diagrams.DragMode::ScrollHandDrag— left-click-drag pans the view. Item-level on-drag handlers are bypassed; the canvas grabs the gesture unconditionally.DragMode::RubberBand(default) — drag-on-empty-space creates a marquee that selects items inside on release. Drag-on-an-item dispatches to that item's drag handler if wired (the drag pipeline honoursIS_DRAGGABLEfor drag-to-move).
#![allow(unused)] fn main() { pub enum DragMode { /* variants */ } }
Variants
NoDrag— Empty-space drag is a no-op; useful for read-only embedded diagrams.ScrollHandDrag— Left-click-drag pans the viewport; item-level drag handlers are bypassed.RubberBand— Empty-space drag draws a selection marquee; item drag dispatches to the item's drag handler (respectingIS_DRAGGABLE). This is the default.
pub struct SceneItemHandlerSet
Per-item event closures + cursor + tooltip + drop acceptance.
Closures are stored as Rc<dyn Fn> so cloning the handler set
is cheap; the SceneView clones into its dispatch path.
#![allow(unused)] fn main() { pub struct SceneItemHandlerSet { /* fields */ } }
Methods
pub fn new() -> Self
An empty handler set — every closure unset, no cursor or tooltip.
pub fn on_tap<F>(&mut self, f: F) -> &mut Self where F: Fn(Point, &mut EventContext) + 'static,
Register a tap callback. Simpler Fn(Point, &mut ctx)
signature for callers that only need the click position;
internally wraps in a shim that extracts
event.position_scene. For modifier-aware handlers (Shift-
click selection, Ctrl-click toggle, etc.) use
Self::on_tap_event which exposes the full
SceneTapEvent.
pub fn on_tap_event<F>(&mut self, f: F) -> &mut Self where F: Fn(&SceneTapEvent, &mut EventContext) + 'static,
Register a tap callback that receives the full
SceneTapEvent — scene-coord position, button, modifiers.
Use for modifier-aware patterns (Shift+click extends selection, Ctrl+click toggles, middle-click handlers
once paired with accept_tap_buttons).
pub fn on_double_tap<F>(&mut self, f: F) -> &mut Self where F: Fn(Point, &mut EventContext) + 'static,
Register a double-tap callback (Point-only shim — see
Self::on_tap). Not wired yet: the SceneView's
dispatch doesn't recognise double-tap; the field is stored
but never fired. A future unit wires the recognizer.
pub fn on_double_tap_event<F>(&mut self, f: F) -> &mut Self where F: Fn(&SceneTapEvent, &mut EventContext) + 'static,
Rich-event variant of Self::on_double_tap.
pub fn on_hover<F>(&mut self, f: F) -> &mut Self where F: Fn(bool, &mut EventContext) + 'static,
Register a hover callback. Receives true on enter,
false on leave.
pub fn on_context_menu<F>(&mut self, f: F) -> &mut Self where F: Fn(Point, &mut EventContext) + 'static,
Register a context-menu callback (right-click). Point-only
shim; see Self::on_context_menu_event for the rich
variant.
pub fn on_context_menu_event<F>(&mut self, f: F) -> &mut Self where F: Fn(&SceneTapEvent, &mut EventContext) + 'static,
Rich-event variant of Self::on_context_menu.
pub fn accept_tap_buttons(&mut self, mask: ButtonMask) -> &mut Self
Mask of pointer buttons that should be treated as a tap
for this item. Default ButtonMask::PRIMARY. Right-click
(SECONDARY) always routes through on_context_menu
regardless of this mask.
pub fn cursor(&mut self, c: CursorIcon) -> &mut Self
Override the cursor icon shown over this item.
pub fn tooltip(&mut self, t: impl Into<LocalizedString>) -> &mut Self
Set a tooltip. Accepts anything convertible into
LocalizedString — most commonly
tr!(...) for translated copy or lit!(...) for fixed text.
Stored unresolved; the SceneView resolves it against the active
locale when the tooltip is shown, so a tr!(...) source tracks
locale changes.
pub fn accepts_drops(&mut self, accepts: bool) -> &mut Self
Mark whether the item accepts dropped payloads.
SceneViewState
SceneViewState — a snapshot of a SceneView's
pan / zoom / rotation, suitable for persistence between sessions.
Pattern
use teksilo_scene::{Scene, SceneView, SceneViewState};
// On load: read from your persistence layer (teksilo-settings,
// a custom JSON file, etc.) and pass to SceneView.
let saved: SceneViewState = my_settings.scene_view.get();
let view = SceneView::new(scene);
view.restore_state(saved);
// On exit / periodic flush: snapshot and persist.
let current: SceneViewState = view.state();
my_settings.scene_view.set(current);
Why a plain struct, not Serialize
teksilo-scene deliberately doesn't depend on serde. Apps that
want to persist via teksilo-settings (which is serde-based)
either:
- Add their own newtype wrapper that implements
Serialize / Deserialize, OR - Store the fields individually (
pan_x,pan_y,zoom,rotation) as scalarSettingsKey<f32>s in aSettingsStore.
The struct is plain-old-data — manual round-trip is trivial.
Builder methods at a glance
IDENTITY, pan, is_identity
API reference
📖 Full rustdoc API for this module
pub struct SceneViewState
Snapshot of a SceneView's view transform: pan offset, zoom
factor, and rotation in radians. Use SceneView::state to
capture the current values; SceneView::restore_state to
apply a saved snapshot.
#![allow(unused)] fn main() { pub struct SceneViewState { /* fields */ } }
Methods
pub const IDENTITY: SceneViewState = SceneViewState { pan_x: 0.0, pan_y: 0.0, zoom: 1.0, rotation: 0.0, };
The identity view state: no pan, zoom 1.0, no rotation.
pub fn new(pan: Vec2, zoom: f32, rotation: f32) -> Self
Construct a new state with the given pan / zoom / rotation.
pub fn pan(&self) -> Vec2
Pan offset as a Vec2.
pub fn is_identity(&self) -> bool
Whether this state is the identity (no pan, zoom 1.0, no rotation). Useful for skipping persistence of fresh-default SceneViews.
SpatialIndex
Spatial index for Scene items.
GridHashIndex is the only shipped implementation — a uniform grid
hash. The SpatialIndex trait is deliberately small — three mutating
operations (insert, remove, query) plus two read methods
(contains, len) — so an application that needs different behaviour
(e.g. an R-tree) can supply its own implementation in a one-line change
via Scene::with_index.
Why grid hash first
-
Cache-friendly: items in the same cell are stored contiguously.
-
Insert / remove / move are amortised
O(k)wherekis the number of cells the item overlaps (typically 1–4 for items smaller than the cell size). -
query(rect)returns deduplicated candidates from the cells the rect overlaps; callers can narrow with a per-item AABB check. -
Oversized items. An item whose AABB would bucket into more than
MAX_CELLS_PER_ITEMgrid cells (a scene backdrop, a full-document canvas rect, or any item at extreme coordinates with large bounds — all reachable in production, not exotic) is NOT bucketed cell-by-cell at all. It is stored instead in a separateoversized: HashMap<ItemId, Rect>thatqueryalways scans in full, in addition to the cell lookup, keeping an exact AABB-intersection test againstscene_rect(so it contributes no cell-fan-out false positives of its own).This closes what used to be an unconditional, uncapped eager allocation:
cells_for_rectcomputed(width / cell_size) * (height / cell_size)cells and reserved that many(i32, i32)slots before the loop that fills them ran — no upper bound, and using barei32arithmetic that could itself overflow for large extents (debug builds panicked, release builds could wrap to a huge or negativeusize). A single 1e6 × 1e6 logical-pixel item at the clamped-minimumcell_sizeof 1.0 asked for(1e6+1)² ≈ 1e12cells — roughly 8 TB for theVec<(i32, i32)>alone — before any assertion or even the fill loop ran; this was reachable from a singleScene::add_itemcall, no adversarial input required. Even at the default 256 pxcell_size, a 1e6-square item alone reserved(1e6 / 256)² ≈ 1.5e7cells (~122 MB) for that one item. The same hazard applied toquery/items_in_rect, since a caller can pass an arbitrarily largescene_recttoo — seequery's own oversized-span fallback.A custom
SpatialIndexwould still handle non-uniform density better — an R-tree, say, for an editor with many overlapping items — but none ships; the trait is the place to add one.
Default cell_size is DEFAULT_CELL_SIZE (256.0 logical pixels)
— large enough that typical card-sized items (~200 px) bucket into 1–4
cells and small enough that viewport queries (~800–1200 px) hit a
manageable fan-out.
Example
// ItemId values are obtained from Scene::add_item in real code;
// the example uses the crate-internal constructor for illustration.
use teksilo_scene::{GridHashIndex, SpatialIndex, ItemId};
use teksilo_canvas::Rect;
let mut index = GridHashIndex::default();
let id = ItemId(1); // in practice: returned by Scene::add_item
index.insert(id, Rect::new(10.0, 10.0, 80.0, 80.0));
assert!(index.contains(id));
let hits = index.query(Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(hits.contains(&id));
index.remove(id);
assert!(index.is_empty());
Builder methods at a glance
cell_size, cell_count
API reference
📖 Full rustdoc API for this module
pub const DEFAULT_CELL_SIZE
Default cell size for GridHashIndex — 256 logical pixels.
Item-side typical 200 px cards bucket into 1–4 cells; viewport
queries (~800–1200 px) hit a small fan-out.
#![allow(unused)] fn main() { pub const DEFAULT_CELL_SIZE: f32 = 256.0; }
pub struct GridHashIndex
Uniform grid spatial hash. Each item is bucketed into every cell
its AABB overlaps; queries union all items from the cells the
query rect overlaps. Items whose AABB would span more than
MAX_CELLS_PER_ITEM cells are NOT bucketed — see oversized
below and the module doc's "Oversized items" section.
#![allow(unused)] fn main() { pub struct GridHashIndex { /* fields */ } }
Methods
pub fn new(cell_size: f32) -> Self
Create a grid with cell_size logical pixels per cell.
Clamped to a minimum of 1.0 to avoid pathological huge bucket
counts.
pub fn cell_size(&self) -> f32
The configured cell size in logical pixels.
pub fn cell_count(&self) -> usize
Number of cells currently storing at least one item. Useful
for diagnostics; not part of the public SpatialIndex trait.
Oversized items (see MAX_CELLS_PER_ITEM) never occupy a
cell, so they never contribute to this count.
TextAlign
TextItem — text in a local-coord rectangle, with alignment + rotation.
TextItem renders text that wraps within a caller-specified rectangle in
local item coordinates. Text can be a static localized string (constructed
via TextItem::new) or a live Signal<String> (constructed via
TextItem::with_signal_text). Signal-bound and locale-reactive text both
register bindings at RepaintOnly so changes dirty the SceneView's
paint pass without triggering a full rebuild.
The foreground colour is a ColorProp, so it accepts a plain
Color, a theme role
(TextRole), a reactive Signal<Color>, or a
Signal<Role> — resolved against the active theme at paint time.
Horizontal alignment (leading / center / trailing) and a free
rotation let a text item self-place value tags, axis
labels, and rotated titles without the caller hand-measuring; measure
reports the item's single-line intrinsic size when the caller does want to
size around it.
Text scale: the global accessibility "grow all text" setting is off by
default for scene text, since a scene has its own pan/zoom. Opt in via
.follow_text_scale(true) for labels that should track the app-wide
setting instead.
When to use
Use TextItem for card labels, node titles, annotation text, or any text
decoration in the lightweight tier. For editable text or text that needs
focus, selection, and full accessibility, embed a RichTextEditor or
TextInput as a heavyweight scene widget instead.
Example
use teksilo_scene::{SceneModel, TextItem, TextAlign};
use teksilo_canvas::{Point, Rect};
use teksilo_tokens::Color;
use teksilo_i18n::lit;
let model = SceneModel::new();
let item = TextItem::new(lit!("Scene node"), Rect::new(0.0, 0.0, 120.0, 30.0))
.color(Color::new(0.1, 0.1, 0.1, 1.0))
.align(TextAlign::Center);
model.add_item(item, Point::new(40.0, 40.0));
Builder methods at a glance
with_signal_text, draggable, color, align, rotation, follow_text_scale, label, measure
API reference
📖 Full rustdoc API for this module
pub enum TextAlign
Horizontal alignment of a TextItem within its local_bounds.
Alignment shifts the text's draw origin by the leftover width
(bounds.width − measured_width); it needs a text backend to measure, so a
mock/headless canvas with no backend renders leading-aligned regardless.
#![allow(unused)] fn main() { pub enum TextAlign { /* variants */ } }
Variants
Leading— Left edge in LTR (the default).Center— Centred within the bounds.Trailing— Right edge in LTR.
pub struct TextItem
Text in a local-coord rectangle, with optional alignment and rotation.
Text wraps within the local_bounds rectangle; the caller is responsible
for sizing the rect so all text is visible. Content is either a static
localized string (see TextItem::new) or a reactive Signal<String>
(see TextItem::with_signal_text). Both sources trigger a repaint on
change without rebuilding the scene.
#![allow(unused)] fn main() { pub struct TextItem { /* fields */ } }
Methods
pub fn new(text: impl Into<LocalizedString>, local_bounds: Rect) -> Self
A static-text item in local coordinates. The text is
resolved eagerly via LocalizedString::resolve_now at
construction; locale changes rebuild the composite parent,
which re-creates this TextItem with a fresh translation.
pub fn with_signal_text(text: Signal<String>, local_bounds: Rect) -> Self
A text item whose content is driven by a Signal<String>.
register_bindings ties the signal to the SceneView at
BindingLevel::RepaintOnly so changes dirty paint and the
next walk reads the current value.
pub fn draggable(mut self, draggable: bool) -> Self
Opt the text into drag-to-move.
pub fn color(mut self, color: impl Into<ColorProp>) -> Self
Override the foreground colour. Accepts a plain Color, a theme role,
a Signal<Color>, or a Signal<Role> — resolved against the active
theme at paint time.
pub fn align(mut self, align: TextAlign) -> Self
Horizontal alignment within local_bounds. Default
TextAlign::Leading. Needs a text backend to measure the text width;
a headless canvas with no backend renders leading-aligned.
pub fn rotation(mut self, radians: f32) -> Self
Rotate the text about the item's centre by radians. Default 0.0
(upright). Pair with Signal::animate_to on a driving signal for
animated rotation, or set a fixed angle for a vertical axis title.
pub fn follow_text_scale(mut self, follow: bool) -> Self
Opt this text into the global accessibility text scale, so it grows with the app-wide "grow all text" setting. Off by default — the scene's own pan/zoom usually governs scene text size.
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self
Override the AT label (defaults to the current text content).
pub fn measure(&self, backend: &mut dyn TextBackend) -> Size
Measure the current text's single-line intrinsic size against backend
at the authored TextStyle. Lets a
consumer size a slot around a label (axis labels, value tags) before
placing it. Does not apply the global text scale — measure at the
authored size.