detached a044fe2

feat(vt): add pixel geometry reports

commit
a044fe2f2d453899a05c54d8475678b18d9e9268
author
brice · · 2026-08-20 13:32 UTC
parent
a667323
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +140 -0
A geometry.go +19 -0
@@ -0,0 +1,19 @@
1+package vt
2+
3+// Geometry combines a terminal's required cell dimensions with optional pixel
4+// dimensions reported by its frontend. Zero pixel dimensions mean they are
5+// unknown and must not be inferred from the cell dimensions.
6+type Geometry struct {
7+ Cols, Rows int
8+ PixelWidth, PixelHeight int
9+}
10+
11+// Valid reports whether the cell dimensions can describe a terminal screen.
12+func (g Geometry) Valid() bool {
13+ return g.Cols > 0 && g.Rows > 0
14+}
15+
16+// PixelsKnown reports whether both pixel dimensions are available.
17+func (g Geometry) PixelsKnown() bool {
18+ return g.PixelWidth > 0 && g.PixelHeight > 0
19+}
@@ -0,0 +1,19 @@
1+package vt
2+
3+// Geometry combines a terminal's required cell dimensions with optional pixel
4+// dimensions reported by its frontend. Zero pixel dimensions mean they are
5+// unknown and must not be inferred from the cell dimensions.
6+type Geometry struct {
7+ Cols, Rows int
8+ PixelWidth, PixelHeight int
9+}
10+
11+// Valid reports whether the cell dimensions can describe a terminal screen.
12+func (g Geometry) Valid() bool {
13+ return g.Cols > 0 && g.Rows > 0
14+}
15+
16+// PixelsKnown reports whether both pixel dimensions are available.
17+func (g Geometry) PixelsKnown() bool {
18+ return g.PixelWidth > 0 && g.PixelHeight > 0
19+}
A geometry_test.go +26 -0
@@ -0,0 +1,26 @@
1+package vt
2+
3+import "testing"
4+
5+func TestGeometryValidityAndPixelsKnown(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ geometry Geometry
9+ valid, hasPixel bool
10+ }{
11+ {name: "cells only", geometry: Geometry{Cols: 80, Rows: 24}, valid: true},
12+ {name: "full", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, valid: true, hasPixel: true},
13+ {name: "partial pixels are unknown", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640}, valid: true},
14+ {name: "zero columns", geometry: Geometry{Rows: 24, PixelWidth: 640, PixelHeight: 384}, hasPixel: true},
15+ }
16+ for _, tt := range tests {
17+ t.Run(tt.name, func(t *testing.T) {
18+ if got := tt.geometry.Valid(); got != tt.valid {
19+ t.Fatalf("Valid() = %t, want %t", got, tt.valid)
20+ }
21+ if got := tt.geometry.PixelsKnown(); got != tt.hasPixel {
22+ t.Fatalf("PixelsKnown() = %t, want %t", got, tt.hasPixel)
23+ }
24+ })
25+ }
26+}
@@ -0,0 +1,26 @@
1+package vt
2+
3+import "testing"
4+
5+func TestGeometryValidityAndPixelsKnown(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ geometry Geometry
9+ valid, hasPixel bool
10+ }{
11+ {name: "cells only", geometry: Geometry{Cols: 80, Rows: 24}, valid: true},
12+ {name: "full", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, valid: true, hasPixel: true},
13+ {name: "partial pixels are unknown", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640}, valid: true},
14+ {name: "zero columns", geometry: Geometry{Rows: 24, PixelWidth: 640, PixelHeight: 384}, hasPixel: true},
15+ }
16+ for _, tt := range tests {
17+ t.Run(tt.name, func(t *testing.T) {
18+ if got := tt.geometry.Valid(); got != tt.valid {
19+ t.Fatalf("Valid() = %t, want %t", got, tt.valid)
20+ }
21+ if got := tt.geometry.PixelsKnown(); got != tt.hasPixel {
22+ t.Fatalf("PixelsKnown() = %t, want %t", got, tt.hasPixel)
23+ }
24+ })
25+ }
26+}
M screen.go +2 -0
@@ -54,6 +54,7 @@ type Screen struct {
5454 buffer *buffer
5555
5656 nextRowID RowID
57+ geometry Geometry
5758
5859 defaultFG renderer.RGB
5960 defaultBG renderer.RGB
@@ -93,6 +94,7 @@ func NewScreen(width, height int) *Screen {
9394 damage: []renderer.Damage{renderer.FullRedraw()},
9495 damageGeneration: 1,
9596 cursorVisible: true,
97+ geometry: Geometry{Cols: width, Rows: height},
9698 }
9799 s.buffer = s.newBuffer(width, height)
98100 s.Frame = s.buffer.frame
@@ -54,6 +54,7 @@ type Screen struct {
54 buffer *buffer54 buffer *buffer
55 55
56 nextRowID RowID56 nextRowID RowID
57+ geometry Geometry
57 58
58 defaultFG renderer.RGB59 defaultFG renderer.RGB
59 defaultBG renderer.RGB60 defaultBG renderer.RGB
@@ -93,6 +94,7 @@ func NewScreen(width, height int) *Screen {
93 damage: []renderer.Damage{renderer.FullRedraw()},94 damage: []renderer.Damage{renderer.FullRedraw()},
94 damageGeneration: 1,95 damageGeneration: 1,
95 cursorVisible: true,96 cursorVisible: true,
97+ geometry: Geometry{Cols: width, Rows: height},
96 }98 }
97 s.buffer = s.newBuffer(width, height)99 s.buffer = s.newBuffer(width, height)
98 s.Frame = s.buffer.frame100 s.Frame = s.buffer.frame
M screen_csi.go +4 -0
@@ -144,6 +144,10 @@ func (s *Screen) applyCSI(params string, cmd byte) {
144144 s.deleteLines(firstPositive(parts, 1))
145145 case 'r':
146146 s.setScrollRegion(parts)
147+ case 't':
148+ if !private {
149+ s.reportWindowSize(parts)
150+ }
147151 case 's':
148152 s.saveCursor()
149153 case 'u':
@@ -144,6 +144,10 @@ func (s *Screen) applyCSI(params string, cmd byte) {
144 s.deleteLines(firstPositive(parts, 1))144 s.deleteLines(firstPositive(parts, 1))
145 case 'r':145 case 'r':
146 s.setScrollRegion(parts)146 s.setScrollRegion(parts)
147+ case 't':
148+ if !private {
149+ s.reportWindowSize(parts)
150+ }
147 case 's':151 case 's':
148 s.saveCursor()152 s.saveCursor()
149 case 'u':153 case 'u':
A screen_geometry.go +59 -0
@@ -0,0 +1,59 @@
1+package vt
2+
3+// Geometry returns the screen's last frontend-supplied geometry.
4+func (s *Screen) Geometry() Geometry {
5+ if s == nil {
6+ return Geometry{}
7+ }
8+ return s.geometry
9+}
10+
11+// SetGeometry updates the frontend-supplied cell and optional pixel geometry.
12+// Cell changes retain the existing Resize behavior; pixel-only changes do not
13+// touch the text frame or damage state.
14+func (s *Screen) SetGeometry(geometry Geometry) {
15+ if s == nil || !geometry.Valid() {
16+ return
17+ }
18+ cellsChanged := s.geometry.Cols != geometry.Cols || s.geometry.Rows != geometry.Rows
19+ s.geometry = geometry
20+ if cellsChanged {
21+ s.Resize(geometry.Cols, geometry.Rows)
22+ }
23+}
24+
25+// reportWindowSize answers the CSI 14 t and CSI 16 t pixel-geometry queries.
26+// Unknown pixel geometry is intentionally not fabricated.
27+func (s *Screen) reportWindowSize(parts []int) {
28+ if s == nil || len(parts) == 0 || !s.geometry.PixelsKnown() {
29+ return
30+ }
31+ kind := parts[0]
32+ var rows, cols int
33+ switch kind {
34+ case 14:
35+ rows, cols = s.geometry.PixelHeight, s.geometry.PixelWidth
36+ case 16:
37+ if s.geometry.Rows <= 0 || s.geometry.Cols <= 0 {
38+ return
39+ }
40+ rows, cols = s.geometry.PixelHeight/s.geometry.Rows, s.geometry.PixelWidth/s.geometry.Cols
41+ default:
42+ return
43+ }
44+ s.respond([]byte("\x1b[" + itoa(kind-10) + ";" + itoa(rows) + ";" + itoa(cols) + "t"))
45+}
46+
47+func itoa(value int) string {
48+ if value == 0 {
49+ return "0"
50+ }
51+ var digits [20]byte
52+ i := len(digits)
53+ for value > 0 {
54+ i--
55+ digits[i] = byte('0' + value%10)
56+ value /= 10
57+ }
58+ return string(digits[i:])
59+}
@@ -0,0 +1,59 @@
1+package vt
2+
3+// Geometry returns the screen's last frontend-supplied geometry.
4+func (s *Screen) Geometry() Geometry {
5+ if s == nil {
6+ return Geometry{}
7+ }
8+ return s.geometry
9+}
10+
11+// SetGeometry updates the frontend-supplied cell and optional pixel geometry.
12+// Cell changes retain the existing Resize behavior; pixel-only changes do not
13+// touch the text frame or damage state.
14+func (s *Screen) SetGeometry(geometry Geometry) {
15+ if s == nil || !geometry.Valid() {
16+ return
17+ }
18+ cellsChanged := s.geometry.Cols != geometry.Cols || s.geometry.Rows != geometry.Rows
19+ s.geometry = geometry
20+ if cellsChanged {
21+ s.Resize(geometry.Cols, geometry.Rows)
22+ }
23+}
24+
25+// reportWindowSize answers the CSI 14 t and CSI 16 t pixel-geometry queries.
26+// Unknown pixel geometry is intentionally not fabricated.
27+func (s *Screen) reportWindowSize(parts []int) {
28+ if s == nil || len(parts) == 0 || !s.geometry.PixelsKnown() {
29+ return
30+ }
31+ kind := parts[0]
32+ var rows, cols int
33+ switch kind {
34+ case 14:
35+ rows, cols = s.geometry.PixelHeight, s.geometry.PixelWidth
36+ case 16:
37+ if s.geometry.Rows <= 0 || s.geometry.Cols <= 0 {
38+ return
39+ }
40+ rows, cols = s.geometry.PixelHeight/s.geometry.Rows, s.geometry.PixelWidth/s.geometry.Cols
41+ default:
42+ return
43+ }
44+ s.respond([]byte("\x1b[" + itoa(kind-10) + ";" + itoa(rows) + ";" + itoa(cols) + "t"))
45+}
46+
47+func itoa(value int) string {
48+ if value == 0 {
49+ return "0"
50+ }
51+ var digits [20]byte
52+ i := len(digits)
53+ for value > 0 {
54+ i--
55+ digits[i] = byte('0' + value%10)
56+ value /= 10
57+ }
58+ return string(digits[i:])
59+}
A screen_geometry_test.go +28 -0
@@ -0,0 +1,28 @@
1+package vt
2+
3+import "testing"
4+
5+func TestScreenWindowSizeReports(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ geometry Geometry
9+ query string
10+ want string
11+ }{
12+ {name: "window pixels", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[14t", want: "\x1b[4;384;640t"},
13+ {name: "cell pixels", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[16t", want: "\x1b[6;16;8t"},
14+ {name: "unknown pixels", geometry: Geometry{Cols: 80, Rows: 24}, query: "\x1b[14t"},
15+ }
16+ for _, tt := range tests {
17+ t.Run(tt.name, func(t *testing.T) {
18+ s := NewScreen(tt.geometry.Cols, tt.geometry.Rows)
19+ s.SetGeometry(tt.geometry)
20+ var got string
21+ s.OnResponse = func(response []byte) { got += string(response) }
22+ s.Write([]byte(tt.query))
23+ if got != tt.want {
24+ t.Fatalf("response = %q, want %q", got, tt.want)
25+ }
26+ })
27+ }
28+}
@@ -0,0 +1,28 @@
1+package vt
2+
3+import "testing"
4+
5+func TestScreenWindowSizeReports(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ geometry Geometry
9+ query string
10+ want string
11+ }{
12+ {name: "window pixels", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[14t", want: "\x1b[4;384;640t"},
13+ {name: "cell pixels", geometry: Geometry{Cols: 80, Rows: 24, PixelWidth: 640, PixelHeight: 384}, query: "\x1b[16t", want: "\x1b[6;16;8t"},
14+ {name: "unknown pixels", geometry: Geometry{Cols: 80, Rows: 24}, query: "\x1b[14t"},
15+ }
16+ for _, tt := range tests {
17+ t.Run(tt.name, func(t *testing.T) {
18+ s := NewScreen(tt.geometry.Cols, tt.geometry.Rows)
19+ s.SetGeometry(tt.geometry)
20+ var got string
21+ s.OnResponse = func(response []byte) { got += string(response) }
22+ s.Write([]byte(tt.query))
23+ if got != tt.want {
24+ t.Fatalf("response = %q, want %q", got, tt.want)
25+ }
26+ })
27+ }
28+}
M screen_resize.go +2 -0
@@ -4,6 +4,7 @@ import renderer "github.com/bnema/vev-vt/core"
44
55 func (s *Screen) Resize(width, height int) {
66 if width == s.Frame.Width && height == s.Frame.Height {
7+ s.geometry.Cols, s.geometry.Rows = width, height
78 return
89 }
910 if width <= 0 || height <= 0 {
@@ -60,6 +61,7 @@ func (s *Screen) Resize(width, height int) {
6061 }
6162 }
6263 s.Frame = s.buffer.frame
64+ s.geometry.Cols, s.geometry.Rows = width, height
6365 // A resize can split an in-flight escape sequence from the terminal state it
6466 // was meant to mutate; keep the durable child state but discard partial bytes.
6567 s.escapeBuf = s.escapeBuf[:0]
@@ -4,6 +4,7 @@ import renderer "github.com/bnema/vev-vt/core"
4 4
5 func (s *Screen) Resize(width, height int) {5 func (s *Screen) Resize(width, height int) {
6 if width == s.Frame.Width && height == s.Frame.Height {6 if width == s.Frame.Width && height == s.Frame.Height {
7+ s.geometry.Cols, s.geometry.Rows = width, height
7 return8 return
8 }9 }
9 if width <= 0 || height <= 0 {10 if width <= 0 || height <= 0 {
@@ -60,6 +61,7 @@ func (s *Screen) Resize(width, height int) {
60 }61 }
61 }62 }
62 s.Frame = s.buffer.frame63 s.Frame = s.buffer.frame
64+ s.geometry.Cols, s.geometry.Rows = width, height
63 // A resize can split an in-flight escape sequence from the terminal state it65 // A resize can split an in-flight escape sequence from the terminal state it
64 // was meant to mutate; keep the durable child state but discard partial bytes.66 // was meant to mutate; keep the durable child state but discard partial bytes.
65 s.escapeBuf = s.escapeBuf[:0]67 s.escapeBuf = s.escapeBuf[:0]

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 2

Tags 13

↑↓ moveenter openesc close