detached a094d2b

Merge pull request 'fix(overview): keep a maximized cascade column at full size beside its cards' (#25) from fix/cascade-overview-maximized-ratio into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/25
commit
a094d2b7e424aee0d7e0732bdeb74b9a448ea6d5
author
bnema · · 2026-10-08 12:53 UTC
merge of
1806e6d + dbfc794 · diff against the first parent
signature
unsigned
2 files · +40 -32
M internal/core/overview.go +16 -16
@@ -863,7 +863,8 @@ func (w *Workspace) overviewZoom() float64 {
863863 }
864864
865865 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
866-// cascade workspace spans one screen whatever its band count.
866+// cascade workspace spans one screen whatever its band count, plus the room
867+// of the cards a maximized column hides.
867868 func (w *Workspace) zoomSpan() (float64, int) {
868869 // Only the span is read: the tiles go in the zoom scratch, dead on return.
869870 var span int
@@ -978,7 +979,11 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
978979 rects = w.colBuf
979980 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
980981 }
982+ // off is the room, left of the band, for the cards a maximized
983+ // cascade column hides (the same room is kept on its right).
984+ off := 0
981985 if w.hasBandCards() {
986+ off = 2 * w.bandCardStep()
982987 // The columns the maximized one hides are cards behind it,
983988 // farthest first so the nearest paint over them.
984989 first, last := w.laneBounds(w.laneOf(w.Focus))
@@ -998,18 +1003,13 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
9981003 continue // A card, above.
9991004 }
10001005 // The band is the lane: it is encoded in Y, one screen apart.
1001- // A maximized column fills its band as on screen, inset for
1002- // the cards it hides.
1003- r.X = w.cellX(i)
1006+ // A maximized column fills its band as on screen; the cards
1007+ // it hides peek out on either side, past the band.
1008+ r.X = off + w.cellX(i)
10041009 r.Y = g + w.band(i)*w.Usable.H
10051010 r.W = w.cellWidth(i)
10061011 if c.FullWidth {
1007- in := 0
1008- if i == w.Focus && w.hasBandCards() {
1009- // Only the focused column has cards behind it.
1010- in = 2 * w.bandCardStep()
1011- }
1012- r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0)
1012+ r.X, r.W = off+g, max(w.Usable.W-2*g, 0)
10131013 }
10141014 } else if w.Overflow == OverflowFixed {
10151015 r = rects[i]
@@ -1042,7 +1042,7 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
10421042 }
10431043 }
10441044 if w.policy().wraps && len(w.Columns) > 0 {
1045- span = w.Usable.W
1045+ span = w.Usable.W + 2*off
10461046 }
10471047 return tiles, span, sel
10481048 }
@@ -1055,8 +1055,7 @@ func (w *Workspace) bandCardStep() int {
10551055
10561056 // hasBandCards reports whether the focused column of a cascade is
10571057 // maximized and hides other columns of its band: the overview shows them
1058-// as cards behind it, the maximized column inset by two card steps on
1059-// each side.
1058+// as cards behind it, peeking out up to two card steps on each side.
10601059 func (w *Workspace) hasBandCards() bool {
10611060 if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {
10621061 return false
@@ -1067,8 +1066,9 @@ func (w *Workspace) hasBandCards() bool {
10671066
10681067 // appendBandCard appends hidden column i of a cascade band as a dimmed card
10691068 // at its cell size behind the maximized column, on its side: the nearest
1070-// column one step out from the maximized one, the next ones two steps out
1071-// (on the band's edge), like the cards of a stack.
1069+// column one step out from the maximized one, the next ones two steps out,
1070+// like the cards of a stack. The maximized column keeps its full size two
1071+// steps right of the row's start (see previewTilesInto).
10721072 func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
10731073 g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
10741074 r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
@@ -1076,7 +1076,7 @@ func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
10761076 if i < w.Focus {
10771077 r.X = g + (2-k)*s
10781078 } else {
1079- r.X = w.Usable.W - g - (2-k)*s - r.W
1079+ r.X = w.Usable.W - g + (2+k)*s - r.W
10801080 }
10811081 w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
10821082 for j, t := range w.rowBuf {
@@ -863,7 +863,8 @@ func (w *Workspace) overviewZoom() float64 {
863 }863 }
864 864
865 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A865 // zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
866-// cascade workspace spans one screen whatever its band count.866+// cascade workspace spans one screen whatever its band count, plus the room
867+// of the cards a maximized column hides.
867 func (w *Workspace) zoomSpan() (float64, int) {868 func (w *Workspace) zoomSpan() (float64, int) {
868 // Only the span is read: the tiles go in the zoom scratch, dead on return.869 // Only the span is read: the tiles go in the zoom scratch, dead on return.
869 var span int870 var span int
@@ -978,7 +979,11 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
978 rects = w.colBuf979 rects = w.colBuf
979 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.980 span = w.Usable.W // Fixed overflow never scrolls, even with a stash pile.
980 }981 }
982+ // off is the room, left of the band, for the cards a maximized
983+ // cascade column hides (the same room is kept on its right).
984+ off := 0
981 if w.hasBandCards() {985 if w.hasBandCards() {
986+ off = 2 * w.bandCardStep()
982 // The columns the maximized one hides are cards behind it,987 // The columns the maximized one hides are cards behind it,
983 // farthest first so the nearest paint over them.988 // farthest first so the nearest paint over them.
984 first, last := w.laneBounds(w.laneOf(w.Focus))989 first, last := w.laneBounds(w.laneOf(w.Focus))
@@ -998,18 +1003,13 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
998 continue // A card, above.1003 continue // A card, above.
999 }1004 }
1000 // The band is the lane: it is encoded in Y, one screen apart.1005 // The band is the lane: it is encoded in Y, one screen apart.
1001- // A maximized column fills its band as on screen, inset for1006+ // A maximized column fills its band as on screen; the cards
1002- // the cards it hides.1007+ // it hides peek out on either side, past the band.
1003- r.X = w.cellX(i)1008+ r.X = off + w.cellX(i)
1004 r.Y = g + w.band(i)*w.Usable.H1009 r.Y = g + w.band(i)*w.Usable.H
1005 r.W = w.cellWidth(i)1010 r.W = w.cellWidth(i)
1006 if c.FullWidth {1011 if c.FullWidth {
1007- in := 01012+ r.X, r.W = off+g, max(w.Usable.W-2*g, 0)
1008- if i == w.Focus && w.hasBandCards() {
1009- // Only the focused column has cards behind it.
1010- in = 2 * w.bandCardStep()
1011- }
1012- r.X, r.W = g+in, max(w.Usable.W-2*g-2*in, 0)
1013 }1013 }
1014 } else if w.Overflow == OverflowFixed {1014 } else if w.Overflow == OverflowFixed {
1015 r = rects[i]1015 r = rects[i]
@@ -1042,7 +1042,7 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
1042 }1042 }
1043 }1043 }
1044 if w.policy().wraps && len(w.Columns) > 0 {1044 if w.policy().wraps && len(w.Columns) > 0 {
1045- span = w.Usable.W1045+ span = w.Usable.W + 2*off
1046 }1046 }
1047 return tiles, span, sel1047 return tiles, span, sel
1048 }1048 }
@@ -1055,8 +1055,7 @@ func (w *Workspace) bandCardStep() int {
1055 1055
1056 // hasBandCards reports whether the focused column of a cascade is1056 // hasBandCards reports whether the focused column of a cascade is
1057 // maximized and hides other columns of its band: the overview shows them1057 // maximized and hides other columns of its band: the overview shows them
1058-// as cards behind it, the maximized column inset by two card steps on1058+// as cards behind it, peeking out up to two card steps on each side.
1059-// each side.
1060 func (w *Workspace) hasBandCards() bool {1059 func (w *Workspace) hasBandCards() bool {
1061 if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {1060 if !w.policy().wraps || w.Focus < 0 || w.Focus >= len(w.Columns) || !w.Columns[w.Focus].FullWidth {
1062 return false1061 return false
@@ -1067,8 +1066,9 @@ func (w *Workspace) hasBandCards() bool {
1067 1066
1068 // appendBandCard appends hidden column i of a cascade band as a dimmed card1067 // appendBandCard appends hidden column i of a cascade band as a dimmed card
1069 // at its cell size behind the maximized column, on its side: the nearest1068 // at its cell size behind the maximized column, on its side: the nearest
1070-// column one step out from the maximized one, the next ones two steps out1069+// column one step out from the maximized one, the next ones two steps out,
1071-// (on the band's edge), like the cards of a stack.1070+// like the cards of a stack. The maximized column keeps its full size two
1071+// steps right of the row's start (see previewTilesInto).
1072 func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {1072 func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1073 g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]1073 g, s, c := w.gap(), w.bandCardStep(), w.Columns[i]
1074 r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}1074 r := Rect{Y: g + w.band(i)*w.Usable.H, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
@@ -1076,7 +1076,7 @@ func (w *Workspace) appendBandCard(tiles []Placement, i int) []Placement {
1076 if i < w.Focus {1076 if i < w.Focus {
1077 r.X = g + (2-k)*s1077 r.X = g + (2-k)*s
1078 } else {1078 } else {
1079- r.X = w.Usable.W - g - (2-k)*s - r.W1079+ r.X = w.Usable.W - g + (2+k)*s - r.W
1080 }1080 }
1081 w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)1081 w.rowBuf = rowRectsInto(w.rowBuf[:0], r, c, g)
1082 for j, t := range w.rowBuf {1082 for j, t := range w.rowBuf {
M internal/core/overview_cascade_test.go +24 -16
@@ -75,10 +75,10 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
7575 }
7676 }
7777
78-// A maximized column fills its band in the preview, inset for the columns
79-// it hides: they are dimmed cards at their cell size behind it, on their
80-// side, the nearest one step out, the farther ones two (the band's edge).
81-// Other bands keep their cells.
78+// A maximized column keeps its full size in the preview (the workspace's
79+// ratio); the columns it hides are dimmed cards at their cell size behind
80+// it, peeking out on their side, the nearest one step, the farther ones two.
81+// The row widens by that room; other bands keep their cells.
8282 func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
8383 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
8484 for _, tc := range []struct {
@@ -97,11 +97,14 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
9797 w := cascadeWorkspace(tc.n)
9898 w.Focus = tc.max
9999 w.maximize(tc.max)
100- tiles, _, sel := w.previewTiles()
100+ tiles, span, sel := w.previewTiles()
101101 g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
102- in := 0
102+ off := 0
103103 if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
104- in = 2 * s
104+ off = 2 * s
105+ }
106+ if span != w.Usable.W+2*off {
107+ t.Fatalf("n=%d max=%d span %d, want %d", tc.n, tc.max, span, w.Usable.W+2*off)
105108 }
106109 var order []int
107110 for _, p := range tiles {
@@ -110,14 +113,18 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
110113 order = append(order, i)
111114 }
112115 band := i / 3
113- want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
116+ want := Rect{X: off + w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
114117 switch k := tc.cards[i]; {
115118 case i == tc.max:
116- want.X, want.W = g+in, w.Usable.W-2*g-2*in
119+ // Full size, as on screen: the workspace's ratio.
120+ want.X, want.W = off+g, w.Usable.W-2*g
117121 case k < 0:
118122 want.X = g + (2+k)*s
119123 case k > 0:
120- want.X = w.Usable.W - g - (2-k)*s - want.W
124+ want.X = w.Usable.W - g + (2+k)*s - want.W
125+ }
126+ if p.Rect.X < 0 || p.Rect.X+p.Rect.W > span {
127+ t.Fatalf("n=%d max=%d column %d %+v outside span %d", tc.n, tc.max, i, p.Rect, span)
121128 }
122129 if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
123130 t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
@@ -197,19 +204,20 @@ func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
197204 }
198205 }
199206
200-// Only the focused maximized column is inset for cards: a FullWidth column
201-// elsewhere fills its band.
202-func TestOverviewCascadeUnfocusedMaximizedNotInset(t *testing.T) {
207+// A FullWidth column outside the focused band fills its band, shifted like
208+// every tile by the room of the focused column's cards.
209+func TestOverviewCascadeUnfocusedMaximizedFillsBand(t *testing.T) {
203210 w := cascadeWorkspace(6)
204211 w.SetGaps(8)
205212 w.Focus = 4
206213 w.maximize(4)
207214 w.Columns[0].FullWidth = true
208215 tiles, _, _ := w.previewTiles()
209- g := w.gap()
216+ // The row keeps room for the focused column's cards on its left.
217+ g, off := w.gap(), 2*w.bandCardStep()
210218 p := previewOf(t, tiles, 1)
211- if p.Hidden || p.Rect.X != g || p.Rect.W != w.Usable.W-2*g {
212- t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, g, w.Usable.W-2*g)
219+ if p.Hidden || p.Rect.X != off+g || p.Rect.W != w.Usable.W-2*g {
220+ t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, off+g, w.Usable.W-2*g)
213221 }
214222 }
215223
@@ -75,10 +75,10 @@ func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
75 }75 }
76 }76 }
77 77
78-// A maximized column fills its band in the preview, inset for the columns78+// A maximized column keeps its full size in the preview (the workspace's
79-// it hides: they are dimmed cards at their cell size behind it, on their79+// ratio); the columns it hides are dimmed cards at their cell size behind
80-// side, the nearest one step out, the farther ones two (the band's edge).80+// it, peeking out on their side, the nearest one step, the farther ones two.
81-// Other bands keep their cells.81+// The row widens by that room; other bands keep their cells.
82 func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {82 func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
83 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.83 // Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
84 for _, tc := range []struct {84 for _, tc := range []struct {
@@ -97,11 +97,14 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
97 w := cascadeWorkspace(tc.n)97 w := cascadeWorkspace(tc.n)
98 w.Focus = tc.max98 w.Focus = tc.max
99 w.maximize(tc.max)99 w.maximize(tc.max)
100- tiles, _, sel := w.previewTiles()100+ tiles, span, sel := w.previewTiles()
101 g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()101 g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
102- in := 0102+ off := 0
103 if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {103 if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
104- in = 2 * s104+ off = 2 * s
105+ }
106+ if span != w.Usable.W+2*off {
107+ t.Fatalf("n=%d max=%d span %d, want %d", tc.n, tc.max, span, w.Usable.W+2*off)
105 }108 }
106 var order []int109 var order []int
107 for _, p := range tiles {110 for _, p := range tiles {
@@ -110,14 +113,18 @@ func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
110 order = append(order, i)113 order = append(order, i)
111 }114 }
112 band := i / 3115 band := i / 3
113- want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}116+ want := Rect{X: off + w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
114 switch k := tc.cards[i]; {117 switch k := tc.cards[i]; {
115 case i == tc.max:118 case i == tc.max:
116- want.X, want.W = g+in, w.Usable.W-2*g-2*in119+ // Full size, as on screen: the workspace's ratio.
120+ want.X, want.W = off+g, w.Usable.W-2*g
117 case k < 0:121 case k < 0:
118 want.X = g + (2+k)*s122 want.X = g + (2+k)*s
119 case k > 0:123 case k > 0:
120- want.X = w.Usable.W - g - (2-k)*s - want.W124+ want.X = w.Usable.W - g + (2+k)*s - want.W
125+ }
126+ if p.Rect.X < 0 || p.Rect.X+p.Rect.W > span {
127+ t.Fatalf("n=%d max=%d column %d %+v outside span %d", tc.n, tc.max, i, p.Rect, span)
121 }128 }
122 if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {129 if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
123 t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)130 t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
@@ -197,19 +204,20 @@ func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
197 }204 }
198 }205 }
199 206
200-// Only the focused maximized column is inset for cards: a FullWidth column207+// A FullWidth column outside the focused band fills its band, shifted like
201-// elsewhere fills its band.208+// every tile by the room of the focused column's cards.
202-func TestOverviewCascadeUnfocusedMaximizedNotInset(t *testing.T) {209+func TestOverviewCascadeUnfocusedMaximizedFillsBand(t *testing.T) {
203 w := cascadeWorkspace(6)210 w := cascadeWorkspace(6)
204 w.SetGaps(8)211 w.SetGaps(8)
205 w.Focus = 4212 w.Focus = 4
206 w.maximize(4)213 w.maximize(4)
207 w.Columns[0].FullWidth = true214 w.Columns[0].FullWidth = true
208 tiles, _, _ := w.previewTiles()215 tiles, _, _ := w.previewTiles()
209- g := w.gap()216+ // The row keeps room for the focused column's cards on its left.
217+ g, off := w.gap(), 2*w.bandCardStep()
210 p := previewOf(t, tiles, 1)218 p := previewOf(t, tiles, 1)
211- if p.Hidden || p.Rect.X != g || p.Rect.W != w.Usable.W-2*g {219+ if p.Hidden || p.Rect.X != off+g || p.Rect.W != w.Usable.W-2*g {
212- t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, g, w.Usable.W-2*g)220+ t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, off+g, w.Usable.W-2*g)
213 }221 }
214 }222 }
215 223

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 25

Tags 10

↑↓ moveenter openesc close