detached b2a4809

feat(core): resize window heights within a column (#81)

Columns keep per-row height shares set by set-window-height; layout and overview previews split rows by them, and joins or leaves reset equal rows.
commit
b2a4809b8f8527825bd1c43dff4863bb47988014
author
bnema · · 2026-10-01 16:31 UTC
committer
GitHub · · 2026-10-01 16:31 UTC
parent
186314e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +327 -15
M docs/config.md +3 -0
@@ -128,6 +128,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
128128 | `move-column-left/right` | Move the focused column |
129129 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it |
130130 | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |
131+| `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |
131132 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
132133 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |
133134 | `workspace <name>` | Toggle a named workspace |
@@ -191,6 +192,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
191192 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |
192193 | `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |
193194 | `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |
195+| `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |
196+| `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |
194197 | `cmd+r` | `cycle-column-width` |
195198 | `cmd+f` | `maximize-column` |
196199 | `cmd+shift+f` | `toggle-fullscreen` |
@@ -128,6 +128,7 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16
128 | `move-column-left/right` | Move the focused column |128 | `move-column-left/right` | Move the focused column |
129 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it |129 | `move-window-up/down` | Move the focused window up or down in its column; focus follows it |
130 | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |130 | `set-column-width <+N%\|-N%>` | Widen or narrow the focused column by N% of the usable width (1-100), between 10% and 100%. The new width stays until changed; `cycle-column-width` then restarts at the first preset. Does nothing in fixed overflow |
131+| `set-window-height <+N%\|-N%>` | Give the focused window N% more or less of its column's height; the other rows give or take it in proportion. Each row keeps at least 10%. A window joining or leaving the column resets equal rows. Does nothing past 9 rows |
131 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |132 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
132 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |133 | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace |
133 | `workspace <name>` | Toggle a named workspace |134 | `workspace <name>` | Toggle a named workspace |
@@ -191,6 +192,8 @@ With `keyboard.cmd = alt`, the `cmd+alt+…` binds have the same keys as `cmd+
191 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |192 | `cmd+shift+down` / `cmd+shift+j` | `move-window-down` |
192 | `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |193 | `cmd+alt+left` / `cmd+alt+h` | `set-column-width -10%` |
193 | `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |194 | `cmd+alt+right` / `cmd+alt+l` | `set-column-width +10%` |
195+| `cmd+alt+up` / `cmd+alt+k` | `set-window-height -10%` |
196+| `cmd+alt+down` / `cmd+alt+j` | `set-window-height +10%` |
194 | `cmd+r` | `cycle-column-width` |197 | `cmd+r` | `cycle-column-width` |
195 | `cmd+f` | `maximize-column` |198 | `cmd+f` | `maximize-column` |
196 | `cmd+shift+f` | `toggle-fullscreen` |199 | `cmd+shift+f` | `toggle-fullscreen` |
M internal/core/action.go +2 -0
@@ -384,6 +384,8 @@ func (w *Workspace) Apply(a Action) Effect {
384384 if axis, pct, ok := ResizeArg(a); ok {
385385 if axis == ResizeWidth {
386386 w.ResizeColumn(pct)
387+ } else {
388+ w.ResizeRow(pct)
387389 }
388390 return Effect{}
389391 }
@@ -384,6 +384,8 @@ func (w *Workspace) Apply(a Action) Effect {
384 if axis, pct, ok := ResizeArg(a); ok {384 if axis, pct, ok := ResizeArg(a); ok {
385 if axis == ResizeWidth {385 if axis == ResizeWidth {
386 w.ResizeColumn(pct)386 w.ResizeColumn(pct)
387+ } else {
388+ w.ResizeRow(pct)
387 }389 }
388 return Effect{}390 return Effect{}
389 }391 }
M internal/core/layout.go +124 -12
@@ -99,6 +99,10 @@ type Column struct {
9999 // Slot is the declared column number (workspace.<name>.column.N) of a
100100 // slot window; 0 for normal columns.
101101 Slot int
102+ // Shares are the row heights set by set-window-height, in percent, in
103+ // Windows order. They apply only while there is one per window and
104+ // they sum to 100; otherwise the rows share the height equally.
105+ Shares []int
102106 }
103107 type Placement struct {
104108 ID WindowID
@@ -463,6 +467,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
463467 }
464468 c := &w.Columns[i]
465469 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
470+ c.Shares = nil
466471 if len(c.Windows) == 0 {
467472 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
468473 if len(w.Columns) == 0 {
@@ -688,6 +693,9 @@ func (w *Workspace) MoveWindow(dir int) {
688693 w.dropSlotOf(c.Windows[0])
689694 }
690695 c.Windows[c.Focus], c.Windows[to] = c.Windows[to], c.Windows[c.Focus]
696+ if validShares(*c) {
697+ c.Shares[c.Focus], c.Shares[to] = c.Shares[to], c.Shares[c.Focus]
698+ }
691699 c.Focus = to
692700 w.scroll()
693701 }
@@ -711,6 +719,80 @@ func (w *Workspace) ResizeColumn(pct int) {
711719 w.scroll()
712720 }
713721
722+// minShare is the smallest row height set-window-height leaves, percent.
723+const minShare = 10
724+
725+// ResizeRow changes the focused window's height share by pct percent; the
726+// other rows of its column give or take it in proportion to their shares.
727+// Every row keeps at least minShare percent.
728+func (w *Workspace) ResizeRow(pct int) {
729+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
730+ return
731+ }
732+ c := &w.Columns[w.Focus]
733+ n := len(c.Windows)
734+ if n < 2 || n*minShare > 100 {
735+ return
736+ }
737+ shares := c.Shares
738+ if !validShares(*c) {
739+ shares = make([]int, n)
740+ for i := range shares {
741+ shares[i] = 100 / n
742+ }
743+ shares[n-1] += 100 % n
744+ } else {
745+ shares = slices.Clone(shares)
746+ }
747+ f := c.Focus
748+ target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))
749+ delta := target - shares[f]
750+ if delta == 0 {
751+ return
752+ }
753+ // The others share -delta in proportion to their room: what they have
754+ // above minShare when shrinking, their share when growing.
755+ weight := func(v int) int {
756+ if delta > 0 {
757+ return v - minShare
758+ }
759+ return v
760+ }
761+ total := 0
762+ for i, v := range shares {
763+ if i != f {
764+ total += weight(v)
765+ }
766+ }
767+ left := delta
768+ for i, v := range shares {
769+ if i == f {
770+ continue
771+ }
772+ part := 0
773+ if total > 0 {
774+ part = delta * weight(v) / total
775+ }
776+ shares[i] = v - part
777+ left -= part
778+ }
779+ shares[f] = target
780+ // Rounding: the remainder goes to the rows that still have room, last
781+ // first.
782+ for i := n - 1; left != 0 && i >= 0; i-- {
783+ if i == f {
784+ continue
785+ }
786+ step := left
787+ if left > 0 {
788+ step = min(left, shares[i]-minShare)
789+ }
790+ shares[i] -= step
791+ left -= step
792+ }
793+ c.Shares = shares
794+}
795+
714796 // takeColumn removes the focused column and returns it, as a normal column.
715797 func (w *Workspace) takeColumn() (Column, bool) {
716798 if len(w.Columns) == 0 || w.onFloat() {
@@ -1213,6 +1295,46 @@ func stackRects(r Rect, n, gap int) []Rect {
12131295 return rows
12141296 }
12151297
1298+// rowRects splits column rect r into the rows of c: by c.Shares when they
1299+// are valid, else equally (stackRects).
1300+func rowRects(r Rect, c Column, gap int) []Rect {
1301+ n := len(c.Windows)
1302+ if !validShares(c) {
1303+ return stackRects(r, n, gap)
1304+ }
1305+ rows := make([]Rect, n)
1306+ avail := max(r.H-(n-1)*gap, 0)
1307+ yy, bottom, used := r.Y, r.Y+r.H, 0
1308+ for i, share := range c.Shares {
1309+ hh := avail * share / 100
1310+ if i == n-1 {
1311+ hh = avail - used
1312+ }
1313+ used += hh
1314+ yy = min(yy, bottom)
1315+ hh = min(hh, bottom-yy)
1316+ rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1317+ yy += hh + gap
1318+ }
1319+ return rows
1320+}
1321+
1322+// validShares reports whether c.Shares has one share per window, each at
1323+// least 1, summing to 100.
1324+func validShares(c Column) bool {
1325+ if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
1326+ return false
1327+ }
1328+ sum := 0
1329+ for _, v := range c.Shares {
1330+ if v < 1 {
1331+ return false
1332+ }
1333+ sum += v
1334+ }
1335+ return sum == 100
1336+}
1337+
12161338 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
12171339 func split(r Rect, gap int, vertical bool) (Rect, Rect) {
12181340 if vertical {
@@ -1239,18 +1361,9 @@ func (w *Workspace) Layout() []Placement {
12391361 if fullColumn {
12401362 col.W = w.Output.W
12411363 }
1242- available := max(col.H-(n-1)*gap, 0)
1243- height := available / n
1244- y := col.Y
1245- bottom := col.Y + col.H
1364+ rows := rowRects(col, c, gap)
12461365 for j, id := range c.Windows {
1247- h := height
1248- if j == n-1 {
1249- h = available - height*(n-1)
1250- }
1251- y = min(y, bottom)
1252- h = min(h, bottom-y)
1253- r := Rect{X: col.X, Y: y, W: col.W, H: h}
1366+ r := rows[j]
12541367 full := w.fullscreen == id && id != 0
12551368 // Fixed overflow cannot scroll to other columns while one fills
12561369 // the view. Keep them in place, but out of the scene.
@@ -1267,7 +1380,6 @@ func (w *Workspace) Layout() []Placement {
12671380 r = Rect{}
12681381 }
12691382 result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1270- y += h + gap
12711383 }
12721384 }
12731385 setVisibleNeighbors(result, gap, w.Output)
@@ -99,6 +99,10 @@ type Column struct {
99 // Slot is the declared column number (workspace.<name>.column.N) of a99 // Slot is the declared column number (workspace.<name>.column.N) of a
100 // slot window; 0 for normal columns.100 // slot window; 0 for normal columns.
101 Slot int101 Slot int
102+ // Shares are the row heights set by set-window-height, in percent, in
103+ // Windows order. They apply only while there is one per window and
104+ // they sum to 100; otherwise the rows share the height equally.
105+ Shares []int
102 }106 }
103 type Placement struct {107 type Placement struct {
104 ID WindowID108 ID WindowID
@@ -463,6 +467,7 @@ func (w *Workspace) RemoveWindow(id WindowID) {
463 }467 }
464 c := &w.Columns[i]468 c := &w.Columns[i]
465 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)469 c.Windows = append(c.Windows[:j], c.Windows[j+1:]...)
470+ c.Shares = nil
466 if len(c.Windows) == 0 {471 if len(c.Windows) == 0 {
467 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)472 w.Columns = append(w.Columns[:i], w.Columns[i+1:]...)
468 if len(w.Columns) == 0 {473 if len(w.Columns) == 0 {
@@ -688,6 +693,9 @@ func (w *Workspace) MoveWindow(dir int) {
688 w.dropSlotOf(c.Windows[0])693 w.dropSlotOf(c.Windows[0])
689 }694 }
690 c.Windows[c.Focus], c.Windows[to] = c.Windows[to], c.Windows[c.Focus]695 c.Windows[c.Focus], c.Windows[to] = c.Windows[to], c.Windows[c.Focus]
696+ if validShares(*c) {
697+ c.Shares[c.Focus], c.Shares[to] = c.Shares[to], c.Shares[c.Focus]
698+ }
691 c.Focus = to699 c.Focus = to
692 w.scroll()700 w.scroll()
693 }701 }
@@ -711,6 +719,80 @@ func (w *Workspace) ResizeColumn(pct int) {
711 w.scroll()719 w.scroll()
712 }720 }
713 721
722+// minShare is the smallest row height set-window-height leaves, percent.
723+const minShare = 10
724+
725+// ResizeRow changes the focused window's height share by pct percent; the
726+// other rows of its column give or take it in proportion to their shares.
727+// Every row keeps at least minShare percent.
728+func (w *Workspace) ResizeRow(pct int) {
729+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
730+ return
731+ }
732+ c := &w.Columns[w.Focus]
733+ n := len(c.Windows)
734+ if n < 2 || n*minShare > 100 {
735+ return
736+ }
737+ shares := c.Shares
738+ if !validShares(*c) {
739+ shares = make([]int, n)
740+ for i := range shares {
741+ shares[i] = 100 / n
742+ }
743+ shares[n-1] += 100 % n
744+ } else {
745+ shares = slices.Clone(shares)
746+ }
747+ f := c.Focus
748+ target := min(max(shares[f]+pct, minShare), 100-minShare*(n-1))
749+ delta := target - shares[f]
750+ if delta == 0 {
751+ return
752+ }
753+ // The others share -delta in proportion to their room: what they have
754+ // above minShare when shrinking, their share when growing.
755+ weight := func(v int) int {
756+ if delta > 0 {
757+ return v - minShare
758+ }
759+ return v
760+ }
761+ total := 0
762+ for i, v := range shares {
763+ if i != f {
764+ total += weight(v)
765+ }
766+ }
767+ left := delta
768+ for i, v := range shares {
769+ if i == f {
770+ continue
771+ }
772+ part := 0
773+ if total > 0 {
774+ part = delta * weight(v) / total
775+ }
776+ shares[i] = v - part
777+ left -= part
778+ }
779+ shares[f] = target
780+ // Rounding: the remainder goes to the rows that still have room, last
781+ // first.
782+ for i := n - 1; left != 0 && i >= 0; i-- {
783+ if i == f {
784+ continue
785+ }
786+ step := left
787+ if left > 0 {
788+ step = min(left, shares[i]-minShare)
789+ }
790+ shares[i] -= step
791+ left -= step
792+ }
793+ c.Shares = shares
794+}
795+
714 // takeColumn removes the focused column and returns it, as a normal column.796 // takeColumn removes the focused column and returns it, as a normal column.
715 func (w *Workspace) takeColumn() (Column, bool) {797 func (w *Workspace) takeColumn() (Column, bool) {
716 if len(w.Columns) == 0 || w.onFloat() {798 if len(w.Columns) == 0 || w.onFloat() {
@@ -1213,6 +1295,46 @@ func stackRects(r Rect, n, gap int) []Rect {
1213 return rows1295 return rows
1214 }1296 }
1215 1297
1298+// rowRects splits column rect r into the rows of c: by c.Shares when they
1299+// are valid, else equally (stackRects).
1300+func rowRects(r Rect, c Column, gap int) []Rect {
1301+ n := len(c.Windows)
1302+ if !validShares(c) {
1303+ return stackRects(r, n, gap)
1304+ }
1305+ rows := make([]Rect, n)
1306+ avail := max(r.H-(n-1)*gap, 0)
1307+ yy, bottom, used := r.Y, r.Y+r.H, 0
1308+ for i, share := range c.Shares {
1309+ hh := avail * share / 100
1310+ if i == n-1 {
1311+ hh = avail - used
1312+ }
1313+ used += hh
1314+ yy = min(yy, bottom)
1315+ hh = min(hh, bottom-yy)
1316+ rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh}
1317+ yy += hh + gap
1318+ }
1319+ return rows
1320+}
1321+
1322+// validShares reports whether c.Shares has one share per window, each at
1323+// least 1, summing to 100.
1324+func validShares(c Column) bool {
1325+ if len(c.Shares) != len(c.Windows) || len(c.Shares) < 2 {
1326+ return false
1327+ }
1328+ sum := 0
1329+ for _, v := range c.Shares {
1330+ if v < 1 {
1331+ return false
1332+ }
1333+ sum += v
1334+ }
1335+ return sum == 100
1336+}
1337+
1216 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.1338 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
1217 func split(r Rect, gap int, vertical bool) (Rect, Rect) {1339 func split(r Rect, gap int, vertical bool) (Rect, Rect) {
1218 if vertical {1340 if vertical {
@@ -1239,18 +1361,9 @@ func (w *Workspace) Layout() []Placement {
1239 if fullColumn {1361 if fullColumn {
1240 col.W = w.Output.W1362 col.W = w.Output.W
1241 }1363 }
1242- available := max(col.H-(n-1)*gap, 0)1364+ rows := rowRects(col, c, gap)
1243- height := available / n
1244- y := col.Y
1245- bottom := col.Y + col.H
1246 for j, id := range c.Windows {1365 for j, id := range c.Windows {
1247- h := height1366+ r := rows[j]
1248- if j == n-1 {
1249- h = available - height*(n-1)
1250- }
1251- y = min(y, bottom)
1252- h = min(h, bottom-y)
1253- r := Rect{X: col.X, Y: y, W: col.W, H: h}
1254 full := w.fullscreen == id && id != 01367 full := w.fullscreen == id && id != 0
1255 // Fixed overflow cannot scroll to other columns while one fills1368 // Fixed overflow cannot scroll to other columns while one fills
1256 // the view. Keep them in place, but out of the scene.1369 // the view. Keep them in place, but out of the scene.
@@ -1267,7 +1380,6 @@ func (w *Workspace) Layout() []Placement {
1267 r = Rect{}1380 r = Rect{}
1268 }1381 }
1269 result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})1382 result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: id == focusedID, Hidden: hidden})
1270- y += h + gap
1271 }1383 }
1272 }1384 }
1273 setVisibleNeighbors(result, gap, w.Output)1385 setVisibleNeighbors(result, gap, w.Output)
M internal/core/overview.go +1 -1
@@ -741,7 +741,7 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
741741 }
742742 continue
743743 }
744- for j, t := range stackRects(r, len(c.Windows), g) {
744+ for j, t := range rowRects(r, c, g) {
745745 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
746746 }
747747 if w.Overflow != OverflowFixed {
@@ -741,7 +741,7 @@ func (w *Workspace) previewTiles() (tiles []Placement, span int, sel Rect) {
741 }741 }
742 continue742 continue
743 }743 }
744- for j, t := range stackRects(r, len(c.Windows), g) {744+ for j, t := range rowRects(r, c, g) {
745 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})745 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
746 }746 }
747 if w.Overflow != OverflowFixed {747 if w.Overflow != OverflowFixed {
M internal/core/overview_stack.go +2 -2
@@ -233,7 +233,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
233233 r := rects[i]
234234 r.X -= w.Usable.X
235235 r.Y -= w.Usable.Y
236- for j, t := range stackRects(r, len(c.Windows), g) {
236+ for j, t := range rowRects(r, c, g) {
237237 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
238238 }
239239 }
@@ -258,7 +258,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
258258 r.X = (w.Usable.W - r.W) / 2
259259 r.Y = (w.Usable.H - r.H) / 2
260260 tiles := make([]Placement, 0, len(c.Windows))
261- for j, t := range stackRects(r, len(c.Windows), g) {
261+ for j, t := range rowRects(r, c, g) {
262262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})
263263 }
264264 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
@@ -233,7 +233,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
233 r := rects[i]233 r := rects[i]
234 r.X -= w.Usable.X234 r.X -= w.Usable.X
235 r.Y -= w.Usable.Y235 r.Y -= w.Usable.Y
236- for j, t := range stackRects(r, len(c.Windows), g) {236+ for j, t := range rowRects(r, c, g) {
237 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})237 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == selected && j == c.Focus})
238 }238 }
239 }239 }
@@ -258,7 +258,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, y int, dim, lit bool
258 r.X = (w.Usable.W - r.W) / 2258 r.X = (w.Usable.W - r.W) / 2
259 r.Y = (w.Usable.H - r.H) / 2259 r.Y = (w.Usable.H - r.H) / 2
260 tiles := make([]Placement, 0, len(c.Windows))260 tiles := make([]Placement, 0, len(c.Windows))
261- for j, t := range stackRects(r, len(c.Windows), g) {261+ for j, t := range rowRects(r, c, g) {
262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})262 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus})
263 }263 }
264 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)264 return w.previewRowTiles(y, dim, lit, tiles, w.Usable.W, r)
A internal/core/row_shares_test.go +195 -0
@@ -0,0 +1,195 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func heights(w *Workspace) map[WindowID]int {
9+ out := map[WindowID]int{}
10+ for _, p := range w.Layout() {
11+ if !p.Floating {
12+ out[p.ID] = p.Rect.H
13+ }
14+ }
15+ return out
16+}
17+
18+func sum(v []int) int {
19+ t := 0
20+ for _, x := range v {
21+ t += x
22+ }
23+ return t
24+}
25+
26+func TestRowRectsEqualWithoutShares(t *testing.T) {
27+ r := Rect{X: 0, Y: 0, W: 50, H: 103}
28+ c := Column{Windows: []WindowID{1, 2, 3}}
29+ if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
30+ t.Fatal(rowRects(r, c, 5))
31+ }
32+ c.Shares = []int{50, 50} // wrong length: ignored
33+ if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
34+ t.Fatal(rowRects(r, c, 5))
35+ }
36+ c.Shares = []int{50, 30, 30} // wrong sum: ignored
37+ if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
38+ t.Fatal(rowRects(r, c, 5))
39+ }
40+ c.Shares = []int{50, 30, 20}
41+ rows := rowRects(r, c, 5)
42+ if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {
43+ t.Fatal(rows)
44+ }
45+}
46+
47+func TestResizeRow(t *testing.T) {
48+ for _, o := range []Overflow{OverflowScroll, OverflowFixed} {
49+ w := workspace()
50+ w.Overflow = o
51+ w.SetOutput(1000, 800)
52+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
53+ w.Apply("set-window-height +10%")
54+ if !slices.Equal(w.Columns[0].Shares, []int{60, 40}) {
55+ t.Fatal(o, w.Columns[0].Shares)
56+ }
57+ h := heights(w)
58+ if h[1] <= h[2] {
59+ t.Fatal(o, h)
60+ }
61+ for range 10 {
62+ w.Apply("set-window-height +10%")
63+ }
64+ if !slices.Equal(w.Columns[0].Shares, []int{90, 10}) {
65+ t.Fatal(o, w.Columns[0].Shares)
66+ }
67+ for range 10 {
68+ w.Apply("set-window-height -10%")
69+ }
70+ if !slices.Equal(w.Columns[0].Shares, []int{10, 90}) {
71+ t.Fatal(o, w.Columns[0].Shares)
72+ }
73+ }
74+}
75+
76+func TestResizeRowThree(t *testing.T) {
77+ w := workspace()
78+ w.SetOutput(1000, 800)
79+ w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
80+ w.Apply("set-window-height +10%")
81+ s := w.Columns[0].Shares
82+ if sum(s) != 100 || s[1] != 43 || s[0] < 10 || s[2] < 10 {
83+ t.Fatal(s)
84+ }
85+ for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {
86+ w.Apply(Action("set-window-height " + step))
87+ s := w.Columns[0].Shares
88+ if sum(s) != 100 || slices.Min(s) < 10 {
89+ t.Fatal(step, s)
90+ }
91+ }
92+ if s := w.Columns[0].Shares; s[1] != 77 {
93+ t.Fatal(s)
94+ }
95+ // Proportional: the others shrink by their room above the minimum.
96+ w.Columns[0].Shares = []int{50, 20, 30}
97+ w.Columns[0].Focus = 1
98+ w.Apply("set-window-height +20%")
99+ if s := w.Columns[0].Shares; !slices.Equal(s, []int{37, 40, 23}) {
100+ t.Fatal(s)
101+ }
102+}
103+
104+func TestResizeRowNoops(t *testing.T) {
105+ w := workspace()
106+ w.Columns = []Column{{Windows: []WindowID{1}}}
107+ w.Apply("set-window-height +10%")
108+ if w.Columns[0].Shares != nil {
109+ t.Fatal(w.Columns[0].Shares)
110+ }
111+ w.Columns = []Column{{Windows: []WindowID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}}
112+ w.Apply("set-window-height +10%")
113+ if w.Columns[0].Shares != nil {
114+ t.Fatal(w.Columns[0].Shares)
115+ }
116+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
117+ w.AddFloating(3, 10, 10)
118+ w.Apply("set-window-height +10%")
119+ if w.Columns[0].Shares != nil {
120+ t.Fatal(w.Columns[0].Shares)
121+ }
122+ // A fullscreen window hides its column.
123+ w = workspace()
124+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
125+ w.SetFullscreen(1, true)
126+ w.Apply("set-window-height +10%")
127+ if w.Columns[0].Shares != nil {
128+ t.Fatal(w.Columns[0].Shares)
129+ }
130+}
131+
132+func TestSharesResetAndTravel(t *testing.T) {
133+ m := monitor()
134+ m.SetOutput(1000, 800)
135+ m.AddWindow(1)
136+ m.AddWindow(2)
137+ w := m.Current()
138+ w.ConsumeOrExpel(-1)
139+ if len(w.Columns) != 1 {
140+ t.Fatal(w.Columns)
141+ }
142+ w.Apply("set-window-height +20%")
143+ if !slices.Equal(w.Columns[0].Shares, []int{30, 70}) {
144+ t.Fatal(w.Columns[0].Shares)
145+ }
146+ // Moving the window inside the column swaps the shares with it.
147+ w.Apply(ActionMoveWindowUp)
148+ if !slices.Equal(w.Columns[0].Shares, []int{70, 30}) || w.Columns[0].Windows[0] != 2 {
149+ t.Fatal(w.Columns[0])
150+ }
151+ // The column takes its shares to another workspace.
152+ m.MoveToWorkspace(1, true)
153+ m.FocusNumber(2)
154+ if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{70, 30}) {
155+ t.Fatal(c)
156+ }
157+ // MoveColumn keeps them.
158+ m.AddWindow(3)
159+ m.Current().FocusColumn(-1)
160+ m.Current().MoveColumn(1)
161+ if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{70, 30}) {
162+ t.Fatal(m.Current().Columns)
163+ }
164+ // A window leaving resets the column to equal rows.
165+ m.RemoveWindow(2)
166+ m.AddWindow(4)
167+ w = m.Current()
168+ for _, c := range w.Columns {
169+ if c.Shares != nil {
170+ t.Fatal(w.Columns)
171+ }
172+ }
173+ // A window joining invalidates them: equal rows.
174+ w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{80, 20}}}
175+ w.Columns[0].Windows = append(w.Columns[0].Windows, 4)
176+ h := heights(w)
177+ if h[1] != h[3] {
178+ t.Fatal(h)
179+ }
180+}
181+
182+func TestOverviewPreviewUsesShares(t *testing.T) {
183+ w := workspace()
184+ w.SetOutput(1000, 800)
185+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}
186+ tiles, _, _ := w.previewTiles()
187+ h := map[WindowID]int{}
188+ for _, p := range tiles {
189+ h[p.ID] = p.Rect.H
190+ }
191+ live := heights(w)
192+ if h[1] != live[1] || h[2] != live[2] {
193+ t.Fatal(h, live)
194+ }
195+}
@@ -0,0 +1,195 @@
1+package core
2+
3+import (
4+ "slices"
5+ "testing"
6+)
7+
8+func heights(w *Workspace) map[WindowID]int {
9+ out := map[WindowID]int{}
10+ for _, p := range w.Layout() {
11+ if !p.Floating {
12+ out[p.ID] = p.Rect.H
13+ }
14+ }
15+ return out
16+}
17+
18+func sum(v []int) int {
19+ t := 0
20+ for _, x := range v {
21+ t += x
22+ }
23+ return t
24+}
25+
26+func TestRowRectsEqualWithoutShares(t *testing.T) {
27+ r := Rect{X: 0, Y: 0, W: 50, H: 103}
28+ c := Column{Windows: []WindowID{1, 2, 3}}
29+ if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
30+ t.Fatal(rowRects(r, c, 5))
31+ }
32+ c.Shares = []int{50, 50} // wrong length: ignored
33+ if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
34+ t.Fatal(rowRects(r, c, 5))
35+ }
36+ c.Shares = []int{50, 30, 30} // wrong sum: ignored
37+ if !slices.Equal(rowRects(r, c, 5), stackRects(r, 3, 5)) {
38+ t.Fatal(rowRects(r, c, 5))
39+ }
40+ c.Shares = []int{50, 30, 20}
41+ rows := rowRects(r, c, 5)
42+ if rows[0].H != 46 || rows[1].H != 27 || rows[2].H != 93-46-27 || rows[2].Y+rows[2].H != 103 {
43+ t.Fatal(rows)
44+ }
45+}
46+
47+func TestResizeRow(t *testing.T) {
48+ for _, o := range []Overflow{OverflowScroll, OverflowFixed} {
49+ w := workspace()
50+ w.Overflow = o
51+ w.SetOutput(1000, 800)
52+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
53+ w.Apply("set-window-height +10%")
54+ if !slices.Equal(w.Columns[0].Shares, []int{60, 40}) {
55+ t.Fatal(o, w.Columns[0].Shares)
56+ }
57+ h := heights(w)
58+ if h[1] <= h[2] {
59+ t.Fatal(o, h)
60+ }
61+ for range 10 {
62+ w.Apply("set-window-height +10%")
63+ }
64+ if !slices.Equal(w.Columns[0].Shares, []int{90, 10}) {
65+ t.Fatal(o, w.Columns[0].Shares)
66+ }
67+ for range 10 {
68+ w.Apply("set-window-height -10%")
69+ }
70+ if !slices.Equal(w.Columns[0].Shares, []int{10, 90}) {
71+ t.Fatal(o, w.Columns[0].Shares)
72+ }
73+ }
74+}
75+
76+func TestResizeRowThree(t *testing.T) {
77+ w := workspace()
78+ w.SetOutput(1000, 800)
79+ w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
80+ w.Apply("set-window-height +10%")
81+ s := w.Columns[0].Shares
82+ if sum(s) != 100 || s[1] != 43 || s[0] < 10 || s[2] < 10 {
83+ t.Fatal(s)
84+ }
85+ for _, step := range []string{"+25%", "+25%", "-7%", "-100%", "+100%", "-3%"} {
86+ w.Apply(Action("set-window-height " + step))
87+ s := w.Columns[0].Shares
88+ if sum(s) != 100 || slices.Min(s) < 10 {
89+ t.Fatal(step, s)
90+ }
91+ }
92+ if s := w.Columns[0].Shares; s[1] != 77 {
93+ t.Fatal(s)
94+ }
95+ // Proportional: the others shrink by their room above the minimum.
96+ w.Columns[0].Shares = []int{50, 20, 30}
97+ w.Columns[0].Focus = 1
98+ w.Apply("set-window-height +20%")
99+ if s := w.Columns[0].Shares; !slices.Equal(s, []int{37, 40, 23}) {
100+ t.Fatal(s)
101+ }
102+}
103+
104+func TestResizeRowNoops(t *testing.T) {
105+ w := workspace()
106+ w.Columns = []Column{{Windows: []WindowID{1}}}
107+ w.Apply("set-window-height +10%")
108+ if w.Columns[0].Shares != nil {
109+ t.Fatal(w.Columns[0].Shares)
110+ }
111+ w.Columns = []Column{{Windows: []WindowID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}}
112+ w.Apply("set-window-height +10%")
113+ if w.Columns[0].Shares != nil {
114+ t.Fatal(w.Columns[0].Shares)
115+ }
116+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
117+ w.AddFloating(3, 10, 10)
118+ w.Apply("set-window-height +10%")
119+ if w.Columns[0].Shares != nil {
120+ t.Fatal(w.Columns[0].Shares)
121+ }
122+ // A fullscreen window hides its column.
123+ w = workspace()
124+ w.Columns = []Column{{Windows: []WindowID{1, 2}}}
125+ w.SetFullscreen(1, true)
126+ w.Apply("set-window-height +10%")
127+ if w.Columns[0].Shares != nil {
128+ t.Fatal(w.Columns[0].Shares)
129+ }
130+}
131+
132+func TestSharesResetAndTravel(t *testing.T) {
133+ m := monitor()
134+ m.SetOutput(1000, 800)
135+ m.AddWindow(1)
136+ m.AddWindow(2)
137+ w := m.Current()
138+ w.ConsumeOrExpel(-1)
139+ if len(w.Columns) != 1 {
140+ t.Fatal(w.Columns)
141+ }
142+ w.Apply("set-window-height +20%")
143+ if !slices.Equal(w.Columns[0].Shares, []int{30, 70}) {
144+ t.Fatal(w.Columns[0].Shares)
145+ }
146+ // Moving the window inside the column swaps the shares with it.
147+ w.Apply(ActionMoveWindowUp)
148+ if !slices.Equal(w.Columns[0].Shares, []int{70, 30}) || w.Columns[0].Windows[0] != 2 {
149+ t.Fatal(w.Columns[0])
150+ }
151+ // The column takes its shares to another workspace.
152+ m.MoveToWorkspace(1, true)
153+ m.FocusNumber(2)
154+ if c := m.Current().Columns[0]; !slices.Equal(c.Shares, []int{70, 30}) {
155+ t.Fatal(c)
156+ }
157+ // MoveColumn keeps them.
158+ m.AddWindow(3)
159+ m.Current().FocusColumn(-1)
160+ m.Current().MoveColumn(1)
161+ if c := m.Current().Columns[1]; !slices.Equal(c.Shares, []int{70, 30}) {
162+ t.Fatal(m.Current().Columns)
163+ }
164+ // A window leaving resets the column to equal rows.
165+ m.RemoveWindow(2)
166+ m.AddWindow(4)
167+ w = m.Current()
168+ for _, c := range w.Columns {
169+ if c.Shares != nil {
170+ t.Fatal(w.Columns)
171+ }
172+ }
173+ // A window joining invalidates them: equal rows.
174+ w.Columns = []Column{{Windows: []WindowID{1, 3}, Shares: []int{80, 20}}}
175+ w.Columns[0].Windows = append(w.Columns[0].Windows, 4)
176+ h := heights(w)
177+ if h[1] != h[3] {
178+ t.Fatal(h)
179+ }
180+}
181+
182+func TestOverviewPreviewUsesShares(t *testing.T) {
183+ w := workspace()
184+ w.SetOutput(1000, 800)
185+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Shares: []int{70, 30}}}
186+ tiles, _, _ := w.previewTiles()
187+ h := map[WindowID]int{}
188+ for _, p := range tiles {
189+ h[p.ID] = p.Rect.H
190+ }
191+ live := heights(w)
192+ if h[1] != live[1] || h[2] != live[2] {
193+ t.Fatal(h, live)
194+ }
195+}

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