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

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