Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/tui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
152 changes: 152 additions & 0 deletions internal/tui/tabsequence_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading