keystroke mapper layer — intents, presets, when-clauses (T-117)
Build the upstream of every keyboard-driven feature: widgets bind
to typed Intents, the keymap resolves chord+context to an Intent,
and Flutter's Actions dispatches. The widget never touches a key.
Layers (low → high precedence):
1. preset YAML in assets/keymaps/<preset>.yaml
2. extension-registered command bindings (via
KeymapService.registerCommandBinding from ExtensionManager)
3. user file at <appDir>/keybindings.yaml
4. settings JSON overlay at app.keymap.overrides
The when-clause grammar is a tiny recursive-descent parser over
boolean expressions on a named context bag — VS-Code style
`palette.open && !textInputFocused`. Producing services publish
scope flags via setScopeFlag.
Keys reference LogicalKeyboardKey.keyId (stable across keyboard
layouts), not the locale-aware keyLabel the consultant flagged.
Ships:
- lib/kernel/src/keymap/{key_chord, when_clause, intents, keymap,
keymap_service}.dart
- assets/keymaps/default.yaml (the baseline preset)
- 90+ unit tests covering parser precedence, layering precedence,
scope evaluation, register/unregister, settings overlay,
malformed-input tolerance
- app.dart root handler routes through KeymapService → Actions
- ExtensionManager mirrors every legacy defaultBinding into the
keymap as a contribution layer
KeybindingResolver kept temporarily as a back-compat shim for
callers we haven't migrated yet; safe to delete once the last
caller goes through Actions.
Closes T-110 (consultant: scoped Shortcuts/Actions; off keyLabel).
Annotates T-23 with what's left for T-100. Unblocks T-64 / T-65 /
T-66 (preset data tickets).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-47
@@ -87,30 +87,58 @@ class _RootShellState extends State<_RootShell> {
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(_textScale)),
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
_HatBar(kernel: widget.services),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
],
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
setState(() => _textScale = 1.0);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
_HatBar(kernel: widget.services),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -120,31 +148,14 @@ class _RootShellState extends State<_RootShell> {
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed;
|
||||
if (ctrl) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.equal || event.logicalKey == LogicalKeyboardKey.add) {
|
||||
setState(() => _textScale = (_textScale + _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.minus) {
|
||||
setState(() => _textScale = (_textScale - _scaleStep).clamp(_scaleMin, _scaleMax));
|
||||
return;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.digit0) {
|
||||
setState(() => _textScale = 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
final binding = KeybindingResolver.fromKeyEvent(
|
||||
event,
|
||||
HardwareKeyboard.instance,
|
||||
);
|
||||
if (binding == null) return;
|
||||
final commandId = widget.services.keybindings.commandFor(binding);
|
||||
if (commandId == null) return;
|
||||
widget.services.commands.execute(commandId);
|
||||
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
|
||||
if (intent == null) return;
|
||||
// Dispatch the intent. Try the focused context first so feature
|
||||
// widgets (palette, editor, …) get a chance to handle their own
|
||||
// intents; fall back to the app root's Actions for global ones
|
||||
// (text scale, generic command bridge).
|
||||
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
|
||||
Actions.maybeInvoke(ctx, intent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user