diff --git a/CHANGELOG.md b/CHANGELOG.md index 2589f0f8..d6857cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/assets/keymaps/vim.yaml b/assets/keymaps/vim.yaml index a6adc410..9975bc2a 100644 --- a/assets/keymaps/vim.yaml +++ b/assets/keymaps/vim.yaml @@ -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 diff --git a/lib/src/shell/root_shell.dart b/lib/src/shell/root_shell.dart index da2eb635..7a40a986 100644 --- a/lib/src/shell/root_shell.dart +++ b/lib/src/shell/root_shell.dart @@ -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 { // (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 { /// (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 { 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 diff --git a/test/app_test.dart b/test/app_test.dart index 439cbe21..c1931986 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -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; diff --git a/test/builtin/editor/vim_preset_test.dart b/test/builtin/editor/vim_preset_test.dart index acb35686..02449cb0 100644 --- a/test/builtin/editor/vim_preset_test.dart +++ b/test/builtin/editor/vim_preset_test.dart @@ -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 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()); + expect(seq(matcher(), ['ctrl+w', 'ctrl+w']), isA()); + expect(seq(matcher(), ['ctrl+w', 'shift+w']), isA()); + }); + + 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 + }); + }); }