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) +}