feat(core): keep cascade bands explicit
Columns carry a band start, so bands keep their columns. move-window-up/down past a column sends it to the adjacent band, joining it or making a new one; cycle-column-width in a full band pushes a neighbor out and brings it back at the end of the cycle; maximize-column splits the band and restores it. New windows join the focused band, and a smaller max-columns splits bands. move-column-left/right no longer crosses bands, and focus at a band edge prefers the neighbor monitor over the neighbor workspace.
- commit
- a978937fa6fbdaf70b083844135ab01b9d3ee306
- author
- brice · · 2026-10-16 16:44 UTC
- parent
- ef6bf5d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +729 -168
M docs/config.md +8 -3
| @@ -240,11 +240,16 @@ With `keyboard.cmd = alt` or `ctrl`, the defaults that would combine `cmd` with | |||
| 240 | 240 | ||
| 241 | 241 | ### Cascade | |
| 242 | 242 | ||
| 243 | - | The default layout. Columns fill the screen side by side up to `max-columns`, then continue in a new band below. Each band fills the screen height. | |
| 243 | + | The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them. | |
| 244 | 244 | ||
| 245 | - | - Left/right focus moves within a band, then to the neighbor workspace. | |
| 245 | + | - Left/right focus moves within a band, then to the neighbor monitor, then to the neighbor workspace. | |
| 246 | + | - `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any. | |
| 246 | 247 | - Up/down moves through the windows of a column, then to the band above or below. | |
| 247 | - | - A maximized column gets a band of its own. | |
| 248 | + | - `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two. | |
| 249 | + | - A new window joins the focused band when it has room, else it gets a new band right below. | |
| 250 | + | - `cycle-column-width` in a full band makes room: the right neighbor goes to the band below (the left one to the band above from the last column). The end of the cycle brings it back. | |
| 251 | + | - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back. | |
| 252 | + | - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it. | |
| 248 | 253 | ||
| 249 | 254 | ### Column widths | |
| 250 | 255 | ||
| @@ -240,11 +240,16 @@ With `keyboard.cmd = alt` or `ctrl`, the defaults that would combine `cmd` with | |||||
| 240 | 240 | ||||
| 241 | ### Cascade | 241 | ### Cascade | ||
| 242 | 242 | ||||
| 243 | - | The default layout. Columns fill the screen side by side up to `max-columns`, then continue in a new band below. Each band fills the screen height. | 243 | + | The default layout. Columns sit side by side in bands of up to `max-columns`, stacked one below the other. Each band fills the screen height and keeps its columns until you move them. |
| 244 | 244 | ||||
| 245 | - | - Left/right focus moves within a band, then to the neighbor workspace. | 245 | + | - Left/right focus moves within a band, then to the neighbor monitor, then to the neighbor workspace. |
| 246 | + | - `move-column-left/right` stays in the band; at its edge the column goes to the neighbor monitor, if any. | |||
| 246 | - Up/down moves through the windows of a column, then to the band above or below. | 247 | - Up/down moves through the windows of a column, then to the band above or below. | ||
| 247 | - | - A maximized column gets a band of its own. | 248 | + | - `move-window-up/down` past the column's top or bottom sends the whole column to the band above or below. It joins that band when it has room, else it gets a new band between the two. |
| 249 | + | - A new window joins the focused band when it has room, else it gets a new band right below. | |||
| 250 | + | - `cycle-column-width` in a full band makes room: the right neighbor goes to the band below (the left one to the band above from the last column). The end of the cycle brings it back. | |||
| 251 | + | - `maximize-column` gives the column a band of its own: the columns on its left go to the band above, the ones on its right to the band below. Maximizing again forms the band back. | |||
| 252 | + | - A band left empty goes away. A smaller `max-columns` moves the extra columns of a band to a new band below it. | |||
| 248 | 253 | ||||
| 249 | ### Column widths | 254 | ### Column widths | ||
| 250 | 255 | ||||
M internal/core/action.go +9 -8
| @@ -285,7 +285,8 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 285 | 285 | case ActionMoveColumnLeft, ActionMoveColumnRight: | |
| 286 | 286 | // Past the edge of the workspace or cascade band the column moves to | |
| 287 | 287 | // the neighbor screen, on the side facing this one, and focus | |
| 288 | - | // follows it. With no screen there, a cascade column crosses bands. | |
| 288 | + | // follows it. With no screen there, a cascade column stays in its | |
| 289 | + | // band. | |
| 289 | 290 | w := c.cur().mon.Current() | |
| 290 | 291 | dir, d := 1, dirRight | |
| 291 | 292 | if a == ActionMoveColumnLeft { | |
| @@ -313,19 +314,19 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 313 | 314 | } | |
| 314 | 315 | ||
| 315 | 316 | // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the | |
| 316 | - | // content edge, in order: to the occupied workspace on that side when the | |
| 317 | - | // workspace axis is horizontal; else to the neighbor monitor; else to the | |
| 318 | - | // trailing empty workspace. It reports whether focus moved. | |
| 317 | + | // content edge, in order: to the neighbor monitor on that side; else to the | |
| 318 | + | // occupied workspace on that side when the workspace axis is horizontal; | |
| 319 | + | // else to the trailing empty workspace. It reports whether focus moved. | |
| 319 | 320 | func (c *Core) edgeFocus(dir int, d direction) bool { | |
| 320 | 321 | m := c.cur().mon | |
| 321 | - | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | |
| 322 | - | m.Focus(to) | |
| 323 | - | return true | |
| 324 | - | } | |
| 325 | 322 | if i := c.neighbor(d); i >= 0 { | |
| 326 | 323 | c.focusScreen = i | |
| 327 | 324 | return true | |
| 328 | 325 | } | |
| 326 | + | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | |
| 327 | + | m.Focus(to) | |
| 328 | + | return true | |
| 329 | + | } | |
| 329 | 330 | return m.stepWorkspace(horizontalAxis, dir) | |
| 330 | 331 | } | |
| 331 | 332 | ||
| @@ -285,7 +285,8 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 285 | case ActionMoveColumnLeft, ActionMoveColumnRight: | 285 | case ActionMoveColumnLeft, ActionMoveColumnRight: | ||
| 286 | // Past the edge of the workspace or cascade band the column moves to | 286 | // Past the edge of the workspace or cascade band the column moves to | ||
| 287 | // the neighbor screen, on the side facing this one, and focus | 287 | // the neighbor screen, on the side facing this one, and focus | ||
| 288 | - | // follows it. With no screen there, a cascade column crosses bands. | 288 | + | // follows it. With no screen there, a cascade column stays in its |
| 289 | + | // band. | |||
| 289 | w := c.cur().mon.Current() | 290 | w := c.cur().mon.Current() | ||
| 290 | dir, d := 1, dirRight | 291 | dir, d := 1, dirRight | ||
| 291 | if a == ActionMoveColumnLeft { | 292 | if a == ActionMoveColumnLeft { | ||
| @@ -313,19 +314,19 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 313 | } | 314 | } | ||
| 314 | 315 | ||||
| 315 | // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the | 316 | // edgeFocus moves focus-column-left/right (dir -1/1, toward d) past the | ||
| 316 | - | // content edge, in order: to the occupied workspace on that side when the | 317 | + | // content edge, in order: to the neighbor monitor on that side; else to the |
| 317 | - | // workspace axis is horizontal; else to the neighbor monitor; else to the | 318 | + | // occupied workspace on that side when the workspace axis is horizontal; |
| 318 | - | // trailing empty workspace. It reports whether focus moved. | 319 | + | // else to the trailing empty workspace. It reports whether focus moved. |
| 319 | func (c *Core) edgeFocus(dir int, d direction) bool { | 320 | func (c *Core) edgeFocus(dir int, d direction) bool { | ||
| 320 | m := c.cur().mon | 321 | m := c.cur().mon | ||
| 321 | - | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | |||
| 322 | - | m.Focus(to) | |||
| 323 | - | return true | |||
| 324 | - | } | |||
| 325 | if i := c.neighbor(d); i >= 0 { | 322 | if i := c.neighbor(d); i >= 0 { | ||
| 326 | c.focusScreen = i | 323 | c.focusScreen = i | ||
| 327 | return true | 324 | return true | ||
| 328 | } | 325 | } | ||
| 326 | + | if to := m.occupiedStep(horizontalAxis, dir); to >= 0 { | |||
| 327 | + | m.Focus(to) | |||
| 328 | + | return true | |||
| 329 | + | } | |||
| 329 | return m.stepWorkspace(horizontalAxis, dir) | 330 | return m.stepWorkspace(horizontalAxis, dir) | ||
| 330 | } | 331 | } | ||
| 331 | 332 | ||||
M internal/core/cascade.go +251 -36
| @@ -2,39 +2,55 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import "slices" | |
| 4 | 4 | ||
| 5 | - | // Cascade bands pack columns in order: a band ends when the next column | |
| 6 | - | // would take it past MaxColumns slots. A column takes one slot and a | |
| 7 | - | // maximized one all of them, so it sits alone in its band. Cells only | |
| 8 | - | // share a band's width (laneWidth); they never move a column to a band. | |
| 5 | + | // Cascade bands are explicit: Column.BandStart marks the first column of | |
| 6 | + | // each band, and moves keep the other bands as they are. A band also ends | |
| 7 | + | // where it would pass MaxColumns columns (max-columns shrank, or a column | |
| 8 | + | // was inserted into a full band), and a maximized column sits alone in its | |
| 9 | + | // band. scroll freezes those ends into BandStart, so the bands stay when | |
| 10 | + | // max-columns grows again or the column is unmaximized. Cells only share | |
| 11 | + | // a band's width (laneWidth); they never move a column to a band. | |
| 9 | 12 | ||
| 10 | - | // bandSlots is the number of slots column i takes in its band. | |
| 11 | - | func (w *Workspace) bandSlots(i int) int { | |
| 12 | - | if w.Columns[i].FullWidth { | |
| 13 | - | return max(w.MaxColumns, 1) | |
| 13 | + | // startsBand reports whether column i starts a band, n being the count of | |
| 14 | + | // columns in the band before it. | |
| 15 | + | func (w *Workspace) startsBand(i, n int) bool { | |
| 16 | + | if i == 0 { | |
| 17 | + | return true | |
| 14 | 18 | } | |
| 15 | - | return 1 | |
| 19 | + | c := w.Columns | |
| 20 | + | return c[i].BandStart || n >= max(w.MaxColumns, 1) || c[i].FullWidth || c[i-1].FullWidth | |
| 16 | 21 | } | |
| 17 | 22 | ||
| 18 | 23 | // walkBands calls f with each band and its column range, in order, until f | |
| 19 | 24 | // returns false. | |
| 20 | 25 | func (w *Workspace) walkBands(f func(b, first, last int) bool) { | |
| 21 | - | k := max(w.MaxColumns, 1) | |
| 22 | - | b, first, sum := 0, 0, 0 | |
| 26 | + | b, first, n := 0, 0, 0 | |
| 23 | 27 | for i := range w.Columns { | |
| 24 | - | c := w.bandSlots(i) | |
| 25 | - | if i > first && sum+c > k { | |
| 28 | + | if i > first && w.startsBand(i, n) { | |
| 26 | 29 | if !f(b, first, i-1) { | |
| 27 | 30 | return | |
| 28 | 31 | } | |
| 29 | - | b, first, sum = b+1, i, 0 | |
| 32 | + | b, first, n = b+1, i, 0 | |
| 30 | 33 | } | |
| 31 | - | sum += c | |
| 34 | + | n++ | |
| 32 | 35 | } | |
| 33 | 36 | if len(w.Columns) > 0 { | |
| 34 | 37 | f(b, first, len(w.Columns)-1) | |
| 35 | 38 | } | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 41 | + | // markBands freezes the band starts into BandStart. | |
| 42 | + | func (w *Workspace) markBands() { | |
| 43 | + | n := 0 | |
| 44 | + | for i := range w.Columns { | |
| 45 | + | s := w.startsBand(i, n) | |
| 46 | + | w.Columns[i].BandStart = s | |
| 47 | + | if s { | |
| 48 | + | n = 0 | |
| 49 | + | } | |
| 50 | + | n++ | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 38 | 54 | // band is the cascade band of column i. | |
| 39 | 55 | func (w *Workspace) band(i int) (band int) { | |
| 40 | 56 | w.walkBands(func(b, _, last int) bool { | |
| @@ -226,32 +242,17 @@ func (w *Workspace) leavesBand(i int) bool { | |||
| 226 | 242 | return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus) | |
| 227 | 243 | } | |
| 228 | 244 | ||
| 229 | - | // moveBand swaps the focused cascade column with the column in the band dir | |
| 230 | - | // away where focus-window-up/down goes; focus follows it. Past the first | |
| 231 | - | // or last band nothing moves. Columns keep their maximization: when the two | |
| 232 | - | // take a different number of slots, a swap would repack the bands around | |
| 233 | - | // them, so the focused column passes the whole adjacent band instead. | |
| 245 | + | // moveBand moves the focused cascade column to the band dir away | |
| 246 | + | // (move-window-up/down past its column), keeping its offset in the band; | |
| 247 | + | // focus follows it. See shiftBand. | |
| 234 | 248 | func (w *Workspace) moveBand(dir int) { | |
| 235 | - | i := w.laneNeighbor(dir) | |
| 236 | - | if i < 0 { | |
| 249 | + | if !w.policy().wraps || len(w.Columns) == 0 { | |
| 237 | 250 | return | |
| 238 | 251 | } | |
| 239 | - | if w.bandSlots(w.Focus) == w.bandSlots(i) { | |
| 240 | - | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | |
| 241 | - | w.Focus = i | |
| 252 | + | first, _ := w.bandBounds(w.band(w.Focus)) | |
| 253 | + | if w.shiftBand(w.Focus, w.Focus, dir, w.Focus-first) { | |
| 242 | 254 | w.scroll() | |
| 243 | - | return | |
| 244 | 255 | } | |
| 245 | - | first, last := w.bandBounds(w.band(i)) | |
| 246 | - | col := w.Columns[w.Focus] | |
| 247 | - | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 248 | - | at := first // dir < 0: before the band above. | |
| 249 | - | if dir > 0 { | |
| 250 | - | at = last // After the band below, one index lower once removed. | |
| 251 | - | } | |
| 252 | - | w.Columns = slices.Insert(w.Columns, at, col) | |
| 253 | - | w.Focus = at | |
| 254 | - | w.scroll() | |
| 255 | 256 | } | |
| 256 | 257 | ||
| 257 | 258 | // laneNeighbor is the closest column to the focus in the lane d lanes away, | |
| @@ -265,3 +266,217 @@ func (w *Workspace) laneNeighbor(d int) int { | |||
| 265 | 266 | } | |
| 266 | 267 | return min(nfirst+w.Focus-first, nlast) | |
| 267 | 268 | } | |
| 269 | + | ||
| 270 | + | // bandList copies the cascade columns into their bands. | |
| 271 | + | func (w *Workspace) bandList() [][]Column { | |
| 272 | + | var bands [][]Column | |
| 273 | + | w.walkBands(func(_, first, last int) bool { | |
| 274 | + | bands = append(bands, slices.Clone(w.Columns[first:last+1])) | |
| 275 | + | return true | |
| 276 | + | }) | |
| 277 | + | return bands | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // setBands stores bands as the columns, empty bands dropped, and focuses | |
| 281 | + | // the column holding window focus. | |
| 282 | + | func (w *Workspace) setBands(bands [][]Column, focus WindowID) { | |
| 283 | + | cols := w.Columns[:0] | |
| 284 | + | for _, b := range bands { | |
| 285 | + | for i, c := range b { | |
| 286 | + | c.BandStart = i == 0 | |
| 287 | + | cols = append(cols, c) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | w.Columns = cols | |
| 291 | + | if i := w.columnOf(focus); i >= 0 { | |
| 292 | + | w.Focus = i | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // fits reports whether cols can join band b: it has room for them and | |
| 297 | + | // neither side holds a maximized column, which keeps a band to itself. | |
| 298 | + | func (w *Workspace) fits(bands [][]Column, b int, cols []Column) bool { | |
| 299 | + | if b < 0 || b >= len(bands) || len(bands[b])+len(cols) > max(w.MaxColumns, 1) { | |
| 300 | + | return false | |
| 301 | + | } | |
| 302 | + | full := func(c Column) bool { return c.FullWidth } | |
| 303 | + | return !slices.ContainsFunc(bands[b], full) && !slices.ContainsFunc(cols, full) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // sendBand moves cols, already taken out of band b, to the band dir away: | |
| 307 | + | // they join it at index at (clamped) when they fit, else they make a new | |
| 308 | + | // band between the two, or past the outer band. When cols were alone in | |
| 309 | + | // band b, it goes away and a band that cannot take them is passed: the | |
| 310 | + | // new band goes on its far side, so repeated moves always progress. | |
| 311 | + | func (w *Workspace) sendBand(bands [][]Column, b, dir, at int, cols []Column) [][]Column { | |
| 312 | + | t, nb := b+dir, b // A new band between b and t, before b going up. | |
| 313 | + | if dir > 0 { | |
| 314 | + | nb = b + 1 | |
| 315 | + | } | |
| 316 | + | if len(bands[b]) == 0 { | |
| 317 | + | bands = slices.Delete(bands, b, b+1) | |
| 318 | + | t, nb = b-1, b-1 // Going up: before band t. | |
| 319 | + | if dir > 0 { | |
| 320 | + | t, nb = b, b+1 // Going down: after band t. | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | if w.fits(bands, t, cols) { | |
| 324 | + | at = min(max(at, 0), len(bands[t])) | |
| 325 | + | bands[t] = slices.Insert(bands[t], at, cols...) | |
| 326 | + | return bands | |
| 327 | + | } | |
| 328 | + | return slices.Insert(bands, min(max(nb, 0), len(bands)), slices.Clone(cols)) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // shiftBand moves the columns lo..hi of one cascade band to the band dir | |
| 332 | + | // away (see sendBand), at offset at in it. It reports whether they moved. | |
| 333 | + | func (w *Workspace) shiftBand(lo, hi, dir, at int) bool { | |
| 334 | + | if len(w.Columns) == 0 || lo < 0 || hi >= len(w.Columns) || lo > hi { | |
| 335 | + | return false | |
| 336 | + | } | |
| 337 | + | b := w.band(lo) | |
| 338 | + | if w.band(hi) != b { | |
| 339 | + | return false | |
| 340 | + | } | |
| 341 | + | first, last := w.bandBounds(b) | |
| 342 | + | if lo == first && hi == last && (b+dir < 0 || b+dir > w.band(len(w.Columns)-1)) { | |
| 343 | + | return false // Alone at the outer band: nothing to cross. | |
| 344 | + | } | |
| 345 | + | id, _ := w.Focused() | |
| 346 | + | bands := w.bandList() | |
| 347 | + | cols := slices.Clone(bands[b][lo-first : hi-first+1]) | |
| 348 | + | bands[b] = slices.Delete(bands[b], lo-first, hi-first+1) | |
| 349 | + | w.setBands(w.sendBand(bands, b, dir, at, cols), id) | |
| 350 | + | return true | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // firstWindows lists the first window of each column. | |
| 354 | + | func firstWindows(cols []Column) []WindowID { | |
| 355 | + | ids := make([]WindowID, len(cols)) | |
| 356 | + | for i, c := range cols { | |
| 357 | + | ids[i] = c.Windows[0] | |
| 358 | + | } | |
| 359 | + | return ids | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // isolateBand gives the focused cascade column a band of its own: the | |
| 363 | + | // columns on its left go to the band above, the ones on its right to the | |
| 364 | + | // band below (sendBand), and the column remembers them for restoreBand. | |
| 365 | + | func (w *Workspace) isolateBand() { | |
| 366 | + | b := w.band(w.Focus) | |
| 367 | + | first, last := w.bandBounds(b) | |
| 368 | + | if first == last { | |
| 369 | + | return | |
| 370 | + | } | |
| 371 | + | id, _ := w.Focused() | |
| 372 | + | bands := w.bandList() | |
| 373 | + | p := w.Focus - first | |
| 374 | + | left := slices.Clone(bands[b][:p]) | |
| 375 | + | right := slices.Clone(bands[b][p+1:]) | |
| 376 | + | c := bands[b][p] | |
| 377 | + | c.pushedUp, c.pushedDown = firstWindows(left), firstWindows(right) | |
| 378 | + | bands[b] = []Column{c} | |
| 379 | + | if len(right) > 0 { | |
| 380 | + | bands = w.sendBand(bands, b, 1, 0, right) | |
| 381 | + | } | |
| 382 | + | if len(left) > 0 { | |
| 383 | + | bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left) | |
| 384 | + | } | |
| 385 | + | w.setBands(bands, id) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // pushNeighbor makes room in the focused column's full cascade band for a | |
| 389 | + | // wider column: its right neighbor goes to the band below, or its left | |
| 390 | + | // one to the band above at the band's right end. The column remembers it. | |
| 391 | + | func (w *Workspace) pushNeighbor() { | |
| 392 | + | first, last := w.bandBounds(w.band(w.Focus)) | |
| 393 | + | if first == last { | |
| 394 | + | return | |
| 395 | + | } | |
| 396 | + | id, _ := w.Focused() | |
| 397 | + | c := &w.Columns[w.Focus] | |
| 398 | + | if w.Focus < last { | |
| 399 | + | c.pushedDown = append(c.pushedDown, w.Columns[w.Focus+1].Windows[0]) | |
| 400 | + | w.shiftBand(w.Focus+1, w.Focus+1, 1, 0) | |
| 401 | + | } else { | |
| 402 | + | c.pushedUp = append(c.pushedUp, w.Columns[w.Focus-1].Windows[0]) | |
| 403 | + | w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1)) | |
| 404 | + | } | |
| 405 | + | w.Focus = w.columnOf(id) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // restoreBand brings back the columns isolateBand or pushNeighbor sent | |
| 409 | + | // away, those still in the adjacent band: the ones sent up before the | |
| 410 | + | // focused column, the ones sent down after it. It reports whether the | |
| 411 | + | // column remembered any. | |
| 412 | + | func (w *Workspace) restoreBand() bool { | |
| 413 | + | c := &w.Columns[w.Focus] | |
| 414 | + | up, down := c.pushedUp, c.pushedDown | |
| 415 | + | c.pushedUp, c.pushedDown = nil, nil | |
| 416 | + | if len(up) == 0 && len(down) == 0 { | |
| 417 | + | return false | |
| 418 | + | } | |
| 419 | + | id, _ := w.Focused() | |
| 420 | + | bands := w.bandList() | |
| 421 | + | b := w.band(w.Focus) | |
| 422 | + | take := func(nb int, ids []WindowID) []Column { | |
| 423 | + | var got []Column | |
| 424 | + | if nb < 0 || nb >= len(bands) { | |
| 425 | + | return nil | |
| 426 | + | } | |
| 427 | + | for _, v := range ids { | |
| 428 | + | if i := slices.IndexFunc(bands[nb], func(c Column) bool { return len(c.Windows) > 0 && c.Windows[0] == v }); i >= 0 { | |
| 429 | + | got = append(got, bands[nb][i]) | |
| 430 | + | bands[nb] = slices.Delete(bands[nb], i, i+1) | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | return got | |
| 434 | + | } | |
| 435 | + | above, below := take(b-1, up), take(b+1, down) | |
| 436 | + | p := slices.IndexFunc(bands[b], func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 437 | + | room := max(max(w.MaxColumns, 1)-len(bands[b]), 0) | |
| 438 | + | // Columns that no longer fit stay where they are. | |
| 439 | + | if len(above) > room { | |
| 440 | + | bands[b-1] = append(bands[b-1], above[:len(above)-room]...) | |
| 441 | + | above = above[len(above)-room:] | |
| 442 | + | } | |
| 443 | + | room -= len(above) | |
| 444 | + | if len(below) > room { | |
| 445 | + | bands[b+1] = slices.Insert(bands[b+1], 0, below[room:]...) | |
| 446 | + | below = below[:room] | |
| 447 | + | } | |
| 448 | + | row := slices.Concat(bands[b][:p], above, bands[b][p:p+1], below, bands[b][p+1:]) | |
| 449 | + | bands[b] = row | |
| 450 | + | w.setBands(bands, id) | |
| 451 | + | return true | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // spawnIndex is where a new cascade column goes: after the focused band, | |
| 455 | + | // which it joins when it has room; otherwise it starts a band of its own | |
| 456 | + | // between it and the next (newBand). | |
| 457 | + | func (w *Workspace) spawnIndex() (at int, newBand bool) { | |
| 458 | + | if len(w.Columns) == 0 { | |
| 459 | + | return 0, true | |
| 460 | + | } | |
| 461 | + | b := w.band(w.Focus) | |
| 462 | + | first, last := w.bandBounds(b) | |
| 463 | + | bands := [][]Column{w.Columns[first : last+1]} | |
| 464 | + | return last + 1, !w.fits(bands, 0, []Column{{}}) | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // deleteColumn removes column i; a band it started keeps its start. | |
| 468 | + | func (w *Workspace) deleteColumn(i int) { | |
| 469 | + | if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) { | |
| 470 | + | w.Columns[i+1].BandStart = true | |
| 471 | + | } | |
| 472 | + | w.Columns = slices.Delete(w.Columns, i, i+1) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | // forgetBands drops the cascade band starts and pushed columns: the bands | |
| 476 | + | // pack again in column order. | |
| 477 | + | func (w *Workspace) forgetBands() { | |
| 478 | + | for i := range w.Columns { | |
| 479 | + | c := &w.Columns[i] | |
| 480 | + | c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil | |
| 481 | + | } | |
| 482 | + | } | |
| @@ -2,39 +2,55 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import "slices" | 3 | import "slices" | ||
| 4 | 4 | ||||
| 5 | - | // Cascade bands pack columns in order: a band ends when the next column | 5 | + | // Cascade bands are explicit: Column.BandStart marks the first column of |
| 6 | - | // would take it past MaxColumns slots. A column takes one slot and a | 6 | + | // each band, and moves keep the other bands as they are. A band also ends |
| 7 | - | // maximized one all of them, so it sits alone in its band. Cells only | 7 | + | // where it would pass MaxColumns columns (max-columns shrank, or a column |
| 8 | - | // share a band's width (laneWidth); they never move a column to a band. | 8 | + | // was inserted into a full band), and a maximized column sits alone in its |
| 9 | + | // band. scroll freezes those ends into BandStart, so the bands stay when | |||
| 10 | + | // max-columns grows again or the column is unmaximized. Cells only share | |||
| 11 | + | // a band's width (laneWidth); they never move a column to a band. | |||
| 9 | 12 | ||||
| 10 | - | // bandSlots is the number of slots column i takes in its band. | 13 | + | // startsBand reports whether column i starts a band, n being the count of |
| 11 | - | func (w *Workspace) bandSlots(i int) int { | 14 | + | // columns in the band before it. |
| 12 | - | if w.Columns[i].FullWidth { | 15 | + | func (w *Workspace) startsBand(i, n int) bool { |
| 13 | - | return max(w.MaxColumns, 1) | 16 | + | if i == 0 { |
| 17 | + | return true | |||
| 14 | } | 18 | } | ||
| 15 | - | return 1 | 19 | + | c := w.Columns |
| 20 | + | return c[i].BandStart || n >= max(w.MaxColumns, 1) || c[i].FullWidth || c[i-1].FullWidth | |||
| 16 | } | 21 | } | ||
| 17 | 22 | ||||
| 18 | // walkBands calls f with each band and its column range, in order, until f | 23 | // walkBands calls f with each band and its column range, in order, until f | ||
| 19 | // returns false. | 24 | // returns false. | ||
| 20 | func (w *Workspace) walkBands(f func(b, first, last int) bool) { | 25 | func (w *Workspace) walkBands(f func(b, first, last int) bool) { | ||
| 21 | - | k := max(w.MaxColumns, 1) | 26 | + | b, first, n := 0, 0, 0 |
| 22 | - | b, first, sum := 0, 0, 0 | |||
| 23 | for i := range w.Columns { | 27 | for i := range w.Columns { | ||
| 24 | - | c := w.bandSlots(i) | 28 | + | if i > first && w.startsBand(i, n) { |
| 25 | - | if i > first && sum+c > k { | |||
| 26 | if !f(b, first, i-1) { | 29 | if !f(b, first, i-1) { | ||
| 27 | return | 30 | return | ||
| 28 | } | 31 | } | ||
| 29 | - | b, first, sum = b+1, i, 0 | 32 | + | b, first, n = b+1, i, 0 |
| 30 | } | 33 | } | ||
| 31 | - | sum += c | 34 | + | n++ |
| 32 | } | 35 | } | ||
| 33 | if len(w.Columns) > 0 { | 36 | if len(w.Columns) > 0 { | ||
| 34 | f(b, first, len(w.Columns)-1) | 37 | f(b, first, len(w.Columns)-1) | ||
| 35 | } | 38 | } | ||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 41 | + | // markBands freezes the band starts into BandStart. | |||
| 42 | + | func (w *Workspace) markBands() { | |||
| 43 | + | n := 0 | |||
| 44 | + | for i := range w.Columns { | |||
| 45 | + | s := w.startsBand(i, n) | |||
| 46 | + | w.Columns[i].BandStart = s | |||
| 47 | + | if s { | |||
| 48 | + | n = 0 | |||
| 49 | + | } | |||
| 50 | + | n++ | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 38 | // band is the cascade band of column i. | 54 | // band is the cascade band of column i. | ||
| 39 | func (w *Workspace) band(i int) (band int) { | 55 | func (w *Workspace) band(i int) (band int) { | ||
| 40 | w.walkBands(func(b, _, last int) bool { | 56 | w.walkBands(func(b, _, last int) bool { | ||
| @@ -226,32 +242,17 @@ func (w *Workspace) leavesBand(i int) bool { | |||||
| 226 | return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus) | 242 | return i < 0 || i >= len(w.Columns) || w.policy().wraps && w.band(i) != w.band(w.Focus) | ||
| 227 | } | 243 | } | ||
| 228 | 244 | ||||
| 229 | - | // moveBand swaps the focused cascade column with the column in the band dir | 245 | + | // moveBand moves the focused cascade column to the band dir away |
| 230 | - | // away where focus-window-up/down goes; focus follows it. Past the first | 246 | + | // (move-window-up/down past its column), keeping its offset in the band; |
| 231 | - | // or last band nothing moves. Columns keep their maximization: when the two | 247 | + | // focus follows it. See shiftBand. |
| 232 | - | // take a different number of slots, a swap would repack the bands around | |||
| 233 | - | // them, so the focused column passes the whole adjacent band instead. | |||
| 234 | func (w *Workspace) moveBand(dir int) { | 248 | func (w *Workspace) moveBand(dir int) { | ||
| 235 | - | i := w.laneNeighbor(dir) | 249 | + | if !w.policy().wraps || len(w.Columns) == 0 { |
| 236 | - | if i < 0 { | |||
| 237 | return | 250 | return | ||
| 238 | } | 251 | } | ||
| 239 | - | if w.bandSlots(w.Focus) == w.bandSlots(i) { | 252 | + | first, _ := w.bandBounds(w.band(w.Focus)) |
| 240 | - | w.Columns[w.Focus], w.Columns[i] = w.Columns[i], w.Columns[w.Focus] | 253 | + | if w.shiftBand(w.Focus, w.Focus, dir, w.Focus-first) { |
| 241 | - | w.Focus = i | |||
| 242 | w.scroll() | 254 | w.scroll() | ||
| 243 | - | return | |||
| 244 | } | 255 | } | ||
| 245 | - | first, last := w.bandBounds(w.band(i)) | |||
| 246 | - | col := w.Columns[w.Focus] | |||
| 247 | - | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |||
| 248 | - | at := first // dir < 0: before the band above. | |||
| 249 | - | if dir > 0 { | |||
| 250 | - | at = last // After the band below, one index lower once removed. | |||
| 251 | - | } | |||
| 252 | - | w.Columns = slices.Insert(w.Columns, at, col) | |||
| 253 | - | w.Focus = at | |||
| 254 | - | w.scroll() | |||
| 255 | } | 256 | } | ||
| 256 | 257 | ||||
| 257 | // laneNeighbor is the closest column to the focus in the lane d lanes away, | 258 | // laneNeighbor is the closest column to the focus in the lane d lanes away, | ||
| @@ -265,3 +266,217 @@ func (w *Workspace) laneNeighbor(d int) int { | |||||
| 265 | } | 266 | } | ||
| 266 | return min(nfirst+w.Focus-first, nlast) | 267 | return min(nfirst+w.Focus-first, nlast) | ||
| 267 | } | 268 | } | ||
| 269 | + | ||||
| 270 | + | // bandList copies the cascade columns into their bands. | |||
| 271 | + | func (w *Workspace) bandList() [][]Column { | |||
| 272 | + | var bands [][]Column | |||
| 273 | + | w.walkBands(func(_, first, last int) bool { | |||
| 274 | + | bands = append(bands, slices.Clone(w.Columns[first:last+1])) | |||
| 275 | + | return true | |||
| 276 | + | }) | |||
| 277 | + | return bands | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // setBands stores bands as the columns, empty bands dropped, and focuses | |||
| 281 | + | // the column holding window focus. | |||
| 282 | + | func (w *Workspace) setBands(bands [][]Column, focus WindowID) { | |||
| 283 | + | cols := w.Columns[:0] | |||
| 284 | + | for _, b := range bands { | |||
| 285 | + | for i, c := range b { | |||
| 286 | + | c.BandStart = i == 0 | |||
| 287 | + | cols = append(cols, c) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | w.Columns = cols | |||
| 291 | + | if i := w.columnOf(focus); i >= 0 { | |||
| 292 | + | w.Focus = i | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | // fits reports whether cols can join band b: it has room for them and | |||
| 297 | + | // neither side holds a maximized column, which keeps a band to itself. | |||
| 298 | + | func (w *Workspace) fits(bands [][]Column, b int, cols []Column) bool { | |||
| 299 | + | if b < 0 || b >= len(bands) || len(bands[b])+len(cols) > max(w.MaxColumns, 1) { | |||
| 300 | + | return false | |||
| 301 | + | } | |||
| 302 | + | full := func(c Column) bool { return c.FullWidth } | |||
| 303 | + | return !slices.ContainsFunc(bands[b], full) && !slices.ContainsFunc(cols, full) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // sendBand moves cols, already taken out of band b, to the band dir away: | |||
| 307 | + | // they join it at index at (clamped) when they fit, else they make a new | |||
| 308 | + | // band between the two, or past the outer band. When cols were alone in | |||
| 309 | + | // band b, it goes away and a band that cannot take them is passed: the | |||
| 310 | + | // new band goes on its far side, so repeated moves always progress. | |||
| 311 | + | func (w *Workspace) sendBand(bands [][]Column, b, dir, at int, cols []Column) [][]Column { | |||
| 312 | + | t, nb := b+dir, b // A new band between b and t, before b going up. | |||
| 313 | + | if dir > 0 { | |||
| 314 | + | nb = b + 1 | |||
| 315 | + | } | |||
| 316 | + | if len(bands[b]) == 0 { | |||
| 317 | + | bands = slices.Delete(bands, b, b+1) | |||
| 318 | + | t, nb = b-1, b-1 // Going up: before band t. | |||
| 319 | + | if dir > 0 { | |||
| 320 | + | t, nb = b, b+1 // Going down: after band t. | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | if w.fits(bands, t, cols) { | |||
| 324 | + | at = min(max(at, 0), len(bands[t])) | |||
| 325 | + | bands[t] = slices.Insert(bands[t], at, cols...) | |||
| 326 | + | return bands | |||
| 327 | + | } | |||
| 328 | + | return slices.Insert(bands, min(max(nb, 0), len(bands)), slices.Clone(cols)) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // shiftBand moves the columns lo..hi of one cascade band to the band dir | |||
| 332 | + | // away (see sendBand), at offset at in it. It reports whether they moved. | |||
| 333 | + | func (w *Workspace) shiftBand(lo, hi, dir, at int) bool { | |||
| 334 | + | if len(w.Columns) == 0 || lo < 0 || hi >= len(w.Columns) || lo > hi { | |||
| 335 | + | return false | |||
| 336 | + | } | |||
| 337 | + | b := w.band(lo) | |||
| 338 | + | if w.band(hi) != b { | |||
| 339 | + | return false | |||
| 340 | + | } | |||
| 341 | + | first, last := w.bandBounds(b) | |||
| 342 | + | if lo == first && hi == last && (b+dir < 0 || b+dir > w.band(len(w.Columns)-1)) { | |||
| 343 | + | return false // Alone at the outer band: nothing to cross. | |||
| 344 | + | } | |||
| 345 | + | id, _ := w.Focused() | |||
| 346 | + | bands := w.bandList() | |||
| 347 | + | cols := slices.Clone(bands[b][lo-first : hi-first+1]) | |||
| 348 | + | bands[b] = slices.Delete(bands[b], lo-first, hi-first+1) | |||
| 349 | + | w.setBands(w.sendBand(bands, b, dir, at, cols), id) | |||
| 350 | + | return true | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // firstWindows lists the first window of each column. | |||
| 354 | + | func firstWindows(cols []Column) []WindowID { | |||
| 355 | + | ids := make([]WindowID, len(cols)) | |||
| 356 | + | for i, c := range cols { | |||
| 357 | + | ids[i] = c.Windows[0] | |||
| 358 | + | } | |||
| 359 | + | return ids | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // isolateBand gives the focused cascade column a band of its own: the | |||
| 363 | + | // columns on its left go to the band above, the ones on its right to the | |||
| 364 | + | // band below (sendBand), and the column remembers them for restoreBand. | |||
| 365 | + | func (w *Workspace) isolateBand() { | |||
| 366 | + | b := w.band(w.Focus) | |||
| 367 | + | first, last := w.bandBounds(b) | |||
| 368 | + | if first == last { | |||
| 369 | + | return | |||
| 370 | + | } | |||
| 371 | + | id, _ := w.Focused() | |||
| 372 | + | bands := w.bandList() | |||
| 373 | + | p := w.Focus - first | |||
| 374 | + | left := slices.Clone(bands[b][:p]) | |||
| 375 | + | right := slices.Clone(bands[b][p+1:]) | |||
| 376 | + | c := bands[b][p] | |||
| 377 | + | c.pushedUp, c.pushedDown = firstWindows(left), firstWindows(right) | |||
| 378 | + | bands[b] = []Column{c} | |||
| 379 | + | if len(right) > 0 { | |||
| 380 | + | bands = w.sendBand(bands, b, 1, 0, right) | |||
| 381 | + | } | |||
| 382 | + | if len(left) > 0 { | |||
| 383 | + | bands = w.sendBand(bands, b, -1, max(w.MaxColumns, 1), left) | |||
| 384 | + | } | |||
| 385 | + | w.setBands(bands, id) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // pushNeighbor makes room in the focused column's full cascade band for a | |||
| 389 | + | // wider column: its right neighbor goes to the band below, or its left | |||
| 390 | + | // one to the band above at the band's right end. The column remembers it. | |||
| 391 | + | func (w *Workspace) pushNeighbor() { | |||
| 392 | + | first, last := w.bandBounds(w.band(w.Focus)) | |||
| 393 | + | if first == last { | |||
| 394 | + | return | |||
| 395 | + | } | |||
| 396 | + | id, _ := w.Focused() | |||
| 397 | + | c := &w.Columns[w.Focus] | |||
| 398 | + | if w.Focus < last { | |||
| 399 | + | c.pushedDown = append(c.pushedDown, w.Columns[w.Focus+1].Windows[0]) | |||
| 400 | + | w.shiftBand(w.Focus+1, w.Focus+1, 1, 0) | |||
| 401 | + | } else { | |||
| 402 | + | c.pushedUp = append(c.pushedUp, w.Columns[w.Focus-1].Windows[0]) | |||
| 403 | + | w.shiftBand(w.Focus-1, w.Focus-1, -1, max(w.MaxColumns, 1)) | |||
| 404 | + | } | |||
| 405 | + | w.Focus = w.columnOf(id) | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // restoreBand brings back the columns isolateBand or pushNeighbor sent | |||
| 409 | + | // away, those still in the adjacent band: the ones sent up before the | |||
| 410 | + | // focused column, the ones sent down after it. It reports whether the | |||
| 411 | + | // column remembered any. | |||
| 412 | + | func (w *Workspace) restoreBand() bool { | |||
| 413 | + | c := &w.Columns[w.Focus] | |||
| 414 | + | up, down := c.pushedUp, c.pushedDown | |||
| 415 | + | c.pushedUp, c.pushedDown = nil, nil | |||
| 416 | + | if len(up) == 0 && len(down) == 0 { | |||
| 417 | + | return false | |||
| 418 | + | } | |||
| 419 | + | id, _ := w.Focused() | |||
| 420 | + | bands := w.bandList() | |||
| 421 | + | b := w.band(w.Focus) | |||
| 422 | + | take := func(nb int, ids []WindowID) []Column { | |||
| 423 | + | var got []Column | |||
| 424 | + | if nb < 0 || nb >= len(bands) { | |||
| 425 | + | return nil | |||
| 426 | + | } | |||
| 427 | + | for _, v := range ids { | |||
| 428 | + | if i := slices.IndexFunc(bands[nb], func(c Column) bool { return len(c.Windows) > 0 && c.Windows[0] == v }); i >= 0 { | |||
| 429 | + | got = append(got, bands[nb][i]) | |||
| 430 | + | bands[nb] = slices.Delete(bands[nb], i, i+1) | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | return got | |||
| 434 | + | } | |||
| 435 | + | above, below := take(b-1, up), take(b+1, down) | |||
| 436 | + | p := slices.IndexFunc(bands[b], func(c Column) bool { return slices.Contains(c.Windows, id) }) | |||
| 437 | + | room := max(max(w.MaxColumns, 1)-len(bands[b]), 0) | |||
| 438 | + | // Columns that no longer fit stay where they are. | |||
| 439 | + | if len(above) > room { | |||
| 440 | + | bands[b-1] = append(bands[b-1], above[:len(above)-room]...) | |||
| 441 | + | above = above[len(above)-room:] | |||
| 442 | + | } | |||
| 443 | + | room -= len(above) | |||
| 444 | + | if len(below) > room { | |||
| 445 | + | bands[b+1] = slices.Insert(bands[b+1], 0, below[room:]...) | |||
| 446 | + | below = below[:room] | |||
| 447 | + | } | |||
| 448 | + | row := slices.Concat(bands[b][:p], above, bands[b][p:p+1], below, bands[b][p+1:]) | |||
| 449 | + | bands[b] = row | |||
| 450 | + | w.setBands(bands, id) | |||
| 451 | + | return true | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // spawnIndex is where a new cascade column goes: after the focused band, | |||
| 455 | + | // which it joins when it has room; otherwise it starts a band of its own | |||
| 456 | + | // between it and the next (newBand). | |||
| 457 | + | func (w *Workspace) spawnIndex() (at int, newBand bool) { | |||
| 458 | + | if len(w.Columns) == 0 { | |||
| 459 | + | return 0, true | |||
| 460 | + | } | |||
| 461 | + | b := w.band(w.Focus) | |||
| 462 | + | first, last := w.bandBounds(b) | |||
| 463 | + | bands := [][]Column{w.Columns[first : last+1]} | |||
| 464 | + | return last + 1, !w.fits(bands, 0, []Column{{}}) | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // deleteColumn removes column i; a band it started keeps its start. | |||
| 468 | + | func (w *Workspace) deleteColumn(i int) { | |||
| 469 | + | if w.policy().wraps && w.Columns[i].BandStart && i+1 < len(w.Columns) { | |||
| 470 | + | w.Columns[i+1].BandStart = true | |||
| 471 | + | } | |||
| 472 | + | w.Columns = slices.Delete(w.Columns, i, i+1) | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | // forgetBands drops the cascade band starts and pushed columns: the bands | |||
| 476 | + | // pack again in column order. | |||
| 477 | + | func (w *Workspace) forgetBands() { | |||
| 478 | + | for i := range w.Columns { | |||
| 479 | + | c := &w.Columns[i] | |||
| 480 | + | c.BandStart, c.pushedUp, c.pushedDown = false, nil, nil | |||
| 481 | + | } | |||
| 482 | + | } | |||
A internal/core/cascade_bands_test.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // cycle-column-width in a full band pushes the right neighbor to the band | |
| 9 | + | // below, or the left one up from the last column; the end of the cycle | |
| 10 | + | // brings it back. | |
| 11 | + | func TestCascadeCyclePushesNeighbor(t *testing.T) { | |
| 12 | + | for _, tc := range []struct { | |
| 13 | + | name string | |
| 14 | + | n int | |
| 15 | + | focus WindowID | |
| 16 | + | pushed [][]WindowID | |
| 17 | + | }{ | |
| 18 | + | {"middle pushes right down", 3, 2, [][]WindowID{{1, 2}, {3}}}, | |
| 19 | + | {"left pushes right down", 3, 1, [][]WindowID{{1, 3}, {2}}}, | |
| 20 | + | {"last pushes left up", 3, 3, [][]WindowID{{2}, {1, 3}}}, | |
| 21 | + | {"joins the band below", 5, 2, [][]WindowID{{1, 2}, {3, 4, 5}}}, | |
| 22 | + | } { | |
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |
| 24 | + | _, w := cascadeMovesCore(t, tc.n, 3, false) | |
| 25 | + | w.FocusID(tc.focus) | |
| 26 | + | before := bandIDs(w) | |
| 27 | + | w.Apply(ActionCycleColumnWidth) | |
| 28 | + | wantBands(t, w, tc.pushed...) | |
| 29 | + | if id, _ := w.Focused(); id != tc.focus { | |
| 30 | + | t.Fatalf("focus %d, want %d", id, tc.focus) | |
| 31 | + | } | |
| 32 | + | if got := w.Columns[w.Focus].Cells; got != 2 { | |
| 33 | + | t.Fatalf("focused cells %d, want 2", got) | |
| 34 | + | } | |
| 35 | + | w.Apply(ActionCycleColumnWidth) | |
| 36 | + | wantBands(t, w, before...) | |
| 37 | + | for _, c := range w.Columns { | |
| 38 | + | if c.Cells != 0 { | |
| 39 | + | t.Fatalf("cells not back to equal: %+v", w.Columns) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // A pushed column that moved meanwhile stays where it is. | |
| 47 | + | func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) { | |
| 48 | + | c, w := cascadeMovesCore(t, 3, 3, false) | |
| 49 | + | w.FocusID(2) | |
| 50 | + | w.Apply(ActionCycleColumnWidth) // {1 2} {3} | |
| 51 | + | w.FocusID(3) | |
| 52 | + | c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand. | |
| 53 | + | w.FocusID(2) | |
| 54 | + | w.Apply(ActionCycleColumnWidth) | |
| 55 | + | wantBands(t, w, []WindowID{3, 1, 2}) | |
| 56 | + | if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) { | |
| 57 | + | t.Fatalf("cells %v, want equal", got) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // maximize-column isolates the column: the left ones go up, the right ones | |
| 62 | + | // down, together; maximizing again forms the band back. | |
| 63 | + | func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) { | |
| 64 | + | for _, tc := range []struct { | |
| 65 | + | name string | |
| 66 | + | n, k int | |
| 67 | + | focus WindowID | |
| 68 | + | split [][]WindowID | |
| 69 | + | }{ | |
| 70 | + | {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}}, | |
| 71 | + | {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}}, | |
| 72 | + | {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}}, | |
| 73 | + | {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}}, | |
| 74 | + | } { | |
| 75 | + | t.Run(tc.name, func(t *testing.T) { | |
| 76 | + | _, w := cascadeMovesCore(t, tc.n, tc.k, false) | |
| 77 | + | w.FocusID(tc.focus) | |
| 78 | + | before := bandIDs(w) | |
| 79 | + | w.ToggleFullWidth() | |
| 80 | + | wantBands(t, w, tc.split...) | |
| 81 | + | if !w.Columns[w.Focus].FullWidth { | |
| 82 | + | t.Fatal("not maximized") | |
| 83 | + | } | |
| 84 | + | w.ToggleFullWidth() | |
| 85 | + | wantBands(t, w, before...) | |
| 86 | + | if w.Columns[w.Focus].FullWidth { | |
| 87 | + | t.Fatal("still maximized") | |
| 88 | + | } | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Columns on the left that fit the band above join it at its end. | |
| 94 | + | func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) { | |
| 95 | + | c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} | |
| 96 | + | w.FocusID(2) | |
| 97 | + | c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5} | |
| 98 | + | w.FocusID(2) | |
| 99 | + | w.ToggleFullWidth() | |
| 100 | + | wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5}) | |
| 101 | + | w.ToggleFullWidth() | |
| 102 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5}) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // No column is hidden by a maximized cascade column. | |
| 106 | + | func TestCascadeMaximizeHidesNothing(t *testing.T) { | |
| 107 | + | _, w := cascadeMovesCore(t, 3, 3, false) | |
| 108 | + | w.FocusID(2) | |
| 109 | + | w.ToggleFullWidth() | |
| 110 | + | for _, p := range w.Layout() { | |
| 111 | + | if p.Hidden && p.ID != 0 { | |
| 112 | + | // Off-screen bands are laid out, not hidden by maximize. | |
| 113 | + | if w.hiddenByMaximized(w.columnOf(p.ID)) { | |
| 114 | + | t.Fatalf("window %d hidden by maximize", p.ID) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // A new window joins the focused band when it has room, else it starts a | |
| 121 | + | // band of its own right below it; other bands do not move. | |
| 122 | + | func TestCascadeSpawnInFocusedBand(t *testing.T) { | |
| 123 | + | _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} | |
| 124 | + | w.FocusID(4) | |
| 125 | + | w.AddWindow(6) | |
| 126 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6}) | |
| 127 | + | w.FocusID(2) | |
| 128 | + | w.AddWindow(7) | |
| 129 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6}) | |
| 130 | + | if id, _ := w.Focused(); id != 7 { | |
| 131 | + | t.Fatalf("focus %d, want 7", id) | |
| 132 | + | } | |
| 133 | + | w.AddWindow(8) | |
| 134 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6}) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // A band keeps its columns: closing one leaves the others in place, and a | |
| 138 | + | // band left empty goes away. | |
| 139 | + | func TestCascadeCloseKeepsBands(t *testing.T) { | |
| 140 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 141 | + | w.FocusID(5) | |
| 142 | + | c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5} | |
| 143 | + | w.RemoveWindow(2) | |
| 144 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5}) | |
| 145 | + | w.RemoveWindow(4) | |
| 146 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{5}) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // A smaller max-columns sends the extra columns of a band to a new band | |
| 150 | + | // right below it; a larger one keeps the bands. | |
| 151 | + | func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) { | |
| 152 | + | _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4} | |
| 153 | + | w.SetMaxColumns(2) | |
| 154 | + | wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}) | |
| 155 | + | w.SetMaxColumns(3) | |
| 156 | + | wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Switching to cascade from another overflow packs the columns in order. | |
| 160 | + | func TestCascadeOverflowSwitchRepacks(t *testing.T) { | |
| 161 | + | m := newMonitor("", "") | |
| 162 | + | m.SetOutput(900, 600) | |
| 163 | + | m.SetMaxColumns(3) | |
| 164 | + | m.SetOverflow(OverflowCascade) | |
| 165 | + | for id := WindowID(1); id <= 4; id++ { | |
| 166 | + | m.AddWindow(id) | |
| 167 | + | } | |
| 168 | + | w := m.Current() | |
| 169 | + | w.FocusID(2) | |
| 170 | + | w.ToggleFullWidth() // {1} {2*} {3 4} | |
| 171 | + | m.SetOverflow(OverflowScroll) | |
| 172 | + | m.SetOverflow(OverflowCascade) | |
| 173 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Moves inside a band keep the band starts in place. | |
| 177 | + | func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) { | |
| 178 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 179 | + | w.FocusID(4) | |
| 180 | + | c.applyAction(ActionMoveColumnRight) | |
| 181 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4}) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func bandIDs(w *Workspace) [][]WindowID { | |
| 185 | + | var out [][]WindowID | |
| 186 | + | w.walkBands(func(_, first, last int) bool { | |
| 187 | + | var b []WindowID | |
| 188 | + | for _, c := range w.Columns[first : last+1] { | |
| 189 | + | b = append(b, c.Windows[0]) | |
| 190 | + | } | |
| 191 | + | out = append(out, slices.Clone(b)) | |
| 192 | + | return true | |
| 193 | + | }) | |
| 194 | + | return out | |
| 195 | + | } | |
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // cycle-column-width in a full band pushes the right neighbor to the band | |||
| 9 | + | // below, or the left one up from the last column; the end of the cycle | |||
| 10 | + | // brings it back. | |||
| 11 | + | func TestCascadeCyclePushesNeighbor(t *testing.T) { | |||
| 12 | + | for _, tc := range []struct { | |||
| 13 | + | name string | |||
| 14 | + | n int | |||
| 15 | + | focus WindowID | |||
| 16 | + | pushed [][]WindowID | |||
| 17 | + | }{ | |||
| 18 | + | {"middle pushes right down", 3, 2, [][]WindowID{{1, 2}, {3}}}, | |||
| 19 | + | {"left pushes right down", 3, 1, [][]WindowID{{1, 3}, {2}}}, | |||
| 20 | + | {"last pushes left up", 3, 3, [][]WindowID{{2}, {1, 3}}}, | |||
| 21 | + | {"joins the band below", 5, 2, [][]WindowID{{1, 2}, {3, 4, 5}}}, | |||
| 22 | + | } { | |||
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 24 | + | _, w := cascadeMovesCore(t, tc.n, 3, false) | |||
| 25 | + | w.FocusID(tc.focus) | |||
| 26 | + | before := bandIDs(w) | |||
| 27 | + | w.Apply(ActionCycleColumnWidth) | |||
| 28 | + | wantBands(t, w, tc.pushed...) | |||
| 29 | + | if id, _ := w.Focused(); id != tc.focus { | |||
| 30 | + | t.Fatalf("focus %d, want %d", id, tc.focus) | |||
| 31 | + | } | |||
| 32 | + | if got := w.Columns[w.Focus].Cells; got != 2 { | |||
| 33 | + | t.Fatalf("focused cells %d, want 2", got) | |||
| 34 | + | } | |||
| 35 | + | w.Apply(ActionCycleColumnWidth) | |||
| 36 | + | wantBands(t, w, before...) | |||
| 37 | + | for _, c := range w.Columns { | |||
| 38 | + | if c.Cells != 0 { | |||
| 39 | + | t.Fatalf("cells not back to equal: %+v", w.Columns) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // A pushed column that moved meanwhile stays where it is. | |||
| 47 | + | func TestCascadeCycleRestoreSkipsMovedColumn(t *testing.T) { | |||
| 48 | + | c, w := cascadeMovesCore(t, 3, 3, false) | |||
| 49 | + | w.FocusID(2) | |||
| 50 | + | w.Apply(ActionCycleColumnWidth) // {1 2} {3} | |||
| 51 | + | w.FocusID(3) | |||
| 52 | + | c.applyAction(ActionMoveWindowUp) // {3 1 2}: 3 is back by hand. | |||
| 53 | + | w.FocusID(2) | |||
| 54 | + | w.Apply(ActionCycleColumnWidth) | |||
| 55 | + | wantBands(t, w, []WindowID{3, 1, 2}) | |||
| 56 | + | if got := cellsOf(w); !slices.Equal(got, []int{0, 0, 0}) { | |||
| 57 | + | t.Fatalf("cells %v, want equal", got) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // maximize-column isolates the column: the left ones go up, the right ones | |||
| 62 | + | // down, together; maximizing again forms the band back. | |||
| 63 | + | func TestCascadeMaximizeSplitsAndRestoresBand(t *testing.T) { | |||
| 64 | + | for _, tc := range []struct { | |||
| 65 | + | name string | |||
| 66 | + | n, k int | |||
| 67 | + | focus WindowID | |||
| 68 | + | split [][]WindowID | |||
| 69 | + | }{ | |||
| 70 | + | {"middle", 3, 3, 2, [][]WindowID{{1}, {2}, {3}}}, | |||
| 71 | + | {"first", 3, 3, 1, [][]WindowID{{1}, {2, 3}}}, | |||
| 72 | + | {"left side moves together", 4, 4, 3, [][]WindowID{{1, 2}, {3}, {4}}}, | |||
| 73 | + | {"left joins the band above", 5, 3, 5, [][]WindowID{{1, 2, 3}, {4}, {5}}}, | |||
| 74 | + | } { | |||
| 75 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 76 | + | _, w := cascadeMovesCore(t, tc.n, tc.k, false) | |||
| 77 | + | w.FocusID(tc.focus) | |||
| 78 | + | before := bandIDs(w) | |||
| 79 | + | w.ToggleFullWidth() | |||
| 80 | + | wantBands(t, w, tc.split...) | |||
| 81 | + | if !w.Columns[w.Focus].FullWidth { | |||
| 82 | + | t.Fatal("not maximized") | |||
| 83 | + | } | |||
| 84 | + | w.ToggleFullWidth() | |||
| 85 | + | wantBands(t, w, before...) | |||
| 86 | + | if w.Columns[w.Focus].FullWidth { | |||
| 87 | + | t.Fatal("still maximized") | |||
| 88 | + | } | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Columns on the left that fit the band above join it at its end. | |||
| 94 | + | func TestCascadeMaximizeLeftJoinsBandAbove(t *testing.T) { | |||
| 95 | + | c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} | |||
| 96 | + | w.FocusID(2) | |||
| 97 | + | c.applyAction(ActionMoveWindowDown) // {1 3} {4 2 5} | |||
| 98 | + | w.FocusID(2) | |||
| 99 | + | w.ToggleFullWidth() | |||
| 100 | + | wantBands(t, w, []WindowID{1, 3, 4}, []WindowID{2}, []WindowID{5}) | |||
| 101 | + | w.ToggleFullWidth() | |||
| 102 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5}) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // No column is hidden by a maximized cascade column. | |||
| 106 | + | func TestCascadeMaximizeHidesNothing(t *testing.T) { | |||
| 107 | + | _, w := cascadeMovesCore(t, 3, 3, false) | |||
| 108 | + | w.FocusID(2) | |||
| 109 | + | w.ToggleFullWidth() | |||
| 110 | + | for _, p := range w.Layout() { | |||
| 111 | + | if p.Hidden && p.ID != 0 { | |||
| 112 | + | // Off-screen bands are laid out, not hidden by maximize. | |||
| 113 | + | if w.hiddenByMaximized(w.columnOf(p.ID)) { | |||
| 114 | + | t.Fatalf("window %d hidden by maximize", p.ID) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // A new window joins the focused band when it has room, else it starts a | |||
| 121 | + | // band of its own right below it; other bands do not move. | |||
| 122 | + | func TestCascadeSpawnInFocusedBand(t *testing.T) { | |||
| 123 | + | _, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} | |||
| 124 | + | w.FocusID(4) | |||
| 125 | + | w.AddWindow(6) | |||
| 126 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5, 6}) | |||
| 127 | + | w.FocusID(2) | |||
| 128 | + | w.AddWindow(7) | |||
| 129 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7}, []WindowID{4, 5, 6}) | |||
| 130 | + | if id, _ := w.Focused(); id != 7 { | |||
| 131 | + | t.Fatalf("focus %d, want 7", id) | |||
| 132 | + | } | |||
| 133 | + | w.AddWindow(8) | |||
| 134 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{7, 8}, []WindowID{4, 5, 6}) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // A band keeps its columns: closing one leaves the others in place, and a | |||
| 138 | + | // band left empty goes away. | |||
| 139 | + | func TestCascadeCloseKeepsBands(t *testing.T) { | |||
| 140 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |||
| 141 | + | w.FocusID(5) | |||
| 142 | + | c.applyAction(ActionMoveWindowDown) // {1 2 3} {4} {5} | |||
| 143 | + | w.RemoveWindow(2) | |||
| 144 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4}, []WindowID{5}) | |||
| 145 | + | w.RemoveWindow(4) | |||
| 146 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{5}) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // A smaller max-columns sends the extra columns of a band to a new band | |||
| 150 | + | // right below it; a larger one keeps the bands. | |||
| 151 | + | func TestCascadeMaxColumnsShrinkSplitsBand(t *testing.T) { | |||
| 152 | + | _, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4} | |||
| 153 | + | w.SetMaxColumns(2) | |||
| 154 | + | wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}) | |||
| 155 | + | w.SetMaxColumns(3) | |||
| 156 | + | wantBands(t, w, []WindowID{1, 2}, []WindowID{3}, []WindowID{4}) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Switching to cascade from another overflow packs the columns in order. | |||
| 160 | + | func TestCascadeOverflowSwitchRepacks(t *testing.T) { | |||
| 161 | + | m := newMonitor("", "") | |||
| 162 | + | m.SetOutput(900, 600) | |||
| 163 | + | m.SetMaxColumns(3) | |||
| 164 | + | m.SetOverflow(OverflowCascade) | |||
| 165 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 166 | + | m.AddWindow(id) | |||
| 167 | + | } | |||
| 168 | + | w := m.Current() | |||
| 169 | + | w.FocusID(2) | |||
| 170 | + | w.ToggleFullWidth() // {1} {2*} {3 4} | |||
| 171 | + | m.SetOverflow(OverflowScroll) | |||
| 172 | + | m.SetOverflow(OverflowCascade) | |||
| 173 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // Moves inside a band keep the band starts in place. | |||
| 177 | + | func TestCascadeMoveColumnKeepsBandStarts(t *testing.T) { | |||
| 178 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |||
| 179 | + | w.FocusID(4) | |||
| 180 | + | c.applyAction(ActionMoveColumnRight) | |||
| 181 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5, 4}) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func bandIDs(w *Workspace) [][]WindowID { | |||
| 185 | + | var out [][]WindowID | |||
| 186 | + | w.walkBands(func(_, first, last int) bool { | |||
| 187 | + | var b []WindowID | |||
| 188 | + | for _, c := range w.Columns[first : last+1] { | |||
| 189 | + | b = append(b, c.Windows[0]) | |||
| 190 | + | } | |||
| 191 | + | out = append(out, slices.Clone(b)) | |||
| 192 | + | return true | |||
| 193 | + | }) | |||
| 194 | + | return out | |||
| 195 | + | } | |||
A internal/core/cascade_edges_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // With no monitor on that side, move-column-left/right at a cascade band | |
| 6 | + | // edge does nothing: the column never crosses into the adjacent band. | |
| 7 | + | // Bands {1 2 3} {4 5}. | |
| 8 | + | func TestCascadeMoveColumnAtBandEdgeDoesNothing(t *testing.T) { | |
| 9 | + | for _, tc := range []struct { | |
| 10 | + | name string | |
| 11 | + | from WindowID | |
| 12 | + | action Action | |
| 13 | + | }{ | |
| 14 | + | {"right from band end", 3, ActionMoveColumnRight}, | |
| 15 | + | {"left from band start", 4, ActionMoveColumnLeft}, | |
| 16 | + | {"left from first column", 1, ActionMoveColumnLeft}, | |
| 17 | + | {"right from last column", 5, ActionMoveColumnRight}, | |
| 18 | + | } { | |
| 19 | + | t.Run(tc.name, func(t *testing.T) { | |
| 20 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 21 | + | w.FocusID(tc.from) | |
| 22 | + | c.applyAction(tc.action) | |
| 23 | + | wantColumns(t, w, 1, 2, 3, 4, 5) | |
| 24 | + | wantFocus(t, c, "M", tc.from) | |
| 25 | + | wantActive(t, c.cur().mon, 0) | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Inside a band the column still swaps with its neighbor. | |
| 31 | + | func TestCascadeMoveColumnInsideBandSwaps(t *testing.T) { | |
| 32 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 33 | + | w.FocusID(2) | |
| 34 | + | c.applyAction(ActionMoveColumnRight) | |
| 35 | + | wantColumns(t, w, 1, 3, 2, 4, 5) | |
| 36 | + | wantFocus(t, c, "M", 2) | |
| 37 | + | c.applyAction(ActionMoveColumnRight) | |
| 38 | + | wantColumns(t, w, 1, 3, 2, 4, 5) | |
| 39 | + | c.applyAction(ActionMoveColumnLeft) | |
| 40 | + | c.applyAction(ActionMoveColumnLeft) | |
| 41 | + | wantColumns(t, w, 2, 1, 3, 4, 5) | |
| 42 | + | c.applyAction(ActionMoveColumnLeft) | |
| 43 | + | wantColumns(t, w, 2, 1, 3, 4, 5) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // A neighbor monitor on the left screen's right side wins over an occupied | |
| 47 | + | // workspace there: focus-column-right at the band edge goes to the screen. | |
| 48 | + | func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) { | |
| 49 | + | c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600}) | |
| 50 | + | m := c.cur().mon | |
| 51 | + | m.SetOverflow(OverflowCascade) | |
| 52 | + | m.SetMaxColumns(3) | |
| 53 | + | m.AddWindow(1) | |
| 54 | + | m.AddWindow(2) | |
| 55 | + | m.Focus(1) | |
| 56 | + | m.AddWindow(3) // an occupied workspace to the right on L | |
| 57 | + | m.Focus(0) | |
| 58 | + | m.normalize() | |
| 59 | + | m.Current().FocusID(2) | |
| 60 | + | c.applyAction(ActionFocusColumnRight) | |
| 61 | + | wantFocus(t, c, "R", 0) | |
| 62 | + | wantActive(t, m, 0) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // With no monitor on that side, the occupied neighbor workspace is next. | |
| 66 | + | func TestCascadeFocusColumnEdgeSwitchesToOccupiedWorkspace(t *testing.T) { | |
| 67 | + | c := layoutCore(0, tile{"M", 0, 0, 900, 600}) | |
| 68 | + | m := c.cur().mon | |
| 69 | + | m.SetOverflow(OverflowCascade) | |
| 70 | + | m.SetMaxColumns(3) | |
| 71 | + | m.AddWindow(1) | |
| 72 | + | m.AddWindow(2) | |
| 73 | + | m.Focus(1) | |
| 74 | + | m.AddWindow(3) | |
| 75 | + | m.Focus(0) | |
| 76 | + | m.normalize() | |
| 77 | + | m.Current().FocusID(2) | |
| 78 | + | c.applyAction(ActionFocusColumnRight) | |
| 79 | + | wantActive(t, m, 1) | |
| 80 | + | wantFocus(t, c, "M", 3) | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // With no monitor on that side, move-column-left/right at a cascade band | |||
| 6 | + | // edge does nothing: the column never crosses into the adjacent band. | |||
| 7 | + | // Bands {1 2 3} {4 5}. | |||
| 8 | + | func TestCascadeMoveColumnAtBandEdgeDoesNothing(t *testing.T) { | |||
| 9 | + | for _, tc := range []struct { | |||
| 10 | + | name string | |||
| 11 | + | from WindowID | |||
| 12 | + | action Action | |||
| 13 | + | }{ | |||
| 14 | + | {"right from band end", 3, ActionMoveColumnRight}, | |||
| 15 | + | {"left from band start", 4, ActionMoveColumnLeft}, | |||
| 16 | + | {"left from first column", 1, ActionMoveColumnLeft}, | |||
| 17 | + | {"right from last column", 5, ActionMoveColumnRight}, | |||
| 18 | + | } { | |||
| 19 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 20 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |||
| 21 | + | w.FocusID(tc.from) | |||
| 22 | + | c.applyAction(tc.action) | |||
| 23 | + | wantColumns(t, w, 1, 2, 3, 4, 5) | |||
| 24 | + | wantFocus(t, c, "M", tc.from) | |||
| 25 | + | wantActive(t, c.cur().mon, 0) | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Inside a band the column still swaps with its neighbor. | |||
| 31 | + | func TestCascadeMoveColumnInsideBandSwaps(t *testing.T) { | |||
| 32 | + | c, w := cascadeMovesCore(t, 5, 3, false) | |||
| 33 | + | w.FocusID(2) | |||
| 34 | + | c.applyAction(ActionMoveColumnRight) | |||
| 35 | + | wantColumns(t, w, 1, 3, 2, 4, 5) | |||
| 36 | + | wantFocus(t, c, "M", 2) | |||
| 37 | + | c.applyAction(ActionMoveColumnRight) | |||
| 38 | + | wantColumns(t, w, 1, 3, 2, 4, 5) | |||
| 39 | + | c.applyAction(ActionMoveColumnLeft) | |||
| 40 | + | c.applyAction(ActionMoveColumnLeft) | |||
| 41 | + | wantColumns(t, w, 2, 1, 3, 4, 5) | |||
| 42 | + | c.applyAction(ActionMoveColumnLeft) | |||
| 43 | + | wantColumns(t, w, 2, 1, 3, 4, 5) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // A neighbor monitor on the left screen's right side wins over an occupied | |||
| 47 | + | // workspace there: focus-column-right at the band edge goes to the screen. | |||
| 48 | + | func TestCascadeFocusColumnEdgePrefersMonitorOverWorkspace(t *testing.T) { | |||
| 49 | + | c := layoutCore(0, tile{"L", 0, 0, 900, 600}, tile{"R", 900, 0, 900, 600}) | |||
| 50 | + | m := c.cur().mon | |||
| 51 | + | m.SetOverflow(OverflowCascade) | |||
| 52 | + | m.SetMaxColumns(3) | |||
| 53 | + | m.AddWindow(1) | |||
| 54 | + | m.AddWindow(2) | |||
| 55 | + | m.Focus(1) | |||
| 56 | + | m.AddWindow(3) // an occupied workspace to the right on L | |||
| 57 | + | m.Focus(0) | |||
| 58 | + | m.normalize() | |||
| 59 | + | m.Current().FocusID(2) | |||
| 60 | + | c.applyAction(ActionFocusColumnRight) | |||
| 61 | + | wantFocus(t, c, "R", 0) | |||
| 62 | + | wantActive(t, m, 0) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // With no monitor on that side, the occupied neighbor workspace is next. | |||
| 66 | + | func TestCascadeFocusColumnEdgeSwitchesToOccupiedWorkspace(t *testing.T) { | |||
| 67 | + | c := layoutCore(0, tile{"M", 0, 0, 900, 600}) | |||
| 68 | + | m := c.cur().mon | |||
| 69 | + | m.SetOverflow(OverflowCascade) | |||
| 70 | + | m.SetMaxColumns(3) | |||
| 71 | + | m.AddWindow(1) | |||
| 72 | + | m.AddWindow(2) | |||
| 73 | + | m.Focus(1) | |||
| 74 | + | m.AddWindow(3) | |||
| 75 | + | m.Focus(0) | |||
| 76 | + | m.normalize() | |||
| 77 | + | m.Current().FocusID(2) | |||
| 78 | + | c.applyAction(ActionFocusColumnRight) | |||
| 79 | + | wantActive(t, m, 1) | |||
| 80 | + | wantFocus(t, c, "M", 3) | |||
| 81 | + | } | |||
M internal/core/cascade_moves_test.go +75 -67
| @@ -52,6 +52,20 @@ func wantColumns(t *testing.T, w *Workspace, want ...WindowID) { | |||
| 52 | 52 | } | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | + | // wantBands checks the cascade bands, each by the first window of its | |
| 56 | + | // columns. | |
| 57 | + | func wantBands(t *testing.T, w *Workspace, want ...[]WindowID) { | |
| 58 | + | t.Helper() | |
| 59 | + | var got [][]WindowID | |
| 60 | + | w.walkBands(func(_, first, last int) bool { | |
| 61 | + | got = append(got, columnOrder(&Workspace{Columns: w.Columns[first : last+1]})) | |
| 62 | + | return true | |
| 63 | + | }) | |
| 64 | + | if !slices.EqualFunc(got, want, slices.Equal) { | |
| 65 | + | t.Fatalf("bands %v, want %v", got, want) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 55 | 69 | func wantFocus(t *testing.T, c *Core, screen string, id WindowID) { | |
| 56 | 70 | t.Helper() | |
| 57 | 71 | if got := c.cur().name(); got != screen { | |
| @@ -102,10 +116,10 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) { | |||
| 102 | 116 | } | |
| 103 | 117 | ||
| 104 | 118 | // With no monitor on that side, left/right never cross into the neighbor | |
| 105 | - | // band: at the band edge they step the workspace (the first has none before | |
| 106 | - | // it; the last is the trailing empty one). up/down cross bands, and | |
| 107 | - | // move-column still crosses in column order. | |
| 108 | - | func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) { | |
| 119 | + | // band: at the band edge focus steps the workspace (the first has none | |
| 120 | + | // before it; the last is the trailing empty one) and move-column does | |
| 121 | + | // nothing. up/down cross bands. | |
| 122 | + | func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveStays(t *testing.T) { | |
| 109 | 123 | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 110 | 124 | m := c.cur().mon | |
| 111 | 125 | w.FocusID(4) | |
| @@ -122,12 +136,12 @@ func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) { | |||
| 122 | 136 | m.Focus(0) | |
| 123 | 137 | w.FocusID(4) | |
| 124 | 138 | c.applyAction(ActionMoveColumnLeft) | |
| 125 | - | wantColumns(t, w, 1, 2, 4, 3, 5) | |
| 139 | + | wantColumns(t, w, 1, 2, 3, 4, 5) | |
| 126 | 140 | wantFocus(t, c, "M", 4) | |
| 127 | - | w.FocusID(4) | |
| 141 | + | w.FocusID(3) | |
| 128 | 142 | c.applyAction(ActionMoveColumnRight) | |
| 129 | 143 | wantColumns(t, w, 1, 2, 3, 4, 5) | |
| 130 | - | wantFocus(t, c, "M", 4) | |
| 144 | + | wantFocus(t, c, "M", 3) | |
| 131 | 145 | } | |
| 132 | 146 | ||
| 133 | 147 | // With one column per band, every column is at both edges of its band. | |
| @@ -150,92 +164,85 @@ func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) { | |||
| 150 | 164 | wantFocus(t, c, "M", 2) | |
| 151 | 165 | } | |
| 152 | 166 | ||
| 153 | - | // At the top or bottom of its column, move-window-up/down moves a cascade | |
| 154 | - | // column to the adjacent band, where focus-window-up/down goes; bands | |
| 155 | - | // {1 2 3} {4 5}. | |
| 167 | + | // At the top or bottom of its column, move-window-up/down sends a cascade | |
| 168 | + | // column to the adjacent band: it joins it when it has room, at its offset | |
| 169 | + | // in the band, else it makes a new band between the two. | |
| 156 | 170 | func TestCascadeMoveWindowCrossesBands(t *testing.T) { | |
| 157 | - | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 171 | + | c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} | |
| 172 | + | w.FocusID(2) | |
| 173 | + | c.applyAction(ActionMoveWindowDown) | |
| 174 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5}) | |
| 175 | + | wantFocus(t, c, "M", 2) | |
| 176 | + | c.applyAction(ActionMoveWindowUp) | |
| 177 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5}) | |
| 178 | + | wantFocus(t, c, "M", 2) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // A full band cannot take the column: a new band goes between the two, | |
| 182 | + | // and the one it left keeps the others. | |
| 183 | + | func TestCascadeMoveWindowIntoFullBandMakesBand(t *testing.T) { | |
| 184 | + | c, w := cascadeMovesCore(t, 6, 3, false) // {1 2 3} {4 5 6} | |
| 158 | 185 | w.FocusID(5) | |
| 159 | 186 | c.applyAction(ActionMoveWindowUp) | |
| 160 | - | wantColumns(t, w, 1, 5, 3, 4, 2) | |
| 187 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5}, []WindowID{4, 6}) | |
| 161 | 188 | wantFocus(t, c, "M", 5) | |
| 162 | - | c.applyAction(ActionMoveWindowDown) | |
| 163 | - | wantColumns(t, w, 1, 2, 3, 4, 5) | |
| 189 | + | // Alone in its band, it passes the full band above. | |
| 190 | + | c.applyAction(ActionMoveWindowUp) | |
| 191 | + | wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}, []WindowID{4, 6}) | |
| 164 | 192 | wantFocus(t, c, "M", 5) | |
| 165 | 193 | } | |
| 166 | 194 | ||
| 167 | - | // The last band is partial: the move clamps to its last column, and going | |
| 168 | - | // back up keeps the new offset, as focus does. | |
| 169 | - | func TestCascadeMoveWindowPartialBand(t *testing.T) { | |
| 170 | - | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 171 | - | w.FocusID(3) | |
| 195 | + | // Past the outer bands a column sharing its band starts a new one; a | |
| 196 | + | // column alone there does not move. | |
| 197 | + | func TestCascadeMoveWindowOuterBands(t *testing.T) { | |
| 198 | + | c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} | |
| 199 | + | w.FocusID(5) | |
| 172 | 200 | c.applyAction(ActionMoveWindowDown) | |
| 173 | - | wantColumns(t, w, 1, 2, 5, 4, 3) | |
| 174 | - | c.applyAction(ActionMoveWindowUp) | |
| 175 | - | wantColumns(t, w, 1, 3, 5, 4, 2) | |
| 176 | - | wantFocus(t, c, "M", 3) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // No band past the first or last: nothing moves. | |
| 180 | - | func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) { | |
| 181 | - | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 201 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5}) | |
| 202 | + | c.applyAction(ActionMoveWindowDown) | |
| 203 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5}) | |
| 182 | 204 | w.FocusID(1) | |
| 183 | 205 | c.applyAction(ActionMoveWindowUp) | |
| 206 | + | wantBands(t, w, []WindowID{1}, []WindowID{2, 3}, []WindowID{4}, []WindowID{5}) | |
| 184 | 207 | wantFocus(t, c, "M", 1) | |
| 185 | - | w.FocusID(4) | |
| 186 | - | c.applyAction(ActionMoveWindowDown) | |
| 187 | - | wantColumns(t, w, 1, 2, 3, 4, 5) | |
| 188 | - | wantFocus(t, c, "M", 4) | |
| 189 | 208 | } | |
| 190 | 209 | ||
| 191 | - | // A maximized column keeps its band to itself when it moves: it swaps | |
| 192 | - | // with the column of the band above and stays maximized. | |
| 193 | - | func TestCascadeMoveWindowKeepsMaximization(t *testing.T) { | |
| 194 | - | c, w := cascadeMovesCore(t, 5, 3, false) | |
| 195 | - | w.FocusID(5) | |
| 196 | - | w.ToggleFullWidth() // Bands {1 2 3} {4} {5}. | |
| 197 | - | c.applyAction(ActionMoveWindowUp) | |
| 198 | - | wantColumns(t, w, 1, 2, 3, 5, 4) | |
| 199 | - | wantFocus(t, c, "M", 5) | |
| 200 | - | if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 { | |
| 201 | - | t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4)) | |
| 202 | - | } | |
| 210 | + | // A band left empty goes away. | |
| 211 | + | func TestCascadeMoveWindowDropsEmptyBand(t *testing.T) { | |
| 212 | + | c, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4} | |
| 213 | + | w.FocusID(2) | |
| 214 | + | c.applyAction(ActionMoveWindowDown) | |
| 215 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2}) | |
| 216 | + | w.FocusID(4) | |
| 217 | + | c.applyAction(ActionMoveWindowUp) // At its offset in the band: first. | |
| 218 | + | wantBands(t, w, []WindowID{4, 1, 3}, []WindowID{2}) | |
| 203 | 219 | } | |
| 204 | 220 | ||
| 205 | - | // A column moving to or from a maximized column's band passes the whole | |
| 206 | - | // adjacent band. Bands follow column order, so a maximized column leaving | |
| 207 | - | // lets the bands around it pack together: {1} and {3 4} join. | |
| 208 | - | // Repeating the move never leaves the column stuck. | |
| 209 | - | func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) { | |
| 221 | + | // A maximized column keeps its band to itself: moving it, or moving a | |
| 222 | + | // column toward it, makes a new band; repeating the move passes it. | |
| 223 | + | func TestCascadeMoveWindowAndMaximizedBand(t *testing.T) { | |
| 210 | 224 | for _, tc := range []struct { | |
| 211 | 225 | name string | |
| 226 | + | n int | |
| 212 | 227 | max, focus WindowID | |
| 213 | 228 | action Action | |
| 214 | - | want []WindowID | |
| 215 | - | band int | |
| 229 | + | want [][]WindowID | |
| 216 | 230 | }{ | |
| 217 | - | // Bands {1} {2*} {3 4}. | |
| 218 | - | {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1}, | |
| 219 | - | {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0}, | |
| 220 | - | // Bands {1 2 3} {4*} {5}. | |
| 221 | - | {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2}, | |
| 222 | - | {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1}, | |
| 231 | + | // After maximizing 2: {1} {2*} {3 4}. | |
| 232 | + | {"maximized down", 4, 2, 2, ActionMoveWindowDown, [][]WindowID{{1}, {3, 4}, {2}}}, | |
| 233 | + | {"maximized up", 4, 2, 2, ActionMoveWindowUp, [][]WindowID{{2}, {1}, {3, 4}}}, | |
| 234 | + | // After maximizing 4: {1 2 3} {4*} {5}. | |
| 235 | + | {"into maximized below", 5, 4, 3, ActionMoveWindowDown, [][]WindowID{{1, 2}, {3}, {4}, {5}}}, | |
| 236 | + | {"into maximized above", 5, 4, 5, ActionMoveWindowUp, [][]WindowID{{1, 2, 3}, {5}, {4}}}, | |
| 223 | 237 | } { | |
| 224 | 238 | t.Run(tc.name, func(t *testing.T) { | |
| 225 | - | n := 5 | |
| 226 | - | if tc.max == 2 { | |
| 227 | - | n = 4 | |
| 228 | - | } | |
| 229 | - | c, w := cascadeMovesCore(t, n, 3, false) | |
| 239 | + | c, w := cascadeMovesCore(t, tc.n, 3, false) | |
| 230 | 240 | w.FocusID(tc.max) | |
| 231 | 241 | w.ToggleFullWidth() | |
| 232 | 242 | w.FocusID(tc.focus) | |
| 233 | 243 | c.applyAction(tc.action) | |
| 234 | - | wantColumns(t, w, tc.want...) | |
| 244 | + | wantBands(t, w, tc.want...) | |
| 235 | 245 | wantFocus(t, c, "M", tc.focus) | |
| 236 | - | if got := w.band(w.Focus); got != tc.band { | |
| 237 | - | t.Fatalf("band %d, want %d", got, tc.band) | |
| 238 | - | } | |
| 239 | 246 | if !w.Columns[w.columnOf(tc.max)].FullWidth { | |
| 240 | 247 | t.Fatal("maximization lost") | |
| 241 | 248 | } | |
| @@ -287,7 +294,8 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) { | |||
| 287 | 294 | t.Fatalf("stack %v, want [5 4]", got) | |
| 288 | 295 | } | |
| 289 | 296 | c.applyAction(ActionMoveWindowUp) | |
| 290 | - | wantColumns(t, w, 5, 2, 3, 1) | |
| 297 | + | // The full band above cannot take it, so it passes it. | |
| 298 | + | wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}) | |
| 291 | 299 | wantFocus(t, c, "M", 5) | |
| 292 | 300 | } | |
| 293 | 301 | ||
| @@ -52,6 +52,20 @@ func wantColumns(t *testing.T, w *Workspace, want ...WindowID) { | |||||
| 52 | } | 52 | } | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | + | // wantBands checks the cascade bands, each by the first window of its | |||
| 56 | + | // columns. | |||
| 57 | + | func wantBands(t *testing.T, w *Workspace, want ...[]WindowID) { | |||
| 58 | + | t.Helper() | |||
| 59 | + | var got [][]WindowID | |||
| 60 | + | w.walkBands(func(_, first, last int) bool { | |||
| 61 | + | got = append(got, columnOrder(&Workspace{Columns: w.Columns[first : last+1]})) | |||
| 62 | + | return true | |||
| 63 | + | }) | |||
| 64 | + | if !slices.EqualFunc(got, want, slices.Equal) { | |||
| 65 | + | t.Fatalf("bands %v, want %v", got, want) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 55 | func wantFocus(t *testing.T, c *Core, screen string, id WindowID) { | 69 | func wantFocus(t *testing.T, c *Core, screen string, id WindowID) { | ||
| 56 | t.Helper() | 70 | t.Helper() | ||
| 57 | if got := c.cur().name(); got != screen { | 71 | if got := c.cur().name(); got != screen { | ||
| @@ -102,10 +116,10 @@ func TestCascadeInsideBandStaysOnMonitor(t *testing.T) { | |||||
| 102 | } | 116 | } | ||
| 103 | 117 | ||||
| 104 | // With no monitor on that side, left/right never cross into the neighbor | 118 | // With no monitor on that side, left/right never cross into the neighbor | ||
| 105 | - | // band: at the band edge they step the workspace (the first has none before | 119 | + | // band: at the band edge focus steps the workspace (the first has none |
| 106 | - | // it; the last is the trailing empty one). up/down cross bands, and | 120 | + | // before it; the last is the trailing empty one) and move-column does |
| 107 | - | // move-column still crosses in column order. | 121 | + | // nothing. up/down cross bands. |
| 108 | - | func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) { | 122 | + | func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveStays(t *testing.T) { |
| 109 | c, w := cascadeMovesCore(t, 5, 3, false) | 123 | c, w := cascadeMovesCore(t, 5, 3, false) | ||
| 110 | m := c.cur().mon | 124 | m := c.cur().mon | ||
| 111 | w.FocusID(4) | 125 | w.FocusID(4) | ||
| @@ -122,12 +136,12 @@ func TestCascadeBandEdgeWithoutMonitorFocusStepsMoveCrosses(t *testing.T) { | |||||
| 122 | m.Focus(0) | 136 | m.Focus(0) | ||
| 123 | w.FocusID(4) | 137 | w.FocusID(4) | ||
| 124 | c.applyAction(ActionMoveColumnLeft) | 138 | c.applyAction(ActionMoveColumnLeft) | ||
| 125 | - | wantColumns(t, w, 1, 2, 4, 3, 5) | 139 | + | wantColumns(t, w, 1, 2, 3, 4, 5) |
| 126 | wantFocus(t, c, "M", 4) | 140 | wantFocus(t, c, "M", 4) | ||
| 127 | - | w.FocusID(4) | 141 | + | w.FocusID(3) |
| 128 | c.applyAction(ActionMoveColumnRight) | 142 | c.applyAction(ActionMoveColumnRight) | ||
| 129 | wantColumns(t, w, 1, 2, 3, 4, 5) | 143 | wantColumns(t, w, 1, 2, 3, 4, 5) | ||
| 130 | - | wantFocus(t, c, "M", 4) | 144 | + | wantFocus(t, c, "M", 3) |
| 131 | } | 145 | } | ||
| 132 | 146 | ||||
| 133 | // With one column per band, every column is at both edges of its band. | 147 | // With one column per band, every column is at both edges of its band. | ||
| @@ -150,92 +164,85 @@ func TestFixedMoveColumnKeepsLinearEdge(t *testing.T) { | |||||
| 150 | wantFocus(t, c, "M", 2) | 164 | wantFocus(t, c, "M", 2) | ||
| 151 | } | 165 | } | ||
| 152 | 166 | ||||
| 153 | - | // At the top or bottom of its column, move-window-up/down moves a cascade | 167 | + | // At the top or bottom of its column, move-window-up/down sends a cascade |
| 154 | - | // column to the adjacent band, where focus-window-up/down goes; bands | 168 | + | // column to the adjacent band: it joins it when it has room, at its offset |
| 155 | - | // {1 2 3} {4 5}. | 169 | + | // in the band, else it makes a new band between the two. |
| 156 | func TestCascadeMoveWindowCrossesBands(t *testing.T) { | 170 | func TestCascadeMoveWindowCrossesBands(t *testing.T) { | ||
| 157 | - | c, w := cascadeMovesCore(t, 5, 3, false) | 171 | + | c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} |
| 172 | + | w.FocusID(2) | |||
| 173 | + | c.applyAction(ActionMoveWindowDown) | |||
| 174 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2, 5}) | |||
| 175 | + | wantFocus(t, c, "M", 2) | |||
| 176 | + | c.applyAction(ActionMoveWindowUp) | |||
| 177 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4, 5}) | |||
| 178 | + | wantFocus(t, c, "M", 2) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // A full band cannot take the column: a new band goes between the two, | |||
| 182 | + | // and the one it left keeps the others. | |||
| 183 | + | func TestCascadeMoveWindowIntoFullBandMakesBand(t *testing.T) { | |||
| 184 | + | c, w := cascadeMovesCore(t, 6, 3, false) // {1 2 3} {4 5 6} | |||
| 158 | w.FocusID(5) | 185 | w.FocusID(5) | ||
| 159 | c.applyAction(ActionMoveWindowUp) | 186 | c.applyAction(ActionMoveWindowUp) | ||
| 160 | - | wantColumns(t, w, 1, 5, 3, 4, 2) | 187 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{5}, []WindowID{4, 6}) |
| 161 | wantFocus(t, c, "M", 5) | 188 | wantFocus(t, c, "M", 5) | ||
| 162 | - | c.applyAction(ActionMoveWindowDown) | 189 | + | // Alone in its band, it passes the full band above. |
| 163 | - | wantColumns(t, w, 1, 2, 3, 4, 5) | 190 | + | c.applyAction(ActionMoveWindowUp) |
| 191 | + | wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}, []WindowID{4, 6}) | |||
| 164 | wantFocus(t, c, "M", 5) | 192 | wantFocus(t, c, "M", 5) | ||
| 165 | } | 193 | } | ||
| 166 | 194 | ||||
| 167 | - | // The last band is partial: the move clamps to its last column, and going | 195 | + | // Past the outer bands a column sharing its band starts a new one; a |
| 168 | - | // back up keeps the new offset, as focus does. | 196 | + | // column alone there does not move. |
| 169 | - | func TestCascadeMoveWindowPartialBand(t *testing.T) { | 197 | + | func TestCascadeMoveWindowOuterBands(t *testing.T) { |
| 170 | - | c, w := cascadeMovesCore(t, 5, 3, false) | 198 | + | c, w := cascadeMovesCore(t, 5, 3, false) // {1 2 3} {4 5} |
| 171 | - | w.FocusID(3) | 199 | + | w.FocusID(5) |
| 172 | c.applyAction(ActionMoveWindowDown) | 200 | c.applyAction(ActionMoveWindowDown) | ||
| 173 | - | wantColumns(t, w, 1, 2, 5, 4, 3) | 201 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5}) |
| 174 | - | c.applyAction(ActionMoveWindowUp) | 202 | + | c.applyAction(ActionMoveWindowDown) |
| 175 | - | wantColumns(t, w, 1, 3, 5, 4, 2) | 203 | + | wantBands(t, w, []WindowID{1, 2, 3}, []WindowID{4}, []WindowID{5}) |
| 176 | - | wantFocus(t, c, "M", 3) | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // No band past the first or last: nothing moves. | |||
| 180 | - | func TestCascadeMoveWindowStopsAtOuterBands(t *testing.T) { | |||
| 181 | - | c, w := cascadeMovesCore(t, 5, 3, false) | |||
| 182 | w.FocusID(1) | 204 | w.FocusID(1) | ||
| 183 | c.applyAction(ActionMoveWindowUp) | 205 | c.applyAction(ActionMoveWindowUp) | ||
| 206 | + | wantBands(t, w, []WindowID{1}, []WindowID{2, 3}, []WindowID{4}, []WindowID{5}) | |||
| 184 | wantFocus(t, c, "M", 1) | 207 | wantFocus(t, c, "M", 1) | ||
| 185 | - | w.FocusID(4) | |||
| 186 | - | c.applyAction(ActionMoveWindowDown) | |||
| 187 | - | wantColumns(t, w, 1, 2, 3, 4, 5) | |||
| 188 | - | wantFocus(t, c, "M", 4) | |||
| 189 | } | 208 | } | ||
| 190 | 209 | ||||
| 191 | - | // A maximized column keeps its band to itself when it moves: it swaps | 210 | + | // A band left empty goes away. |
| 192 | - | // with the column of the band above and stays maximized. | 211 | + | func TestCascadeMoveWindowDropsEmptyBand(t *testing.T) { |
| 193 | - | func TestCascadeMoveWindowKeepsMaximization(t *testing.T) { | 212 | + | c, w := cascadeMovesCore(t, 4, 3, false) // {1 2 3} {4} |
| 194 | - | c, w := cascadeMovesCore(t, 5, 3, false) | 213 | + | w.FocusID(2) |
| 195 | - | w.FocusID(5) | 214 | + | c.applyAction(ActionMoveWindowDown) |
| 196 | - | w.ToggleFullWidth() // Bands {1 2 3} {4} {5}. | 215 | + | wantBands(t, w, []WindowID{1, 3}, []WindowID{4, 2}) |
| 197 | - | c.applyAction(ActionMoveWindowUp) | 216 | + | w.FocusID(4) |
| 198 | - | wantColumns(t, w, 1, 2, 3, 5, 4) | 217 | + | c.applyAction(ActionMoveWindowUp) // At its offset in the band: first. |
| 199 | - | wantFocus(t, c, "M", 5) | 218 | + | wantBands(t, w, []WindowID{4, 1, 3}, []WindowID{2}) |
| 200 | - | if !w.Columns[3].FullWidth || w.band(3) != 1 || w.band(4) != 2 { | |||
| 201 | - | t.Fatalf("maximized %t, bands %d %d", w.Columns[3].FullWidth, w.band(3), w.band(4)) | |||
| 202 | - | } | |||
| 203 | } | 219 | } | ||
| 204 | 220 | ||||
| 205 | - | // A column moving to or from a maximized column's band passes the whole | 221 | + | // A maximized column keeps its band to itself: moving it, or moving a |
| 206 | - | // adjacent band. Bands follow column order, so a maximized column leaving | 222 | + | // column toward it, makes a new band; repeating the move passes it. |
| 207 | - | // lets the bands around it pack together: {1} and {3 4} join. | 223 | + | func TestCascadeMoveWindowAndMaximizedBand(t *testing.T) { |
| 208 | - | // Repeating the move never leaves the column stuck. | |||
| 209 | - | func TestCascadeMoveWindowAcrossMaximizedBand(t *testing.T) { | |||
| 210 | for _, tc := range []struct { | 224 | for _, tc := range []struct { | ||
| 211 | name string | 225 | name string | ||
| 226 | + | n int | |||
| 212 | max, focus WindowID | 227 | max, focus WindowID | ||
| 213 | action Action | 228 | action Action | ||
| 214 | - | want []WindowID | 229 | + | want [][]WindowID |
| 215 | - | band int | |||
| 216 | }{ | 230 | }{ | ||
| 217 | - | // Bands {1} {2*} {3 4}. | 231 | + | // After maximizing 2: {1} {2*} {3 4}. |
| 218 | - | {"maximized down", 2, 2, ActionMoveWindowDown, []WindowID{1, 3, 4, 2}, 1}, | 232 | + | {"maximized down", 4, 2, 2, ActionMoveWindowDown, [][]WindowID{{1}, {3, 4}, {2}}}, |
| 219 | - | {"maximized up", 2, 2, ActionMoveWindowUp, []WindowID{2, 1, 3, 4}, 0}, | 233 | + | {"maximized up", 4, 2, 2, ActionMoveWindowUp, [][]WindowID{{2}, {1}, {3, 4}}}, |
| 220 | - | // Bands {1 2 3} {4*} {5}. | 234 | + | // After maximizing 4: {1 2 3} {4*} {5}. |
| 221 | - | {"into maximized below", 4, 3, ActionMoveWindowDown, []WindowID{1, 2, 4, 3, 5}, 2}, | 235 | + | {"into maximized below", 5, 4, 3, ActionMoveWindowDown, [][]WindowID{{1, 2}, {3}, {4}, {5}}}, |
| 222 | - | {"into maximized above", 4, 5, ActionMoveWindowUp, []WindowID{1, 2, 3, 5, 4}, 1}, | 236 | + | {"into maximized above", 5, 4, 5, ActionMoveWindowUp, [][]WindowID{{1, 2, 3}, {5}, {4}}}, |
| 223 | } { | 237 | } { | ||
| 224 | t.Run(tc.name, func(t *testing.T) { | 238 | t.Run(tc.name, func(t *testing.T) { | ||
| 225 | - | n := 5 | 239 | + | c, w := cascadeMovesCore(t, tc.n, 3, false) |
| 226 | - | if tc.max == 2 { | |||
| 227 | - | n = 4 | |||
| 228 | - | } | |||
| 229 | - | c, w := cascadeMovesCore(t, n, 3, false) | |||
| 230 | w.FocusID(tc.max) | 240 | w.FocusID(tc.max) | ||
| 231 | w.ToggleFullWidth() | 241 | w.ToggleFullWidth() | ||
| 232 | w.FocusID(tc.focus) | 242 | w.FocusID(tc.focus) | ||
| 233 | c.applyAction(tc.action) | 243 | c.applyAction(tc.action) | ||
| 234 | - | wantColumns(t, w, tc.want...) | 244 | + | wantBands(t, w, tc.want...) |
| 235 | wantFocus(t, c, "M", tc.focus) | 245 | wantFocus(t, c, "M", tc.focus) | ||
| 236 | - | if got := w.band(w.Focus); got != tc.band { | |||
| 237 | - | t.Fatalf("band %d, want %d", got, tc.band) | |||
| 238 | - | } | |||
| 239 | if !w.Columns[w.columnOf(tc.max)].FullWidth { | 246 | if !w.Columns[w.columnOf(tc.max)].FullWidth { | ||
| 240 | t.Fatal("maximization lost") | 247 | t.Fatal("maximization lost") | ||
| 241 | } | 248 | } | ||
| @@ -287,7 +294,8 @@ func TestCascadeMoveWindowStackedFirst(t *testing.T) { | |||||
| 287 | t.Fatalf("stack %v, want [5 4]", got) | 294 | t.Fatalf("stack %v, want [5 4]", got) | ||
| 288 | } | 295 | } | ||
| 289 | c.applyAction(ActionMoveWindowUp) | 296 | c.applyAction(ActionMoveWindowUp) | ||
| 290 | - | wantColumns(t, w, 5, 2, 3, 1) | 297 | + | // The full band above cannot take it, so it passes it. |
| 298 | + | wantBands(t, w, []WindowID{5}, []WindowID{1, 2, 3}) | |||
| 291 | wantFocus(t, c, "M", 5) | 299 | wantFocus(t, c, "M", 5) | ||
| 292 | } | 300 | } | ||
| 293 | 301 | ||||
M internal/core/cascade_test.go +2 -2
| @@ -128,10 +128,10 @@ func TestCascadeCompactionAndNavigation(t *testing.T) { | |||
| 128 | 128 | if w.View != 600 { | |
| 129 | 129 | t.Fatalf("changed max view=%d", w.View) | |
| 130 | 130 | } | |
| 131 | - | w.FocusID(2) | |
| 131 | + | w.FocusID(7) | |
| 132 | 132 | w.AddWindow(8) | |
| 133 | 133 | if w.Columns[len(w.Columns)-1].Windows[0] != 8 { | |
| 134 | - | t.Fatal("new column did not append") | |
| 134 | + | t.Fatal("new column did not join the focused last band") | |
| 135 | 135 | } | |
| 136 | 136 | w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580}) | |
| 137 | 137 | if w.View != w.band(w.Focus)*580 { | |
| @@ -128,10 +128,10 @@ func TestCascadeCompactionAndNavigation(t *testing.T) { | |||||
| 128 | if w.View != 600 { | 128 | if w.View != 600 { | ||
| 129 | t.Fatalf("changed max view=%d", w.View) | 129 | t.Fatalf("changed max view=%d", w.View) | ||
| 130 | } | 130 | } | ||
| 131 | - | w.FocusID(2) | 131 | + | w.FocusID(7) |
| 132 | w.AddWindow(8) | 132 | w.AddWindow(8) | ||
| 133 | if w.Columns[len(w.Columns)-1].Windows[0] != 8 { | 133 | if w.Columns[len(w.Columns)-1].Windows[0] != 8 { | ||
| 134 | - | t.Fatal("new column did not append") | 134 | + | t.Fatal("new column did not join the focused last band") |
| 135 | } | 135 | } | ||
| 136 | w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580}) | 136 | w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580}) | ||
| 137 | if w.View != w.band(w.Focus)*580 { | 137 | if w.View != w.band(w.Focus)*580 { | ||
M internal/core/column_cells_test.go +3 -5
| @@ -117,8 +117,6 @@ func TestGrowColumnNoop(t *testing.T) { | |||
| 117 | 117 | }{ | |
| 118 | 118 | {"cascade lone", OverflowCascade, 4, 1}, | |
| 119 | 119 | {"fixed lone", OverflowFixed, 4, 1}, | |
| 120 | - | {"cascade full band", OverflowCascade, 4, 4}, | |
| 121 | - | {"cascade full bands", OverflowCascade, 3, 6}, | |
| 122 | 120 | {"fixed full row", OverflowFixed, 4, 4}, | |
| 123 | 121 | {"fixed spiral", OverflowFixed, 2, 4}, | |
| 124 | 122 | {"max-columns 1", OverflowCascade, 1, 3}, | |
| @@ -287,10 +285,10 @@ func TestGrowColumnEdgeCases(t *testing.T) { | |||
| 287 | 285 | if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) { | |
| 288 | 286 | t.Fatalf("cells %v", got) | |
| 289 | 287 | } | |
| 290 | - | w.FocusID(1) // a full band | |
| 288 | + | w.FocusID(1) // A full band pushes a neighbor down (cascade_cycle_test). | |
| 291 | 289 | w.Apply(ActionCycleColumnWidth) | |
| 292 | - | if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, 6)) { | |
| 293 | - | t.Fatalf("cells %v", got) | |
| 290 | + | if got := cellsOf(w)[4:]; !reflect.DeepEqual(got, []int{0, 0}) { | |
| 291 | + | t.Fatalf("other band changed: cells %v", cellsOf(w)) | |
| 294 | 292 | } | |
| 295 | 293 | }) | |
| 296 | 294 | } | |
| @@ -117,8 +117,6 @@ func TestGrowColumnNoop(t *testing.T) { | |||||
| 117 | }{ | 117 | }{ | ||
| 118 | {"cascade lone", OverflowCascade, 4, 1}, | 118 | {"cascade lone", OverflowCascade, 4, 1}, | ||
| 119 | {"fixed lone", OverflowFixed, 4, 1}, | 119 | {"fixed lone", OverflowFixed, 4, 1}, | ||
| 120 | - | {"cascade full band", OverflowCascade, 4, 4}, | |||
| 121 | - | {"cascade full bands", OverflowCascade, 3, 6}, | |||
| 122 | {"fixed full row", OverflowFixed, 4, 4}, | 120 | {"fixed full row", OverflowFixed, 4, 4}, | ||
| 123 | {"fixed spiral", OverflowFixed, 2, 4}, | 121 | {"fixed spiral", OverflowFixed, 2, 4}, | ||
| 124 | {"max-columns 1", OverflowCascade, 1, 3}, | 122 | {"max-columns 1", OverflowCascade, 1, 3}, | ||
| @@ -287,10 +285,10 @@ func TestGrowColumnEdgeCases(t *testing.T) { | |||||
| 287 | if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) { | 285 | if got := cellsOf(w); !reflect.DeepEqual(got, []int{0, 0, 0, 0, 0, 0}) { | ||
| 288 | t.Fatalf("cells %v", got) | 286 | t.Fatalf("cells %v", got) | ||
| 289 | } | 287 | } | ||
| 290 | - | w.FocusID(1) // a full band | 288 | + | w.FocusID(1) // A full band pushes a neighbor down (cascade_cycle_test). |
| 291 | w.Apply(ActionCycleColumnWidth) | 289 | w.Apply(ActionCycleColumnWidth) | ||
| 292 | - | if got := cellsOf(w); !reflect.DeepEqual(got, make([]int, 6)) { | 290 | + | if got := cellsOf(w)[4:]; !reflect.DeepEqual(got, []int{0, 0}) { |
| 293 | - | t.Fatalf("cells %v", got) | 291 | + | t.Fatalf("other band changed: cells %v", cellsOf(w)) |
| 294 | } | 292 | } | ||
| 295 | }) | 293 | }) | ||
| 296 | } | 294 | } | ||
M internal/core/drag.go +2 -0
| @@ -461,6 +461,8 @@ func (c *Core) drop(id WindowID, t dropTarget) { | |||
| 461 | 461 | to := w.columnOf(t.anchor) | |
| 462 | 462 | // Their fields move with them, as MoveColumn. | |
| 463 | 463 | w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci] | |
| 464 | + | // Band starts are positional: the flag stays at its index. | |
| 465 | + | w.Columns[ci].BandStart, w.Columns[to].BandStart = w.Columns[to].BandStart, w.Columns[ci].BandStart | |
| 464 | 466 | w.FocusID(id) | |
| 465 | 467 | } else { | |
| 466 | 468 | cells := 0 | |
| @@ -461,6 +461,8 @@ func (c *Core) drop(id WindowID, t dropTarget) { | |||||
| 461 | to := w.columnOf(t.anchor) | 461 | to := w.columnOf(t.anchor) | ||
| 462 | // Their fields move with them, as MoveColumn. | 462 | // Their fields move with them, as MoveColumn. | ||
| 463 | w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci] | 463 | w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci] | ||
| 464 | + | // Band starts are positional: the flag stays at its index. | |||
| 465 | + | w.Columns[ci].BandStart, w.Columns[to].BandStart = w.Columns[to].BandStart, w.Columns[ci].BandStart | |||
| 464 | w.FocusID(id) | 466 | w.FocusID(id) | ||
| 465 | } else { | 467 | } else { | ||
| 466 | cells := 0 | 468 | cells := 0 | ||
M internal/core/layout.go +75 -7
| @@ -30,6 +30,13 @@ type Column struct { | |||
| 30 | 30 | // Windows order. They apply only while there is one per window and | |
| 31 | 31 | // they sum to 100; otherwise the rows share the height equally. | |
| 32 | 32 | Shares []int | |
| 33 | + | // BandStart marks the first column of a cascade band. It belongs to the | |
| 34 | + | // position, not the column: swaps leave it in place. See cascade.go. | |
| 35 | + | BandStart bool | |
| 36 | + | // pushedUp and pushedDown are the columns (by first window) that | |
| 37 | + | // maximize-column or cycle-column-width sent to the band above or | |
| 38 | + | // below; they come back when the column is restored. | |
| 39 | + | pushedUp, pushedDown []WindowID | |
| 33 | 40 | } | |
| 34 | 41 | type Placement struct { | |
| 35 | 42 | ID WindowID | |
| @@ -534,7 +541,12 @@ func (w *Workspace) addWindow(id WindowID, cells int) { | |||
| 534 | 541 | // addColumn places a new column like a new window and focuses it. | |
| 535 | 542 | func (w *Workspace) addColumn(col Column) { | |
| 536 | 543 | at := 0 | |
| 537 | - | if w.policy().equalCells { | |
| 544 | + | col.BandStart = false | |
| 545 | + | if w.policy().wraps { | |
| 546 | + | // After the focused band: it joins it when it has room, else it | |
| 547 | + | // starts a band of its own below it. | |
| 548 | + | at, col.BandStart = w.spawnIndex() | |
| 549 | + | } else if w.policy().equalCells { | |
| 538 | 550 | // Ordered overflow depends only on window order: new windows go last. | |
| 539 | 551 | at = len(w.Columns) | |
| 540 | 552 | } else if len(w.Columns) > 0 { | |
| @@ -642,7 +654,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||
| 642 | 654 | c.Windows = append(c.Windows[:j], c.Windows[j+1:]...) | |
| 643 | 655 | c.Shares = nil | |
| 644 | 656 | if len(c.Windows) == 0 { | |
| 645 | - | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | |
| 657 | + | w.deleteColumn(i) | |
| 646 | 658 | if len(w.Columns) == 0 { | |
| 647 | 659 | w.Focus = 0 | |
| 648 | 660 | w.View = 0 | |
| @@ -867,8 +879,13 @@ func (w *Workspace) MoveColumn(dir int) { | |||
| 867 | 879 | return | |
| 868 | 880 | } | |
| 869 | 881 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | |
| 882 | + | if w.policy().wraps && w.leavesBand(w.Focus+dir) { | |
| 883 | + | return // a cascade column never crosses into the adjacent band | |
| 884 | + | } | |
| 870 | 885 | i := w.Focus | |
| 871 | 886 | w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i] | |
| 887 | + | // Band starts are positional: the flag stays at its index. | |
| 888 | + | w.Columns[i].BandStart, w.Columns[i+dir].BandStart = w.Columns[i+dir].BandStart, w.Columns[i].BandStart | |
| 872 | 889 | w.Focus += dir | |
| 873 | 890 | w.scroll() | |
| 874 | 891 | } | |
| @@ -990,7 +1007,8 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 990 | 1007 | } | |
| 991 | 1008 | // Its windows leave this workspace's recent use with it. | |
| 992 | 1009 | w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) }) | |
| 993 | - | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | |
| 1010 | + | col.BandStart = false | |
| 1011 | + | w.deleteColumn(w.Focus) | |
| 994 | 1012 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | |
| 995 | 1013 | if len(w.Columns) == 0 { | |
| 996 | 1014 | w.View = 0 | |
| @@ -1006,6 +1024,11 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 1006 | 1024 | if w.policy().maximizeHides() && len(w.Columns) > 0 { | |
| 1007 | 1025 | w.unmaximize() | |
| 1008 | 1026 | } | |
| 1027 | + | // In a cascade the column goes before column at, in its band. | |
| 1028 | + | col.BandStart = false | |
| 1029 | + | if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart { | |
| 1030 | + | col.BandStart, w.Columns[at].BandStart = true, false | |
| 1031 | + | } | |
| 1009 | 1032 | w.Columns = slices.Insert(w.Columns, at, col) | |
| 1010 | 1033 | w.Focus = at | |
| 1011 | 1034 | w.scroll() | |
| @@ -1021,8 +1044,11 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||
| 1021 | 1044 | if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() { | |
| 1022 | 1045 | return | |
| 1023 | 1046 | } | |
| 1024 | - | w.unmaximize() | |
| 1025 | 1047 | defer w.scroll() | |
| 1048 | + | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | |
| 1049 | + | w.unmaximizeBand() | |
| 1050 | + | } | |
| 1051 | + | w.unmaximize() | |
| 1026 | 1052 | k := max(w.MaxColumns, 1) | |
| 1027 | 1053 | if w.policy().equalCells { | |
| 1028 | 1054 | if !w.policy().wraps && !w.laneRow() { | |
| @@ -1030,7 +1056,20 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||
| 1030 | 1056 | } | |
| 1031 | 1057 | first, last := w.laneBounds(w.laneOf(w.Focus)) | |
| 1032 | 1058 | n := last - first + 1 | |
| 1059 | + | pushed := false | |
| 1060 | + | if w.policy().wraps && cycle && n > 1 && n >= k && !w.unevenLane(first, last) { | |
| 1061 | + | // A full band makes room: a neighbor goes to the adjacent band. | |
| 1062 | + | w.pushNeighbor() | |
| 1063 | + | first, last = w.laneBounds(w.laneOf(w.Focus)) | |
| 1064 | + | n, pushed = last-first+1, true | |
| 1065 | + | } | |
| 1033 | 1066 | if n == 1 || n >= k { | |
| 1067 | + | if w.policy().wraps && cycle && !pushed && w.restoreBand() { | |
| 1068 | + | // End of the cycle: the pushed columns came back. | |
| 1069 | + | first, last = w.laneBounds(w.laneOf(w.Focus)) | |
| 1070 | + | w.setLaneCells(first, make([]int, last-first+1)) | |
| 1071 | + | return | |
| 1072 | + | } | |
| 1034 | 1073 | // Nothing to grow, but a lane left uneven (a column joined | |
| 1035 | 1074 | // it, or max-columns shrank) still cycles back to equal. | |
| 1036 | 1075 | if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) { | |
| @@ -1048,6 +1087,11 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||
| 1048 | 1087 | if d := w.donor(first, last, cells); d >= 0 { | |
| 1049 | 1088 | w.moveCell(first, cells, d, w.Focus) | |
| 1050 | 1089 | } else if cycle { | |
| 1090 | + | if w.policy().wraps && w.restoreBand() { | |
| 1091 | + | // End of the cycle: the pushed columns came back. | |
| 1092 | + | first, last = w.laneBounds(w.laneOf(w.Focus)) | |
| 1093 | + | n = last - first + 1 | |
| 1094 | + | } | |
| 1051 | 1095 | w.setLaneCells(first, make([]int, n)) | |
| 1052 | 1096 | } | |
| 1053 | 1097 | return | |
| @@ -1069,8 +1113,11 @@ func (w *Workspace) ShrinkColumn() { | |||
| 1069 | 1113 | if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() { | |
| 1070 | 1114 | return | |
| 1071 | 1115 | } | |
| 1072 | - | w.unmaximize() | |
| 1073 | 1116 | defer w.scroll() | |
| 1117 | + | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | |
| 1118 | + | w.unmaximizeBand() | |
| 1119 | + | } | |
| 1120 | + | w.unmaximize() | |
| 1074 | 1121 | k := max(w.MaxColumns, 1) | |
| 1075 | 1122 | if w.policy().equalCells { | |
| 1076 | 1123 | if !w.policy().wraps && !w.laneRow() { | |
| @@ -1138,6 +1185,12 @@ func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) { | |||
| 1138 | 1185 | return cells, false | |
| 1139 | 1186 | } | |
| 1140 | 1187 | ||
| 1188 | + | // unevenLane reports whether a column of the lane first..last has its | |
| 1189 | + | // own width. | |
| 1190 | + | func (w *Workspace) unevenLane(first, last int) bool { | |
| 1191 | + | return slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) | |
| 1192 | + | } | |
| 1193 | + | ||
| 1141 | 1194 | // setLaneCells stores cells on the lane's columns, from column first. | |
| 1142 | 1195 | func (w *Workspace) setLaneCells(first int, cells []int) { | |
| 1143 | 1196 | for i, c := range cells { | |
| @@ -1224,14 +1277,28 @@ func (w *Workspace) ToggleFullWidth() { | |||
| 1224 | 1277 | if len(w.Columns) == 0 || w.onFloat() { | |
| 1225 | 1278 | return | |
| 1226 | 1279 | } | |
| 1227 | - | if w.Columns[w.Focus].FullWidth { | |
| 1280 | + | switch { | |
| 1281 | + | case w.policy().wraps && w.Columns[w.Focus].FullWidth: | |
| 1282 | + | w.unmaximizeBand() | |
| 1283 | + | case w.policy().wraps: | |
| 1284 | + | // A cascade column takes a band of its own; the others stay seen. | |
| 1285 | + | w.isolateBand() | |
| 1286 | + | w.maximize(w.Focus) | |
| 1287 | + | case w.Columns[w.Focus].FullWidth: | |
| 1228 | 1288 | w.unmaximize() | |
| 1229 | - | } else { | |
| 1289 | + | default: | |
| 1230 | 1290 | w.maximize(w.Focus) | |
| 1231 | 1291 | } | |
| 1232 | 1292 | w.scroll() | |
| 1233 | 1293 | } | |
| 1234 | 1294 | ||
| 1295 | + | // unmaximizeBand unmaximizes the focused cascade column and brings back | |
| 1296 | + | // the columns its maximization sent to the adjacent bands. | |
| 1297 | + | func (w *Workspace) unmaximizeBand() { | |
| 1298 | + | w.Columns[w.Focus].FullWidth = false | |
| 1299 | + | w.restoreBand() | |
| 1300 | + | } | |
| 1301 | + | ||
| 1235 | 1302 | func (w *Workspace) ToggleFullscreen() { | |
| 1236 | 1303 | id, ok := w.Focused() | |
| 1237 | 1304 | if !ok { | |
| @@ -1612,6 +1679,7 @@ func (w *Workspace) scroll() { | |||
| 1612 | 1679 | before := w.View | |
| 1613 | 1680 | defer func() { w.retarget(before) }() | |
| 1614 | 1681 | if w.policy().wraps { | |
| 1682 | + | w.markBands() | |
| 1615 | 1683 | w.View = w.band(w.Focus) * w.Usable.H | |
| 1616 | 1684 | return | |
| 1617 | 1685 | } | |
| @@ -30,6 +30,13 @@ type Column struct { | |||||
| 30 | // Windows order. They apply only while there is one per window and | 30 | // Windows order. They apply only while there is one per window and | ||
| 31 | // they sum to 100; otherwise the rows share the height equally. | 31 | // they sum to 100; otherwise the rows share the height equally. | ||
| 32 | Shares []int | 32 | Shares []int | ||
| 33 | + | // BandStart marks the first column of a cascade band. It belongs to the | |||
| 34 | + | // position, not the column: swaps leave it in place. See cascade.go. | |||
| 35 | + | BandStart bool | |||
| 36 | + | // pushedUp and pushedDown are the columns (by first window) that | |||
| 37 | + | // maximize-column or cycle-column-width sent to the band above or | |||
| 38 | + | // below; they come back when the column is restored. | |||
| 39 | + | pushedUp, pushedDown []WindowID | |||
| 33 | } | 40 | } | ||
| 34 | type Placement struct { | 41 | type Placement struct { | ||
| 35 | ID WindowID | 42 | ID WindowID | ||
| @@ -534,7 +541,12 @@ func (w *Workspace) addWindow(id WindowID, cells int) { | |||||
| 534 | // addColumn places a new column like a new window and focuses it. | 541 | // addColumn places a new column like a new window and focuses it. | ||
| 535 | func (w *Workspace) addColumn(col Column) { | 542 | func (w *Workspace) addColumn(col Column) { | ||
| 536 | at := 0 | 543 | at := 0 | ||
| 537 | - | if w.policy().equalCells { | 544 | + | col.BandStart = false |
| 545 | + | if w.policy().wraps { | |||
| 546 | + | // After the focused band: it joins it when it has room, else it | |||
| 547 | + | // starts a band of its own below it. | |||
| 548 | + | at, col.BandStart = w.spawnIndex() | |||
| 549 | + | } else if w.policy().equalCells { | |||
| 538 | // Ordered overflow depends only on window order: new windows go last. | 550 | // Ordered overflow depends only on window order: new windows go last. | ||
| 539 | at = len(w.Columns) | 551 | at = len(w.Columns) | ||
| 540 | } else if len(w.Columns) > 0 { | 552 | } else if len(w.Columns) > 0 { | ||
| @@ -642,7 +654,7 @@ func (w *Workspace) RemoveWindow(id WindowID) { | |||||
| 642 | c.Windows = append(c.Windows[:j], c.Windows[j+1:]...) | 654 | c.Windows = append(c.Windows[:j], c.Windows[j+1:]...) | ||
| 643 | c.Shares = nil | 655 | c.Shares = nil | ||
| 644 | if len(c.Windows) == 0 { | 656 | if len(c.Windows) == 0 { | ||
| 645 | - | w.Columns = append(w.Columns[:i], w.Columns[i+1:]...) | 657 | + | w.deleteColumn(i) |
| 646 | if len(w.Columns) == 0 { | 658 | if len(w.Columns) == 0 { | ||
| 647 | w.Focus = 0 | 659 | w.Focus = 0 | ||
| 648 | w.View = 0 | 660 | w.View = 0 | ||
| @@ -867,8 +879,13 @@ func (w *Workspace) MoveColumn(dir int) { | |||||
| 867 | return | 879 | return | ||
| 868 | } | 880 | } | ||
| 869 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | 881 | if (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | ||
| 882 | + | if w.policy().wraps && w.leavesBand(w.Focus+dir) { | |||
| 883 | + | return // a cascade column never crosses into the adjacent band | |||
| 884 | + | } | |||
| 870 | i := w.Focus | 885 | i := w.Focus | ||
| 871 | w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i] | 886 | w.Columns[i], w.Columns[i+dir] = w.Columns[i+dir], w.Columns[i] | ||
| 887 | + | // Band starts are positional: the flag stays at its index. | |||
| 888 | + | w.Columns[i].BandStart, w.Columns[i+dir].BandStart = w.Columns[i+dir].BandStart, w.Columns[i].BandStart | |||
| 872 | w.Focus += dir | 889 | w.Focus += dir | ||
| 873 | w.scroll() | 890 | w.scroll() | ||
| 874 | } | 891 | } | ||
| @@ -990,7 +1007,8 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 990 | } | 1007 | } | ||
| 991 | // Its windows leave this workspace's recent use with it. | 1008 | // Its windows leave this workspace's recent use with it. | ||
| 992 | w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) }) | 1009 | w.recent = slices.DeleteFunc(w.recent, func(v WindowID) bool { return slices.Contains(col.Windows, v) }) | ||
| 993 | - | w.Columns = slices.Delete(w.Columns, w.Focus, w.Focus+1) | 1010 | + | col.BandStart = false |
| 1011 | + | w.deleteColumn(w.Focus) | |||
| 994 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | 1012 | w.Focus = min(w.Focus, max(len(w.Columns)-1, 0)) | ||
| 995 | if len(w.Columns) == 0 { | 1013 | if len(w.Columns) == 0 { | ||
| 996 | w.View = 0 | 1014 | w.View = 0 | ||
| @@ -1006,6 +1024,11 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||||
| 1006 | if w.policy().maximizeHides() && len(w.Columns) > 0 { | 1024 | if w.policy().maximizeHides() && len(w.Columns) > 0 { | ||
| 1007 | w.unmaximize() | 1025 | w.unmaximize() | ||
| 1008 | } | 1026 | } | ||
| 1027 | + | // In a cascade the column goes before column at, in its band. | |||
| 1028 | + | col.BandStart = false | |||
| 1029 | + | if w.policy().wraps && at < len(w.Columns) && w.Columns[at].BandStart { | |||
| 1030 | + | col.BandStart, w.Columns[at].BandStart = true, false | |||
| 1031 | + | } | |||
| 1009 | w.Columns = slices.Insert(w.Columns, at, col) | 1032 | w.Columns = slices.Insert(w.Columns, at, col) | ||
| 1010 | w.Focus = at | 1033 | w.Focus = at | ||
| 1011 | w.scroll() | 1034 | w.scroll() | ||
| @@ -1021,8 +1044,11 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||||
| 1021 | if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() { | 1044 | if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() { | ||
| 1022 | return | 1045 | return | ||
| 1023 | } | 1046 | } | ||
| 1024 | - | w.unmaximize() | |||
| 1025 | defer w.scroll() | 1047 | defer w.scroll() | ||
| 1048 | + | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | |||
| 1049 | + | w.unmaximizeBand() | |||
| 1050 | + | } | |||
| 1051 | + | w.unmaximize() | |||
| 1026 | k := max(w.MaxColumns, 1) | 1052 | k := max(w.MaxColumns, 1) | ||
| 1027 | if w.policy().equalCells { | 1053 | if w.policy().equalCells { | ||
| 1028 | if !w.policy().wraps && !w.laneRow() { | 1054 | if !w.policy().wraps && !w.laneRow() { | ||
| @@ -1030,7 +1056,20 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||||
| 1030 | } | 1056 | } | ||
| 1031 | first, last := w.laneBounds(w.laneOf(w.Focus)) | 1057 | first, last := w.laneBounds(w.laneOf(w.Focus)) | ||
| 1032 | n := last - first + 1 | 1058 | n := last - first + 1 | ||
| 1059 | + | pushed := false | |||
| 1060 | + | if w.policy().wraps && cycle && n > 1 && n >= k && !w.unevenLane(first, last) { | |||
| 1061 | + | // A full band makes room: a neighbor goes to the adjacent band. | |||
| 1062 | + | w.pushNeighbor() | |||
| 1063 | + | first, last = w.laneBounds(w.laneOf(w.Focus)) | |||
| 1064 | + | n, pushed = last-first+1, true | |||
| 1065 | + | } | |||
| 1033 | if n == 1 || n >= k { | 1066 | if n == 1 || n >= k { | ||
| 1067 | + | if w.policy().wraps && cycle && !pushed && w.restoreBand() { | |||
| 1068 | + | // End of the cycle: the pushed columns came back. | |||
| 1069 | + | first, last = w.laneBounds(w.laneOf(w.Focus)) | |||
| 1070 | + | w.setLaneCells(first, make([]int, last-first+1)) | |||
| 1071 | + | return | |||
| 1072 | + | } | |||
| 1034 | // Nothing to grow, but a lane left uneven (a column joined | 1073 | // Nothing to grow, but a lane left uneven (a column joined | ||
| 1035 | // it, or max-columns shrank) still cycles back to equal. | 1074 | // it, or max-columns shrank) still cycles back to equal. | ||
| 1036 | if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) { | 1075 | if cycle && slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) { | ||
| @@ -1048,6 +1087,11 @@ func (w *Workspace) GrowColumn(cycle bool) { | |||||
| 1048 | if d := w.donor(first, last, cells); d >= 0 { | 1087 | if d := w.donor(first, last, cells); d >= 0 { | ||
| 1049 | w.moveCell(first, cells, d, w.Focus) | 1088 | w.moveCell(first, cells, d, w.Focus) | ||
| 1050 | } else if cycle { | 1089 | } else if cycle { | ||
| 1090 | + | if w.policy().wraps && w.restoreBand() { | |||
| 1091 | + | // End of the cycle: the pushed columns came back. | |||
| 1092 | + | first, last = w.laneBounds(w.laneOf(w.Focus)) | |||
| 1093 | + | n = last - first + 1 | |||
| 1094 | + | } | |||
| 1051 | w.setLaneCells(first, make([]int, n)) | 1095 | w.setLaneCells(first, make([]int, n)) | ||
| 1052 | } | 1096 | } | ||
| 1053 | return | 1097 | return | ||
| @@ -1069,8 +1113,11 @@ func (w *Workspace) ShrinkColumn() { | |||||
| 1069 | if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() { | 1113 | if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() { | ||
| 1070 | return | 1114 | return | ||
| 1071 | } | 1115 | } | ||
| 1072 | - | w.unmaximize() | |||
| 1073 | defer w.scroll() | 1116 | defer w.scroll() | ||
| 1117 | + | if w.policy().wraps && w.Columns[w.Focus].FullWidth { | |||
| 1118 | + | w.unmaximizeBand() | |||
| 1119 | + | } | |||
| 1120 | + | w.unmaximize() | |||
| 1074 | k := max(w.MaxColumns, 1) | 1121 | k := max(w.MaxColumns, 1) | ||
| 1075 | if w.policy().equalCells { | 1122 | if w.policy().equalCells { | ||
| 1076 | if !w.policy().wraps && !w.laneRow() { | 1123 | if !w.policy().wraps && !w.laneRow() { | ||
| @@ -1138,6 +1185,12 @@ func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) { | |||||
| 1138 | return cells, false | 1185 | return cells, false | ||
| 1139 | } | 1186 | } | ||
| 1140 | 1187 | ||||
| 1188 | + | // unevenLane reports whether a column of the lane first..last has its | |||
| 1189 | + | // own width. | |||
| 1190 | + | func (w *Workspace) unevenLane(first, last int) bool { | |||
| 1191 | + | return slices.ContainsFunc(w.Columns[first:last+1], func(c Column) bool { return c.Cells != 0 }) | |||
| 1192 | + | } | |||
| 1193 | + | ||||
| 1141 | // setLaneCells stores cells on the lane's columns, from column first. | 1194 | // setLaneCells stores cells on the lane's columns, from column first. | ||
| 1142 | func (w *Workspace) setLaneCells(first int, cells []int) { | 1195 | func (w *Workspace) setLaneCells(first int, cells []int) { | ||
| 1143 | for i, c := range cells { | 1196 | for i, c := range cells { | ||
| @@ -1224,14 +1277,28 @@ func (w *Workspace) ToggleFullWidth() { | |||||
| 1224 | if len(w.Columns) == 0 || w.onFloat() { | 1277 | if len(w.Columns) == 0 || w.onFloat() { | ||
| 1225 | return | 1278 | return | ||
| 1226 | } | 1279 | } | ||
| 1227 | - | if w.Columns[w.Focus].FullWidth { | 1280 | + | switch { |
| 1281 | + | case w.policy().wraps && w.Columns[w.Focus].FullWidth: | |||
| 1282 | + | w.unmaximizeBand() | |||
| 1283 | + | case w.policy().wraps: | |||
| 1284 | + | // A cascade column takes a band of its own; the others stay seen. | |||
| 1285 | + | w.isolateBand() | |||
| 1286 | + | w.maximize(w.Focus) | |||
| 1287 | + | case w.Columns[w.Focus].FullWidth: | |||
| 1228 | w.unmaximize() | 1288 | w.unmaximize() | ||
| 1229 | - | } else { | 1289 | + | default: |
| 1230 | w.maximize(w.Focus) | 1290 | w.maximize(w.Focus) | ||
| 1231 | } | 1291 | } | ||
| 1232 | w.scroll() | 1292 | w.scroll() | ||
| 1233 | } | 1293 | } | ||
| 1234 | 1294 | ||||
| 1295 | + | // unmaximizeBand unmaximizes the focused cascade column and brings back | |||
| 1296 | + | // the columns its maximization sent to the adjacent bands. | |||
| 1297 | + | func (w *Workspace) unmaximizeBand() { | |||
| 1298 | + | w.Columns[w.Focus].FullWidth = false | |||
| 1299 | + | w.restoreBand() | |||
| 1300 | + | } | |||
| 1301 | + | ||||
| 1235 | func (w *Workspace) ToggleFullscreen() { | 1302 | func (w *Workspace) ToggleFullscreen() { | ||
| 1236 | id, ok := w.Focused() | 1303 | id, ok := w.Focused() | ||
| 1237 | if !ok { | 1304 | if !ok { | ||
| @@ -1612,6 +1679,7 @@ func (w *Workspace) scroll() { | |||||
| 1612 | before := w.View | 1679 | before := w.View | ||
| 1613 | defer func() { w.retarget(before) }() | 1680 | defer func() { w.retarget(before) }() | ||
| 1614 | if w.policy().wraps { | 1681 | if w.policy().wraps { | ||
| 1682 | + | w.markBands() | |||
| 1615 | w.View = w.band(w.Focus) * w.Usable.H | 1683 | w.View = w.band(w.Focus) * w.Usable.H | ||
| 1616 | return | 1684 | return | ||
| 1617 | } | 1685 | } | ||
M internal/core/monitor.go +4 -0
| @@ -575,6 +575,10 @@ func (m *Monitor) applyNamed() { | |||
| 575 | 575 | if before != w.Overflow && w.policy().equalCells { | |
| 576 | 576 | w.clearMaximized() | |
| 577 | 577 | } | |
| 578 | + | if before != w.Overflow { | |
| 579 | + | // Cascade bands start over from the column order. | |
| 580 | + | w.forgetBands() | |
| 581 | + | } | |
| 578 | 582 | // The view offset belongs to the old content axis: SetMaxColumns | |
| 579 | 583 | // scrolls back to the focus along the new one. | |
| 580 | 584 | if w.policy().content != axis { | |
| @@ -575,6 +575,10 @@ func (m *Monitor) applyNamed() { | |||||
| 575 | if before != w.Overflow && w.policy().equalCells { | 575 | if before != w.Overflow && w.policy().equalCells { | ||
| 576 | w.clearMaximized() | 576 | w.clearMaximized() | ||
| 577 | } | 577 | } | ||
| 578 | + | if before != w.Overflow { | |||
| 579 | + | // Cascade bands start over from the column order. | |||
| 580 | + | w.forgetBands() | |||
| 581 | + | } | |||
| 578 | // The view offset belongs to the old content axis: SetMaxColumns | 582 | // The view offset belongs to the old content axis: SetMaxColumns | ||
| 579 | // scrolls back to the focus along the new one. | 583 | // scrolls back to the focus along the new one. | ||
| 580 | if w.policy().content != axis { | 584 | if w.policy().content != axis { | ||
M internal/core/overview_cascade_test.go +1 -0
| @@ -121,6 +121,7 @@ func TestOverviewCascadeOtherLanes(t *testing.T) { | |||
| 121 | 121 | // Five bands (13 columns): the ones two lanes away are off the area. | |
| 122 | 122 | m = cascadeOverview() | |
| 123 | 123 | w := m.Current() | |
| 124 | + | w.FocusID(10) // New windows join the focused band, the last one. | |
| 124 | 125 | for id := WindowID(11); id <= 16; id++ { | |
| 125 | 126 | w.AddWindow(id) | |
| 126 | 127 | } | |
| @@ -121,6 +121,7 @@ func TestOverviewCascadeOtherLanes(t *testing.T) { | |||||
| 121 | // Five bands (13 columns): the ones two lanes away are off the area. | 121 | // Five bands (13 columns): the ones two lanes away are off the area. | ||
| 122 | m = cascadeOverview() | 122 | m = cascadeOverview() | ||
| 123 | w := m.Current() | 123 | w := m.Current() | ||
| 124 | + | w.FocusID(10) // New windows join the focused band, the last one. | |||
| 124 | for id := WindowID(11); id <= 16; id++ { | 125 | for id := WindowID(11); id <= 16; id++ { | ||
| 125 | w.AddWindow(id) | 126 | w.AddWindow(id) | ||
| 126 | } | 127 | } | ||
M internal/core/workspace_axis_test.go +23 -40
| @@ -50,30 +50,23 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) { | |||
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | // An occupied workspace wins over a monitor on that side, so the opposite | |
| 54 | - | // key brings focus back; the monitor wins over the trailing empty | |
| 55 | - | // workspace, so it stays reachable. | |
| 56 | - | func TestCascadeFocusColumnStepsWorkspaceBeforeMonitor(t *testing.T) { | |
| 53 | + | // A monitor on that side wins over an occupied workspace, so the opposite | |
| 54 | + | // key brings focus back; the workspace is reached only with no monitor. | |
| 55 | + | func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) { | |
| 57 | 56 | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 58 | 57 | m.Current().FocusID(23) | |
| 59 | 58 | c.applyAction(ActionFocusColumnRight) | |
| 60 | - | wantActive(t, m, 2) | |
| 61 | - | if got := c.cur().name(); got != "M" { | |
| 62 | - | t.Fatalf("focused screen %q, want M", got) | |
| 63 | - | } | |
| 64 | - | // Back with the opposite key, from the first column of workspace 3. | |
| 65 | - | m.Current().FocusID(31) | |
| 59 | + | wantFocus(t, c, "R", 0) | |
| 60 | + | wantActive(t, m, 1) | |
| 61 | + | // Back with the opposite key. | |
| 66 | 62 | c.applyAction(ActionFocusColumnLeft) | |
| 63 | + | wantFocus(t, c, "M", 23) | |
| 67 | 64 | wantActive(t, m, 1) | |
| 68 | - | if got := c.cur().name(); got != "M" { | |
| 69 | - | t.Fatalf("focused screen %q, want M", got) | |
| 70 | - | } | |
| 71 | - | // Past the first workspace: the monitor on the left. | |
| 72 | - | m.Focus(0) | |
| 73 | - | m.Current().FocusID(11) | |
| 65 | + | // The first column of a band: the monitor on the left. | |
| 66 | + | m.Current().FocusID(24) | |
| 74 | 67 | c.applyAction(ActionFocusColumnLeft) | |
| 75 | 68 | wantFocus(t, c, "L", 0) | |
| 76 | - | wantActive(t, m, 0) | |
| 69 | + | wantActive(t, m, 1) | |
| 77 | 70 | // From the last occupied workspace: the monitor on the right, not the | |
| 78 | 71 | // trailing empty workspace. | |
| 79 | 72 | c.focusScreen = 1 | |
| @@ -99,10 +92,9 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) | |||
| 99 | 92 | wantActive(t, m, 2) | |
| 100 | 93 | } | |
| 101 | 94 | ||
| 102 | - | // The live regression: one monitor on the left only. Right steps to the | |
| 103 | - | // next workspace, and left from its empty band comes back to the previous | |
| 104 | - | // workspace instead of leaving for that monitor. | |
| 105 | - | func TestCascadeStepRightThenLeftComesBack(t *testing.T) { | |
| 95 | + | // One monitor on the left only. Right steps to the next workspace; left | |
| 96 | + | // from its empty band leaves for that monitor, which comes first. | |
| 97 | + | func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { | |
| 106 | 98 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | |
| 107 | 99 | m := c.cur().mon | |
| 108 | 100 | m.SetOverflow(OverflowCascade) | |
| @@ -112,8 +104,8 @@ func TestCascadeStepRightThenLeftComesBack(t *testing.T) { | |||
| 112 | 104 | c.applyAction(ActionFocusColumnRight) | |
| 113 | 105 | wantActive(t, m, 1) | |
| 114 | 106 | c.applyAction(ActionFocusColumnLeft) | |
| 115 | - | wantFocus(t, c, "M", 5) | |
| 116 | - | wantActive(t, m, 0) | |
| 107 | + | wantFocus(t, c, "L", 0) | |
| 108 | + | wantActive(t, m, 1) | |
| 117 | 109 | } | |
| 118 | 110 | ||
| 119 | 111 | // AC3: up/down cross bands and stop at the last, with no workspace change. | |
| @@ -196,34 +188,25 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) { | |||
| 196 | 188 | } | |
| 197 | 189 | } | |
| 198 | 190 | ||
| 199 | - | // A covering fullscreen tile follows the same order: the occupied workspace | |
| 200 | - | // before the monitor on that side, which comes back only past the last one. | |
| 201 | - | func TestCascadeFullscreenStepsWorkspaceBeforeMonitor(t *testing.T) { | |
| 191 | + | // A covering fullscreen tile follows the same order: the monitor on that | |
| 192 | + | // side before the occupied workspace. | |
| 193 | + | func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) { | |
| 202 | 194 | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 203 | 195 | w := m.Current() | |
| 204 | 196 | w.FocusID(23) | |
| 205 | 197 | w.ToggleFullscreen() | |
| 206 | 198 | c.applyAction(ActionFocusColumnRight) | |
| 207 | - | wantActive(t, m, 2) | |
| 208 | - | if got := c.cur().name(); got != "M" { | |
| 209 | - | t.Fatalf("focused screen %q, want M", got) | |
| 210 | - | } | |
| 199 | + | wantFocus(t, c, "R", 0) | |
| 200 | + | wantActive(t, m, 1) | |
| 211 | 201 | if w.fullscreen != 23 { | |
| 212 | 202 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | |
| 213 | 203 | } | |
| 214 | - | last := m.Current() | |
| 215 | - | last.FocusID(35) | |
| 216 | - | last.ToggleFullscreen() | |
| 217 | - | c.applyAction(ActionFocusColumnRight) | |
| 218 | - | wantFocus(t, c, "R", 0) | |
| 219 | - | if last.fullscreen != 35 { | |
| 220 | - | t.Fatalf("fullscreen %d, want 35", last.fullscreen) | |
| 221 | - | } | |
| 222 | 204 | } | |
| 223 | 205 | ||
| 224 | - | // A workspace holding only a float is occupied: it comes before the monitor. | |
| 206 | + | // A workspace holding only a float is occupied: with no monitor on that | |
| 207 | + | // side, it comes before the trailing empty one. | |
| 225 | 208 | func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) { | |
| 226 | - | c, m := axisCore(t, true, OverflowCascade, 3) | |
| 209 | + | c, m := axisCore(t, false, OverflowCascade, 3) | |
| 227 | 210 | m.Focus(3) | |
| 228 | 211 | m.AddFloating(9, 100, 100) | |
| 229 | 212 | m.Focus(2) | |
| @@ -50,30 +50,23 @@ func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) { | |||||
| 50 | } | 50 | } | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | - | // An occupied workspace wins over a monitor on that side, so the opposite | 53 | + | // A monitor on that side wins over an occupied workspace, so the opposite |
| 54 | - | // key brings focus back; the monitor wins over the trailing empty | 54 | + | // key brings focus back; the workspace is reached only with no monitor. |
| 55 | - | // workspace, so it stays reachable. | 55 | + | func TestCascadeFocusColumnGoesToMonitorBeforeWorkspace(t *testing.T) { |
| 56 | - | func TestCascadeFocusColumnStepsWorkspaceBeforeMonitor(t *testing.T) { | |||
| 57 | c, m := axisCore(t, true, OverflowCascade, 3) | 56 | c, m := axisCore(t, true, OverflowCascade, 3) | ||
| 58 | m.Current().FocusID(23) | 57 | m.Current().FocusID(23) | ||
| 59 | c.applyAction(ActionFocusColumnRight) | 58 | c.applyAction(ActionFocusColumnRight) | ||
| 60 | - | wantActive(t, m, 2) | 59 | + | wantFocus(t, c, "R", 0) |
| 61 | - | if got := c.cur().name(); got != "M" { | 60 | + | wantActive(t, m, 1) |
| 62 | - | t.Fatalf("focused screen %q, want M", got) | 61 | + | // Back with the opposite key. |
| 63 | - | } | |||
| 64 | - | // Back with the opposite key, from the first column of workspace 3. | |||
| 65 | - | m.Current().FocusID(31) | |||
| 66 | c.applyAction(ActionFocusColumnLeft) | 62 | c.applyAction(ActionFocusColumnLeft) | ||
| 63 | + | wantFocus(t, c, "M", 23) | |||
| 67 | wantActive(t, m, 1) | 64 | wantActive(t, m, 1) | ||
| 68 | - | if got := c.cur().name(); got != "M" { | 65 | + | // The first column of a band: the monitor on the left. |
| 69 | - | t.Fatalf("focused screen %q, want M", got) | 66 | + | m.Current().FocusID(24) |
| 70 | - | } | |||
| 71 | - | // Past the first workspace: the monitor on the left. | |||
| 72 | - | m.Focus(0) | |||
| 73 | - | m.Current().FocusID(11) | |||
| 74 | c.applyAction(ActionFocusColumnLeft) | 67 | c.applyAction(ActionFocusColumnLeft) | ||
| 75 | wantFocus(t, c, "L", 0) | 68 | wantFocus(t, c, "L", 0) | ||
| 76 | - | wantActive(t, m, 0) | 69 | + | wantActive(t, m, 1) |
| 77 | // From the last occupied workspace: the monitor on the right, not the | 70 | // From the last occupied workspace: the monitor on the right, not the | ||
| 78 | // trailing empty workspace. | 71 | // trailing empty workspace. | ||
| 79 | c.focusScreen = 1 | 72 | c.focusScreen = 1 | ||
| @@ -99,10 +92,9 @@ func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) | |||||
| 99 | wantActive(t, m, 2) | 92 | wantActive(t, m, 2) | ||
| 100 | } | 93 | } | ||
| 101 | 94 | ||||
| 102 | - | // The live regression: one monitor on the left only. Right steps to the | 95 | + | // One monitor on the left only. Right steps to the next workspace; left |
| 103 | - | // next workspace, and left from its empty band comes back to the previous | 96 | + | // from its empty band leaves for that monitor, which comes first. |
| 104 | - | // workspace instead of leaving for that monitor. | 97 | + | func TestCascadeStepRightThenLeftLeavesForMonitor(t *testing.T) { |
| 105 | - | func TestCascadeStepRightThenLeftComesBack(t *testing.T) { | |||
| 106 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | 98 | c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600}) | ||
| 107 | m := c.cur().mon | 99 | m := c.cur().mon | ||
| 108 | m.SetOverflow(OverflowCascade) | 100 | m.SetOverflow(OverflowCascade) | ||
| @@ -112,8 +104,8 @@ func TestCascadeStepRightThenLeftComesBack(t *testing.T) { | |||||
| 112 | c.applyAction(ActionFocusColumnRight) | 104 | c.applyAction(ActionFocusColumnRight) | ||
| 113 | wantActive(t, m, 1) | 105 | wantActive(t, m, 1) | ||
| 114 | c.applyAction(ActionFocusColumnLeft) | 106 | c.applyAction(ActionFocusColumnLeft) | ||
| 115 | - | wantFocus(t, c, "M", 5) | 107 | + | wantFocus(t, c, "L", 0) |
| 116 | - | wantActive(t, m, 0) | 108 | + | wantActive(t, m, 1) |
| 117 | } | 109 | } | ||
| 118 | 110 | ||||
| 119 | // AC3: up/down cross bands and stop at the last, with no workspace change. | 111 | // AC3: up/down cross bands and stop at the last, with no workspace change. | ||
| @@ -196,34 +188,25 @@ func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) { | |||||
| 196 | } | 188 | } | ||
| 197 | } | 189 | } | ||
| 198 | 190 | ||||
| 199 | - | // A covering fullscreen tile follows the same order: the occupied workspace | 191 | + | // A covering fullscreen tile follows the same order: the monitor on that |
| 200 | - | // before the monitor on that side, which comes back only past the last one. | 192 | + | // side before the occupied workspace. |
| 201 | - | func TestCascadeFullscreenStepsWorkspaceBeforeMonitor(t *testing.T) { | 193 | + | func TestCascadeFullscreenGoesToMonitorBeforeWorkspace(t *testing.T) { |
| 202 | c, m := axisCore(t, true, OverflowCascade, 3) | 194 | c, m := axisCore(t, true, OverflowCascade, 3) | ||
| 203 | w := m.Current() | 195 | w := m.Current() | ||
| 204 | w.FocusID(23) | 196 | w.FocusID(23) | ||
| 205 | w.ToggleFullscreen() | 197 | w.ToggleFullscreen() | ||
| 206 | c.applyAction(ActionFocusColumnRight) | 198 | c.applyAction(ActionFocusColumnRight) | ||
| 207 | - | wantActive(t, m, 2) | 199 | + | wantFocus(t, c, "R", 0) |
| 208 | - | if got := c.cur().name(); got != "M" { | 200 | + | wantActive(t, m, 1) |
| 209 | - | t.Fatalf("focused screen %q, want M", got) | |||
| 210 | - | } | |||
| 211 | if w.fullscreen != 23 { | 201 | if w.fullscreen != 23 { | ||
| 212 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | 202 | t.Fatalf("fullscreen %d, want 23", w.fullscreen) | ||
| 213 | } | 203 | } | ||
| 214 | - | last := m.Current() | |||
| 215 | - | last.FocusID(35) | |||
| 216 | - | last.ToggleFullscreen() | |||
| 217 | - | c.applyAction(ActionFocusColumnRight) | |||
| 218 | - | wantFocus(t, c, "R", 0) | |||
| 219 | - | if last.fullscreen != 35 { | |||
| 220 | - | t.Fatalf("fullscreen %d, want 35", last.fullscreen) | |||
| 221 | - | } | |||
| 222 | } | 204 | } | ||
| 223 | 205 | ||||
| 224 | - | // A workspace holding only a float is occupied: it comes before the monitor. | 206 | + | // A workspace holding only a float is occupied: with no monitor on that |
| 207 | + | // side, it comes before the trailing empty one. | |||
| 225 | func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) { | 208 | func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) { | ||
| 226 | - | c, m := axisCore(t, true, OverflowCascade, 3) | 209 | + | c, m := axisCore(t, false, OverflowCascade, 3) |
| 227 | m.Focus(3) | 210 | m.Focus(3) | ||
| 228 | m.AddFloating(9, 100, 100) | 211 | m.AddFloating(9, 100, 100) | ||
| 229 | m.Focus(2) | 212 | m.Focus(2) | ||