From 041da143a91cfec77725a9128081afbf949fe079 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:47:02 -0400 Subject: [PATCH] Fix(tui): `[b` and `]b` switch tabs from every view and panel With Vim mode on, `[b` / `]b` (and `gt` / `gT`) switched tabs only while a note editor had focus, because the two-key sequences were matched only in the editor's key path. On Home, Tasks, Calendar, the database views and the side panels the first key went straight to the surface, so the sequence never completed. handleKey now matches the buffer and tab sequences before handing a key to any surface other than a note editor, honouring custom keymaps. When the second key does not complete a sequence, the held first key is replayed to the surface and the second follows, so `]` month navigation and `gg` keep working, one key later. Counts such as `3]b` stay editor-only. The held key lasts only until the next key: an overlay, chord, leader key, Ctrl+W prefix, mouse click, focus moving to a note editor or Vim mode turning off drops it without replaying it, and Esc cancels it. Tests, the cycling one red first: `]b` / `[b` from a view, a key that does not complete the sequence reaching the view, and a held key dropped by each of those paths. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 1 + internal/tui/input.go | 54 ++++++++++- internal/tui/mouse.go | 3 + internal/tui/tabsequence_test.go | 152 +++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 internal/tui/tabsequence_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..405f362 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -107,6 +107,7 @@ type App struct { overlay overlay leader *leaderState navKeys []vim.Key + tabSeq []vim.Key // first key of a buffer/tab sequence typed outside the editor paneKeys bool // tagMenu is the tag completion under the cursor; tagDismissed the token diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..1d6f878 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -38,6 +38,8 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { } a.deferReads = true defer func() { a.deferReads = false }() + held := a.tabSeq + a.tabSeq = nil if a.message != "" && !(k.Is("esc") && a.overlay == nil) { a.message, a.messageErr = "", false } @@ -100,6 +102,15 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { return a.afterKey() } } + if a.tabSequenceKey(k, held) { + return a.afterKey() + } + a.surfaceKey(k) + return a.afterKey() +} + +// surfaceKey hands a key to the focused surface. +func (a *App) surfaceKey(k vim.Key) { switch a.focus { case focusSidebar: a.sidebar.handleKey(a, k) @@ -114,7 +125,48 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { default: a.paneKey(k) } - return a.afterKey() +} + +// tabSequenceKey runs the buffer and tab sequences (`[b`, `]b`, `gt`, `gT`) +// from every surface that is not a note editor; the editor handles them in +// editorKey. A started sequence that does not complete is replayed to the +// surface, so a lone `]` still reaches the view that binds it. +func (a *App) tabSequenceKey(k vim.Key, held []vim.Key) bool { + if !a.prefs.VimMode || len(a.navKeys) > 0 { + return false + } + if a.focus == focusPane { + if t := a.activeTab(); t != nil && t.view == nil && a.buffers[t.path] != nil && t.mode != modePreview { + return false + } + } + seqIDs := []string{"vim.bufferPrevious", "vim.bufferNext", "vim.tabPrevious", "vim.tabNext"} + if len(held) > 0 { + if k.Is("esc") { + return true + } + seq := append(append([]vim.Key{}, held...), k) + for _, id := range seqIDs { + if complete, _ := matchSequence(seq, bindingKeys(a.keymap.Binding(id))); complete { + a.runSequenceAction(id, 1, false) + return true + } + } + a.surfaceKey(seq[0]) + return false + } + for _, id := range seqIDs { + keys := bindingKeys(a.keymap.Binding(id)) + if len(keys) > 1 && sameKey(k, keys[0]) { + a.tabSeq = []vim.Key{k} + return true + } + if len(keys) == 1 && sameKey(k, keys[0]) { + a.runSequenceAction(id, 1, false) + return true + } + } + return false } // completionKey takes the keys insert-mode completion owns: the ones that diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 7fda616..72cdcb8 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -123,6 +123,9 @@ func (a *App) dividerAt(n *paneNode, x, y int) *paneNode { // handleMouse is the entry point for every mouse event. func (a *App) handleMouse(m tea.MouseMsg) { + if m.Action != tea.MouseActionMotion { + a.tabSeq = nil + } switch m.Action { case tea.MouseActionRelease: a.mouse.drag = "" diff --git a/internal/tui/tabsequence_test.go b/internal/tui/tabsequence_test.go new file mode 100644 index 0000000..7bd84cc --- /dev/null +++ b/internal/tui/tabsequence_test.go @@ -0,0 +1,152 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/keymaps" + "github.com/ZenNotes/tui/internal/vim" +) + +type probeView struct{ keys []vim.Key } + +func (v *probeView) title() string { return "probe" } +func (v *probeView) hint(a *App) string { return "" } +func (v *probeView) refresh(a *App) {} +func (v *probeView) render(a *App, w, h int, focused bool) string { return "" } +func (v *probeView) handleKey(a *App, k vim.Key) bool { + v.keys = append(v.keys, k) + return true +} + +func tabSequenceApp() (*App, *probeView) { + a := &App{keymap: keymaps.NewResolver(nil), theme: buildTheme(true), prefs: prefsView{}} + a.prefs.VimMode = true + pv := &probeView{} + p := &pane{tabs: []*tab{{path: "virtual:a", view: pv}, {path: "virtual:b", view: &probeView{}}}} + a.panes = &paneNode{leaf: p} + a.activePane = p + a.focus = focusPane + return a, pv +} + +func TestBufferSequencesCycleTabsFromAView(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]b") + if a.activePane.active != 1 { + t.Fatalf("]b from a view: active = %d", a.activePane.active) + } + typeKeys(a, "[b") + if a.activePane.active != 0 { + t.Fatalf("[b from a view: active = %d", a.activePane.active) + } + if len(pv.keys) != 0 { + t.Fatalf("a completed sequence leaked keys to the view: %v", pv.keys) + } +} + +func TestBracketNotFollowedByBReachesTheView(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]x") + if a.activePane.active != 0 || len(pv.keys) != 2 { + t.Fatalf("active=%d keys=%v", a.activePane.active, pv.keys) + } +} + +type closeOverlay struct{} + +func (closeOverlay) handleKey(a *App, k vim.Key) { a.overlay = nil } +func (closeOverlay) render(a *App, w, h int) string { return "" } + +func requireDropped(t *testing.T, a *App, pv *probeView) { + t.Helper() + if a.activePane.active != 0 { + t.Fatalf("a stale held key switched tabs: active = %d", a.activePane.active) + } + for _, k := range pv.keys { + if k.IsRune(']') { + t.Fatalf("a held key was replayed to the view: %v", pv.keys) + } + } +} + +func TestHeldKeyIsDroppedWhenAnOverlayTakesTheInput(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.overlay = closeOverlay{} + a.handleKey(vim.R('x')) + typeKeys(a, "b") + requireDropped(t, a, pv) + typeKeys(a, "]b") + if a.activePane.active != 1 { + t.Fatalf("a fresh sequence after the drop: active = %d", a.activePane.active) + } +} + +func TestHeldKeyIsDroppedByALeaderSequence(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleKey(vim.R(' ')) + a.leader = nil + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedByThePanePrefix(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleKey(vim.Ctrl('w')) + a.paneKeys = false + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedByAMouseClick(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleMouse(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedWhenFocusMovesToAnEditor(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.openNote("note.md", true) + note := a.activePane.active + pv := &probeView{} + a.activePane.tabs = append(a.activePane.tabs, &tab{path: "virtual:a", view: pv}, &tab{path: "virtual:b", view: &probeView{}}) + view := len(a.activePane.tabs) - 2 + a.activePane.active = view + a.focus = focusPane + typeKeys(a, "]") + a.activePane.active = note + a.handleKey(vim.KeyEsc) + a.activePane.active = view + typeKeys(a, "b") + if a.activePane.active != view || len(pv.keys) != 1 || !pv.keys[0].IsRune('b') { + t.Fatalf("active = %d keys = %v", a.activePane.active, pv.keys) + } +} + +func TestEscapeCancelsAHeldKeyWithoutReplay(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.handleKey(vim.KeyEsc) + if len(pv.keys) != 0 { + t.Fatalf("Esc replayed the held key: %v", pv.keys) + } + typeKeys(a, "b") + requireDropped(t, a, pv) +} + +func TestHeldKeyIsDroppedWhenVimModeTurnsOff(t *testing.T) { + a, pv := tabSequenceApp() + typeKeys(a, "]") + a.prefs.VimMode = false + a.handleKey(vim.R('x')) + a.prefs.VimMode = true + pv.keys = nil + typeKeys(a, "b") + requireDropped(t, a, pv) +}