detached 6010507

feat(layout): record module spans and look up the module at a column

commit
60105079c3b767672267307f6b2c10612bf7b6c5
author
brice · · 2026-10-09 03:34 UTC
parent
950b5b5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +192 -3
M internal/layout/layout.go +42 -3
@@ -18,6 +18,16 @@ type Source struct {
1818 input []byte // reset + erase + frame, reused for each Write
1919 cells []gpu.Cell
2020 failed bool
21+
22+ // start and shown are where Compose put the module's cells in the row, and
23+ // how many of them survived clipping.
24+ start, shown int
25+}
26+
27+// Span is the part of the row a module occupies after Compose.
28+type Span struct {
29+ M *module.Module
30+ Start, Width int
2131 }
2232
2333 // Layout owns the sources and the composed row.
@@ -208,6 +218,7 @@ func (l *Layout) Compose() []gpu.Cell {
208218 }
209219 var left, center, right int
210220 for _, s := range l.sources {
221+ s.start, s.shown = 0, 0
211222 n := len(s.cells)
212223 switch s.M.Zone {
213224 case module.Left:
@@ -224,14 +235,18 @@ func (l *Layout) Compose() []gpu.Cell {
224235 x := l.cols - right
225236 for _, s := range l.sources {
226237 if s.M.Zone == module.Right {
227- x += copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
238+ s.start = x
239+ s.shown = copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
240+ x += s.shown
228241 }
229242 }
230243 // Left zone from the left edge, clipped to its budget.
231244 x, limit := 0, left
232245 for _, s := range l.sources {
233246 if s.M.Zone == module.Left && x < limit {
234- x += copy(l.row[x:limit], s.cells)
247+ s.start = x
248+ s.shown = copy(l.row[x:limit], s.cells)
249+ x += s.shown
235250 }
236251 }
237252 // Center zone: on the middle of the bar, and only as far from it as the
@@ -245,9 +260,33 @@ func (l *Layout) Compose() []gpu.Cell {
245260 x, limit = start, start+center
246261 for _, s := range l.sources {
247262 if s.M.Zone == module.Center && x < limit {
248- x += copy(l.row[x:limit], s.cells)
263+ s.start = x
264+ s.shown = copy(l.row[x:limit], s.cells)
265+ x += s.shown
249266 }
250267 }
251268 }
252269 return l.row
253270 }
271+
272+// Spans appends the span of every module that has cells in the row composed
273+// last, in module order.
274+func (l *Layout) Spans(dst []Span) []Span {
275+ for _, s := range l.sources {
276+ if s.shown > 0 {
277+ dst = append(dst, Span{M: s.M, Start: s.start, Width: s.shown})
278+ }
279+ }
280+ return dst
281+}
282+
283+// At returns the module shown at column col of the row composed last, and the
284+// column's offset inside the module.
285+func (l *Layout) At(col int) (m *module.Module, offset int, ok bool) {
286+ for _, s := range l.sources {
287+ if col >= s.start && col < s.start+s.shown {
288+ return s.M, col - s.start, true
289+ }
290+ }
291+ return nil, 0, false
292+}
@@ -18,6 +18,16 @@ type Source struct {
18 input []byte // reset + erase + frame, reused for each Write18 input []byte // reset + erase + frame, reused for each Write
19 cells []gpu.Cell19 cells []gpu.Cell
20 failed bool20 failed bool
21+
22+ // start and shown are where Compose put the module's cells in the row, and
23+ // how many of them survived clipping.
24+ start, shown int
25+}
26+
27+// Span is the part of the row a module occupies after Compose.
28+type Span struct {
29+ M *module.Module
30+ Start, Width int
21 }31 }
22 32
23 // Layout owns the sources and the composed row.33 // Layout owns the sources and the composed row.
@@ -208,6 +218,7 @@ func (l *Layout) Compose() []gpu.Cell {
208 }218 }
209 var left, center, right int219 var left, center, right int
210 for _, s := range l.sources {220 for _, s := range l.sources {
221+ s.start, s.shown = 0, 0
211 n := len(s.cells)222 n := len(s.cells)
212 switch s.M.Zone {223 switch s.M.Zone {
213 case module.Left:224 case module.Left:
@@ -224,14 +235,18 @@ func (l *Layout) Compose() []gpu.Cell {
224 x := l.cols - right235 x := l.cols - right
225 for _, s := range l.sources {236 for _, s := range l.sources {
226 if s.M.Zone == module.Right {237 if s.M.Zone == module.Right {
227- x += copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])238+ s.start = x
239+ s.shown = copy(l.row[x:], s.cells[:min(len(s.cells), l.cols-x)])
240+ x += s.shown
228 }241 }
229 }242 }
230 // Left zone from the left edge, clipped to its budget.243 // Left zone from the left edge, clipped to its budget.
231 x, limit := 0, left244 x, limit := 0, left
232 for _, s := range l.sources {245 for _, s := range l.sources {
233 if s.M.Zone == module.Left && x < limit {246 if s.M.Zone == module.Left && x < limit {
234- x += copy(l.row[x:limit], s.cells)247+ s.start = x
248+ s.shown = copy(l.row[x:limit], s.cells)
249+ x += s.shown
235 }250 }
236 }251 }
237 // Center zone: on the middle of the bar, and only as far from it as the252 // Center zone: on the middle of the bar, and only as far from it as the
@@ -245,9 +260,33 @@ func (l *Layout) Compose() []gpu.Cell {
245 x, limit = start, start+center260 x, limit = start, start+center
246 for _, s := range l.sources {261 for _, s := range l.sources {
247 if s.M.Zone == module.Center && x < limit {262 if s.M.Zone == module.Center && x < limit {
248- x += copy(l.row[x:limit], s.cells)263+ s.start = x
264+ s.shown = copy(l.row[x:limit], s.cells)
265+ x += s.shown
249 }266 }
250 }267 }
251 }268 }
252 return l.row269 return l.row
253 }270 }
271+
272+// Spans appends the span of every module that has cells in the row composed
273+// last, in module order.
274+func (l *Layout) Spans(dst []Span) []Span {
275+ for _, s := range l.sources {
276+ if s.shown > 0 {
277+ dst = append(dst, Span{M: s.M, Start: s.start, Width: s.shown})
278+ }
279+ }
280+ return dst
281+}
282+
283+// At returns the module shown at column col of the row composed last, and the
284+// column's offset inside the module.
285+func (l *Layout) At(col int) (m *module.Module, offset int, ok bool) {
286+ for _, s := range l.sources {
287+ if col >= s.start && col < s.start+s.shown {
288+ return s.M, col - s.start, true
289+ }
290+ }
291+ return nil, 0, false
292+}
M internal/layout/layout_test.go +150 -0
@@ -221,3 +221,153 @@ func TestCenterMovesOnlyAsFarAsNeeded(t *testing.T) {
221221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)
222222 }
223223 }
224+
225+func spanOf(spans []Span, m *module.Module) (Span, bool) {
226+ for _, s := range spans {
227+ if s.M == m {
228+ return s, true
229+ }
230+ }
231+ return Span{}, false
232+}
233+
234+func TestSpansPerZone(t *testing.T) {
235+ l, m := newTestLayout(20)
236+ publish(m[0], "LEFT")
237+ publish(m[1], "mid")
238+ publish(m[2], "RIGHT")
239+ l.Update()
240+ row := rowText(l)
241+ spans := l.Spans(nil)
242+ if len(spans) != 3 {
243+ t.Fatalf("spans = %+v, want 3", spans)
244+ }
245+ for i, want := range []string{"LEFT", "mid", "RIGHT"} {
246+ s, ok := spanOf(spans, m[i])
247+ if !ok || s.Width != len(want) || row[s.Start:s.Start+s.Width] != want {
248+ t.Errorf("span %d = %+v (ok %v) in %q, want %q", i, s, ok, row, want)
249+ }
250+ }
251+ // Spans come in module order.
252+ if spans[0].M != m[0] || spans[1].M != m[1] || spans[2].M != m[2] {
253+ t.Errorf("spans out of order: %+v", spans)
254+ }
255+}
256+
257+func TestSpansTwoModulesInOneZone(t *testing.T) {
258+ wake := make(chan struct{}, 1)
259+ log := slog.New(slog.DiscardHandler)
260+ a := module.New("a", module.Left, "true", wake, log)
261+ b := module.New("b", module.Left, "true", wake, log)
262+ l := New(10, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, testPalette, []*module.Module{a, b})
263+ publish(a, "AA")
264+ publish(b, "BBB")
265+ l.Update()
266+ l.Compose()
267+ sa, _ := spanOf(l.Spans(nil), a)
268+ sb, _ := spanOf(l.Spans(nil), b)
269+ if sa != (Span{a, 0, 2}) || sb != (Span{b, 2, 3}) {
270+ t.Fatalf("spans = %+v %+v", sa, sb)
271+ }
272+ for col, want := range map[int]struct {
273+ m *module.Module
274+ off int
275+ ok bool
276+ }{0: {a, 0, true}, 1: {a, 1, true}, 2: {b, 0, true}, 4: {b, 2, true}, 5: {nil, 0, false}, -1: {nil, 0, false}, 99: {nil, 0, false}} {
277+ gm, off, ok := l.At(col)
278+ if gm != want.m || off != want.off || ok != want.ok {
279+ t.Errorf("At(%d) = %v, %d, %v; want %v, %d, %v", col, gm, off, ok, want.m, want.off, want.ok)
280+ }
281+ }
282+}
283+
284+func TestSpansAreClipped(t *testing.T) {
285+ // The right zone wins over the left one: left is cut to the room left.
286+ l, m := newTestLayout(8)
287+ publish(m[0], "AAAAA")
288+ publish(m[2], "ZZZZZ")
289+ l.Update()
290+ l.Compose()
291+ spans := l.Spans(nil)
292+ if s, ok := spanOf(spans, m[0]); !ok || s != (Span{m[0], 0, 3}) {
293+ t.Errorf("left span = %+v, want {0,3}", s)
294+ }
295+ if s, ok := spanOf(spans, m[2]); !ok || s != (Span{m[2], 3, 5}) {
296+ t.Errorf("right span = %+v, want {3,5}", s)
297+ }
298+ if _, _, ok := l.At(3); !ok {
299+ t.Error("column 3 belongs to the right module")
300+ }
301+ if gm, off, _ := l.At(3); gm != m[2] || off != 0 {
302+ t.Errorf("At(3) = %v, %d", gm, off)
303+ }
304+
305+ // The center is cut first, and may vanish entirely.
306+ l, m = newTestLayout(12)
307+ publish(m[0], "AAAAA")
308+ publish(m[1], "CCCCCCCC")
309+ publish(m[2], "ZZZZZ")
310+ l.Update()
311+ l.Compose()
312+ if s, ok := spanOf(l.Spans(nil), m[1]); !ok || s != (Span{m[1], 5, 2}) {
313+ t.Errorf("center span = %+v (ok %v), want {5,2}", s, ok)
314+ }
315+ l, m = newTestLayout(10)
316+ publish(m[0], "AAAAA")
317+ publish(m[1], "CC")
318+ publish(m[2], "ZZZZZ")
319+ l.Update()
320+ l.Compose()
321+ if _, ok := spanOf(l.Spans(nil), m[1]); ok {
322+ t.Error("a fully clipped module must have no span")
323+ }
324+ if _, _, ok := l.At(5); !ok {
325+ // column 5 is the first of the right zone
326+ t.Error("At(5) must find the right module")
327+ }
328+}
329+
330+func TestSpansEmptyModuleAndRecompose(t *testing.T) {
331+ l, m := newTestLayout(10)
332+ publish(m[0], "")
333+ publish(m[2], "R")
334+ l.Update()
335+ l.Compose()
336+ if _, ok := spanOf(l.Spans(nil), m[0]); ok {
337+ t.Error("an empty module has no span")
338+ }
339+ if len(l.Spans(nil)) != 1 {
340+ t.Errorf("spans = %+v", l.Spans(nil))
341+ }
342+ // A module that empties out loses its span at the next Compose.
343+ publish(m[2], "")
344+ l.Update()
345+ l.Compose()
346+ if got := l.Spans(nil); len(got) != 0 {
347+ t.Errorf("spans after emptying = %+v", got)
348+ }
349+ if _, _, ok := l.At(9); ok {
350+ t.Error("At must not find an emptied module")
351+ }
352+}
353+
354+func TestComposeAndSpansAllocs(t *testing.T) {
355+ if racecheck.Enabled {
356+ t.Skip("the race detector allocates")
357+ }
358+ l, m := newTestLayout(40)
359+ publish(m[0], "left")
360+ publish(m[1], "center")
361+ publish(m[2], "right")
362+ l.Update()
363+ if got := testing.AllocsPerRun(100, func() { l.Compose() }); got > 0 {
364+ t.Errorf("Compose allocates %.1f objects; want 0", got)
365+ }
366+ buf := make([]Span, 0, 8)
367+ if got := testing.AllocsPerRun(100, func() { buf = l.Spans(buf[:0]) }); got > 0 {
368+ t.Errorf("Spans allocates %.1f objects; want 0", got)
369+ }
370+ if got := testing.AllocsPerRun(100, func() { l.At(3) }); got > 0 {
371+ t.Errorf("At allocates %.1f objects; want 0", got)
372+ }
373+}
@@ -221,3 +221,153 @@ func TestCenterMovesOnlyAsFarAsNeeded(t *testing.T) {
221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)221 t.Fatalf("center starts at cell %d, want 16 (ending where the right zone starts): %q", i, got)
222 }222 }
223 }223 }
224+
225+func spanOf(spans []Span, m *module.Module) (Span, bool) {
226+ for _, s := range spans {
227+ if s.M == m {
228+ return s, true
229+ }
230+ }
231+ return Span{}, false
232+}
233+
234+func TestSpansPerZone(t *testing.T) {
235+ l, m := newTestLayout(20)
236+ publish(m[0], "LEFT")
237+ publish(m[1], "mid")
238+ publish(m[2], "RIGHT")
239+ l.Update()
240+ row := rowText(l)
241+ spans := l.Spans(nil)
242+ if len(spans) != 3 {
243+ t.Fatalf("spans = %+v, want 3", spans)
244+ }
245+ for i, want := range []string{"LEFT", "mid", "RIGHT"} {
246+ s, ok := spanOf(spans, m[i])
247+ if !ok || s.Width != len(want) || row[s.Start:s.Start+s.Width] != want {
248+ t.Errorf("span %d = %+v (ok %v) in %q, want %q", i, s, ok, row, want)
249+ }
250+ }
251+ // Spans come in module order.
252+ if spans[0].M != m[0] || spans[1].M != m[1] || spans[2].M != m[2] {
253+ t.Errorf("spans out of order: %+v", spans)
254+ }
255+}
256+
257+func TestSpansTwoModulesInOneZone(t *testing.T) {
258+ wake := make(chan struct{}, 1)
259+ log := slog.New(slog.DiscardHandler)
260+ a := module.New("a", module.Left, "true", wake, log)
261+ b := module.New("b", module.Left, "true", wake, log)
262+ l := New(10, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, testPalette, []*module.Module{a, b})
263+ publish(a, "AA")
264+ publish(b, "BBB")
265+ l.Update()
266+ l.Compose()
267+ sa, _ := spanOf(l.Spans(nil), a)
268+ sb, _ := spanOf(l.Spans(nil), b)
269+ if sa != (Span{a, 0, 2}) || sb != (Span{b, 2, 3}) {
270+ t.Fatalf("spans = %+v %+v", sa, sb)
271+ }
272+ for col, want := range map[int]struct {
273+ m *module.Module
274+ off int
275+ ok bool
276+ }{0: {a, 0, true}, 1: {a, 1, true}, 2: {b, 0, true}, 4: {b, 2, true}, 5: {nil, 0, false}, -1: {nil, 0, false}, 99: {nil, 0, false}} {
277+ gm, off, ok := l.At(col)
278+ if gm != want.m || off != want.off || ok != want.ok {
279+ t.Errorf("At(%d) = %v, %d, %v; want %v, %d, %v", col, gm, off, ok, want.m, want.off, want.ok)
280+ }
281+ }
282+}
283+
284+func TestSpansAreClipped(t *testing.T) {
285+ // The right zone wins over the left one: left is cut to the room left.
286+ l, m := newTestLayout(8)
287+ publish(m[0], "AAAAA")
288+ publish(m[2], "ZZZZZ")
289+ l.Update()
290+ l.Compose()
291+ spans := l.Spans(nil)
292+ if s, ok := spanOf(spans, m[0]); !ok || s != (Span{m[0], 0, 3}) {
293+ t.Errorf("left span = %+v, want {0,3}", s)
294+ }
295+ if s, ok := spanOf(spans, m[2]); !ok || s != (Span{m[2], 3, 5}) {
296+ t.Errorf("right span = %+v, want {3,5}", s)
297+ }
298+ if _, _, ok := l.At(3); !ok {
299+ t.Error("column 3 belongs to the right module")
300+ }
301+ if gm, off, _ := l.At(3); gm != m[2] || off != 0 {
302+ t.Errorf("At(3) = %v, %d", gm, off)
303+ }
304+
305+ // The center is cut first, and may vanish entirely.
306+ l, m = newTestLayout(12)
307+ publish(m[0], "AAAAA")
308+ publish(m[1], "CCCCCCCC")
309+ publish(m[2], "ZZZZZ")
310+ l.Update()
311+ l.Compose()
312+ if s, ok := spanOf(l.Spans(nil), m[1]); !ok || s != (Span{m[1], 5, 2}) {
313+ t.Errorf("center span = %+v (ok %v), want {5,2}", s, ok)
314+ }
315+ l, m = newTestLayout(10)
316+ publish(m[0], "AAAAA")
317+ publish(m[1], "CC")
318+ publish(m[2], "ZZZZZ")
319+ l.Update()
320+ l.Compose()
321+ if _, ok := spanOf(l.Spans(nil), m[1]); ok {
322+ t.Error("a fully clipped module must have no span")
323+ }
324+ if _, _, ok := l.At(5); !ok {
325+ // column 5 is the first of the right zone
326+ t.Error("At(5) must find the right module")
327+ }
328+}
329+
330+func TestSpansEmptyModuleAndRecompose(t *testing.T) {
331+ l, m := newTestLayout(10)
332+ publish(m[0], "")
333+ publish(m[2], "R")
334+ l.Update()
335+ l.Compose()
336+ if _, ok := spanOf(l.Spans(nil), m[0]); ok {
337+ t.Error("an empty module has no span")
338+ }
339+ if len(l.Spans(nil)) != 1 {
340+ t.Errorf("spans = %+v", l.Spans(nil))
341+ }
342+ // A module that empties out loses its span at the next Compose.
343+ publish(m[2], "")
344+ l.Update()
345+ l.Compose()
346+ if got := l.Spans(nil); len(got) != 0 {
347+ t.Errorf("spans after emptying = %+v", got)
348+ }
349+ if _, _, ok := l.At(9); ok {
350+ t.Error("At must not find an emptied module")
351+ }
352+}
353+
354+func TestComposeAndSpansAllocs(t *testing.T) {
355+ if racecheck.Enabled {
356+ t.Skip("the race detector allocates")
357+ }
358+ l, m := newTestLayout(40)
359+ publish(m[0], "left")
360+ publish(m[1], "center")
361+ publish(m[2], "right")
362+ l.Update()
363+ if got := testing.AllocsPerRun(100, func() { l.Compose() }); got > 0 {
364+ t.Errorf("Compose allocates %.1f objects; want 0", got)
365+ }
366+ buf := make([]Span, 0, 8)
367+ if got := testing.AllocsPerRun(100, func() { buf = l.Spans(buf[:0]) }); got > 0 {
368+ t.Errorf("Spans allocates %.1f objects; want 0", got)
369+ }
370+ if got := testing.AllocsPerRun(100, func() { l.At(3) }); got > 0 {
371+ t.Errorf("At allocates %.1f objects; want 0", got)
372+ }
373+}

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 5

Tags 4

↑↓ moveenter openesc close