detached 1a915d8

feat(ui): add anchored frame blitting

commit
1a915d855aa06a1787b5d5dd1f19981bdf8c8282
author
brice · · 2026-07-14 10:57 UTC
parent
0adb096
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +157 -0
A internal/usecase/ui/frame.go +68 -0
@@ -0,0 +1,68 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// VerticalAnchor controls which end of a source is retained when its height
9+// differs from the destination rectangle. Source and destination aliasing is
10+// unsupported.
11+type VerticalAnchor uint8
12+
13+const (
14+ VerticalAnchorTop VerticalAnchor = iota
15+ VerticalAnchorBottom
16+)
17+
18+// FrameView is a bounded, row-oriented view of cells.
19+type FrameView struct {
20+ Rows [][]renderer.Cell
21+ Width, Height int
22+}
23+
24+// BlitFrame copies the valid intersection of src and rect into dst. Uncovered
25+// destination cells are left untouched. Invalid dimensions, views, rectangles,
26+// or anchors are ignored.
27+func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) {
28+ if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom {
29+ return
30+ }
31+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 {
32+ return
33+ }
34+ if len(src.Rows) != src.Height {
35+ return
36+ }
37+ for _, row := range src.Rows {
38+ if len(row) != src.Width {
39+ return
40+ }
41+ }
42+ if rect.X+rect.Width <= 0 || rect.Y+rect.Height <= 0 || rect.X >= dst.Width || rect.Y >= dst.Height {
43+ return
44+ }
45+
46+ srcY, dstY := 0, 0
47+ if anchor == VerticalAnchorBottom {
48+ if src.Height > rect.Height {
49+ srcY = src.Height - rect.Height
50+ } else {
51+ dstY = rect.Height - src.Height
52+ }
53+ }
54+ for dy := 0; dy < src.Height && dy+dstY < rect.Height; dy++ {
55+ sy := srcY + dy
56+ y := rect.Y + dstY + dy
57+ if y < 0 || y >= dst.Height {
58+ continue
59+ }
60+ for dx := 0; dx < rect.Width; dx++ {
61+ x := rect.X + dx
62+ if x < 0 || x >= dst.Width || dx >= src.Width {
63+ continue
64+ }
65+ dst.Set(x, y, src.Rows[sy][dx])
66+ }
67+ }
68+}
@@ -0,0 +1,68 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// VerticalAnchor controls which end of a source is retained when its height
9+// differs from the destination rectangle. Source and destination aliasing is
10+// unsupported.
11+type VerticalAnchor uint8
12+
13+const (
14+ VerticalAnchorTop VerticalAnchor = iota
15+ VerticalAnchorBottom
16+)
17+
18+// FrameView is a bounded, row-oriented view of cells.
19+type FrameView struct {
20+ Rows [][]renderer.Cell
21+ Width, Height int
22+}
23+
24+// BlitFrame copies the valid intersection of src and rect into dst. Uncovered
25+// destination cells are left untouched. Invalid dimensions, views, rectangles,
26+// or anchors are ignored.
27+func BlitFrame(dst renderer.Frame, rect domain.Rect, src FrameView, anchor VerticalAnchor) {
28+ if anchor != VerticalAnchorTop && anchor != VerticalAnchorBottom {
29+ return
30+ }
31+ if dst.Validate() != nil || rect.Width <= 0 || rect.Height <= 0 || src.Width <= 0 || src.Height <= 0 {
32+ return
33+ }
34+ if len(src.Rows) != src.Height {
35+ return
36+ }
37+ for _, row := range src.Rows {
38+ if len(row) != src.Width {
39+ return
40+ }
41+ }
42+ if rect.X+rect.Width <= 0 || rect.Y+rect.Height <= 0 || rect.X >= dst.Width || rect.Y >= dst.Height {
43+ return
44+ }
45+
46+ srcY, dstY := 0, 0
47+ if anchor == VerticalAnchorBottom {
48+ if src.Height > rect.Height {
49+ srcY = src.Height - rect.Height
50+ } else {
51+ dstY = rect.Height - src.Height
52+ }
53+ }
54+ for dy := 0; dy < src.Height && dy+dstY < rect.Height; dy++ {
55+ sy := srcY + dy
56+ y := rect.Y + dstY + dy
57+ if y < 0 || y >= dst.Height {
58+ continue
59+ }
60+ for dx := 0; dx < rect.Width; dx++ {
61+ x := rect.X + dx
62+ if x < 0 || x >= dst.Width || dx >= src.Width {
63+ continue
64+ }
65+ dst.Set(x, y, src.Rows[sy][dx])
66+ }
67+ }
68+}
A internal/usecase/ui/frame_test.go +89 -0
@@ -0,0 +1,89 @@
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 cell(r rune, style renderer.Style, continuation bool) renderer.Cell {
11+ return renderer.Cell{Rune: r, Style: style, Continuation: continuation}
12+}
13+
14+func TestBlitFrameAnchorsAndClips(t *testing.T) {
15+ style := renderer.Style{Foreground: 1, Background: 2}
16+ tests := []struct {
17+ name string
18+ anchor VerticalAnchor
19+ srcH, srcW, rectX, rectY, rectW, rectH int
20+ want string
21+ }{
22+ {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"},
23+ {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"},
24+ {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "},
25+ {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)}
30+ for y := range src.Rows {
31+ src.Rows[y] = make([]renderer.Cell, tt.srcW)
32+ for x := range src.Rows[y] {
33+ src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, x == 1)
34+ }
35+ }
36+ dst := renderer.NewFrame(5, 5)
37+ BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor)
38+ got := ""
39+ for y := 1; y < 3; y++ {
40+ for x := 1; x < 3; x++ {
41+ got += string(dst.At(x, y).Rune)
42+ }
43+ }
44+ if got != tt.want {
45+ t.Fatalf("got %q want %q", got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestBlitFrameDestinationClippingAndSentinels(t *testing.T) {
52+ dst := renderer.NewFrame(3, 3)
53+ mark := cell('!', renderer.Style{Foreground: 3}, false)
54+ for y := 0; y < 3; y++ {
55+ for x := 0; x < 3; x++ {
56+ dst.Set(x, y, mark)
57+ }
58+ }
59+ src := FrameView{Width: 2, Height: 2, Rows: [][]renderer.Cell{{cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), true)}, {cell('c', renderer.DefaultStyle(), false), cell('d', renderer.DefaultStyle(), true)}}}
60+ BlitFrame(dst, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
61+ if dst.At(0, 0).Rune != 'd' || dst.At(2, 2).Rune != '!' {
62+ t.Fatalf("unexpected clipping/sentinel")
63+ }
64+}
65+
66+func TestBlitFrameInvalidNoOp(t *testing.T) {
67+ dst := renderer.NewFrame(2, 2)
68+ before := dst.Clone()
69+ cases := []struct {
70+ r domain.Rect
71+ s FrameView
72+ a VerticalAnchor
73+ }{
74+ {domain.Rect{}, FrameView{}, VerticalAnchorTop}, {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
75+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},
76+ }
77+ for _, tc := range cases {
78+ BlitFrame(dst, tc.r, tc.s, tc.a)
79+ }
80+ for y := 0; y < 2; y++ {
81+ for x := 0; x < 2; x++ {
82+ if !dst.At(x, y).Equal(before.At(x, y)) {
83+ t.Fatal("invalid blit changed destination")
84+ }
85+ }
86+ }
87+}
88+
89+func TestBlitFrameDocumentsAliasUnsupported(t *testing.T) {}
@@ -0,0 +1,89 @@
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 cell(r rune, style renderer.Style, continuation bool) renderer.Cell {
11+ return renderer.Cell{Rune: r, Style: style, Continuation: continuation}
12+}
13+
14+func TestBlitFrameAnchorsAndClips(t *testing.T) {
15+ style := renderer.Style{Foreground: 1, Background: 2}
16+ tests := []struct {
17+ name string
18+ anchor VerticalAnchor
19+ srcH, srcW, rectX, rectY, rectW, rectH int
20+ want string
21+ }{
22+ {"top taller", VerticalAnchorTop, 4, 2, 1, 1, 2, 2, "abcd"},
23+ {"bottom taller", VerticalAnchorBottom, 4, 2, 1, 1, 2, 2, "efgh"},
24+ {"top shorter", VerticalAnchorTop, 1, 2, 1, 1, 2, 2, "ab "},
25+ {"bottom shorter", VerticalAnchorBottom, 1, 2, 1, 1, 2, 2, " ab"},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ src := FrameView{Width: tt.srcW, Height: tt.srcH, Rows: make([][]renderer.Cell, tt.srcH)}
30+ for y := range src.Rows {
31+ src.Rows[y] = make([]renderer.Cell, tt.srcW)
32+ for x := range src.Rows[y] {
33+ src.Rows[y][x] = cell(rune('a'+y*tt.srcW+x), style, x == 1)
34+ }
35+ }
36+ dst := renderer.NewFrame(5, 5)
37+ BlitFrame(dst, domain.Rect{X: tt.rectX, Y: tt.rectY, Width: tt.rectW, Height: tt.rectH}, src, tt.anchor)
38+ got := ""
39+ for y := 1; y < 3; y++ {
40+ for x := 1; x < 3; x++ {
41+ got += string(dst.At(x, y).Rune)
42+ }
43+ }
44+ if got != tt.want {
45+ t.Fatalf("got %q want %q", got, tt.want)
46+ }
47+ })
48+ }
49+}
50+
51+func TestBlitFrameDestinationClippingAndSentinels(t *testing.T) {
52+ dst := renderer.NewFrame(3, 3)
53+ mark := cell('!', renderer.Style{Foreground: 3}, false)
54+ for y := 0; y < 3; y++ {
55+ for x := 0; x < 3; x++ {
56+ dst.Set(x, y, mark)
57+ }
58+ }
59+ src := FrameView{Width: 2, Height: 2, Rows: [][]renderer.Cell{{cell('a', renderer.DefaultStyle(), false), cell('b', renderer.DefaultStyle(), true)}, {cell('c', renderer.DefaultStyle(), false), cell('d', renderer.DefaultStyle(), true)}}}
60+ BlitFrame(dst, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, src, VerticalAnchorTop)
61+ if dst.At(0, 0).Rune != 'd' || dst.At(2, 2).Rune != '!' {
62+ t.Fatalf("unexpected clipping/sentinel")
63+ }
64+}
65+
66+func TestBlitFrameInvalidNoOp(t *testing.T) {
67+ dst := renderer.NewFrame(2, 2)
68+ before := dst.Clone()
69+ cases := []struct {
70+ r domain.Rect
71+ s FrameView
72+ a VerticalAnchor
73+ }{
74+ {domain.Rect{}, FrameView{}, VerticalAnchorTop}, {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 0, Height: 1}, VerticalAnchorTop},
75+ {domain.Rect{Width: 1, Height: 1}, FrameView{Width: 1, Height: 1, Rows: [][]renderer.Cell{{}}}, VerticalAnchor(99)},
76+ }
77+ for _, tc := range cases {
78+ BlitFrame(dst, tc.r, tc.s, tc.a)
79+ }
80+ for y := 0; y < 2; y++ {
81+ for x := 0; x < 2; x++ {
82+ if !dst.At(x, y).Equal(before.At(x, y)) {
83+ t.Fatal("invalid blit changed destination")
84+ }
85+ }
86+ }
87+}
88+
89+func TestBlitFrameDocumentsAliasUnsupported(t *testing.T) {}

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