feat(daemon): overflow horizontal focus across tabs
- commit
- ca3316c9431170425a5f0e407840859476f91503
- author
- brice · · 2026-07-25 05:15 UTC
- parent
- 15f2a25
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +317 -4
A internal/usecase/daemon/navigation_overflow.go +159 -0
| @@ -0,0 +1,159 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type overflowKind uint8 | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | overflowNone overflowKind = iota | |
| 12 | + | overflowTabs | |
| 13 | + | overflowSessions | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type overflowStep struct { | |
| 17 | + | kind overflowKind | |
| 18 | + | delta int | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // resolveOverflow is the pure escalation policy for directional navigation. | |
| 22 | + | // The caller supplies its position on the selected axis, so disabled axes and | |
| 23 | + | // either hard wall all resolve to the zero step. | |
| 24 | + | func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep { | |
| 25 | + | var step overflowStep | |
| 26 | + | switch dir { | |
| 27 | + | case layout.Left: | |
| 28 | + | if cfg.OverflowTabs { | |
| 29 | + | step = overflowStep{kind: overflowTabs, delta: -1} | |
| 30 | + | } | |
| 31 | + | case layout.Right: | |
| 32 | + | if cfg.OverflowTabs { | |
| 33 | + | step = overflowStep{kind: overflowTabs, delta: 1} | |
| 34 | + | } | |
| 35 | + | case layout.Up: | |
| 36 | + | if cfg.OverflowSessions { | |
| 37 | + | step = overflowStep{kind: overflowSessions, delta: -1} | |
| 38 | + | } | |
| 39 | + | case layout.Down: | |
| 40 | + | if cfg.OverflowSessions { | |
| 41 | + | step = overflowStep{kind: overflowSessions, delta: 1} | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count { | |
| 45 | + | return overflowStep{} | |
| 46 | + | } | |
| 47 | + | return step | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type tabOverflowCandidate struct { | |
| 51 | + | source *tab | |
| 52 | + | target *tab | |
| 53 | + | sourceIndex int | |
| 54 | + | delta int | |
| 55 | + | direction layout.Direction | |
| 56 | + | span domain.Rect | |
| 57 | + | entry layout.PaneID | |
| 58 | + | entryPane *pane | |
| 59 | + | targetOldFocus layout.PaneID | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // prepareTabOverflow captures tab identities under the session lock and uses | |
| 63 | + | // EntryPane's pure lookup before any active-tab state is changed. | |
| 64 | + | func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) { | |
| 65 | + | if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) { | |
| 66 | + | return tabOverflowCandidate{}, false | |
| 67 | + | } | |
| 68 | + | sess.mu.Lock() | |
| 69 | + | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |
| 70 | + | sess.mu.Unlock() | |
| 71 | + | return tabOverflowCandidate{}, false | |
| 72 | + | } | |
| 73 | + | sourceIndex := sess.active | |
| 74 | + | targetIndex := sourceIndex + delta | |
| 75 | + | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |
| 76 | + | sess.mu.Unlock() | |
| 77 | + | return tabOverflowCandidate{}, false | |
| 78 | + | } | |
| 79 | + | source := sess.tabs[sourceIndex] | |
| 80 | + | target := sess.tabs[targetIndex] | |
| 81 | + | sess.mu.Unlock() | |
| 82 | + | ||
| 83 | + | source.mu.Lock() | |
| 84 | + | floatingVisible := source.floating.state == floatingVisible | |
| 85 | + | source.mu.Unlock() | |
| 86 | + | if floatingVisible { | |
| 87 | + | return tabOverflowCandidate{}, false | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | target.mu.Lock() | |
| 91 | + | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | |
| 92 | + | entry, err := target.tree.EntryPane(dir, span, area) | |
| 93 | + | entryPane := target.panes[entry] | |
| 94 | + | oldFocus := layout.PaneID("") | |
| 95 | + | if target.tree != nil { | |
| 96 | + | oldFocus = target.tree.Focus | |
| 97 | + | } | |
| 98 | + | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | |
| 99 | + | target.mu.Unlock() | |
| 100 | + | if !valid { | |
| 101 | + | return tabOverflowCandidate{}, false | |
| 102 | + | } | |
| 103 | + | return tabOverflowCandidate{ | |
| 104 | + | source: source, | |
| 105 | + | target: target, | |
| 106 | + | sourceIndex: sourceIndex, | |
| 107 | + | delta: delta, | |
| 108 | + | direction: dir, | |
| 109 | + | span: span, | |
| 110 | + | entry: entry, | |
| 111 | + | entryPane: entryPane, | |
| 112 | + | targetOldFocus: oldFocus, | |
| 113 | + | }, true | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // commitTabOverflow atomically revalidates the source and target tab entries. | |
| 117 | + | // The target layout and selected pane are revalidated while locked before the | |
| 118 | + | // focus and active-tab mutations become visible together. | |
| 119 | + | func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool { | |
| 120 | + | if sess == nil || candidate.source == nil || candidate.target == nil { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | sess.mu.Lock() | |
| 124 | + | targetIndex := candidate.sourceIndex + candidate.delta | |
| 125 | + | if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) || | |
| 126 | + | sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target { | |
| 127 | + | sess.mu.Unlock() | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | candidate.source.mu.Lock() | |
| 132 | + | if candidate.source.floating.state == floatingVisible { | |
| 133 | + | candidate.source.mu.Unlock() | |
| 134 | + | sess.mu.Unlock() | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | candidate.target.mu.Lock() | |
| 138 | + | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | |
| 139 | + | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | |
| 140 | + | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | |
| 141 | + | candidate.target.mu.Unlock() | |
| 142 | + | candidate.source.mu.Unlock() | |
| 143 | + | sess.mu.Unlock() | |
| 144 | + | return false | |
| 145 | + | } | |
| 146 | + | if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil { | |
| 147 | + | candidate.target.mu.Unlock() | |
| 148 | + | candidate.source.mu.Unlock() | |
| 149 | + | sess.mu.Unlock() | |
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | d.applyLayoutLocked(candidate.target) | |
| 153 | + | sess.active = targetIndex | |
| 154 | + | candidate.target.mu.Unlock() | |
| 155 | + | candidate.source.mu.Unlock() | |
| 156 | + | sess.mu.Unlock() | |
| 157 | + | markSnapshotDirty(sess) | |
| 158 | + | return true | |
| 159 | + | } | |
| @@ -0,0 +1,159 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | type overflowKind uint8 | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | overflowNone overflowKind = iota | |||
| 12 | + | overflowTabs | |||
| 13 | + | overflowSessions | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type overflowStep struct { | |||
| 17 | + | kind overflowKind | |||
| 18 | + | delta int | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // resolveOverflow is the pure escalation policy for directional navigation. | |||
| 22 | + | // The caller supplies its position on the selected axis, so disabled axes and | |||
| 23 | + | // either hard wall all resolve to the zero step. | |||
| 24 | + | func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count int) overflowStep { | |||
| 25 | + | var step overflowStep | |||
| 26 | + | switch dir { | |||
| 27 | + | case layout.Left: | |||
| 28 | + | if cfg.OverflowTabs { | |||
| 29 | + | step = overflowStep{kind: overflowTabs, delta: -1} | |||
| 30 | + | } | |||
| 31 | + | case layout.Right: | |||
| 32 | + | if cfg.OverflowTabs { | |||
| 33 | + | step = overflowStep{kind: overflowTabs, delta: 1} | |||
| 34 | + | } | |||
| 35 | + | case layout.Up: | |||
| 36 | + | if cfg.OverflowSessions { | |||
| 37 | + | step = overflowStep{kind: overflowSessions, delta: -1} | |||
| 38 | + | } | |||
| 39 | + | case layout.Down: | |||
| 40 | + | if cfg.OverflowSessions { | |||
| 41 | + | step = overflowStep{kind: overflowSessions, delta: 1} | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if step.kind == overflowNone || count < 2 || position < 0 || position >= count || position+step.delta < 0 || position+step.delta >= count { | |||
| 45 | + | return overflowStep{} | |||
| 46 | + | } | |||
| 47 | + | return step | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | type tabOverflowCandidate struct { | |||
| 51 | + | source *tab | |||
| 52 | + | target *tab | |||
| 53 | + | sourceIndex int | |||
| 54 | + | delta int | |||
| 55 | + | direction layout.Direction | |||
| 56 | + | span domain.Rect | |||
| 57 | + | entry layout.PaneID | |||
| 58 | + | entryPane *pane | |||
| 59 | + | targetOldFocus layout.PaneID | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // prepareTabOverflow captures tab identities under the session lock and uses | |||
| 63 | + | // EntryPane's pure lookup before any active-tab state is changed. | |||
| 64 | + | func (d *Daemon) prepareTabOverflow(sess *session, expectedSource *tab, dir layout.Direction, span domain.Rect, delta int) (tabOverflowCandidate, bool) { | |||
| 65 | + | if sess == nil || expectedSource == nil || (delta != -1 && delta != 1) { | |||
| 66 | + | return tabOverflowCandidate{}, false | |||
| 67 | + | } | |||
| 68 | + | sess.mu.Lock() | |||
| 69 | + | if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource { | |||
| 70 | + | sess.mu.Unlock() | |||
| 71 | + | return tabOverflowCandidate{}, false | |||
| 72 | + | } | |||
| 73 | + | sourceIndex := sess.active | |||
| 74 | + | targetIndex := sourceIndex + delta | |||
| 75 | + | if targetIndex < 0 || targetIndex >= len(sess.tabs) { | |||
| 76 | + | sess.mu.Unlock() | |||
| 77 | + | return tabOverflowCandidate{}, false | |||
| 78 | + | } | |||
| 79 | + | source := sess.tabs[sourceIndex] | |||
| 80 | + | target := sess.tabs[targetIndex] | |||
| 81 | + | sess.mu.Unlock() | |||
| 82 | + | ||||
| 83 | + | source.mu.Lock() | |||
| 84 | + | floatingVisible := source.floating.state == floatingVisible | |||
| 85 | + | source.mu.Unlock() | |||
| 86 | + | if floatingVisible { | |||
| 87 | + | return tabOverflowCandidate{}, false | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | target.mu.Lock() | |||
| 91 | + | area := domain.Rect{Width: target.size.Cols, Height: target.size.Rows} | |||
| 92 | + | entry, err := target.tree.EntryPane(dir, span, area) | |||
| 93 | + | entryPane := target.panes[entry] | |||
| 94 | + | oldFocus := layout.PaneID("") | |||
| 95 | + | if target.tree != nil { | |||
| 96 | + | oldFocus = target.tree.Focus | |||
| 97 | + | } | |||
| 98 | + | valid := err == nil && entryPane != nil && layout.ContainsLeaf(target.tree.Root, entry) | |||
| 99 | + | target.mu.Unlock() | |||
| 100 | + | if !valid { | |||
| 101 | + | return tabOverflowCandidate{}, false | |||
| 102 | + | } | |||
| 103 | + | return tabOverflowCandidate{ | |||
| 104 | + | source: source, | |||
| 105 | + | target: target, | |||
| 106 | + | sourceIndex: sourceIndex, | |||
| 107 | + | delta: delta, | |||
| 108 | + | direction: dir, | |||
| 109 | + | span: span, | |||
| 110 | + | entry: entry, | |||
| 111 | + | entryPane: entryPane, | |||
| 112 | + | targetOldFocus: oldFocus, | |||
| 113 | + | }, true | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // commitTabOverflow atomically revalidates the source and target tab entries. | |||
| 117 | + | // The target layout and selected pane are revalidated while locked before the | |||
| 118 | + | // focus and active-tab mutations become visible together. | |||
| 119 | + | func (d *Daemon) commitTabOverflow(sess *session, candidate tabOverflowCandidate) bool { | |||
| 120 | + | if sess == nil || candidate.source == nil || candidate.target == nil { | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | sess.mu.Lock() | |||
| 124 | + | targetIndex := candidate.sourceIndex + candidate.delta | |||
| 125 | + | if candidate.sourceIndex < 0 || candidate.sourceIndex >= len(sess.tabs) || targetIndex < 0 || targetIndex >= len(sess.tabs) || | |||
| 126 | + | sess.active != candidate.sourceIndex || sess.tabs[candidate.sourceIndex] != candidate.source || sess.tabs[targetIndex] != candidate.target { | |||
| 127 | + | sess.mu.Unlock() | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | candidate.source.mu.Lock() | |||
| 132 | + | if candidate.source.floating.state == floatingVisible { | |||
| 133 | + | candidate.source.mu.Unlock() | |||
| 134 | + | sess.mu.Unlock() | |||
| 135 | + | return false | |||
| 136 | + | } | |||
| 137 | + | candidate.target.mu.Lock() | |||
| 138 | + | area := domain.Rect{Width: candidate.target.size.Cols, Height: candidate.target.size.Rows} | |||
| 139 | + | entry, err := candidate.target.tree.EntryPane(candidate.direction, candidate.span, area) | |||
| 140 | + | if err != nil || entry != candidate.entry || candidate.target.panes[entry] != candidate.entryPane || !layout.ContainsLeaf(candidate.target.tree.Root, entry) { | |||
| 141 | + | candidate.target.mu.Unlock() | |||
| 142 | + | candidate.source.mu.Unlock() | |||
| 143 | + | sess.mu.Unlock() | |||
| 144 | + | return false | |||
| 145 | + | } | |||
| 146 | + | if err := candidate.target.tree.FocusEnter(candidate.direction, candidate.span, area); err != nil { | |||
| 147 | + | candidate.target.mu.Unlock() | |||
| 148 | + | candidate.source.mu.Unlock() | |||
| 149 | + | sess.mu.Unlock() | |||
| 150 | + | return false | |||
| 151 | + | } | |||
| 152 | + | d.applyLayoutLocked(candidate.target) | |||
| 153 | + | sess.active = targetIndex | |||
| 154 | + | candidate.target.mu.Unlock() | |||
| 155 | + | candidate.source.mu.Unlock() | |||
| 156 | + | sess.mu.Unlock() | |||
| 157 | + | markSnapshotDirty(sess) | |||
| 158 | + | return true | |||
| 159 | + | } | |||
A internal/usecase/daemon/navigation_overflow_test.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | dir layout.Direction | |
| 17 | + | cfg domain.NavConfig | |
| 18 | + | position int | |
| 19 | + | count int | |
| 20 | + | want overflowStep | |
| 21 | + | }{ | |
| 22 | + | {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2}, | |
| 23 | + | {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2}, | |
| 24 | + | {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}}, | |
| 25 | + | {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}}, | |
| 26 | + | {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |
| 27 | + | {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2}, | |
| 28 | + | {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}}, | |
| 29 | + | {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}}, | |
| 30 | + | {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |
| 31 | + | {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1}, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | for _, tt := range tests { | |
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |
| 36 | + | require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count)) | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |
| 42 | + | tests := []struct { | |
| 43 | + | name string | |
| 44 | + | start int | |
| 45 | + | action keys.Action | |
| 46 | + | wantActive int | |
| 47 | + | wantFocus layout.PaneID | |
| 48 | + | }{ | |
| 49 | + | {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"}, | |
| 50 | + | {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"}, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 56 | + | sess.mu.Lock() | |
| 57 | + | sess.active = tt.start | |
| 58 | + | target := sess.tabs[tt.wantActive] | |
| 59 | + | source := sess.tabs[tt.start] | |
| 60 | + | sess.mu.Unlock() | |
| 61 | + | target.mu.Lock() | |
| 62 | + | target.size = domain.Size{Cols: 41, Rows: 10} | |
| 63 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |
| 64 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 65 | + | target.mu.Unlock() | |
| 66 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |
| 67 | + | ||
| 68 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | |
| 69 | + | ||
| 70 | + | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | |
| 71 | + | target.mu.Lock() | |
| 72 | + | require.Equal(t, tt.wantFocus, target.tree.Focus) | |
| 73 | + | target.mu.Unlock() | |
| 74 | + | require.NotSame(t, source, sess.activeTab()) | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | |
| 80 | + | tests := []struct { | |
| 81 | + | name string | |
| 82 | + | setup func(d *Daemon, sess *session) | |
| 83 | + | dir keys.Action | |
| 84 | + | }{ | |
| 85 | + | {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight}, | |
| 86 | + | {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft}, | |
| 87 | + | {name: "failed target entry", setup: func(d *Daemon, sess *session) { | |
| 88 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |
| 89 | + | sess.tabs[1].mu.Lock() | |
| 90 | + | sess.tabs[1].tree = nil | |
| 91 | + | sess.tabs[1].mu.Unlock() | |
| 92 | + | }, dir: keys.ActionFocusPaneRight}, | |
| 93 | + | {name: "visible floating pane", setup: func(d *Daemon, sess *session) { | |
| 94 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |
| 95 | + | installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 96 | + | }, dir: keys.ActionFocusPaneRight}, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | for _, tt := range tests { | |
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |
| 101 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 102 | + | tt.setup(d, sess) | |
| 103 | + | ||
| 104 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir) | |
| 105 | + | ||
| 106 | + | require.Equal(t, 0, activeTabIndex(sess)) | |
| 107 | + | }) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |
| 112 | + | tests := []struct { | |
| 113 | + | name string | |
| 114 | + | mutate func(sess *session, candidate tabOverflowCandidate) | |
| 115 | + | }{ | |
| 116 | + | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }}, | |
| 117 | + | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }}, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | for _, tt := range tests { | |
| 121 | + | t.Run(tt.name, func(t *testing.T) { | |
| 122 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |
| 123 | + | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | |
| 124 | + | require.True(t, ok) | |
| 125 | + | sess.mu.Lock() | |
| 126 | + | tt.mutate(sess, candidate) | |
| 127 | + | sess.mu.Unlock() | |
| 128 | + | ||
| 129 | + | require.False(t, d.commitTabOverflow(sess, candidate)) | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) { | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | dir layout.Direction | |||
| 17 | + | cfg domain.NavConfig | |||
| 18 | + | position int | |||
| 19 | + | count int | |||
| 20 | + | want overflowStep | |||
| 21 | + | }{ | |||
| 22 | + | {name: "tabs disabled", dir: layout.Right, cfg: domain.NavConfig{}, position: 0, count: 2}, | |||
| 23 | + | {name: "sessions alone do not enable tabs", dir: layout.Right, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2}, | |||
| 24 | + | {name: "right tab", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2, want: overflowStep{kind: overflowTabs, delta: 1}}, | |||
| 25 | + | {name: "left tab", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2, want: overflowStep{kind: overflowTabs, delta: -1}}, | |||
| 26 | + | {name: "left wall", dir: layout.Left, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |||
| 27 | + | {name: "right wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 1, count: 2}, | |||
| 28 | + | {name: "up session", dir: layout.Up, cfg: domain.NavConfig{OverflowSessions: true}, position: 1, count: 2, want: overflowStep{kind: overflowSessions, delta: -1}}, | |||
| 29 | + | {name: "down session", dir: layout.Down, cfg: domain.NavConfig{OverflowSessions: true}, position: 0, count: 2, want: overflowStep{kind: overflowSessions, delta: 1}}, | |||
| 30 | + | {name: "tabs alone do not enable sessions", dir: layout.Down, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 2}, | |||
| 31 | + | {name: "single destination is a wall", dir: layout.Right, cfg: domain.NavConfig{OverflowTabs: true}, position: 0, count: 1}, | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | for _, tt := range tests { | |||
| 35 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 36 | + | require.Equal(t, tt.want, resolveOverflow(tt.dir, tt.cfg, tt.position, tt.count)) | |||
| 37 | + | }) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestKeyboardHorizontalOverflowLandsOnFacingEdge(t *testing.T) { | |||
| 42 | + | tests := []struct { | |||
| 43 | + | name string | |||
| 44 | + | start int | |||
| 45 | + | action keys.Action | |||
| 46 | + | wantActive int | |||
| 47 | + | wantFocus layout.PaneID | |||
| 48 | + | }{ | |||
| 49 | + | {name: "right enters target left edge", start: 0, action: keys.ActionFocusPaneRight, wantActive: 1, wantFocus: "pane-1"}, | |||
| 50 | + | {name: "left enters target right edge", start: 1, action: keys.ActionFocusPaneLeft, wantActive: 0, wantFocus: "pane-2"}, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 56 | + | sess.mu.Lock() | |||
| 57 | + | sess.active = tt.start | |||
| 58 | + | target := sess.tabs[tt.wantActive] | |||
| 59 | + | source := sess.tabs[tt.start] | |||
| 60 | + | sess.mu.Unlock() | |||
| 61 | + | target.mu.Lock() | |||
| 62 | + | target.size = domain.Size{Cols: 41, Rows: 10} | |||
| 63 | + | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"} | |||
| 64 | + | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |||
| 65 | + | target.mu.Unlock() | |||
| 66 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |||
| 67 | + | ||||
| 68 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | |||
| 69 | + | ||||
| 70 | + | require.Equal(t, tt.wantActive, activeTabIndex(sess)) | |||
| 71 | + | target.mu.Lock() | |||
| 72 | + | require.Equal(t, tt.wantFocus, target.tree.Focus) | |||
| 73 | + | target.mu.Unlock() | |||
| 74 | + | require.NotSame(t, source, sess.activeTab()) | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestKeyboardHorizontalOverflowRespectsDefaultsWallsFailedEntryAndFloating(t *testing.T) { | |||
| 80 | + | tests := []struct { | |||
| 81 | + | name string | |||
| 82 | + | setup func(d *Daemon, sess *session) | |||
| 83 | + | dir keys.Action | |||
| 84 | + | }{ | |||
| 85 | + | {name: "defaults off", setup: func(_ *Daemon, _ *session) {}, dir: keys.ActionFocusPaneRight}, | |||
| 86 | + | {name: "left wall", setup: func(d *Daemon, _ *session) { d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) }, dir: keys.ActionFocusPaneLeft}, | |||
| 87 | + | {name: "failed target entry", setup: func(d *Daemon, sess *session) { | |||
| 88 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |||
| 89 | + | sess.tabs[1].mu.Lock() | |||
| 90 | + | sess.tabs[1].tree = nil | |||
| 91 | + | sess.tabs[1].mu.Unlock() | |||
| 92 | + | }, dir: keys.ActionFocusPaneRight}, | |||
| 93 | + | {name: "visible floating pane", setup: func(d *Daemon, sess *session) { | |||
| 94 | + | d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowTabs: true}}) | |||
| 95 | + | installTestFloating(sess.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 96 | + | }, dir: keys.ActionFocusPaneRight}, | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | for _, tt := range tests { | |||
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 102 | + | tt.setup(d, sess) | |||
| 103 | + | ||||
| 104 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.dir) | |||
| 105 | + | ||||
| 106 | + | require.Equal(t, 0, activeTabIndex(sess)) | |||
| 107 | + | }) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestCommitTabOverflowRevalidatesTabPointerIdentities(t *testing.T) { | |||
| 112 | + | tests := []struct { | |||
| 113 | + | name string | |||
| 114 | + | mutate func(sess *session, candidate tabOverflowCandidate) | |||
| 115 | + | }{ | |||
| 116 | + | {name: "source no longer active", mutate: func(sess *session, _ tabOverflowCandidate) { sess.active = 1 }}, | |||
| 117 | + | {name: "target entry replaced", mutate: func(sess *session, candidate tabOverflowCandidate) { sess.tabs[1] = newTab(nil, candidate.target.size) }}, | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | for _, tt := range tests { | |||
| 121 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 122 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil, nil) | |||
| 123 | + | candidate, ok := d.prepareTabOverflow(sess, sess.tabs[0], layout.Right, domain.Rect{Width: 80, Height: 23}, 1) | |||
| 124 | + | require.True(t, ok) | |||
| 125 | + | sess.mu.Lock() | |||
| 126 | + | tt.mutate(sess, candidate) | |||
| 127 | + | sess.mu.Unlock() | |||
| 128 | + | ||||
| 129 | + | require.False(t, d.commitTabOverflow(sess, candidate)) | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
M internal/usecase/daemon/pane_actions.go +26 -4
| @@ -309,11 +309,33 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 309 | 309 | if target.pane != nil { | |
| 310 | 310 | oldFocus = target.pane.id | |
| 311 | 311 | } | |
| 312 | - | _, err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 313 | - | if err == nil && ac != nil { | |
| 314 | - | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | |
| 312 | + | span, err := d.focusDirAt(sess, target.tab, target.pane, dir) | |
| 313 | + | if err == nil { | |
| 314 | + | if ac != nil { | |
| 315 | + | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | |
| 316 | + | } | |
| 317 | + | return nil | |
| 315 | 318 | } | |
| 316 | - | return err | |
| 319 | + | if !errors.Is(err, errNoNeighbor) || target.tab == nil { | |
| 320 | + | return err | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | sess.mu.Lock() | |
| 324 | + | position, count := sess.active, len(sess.tabs) | |
| 325 | + | sess.mu.Unlock() | |
| 326 | + | step := resolveOverflow(dir, d.currentNavConfig(), position, count) | |
| 327 | + | if step.kind != overflowTabs { | |
| 328 | + | return err | |
| 329 | + | } | |
| 330 | + | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |
| 331 | + | if !ok || !d.commitTabOverflow(sess, candidate) { | |
| 332 | + | return errNoNeighbor | |
| 333 | + | } | |
| 334 | + | d.activateTab(sess, candidate.target) | |
| 335 | + | if ac != nil { | |
| 336 | + | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go") | |
| 337 | + | } | |
| 338 | + | return nil | |
| 317 | 339 | } | |
| 318 | 340 | ||
| 319 | 341 | // finishPaneFocusForClient applies the attachment lifecycle shared by every | |
| @@ -309,11 +309,33 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 309 | if target.pane != nil { | 309 | if target.pane != nil { | ||
| 310 | oldFocus = target.pane.id | 310 | oldFocus = target.pane.id | ||
| 311 | } | 311 | } | ||
| 312 | - | _, err := d.focusDirAt(sess, target.tab, target.pane, dir) | 312 | + | span, err := d.focusDirAt(sess, target.tab, target.pane, dir) |
| 313 | - | if err == nil && ac != nil { | 313 | + | if err == nil { |
| 314 | - | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | 314 | + | if ac != nil { |
| 315 | + | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "pane_actions.go") | |||
| 316 | + | } | |||
| 317 | + | return nil | |||
| 315 | } | 318 | } | ||
| 316 | - | return err | 319 | + | if !errors.Is(err, errNoNeighbor) || target.tab == nil { |
| 320 | + | return err | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | sess.mu.Lock() | |||
| 324 | + | position, count := sess.active, len(sess.tabs) | |||
| 325 | + | sess.mu.Unlock() | |||
| 326 | + | step := resolveOverflow(dir, d.currentNavConfig(), position, count) | |||
| 327 | + | if step.kind != overflowTabs { | |||
| 328 | + | return err | |||
| 329 | + | } | |||
| 330 | + | candidate, ok := d.prepareTabOverflow(sess, target.tab, dir, span, step.delta) | |||
| 331 | + | if !ok || !d.commitTabOverflow(sess, candidate) { | |||
| 332 | + | return errNoNeighbor | |||
| 333 | + | } | |||
| 334 | + | d.activateTab(sess, candidate.target) | |||
| 335 | + | if ac != nil { | |||
| 336 | + | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "pane_actions.go") | |||
| 337 | + | } | |||
| 338 | + | return nil | |||
| 317 | } | 339 | } | ||
| 318 | 340 | ||||
| 319 | // finishPaneFocusForClient applies the attachment lifecycle shared by every | 341 | // finishPaneFocusForClient applies the attachment lifecycle shared by every | ||