Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.mdHStack / 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, FocusScope Tab-traversal scopes, gesture recognizers.
  • styling-system.md — the four-tier styling ladder (tokens → variants → recipes → style protocols); Theme aggregator, ThemeAppearance, per-widget *Variant enums and *Style traits, per-call vs theme-wide style installation, writing a custom preset.
  • reactive-theme.mdSignal<Theme>, role-driven colors (ColorProp, TextStyleProp), reactive switching without rebuild.
  • animation.mdSignal<f32>::animate_to, MotionTokens, AnimationSpec builder, 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 TextScaleControl widget, persistence + startup restore, the effective_theme blanket mechanism, ctx.text_scale / text_scale_signal, the per-engine font_scale for editable text, and the follow_text_scale opt-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.mdcargo teksilo-fmt, the formatter for teksu! blocks (rustfmt skips macro bodies).
  • teksilo-fmt-vscode.md — wiring teksilo-fmt-lsp into VS Code for in-editor formatting.

Input, navigation, chrome

  • shortcut-intent-action.mdShortcut / Intent / Action pipeline, #[derive(IntentKind)], rebindable keystrokes via ShortcutRegistry.
  • tooltips.md — plain TooltipWidget, registry-driven RichTooltipWidget, sticky-on-dwell promotion, focus-driven a11y promotion, attach helpers.
  • toast.mdToast floating notifications (info / success / warning / error / loading severities, link + button actions, Toast::id update-in-place) + ToastHost queue + persistent NotificationArchiveModel + NotificationLog / bell NotificationCenterButton / NotificationLogDialog UI; TeksiloAppBuilder::install_toast_default() one-line install.
  • native-menu.md — declarative MenuModel shared by the in-window MenuBar and 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 WebView widget (native OS subview on top of wgpu), pluggable WebViewBackend (wry default / Servo additive for Wayland / headless), the dormancy→set_visible activation 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.mdWindowConfig, signal-driven multi-window orchestration, modal dialogs, restore-from-state.
  • title-bar.md — custom widget-level title bar plus the per-OS PlatformTitleBarHost for drag / zoom / close / inset.
  • toolbar.mdToolbar command bar with automatic overflow: actions (priority / always_overflow / toggle), pinned + collapsible custom widgets (overflow_as menu row, overflow_widget live embedded control, the ToolbarOverflow trait), the MenuList-backed chevron menu, display modes / orientation, and the ARIA roving-tabindex pattern.

Data, persistence, telemetry

  • data-models.mdListModel, 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 / TreeDataSource read-and-command interface every data view talks to: the capability protocol (identity, DnD validation via can_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.yaml schema 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-agnostic on_loop_tick hook, and the teksilo-tokio / teksilo-async-std reactor adapters for awaiting native runtime futures. Off by default; complements the reactive subscribe_event data path.

Specialized widgets

  • splitter.md — N-pane Splitter with draggable, collapsible dividers, per-pane stretch, animated collapse (four triggers), a shared serializable SplitterModel, and a Tier-3 SplitterStyle. The building block for DockingLayout.
  • 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 serializable DockingModel with export_state/import_state.
  • table-view.md — virtualized TableView and TreeTableView (multi-column, sort/filter, drag-resize, drag-reorder, full keyboard navigation).
  • tab-widget.mdTabBar<T> and TabWidget (static + dynamic tabs, Signal<Option<TabId>> selection, pinned tabs, drag reorder, overflow dropdown, horizontal + vertical orientations).
  • charts.mdBarChart / 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-scene viewport, including synthetic magnet nodes and the roving active_descendant keyboard connect flow.

Visuals & resources

Tooling

  • inspector.mdteksilo-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 §N and 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:


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, then build() produces a fresh set. Correct for data-driven widgets (Repeater, ListView) that reconstruct children from current model state with fresh WidgetIds. A false widget 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: Switcher pages, TabWidget / DockingLayout panes, the CompositeTooltip body, SceneView's heavyweight scene widgets, and MenuBar'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:

  1. Keep-alive. Every replay of a retained frame calls TextBackend::touch_layout for each of the frame's layout_keys (per-widget cache hits, post-paint cache hits, and the full-frame early-out in rendering_impl.rs), refreshing the glyphs' LRU timestamps so on-screen glyphs never age out.
  2. Eviction reporting. TextFontService::eviction_epoch is the single source of truth: it is bumped by every eviction path — the atlas_snapshot scan, the scan at the start of every rich-text render() (build_render_frame), and the wholesale reset on scale-factor change. TypesetterBridge::atlas_info compares it against a last-seen value and reports glyphs_evicted; the app-level recovery in teksilo-app then clears the bridge caches and calls invalidate_all_paints on every window (the bridge and atlas are shared process-wide), requesting redraws. Caches living outside the widget arena (the scene ItemCoordinateCache) instead self-gate on TextBackend::glyph_epoch at paint time.
  3. Versioned uploads. Each window's renderer owns its own GPU atlas texture. atlas_info(seen_version) carries a monotonic content version; a window uploads (and receives pixels) only when its recorded atlas_uploaded_version lags. 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 internal HStack or VStack (by IconPosition) containing an optional IconWidget and a TextWidget label. Leading/Trailing positions respect locale LayoutDirection.
  • 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 via Signal<InteractionState> mapped to Signal<Role>.
  • Behavior. Pointer enter/leave/down/up drives interaction state; keyboard Space/Enter triggers activation; cursor is Pointer on hover; TapRecognizer commits the click.
  • Accessibility. Role::Button, name from label (resolved via tr! / 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 in teksilo-widgets. Run python3 tools/extract_widget_api.py --list to 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/*.rs integration test when the target is pub and reachable from outside the crate. Example: crates/teksilo-tokens/tests/prop_color.rs tests Color, a public type re-exported at the crate root.

  • Inline #[cfg(test)] mod proptests, placed as a sibling of the existing #[cfg(test)] mod tests in the same file, when the target is pub(crate) or otherwise unreachable from tests/. 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: distribute is pub fn, but its declaring module (mod distribute; in splitter.rs) is private and not re-exported, so the function is unreachable from an external test crate even though the fn signature itself says pub.
    • crates/teksilo-scene/src/index.rs: GridHashIndex is declared pub struct, but it lives inside pub(crate) mod index; in lib.rs — the module's visibility caps the struct's, regardless of the pub on the struct itself. Reading a pub keyword on the item is not sufficient to decide placement; check the declaring module's visibility too.
    • crates/teksilo-widgets/src/primitives/column_flow.rs: ColumnFlow itself is public, but balance_columns — the pure function the suite actually targets — is pub(crate).

    Never widen an item's visibility to make it reachable from tests/. If the item is pub(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:

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.

ShapeWhat it checksExample
Round-tripencode then decode (or the reverse) reproduces the original, or a documented fixed pointhex_roundtrip_is_stable_after_one_quantization, hsv_roundtrip_holds_for_arbitrary_opaque_colors (teksilo-tokens)
Idempotencea second application changes nothing once the first has convergedreaggregate_is_a_noop_when_the_model_is_already_consistent (teksilo-data), reinserting_identical_bounds_is_idempotent (teksilo-scene)
Conservationa quantity (item multiset, total height, node set) survives a transform exactlymove_items_preserves_the_item_multiset (teksilo-data), total_conserves_the_sum_of_heights_and_gaps_and_insets (teksilo-widgets)
Monotonicityan ordered change in input can only move the output in one directionquery_is_monotonic_in_rect_containment (teksilo-scene), desaturation_spread_is_monotone_in_amount (teksilo-tokens)
Oracle vs. brute forcea fast/incremental/cached path agrees with an independent from-scratch recomputethe_incremental_tree_projection_equals_a_full_recompute (teksilo-data), query_narrowed_matches_brute_force (teksilo-scene)
Metamorphicthe same operation under a transformed input yields a predictably related outputchecked_state_survives_a_reload_with_a_different_shape_then_resyncs_on_reaggregate (teksilo-data)
Determinismidentical inputs always produce identical outputsdistribute_is_deterministic_for_identical_inputs (teksilo-widgets), query_is_independent_of_insertion_order (teksilo-scene)
Panic-freedomthe function returns rather than unwinds, across arbitrary — including malformed — inputfrom_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-run builds the test binary without executing anything, so a pathological generator can't run before you've had a chance to cap it.
  • --message-format=json on the same --no-run build reports the exact binary path (.executable) without guessing at target/debug/deps/... hashes.
  • ulimit -v 6000000 caps the subshell's virtual address space at ~6 GB (adjust to the machine); ulimit -t 300 caps CPU time at 5 minutes as cheap insurance against a runaway loop that isn't merely allocating. --test-threads=1 keeps 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 (PaneDescriptor bounds are app-supplied; a min derived from a 0/0 ratio is NaN), and explicitly declining to guess at the right normalization without owner review. It was later un-ignored once 03341d1f (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 every PrefixSumOffsets consumer, so it is left for review") and was later un-ignored by a788e191.

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.

CrateBugShape
teksilo-tokensColor::mix's t.clamp(0.0, 1.0) propagates a NaN factor into every channel, since f32::clamp returns NaN for a NaN inputNaN-unsafe guard written for the ordered case
teksilo-sceneGridHashIndex::cells_for_rect reserved one (i32, i32) slot per covered cell with no upper bound; a single oversized item could request ~1e12 cellsunbounded allocation, not a disagreement
teksilo-dataSelectionModel::select_all checked SelectionMode::None but not Single, so it selected the full 0..count range on a single-selection modelone mutator not upholding an invariant every sibling mutator (select, toggle, extend_to, select_indices) already honored
teksilo-dataSortFilterListModel's incremental ItemUpdated fast path bailed out to a full rebuild only on a Greater comparison, not a tiefast path disagreeing with the full stable-sort recompute it exists to optimise
teksilo-dataSortFilterTreeModel's incremental NodeUpdated fast path had the identical tie-blindness, independently, in a different filesame class of bug, duplicated logic drifting apart
teksilo-dataKeyedTreeCheckedModel::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 listuntracked-ness conflated with unchanged; skipped reaggregate() left a stale ancestor tristate
teksilo-widgetsdistribute'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 falsethe same NaN-unsafe-guard shape as the Color::mix bug, in an unrelated crate
teksilo-widgetsRowMetrics::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 rowtwo 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

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.

WidgetVariant enumStyle traitSlot
ToggleToggleVariant (Switch/Pill/Square/Inset)ToggleStylestyle_slots.toggle
ButtonButtonVariant (Filled/Tinted/Outlined/Plain/Ghost/Link/Destructive)ButtonStylestyle_slots.button
CheckboxCheckboxVariant (Square/Rounded/Circle)CheckboxStylestyle_slots.checkbox
RadioButtonRadioVariant (Circle/Square/Rounded)RadioStylestyle_slots.radio
RadioTileRadioTileVariant (Outlined/Elevated/Filled)RadioTileStylestyle_slots.radio_tile
IconButtonIconButtonSize (Compact/Default/Toolbar/Large/Hero)IconButtonStylestyle_slots.icon_button
PanelPanelVariant (Plain/Sunken/Raised/Highlighted)PanelStylestyle_slots.panel
CardCardVariant (Plain/Elevated/Outlined/Filled)CardStylestyle_slots.card
TooltipWidgetTooltipStylestyle_slots.tooltip
MenuItemMenuItemStylestyle_slots.menu_item
StandardListItem / StandardTreeItemStandardItemStylestyle_slots.standard_item
PopoverPopoverVariant (Default/Menu/Tooltip) — surfacePopoverStylestyle_slots.popover
ScrollBarScrollBarVariant (Permanent/Overlay/Thin) + ScrollBarOrientationScrollBarStylestyle_slots.scroll_bar
TabBar— (carries TabBarOrientation)TabStylestyle_slots.tab
ComboBoxComboBoxVariant (Outlined/Filled/Underline/Plain)ComboBoxStylestyle_slots.combo_box
SliderSliderVariant (Continuous/Discrete/Range) + SliderOrientationSliderStylestyle_slots.slider
TextInputTextInputVariant (Outlined/Filled/Underline/Bare)TextInputStylestyle_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-in semantic_list, reactive column_count_signal(). Pair with a ScrollArea for 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 in Expand for 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 a min floor) when a stack is over-constrained. Native shrink covers single-line / ellipsis text; controls (Button etc.) stay rigid and overflow via Toolbar.
  • 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-level gesture_dead_zone flag (robust by construction, not a recognizer-timing race).
  • FocusScope — layout-transparent Tab traversal boundary (lives at crate root). Scopes its descendants' tab_index so sibling regions don't interleave, and traps or passes Tab via TraversalScopePolicy::{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; IconMode for 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 MenuList popover (Qt extension / NSToolbar overflow / WinUI CommandBar). Per-action overflow priority, always_overflow, toggle, pinned custom widgets, collapsible custom widgets (overflow_as menu row / overflow_widget live embedded control / the ToolbarOverflow trait), separators, flexible space, display mode, orientation, is_overflowing(). Full ARIA toolbar a11y (Role::Toolbar + orientation, roving tab-index + arrow nav, chevron HasPopup::Menu, no AT duplication of overflowed items). Built on LayoutContext::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_extensions filter, allow_multiple, on_files_dropped / on_text_dropped / on_urls_dropped, keyboard Browse fallback; Tier-3 DropZoneStyle, Role::Group + Live::Polite. See drag-and-drop.md §11.4.
  • DropTargetwrapping 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_when filters, on_drop / on_drop_typed::<T>, targeted_signal (SwiftUI isTargeted pattern); Tier-3 DropTargetStyle, 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 QToolBox analog).
  • 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 MenuList dropdown (Windows Explorer / web breadcrumb pattern) while the root + current crumb stay; is_overflowing() signal. RTL-aware separators (chevron mirrors). Built on measure_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; IconLocation for leading/trailing icon; chrome via the ButtonStyle trait (see Styling status above). Reference exemplar — read the source.
  • IconButton — square icon-only button at five IconButtonSize steps (Compact / Default / Toolbar / Large / Hero). .embedded() mode for trailing-slot use inside fields. Includes BuiltInIcons factory.
  • 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, and Role::RadioGroup + per-tile Role::RadioButton.
  • Toggle — on/off control; four ToggleVariants (Switch / Pill / Square / Inset) via the ToggleStyle trait.
  • 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; RadioGroup AT role. See segmented-control.md.
  • ComboBox — selection-only dropdown; virtualized via ListView past max_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::SpinnerArc pipeline; honours prefers-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_result delivers Some(value) / None.

Date and time

  • Calendar — month grid with WAI-ARIA grid keyboard pattern; CalendarMode::Single / Range (DateRange); WeekNumberDisplay toggle. 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; ColorPickerLayout controls panel arrangement.

  • MenuBar — top-of-window menu strip; widget-based on Windows/Linux. On macOS it mirrors a declarative MenuModel into the system NSMenuMenuBar::from_model(..).native_on_macos(..) + install_native_menu(), see native-menu.md (on-device validation pending). MenuBar::build installs an Rc<dyn MenubarDispatcher> into WindowState on every platform so the framework can intercept F10, Alt+<letter>, and bare-Alt-tap before focus-based key dispatch — matching Win32's WM_SYSKEYDOWN semantics. Returns MenubarAction::{OpenMenu, FocusTrigger, Intercept}. Alt-tap is detected on the WindowState::alt_down falling edge with other_key_pressed_during_alt == false. Mnemonic-derived chords NEVER enter ShortcutRegistry — by construction ShortcutSettings cannot 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 via cfg!(target_os = "macos") in MenuLabel::paint so the UI doesn't promise a chord that won't fire. Use F10 + arrows + Enter for keyboard menu navigation, and the existing Shortcut system for Cmd+? accelerators.

  • MenuList — overlay menu panel; accepts arbitrary impl Widget children. MenuSeparator for 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 when selected == value. Radio items in the same MenuList auto-group via Signal::same and announce "2 of N" via push_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 mirrors Checkbox exactly: set_toggled(bool) for binary, inner_mut().set_toggled(Toggled::Mixed) for Indeterminate.

  • Mnemonics use the in-string Windows / Qt & convention: &Save underlines 'S' when Alt is held; && produces a literal &. MenuLabel (private leaf widget) renders the underline via canvas.draw_underline gated on WindowState::alt_down; the AT name strips the &, and the mnemonic letter is written to inner_mut().set_access_key("S") for Windows Narrator. Parser at mnemonic.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, call point_in_safe_triangle(cursor, anchor, submenu_bounds). The triangle's near edge is inferred from anchor.x vs submenu.x — the algorithm is RTL-symmetric automatically. EventContext exposes tree_pointer_position() + overlay_bounds_for_content(content_id) (snapshotted per dispatch). The existing 150 ms PointerLeave close 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; TooltipRegistry for 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 Widget content; configurable placement, dismissal, optional caret.
  • Dialog — modal dialog frame; DialogContent / ModalContainer for content + presentation.
  • MessageBox — predefined info/warning/error/question modals (MessageBoxSeverity); semantic-role buttons (ButtonRole, StandardButton, MessageBoxButton, MessageBoxButtons) with platform-aware ordering; result via MessageBoxResult.
  • 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::id update-in-place; persistent archive backing; corner-anchored hover-pause stack. The "upgrade path" from Snackbar. 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 (AttachedSide for 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. Pluggable GridLayoutStrategy: 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). Flat SelectionModel (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's drag/can_accept/accept_drop (+ on_item_drop escape 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), and Role::Grid > Role::GridCell accessibility. See grid-view.md; demo cargo run -p grid-view.
  • TreeView — hierarchical list with twist-arrow expand/collapse. The 4-arg new_with_context variant passes a TreeRowContext carrying a one-line toggle_callback() for chevron wiring.
  • StandardListItem — canonical row layout for ListView delegates: [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 through SurfaceRole::Selected / AccentSubtle / Pressed (theme-driven, rounded item_corner_radius: 8.0, mirrors MenuItem / 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.
  • StandardTreeItemStandardListItem plus 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 a TreeSliceHandle::toggle_expand callback (cleanest with TreeView::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, full Role::Table AT 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 (telemetry feature) — 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 persisted TEXT_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 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:

  1. The parent calls child.layout_response(proposal, ctx). The child returns a LayoutResponse { size, flex, min, shrink } — the size it wants (a floor for growth), a flex weight for positive-slack distribution, a min compression floor, and a shrink weight for over-constraint deficits. flex and shrink are independent (CSS-flexbox grow vs shrink); From<Size> defaults to fully rigid (flex = 0, shrink = 0, min = size).
  2. 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. When slack ≥ 0 each child's final size is wanted + (flex / Σ flex) × slack. When slack < 0 (over-constraint) the deficit is distributed across children with shrink > 0 proportional to their shrink weight, never below min (iterative clamp-and-redistribute). There is no special "spacer"/"expand"/"shrinkable" branch in the engine — they are ordinary widgets that report flex > 0 / shrink > 0.
  • Logical pixels, Leading / Trailing. All values in f32 logical px; the renderer multiplies by scale factor at the boundary. Leading / Trailing flip with LayoutDirection::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 a VStack (parent binds width, distributes height) — fills the VStack's full width AND distributes vertical slack.
  • Expand::horizontal() inside a VStack — claims the VStack's full width, but reports flex = 0 on 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:

WrapperRuleWhen to use
FixedSizeChild 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.
MinSizeChild's wanted size is clamped upward on each constrained axis.Touch targets (MinSize::new(48.0, 48.0)), readable column widths.
MaxSizeChild's wanted size is clamped downward. Sets clips_children: true so overflow is scissored.Reading-width caps (MaxSize::width(640.0)), modal max-height.
AspectRatioWanted size fits within proposal at a fixed width / height.Image previews, video tiles, square avatars.
PaddingWraps 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

KnobEffect
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

  • ColumnFlow is rigid (flex = 0, shrink = 0). It adapts when width is decided for it — as a VStack child, inside a ScrollArea, at a window root. As an HStack main-axis child it gets its natural width and won't reflow; wrap it in Expand to claim main-axis width. Same as Wrap.
  • It is not a CSS multicol port. CSS column-fill: balance balances 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. No CSS column-fill mode 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 the Role::Form accessibility landmark with that name. Without a label, the layout demotes to GenericContainer — an unnamed landmark hurts AT users more than it helps. Pass tr!(…) 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

NeedUse
Push siblings to the edgesSpacer::new()
Hard gap with grow-if-availableSpacer::new().min_length(n)
Static gap between siblingsHStack::new().spacing(n) / VStack::new().spacing(n)
Visual divider lineDivider::new()
Inset around a childPadding::uniform(n) / Padding::symmetric(v, h) / Padding::new(t, r, b, l)

7. When to use which

GoalReach for
Vertical column of widgetsVStack
Horizontal row, RTL-safeHStack
Background + foreground on the same areaZStack
Push to one edgeSpacer in a stack
Equal split (1:1, 1:2, …)Expand::flex(n) pairs in a stack
One panel takes the restExpand::new().child(panel)
Center one childCenter::new().child(w)
Force a minimum touch areaMinSize::new(48.0, 48.0)
Cap reading widthMaxSize::width(640.0)
Dialog with a fixed widthFixedSize::new().width(w)
Animated panel widthFixedSize::width(animated_signal)
Locked aspect ratio (image, video)AspectRatio::new(w/h)
Inner spacingPadding
Tabular data with mixed track sizesGrid
Tag cloud / toolbar overflowWrap
Pinterest-style heterogeneous cardsMasonryLayout
Columns that follow the width and read in orderColumnFlow
Settings formsFormLayout
Tab pages / wizard stepsSwitcher

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.width for the child proposal in layout_response. That measures children against the parent's offered width, not the wrapper's bounds. Use proposal.width.
  • Match place_children's child query to your sizing policy. If layout_response queried with SizeProposal::with_width(w), query the same way in place_children — otherwise wrapping children measure twice with different results.
  • Honor flex. If your layout wants stacks-style slack distribution, sum child_layout_response(...).flex and 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

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:

  1. Raw input from the platform — pointer moves, key presses, scroll, IME composition, trackpad pinch.
  2. Recognized gestures composed from raw events — tap, double-tap, long-press, drag, swipe.
  3. 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 during build() from inside event().
  • 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 MenuList overlay 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 return EventResponse::Handled to 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 a Button's .on_key(Key::Space) handler can be registered on the button itself, but Ctrl+S falls through to a root-level Action.
     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_scroll that returns Handled (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:

HandlerFires whenSignature (simplified)
on_tapA single primary-button tap completesFnMut(&TapEvent, &mut EventContext)
on_double_tapTwo taps within 300 ms, within 10 pxsame
on_triple_tapThree taps within the recognizer windowsame
on_long_pressPointer held past the long-press thresholdsame
on_hoverPointer enters / leaves the widget's boundsFnMut(bool, &mut EventContext)
on_focusWidget gains or loses focusFnMut(bool, &mut EventContext)
on_keyFocused widget receives a KeyDown / KeyUpFnMut(&WidgetEvent, &mut EventContext) -> EventResponse
on_scrollScroll event hits the widgetsame
on_pointer_eventLow-level pointer escape hatch (any Pointer* variant)same
on_dragGesture-based drag — Started, Moved*, Ended phasesFnMut(DragPhase, &mut EventContext)
on_swipeOne-shot swipe with direction + velocityFnMut(SwipeDirection, f32, &mut EventContext)
on_pinchOS trackpad magnify / rotate phasesFnMut(PinchPhase, &mut EventContext)
on_drag_hoverDnD payload hovers over the widgetFnMut(&DragPayload, Point, &mut EventContext) -> DropFeedback
on_drag_leaveDrag leaves the widget (target change, drop, cancel, or source destroyed)FnMut(&mut EventContext)
on_drag_tickPer-frame tick while the widget is the current drop targetFnMut(Point, &mut EventContext)
on_dropDnD payload released on the widgetFnMut(DragPayload, Point, &mut EventContext) -> bool
on_access_actionAccessKit action request targets the widgetFnMut(accesskit::Action, &mut EventContext) -> EventResponse
on_access_action_requestFull 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), and on_pointer_event (PointerDown / Move / Up) — is in that handler's widget-local space (relative to the node's top-left, with any ancestor Scale / Rotate transform undone). The framework converts once at dispatch via WidgetArena::local_pointer_position; widgets must not subtract their own bounds origin. (A content_transform node such as SceneView is 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 the context_menu factory 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:

FlagPurpose
.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::InTree overlay 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 a TapRecognizer in 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 MinSize and also listening for taps on the outer wrapper used to require a second widget with a custom event() impl. Now it's .child(content).on_tap(...) — the MinSize doesn't need to know its parent wrote a tap handler, because the handler is on the wrapper's node, not inside MinSize.
  • Mutation without RefCell<Option<State>>. V1's event(&mut self, ...) required &mut access to state built in build(&self), forcing the RefCell<Option<State<T>>> pattern. Attached handlers close over Signal<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 at Down.
  • DragRecognizer — emits DragStarted once the pointer moves past the drag-start threshold, then DragMoved per move, then DragEnded on 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 Recognized or Failed.
  • When one recognizes, competing recognizers whose resets_on_peer_recognition flag is set get reset (DoubleTapRecognizer peers-reset when TapRecognizer alone 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 matching Action. 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 on TeksiloAppBuilder by TypeId.

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 originKeyboard (tab/shift-tab/programmatic) or Pointer (tap) or Programmatic. 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:

PolicyAt the scope boundary
ContinueTab 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.
CycleTab 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 the focus_scope chrome 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 the FocusScope widget + 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 anchorBelow, 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:

  1. on_drag_hover(payload, pos, ctx) -> DropFeedback — fires on every PointerMove while this widget is the drop target (pointer inside its bounds and the framework picked it via find_drop_target_at_or_above). The widget typically stashes its own feedback state (an insertion line y, a highlight rect) and returns the matching DropFeedback descriptor. pos is 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 own bounds and paint layout.
  2. 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.
  3. 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 in on_drag_hover here — the framework does not touch widget-owned state.
  4. on_drop(payload, 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 feedback is cleared by the time the drop handler decides acceptance). Returns true if 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 from PointerMove by comparing the hit target frame-over-frame. A widget moving out from under a stationary pointer still gets PointerLeave — 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 Handled stops the pass.
  • Attach handlers on children with .on_foo(…) via WidgetBuilder; attach on self with HandlerSet + ctx.apply_self_handlers.
  • Gesture recognizers are auto-wired from attached handlers; GestureArena arbitrates 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_overlays cover the framework-level ambient ops.
  • Focus is a single optional WidgetId; transfers happen via ctx.request_focus(id); Tab/Shift+Tab walks a tree of FocusScopes (scoped tab_index, per-scope Continue/Cycle policy), 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

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 of impl FooStyle blocks on top of the same Tier-3 surface — same widgets, different chrome.

Mental model — which tier do I use for X?

TaskTierAPI
Tweak a color across the whole app0theme.colors.accent = …
Make a single Button redn/aButton::color(Color::RED) (always-allowed prop override)
Pick "outlined" instead of "filled" on a Button1Button::variant(ButtonVariant::Outlined)
Make every Outlined Button thicker2Modify a BorderRecipe in the IntUI preset, OR ship a new preset
Replace Button chrome entirely (glassmorphism / brutalist / Material-3)3impl ButtonStyle for MyGlassButton then theme.style_slots.button = Some(Rc::new(MyGlassButton))
Reskin from designer-exported SVGs3Ship 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 WidgetTreeset_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 TextRoleTextRole::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

PresetWhereStatus
intui::light / intui::darkteksilo_core::presets::intuishipped — the default look
material3::light / material3::darkteksilo-theme-material3 crateshipped — Material 3
fluent::light / fluent::darkteksilo-theme-fluent crateshipped — Windows 11 / WinUI 3
macos::light / macos::darkteksilo-theme-macos crateshipped — macOS Aqua / Dark Aqua
Image-backed themesteksilo-image-theme cratenot 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

WidgetTraitDefault implSlot
ToggleToggleStyleRecipeToggleStylestyle_slots.toggle
ButtonButtonStyleRecipeButtonStylestyle_slots.button
SplitButtonSplitButtonStyleRecipeSplitButtonStylestyle_slots.split_button
CheckboxCheckboxStyleRecipeCheckboxStylestyle_slots.checkbox
RadioButtonRadioStyleRecipeRadioStylestyle_slots.radio
RadioTileRadioTileStyleRecipeRadioTileStylestyle_slots.radio_tile
IconButtonIconButtonStyleRecipeIconButtonStylestyle_slots.icon_button
SliderSliderStyleRecipeSliderStylestyle_slots.slider
SegmentedControlSegmentedControlStyleRecipeSegmentedControlStylestyle_slots.segmented_control
ProgressBarProgressBarStyleRecipeProgressBarStylestyle_slots.progress_bar
LinkLinkStyleRecipeLinkStylestyle_slots.link
AvatarAvatarStyleRecipeAvatarStylestyle_slots.avatar
BadgeBadgeStyleRecipeBadgeStylestyle_slots.badge

Inputs

WidgetTraitDefault implSlot
TextInputTextInputStyleRecipeTextInputStylestyle_slots.text_input
SearchFieldSearchFieldStyleRecipeSearchFieldStylestyle_slots.search_field
ComboBoxComboBoxStyleRecipeComboBoxStylestyle_slots.combo_box
SpinBoxSpinBoxStyleRecipeSpinBoxStylestyle_slots.spin_box
DateEditDateEditStyleRecipeDateEditStylestyle_slots.date_edit
ColorPickerColorPickerStyleRecipeColorPickerStylestyle_slots.color_picker
CalendarCalendarStyle ¹RecipeCalendarStylestyle_slots.calendar
RichTextEditorRichTextEditorStyleRecipeRichTextEditorStylestyle_slots.rich_text_editor

Containers

WidgetTraitDefault implSlot
PanelPanelStyleRecipePanelStylestyle_slots.panel
CardCardStyleRecipeCardStylestyle_slots.card
TabBarTabStyle ¹RecipeTabStylestyle_slots.tab
ListView / TreeView (container chrome)ListContainerStyleRecipeListContainerStylestyle_slots.list_container
TableView / TreeTableView (header + sort + row chrome)TableStyle ¹RecipeTableStylestyle_slots.table
DropZoneDropZoneStyleRecipeDropZoneStylestyle_slots.drop_zone
DropTargetDropTargetStyleRecipeDropTargetStylestyle_slots.drop_target

Overlays

WidgetTraitDefault implSlot
TooltipWidgetTooltipStyleRecipeTooltipStylestyle_slots.tooltip
PopoverPopoverStyleRecipePopoverStylestyle_slots.popover
Dialog (in-tree modal)DialogStyle ¹RecipeDialogStylestyle_slots.dialog
SnackbarSnackbarStyleRecipeSnackbarStylestyle_slots.snackbar
ToastToastStyleRecipeToastStylestyle_slots.toast
BannerBannerStyleRecipeBannerStylestyle_slots.banner

Rows / Items

WidgetTraitDefault implSlot
MenuItemMenuItemStyle ³RecipeMenuItemStylestyle_slots.menu_item
StandardListItem / StandardTreeItemStandardItemStyle ³RecipeStandardItemStylestyle_slots.standard_item

Chrome

WidgetTraitDefault implSlot
ScrollBarScrollBarStyleRecipeScrollBarStylestyle_slots.scroll_bar

Data Visualization

WidgetTraitDefault implSlot
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 hookStandardItemStyle::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 kCAMediaTimingFunctionEaseInEaseOutcubic-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:

  • TabStylemake_body themes a single tab header (accent indicator + focus ring + label slot composition); make_bar themes the whole strip (optional backdrop fill, content-pane separator, drag-reorder drop indicator). TabStyleConfig carries indicator_position (TabIndicatorPosition::{OuterEdge, InnerEdge}) so the active-tab highlight can hug either edge; the default RecipeTabStyle honours all four edges (outer/inner × horizontal/ vertical, RTL-correct). Per-tab backgrounds, the bar backdrop, inter-tab dividers, and text-colour roles are widget-level TabBar/TabWidget builders rather than part of the trait — see tab-widget.md "Appearance".
  • DialogStylemake_panel themes the modal surface (shadow + corner radius + padding + container chrome); make_scrim themes 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.
  • TableStylemake_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.
  • CalendarStylemake_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:

  1. Declare a closed MyWidgetVariant enum for the design-language presentations users can pick (mirror ButtonVariant's shape).
  2. Define a MyWidgetStyle trait in your own crate with a make_body(cfg, ctx) -> WidgetId signature. The cfg struct exposes the inputs that vary by interaction state (Signal<bool>s for hover/pressed/etc.), the variant, and pre-built child subtrees.
  3. Ship a RecipeMyWidgetStyle as the default impl. Add a slot to your own slot-bag struct (or attach via theme.extensions if 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 Theme flows through Signals, 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, Theme construction, per-widget *Variant enums and *Style traits), see styling-system.md.

Teksilo runs its theme through three layers of reactive primitives:

LayerTypeLives onPurpose
Root signalSignal<Theme>WidgetTreeSource of truth; set_theme fires this
Role enumsTextRole, SurfaceRole, BorderRole, TextStyleRoleteksilo-tokensName what a value represents, not which literal it is
PropsColorProp, TextStylePropteksilo-coreUnified input type accepted by widget builders

The rules:

  1. set_theme never 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.
  2. Roles resolve at paint/layout time. A widget that stores ColorProp::TextRole(TextRole::Primary) looks up ctx.theme.colors.text_primary in its paint — never at build time.
  3. 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:

  1. self.theme = theme.clone() — the cached &Theme accessor still works.
  2. self.theme_signal.set(theme) — fires observers (derived Signal<Theme>s, role-carrying ColorProps via their bindings).
  3. 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

CaseVariantHow to construct
"Normal" theme colorTextRole / 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 themeStatic.color(Color::from_hex("#..."))
Externally-provided signal (not tied to theme)Bound.color(user_color_signal)
Legacy code still using Prop<Color>via From implpass-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:

  1. Color transformations (token.with_alpha(0.2) etc.) — no role represents "accent at 12 % alpha"; use theme_signal.map(|t| t.colors.accent.with_alpha(0.12)) to get a Signal<Color>.
  2. Effects on external state — the rich-text engine's per-frame palette, for instance. See primitives/text_input_field.rs for the ctx.effect(&theme_signal, move |theme| { ... }) pattern.
  3. Layout snapshotslet shape = ctx.theme_signal().get().shape at the top of build() captures corner radii; let layout = ctx.theme_signal().get().layout captures 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:

PrimitiveMethodFile
HStack / VStack / Wrap.spacing(...)primitives/hstack.rs, vstack.rs, wrap.rs
Grid.column_gap(...) / .row_gap(...)primitives/grid.rs
PaddingPadding::new / uniform / symmetricprimitives/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 formNew 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

FileContents
crates/teksilo-tokens/src/roles.rsRole enums + resolve
crates/teksilo-core/src/color_prop.rsColorProp, TextStyleProp, From impls
crates/teksilo-core/src/widget_tree.rsset_theme, set_locale, theme_signal, locale_signal
crates/teksilo-core/src/build_context.rsBuildContext::theme(), theme_signal(), locale_signal()
crates/teksilo-widgets/src/button.rsCanonical Signal<Role> pattern
crates/teksilo-widgets/src/primitives/text_widget.rsDefault role usage, paint-time resolve
crates/teksilo-widgets/src/panel.rsColorProp 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_to would 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)); t is 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 in WaitUntil. 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 a Signal<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 Collapse whose 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 in duration and 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.

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_position is recreated each build() — not preserved across rebuilds. This is intentional: rebuilds are rare (theme change, structural dirty), and the value is trivially restorable from self.on.
  • The paint() method reads knob_position.get() directly and positions the knob at lerp(left_edge, right_edge, position).
  • No Signal::map, no Prop::Bound wrapping needed — direct read in paint is fine because the scheduler's signal.set() on each tick already dirty-marks the widget for repaint via the binding registry.
  • to_or_snap quietly snaps to the target without tweening when the platform reports prefers_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:

  • Fade wraps a child and tweens an internal opacity signal between 0 and 1 driven by a Signal<bool>. Layout-transparent: the child reports its full natural size at all opacity values. Built on BuildContext::set_opacity, a node-level opacity scope (parallel to clips_children) emitted by the rendering walker as SetOpacity / RestoreOpacity draw commands wrapping the subtree. Sub-perceptual opacities (< 1/512) are short-circuited — no draw passes.
  • Collapse — see §5.2. The accordion-pattern primitive.
  • Scale wraps a child and animates a uniform 2D scale on its entire subtree, driven by a Prop<bool>. Built on BuildContext::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 with ScaleOrigin::TopLeading so the visual stays anchored at the slot's top-left as it collapses). Distinct from Collapse: Collapse shrinks one axis and "wipes" content via clipping (text inside stays full-size); Scale shrinks uniformly and text/icons visually get smaller.
  • Rotate wraps a child and applies a 2D rotation (radians) to its subtree via set_transform. Layout-stable. No internal animation — the caller owns the angle signal and pairs it with Signal::animate_to for animated rotations. Use for animated chevrons (replacing the old "flip-two-static-icons" trick), spinning loaders not covered by Spinner, dial controls.
  • Blur wraps a child and applies a Gaussian-equivalent blur to the entire subtree, driven by a Prop<f32> radius (logical pixels). Built on BuildContext::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 an animated_signal and animate_to for animated enable/disable. See §5.8 for the offscreen-pass cost model.
  • Spinner — circular-arc loading indicator backed by AnimatedQuadKind::SpinnerArc, the shader-driven path (see idle-and-animation.md §"Three animation paths — signal vs shader vs per-frame-effect"). One queue.write_buffer of AnimParams + one draw_indexed per frame; paint() does not run while spinning. Edges are anti-aliased via fwidth smoothstep ramps in the fragment shader. Honours prefers-reduced-motion with 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 emits SetOpacity(value) / RestoreOpacity around the subtree; renderer maintains a stack and multiplies through. Bound at RepaintOnly. Used by Fade, Pulse, Crossfade, OverlayRequest::with_fade.
  • set_clips_children(id, true) — a per-node clip rectangle to the node's own bounds. Used by ScrollArea, Collapse, MaxSize, Slide, Shake, SmoothSize, Scale, Rotate — anything whose subtree may overshoot the slot.
  • BuildContext::set_transform(id, prop) — added with Scale and Rotate. Render walker emits PushTransform(matrix) / PopTransform around the subtree; renderer maintains a transform stack. Bound at RepaintOnly by default; reflow-driving wrappers (e.g. Scale::reflow(true)) must additionally bind their driver signal to themselves at Relayout for layout to track the value.
  • BuildContext::set_blur(id, prop) — added with Blur. Render walker emits BeginBlurredSubtree { bounds, radius } / EndBlurredSubtree around 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 at RepaintOnly. 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:

  1. 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).
  2. 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).
  3. Runs the dual-Kawase chain on it: N = ceil(log2(radius)) downsample passes (each halves the texture, applies a 4-tap bilinear shader), then N upsample passes back to the source size with a different 4-tap shader.
  4. Composites the final blurred texture into the parent pass at bounds via 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, so animate_to would 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 own animate_to on the tooltip content.
  • Single-axis disclosure (an accordion section opening, a drawer expanding to a known height). Use Collapse instead of ScaleCollapse clips 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. Scale is 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. Blur is the most expensive scope in the framework — every enabled scope opens a separate render pass and runs 2N+1 Kawase 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 in Blur.

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). Captures MotionTokens, easing presets, and prefers_reduced_motion in 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) inside build(). That handles scheduler registration and widget-lifetime cancellation.
  • Respect ctx.prefers_reduced_motion() before starting a looping animation; to_or_snap already 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. EaseInOut for symmetric transitions (toggle thumbs), EaseOut / easing_standard for appearance (snackbar slide-in, dialog fade), Linear for loops and indeterminate work. Easing::CubicBezier { x1, y1, x2, y2 } (via ctx.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

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 pathSignal<f32>::animate_to / animate_looping, scheduled by AnimationScheduler.
  • Shader-quad pathctx.animated_quad(kind), scheduled by AnimatedQuadRegistry.
  • Per-frame-effect pathctx.subscribe_frame_tick(), scheduled by FrameTickScheduler. 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-rolled frame_tick consumer.

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.

  1. 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_widget and destroy_subtree both call scheduler.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)

  2. Per-window active flag. WindowEvent::Focused(false) (and on macOS, Occluded(true)) calls tree.set_window_active(false), which makes AnimationScheduler::tick a no-op and next_deadline return None. The event loop falls through to ControlFlow::Wait. On resume, each animation's start_time is rebased by the paused duration so phase is continuous — a half-swept sweep resumes at 50%, not snapped forward. (app.rs, window_manager.rs)

  3. Per-widget paint-epoch visibility. WidgetTree::paint_epoch ticks on every non-cache-hit render(). paint_widget_cached stamps last_painted_epoch on each widget whose bounds survive clip intersection. The shared motion_visibility helpers turn that into a yes/no for each scheduler:

    • Signal-tween path uses painted_recently (last_painted_epoch + 1 >= paint_epoch) — tolerant, because the signal set on each tick dirties its widget, which forces a non-cache-hit paint that bumps both values in lockstep; the +1 slack 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 original Pulse / Cycle bug, where a Pulse parked inside a non-selected Switcher branch 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_flow re-queries next_deadline in post_event (signal + quad paths) and WidgetTree::render re-arms frame_tick_requested after 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 Collapse drives a one-shot 0..1 progress signal that determines its own height — so when collapsed, its bounds are zero, it never paints, never re-stamps last_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 == 0 is the "never rendered" sentinel: always visible, so headless unit tests that only call layout() don't regress. (rendering_impl.rs, arena.rs, animation.rs, animated_quad.rs, frame_tick_scheduler.rs)

  4. Pixel-stable ε, mandatory terminal bypass. Each AnimationRequest can carry an epsilon (unit: the signal's own units, so usually logical pixels). Intermediate ticks skip signal.set when 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 exactly end_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's last_painted_epoch vs. tree.paint_epoch.
  • Timer source you forgot? Every timer-backed deadline must flow through next_timer_deadline in overlay_impl.rs. If it doesn't, the event loop can't decide whether to sleep.
  • Poll mode forced? ControlFlow::Poll is 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 via WidgetTree::frame_tick_deadline, folded into next_timer_deadline, so continuous animations render through ControlFlow::WaitUntil at 60 Hz regardless of the display's refresh rate. (It used to force Poll, which free-ran at the panel's refresh — a single Pulse/Cycle measured 300 fps / ~45 % CPU on a 300 Hz panel vs. 60 fps / ~13 % CPU after the cap.) This matches the signal-tween AnimationScheduler and shader-quad AnimatedQuadRegistry, which already pace at the same 16.667 ms interval. For visual continuous animations (Pulse, Cycle, …), prefer ctx.subscribe_frame_tick() over the raw frame_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:

PathWhen to useCost when visiblepaint() 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.setpaint() → 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 / skeletonCPU: 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:

PhaseWhat it fixedRecovery on catalog scene
1 — A11y dirty-gatelayout() 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 iteratorsWidgetArena::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 setPer-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 registryBindingRegistry 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:

  1. 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 for queue.write_buffer(anim_uniforms, 8 KiB) and command encoding. None of it was rasterisation time.

  2. Second round (60 Hz, full measurement on the catalog --tab animations scene): the framework-level path dominated at 50.7 % CPU, with queue.write_buffer not 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.

  1. Builder chainWidget::new(...).access_label(...).access_role(...) each return a WidgetWithHandlers<W> whose HandlerSet carries an Option<Box<AccessibilityOverrides>>. The first access_* call lazily allocates the box; subsequent calls extend it.
  2. InsertionWidgetTree::add(...) calls take_handler_set() on the wrapper and apply_handler_set (crates/teksilo-core/src/arena.rs) mirrors the box onto the persistent WidgetNode::access_overrides field. After this point, the wrapper has no override state — the source of truth is on the node.
  3. AT tree build — when the framework calls WidgetTree::sync_accessibility(), the walker at crates/teksilo-core/src/widget_tree/accessibility_impl.rs:137 runs node.widget.accessibility(builder) first (so the inner widget emits its defaults), then calls node.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

MethodSetsNotes
.access_label(s)Node::labelWhat screen readers announce. Replaces widget-emitted name.
.access_label(lit!(s))same#[doc(hidden)] grep marker for explicitly untranslated strings.
.access_description(s)Node::descriptionLong-form context.
.access_description(lit!(s))same#[doc(hidden)] grep marker.
.access_hint(s)Node::descriptionAlias for access_description (SwiftUI parity — AccessKit has no separate hint slot).
.access_hint(lit!(s))same#[doc(hidden)] grep marker.
.access_value(s)Node::valueCurrent value (sliders, spin boxes, text input).
.access_value(lit!(s))same#[doc(hidden)] grep marker.
.access_role(role)Node::roleReplace widget-emitted role.
.access_hidden(bool)Node::hidden flagtrue hides from AT, false un-hides (clears even widget-emitted hidden).
.access_disabled(bool)Node::disabled flagtrue marks disabled, false clears even arena-driven disabled.

Tier 2 — relationships, live regions, identity

MethodSetsNotes
.access_identifier(s)Node::author_idStable test/debug id (like data-testid). Not user-visible.
.access_controls(target_id)Node::controlsAppend. ARIA aria-controls.
.access_described_by(target_id)Node::described_byAppend.
.access_labelled_by(target_id)Node::labelled_byAppend.
.access_live(mode)Node::livePoliteness for status regions (Polite, Assertive).
.access_current(c)Node::aria_currentMark this as the current item in its container (aria-current).
.access_has_popup(kind)Node::has_popupDisclosure flag — Menu, Listbox, Dialog, …
.access_orientation(o)Node::orientationSliders, scrollbars, separators.

Tier 3 — subtree modes, numeric, actions, escape hatch

MethodEffect
.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.

Excludeaccess_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.

Mergeaccess_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:

SourceMerged into parentRule
descendant nameparent nameAppend with single space; existing parent name kept first if any.
descendant valueparent valueFirst non-empty wins.
descendant supported actionsparent action setUnion, deduplicated.
descendant roleDiscarded. Parent's role wins.
descendant numeric_value / range / stepDiscarded.
descendant hidden / disabledDiscarded. Parent's state governs the merged element.
descendant description / controls / described_by / labelled_byCurrently dropped (no AccessNodeBuilder getters); use access_customize on the parent if you need them.

Nested subtree modes:

  • Merge containing Exclude somewhere — Exclude wins for that subtree (descendants of the excluded node contribute nothing to the merge).
  • Merge containing Merge — the inner merge runs first into a temp builder, the outer merge then absorbs the inner's already-merged label as one element.
  • Exclude containing 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 HStack with .access_label(lit!("Toolbar"))Role::GenericContainer with a name → kept (a named group).
  • A Panel (Role::Group) or GroupBox → 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-emitted set_hidden(). Full clear (no framework re-application of hidden).
  • .access_disabled(false) — clears widget-emitted disabled AND arena-driven disabled. The framework's gate at accessibility_impl.rs respects the override, so even a .disabled(true) set on the widget's enabled-state can be overridden for AT purposes.

Real use cases:

  1. App author wraps a Panel configured as decorative but a screen reader user does need to know about it ("Settings panel — collapsed").
  2. 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.
  3. Test scaffolding asserts a widget is exposed regardless of internal a11y_presentational plumbing.

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:

  1. node.widget.accessibility(&mut builder) — inner widget emits role, name, value, actions, hidden/disabled, etc.
  2. overrides.apply(&mut builder) in this order:
    1. Scalars: label, description, value, role (replace if Some).
    2. State flags: hidden/disabled set or clear based on Some(true)/Some(false).
    3. Identity: identifierset_author_id.
    4. Relationships: controls, described_by, labelled_by (append).
    5. Live region / aria_current / keyboard_shortcut literal / has_popup / orientation.
    6. Numeric: numeric_value, min, max, step.
    7. Action suppression: removed_actions (call remove_action).
    8. Action advertisement: actions (call add_action).
    9. Custom actions: write Vec<accesskit::CustomAction> with sequential ids.
    10. customize closure runs last with full inner_mut() access.
  3. Walker post-processing:
    1. Resolve access_shortcut_id against ShortcutRegistry (this needs tree access, so it lives outside apply()).
    2. Subtree dispatch — for Merge, walk descendants and absorb into the current builder; for Exclude, prune.
  4. Framework finalization:
    1. Push child NodeIds (skipped for Exclude / Merge).
    2. Inject layout bounds.
    3. Re-apply set_disabled() from the arena's enabled-flag UNLESS the override has disabled: Some(false).
    4. Tooltip → push_described_by.
    5. 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.

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 as ctx.set_theme / ctx.set_locale. Persist alongside it via ctx.settings().signal_for(&TEXT_SCALE_KEY).set(...) (the TextScaleControl does both for you).
  • Read the current factor at build via ctx.text_scale(), or bind the reactive ctx.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 freeTextWidget, 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 — the f32 factor, read during layout/paint.
  • WidgetTree::text_scale_signal() (and BuildContext::text_scale_signal()) — a reactive Signal<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_factorfont_scale
Questionphysical px per logical px (HiDPI)how big, logically (a11y + per-editor size)
Acts atrasterizationshaping (size before layout)
Changes logical metrics?No (cancels out)Yes (grows + reflows)
Glyph sharpnessdensifiesdensifies (larger ppem)
Scopeglobal serviceper-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:

SurfaceDefaultKnob
IconWidgetoff (fixed-footprint glyphs).follow_text_scale(true)
Severity badges (Banner/Toast/MessageBox/NotificationLog)on(built in — enabled on the badge's icons)
RichTextEditoron.follow_text_scale(false) to opt out (e.g. a WYSIWYG editor whose font sizes are document content)
teksilo-scene TextItemoff (the scene has its own pan/zoom).follow_text_scale(true)
Calendaron (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", default 1.0).
  • Widget: TextScaleControl.
  • Core: WidgetTree::{set_user_text_scale, effective_text_scale, text_scale_signal} (widget_tree.rs); text_scale on LayoutContext / 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 — the TextScaleControl in 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

SurfaceAPI
In build(), reactivectx.window_active_signal() -> Signal<bool> (bind at RepaintOnly)
In build(), one-shotctx.window_active() -> bool
In paint()ctx.window_active: bool (on PaintContext)
In an event handlerctx.window_active() -> bool (on EventContext)
On the treeWidgetTree::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 / Qt QPalette::Inactive model. Because every themed control resolves its accent from the live ColorTokens at paint time, this single swap greys out all of them with no per-widget code: the default (Filled) Button, Toggle's on-track, checked Checkbox / RadioButton, the selected TabBar tab and SegmentedControl segment, Slider fills, ProgressBar, Badge, links-as-accent, and focus rings (BorderRole::Focused / focus_ring). It applies to any preset that populates these tokens — IntUI, Material 3 (where accent = 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 (RichTextEditor and every TextInput / PasswordField / SpinBox / SearchField built on TextInputField). 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:
WidgetActive role / tokenInactive role / token
StandardListItem / StandardTreeItemSurfaceRole::SelectedSurfaceRole::SelectedInactive
TableView / TreeTableViewSurfaceRole::SelectedSurfaceRole::SelectedInactive
RichTextEditoreditor_selection_bgselection_bg_inactive
TextInput familyselection_bg_activeselection_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 by set_window_active and threaded onto PaintContext / BuildContext / EventContext exactly 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 than set_theme's mark_all_dirty (which also relayouts) and means any paint-time ctx.window_active reader is correct without per-widget binding ceremony.
  • The frame-loop tick() of each text stack has no context, so build() registers an effect on window_active_signal that 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 resulting I18nManager on the thread-local; once installed, every tr! / tr_signal! / current_locale() / NumberFormatter call in the same thread routes through it.
  • compile_in registers .ftl strings (typically pulled in via include_str!) so they're embedded in the binary. Use compile_in_locales! for the multi-locale × multi-file case.
  • The framework's own widget strings come from teksilo::widgets::framework_locales() — this is what gives MenuBar, Dialog, accessibility labels, keystroke names, etc. their localized text. Apps almost always want this.
  • teksilo::prelude::* re-exports tr / tr_widget / LocalizedString / I18nConfig / LanguageIdentifier (gated on the i18n cargo feature). The reactive macros (tr_signal, tr_signal_widget) and the formatter types (NumberFormatter, TeksiloDateTimeFormatter, TeksiloDateTime, NumberStyle, DateStyle, TimeStyle) are reachable through the teksilo::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.

MethodPurpose
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_resource keeps 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-FR from an extension does not fold those strings into the application's en-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):

  1. config.user_locale — explicit app choice (e.g. read from SettingsStore). Honored only if it's in supported_locales.

  2. OS locale, if auto_detect_os_locale is on (the default). The writer's whole preferred-language chain is read from the sys-locale crate 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:

    1. exact — every subtag agrees;
    2. same language and script, any region — an OS-reported en-GB matches a supported en-US, and fr, fr-CA, fr-BE or fr-CH all match a supported fr-FR;
    3. same language, script named on only one sidezh-Hans matches a supported zh-Hans-CN. A script named on both sides and disagreeing is never bridged, so zh-Hans can not land on zh-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.

  3. config.fallback_locale — returned unconditionally if neither of the above produced a hit. Defaults to en-US if not set; not validated against supported_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 .ftl in this order: directory at TEKSILO_I18N_SOURCE_DIR (env var, dir of .ftl files) → single file at TEKSILO_I18N_SOURCE_PATH (env var, one .ftl file) → 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-file auth.ftl/editor.ftl/... projects work without configuration.
  • It checks the message key exists, and that every named arg matches a $variable declared in the message. Missing key, missing arg, unknown arg — all produce compile_error! at the call site, with Levenshtein-based "did you mean" hints for typos.
  • Every parsed .ftl file is registered as a build dependency via an emitted include_bytes!, so cargo rebuilds the calling crate when the source file changes.

Key path → Fluent key mapping:

  • tr!(count_items()) → fluent key count-items (single underscore inside a segment becomes a dash).
  • tr!(auth::login_title()) → fluent key auth__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's version_signal, which fires on locale changes and .ftl hot 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.

Source.

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:

MethodDefaultPurpose
.style(NumberStyle)DecimalSet 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)noneMin zero-pads; max rounds half-to-even.
.use_grouping(bool)trueToggle locale grouping separators.

TeksiloDateTimeFormatter builder methods:

MethodDefaultPurpose
.date_style(DateStyle)Medium if neither setLong/Medium/Short.
.time_style(TimeStyle)noneLong/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 CurrencyFormatter with 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(...). JPY renders "¥1,235", not "¥1,234.50". This is ECMA-402 behaviour.
    • .use_grouping(false) does not reach currency: the CurrencyFormatter constructors build their own DecimalFormatter and expose no seam to pass ours. A Currency style with no code, or a code ICU rejects, falls back to plain decimal rather than rendering a wrong currency.
  • 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:

FunctionReturns
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 × file combination must exist on disk — include_str! fails at compile time on a missing file.
  • base is relative to the source file that invokes the macro, not the crate root. For a binary crate with main.rs in src/ 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

TopicPath
I18nConfig buildercrates/teksilo-i18n/src/config.rs
I18nManager + bundle wiringcrates/teksilo-i18n/src/manager.rs
LocalizedString + to_signalcrates/teksilo-i18n/src/localized_string.rs
Locale-aware formatting (Number/DateTime)crates/teksilo-i18n/src/format.rs
Hot-reload file watchercrates/teksilo-i18n/src/file_watcher.rs
Directory-override testscrates/teksilo-i18n/tests/runtime_override_dir.rs
Layout direction (RTL)crates/teksilo-i18n/src/direction.rs
Thread-local + crate-root accessorscrates/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 macrocrates/teksilo-i18n/src/lib.rs
End-to-end demoexamples/internationalization
Format integration testscrates/teksilo-i18n/tests/format_integration.rs
tr! integration testscrates/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:

StrategySelected byHeightsNotes
Uniform (default).sizing(...) / .tile_size / .column_countfixedExact O(1) positions. The common photo/icon grid.
Variable row.variable_row_heights(estimated)each row = tallest tileSwiftUI LazyVGrid. Auto-measure + scroll-anchoring, or exact via .item_height(i).
Waterfall.waterfall(estimated)per-itemPinterest 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 } — exactly count stretched columns.
  • Adaptive { min_width, max_width, height } — fit as many ≥ min_width columns as possible, stretch up to max_width (CSS repeat(auto-fill, minmax(...)) / Flutter maxCrossAxisExtent).

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, VariableRowGrid adjusts scroll_y to 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 — from common/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 (a Relayout-bound signal) so max_scroll_y and 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 as max(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):

KeysAction
Arrow ←/→±1 (within row; .wrap_navigation(true) to cross rows)
Arrow ↑/↓±columns
Home / Endfirst / last of row
Ctrl+Home / Ctrl+Endfirst / last item
PageUp / PageDown± a viewport of rows + scroll
Spacetoggle selection
Enter.on_tile_activate (else select)
Escclear focus
Ctrl+Aselect all (Multi mode only)
Alt+Arrowreorder the focused tile (when .reorderable)
printabletype-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 default i18n feature a widget label is a LocalizedString and there is no From<&str>, so in a real app wrap it: Button(lit!("Save")) (untranslated) or Button(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 &str and 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 formExpansion
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 bodyhoisted let name = ctx.add(‹E›); + .add_child(name)
name = ‹E› in slot shoisted 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›))
..exprstmt-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 — "Card is a Category B widget with named slots — use content: <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 returning Box<dyn Widget> (or refactored to match).
  • Binding hoist scope: bindings declared inside if/else/match/ for bodies currently hoist to the outermost teksu! 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 { ... } where signal: Signal<bool> does not auto-bind visible_when. Bind visibility through ctx.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::Variant and prop: Type::Variant(inner) are recognized as expressions because of the UpperCamel::UpperCamel shape.
  • 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

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 default i18n feature a widget label is a LocalizedString and there is no From<&str>, so in a real app wrap it: Button(lit!("Save")) (untranslated) or Button(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 &str and 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, with title taken as constructor arg)
  • SplitView (first, second)
  • TitleBar (leading, center, trailing, plus the non-widget close_action handler)
  • DialogContent (body, footer, with title and supporting_text as LocalizedString properties)
  • Breadcrumb (item and trailing_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 (content taking a Fn() -> impl Widget factory, plus trigger; 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: value shape.
  • 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 { … }) becomes teksu!(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/formatting request, the server runs teksilo_fmt::format_file on the buffer contents and returns either an empty edit list (already canonical) or a single full-document TextEdit (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 how rustfmt behaves 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 from teksilo-macros so non-proc-macro consumers (the formatter, future linters, editor tooling) can build on the same grammar without depending on a proc-macro = true crate. 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, --check mode.
  • 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 { } becomes Divider. The two are semantically identical to the macro; the formatter picks one.
  • ctx => line-break collapse: a ctx => 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.

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-fmt from a shell hook on every save. Simplest setup, no extension authoring.
  • Path B — LSP via a tiny custom extension: install teksilo-fmt-lsp and 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:

  1. Open the extension folder in VS Code.
  2. Press F5 to launch a development host with the extension loaded.

For a permanent install without packaging:

  1. Symlink the folder into ~/.vscode/extensions/:
    ln -s ~/.vscode/teksilo-fmt-extension ~/.vscode/extensions/local.teksilo-fmt-vscode-0.1.0
    
  2. 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.formatDocument for 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).


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 the ShortcutRegistry; 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 via ctx.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:

PathHow the intent is builtAnchor for source→root walk
Shortcut (keyboard chord)Registry invokes on_activate or synthesizes Intent::newFocused widget or root fallback
Widget handler (ctx.send_intent)Handler builds or returns an Into<Intent> valueThe originating widget
Programmatic (tests, tools)Build Intent by hand or via IntentKind::into_intentCaller-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 Super explicitly is left alone, so Ctrl+Super survives 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: ShortcutScopeGlobal (fires regardless of focus) or Scoped(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 matched KeyStroke (so you can branch on which chord fired) and an EventContext (for side effects). Returns anything Into<Intent> — typically an IntentKind variant. Omit when the shortcut only needs the name: the registry synthesizes Intent::new(intent_name) for you.
  • enabled_when: Option<Signal<bool>> — reactive "is this shortcut live?" predicate. When false, the shortcut is treated as if not registered — the keystroke falls through to the focused widget's normal on_key dispatch. Compose composite predicates with the Signal<bool> combinators (and / or / not) or Signal::zip for typed tuples.
  • propagate_when_disabled: bool — controls what happens when the matching Action is disabled: true (default) lets the intent continue bubbling; false consumes 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):

  1. Defaults — records registered by widgets during build() or declared statically via Widget::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.
  2. 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 Switcher arm 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:

  • Insertiontree.add(w) / ctx.add_child(parent, w), right after handler-set extraction, before build().
  • Rebuild — right after unregister_all_for_owner wipes the previous build's registrations, so declared metadata survives the rebuild cycle even if build() only conditionally re-registers.
  • Switcher::build — for every still-Pending slot. 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 in defaults. Two widgets sharing an id share the user rebind.
  • by_owner tracks one owner per id at a time. The newer registration's register_owned calls detach_owner_index to 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:

  1. Applicability first. A Scoped(id) binding is a candidate only when the focused widget is inside id's subtree. A Global binding is always a candidate.
  2. Most-specific scope wins. An applicable Scoped binding beats a Global one. Among nested applicable scopes, the one closest to the focused widget (deepest) wins.
  3. Id order is only a tiebreak within equal specificity (the deterministic (category, id) order of iter_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 match Intent::name. Typo-safety comes from IntentKind's name attributes, not from the action side.
  • enabled_when: Option<Signal<bool>> — reactive predicate. When false, the action is skipped during dispatch (the intent propagates past this level as if no match existed here — unless the firing shortcut has propagate_when_disabled == false, in which case it is consumed dormant).
  • on_invoke(|intent, ctx| …) — handler that always reports Handled.
  • on_invoke_with_response(|intent, ctx| …) -> IntentResponse — when the handler needs to decide Handled vs Propagated at 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 fallbackafter 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 with register_action on 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:

  1. Build the chain source → parent → … → root.
  2. For each id in 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 (when propagate_when_disabled) or return (otherwise).
    • Invoke the handler. On Handled → return. On Propagated → continue.
  3. 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. Default propagate_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:

  1. Shortcut resolution. The registry is consulted before any widget dispatch. ShortcutRegistry::matches_by_keystroke yields 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 a Scoped binding outside the focused subtree — the event falls through to stage 2.
  2. Ancestor key preview. If no shortcut matched, the framework walks the focused widget's strict ancestors root → parent-of-target, firing on_key_preview on each. Returning EventResponse::Handled consumes the event.
  3. Focused widget bubble. If preview returned Ignored for every ancestor, the focused widget's own on_key runs, then the event bubbles to ancestors via their on_key slots.

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

TaskAPI
Declare a keyboard shortcutShortcut::new("id").primary(KeyStroke::…).build()
Register widget-scopedctx.register_shortcut(shortcut)
Register app-levelctx.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 signala.zip(&b), a.zip3(&b, &c)
Switch to a selected inner signalselector.flat_map(|t| inner_signal(t))
Consume when disabled.propagate_when_disabled(false)
Declare a handlerAction::new("id").on_invoke(|intent, ctx| …)
Propagate after observing.on_invoke_with_response(|i, c| IntentResponse::Propagated)
Register handler on widgetctx.register_action(action)
Register app-global handler (menu/chrome)ctx.register_action_global(action)
Fire programmaticallyctx.send_intent(AppIntent::X)
Typed enum bridge#[derive(IntentKind)] + #[name = "…"] on each variant
Recover typed variantAppIntent::from_intent(intent)
Raw payload lookupintent.payload::<T>()
Observe registry changesctx.shortcut_registry().version()Signal<u64>
Effective view of a shortcutctx.effective_shortcut("id") — merged defaults + overrides
Menu label follows rebindsMenuItem::new(...).for_shortcut("id")
Tooltip shows chord + rebinds liveTooltipContent::new(...).for_shortcut("id")
Rebind UI out of the boxShortcutSettings::new()
One-shot key capturectx.begin_key_capture(|ks, registry, ctx| …) — returns CaptureHandle

See also

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 label cascade 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.

LayerTypeCrateWhat it does
Plain content widgetTooltipWidgetteksilo-widgetsThemed rounded-rect with one line of text
Rich content widgetRichTooltipWidgetteksilo-widgetsBody + shortcut chip + "more" disclosure + dwell indicator
Composite content widgetCompositeTooltipWidgetteksilo-widgetsSurface hosting an arbitrary widget tree (TabWidget, charts, progress bars, conditional rows, dynamic values) with dwell-to-sticky promotion
RegistryTooltipRegistry / TooltipContentteksilo-widgetsThread-local catalog keyed by short stable ids
Attach helpersattach_rich_tooltip* / attach_composite_tooltip*teksilo-widgetsWire a tooltip onto an anchor inside build()
Tree machineryWidgetTree::attach_tooltip*teksilo-coreHover/focus tracking, dwell promotion, overlay lifetime
Visual progressDwellIndicatorteksilo-widgetsPie-wedge / pin glyph for sticky-on-dwell
TokensTooltipStyle (trait) / constants in recipe_tooltip_style.rsteksilo-core / teksilo-widgetsTOOLTIP_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.

SetterSetsClears
.tooltip(text) / .tooltip(lit!(text))plain textrich source, composite body
.rich_tooltip(key) / .rich_tooltip_content(c)rich sourceplain text, composite body
.composite_tooltip(w)composite bodyplain 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 inner ComboBox).
  • Data / command delegates: TabInfo / TabDelegate for tab strips, ToolbarAction for Toolbar commands.

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 closureimpl 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
}
}
MethodBehavior
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:

  1. Construct the content widget (TooltipWidget / RichTooltipWidget).
  2. 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.
  3. Register a TooltipEntry on the tree with the anchor id, content id, delay, and (for rich) the dwell threshold + a shared shown_at sink.

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.*.

PathFieldDefault
Plain + rich tooltips (all widgets)motion.tooltip_delay500 ms
Composite tooltips + scene-item tipsmotion.tooltip_delay_heavy700 ms
Subsequent tip while a tip is open / just dismissedmotion.tooltip_reshow_delay100 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

MethodUse
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:

  1. Hover enter (tooltip_pointer_enter). The innermost entry whose anchor_id contains the entered widget records hover_start = now and the pointer position as hover_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.
  2. Stationary filter (tooltip_pointer_moved). While the tip is still pending, moving more than ~4 logical px from hover_origin restarts the timer (Windows-style hover-tracking slop). Intentional pause, not fly-by, shows the tip.
  3. Delay tick (process_tooltips / process_tooltips_real). Each entry whose elapsed time since hover_start ≥ the effective delay is shown. Effective delay is the entry's delay, scaled by the theme's tooltip_reshow_delay : tooltip_delay ratio while a tooltip session is active (any non-sticky tip currently shown, or within TOOLTIP_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.activate on the dormant content, show_overlay with placement NearAnchor { offset: (0, 8) } and dismiss behavior PointerLeave { delay: 100 ms }. The shown_at_sim / shown_at_real timestamps are recorded; the optional shown_at_sink is updated.
  4. 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.
  5. 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 via EscapeOrClickOutside.

Suppression and dismissal

Beyond hover-leave, four things retire a tooltip:

TriggerPending dwellShown non-stickySticky
PointerDown anywhere (tooltip_pointer_press)cancelleddismissedkept
Drag session active (tooltip_cancel_pending_dwell)cancelledkept
Window deactivated (tooltip_window_deactivated)cancelleddismissedkept
Escape (try_dismiss_top_on_escape)dismisseddismissed

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:

StepGlyph
0Empty 14×14 circle outline (just shown)
125 % pie wedge filled (12 → 3 o'clock)
250 % wedge
375 % wedge
4 / stickyFilled 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_sticky flag flips to true so tooltip_pointer_leave no longer auto-dismisses it.
  • The overlay's dismiss behavior is swapped to EscapeOrClickOutside.
  • The tooltip's a11y role flips from Role::Tooltip to Role::Dialog and the AT node advertises Action::Focus (the rebind happens at BindingLevel::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 promotionWidgetTree::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, and self_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-free GenericContainer is not a node a description can be read from — TextInput says 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:

  • Showndescribed_by pointing 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 own accessibility() (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

StateRoleNotes
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::FocusTab-reachable, click-through
DwellIndicatorRole::GenericContainerDecorative — 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):

TokenRoleDefault (light + dark — both intentionally dark)
tooltip_bgSurfaceRole::TooltipBg#1E1F22
tooltip_textTextRole::TooltipText#DFE1E5
tooltip_borderBorderRole::TooltipBorder#393B40
tooltip_shortcutTextRole::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:

TokenDefaultUsed for
duration_fast120 msTooltip 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
SplitButtonMirrors all four onto its chevron with a parallel chevron_tooltip / chevron_rich_tooltip / chevron_rich_tooltip_content / chevron_composite_tooltip matrix.
SegmentedControl (per-Segment), ToolbarActionClone 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, PasswordFieldAlso keep a legacy rich_tooltip_key(key) alias predating the canonical rich_tooltip(key); prefer the canonical name.
TabDelegateClosure-driven per-tab delegate — rich_tooltip_key / rich_tooltip_content_with / composite_tooltip_with take Fn(&T) -> … closures rather than fixed values.
ThemeSwitcher, LanguageSwitcherThin ComboBox presets; the four setters forward onto the inner ComboBox.
ToastNot 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

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:

WidgetShapeLifetimeStackableArchive
SnackbarBottom-center, single-instanceAuto-dismiss (4s default)No (calls dismiss_all_except_hosts)No
BannerInline persistent stripUntil user dismissesNo (inline)No
MessageBoxModal dialogUntil user picks a buttonNo (one modal at a time)No
ToastCorner-anchored, stackableAuto-dismiss (10s default) or persistentYesYes (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:

  1. Opens the NotificationArchiveModelInMemory or Persistent. Persistent requires app_paths(…) to be set on the builder first; the install panics with a helpful message otherwise.
  2. Constructs a shared ToastRegistry bound to the archive (or naked if no archive is configured).
  3. Registers a DefaultPostRoot closure that wraps every window's root with ZStack { user_root, ToastHost::new(registry, options) }. The DefaultPostRoot fires for every window the app opens — initial AND runtime-opened — so the host installs everywhere automatically.
  4. Registers the ToastRegistry + Rc<NotificationArchiveModel> into app_state so NotificationLog / NotificationCenterButton / NotificationLogDialog can 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)
}
}
FieldNotes
cornerRTL-aware: Trailing flips to the physical left edge. Top corners stack downward, bottom corners upward.
max_visibleNormal-priority overflow drops with cause SlotPoolFull. High / Urgent priority evicts the oldest Normal to make room.
pause_on_hover_group: trueHovering ANY live toast pauses every timer. false pauses only the hovered toast (libadwaita behaviour).
archive: NoneToasts 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

ConstructorSeverityDefault behaviour
Toast::info(title)InfoStatus / confirmation
Toast::success(title)Success"Saved", "Deploy complete"
Toast::warning(title)WarningNon-fatal issue
Toast::error(title)ErrorFailure (forces Role::Alert + Live::Assertive)
Toast::loading(title)InfoPersistent, 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_id is preserved — the first call's ToastHandle keeps working.
  • Archive side: the existing archived entry merges (an UpdateRecord appended to its updates Vec); no new row appears in NotificationLog.

Preserved across updates that don't re-specify them:

FieldPreserved when update omits it
on_dismissYes (the original callback survives)
leadingYes (a Toast::loading spinner survives Toast::info updates)
Other fieldsNo (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:

SeverityPriorityRolearia-live
Info, SuccessanyStatusPolite
WarningNormalStatusPolite
WarningHigh / UrgentAlertAssertive
ErroranyAlertAssertive
anyUrgent(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

CauseWhen
Timeoutauto_dismiss_after reached zero (timer expired naturally)
ActionInvokedA ToastAction with closes_toast(true) (the default) fired
CloseClickedUser clicked the X
EscapePressedUser pressed Escape while focused into the toast
ProgrammaticToastHandle::dismiss() or ctx.dismiss_toast(handle)
HostShutdownWindow is being torn down
SlotPoolFullPool 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:

BackendUse 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, bumps unread_count + version.
  • Existing dedup_id → updates the matching entry's title/body in place, appends a NotificationUpdate to its updates, flips it back to unread, bumps unread_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_nameon_action_invoked setRenders as
PresentYesClickable Link / Button → fires the callback
PresentNoInert text tag
NoneanyInert 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:

BucketRange
TodaySame local-calendar date
Yesterdaytoday - 1 day
This week2..=6 days ago
Earlier7+ 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

ConcernHow it's handled
Role mappingStatus / Alert per the severity × priority table above.
Live regionLive::Polite / Live::Assertive per the same table; set_live_atomic() so title+body is one announcement.
Custom announcement textToast::announcement(text) overrides the AT name without changing the visible title.
Body descriptionToast::body(text)set_description(body) on the surface node.
Severity glyphset_hidden() — presentational, not in the AT tree (the surface itself carries the role + name).
Close buttonIconButton with its own Role::Button + tooltip / a11y label. Reachable by Tab once focus is inside the toast.
Action buttonsStandard Button / Link a11y.
EscapeDismisses with cause EscapePressed while focus is inside the toast.
Bell buttonIconButton with the localized "Notifications" tooltip. Badge widget contributes its own Role::Label with the count.
LogRole::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):

KeyDefault 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

ConcernFile
Toast request + ToastAction + ToastDismissCause + ToastHandlecrates/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) + ToastInstallOptionscrates/teksilo-widgets/src/toast/host.rs
RecipeToastStyle (default chrome)crates/teksilo-widgets/src/styles/recipe_toast_style.rs
ToastStyle trait + ToastPriority + ToastStyleConfigcrates/teksilo-core/src/styles/toast_style.rs
NotificationEntry + ArchivedAction + NotificationUpdatecrates/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 demoexamples/toast_demo/src/main.rs

The toast system landed alongside small teksilo-core extensions reusable by other overlay-shaped widgets:

  • teksilo_tokens::Corner (TopLeading / TopTrailing / BottomLeading / BottomTrailing) with RTL-aware resolve(content, viewport, margin, rtl).
  • OverlayPlacement::ViewportCorner { corner, margin } — anchor-independent corner-snapped overlay placement.
  • OverlayManager::pause_auto_dismiss(id) / resume_auto_dismiss(id) — stash auto_dismiss_after - elapsed and restore with a fresh shown_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 to OverlayManager after the dispatcher returns.
  • TeksiloAppBuilder::configured_app_paths() — read-side companion to the existing app_paths(paths) setter. Builder-extension traits use it to open persistent files at install time before run.
  • teksilo_core::styles::ToastStyle slot + ComponentStyleSlots::toast.

Limitations & explicit non-goals

  • Multi-window: a single shared ToastRegistry per 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::id is per-app (the registry is app-singleton), but the NotificationLog rebuild 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:

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:

methodeffect
.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, … */ });
}
methodeffect
push_item(into, entry)append an item to the submenu with id into
push_separator(into)append a separator
push_menu(title, |m| …) -> MenuItemIdadd a top-level menu
remove(id) -> boolremove 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 — version bump → 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 English lit! defaults; pass tr! 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

layertypecrate
id tokenMenuItemIdteksilo-core
rich modelMenuModel / MenuEntry / MenuItemStateteksilo-widgets
model → native bridgemenu::native::install (+ reactive observers)teksilo-widgets
boundary dataNativeMenuSnapshot / NativeMenuNode / MenuItemDeltateksilo-platform
trait + handleNativeMenuBackend / NativeMenuHandleteksilo-platform
macOS implNSMenu builder + TeksiloMenuTargetteksilo-platform/native_menu/macos.rs
app wiringinstall_native_menu, payload router, focus arbitrationteksilo-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":

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.

FeatureEngine(s) compiledinstall_web_view_default() installs
web-viewwryWryBackend (macOS / Windows / Linux-X11)
web-view-servo (implies web-view)wry + ServoServoBackend under a Wayland session, WryBackend everywhere else (runtime, via is_wayland)
web-view-headlessnoneNoopWebViewBackend (renders nothing)
  • wry by default. Enabling web-view gives a working webview with no extra flag. cargo run -p web-view-demo renders via wry.
  • Servo is additive, Wayland-only at runtime. web-view-servo implies web-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-headless is the no-engine escape hatch (mirrors file-dialog-trait): the widget + event routing, the inert no-op backend. Use for headless tests, or apps that install their own backend with install_web_view(custom_backend).
  • A true Servo-only target (Linux-only / no-GTK) bypasses the umbrella: depend on teksilo-webview directly with features = ["servo-backend"] and pass ServoBackend::new() to install_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:

  1. Init GTK — handled automatically; WryBackend::open calls gtk::init().
  2. Pump the GLib loop each turn — winit doesn't, so the page never paints otherwise. Call [teksilo_webview::pump_gtk_events] from TeksiloAppBuilder::on_loop_tick, holding the poll source high while a WebView is 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_events is a no-op off Linux / without the wry engine, so the call is portable.)
  3. Run under X11 — winit 0.30 picks Wayland whenever WAYLAND_DISPLAY is 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 (unset WAYLAND_DISPLAY, set GDK_BACKEND=x11), or build --features servo for the native Wayland engine. examples/web_view_demo does this automatically (see its force_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:

  1. Wire an EventLoopWaker to teksilo-app's winit proxy so Servo gets pumped.
  2. Call servo.spin_event_loop() + webview.paint() + rendering_context.present() from the render loop.
  3. 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 explicit WebViewBackend (a native engine, a custom backend, or MemoryWebViewBackend for 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 as on_message(|msg, ctx| …). (wry built-in; on Servo this is best-effort.)
  • Rust → JS: webview.post_message("…") dispatches a teksilo-message MessageEvent; the page listens with addEventListener('teksilo-message', e => …). e.data is 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 WebView is bound to the TeksiloWindowId it was mounted in.
  • WindowManager::close_window purges the window's WebViewRegistry registrations, so a late backend event can't fire into a torn-down tree.
  • Moving a WebView between 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 through WebViewAttributes — 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_html base_url is ignored on wry (no runtime load-HTML API; emulated via document.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 WebView is 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 a keyboard_capture surface 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:

  1. Intra-widget reordering — drag a row inside a list or a node inside a tree. No serialisation; the payload is a typed Rust value.
  2. 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.
  3. 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, Wayland wl_data_device and X11 XDND cfg-gated) — see §11 and the DropZone widget.
  4. 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_drag whose 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 via payload.get_typed::<T>() / take_typed::<T>() or probed with has_typed::<T>().
  • Zero or more MIME-annotated byte representations — added via with_mime(mime_type, bytes), queried via mime_types() / get_mime(mime). Populated for external (OS) drops (e.g. text/uri-list, text/plain) alongside the typed files() / 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:

CallEffect
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_cursor switches to CursorIcon::Grabbing at drag start and resets to Default on 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 receiving PointerMove / PointerUp even when the cursor leaves its bounds.
  • Escape cancels: the preview overlay is dismissed and on_drag_leave fires 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:

  1. Decide acceptance. Inspect payload via has_typed / mime_types. If this target doesn't want this payload, return DropFeedback::NoFeedback (and make sure your on_drop also rejects it — nothing enforces consistency).
  2. Provide visual feedback. Return DropFeedback::InsertionLine { y, width } (between-items insertion) or DropFeedback::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 its paint().
#![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 / TreeView ship this — see the on_drag_tick handler block in list_view.rs.
  • Spring-loaded folders. TreeView records which flat row the pointer sits over in on_drag_hover, together with the time it first saw it. on_drag_tick checks elapsed time against SPRING_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:

ScenarioTrigger
Pointer moved to a different targethandle_drag_move detects prev_target != drop_target
Drop completed on this or another targethandle_drag_drop, before on_drop runs
Drag cancelledEscape key, or EventContext::cancel_drag()
Source destroyed mid-dragrevalidate_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::NoFeedback does 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-row DropTarget still 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:

  1. Inserts the preview widget as a root via add_boxed — which runs build(), so composite preview widgets actually instantiate their child subtrees. (Plain arena.insert doesn'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.)
  2. Creates an overlay with OverlayLayer::InTree + OverlayPlacement::AtPointer(Point::ZERO).
  3. Marks the preview content needs_layout so the next layout pass runs position_overlays and 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 a Spacer inside 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_feedback signal (bound at BindingLevel::RepaintOnly) — set by on_drag_hover, cleared by on_drag_leave. Reading it in paint() is enough; the binding dirties the widget when the signal changes.
  • on_drag (per item wrapper) — fires only when the source's drag(key) returns CanDrag; emits the shared public RowDragData<T> { source: ViewId, rows: Vec<usize>, items: Option<Vec<T>> } typed payload (one type for all five data views) and a DragPreview built by re-invoking the delegate for the dragged row. rows is the selection-aware dragged set; items is Some only when the view opted into .exportable(..). The identity is a kind-tagged, process-global ViewId so 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 source can_accept, and sets the feedback signal to match the verdict (Accept → the affordance for the effective position, Reject → suppress, Redirect → snap). TreeView also records the hovered node + timestamp for spring-load. The row under a given y is resolved by the same PrefixSumOffsets::row_at a click uses, so the two agree even at a zero-height row boundary — see table-view.md "Which row a y coordinate resolves to" for the degenerate-height tie-break row_at applies.

The two affordances must not look alike

A tree answers two different questions during a drag, and TreeView / TreeTableView paint them differently on purpose:

VerdictAffordanceRecipe
Before / Afteran insertion line at the row boundary, indented to the depth the dropped row lands atListInsertionRecipe (role, thickness, indent_step)
Intoa rounded box round the target row, inset on every sideListDropIntoRecipe (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 Into box shares its top edge with a Before line and its bottom edge with an After line, 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 setting inset to zero re-creates exactly that.
  • The indent. Un-indented, an After line 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}) so paint can multiply it by the step; the hover handler reads it from the source's FlatEntry, 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). TreeView additionally 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-queries can_accept; if not Reject, routes the commit to the source's accept_drop. A same-view RowDragData is a DragSource::SameView the source applies (a ListModel reorders in place — one row via move_item, a multi-row block via move_items; a TreeModel-backed source move_nodes with the cycle guard); a cross-view or OS payload arrives as DragSource::Foreign { payload } at the same accept_drop, which downcasts it. The same-view reorder only runs when the view is reorderable.
  • on_key — Alt+ArrowUp / Alt+ArrowDown synthesize the same RowDragData and route it through accept_drop, so the keyboard contract travels the identical path.

10. Testing

Everything is headless. The key harness helpers (on WidgetTree):

HelperPurpose
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 / StackWidget with handlers attached directly, drive events with tree.dispatch_event(...), assert via Rc<Cell<u32>> counters and tree.active_drag. The on_drag_leave_* tests are the canonical examples.
  • Widget-level integration tests (list_view.rs, tree_view.rs tests modules) — build a real ListView/TreeView with a ListModel / TreeModel, run the full gesture chain via a drag_item helper, assert the model's observable state (with_item, root_count) and the feedback signal via the widget_as_any downcast.
  • Drag across a rebuilddrag_survives_rebuild_triggered_by_selection in list_view.rs pins the scenario where clicking a row triggers a selection-driven rebuild between PointerDown and the first PointerMove. The drag must still complete.
  • External handlers survive rebuildexternal_handlers_survive_rebuild in widget_builder.rs pins the handler-bucket invariant: closures attached via SomeWidget::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

PlatformInbound backendOutbound (export)Notes
macOSNSDraggingDestination on a transparent overlay NSViewNSDraggingSource on the same overlayFull position + files + text + URLs. Both directions verified.
WindowsOLE IDropTarget (RevokeDragDrop winit's, then RegisterDragDrop ours)OLE IDropSource + DoDragDrop (deferred off the dispatch that armed it)Inbound: full position + formats.
Waylandwl_data_device from the seatwl_data_source + start_dragNo winit conflict (winit leaves Wayland DnD unimplemented). Both directions verified.
X11XDND v5 via an XdndProxy helper windowXDND 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_dragWindowOps::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 regionsDropRegion::{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) (default true): a bound Signal<bool> disables the zone live, without a rebuild — it stops hit-testing (its strip falls through to the next-priority enabled zone, or Center, or rejects), never highlights, and never shows its hint.
  • region_at classifies the target-local pointer (§4.1) against the currently enabled zones — side zones are size_factor-thick strips tested in leading→trailing→top→bottom priority; a middle covered by no enabled zone resolves to Center when enabled, else the drop is rejected (the hover never engages there, and on_region_drop only 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 / Trailing map 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 same T and 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/TreeDataSource whose can_accept/accept_drop inspect the DragSource::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.

  • TreeTableView is not source-pluggable (it wraps a concrete SortFilterTreeModel<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, never OsMove/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 DragImageData is ignored on X11.
  • Non-XdndProxy X11 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.
  • Opacity primitive for previews. The current DragPreview uses 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-built DragPreview. Apps that need a differently-styled preview have to re-implement the full reorderable widget or wait for the builder API.

See also

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:

  • TeksiloAppBuilder has no .window_title, .window_size, .root, or .custom_chrome — every window is described by WindowConfig. One conceptual surface, no special-casing for the initial window.
  • root_builder receives (tree, WindowState) — the state clone is how a widget can bind against its own window's signals at construction time, without going through a BuildContext.

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:

VariantMeaning
FloatingRegular overlapping window — uses WindowState::size / position as current geometry
MaximizedFills the current monitor's work area
FullscreenExclusive fullscreen (Space-based on macOS)
MinimizedHidden 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

VariantMeaning
NativeOS-provided title bar, borders, resize handles. Default
CustomChromeNo 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)
NoneBorderless, 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:

  1. Checks the applying_from_os flag on WindowStateInner.
  2. If set (OS-initiated write): does nothing — the OS already knows.
  3. If unset (app-initiated write): pushes a WindowCommand onto the shared pending_os_commands queue.

Each event-loop tick:

  1. teksilo-app's handle_window_event_inner translates winit Resized / Moved / Focused events into calls like state.set_placement_from_os(new) / set_size_from_os(size). These flip applying_from_os to true before writing the signal, suppressing the observer's outbound echo.
  2. After event dispatch, WindowManager::drain_window_commands drains 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 use register_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_TOKEN should have it forwarded in the payload and applied via WindowState::set_activation_token before focus(), 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.
}
}

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 (winit CloseRequested);
  • 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 originGuarded?
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

  1. run() builds a TeksiloAppHandler and spins up the winit event loop.
  2. On resumed(), the handler calls WindowManager::create_window(initial_window_config, event_loop) — synchronous winit creation, widget tree built, first paint requested.
  3. On every winit::WindowEvent:
    • Event translation → WidgetEvent.
    • dispatch_in_window(winit_id, evt, event_loop) — temporarily removes the window from the map, constructs WindowOpsImpl with &mut WindowManager + &ActiveEventLoop, calls tree.dispatch_event_with_ops(evt, ops), reinserts the window.
    • Handlers can call ctx.open_window(...) which synchronously reaches wm.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.
  4. After dispatch, post_event:
    • Drains tree-level pending operations (locale, close-window).
    • Processes in-tree modal requests.
    • Drains every window's pending_os_commands and applies them via winit calls.
    • Drains pending_closes (from any source — ctx.close_window(), ctx.close_window_by_id(id), state.close(), close requests via TitleBarHostCallbacks::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.
  5. handle_redraw_requested runs layout_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 with NoopWindowOps
  • tree.layout(proposal) — wraps with NoopWindowOps
  • tree.render() — wraps with NoopWindowOps
  • tree.tick_gestures(now) — wraps with NoopWindowOps
  • tree.focus(id) / tree.focus_with_origin(id, origin) — wraps
  • tree.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

  1. Register a shortcut for F11.
  2. Register an Action that reads ctx.window().placement() and writes the opposite Floating / Fullscreen.
  3. 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

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.

LayerTypeCrateWhat it does
WidgetTitleBarteksilo-widgetsLays out the bar, dispatches gestures, renders controls
Host traitPlatformTitleBarHostteksilo-coreSeam between the widget and the OS
BackendsWaylandHost / MacOsHost / WindowsHost / X11Hostteksilo-platformConcrete per-OS implementations
Resize frameWindowFrameteksilo-widgetsOptional 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:

  1. WindowConfig::decorations(DecorationsMode::CustomChrome) — opts the window into custom chrome.
  2. WidgetTree::title_bar_host() — returns Option<Rc<dyn PlatformTitleBarHost>>. None means 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.rscargo 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 insethost.reserved_leading_inset(). Blank. Reserved so the OS can draw over it; on macOS this is where the traffic lights land. Windows/Wayland return Size::ZERO.
  • Leading slot.leading(widget). App icon, menu bar, title text.
  • Drag region — the center slot is wrapped in a flex DragRegion: unconsumed presses call host.begin_drag(), double-clicks toggle maximize via WindowState::placement, right-clicks call host.show_window_menu().
  • Trailing slot.trailing(widget). Search field, action buttons.
  • Window controls — min/max/close cluster. Rendered only when host.renders_custom_controls() is true (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.

CapabilityWaylandmacOSWindowsX11
custom_chrome supportedyesyesyesyes, if the WM implements _NET_WM_MOVERESIZE
reserved_leading_inset()ZERO~78×22 (traffic-light cluster)ZEROZERO
renders_custom_controls()truefalsetruetrue
needs_custom_resize_handles()truefalsefalse (OS handles via WM_NCHITTEST)true
begin_drag()winit drag_windowwinit drag_windowwinit drag_windowwinit drag_window (EWMH _NET_WM_MOVERESIZE)
begin_resize(edge)winit drag_resize_windowUnsupported (NSWindow handles edges)winit drag_resize_windowwinit drag_resize_window
has_window_menu()truetruetruefalse — no OS menu exists; TitleBar builds its own
show_window_menu(at)xdg-shell show_window_menuno-op (Ok(()))SendMessage(WM_SYSCOMMAND, SC_KEYMENU)Unsupported (never called)
update_hit_regions(&HitRegions)no-opno-opsnapshot stored for WM_NCHITTEST (logical→physical converted via GetDpiForWindow)no-op
Snap-layout flyout (Win11)n/an/ayes — proc returns HTMAXBUTTON for the maximize-button rectn/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::closeWindowControls 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>.

MethodPurpose
reserved_leading_inset() -> SizeBlank leading-edge area the OS draws over (macOS traffic lights); ZERO elsewhere
reserved_trailing_inset() -> SizeReserved trailing-edge area (always ZERO today)
renders_custom_controls() -> boolWhether the widget should draw min/max/close
needs_custom_resize_handles() -> boolWhether 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:

  1. Default — the button's on_tap calls EventContext::close_window, which queues a WindowCommand::Close on the window's WindowState. The app drains the queue on the next event-loop tick (winit 0.30 has no synchronous Window::request_close, so we hop through the command queue).
  2. OverrideTitleBar::close_action(|ctx| …) replaces the default on_tap entirely. Useful when the app wants to confirm unsaved work first, or to send an Intent for a root-level Action:
#![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.isZoomed tracks traffic-light zoom only. Native fullscreen (green light + Option, or -[NSWindow toggleFullScreen:]) puts the window on its own Space and leaves isZoomed false. 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 via WindowState::placement, and the reactive a11y name (Maximize / Restore via tr_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. When IsZoomed is true, restore the system SM_CXFRAME + SM_CXPADDEDBORDER insets and clamp to the monitor work area so the maximized window doesn't cover the taskbar.
  • WM_NCHITTEST — return HTLEFT / HTTOP / corner codes for the outer N pixels (so the OS handles the resize loop natively, with the right cursor and snap behavior), HTCAPTION for the widget's drag region, and HTMINBUTTON / HTMAXBUTTON / HTCLOSE for the control-button rects. Returning HTMAXBUTTON is what makes Win11 show the snap-layout flyout on hover. no_drag holes are tested before the button and drag rects and return HTCLIENT — 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_NCLBUTTONDOWN over a button hit code — return 0 to prevent DefSubclassProc from entering its built-in press-tracking modal loop, which would otherwise consume the matching WM_NCLBUTTONUP itself (user-visible symptom: the button appears to need a double-click).
  • WM_NCLBUTTONUP over a button hit code — post a TitleBarSyntheticEvent through AppEventProxy::send_external_boxed. The teksilo-app dispatcher resolves the matching WidgetId via host.title_bar_widget_id(target) and calls WidgetTree::synthesise_tap to run the button's on_tap handler. close_action overrides fire here.
  • WM_NCMOUSEMOVE / WM_NCMOUSELEAVE — post TitleBarHoverEvent for the same reason. The host writes the matching Signal<bool> (registered by WindowControls via host.register_hover_signal(...) at build time); an effect inside ControlButton maps the bool to its visual bg_signal, so OS-driven hover renders identically to widget-tree hover.
  • WM_DPICHANGED — re-call DwmExtendFrameIntoClientArea so rounded corners survive a DPI change (winit handles the resize but doesn't re-extend). Falls through to DefSubclassProc for the rest.
  • WM_NCPAINT / WM_NCACTIVATE — return early (0 and TRUE respectively) 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:

Core trait:

Backends:

App integration:

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:

ItemConstructorCollapses?Renders inline asRenders in the menu as
ActionToolbarItem::action(a) / .action(a)yes (by priority)a Buttona MenuItem (with the action's icon)
Pinned widgetToolbarItem::custom(w) / .child(w)nothe widget itself— (never collapses)
Collapsible widgetToolbarItem::custom(w).overflow_as(...) / .overflow_widget(...) / ToolbarItem::collapsible(w)yesthe widget itselfits declared overflow form
Separator / flexible spaceToolbarItem::separator() / flexible_space()noa 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't Clone and 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:

  1. If everything fits, nothing collapses and no chevron is shown.
  2. 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).
  3. always_overflow commands 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 / Enter navigation, skipping the rows that aren't currently collapsed;
  • hosts both ordinary menu rows (from overflow_as actions) and live embedded widgets (from overflow_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::Toolbar with 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, an IconButton) 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-FR shipped), so they translate and update reactively on a locale change.
  • The chevron announces HasPopup::Menu and 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):

  • Toolbarnew, item, action, child, add_child, orientation, display_mode, spacing, label, is_overflowing.
  • ToolbarActionnew, icon, tooltip, enabled, on_activate, toggle, priority, always_overflow.
  • ToolbarItemaction, custom, custom_id, collapsible, overflow_as, overflow_widget, separator, flexible_space.
  • ToolbarOverflowtoolbar_menu_form (implement on a widget for ToolbarItem::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 on teksilo-core only for Signal<T> and ObserverHandle (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:

TypeLayerData fromKeyReach for it when
TreeModel<T> (§4.1)sourceyou build it in memoryNodeIdthe tree lives in memory and you own it — the in-memory container
TreeSlice<T> (§4.2)projectionwraps a TreeModelNodeIdyou need per-view expand + flatten over a TreeModel (the built-in TreeView source)
SortFilterTreeModel<T> (§13)projectionwraps a TreeModelNodeId…and sort / tree-aware filter too; it owns its own expand state
TreeDataSlice<K, T> (§4.4)projectionVec<TreeRow> you supply (indent-ordered)your domain Kthe tree lives in an external store (Qleany / DB) as an outline — no TreeModel mirror
TreeRowFilter<K, T> (§4.5)pre-transformVec<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: selectionSelectionModel / KeyedSelectionModel<K> (§5); checkboxesTreeCheckedModel<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 TreeChange from 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 a TreeModel mirror can't guarantee because NodeIds are reassigned on rebuild;
  • the collapse-aware flatten into visible rows;
  • the version_signal() and first_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:

SetterPurpose
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. In Single mode, degrades to select.
  • 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:

SourceIdentityAnchors
TreeSlice, TreeDataSlice, SortFilterTreeModelreal (NodeId / domain key)track fully
SortFilterListModelthe row's source indextracks across any sort/filter reprojection
ListModel, accessor-backed sourcesnone — a Vec row is its positionfixed (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 a ListModel<ProjectVM>. The view-model observes the domain; when a domain entity changes, it updates the model, which emits DataChange.
  • The view is a ListView bound to the ListModel<ProjectVM> via a delegate closure. Item interaction fires typed intents (see shortcut-intent-action.md); an ancestor Action::on_invoke translates 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 / TreeView to 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 and Clone-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. The RefCell borrow stays internal.
  • NodeId is 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 of ListModel / TreeModel.
  • ListModel<T> in memory, ListDataSource for external; pick one per view — they are not composable.
  • Repeater for bounded non-scrollable collections, ListView for scrollable or large ones.
  • Two TreeViews sharing a TreeModel get independent TreeSlices; expand state is per-view.
  • Mutations flow one-way: widget emits typed intent → Action translates → model mutates → change notification → widgets repaint. The widget never writes directly to the model.
  • ChartModel<T> (§15) follows the same Rc<RefCell<…>> / mutate-then-notify discipline as every other model here, keyed by SeriesId — a slotmap key that survives series reorder, exactly like NodeId survives 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) — rows 0..d show 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); a NodeUpdated with 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 the T: PartialEq bound). Unlike TreeSlice it has no NodeUpdated event — 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 key K is 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. An ItemUpdated that 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:

  1. Emits a ChartChange describing exactly what changed — SeriesInserted / SeriesRemoved / SeriesMoved / SeriesRenamed / SeriesColorChanged / SeriesPatternChanged / SeriesVisibilityChanged / PointsInserted / PointsRemoved / PointUpdated / SeriesDataReplaced / Reset — to every observer registered via model.observe_changes(|change| …) -> ObserverHandle (RAII, same as every other model's observer handle).
  2. 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), or style_version() for the two variants that are paint-only — SeriesColorChanged (from set_series_color / clear_series_color) and SeriesPatternChanged (from set_series_pattern / clear_series_pattern). This binary split is deliberately coarse: a consumer that only cares "did anything change" can bind either signal at Rebuild; a chart widget that wants to skip a relayout for a pure color change binds structure_version at Relayout and style_version at RepaintOnly separately (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 the RefCell borrow 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 ChartAggregateFnMean / 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

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:

TraitShapeBuilt-in impls
ListDataSourceflat listListModel<T>, SortFilterListModel<T>
TreeDataSourceper-view flattened treeTreeSlice<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:

  1. Start. A row begins a drag only if drag(key) == CanDrag. The view emits a RowDrag { source_index, source_view_id } typed payload (shared by all four views via data_views).
  2. Hover. The view computes the geometric (target_key, position) and calls can_accept. Accept paints the insertion line / reparent box; Reject paints the no-drop affordance and will refuse; Redirect(pos) snaps the indicator to pos (e.g. a container that takes children but not sibling reorder redirects Before/AfterInto). This is the pre-drop validation the integrated reorder never had.
  3. Drop. The view re-queries can_accept; if not Reject, it calls accept_drop(commit). A store-backed source mutates its Vec/arena; an external source routes to its command (a Qleany move_node, an SQL UPDATE).
  4. 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 same can_accept / accept_drop path covers intra-view reorder, list→list transfer, palette→outline drop, and OS file drops — one protocol, not a bolt- on.
  5. Source-side completion. After a Foreign drop is accepted elsewhere, the framework calls on_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_acceptaccept_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_state is Loading outside the resident window; request_window slides the window.
  • Append (total unknown — infinite scroll): can_fetch_more / fetch_more grow 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

TypeTraitKeyDnDLazy
ListModel<T>ListDataSourceusizeaccept_drop = move_item, can_accept = Acceptresident
SortFilterListModel<T>ListDataSourceusizeinert (sorted view)resident
TreeSlice<T>TreeDataSourceNodeIdaccept_drop = move_node w/ cycle guardresident
SortFilterTreeModel<T>TreeDataSourceNodeIdas TreeSliceresident

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

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:

ShapeTypeUse for
Dynamic K/VSettingsStoreSignal<T>Scalar prefs (font size, theme name, bools, arrays of scalars)
Typed fileSettingsFile<T>App-shaped structs with their own schema + migrations
Reactive collectionPersistedListModel<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:

ServiceBacked byScope
MruList<T: MruEntry>PersistedListModel<T>Generic dedupe + pin + cap recents over an app-defined item type
WindowStateServiceSettingsFile<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_paths or application must be set and settings must be configured before run() / build_headless() is called — the bundle has nowhere to write without a directory, and the runtime panics if settings(...) 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 own MruList<T>, SettingsFile<T>, etc. via .app_state(handle).
  • Auto-save / auto-restore of window geometry is enabled by .id("main") on WindowConfig plus .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 poll Reloadable::reload_from_disk themselves.

Why three shapes, not one

The clean shapes lose information when you collapse them.

  • SettingsStore is 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 through SettingsFile<T>.
  • SettingsFile<T> owns one struct per file with its own schema and Versioned impl. The migration story (raw toml::Value transformations registered as from → from + 1 steps) 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 reactive ListModel<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 (Repeater does the right thing on inserts/removes/reorderings, and never sees a DataChange::Reset from 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:

  • SettingsStore builds 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 small ListOp<T> (UpsertFront / UpdateInPlace / Remove / Clear, keyed by Keyed::key — see "MruList<T: MruEntry>" below) — never a re-derived Vec<T> — so a peer's concurrent insert or removal survives.
  • SettingsFile<T>::mutate/replace apply 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 unitdotted key / ListOp<T>whole struct, via caller's closure
Timingdebounced (default 500 ms), shared I/O threadsynchronous, calling thread
Retry on failurequeue retained, replayed next tickerror propagates immediately
Right forfrequent 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, calling signal::<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. Use SettingsFile<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 explicit Signal::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:

  1. Its WindowConfig carries an id(...) (a stable string label), and
  2. A WindowStateService is 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, Fullscreen round-trip exactly. Minimized is downgraded to Floating on 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=100 on a now-disconnected secondary monitor recenters its x to the primary's middle while keeping y=100 (it was always on-screen vertically). A window with x=-2000, y=-2000 recenters 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() returns Err(NotSupportedError).
  • The position observer in window_persist.rs almost never fires on Wayland because the compositor doesn't notify apps of their position.
  • WindowState.position keeps 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:

  1. 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_disk returns Ok(false) immediately — no read, no parse, nothing touched. This is the common case for a self-write notification.
  2. 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_pinset_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

TaskRecipe
Add a new scalar prefDeclare a const KEY: SettingsKey<T> = SettingsKey::new(...), call ctx.settings().signal_for(&KEY) from build(), bind with .text(...) / .color(...) etc.
Persist a structDefine struct Foo { version: u32, ... }, impl Versioned for Foo, open with SettingsFile::load(path, Migrator::new()), register via app_state(handle.clone()).
Persist a listDefine 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 migrationBump CURRENT_VERSION, register a Migrator::new().step(1, ...) transformation, plumb the migrator into SettingsFile::load.
Force a flush before a child processopened.flush_all() (or per-service flush_now()).
Test settings code without touching ~/.configAppPaths::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 reloadctx.app_state::<SettingsRegistry>().register(Rc::new(my_handle.clone()) as Rc<dyn Reloadable>), keep the returned Rc alive.

Reference

Out of scope — intentional

  • Encryption. Plaintext TOML. Secrets go through a future teksilo-secrets crate 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_all is 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:

Modeinstall_idDefault scopes availableWhat the user can do
AnonymousNoneanonymous_metrics_only()Withdraw consent
PseudonymousUUID, 13-month rotationConsentScope::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:

ShapeTypeUse for
Consent stateConsentStoreSignal<ConsentState>The user's grant/deny/scope decision, atop SettingsFile<ConsentFile>
Pseudonymous identityInstallIdPer-install UUID with 13-month rotation, atop SettingsFile<InstallIdFile>
Event queueInMemoryEventQueue / PersistentEventQueueOutbound buffering with retry; redb-backed for cross-restart durability

Two reference adapters ship in tree:

AdapterCrateMode(s)Backend
StubReporterteksilo-telemetryAnonymous + PseudonymousIn-memory Vec (testing only)
PlausibleAdapterteksilo-analytics-plausibleAnonymousPlausible Cloud or self-hosted
TeksiloAdapterteksilo-analytics-nativeAnonymous + PseudonymousSelf-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():

  1. The TelemetryBundle opens — ConsentStore, InstallId (when in pseudonymous mode), the recent-log ring buffer, and the DynamicReporter are constructed.
  2. The resulting OpenedTelemetry is registered into the app_state registry — accessible from any widget via TelemetryExt.
  3. The dispatch tap in teksilo-core starts forwarding every dispatched intent through DynamicReporter::record, but the consent gate silently drops everything until the user grants — the app stays functional in the Unknown state, 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_version bumps 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

TypeUse caseBackend
InMemoryEventQueueTests, the recent-log ring buffer, simple deploymentsMutex<VecDeque<OwnedEvent>>
PersistentEventQueueAdapter outbound buffering across process restartsredb (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() returns ConsentScope::all().
  • Every batch is tagged mode = Pseudonymous.
  • fetch_remote_data() calls Telemetry.Fetch and rebuilds a RemoteDataExport.
  • erase_remote_data() calls Telemetry.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.

BehaviorPlausibleOTLPTeksilo
Send unitPer event (one HTTP POST per event)Per batch (one OTLP request per drain)Per batch (one gRPC call per drain)
First failure inside a drainRe-enqueue failed event at the tail, mark hit_retry, re-enqueue remaining events without tryingPush the whole batch back to the front of the buffer in reverse orderRe-enqueue every event, reset channel = None to force re-dial
Subsequent events in the same drainSkipped — re-enqueued unsentn/a (batched)n/a (batched)
Order preservation across retriesFIFO only among events that succeeded; failed events drift to the tailStrict — failed batch retries before any newer events drainStrict — failed batch is the first thing the next attempt sends
Effect on flush latency for poison eventsBounded — newer events still ship; bad event keeps cycling at the tailHead-of-line blocking — buffer stuck behind the bad batchHead-of-line blocking — drain breaks, retried on next opportunity
Backoff resetReset on first AcceptedReset on AcceptedReset 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:

ConcernWhere setMechanism
Adapter type / wire format / API tokenBuild-time in the binaryAdapter builder calls in main.rs
Default mode, retention policy, processor nameApp-builder timeTelemetryBundle::with_*
User's per-scope togglesRuntime, per-userConsentStore + per-scope SettingsKey<bool> mirror
User's endpoint overrideRuntime, per-deploymentscopes::TELEMETRY_ENDPOINT_OVERRIDE — apps feed this into adapter builder via .endpoint_override(...)
Active modeRuntimeDynamicReporter::active, mutated by the widget
Install IDRuntime, automaticSettingsFile<InstallIdFile>, 13-month rotation
Consent decisionRuntime, persistentSettingsFile<ConsentFile>
Pending eventsRuntime, persistent (Plausible + Teksilo adapters)redb at AppPaths::data_dir().join("<adapter>-queue.redb")
Pending eventsRuntime, 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:

ArticleWhat Teksilo does
Art. 6(1)(a) consentConsentStore. 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 withdrawWithdraw button in the widget, equal prominence to Accept.
Art. 13 transparencyPlain-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 portabilityRemoteDataExport 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

FilePurpose
crates/teksilo-core/src/telemetry/event.rsEvent, OwnedEvent, Prop, RemoteDataExport, serde derives
crates/teksilo-core/src/telemetry/reporter.rsUsageReporter trait, TelemetryError
crates/teksilo-telemetry/src/bundle.rsTelemetryBundle, OpenedTelemetry, PrivacyPolicy
crates/teksilo-telemetry/src/consent.rsConsentStore, ConsentFile, settings-mirror integration
crates/teksilo-telemetry/src/install_id.rsInstallId with 13-month rotation
crates/teksilo-telemetry/src/dynamic_reporter.rsDynamicReporter, 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.rsSettingsKey<bool> constants for per-scope mirror, TELEMETRY_ENDPOINT_OVERRIDE, TELEMETRY_REGION_OVERRIDE
crates/teksilo-telemetry/src/ext.rsTelemetryExt accessors on BuildContext / EventContext
crates/teksilo-widgets/src/privacy_settings.rsThe widget
crates/teksilo-widgets/locales/en-US.ftli18n 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 + the PrivacySettings widget. Default endpoint http://127.0.0.1:8000/api/event; override via PLAUSIBLE_ENDPOINT env var.

  • examples/telemetry_teksilo/ — anonymous OR pseudonymous against a self-hosted teksilo-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 sibling teksilo-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 UIThe 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 resultspawn_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

CrateAddsDepends on
teksilo-asyncthe executor, spawn_local / spawn_local_with, spawn_blocking, install_async()teksilo-app, teksilo-core (+ async-channel, thiserror)
teksilo-tokioinstall_async_tokio() + TokioHandle; awaits native Tokio futuresteksilo-async + tokio
teksilo-async-stdinstall_async_async_std(); awaits native async-std futuresteksilo-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-tokio owns a multi-thread tokio::runtime::Runtime (reactor + timer driver on background threads). install_async_tokio() wraps each tick in runtime.enter(), so a Tokio leaf future polled on the UI thread registers with that background driver and registers the executor's Waker as 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() for Send tasks and .handle().
  • teksilo-async-std needs no per-tick guard — async-std's reactor is global and auto-starting, so install_async_async_std() is just install_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_eventSignal::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_local futures cannot hold an EventContext across .await; ambient ops post-await go through spawn_local_with's completion callback or a Signal an Action watches.
  • A spawn_blocking closure panic is caught and returned as BlockingError::Panicked. A panic in a spawn_local body (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 tokio and async-std in one binary pulls both runtimes (rarely desirable).
  • Task progress repaints all open windows (not just the one whose Signal changed), matching the subscribe_event data path. Negligible for single- window apps; a per-window targeted repaint would need framework-level dirty tracking.

Code reference

ConcernFile
Executor, AsyncRuntimeHandle, TaskHandle, cross-thread wakercrates/teksilo-async/src/executor.rs
spawn_blockingcrates/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 purgecrates/teksilo-app/src/app.rs, window_manager.rs
Tokio adaptercrates/teksilo-tokio/src/lib.rs
async-std adaptercrates/teksilo-async-std/src/lib.rs
Demoexamples/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.

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, Qt setStretchFactor): positive container slack is distributed to stretch > 0 panes proportional to weight; stretch = 0 panes 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. max clamps 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 than available, not ≤ available.
  • Equal-size panes: SplitterModel::new(n, orientation) (each stretch = 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:

  • Programmaticmodel.set_collapsed(i, bool) / toggle_collapsed(i) (animated). Ignores the collapsible flag (that flag only gates user interaction, like Qt childrenCollapsible).
  • Double-click a divider — toggles the adjacent collapsible pane.
  • Drag-past-min snap — drag a pane below min − snap_offset to 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:

PaneIts gutter/handleContentReactive (no rebuild)?
Collapsesize → 0, animatedstays (grab it to restore)dormantyes
Hidesize → 0, animatedremoved — reads as absentdormantyes (pane pre-mounted)
Add / remove (new content)created / destroyedcreated / destroyedbrand-newno — 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.

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, bind model.rail_size_mode_signal(side) inside the factory and map it to an IconButton::size; the rail rebuilds its slots whenever the size mode changes, so reading the signal keeps the slot in step (the factory stays a plain Fn() -> 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 of top_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:

DockActionPlacementPosition
Startbefore the first activity item, flowing with them
Endafter the last activity item and after the overflow trigger, still flowing
Pinnedpast 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 with disable_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 external set_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 DockActivityBar always shows) → the list + size submenu; in Strip presentation, the tab bar keeps a trailing hamburger () that opens the same menu. The menu is placed with BelowPreferred, 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, the DockActivityBar background, 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:

  • RailDefault / Compact show the icon alone (the title is a hover tooltip); Labeled adds 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 DockTabDisplay maps straight onto the TabWidget's TabDisplayMode: Icon shows the icon alone (title → tooltip) and the tab sizes to its icon, Text the title, IconText both (the tab grows to fit the icon). An icon-less dock in Icon mode 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 actionsDockWidget::header_actions(|id| …) declares a flat Vec<ToolbarAction> ("New File", "Collapse All", refresh, filter …) shown inline in the header. The framework hosts them in a compact Toolbar and lays it out along the header's axis, so you never pick HStack vs VStack: a horizontal row on leading / trailing sides, a vertical column on the rotated top / bottom strip. Because it is a Toolbar, excess actions collapse into a trailing overflow menu when the header is tight (lowest priority first) 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-forwarding DeadZone (see below). Each ToolbarAction takes 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) activityMove to new activity (promote_to_tab — pull this dock out into its own tab) and Move to side ▸ (move just this dock to another enabled side as a new activity).
    • a sole-pane dock (it is its activity) → Hide and Move 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.

Where the header appears:

  • Split panes always have an Accordion header, so the actions + show there automatically (in the header's trailing slot, before the chevron — the new Accordion::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:

  1. an explicit title set with DockingModel::set_tab_title(tab_id, …) (or the sugar set_dock_activity_title(dock_id, …) — apps hold stable dock ids), else
  2. 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
  3. 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_dragdrag rail items / tab headers to reorder or move activities, nor use the context-menu Move to.
allow_dock_dragdrag a single dock out of a split pane (its accordion header stops being a drag handle).
allow_side_collapsehide/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_hidehide 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 region Role::Complementary with 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 presentational Role::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 a tablist to tab children, so a slot or a command button nested there would be an invalid owned element.
  • Rail actions: one Role::Toolbar per placement (Role::Button children, with toggled when 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 handles Role::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_version split 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 — see docs/docking.md for the resulting user-facing API and the CHANGELOG entry for the summary. This document is kept for the reasoning: why actions are not DockTabs (§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_menu keyboard trap) and Phase 7 (the horizontal rail) are not.

DockActivityBar Slots + Dockless Actions — Design (Revision 2)

1. What already exists (correcting the mental model)

  • DockRail already has top_slot()/bottom_slot()Rc<dyn Fn() -> Box<dyn Widget>> factories, rebuilt every rail rebuild, placed above the items / after a trailing Spacer. docking/activity_bar.rs:127-128,149-150,187-203
  • TabWidget already has bar_leading_slot()/bar_trailing_slot() — a different mechanism: a memoize-once BarSlot taking a built impl Widget + 'static, not a factory. tab_widget.rs:911-935
  • docking/panel.rs already consumes bar_trailing_slot internally, 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. DockSidePanel never sets .orientation(...), so the Strip bar is horizontal for every DockSide including 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
  • DockActivityBar is vertical-only, end to end — item column is a VStack, overflow capacity is computed from bounds.height, rail_insertion is y-only, the drop indicator draws a horizontal line, a11y hardcodes Orientation::Vertical, Labeled mode uses a 90°-rotated label, tooltips are hardcoded TooltipPlacement::Side with an explicit comment explaining why a Below tooltip 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
  • DockRail is per-view builder config on DockingLayout, not on DockingModel. It's declared fresh per DockingLayout::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 call model.import_state(dto) afterward with all ids already known. docking.rs:79-128,156-164,159-164
  • DockingModel::register_meta — the actual metadata-registration mutator — is pub(crate). App code never calls a model-level register_* method directly; it only reaches metadata through the DockingLayout builder. model.rs:527-531
  • Teksilo already has a live, unfixed ARIA "required owned elements" violation. DockActivityBar::accessibility() sets Role::TabList on the rail's whole root; top_slot/bottom_slot widgets and the overflow-trigger IconButton are ordinary children of that same VStack, so they are already non-Role::Tab descendants of a role=tablist today, before any of this design ships. activity_bar.rs:394-433,601-611
  • Role::GenericContainer is already this codebase's idiom for a presentational, unnamed grouping wrapper — used by DockingLayout's own root, menu_bar.rs, and splitter.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 is TabWidget::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, ProjectSwitcherButton are hand-built in the window's TitleBar (shell/windows.rs:612-660), specifically because they are window-global, not tied to any DockSide — none of them has a coherent side to attach to.

2. The real gaps, ranked

  1. No app-facing Strip-side slot. TabWidget::bar_leading_slot/bar_trailing_slot exist 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.
  2. 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.
  3. A pre-existing ARIA violation (top_slot/bottom_slot/the overflow trigger as non-tab children of role=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.
  4. 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).
  5. 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 DockRailSlot to report a measured extent, which is its own small design.
  6. 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 saveState parity)."

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 the DockRailItems. 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. Carries Role::TabList + rail_label(side) + Orientation::Vertical.
  • DockRailActionGroup — one per (side, placement) with ≥1 action; omitted from the VStack entirely when empty (never an empty Role::Toolbar). Carries Role::Toolbar + rail_actions_label(side, placement) + Orientation::Vertical, and a local roving: Signal<usize> mirroring Toolbar's pattern — not DockRailItem's model-level selected: Signal<usize>, since an action group has no "currently selected" concept.
  • DockActivityBar's root drops Role::TabList for Role::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 — no set_name, no set_orientation — or accessibility_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 own plain_button_is_a_leaf_no_group_node test 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 as DockRailItem does — never IconButton::tooltip's default path, which opens Below and would drop the tooltip onto the next stacked item.
  • Labeled mode: read side_rail_size(side).shows_label() and mirror DockRailItem'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. (An IconButton in bottom_slot does not get this for free — the app must bind rail_size_mode_signal by hand. That asymmetry is the main reason §4.8 recommends DockAction over 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
a11ynon-tab child of Role::TabList — the live violation from §1inside the sibling Role::Toolbar — correct
Roving focusnone; a lone sequential Tab stopjoins the toolbar's Arrow/Home/End cycle
Glyph sizingfixed dp unless the app hand-binds rail_size_mode_signaltracks Compact/Default/Labeled automatically
Labeled modeno caption — looks broken next to captioned tabsgets 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.settings is already registered via register_action_global with 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 DockWidget to hang it on. It is precisely the case DockRail::action exists for. Wire the rail half as a new unit AppIntent variant (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 a BinderItem's (role, sub_role) drawn from skribisto_model::COMBINATIONS, and the per-tab payload is a ContentTab over a shared Rc<OpenDoc> — machinery built end to end around a real, uid-bearing BinderItem. Analysis is not a BinderItem and has no (role, sub_role) to key on, so there is no arm to add.

    Hosting a non-document tab in EditorsViewModel is 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 by BinderItem.uid, which is also what workspace.toml v2 persists); whether it survives a session restore or is deliberately transient; and whether EditorsViewModel's save/dirty path must learn that some tabs own no document. Scope that separately before starting — the feat/analysis-tab branch is where it belongs, and DockRail::action will be waiting for it.

4.9 What Revision 1 had that is now deleted

Recorded so the reasoning isn't re-derived later:

DeletedWhy
DockAction.hidable, not_hidable()actions are never hidable
DockPolicy.allow_action_hidenothing left to gate — and this un-breaks the API
set_action_hidden / is_action_hidden / action_hidden_signalno user-mutable state
action_context_menuits only item was "Hide"
background_menu's action checklist sectionnothing to restore
DockActionState, DockSideState.actionsnothing to persist — state.rs untouched
DockingLayout::action() + register_action_metaactions are DockRail config (§4.3)
the registration-vs-import_state ordering contractno import step exists
focus re-homing on hideactions never vanish
the toggled-vs-hidden visual ambiguityonly 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-206 puts DockSide::Bottom in Rail presentation at 36 dp, Compact.
  • project_shell.rs:311 then calls set_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:63 toggle command and the project_menus.rs:387 menu item bound to side_visible_signal(DockSide::Bottom). That menu item is the "external button" geometry.rs tells 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.rs is already fully orientation-generic (branches on is_horizontal_axis) — no work there.
  • Arrow-key nav in activity_bar.rs already 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).
  • RotatedLabel should 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::Side must become Below/Aboveteksilo-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::Strip redundant for those sides, or whether they stay complementary.

6. Interaction & policy matrix

Entry kindMovableHidableOwn Tab stopOverflowableDrop targetPersisted
DockTab (activity item)yes, if allow_activity_dragyes, if allow_activity_hideyes — roving, in DockRailTabListyes (DockOverflowMenu)yesyes (DockTabState)
top_slot/bottom_slot (Rail)no — no drag code path existsno — no hide code path existswhatever the widget is; sequential, not rovingno — reserved, approximate chargenono — app-declared each run
leading_slot/trailing_slot (Strip)nonosequential, not rovingno — reservednono — app-declared each run
DockAction (Start/End/Pinned)no — no drag code path existsno — by decisionyes — roving, in its own Role::Toolbarno — reserved, exact chargenono — 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

  1. 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.
  2. 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.
  3. 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.
  4. 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 DockAction acquire draggable/reorderable behaviour.
  5. 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.
  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.

#QuestionDecision
R1-1Should DockAction be hidable?No. Never hidable — collapses §4 (see §4.9) and un-breaks the API
R1-2toggled vs hidden visual disambiguationVoidhidden no longer exists
R1-3Real near-term consumer?Yes — "Analysis" (opens a centre editor tab) and Settings (§4.8)
R1-4The narrow Top/Bottom reopen fixRetracted as unsound by §5.1 after the accepting decision — do not build
R1-5DockActionId::named() stable-hash ctorYes, const fn, FNV-1a — justified by automation addressing, not persistence (§4.4)
R2-1Two placements or three?ThreeStart, End, Pinned ship together
R2-2Render 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 in TabPresentation::Strip renders no actions — mirror them with trailing_slot if the side may flip presentation at runtime."
  • docs/docking.md's Activity rail section, beside the existing top_slot/bottom_slot prose.
  • 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 callback fn(visible_index) -> f32 seeds 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_y is 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.

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 SortFilterListModel notifies with a blanket Reset;
  • expanding/collapsing a TreeTableView node 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:

FieldMeaning
row_indexvisible row (post sort/filter)
col_idthe column's stable id
col_indexdisplay position (0-based, post pin + reorder)
is_selectedtrue when this row (or cell, in cell-mode) is selected
is_focusedtrue when this cell carries the keyboard focus
is_editingtrue when editing_cell_signal == Some((row, col_index))
depthSome(level) in TreeTableView, None in TableView
is_tree_columntrue 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.).

SignalTypeMutated byPersistence key
sort_signalSignal<Option<(String, SortDirection)>>header click cycle, set_sort, clear_sorttable.sort
filters_signalSignal<HashMap<String, String>>filter popover, set_filter, clear_filterstable.filters
column_widths_signalSignal<HashMap<String, f32>>header drag-resize, set_column_widthtable.widths
column_order_signalSignal<Vec<String>>header drag-reorder, set_column_ordertable.order
column_pinning_signalSignal<HashMap<String, PinnedSide>>drag across pane boundary, set_column_pinningtable.pinning
focused_cell_signalSignal<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::Reset once per upstream change (one rebuild, not two),
  • forwards row-level inserts/removes to the table's SelectionModel via the observe_changes chain, so MultiRow selection survives data mutations.

For trees, SortFilterTreeModel<T> plays the same role, plus a TreeFilterMode switch:

ModeBehaviour
HideNonMatchingrows that don't match are hidden, taking their entire subtree with them
KeepAncestorsa match keeps every ancestor visible (file-tree convention; the default)
KeepDescendantsa 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::Accent when the column has an active filter and TextRole::Secondary otherwise.

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:

ModeBacking modelNotes
Noneclicks just move focus
SingleRowteksilo_data::SelectionModelreplaces; modifier keys ignored
MultiRow (default)teksilo_data::SelectionModel with SelectionMode::MultiCtrl-click toggles, Shift-click extends, Shift+Arrow extends
SingleCellCellSelectionModelExcel-style; one (row,col) at a time
MultiCellCellSelectionModelrectangular 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:

VariantF2TypeDouble-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

KeyEffect
Arrow keysmove focused cell within the visible grid
Home / Endjump to first / last column of the current row
Ctrl-Home / Ctrl-Endjump to first / last cell
PgUp / PgDnscroll one page; focus moves the same number of rows
Tab / Shift+Tabnext / previous cell in row order, wrapping rows (configurable via tab_traversal)
Shift + Arrowextend selection in MultiRow / MultiCell modes
Spacetoggle selection at focus
Enterinvoke on_row_activate (or fall back to toggle-select)
Ctrl-Aselect all rows / cells in multi modes
F2 / typingbegin edit (gated by EditTrigger)
Escapeend edit if any, else clear focus
ArrowLeft on tree columncollapse the row when expanded (TreeTableView)
ArrowRight on tree columnexpand 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_x is 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_width keeps 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 guardtree_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

  • TableView root: Role::Table with row_count (header inclusive when shown) + column_count.
  • TreeTableView root: Role::TreeGrid, same counts.
  • Each header cell: Role::ColumnHeader with column_index and, on the active sort column, sort_direction.
  • Each body row: Role::Row with row_index (1-based; header is row 1, first body row is row 2). On TreeTableView, the row also carries level (1-based depth) and expanded for non-leaf rows.
  • Each body cell: Role::Cell with row_index and column_index, plus selected reflecting the current selection.
  • The filter popover's trigger inherits the popover's set_expanded state 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

SurfaceRole
outer frame borderBorderRole::Default
header backgroundSurfaceRole::Raised
header bottom dividerBorderRole::DividerStrong
body even-row bgSurfaceRole::Content
body odd-row bgSurfaceRole::AltRow
row selected bgSurfaceRole::Selected
cell focus ringBorderRole::Focused
grid linesBorderRole::Divider
sort indicator (active)TextRole::Accent
filter glyph (inactive)TextRole::Secondary
filter glyph (active)TextRole::Accent
TreeTableView connector linesBorderRole::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_span for screen readers; the layout doesn't merge),
  • formula evaluation / computed cells,
  • multi-row column-group headers,
  • footer / summary rows (compose a StatusBar below 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 flat TableView with SortFilterListModel, MultiRow selection, alternating rows, and filterable name/email/role columns.
  • cargo run -p tree-table-view — mock filesystem TreeTableView with KeepAncestors filtering, 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:

BuilderEffect
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_policyAuto (default) / AlwaysOn / AlwaysOff.
min_lines / max_linesSwitch from greedy to intrinsic sizing — grow with content up to max_lines, then scroll (the composer pattern).
font_family / zoom / follow_text_scaleTypography. follow_text_scale (default on) grows text with the global accessibility scale.
background / text_color / caret_color / selection_colorColor, 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:

BuilderEffect
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_tabsSpaces 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_matchingDelimiter 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)>> (via handle.bracket_match()) and washes both cells behind the text.
  • Multiple caretsCtrl+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:

motionWindows / LinuxmacOS
character
wordCtrl+←/→⌥←/→
line edgeHome End⌘←/→, Home End
document edgeCtrl+Home/End⌘↑/↓, ⌘Home/End
delete wordCtrl+⌫ 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.

  • CompletionContext carries the prefix, line, column, and document position.
  • CompletionItem is new(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::List
    • active_descendant) is on the editor node.
  • auto_complete(false) restricts opening to Ctrl+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_starts are character indices stored as u8, 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):

LinesFull relayout (per line)Windowed append (per line)speedup
1 00010.9 ms~10 µs1 046×
10 000113 ms~10 µs11 655×
100 0001.167 s~10 µs117 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:

LinesFully residentWindowed (viewport only)
1 00010.8 MB3.7 MB
10 00068.1 MB3.7 MB
100 000622.9 MB3.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-typesetDocumentFlow::{layout_window, set_uniform_extent, add_block, remove_leading, block_params_for}.
  • teksilo-textRichTextEngine::{layout_window, layout_window_from_snapshots, set_uniform_extent, append_block, remove_leading, block_visual_info}.
  • text-documentTextDocument::{append_line, append_lines, truncate_front} (undoable-false, all-or-nothing), whose events now also reach on_change subscribers, 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. A NonZeroU64 wrapper. Allocate fresh ids with TabId::fresh() (a monotonic counter), or wrap an external key with TabId::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 are LocalizedString (accept tr!(...)); the icon is a factory closure (no IconWidget: Clone requirement) called each build, so it picks up theme/state changes naturally.
  • TabHandle — the thing that lives in the data source. Carries id, info, a kind discriminator, and an Rc<dyn Any> payload. Heavy state (the document, the image, the page) lives on payloadnot 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.

BuilderContent shapeNotes
static_tab(info, content)impl Widget + 'staticOne-shot ownership; consumed on first build.
static_tab_factory(info, fn(&TabHandle) -> Box)factory closureCalled once on first build.
static_tab_id(info, WidgetId)pre-registered WidgetIdFor the teksu! DSL — wraps the id in an alias on first build.
static_tab_with_id(id, info, content)impl Widget + 'static + caller-chosen idUse when external code (deep links, session restore) flips selection by id.
static_tab_factory_with_id(id, info, factory)factory closure + caller-chosen idFactory 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 (Tab role + controls() relation)

TabWidget adds:

  • the Switcher of 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 sets selected_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"DefaultMeaning
HorizontalwidthSharedUniform tab widths (Firefox / Chrome convention).
VerticalheightSharedUniform 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_width knobs:

    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 past max). If target * 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. The min_tab_width / max_tab_width knobs 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:

  • Icon blanks the visible label so the header sizes to its icon (Independent sizing) 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.
  • Text drops the icon; IconText keeps both (and so does Auto, which is the identity transform — they differ only in intent).
  • The content TabPanel keeps 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 small min_tab_width(..) (as DockingLayout does) 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_id distinguishes an intra-bar reorder (matches the bar's own id) from a cross-bar transfer (see below); being generic over T means a TabBar<T> only ever downcasts a drag started by a peer TabBar<T>, so unrelated drags never match. item carries a clone of the dragged item for cross-bar transfer (None for reorder-only / non-transferable tabs).
  • Insertion math. on_drag_hover computes 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 shared Cell<Option<f32>> that the bar's paint() 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_tick ramps scroll velocity inside a 32 dp edge zone, capped at 12 dp/frame — same constants as ListView.
  • Pinned/unpinned model index translation. Insertion is computed in unpinned-view space; the bar maintains an unpinned_to_model map and converts before applying the post-removal -1 adjustment (from < to_model → to_model - 1) and calling on_reorder(from_model, adjusted_to).
  • Cross-pane drops (drop a non-pinned tab into the pinned strip, or vice versa) fire on_pin_toggle instead of on_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 an Rc).
  • On drop in a different bar, the target's on_drop calls on_tab_received with the moved handle and the model insertion index (no -1 correction — 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 fires on_transfer_out. A self-reorder flag (set by the source bar's own on_drop, which runs before on_drag_ended) suppresses on_transfer_out on 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). TabWidget installs the predicate that enforces this.
  • Type-safe interop only: TabWidgetTabWidget (both are TabBar<TabHandle> underneath). A TabBar<OtherT> never matches.
  • Same-window only. Cross-window transfer is feasible via the DnD layer's typed re-entry but needs mime_data on the payload to escalate at the window boundary — not wired here.
  • Requires T: Clone (TabHandle is). Stand-alone TabBar<T> exposes the same accept_external_tabs / on_tab_received / on_transfer_out methods, 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 failed TabBarDragData<T> downcast leaves the payload intact for inspection).
  • OS drops reuse the same on_drop path, so installing the handler makes the bar an OS-drop target automatically — the app must still call TeksiloAppBuilder::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 bool return 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:

ModeBehaviour
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.
AlwaysShown whenever the bar has at least one tab, even when everything fits (a persistent fast-jump affordance — the old default).
NeverNever built.

show_overflow_dropdown(bool) is a convenience over overflow_button: trueAlways, falseNever. 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

KeyEffect
ArrowLeft / ArrowUpmove selection to previous enabled tab
ArrowRight / ArrowDownmove selection to next enabled tab
Homejump to first enabled tab
Endjump to last enabled tab
Enter / Spaceactivate the tab and move focus into its content panel (first focusable descendant)
Ctrl+Wclose the focused tab if closable
Middle-clickclose 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::TabList with orientation = Horizontal | Vertical.
  • Each tab header: Role::Tab, with selected = bool reflecting the active tab. The controls() relation points at the tab's content-panel WidgetId when the bar is composed inside TabWidget.
  • 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::Default plus a custom action with i18n name "Close" wired to on_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::Button with 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

SurfaceRole
bar backdrop + tab fillstab_surface_role (settable)
label text — selectedselected_text_role (settable)
label text — idleidle_text_role (settable)
label text — disabledTextRole::Disabled (always)
accent indicator (selected)theme.colors.accent
bar bottom separatorBorderRole::DividerStrong
close button hoverSurfaceRole::Hover
drop indicator lineTextRole::Accent
overflow popover surfaceSurfaceRole::Raised
overflow popover borderBorderRole::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 from theme.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_toggle semantics
  • 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 / TabPanel roles; "Move Left/Right" custom actions for AT-driven reorder; named close action; HasPopup on 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::Pixels path with Easing::EaseOut animation; touch flicks would need ScrollAreaSwipeRecognizer wiring, ~150 LOC, separate task)
  • tool_window_tab_height (28 dp) is reserved on TabStyle but not yet consumed by vertical bars — they currently pick up editor_tab_height like horizontal bars

Demos

  • cargo run -p tab-widget — full showcase: static tabs (pinned, disabled, default), three dynamic tabs from a ListModel<TabHandle>, registered dynamic_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

MethodEffect
.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

MethodLevel
.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.

KeyEffect
← / →previous / next selectable segment, wrapping; RTL-swapped, resolved at event time so a locale flip needs no rebuild
Home / Endfirst / 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 path SpinBox uses 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/ListBoxOption rows, 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):

  • EnumerationTextFontService::families() / family_names() / family_is_monospaced() (fontdb metadata; no bytes loaded).
  • Writing-system coverageTextFontService::writing_system_index_builder() returns a Send snapshot whose build() computes the per-family coverage map off-thread, plus the WritingSystem / WritingSystemSet types. Reads OS/2 via a new ttf-parser dependency (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:

  1. emits a ChartChange describing exactly what changed (SeriesInserted, SeriesRemoved, SeriesMoved, SeriesRenamed, SeriesColorChanged, SeriesVisibilityChanged, PointsInserted, PointsRemoved, PointUpdated, SeriesDataReplaced, Reset) to every observer registered via model.observe_changes(|change| …), and
  2. 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:

#NameHex
1Orange#E69F00
2Sky blue#56B4E9
3Bluish green#009E73
4Yellow#F0E442
5Blue#0072B2
6Vermilion#D55E00
7Reddish purple#CC79A7
8Black (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 desaturates chart_palette by ColorTokens::INACTIVE_CHART_DESATURATION (0.35) — deliberately lighter than INACTIVE_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:

linemarkerfilled area
Solidsolidcircleplain
Dashedlong dashsquare45° hatch
Dotteddottedtriangleback-hatch
DashDotdash-dotdiamondcross-hatch
ShortDashshort dash×horizontal
WideDashwide 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 for BarChart (so BarGrouping::Single, which draws one series however many the model holds, stays plain) and LineChart; for PieChart, 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 is Solid, so a single-series chart needs an explicit .pattern(..) for Always to 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 / LineChartPieChart 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:

  1. Reserves the legend band on the requested edge (when shown).
  2. Reserves a y-axis band on the leading edge: max tick label width + tick length + gap + axis-title height (when applicable).
  3. Reserves an x-axis band on the bottom edge: tick label height + tick length + gap + axis-title height.
  4. 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-3 ChartStyle trait — §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.

ChangeModel signalBinding levelWhy
Series add/insert/remove/move/renamestructure_versionRelayout + AccessibilityOnlyY-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, clearstructure_versionRelayout + AccessibilityOnlySame — any point-shape change can move the domain
set_series_visiblestructure_versionRelayout + AccessibilityOnlyVisible set changes the auto y-domain and bar widths, not just paint
set_series_color / clear_series_colorstyle_versionRepaintOnlyGeometry unchanged — this is the only ChartChange variant that doesn't bump structure_version
Hover state (private Signal<Option<(SeriesId, usize)>>, all three charts)RepaintOnlyMarker + tooltip only
Theme changeAuto via tree-wide mark_all_dirtyColors/fonts re-resolved on next paint
Prop<ChartPalette> changeRepaintOnlyColor-only
PieChart inner_radius_ratio changeRelayoutCenter-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. TooltipWidget is anchored to a widget bounds box.
  • Chart tooltips appear instantly. TooltipWidget waits ~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:

  1. The chart owns a private hover signal — Signal<Option<(SeriesId, usize)>>, the same (series, point index) shape across all three chart kinds — bound at BindingLevel::RepaintOnly.
  2. An on_pointer_event handler attached via HandlerSet reads the pointer position, finds the nearest hit in a Vec<…Hit> snapshot the chart wrote during paint, and updates the signal.
  3. paint() reads the signal — if Some, it draws a marker (a small ring + filled circle for line charts, the wedge stroke for pie) and a tooltip rect above the marker.
  4. 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::Default with 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_ticks is linear-only.
  • No time-axis formatters. T = chrono::DateTime works structurally (the chart only needs Display), 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 no animate_to integration 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 / ChartAggregate internally. Both remain teksilo-data building blocks (§3, and data-models.md §15) an app composes on top of a ChartModel for a strip-chart or a downsampled long series. ChartSelection is 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 a Switcher between the three panels. Bar and Line share one series ChartModel<String> — constructed once, cloned into both chart widgets, the same sharing pattern ChartModel::clone() gives for free (§3) — and one ChartSelection, so switching between the two panels keeps the highlighted point selected. The donut consumes a second, single-series ChartModel<String>.
  • Default / Gradient theme toggle. A second SegmentedControl drives a Switcher between the shipped flat RecipeChartStyle and a demo-defined GradientChartStyle (§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 via ChartStyle::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 own ChartSelection::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_data per 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 LiveStripPane widget appends one point every tick (via a periodic frame-tick timer) to an unbounded history ChartModel<u32>, then projects its tail through a ChartWindow<u32> ("last N points"). Since chart widgets bind to a ChartModel, not a ChartWindow projection directly, the window's current tail is materialized each tick into a small render-bound ChartModel the LineChart actually 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 tierSceneItems: 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 if parent == None).
  • local_bounds: Rect — AABB at origin in local coords.
  • transform: Transform2D — rotation / scale / shear applied around the local origin before translating by local_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:

FieldMeaning
theme: &ThemeThe 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: booltrue iff the host window is focused and unoccluded (true in headless tests). For behavioural blur cues the theme swap alone can't cover.
enabled: boolThe item's effective IS_ENABLED flag, forwarded to ColorProp::resolve so a role colour picks its disabled variant.
text_scale: f32The global accessibility text-scale factor, for TextItem::follow_text_scale opt-ins.
view_transform / dirty_scene_rectUnchanged — 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 a Signal<Role> (e.g. .fill(my_signal.clone())). Every colour-bearing built-in (RectItem, PathItem, GroupItem, TextItem) registers its bound ColorProps at BindingLevel::RepaintOnly in register_bindings, so a signal change repaints the owning SceneView — 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 emits ItemChange::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 a Signal/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-runs register_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 (what SceneMinimap renders) 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 concrete Color or a Signal<Color> for items you want faithfully represented there.

Cache caveat. A custom item that opts into CacheMode::ItemCoordinate bakes its resolved colours into the cached frame. The SceneView invalidates that cache on a theme swap, a window-active flip, and an IS_ENABLED change, 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 default CacheMode::None.


Item flags

Per-item behaviour is a bitset on ItemFlags. Default is IS_VISIBLE | IS_ENABLED | IS_SELECTABLE.

FlagEffect
IS_VISIBLEWhen cleared, the item skips paint and hit-test. Composes through ancestors via Scene::is_effectively_visible.
IS_ENABLEDDisabled items don't dispatch pointer events and don't take focus.
IS_DRAGGABLEThe item participates in drag-to-move when the view is in DragMode::RubberBand.
IS_SELECTABLEThe item can be picked up by marquee-select.
IS_FOCUSABLEThe item can receive keyboard focus.
ACCEPTS_HOVERReserved — tracks hover entrance / exit.
CLIPS_TO_SHAPE / CLIPS_CHILDREN_TO_SHAPEReserved for clip-region paint.
IGNORES_TRANSFORMATIONSItem 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_CONTENTSLogical-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_y
  • zoom
  • rotation

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 zoom and rotation anchored 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: backgroundUnder 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:

PassWhat paintsTier
paint (backdrop)lightweight Under items, z-sortedlightweight
arena child-walkheavyweight widgets, z-sortedheavyweight
post_paint (foreground)lightweight Over items, then the selection marquee / app foreground hook / debug overlayslightweight

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-viewmodel.add_widget(widget, rect) (or Scene::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-viewmodel.add_widget_item(payload, rect) stores a type-erased payload (any 'static type). 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 a Signal for 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 ItemChange variant 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 separate Scene::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

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:

  1. Visual default. A11yOffScreenMode decides which off-viewport entries the walker still emits. Pick Cooperative (default) when the visual layout is a sensible reading order; pick StrictlyParallel when AT shape diverges meaningfully from visual layout.
  2. 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:

  • Controlsfrom controls to (button opening a menu).
  • DescribedByfrom is described by to (cross-item annotation).
  • LabelledByfrom is labelled by to (cross-item label).
  • FlowTo — logical reading flow from from to to. 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:

ModeEffect
Inherit (default)Descendants emit AT nodes normally.
ExcludeDescendants are pruned from the AT tree.
MergeDescendants' 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 FlowTo relations.
  • 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_signalpure 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.mdShared 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

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

FormatUse caseTintableNotes
SVGVector icons (preferred)YesScales to any size without loss
PNGRaster iconsYesFixed resolution, best at native size
WebPRaster icons (smaller files)YesUse lossless encoding for icons
OtherArbitrary filesN/AEmbedded 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

IconLocationLayout
NoneNo icon (default)
LeadingIcon left of label
TrailingIcon right of label
IconOnlyIcon only, no label
TopIcon above label
BottomIcon 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:

  1. Parses --teksilo-inspector from std::env::args() and TEKSILO_INSPECTOR=1 (or =true) from the environment to seed the inspector's initial visibility.
  2. Stores a shared InspectorState (toggle / selection / picker mode / overlay mode / opacity / shell ids) into app_state.
  3. Registers a default WindowConfig::post_root hook that wraps every window's user root with InspectorShell and registers the F12 shortcut.
  4. If the app has wired a SettingsStore via TeksiloAppBuilder::settings(...), bridges the inspector's persistent preferences to it (see Persistence below).

Toggling the inspector

PathWhat it does
F12Global shortcut, owned by the user-root widget per window. Toggles the panel on/off.
--teksilo-inspector CLI argOpen the inspector at startup.
TEKSILO_INSPECTOR=1 envSame as the CLI arg.
× toolbar buttonCloses the panel (F12 reopens).
Persisted stateIf 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:

KeyAction
Ctrl+PToggle the picker tool (same as the toolbar Pick button).
Ctrl+BCycle bounds overlay: Off → Sel → All → Off.
Ctrl+TabSwitch to the next tab.
Ctrl+Shift+TabSwitch to the previous tab.
EscIf 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 TextWidget inside a Button) 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 overlayOff (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 px is 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

TabWhat it shows
TreeLive 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).
PropertiesFor 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.
AccessibilityRole / name / value / advertised actions / toggled / expanded / selected / hidden, from the widget's accessibility(builder) output.
ThemePreset 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.
LocaleEvery locale declared in I18nConfig::supported_locales. Click a row to call EventContext::set_locale(...). The active locale is highlighted.
FocusCurrent focused widget plus its ancestor chain (root → leaf). Leaf shown in primary color, ancestors dimmed.
ShortcutsEvery shortcut in the tree's ShortcutRegistry with its effective primary keystroke. Framework-reserved ids (__-prefixed) are dimmed.
OverlaysActive overlays from OverlayManager, with their content + anchor labels.
ModelsData 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 a Weak to the model's inner Rc<RefCell<…>>, so it never extends the model's lifetime. When the last model handle drops, the registration becomes dead and is pruned on the next snapshot().
  • SelectionModel — has no shared inner; the strong adapter Rc lives inside an Rc<RefCell<Option<…>>> cloned across handles. When the last SelectionModel clone drops, the holder reaches zero, the adapter is freed, and the registry's Weak goes 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-inspector nor TEKSILO_INSPECTOR was 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 as i64 because TOML lacks unsigned integers and usize width 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 a Padding widget's outer rect and its child's inner rect (top, bottom, leading, trailing).
  • Green fill — the gap between consecutive HStack / VStack siblings, 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_transform scopes. 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 / Sel when 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

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 to WidgetArena::hit_test_at)
  • WidgetArena::hit_test_at(point, exclude)
  • WidgetBuilder::event_pass_through(bool) and the corresponding WidgetNode::event_pass_through field
  • WindowConfig::post_root(F) per-window root-wrapping hook
  • LayoutContext::widget_bounds(id), widget_at_point(point, exclude), arena(), focused(), shortcut_registry(), overlay_manager()
  • teksilo_app::DefaultPostRoot typed app_state slot for an app-wide default post_root wrapper

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

ModeCommandWhat it drives
Headless (default)teksilo-automation-mcp --headlessA 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-automation crate: own your app's WidgetTree on one thread (or reuse a headless test tree) and call teksilo_automation::execute(&mut tree, &mut ops, &op, &settle) per request. execute works against any WidgetTree, so this is ~a screenful of glue — but it is a kit, not a turnkey "point it at my app" binary.
  • Use the live --connect mode 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.

Querysnapshot_tree, read_node, find_node, assert_node, list_windows

Layout / geometrylayout_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 inputinject_pointer, right_click, inject_key, type_text, type_ime, drag_node

Introspectionget_overlays, get_shortcuts, list_live_regions, pull_announcements

Time / settleadvance_clock, settle, wait_for_condition

Visualscreenshot (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") — the ShowContextMenu AT action. It routes to the same .context_menu(..) factory (unless the widget wires its own ShowContextMenu handler, 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's bounds).

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:

FieldDefaultMeaning
clock_millis0Advance the simulation clock first (drives tooltip / overlay timers).
max_anim_frames60Cap on 16 ms animation ticks (~1 s). A perpetually-looping animation hits the cap — expected.
layout_aftertrueRun a layout pass after ticking, so height-for-width / reflow settles before the AT re-walk.
settle_timeout_ms500Hard 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_SRC texture, rendered via the test renderer, read back, PNG-encoded. If no GPU backend is present (CI without a GPU), the tool returns GPU_UNAVAILABLE (non-fatal).
  • Live: PlatformWindow::capture_offscreen renders the live frame into an offscreen texture in the window's own surface format (the swapchain texture lacks COPY_SRC), swizzling BGRA→RGBA as needed; the bridge base64-encodes the PNG over the socket and the --connect client 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's install_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 0700 per-process directory under $XDG_RUNTIME_DIR with a 0600 socket (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 ≤ 120 and settle_timeout_ms ≤ 2000 so no op (including a long wait_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_UNAVAILABLE error, 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.

CrateRole
teksilo-automationGUI-free toolkit: DTOs, execute, RecordingWindowOps, the tool catalog. Mirrors teksilo-data's core-only-peer design.
teksilo-automation-mcpThe 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-platformPlatformWindow::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 through execute(), each validating the produced TreeUpdate with the real accesskit_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-tests feature (cargo test -p teksilo-automation-mcp --features golden-tests), inline per-channel pixel compare with tolerance ≤ 2, UPDATE_GOLDENS=1 to refresh.
  • Bridge round-trip (examples/automation_bridge_smoke): a real app + install_automation_bridge_in_debug(), an in-process client running snapshot → invoke → re-snapshot and asserting the 0600 socket.

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
  • ColumnFlowColumnFlow — flows children into as many columns as the width affords,
  • DeadZoneDeadZone — 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,
  • FocusScopeFocusScope — 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
  • TextInputFieldTextInputField — 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
  • DockingLayoutDockingLayout — a VS Code-style dockable layout: a fixed centre slot
  • DropTargetDropTarget — a transparent wrapping drop container
  • DropZoneDropZone — 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
  • StepperStepper — a modern, embeddable step-flow widget (Material/Ant/Flutter
  • TabWidget — Tabbed-container widgets
  • TitleBar — Custom window title bar widget
  • ToolbarToolbar — a command bar with automatic overflow
  • ToolBox — ToolBox — a vertical stack of collapsible sections, exactly one expanded
  • WizardWizard — a thin modal launcher around Stepper

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

  • AvatarAvatar — 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>
  • SpinnerSpinner — 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: CodeEditor and PlainTextEditor
  • FilePickerFieldFilePickerField — a text-input preset for path entry with a Browse button
  • InputDialog — InputDialog — a QInputDialog-style modal that prompts the user for
  • LogViewLogView — a read-only, append-only, tail-following streaming view
  • PasswordFieldPasswordField — secure single-line text entry with a reveal
  • RichTextEditor — Rich text editor and viewer widget
  • SearchField — SearchField — a TextInput preset
  • SpinBoxSpinBox — numeric input with increment/decrement buttons
  • TextInputTextInput — styled single-line text field composite

Date and time

  • CalendarCalendar — month-grid date picker, standalone widget
  • DateEditDateEdit — text input + calendar popover, bound to Signal<Option<Date>>
  • DateRangeEditDateRangeEdit — single unified control for picking a DateRange
  • DateTimeEditDateTimeEdit — single unified control for picking a DateTime
  • TimeEditTimeEdit — text input for time-of-day, bound to Signal<Option<Time>>

Color

  • ColorEditColorEdit — compact field-style color picker trigger that opens
  • ColorPickerColorPicker — embeddable composite color selector
  • HexColorInputHexColorInput — single-line #RRGGBB[AA] color editor
  • 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
  • NotificationCenterButtonNotificationCenterButton — bell icon with an unread-count badge that
  • NotificationLogNotificationLog — 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
  • ToastHostToastHost — 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 / TreeView delegates
  • TableViewTableView<T> — generic, virtualized, accessible tabular widget
  • TreeTableViewTreeTableView<T> — hierarchical multi-column data table with expand/collapse
  • TreeView — TreeView — a virtualized, expandable/collapsible hierarchical list widget

Animation wrappers

  • BlurBlur — a wrapper widget that applies a Gaussian-equivalent blur
  • CollapseCollapse — a wrapper widget that animates its child between
  • CrossfadeCrossfade — when an external Signal<K> changes, the
  • CycleCycle — show one of N children at a time, advancing on a fixed
  • FadeFade — a wrapper widget that animates its child between hidden
  • PulsePulse — a wrapper widget that pulses its child's opacity between
  • RotateRotate — wraps a child and applies a 2D rotation to its entire
  • ScaleScale — wraps a child and animates a uniform 2D scale on its
  • ShakeShake — wraps a child and plays a damped horizontal oscillation
  • SlideSlide — wraps a child and slides it in or out from a chosen
  • SmoothSizeSmoothSize — auto-sizes the slot to fit the child's intrinsic
  • UnrollUnroll — the horizontal sibling of Collapse

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
  • TextScaleControlTextScaleControl — the settings control that grows all text in the app
  • ThemeSwitcher — ThemeSwitcher — a drop-in app-theme picker for settings screens & toolbars

ColorPicker (submodule)

  • ColorSwatchColorSwatch — single clickable color cell with Role::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
  • PopoverSurfacePopoverSurface — the themed panel a popover's content sits in
  • PopoverWidgetPopoverWidget<T> — a generic trigger that opens a popover when
  • TreeRowMeta — Type-erased data source adapter for TreeView

TabWidget (submodule)

  • TabBarTabBar<T> — header strip driven by a data source

TitleBar (submodule)

  • DragRegionDragRegion — flexible drag region inside a TitleBar
  • 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)

  • ToastSurfaceToastSurface — the rendered chrome of one toast

Accordion

Accordion preview

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 (matching ViewId) from a foreign drop, and translates the origin's rows → its own key via key_at, so the source's Key type never leaks into the view. When the origin opted into export it also carries items (clones of the dragged T), so a foreign DropTarget, a different data view, or the OS can consume the drag.
  • DropIndicator — what paint renders; allowed == false is the pre-commit forbidden affordance.
  • flat_insertion_target — maps a flat insertion index to the (target, position) pair can_accept / accept_drop expect.
  • default_placeholder — the skeleton for a Loading row.

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 the Default.

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 the Default.

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 + rows to recognise a same-view reorder;
  • a foreign consumer (another view's custom ListDataSource, a DropTarget::accept_typed::<RowDragData<T>>(), or on_rows_received) reads items.

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 preview

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 (typically theme.shape.shadow_*).
  • inner — the sharp short-blur rim that gives the surface a clearly "lifted" edge instead of a vague glow (typically the matching theme.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 preview

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", "[email protected]" → "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 → alt if 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 preview

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 SeverityBadge for 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 preview

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 preview

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 preview

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 preview

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

  • Idle
  • Hovered
  • Pressed
  • Focused
  • Disabled

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 preview

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 to Signal<Option<Date>>.
  • Calendar::range — pick a start + end day. Bound to Signal<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::Disabled and remain selectable (matching macOS / Material). To prevent selection use disabled_date_filter.
  • Keyboard (matches the WAI-ARIA grid pattern):
    • 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::Grid with set_label("Calendar, May 2026") (localized). Single-mode also sets set_value to the current ISO selection or empty; range mode sets "start – end".
  • Header arrow buttons — Role::Button with localized labels ("Previous month", "Next month") and Action::Click advertised.
  • Header month/year label — Role::Button (clickable to open the month picker) with set_has_popup(HasPopup::Grid) and set_expanded(open).
  • Weekday header row — Role::Row of Role::ColumnHeader cells, each labelled with the long weekday name (e.g. "Monday").
  • Day cells — Role::GridCell with localized long-form labels ("May 2, 2026"), set_selected, set_focused, set_disabled for filter rejections, and Action::Click advertised.

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 into Self::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 into Self::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 preview

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.

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 preview

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 ExpandExpand::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 preview

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 a Signal<bool> between true (checked) and false (unchecked) on click or Space.
  • Tristate (Checkbox::tristate): cycles a Signal<CheckState> between Checked and Unchecked on user interaction; the Indeterminate state is set only by external sources such as TreeCheckedModel aggregation — clicking from Indeterminate goes to Checked, 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

CodeEditorHandle preview

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

  • Text
  • Keyword
  • Function
  • Method
  • Variable
  • Field
  • Type
  • Module
  • Constant
  • Snippet

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

  • Spaceswidth spaces per indent level.
  • Tabs — One tab character per level, rendered width columns 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 preview

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 preview

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 preview

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 preview

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 preview

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 — reactive ListModel<T>.
  • ComboBox::from_source — external ListDataSource<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:

  • state holds the interaction-state enum, the ItemSource accessor, and color/index helpers.
  • item holds the single-row DropdownItem widget.
  • panel holds the DropdownPanel overlay content and the FilteredItemList inner widget.
  • tests holds 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 preview

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

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;
}
#![allow(unused)]
fn main() {
pub const COMMAND_LINK_BUTTON_ICON_TEXT_GAP: f32 = 14.0;
}
#![allow(unused)]
fn main() {
pub const COMMAND_LINK_BUTTON_TITLE_DESCRIPTION_GAP: f32 = 4.0;
}
#![allow(unused)]
fn main() {
pub const COMMAND_LINK_BUTTON_PADDING_HORIZONTAL: f32 = 16.0;
}
#![allow(unused)]
fn main() {
pub const COMMAND_LINK_BUTTON_PADDING_VERTICAL: f32 = 14.0;
}
#![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 preview

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. None shows the placeholder.
  • Pattern: locale-derived strftime-subset (%Y-%m-%d, %m/%d/%Y, …); override via format_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_fast fade.
  • Min / Max: clamps on commit and on step. Out-of-range values in the popover cell are disabled.

Accessibility

  • Container — Role::DateInput, set_value to ISO selection, set_label from .label() builder, set_placeholder when value is None.
  • Calendar trigger button — Role::Button with set_has_popup(HasPopup::Grid) and set_expanded(open).
  • Internally the editing surface remains a Role::TextInput for 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

  • DefaultDefault. 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 the Default.
  • 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 an Expand::horizontal column 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/202612/31/2026) and announced via Live::Polite. Matches macOS Calendar and iOS DatePicker. This is the Default.
  • 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 preview

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 hosting Calendar::range bound 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::Focused while any half holds focus, otherwise Default), validation-aware border (Error for Invalid, Focused for Corrected).
  • One validation strip below the frame — composed feedback from both halves (worse of the two wins).

Accessibility

  • Container — Role::DateInput with set_value formatted as YYYY-MM-DD/YYYY-MM-DD (ISO range).
  • Each TextInputField keeps its own Role::TextInput AT node; the wrapper's Role::DateInput provides 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 preview

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's Role::DateTimeInput. The separator can be replaced with a custom string via separator (rendered as styled secondary text).
  • One trailing calendar button — Int UI IconButton::embedded() with the calendar glyph. Opens a single popover hosting Calendar::single bound 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::Focused while any half holds focus, otherwise Default), validation-aware border (Error for Invalid, Focused for Corrected).
  • One validation strip below the frame — composed feedback from both halves (worse of the two wins).

Accessibility

  • Container — Role::DateTimeInput with set_value formatted as YYYY-MM-DDTHH:MM:SS (ISO 8601 datetime).
  • Each TextInputField keeps its own Role::TextInput AT node; the wrapper's Role::DateTimeInput provides 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

Dialog preview

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 default Button.
  • ModalContainer::new(content) directly when you need to present a modal from handler code via ctx.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.

Footer slot separated from the body by a Divider (typically action buttons like "OK" / "Cancel").

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 preview

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 preview

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::CompactHero). 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.
  • Top
  • Bottom

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

  • TopLeading
  • TopTrailing
  • BottomLeading
  • BottomTrailing

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 standard IconButtonSize::Default regardless of the rail's configured (larger) size; icon only, title on hover. Not the extra-small Compact button: 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 preview

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 preview

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 preview

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. So Expand::vertical(child) inside a VStack (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 flex weight only on axes the parent is distributing (i.e. left open). Expand::horizontal() inside a VStack reports flex = 0 on 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 for Expand::vertical() inside an HStack.

#![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 preview

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 preview

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 into TextStyle.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 (UIKit displayUsingSystemFont). 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 preview

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 preview

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 after Fixed and Auto tracks are resolved; equivalent to the CSS fr unit. Multiple Fractional tracks 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

GridView preview

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::GridRole::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 preview

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 preview

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 preview

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); #RRGGBBAA if alpha_enabled; #RGB short-form expands to #RRGGBB if short_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 \\#hhhhhhhh with alpha) — the TextInputField mask grammar (h = hex digit slot, \\ literal escape).
  • Validation: commits on Enter / Tab-out / blur. Returns ValidationOutcome::Valid / ValidationOutcome::Corrected / ValidationOutcome::Invalid which the inner field maps to a visible inline strip via the standard validation_feedback bridge.
  • Nullable: empty (after trim) commits None; non-empty parses normally and commits Some(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 preview

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 preview

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 bound Signal<bool>; while true, the background reads as SurfaceRole::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 preview

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 supplies currentColor and 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 carried f32 is the corner radius as a fraction of min(width, height), clamped to 0.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:

  1. Box size — how big the widget's layout rectangle is.
    • width / height / size pin 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 (CSS width: 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).
  2. Content fit — how the image pixels map into that box, via ImageFit (Contain / Cover / Fill / ScaleDown / None, the CSS object-fit set) plus alignment (the CSS object-position equivalent) for where slack/overflow lands. Modes that overflow the box (Cover, and None on 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 by alignment); if smaller it sits inside with empty space. CSS object-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 preview

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 preview

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

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 preview

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 Repeater instead.
  • 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 preview

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_limit to 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 preview

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 preview

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 responsive Toolbar overflow 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 preview

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:

BuilderAT RoleLeading glyph
(default)Role::MenuItemicon or blank
.checked(signal)Role::MenuItemCheckBoxcheckmark / blank
.check_state(signal)Role::MenuItemCheckBoxcheck / dash / blank
.reflect_checked(signal)Role::MenuItemCheckBoxcheckmark (read-only)
.radio(value, selected)Role::MenuItemRadiofilled 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: Static and Bound ignore 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 a tr!(...) 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 UncheckedChecked (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 preview

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 preview

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_fg tint.
  • Question — question mark glyph, accent tint.
  • Warning — exclamation triangle, status_warning_fg tint.
  • Critical — X-mark circle, status_error_fg tint. 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 (via ModalRequest::focus_target plus Widget::initial_focus_hint). Styled with ButtonVariant::Filled.
  • escape_button — activated by Escape. The fallback logic (for presets with no explicit escape_button) picks: explicit escape_button → first Reject-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 preview

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_memory directly 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 a ListModel for the running session. Cheap, no disk I/O. Default for apps that don't install a SettingsBundle.
  • Persistent — File-backed via PersistedListModel. The path is built at install time from AppPaths::config_file using the configured file_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 preview

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 NotificationCenterButton for the standard bell-icon-with-popover pattern.
  • Call NotificationLogDialog::show for 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;
  • StandardListItem rows 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 preview

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 preview

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 preview

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 by IconButton::visibility_toggle; fully keyboard- and screen-reader-accessible. (Default.)
  • Hold — Press-and-hold to reveal, release to re-mask (WinUI "Peek"). Pointer-oriented; prefer Toggle for keyboard accessibility.
  • None — No reveal button — the field is always masked per its EchoMode.

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)

  1. Plain-language Art. 13 notice — controller, processor name, purposes, lawful basis, retention, recipients, withdrawal right. All strings flow through tr_widget! against keys defined in crates/teksilo-widgets/locales/en-US.ftl and fr-FR.ftl under the privacy-* namespace. Apps install the framework bundle via I18nConfig::framework_locales(teksilo_widgets::framework_locales()).
  2. Per-scope toggles — one per ConsentScope field, intersected with reporter.supported_scopes() so toggles for unsupported scopes are hidden, not just disabled. Toggles work from Unknown (auto-transition to Granted with the toggled scope) and Granted states; they're disabled when state is Denied until the user clicks Withdraw → Accept.
  3. Accept all / Reject all — equal-prominence buttons (CNIL parity rule, GDPR Art. 7).
  4. Identity row (pseudonymous mode only) — install_id display, Get-my-data button (Art. 15 + 20), Erase-my-data button (Art. 17).
  5. Inspect data sent — accordion listing the most-recent events from the bundle's recent-log ring buffer.
  6. Mode switch (when both adapters configured) — confirm-button pair to flip anonymous ↔ pseudonymous.
  7. 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 preview

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::build registers an AnimatedQuadHandle and mounts a single IndeterminateSweepLeaf whose paint() issues one draw_animated_quad per 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 IndeterminateSweepLeaf in signal mode paints a moving fill rect on top driven by a Signal<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 preview

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 preview

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 preview

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 preview

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 wrapping TileLayout::Grid, a full-width TileLayout::Column, or a compact fixed-height TileLayout::Vertical settings list. Row and Grid equalize tile size (uniform width + the tallest tile's height) via a custom place_children measuring 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/Decrement AT actions mirror the arrows for switch access.
  • AccessibilityRole::RadioGroup with active_descendant pointing at the selected tile; each tile is Role::RadioButton and declares its siblings via push_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 active RadioTileStyle (the theme's RadioTileRecipe::vertical_row_height, 44 dp by default; override per-group with RadioTileGroup::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 preview

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 preview

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 via HStack). Gap = Repeater::spacing.
  • Wrap — A horizontal flow that wraps to the next line when items exceed the available width — chip rows, badge lists. Repeater::spacing is the inter-item gap, Repeater::line_spacing the 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

RichTextEditor preview

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 outer ScrollArea or 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 from Self::Keyboard because 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's SetValue or ReplaceSelectedText, which is how dictation and a braille display write. Never folded into Self::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 SetValue and ReplaceSelectedText.

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.

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.

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_bold and the italic / underline / strikethrough variants.
  • Block-level formatting — set_alignment, set_heading_level, apply_block_format, insert_list, indent / outdent.
  • Tables — insert_table and the per-row / per-column / remove operations, plus is_in_table for contextual UI enable state.
  • History — undo / redo.
  • Clipboard — copy / cut / paste / paste_unformatted, plus can_paste for 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 an EditorHandle can derive bound signals without keeping a separate RichTextEditor reference.

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.

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 preview

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 with Center origin (the default).
  • Reflow.reflow(true). The wrapper's layout_response returns child_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 with TopLeading origin (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 preview

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 like Overlay but 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 preview

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: shared Signal<f32> for current scroll offset
  • max_scroll: shared Signal<f32> for maximum scroll offset
  • viewport_ratio: shared Signal<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 preview

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 preview

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 SegmentedControl for mutually exclusive modes that read well as a compact horizontal strip (view mode, time period).
  • Prefer a ComboBox when 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 / RadioTileGroup when 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 every SegmentedControl before 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 to Auto for a segment that declares both; kept for parity with TabDisplayMode so 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 every SegmentedControl before 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 every SegmentedControl before 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 preview

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 press Delete / Backspace during 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 preview

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_fits returns 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 preview

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 preview

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 preview

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 Enter or on focus loss — at commit time the text is parsed, clamped into [min, max] (or wrapped, per WrapMode), and reformatted. Invalid input reverts to the last known good value.
  • Keyboard:
    • Up / Down → ±single_step
    • PageUp / PageDown → ±page_step (default: 10 × single_step)
    • Enter → commit (stays focused)
    • Home / End stay bound to the text cursor (Qt-compatible).
  • Mouse wheel: adjusts by single_step — wheel down decreases, wheel up increases, matching QAbstractSpinBox, GtkSpinButton and WinUI's NumberBox. Gated by wheel_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 min and special_value_text is 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's AdaptiveDecimalStepType). 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 one NumberPresentation, so they cannot disagree about which separator the field is using — a French user sees 12,5, types 12,5, and the numeric keypad's . still works. Rendering is a string transform over the value's own Display, never an f64 round-trip, so a SpinBox<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 no I18nManager installed the active locale is the C locale and this is a no-op.
  • Custom formatter / parser: full override via text_from_value and value_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 to min / max (default).
  • Wrap — Wrap around: past max jumps to min, past min jumps to max. Matches Qt's QAbstractSpinBox::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 by single_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's AdaptiveDecimalStepType. 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 is DEFAULT_PREFERRED_WIDTH (120 dp), matching Qt's QSpinBox sizeHint.
  • 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: trueButtonLayout::Stacked, falseButtonLayout::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 preview

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 preview

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

Splitter preview

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

StandardListItem preview

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_context exposes a TreeRowContext that yields toggle_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, prefer bool_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 TreeViewStandardListItem 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 preview

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 preview

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 to Top in 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

  • Upcoming
  • Active
  • Complete
  • Error
  • Disabled
  • Optional
  • Skipped

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_drop matches source_bar_id == self_id and uses source_index to drive move_item. item is unused on this path (and may be None).
  • Cross-bar transfer: a different bar that opted in via accept_external_tabs takes item by value and hands it to its on_tab_received callback. item is Some only 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 preview

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 by min_width / max_width.
  • Flex — Share of the leftover space proportional to the flex factor — behaves like CSS flex-grow. The factor must be > 0.0.
  • Auto — Intrinsic content width (currently approximated by the table's min_column_width_default token; 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

  • Leading
  • Center
  • Trailing

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

  • None
  • Horizontal
  • Vertical
  • Both

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

  • Live
  • OnRelease

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

TabWidget preview

Tabbed-container widgets.

Two public entry points:

  • TabBar<T> — a header strip driven by a ListModel<T> / ListDataSource and a TabDelegate<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, content Switcher below, 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 tab kind ("plain-text-doc", "image", …); the live tab list is a mutable ListModel<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 tabtab_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) when vertical_wheel_scrolls_horizontally is 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 hits max.
  • 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 and max_tab_width is not applied, so the strip is filled edge to edge instead of leaving trailing slack. min_tab_width still 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 intrinsic editor_tab_height, or the tab_bar_height override). With no width proposed at all (an unbounded measure — a Center, an HStack asking for the natural size), there is nothing to fill: a vertical bar falls back to the Shared fit-to-widest-label width. Give the bar a bounded width (a FixedSize, an Expand in a sized parent) for Fill to 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 its TabInfo declares — the title if set, the icon if set. The default; preserves per-tab no_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 preview

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; LocalizedString is shown in red below the field.
  • Warning — The committed value is suspicious but accepted; LocalizedString is 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 / ValidValidationState::None
  • Corrected { 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_fn and focus loss fires on_blur_fn.
  • Non-editable trailing suffix, rendered flush-right inside the field's bounds (Qt's QSpinBox::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::TextInput with 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 bound Signal<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's NoEcho.
  • RevealWhileTyping — Show plaintext while the field is focused (being edited) and re-mask on blur. Qt's PasswordEchoOnEdit.

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 normal Role::TextInput carrying the plaintext value — matching what is visibly on screen and the web type=password ↔ type=text swap. When masked, it reverts to Role::PasswordInput. (Default.)
  • AlwaysProtected — Always report Role::PasswordInput and 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 to ValidationFeedback::Valid.
  • Corrected — Input was accepted after normalization. The field replaces its text with corrected, the bound Signal<String> observes the new value, and the feedback signal carries message for 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 carries message for 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 returned ValidationOutcome::Valid. Composites typically render this identically to Pristine — the distinction matters for tests and for callers that want to signal "yes, it's confirmed valid" with a checkmark.
  • Corrected — Last commit returned ValidationOutcome::Corrected. since is the wall-clock instant the correction was applied; composites use it to decay the visual after corrected_pulse_duration_ms from the theme.
  • Invalid — Last commit returned ValidationOutcome::Invalid. Persists until the user edits again or an external Pristine reset.

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 preview

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 preview

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.

Called when an inline link is tapped. Enables markup automatically.

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 preview

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) or EventContext::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 preview

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>>None shows the placeholder.
  • Pattern: 24h default %H:%M; 12h is %I:%M %p. Override via format_pattern. Add seconds with seconds(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)

Accessibility

  • Container — Role::TimeInput with set_value formatted as HH:MM:SS and set_label from .label().
  • Underlying TextInputField keeps Role::TextInput so 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. Calling present_snackbar dismisses 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

  • Timeoutauto_dismiss_after reached zero (timer expired naturally).
  • ActionInvoked — A ToastAction with closes_toast(true) (the default) fired.
  • CloseClicked — The user clicked the close (X) button.
  • EscapePressed — The user pressed Escape while focus was inside the toast.
  • ProgrammaticToastHandle::dismiss was called from app code.
  • HostShutdown — The host's window is being torn down.
  • SlotPoolFull — The host's slot pool was at max_visible and this toast was dropped (Normal priority overflow) or was evicted by a higher-priority arrival. Reported synthetically so on_dismiss always 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 a Toast carries no explicit .target() / .broadcast() and was presented through a real EventContext (i.e. ctx.show_toast(...) / toast.present(ctx) from an actual input handler) — see EventContextToastExt::show_toast.
  • Audience — every window currently assigned the given ToastAudience via ToastRegistry::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 background AppEvent observer with no EventContext at 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 a Toast builder — only the framework stamps this, from a real EventContext::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 preview

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 preview

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 dropdown menu, an overflow priority (NSToolbar: lowest priority collapses first), and an always_overflow flag (WinUI secondary commands). Each action has a toolbar form (an IconButton, or a PopoverIconButton when it carries a menu) and a menu form (a MenuItem, or a submenu), so it renders correctly whether inline or in the overflow menu.
  • Pinned widgets (ToolbarItem::custom) — arbitrary widgets (a search field, a SegmentedControl) that never collapse.
  • Collapsible widgets — an arbitrary widget that does overflow, by supplying an overflow representation (NSToolbar menuFormRepresentation / Qt QWidgetAction): a menu row (ToolbarAction) via ToolbarItem::custom(w).overflow_as(action) (or ToolbarOverflow + ToolbarItem::collapsible; an icon-only control reuses its icon as the menu glyph), or a live embedded widget via ToolbarItem::custom(w).overflow_widget(f) (the factory rebuilds the control — e.g. a ComboBox bound 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 (default Compact), button_style (a shared IconButtonStyle for 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 preview

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 existing TabWidget precedent 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

TooltipWidget preview

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.
  • RichTooltipWidgetTooltipContent-driven (body + optional long-form "more" disclosure + shortcut chip), inline-markup body so label cascade links resolve against TooltipRegistry. Attached via .rich_tooltip(key) / .rich_tooltip_content(content). On dwell it flips its AT role to Role::Dialog and advertises a Focus action — keyboard focus is not auto-transferred; the user Tabs in (the correct non-modal-panel a11y pattern).
  • composite::CompositeTooltipWidget — hosts an arbitrary impl Widget + 'static body 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 a label cascade. 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 preview

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 preview

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 preview

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, like Collapse. Flips between 0 and natural width over MotionTokens::duration_collapse whenever expanded toggles.
  • Unroll::from_progress(progress) — driven by an external animated Signal<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 preview

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 preview

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 preview

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 preview

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

  • ChartAggregateChartAggregate<T> — a bucket/rollup projection over a crate::ChartModel
  • ChartChange — ChartChange — change notifications and stable series identifiers for chart collections
  • ChartModelChartModel<T> — concrete reactive multi-series chart data model
  • ChartSelectionChartSelection — point-level selection state for chart widgets
  • ChartWindowChartWindow<T> — a "last N points per series" streaming projection over
  • CheckedModelCheckedModel — per-row checkbox state for flat collection widgets
  • CheckStateCheckState — tri-state checkbox value shared by the data layer and widgets
  • DataChangeDataChange — change notifications for flat collections
  • ItemKey — Shared capability types for the data-source drag-and-drop + lazy protocol
  • KeyedSelectionModelKeyedSelectionModel<K> — identity-based selection for collection widgets
  • KeyedTreeCheckedModelKeyedTreeCheckedModel<K> — per-node checkbox state for a tree **keyed by a
  • ListDataSourceListDataSource — read-and-command interface for a flat collection behind a ListView /
  • ListModelListModel<T> — concrete reactive list backed by a Vec<T>
  • SelectionModel — SelectionModel — index-based selection state for collection widgets
  • SeriesPatternSeriesPattern — 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
  • TreeCheckedModelTreeCheckedModel — per-node checkbox state for a tree, with optional
  • TreeDataSliceTreeDataSlice — the reusable TreeDataSource engine for an **external,
  • TreeDataSourceTreeDataSource — read-and-command interface for hierarchical data behind a
  • TreeModelTreeModel — concrete reactive tree with shared, cloneable handles
  • TreeRowFilterTreeRowFilter — sort + tree-aware filter over a TreeRow stream
  • TreeSliceTreeSlice — per-view flattened projection of a TreeModel

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.0 for 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 bumps crate::ChartModel::style_version rather than crate::ChartModel::structure_version.
  • SeriesPatternChanged — A series' explicit SeriesPattern changed (set or cleared). Paint-only, like SeriesColorChanged: it bumps crate::ChartModel::style_version, not structure_version.
  • SeriesVisibilityChanged — A series' visibility flag changed.
  • PointsInserted — Points were inserted; range holds the indices of the newly inserted points within series.
  • PointsRemoved — Points were removed; range holds the indices they occupied before removal within series.
  • 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; range holds the indices of the newly inserted items.
  • ItemsRemoved — Rows were removed; range holds the indices they occupied before removal.
  • ItemsMoved — A contiguous block of count rows moved from from to to (post-removal index).
  • ItemUpdated — A single row's data changed in place without any structural shift.
  • WindowLoaded — A window of previously-Loading rows became Ready (lazy / windowed sources). Semantically like ItemsInserted for a row-height cache (divergence = range.start), but no rows were added — the count was already declared — so a SelectionModel must 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 to map_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 any Clone + Eq + Hash + Debug + 'static type.
  • RowState — whether a lazy row's data is resident (Ready) or still loading (Loading).
  • DragEligibility — per-row drag gate returned by ListDataSource::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 to accept_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_entry returns Some.
  • Loading — The row exists (counts against len/visible_count) but its data is not yet loaded; with_item/with_entry returns None.

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; key identifies the dragged row.
  • Foreign — A drag from another view or the OS; downcast payload to 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 redirects Before/AfterInto).

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::ItemsRemoved for keys present in the old list but absent from new_items;
  • DataChange::ItemsMoved (single-row blocks) to re-order the surviving rows into new_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::ItemsInserted for keys present in new_items but not in the old list;
  • DataChange::ItemUpdated for 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:

linemarkerfilled area
Solidsolidcircleplain
Dashedlong dashsquare45° hatch
Dotteddottedtriangleback-hatch
DashDotdash-dotdiamondcross-hatch
ShortDashshort dashcrosshorizontal
WideDashwide dashplusvertical

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

  • Circle
  • Square
  • Triangle
  • Diamond
  • Cross
  • Plus

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:

  1. Filtering: each column may register a predicate factory; rows that fail any non-empty filter are hidden.
  2. 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 single DataChange::Reset for 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), so Reset is 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 scoped ItemUpdated at the mapped visible index instead of paying for a full re-filter + re-sort + Reset on 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:

  1. Filtering with one of three TreeFilterMode strategies:
    • 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").
  2. 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 of parent at the given index. parent is None for root-level insertions.
  • NodeRemoved — A node (and its entire subtree) was removed. parent is None if 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 stale NodeId whose 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_for for a removed node still return their cached signal handle. Setting it has no observable effect on the tree (the cascade walks tree.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 cascades Checked/Unchecked to 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 (UncheckedChecked); 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 the T: PartialEq bound), 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_drop plumbing; 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 to SortFilterTreeModel).
  • HideNonMatching — a node stays only if it and every ancestor match (children of a hidden parent stay hidden; equivalent to SortFilterTreeModel).
  • 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 from SortFilterTreeModel, 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

  • KeyedPersistedListModel<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
  • ReloadableReloadable — the contract a (separately-built) file watcher uses to
  • SettingsBundleErrorSettingsBundle — declarative configuration for the teksilo-app
  • SettingsExt — Extension traits exposing settings services on BuildContext and
  • SettingsFileErrorSettingsFile<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 Schedule bursts 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 live RETRY_BACKOFF deadline 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 — A Patch could 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 insert T at 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 invariant MruList::add relies 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: boolplacement: 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 / height are 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.
  • maximized and label are 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:

  1. 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_disk returns Ok(false) immediately — no read, no parse, nothing touched. This is the common case for a self-write notification.
  2. 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 via with_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):

  • load acquires the lock, reads + migrates the file fresh, and retains the Migrator for the handle's whole lifetime — not just for this one read — because mutate, replace, reload_if_stale and reload_from_disk all need to re-migrate a peer's still-older on-disk schema on demand, not just once at construction.
  • mutate and replace perform 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. Contrast crate::SettingsStore and crate::PersistedListModel, which write far more often and keep the debounce.
  • reload_if_stale and reload_from_disk are 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 to T::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:

  • Migrate means the TOML parsed fine, but this build's own Migrator chain doesn't know how to bring it up to T::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.
  • Io means 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, calling signal::<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 exceeds T::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 reach T::CURRENT_VERSION.
  • Step — A migration step closure returned Err(message).
  • Deserialize — The migrated toml::Value did not deserialize as T.

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

  • GroupItemGroupItem — labelled box / logical AT container
  • ImageItemImageItem — a raster image at a local-coord rectangle
  • PathItemPathItem — vector path with optional fill and stroke
  • RectItemRectItem — filled / stroked rectangle in local item coords
  • TextAlignTextItem — text in a local-coord rectangle, with alignment + rotation

Scene

  • A11yGroupId — Accessibility policies for SceneView
  • AccessSubtreeMode — Built-in SceneItem implementations
  • CacheMode — Item-coordinate paint caching
  • DebugOverlaySceneView — the viewport widget that hosts a Scene and
  • ItemFlags — Per-item behavior flags
  • ItemId — The SceneItem trait and its supporting context types
  • Magnet — Magnetism: typed snap-and-connect between anchor points on scene items
  • Scene — The Scene data model — the owner of all items in a pannable/zoomable
  • SceneListAdapterSceneListAdapter — keep lightweight scene items in sync with a
  • SceneMinimapSceneMinimap — a small thumbnail of a Scene
  • SceneModelSceneModel — a shared, cloneable handle to a Scene
  • SceneScrollViewSceneScrollView — a thin composite that gives a SceneView draggable
  • SceneSelectionMode — Selection model for Scene items
  • SceneTapEvent — Per-item event handlers, cursor and tooltip overrides
  • SceneViewStateSceneViewState — a snapshot of a SceneView's
  • SpatialIndex — Spatial index for Scene items

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 — lightweight SceneItem or heavyweight Widget added via Scene::add_widget. The walker discriminates by entry kind: lightweight items get a synthetic SyntheticKind::SceneItem AT node; heavyweight items get auto-grafted via the framework redirect hook, landing the real widget's NodeId under the declared parent.
  • Group — A virtual A11yGroup declared via Scene::add_a11y_group.
  • Widget — A real interactive widget addressed by its arena WidgetId. Use this to relocate widgets that aren't Scene::add_widget-managed — typically a descendant of a heavyweight scene item that should logically belong elsewhere (a global ComboBox nested visually inside a Scene card but logically under a top-level "Tools" group). For widgets you added via Scene::add_widget, prefer A11yNode::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

  • Controlsfrom controls to (e.g. a button that opens a menu).
  • DescribedByfrom is described by to (cross-item annotation).
  • LabelledByfrom is labelled by to (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

  • CooperativeDefault. Visual is the AT structure unless overridden. Items inside the off-screen-mode policy emit as direct AT children of SceneView (or their declared logical parent if set_a11y_parent placed them). Heavyweight widgets emit through the arena walker as natural descendants of SceneView. 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 via Scene::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, else SceneView itself. 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 an n × viewport-grown margin around it. n = 0 collapses to "viewport only" with the same allocation pattern as ViewportOnly. n = 1 is the default — gives screen-reader users a one-screen "lookahead" to navigate without ensure_visible round-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-run item.paint every frame. Default for every item.
  • ItemCoordinate — Cache the paint output as a RenderFrame keyed by the item's local_bounds. Cheap when the item's geometry is stable and its content doesn't depend on external signal state. The cache is dropped on LocalBoundsChanged for 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_children plants each materialised heavyweight widget at its scene-space rect (composed from the item's local_pos, transform, and parent chain).
  • Paint bands. Three passes: paint draws the Under lightweight items (backdrop), the arena child-walk draws the heavyweight widgets, then post_paint draws the Over lightweight items + marquee / foreground / debug overlays. z orders within each tier; the Under/Over band (Scene::set_layer) chooses the side. See docs/teksilo-scene.md §"Z-order and paint bands".
  • View transform. Pan / zoom / rotation are four animated Signal<f32>s on SceneView, composed into a derived Signal<Transform2D> bound via BuildContext::set_content_transform on 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_children and the paint walk consult Scene::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 via Easing::EaseOut. Trackpad momentum events from winit arrive as further Pixels deltas; the existing animation pipeline turns this into smooth inertial fling without a custom recognizer.
  • on_pinch — OS trackpad pinch (PinchPhase::Changed) feeds scale into the zoom signal and rotation into 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 handlers set the signals directly instead of animate_to-ing them; pinch is already instantaneous.
  • Drag-to-move for items carrying IS_DRAGGABLE; marquee selection on the empty viewport surface (or under DragMode::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's bounds_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::label but omit fill and stroke, leaving is_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 resulting MagnetConnection (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

  • LocalPosChangedset_local_pos: position in parent coords moved.
  • LocalBoundsChangedset_local_bounds: AABB in local coords changed.
  • TransformChangedset_transform: local→parent transform changed.
  • VisibilityChangedset_visible flipped IS_VISIBLE.
  • FlagsChangedset_flags / set_flag changed the bitset.
  • OpacityChangedset_opacity: local opacity multiplier changed.
  • ZChangedset_z: paint z-order changed.
  • LayerChangedset_layer: the Under/Over paint band changed.
  • ParentChangedset_item_parent: logical parent changed.
  • Removedremove: item is gone.
  • Addedadd_item / add_widget: item was inserted.
  • PayloadChangedset_payload: the type-erased payload of a Delegated heavyweight entry was replaced. A SceneView rebuilds that entry's widget (re-invokes its delegate) on the next build. Routed through emit_item_change, so mutation_seq advances and the AT-walk gate notices.
  • AppearanceChangedset_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 observing SceneView evicts 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. Programmatic SceneView::set_pan / pan_to become 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::AppearanceChangedalways 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-runs register_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 on index (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 at index replaces it.
  • WindowLoaded { range } is treated as a structural change (full rebuild), not a per-row patch. A row for which ListDataSource::with_item returns None (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. Since WindowLoaded fires rarely (after a batch fetch, not per frame), the O(n) cost is a non-issue; internally the id table tracks unloaded rows as None slots 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-viewadd_widget stores 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-viewadd_widget_item stores a type-erased payload; each view's delegate (SceneView::delegate_typed) builds its own instance from the payload. set_payload replaces 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 honours IS_DRAGGABLE for 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 (respecting IS_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 scalar SettingsKey<f32>s in a SettingsStore.

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) where k is 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_ITEM grid 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 separate oversized: HashMap<ItemId, Rect> that query always scans in full, in addition to the cell lookup, keeping an exact AABB-intersection test against scene_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_rect computed (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 bare i32 arithmetic that could itself overflow for large extents (debug builds panicked, release builds could wrap to a huge or negative usize). A single 1e6 × 1e6 logical-pixel item at the clamped-minimum cell_size of 1.0 asked for (1e6+1)² ≈ 1e12 cells — roughly 8 TB for the Vec<(i32, i32)> alone — before any assertion or even the fill loop ran; this was reachable from a single Scene::add_item call, no adversarial input required. Even at the default 256 px cell_size, a 1e6-square item alone reserved (1e6 / 256)² ≈ 1.5e7 cells (~122 MB) for that one item. The same hazard applied to query/items_in_rect, since a caller can pass an arbitrarily large scene_rect too — see query's own oversized-span fallback.

    A custom SpatialIndex would 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.