detached d4e061d

feat(ui): add oriented separators

commit
d4e061d1c03d6414be29b0f2df8f9afd7a9cb359
author
brice · · 2026-07-14 10:58 UTC
parent
1a915d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +112 -0
A internal/usecase/ui/separator.go +31 -0
@@ -0,0 +1,31 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// SeparatorOrientation selects the axis of a separator slice.
9+type SeparatorOrientation uint8
10+
11+const (
12+ SeparatorHorizontal SeparatorOrientation = iota
13+ SeparatorVertical
14+)
15+
16+// DrawSeparator draws a styled, clipped separator slice. It contains no layout
17+// or theme policy; callers provide both the rectangle and style.
18+func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) {
19+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 {
20+ return
21+ }
22+ var cell renderer.Cell
23+ switch orientation {
24+ case SeparatorHorizontal:
25+ cell = renderer.Cell{Rune: '─', Style: style}
26+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell)
27+ case SeparatorVertical:
28+ cell = renderer.Cell{Rune: '│', Style: style}
29+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell)
30+ }
31+}
@@ -0,0 +1,31 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// SeparatorOrientation selects the axis of a separator slice.
9+type SeparatorOrientation uint8
10+
11+const (
12+ SeparatorHorizontal SeparatorOrientation = iota
13+ SeparatorVertical
14+)
15+
16+// DrawSeparator draws a styled, clipped separator slice. It contains no layout
17+// or theme policy; callers provide both the rectangle and style.
18+func DrawSeparator(dst renderer.Frame, rect domain.Rect, orientation SeparatorOrientation, style renderer.Style) {
19+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 {
20+ return
21+ }
22+ var cell renderer.Cell
23+ switch orientation {
24+ case SeparatorHorizontal:
25+ cell = renderer.Cell{Rune: '─', Style: style}
26+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: 1}, cell)
27+ case SeparatorVertical:
28+ cell = renderer.Cell{Rune: '│', Style: style}
29+ FillRect(dst, domain.Rect{X: rect.X, Y: rect.Y, Width: 1, Height: rect.Height}, cell)
30+ }
31+}
A internal/usecase/ui/separator_test.go +81 -0
@@ -0,0 +1,81 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestDrawSeparator(t *testing.T) {
11+ style := renderer.DefaultStyle()
12+ style.Attrs = renderer.AttrDim
13+ cases := []struct {
14+ name string
15+ orientation SeparatorOrientation
16+ rune rune
17+ rect domain.Rect
18+ }{
19+ {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}},
20+ {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}},
21+ }
22+ for _, tc := range cases {
23+ t.Run(tc.name, func(t *testing.T) {
24+ f := renderer.NewFrame(6, 6)
25+ DrawSeparator(f, tc.rect, tc.orientation, style)
26+ for y := 0; y < 6; y++ {
27+ for x := 0; x < 6; x++ {
28+ want := renderer.BlankCell()
29+ if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) ||
30+ (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) {
31+ want = renderer.Cell{Rune: tc.rune, Style: style}
32+ }
33+ if !f.At(x, y).Equal(want) {
34+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want)
35+ }
36+ }
37+ }
38+ })
39+ }
40+}
41+
42+func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
43+ style := renderer.DefaultStyle()
44+ f := renderer.NewFrame(3, 3)
45+ for i := range f.Cells {
46+ f.Cells[i] = renderer.Cell{Rune: 'x', Style: style}
47+ }
48+ DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)
49+ for x := 0; x < 3; x++ {
50+ if f.At(x, 1).Rune != '─' {
51+ t.Fatalf("clipping failed at %d", x)
52+ }
53+ }
54+ before := f.Clone()
55+ for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} {
56+ DrawSeparator(f, rect, SeparatorHorizontal, style)
57+ }
58+ DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style)
59+ if !framesEqual(f, before) {
60+ t.Fatal("invalid rectangles/orientation changed frame")
61+ }
62+}
63+
64+func TestDrawSeparatorInvalidFrame(t *testing.T) {
65+ f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)}
66+ DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())
67+}
68+
69+func framesEqual(a, b renderer.Frame) bool {
70+ if a.Width != b.Width || a.Height != b.Height {
71+ return false
72+ }
73+ for y := 0; y < a.Height; y++ {
74+ for x := 0; x < a.Width; x++ {
75+ if !a.At(x, y).Equal(b.At(x, y)) {
76+ return false
77+ }
78+ }
79+ }
80+ return true
81+}
@@ -0,0 +1,81 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestDrawSeparator(t *testing.T) {
11+ style := renderer.DefaultStyle()
12+ style.Attrs = renderer.AttrDim
13+ cases := []struct {
14+ name string
15+ orientation SeparatorOrientation
16+ rune rune
17+ rect domain.Rect
18+ }{
19+ {"horizontal", SeparatorHorizontal, '─', domain.Rect{X: 1, Y: 2, Width: 3, Height: 1}},
20+ {"vertical", SeparatorVertical, '│', domain.Rect{X: 2, Y: 1, Width: 1, Height: 3}},
21+ }
22+ for _, tc := range cases {
23+ t.Run(tc.name, func(t *testing.T) {
24+ f := renderer.NewFrame(6, 6)
25+ DrawSeparator(f, tc.rect, tc.orientation, style)
26+ for y := 0; y < 6; y++ {
27+ for x := 0; x < 6; x++ {
28+ want := renderer.BlankCell()
29+ if (tc.orientation == SeparatorHorizontal && y == tc.rect.Y && x >= tc.rect.X && x < tc.rect.X+tc.rect.Width) ||
30+ (tc.orientation == SeparatorVertical && x == tc.rect.X && y >= tc.rect.Y && y < tc.rect.Y+tc.rect.Height) {
31+ want = renderer.Cell{Rune: tc.rune, Style: style}
32+ }
33+ if !f.At(x, y).Equal(want) {
34+ t.Fatalf("cell %d,%d = %#v, want %#v", x, y, f.At(x, y), want)
35+ }
36+ }
37+ }
38+ })
39+ }
40+}
41+
42+func TestDrawSeparatorClipsAndRejectsInvalid(t *testing.T) {
43+ style := renderer.DefaultStyle()
44+ f := renderer.NewFrame(3, 3)
45+ for i := range f.Cells {
46+ f.Cells[i] = renderer.Cell{Rune: 'x', Style: style}
47+ }
48+ DrawSeparator(f, domain.Rect{X: -2, Y: 1, Width: 8, Height: 1}, SeparatorHorizontal, style)
49+ for x := 0; x < 3; x++ {
50+ if f.At(x, 1).Rune != '─' {
51+ t.Fatalf("clipping failed at %d", x)
52+ }
53+ }
54+ before := f.Clone()
55+ for _, rect := range []domain.Rect{{Width: 0, Height: 1}, {Width: 1, Height: 0}} {
56+ DrawSeparator(f, rect, SeparatorHorizontal, style)
57+ }
58+ DrawSeparator(f, domain.Rect{Width: 1, Height: 1}, SeparatorOrientation(99), style)
59+ if !framesEqual(f, before) {
60+ t.Fatal("invalid rectangles/orientation changed frame")
61+ }
62+}
63+
64+func TestDrawSeparatorInvalidFrame(t *testing.T) {
65+ f := renderer.Frame{Width: 2, Height: 2, Cells: make([]renderer.Cell, 3)}
66+ DrawSeparator(f, domain.Rect{Width: 2, Height: 1}, SeparatorHorizontal, renderer.DefaultStyle())
67+}
68+
69+func framesEqual(a, b renderer.Frame) bool {
70+ if a.Width != b.Width || a.Height != b.Height {
71+ return false
72+ }
73+ for y := 0; y < a.Height; y++ {
74+ for x := 0; x < a.Width; x++ {
75+ if !a.At(x, y).Equal(b.At(x, y)) {
76+ return false
77+ }
78+ }
79+ }
80+ return true
81+}

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