detached 26e0449

feat(render): draw scene windows as clipped placeholder rectangles

commit
26e0449c02f3b97f63f163e2fa32645052c3b2f5
author
brice · · 2026-09-23 15:24 UTC
parent
88e7637
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +178 -0
M internal/adapters/vulkan/renderer.go +133 -0
@@ -9,6 +9,8 @@ import (
99 "strconv"
1010 "unsafe"
1111
12+ "github.com/bnema/nefertty/internal/ports"
13+
1214 vk "github.com/bnema/purego-vulkan/vulkan"
1315 )
1416
@@ -24,6 +26,9 @@ type Renderer struct {
2426 imageMemory vk.DeviceMemory
2527 buffer vk.Buffer
2628 bufferMemory vk.DeviceMemory
29+ staging vk.Buffer
30+ stagingMemory vk.DeviceMemory
31+ stagingMapped unsafe.Pointer
2732 mapped unsafe.Pointer
2833 pool vk.CommandPool
2934 command vk.CommandBuffer
@@ -169,6 +174,27 @@ func New(width, height int) (r *Renderer, err error) {
169174 if err = checked("vkMapMemory", r.dd.MapMemory(r.device, r.bufferMemory, 0, size, 0, &r.mapped)); err != nil {
170175 return
171176 }
177+ // Two frame-sized regions accommodate non-overlapping window bodies and their overlapping borders.
178+ stagingSize := size * 2
179+ stagingInfo := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: stagingSize, Usage: vk.BufferUsageTransferSrcBit, SharingMode: vk.SharingModeExclusive}
180+ if err = checked("vkCreateBuffer(staging)", r.dd.CreateBuffer(r.device, &stagingInfo, nil, &r.staging)); err != nil {
181+ return
182+ }
183+ r.dd.GetBufferMemoryRequirements(r.device, r.staging, &req)
184+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
185+ if err != nil {
186+ return
187+ }
188+ alloc = vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
189+ if err = checked("vkAllocateMemory(staging)", r.dd.AllocateMemory(r.device, &alloc, nil, &r.stagingMemory)); err != nil {
190+ return
191+ }
192+ if err = checked("vkBindBufferMemory(staging)", r.dd.BindBufferMemory(r.device, r.staging, r.stagingMemory, 0)); err != nil {
193+ return
194+ }
195+ if err = checked("vkMapMemory(staging)", r.dd.MapMemory(r.device, r.stagingMemory, 0, stagingSize, 0, &r.stagingMapped)); err != nil {
196+ return
197+ }
172198 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}
173199 if err = checked("vkCreateCommandPool", r.dd.CreateCommandPool(r.device, &pi, nil, &r.pool)); err != nil {
174200 return
@@ -194,7 +220,93 @@ func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (ui
194220 return 0, fmt.Errorf("no Vulkan memory type for bits %#x and properties %#x", bits, props)
195221 }
196222
223+func parseColor(s string) [3]uint8 {
224+ var c [3]uint8
225+ if len(s) != 7 || s[0] != '#' {
226+ return c
227+ }
228+ for i := range c {
229+ v, e := strconv.ParseUint(s[1+i*2:3+i*2], 16, 8)
230+ if e != nil {
231+ return [3]uint8{}
232+ }
233+ c[i] = uint8(v)
234+ }
235+ return c
236+}
237+
238+func windowColor(id ports.WindowID) [3]uint8 {
239+ h := math.Mod(float64(id)*0.618034, 1) * 6
240+ sector := int(h)
241+ f := h - float64(sector)
242+ p, q, t := 0.4, 0.8*(1-0.5*f), 0.8*(1-0.5*(1-f))
243+ var a, b, c float64
244+ switch sector {
245+ case 0:
246+ a, b, c = 0.8, t, p
247+ case 1:
248+ a, b, c = q, 0.8, p
249+ case 2:
250+ a, b, c = p, 0.8, t
251+ case 3:
252+ a, b, c = p, q, 0.8
253+ case 4:
254+ a, b, c = t, p, 0.8
255+ default:
256+ a, b, c = 0.8, p, q
257+ }
258+ return [3]uint8{uint8(a * 255), uint8(b * 255), uint8(c * 255)}
259+}
260+
197261 func (r *Renderer) Clear(rgb [3]uint8) error {
262+ return r.Render(ports.Scene{Background: fmt.Sprintf("#%02x%02x%02x", rgb[0], rgb[1], rgb[2])})
263+}
264+
265+func (r *Renderer) Render(s ports.Scene) error {
266+ // Stage before recording: a full buffer is an error, not a partial submission.
267+ type fill struct {
268+ rect image.Rectangle
269+ color [3]uint8
270+ offset int
271+ }
272+ var fills []fill
273+ used := 0
274+ add := func(rect image.Rectangle, c [3]uint8) error {
275+ rect = rect.Intersect(image.Rect(0, 0, r.width, r.height))
276+ if rect.Empty() {
277+ return nil
278+ }
279+ n := rect.Dx() * rect.Dy() * 4
280+ if n > r.width*r.height*8-used {
281+ return fmt.Errorf("staging buffer full")
282+ }
283+ data := unsafe.Slice((*byte)(r.stagingMapped), r.width*r.height*8)[used : used+n]
284+ for i := 0; i < n; i += 4 {
285+ data[i], data[i+1], data[i+2], data[i+3] = c[2], c[1], c[0], 255
286+ }
287+ fills = append(fills, fill{rect, c, used})
288+ used += n
289+ return nil
290+ }
291+ for _, w := range s.Windows {
292+ if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
293+ continue
294+ }
295+ x, y := w.Rect.X, w.Rect.Y
296+ // Clip before converting coordinates to Vulkan's signed 32-bit offsets.
297+ body := image.Rect(x, y, x+w.Rect.W, y+w.Rect.H)
298+ if err := add(body, windowColor(w.ID)); err != nil {
299+ return err
300+ }
301+ if w.Focused {
302+ for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+4), image.Rect(x, y+w.Rect.H-4, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+4, x+4, y+w.Rect.H-4), image.Rect(x+w.Rect.W-4, y+4, x+w.Rect.W, y+w.Rect.H-4)} {
303+ if err := add(strip, [3]uint8{255, 255, 255}); err != nil {
304+ return err
305+ }
306+ }
307+ }
308+ }
309+ rgb := parseColor(s.Background)
198310 d := r.dd
199311 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(r.command, 0)); err != nil {
200312 return err
@@ -217,6 +329,15 @@ func (r *Renderer) Clear(rgb [3]uint8) error {
217329 color := vk.ClearColorValue{unorm(rgb[0]), unorm(rgb[1]), unorm(rgb[2]), math.Float32bits(1)}
218330 d.CmdClearColorImage(r.command, r.image, vk.ImageLayoutTransferDstOptimal, &color, 1, &rangeInfo)
219331 barrier.SrcAccessMask = vk.AccessTransferWriteBit
332+ barrier.DstAccessMask = vk.AccessTransferWriteBit
333+ barrier.OldLayout = vk.ImageLayoutTransferDstOptimal
334+ barrier.NewLayout = vk.ImageLayoutTransferDstOptimal
335+ d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)
336+ for _, f := range fills {
337+ rect := f.rect
338+ region := vk.BufferImageCopy{BufferOffset: vk.DeviceSize(f.offset), ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageOffset: vk.Offset3D{X: int32(rect.Min.X), Y: int32(rect.Min.Y)}, ImageExtent: vk.Extent3D{Width: uint32(rect.Dx()), Height: uint32(rect.Dy()), Depth: 1}}
339+ d.CmdCopyBufferToImage(r.command, r.staging, r.image, vk.ImageLayoutTransferDstOptimal, 1, &region)
340+ }
220341 barrier.DstAccessMask = vk.AccessTransferReadBit
221342 barrier.OldLayout = vk.ImageLayoutTransferDstOptimal
222343 barrier.NewLayout = vk.ImageLayoutTransferSrcOptimal
@@ -270,6 +391,18 @@ func (r *Renderer) Close() {
270391 d.DestroyCommandPool(r.device, r.pool, nil)
271392 r.pool = 0
272393 }
394+ if r.stagingMapped != nil {
395+ d.UnmapMemory(r.device, r.stagingMemory)
396+ r.stagingMapped = nil
397+ }
398+ if r.staging != 0 {
399+ d.DestroyBuffer(r.device, r.staging, nil)
400+ r.staging = 0
401+ }
402+ if r.stagingMemory != 0 {
403+ d.FreeMemory(r.device, r.stagingMemory, nil)
404+ r.stagingMemory = 0
405+ }
273406 if r.mapped != nil {
274407 d.UnmapMemory(r.device, r.bufferMemory)
275408 r.mapped = nil
@@ -9,6 +9,8 @@ import (
9 "strconv"9 "strconv"
10 "unsafe"10 "unsafe"
11 11
12+ "github.com/bnema/nefertty/internal/ports"
13+
12 vk "github.com/bnema/purego-vulkan/vulkan"14 vk "github.com/bnema/purego-vulkan/vulkan"
13 )15 )
14 16
@@ -24,6 +26,9 @@ type Renderer struct {
24 imageMemory vk.DeviceMemory26 imageMemory vk.DeviceMemory
25 buffer vk.Buffer27 buffer vk.Buffer
26 bufferMemory vk.DeviceMemory28 bufferMemory vk.DeviceMemory
29+ staging vk.Buffer
30+ stagingMemory vk.DeviceMemory
31+ stagingMapped unsafe.Pointer
27 mapped unsafe.Pointer32 mapped unsafe.Pointer
28 pool vk.CommandPool33 pool vk.CommandPool
29 command vk.CommandBuffer34 command vk.CommandBuffer
@@ -169,6 +174,27 @@ func New(width, height int) (r *Renderer, err error) {
169 if err = checked("vkMapMemory", r.dd.MapMemory(r.device, r.bufferMemory, 0, size, 0, &r.mapped)); err != nil {174 if err = checked("vkMapMemory", r.dd.MapMemory(r.device, r.bufferMemory, 0, size, 0, &r.mapped)); err != nil {
170 return175 return
171 }176 }
177+ // Two frame-sized regions accommodate non-overlapping window bodies and their overlapping borders.
178+ stagingSize := size * 2
179+ stagingInfo := vk.BufferCreateInfo{SType: vk.StructureTypeBufferCreateInfo, Size: stagingSize, Usage: vk.BufferUsageTransferSrcBit, SharingMode: vk.SharingModeExclusive}
180+ if err = checked("vkCreateBuffer(staging)", r.dd.CreateBuffer(r.device, &stagingInfo, nil, &r.staging)); err != nil {
181+ return
182+ }
183+ r.dd.GetBufferMemoryRequirements(r.device, r.staging, &req)
184+ kind, err = r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
185+ if err != nil {
186+ return
187+ }
188+ alloc = vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, AllocationSize: req.Size, MemoryTypeIndex: kind}
189+ if err = checked("vkAllocateMemory(staging)", r.dd.AllocateMemory(r.device, &alloc, nil, &r.stagingMemory)); err != nil {
190+ return
191+ }
192+ if err = checked("vkBindBufferMemory(staging)", r.dd.BindBufferMemory(r.device, r.staging, r.stagingMemory, 0)); err != nil {
193+ return
194+ }
195+ if err = checked("vkMapMemory(staging)", r.dd.MapMemory(r.device, r.stagingMemory, 0, stagingSize, 0, &r.stagingMapped)); err != nil {
196+ return
197+ }
172 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}198 pi := vk.CommandPoolCreateInfo{SType: vk.StructureTypeCommandPoolCreateInfo, Flags: vk.CommandPoolCreateResetCommandBufferBit, QueueFamilyIndex: family}
173 if err = checked("vkCreateCommandPool", r.dd.CreateCommandPool(r.device, &pi, nil, &r.pool)); err != nil {199 if err = checked("vkCreateCommandPool", r.dd.CreateCommandPool(r.device, &pi, nil, &r.pool)); err != nil {
174 return200 return
@@ -194,7 +220,93 @@ func (r *Renderer) findMemoryType(bits uint32, props vk.MemoryPropertyFlags) (ui
194 return 0, fmt.Errorf("no Vulkan memory type for bits %#x and properties %#x", bits, props)220 return 0, fmt.Errorf("no Vulkan memory type for bits %#x and properties %#x", bits, props)
195 }221 }
196 222
223+func parseColor(s string) [3]uint8 {
224+ var c [3]uint8
225+ if len(s) != 7 || s[0] != '#' {
226+ return c
227+ }
228+ for i := range c {
229+ v, e := strconv.ParseUint(s[1+i*2:3+i*2], 16, 8)
230+ if e != nil {
231+ return [3]uint8{}
232+ }
233+ c[i] = uint8(v)
234+ }
235+ return c
236+}
237+
238+func windowColor(id ports.WindowID) [3]uint8 {
239+ h := math.Mod(float64(id)*0.618034, 1) * 6
240+ sector := int(h)
241+ f := h - float64(sector)
242+ p, q, t := 0.4, 0.8*(1-0.5*f), 0.8*(1-0.5*(1-f))
243+ var a, b, c float64
244+ switch sector {
245+ case 0:
246+ a, b, c = 0.8, t, p
247+ case 1:
248+ a, b, c = q, 0.8, p
249+ case 2:
250+ a, b, c = p, 0.8, t
251+ case 3:
252+ a, b, c = p, q, 0.8
253+ case 4:
254+ a, b, c = t, p, 0.8
255+ default:
256+ a, b, c = 0.8, p, q
257+ }
258+ return [3]uint8{uint8(a * 255), uint8(b * 255), uint8(c * 255)}
259+}
260+
197 func (r *Renderer) Clear(rgb [3]uint8) error {261 func (r *Renderer) Clear(rgb [3]uint8) error {
262+ return r.Render(ports.Scene{Background: fmt.Sprintf("#%02x%02x%02x", rgb[0], rgb[1], rgb[2])})
263+}
264+
265+func (r *Renderer) Render(s ports.Scene) error {
266+ // Stage before recording: a full buffer is an error, not a partial submission.
267+ type fill struct {
268+ rect image.Rectangle
269+ color [3]uint8
270+ offset int
271+ }
272+ var fills []fill
273+ used := 0
274+ add := func(rect image.Rectangle, c [3]uint8) error {
275+ rect = rect.Intersect(image.Rect(0, 0, r.width, r.height))
276+ if rect.Empty() {
277+ return nil
278+ }
279+ n := rect.Dx() * rect.Dy() * 4
280+ if n > r.width*r.height*8-used {
281+ return fmt.Errorf("staging buffer full")
282+ }
283+ data := unsafe.Slice((*byte)(r.stagingMapped), r.width*r.height*8)[used : used+n]
284+ for i := 0; i < n; i += 4 {
285+ data[i], data[i+1], data[i+2], data[i+3] = c[2], c[1], c[0], 255
286+ }
287+ fills = append(fills, fill{rect, c, used})
288+ used += n
289+ return nil
290+ }
291+ for _, w := range s.Windows {
292+ if w.Hidden || w.Rect.W <= 0 || w.Rect.H <= 0 {
293+ continue
294+ }
295+ x, y := w.Rect.X, w.Rect.Y
296+ // Clip before converting coordinates to Vulkan's signed 32-bit offsets.
297+ body := image.Rect(x, y, x+w.Rect.W, y+w.Rect.H)
298+ if err := add(body, windowColor(w.ID)); err != nil {
299+ return err
300+ }
301+ if w.Focused {
302+ for _, strip := range []image.Rectangle{image.Rect(x, y, x+w.Rect.W, y+4), image.Rect(x, y+w.Rect.H-4, x+w.Rect.W, y+w.Rect.H), image.Rect(x, y+4, x+4, y+w.Rect.H-4), image.Rect(x+w.Rect.W-4, y+4, x+w.Rect.W, y+w.Rect.H-4)} {
303+ if err := add(strip, [3]uint8{255, 255, 255}); err != nil {
304+ return err
305+ }
306+ }
307+ }
308+ }
309+ rgb := parseColor(s.Background)
198 d := r.dd310 d := r.dd
199 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(r.command, 0)); err != nil {311 if err := checked("vkResetCommandBuffer", d.ResetCommandBuffer(r.command, 0)); err != nil {
200 return err312 return err
@@ -217,6 +329,15 @@ func (r *Renderer) Clear(rgb [3]uint8) error {
217 color := vk.ClearColorValue{unorm(rgb[0]), unorm(rgb[1]), unorm(rgb[2]), math.Float32bits(1)}329 color := vk.ClearColorValue{unorm(rgb[0]), unorm(rgb[1]), unorm(rgb[2]), math.Float32bits(1)}
218 d.CmdClearColorImage(r.command, r.image, vk.ImageLayoutTransferDstOptimal, &color, 1, &rangeInfo)330 d.CmdClearColorImage(r.command, r.image, vk.ImageLayoutTransferDstOptimal, &color, 1, &rangeInfo)
219 barrier.SrcAccessMask = vk.AccessTransferWriteBit331 barrier.SrcAccessMask = vk.AccessTransferWriteBit
332+ barrier.DstAccessMask = vk.AccessTransferWriteBit
333+ barrier.OldLayout = vk.ImageLayoutTransferDstOptimal
334+ barrier.NewLayout = vk.ImageLayoutTransferDstOptimal
335+ d.CmdPipelineBarrier(r.command, vk.PipelineStageTransferBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &barrier)
336+ for _, f := range fills {
337+ rect := f.rect
338+ region := vk.BufferImageCopy{BufferOffset: vk.DeviceSize(f.offset), ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageOffset: vk.Offset3D{X: int32(rect.Min.X), Y: int32(rect.Min.Y)}, ImageExtent: vk.Extent3D{Width: uint32(rect.Dx()), Height: uint32(rect.Dy()), Depth: 1}}
339+ d.CmdCopyBufferToImage(r.command, r.staging, r.image, vk.ImageLayoutTransferDstOptimal, 1, &region)
340+ }
220 barrier.DstAccessMask = vk.AccessTransferReadBit341 barrier.DstAccessMask = vk.AccessTransferReadBit
221 barrier.OldLayout = vk.ImageLayoutTransferDstOptimal342 barrier.OldLayout = vk.ImageLayoutTransferDstOptimal
222 barrier.NewLayout = vk.ImageLayoutTransferSrcOptimal343 barrier.NewLayout = vk.ImageLayoutTransferSrcOptimal
@@ -270,6 +391,18 @@ func (r *Renderer) Close() {
270 d.DestroyCommandPool(r.device, r.pool, nil)391 d.DestroyCommandPool(r.device, r.pool, nil)
271 r.pool = 0392 r.pool = 0
272 }393 }
394+ if r.stagingMapped != nil {
395+ d.UnmapMemory(r.device, r.stagingMemory)
396+ r.stagingMapped = nil
397+ }
398+ if r.staging != 0 {
399+ d.DestroyBuffer(r.device, r.staging, nil)
400+ r.staging = 0
401+ }
402+ if r.stagingMemory != 0 {
403+ d.FreeMemory(r.device, r.stagingMemory, nil)
404+ r.stagingMemory = 0
405+ }
273 if r.mapped != nil {406 if r.mapped != nil {
274 d.UnmapMemory(r.device, r.bufferMemory)407 d.UnmapMemory(r.device, r.bufferMemory)
275 r.mapped = nil408 r.mapped = nil
M internal/adapters/vulkan/renderer_test.go +45 -0
@@ -1,6 +1,8 @@
11 package vulkan
22
33 import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "image"
46 "image/color"
57 "testing"
68 )
@@ -22,3 +24,46 @@ func TestRendererClear(t *testing.T) {
2224 }
2325 }
2426 }
27+
28+func TestParseColor(t *testing.T) {
29+ for _, tc := range []struct {
30+ s string
31+ want [3]uint8
32+ }{{"#102030", [3]uint8{16, 32, 48}}, {"#FFFFFF", [3]uint8{255, 255, 255}}, {"bad", [3]uint8{}}, {"#12gg34", [3]uint8{}}, {"102030", [3]uint8{}}} {
33+ if got := parseColor(tc.s); got != tc.want {
34+ t.Errorf("parseColor(%q)=%v want %v", tc.s, got, tc.want)
35+ }
36+ }
37+}
38+
39+func TestRendererRender(t *testing.T) {
40+ r, err := New(64, 48)
41+ if err != nil {
42+ t.Skipf("Vulkan unavailable: %v", err)
43+ }
44+ defer r.Close()
45+ bg := color.RGBA{16, 32, 48, 255}
46+ check := func(s ports.Scene, expected map[image.Point]color.RGBA) {
47+ t.Helper()
48+ if err := r.Render(s); err != nil {
49+ t.Fatal(err)
50+ }
51+ for p, want := range expected {
52+ if got := r.Pixels().At(p.X, p.Y); got != want {
53+ t.Errorf("pixel %v=%v want %v", p, got, want)
54+ }
55+ }
56+ }
57+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
58+ c := windowColor(1)
59+ wc := color.RGBA{c[0], c[1], c[2], 255}
60+ s := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{win}}
61+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {16, 16}: wc})
62+ s.Windows[0].Focused = true
63+ check(s, map[image.Point]color.RGBA{{9, 9}: {255, 255, 255, 255}, {16, 16}: wc})
64+ s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}
65+ c = windowColor(2)
66+ check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})
67+ s.Windows[0].Hidden = true
68+ check(s, map[image.Point]color.RGBA{{5, 5}: bg})
69+}
@@ -1,6 +1,8 @@
1 package vulkan1 package vulkan
2 2
3 import (3 import (
4+ "github.com/bnema/nefertty/internal/ports"
5+ "image"
4 "image/color"6 "image/color"
5 "testing"7 "testing"
6 )8 )
@@ -22,3 +24,46 @@ func TestRendererClear(t *testing.T) {
22 }24 }
23 }25 }
24 }26 }
27+
28+func TestParseColor(t *testing.T) {
29+ for _, tc := range []struct {
30+ s string
31+ want [3]uint8
32+ }{{"#102030", [3]uint8{16, 32, 48}}, {"#FFFFFF", [3]uint8{255, 255, 255}}, {"bad", [3]uint8{}}, {"#12gg34", [3]uint8{}}, {"102030", [3]uint8{}}} {
33+ if got := parseColor(tc.s); got != tc.want {
34+ t.Errorf("parseColor(%q)=%v want %v", tc.s, got, tc.want)
35+ }
36+ }
37+}
38+
39+func TestRendererRender(t *testing.T) {
40+ r, err := New(64, 48)
41+ if err != nil {
42+ t.Skipf("Vulkan unavailable: %v", err)
43+ }
44+ defer r.Close()
45+ bg := color.RGBA{16, 32, 48, 255}
46+ check := func(s ports.Scene, expected map[image.Point]color.RGBA) {
47+ t.Helper()
48+ if err := r.Render(s); err != nil {
49+ t.Fatal(err)
50+ }
51+ for p, want := range expected {
52+ if got := r.Pixels().At(p.X, p.Y); got != want {
53+ t.Errorf("pixel %v=%v want %v", p, got, want)
54+ }
55+ }
56+ }
57+ win := ports.SceneWindow{ID: 1, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}}
58+ c := windowColor(1)
59+ wc := color.RGBA{c[0], c[1], c[2], 255}
60+ s := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{win}}
61+ check(s, map[image.Point]color.RGBA{{0, 0}: bg, {16, 16}: wc})
62+ s.Windows[0].Focused = true
63+ check(s, map[image.Point]color.RGBA{{9, 9}: {255, 255, 255, 255}, {16, 16}: wc})
64+ s.Windows[0] = ports.SceneWindow{ID: 2, Rect: ports.Rect{X: -10, Y: 0, W: 20, H: 10}}
65+ c = windowColor(2)
66+ check(s, map[image.Point]color.RGBA{{5, 5}: {c[0], c[1], c[2], 255}, {15, 5}: bg})
67+ s.Windows[0].Hidden = true
68+ check(s, map[image.Point]color.RGBA{{5, 5}: bg})
69+}

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 26

Tags 10

↑↓ moveenter openesc close