detached 20a6275

feat(layout): add cross-tree focus entry primitives

commit
20a6275cbc46626fc44373fc0f5f395978616227
author
brice · · 2026-07-25 05:04 UTC
parent
c240dab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +258 -0
A internal/usecase/layout/cross_focus_test.go +178 -0
@@ -0,0 +1,178 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestFocusSpan(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{X: 5, Y: 7, Width: 41, Height: 5}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ area domain.Rect
18+ want domain.Rect
19+ wantErr error
20+ }{
21+ {
22+ name: "nil root",
23+ tree: &Tree{},
24+ area: area,
25+ wantErr: ErrNotFound,
26+ },
27+ {
28+ name: "layout too small",
29+ tree: NewTree("a"),
30+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
31+ wantErr: ErrTooSmall,
32+ },
33+ {
34+ name: "focused pane unknown",
35+ tree: &Tree{Root: NewLeaf("a"), Focus: "missing"},
36+ area: area,
37+ wantErr: ErrNotFound,
38+ },
39+ {
40+ name: "returns collapsed stack title bar",
41+ tree: &Tree{
42+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"},
43+ Focus: "b",
44+ },
45+ area: area,
46+ want: domain.Rect{X: 5, Y: 11, Width: 41, Height: 1},
47+ },
48+ }
49+
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ got, err := tt.tree.FocusSpan(tt.area)
53+ require.ErrorIs(t, err, tt.wantErr)
54+ require.Equal(t, tt.want, got)
55+ })
56+ }
57+}
58+
59+func TestEntryPane(t *testing.T) {
60+ t.Parallel()
61+
62+ area := domain.Rect{Width: 41, Height: 5}
63+ tests := []struct {
64+ name string
65+ tree *Tree
66+ dir Direction
67+ span domain.Rect
68+ area domain.Rect
69+ want PaneID
70+ wantErr error
71+ }{
72+ {
73+ name: "nil root",
74+ tree: &Tree{},
75+ dir: Right,
76+ area: area,
77+ wantErr: ErrNotFound,
78+ },
79+ {
80+ name: "layout too small",
81+ tree: NewTree("a"),
82+ dir: Right,
83+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
84+ wantErr: ErrTooSmall,
85+ },
86+ {
87+ name: "unknown direction has no pane",
88+ tree: NewTree("a"),
89+ dir: Direction(99),
90+ area: area,
91+ wantErr: ErrNoPane,
92+ },
93+ {
94+ name: "right enters left edge",
95+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "right"},
96+ dir: Right,
97+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
98+ area: area,
99+ want: "left",
100+ },
101+ {
102+ name: "left enters right edge",
103+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "left"},
104+ dir: Left,
105+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
106+ area: area,
107+ want: "right",
108+ },
109+ {
110+ name: "down enters top edge",
111+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "bottom"},
112+ dir: Down,
113+ span: domain.Rect{X: 5, Width: 10, Height: 2},
114+ area: area,
115+ want: "top",
116+ },
117+ {
118+ name: "up enters bottom edge",
119+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "top"},
120+ dir: Up,
121+ span: domain.Rect{X: 5, Width: 10, Height: 2},
122+ area: area,
123+ want: "bottom",
124+ },
125+ {
126+ name: "minimum edge gap precedes overlap",
127+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
128+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("edge-top"), NewLeaf("edge-bottom")}},
129+ NewLeaf("far"),
130+ }}},
131+ dir: Right,
132+ span: domain.Rect{Y: 1, Width: 20, Height: 4},
133+ area: domain.Rect{Width: 62, Height: 5},
134+ want: "edge-bottom",
135+ },
136+ {
137+ name: "maximum perpendicular overlap breaks equal gap",
138+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}},
139+ dir: Right,
140+ span: domain.Rect{Y: 3, Width: 20, Height: 2},
141+ area: domain.Rect{Width: 20, Height: 5},
142+ want: "bottom",
143+ },
144+ {
145+ name: "pane id deterministically breaks full tie",
146+ tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("z"), NewLeaf("a")}, Expanded: "z"}},
147+ dir: Right,
148+ span: domain.Rect{Y: 10, Width: 20, Height: 1},
149+ area: domain.Rect{Width: 20, Height: 4},
150+ want: "a",
151+ },
152+ }
153+
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ before := tt.tree.Clone()
157+ got, err := tt.tree.EntryPane(tt.dir, tt.span, tt.area)
158+ require.ErrorIs(t, err, tt.wantErr)
159+ require.Equal(t, tt.want, got)
160+ require.Equal(t, before, tt.tree, "EntryPane must be pure")
161+ })
162+ }
163+}
164+
165+func TestFocusEnterExpandsCollapsedStackMember(t *testing.T) {
166+ t.Parallel()
167+
168+ area := domain.Rect{Width: 20, Height: 4}
169+ tr := &Tree{
170+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("expanded"), NewLeaf("collapsed")}, Expanded: "expanded"},
171+ Focus: "expanded",
172+ }
173+
174+ err := tr.FocusEnter(Right, domain.Rect{Y: 3, Width: 20, Height: 1}, area)
175+ require.NoError(t, err)
176+ require.Equal(t, PaneID("collapsed"), tr.Focus)
177+ require.Equal(t, PaneID("collapsed"), tr.Root.Expanded)
178+}
@@ -0,0 +1,178 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestFocusSpan(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{X: 5, Y: 7, Width: 41, Height: 5}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ area domain.Rect
18+ want domain.Rect
19+ wantErr error
20+ }{
21+ {
22+ name: "nil root",
23+ tree: &Tree{},
24+ area: area,
25+ wantErr: ErrNotFound,
26+ },
27+ {
28+ name: "layout too small",
29+ tree: NewTree("a"),
30+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
31+ wantErr: ErrTooSmall,
32+ },
33+ {
34+ name: "focused pane unknown",
35+ tree: &Tree{Root: NewLeaf("a"), Focus: "missing"},
36+ area: area,
37+ wantErr: ErrNotFound,
38+ },
39+ {
40+ name: "returns collapsed stack title bar",
41+ tree: &Tree{
42+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"},
43+ Focus: "b",
44+ },
45+ area: area,
46+ want: domain.Rect{X: 5, Y: 11, Width: 41, Height: 1},
47+ },
48+ }
49+
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ got, err := tt.tree.FocusSpan(tt.area)
53+ require.ErrorIs(t, err, tt.wantErr)
54+ require.Equal(t, tt.want, got)
55+ })
56+ }
57+}
58+
59+func TestEntryPane(t *testing.T) {
60+ t.Parallel()
61+
62+ area := domain.Rect{Width: 41, Height: 5}
63+ tests := []struct {
64+ name string
65+ tree *Tree
66+ dir Direction
67+ span domain.Rect
68+ area domain.Rect
69+ want PaneID
70+ wantErr error
71+ }{
72+ {
73+ name: "nil root",
74+ tree: &Tree{},
75+ dir: Right,
76+ area: area,
77+ wantErr: ErrNotFound,
78+ },
79+ {
80+ name: "layout too small",
81+ tree: NewTree("a"),
82+ dir: Right,
83+ area: domain.Rect{Width: MinPaneCols - 1, Height: MinPaneRows},
84+ wantErr: ErrTooSmall,
85+ },
86+ {
87+ name: "unknown direction has no pane",
88+ tree: NewTree("a"),
89+ dir: Direction(99),
90+ area: area,
91+ wantErr: ErrNoPane,
92+ },
93+ {
94+ name: "right enters left edge",
95+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "right"},
96+ dir: Right,
97+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
98+ area: area,
99+ want: "left",
100+ },
101+ {
102+ name: "left enters right edge",
103+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("left"), NewLeaf("right")}}, Focus: "left"},
104+ dir: Left,
105+ span: domain.Rect{Y: 1, Width: 20, Height: 2},
106+ area: area,
107+ want: "right",
108+ },
109+ {
110+ name: "down enters top edge",
111+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "bottom"},
112+ dir: Down,
113+ span: domain.Rect{X: 5, Width: 10, Height: 2},
114+ area: area,
115+ want: "top",
116+ },
117+ {
118+ name: "up enters bottom edge",
119+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}, Focus: "top"},
120+ dir: Up,
121+ span: domain.Rect{X: 5, Width: 10, Height: 2},
122+ area: area,
123+ want: "bottom",
124+ },
125+ {
126+ name: "minimum edge gap precedes overlap",
127+ tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
128+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("edge-top"), NewLeaf("edge-bottom")}},
129+ NewLeaf("far"),
130+ }}},
131+ dir: Right,
132+ span: domain.Rect{Y: 1, Width: 20, Height: 4},
133+ area: domain.Rect{Width: 62, Height: 5},
134+ want: "edge-bottom",
135+ },
136+ {
137+ name: "maximum perpendicular overlap breaks equal gap",
138+ tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("top"), NewLeaf("bottom")}}},
139+ dir: Right,
140+ span: domain.Rect{Y: 3, Width: 20, Height: 2},
141+ area: domain.Rect{Width: 20, Height: 5},
142+ want: "bottom",
143+ },
144+ {
145+ name: "pane id deterministically breaks full tie",
146+ tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("z"), NewLeaf("a")}, Expanded: "z"}},
147+ dir: Right,
148+ span: domain.Rect{Y: 10, Width: 20, Height: 1},
149+ area: domain.Rect{Width: 20, Height: 4},
150+ want: "a",
151+ },
152+ }
153+
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ before := tt.tree.Clone()
157+ got, err := tt.tree.EntryPane(tt.dir, tt.span, tt.area)
158+ require.ErrorIs(t, err, tt.wantErr)
159+ require.Equal(t, tt.want, got)
160+ require.Equal(t, before, tt.tree, "EntryPane must be pure")
161+ })
162+ }
163+}
164+
165+func TestFocusEnterExpandsCollapsedStackMember(t *testing.T) {
166+ t.Parallel()
167+
168+ area := domain.Rect{Width: 20, Height: 4}
169+ tr := &Tree{
170+ Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("expanded"), NewLeaf("collapsed")}, Expanded: "expanded"},
171+ Focus: "expanded",
172+ }
173+
174+ err := tr.FocusEnter(Right, domain.Rect{Y: 3, Width: 20, Height: 1}, area)
175+ require.NoError(t, err)
176+ require.Equal(t, PaneID("collapsed"), tr.Focus)
177+ require.Equal(t, PaneID("collapsed"), tr.Root.Expanded)
178+}
M internal/usecase/layout/ops.go +80 -0
@@ -372,6 +372,86 @@ func leafIDs(n *Node) []PaneID {
372372 return ids
373373 }
374374
375+// FocusSpan returns the solved focus rectangle of the focused pane.
376+func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) {
377+ if t == nil || t.Root == nil {
378+ return domain.Rect{}, ErrNotFound
379+ }
380+ placements, ok := Solve(t.Root, area)
381+ if !ok {
382+ return domain.Rect{}, ErrTooSmall
383+ }
384+ for _, placement := range placements {
385+ if placement.ID == t.Focus {
386+ return focusRect(placement), nil
387+ }
388+ }
389+ return domain.Rect{}, ErrNotFound
390+}
391+
392+// EntryPane returns the pane on the facing edge that best preserves span on
393+// the perpendicular axis. It does not mutate the tree.
394+func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) {
395+ if t == nil || t.Root == nil {
396+ return "", ErrNotFound
397+ }
398+ placements, ok := Solve(t.Root, area)
399+ if !ok {
400+ return "", ErrTooSmall
401+ }
402+
403+ outside := span
404+ switch dir {
405+ case Left:
406+ outside.X = area.X + area.Width
407+ case Right:
408+ outside.X = area.X - span.Width
409+ case Up:
410+ outside.Y = area.Y + area.Height
411+ case Down:
412+ outside.Y = area.Y - span.Height
413+ default:
414+ return "", ErrNoPane
415+ }
416+
417+ type candidate struct {
418+ id PaneID
419+ gap int
420+ overlap int
421+ }
422+ candidates := make([]candidate, 0, len(placements))
423+ for _, placement := range placements {
424+ if facing, gap, perpendicularOverlap := sideGapOverlap(outside, focusRect(placement), dir); facing {
425+ candidates = append(candidates, candidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
426+ }
427+ }
428+ if len(candidates) == 0 {
429+ return "", ErrNoPane
430+ }
431+ sort.Slice(candidates, func(i, j int) bool {
432+ if candidates[i].gap != candidates[j].gap {
433+ return candidates[i].gap < candidates[j].gap
434+ }
435+ if candidates[i].overlap != candidates[j].overlap {
436+ return candidates[i].overlap > candidates[j].overlap
437+ }
438+ return candidates[i].id < candidates[j].id
439+ })
440+ return candidates[0].id, nil
441+}
442+
443+// FocusEnter focuses the best pane on the facing edge and expands it when it
444+// is a collapsed stack member.
445+func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error {
446+ id, err := t.EntryPane(dir, span, area)
447+ if err != nil {
448+ return err
449+ }
450+ t.Focus = id
451+ setExpanded(t.Root, id)
452+ return nil
453+}
454+
375455 // FocusDir moves focus in dir. Vertical movement within a stack walks stack
376456 // members before falling back to geometric focus.
377457 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
@@ -372,6 +372,86 @@ func leafIDs(n *Node) []PaneID {
372 return ids372 return ids
373 }373 }
374 374
375+// FocusSpan returns the solved focus rectangle of the focused pane.
376+func (t *Tree) FocusSpan(area domain.Rect) (domain.Rect, error) {
377+ if t == nil || t.Root == nil {
378+ return domain.Rect{}, ErrNotFound
379+ }
380+ placements, ok := Solve(t.Root, area)
381+ if !ok {
382+ return domain.Rect{}, ErrTooSmall
383+ }
384+ for _, placement := range placements {
385+ if placement.ID == t.Focus {
386+ return focusRect(placement), nil
387+ }
388+ }
389+ return domain.Rect{}, ErrNotFound
390+}
391+
392+// EntryPane returns the pane on the facing edge that best preserves span on
393+// the perpendicular axis. It does not mutate the tree.
394+func (t *Tree) EntryPane(dir Direction, span, area domain.Rect) (PaneID, error) {
395+ if t == nil || t.Root == nil {
396+ return "", ErrNotFound
397+ }
398+ placements, ok := Solve(t.Root, area)
399+ if !ok {
400+ return "", ErrTooSmall
401+ }
402+
403+ outside := span
404+ switch dir {
405+ case Left:
406+ outside.X = area.X + area.Width
407+ case Right:
408+ outside.X = area.X - span.Width
409+ case Up:
410+ outside.Y = area.Y + area.Height
411+ case Down:
412+ outside.Y = area.Y - span.Height
413+ default:
414+ return "", ErrNoPane
415+ }
416+
417+ type candidate struct {
418+ id PaneID
419+ gap int
420+ overlap int
421+ }
422+ candidates := make([]candidate, 0, len(placements))
423+ for _, placement := range placements {
424+ if facing, gap, perpendicularOverlap := sideGapOverlap(outside, focusRect(placement), dir); facing {
425+ candidates = append(candidates, candidate{id: placement.ID, gap: gap, overlap: perpendicularOverlap})
426+ }
427+ }
428+ if len(candidates) == 0 {
429+ return "", ErrNoPane
430+ }
431+ sort.Slice(candidates, func(i, j int) bool {
432+ if candidates[i].gap != candidates[j].gap {
433+ return candidates[i].gap < candidates[j].gap
434+ }
435+ if candidates[i].overlap != candidates[j].overlap {
436+ return candidates[i].overlap > candidates[j].overlap
437+ }
438+ return candidates[i].id < candidates[j].id
439+ })
440+ return candidates[0].id, nil
441+}
442+
443+// FocusEnter focuses the best pane on the facing edge and expands it when it
444+// is a collapsed stack member.
445+func (t *Tree) FocusEnter(dir Direction, span, area domain.Rect) error {
446+ id, err := t.EntryPane(dir, span, area)
447+ if err != nil {
448+ return err
449+ }
450+ t.Focus = id
451+ setExpanded(t.Root, id)
452+ return nil
453+}
454+
375 // FocusDir moves focus in dir. Vertical movement within a stack walks stack455 // FocusDir moves focus in dir. Vertical movement within a stack walks stack
376 // members before falling back to geometric focus.456 // members before falling back to geometric focus.
377 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {457 func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close