vim ctrl+w window-command family + global multi-chord matcher (T-404)

The structural piece: a global SequenceMatcher in root_shell, at the
HardwareKeyboard level so a focused editor/pane can't swallow the second
chord. It only STARTS on a modified chord that prefixes a sequence (ctrl+w),
so bare-key sequences (gg, dd) stay editor/pane-local and single-chord presets
are untouched; bare ctrl+w still fires editor.close after the D-82 timeout.

vim.yaml binds the window family under vim.normal||vim.visual: ctrl+w h/l →
panel.focus.left/right, j → dock.toggle, w / ctrl+w → focus.nextPanel,
shift+w → focus.previousPanel, o → panel.focusMode, q/c → editor.close.

Tests: ctrl+w sequence resolution at the keymap layer, plus app-level
integration (ctrl+w o toggles focus mode; bare ctrl+w closes the editor after
the timeout; a bare g is not grabbed globally).

This is the global matcher T-405 part 2 (gt/gT) was waiting on — though bare-g
sequences need more thought (g is editor-local), noted for that follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:19:32 +02:00
co-authored by Claude Opus 4.8
parent 5a21ea949a
commit cbe76938a8
5 changed files with 215 additions and 0 deletions
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Vim `ctrl+w` window commands.** Under the vim preset, `ctrl+w` followed by
h/l (focus left/right panel), j (toggle dock), w / ctrl+w (cycle panels),
shift+w (cycle back), o (focus mode), or q/c (close editor). A new global
multi-chord matcher in the shell resolves these from any focus; bare `ctrl+w`
still closes the editor after the ambiguity timeout. (T-404)
- **Workspace tab cycling with ctrl+pagedown / ctrl+pageup.** New
`workspace.tab.next` / `workspace.tab.previous` commands cycle the workspace
tab strip with wraparound, bound across every preset. (T-405)
+28
View File
@@ -100,6 +100,34 @@ bindings:
keys: [o, enter]
when: "vim.normal && !editor.focused"
# ---- ctrl+w window-command family (T-404) ----------------------------
# Multi-chord sequences resolved by the GLOBAL matcher (root_shell), so they
# work from any focus. Bare ctrl+w still closes the editor after the ambiguity
# timeout (the editor.close binding below / contributions layer). The 3-column
# clide layout approximates vim's window grid: h/l focus left/right panels,
# j toggles the dock, o is "only" (focus mode), q/c close the editor.
- intent: command:panel.focus.left
keys: ctrl+w h
when: "vim.normal || vim.visual"
- intent: command:panel.focus.right
keys: ctrl+w l
when: "vim.normal || vim.visual"
- intent: command:dock.toggle
keys: ctrl+w j
when: "vim.normal || vim.visual"
- intent: focus.nextPanel
keys: [ctrl+w w, ctrl+w ctrl+w]
when: "vim.normal || vim.visual"
- intent: focus.previousPanel
keys: ctrl+w shift+w # ctrl+w W
when: "vim.normal || vim.visual"
- intent: command:panel.focusMode
keys: ctrl+w o
when: "vim.normal || vim.visual"
- intent: command:editor.close
keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual"
# ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual
keys: v
+77
View File
@@ -4,6 +4,8 @@
/// of app.dart (T-394).
library;
import 'dart:async';
import 'package:clide/builtin/menubar/menubar.dart';
import 'package:clide/builtin/welcome/src/welcome_view.dart';
import 'package:clide/kernel/kernel.dart';
@@ -31,17 +33,33 @@ class RootShellState extends State<RootShell> {
// (T-341, T-409).
final ModifierTapTracker _modTap = ModifierTapTracker();
// Global multi-chord matcher for window/tab commands (ctrl+w h, gt …) (T-404).
// The passive KeyboardListener can't run sequences or consume the second
// chord (a focused editor/pane swallows it), so this lives at the
// HardwareKeyboard level where returning true consumes the event before focus
// dispatch. It only engages for chords that START a multi-chord binding in the
// active keymap, so single-chord presets (default/vscode/jetbrains) are
// untouched.
late final SequenceMatcher _globalSeq;
Timer? _seqTimeout;
@override
void initState() {
super.initState();
_keyFocus = FocusNode()..requestFocus();
widget.services.textZoom.addListener(_onZoom);
_globalSeq = SequenceMatcher(
keymap: () => widget.services.keymap.keymap ?? Keymap(const []),
context: () => widget.services.keymap.scope,
captureCounts: false,
);
HardwareKeyboard.instance.addHandler(_onRawKey);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_onRawKey);
_seqTimeout?.cancel();
widget.services.textZoom.removeListener(_onZoom);
_menuBar.dispose();
_keyFocus.dispose();
@@ -171,6 +189,9 @@ class RootShellState extends State<RootShell> {
/// (the `;` of `Shift+;`) still dirties the press (T-341, T-409). Fires on
/// the second clean *release*; never consumes anything.
bool _onRawKey(KeyEvent event) {
// Global window/tab sequences (ctrl+w h, gt …) get first claim — handled
// here so a focused editor/pane can't swallow the second chord (T-404).
if (_handleGlobalSequence(event)) return true;
if (event is KeyDownEvent) {
var mod = KeyChord.modifierForLogicalKey(event.logicalKey);
// A modifier pressed while a non-modifier is already held (rolled
@@ -190,6 +211,62 @@ class RootShellState extends State<RootShell> {
bool _nonModifierHeld() => HardwareKeyboard.instance.logicalKeysPressed.any((k) => KeyChord.modifierForLogicalKey(k) == null);
/// Feed one key into the global multi-chord matcher (T-404). Returns true to
/// CONSUME the event (suppressing focus dispatch) while a sequence is being
/// built or completes; false leaves the normal single-chord [_onKey] path
/// untouched. Only KeyDown events drive it — a held key must not re-fire a
/// window command.
bool _handleGlobalSequence(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final chord = KeyChord.fromKeyEvent(event, HardwareKeyboard.instance);
if (chord == null) return false;
final km = widget.services.keymap.keymap;
if (km == null) return false;
final scope = widget.services.keymap.scope;
// Not mid-sequence: only START on a MODIFIED chord that's a sequence prefix
// (ctrl+w …). Bare-key sequences (gg, dd) are editor/pane-local — the
// focused widget owns them, so a global grab would steal the first chord
// before the editor ever saw it. Once pending, the bare second chord (the
// `h` of `ctrl+w h`) is consumed normally. Single-chord presets are
// untouched (no prefix → no engage).
if (!_globalSeq.hasPending) {
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (!modified || !km.match([chord], scope).isPrefix) return false;
}
final r = _globalSeq.feed(chord);
switch (r.outcome) {
case SeqOutcome.pending:
_armSeqTimeout();
return true;
case SeqOutcome.fired:
_cancelSeqTimeout();
_dispatchIntent(r.intent!);
return true;
case SeqOutcome.unmatched:
// The sequence broke — drop the buffer and let this lone key through to
// normal handling (the abandoned prefix, e.g. a bare ctrl+w, simply
// does nothing rather than firing late).
_cancelSeqTimeout();
return false;
}
}
/// After a pending prefix, fire its buffered exact match (bare ctrl+w →
/// editor.close) if no completing chord arrives in time — the d-vs-dd timeout
/// (D-82), applied globally.
void _armSeqTimeout() {
_seqTimeout?.cancel();
_seqTimeout = Timer(const Duration(milliseconds: 400), () {
final r = _globalSeq.flush();
if (r.outcome == SeqOutcome.fired) _dispatchIntent(r.intent!);
});
}
void _cancelSeqTimeout() {
_seqTimeout?.cancel();
_seqTimeout = null;
}
void _dispatchIntent(Intent 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
+52
View File
@@ -236,6 +236,58 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('ctrl+w o fires a window command via the global matcher, not editor.close (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
expect(f.services.arrangement.isInFocusMode, isFalse);
// ctrl+w (chord) then a BARE o → panel.focusMode. The second chord is
// consumed at the hardware level, so a focused pane can't swallow it.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isTrue);
});
testWidgets('bare ctrl+w closes the editor after the ambiguity timeout (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
expect(f.services.arrangement.editorOpen, isTrue);
// ctrl+w with no completing chord: pends, then the timeout flushes the
// exact bare-ctrl+w binding (editor.close from the contributions layer).
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump(const Duration(milliseconds: 450));
expect(f.services.arrangement.editorOpen, isFalse);
});
testWidgets('a bare-key sequence prefix (g) is not grabbed by the global matcher (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
// `g` is a prefix (gg) but bare → editor/pane-local. The global matcher must
// NOT consume it or fire a window command; the editor stays open.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isFalse);
expect(f.services.arrangement.editorOpen, isTrue);
});
testWidgets('window control buttons render and tap as no-ops in tests', (tester) async {
await pumpApp(tester);
// _RightHatContent renders ClideTappable window buttons on non-macOS;
+53
View File
@@ -126,4 +126,57 @@ void main() {
expect(_cmd(resolve('j', visual)), 'editor.vim.down');
});
});
group('ctrl+w window family (T-404)', () {
SequenceMatcher matcher([Keymap? k]) => SequenceMatcher(keymap: () => k ?? km, context: () => normal, captureCounts: false);
Intent? seq(SequenceMatcher m, List<String> chords) {
SeqResult? r;
for (final c in chords) {
r = m.feed(KeyChord.parse(c));
}
return r?.intent;
}
test('ctrl+w h/l/j/o resolve to the panel commands', () {
expect(_cmd(seq(matcher(), ['ctrl+w', 'h'])), 'panel.focus.left');
expect(_cmd(seq(matcher(), ['ctrl+w', 'l'])), 'panel.focus.right');
expect(_cmd(seq(matcher(), ['ctrl+w', 'j'])), 'dock.toggle');
expect(_cmd(seq(matcher(), ['ctrl+w', 'o'])), 'panel.focusMode');
});
test('ctrl+w w and ctrl+w ctrl+w cycle panels; shift+w cycles back', () {
expect(seq(matcher(), ['ctrl+w', 'w']), isA<FocusNextPanelIntent>());
expect(seq(matcher(), ['ctrl+w', 'ctrl+w']), isA<FocusNextPanelIntent>());
expect(seq(matcher(), ['ctrl+w', 'shift+w']), isA<FocusPreviousPanelIntent>());
});
test('ctrl+w q and ctrl+w c close the editor', () {
expect(_cmd(seq(matcher(), ['ctrl+w', 'q'])), 'editor.close');
expect(_cmd(seq(matcher(), ['ctrl+w', 'c'])), 'editor.close');
});
test('bare ctrl+w is a live prefix; the timeout flush fires editor.close', () {
// editor.close's bare ctrl+w binding comes from the default-layout
// contributions layer, which sits under the preset in the real app.
final layered = Keymap([
KeymapLayer.fromYaml(File('assets/keymaps/vim.yaml').readAsStringSync()),
KeymapLayer(
name: 'contrib',
bindings: [KeymapBinding.chord(KeyChord.parse('ctrl+w'), intent: const InvokeCommandIntent('editor.close'))],
),
]);
final m = matcher(layered);
expect(m.feed(KeyChord.parse('ctrl+w')).outcome, SeqOutcome.pending);
expect(_cmd(m.flush().intent), 'editor.close'); // bare ctrl+w → close, after the wait
});
test('ctrl+w sequences need vim.normal/visual — inert under no vim scope', () {
final m = SequenceMatcher(keymap: () => km, context: () => const {}, captureCounts: false);
// With no vim scope, ctrl+w isn't a sequence prefix here, so the first
// chord doesn't pend on the family.
expect(m.feed(KeyChord.parse('ctrl+w')).outcome, isNot(SeqOutcome.fired));
expect(seq(matcher(km), ['ctrl+w', 'h']), isNotNull); // but it does under vim.normal
});
});
}