feat(layout): expand columns and focus by screen position in fixed overflow (#42)
* feat(layout): expand the focused column with cmd+r in fixed overflow * fix(layout): focus stacked columns up and down in fixed overflow * fix(layout): focus left and right by screen position in fixed overflow * fix(layout): keep expanded fixed columns on screen and across fullscreen * test(wayland): wait for the capture stop after output removal
- commit
- 8e13c904a37d7482883f64df162c18ad8d5ccb8e
- author
- bnema · · 2026-09-28 11:49 UTC
- committer
- GitHub · · 2026-09-28 11:49 UTC
- parent
- 8544ffa
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +402 -22
M docs/config.md +4 -4
| @@ -27,7 +27,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 27 | 27 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | |
| 28 | 28 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | |
| 29 | 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |
| 30 | - | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` | | |
| 30 | + | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | |
| 31 | 31 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 32 | 32 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | |
| 33 | 33 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | |
| @@ -76,9 +76,9 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||
| 76 | 76 | | `close-window` | Close the focused window | | |
| 77 | 77 | | `toggle-fullscreen` | Fullscreen the focused window | | |
| 78 | 78 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | |
| 79 | - | | `cycle-column-width` | Step through `layout.presets` | | |
| 80 | - | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor | | |
| 81 | - | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace | | |
| 79 | + | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | | |
| 80 | + | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | | |
| 81 | + | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first | | |
| 82 | 82 | | `move-column-left/right` | Move the focused column | | |
| 83 | 83 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | |
| 84 | 84 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | |
| @@ -27,7 +27,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 27 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | 27 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | ||
| 28 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | 28 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | ||
| 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | ||
| 30 | - | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` | | 30 | + | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | |
| 31 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 31 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 32 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | 32 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | ||
| 33 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | 33 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | ||
| @@ -76,9 +76,9 @@ HDR requires DRM HDR connector properties, suitable KMS planes, and Vulkan fp16 | |||||
| 76 | | `close-window` | Close the focused window | | 76 | | `close-window` | Close the focused window | | ||
| 77 | | `toggle-fullscreen` | Fullscreen the focused window | | 77 | | `toggle-fullscreen` | Fullscreen the focused window | | ||
| 78 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | 78 | | `maximize-column` | Toggle full usable width for the focused column, preserving gaps and its saved width; in fixed overflow, other columns are hidden until focus moves or it is toggled off. On a window that made itself fullscreen (e.g. a Wine app at monitor size), it first returns the window to its column | | ||
| 79 | - | | `cycle-column-width` | Step through `layout.presets` | | 79 | + | | `cycle-column-width` | Step through `layout.presets`. In fixed overflow, toggle the focused column to `max-columns - 1` cells in place; the other columns stack on each side in the last cell. One column is expanded at a time | |
| 80 | - | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor | | 80 | + | | `focus-column-left/right` | Focus the neighbor column; at the edge, the neighbor monitor. In fixed overflow, the neighbor on screen | |
| 81 | - | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace | | 81 | + | | `focus-window-up/down` | Focus in the column; past the edge, the next workspace. In fixed overflow, the column on screen above or below comes first | |
| 82 | | `move-column-left/right` | Move the focused column | | 82 | | `move-column-left/right` | Move the focused column | | ||
| 83 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | 83 | | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column | | ||
| 84 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | 84 | | `focus-workspace <N>` / `focus-workspace-up/down` | Show a numbered or neighbor workspace | | ||
M internal/adapters/wayland/screencopy_test.go +18 -8
| @@ -590,14 +590,24 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||
| 590 | 590 | <-p.events | |
| 591 | 591 | } | |
| 592 | 592 | commands <- ports.SetOutputs{} | |
| 593 | - | if !s.display.Do(func() {}) { | |
| 594 | - | t.Fatal("display stopped") | |
| 595 | - | } | |
| 596 | - | if err := c.Roundtrip(); err != nil { | |
| 597 | - | t.Fatal(err) | |
| 598 | - | } | |
| 599 | - | if got := <-p.events; !reflect.DeepEqual(got, []uint32{5}) { | |
| 600 | - | t.Fatalf("stopped: %v", got) | |
| 593 | + | // The command goroutine applies SetOutputs on its own schedule: round | |
| 594 | + | // trip until the stopped event arrives. | |
| 595 | + | deadline := time.Now().Add(5 * time.Second) | |
| 596 | + | for { | |
| 597 | + | if err := c.Roundtrip(); err != nil { | |
| 598 | + | t.Fatal(err) | |
| 599 | + | } | |
| 600 | + | select { | |
| 601 | + | case got := <-p.events: | |
| 602 | + | if !reflect.DeepEqual(got, []uint32{5}) { | |
| 603 | + | t.Fatalf("stopped: %v", got) | |
| 604 | + | } | |
| 605 | + | return | |
| 606 | + | case <-time.After(10 * time.Millisecond): | |
| 607 | + | } | |
| 608 | + | if time.Now().After(deadline) { | |
| 609 | + | t.Fatal("session not stopped") | |
| 610 | + | } | |
| 601 | 611 | } | |
| 602 | 612 | } | |
| 603 | 613 | ||
| @@ -590,14 +590,24 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||||
| 590 | <-p.events | 590 | <-p.events | ||
| 591 | } | 591 | } | ||
| 592 | commands <- ports.SetOutputs{} | 592 | commands <- ports.SetOutputs{} | ||
| 593 | - | if !s.display.Do(func() {}) { | 593 | + | // The command goroutine applies SetOutputs on its own schedule: round |
| 594 | - | t.Fatal("display stopped") | 594 | + | // trip until the stopped event arrives. |
| 595 | - | } | 595 | + | deadline := time.Now().Add(5 * time.Second) |
| 596 | - | if err := c.Roundtrip(); err != nil { | 596 | + | for { |
| 597 | - | t.Fatal(err) | 597 | + | if err := c.Roundtrip(); err != nil { |
| 598 | - | } | 598 | + | t.Fatal(err) |
| 599 | - | if got := <-p.events; !reflect.DeepEqual(got, []uint32{5}) { | 599 | + | } |
| 600 | - | t.Fatalf("stopped: %v", got) | 600 | + | select { |
| 601 | + | case got := <-p.events: | |||
| 602 | + | if !reflect.DeepEqual(got, []uint32{5}) { | |||
| 603 | + | t.Fatalf("stopped: %v", got) | |||
| 604 | + | } | |||
| 605 | + | return | |||
| 606 | + | case <-time.After(10 * time.Millisecond): | |||
| 607 | + | } | |||
| 608 | + | if time.Now().After(deadline) { | |||
| 609 | + | t.Fatal("session not stopped") | |||
| 610 | + | } | |||
| 601 | } | 611 | } | ||
| 602 | } | 612 | } | ||
| 603 | 613 | ||||
M internal/core/action.go +1 -1
| @@ -124,12 +124,12 @@ func (c *Core) applyAction(a Action) Effect { | |||
| 124 | 124 | return Effect{} | |
| 125 | 125 | case ActionFocusColumnLeft, ActionFocusColumnRight: | |
| 126 | 126 | w := c.cur().mon.Current() | |
| 127 | - | edge := len(w.Columns) == 0 || (a == ActionFocusColumnLeft && w.Focus == 0) || (a == ActionFocusColumnRight && w.Focus == len(w.Columns)-1) | |
| 128 | 127 | if a == ActionFocusColumnLeft { | |
| 129 | 128 | dir = -1 | |
| 130 | 129 | } else { | |
| 131 | 130 | dir = 1 | |
| 132 | 131 | } | |
| 132 | + | edge := w.columnToward(dir) < 0 | |
| 133 | 133 | if i := c.neighbor(dir); edge && i >= 0 { | |
| 134 | 134 | c.focusScreen = i | |
| 135 | 135 | return Effect{} | |
| @@ -124,12 +124,12 @@ func (c *Core) applyAction(a Action) Effect { | |||||
| 124 | return Effect{} | 124 | return Effect{} | ||
| 125 | case ActionFocusColumnLeft, ActionFocusColumnRight: | 125 | case ActionFocusColumnLeft, ActionFocusColumnRight: | ||
| 126 | w := c.cur().mon.Current() | 126 | w := c.cur().mon.Current() | ||
| 127 | - | edge := len(w.Columns) == 0 || (a == ActionFocusColumnLeft && w.Focus == 0) || (a == ActionFocusColumnRight && w.Focus == len(w.Columns)-1) | |||
| 128 | if a == ActionFocusColumnLeft { | 127 | if a == ActionFocusColumnLeft { | ||
| 129 | dir = -1 | 128 | dir = -1 | ||
| 130 | } else { | 129 | } else { | ||
| 131 | dir = 1 | 130 | dir = 1 | ||
| 132 | } | 131 | } | ||
| 132 | + | edge := w.columnToward(dir) < 0 | |||
| 133 | if i := c.neighbor(dir); edge && i >= 0 { | 133 | if i := c.neighbor(dir); edge && i >= 0 { | ||
| 134 | c.focusScreen = i | 134 | c.focusScreen = i | ||
| 135 | return Effect{} | 135 | return Effect{} | ||
M internal/core/layout.go +157 -7
| @@ -83,7 +83,10 @@ type Column struct { | |||
| 83 | 83 | Windows []WindowID | |
| 84 | 84 | Width Width | |
| 85 | 85 | FullWidth bool | |
| 86 | - | Focus int | |
| 86 | + | // Expanded is the fixed-overflow wide column: max-columns - 1 cells, | |
| 87 | + | // the others share the last one. One column at most has it. | |
| 88 | + | Expanded bool | |
| 89 | + | Focus int | |
| 87 | 90 | // Slot is the declared column number (workspace.<name>.column.N) of a | |
| 88 | 91 | // slot window; 0 for normal columns. | |
| 89 | 92 | Slot int | |
| @@ -158,9 +161,10 @@ type origPlace struct { | |||
| 158 | 161 | col, row, slot int | |
| 159 | 162 | // stacked holds the windows left in the column: the window returns to | |
| 160 | 163 | // its row in the column that still holds one of them. | |
| 161 | - | stacked []WindowID | |
| 162 | - | width Width | |
| 163 | - | float *Float | |
| 164 | + | stacked []WindowID | |
| 165 | + | width Width | |
| 166 | + | expanded bool | |
| 167 | + | float *Float | |
| 164 | 168 | } | |
| 165 | 169 | ||
| 166 | 170 | // Float is a floating window and its client size, logical. | |
| @@ -382,16 +386,41 @@ func (w *Workspace) FocusColumn(dir int) { | |||
| 382 | 386 | w.floatFocus = false | |
| 383 | 387 | return | |
| 384 | 388 | } | |
| 385 | - | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | |
| 389 | + | if i := w.columnToward(dir); i >= 0 { | |
| 386 | 390 | if w.Overflow == OverflowFixed { | |
| 387 | 391 | w.Columns[w.Focus].FullWidth = false | |
| 388 | 392 | } | |
| 389 | - | w.Focus += dir | |
| 393 | + | w.Focus = i | |
| 390 | 394 | w.scroll() | |
| 391 | 395 | } | |
| 392 | 396 | } | |
| 393 | 397 | ||
| 398 | + | // columnToward returns the column focus-column-left/right (dir -1/1) goes | |
| 399 | + | // to, or -1 at the edge. Scroll overflow follows the column order; fixed | |
| 400 | + | // overflow stacks columns (spiral, expanded strips), so it follows the | |
| 401 | + | // screen: the column on that side. | |
| 402 | + | func (w *Workspace) columnToward(dir int) int { | |
| 403 | + | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | |
| 404 | + | return -1 | |
| 405 | + | } | |
| 406 | + | if w.onScreenFocus() { | |
| 407 | + | return w.screenNeighbor(dir, 0) | |
| 408 | + | } | |
| 409 | + | if i := w.Focus + dir; i >= 0 && i < len(w.Columns) { | |
| 410 | + | return i | |
| 411 | + | } | |
| 412 | + | return -1 | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // onScreenFocus reports whether directional focus follows the screen: | |
| 416 | + | // fixed overflow with every column visible. | |
| 417 | + | func (w *Workspace) onScreenFocus() bool { | |
| 418 | + | return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth | |
| 419 | + | } | |
| 420 | + | ||
| 394 | 421 | // FocusWindow moves focus inside the column; false means it was already at the edge. | |
| 422 | + | // Fixed overflow also stacks columns (spiral, expanded strips): past the | |
| 423 | + | // column edge, focus goes to the column on screen above or below. | |
| 395 | 424 | func (w *Workspace) FocusWindow(dir int) bool { | |
| 396 | 425 | if w.floatFocus { | |
| 397 | 426 | w.floatFocus = false | |
| @@ -406,8 +435,52 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||
| 406 | 435 | w.scroll() | |
| 407 | 436 | return true | |
| 408 | 437 | } | |
| 438 | + | if !w.onScreenFocus() { | |
| 439 | + | return false | |
| 440 | + | } | |
| 441 | + | if i := w.screenNeighbor(0, dir); i >= 0 { | |
| 442 | + | w.Focus = i | |
| 443 | + | w.Columns[i].Focus = 0 | |
| 444 | + | if dir < 0 { | |
| 445 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |
| 446 | + | } | |
| 447 | + | return true | |
| 448 | + | } | |
| 409 | 449 | return false | |
| 410 | 450 | } | |
| 451 | + | ||
| 452 | + | // screenNeighbor returns the column on screen next to the focused one, left | |
| 453 | + | // or right (dx -1/1) or above or below (dy -1/1), or -1. The closest wins, | |
| 454 | + | // then the one sharing the longest edge, then the nearest in column order. | |
| 455 | + | func (w *Workspace) screenNeighbor(dx, dy int) int { | |
| 456 | + | rects := w.columnRects() | |
| 457 | + | cur := rects[w.Focus] | |
| 458 | + | best, bestDist, bestOverlap := -1, 0, 0 | |
| 459 | + | for i, r := range rects { | |
| 460 | + | var overlap, dist int | |
| 461 | + | switch { | |
| 462 | + | case dx < 0: | |
| 463 | + | overlap, dist = min(cur.Y+cur.H, r.Y+r.H)-max(cur.Y, r.Y), cur.X-(r.X+r.W) | |
| 464 | + | case dx > 0: | |
| 465 | + | overlap, dist = min(cur.Y+cur.H, r.Y+r.H)-max(cur.Y, r.Y), r.X-(cur.X+cur.W) | |
| 466 | + | case dy < 0: | |
| 467 | + | overlap, dist = min(cur.X+cur.W, r.X+r.W)-max(cur.X, r.X), cur.Y-(r.Y+r.H) | |
| 468 | + | default: | |
| 469 | + | overlap, dist = min(cur.X+cur.W, r.X+r.W)-max(cur.X, r.X), r.Y-(cur.Y+cur.H) | |
| 470 | + | } | |
| 471 | + | if i == w.Focus || overlap <= 0 || dist < 0 { | |
| 472 | + | continue | |
| 473 | + | } | |
| 474 | + | closer := best >= 0 && (dist < bestDist || dist == bestDist && (overlap > bestOverlap || | |
| 475 | + | overlap == bestOverlap && abs(i-w.Focus) < abs(best-w.Focus))) | |
| 476 | + | if best < 0 || closer { | |
| 477 | + | best, bestDist, bestOverlap = i, dist, overlap | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | return best | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func abs(n int) int { return max(n, -n) } | |
| 411 | 484 | func (w *Workspace) MoveColumn(dir int) { | |
| 412 | 485 | if w.floatFocus { | |
| 413 | 486 | return | |
| @@ -426,7 +499,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 426 | 499 | return Column{}, false | |
| 427 | 500 | } | |
| 428 | 501 | col := w.Columns[w.Focus] | |
| 429 | - | col.Slot = 0 | |
| 502 | + | col.Slot, col.Expanded = 0, false | |
| 430 | 503 | if slices.Contains(col.Windows, w.fullscreen) { | |
| 431 | 504 | w.fullscreen = 0 | |
| 432 | 505 | } | |
| @@ -451,7 +524,13 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 451 | 524 | w.scroll() | |
| 452 | 525 | } | |
| 453 | 526 | ||
| 527 | + | // CycleWidth steps the focused column through the presets. Fixed overflow | |
| 528 | + | // ignores presets: it toggles the expanded column instead. | |
| 454 | 529 | func (w *Workspace) CycleWidth() { | |
| 530 | + | if w.Overflow == OverflowFixed { | |
| 531 | + | w.toggleExpanded() | |
| 532 | + | return | |
| 533 | + | } | |
| 455 | 534 | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { | |
| 456 | 535 | return | |
| 457 | 536 | } | |
| @@ -472,6 +551,20 @@ func (w *Workspace) CycleWidth() { | |||
| 472 | 551 | w.scroll() | |
| 473 | 552 | } | |
| 474 | 553 | ||
| 554 | + | // toggleExpanded makes the focused column the expanded one, or shrinks it | |
| 555 | + | // back when it already is. One column at most is expanded. | |
| 556 | + | func (w *Workspace) toggleExpanded() { | |
| 557 | + | if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.floatFocus { | |
| 558 | + | return | |
| 559 | + | } | |
| 560 | + | on := !w.Columns[w.Focus].Expanded | |
| 561 | + | for i := range w.Columns { | |
| 562 | + | w.Columns[i].Expanded = false | |
| 563 | + | } | |
| 564 | + | w.Columns[w.Focus].Expanded = on | |
| 565 | + | w.Columns[w.Focus].FullWidth = false | |
| 566 | + | } | |
| 567 | + | ||
| 475 | 568 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | |
| 476 | 569 | func (w *Workspace) ToggleFullWidth() { | |
| 477 | 570 | if len(w.Columns) == 0 || w.floatFocus { | |
| @@ -637,6 +730,9 @@ func (w *Workspace) columnRects() []Rect { | |||
| 637 | 730 | return rects | |
| 638 | 731 | } | |
| 639 | 732 | k := max(w.MaxColumns, 1) | |
| 733 | + | if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 { | |
| 734 | + | return w.expandedRects(e, k, y, h) | |
| 735 | + | } | |
| 640 | 736 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 641 | 737 | return rects | |
| 642 | 738 | } | |
| @@ -649,6 +745,60 @@ func (w *Workspace) columnRects() []Rect { | |||
| 649 | 745 | return rects | |
| 650 | 746 | } | |
| 651 | 747 | ||
| 748 | + | // expandedRects places expanded column e over k-1 cells in its place. The | |
| 749 | + | // columns before it stack in a strip on its left, those after it on its | |
| 750 | + | // right; the two strips share the last cell. | |
| 751 | + | func (w *Workspace) expandedRects(e, k, y, h int) []Rect { | |
| 752 | + | g := w.gap() | |
| 753 | + | cell := max((w.Usable.W-g*(k+1))/k, 0) | |
| 754 | + | wide := (k-1)*cell + (k-2)*g | |
| 755 | + | rest := max(w.Usable.W-2*g-wide-g, 0) | |
| 756 | + | before, after := w.Columns[:e], w.Columns[e+1:] | |
| 757 | + | leftW, rightW := rest, rest | |
| 758 | + | if len(before) > 0 && len(after) > 0 { | |
| 759 | + | leftW = max((rest-g)/2, 0) | |
| 760 | + | rightW = max(rest-g-leftW, 0) | |
| 761 | + | } | |
| 762 | + | rects := make([]Rect, 0, len(w.Columns)) | |
| 763 | + | x := w.Usable.X + g | |
| 764 | + | if len(before) > 0 { | |
| 765 | + | rects = append(rects, stackRects(Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)...) | |
| 766 | + | x += leftW + g | |
| 767 | + | } | |
| 768 | + | rects = append(rects, Rect{X: x, Y: y, W: wide, H: h}) | |
| 769 | + | if len(after) > 0 { | |
| 770 | + | rects = append(rects, stackRects(Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)...) | |
| 771 | + | } | |
| 772 | + | // Gaps wider than a narrow output would push columns past its edge. | |
| 773 | + | right := w.Usable.X + w.Usable.W | |
| 774 | + | for i := range rects { | |
| 775 | + | rects[i].X = min(rects[i].X, right) | |
| 776 | + | rects[i].W = min(rects[i].W, right-rects[i].X) | |
| 777 | + | } | |
| 778 | + | return rects | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | // stackRects cuts r in n rows with a gap between them; the last row takes | |
| 782 | + | // the rounding remainder. Rows past the bottom (gaps taller than r) are | |
| 783 | + | // clamped to it, like windows stacked in a column. | |
| 784 | + | func stackRects(r Rect, n, gap int) []Rect { | |
| 785 | + | rows := make([]Rect, n) | |
| 786 | + | avail := max(r.H-(n-1)*gap, 0) | |
| 787 | + | height := avail / n | |
| 788 | + | yy, bottom := r.Y, r.Y+r.H | |
| 789 | + | for i := range rows { | |
| 790 | + | hh := height | |
| 791 | + | if i == n-1 { | |
| 792 | + | hh = avail - height*(n-1) | |
| 793 | + | } | |
| 794 | + | yy = min(yy, bottom) | |
| 795 | + | hh = min(hh, bottom-yy) | |
| 796 | + | rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh} | |
| 797 | + | yy += hh + gap | |
| 798 | + | } | |
| 799 | + | return rows | |
| 800 | + | } | |
| 801 | + | ||
| 652 | 802 | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | |
| 653 | 803 | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | |
| 654 | 804 | if vertical { | |
| @@ -83,7 +83,10 @@ type Column struct { | |||||
| 83 | Windows []WindowID | 83 | Windows []WindowID | ||
| 84 | Width Width | 84 | Width Width | ||
| 85 | FullWidth bool | 85 | FullWidth bool | ||
| 86 | - | Focus int | 86 | + | // Expanded is the fixed-overflow wide column: max-columns - 1 cells, |
| 87 | + | // the others share the last one. One column at most has it. | |||
| 88 | + | Expanded bool | |||
| 89 | + | Focus int | |||
| 87 | // Slot is the declared column number (workspace.<name>.column.N) of a | 90 | // Slot is the declared column number (workspace.<name>.column.N) of a | ||
| 88 | // slot window; 0 for normal columns. | 91 | // slot window; 0 for normal columns. | ||
| 89 | Slot int | 92 | Slot int | ||
| @@ -158,9 +161,10 @@ type origPlace struct { | |||||
| 158 | col, row, slot int | 161 | col, row, slot int | ||
| 159 | // stacked holds the windows left in the column: the window returns to | 162 | // stacked holds the windows left in the column: the window returns to | ||
| 160 | // its row in the column that still holds one of them. | 163 | // its row in the column that still holds one of them. | ||
| 161 | - | stacked []WindowID | 164 | + | stacked []WindowID |
| 162 | - | width Width | 165 | + | width Width |
| 163 | - | float *Float | 166 | + | expanded bool |
| 167 | + | float *Float | |||
| 164 | } | 168 | } | ||
| 165 | 169 | ||||
| 166 | // Float is a floating window and its client size, logical. | 170 | // Float is a floating window and its client size, logical. | ||
| @@ -382,16 +386,41 @@ func (w *Workspace) FocusColumn(dir int) { | |||||
| 382 | w.floatFocus = false | 386 | w.floatFocus = false | ||
| 383 | return | 387 | return | ||
| 384 | } | 388 | } | ||
| 385 | - | if len(w.Columns) > 0 && (dir == -1 || dir == 1) && w.Focus+dir >= 0 && w.Focus+dir < len(w.Columns) { | 389 | + | if i := w.columnToward(dir); i >= 0 { |
| 386 | if w.Overflow == OverflowFixed { | 390 | if w.Overflow == OverflowFixed { | ||
| 387 | w.Columns[w.Focus].FullWidth = false | 391 | w.Columns[w.Focus].FullWidth = false | ||
| 388 | } | 392 | } | ||
| 389 | - | w.Focus += dir | 393 | + | w.Focus = i |
| 390 | w.scroll() | 394 | w.scroll() | ||
| 391 | } | 395 | } | ||
| 392 | } | 396 | } | ||
| 393 | 397 | ||||
| 398 | + | // columnToward returns the column focus-column-left/right (dir -1/1) goes | |||
| 399 | + | // to, or -1 at the edge. Scroll overflow follows the column order; fixed | |||
| 400 | + | // overflow stacks columns (spiral, expanded strips), so it follows the | |||
| 401 | + | // screen: the column on that side. | |||
| 402 | + | func (w *Workspace) columnToward(dir int) int { | |||
| 403 | + | if len(w.Columns) == 0 || (dir != -1 && dir != 1) { | |||
| 404 | + | return -1 | |||
| 405 | + | } | |||
| 406 | + | if w.onScreenFocus() { | |||
| 407 | + | return w.screenNeighbor(dir, 0) | |||
| 408 | + | } | |||
| 409 | + | if i := w.Focus + dir; i >= 0 && i < len(w.Columns) { | |||
| 410 | + | return i | |||
| 411 | + | } | |||
| 412 | + | return -1 | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // onScreenFocus reports whether directional focus follows the screen: | |||
| 416 | + | // fixed overflow with every column visible. | |||
| 417 | + | func (w *Workspace) onScreenFocus() bool { | |||
| 418 | + | return w.Overflow == OverflowFixed && w.fullscreen == 0 && !w.Columns[w.Focus].FullWidth | |||
| 419 | + | } | |||
| 420 | + | ||||
| 394 | // FocusWindow moves focus inside the column; false means it was already at the edge. | 421 | // FocusWindow moves focus inside the column; false means it was already at the edge. | ||
| 422 | + | // Fixed overflow also stacks columns (spiral, expanded strips): past the | |||
| 423 | + | // column edge, focus goes to the column on screen above or below. | |||
| 395 | func (w *Workspace) FocusWindow(dir int) bool { | 424 | func (w *Workspace) FocusWindow(dir int) bool { | ||
| 396 | if w.floatFocus { | 425 | if w.floatFocus { | ||
| 397 | w.floatFocus = false | 426 | w.floatFocus = false | ||
| @@ -406,8 +435,52 @@ func (w *Workspace) FocusWindow(dir int) bool { | |||||
| 406 | w.scroll() | 435 | w.scroll() | ||
| 407 | return true | 436 | return true | ||
| 408 | } | 437 | } | ||
| 438 | + | if !w.onScreenFocus() { | |||
| 439 | + | return false | |||
| 440 | + | } | |||
| 441 | + | if i := w.screenNeighbor(0, dir); i >= 0 { | |||
| 442 | + | w.Focus = i | |||
| 443 | + | w.Columns[i].Focus = 0 | |||
| 444 | + | if dir < 0 { | |||
| 445 | + | w.Columns[i].Focus = len(w.Columns[i].Windows) - 1 | |||
| 446 | + | } | |||
| 447 | + | return true | |||
| 448 | + | } | |||
| 409 | return false | 449 | return false | ||
| 410 | } | 450 | } | ||
| 451 | + | ||||
| 452 | + | // screenNeighbor returns the column on screen next to the focused one, left | |||
| 453 | + | // or right (dx -1/1) or above or below (dy -1/1), or -1. The closest wins, | |||
| 454 | + | // then the one sharing the longest edge, then the nearest in column order. | |||
| 455 | + | func (w *Workspace) screenNeighbor(dx, dy int) int { | |||
| 456 | + | rects := w.columnRects() | |||
| 457 | + | cur := rects[w.Focus] | |||
| 458 | + | best, bestDist, bestOverlap := -1, 0, 0 | |||
| 459 | + | for i, r := range rects { | |||
| 460 | + | var overlap, dist int | |||
| 461 | + | switch { | |||
| 462 | + | case dx < 0: | |||
| 463 | + | overlap, dist = min(cur.Y+cur.H, r.Y+r.H)-max(cur.Y, r.Y), cur.X-(r.X+r.W) | |||
| 464 | + | case dx > 0: | |||
| 465 | + | overlap, dist = min(cur.Y+cur.H, r.Y+r.H)-max(cur.Y, r.Y), r.X-(cur.X+cur.W) | |||
| 466 | + | case dy < 0: | |||
| 467 | + | overlap, dist = min(cur.X+cur.W, r.X+r.W)-max(cur.X, r.X), cur.Y-(r.Y+r.H) | |||
| 468 | + | default: | |||
| 469 | + | overlap, dist = min(cur.X+cur.W, r.X+r.W)-max(cur.X, r.X), r.Y-(cur.Y+cur.H) | |||
| 470 | + | } | |||
| 471 | + | if i == w.Focus || overlap <= 0 || dist < 0 { | |||
| 472 | + | continue | |||
| 473 | + | } | |||
| 474 | + | closer := best >= 0 && (dist < bestDist || dist == bestDist && (overlap > bestOverlap || | |||
| 475 | + | overlap == bestOverlap && abs(i-w.Focus) < abs(best-w.Focus))) | |||
| 476 | + | if best < 0 || closer { | |||
| 477 | + | best, bestDist, bestOverlap = i, dist, overlap | |||
| 478 | + | } | |||
| 479 | + | } | |||
| 480 | + | return best | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func abs(n int) int { return max(n, -n) } | |||
| 411 | func (w *Workspace) MoveColumn(dir int) { | 484 | func (w *Workspace) MoveColumn(dir int) { | ||
| 412 | if w.floatFocus { | 485 | if w.floatFocus { | ||
| 413 | return | 486 | return | ||
| @@ -426,7 +499,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 426 | return Column{}, false | 499 | return Column{}, false | ||
| 427 | } | 500 | } | ||
| 428 | col := w.Columns[w.Focus] | 501 | col := w.Columns[w.Focus] | ||
| 429 | - | col.Slot = 0 | 502 | + | col.Slot, col.Expanded = 0, false |
| 430 | if slices.Contains(col.Windows, w.fullscreen) { | 503 | if slices.Contains(col.Windows, w.fullscreen) { | ||
| 431 | w.fullscreen = 0 | 504 | w.fullscreen = 0 | ||
| 432 | } | 505 | } | ||
| @@ -451,7 +524,13 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||||
| 451 | w.scroll() | 524 | w.scroll() | ||
| 452 | } | 525 | } | ||
| 453 | 526 | ||||
| 527 | + | // CycleWidth steps the focused column through the presets. Fixed overflow | |||
| 528 | + | // ignores presets: it toggles the expanded column instead. | |||
| 454 | func (w *Workspace) CycleWidth() { | 529 | func (w *Workspace) CycleWidth() { | ||
| 530 | + | if w.Overflow == OverflowFixed { | |||
| 531 | + | w.toggleExpanded() | |||
| 532 | + | return | |||
| 533 | + | } | |||
| 455 | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { | 534 | if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus { | ||
| 456 | return | 535 | return | ||
| 457 | } | 536 | } | ||
| @@ -472,6 +551,20 @@ func (w *Workspace) CycleWidth() { | |||||
| 472 | w.scroll() | 551 | w.scroll() | ||
| 473 | } | 552 | } | ||
| 474 | 553 | ||||
| 554 | + | // toggleExpanded makes the focused column the expanded one, or shrinks it | |||
| 555 | + | // back when it already is. One column at most is expanded. | |||
| 556 | + | func (w *Workspace) toggleExpanded() { | |||
| 557 | + | if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.floatFocus { | |||
| 558 | + | return | |||
| 559 | + | } | |||
| 560 | + | on := !w.Columns[w.Focus].Expanded | |||
| 561 | + | for i := range w.Columns { | |||
| 562 | + | w.Columns[i].Expanded = false | |||
| 563 | + | } | |||
| 564 | + | w.Columns[w.Focus].Expanded = on | |||
| 565 | + | w.Columns[w.Focus].FullWidth = false | |||
| 566 | + | } | |||
| 567 | + | ||||
| 475 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | 568 | // ToggleFullWidth expands the focused tiled column without changing its saved width. | ||
| 476 | func (w *Workspace) ToggleFullWidth() { | 569 | func (w *Workspace) ToggleFullWidth() { | ||
| 477 | if len(w.Columns) == 0 || w.floatFocus { | 570 | if len(w.Columns) == 0 || w.floatFocus { | ||
| @@ -637,6 +730,9 @@ func (w *Workspace) columnRects() []Rect { | |||||
| 637 | return rects | 730 | return rects | ||
| 638 | } | 731 | } | ||
| 639 | k := max(w.MaxColumns, 1) | 732 | k := max(w.MaxColumns, 1) | ||
| 733 | + | if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 { | |||
| 734 | + | return w.expandedRects(e, k, y, h) | |||
| 735 | + | } | |||
| 640 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | 736 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | ||
| 641 | return rects | 737 | return rects | ||
| 642 | } | 738 | } | ||
| @@ -649,6 +745,60 @@ func (w *Workspace) columnRects() []Rect { | |||||
| 649 | return rects | 745 | return rects | ||
| 650 | } | 746 | } | ||
| 651 | 747 | ||||
| 748 | + | // expandedRects places expanded column e over k-1 cells in its place. The | |||
| 749 | + | // columns before it stack in a strip on its left, those after it on its | |||
| 750 | + | // right; the two strips share the last cell. | |||
| 751 | + | func (w *Workspace) expandedRects(e, k, y, h int) []Rect { | |||
| 752 | + | g := w.gap() | |||
| 753 | + | cell := max((w.Usable.W-g*(k+1))/k, 0) | |||
| 754 | + | wide := (k-1)*cell + (k-2)*g | |||
| 755 | + | rest := max(w.Usable.W-2*g-wide-g, 0) | |||
| 756 | + | before, after := w.Columns[:e], w.Columns[e+1:] | |||
| 757 | + | leftW, rightW := rest, rest | |||
| 758 | + | if len(before) > 0 && len(after) > 0 { | |||
| 759 | + | leftW = max((rest-g)/2, 0) | |||
| 760 | + | rightW = max(rest-g-leftW, 0) | |||
| 761 | + | } | |||
| 762 | + | rects := make([]Rect, 0, len(w.Columns)) | |||
| 763 | + | x := w.Usable.X + g | |||
| 764 | + | if len(before) > 0 { | |||
| 765 | + | rects = append(rects, stackRects(Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)...) | |||
| 766 | + | x += leftW + g | |||
| 767 | + | } | |||
| 768 | + | rects = append(rects, Rect{X: x, Y: y, W: wide, H: h}) | |||
| 769 | + | if len(after) > 0 { | |||
| 770 | + | rects = append(rects, stackRects(Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)...) | |||
| 771 | + | } | |||
| 772 | + | // Gaps wider than a narrow output would push columns past its edge. | |||
| 773 | + | right := w.Usable.X + w.Usable.W | |||
| 774 | + | for i := range rects { | |||
| 775 | + | rects[i].X = min(rects[i].X, right) | |||
| 776 | + | rects[i].W = min(rects[i].W, right-rects[i].X) | |||
| 777 | + | } | |||
| 778 | + | return rects | |||
| 779 | + | } | |||
| 780 | + | ||||
| 781 | + | // stackRects cuts r in n rows with a gap between them; the last row takes | |||
| 782 | + | // the rounding remainder. Rows past the bottom (gaps taller than r) are | |||
| 783 | + | // clamped to it, like windows stacked in a column. | |||
| 784 | + | func stackRects(r Rect, n, gap int) []Rect { | |||
| 785 | + | rows := make([]Rect, n) | |||
| 786 | + | avail := max(r.H-(n-1)*gap, 0) | |||
| 787 | + | height := avail / n | |||
| 788 | + | yy, bottom := r.Y, r.Y+r.H | |||
| 789 | + | for i := range rows { | |||
| 790 | + | hh := height | |||
| 791 | + | if i == n-1 { | |||
| 792 | + | hh = avail - height*(n-1) | |||
| 793 | + | } | |||
| 794 | + | yy = min(yy, bottom) | |||
| 795 | + | hh = min(hh, bottom-yy) | |||
| 796 | + | rows[i] = Rect{X: r.X, Y: yy, W: r.W, H: hh} | |||
| 797 | + | yy += hh + gap | |||
| 798 | + | } | |||
| 799 | + | return rows | |||
| 800 | + | } | |||
| 801 | + | ||||
| 652 | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | 802 | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | ||
| 653 | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | 803 | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | ||
| 654 | if vertical { | 804 | if vertical { | ||
M internal/core/layout_test.go +171 -0
| @@ -440,6 +440,177 @@ func TestFixedOverflowSpiral(t *testing.T) { | |||
| 440 | 440 | } | |
| 441 | 441 | } | |
| 442 | 442 | ||
| 443 | + | // In fixed overflow, cycle-column-width expands the focused column over | |
| 444 | + | // max-columns - 1 cells in its place; the others stack beside it. | |
| 445 | + | func TestFixedExpandColumn(t *testing.T) { | |
| 446 | + | rects := func(w *Workspace) map[WindowID]Rect { | |
| 447 | + | got := map[WindowID]Rect{} | |
| 448 | + | for id, p := range placements(w) { | |
| 449 | + | got[id] = p.Rect | |
| 450 | + | } | |
| 451 | + | return got | |
| 452 | + | } | |
| 453 | + | check := func(t *testing.T, w *Workspace, want map[WindowID]Rect) { | |
| 454 | + | t.Helper() | |
| 455 | + | if got := rects(w); !reflect.DeepEqual(got, want) { | |
| 456 | + | t.Fatalf("got %v\nwant %v", got, want) | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | t.Run("free cell", func(t *testing.T) { | |
| 460 | + | w := fixedWorkspace(4, 0, 2) | |
| 461 | + | w.FocusID(1) | |
| 462 | + | w.CycleWidth() | |
| 463 | + | check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}}) | |
| 464 | + | w.CycleWidth() | |
| 465 | + | check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}}) | |
| 466 | + | }) | |
| 467 | + | t.Run("middle column keeps its place", func(t *testing.T) { | |
| 468 | + | w := fixedWorkspace(4, 0, 4) | |
| 469 | + | w.FocusID(2) | |
| 470 | + | w.CycleWidth() | |
| 471 | + | check(t, w, map[WindowID]Rect{ | |
| 472 | + | 1: {W: 12, H: 80}, | |
| 473 | + | 2: {X: 12, W: 75, H: 80}, | |
| 474 | + | 3: {X: 87, W: 13, H: 40}, | |
| 475 | + | 4: {X: 87, Y: 40, W: 13, H: 40}, | |
| 476 | + | }) | |
| 477 | + | }) | |
| 478 | + | t.Run("with gaps", func(t *testing.T) { | |
| 479 | + | w := fixedWorkspace(2, 4, 3) | |
| 480 | + | w.FocusID(1) | |
| 481 | + | w.CycleWidth() | |
| 482 | + | // Two cells of 44: the wide one keeps one, the others stack in the other. | |
| 483 | + | check(t, w, map[WindowID]Rect{ | |
| 484 | + | 1: {X: 4, Y: 4, W: 44, H: 72}, | |
| 485 | + | 2: {X: 52, Y: 4, W: 44, H: 34}, | |
| 486 | + | 3: {X: 52, Y: 42, W: 44, H: 34}, | |
| 487 | + | }) | |
| 488 | + | }) | |
| 489 | + | t.Run("stays on focus change and new windows", func(t *testing.T) { | |
| 490 | + | w := fixedWorkspace(4, 0, 2) | |
| 491 | + | w.FocusID(1) | |
| 492 | + | w.CycleWidth() | |
| 493 | + | w.FocusColumn(1) | |
| 494 | + | w.AddWindow(3) | |
| 495 | + | check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 40}, 3: {X: 75, Y: 40, W: 25, H: 40}}) | |
| 496 | + | }) | |
| 497 | + | t.Run("moves to the focused column", func(t *testing.T) { | |
| 498 | + | w := fixedWorkspace(4, 0, 2) | |
| 499 | + | w.FocusID(1) | |
| 500 | + | w.CycleWidth() | |
| 501 | + | w.FocusID(2) | |
| 502 | + | w.CycleWidth() | |
| 503 | + | check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 75, H: 80}}) | |
| 504 | + | }) | |
| 505 | + | t.Run("closing it shares again", func(t *testing.T) { | |
| 506 | + | w := fixedWorkspace(3, 0, 3) | |
| 507 | + | w.FocusID(1) | |
| 508 | + | w.CycleWidth() | |
| 509 | + | w.RemoveWindow(1) | |
| 510 | + | check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}}) | |
| 511 | + | }) | |
| 512 | + | t.Run("no-op when it cannot grow", func(t *testing.T) { | |
| 513 | + | for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(1, 0, 2)} { | |
| 514 | + | before := rects(w) | |
| 515 | + | w.CycleWidth() | |
| 516 | + | check(t, w, before) | |
| 517 | + | } | |
| 518 | + | }) | |
| 519 | + | t.Run("stays on screen when gaps do not fit", func(t *testing.T) { | |
| 520 | + | for _, w := range []*Workspace{fixedWorkspace(2, 4, 30), fixedWorkspace(4, 4, 4)} { | |
| 521 | + | if w.MaxColumns == 4 { | |
| 522 | + | w.SetOutput(12, 80) | |
| 523 | + | } | |
| 524 | + | w.FocusID(2) | |
| 525 | + | w.CycleWidth() | |
| 526 | + | for id, p := range placements(w) { | |
| 527 | + | r := p.Rect | |
| 528 | + | if r.X < 0 || r.Y < 0 || r.W < 0 || r.H < 0 || r.X+r.W > w.Output.W || r.Y+r.H > w.Output.H { | |
| 529 | + | t.Fatal(id, r) | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | }) | |
| 534 | + | t.Run("maximize wins and gives it back", func(t *testing.T) { | |
| 535 | + | w := fixedWorkspace(4, 0, 2) | |
| 536 | + | w.FocusID(1) | |
| 537 | + | w.CycleWidth() | |
| 538 | + | w.ToggleFullWidth() | |
| 539 | + | if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden { | |
| 540 | + | t.Fatal(p) | |
| 541 | + | } | |
| 542 | + | w.ToggleFullWidth() | |
| 543 | + | check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}}) | |
| 544 | + | }) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // Up and down cross the columns the spiral or the expanded strips stack. | |
| 548 | + | func TestFixedFocusStackedColumns(t *testing.T) { | |
| 549 | + | focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id } | |
| 550 | + | t.Run("spiral", func(t *testing.T) { | |
| 551 | + | // 1 left, 2 top right, 3 bottom left, 4 over 5 bottom right. | |
| 552 | + | w := fixedWorkspace(2, 4, 5) | |
| 553 | + | for _, want := range []WindowID{4, 2} { | |
| 554 | + | if !w.FocusWindow(-1) || focused(w) != want { | |
| 555 | + | t.Fatal(focused(w), want) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | if w.FocusWindow(-1) { | |
| 559 | + | t.Fatal("moved past the top", focused(w)) | |
| 560 | + | } | |
| 561 | + | w.FocusID(1) | |
| 562 | + | if w.FocusWindow(1) { | |
| 563 | + | t.Fatal("full height column moved", focused(w)) | |
| 564 | + | } | |
| 565 | + | }) | |
| 566 | + | t.Run("left and right follow the screen", func(t *testing.T) { | |
| 567 | + | // 1 left, 2 top right, 3 bottom left, 4 over 5 bottom right. | |
| 568 | + | w := fixedWorkspace(2, 4, 5) | |
| 569 | + | for _, step := range []struct { | |
| 570 | + | dir int | |
| 571 | + | want WindowID | |
| 572 | + | }{{-1, 3}, {-1, 1}, {1, 2}} { | |
| 573 | + | w.FocusColumn(step.dir) | |
| 574 | + | if focused(w) != step.want { | |
| 575 | + | t.Fatal(focused(w), step.want) | |
| 576 | + | } | |
| 577 | + | } | |
| 578 | + | w.FocusID(3) | |
| 579 | + | w.FocusColumn(1) | |
| 580 | + | if focused(w) != 4 { | |
| 581 | + | t.Fatal(focused(w)) | |
| 582 | + | } | |
| 583 | + | if w.FocusID(2); w.columnToward(1) >= 0 { | |
| 584 | + | t.Fatal("right edge has a neighbor") | |
| 585 | + | } | |
| 586 | + | }) | |
| 587 | + | t.Run("expanded strip", func(t *testing.T) { | |
| 588 | + | // 1 wide; 2, 3 and 4 stacked on its right. | |
| 589 | + | w := fixedWorkspace(3, 0, 4) | |
| 590 | + | w.FocusID(1) | |
| 591 | + | w.CycleWidth() | |
| 592 | + | w.FocusID(2) | |
| 593 | + | for _, want := range []WindowID{3, 4} { | |
| 594 | + | if !w.FocusWindow(1) || focused(w) != want { | |
| 595 | + | t.Fatal(focused(w), want) | |
| 596 | + | } | |
| 597 | + | } | |
| 598 | + | if !w.FocusWindow(-1) || focused(w) != 3 { | |
| 599 | + | t.Fatal(focused(w)) | |
| 600 | + | } | |
| 601 | + | }) | |
| 602 | + | t.Run("lands on the nearest window of a stacked column", func(t *testing.T) { | |
| 603 | + | // 1 left; 2 top right holds two windows; 3 bottom right. | |
| 604 | + | w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |
| 605 | + | w.SetOutput(100, 80) | |
| 606 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 5}}, {Windows: []WindowID{3}}} | |
| 607 | + | w.Focus = 2 | |
| 608 | + | if !w.FocusWindow(-1) || focused(w) != 5 { | |
| 609 | + | t.Fatal(focused(w)) | |
| 610 | + | } | |
| 611 | + | }) | |
| 612 | + | } | |
| 613 | + | ||
| 443 | 614 | func fixedWorkspace(maxCols, gaps, n int) *Workspace { | |
| 444 | 615 | w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps} | |
| 445 | 616 | w.SetOutput(100, 80) | |
| @@ -440,6 +440,177 @@ func TestFixedOverflowSpiral(t *testing.T) { | |||||
| 440 | } | 440 | } | ||
| 441 | } | 441 | } | ||
| 442 | 442 | ||||
| 443 | + | // In fixed overflow, cycle-column-width expands the focused column over | |||
| 444 | + | // max-columns - 1 cells in its place; the others stack beside it. | |||
| 445 | + | func TestFixedExpandColumn(t *testing.T) { | |||
| 446 | + | rects := func(w *Workspace) map[WindowID]Rect { | |||
| 447 | + | got := map[WindowID]Rect{} | |||
| 448 | + | for id, p := range placements(w) { | |||
| 449 | + | got[id] = p.Rect | |||
| 450 | + | } | |||
| 451 | + | return got | |||
| 452 | + | } | |||
| 453 | + | check := func(t *testing.T, w *Workspace, want map[WindowID]Rect) { | |||
| 454 | + | t.Helper() | |||
| 455 | + | if got := rects(w); !reflect.DeepEqual(got, want) { | |||
| 456 | + | t.Fatalf("got %v\nwant %v", got, want) | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | t.Run("free cell", func(t *testing.T) { | |||
| 460 | + | w := fixedWorkspace(4, 0, 2) | |||
| 461 | + | w.FocusID(1) | |||
| 462 | + | w.CycleWidth() | |||
| 463 | + | check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}}) | |||
| 464 | + | w.CycleWidth() | |||
| 465 | + | check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}}) | |||
| 466 | + | }) | |||
| 467 | + | t.Run("middle column keeps its place", func(t *testing.T) { | |||
| 468 | + | w := fixedWorkspace(4, 0, 4) | |||
| 469 | + | w.FocusID(2) | |||
| 470 | + | w.CycleWidth() | |||
| 471 | + | check(t, w, map[WindowID]Rect{ | |||
| 472 | + | 1: {W: 12, H: 80}, | |||
| 473 | + | 2: {X: 12, W: 75, H: 80}, | |||
| 474 | + | 3: {X: 87, W: 13, H: 40}, | |||
| 475 | + | 4: {X: 87, Y: 40, W: 13, H: 40}, | |||
| 476 | + | }) | |||
| 477 | + | }) | |||
| 478 | + | t.Run("with gaps", func(t *testing.T) { | |||
| 479 | + | w := fixedWorkspace(2, 4, 3) | |||
| 480 | + | w.FocusID(1) | |||
| 481 | + | w.CycleWidth() | |||
| 482 | + | // Two cells of 44: the wide one keeps one, the others stack in the other. | |||
| 483 | + | check(t, w, map[WindowID]Rect{ | |||
| 484 | + | 1: {X: 4, Y: 4, W: 44, H: 72}, | |||
| 485 | + | 2: {X: 52, Y: 4, W: 44, H: 34}, | |||
| 486 | + | 3: {X: 52, Y: 42, W: 44, H: 34}, | |||
| 487 | + | }) | |||
| 488 | + | }) | |||
| 489 | + | t.Run("stays on focus change and new windows", func(t *testing.T) { | |||
| 490 | + | w := fixedWorkspace(4, 0, 2) | |||
| 491 | + | w.FocusID(1) | |||
| 492 | + | w.CycleWidth() | |||
| 493 | + | w.FocusColumn(1) | |||
| 494 | + | w.AddWindow(3) | |||
| 495 | + | check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 40}, 3: {X: 75, Y: 40, W: 25, H: 40}}) | |||
| 496 | + | }) | |||
| 497 | + | t.Run("moves to the focused column", func(t *testing.T) { | |||
| 498 | + | w := fixedWorkspace(4, 0, 2) | |||
| 499 | + | w.FocusID(1) | |||
| 500 | + | w.CycleWidth() | |||
| 501 | + | w.FocusID(2) | |||
| 502 | + | w.CycleWidth() | |||
| 503 | + | check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 75, H: 80}}) | |||
| 504 | + | }) | |||
| 505 | + | t.Run("closing it shares again", func(t *testing.T) { | |||
| 506 | + | w := fixedWorkspace(3, 0, 3) | |||
| 507 | + | w.FocusID(1) | |||
| 508 | + | w.CycleWidth() | |||
| 509 | + | w.RemoveWindow(1) | |||
| 510 | + | check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}}) | |||
| 511 | + | }) | |||
| 512 | + | t.Run("no-op when it cannot grow", func(t *testing.T) { | |||
| 513 | + | for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(1, 0, 2)} { | |||
| 514 | + | before := rects(w) | |||
| 515 | + | w.CycleWidth() | |||
| 516 | + | check(t, w, before) | |||
| 517 | + | } | |||
| 518 | + | }) | |||
| 519 | + | t.Run("stays on screen when gaps do not fit", func(t *testing.T) { | |||
| 520 | + | for _, w := range []*Workspace{fixedWorkspace(2, 4, 30), fixedWorkspace(4, 4, 4)} { | |||
| 521 | + | if w.MaxColumns == 4 { | |||
| 522 | + | w.SetOutput(12, 80) | |||
| 523 | + | } | |||
| 524 | + | w.FocusID(2) | |||
| 525 | + | w.CycleWidth() | |||
| 526 | + | for id, p := range placements(w) { | |||
| 527 | + | r := p.Rect | |||
| 528 | + | if r.X < 0 || r.Y < 0 || r.W < 0 || r.H < 0 || r.X+r.W > w.Output.W || r.Y+r.H > w.Output.H { | |||
| 529 | + | t.Fatal(id, r) | |||
| 530 | + | } | |||
| 531 | + | } | |||
| 532 | + | } | |||
| 533 | + | }) | |||
| 534 | + | t.Run("maximize wins and gives it back", func(t *testing.T) { | |||
| 535 | + | w := fixedWorkspace(4, 0, 2) | |||
| 536 | + | w.FocusID(1) | |||
| 537 | + | w.CycleWidth() | |||
| 538 | + | w.ToggleFullWidth() | |||
| 539 | + | if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden { | |||
| 540 | + | t.Fatal(p) | |||
| 541 | + | } | |||
| 542 | + | w.ToggleFullWidth() | |||
| 543 | + | check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}}) | |||
| 544 | + | }) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | // Up and down cross the columns the spiral or the expanded strips stack. | |||
| 548 | + | func TestFixedFocusStackedColumns(t *testing.T) { | |||
| 549 | + | focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id } | |||
| 550 | + | t.Run("spiral", func(t *testing.T) { | |||
| 551 | + | // 1 left, 2 top right, 3 bottom left, 4 over 5 bottom right. | |||
| 552 | + | w := fixedWorkspace(2, 4, 5) | |||
| 553 | + | for _, want := range []WindowID{4, 2} { | |||
| 554 | + | if !w.FocusWindow(-1) || focused(w) != want { | |||
| 555 | + | t.Fatal(focused(w), want) | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | if w.FocusWindow(-1) { | |||
| 559 | + | t.Fatal("moved past the top", focused(w)) | |||
| 560 | + | } | |||
| 561 | + | w.FocusID(1) | |||
| 562 | + | if w.FocusWindow(1) { | |||
| 563 | + | t.Fatal("full height column moved", focused(w)) | |||
| 564 | + | } | |||
| 565 | + | }) | |||
| 566 | + | t.Run("left and right follow the screen", func(t *testing.T) { | |||
| 567 | + | // 1 left, 2 top right, 3 bottom left, 4 over 5 bottom right. | |||
| 568 | + | w := fixedWorkspace(2, 4, 5) | |||
| 569 | + | for _, step := range []struct { | |||
| 570 | + | dir int | |||
| 571 | + | want WindowID | |||
| 572 | + | }{{-1, 3}, {-1, 1}, {1, 2}} { | |||
| 573 | + | w.FocusColumn(step.dir) | |||
| 574 | + | if focused(w) != step.want { | |||
| 575 | + | t.Fatal(focused(w), step.want) | |||
| 576 | + | } | |||
| 577 | + | } | |||
| 578 | + | w.FocusID(3) | |||
| 579 | + | w.FocusColumn(1) | |||
| 580 | + | if focused(w) != 4 { | |||
| 581 | + | t.Fatal(focused(w)) | |||
| 582 | + | } | |||
| 583 | + | if w.FocusID(2); w.columnToward(1) >= 0 { | |||
| 584 | + | t.Fatal("right edge has a neighbor") | |||
| 585 | + | } | |||
| 586 | + | }) | |||
| 587 | + | t.Run("expanded strip", func(t *testing.T) { | |||
| 588 | + | // 1 wide; 2, 3 and 4 stacked on its right. | |||
| 589 | + | w := fixedWorkspace(3, 0, 4) | |||
| 590 | + | w.FocusID(1) | |||
| 591 | + | w.CycleWidth() | |||
| 592 | + | w.FocusID(2) | |||
| 593 | + | for _, want := range []WindowID{3, 4} { | |||
| 594 | + | if !w.FocusWindow(1) || focused(w) != want { | |||
| 595 | + | t.Fatal(focused(w), want) | |||
| 596 | + | } | |||
| 597 | + | } | |||
| 598 | + | if !w.FocusWindow(-1) || focused(w) != 3 { | |||
| 599 | + | t.Fatal(focused(w)) | |||
| 600 | + | } | |||
| 601 | + | }) | |||
| 602 | + | t.Run("lands on the nearest window of a stacked column", func(t *testing.T) { | |||
| 603 | + | // 1 left; 2 top right holds two windows; 3 bottom right. | |||
| 604 | + | w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |||
| 605 | + | w.SetOutput(100, 80) | |||
| 606 | + | w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 5}}, {Windows: []WindowID{3}}} | |||
| 607 | + | w.Focus = 2 | |||
| 608 | + | if !w.FocusWindow(-1) || focused(w) != 5 { | |||
| 609 | + | t.Fatal(focused(w)) | |||
| 610 | + | } | |||
| 611 | + | }) | |||
| 612 | + | } | |||
| 613 | + | ||||
| 443 | func fixedWorkspace(maxCols, gaps, n int) *Workspace { | 614 | func fixedWorkspace(maxCols, gaps, n int) *Workspace { | ||
| 444 | w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps} | 615 | w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps} | ||
| 445 | w.SetOutput(100, 80) | 616 | w.SetOutput(100, 80) | ||
M internal/core/monitor.go +5 -2
| @@ -339,7 +339,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||
| 339 | 339 | } else { | |
| 340 | 340 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | |
| 341 | 341 | c := w.Columns[col] | |
| 342 | - | fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width} | |
| 342 | + | fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width, expanded: c.Expanded} | |
| 343 | 343 | for _, v := range c.Windows { | |
| 344 | 344 | if v != id { | |
| 345 | 345 | fs.back.stacked = append(fs.back.stacked, v) | |
| @@ -402,7 +402,10 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||
| 402 | 402 | } | |
| 403 | 403 | default: | |
| 404 | 404 | at := min(back.col, len(origin.Columns)) | |
| 405 | - | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot}) | |
| 405 | + | // Its column is gone: it comes back expanded, unless another | |
| 406 | + | // column was expanded meanwhile. | |
| 407 | + | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | |
| 408 | + | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |
| 406 | 409 | if focus { | |
| 407 | 410 | origin.floatFocus = false | |
| 408 | 411 | } else if len(origin.Columns) > 1 { | |
| @@ -339,7 +339,7 @@ func (m *Monitor) enterFullscreen(w *Workspace, id WindowID, show bool) { | |||||
| 339 | } else { | 339 | } else { | ||
| 340 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | 340 | col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) }) | ||
| 341 | c := w.Columns[col] | 341 | c := w.Columns[col] | ||
| 342 | - | fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width} | 342 | + | fs.back = origPlace{id: id, col: col, row: slices.Index(c.Windows, id), slot: c.Slot, width: c.Width, expanded: c.Expanded} |
| 343 | for _, v := range c.Windows { | 343 | for _, v := range c.Windows { | ||
| 344 | if v != id { | 344 | if v != id { | ||
| 345 | fs.back.stacked = append(fs.back.stacked, v) | 345 | fs.back.stacked = append(fs.back.stacked, v) | ||
| @@ -402,7 +402,10 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) { | |||||
| 402 | } | 402 | } | ||
| 403 | default: | 403 | default: | ||
| 404 | at := min(back.col, len(origin.Columns)) | 404 | at := min(back.col, len(origin.Columns)) | ||
| 405 | - | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot}) | 405 | + | // Its column is gone: it comes back expanded, unless another |
| 406 | + | // column was expanded meanwhile. | |||
| 407 | + | expanded := back.expanded && !slices.ContainsFunc(origin.Columns, func(c Column) bool { return c.Expanded }) | |||
| 408 | + | origin.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded}) | |||
| 406 | if focus { | 409 | if focus { | ||
| 407 | origin.floatFocus = false | 410 | origin.floatFocus = false | ||
| 408 | } else if len(origin.Columns) > 1 { | 411 | } else if len(origin.Columns) > 1 { | ||
M internal/core/monitor_test.go +46 -0
| @@ -411,6 +411,52 @@ func TestNamedWorkspaceSettings(t *testing.T) { | |||
| 411 | 411 | } | |
| 412 | 412 | } | |
| 413 | 413 | ||
| 414 | + | // The expanded column comes back expanded from its fullscreen workspace. | |
| 415 | + | func TestMonitorFixedFullscreenKeepsExpanded(t *testing.T) { | |
| 416 | + | m := monitor() | |
| 417 | + | m.SetOverflow(OverflowFixed) | |
| 418 | + | m.SetMaxColumns(3) | |
| 419 | + | for id := WindowID(1); id <= 3; id++ { | |
| 420 | + | m.AddWindow(id) | |
| 421 | + | } | |
| 422 | + | m.Current().FocusID(2) | |
| 423 | + | m.Apply(ActionCycleColumnWidth) | |
| 424 | + | m.ToggleFullscreen() | |
| 425 | + | m.ToggleFullscreen() | |
| 426 | + | if c := m.Current().Columns; len(c) != 3 || !c[1].Expanded || c[0].Expanded || c[2].Expanded { | |
| 427 | + | t.Fatalf("%+v", c) | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // A window of an expanded stacked column comes back expanded when the rest | |
| 432 | + | // of its column closed meanwhile, unless another column took the expansion. | |
| 433 | + | func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) { | |
| 434 | + | for _, other := range []bool{false, true} { | |
| 435 | + | m := monitor() | |
| 436 | + | m.SetOverflow(OverflowFixed) | |
| 437 | + | m.SetMaxColumns(3) | |
| 438 | + | for id := WindowID(1); id <= 4; id++ { | |
| 439 | + | m.AddWindow(id) | |
| 440 | + | } | |
| 441 | + | w := m.Current() | |
| 442 | + | w.FocusID(2) | |
| 443 | + | w.stack(2, 1) // column {1, 2}, then {3}, {4} | |
| 444 | + | w.FocusID(2) | |
| 445 | + | m.Apply(ActionCycleColumnWidth) | |
| 446 | + | m.ToggleFullscreen() | |
| 447 | + | w.RemoveWindow(1) | |
| 448 | + | if other { | |
| 449 | + | w.FocusID(3) | |
| 450 | + | w.CycleWidth() | |
| 451 | + | } | |
| 452 | + | m.ToggleFullscreen() | |
| 453 | + | c := m.Current().Columns | |
| 454 | + | if len(c) != 3 || c[0].Windows[0] != 2 || c[0].Expanded == other || c[1].Expanded != other || c[2].Expanded { | |
| 455 | + | t.Fatalf("other=%v %+v", other, c) | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 414 | 460 | func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | |
| 415 | 461 | m := monitor() | |
| 416 | 462 | m.SetOverflow(OverflowFixed) | |
| @@ -411,6 +411,52 @@ func TestNamedWorkspaceSettings(t *testing.T) { | |||||
| 411 | } | 411 | } | ||
| 412 | } | 412 | } | ||
| 413 | 413 | ||||
| 414 | + | // The expanded column comes back expanded from its fullscreen workspace. | |||
| 415 | + | func TestMonitorFixedFullscreenKeepsExpanded(t *testing.T) { | |||
| 416 | + | m := monitor() | |||
| 417 | + | m.SetOverflow(OverflowFixed) | |||
| 418 | + | m.SetMaxColumns(3) | |||
| 419 | + | for id := WindowID(1); id <= 3; id++ { | |||
| 420 | + | m.AddWindow(id) | |||
| 421 | + | } | |||
| 422 | + | m.Current().FocusID(2) | |||
| 423 | + | m.Apply(ActionCycleColumnWidth) | |||
| 424 | + | m.ToggleFullscreen() | |||
| 425 | + | m.ToggleFullscreen() | |||
| 426 | + | if c := m.Current().Columns; len(c) != 3 || !c[1].Expanded || c[0].Expanded || c[2].Expanded { | |||
| 427 | + | t.Fatalf("%+v", c) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | // A window of an expanded stacked column comes back expanded when the rest | |||
| 432 | + | // of its column closed meanwhile, unless another column took the expansion. | |||
| 433 | + | func TestMonitorFixedFullscreenStackedExpanded(t *testing.T) { | |||
| 434 | + | for _, other := range []bool{false, true} { | |||
| 435 | + | m := monitor() | |||
| 436 | + | m.SetOverflow(OverflowFixed) | |||
| 437 | + | m.SetMaxColumns(3) | |||
| 438 | + | for id := WindowID(1); id <= 4; id++ { | |||
| 439 | + | m.AddWindow(id) | |||
| 440 | + | } | |||
| 441 | + | w := m.Current() | |||
| 442 | + | w.FocusID(2) | |||
| 443 | + | w.stack(2, 1) // column {1, 2}, then {3}, {4} | |||
| 444 | + | w.FocusID(2) | |||
| 445 | + | m.Apply(ActionCycleColumnWidth) | |||
| 446 | + | m.ToggleFullscreen() | |||
| 447 | + | w.RemoveWindow(1) | |||
| 448 | + | if other { | |||
| 449 | + | w.FocusID(3) | |||
| 450 | + | w.CycleWidth() | |||
| 451 | + | } | |||
| 452 | + | m.ToggleFullscreen() | |||
| 453 | + | c := m.Current().Columns | |||
| 454 | + | if len(c) != 3 || c[0].Windows[0] != 2 || c[0].Expanded == other || c[1].Expanded != other || c[2].Expanded { | |||
| 455 | + | t.Fatalf("other=%v %+v", other, c) | |||
| 456 | + | } | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | ||||
| 414 | func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | 460 | func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) { | ||
| 415 | m := monitor() | 461 | m := monitor() | ||
| 416 | m.SetOverflow(OverflowFixed) | 462 | m.SetOverflow(OverflowFixed) | ||