detached 8e13c90

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 UnverifiedB5690EEEBB952194 The 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`).
2727 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
2828 | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split |
2929 | `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) |
3131 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
3232 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
3333 | `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
7676 | `close-window` | Close the focused window |
7777 | `toggle-fullscreen` | Fullscreen the focused window |
7878 | `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 |
8282 | `move-column-left/right` | Move the focused column |
8383 | `consume-or-expel-window-left/right` | A lone window joins the neighbor column; a stacked one leaves for a new column |
8484 | `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) {
590590 <-p.events
591591 }
592592 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+ }
601611 }
602612 }
603613
@@ -590,14 +590,24 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
590 <-p.events590 <-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 {
124124 return Effect{}
125125 case ActionFocusColumnLeft, ActionFocusColumnRight:
126126 w := c.cur().mon.Current()
127- edge := len(w.Columns) == 0 || (a == ActionFocusColumnLeft && w.Focus == 0) || (a == ActionFocusColumnRight && w.Focus == len(w.Columns)-1)
128127 if a == ActionFocusColumnLeft {
129128 dir = -1
130129 } else {
131130 dir = 1
132131 }
132+ edge := w.columnToward(dir) < 0
133133 if i := c.neighbor(dir); edge && i >= 0 {
134134 c.focusScreen = i
135135 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 = -1128 dir = -1
130 } else {129 } else {
131 dir = 1130 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 = i134 c.focusScreen = i
135 return Effect{}135 return Effect{}
M internal/core/layout.go +157 -7
@@ -83,7 +83,10 @@ type Column struct {
8383 Windows []WindowID
8484 Width Width
8585 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
8790 // Slot is the declared column number (workspace.<name>.column.N) of a
8891 // slot window; 0 for normal columns.
8992 Slot int
@@ -158,9 +161,10 @@ type origPlace struct {
158161 col, row, slot int
159162 // stacked holds the windows left in the column: the window returns to
160163 // 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
164168 }
165169
166170 // Float is a floating window and its client size, logical.
@@ -382,16 +386,41 @@ func (w *Workspace) FocusColumn(dir int) {
382386 w.floatFocus = false
383387 return
384388 }
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 {
386390 if w.Overflow == OverflowFixed {
387391 w.Columns[w.Focus].FullWidth = false
388392 }
389- w.Focus += dir
393+ w.Focus = i
390394 w.scroll()
391395 }
392396 }
393397
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+
394421 // 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.
395424 func (w *Workspace) FocusWindow(dir int) bool {
396425 if w.floatFocus {
397426 w.floatFocus = false
@@ -406,8 +435,52 @@ func (w *Workspace) FocusWindow(dir int) bool {
406435 w.scroll()
407436 return true
408437 }
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+ }
409449 return false
410450 }
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) }
411484 func (w *Workspace) MoveColumn(dir int) {
412485 if w.floatFocus {
413486 return
@@ -426,7 +499,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
426499 return Column{}, false
427500 }
428501 col := w.Columns[w.Focus]
429- col.Slot = 0
502+ col.Slot, col.Expanded = 0, false
430503 if slices.Contains(col.Windows, w.fullscreen) {
431504 w.fullscreen = 0
432505 }
@@ -451,7 +524,13 @@ func (w *Workspace) insertColumn(at int, col Column) {
451524 w.scroll()
452525 }
453526
527+// CycleWidth steps the focused column through the presets. Fixed overflow
528+// ignores presets: it toggles the expanded column instead.
454529 func (w *Workspace) CycleWidth() {
530+ if w.Overflow == OverflowFixed {
531+ w.toggleExpanded()
532+ return
533+ }
455534 if len(w.Columns) == 0 || len(w.presets) == 0 || w.floatFocus {
456535 return
457536 }
@@ -472,6 +551,20 @@ func (w *Workspace) CycleWidth() {
472551 w.scroll()
473552 }
474553
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+
475568 // ToggleFullWidth expands the focused tiled column without changing its saved width.
476569 func (w *Workspace) ToggleFullWidth() {
477570 if len(w.Columns) == 0 || w.floatFocus {
@@ -637,6 +730,9 @@ func (w *Workspace) columnRects() []Rect {
637730 return rects
638731 }
639732 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+ }
640736 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
641737 return rects
642738 }
@@ -649,6 +745,60 @@ func (w *Workspace) columnRects() []Rect {
649745 return rects
650746 }
651747
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+
652802 // split cuts r in two halves with a gap: top/bottom when vertical, else left/right.
653803 func split(r Rect, gap int, vertical bool) (Rect, Rect) {
654804 if vertical {
@@ -83,7 +83,10 @@ type Column struct {
83 Windows []WindowID83 Windows []WindowID
84 Width Width84 Width Width
85 FullWidth bool85 FullWidth bool
86- Focus int86+ // 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 a90 // 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 int92 Slot int
@@ -158,9 +161,10 @@ type origPlace struct {
158 col, row, slot int161 col, row, slot int
159 // stacked holds the windows left in the column: the window returns to162 // 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 []WindowID164+ stacked []WindowID
162- width Width165+ width Width
163- float *Float166+ 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 = false386 w.floatFocus = false
383 return387 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 = false391 w.Columns[w.Focus].FullWidth = false
388 }392 }
389- w.Focus += dir393+ 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 = false426 w.floatFocus = false
@@ -406,8 +435,52 @@ func (w *Workspace) FocusWindow(dir int) bool {
406 w.scroll()435 w.scroll()
407 return true436 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 false449 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 return486 return
@@ -426,7 +499,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
426 return Column{}, false499 return Column{}, false
427 }500 }
428 col := w.Columns[w.Focus]501 col := w.Columns[w.Focus]
429- col.Slot = 0502+ 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 = 0504 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 return535 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 rects730 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 rects737 return rects
642 }738 }
@@ -649,6 +745,60 @@ func (w *Workspace) columnRects() []Rect {
649 return rects745 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) {
440440 }
441441 }
442442
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+
443614 func fixedWorkspace(maxCols, gaps, n int) *Workspace {
444615 w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
445616 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) {
339339 } else {
340340 col := slices.IndexFunc(w.Columns, func(c Column) bool { return slices.Contains(c.Windows, id) })
341341 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}
343343 for _, v := range c.Windows {
344344 if v != id {
345345 fs.back.stacked = append(fs.back.stacked, v)
@@ -402,7 +402,10 @@ func (m *Monitor) leaveFullscreen(fs *Workspace, focus bool) {
402402 }
403403 default:
404404 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})
406409 if focus {
407410 origin.floatFocus = false
408411 } 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 = false410 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) {
411411 }
412412 }
413413
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+
414460 func TestMonitorFixedFullscreenOwnWorkspace(t *testing.T) {
415461 m := monitor()
416462 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close