Shortcut / Intent / Action Reference
Teksilo's input-to-behavior pipeline has three first-class concepts:
Shortcut, a rebindable keyboard binding (KeyStroke→ intent name). Owned by theShortcutRegistry; user rebindings layer on top of widget-declared defaults.Intent, a runtime "something wants to happen" message: a stable name plus an optional type-erased payload. Produced by shortcuts, by widgets viactx.send_intent(...), or programmatically.Action, a widget-owned handler bound to an intent name. When an intent dispatches, the framework walks source-widget → root and lets the first matching enabled action consume (or propagate) it.
Pipeline in one line: KeyStroke → Shortcut → Intent → Action handler.
Typed DTO bridge between an app's intent enum and the runtime
usually derived with #[derive(IntentKind)] from teksilo-macros.
Full end-to-end example:
examples/shortcuts_demo.
Mental model: three paths, one dispatcher
Every intent hits the same dispatcher, actions don't care where the intent came from. The three firing paths:
| Path | How the intent is built | Anchor for source→root walk |
|---|---|---|
| Shortcut (keyboard chord) | Registry invokes on_activate or synthesizes Intent::new | Focused widget or root fallback |
Widget handler (ctx.send_intent) | Handler builds or returns an Into<Intent> value | The originating widget |
| Programmatic (tests, tools) | Build Intent by hand or via IntentKind::into_intent | First arena root (WidgetTree::run_with_event_context) |
The name is the dispatch key; the payload (if any) is downcastable data the handler extracts when it needs typed fields.
KeyStroke
A single chord, one Key plus its Modifiers:
#![allow(unused)] fn main() { KeyStroke::new(Key::S, Modifiers::CTRL) KeyStroke::ctrl(Key::S) // same thing KeyStroke::ctrl_shift(Key::S) KeyStroke::command(Key::S) // ⌘S on macOS, Ctrl+S elsewhere KeyStroke::command_shift(Key::S) KeyStroke::alt(Key::Enter) KeyStroke::new(Key::PageUp, Modifiers::NONE) // plain PageUp }
Display renders "Ctrl+S" style text. Widgets displaying shortcuts to users
should call teksilo_widgets::keystroke_format::format_keystroke() which handles
platform-specific symbols (⌘ on macOS) and locale-aware modifier names via
tr_widget! (e.g., "Strg" in German). Serialize/Deserialize are derived
so user overrides can be persisted.
Ctrl means ⌘ on macOS
Desktop platforms disagree about which key carries application accelerators, and
on macOS the disagreement is not cosmetic: Control there belongs to the text
system and to the secondary click, while ⌘ is what a user presses for Save or
Find. So a declared shortcut default written with Ctrl is read as the
platform's primary accelerator and resolves to ⌘ on macOS, the convention Qt
spells Qt::CTRL, and the one Teksilo's native menu bar has always applied to
its key equivalents. Write the chord once:
#![allow(unused)] fn main() { Shortcut::new("editor.find").primary(KeyStroke::ctrl(Key::F)).build() // Ctrl+F on Windows and Linux, ⌘F on macOS, one declaration, no cfg branching. }
KeyStroke::command(Key::F) is the same chord with the intent stated outright;
prefer it for chords built outside the registry, such as the labels a
context menu renders for itself. Modifiers::COMMAND and Modifiers::command()
are the raw forms, for widgets testing a live Modifiers value.
Three consequences worth knowing:
- User overrides are literal. A chord captured in a settings UI is taken exactly as pressed, so physical ⌃F stays bindable on macOS. Only declared defaults go through the convention.
- A chord that names
Superexplicitly is left alone, soCtrl+Supersurvives as the genuine ⌃⌘ two-modifier chord. - Some chords really are Control everywhere. Ctrl+Tab cycles tabs on macOS
too (⌘⇥ belongs to the application switcher and never reaches an app), and the
⌘ form of Space, H or Q is taken by the system. Declare those with
ShortcutBuilder::literal_modifiers()and no rewriting happens on any platform:
#![allow(unused)] fn main() { Shortcut::new("view.next_tab") .literal_modifiers() .primary(KeyStroke::ctrl(Key::Tab)) .build() }
Caret motion is a separate question
Inside a text surface the accelerator is not the whole story. macOS lays the
caret motions out across three modifiers, ⌥←/→ for word, ⌘←/→ for the
line edge, ⌘↑/↓ for the document, where Windows and Linux use Ctrl+←/→
plus bare Home/End. No single "is the accelerator held?" flag can express
that, so RichTextEditor, CodeEditor and every field built on
TextInputField read their arrows through
common::text_nav instead.
That is internal to the widgets; app-declared Shortcuts are unaffected.
Shortcut
Declarative, rebindable record. Built with a fluent ShortcutBuilder:
#![allow(unused)] fn main() { use teksilo::core::shortcut::{KeyStroke, Shortcut, ShortcutScope}; Shortcut::new("app.save") // stable id (dispatch key) .name("Save") // menu/settings label .category("file") // grouping key (stable, orders groups) .category_label(tr!(menu_file())) // what the group header says .primary(KeyStroke::ctrl(Key::S)) // default primary chord .secondary(KeyStroke::new(Key::F12, Modifiers::NONE)) // .scope(ShortcutScope::Global) // default // .scope_to(scope_root_id) // widget-scoped variant // .enabled_when(has_selection_signal) // reactive "live" predicate // .propagate_when_disabled(false) // consume-when-disabled instead // .on_activate(|ks, ctx| AppIntent::ScrollBy(...)) // parametric .build(); }
Key fields (source):
id: &'static str, stable key used for persistence, menu lookups (MenuItem::for_shortcut), and dispatch. Dot-style convention:"editor.format.bold". Doubles as the intent name when.intent(...)isn't set.primary/secondary, the two default chords. User overrides (loaded from disk or set through the settings UI) are applied per slot independently.scope: ShortcutScope,Global(fires regardless of focus) orScoped(WidgetId)(fires only when focus is inside that subtree). Widget-declared shortcuts default to scoped; app-level declarations use global.on_activate, optional closure invoked at activation time. Receives the matchedKeyStroke(so you can branch on which chord fired) and anEventContext(for side effects). Returns anythingInto<Intent>, typically anIntentKindvariant. Omit when the shortcut only needs the name: the registry synthesizesIntent::new(intent_name)for you.enabled_when: Option<Prop<bool>>, reactive "is this shortcut live?" predicate (the builder takes anyimpl Into<Prop<bool>>, so aSignal<bool>or a plainbool). Whenfalse, the shortcut is treated as if not registered, the keystroke falls through to the focused widget's normalon_keydispatch. Compose composite predicates with theSignal<bool>combinators (and/or/not) orSignal::zipfor typed tuples.propagate_when_disabled: bool, controls what happens when the matchingActionis disabled:true(default) lets the intent continue bubbling;falseconsumes at that level ("owned but dormant").
Composing enabled_when predicates
enabled_when takes any Signal<bool>, and Signal ships combinators
for multi-source predicates that correctly dirty-track every upstream
root:
#![allow(unused)] fn main() { let editor_focused: Signal<bool> = …; let readonly: Signal<bool> = …; let in_editor: Signal<bool> = …; // `focus && !readonly && in_editor`, each source registered independently // with the binding registry, so widgets observing `when` re-render on any flip. let when = editor_focused.and(&readonly.not()).and(&in_editor); Shortcut::new("edit.format.bold") .primary(KeyStroke::ctrl(Key::B)) .enabled_when(when) .build(); }
Available on Signal<bool>: and, or, not. Available on any
Signal<T: Clone>: zip(&Signal<U>) -> Signal<(T, U)>,
zip3(&Signal<U>, &Signal<V>) -> Signal<(T, U, V)>, and map for
arbitrary projections. The same combinators work for Action::enabled_when.
ShortcutRegistry
Two-layer store, both keyed by shortcut id (&'static str):
- Defaults, records registered by widgets during
build()or declared statically viaWidget::declare_shortcuts. Re-registering the same id upserts: code-owned fields are refreshed, the user override is preserved. Id is the unique key, so two widgets declaring the same id share the entry, see Same-id collisions. - Overrides, user-supplied keystroke rebindings keyed by shortcut id, persisted across widget rebuilds (graveyard semantics, a widget that disappears and reappears keeps its customised bindings).
The merged view is EffectiveShortcut:
primary/secondary = user override if touched, else declared default.
Menus, tooltips, and dispatch consume this shape.
Every mutation bumps ShortcutRegistry::version(), a Signal<u64>.
Menus, tooltips, and settings widgets observe it and re-read through
effective(id) to refresh labels after rebinds.
Registration from a widget
Inside build():
#![allow(unused)] fn main() { // Widget-scoped (default: Scoped(self_id), fires only when focus is // inside the widget's subtree): ctx.register_shortcut( Shortcut::new("editor.format.bold") .name("Bold") .primary(KeyStroke::ctrl(Key::B)) .build(), ); // App-level (Global, fires regardless of focus): ctx.register_shortcut_global( Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ); }
Both register with ownership: when the widget is destroyed or
rebuilt, the framework calls unregister_all_for_owner(widget_id) so
stale entries don't leak.
Static declaration: Widget::declare_shortcuts
ctx.register_shortcut runs from build(), so a chord only enters
the registry once its owning widget has actually been built. That's
fine for always-mounted widgets, build() runs immediately on
insert. It's not fine when the widget lives behind a lazy
boundary:
- A
Switcherarm that hasn't been selected yet (lazy mount: the page widget stays Boxed until first selection). - A subtree gated by a feature flag or a closed disclosure.
- Anything else that defers
build().
For those cases, the chord won't appear in ShortcutSettings (or any
other registry consumer) until the user happens to visit that
subtree at least once. A rebind UI whose contents depend on where
you've clicked is the wrong shape.
Widget::declare_shortcuts(&self) -> Vec<Shortcut> opts in to
eager registration of metadata, same id and keystrokes, no
handler:
#![allow(unused)] fn main() { impl Widget for SaveTools { fn declare_shortcuts(&self) -> Vec<Shortcut> { // Metadata only, no on_activate, no captured state. vec![ Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .build(), ] } fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> { // Install the handler. Same id, the registry upserts. let do_save = self.do_save.clone(); ctx.register_shortcut( Shortcut::new("app.save") .name("Save") .primary(KeyStroke::ctrl(Key::S)) .on_activate(move |_, _| { (do_save)(); Intent::new("app.save") }) .build(), ); // ... } } }
The framework walks declare_shortcuts at three sites:
- Insertion,
tree.add(w)/ctx.add_child(parent, w), right after handler-set extraction, beforebuild(). - Rebuild, right after
unregister_all_for_ownerwipes the previous build's registrations, so declared metadata survives the rebuild cycle even ifbuild()only conditionally re-registers. Switcher::build, for every still-Pendingslot. The Switcher pre-registers each lazy page's declared shortcuts owned by itself, so the chord is visible from the moment the Switcher builds, without mounting the page. When the page is eventually mounted, the insertion walk re-registers the same id owned by the page widget; the registry's idempotent upsert transfers ownership cleanly and preserves any user override.
When you need it. Any widget that might live behind a lazy boundary, or any widget whose chord must appear in a rebind UI on the first frame regardless of which views the user has visited.
When you don't. Always-mounted widgets (app root, top-level
toolbar, modeless docked panels). Build-time register_shortcut
already runs immediately on insert, same visibility, no
duplication.
Pairing convention. When you opt in, mirror the metadata in
both methods (same id, name, default keystrokes). declare_shortcuts
omits on_activate; register_shortcut adds it. The registry's
upsert refreshes the entry with the handler-bearing version when
build() runs.
The default impl is empty (fn declare_shortcuts(&self) -> Vec<Shortcut> { Vec::new() }),
so existing widgets keep working unchanged. This is strictly
opt-in.
Same-id collisions
The registry is keyed by id, not by (id, owner). Two widgets
registering the same id is not an error, it's intentional aliasing.
Concrete behaviour:
defaults: HashMap<&'static str, Shortcut>, the second registration replaces the first (last-write-wins). Metadata, default keystrokes, and handler from the loser are discarded.overrides: HashMap<String, KeyStrokeOverride>, one override per id. A user rebind of"app.save"applies to whichever shortcut is currently indefaults. Two widgets sharing an id share the user rebind.by_ownertracks one owner per id at a time. The newer registration'sregister_ownedcallsdetach_owner_indexto pull the id off the previous owner's cleanup list, then the new owner inherits it. Destroying the previous owner doesn't touch the entry; destroying the current owner removes it (and any remaining declaration would have to re-register to refill).
Use this intentionally. If two widgets implement the same
logical action ("app.save" from a toolbar button, a menu item,
and a keyboard chord all targeting the same code), declaring the
same id is correct, the user rebinds once, all three follow.
The footgun. Two unrelated widgets accidentally picking the
same id. Rebinding one silently rebinds the other. Hierarchical
dotted ids prevent this in practice (editor.format.bold, not just
bold); there's no namespacing enforcement at the type level.
Framework-internal chords use a __ prefix by convention
(__teksilo_inspector.pick) so app ids can't collide with them.
Same-chord precedence
Distinct ids may bind the same chord, a normal IDE pattern (a
global Ctrl+W "close window" alongside a panel-scoped Ctrl+W "close
tab"). When a chord matches more than one enabled shortcut, the
dispatcher resolves them by focus and scope specificity, not by id
order:
- Applicability first. A
Scoped(id)binding is a candidate only when the focused widget is insideid's subtree. AGlobalbinding is always a candidate. - Most-specific scope wins. An applicable
Scopedbinding beats aGlobalone. Among nested applicable scopes, the one closest to the focused widget (deepest) wins. - Id order is only a tiebreak within equal specificity (the
deterministic
(category, id)order ofiter_effective).
So with focus in the editor, the editor-scoped Ctrl+W fires; move
focus to the sidebar and the global Ctrl+W fires instead. An
inapplicable scoped binding never "eats" the chord from an applicable
global one, and a global binding never shadows an in-focus scoped one.
Scope applicability needs the widget tree (descendant checks), which the
registry can't see, so it hands back every candidate via
matches_by_keystroke and the dispatcher does the focus-aware
selection. (find_by_keystroke, which returns just the first by id
order, ignores scope and is for non-dispatch queries only.)
This is cross-id collision resolution by focus; it is distinct from
the rebind-time conflict check (find_conflict,
used by the settings UI to auto-unbind), and from the same-id
aliasing above.
Per-slot overrides
User overrides are per-slot (SlotOverride::{Default, Bound(ks), Unbound}):
Default, delegate to whatever default the shortcut currently declares (a later code-side change flows through).Bound(ks), lock the slot to this chord.Unbound, lock the slot to no chord.
Rebinding primary does not disturb secondary, and vice versa. The
registry's rebind_primary / rebind_secondary only touch the
targeted slot, they do not auto-unbind conflicting shortcuts.
Use ShortcutRegistry::find_conflict(keystroke, excluding_id) before
rebinding if you want the "exactly one effective binding per chord"
invariant; that is what the pre-built
ShortcutSettings
widget does when it records a chord. By default it asks first: the
row shows "Ctrl+S is already used by Save" with Reassign and Cancel,
and nothing changes until the user answers.
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: each chord is a control that records a new
chord when activated, refuses keys that cannot be shortcuts (a bare
letter, Shift plus a letter, reserved chords such as Ctrl+Tab), asks
before taking a chord from another command, and resets a changed row.
Intent
Runtime message, name + optional payload. Construction:
#![allow(unused)] fn main() { use teksilo::core::Intent; // Name-only (parameter-less): let i = Intent::new("app.save"); // Typed payload (any T: 'static, stored in an Rc<dyn Any>): let i = Intent::with_payload("app.scroll_by", -1_i32); let i = Intent::with_payload("app.add_item", my_dto); // Blanket conversion from any IntentKind variant: let i: Intent = AppIntent::Save.into(); }
Recover the payload by type:
#![allow(unused)] fn main() { if let Some(&delta) = intent.payload::<i32>() { … } }
from_intent is the typed counterpart when the payload was built from
an IntentKind:
#![allow(unused)] fn main() { if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) { open_file(path); } }
IntentResponse
Action handlers return IntentResponse:
Handled(default), stop walking; the intent is consumed here.Propagated, observe-and-keep-going; ancestor widgets also get a chance. Useful when a widget wants to react (update a draft indicator) but lets an ancestor perform the primary action.
ActionBuilder::on_invoke always reports Handled, use
on_invoke_with_response when you need to propagate.
IntentKind: typed DTO bridge
Use #[derive(IntentKind)] on an enum that catalogs the app's intents.
Each variant declares its name via #[name = "..."]:
#![allow(unused)] fn main() { use teksilo::IntentKind; #[derive(Debug, IntentKind)] enum AppIntent { // Unit variants, no payload fields: #[name = "app.save"] Save, #[name = "app.quit"] Quit, // Tuple variants, whole variant is the payload: #[name = "app.open"] Open(String), #[name = "app.scroll_by"] ScrollBy(i32), // Struct variants work identically: #[name = "app.goto_line"] GoToLine { line: u32 }, // Complex payloads are fine too: #[name = "app.add_item"] AddItem { id: i64, dto: CreateItemDto }, } }
What the derive generates (verbatim):
#![allow(unused)] fn main() { impl IntentKind for AppIntent { fn into_intent(self) -> Intent { let name: &'static str = match &self { Self::Save => "app.save", Self::Open(..) => "app.open", Self::GoToLine { .. } => "app.goto_line", // ... }; Intent::with_payload(name, self) } fn from_intent(intent: &Intent) -> Option<&Self> { intent.payload::<Self>() } } }
A blanket impl<K: IntentKind> From<K> for Intent lets most call sites
skip the explicit .into_intent():
#![allow(unused)] fn main() { ctx.send_intent(AppIntent::Save); // unit ctx.send_intent(AppIntent::Open(path)); // tuple ctx.send_intent(AppIntent::GoToLine { line: 42 }); // struct }
Why the derive is dumb on purpose
The macro never inspects fields. Any variant shape works, unit,
tuple, struct, arbitrary user types, because the whole variant is
stored as the payload. The only requirement: the enum itself is
'static (typically trivially true).
Trade-off this codifies: Teksilo sits between Flutter's fully-typed
Intents (no strings anywhere) and Qt's string-keyed QAction.
Names are the dispatch key; IntentKind layers compile-time checking
on top when the app opts in. Third-party widgets can still declare
intents without knowing the consuming app's enum.
Action
Widget-owned handler for one intent name:
#![allow(unused)] fn main() { use teksilo::core::{Action, IntentResponse}; ctx.register_action( Action::new("app.save") .on_invoke(|_intent, _ctx| { println!("saved"); }), ); }
Key bits:
- One action per intent name per widget. Register multiple for different names on the same widget if needed; at a given level, if two actions match the same name, the first (by declaration order) wins.
intent: &'static str, the dispatch key. Must exactly matchIntent::name. Typo-safety comes fromIntentKind's name attributes, not from the action side.enabled_when: Option<Prop<bool>>, reactive predicate (builder takesimpl Into<Prop<bool>>). Whenfalse, the action is skipped during dispatch (the intent propagates past this level as if no match existed here, unless the firing shortcut haspropagate_when_disabled == false, in which case it is consumed dormant).on_invoke(|intent, ctx| …), handler that always reportsHandled.on_invoke_with_response(|intent, ctx| …) -> IntentResponse, when the handler needs to decideHandledvsPropagatedat runtime.
Scoped vs global actions
ctx.register_action(action) attaches the action to the registering widget's
node, it only fires when that widget is on the intent's source→root walk.
That's right for actions co-located with their UI (a panel handling a command
fired from within itself).
ctx.register_action_global(action) registers an app-global action consulted
as a dispatch fallback, after the source→root walk finds no consuming node
action, so it fires no matter where the intent originated. Use it for app-wide
commands whose handler lives at the app root but whose triggers are scattered
across the tree and the window chrome:
- A menu-bar dropdown renders in an overlay, not under the widget that
built the menu, so a
MenuEntry::intent("app.x")dispatched from it will not reach an action registered withregister_actionon a sibling widget (e.g. the app body). This is the most common footgun: the menu item looks wired but nothing happens. - A global shortcut with no widget focused anchors at the arena root; a scoped action deep in the tree won't be on that walk.
register_action_global is the action-side counterpart to
register_shortcut_global. Ownership applies: the action is torn down when the
registering widget rebuilds or is destroyed. Multiple globals for the same intent
name fire in registration order, honouring IntentResponse (Handled stops,
Propagated continues to the next global).
#![allow(unused)] fn main() { // App root: command reachable from the menu bar, a shortcut, and content alike. ctx.register_shortcut_global( Shortcut::new("view.toggle_sidebar").primary(KeyStroke::ctrl(Key::B)).build(), ); ctx.register_action_global( Action::new("view.toggle_sidebar").on_invoke(|_i, _c| sidebar.toggle()), ); }
Handler patterns: extract only when needed
The framework already name-matches before invoking a handler, an
action's invocation is proof of intent.name == action.intent. You
only call from_intent when you need the typed fields.
#![allow(unused)] fn main() { // Unit intent, no fields to extract, react by name alone. // This also means the handler fires whether the intent came from // a shortcut (name-only) or from `send_intent(AppIntent::Save)`. Action::new("app.save").on_invoke(|_intent, _ctx| { println!("[action] Save"); }); // Data-bearing intent, extract the typed variant: Action::new("app.open").on_invoke(|intent, _ctx| { if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) { open_file(path); } }); }
Dispatch walk
From
widget_tree::dispatch_intent:
- Build the chain
source → parent → … → root. - For each
idin the chain:- Skip if the node is inactive or disabled.
- Find the first action on that node whose
intent == intent.name. If none, continue to the parent. - If the action is disabled: restore it and either
continue(whenpropagate_when_disabled) orreturn(otherwise). - Invoke the handler. On
Handled→ return. OnPropagated→ continue.
- Global fallback. If the chain walk consumed nothing, consult the
window-global actions (registered via
register_action_global) in registration order. First enabled match handles it (Handled→ stop,Propagated→ next global). This is position-independent, so it catches intents from menu-bar overlays and root-anchored shortcuts that the source→root walk would otherwise miss.
Handlers may call ctx.send_intent(...) from inside; those intents
queue and drain after the current one, until the queue empties. FIFO
ordering.
Source anchoring
- Shortcut path: anchor is the focused widget for scoped shortcuts. Global shortcuts use the focused widget when present, otherwise fall back to the first arena root, so global shortcuts fire even before anything has been focused or after the focused widget is destroyed by a rebuild.
ctx.send_intent(...): anchor is the widget whose handler ran. Defaultpropagate_when_disabled = true, programmatic sends have no shortcut to consult and take the least-surprising path. Note:️ 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.- Outside any handler (a test, an app-layer hook):
WidgetTree::dispatch_intentis crate-internal; callctx.send_intent(...)insidetree.run_with_event_context(ops, |ctx| …), which anchors at the first arena root.
Focus invalidation on destroy
WidgetTree::destroy_subtree clears the focused and hovered widget
when they point at the widget about to be destroyed. Without this, a
rebuild of a currently-focused subtree (classic scenario: hitting
Rebind and editing a chord) would leave focus pointing at a dead id,
making subsequent global shortcuts look dead until the user clicked
elsewhere.
Interaction with on_key_preview
A KeyDown event flows through three stages, in this order:
- Shortcut resolution. The registry is consulted before any
widget dispatch.
ShortcutRegistry::matches_by_keystrokeyields every enabled shortcut bound to the chord; the dispatcher then picks the one whose scope applies to the current focus (see Same-chord precedence above). If an applicable shortcut is found, its intent is dispatched, and the key event is consumed if an action handled the intent. When nothing did (no action matched, or every one returnedPropagated), the event falls through to stage 2 as if no shortcut had matched. So does an event for which no candidate applies (every match is aScopedbinding outside the focused subtree). - Ancestor key preview. If no shortcut matched, the framework
walks the focused widget's strict ancestors root → parent-of-target,
firing
on_key_previewon each. ReturningEventResponse::Handledconsumes the event. - Focused widget bubble. If preview returned
Ignoredfor every ancestor, the focused widget's ownon_keyruns, then the event bubbles to ancestors via theiron_keyslots.
Two things run ahead of stage 1: an armed begin_key_capture takes the
KeyDown outright, and a focused surface built with
.keyboard_capture(true) (a terminal) skips shortcut resolution entirely,
so a host Ctrl+C shortcut cannot steal the chord it forwards to its child.
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.
Standard edit shortcuts: cut, copy, paste, undo, redo, select all
Every widget tree registers seven global shortcuts at creation, one per
StandardEdit command.
The id is also the intent name:
| Command | Id | Default chord |
|---|---|---|
| Undo | edit.undo | Ctrl+Z |
| Redo | edit.redo | Ctrl+Shift+Z and Ctrl+Y (Ctrl+Y first on Windows) |
| Cut | edit.cut | Ctrl+X |
| Copy | edit.copy | Ctrl+C |
| Paste | edit.paste | Ctrl+V |
| Paste Unformatted | edit.paste_plain | Ctrl+Shift+V |
| Select All | edit.select_all | Ctrl+A |
Ctrl is ⌘ on macOS, as for every declared chord. They sit in the edit
category, so ShortcutSettings lists them under Edit, in the user's
language (teksilo-app names them in every window it opens), and a user
can rebind them or give them a second chord like any other shortcut.
The text widgets (TextInputField and everything built on it,
RichTextEditor, CodeEditor, LogView) and the select-all of the data
views (ListView, TreeView, TableView, TreeTableView, GridView)
do not match these chords in on_key. Each registers an action per command
on its own node, so the focused widget, the start of the walk, runs the
command. A widget that declines one (Cut in a read-only field, a command
its filter refuses, select-all in a single-selection list) returns
Propagated: the intent goes on up the walk, and if nothing takes it the
key reaches the focused widget as a plain KeyDown.
A widget of your own that copies, pastes or selects all should do the
same: register an Action for the edit.* id rather than match the
chord. A widget that still matches Ctrl+C keeps copying on it after the
user has moved Copy elsewhere.
Editor shortcuts: format and code commands
The editors' own commands work the same way, with one difference: they exist only while an editor that runs them does.
| Command | Id | Default chord | Declared by |
|---|---|---|---|
| Bold | format.bold | Ctrl+B | RichTextEditor |
| Italic | format.italic | Ctrl+I | RichTextEditor |
| Underline | format.underline | Ctrl+U | RichTextEditor |
| Insert Paragraph | format.insert_paragraph | Ctrl+Enter | RichTextEditor |
| Toggle Comment | code.toggle_comment | Ctrl+/ and Ctrl+Shift+/ | CodeEditor |
| Duplicate | code.duplicate | Ctrl+D | CodeEditor |
The ids are on
FormatShortcut and CodeShortcut.
Ctrl+Shift+/ is there for the layouts that type / with Shift (AZERTY,
German): the chord arrives with Shift held. Each editable editor declares its
set with BuildContext::register_shortcut_shared, so ShortcutSettings
lists Bold under Format only in an application that has a rich-text editor,
and a read-only viewer declares nothing.
A shared registration is one global entry with several owners. The editors
cannot use register_shortcut_global: an owned id belongs to one widget and
goes with it, so destroying one editor would take Bold from every other.
register_shortcut_shared keeps the entry while any owner lives, the last one
to go removes it, and the user's binding waits in the registry for the next
editor, as every override does. Use it for any command several instances of a
widget run, each from an Action of its own.
Routing an application's own undo
An application that wants one Undo command (one chord, one menu row)
handles edit.undo; it does not register Ctrl+Z itself, which would
conflict with the standard shortcut:
#![allow(unused)] fn main() { ctx.register_action_global(Action::new(StandardEdit::Undo.id()).on_invoke(|_, _| { app_undo(); })); }
The order of the walk does the routing. With a text widget focused, its own action is reached first and undoes its text; with focus anywhere else, the walk ends at the application's action. A rename box in a table cell, a search field, an input inside a dialog the application did not write: each undoes itself, without the application knowing it exists.
Two cases still need the application to ask which widget holds the caret:
- Menus and toolbars. A menu row's intent starts its walk in the menu's overlay, which never passes through the editor, so it reaches only the global action.
- A refused history. An editor whose filter refuses Undo (a forward-only writing mode, a read-only review pass) declines it, and the intent then reaches the application's action. Routing it to the application's own history there would defeat the mode.
Every text widget calls
BuildContext::register_text_surface,
so the tree answers both 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(); // In the global edit.undo action: match surfaces.focused() { Some(surface) if surface.history_frozen() => {} // refused: do nothing 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.
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 commands from a menu and your widget quietly loses it.
Falling back when the caret's history is empty
By default a focused text widget answers Undo and Redo even when it has nothing to step through, so from the keyboard the application's action is reached only where no text widget has focus. An application whose Undo falls back to a wider history once the caret's own is exhausted (and says so, in a menu row that names what it will undo) turns that on for the whole tree:
#![allow(unused)] fn main() { // Once, in the root's build. ctx.text_surfaces().set_empty_history_passes_on(true); }
RichTextEditor, TextInputField and every field built on it, and
CodeEditor then decline Undo when their document has nothing to undo,
and Redo when it has nothing to redo, and the intent climbs to the
application's action as it does from a refused history. They ask the
document when the command runs, not the debounced can_undo signal, so a
character typed a moment earlier is still undone in the widget. Leave it
off unless the application announces the fallback: otherwise Ctrl+Z in an
empty search field undoes something the user is not looking at.
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 --- // Global: the File menu below renders in an overlay, so its intent's // source→root walk never passes through `Root` (see "Scoped vs global // actions"). ctx.register_action_global( Action::new("app.save") .on_invoke(|_intent, _ctx| println!("saved")), ); ctx.register_action(Action::new("app.open").on_invoke(|intent, _ctx| { if let Some(AppIntent::Open(path)) = AppIntent::from_intent(intent) { open_file(path); } })); ctx.register_action(Action::new("app.scroll_by").on_invoke(|intent, _ctx| { if let Some(AppIntent::ScrollBy(delta)) = AppIntent::from_intent(intent) { scroll(*delta); } })); // --- UI, menus, buttons, tooltips all reference shortcuts // by id. Labels refresh when the user rebinds because the // widgets observe `shortcut_registry.version()`. let menu = MenuBar::new().menu(lit!("File"), || { Box::new(MenuList::new().item( MenuItem::new(lit!("Save")) .for_shortcut("app.save") .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)), )) }); let save_button = Button::new(lit!("Save")) .on_activate_fn(|ctx| ctx.send_intent(AppIntent::Save)); let root = ctx.add(VStack::new().child(menu).child(save_button)); self.root_child_id = Some(root); vec![root] } // layout_response delegates to root child… } }
Cheat sheet
| Task | API |
|---|---|
| Declare a keyboard shortcut | Shortcut::new("id").primary(KeyStroke::…).build() |
| Register widget-scoped | ctx.register_shortcut(shortcut) |
| Register app-level | ctx.register_shortcut_global(shortcut) |
| Declare metadata eagerly (lazy-safe) | fn declare_shortcuts(&self) -> Vec<Shortcut> on the Widget impl |
| Parametric payload | .on_activate(|ks, ctx| AppIntent::X(…)) |
| Disable reactively | .enabled_when(signal) |
| Composite predicate (AND/OR/NOT) | a.and(&b.not()), a.or(&b), s.not() on Signal<bool> |
| Tuple multi-source signal | a.zip(&b), a.zip3(&b, &c) |
| Switch to a selected inner signal | selector.flat_map(|t| inner_signal(t)) |
| Consume when disabled | .propagate_when_disabled(false) |
| Declare a handler | Action::new("id").on_invoke(|intent, ctx| …) |
| Propagate after observing | .on_invoke_with_response(|i, c| IntentResponse::Propagated) |
| Register handler on widget | ctx.register_action(action) |
| Register app-global handler (menu/chrome) | ctx.register_action_global(action) |
| Fire programmatically | ctx.send_intent(AppIntent::X) |
| Typed enum bridge | #[derive(IntentKind)] + #[name = "…"] on each variant |
| Recover typed variant | AppIntent::from_intent(intent) |
| Raw payload lookup | intent.payload::<T>() |
| Observe registry changes | ctx.shortcut_registry().version(), Signal<u64> |
| Effective view of a shortcut | ctx.effective_shortcut("id"), merged defaults + overrides |
| Menu label follows rebinds | MenuItem::new(...).for_shortcut("id") |
| Tooltip shows chord + rebinds live | TooltipContent::new(...).for_shortcut("id") |
| Rebind UI out of the box | ShortcutSettings::new() |
| One-shot key capture | ctx.begin_key_capture(|ks, registry, ctx| …), returns CaptureHandle |
See also
- Working demo:
examples/shortcuts_demo/src/main.rs - Source:
crates/teksilo-core/src/shortcut.rs,intent.rs,action.rs - Derive macro:
crates/teksilo-macros/src/intent_kind.rs - Pre-built settings widget:
crates/teksilo-widgets/src/shortcut_settings.rs - Architecture §11: one-paragraph summary pointing back here