feat(vt): add pixel geometry reports
- commit
- a044fe2f2d453899a05c54d8475678b18d9e9268
- author
- brice · · 2026-08-20 13:32 UTC
- parent
- a667323
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 54 | 54 | buffer *buffer | |
| 55 | 55 | ||
| 56 | 56 | nextRowID RowID | |
| 57 | + | geometry Geometry | |
| 57 | 58 | ||
| 58 | 59 | defaultFG renderer.RGB | |
| 59 | 60 | defaultBG renderer.RGB | |
| @@ -93,6 +94,7 @@ func NewScreen(width, height int) *Screen { | |||
| 93 | 94 | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 94 | 95 | damageGeneration: 1, | |
| 95 | 96 | cursorVisible: true, | |
| 97 | + | geometry: Geometry{Cols: width, Rows: height}, | |
| 96 | 98 | } | |
| 97 | 99 | s.buffer = s.newBuffer(width, height) | |
| 98 | 100 | s.Frame = s.buffer.frame | |
| @@ -54,6 +54,7 @@ type Screen struct { | |||||
| 54 | buffer *buffer | 54 | buffer *buffer | ||
| 55 | 55 | ||||
| 56 | nextRowID RowID | 56 | nextRowID RowID | ||
| 57 | + | geometry Geometry | |||
| 57 | 58 | ||||
| 58 | defaultFG renderer.RGB | 59 | defaultFG renderer.RGB | ||
| 59 | defaultBG renderer.RGB | 60 | 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.frame | 100 | s.Frame = s.buffer.frame | ||
M screen_csi.go +4 -0
| @@ -144,6 +144,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 144 | 144 | s.deleteLines(firstPositive(parts, 1)) | |
| 145 | 145 | case 'r': | |
| 146 | 146 | s.setScrollRegion(parts) | |
| 147 | + | case 't': | |
| 148 | + | if !private { | |
| 149 | + | s.reportWindowSize(parts) | |
| 150 | + | } | |
| 147 | 151 | case 's': | |
| 148 | 152 | s.saveCursor() | |
| 149 | 153 | 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" | |||
| 4 | 4 | ||
| 5 | 5 | func (s *Screen) Resize(width, height int) { | |
| 6 | 6 | if width == s.Frame.Width && height == s.Frame.Height { | |
| 7 | + | s.geometry.Cols, s.geometry.Rows = width, height | |
| 7 | 8 | return | |
| 8 | 9 | } | |
| 9 | 10 | if width <= 0 || height <= 0 { | |
| @@ -60,6 +61,7 @@ func (s *Screen) Resize(width, height int) { | |||
| 60 | 61 | } | |
| 61 | 62 | } | |
| 62 | 63 | s.Frame = s.buffer.frame | |
| 64 | + | s.geometry.Cols, s.geometry.Rows = width, height | |
| 63 | 65 | // A resize can split an in-flight escape sequence from the terminal state it | |
| 64 | 66 | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 65 | 67 | 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 | return | 8 | 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.frame | 63 | 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 it | 65 | // 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] | ||