detached 25f53bf

refactor(core): size columns in max-columns cells

commit
25f53bf322ada4e05c1723396d8258940aa45e79
author
brice · · 2026-10-12 09:45 UTC
parent
0e0d396
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
34 files · +791 -643
M examples/config +15 -15
@@ -37,15 +37,14 @@ border.inactive = #111111
3737 floating.dim = 0.3
3838
3939 layout.gaps = 0
40-# Columns sharing the screen equally before the overflow applies.
41-# A lone column fills the screen without separators.
40+# Columns sharing the screen equally before the overflow applies: one cell is
41+# a max-columns-th of the width, and columns, rules and slots are sized in
42+# cells. A lone column fills the screen without separators.
4243 layout.max-columns = 2
4344 # Past max-columns: cascade starts a new band of columns below, scrolling
4445 # vertically; scroll opens columns to the right; fixed keeps everything on
4546 # screen, each new window splitting the newest one (spiral).
4647 layout.overflow = cascade
47-# cycle-column-width steps through these, then back to the equal share.
48-layout.presets = 1/3, 1/2, 2/3, 1
4948
5049 # Touchpad. A three-finger swipe up or down slides between workspaces, left
5150 # or right scrolls the columns (cascade swaps the two axes). natural-scroll
@@ -96,21 +95,20 @@ focus.strength = 0.04
9695 # workspace.web.max-columns = 3
9796 # workspace.web.overflow = fixed
9897 #
99-# Slots: column.N = <width>, <command>. neferwl starts each command in the
98+# Slots: column.N = <cells>, <command> (1-16). neferwl starts each command in the
10099 # background and puts its first window in column N, without taking focus.
101100 # A closed slot stays empty until the workspace is shown again. Apps that
102-# reuse a running instance need a new-instance flag. Widths need
103-# overflow = scroll.
101+# reuse a running instance need a new-instance flag.
104102 # workspace.dev.monitor = DP-2
105-# workspace.dev.column.1 = 67%, code --new-window
106-# workspace.dev.column.2 = 33%, foot
103+# workspace.dev.column.1 = 2, code --new-window
104+# workspace.dev.column.2 = 1, foot
107105 # bind.cmd+d = workspace dev
108106
109107 # Window rules place an app's windows when they first open, without moving
110108 # the focus or the view. app-id is a regular expression matching the whole
111109 # app ID (see `neferwl state`); matching rules merge, a later one winning.
112110 # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a
113-# monitor key, width = like a column width.
111+# monitor key, width = cells (1-16).
114112 # rule.games.app-id = steam_app_.*
115113 # rule.games.workspace = 3
116114 # rule.games.monitor = DP-2
@@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up
172170 bind.cmd+shift+down = move-window-down
173171 bind.cmd+shift+k = move-window-up
174172 bind.cmd+shift+j = move-window-down
175-# Resize by a step of the usable width or height: +N% or -N% (1-100).
176-bind.cmd+alt+left = set-column-width -10%
177-bind.cmd+alt+right = set-column-width +10%
173+# Resize a column by N cells (+N or -N, 1-16), a window by a step of the
174+# usable height (+N% or -N%, 1-100).
175+bind.cmd+alt+left = set-column-width -1
176+bind.cmd+alt+right = set-column-width +1
178177 bind.cmd+alt+up = set-window-height -10%
179178 bind.cmd+alt+down = set-window-height +10%
180-bind.cmd+alt+h = set-column-width -10%
181-bind.cmd+alt+l = set-column-width +10%
179+bind.cmd+alt+h = set-column-width -1
180+bind.cmd+alt+l = set-column-width +1
182181 bind.cmd+alt+k = set-window-height -10%
183182 bind.cmd+alt+j = set-window-height +10%
184183 # A tile becomes a free floating window, or returns to its column.
185184 bind.cmd+shift+space = toggle-floating
185+# Grow the focused column by one cell, then back to equal shares.
186186 bind.cmd+r = cycle-column-width
187187 bind.cmd+f = maximize-column
188188 bind.cmd+shift+f = toggle-fullscreen
@@ -37,15 +37,14 @@ border.inactive = #111111
37 floating.dim = 0.337 floating.dim = 0.3
38 38
39 layout.gaps = 039 layout.gaps = 0
40-# Columns sharing the screen equally before the overflow applies.40+# Columns sharing the screen equally before the overflow applies: one cell is
41-# A lone column fills the screen without separators.41+# a max-columns-th of the width, and columns, rules and slots are sized in
42+# cells. A lone column fills the screen without separators.
42 layout.max-columns = 243 layout.max-columns = 2
43 # Past max-columns: cascade starts a new band of columns below, scrolling44 # Past max-columns: cascade starts a new band of columns below, scrolling
44 # vertically; scroll opens columns to the right; fixed keeps everything on45 # vertically; scroll opens columns to the right; fixed keeps everything on
45 # screen, each new window splitting the newest one (spiral).46 # screen, each new window splitting the newest one (spiral).
46 layout.overflow = cascade47 layout.overflow = cascade
47-# cycle-column-width steps through these, then back to the equal share.
48-layout.presets = 1/3, 1/2, 2/3, 1
49 48
50 # Touchpad. A three-finger swipe up or down slides between workspaces, left49 # Touchpad. A three-finger swipe up or down slides between workspaces, left
51 # or right scrolls the columns (cascade swaps the two axes). natural-scroll50 # or right scrolls the columns (cascade swaps the two axes). natural-scroll
@@ -96,21 +95,20 @@ focus.strength = 0.04
96 # workspace.web.max-columns = 395 # workspace.web.max-columns = 3
97 # workspace.web.overflow = fixed96 # workspace.web.overflow = fixed
98 #97 #
99-# Slots: column.N = <width>, <command>. neferwl starts each command in the98+# Slots: column.N = <cells>, <command> (1-16). neferwl starts each command in the
100 # background and puts its first window in column N, without taking focus.99 # background and puts its first window in column N, without taking focus.
101 # A closed slot stays empty until the workspace is shown again. Apps that100 # A closed slot stays empty until the workspace is shown again. Apps that
102-# reuse a running instance need a new-instance flag. Widths need101+# reuse a running instance need a new-instance flag.
103-# overflow = scroll.
104 # workspace.dev.monitor = DP-2102 # workspace.dev.monitor = DP-2
105-# workspace.dev.column.1 = 67%, code --new-window103+# workspace.dev.column.1 = 2, code --new-window
106-# workspace.dev.column.2 = 33%, foot104+# workspace.dev.column.2 = 1, foot
107 # bind.cmd+d = workspace dev105 # bind.cmd+d = workspace dev
108 106
109 # Window rules place an app's windows when they first open, without moving107 # Window rules place an app's windows when they first open, without moving
110 # the focus or the view. app-id is a regular expression matching the whole108 # the focus or the view. app-id is a regular expression matching the whole
111 # app ID (see `neferwl state`); matching rules merge, a later one winning.109 # app ID (see `neferwl state`); matching rules merge, a later one winning.
112 # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a110 # floating = on|off, workspace = N or a declared name, monitor = DP-2 or a
113-# monitor key, width = like a column width.111+# monitor key, width = cells (1-16).
114 # rule.games.app-id = steam_app_.*112 # rule.games.app-id = steam_app_.*
115 # rule.games.workspace = 3113 # rule.games.workspace = 3
116 # rule.games.monitor = DP-2114 # rule.games.monitor = DP-2
@@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up
172 bind.cmd+shift+down = move-window-down170 bind.cmd+shift+down = move-window-down
173 bind.cmd+shift+k = move-window-up171 bind.cmd+shift+k = move-window-up
174 bind.cmd+shift+j = move-window-down172 bind.cmd+shift+j = move-window-down
175-# Resize by a step of the usable width or height: +N% or -N% (1-100).173+# Resize a column by N cells (+N or -N, 1-16), a window by a step of the
176-bind.cmd+alt+left = set-column-width -10%174+# usable height (+N% or -N%, 1-100).
177-bind.cmd+alt+right = set-column-width +10%175+bind.cmd+alt+left = set-column-width -1
176+bind.cmd+alt+right = set-column-width +1
178 bind.cmd+alt+up = set-window-height -10%177 bind.cmd+alt+up = set-window-height -10%
179 bind.cmd+alt+down = set-window-height +10%178 bind.cmd+alt+down = set-window-height +10%
180-bind.cmd+alt+h = set-column-width -10%179+bind.cmd+alt+h = set-column-width -1
181-bind.cmd+alt+l = set-column-width +10%180+bind.cmd+alt+l = set-column-width +1
182 bind.cmd+alt+k = set-window-height -10%181 bind.cmd+alt+k = set-window-height -10%
183 bind.cmd+alt+j = set-window-height +10%182 bind.cmd+alt+j = set-window-height +10%
184 # A tile becomes a free floating window, or returns to its column.183 # A tile becomes a free floating window, or returns to its column.
185 bind.cmd+shift+space = toggle-floating184 bind.cmd+shift+space = toggle-floating
185+# Grow the focused column by one cell, then back to equal shares.
186 bind.cmd+r = cycle-column-width186 bind.cmd+r = cycle-column-width
187 bind.cmd+f = maximize-column187 bind.cmd+f = maximize-column
188 bind.cmd+shift+f = toggle-fullscreen188 bind.cmd+shift+f = toggle-fullscreen
M internal/adapters/config/cascade_test.go +8 -3
@@ -21,9 +21,14 @@ func TestCascadeOverflowInherited(t *testing.T) {
2121 }
2222 }
2323
24-func TestCascadeIgnoresColumnWidths(t *testing.T) {
25- _, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
26- if len(w) != 1 || !strings.Contains(w[0].Msg, "ignored with overflow = cascade") {
24+// Column widths are cells in every overflow, cascade included.
25+func TestCascadeColumnWidthsInCells(t *testing.T) {
26+ c, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 2, foot\nbind.cmd+w = workspace work\n")
27+ if len(w) != 0 || c.Workspaces[0].Slots[0].Cells != 2 {
28+ t.Fatalf("warnings=%v %+v", w, c.Workspaces)
29+ }
30+ _, w = parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
31+ if len(w) != 1 || !strings.Contains(w[0].Msg, "cells") {
2732 t.Fatalf("warnings=%v", w)
2833 }
2934 }
@@ -21,9 +21,14 @@ func TestCascadeOverflowInherited(t *testing.T) {
21 }21 }
22 }22 }
23 23
24-func TestCascadeIgnoresColumnWidths(t *testing.T) {24+// Column widths are cells in every overflow, cascade included.
25- _, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")25+func TestCascadeColumnWidthsInCells(t *testing.T) {
26- if len(w) != 1 || !strings.Contains(w[0].Msg, "ignored with overflow = cascade") {26+ c, w := parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 2, foot\nbind.cmd+w = workspace work\n")
27+ if len(w) != 0 || c.Workspaces[0].Slots[0].Cells != 2 {
28+ t.Fatalf("warnings=%v %+v", w, c.Workspaces)
29+ }
30+ _, w = parseString(t, "workspace.work.overflow = cascade\nworkspace.work.column.1 = 50%, foot\nbind.cmd+w = workspace work\n")
31+ if len(w) != 1 || !strings.Contains(w[0].Msg, "cells") {
27 t.Fatalf("warnings=%v", w)32 t.Fatalf("warnings=%v", w)
28 }33 }
29 }34 }
M internal/adapters/config/config.go +36 -51
@@ -64,8 +64,8 @@ var defaultBinds = []struct{ combo, action string }{
6464 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
6565 {"cmd+shift+up", "move-window-up"},
6666 {"cmd+shift+down", "move-window-down"},
67- {"cmd+alt+left", "set-column-width -10%"},
68- {"cmd+alt+right", "set-column-width +10%"},
67+ {"cmd+alt+left", "set-column-width -1"},
68+ {"cmd+alt+right", "set-column-width +1"},
6969 {"cmd+alt+up", "set-window-height -10%"},
7070 {"cmd+alt+down", "set-window-height +10%"},
7171 {"cmd+ctrl+shift+up", "move-workspace-prev"},
@@ -118,9 +118,13 @@ var actions = map[string]bool{
118118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
120120
121-// resizeStep is the +N% or -N% step of set-column-width and
122-// set-window-height, N from 1 to 100.
123-var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
121+// columnStep is the +N or -N step of set-column-width, in cells, N from 1
122+// to 16; heightStep is the +N% or -N% step of set-window-height, N from 1
123+// to 100.
124+var (
125+ columnStep = regexp.MustCompile(`^[+-]([1-9]|1[0-6])$`)
126+ heightStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
127+)
124128
125129 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
126130 var namedKeys = map[string]string{
@@ -170,7 +174,6 @@ func Defaults() ports.Config {
170174 c.Border.Active = "#808080"
171175 c.Border.Inactive = "#111111"
172176 c.Layout.MaxColumns = 2
173- c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
174177 c.Layout.Overflow = "cascade"
175178 c.Touchpad.Tap = true
176179 c.Touchpad.AccelProfile = ports.AccelAdaptive
@@ -785,17 +788,6 @@ func set(c *ports.Config, key, v string) error {
785788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
786789 case "layout.overflow":
787790 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)
788- case "layout.presets":
789- list := splitList(v)
790- if len(list) == 0 {
791- return fmt.Errorf("must not be empty")
792- }
793- for _, s := range list {
794- if !width(s) {
795- return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
796- }
797- }
798- c.Layout.Presets = list
799791 case "touchpad.tap":
800792 b, err := onOff(v)
801793 if err != nil {
@@ -1018,10 +1010,6 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
10181010 rules = o.Over(rules)
10191011 }
10201012 }
1021- if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 {
1022- line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
1023- warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)})
1024- }
10251013 if !bound[w.Name] {
10261014 line := 0
10271015 for key, n := range seen {
@@ -1089,10 +1077,11 @@ func setRule(r *ports.WindowRule, field, v string) error {
10891077 }
10901078 r.Monitor = v
10911079 case "width":
1092- if !width(v) {
1093- return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")
1080+ n, ok := cells(v)
1081+ if !ok {
1082+ return errCells
10941083 }
1095- r.Width = v
1084+ r.Cells = n
10961085 default:
10971086 return errUnknownRuleField
10981087 }
@@ -1159,10 +1148,11 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
11591148 size, cmd, _ := strings.Cut(v, ",")
11601149 size = strings.TrimSpace(size)
11611150 argv := strings.Fields(cmd)
1162- if !width(size) || len(argv) == 0 {
1163- return fmt.Errorf("must be <width>, <command> like 67%%, foot")
1151+ count, ok := cells(size)
1152+ if !ok || len(argv) == 0 {
1153+ return fmt.Errorf("must be <cells>, <command> like 2, foot (cells from 1 to 16)")
11641154 }
1165- slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}
1155+ slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv}
11661156 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
11671157 if i < len(w.Slots) && w.Slots[i].Index == index {
11681158 w.Slots[i] = slot
@@ -1231,13 +1221,17 @@ func checkAction(v string) error {
12311221 return nil
12321222 }
12331223 }
1234- for _, prefix := range []string{"set-column-width ", "set-window-height "} {
1235- if rest, ok := strings.CutPrefix(v, prefix); ok {
1236- if !resizeStep.MatchString(strings.TrimSpace(rest)) {
1237- return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix)
1238- }
1239- return nil
1224+ if rest, ok := strings.CutPrefix(v, "set-column-width "); ok {
1225+ if !columnStep.MatchString(strings.TrimSpace(rest)) {
1226+ return fmt.Errorf("set-column-width needs +N or -N cells (1-16)")
1227+ }
1228+ return nil
1229+ }
1230+ if rest, ok := strings.CutPrefix(v, "set-window-height "); ok {
1231+ if !heightStep.MatchString(strings.TrimSpace(rest)) {
1232+ return fmt.Errorf("set-window-height needs +N%% or -N%% (1-100)")
12401233 }
1234+ return nil
12411235 }
12421236 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
12431237 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
@@ -1395,23 +1389,14 @@ func ParseMode(s string) (w, h int, hz float64, err error) {
13951389 return w, h, hz, nil
13961390 }
13971391
1398-func width(s string) bool {
1399- if s == "1" {
1400- return true
1401- }
1402- if n, ok := strings.CutSuffix(s, "%"); ok {
1403- v, e := strconv.Atoi(n)
1404- return e == nil && v > 0 && v <= 100
1405- }
1406- if strings.HasSuffix(s, "px") {
1407- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
1408- return e == nil && n > 0
1409- }
1410- p := strings.Split(s, "/")
1411- if len(p) != 2 {
1412- return false
1392+// errCells is the error of a width that is not a cell count.
1393+var errCells = fmt.Errorf("must be a cell count from 1 to 16")
1394+
1395+// cells parses a column width in cells: an integer from 1 to 16.
1396+func cells(s string) (int, bool) {
1397+ if s == "" || strings.Trim(s, "0123456789") != "" {
1398+ return 0, false
14131399 }
1414- a, e1 := strconv.Atoi(p[0])
1415- b, e2 := strconv.Atoi(p[1])
1416- return e1 == nil && e2 == nil && a > 0 && a <= b
1400+ n, err := strconv.Atoi(s)
1401+ return n, err == nil && n >= 1 && n <= 16
14171402 }
@@ -64,8 +64,8 @@ var defaultBinds = []struct{ combo, action string }{
64 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},64 {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"},
65 {"cmd+shift+up", "move-window-up"},65 {"cmd+shift+up", "move-window-up"},
66 {"cmd+shift+down", "move-window-down"},66 {"cmd+shift+down", "move-window-down"},
67- {"cmd+alt+left", "set-column-width -10%"},67+ {"cmd+alt+left", "set-column-width -1"},
68- {"cmd+alt+right", "set-column-width +10%"},68+ {"cmd+alt+right", "set-column-width +1"},
69 {"cmd+alt+up", "set-window-height -10%"},69 {"cmd+alt+up", "set-window-height -10%"},
70 {"cmd+alt+down", "set-window-height +10%"},70 {"cmd+alt+down", "set-window-height +10%"},
71 {"cmd+ctrl+shift+up", "move-workspace-prev"},71 {"cmd+ctrl+shift+up", "move-workspace-prev"},
@@ -118,9 +118,13 @@ var actions = map[string]bool{
118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}118 var components = map[string]bool{"core": true, "wayland": true, "input": true, "drm": true, "seat": true, "render": true, "sync": true, "config": true, "app": true}
119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)119 var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
120 120
121-// resizeStep is the +N% or -N% step of set-column-width and121+// columnStep is the +N or -N step of set-column-width, in cells, N from 1
122-// set-window-height, N from 1 to 100.122+// to 16; heightStep is the +N% or -N% step of set-window-height, N from 1
123-var resizeStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)123+// to 100.
124+var (
125+ columnStep = regexp.MustCompile(`^[+-]([1-9]|1[0-6])$`)
126+ heightStep = regexp.MustCompile(`^[+-]([1-9][0-9]?|100)%$`)
127+)
124 128
125 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.129 // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive.
126 var namedKeys = map[string]string{130 var namedKeys = map[string]string{
@@ -170,7 +174,6 @@ func Defaults() ports.Config {
170 c.Border.Active = "#808080"174 c.Border.Active = "#808080"
171 c.Border.Inactive = "#111111"175 c.Border.Inactive = "#111111"
172 c.Layout.MaxColumns = 2176 c.Layout.MaxColumns = 2
173- c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
174 c.Layout.Overflow = "cascade"177 c.Layout.Overflow = "cascade"
175 c.Touchpad.Tap = true178 c.Touchpad.Tap = true
176 c.Touchpad.AccelProfile = ports.AccelAdaptive179 c.Touchpad.AccelProfile = ports.AccelAdaptive
@@ -785,17 +788,6 @@ func set(c *ports.Config, key, v string) error {
785 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)788 return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
786 case "layout.overflow":789 case "layout.overflow":
787 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)790 return setLayoutRule(&c.Layout.LayoutRules, "overflow", v)
788- case "layout.presets":
789- list := splitList(v)
790- if len(list) == 0 {
791- return fmt.Errorf("must not be empty")
792- }
793- for _, s := range list {
794- if !width(s) {
795- return fmt.Errorf("%q: must be a fraction like 1/2, a percentage like 50%%, 1, or pixels like 800px", s)
796- }
797- }
798- c.Layout.Presets = list
799 case "touchpad.tap":791 case "touchpad.tap":
800 b, err := onOff(v)792 b, err := onOff(v)
801 if err != nil {793 if err != nil {
@@ -1018,10 +1010,6 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
1018 rules = o.Over(rules)1010 rules = o.Over(rules)
1019 }1011 }
1020 }1012 }
1021- if overflow := w.Over(rules).Overflow; (overflow == "fixed" || overflow == "cascade") && len(w.Slots) > 0 {
1022- line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
1023- warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = %s (columns share the width)", w.Name, overflow)})
1024- }
1025 if !bound[w.Name] {1013 if !bound[w.Name] {
1026 line := 01014 line := 0
1027 for key, n := range seen {1015 for key, n := range seen {
@@ -1089,10 +1077,11 @@ func setRule(r *ports.WindowRule, field, v string) error {
1089 }1077 }
1090 r.Monitor = v1078 r.Monitor = v
1091 case "width":1079 case "width":
1092- if !width(v) {1080+ n, ok := cells(v)
1093- return fmt.Errorf("must be a width like 50%%, 1/3 or 800px")1081+ if !ok {
1082+ return errCells
1094 }1083 }
1095- r.Width = v1084+ r.Cells = n
1096 default:1085 default:
1097 return errUnknownRuleField1086 return errUnknownRuleField
1098 }1087 }
@@ -1159,10 +1148,11 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
1159 size, cmd, _ := strings.Cut(v, ",")1148 size, cmd, _ := strings.Cut(v, ",")
1160 size = strings.TrimSpace(size)1149 size = strings.TrimSpace(size)
1161 argv := strings.Fields(cmd)1150 argv := strings.Fields(cmd)
1162- if !width(size) || len(argv) == 0 {1151+ count, ok := cells(size)
1163- return fmt.Errorf("must be <width>, <command> like 67%%, foot")1152+ if !ok || len(argv) == 0 {
1153+ return fmt.Errorf("must be <cells>, <command> like 2, foot (cells from 1 to 16)")
1164 }1154 }
1165- slot := ports.SlotConfig{Index: index, Width: size, Argv: argv}1155+ slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv}
1166 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })1156 i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index })
1167 if i < len(w.Slots) && w.Slots[i].Index == index {1157 if i < len(w.Slots) && w.Slots[i].Index == index {
1168 w.Slots[i] = slot1158 w.Slots[i] = slot
@@ -1231,13 +1221,17 @@ func checkAction(v string) error {
1231 return nil1221 return nil
1232 }1222 }
1233 }1223 }
1234- for _, prefix := range []string{"set-column-width ", "set-window-height "} {1224+ if rest, ok := strings.CutPrefix(v, "set-column-width "); ok {
1235- if rest, ok := strings.CutPrefix(v, prefix); ok {1225+ if !columnStep.MatchString(strings.TrimSpace(rest)) {
1236- if !resizeStep.MatchString(strings.TrimSpace(rest)) {1226+ return fmt.Errorf("set-column-width needs +N or -N cells (1-16)")
1237- return fmt.Errorf("%sneeds +N%% or -N%% (1-100)", prefix)1227+ }
1238- }1228+ return nil
1239- return nil1229+ }
1230+ if rest, ok := strings.CutPrefix(v, "set-window-height "); ok {
1231+ if !heightStep.MatchString(strings.TrimSpace(rest)) {
1232+ return fmt.Errorf("set-window-height needs +N%% or -N%% (1-100)")
1240 }1233 }
1234+ return nil
1241 }1235 }
1242 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {1236 for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} {
1243 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {1237 if base, ok := strings.CutSuffix(v, old); ok && actions[base+repl] {
@@ -1395,23 +1389,14 @@ func ParseMode(s string) (w, h int, hz float64, err error) {
1395 return w, h, hz, nil1389 return w, h, hz, nil
1396 }1390 }
1397 1391
1398-func width(s string) bool {1392+// errCells is the error of a width that is not a cell count.
1399- if s == "1" {1393+var errCells = fmt.Errorf("must be a cell count from 1 to 16")
1400- return true1394+
1401- }1395+// cells parses a column width in cells: an integer from 1 to 16.
1402- if n, ok := strings.CutSuffix(s, "%"); ok {1396+func cells(s string) (int, bool) {
1403- v, e := strconv.Atoi(n)1397+ if s == "" || strings.Trim(s, "0123456789") != "" {
1404- return e == nil && v > 0 && v <= 1001398+ return 0, false
1405- }
1406- if strings.HasSuffix(s, "px") {
1407- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
1408- return e == nil && n > 0
1409- }
1410- p := strings.Split(s, "/")
1411- if len(p) != 2 {
1412- return false
1413 }1399 }
1414- a, e1 := strconv.Atoi(p[0])1400+ n, err := strconv.Atoi(s)
1415- b, e2 := strconv.Atoi(p[1])1401+ return n, err == nil && n >= 1 && n <= 16
1416- return e1 == nil && e2 == nil && a > 0 && a <= b
1417 }1402 }
M internal/adapters/config/config_test.go +29 -19
@@ -32,7 +32,7 @@ func TestDefaultsAndLoad(t *testing.T) {
3232 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
3333 t.Fatal(d.Binds)
3434 }
35- if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
35+ if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -1" || d.Binds["Alt+Cmd+l"] != "set-column-width +1" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
3636 t.Fatal(d.Binds)
3737 }
3838 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -224,7 +224,6 @@ keyboard.repeat-rate = 40 # inline comment
224224 terminal = foot --server
225225 background = #000000
226226 border.inactive =
227-layout.presets = 1/3, 1/2 ,1
228227 log.debug = core, input
229228 output.DP-2.scale = 4/3
230229 output.DP-2 = 5120x2160@165.058
@@ -248,7 +247,7 @@ startup = wl-paste --primary --watch cliphist store; waybar ;mako;
248247 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {
249248 t.Fatalf("%+v", c)
250249 }
251- if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
250+ if !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
252251 t.Fatalf("%+v", c)
253252 }
254253 // Scale and mode lines combine in either order.
@@ -270,7 +269,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
270269 {"layout.gaps = 201", "layout.gaps"},
271270 {"layout.max-columns = 0", "layout.max-columns"},
272271 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
273- {"layout.presets = 0px", "layout.presets"},
272+ {"layout.presets = 1/2, 1", "layout.presets: unknown key"},
274273 {"log.level = trace", "log.level"},
275274 {"log.debug = nope", "log.debug"},
276275 {"output.DP-2 = big", "output.DP-2"},
@@ -287,8 +286,12 @@ func TestWarningsKeepDefaults(t *testing.T) {
287286 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
288287 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
289288 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
290- {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
291- {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
289+ {"bind.cmd+x = set-column-width 1", "set-column-width needs"},
290+ {"bind.cmd+x = set-column-width +0", "set-column-width needs"},
291+ {"bind.cmd+x = set-column-width +17", "set-column-width needs"},
292+ {"bind.cmd+x = set-column-width +10%", "set-column-width needs"},
293+ {"bind.cmd+x = set-column-width +-1", "set-column-width needs"},
294+ {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
292295 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
293296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
294297 {"bind.cmd+x = spawn ", "unknown action"},
@@ -523,17 +526,22 @@ workspace.web.column.1 = 50%, foot
523526 func TestWorkspaceSlots(t *testing.T) {
524527 c, w := parseString(t, `layout.overflow = scroll
525528 workspace.dev.monitor = DP-2
526-workspace.dev.column.2 = 33%, foot --title x
527-workspace.dev.column.1 = 67%, code --new-window
528-workspace.dev.column.2 = 1/3, kitty
529-workspace.dev.column.0 = 50%, foot
530-workspace.dev.column.3 = 50%
529+workspace.dev.column.2 = 1, foot --title x
530+workspace.dev.column.1 = 3, code --new-window
531+workspace.dev.column.2 = 16, kitty
532+workspace.dev.column.0 = 2, foot
533+workspace.dev.column.3 = 2
531534 workspace.dev.column.4 = huge, foot
535+workspace.dev.column.5 = 67%, foot
536+workspace.dev.column.6 = 1/3, foot
537+workspace.dev.column.7 = 800px, foot
538+workspace.dev.column.8 = 0, foot
539+workspace.dev.column.9 = 17, foot
532540 bind.cmd+d = workspace dev
533541 `)
534542 want := []ports.SlotConfig{
535- {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
536- {Index: 2, Width: "1/3", Argv: []string{"kitty"}},
543+ {Index: 1, Cells: 3, Argv: []string{"code", "--new-window"}},
544+ {Index: 2, Cells: 16, Argv: []string{"kitty"}},
537545 }
538546 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
539547 t.Fatalf("%+v", c.Workspaces)
@@ -542,17 +550,19 @@ bind.cmd+d = workspace dev
542550 for _, x := range w {
543551 lines = append(lines, x.Line)
544552 }
545- if !reflect.DeepEqual(lines, []int{5, 6, 7, 8}) {
553+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 9, 10, 11, 12, 13}) {
546554 t.Fatal(w)
547555 }
548556 }
549557
550-func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
551- _, w := parseString(t, `workspace.dev.overflow = fixed
552-workspace.dev.column.1 = 67%, foot
558+// Slot widths are cells in every overflow: nothing is ignored.
559+func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) {
560+ c, w := parseString(t, `workspace.dev.overflow = fixed
561+workspace.dev.column.1 = 2, foot
562+bind.cmd+d = workspace dev
553563 `)
554- if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
555- t.Fatal(w)
564+ if len(w) != 0 || len(c.Workspaces) != 1 || len(c.Workspaces[0].Slots) != 1 || c.Workspaces[0].Slots[0].Cells != 2 {
565+ t.Fatal(w, c.Workspaces)
556566 }
557567 }
558568
@@ -32,7 +32,7 @@ func TestDefaultsAndLoad(t *testing.T) {
32 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {32 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
33 t.Fatal(d.Binds)33 t.Fatal(d.Binds)
34 }34 }
35- if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {35+ if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -1" || d.Binds["Alt+Cmd+l"] != "set-column-width +1" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-next" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-prev" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
36 t.Fatal(d.Binds)36 t.Fatal(d.Binds)
37 }37 }
38 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {38 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
@@ -224,7 +224,6 @@ keyboard.repeat-rate = 40 # inline comment
224 terminal = foot --server224 terminal = foot --server
225 background = #000000225 background = #000000
226 border.inactive =226 border.inactive =
227-layout.presets = 1/3, 1/2 ,1
228 log.debug = core, input227 log.debug = core, input
229 output.DP-2.scale = 4/3228 output.DP-2.scale = 4/3
230 output.DP-2 = 5120x2160@165.058229 output.DP-2 = 5120x2160@165.058
@@ -248,7 +247,7 @@ startup = wl-paste --primary --watch cliphist store; waybar ;mako;
248 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {247 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {
249 t.Fatalf("%+v", c)248 t.Fatalf("%+v", c)
250 }249 }
251- if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {250+ if !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
252 t.Fatalf("%+v", c)251 t.Fatalf("%+v", c)
253 }252 }
254 // Scale and mode lines combine in either order.253 // Scale and mode lines combine in either order.
@@ -270,7 +269,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
270 {"layout.gaps = 201", "layout.gaps"},269 {"layout.gaps = 201", "layout.gaps"},
271 {"layout.max-columns = 0", "layout.max-columns"},270 {"layout.max-columns = 0", "layout.max-columns"},
272 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},271 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
273- {"layout.presets = 0px", "layout.presets"},272+ {"layout.presets = 1/2, 1", "layout.presets: unknown key"},
274 {"log.level = trace", "log.level"},273 {"log.level = trace", "log.level"},
275 {"log.debug = nope", "log.debug"},274 {"log.debug = nope", "log.debug"},
276 {"output.DP-2 = big", "output.DP-2"},275 {"output.DP-2 = big", "output.DP-2"},
@@ -287,8 +286,12 @@ func TestWarningsKeepDefaults(t *testing.T) {
287 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},286 {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`},
288 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},287 {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`},
289 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},288 {"bind.cmd+x = move-column-to-workspace-up", `renamed to "move-column-to-workspace-prev"`},
290- {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},289+ {"bind.cmd+x = set-column-width 1", "set-column-width needs"},
291- {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},290+ {"bind.cmd+x = set-column-width +0", "set-column-width needs"},
291+ {"bind.cmd+x = set-column-width +17", "set-column-width needs"},
292+ {"bind.cmd+x = set-column-width +10%", "set-column-width needs"},
293+ {"bind.cmd+x = set-column-width +-1", "set-column-width needs"},
294+ {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
292 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},295 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
293 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},296 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
294 {"bind.cmd+x = spawn ", "unknown action"},297 {"bind.cmd+x = spawn ", "unknown action"},
@@ -523,17 +526,22 @@ workspace.web.column.1 = 50%, foot
523 func TestWorkspaceSlots(t *testing.T) {526 func TestWorkspaceSlots(t *testing.T) {
524 c, w := parseString(t, `layout.overflow = scroll527 c, w := parseString(t, `layout.overflow = scroll
525 workspace.dev.monitor = DP-2528 workspace.dev.monitor = DP-2
526-workspace.dev.column.2 = 33%, foot --title x529+workspace.dev.column.2 = 1, foot --title x
527-workspace.dev.column.1 = 67%, code --new-window530+workspace.dev.column.1 = 3, code --new-window
528-workspace.dev.column.2 = 1/3, kitty531+workspace.dev.column.2 = 16, kitty
529-workspace.dev.column.0 = 50%, foot532+workspace.dev.column.0 = 2, foot
530-workspace.dev.column.3 = 50%533+workspace.dev.column.3 = 2
531 workspace.dev.column.4 = huge, foot534 workspace.dev.column.4 = huge, foot
535+workspace.dev.column.5 = 67%, foot
536+workspace.dev.column.6 = 1/3, foot
537+workspace.dev.column.7 = 800px, foot
538+workspace.dev.column.8 = 0, foot
539+workspace.dev.column.9 = 17, foot
532 bind.cmd+d = workspace dev540 bind.cmd+d = workspace dev
533 `)541 `)
534 want := []ports.SlotConfig{542 want := []ports.SlotConfig{
535- {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},543+ {Index: 1, Cells: 3, Argv: []string{"code", "--new-window"}},
536- {Index: 2, Width: "1/3", Argv: []string{"kitty"}},544+ {Index: 2, Cells: 16, Argv: []string{"kitty"}},
537 }545 }
538 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {546 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
539 t.Fatalf("%+v", c.Workspaces)547 t.Fatalf("%+v", c.Workspaces)
@@ -542,17 +550,19 @@ bind.cmd+d = workspace dev
542 for _, x := range w {550 for _, x := range w {
543 lines = append(lines, x.Line)551 lines = append(lines, x.Line)
544 }552 }
545- if !reflect.DeepEqual(lines, []int{5, 6, 7, 8}) {553+ if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 9, 10, 11, 12, 13}) {
546 t.Fatal(w)554 t.Fatal(w)
547 }555 }
548 }556 }
549 557
550-func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {558+// Slot widths are cells in every overflow: nothing is ignored.
551- _, w := parseString(t, `workspace.dev.overflow = fixed559+func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) {
552-workspace.dev.column.1 = 67%, foot560+ c, w := parseString(t, `workspace.dev.overflow = fixed
561+workspace.dev.column.1 = 2, foot
562+bind.cmd+d = workspace dev
553 `)563 `)
554- if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {564+ if len(w) != 0 || len(c.Workspaces) != 1 || len(c.Workspaces[0].Slots) != 1 || c.Workspaces[0].Slots[0].Cells != 2 {
555- t.Fatal(w)565+ t.Fatal(w, c.Workspaces)
556 }566 }
557 }567 }
558 568
M internal/adapters/config/rules_test.go +16 -10
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "strconv"
45 "strings"
56 "testing"
67
@@ -17,8 +18,8 @@ rule.games.workspace = 3
1718 rule.chat.app-id = org\.telegram\..*|discord
1819 rule.chat.workspace = chat
1920 rule.chat.monitor = DP-2
20-rule.chat.width = 1/3
21-rule.games.width = 50%
21+rule.chat.width = 1
22+rule.games.width = 4
2223 `)
2324 if len(w) != 0 {
2425 t.Fatal(w)
@@ -27,10 +28,10 @@ rule.games.width = 50%
2728 t.Fatalf("%+v", c.Rules)
2829 }
2930 g, ch := c.Rules[0], c.Rules[1]
30- if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {
31+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Cells != 4 || g.Monitor != "" {
3132 t.Fatalf("%+v", g)
3233 }
33- if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {
34+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Cells != 1 {
3435 t.Fatalf("%+v", ch)
3536 }
3637 if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
@@ -71,7 +72,12 @@ func TestRuleWarnings(t *testing.T) {
7172 // An invalid duplicate keeps the earlier value and the rule.
7273 {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},
7374 {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},
74- {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
75+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a cell count from 1 to 16", 2, 0},
76+ {"percent width", "rule.a.app-id = foo\nrule.a.width = 50%\n", "rule.a.width: must be a cell count", 2, 0},
77+ {"fraction width", "rule.a.app-id = foo\nrule.a.width = 1/3\n", "rule.a.width: must be a cell count", 2, 0},
78+ {"pixel width", "rule.a.app-id = foo\nrule.a.width = 800px\n", "rule.a.width: must be a cell count", 2, 0},
79+ {"zero width", "rule.a.app-id = foo\nrule.a.width = 0\n", "rule.a.width: must be a cell count", 2, 0},
80+ {"too wide", "rule.a.app-id = foo\nrule.a.width = 17\n", "rule.a.width: must be a cell count", 2, 0},
7581 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
7682 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
7783 {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
@@ -90,11 +96,11 @@ func TestRuleWarnings(t *testing.T) {
9096 }
9197 }
9298
93-func TestRuleWidthReusesColumnWidth(t *testing.T) {
94- for _, v := range []string{"1", "33%", "1/3", "640px"} {
95- c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")
96- if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {
97- t.Fatalf("%s: %v %+v", v, w, c.Rules)
99+func TestRuleWidthIsACellCount(t *testing.T) {
100+ for _, v := range []int{1, 2, 16} {
101+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+strconv.Itoa(v)+"\n")
102+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Cells != v {
103+ t.Fatalf("%d: %v %+v", v, w, c.Rules)
98104 }
99105 }
100106 }
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "strconv"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
@@ -17,8 +18,8 @@ rule.games.workspace = 3
17 rule.chat.app-id = org\.telegram\..*|discord18 rule.chat.app-id = org\.telegram\..*|discord
18 rule.chat.workspace = chat19 rule.chat.workspace = chat
19 rule.chat.monitor = DP-220 rule.chat.monitor = DP-2
20-rule.chat.width = 1/321+rule.chat.width = 1
21-rule.games.width = 50%22+rule.games.width = 4
22 `)23 `)
23 if len(w) != 0 {24 if len(w) != 0 {
24 t.Fatal(w)25 t.Fatal(w)
@@ -27,10 +28,10 @@ rule.games.width = 50%
27 t.Fatalf("%+v", c.Rules)28 t.Fatalf("%+v", c.Rules)
28 }29 }
29 g, ch := c.Rules[0], c.Rules[1]30 g, ch := c.Rules[0], c.Rules[1]
30- if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Width != "50%" || g.Monitor != "" {31+ if g.Floating == nil || !*g.Floating || g.Workspace != "3" || g.Cells != 4 || g.Monitor != "" {
31 t.Fatalf("%+v", g)32 t.Fatalf("%+v", g)
32 }33 }
33- if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Width != "1/3" {34+ if ch.Floating != nil || ch.Workspace != "chat" || ch.Monitor != "DP-2" || ch.Cells != 1 {
34 t.Fatalf("%+v", ch)35 t.Fatalf("%+v", ch)
35 }36 }
36 if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {37 if !g.AppID.MatchString("steam_app_1245620") || !ch.AppID.MatchString("discord") || !ch.AppID.MatchString("org.telegram.desktop") {
@@ -71,7 +72,12 @@ func TestRuleWarnings(t *testing.T) {
71 // An invalid duplicate keeps the earlier value and the rule.72 // An invalid duplicate keeps the earlier value and the rule.
72 {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},73 {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},
73 {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},74 {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},
74- {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},75+ {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a cell count from 1 to 16", 2, 0},
76+ {"percent width", "rule.a.app-id = foo\nrule.a.width = 50%\n", "rule.a.width: must be a cell count", 2, 0},
77+ {"fraction width", "rule.a.app-id = foo\nrule.a.width = 1/3\n", "rule.a.width: must be a cell count", 2, 0},
78+ {"pixel width", "rule.a.app-id = foo\nrule.a.width = 800px\n", "rule.a.width: must be a cell count", 2, 0},
79+ {"zero width", "rule.a.app-id = foo\nrule.a.width = 0\n", "rule.a.width: must be a cell count", 2, 0},
80+ {"too wide", "rule.a.app-id = foo\nrule.a.width = 17\n", "rule.a.width: must be a cell count", 2, 0},
75 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},81 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
76 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},82 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
77 {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},83 {"empty monitor", "rule.a.app-id = foo\nrule.a.monitor =\n", "rule.a.monitor: needs", 2, 0},
@@ -90,11 +96,11 @@ func TestRuleWarnings(t *testing.T) {
90 }96 }
91 }97 }
92 98
93-func TestRuleWidthReusesColumnWidth(t *testing.T) {99+func TestRuleWidthIsACellCount(t *testing.T) {
94- for _, v := range []string{"1", "33%", "1/3", "640px"} {100+ for _, v := range []int{1, 2, 16} {
95- c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+v+"\n")101+ c, w := parseString(t, "rule.a.app-id = foo\nrule.a.width = "+strconv.Itoa(v)+"\n")
96- if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Width != v {102+ if len(w) != 0 || len(c.Rules) != 1 || c.Rules[0].Cells != v {
97- t.Fatalf("%s: %v %+v", v, w, c.Rules)103+ t.Fatalf("%d: %v %+v", v, w, c.Rules)
98 }104 }
99 }105 }
100 }106 }
M internal/core/action.go +31 -15
@@ -91,30 +91,40 @@ var resizePrefixes = [...]string{
9191 ResizeHeight: "set-window-height ",
9292 }
9393
94-// ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and
95-// "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign
96-// is required.
97-func ResizeArg(a Action) (axis, pct int, ok bool) {
94+// ResizeArg parses "set-column-width +N" (axis ResizeWidth) and
95+// "set-window-height -N%" (axis ResizeHeight). The sign is required and the
96+// unit depends on the axis: step is in cells for a width (N from 1 to 16,
97+// no %), in percent for a height (N from 1 to 100, with %).
98+func ResizeArg(a Action) (axis, step int, ok bool) {
9899 for i, prefix := range resizePrefixes {
99100 rest, found := strings.CutPrefix(string(a), prefix)
100101 if !found {
101102 continue
102103 }
103- if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok {
104- return i, p, true
104+ rest = strings.TrimSpace(rest)
105+ if i == ResizeWidth {
106+ if p, ok := parseResizeStep(rest, maxCells); ok {
107+ return i, p, true
108+ }
109+ return 0, 0, false
110+ }
111+ if num, found := strings.CutSuffix(rest, "%"); found {
112+ if p, ok := parseResizeStep(num, 100); ok {
113+ return i, p, true
114+ }
105115 }
106116 return 0, 0, false
107117 }
108118 return 0, 0, false
109119 }
110120
111-// parseResizeStep parses "+N%" or "-N%" with N from 1 to 100.
112-func parseResizeStep(s string) (int, bool) {
113- if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' {
121+// parseResizeStep parses "+N" or "-N" with N from 1 to limit.
122+func parseResizeStep(s string, limit int) (int, bool) {
123+ if len(s) < 2 || (s[0] != '+' && s[0] != '-') {
114124 return 0, false
115125 }
116- n, err := strconv.Atoi(s[1 : len(s)-1])
117- if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' {
126+ n, err := strconv.Atoi(s[1:])
127+ if err != nil || n < 1 || n > limit || s[1] == '+' || s[1] == '-' {
118128 return 0, false
119129 }
120130 if s[0] == '-' {
@@ -418,11 +428,17 @@ func (w *Workspace) Apply(a Action) Effect {
418428 if w.freeAction(a) {
419429 return Effect{}
420430 }
421- if axis, pct, ok := ResizeArg(a); ok {
431+ if axis, step, ok := ResizeArg(a); ok {
422432 if axis == ResizeWidth {
423- w.ResizeColumn(pct)
433+ for range max(step, -step) {
434+ if step > 0 {
435+ w.GrowColumn(false)
436+ } else {
437+ w.ShrinkColumn()
438+ }
439+ }
424440 } else {
425- w.ResizeRow(pct)
441+ w.ResizeRow(step)
426442 }
427443 return Effect{}
428444 }
@@ -446,7 +462,7 @@ func (w *Workspace) Apply(a Action) Effect {
446462 case ActionMoveWindowDown:
447463 w.MoveWindow(1)
448464 case ActionCycleColumnWidth:
449- w.CycleWidth()
465+ w.GrowColumn(true)
450466 case ActionMaximizeColumn:
451467 w.ToggleFullWidth()
452468 case ActionToggleFullscreen:
@@ -91,30 +91,40 @@ var resizePrefixes = [...]string{
91 ResizeHeight: "set-window-height ",91 ResizeHeight: "set-window-height ",
92 }92 }
93 93
94-// ResizeArg parses "set-column-width +N%" (axis ResizeWidth) and94+// ResizeArg parses "set-column-width +N" (axis ResizeWidth) and
95-// "set-window-height -N%" (axis ResizeHeight); N is 1 to 100 and the sign95+// "set-window-height -N%" (axis ResizeHeight). The sign is required and the
96-// is required.96+// unit depends on the axis: step is in cells for a width (N from 1 to 16,
97-func ResizeArg(a Action) (axis, pct int, ok bool) {97+// no %), in percent for a height (N from 1 to 100, with %).
98+func ResizeArg(a Action) (axis, step int, ok bool) {
98 for i, prefix := range resizePrefixes {99 for i, prefix := range resizePrefixes {
99 rest, found := strings.CutPrefix(string(a), prefix)100 rest, found := strings.CutPrefix(string(a), prefix)
100 if !found {101 if !found {
101 continue102 continue
102 }103 }
103- if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok {104+ rest = strings.TrimSpace(rest)
104- return i, p, true105+ if i == ResizeWidth {
106+ if p, ok := parseResizeStep(rest, maxCells); ok {
107+ return i, p, true
108+ }
109+ return 0, 0, false
110+ }
111+ if num, found := strings.CutSuffix(rest, "%"); found {
112+ if p, ok := parseResizeStep(num, 100); ok {
113+ return i, p, true
114+ }
105 }115 }
106 return 0, 0, false116 return 0, 0, false
107 }117 }
108 return 0, 0, false118 return 0, 0, false
109 }119 }
110 120
111-// parseResizeStep parses "+N%" or "-N%" with N from 1 to 100.121+// parseResizeStep parses "+N" or "-N" with N from 1 to limit.
112-func parseResizeStep(s string) (int, bool) {122+func parseResizeStep(s string, limit int) (int, bool) {
113- if len(s) < 3 || (s[0] != '+' && s[0] != '-') || s[len(s)-1] != '%' {123+ if len(s) < 2 || (s[0] != '+' && s[0] != '-') {
114 return 0, false124 return 0, false
115 }125 }
116- n, err := strconv.Atoi(s[1 : len(s)-1])126+ n, err := strconv.Atoi(s[1:])
117- if err != nil || n < 1 || n > 100 || s[1] == '+' || s[1] == '-' {127+ if err != nil || n < 1 || n > limit || s[1] == '+' || s[1] == '-' {
118 return 0, false128 return 0, false
119 }129 }
120 if s[0] == '-' {130 if s[0] == '-' {
@@ -418,11 +428,17 @@ func (w *Workspace) Apply(a Action) Effect {
418 if w.freeAction(a) {428 if w.freeAction(a) {
419 return Effect{}429 return Effect{}
420 }430 }
421- if axis, pct, ok := ResizeArg(a); ok {431+ if axis, step, ok := ResizeArg(a); ok {
422 if axis == ResizeWidth {432 if axis == ResizeWidth {
423- w.ResizeColumn(pct)433+ for range max(step, -step) {
434+ if step > 0 {
435+ w.GrowColumn(false)
436+ } else {
437+ w.ShrinkColumn()
438+ }
439+ }
424 } else {440 } else {
425- w.ResizeRow(pct)441+ w.ResizeRow(step)
426 }442 }
427 return Effect{}443 return Effect{}
428 }444 }
@@ -446,7 +462,7 @@ func (w *Workspace) Apply(a Action) Effect {
446 case ActionMoveWindowDown:462 case ActionMoveWindowDown:
447 w.MoveWindow(1)463 w.MoveWindow(1)
448 case ActionCycleColumnWidth:464 case ActionCycleColumnWidth:
449- w.CycleWidth()465+ w.GrowColumn(true)
450 case ActionMaximizeColumn:466 case ActionMaximizeColumn:
451 w.ToggleFullWidth()467 w.ToggleFullWidth()
452 case ActionToggleFullscreen:468 case ActionToggleFullscreen:
M internal/core/action_test.go +18 -14
@@ -38,26 +38,30 @@ func TestDefaultBindsAccepted(t *testing.T) {
3838
3939 func TestResizeArg(t *testing.T) {
4040 for _, tc := range []struct {
41- a core.Action
42- axis, pct int
43- ok bool
41+ a core.Action
42+ axis, step int
43+ ok bool
4444 }{
45- {"set-column-width +10%", core.ResizeWidth, 10, true},
46- {"set-column-width -5%", core.ResizeWidth, -5, true},
45+ {"set-column-width +1", core.ResizeWidth, 1, true},
46+ {"set-column-width -5", core.ResizeWidth, -5, true},
47+ {"set-column-width +16", core.ResizeWidth, 16, true},
4748 {"set-window-height +100%", core.ResizeHeight, 100, true},
4849 {"set-window-height -1%", core.ResizeHeight, -1, true},
49- {"set-column-width 10%", 0, 0, false},
50- {"set-column-width +0%", 0, 0, false},
51- {"set-column-width +101%", 0, 0, false},
52- {"set-column-width +10", 0, 0, false},
53- {"set-column-width +-1%", 0, 0, false},
54- {"set-column-width ++1%", 0, 0, false},
50+ {"set-column-width 1", 0, 0, false},
51+ {"set-column-width +0", 0, 0, false},
52+ {"set-column-width +17", 0, 0, false},
53+ {"set-column-width +10%", 0, 0, false},
54+ {"set-column-width +-1", 0, 0, false},
55+ {"set-column-width ++1", 0, 0, false},
56+ {"set-window-height +10", 0, 0, false},
57+ {"set-window-height +101%", 0, 0, false},
58+ {"set-window-height +0%", 0, 0, false},
5559 {"set-window-height", 0, 0, false},
5660 {"cycle-column-width", 0, 0, false},
5761 } {
58- axis, pct, ok := core.ResizeArg(tc.a)
59- if axis != tc.axis || pct != tc.pct || ok != tc.ok {
60- t.Errorf("%q: %d %d %v", tc.a, axis, pct, ok)
62+ axis, step, ok := core.ResizeArg(tc.a)
63+ if axis != tc.axis || step != tc.step || ok != tc.ok {
64+ t.Errorf("%q: %d %d %v", tc.a, axis, step, ok)
6165 }
6266 }
6367 }
@@ -38,26 +38,30 @@ func TestDefaultBindsAccepted(t *testing.T) {
38 38
39 func TestResizeArg(t *testing.T) {39 func TestResizeArg(t *testing.T) {
40 for _, tc := range []struct {40 for _, tc := range []struct {
41- a core.Action41+ a core.Action
42- axis, pct int42+ axis, step int
43- ok bool43+ ok bool
44 }{44 }{
45- {"set-column-width +10%", core.ResizeWidth, 10, true},45+ {"set-column-width +1", core.ResizeWidth, 1, true},
46- {"set-column-width -5%", core.ResizeWidth, -5, true},46+ {"set-column-width -5", core.ResizeWidth, -5, true},
47+ {"set-column-width +16", core.ResizeWidth, 16, true},
47 {"set-window-height +100%", core.ResizeHeight, 100, true},48 {"set-window-height +100%", core.ResizeHeight, 100, true},
48 {"set-window-height -1%", core.ResizeHeight, -1, true},49 {"set-window-height -1%", core.ResizeHeight, -1, true},
49- {"set-column-width 10%", 0, 0, false},50+ {"set-column-width 1", 0, 0, false},
50- {"set-column-width +0%", 0, 0, false},51+ {"set-column-width +0", 0, 0, false},
51- {"set-column-width +101%", 0, 0, false},52+ {"set-column-width +17", 0, 0, false},
52- {"set-column-width +10", 0, 0, false},53+ {"set-column-width +10%", 0, 0, false},
53- {"set-column-width +-1%", 0, 0, false},54+ {"set-column-width +-1", 0, 0, false},
54- {"set-column-width ++1%", 0, 0, false},55+ {"set-column-width ++1", 0, 0, false},
56+ {"set-window-height +10", 0, 0, false},
57+ {"set-window-height +101%", 0, 0, false},
58+ {"set-window-height +0%", 0, 0, false},
55 {"set-window-height", 0, 0, false},59 {"set-window-height", 0, 0, false},
56 {"cycle-column-width", 0, 0, false},60 {"cycle-column-width", 0, 0, false},
57 } {61 } {
58- axis, pct, ok := core.ResizeArg(tc.a)62+ axis, step, ok := core.ResizeArg(tc.a)
59- if axis != tc.axis || pct != tc.pct || ok != tc.ok {63+ if axis != tc.axis || step != tc.step || ok != tc.ok {
60- t.Errorf("%q: %d %d %v", tc.a, axis, pct, ok)64+ t.Errorf("%q: %d %d %v", tc.a, axis, step, ok)
61 }65 }
62 }66 }
63 }67 }
M internal/core/cascade.go +82 -12
@@ -4,8 +4,16 @@ import "slices"
44
55 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
66 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+
8+// cellX is the x of column i in its lane, from the usable area's left edge.
79 func (w *Workspace) cellX(i int) int {
8- return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap())
10+ first, last := w.laneBounds(w.laneOf(i))
11+ S, u, sum := w.laneStats(first, last)
12+ x := w.gap()
13+ for j := first; j < i; j++ {
14+ x += w.laneWidth(j, first, last, S, u, sum) + w.gap()
15+ }
16+ return x
917 }
1018 func (w *Workspace) cascadeBands() (n, spacing int) {
1119 if len(w.Columns) == 0 || w.Usable.H <= 0 {
@@ -14,12 +22,64 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
1422 return w.band(len(w.Columns)-1) + 1, w.Usable.H
1523 }
1624
17-// cellWidth is the width of the cell of column i: the columns of its band
18-// share the usable width equally, as in fixed overflow, so a lone column
19-// fills it.
25+// cellWidth is the width of column i in its lane: the columns of a band
26+// (the whole row in fixed overflow) share the usable width by their cells,
27+// auto columns sharing what the others leave, so a lone column fills it.
2028 func (w *Workspace) cellWidth(i int) int {
21- first, last := w.laneBounds(w.band(i))
22- return w.equalShare(last - first + 1)
29+ first, last := w.laneBounds(w.laneOf(i))
30+ S, u, sum := w.laneStats(first, last)
31+ return w.laneWidth(i, first, last, S, u, sum)
32+}
33+
34+// laneStats measures the lane first..last: S is the sum of the cells of
35+// its set columns, u the count of its auto columns and sum the total of
36+// their weights (laneWeight).
37+func (w *Workspace) laneStats(first, last int) (S, u, sum int) {
38+ for i := first; i <= last; i++ {
39+ if c := w.Columns[i].Cells; c > 0 {
40+ S += c
41+ } else {
42+ u++
43+ }
44+ }
45+ for i := first; i <= last; i++ {
46+ sum += w.laneWeight(i, S, u)
47+ }
48+ return S, u, sum
49+}
50+
51+// laneWeight is the share of column i in its lane, given its stats. With
52+// room left for every auto column, they share the cells the set ones leave
53+// and the set ones keep their cells; otherwise an auto column weighs one
54+// cell.
55+func (w *Workspace) laneWeight(i, S, u int) int {
56+ k := max(w.MaxColumns, 1)
57+ c := w.Columns[i].Cells
58+ switch {
59+ case u == 0:
60+ return c
61+ case k-S >= u:
62+ if c > 0 {
63+ return c * u
64+ }
65+ return k - S
66+ case c > 0:
67+ return c
68+ }
69+ return 1
70+}
71+
72+// laneWidth is the width of column i of the lane first..last with the
73+// stats of laneStats: the usable width after gaps, split by weight. The
74+// remainder under one pixel per weight stays empty, as in equalShare.
75+func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
76+ if sum <= 0 {
77+ return 0
78+ }
79+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
80+ // floor(avail*weight/sum) without overflowing on a huge area.
81+ weight := w.laneWeight(i, S, u)
82+ return avail/sum*weight + avail%sum*weight/sum
2383 }
2484
2585 // equalShare is the width of each of k columns sharing the usable width
@@ -51,13 +111,23 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
51111 g := w.gap()
52112 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
53113 view := w.View + w.shiftPixels()
54- for i := range w.Columns {
55- r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}
56- if w.Columns[i].FullWidth && !ignoreFullWidth {
57- r.X = w.Usable.X + g
58- r.W = max(w.Usable.W-2*g, 0)
114+ for lane := 0; ; lane++ {
115+ first, last := w.laneBounds(lane)
116+ if first > last {
117+ break
118+ }
119+ S, u, sum := w.laneStats(first, last)
120+ x := g
121+ for i := first; i <= last; i++ {
122+ cw := w.laneWidth(i, first, last, S, u, sum)
123+ r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
124+ if w.Columns[i].FullWidth && !ignoreFullWidth {
125+ r.X = w.Usable.X + g
126+ r.W = max(w.Usable.W-2*g, 0)
127+ }
128+ rects[i] = r
129+ x += cw + g
59130 }
60- rects[i] = r
61131 }
62132 return rects
63133 }
@@ -4,8 +4,16 @@ import "slices"
4 4
5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }5 func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) }
6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }6 func (w *Workspace) sameBand(a, b int) bool { return !w.policy().wraps || w.band(a) == w.band(b) }
7+
8+// cellX is the x of column i in its lane, from the usable area's left edge.
7 func (w *Workspace) cellX(i int) int {9 func (w *Workspace) cellX(i int) int {
8- return w.gap() + (i%max(w.MaxColumns, 1))*(w.cellWidth(i)+w.gap())10+ first, last := w.laneBounds(w.laneOf(i))
11+ S, u, sum := w.laneStats(first, last)
12+ x := w.gap()
13+ for j := first; j < i; j++ {
14+ x += w.laneWidth(j, first, last, S, u, sum) + w.gap()
15+ }
16+ return x
9 }17 }
10 func (w *Workspace) cascadeBands() (n, spacing int) {18 func (w *Workspace) cascadeBands() (n, spacing int) {
11 if len(w.Columns) == 0 || w.Usable.H <= 0 {19 if len(w.Columns) == 0 || w.Usable.H <= 0 {
@@ -14,12 +22,64 @@ func (w *Workspace) cascadeBands() (n, spacing int) {
14 return w.band(len(w.Columns)-1) + 1, w.Usable.H22 return w.band(len(w.Columns)-1) + 1, w.Usable.H
15 }23 }
16 24
17-// cellWidth is the width of the cell of column i: the columns of its band25+// cellWidth is the width of column i in its lane: the columns of a band
18-// share the usable width equally, as in fixed overflow, so a lone column26+// (the whole row in fixed overflow) share the usable width by their cells,
19-// fills it.27+// auto columns sharing what the others leave, so a lone column fills it.
20 func (w *Workspace) cellWidth(i int) int {28 func (w *Workspace) cellWidth(i int) int {
21- first, last := w.laneBounds(w.band(i))29+ first, last := w.laneBounds(w.laneOf(i))
22- return w.equalShare(last - first + 1)30+ S, u, sum := w.laneStats(first, last)
31+ return w.laneWidth(i, first, last, S, u, sum)
32+}
33+
34+// laneStats measures the lane first..last: S is the sum of the cells of
35+// its set columns, u the count of its auto columns and sum the total of
36+// their weights (laneWeight).
37+func (w *Workspace) laneStats(first, last int) (S, u, sum int) {
38+ for i := first; i <= last; i++ {
39+ if c := w.Columns[i].Cells; c > 0 {
40+ S += c
41+ } else {
42+ u++
43+ }
44+ }
45+ for i := first; i <= last; i++ {
46+ sum += w.laneWeight(i, S, u)
47+ }
48+ return S, u, sum
49+}
50+
51+// laneWeight is the share of column i in its lane, given its stats. With
52+// room left for every auto column, they share the cells the set ones leave
53+// and the set ones keep their cells; otherwise an auto column weighs one
54+// cell.
55+func (w *Workspace) laneWeight(i, S, u int) int {
56+ k := max(w.MaxColumns, 1)
57+ c := w.Columns[i].Cells
58+ switch {
59+ case u == 0:
60+ return c
61+ case k-S >= u:
62+ if c > 0 {
63+ return c * u
64+ }
65+ return k - S
66+ case c > 0:
67+ return c
68+ }
69+ return 1
70+}
71+
72+// laneWidth is the width of column i of the lane first..last with the
73+// stats of laneStats: the usable width after gaps, split by weight. The
74+// remainder under one pixel per weight stays empty, as in equalShare.
75+func (w *Workspace) laneWidth(i, first, last, S, u, sum int) int {
76+ if sum <= 0 {
77+ return 0
78+ }
79+ avail := max(w.Usable.W-w.gap()*(last-first+2), 0)
80+ // floor(avail*weight/sum) without overflowing on a huge area.
81+ weight := w.laneWeight(i, S, u)
82+ return avail/sum*weight + avail%sum*weight/sum
23 }83 }
24 84
25 // equalShare is the width of each of k columns sharing the usable width85 // equalShare is the width of each of k columns sharing the usable width
@@ -51,13 +111,23 @@ func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
51 g := w.gap()111 g := w.gap()
52 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]112 rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)]
53 view := w.View + w.shiftPixels()113 view := w.View + w.shiftPixels()
54- for i := range w.Columns {114+ for lane := 0; ; lane++ {
55- r := Rect{X: w.Usable.X + w.cellX(i), Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: w.cellWidth(i), H: max(w.Usable.H-2*g, 0)}115+ first, last := w.laneBounds(lane)
56- if w.Columns[i].FullWidth && !ignoreFullWidth {116+ if first > last {
57- r.X = w.Usable.X + g117+ break
58- r.W = max(w.Usable.W-2*g, 0)118+ }
119+ S, u, sum := w.laneStats(first, last)
120+ x := g
121+ for i := first; i <= last; i++ {
122+ cw := w.laneWidth(i, first, last, S, u, sum)
123+ r := Rect{X: w.Usable.X + x, Y: w.Usable.Y + g + w.band(i)*w.Usable.H - view, W: cw, H: max(w.Usable.H-2*g, 0)}
124+ if w.Columns[i].FullWidth && !ignoreFullWidth {
125+ r.X = w.Usable.X + g
126+ r.W = max(w.Usable.W-2*g, 0)
127+ }
128+ rects[i] = r
129+ x += cw + g
59 }130 }
60- rects[i] = r
61 }131 }
62 return rects132 return rects
63 }133 }
M internal/core/core.go +1 -10
@@ -116,7 +116,6 @@ type Core struct {
116116 layerChanged bool
117117 sent sentCache
118118 specs []NamedWorkspace
119- presets []Width
120119 slots map[slotKey]*slotState
121120 rules []rule
122121 placement spawnPlacement
@@ -227,14 +226,6 @@ func (c *Core) apply(cfg ports.Config) error {
227226 if cfg.Layout.MaxColumns < 1 {
228227 return fmt.Errorf("invalid max columns")
229228 }
230- presets := make([]Width, 0, len(cfg.Layout.Presets))
231- for _, s := range cfg.Layout.Presets {
232- v, e := ParseWidth(s)
233- if e != nil {
234- return e
235- }
236- presets = append(presets, v)
237- }
238229 if cfg.Layout.Gaps < 0 {
239230 return fmt.Errorf("invalid gaps")
240231 }
@@ -328,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error {
328319 c.cfg = cfg
329320 c.binds = binds
330321 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
331- c.specs, c.presets, c.rules = named, presets, rules
322+ c.specs, c.rules = named, rules
332323 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
333324 c.named()
334325 for _, s := range c.screens {
@@ -116,7 +116,6 @@ type Core struct {
116 layerChanged bool116 layerChanged bool
117 sent sentCache117 sent sentCache
118 specs []NamedWorkspace118 specs []NamedWorkspace
119- presets []Width
120 slots map[slotKey]*slotState119 slots map[slotKey]*slotState
121 rules []rule120 rules []rule
122 placement spawnPlacement121 placement spawnPlacement
@@ -227,14 +226,6 @@ func (c *Core) apply(cfg ports.Config) error {
227 if cfg.Layout.MaxColumns < 1 {226 if cfg.Layout.MaxColumns < 1 {
228 return fmt.Errorf("invalid max columns")227 return fmt.Errorf("invalid max columns")
229 }228 }
230- presets := make([]Width, 0, len(cfg.Layout.Presets))
231- for _, s := range cfg.Layout.Presets {
232- v, e := ParseWidth(s)
233- if e != nil {
234- return e
235- }
236- presets = append(presets, v)
237- }
238 if cfg.Layout.Gaps < 0 {229 if cfg.Layout.Gaps < 0 {
239 return fmt.Errorf("invalid gaps")230 return fmt.Errorf("invalid gaps")
240 }231 }
@@ -328,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error {
328 c.cfg = cfg319 c.cfg = cfg
329 c.binds = binds320 c.binds = binds
330 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]321 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
331- c.specs, c.presets, c.rules = named, presets, rules322+ c.specs, c.rules = named, rules
332 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)323 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
333 c.named()324 c.named()
334 for _, s := range c.screens {325 for _, s := range c.screens {
M internal/core/drag.go +3 -3
@@ -463,14 +463,14 @@ func (c *Core) drop(id WindowID, t dropTarget) {
463463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
464464 w.FocusID(id)
465465 } else {
466- width := Width{}
466+ cells := 0
467467 if lone {
468- width = src.Columns[ci].Width
468+ cells = src.Columns[ci].Cells
469469 }
470470 srcIndex := ci
471471 src.dropSlotOf(id)
472472 src.RemoveWindow(id)
473- col := Column{Windows: []WindowID{id}, Width: width}
473+ col := Column{Windows: []WindowID{id}, Cells: cells}
474474 switch t.kind {
475475 case dropAbove, dropBelow:
476476 ai := w.columnOf(t.anchor)
@@ -463,14 +463,14 @@ func (c *Core) drop(id WindowID, t dropTarget) {
463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]463 w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci]
464 w.FocusID(id)464 w.FocusID(id)
465 } else {465 } else {
466- width := Width{}466+ cells := 0
467 if lone {467 if lone {
468- width = src.Columns[ci].Width468+ cells = src.Columns[ci].Cells
469 }469 }
470 srcIndex := ci470 srcIndex := ci
471 src.dropSlotOf(id)471 src.dropSlotOf(id)
472 src.RemoveWindow(id)472 src.RemoveWindow(id)
473- col := Column{Windows: []WindowID{id}, Width: width}473+ col := Column{Windows: []WindowID{id}, Cells: cells}
474 switch t.kind {474 switch t.kind {
475 case dropAbove, dropBelow:475 case dropAbove, dropBelow:
476 ai := w.columnOf(t.anchor)476 ai := w.columnOf(t.anchor)
M internal/core/expel.go +3 -3
@@ -40,13 +40,13 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
4040 // The column goes away, and a slot with it.
4141 w.stackWindow(id, w.Columns[next].Windows[0])
4242 case !w.full():
43- c := Column{Windows: []WindowID{id}, Width: col.Width}
43+ c := Column{Windows: []WindowID{id}, Cells: col.Cells}
4444 if col.Focus == 0 && col.Slot != 0 {
4545 // The slot and its width follow the slot window.
4646 c.Slot = col.Slot
47- w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}
47+ w.Columns[src].Slot, w.Columns[src].Cells = 0, 0
4848 } else if col.Slot != 0 {
49- c.Width = Width{}
49+ c.Cells = 0
5050 }
5151 w.RemoveWindow(id)
5252 w.insertColumn(src+max(dir, 0), c)
@@ -40,13 +40,13 @@ func (w *Workspace) ConsumeOrExpel(dir int) bool {
40 // The column goes away, and a slot with it.40 // The column goes away, and a slot with it.
41 w.stackWindow(id, w.Columns[next].Windows[0])41 w.stackWindow(id, w.Columns[next].Windows[0])
42 case !w.full():42 case !w.full():
43- c := Column{Windows: []WindowID{id}, Width: col.Width}43+ c := Column{Windows: []WindowID{id}, Cells: col.Cells}
44 if col.Focus == 0 && col.Slot != 0 {44 if col.Focus == 0 && col.Slot != 0 {
45 // The slot and its width follow the slot window.45 // The slot and its width follow the slot window.
46 c.Slot = col.Slot46 c.Slot = col.Slot
47- w.Columns[src].Slot, w.Columns[src].Width = 0, Width{}47+ w.Columns[src].Slot, w.Columns[src].Cells = 0, 0
48 } else if col.Slot != 0 {48 } else if col.Slot != 0 {
49- c.Width = Width{}49+ c.Cells = 0
50 }50 }
51 w.RemoveWindow(id)51 w.RemoveWindow(id)
52 w.insertColumn(src+max(dir, 0), c)52 w.insertColumn(src+max(dir, 0), c)
M internal/core/expel_test.go +2 -2
@@ -100,10 +100,10 @@ func TestConsumeOrExpelSlots(t *testing.T) {
100100 }
101101 t.Run("the slot follows its window", func(t *testing.T) {
102102 w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
103- w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}
103+ w.Columns[0].Slot, w.Columns[0].Cells = 1, 2
104104 w.Columns[0].Focus = 0
105105 w.ConsumeOrExpel(1)
106- if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {
106+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Cells != 2 || w.Columns[0].Cells != 0 {
107107 t.Fatal(got, slots(w), w.Columns)
108108 }
109109 })
@@ -100,10 +100,10 @@ func TestConsumeOrExpelSlots(t *testing.T) {
100 }100 }
101 t.Run("the slot follows its window", func(t *testing.T) {101 t.Run("the slot follows its window", func(t *testing.T) {
102 w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})102 w := cols(OverflowScroll, 1, 0, []WindowID{1, 2})
103- w.Columns[0].Slot, w.Columns[0].Width = 1, Width{Num: 2, Den: 3}103+ w.Columns[0].Slot, w.Columns[0].Cells = 1, 2
104 w.Columns[0].Focus = 0104 w.Columns[0].Focus = 0
105 w.ConsumeOrExpel(1)105 w.ConsumeOrExpel(1)
106- if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Width != (Width{Num: 2, Den: 3}) || w.Columns[0].Width != (Width{}) {106+ if got := ids(w); !reflect.DeepEqual(got, [][]WindowID{{2}, {1}}) || !reflect.DeepEqual(slots(w), []int{0, 1}) || w.Columns[1].Cells != 2 || w.Columns[0].Cells != 0 {
107 t.Fatal(got, slots(w), w.Columns)107 t.Fatal(got, slots(w), w.Columns)
108 }108 }
109 })109 })
M internal/core/floating_test.go +10 -10
@@ -15,7 +15,7 @@ func TestToggleWindowStash(t *testing.T) {
1515 w.border = 2
1616 w.AddWindow(1)
1717 w.AddWindow(2)
18- w.Columns[1].Width = Width{Pixels: 35}
18+ w.Columns[1].Cells = 3
1919 if stacked {
2020 w.Columns[1].Windows = []WindowID{3, 2}
2121 w.Columns[1].Focus = 1
@@ -34,7 +34,7 @@ func TestToggleWindowStash(t *testing.T) {
3434 }
3535 w.Apply(ActionToggleWindowStash)
3636 id, _ := w.Focused()
37- if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) {
37+ if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Cells != 3 || !slices.Contains(w.Columns[1].Windows, 2) {
3838 t.Fatalf("round trip: %+v", w)
3939 }
4040 })
@@ -103,7 +103,7 @@ func TestStashStrip(t *testing.T) {
103103 // Column actions do nothing while the stash has the focus.
104104 w.Apply(ActionMoveColumnLeft)
105105 w.Apply(ActionCycleColumnWidth)
106- if w.Columns[0].Windows[0] != 1 || w.Columns[0].Width != (Width{}) {
106+ if w.Columns[0].Windows[0] != 1 || w.Columns[0].Cells != 0 {
107107 t.Fatalf("column changed: %+v", w.Columns)
108108 }
109109 // A gap as wide as the margin leaves nothing to show: neighbors wait
@@ -263,7 +263,7 @@ func TestFullscreenPinsFocus(t *testing.T) {
263263 w.focusCover()
264264 w.Apply(ActionMoveColumnLeft)
265265 w.Apply(ActionCycleColumnWidth)
266- if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Width != (Width{}) {
266+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Cells != 0 {
267267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
268268 }
269269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {
@@ -353,20 +353,20 @@ func TestToggleWindowStashKeepsFullWidth(t *testing.T) {
353353 }
354354 }
355355
356-// A lone expanded column (fixed overflow) comes back expanded.
357-func TestToggleWindowStashKeepsExpanded(t *testing.T) {
356+// A lone wide column (fixed overflow) comes back at its width in cells.
357+func TestToggleWindowStashKeepsCells(t *testing.T) {
358358 w := workspace()
359359 w.Overflow = OverflowFixed
360360 w.SetMaxColumns(3)
361361 w.AddWindow(1)
362362 w.AddWindow(2)
363- w.CycleWidth()
364- if !w.Columns[w.Focus].Expanded {
365- t.Fatal("column not expanded")
363+ w.Apply(ActionCycleColumnWidth)
364+ if w.Columns[w.Focus].Cells != 2 {
365+ t.Fatal("column not wider", w.Columns)
366366 }
367367 w.Apply(ActionToggleWindowStash)
368368 w.Apply(ActionToggleWindowStash)
369- if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {
369+ if id, _ := w.Focused(); id != 2 || w.Columns[w.Focus].Cells != 2 {
370370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
371371 }
372372 }
@@ -15,7 +15,7 @@ func TestToggleWindowStash(t *testing.T) {
15 w.border = 215 w.border = 2
16 w.AddWindow(1)16 w.AddWindow(1)
17 w.AddWindow(2)17 w.AddWindow(2)
18- w.Columns[1].Width = Width{Pixels: 35}18+ w.Columns[1].Cells = 3
19 if stacked {19 if stacked {
20 w.Columns[1].Windows = []WindowID{3, 2}20 w.Columns[1].Windows = []WindowID{3, 2}
21 w.Columns[1].Focus = 121 w.Columns[1].Focus = 1
@@ -34,7 +34,7 @@ func TestToggleWindowStash(t *testing.T) {
34 }34 }
35 w.Apply(ActionToggleWindowStash)35 w.Apply(ActionToggleWindowStash)
36 id, _ := w.Focused()36 id, _ := w.Focused()
37- if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Width != (Width{Pixels: 35}) || !slices.Contains(w.Columns[1].Windows, 2) {37+ if id != 2 || len(w.Stash) != 0 || w.Focus != 1 || w.Columns[1].Cells != 3 || !slices.Contains(w.Columns[1].Windows, 2) {
38 t.Fatalf("round trip: %+v", w)38 t.Fatalf("round trip: %+v", w)
39 }39 }
40 })40 })
@@ -103,7 +103,7 @@ func TestStashStrip(t *testing.T) {
103 // Column actions do nothing while the stash has the focus.103 // Column actions do nothing while the stash has the focus.
104 w.Apply(ActionMoveColumnLeft)104 w.Apply(ActionMoveColumnLeft)
105 w.Apply(ActionCycleColumnWidth)105 w.Apply(ActionCycleColumnWidth)
106- if w.Columns[0].Windows[0] != 1 || w.Columns[0].Width != (Width{}) {106+ if w.Columns[0].Windows[0] != 1 || w.Columns[0].Cells != 0 {
107 t.Fatalf("column changed: %+v", w.Columns)107 t.Fatalf("column changed: %+v", w.Columns)
108 }108 }
109 // A gap as wide as the margin leaves nothing to show: neighbors wait109 // A gap as wide as the margin leaves nothing to show: neighbors wait
@@ -263,7 +263,7 @@ func TestFullscreenPinsFocus(t *testing.T) {
263 w.focusCover()263 w.focusCover()
264 w.Apply(ActionMoveColumnLeft)264 w.Apply(ActionMoveColumnLeft)
265 w.Apply(ActionCycleColumnWidth)265 w.Apply(ActionCycleColumnWidth)
266- if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Width != (Width{}) {266+ if id, _ := w.Focused(); id != 3 || w.fullscreen != 3 || w.Columns[0].Windows[0] != 1 || w.Columns[1].Cells != 0 {
267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)267 t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns)
268 }268 }
269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {269 if e := w.Apply(ActionCloseWindow); e.Close != 3 {
@@ -353,20 +353,20 @@ func TestToggleWindowStashKeepsFullWidth(t *testing.T) {
353 }353 }
354 }354 }
355 355
356-// A lone expanded column (fixed overflow) comes back expanded.356+// A lone wide column (fixed overflow) comes back at its width in cells.
357-func TestToggleWindowStashKeepsExpanded(t *testing.T) {357+func TestToggleWindowStashKeepsCells(t *testing.T) {
358 w := workspace()358 w := workspace()
359 w.Overflow = OverflowFixed359 w.Overflow = OverflowFixed
360 w.SetMaxColumns(3)360 w.SetMaxColumns(3)
361 w.AddWindow(1)361 w.AddWindow(1)
362 w.AddWindow(2)362 w.AddWindow(2)
363- w.CycleWidth()363+ w.Apply(ActionCycleColumnWidth)
364- if !w.Columns[w.Focus].Expanded {364+ if w.Columns[w.Focus].Cells != 2 {
365- t.Fatal("column not expanded")365+ t.Fatal("column not wider", w.Columns)
366 }366 }
367 w.Apply(ActionToggleWindowStash)367 w.Apply(ActionToggleWindowStash)
368 w.Apply(ActionToggleWindowStash)368 w.Apply(ActionToggleWindowStash)
369- if id, _ := w.Focused(); id != 2 || !w.Columns[w.Focus].Expanded {369+ if id, _ := w.Focused(); id != 2 || w.Columns[w.Focus].Cells != 2 {
370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])370 t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus])
371 }371 }
372 }372 }
M internal/core/free.go +5 -4
@@ -74,15 +74,16 @@ func (w *Workspace) freeAction(a Action) bool {
7474 }
7575 f := &w.Floats[i]
7676 u := w.Usable
77- if axis, pct, ok := ResizeArg(a); ok {
77+ if axis, step, ok := ResizeArg(a); ok {
7878 if u.W <= 0 || u.H <= 0 {
7979 return true
8080 }
81- // The size is the client's: the border must fit too.
81+ // The size is the client's: the border must fit too. A width
82+ // step is in cells, a cell being Usable.W/MaxColumns.
8283 if axis == ResizeWidth {
83- f.W = max(min(f.W+u.W*pct/100, u.W-2*w.border), freeMinSize)
84+ f.W = max(min(f.W+step*u.W/max(w.MaxColumns, 1), u.W-2*w.border), freeMinSize)
8485 } else {
85- f.H = max(min(f.H+u.H*pct/100, u.H-2*w.border), freeMinSize)
86+ f.H = max(min(f.H+u.H*step/100, u.H-2*w.border), freeMinSize)
8687 }
8788 w.clampFree(f)
8889 return true
@@ -74,15 +74,16 @@ func (w *Workspace) freeAction(a Action) bool {
74 }74 }
75 f := &w.Floats[i]75 f := &w.Floats[i]
76 u := w.Usable76 u := w.Usable
77- if axis, pct, ok := ResizeArg(a); ok {77+ if axis, step, ok := ResizeArg(a); ok {
78 if u.W <= 0 || u.H <= 0 {78 if u.W <= 0 || u.H <= 0 {
79 return true79 return true
80 }80 }
81- // The size is the client's: the border must fit too.81+ // The size is the client's: the border must fit too. A width
82+ // step is in cells, a cell being Usable.W/MaxColumns.
82 if axis == ResizeWidth {83 if axis == ResizeWidth {
83- f.W = max(min(f.W+u.W*pct/100, u.W-2*w.border), freeMinSize)84+ f.W = max(min(f.W+step*u.W/max(w.MaxColumns, 1), u.W-2*w.border), freeMinSize)
84 } else {85 } else {
85- f.H = max(min(f.H+u.H*pct/100, u.H-2*w.border), freeMinSize)86+ f.H = max(min(f.H+u.H*step/100, u.H-2*w.border), freeMinSize)
86 }87 }
87 w.clampFree(f)88 w.clampFree(f)
88 return true89 return true
M internal/core/free_test.go +7 -7
@@ -33,7 +33,7 @@ func TestFreeFloatRect(t *testing.T) {
3333
3434 func TestToggleFloatingRoundTrip(t *testing.T) {
3535 w := freeWorkspace()
36- w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Width: Width{Num: 1, Den: 2}}}
36+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Cells: 1}}
3737 w.Focus = 1
3838 r, _ := w.placed(3)
3939 w.Apply(ActionToggleFloating)
@@ -58,11 +58,11 @@ func TestToggleFloatingRoundTrip(t *testing.T) {
5858 }
5959 // A lone column comes back at its width and index.
6060 w.Focus = 1
61- w.Columns[1] = Column{Windows: []WindowID{2}, Width: Width{Num: 1, Den: 2}}
61+ w.Columns[1] = Column{Windows: []WindowID{2}, Cells: 1}
6262 w.Columns = append(w.Columns, Column{Windows: []WindowID{3}})
6363 w.Apply(ActionToggleFloating)
6464 w.Apply(ActionToggleFloating)
65- if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Width != (Width{Num: 1, Den: 2}) || w.Focus != 1 {
65+ if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Cells != 1 || w.Focus != 1 {
6666 t.Fatal(w.Columns, w.Focus)
6767 }
6868 }
@@ -188,7 +188,7 @@ func TestFreeFloatNoUsableArea(t *testing.T) {
188188 t.Fatal(fx)
189189 }
190190 w.Apply(ActionMoveColumnLeft)
191- w.Apply("set-column-width +10%")
191+ w.Apply("set-column-width +1")
192192 if w.floatIndex(1) < 0 {
193193 t.Fatal(w.Floats)
194194 }
@@ -213,13 +213,13 @@ func TestFreeFloatKeyboard(t *testing.T) {
213213 if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X {
214214 t.Fatal(got)
215215 }
216- w.Apply("set-column-width +10%")
216+ w.Apply("set-column-width +1")
217217 w.Apply("set-window-height -10%")
218- if w.Floats[i].W != 200+w.Usable.W/10 || w.Floats[i].H != freeMinSize {
218+ if w.Floats[i].W != 200+w.Usable.W/w.MaxColumns || w.Floats[i].H != freeMinSize {
219219 t.Fatal(w.Floats[i])
220220 }
221221 // The columns did not change.
222- if len(w.Columns) != 1 || w.Columns[0].Width != (Width{}) {
222+ if len(w.Columns) != 1 || w.Columns[0].Cells != 0 {
223223 t.Fatal(w.Columns)
224224 }
225225 }
@@ -33,7 +33,7 @@ func TestFreeFloatRect(t *testing.T) {
33 33
34 func TestToggleFloatingRoundTrip(t *testing.T) {34 func TestToggleFloatingRoundTrip(t *testing.T) {
35 w := freeWorkspace()35 w := freeWorkspace()
36- w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Width: Width{Num: 1, Den: 2}}}36+ w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1, Cells: 1}}
37 w.Focus = 137 w.Focus = 1
38 r, _ := w.placed(3)38 r, _ := w.placed(3)
39 w.Apply(ActionToggleFloating)39 w.Apply(ActionToggleFloating)
@@ -58,11 +58,11 @@ func TestToggleFloatingRoundTrip(t *testing.T) {
58 }58 }
59 // A lone column comes back at its width and index.59 // A lone column comes back at its width and index.
60 w.Focus = 160 w.Focus = 1
61- w.Columns[1] = Column{Windows: []WindowID{2}, Width: Width{Num: 1, Den: 2}}61+ w.Columns[1] = Column{Windows: []WindowID{2}, Cells: 1}
62 w.Columns = append(w.Columns, Column{Windows: []WindowID{3}})62 w.Columns = append(w.Columns, Column{Windows: []WindowID{3}})
63 w.Apply(ActionToggleFloating)63 w.Apply(ActionToggleFloating)
64 w.Apply(ActionToggleFloating)64 w.Apply(ActionToggleFloating)
65- if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Width != (Width{Num: 1, Den: 2}) || w.Focus != 1 {65+ if len(w.Columns) != 3 || w.Columns[1].Windows[0] != 2 || w.Columns[1].Cells != 1 || w.Focus != 1 {
66 t.Fatal(w.Columns, w.Focus)66 t.Fatal(w.Columns, w.Focus)
67 }67 }
68 }68 }
@@ -188,7 +188,7 @@ func TestFreeFloatNoUsableArea(t *testing.T) {
188 t.Fatal(fx)188 t.Fatal(fx)
189 }189 }
190 w.Apply(ActionMoveColumnLeft)190 w.Apply(ActionMoveColumnLeft)
191- w.Apply("set-column-width +10%")191+ w.Apply("set-column-width +1")
192 if w.floatIndex(1) < 0 {192 if w.floatIndex(1) < 0 {
193 t.Fatal(w.Floats)193 t.Fatal(w.Floats)
194 }194 }
@@ -213,13 +213,13 @@ func TestFreeFloatKeyboard(t *testing.T) {
213 if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X {213 if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X {
214 t.Fatal(got)214 t.Fatal(got)
215 }215 }
216- w.Apply("set-column-width +10%")216+ w.Apply("set-column-width +1")
217 w.Apply("set-window-height -10%")217 w.Apply("set-window-height -10%")
218- if w.Floats[i].W != 200+w.Usable.W/10 || w.Floats[i].H != freeMinSize {218+ if w.Floats[i].W != 200+w.Usable.W/w.MaxColumns || w.Floats[i].H != freeMinSize {
219 t.Fatal(w.Floats[i])219 t.Fatal(w.Floats[i])
220 }220 }
221 // The columns did not change.221 // The columns did not change.
222- if len(w.Columns) != 1 || w.Columns[0].Width != (Width{}) {222+ if len(w.Columns) != 1 || w.Columns[0].Cells != 0 {
223 t.Fatal(w.Columns)223 t.Fatal(w.Columns)
224 }224 }
225 }225 }
M internal/core/layout.go +202 -193
@@ -1,12 +1,8 @@
11 package core
22
33 import (
4- "fmt"
54 "math"
6- "math/bits"
75 "slices"
8- "strconv"
9- "strings"
106 "time"
117
128 "github.com/bnema/neferwl/internal/ports"
@@ -16,87 +12,17 @@ type WindowID = ports.WindowID
1612
1713 type Rect = ports.Rect
1814
19-type Width struct{ Num, Den, Pixels int }
15+// maxCells is the largest width a rule or a slot may declare, in cells.
16+const maxCells = 16
2017
21-func ParseWidth(s string) (Width, error) {
22- if s == "1" {
23- return Width{Num: 1, Den: 1}, nil
24- }
25- if strings.HasSuffix(s, "px") {
26- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
27- if e == nil && n > 0 {
28- return Width{Pixels: n}, nil
29- }
30- } else if strings.HasSuffix(s, "%") {
31- n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
32- if e == nil && n > 0 && n <= 100 {
33- return Width{Num: n, Den: 100}, nil
34- }
35- } else {
36- p := strings.Split(s, "/")
37- if len(p) == 2 {
38- a, e1 := strconv.Atoi(p[0])
39- b, e2 := strconv.Atoi(p[1])
40- if e1 == nil && e2 == nil && a > 0 && a <= b {
41- return Width{Num: a, Den: b}, nil
42- }
43- }
44- }
45- return Width{}, fmt.Errorf("invalid width %q", s)
46-}
47-
48-// same reports whether v and o are the same width: 1/2 and 50/100 are.
49-func (v Width) same(o Width) bool {
50- if v.Pixels > 0 || o.Pixels > 0 || v.Den == 0 || o.Den == 0 {
51- return v == o
52- }
53- return v.Num*o.Den == o.Num*v.Den
54-}
55-
56-// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
57-// Widths are clamped to the available area (zero if no space remains).
58-func (v Width) Resolve(usableW, gaps int) int {
59- if usableW <= 0 {
60- return 0
61- }
62- gaps = min(max(gaps, 0), usableW/2)
63- limit := max(usableW-2*gaps, 0)
64- if limit == 0 {
65- return 0
66- }
67- if v.Pixels > 0 {
68- return min(v.Pixels, limit)
69- }
70- if v.Den <= 0 || v.Num <= 0 {
71- return 1
72- }
73- // Cap invalid fractions above one before multiplying; the result would
74- // be clamped to the available width anyway.
75- num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
76- avail := uint64(usableW - gaps)
77- q, r := avail/den, avail%den
78- hi, lo := bits.Mul64(r, num)
79- lo, carry := bits.Add64(lo, den/2, 0)
80- hi += carry
81- // hi < den: r and num are both smaller than den.
82- fraction, _ := bits.Div64(hi, lo, den)
83- rounded := q*num + fraction // no larger than avail (plus rounding)
84- if rounded <= uint64(gaps) {
85- return 1
86- }
87- return int(min(rounded-uint64(gaps), uint64(limit)))
88-}
89-
90-// Column.Width is zero (auto) until the user picks a preset: auto columns share
91-// the usable width equally, up to MaxColumns visible at once.
18+// Column.Cells is the width of the column in cells, a cell being one
19+// max-columns-th of the usable width. Zero (auto) shares the width equally
20+// with the other auto columns, up to MaxColumns visible at once.
9221 type Column struct {
9322 Windows []WindowID
94- Width Width
23+ Cells int
9524 FullWidth bool
96- // Expanded is the fixed-overflow wide column: max-columns - 1 cells,
97- // the others share the last one. One column at most has it.
98- Expanded bool
99- Focus int
25+ Focus int
10026 // Slot is the declared column number (workspace.<name>.column.N) of a
10127 // slot window; 0 for normal columns.
10228 Slot int
@@ -192,7 +118,6 @@ type Workspace struct {
192118 MaxColumns int
193119 // border is drawn around floating windows, inside their rect.
194120 border int
195- presets []Width
196121 fullscreen WindowID
197122 // left is the window whose fullscreen the user left for another
198123 // window: its client requests are refused until it is focused again,
@@ -242,14 +167,13 @@ type Workspace struct {
242167 }
243168
244169 // origPlace remembers where a stashed window was: its column, its row in a
245-// stacked column, the column's width and slot.
170+// stacked column, the column's width in cells and slot.
246171 type origPlace struct {
247172 col, row, slot int
248173 // stacked holds the windows left in the column: the window returns to
249174 // its row in the column that still holds one of them.
250- stacked []WindowID
251- width Width
252- expanded bool
175+ stacked []WindowID
176+ cells int
253177 // fullWidth is the column's maximize-column state.
254178 fullWidth bool
255179 }
@@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) {
552476 }
553477 return c.Windows[c.Focus], true
554478 }
555-func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }
479+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, 0) }
556480
557-// addWindow is AddWindow with the width of the new column (zero: automatic).
558-func (w *Workspace) addWindow(id WindowID, width Width) {
481+// addWindow is AddWindow with the width of the new column in cells (zero:
482+// automatic).
483+func (w *Workspace) addWindow(id WindowID, cells int) {
559484 if id == 0 || w.has(id) {
560485 return
561486 }
@@ -567,10 +492,10 @@ func (w *Workspace) addWindow(id WindowID, width Width) {
567492 // A covering float or stashed window may have no column under it.
568493 at = min(w.Focus+1, len(w.Columns))
569494 }
570- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})
495+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
571496 return
572497 }
573- w.addColumn(Column{Windows: []WindowID{id}, Width: width})
498+ w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
574499 }
575500
576501 // addColumn places a new column like a new window and focuses it.
@@ -595,7 +520,7 @@ func (w *Workspace) addColumn(col Column) {
595520 // AddSlotWindow places a slot window in its own column, ordered by slot
596521 // number before the other columns. Focus stays on the focused window: slot
597522 // windows are automatic events (ADR 011 golden rule).
598-func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
523+func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
599524 if id == 0 || w.has(id) {
600525 return
601526 }
@@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
606531 at = i + 1
607532 }
608533 }
609- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})
534+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
610535 if len(w.Columns) > 1 && at <= w.Focus {
611536 w.Focus++
612537 }
613538 w.scroll()
614539 }
615540
616-// setSlotWidth updates the width of slot column n, if present.
617-func (w *Workspace) setSlotWidth(n int, width Width) {
541+// setSlotCells updates the width in cells of slot column n, if present.
542+func (w *Workspace) setSlotCells(n int, cells int) {
618543 for i := range w.Columns {
619544 if w.Columns[i].Slot == n {
620- w.Columns[i].Width = width
545+ w.Columns[i].Cells = cells
621546 }
622547 }
623548 w.scroll()
@@ -939,25 +864,6 @@ func (w *Workspace) MoveWindow(dir int) {
939864 w.scroll()
940865 }
941866
942-// ResizeColumn changes the focused column's width by pct percent of the
943-// usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
944-// overflow shares the width equally: nothing changes there.
945-func (w *Workspace) ResizeColumn(pct int) {
946- if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
947- return
948- }
949- w.unmaximize()
950- g := w.gap()
951- avail := w.Usable.W - g
952- if avail <= 0 {
953- return
954- }
955- // Inverse of Width.Resolve: width = round(p/100 * avail) - g.
956- cur := (200*(w.columnWidth(w.Focus)+g) + avail) / (2 * avail)
957- w.Columns[w.Focus].Width = Width{Num: min(max(cur+pct, 10), 100), Den: 100}
958- w.scroll()
959-}
960-
961867 // minShare is the smallest row height set-window-height leaves, percent.
962868 const minShare = 10
963869
@@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
1038944 return Column{}, false
1039945 }
1040946 col := w.Columns[w.Focus]
1041- col.Slot, col.Expanded = 0, false
947+ col.Slot = 0
1042948 if slices.Contains(col.Windows, w.fullscreen) {
1043949 w.fullscreen = 0
1044950 }
@@ -1071,50 +977,163 @@ func (w *Workspace) insertColumn(at int, col Column) {
1071977 w.scroll()
1072978 }
1073979
1074-// CycleWidth steps the focused column through the presets. Fixed overflow
1075-// ignores presets: it toggles the expanded column instead; cascade does
1076-// nothing.
1077-func (w *Workspace) CycleWidth() {
1078- if w.policy().wraps {
980+// GrowColumn widens the focused column by one cell, taking it from another
981+// column of its lane, or from the empty space in scroll overflow. A lane
982+// that cannot grow further goes back to equal shares when cycle is set
983+// (cycle-column-width); otherwise nothing changes (set-column-width +N).
984+// A lane is the band of a cascade, or the whole row in fixed overflow while
985+// it holds MaxColumns columns at most; past that the spiral ignores cells.
986+func (w *Workspace) GrowColumn(cycle bool) {
987+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1079988 return
1080989 }
1081- if w.Overflow == OverflowFixed {
1082- w.toggleExpanded()
990+ w.unmaximize()
991+ defer w.scroll()
992+ k := max(w.MaxColumns, 1)
993+ if w.policy().equalCells {
994+ if !w.policy().wraps && !w.laneRow() {
995+ return
996+ }
997+ first, last := w.laneBounds(w.laneOf(w.Focus))
998+ n := last - first + 1
999+ if n == 1 || n >= k {
1000+ return
1001+ }
1002+ cells, ok := w.laneCells(first, last, w.Focus)
1003+ if !ok {
1004+ w.setLaneCells(first, cells)
1005+ if k%n > 0 {
1006+ return // The remainder to the focused column was the step.
1007+ }
1008+ }
1009+ if d := w.donor(first, last, cells); d >= 0 {
1010+ w.moveCell(first, cells, d, w.Focus)
1011+ } else if cycle {
1012+ w.setLaneCells(first, make([]int, n))
1013+ }
10831014 return
10841015 }
1085- if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() {
1016+ c := &w.Columns[w.Focus]
1017+ cur := w.scrollCells(w.Focus)
1018+ switch {
1019+ case cur < k:
1020+ c.Cells = cur + 1
1021+ case cycle:
1022+ c.Cells = 0
1023+ }
1024+}
1025+
1026+// ShrinkColumn narrows the focused column by one cell, giving it to its
1027+// right neighbor in the lane (the left one at the lane's end). A column
1028+// never goes below one cell.
1029+func (w *Workspace) ShrinkColumn() {
1030+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
10861031 return
10871032 }
1088- // auto → presets in order → auto; a width set by set-column-width
1089- // restarts at the first preset.
1090- c := &w.Columns[w.Focus]
1091- next := Width{}
1092- if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) {
1093- next = w.presets[0]
1033+ w.unmaximize()
1034+ defer w.scroll()
1035+ k := max(w.MaxColumns, 1)
1036+ if w.policy().equalCells {
1037+ if !w.policy().wraps && !w.laneRow() {
1038+ return
1039+ }
1040+ first, last := w.laneBounds(w.laneOf(w.Focus))
1041+ n := last - first + 1
1042+ if n == 1 || n >= k {
1043+ return
1044+ }
1045+ cells, _ := w.laneCells(first, last, -1)
1046+ if cells[w.Focus-first] <= 1 {
1047+ return
1048+ }
1049+ to := w.Focus + 1
1050+ if to > last {
1051+ to = w.Focus - 1
1052+ }
1053+ w.moveCell(first, cells, w.Focus, to)
1054+ return
10941055 }
1095- for i, v := range w.presets {
1096- if v.same(c.Width) && i+1 < len(w.presets) {
1097- next = w.presets[i+1]
1098- break
1056+ w.Columns[w.Focus].Cells = max(w.scrollCells(w.Focus)-1, 1)
1057+}
1058+
1059+// scrollCells is the width in cells of column i in scroll overflow: its
1060+// cells, at most the ones in view for an auto column.
1061+func (w *Workspace) scrollCells(i int) int {
1062+ if c := w.Columns[i].Cells; c > 0 {
1063+ return c
1064+ }
1065+ k := max(w.MaxColumns, 1)
1066+ return k / min(len(w.Columns), k)
1067+}
1068+
1069+// laneCells is the cells of the lane first..last, summing to MaxColumns.
1070+// The stored cells are used when every column is set and they sum to it
1071+// (ok); otherwise each column gets an equal share and the remainder goes
1072+// to column remAt, or one cell each to the leftmost columns when remAt is
1073+// negative.
1074+func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1075+ k := max(w.MaxColumns, 1)
1076+ n := last - first + 1
1077+ cells = make([]int, n)
1078+ sum := 0
1079+ for i := range cells {
1080+ cells[i] = w.Columns[first+i].Cells
1081+ sum += cells[i]
1082+ if cells[i] == 0 {
1083+ sum = -1 << 30
10991084 }
11001085 }
1101- c.Width = next
1102- w.unmaximize()
1103- w.scroll()
1086+ if sum == k {
1087+ return cells, true
1088+ }
1089+ for i := range cells {
1090+ cells[i] = k / n
1091+ }
1092+ if remAt >= 0 {
1093+ cells[remAt-first] += k % n
1094+ } else {
1095+ for i := range k % n {
1096+ cells[i]++
1097+ }
1098+ }
1099+ return cells, false
11041100 }
11051101
1106-// toggleExpanded makes the focused column the expanded one, or shrinks it
1107-// back when it already is. One column at most is expanded.
1108-func (w *Workspace) toggleExpanded() {
1109- if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.onFloat() {
1110- return
1102+// setLaneCells stores cells on the lane's columns, from column first.
1103+func (w *Workspace) setLaneCells(first int, cells []int) {
1104+ for i, c := range cells {
1105+ w.Columns[first+i].Cells = c
11111106 }
1112- on := !w.Columns[w.Focus].Expanded
1113- for i := range w.Columns {
1114- w.Columns[i].Expanded = false
1107+}
1108+
1109+// donor is the lane column that gives a cell to the focused one: among the
1110+// others with more than one cell, the largest; on a tie the nearest to the
1111+// focus, the right side first. -1 when there is none.
1112+func (w *Workspace) donor(first, last int, cells []int) int {
1113+ best := -1
1114+ for i := first; i <= last; i++ {
1115+ c := cells[i-first]
1116+ if i == w.Focus || c <= 1 {
1117+ continue
1118+ }
1119+ if best < 0 {
1120+ best = i
1121+ continue
1122+ }
1123+ bc, bd, d := cells[best-first], abs(best-w.Focus), abs(i-w.Focus)
1124+ if c > bc || c == bc && (d < bd || d == bd && i > w.Focus) {
1125+ best = i
1126+ }
11151127 }
1116- w.Columns[w.Focus].Expanded = on
1117- w.unmaximize()
1128+ return best
1129+}
1130+
1131+// moveCell moves one cell from column from to column to of the lane that
1132+// starts at first, and stores the lane.
1133+func (w *Workspace) moveCell(first int, cells []int, from, to int) {
1134+ cells[from-first]--
1135+ cells[to-first]++
1136+ w.setLaneCells(first, cells)
11181137 }
11191138
11201139 // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
@@ -1320,7 +1339,6 @@ func (w *Workspace) SetGaps(g int) {
13201339 w.Gaps = g
13211340 w.scroll()
13221341 }
1323-func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
13241342 func (w *Workspace) SetMaxColumns(n int) {
13251343 w.MaxColumns = n
13261344 w.scroll()
@@ -1457,21 +1475,48 @@ func (w *Workspace) columnWidth(i int) int {
14571475 }
14581476
14591477 func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1478+ if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1479+ return width
1480+ }
1481+ if w.policy().wraps || w.laneRow() {
1482+ return w.cellWidth(i)
1483+ }
1484+ return w.scrollWidth(i)
1485+}
1486+
1487+// specialWidth is the width of a maximized or fullscreen column.
1488+func (w *Workspace) specialWidth(i int, ignoreFullWidth bool) (int, bool) {
14601489 if w.Columns[i].FullWidth && !ignoreFullWidth {
1461- return max(w.Usable.W-2*w.gap(), 0)
1490+ return max(w.Usable.W-2*w.gap(), 0), true
14621491 }
14631492 if w.fullscreenColumn(i) {
1464- return w.Output.W
1493+ return w.Output.W, true
14651494 }
1466- g := w.gap()
1467- // Fixed overflow never scrolls, so presets would push columns off screen.
1468- if w.policy().wraps {
1469- return w.cellWidth(i)
1495+ return 0, false
1496+}
1497+
1498+// laneRow reports whether the columns share one row of cells: fixed
1499+// overflow up to MaxColumns of them. Past that the spiral applies and
1500+// cells are ignored.
1501+func (w *Workspace) laneRow() bool {
1502+ return w.Overflow == OverflowFixed && len(w.Columns) <= max(w.MaxColumns, 1)
1503+}
1504+
1505+// scrollWidth is the width of column i when it is sized on its own: auto
1506+// columns share the usable width as the visible ones would, the others take
1507+// their cells (at most MaxColumns) and the gaps between them.
1508+func (w *Workspace) scrollWidth(i int) int {
1509+ k := max(w.MaxColumns, 1)
1510+ if w.policy().equalCells {
1511+ // Fixed overflow past MaxColumns: the spiral's slots.
1512+ return w.equalShare(k)
14701513 }
1471- if w.Columns[i].Width == (Width{}) || w.policy().equalCells {
1472- return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1)))
1514+ c := w.Columns[i].Cells
1515+ if c <= 0 {
1516+ return w.equalShare(min(len(w.Columns), k))
14731517 }
1474- return w.Columns[i].Width.Resolve(w.Usable.W, g)
1518+ c = min(c, k)
1519+ return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
14751520 }
14761521 func (w *Workspace) columnX(i int) int {
14771522 x := w.Usable.X + w.gap()
@@ -1542,10 +1587,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
15421587 return rects
15431588 }
15441589 k := max(w.MaxColumns, 1)
1545- if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1546- // rects is dead here: the expanded rects reuse its storage.
1547- return w.expandedRects(rects[:0], e, k, y, h)
1548- }
15491590 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
15501591 return rects
15511592 }
@@ -1558,38 +1599,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
15581599 return rects
15591600 }
15601601
1561-// expandedRects places expanded column e over k-1 cells in its place. The
1562-// columns before it stack in a strip on its left, those after it on its
1563-// right; the two strips share the last cell.
1564-func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
1565- g := w.gap()
1566- cell := w.equalShare(k)
1567- wide := (k-1)*cell + (k-2)*g
1568- rest := max(w.Usable.W-2*g-wide-g, 0)
1569- before, after := w.Columns[:e], w.Columns[e+1:]
1570- leftW, rightW := rest, rest
1571- if len(before) > 0 && len(after) > 0 {
1572- leftW = max((rest-g)/2, 0)
1573- rightW = max(rest-g-leftW, 0)
1574- }
1575- x := w.Usable.X + g
1576- if len(before) > 0 {
1577- rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
1578- x += leftW + g
1579- }
1580- rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
1581- if len(after) > 0 {
1582- rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
1583- }
1584- // Gaps wider than a narrow output would push columns past its edge.
1585- right := w.Usable.X + w.Usable.W
1586- for i := range rects {
1587- rects[i].X = min(rects[i].X, right)
1588- rects[i].W = min(rects[i].W, right-rects[i].X)
1589- }
1590- return rects
1591-}
1592-
15931602 // stackRects cuts r in n rows with a gap between them; the last row takes
15941603 // the rounding remainder. Rows past the bottom (gaps taller than r) are
15951604 // clamped to it, like windows stacked in a column.
@@ -1,12 +1,8 @@
1 package core1 package core
2 2
3 import (3 import (
4- "fmt"
5 "math"4 "math"
6- "math/bits"
7 "slices"5 "slices"
8- "strconv"
9- "strings"
10 "time"6 "time"
11 7
12 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
@@ -16,87 +12,17 @@ type WindowID = ports.WindowID
16 12
17 type Rect = ports.Rect13 type Rect = ports.Rect
18 14
19-type Width struct{ Num, Den, Pixels int }15+// maxCells is the largest width a rule or a slot may declare, in cells.
16+const maxCells = 16
20 17
21-func ParseWidth(s string) (Width, error) {18+// Column.Cells is the width of the column in cells, a cell being one
22- if s == "1" {19+// max-columns-th of the usable width. Zero (auto) shares the width equally
23- return Width{Num: 1, Den: 1}, nil20+// with the other auto columns, up to MaxColumns visible at once.
24- }
25- if strings.HasSuffix(s, "px") {
26- n, e := strconv.Atoi(strings.TrimSuffix(s, "px"))
27- if e == nil && n > 0 {
28- return Width{Pixels: n}, nil
29- }
30- } else if strings.HasSuffix(s, "%") {
31- n, e := strconv.Atoi(strings.TrimSuffix(s, "%"))
32- if e == nil && n > 0 && n <= 100 {
33- return Width{Num: n, Den: 100}, nil
34- }
35- } else {
36- p := strings.Split(s, "/")
37- if len(p) == 2 {
38- a, e1 := strconv.Atoi(p[0])
39- b, e2 := strconv.Atoi(p[1])
40- if e1 == nil && e2 == nil && a > 0 && a <= b {
41- return Width{Num: a, Den: b}, nil
42- }
43- }
44- }
45- return Width{}, fmt.Errorf("invalid width %q", s)
46-}
47-
48-// same reports whether v and o are the same width: 1/2 and 50/100 are.
49-func (v Width) same(o Width) bool {
50- if v.Pixels > 0 || o.Pixels > 0 || v.Den == 0 || o.Den == 0 {
51- return v == o
52- }
53- return v.Num*o.Den == o.Num*v.Den
54-}
55-
56-// Resolve returns column pixels: round(frac * (usableW - gaps)) - gaps.
57-// Widths are clamped to the available area (zero if no space remains).
58-func (v Width) Resolve(usableW, gaps int) int {
59- if usableW <= 0 {
60- return 0
61- }
62- gaps = min(max(gaps, 0), usableW/2)
63- limit := max(usableW-2*gaps, 0)
64- if limit == 0 {
65- return 0
66- }
67- if v.Pixels > 0 {
68- return min(v.Pixels, limit)
69- }
70- if v.Den <= 0 || v.Num <= 0 {
71- return 1
72- }
73- // Cap invalid fractions above one before multiplying; the result would
74- // be clamped to the available width anyway.
75- num, den := uint64(min(v.Num, v.Den)), uint64(v.Den)
76- avail := uint64(usableW - gaps)
77- q, r := avail/den, avail%den
78- hi, lo := bits.Mul64(r, num)
79- lo, carry := bits.Add64(lo, den/2, 0)
80- hi += carry
81- // hi < den: r and num are both smaller than den.
82- fraction, _ := bits.Div64(hi, lo, den)
83- rounded := q*num + fraction // no larger than avail (plus rounding)
84- if rounded <= uint64(gaps) {
85- return 1
86- }
87- return int(min(rounded-uint64(gaps), uint64(limit)))
88-}
89-
90-// Column.Width is zero (auto) until the user picks a preset: auto columns share
91-// the usable width equally, up to MaxColumns visible at once.
92 type Column struct {21 type Column struct {
93 Windows []WindowID22 Windows []WindowID
94- Width Width23+ Cells int
95 FullWidth bool24 FullWidth bool
96- // Expanded is the fixed-overflow wide column: max-columns - 1 cells,25+ Focus int
97- // the others share the last one. One column at most has it.
98- Expanded bool
99- Focus int
100 // Slot is the declared column number (workspace.<name>.column.N) of a26 // Slot is the declared column number (workspace.<name>.column.N) of a
101 // slot window; 0 for normal columns.27 // slot window; 0 for normal columns.
102 Slot int28 Slot int
@@ -192,7 +118,6 @@ type Workspace struct {
192 MaxColumns int118 MaxColumns int
193 // border is drawn around floating windows, inside their rect.119 // border is drawn around floating windows, inside their rect.
194 border int120 border int
195- presets []Width
196 fullscreen WindowID121 fullscreen WindowID
197 // left is the window whose fullscreen the user left for another122 // left is the window whose fullscreen the user left for another
198 // window: its client requests are refused until it is focused again,123 // window: its client requests are refused until it is focused again,
@@ -242,14 +167,13 @@ type Workspace struct {
242 }167 }
243 168
244 // origPlace remembers where a stashed window was: its column, its row in a169 // origPlace remembers where a stashed window was: its column, its row in a
245-// stacked column, the column's width and slot.170+// stacked column, the column's width in cells and slot.
246 type origPlace struct {171 type origPlace struct {
247 col, row, slot int172 col, row, slot int
248 // stacked holds the windows left in the column: the window returns to173 // stacked holds the windows left in the column: the window returns to
249 // its row in the column that still holds one of them.174 // its row in the column that still holds one of them.
250- stacked []WindowID175+ stacked []WindowID
251- width Width176+ cells int
252- expanded bool
253 // fullWidth is the column's maximize-column state.177 // fullWidth is the column's maximize-column state.
254 fullWidth bool178 fullWidth bool
255 }179 }
@@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) {
552 }476 }
553 return c.Windows[c.Focus], true477 return c.Windows[c.Focus], true
554 }478 }
555-func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, Width{}) }479+func (w *Workspace) AddWindow(id WindowID) { w.addWindow(id, 0) }
556 480
557-// addWindow is AddWindow with the width of the new column (zero: automatic).481+// addWindow is AddWindow with the width of the new column in cells (zero:
558-func (w *Workspace) addWindow(id WindowID, width Width) {482+// automatic).
483+func (w *Workspace) addWindow(id WindowID, cells int) {
559 if id == 0 || w.has(id) {484 if id == 0 || w.has(id) {
560 return485 return
561 }486 }
@@ -567,10 +492,10 @@ func (w *Workspace) addWindow(id WindowID, width Width) {
567 // A covering float or stashed window may have no column under it.492 // A covering float or stashed window may have no column under it.
568 at = min(w.Focus+1, len(w.Columns))493 at = min(w.Focus+1, len(w.Columns))
569 }494 }
570- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width})495+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells})
571 return496 return
572 }497 }
573- w.addColumn(Column{Windows: []WindowID{id}, Width: width})498+ w.addColumn(Column{Windows: []WindowID{id}, Cells: cells})
574 }499 }
575 500
576 // addColumn places a new column like a new window and focuses it.501 // addColumn places a new column like a new window and focuses it.
@@ -595,7 +520,7 @@ func (w *Workspace) addColumn(col Column) {
595 // AddSlotWindow places a slot window in its own column, ordered by slot520 // AddSlotWindow places a slot window in its own column, ordered by slot
596 // number before the other columns. Focus stays on the focused window: slot521 // number before the other columns. Focus stays on the focused window: slot
597 // windows are automatic events (ADR 011 golden rule).522 // windows are automatic events (ADR 011 golden rule).
598-func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {523+func (w *Workspace) AddSlotWindow(id WindowID, slot int, cells int) {
599 if id == 0 || w.has(id) {524 if id == 0 || w.has(id) {
600 return525 return
601 }526 }
@@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) {
606 at = i + 1531 at = i + 1
607 }532 }
608 }533 }
609- w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Width: width, Slot: slot})534+ w.Columns = slices.Insert(w.Columns, at, Column{Windows: []WindowID{id}, Cells: cells, Slot: slot})
610 if len(w.Columns) > 1 && at <= w.Focus {535 if len(w.Columns) > 1 && at <= w.Focus {
611 w.Focus++536 w.Focus++
612 }537 }
613 w.scroll()538 w.scroll()
614 }539 }
615 540
616-// setSlotWidth updates the width of slot column n, if present.541+// setSlotCells updates the width in cells of slot column n, if present.
617-func (w *Workspace) setSlotWidth(n int, width Width) {542+func (w *Workspace) setSlotCells(n int, cells int) {
618 for i := range w.Columns {543 for i := range w.Columns {
619 if w.Columns[i].Slot == n {544 if w.Columns[i].Slot == n {
620- w.Columns[i].Width = width545+ w.Columns[i].Cells = cells
621 }546 }
622 }547 }
623 w.scroll()548 w.scroll()
@@ -939,25 +864,6 @@ func (w *Workspace) MoveWindow(dir int) {
939 w.scroll()864 w.scroll()
940 }865 }
941 866
942-// ResizeColumn changes the focused column's width by pct percent of the
943-// usable width, clamped to 10-100%. An auto column becomes explicit. Fixed
944-// overflow shares the width equally: nothing changes there.
945-func (w *Workspace) ResizeColumn(pct int) {
946- if w.onFloat() || len(w.Columns) == 0 || w.policy().equalCells || w.fullscreenHides() {
947- return
948- }
949- w.unmaximize()
950- g := w.gap()
951- avail := w.Usable.W - g
952- if avail <= 0 {
953- return
954- }
955- // Inverse of Width.Resolve: width = round(p/100 * avail) - g.
956- cur := (200*(w.columnWidth(w.Focus)+g) + avail) / (2 * avail)
957- w.Columns[w.Focus].Width = Width{Num: min(max(cur+pct, 10), 100), Den: 100}
958- w.scroll()
959-}
960-
961 // minShare is the smallest row height set-window-height leaves, percent.867 // minShare is the smallest row height set-window-height leaves, percent.
962 const minShare = 10868 const minShare = 10
963 869
@@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) {
1038 return Column{}, false944 return Column{}, false
1039 }945 }
1040 col := w.Columns[w.Focus]946 col := w.Columns[w.Focus]
1041- col.Slot, col.Expanded = 0, false947+ col.Slot = 0
1042 if slices.Contains(col.Windows, w.fullscreen) {948 if slices.Contains(col.Windows, w.fullscreen) {
1043 w.fullscreen = 0949 w.fullscreen = 0
1044 }950 }
@@ -1071,50 +977,163 @@ func (w *Workspace) insertColumn(at int, col Column) {
1071 w.scroll()977 w.scroll()
1072 }978 }
1073 979
1074-// CycleWidth steps the focused column through the presets. Fixed overflow980+// GrowColumn widens the focused column by one cell, taking it from another
1075-// ignores presets: it toggles the expanded column instead; cascade does981+// column of its lane, or from the empty space in scroll overflow. A lane
1076-// nothing.982+// that cannot grow further goes back to equal shares when cycle is set
1077-func (w *Workspace) CycleWidth() {983+// (cycle-column-width); otherwise nothing changes (set-column-width +N).
1078- if w.policy().wraps {984+// A lane is the band of a cascade, or the whole row in fixed overflow while
985+// it holds MaxColumns columns at most; past that the spiral ignores cells.
986+func (w *Workspace) GrowColumn(cycle bool) {
987+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1079 return988 return
1080 }989 }
1081- if w.Overflow == OverflowFixed {990+ w.unmaximize()
1082- w.toggleExpanded()991+ defer w.scroll()
992+ k := max(w.MaxColumns, 1)
993+ if w.policy().equalCells {
994+ if !w.policy().wraps && !w.laneRow() {
995+ return
996+ }
997+ first, last := w.laneBounds(w.laneOf(w.Focus))
998+ n := last - first + 1
999+ if n == 1 || n >= k {
1000+ return
1001+ }
1002+ cells, ok := w.laneCells(first, last, w.Focus)
1003+ if !ok {
1004+ w.setLaneCells(first, cells)
1005+ if k%n > 0 {
1006+ return // The remainder to the focused column was the step.
1007+ }
1008+ }
1009+ if d := w.donor(first, last, cells); d >= 0 {
1010+ w.moveCell(first, cells, d, w.Focus)
1011+ } else if cycle {
1012+ w.setLaneCells(first, make([]int, n))
1013+ }
1083 return1014 return
1084 }1015 }
1085- if len(w.Columns) == 0 || len(w.presets) == 0 || w.onFloat() {1016+ c := &w.Columns[w.Focus]
1017+ cur := w.scrollCells(w.Focus)
1018+ switch {
1019+ case cur < k:
1020+ c.Cells = cur + 1
1021+ case cycle:
1022+ c.Cells = 0
1023+ }
1024+}
1025+
1026+// ShrinkColumn narrows the focused column by one cell, giving it to its
1027+// right neighbor in the lane (the left one at the lane's end). A column
1028+// never goes below one cell.
1029+func (w *Workspace) ShrinkColumn() {
1030+ if w.onFloat() || len(w.Columns) == 0 || w.fullscreenHides() {
1086 return1031 return
1087 }1032 }
1088- // auto → presets in order → auto; a width set by set-column-width1033+ w.unmaximize()
1089- // restarts at the first preset.1034+ defer w.scroll()
1090- c := &w.Columns[w.Focus]1035+ k := max(w.MaxColumns, 1)
1091- next := Width{}1036+ if w.policy().equalCells {
1092- if c.Width == next || !slices.ContainsFunc(w.presets, c.Width.same) {1037+ if !w.policy().wraps && !w.laneRow() {
1093- next = w.presets[0]1038+ return
1039+ }
1040+ first, last := w.laneBounds(w.laneOf(w.Focus))
1041+ n := last - first + 1
1042+ if n == 1 || n >= k {
1043+ return
1044+ }
1045+ cells, _ := w.laneCells(first, last, -1)
1046+ if cells[w.Focus-first] <= 1 {
1047+ return
1048+ }
1049+ to := w.Focus + 1
1050+ if to > last {
1051+ to = w.Focus - 1
1052+ }
1053+ w.moveCell(first, cells, w.Focus, to)
1054+ return
1094 }1055 }
1095- for i, v := range w.presets {1056+ w.Columns[w.Focus].Cells = max(w.scrollCells(w.Focus)-1, 1)
1096- if v.same(c.Width) && i+1 < len(w.presets) {1057+}
1097- next = w.presets[i+1]1058+
1098- break1059+// scrollCells is the width in cells of column i in scroll overflow: its
1060+// cells, at most the ones in view for an auto column.
1061+func (w *Workspace) scrollCells(i int) int {
1062+ if c := w.Columns[i].Cells; c > 0 {
1063+ return c
1064+ }
1065+ k := max(w.MaxColumns, 1)
1066+ return k / min(len(w.Columns), k)
1067+}
1068+
1069+// laneCells is the cells of the lane first..last, summing to MaxColumns.
1070+// The stored cells are used when every column is set and they sum to it
1071+// (ok); otherwise each column gets an equal share and the remainder goes
1072+// to column remAt, or one cell each to the leftmost columns when remAt is
1073+// negative.
1074+func (w *Workspace) laneCells(first, last, remAt int) (cells []int, ok bool) {
1075+ k := max(w.MaxColumns, 1)
1076+ n := last - first + 1
1077+ cells = make([]int, n)
1078+ sum := 0
1079+ for i := range cells {
1080+ cells[i] = w.Columns[first+i].Cells
1081+ sum += cells[i]
1082+ if cells[i] == 0 {
1083+ sum = -1 << 30
1099 }1084 }
1100 }1085 }
1101- c.Width = next1086+ if sum == k {
1102- w.unmaximize()1087+ return cells, true
1103- w.scroll()1088+ }
1089+ for i := range cells {
1090+ cells[i] = k / n
1091+ }
1092+ if remAt >= 0 {
1093+ cells[remAt-first] += k % n
1094+ } else {
1095+ for i := range k % n {
1096+ cells[i]++
1097+ }
1098+ }
1099+ return cells, false
1104 }1100 }
1105 1101
1106-// toggleExpanded makes the focused column the expanded one, or shrinks it1102+// setLaneCells stores cells on the lane's columns, from column first.
1107-// back when it already is. One column at most is expanded.1103+func (w *Workspace) setLaneCells(first int, cells []int) {
1108-func (w *Workspace) toggleExpanded() {1104+ for i, c := range cells {
1109- if len(w.Columns) < 2 || max(w.MaxColumns, 1) < 2 || w.onFloat() {1105+ w.Columns[first+i].Cells = c
1110- return
1111 }1106 }
1112- on := !w.Columns[w.Focus].Expanded1107+}
1113- for i := range w.Columns {1108+
1114- w.Columns[i].Expanded = false1109+// donor is the lane column that gives a cell to the focused one: among the
1110+// others with more than one cell, the largest; on a tie the nearest to the
1111+// focus, the right side first. -1 when there is none.
1112+func (w *Workspace) donor(first, last int, cells []int) int {
1113+ best := -1
1114+ for i := first; i <= last; i++ {
1115+ c := cells[i-first]
1116+ if i == w.Focus || c <= 1 {
1117+ continue
1118+ }
1119+ if best < 0 {
1120+ best = i
1121+ continue
1122+ }
1123+ bc, bd, d := cells[best-first], abs(best-w.Focus), abs(i-w.Focus)
1124+ if c > bc || c == bc && (d < bd || d == bd && i > w.Focus) {
1125+ best = i
1126+ }
1115 }1127 }
1116- w.Columns[w.Focus].Expanded = on1128+ return best
1117- w.unmaximize()1129+}
1130+
1131+// moveCell moves one cell from column from to column to of the lane that
1132+// starts at first, and stores the lane.
1133+func (w *Workspace) moveCell(first int, cells []int, from, to int) {
1134+ cells[from-first]--
1135+ cells[to-first]++
1136+ w.setLaneCells(first, cells)
1118 }1137 }
1119 1138
1120 // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.1139 // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles.
@@ -1320,7 +1339,6 @@ func (w *Workspace) SetGaps(g int) {
1320 w.Gaps = g1339 w.Gaps = g
1321 w.scroll()1340 w.scroll()
1322 }1341 }
1323-func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) }
1324 func (w *Workspace) SetMaxColumns(n int) {1342 func (w *Workspace) SetMaxColumns(n int) {
1325 w.MaxColumns = n1343 w.MaxColumns = n
1326 w.scroll()1344 w.scroll()
@@ -1457,21 +1475,48 @@ func (w *Workspace) columnWidth(i int) int {
1457 }1475 }
1458 1476
1459 func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {1477 func (w *Workspace) columnWidthFor(i int, ignoreFullWidth bool) int {
1478+ if width, ok := w.specialWidth(i, ignoreFullWidth); ok {
1479+ return width
1480+ }
1481+ if w.policy().wraps || w.laneRow() {
1482+ return w.cellWidth(i)
1483+ }
1484+ return w.scrollWidth(i)
1485+}
1486+
1487+// specialWidth is the width of a maximized or fullscreen column.
1488+func (w *Workspace) specialWidth(i int, ignoreFullWidth bool) (int, bool) {
1460 if w.Columns[i].FullWidth && !ignoreFullWidth {1489 if w.Columns[i].FullWidth && !ignoreFullWidth {
1461- return max(w.Usable.W-2*w.gap(), 0)1490+ return max(w.Usable.W-2*w.gap(), 0), true
1462 }1491 }
1463 if w.fullscreenColumn(i) {1492 if w.fullscreenColumn(i) {
1464- return w.Output.W1493+ return w.Output.W, true
1465 }1494 }
1466- g := w.gap()1495+ return 0, false
1467- // Fixed overflow never scrolls, so presets would push columns off screen.1496+}
1468- if w.policy().wraps {1497+
1469- return w.cellWidth(i)1498+// laneRow reports whether the columns share one row of cells: fixed
1499+// overflow up to MaxColumns of them. Past that the spiral applies and
1500+// cells are ignored.
1501+func (w *Workspace) laneRow() bool {
1502+ return w.Overflow == OverflowFixed && len(w.Columns) <= max(w.MaxColumns, 1)
1503+}
1504+
1505+// scrollWidth is the width of column i when it is sized on its own: auto
1506+// columns share the usable width as the visible ones would, the others take
1507+// their cells (at most MaxColumns) and the gaps between them.
1508+func (w *Workspace) scrollWidth(i int) int {
1509+ k := max(w.MaxColumns, 1)
1510+ if w.policy().equalCells {
1511+ // Fixed overflow past MaxColumns: the spiral's slots.
1512+ return w.equalShare(k)
1470 }1513 }
1471- if w.Columns[i].Width == (Width{}) || w.policy().equalCells {1514+ c := w.Columns[i].Cells
1472- return w.equalShare(min(len(w.Columns), max(w.MaxColumns, 1)))1515+ if c <= 0 {
1516+ return w.equalShare(min(len(w.Columns), k))
1473 }1517 }
1474- return w.Columns[i].Width.Resolve(w.Usable.W, g)1518+ c = min(c, k)
1519+ return max(c*w.equalShare(k)+(c-1)*w.gap(), 0)
1475 }1520 }
1476 func (w *Workspace) columnX(i int) int {1521 func (w *Workspace) columnX(i int) int {
1477 x := w.Usable.X + w.gap()1522 x := w.Usable.X + w.gap()
@@ -1542,10 +1587,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1542 return rects1587 return rects
1543 }1588 }
1544 k := max(w.MaxColumns, 1)1589 k := max(w.MaxColumns, 1)
1545- if e := slices.IndexFunc(w.Columns, func(c Column) bool { return c.Expanded }); w.Overflow == OverflowFixed && e >= 0 && k > 1 && len(w.Columns) > 1 {
1546- // rects is dead here: the expanded rects reuse its storage.
1547- return w.expandedRects(rects[:0], e, k, y, h)
1548- }
1549 if w.Overflow != OverflowFixed || len(w.Columns) <= k {1590 if w.Overflow != OverflowFixed || len(w.Columns) <= k {
1550 return rects1591 return rects
1551 }1592 }
@@ -1558,38 +1599,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
1558 return rects1599 return rects
1559 }1600 }
1560 1601
1561-// expandedRects places expanded column e over k-1 cells in its place. The
1562-// columns before it stack in a strip on its left, those after it on its
1563-// right; the two strips share the last cell.
1564-func (w *Workspace) expandedRects(rects []Rect, e, k, y, h int) []Rect {
1565- g := w.gap()
1566- cell := w.equalShare(k)
1567- wide := (k-1)*cell + (k-2)*g
1568- rest := max(w.Usable.W-2*g-wide-g, 0)
1569- before, after := w.Columns[:e], w.Columns[e+1:]
1570- leftW, rightW := rest, rest
1571- if len(before) > 0 && len(after) > 0 {
1572- leftW = max((rest-g)/2, 0)
1573- rightW = max(rest-g-leftW, 0)
1574- }
1575- x := w.Usable.X + g
1576- if len(before) > 0 {
1577- rects = stackRectsInto(rects, Rect{X: x, Y: y, W: leftW, H: h}, len(before), g)
1578- x += leftW + g
1579- }
1580- rects = append(rects, Rect{X: x, Y: y, W: wide, H: h})
1581- if len(after) > 0 {
1582- rects = stackRectsInto(rects, Rect{X: x + wide + g, Y: y, W: rightW, H: h}, len(after), g)
1583- }
1584- // Gaps wider than a narrow output would push columns past its edge.
1585- right := w.Usable.X + w.Usable.W
1586- for i := range rects {
1587- rects[i].X = min(rects[i].X, right)
1588- rects[i].W = min(rects[i].W, right-rects[i].X)
1589- }
1590- return rects
1591-}
1592-
1593 // stackRects cuts r in n rows with a gap between them; the last row takes1602 // stackRects cuts r in n rows with a gap between them; the last row takes
1594 // the rounding remainder. Rows past the bottom (gaps taller than r) are1603 // the rounding remainder. Rows past the bottom (gaps taller than r) are
1595 // clamped to it, like windows stacked in a column.1604 // clamped to it, like windows stacked in a column.
M internal/core/layout_buffers_test.go +10 -10
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 // layoutVariants builds monitors covering the layout paths that use scratch:
14-// scroll and fixed overflow, an expanded column, row shares, floats, a stash,
14+// scroll and fixed overflow, a column set in cells, row shares, floats, a stash,
1515 // a fullscreen window, a hidden neighbor workspace, the overview and a slide
1616 // toward a neighbor workspace.
1717 func layoutVariants(t *testing.T) map[string]*Monitor {
@@ -32,9 +32,9 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
3232 v["fixed"] = base(OverflowFixed, 5)
3333 v["fixed-spiral"] = base(OverflowFixed, 7)
3434
35- m := base(OverflowFixed, 4)
36- m.Current().Columns[1].Expanded = true
37- v["fixed-expanded"] = m
35+ m := base(OverflowFixed, 3)
36+ m.Current().Columns[1].Cells = 2
37+ v["fixed-cells"] = m
3838
3939 m = base(OverflowScroll, 4)
4040 w := m.Current()
@@ -78,12 +78,12 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
7878 // The variants must hold what they are named for, on the workspace on
7979 // screen (the one whose scratch the tests exercise).
8080 for name, has := range map[string]func(*Workspace) bool{
81- "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82- "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83- "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84- "fixed-expanded": func(w *Workspace) bool { return w.Columns[1].Expanded },
85- "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86- "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
81+ "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82+ "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83+ "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84+ "fixed-cells": func(w *Workspace) bool { return w.Columns[1].Cells == 2 },
85+ "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86+ "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
8787 } {
8888 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {
8989 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 // layoutVariants builds monitors covering the layout paths that use scratch:13 // layoutVariants builds monitors covering the layout paths that use scratch:
14-// scroll and fixed overflow, an expanded column, row shares, floats, a stash,14+// scroll and fixed overflow, a column set in cells, row shares, floats, a stash,
15 // a fullscreen window, a hidden neighbor workspace, the overview and a slide15 // a fullscreen window, a hidden neighbor workspace, the overview and a slide
16 // toward a neighbor workspace.16 // toward a neighbor workspace.
17 func layoutVariants(t *testing.T) map[string]*Monitor {17 func layoutVariants(t *testing.T) map[string]*Monitor {
@@ -32,9 +32,9 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
32 v["fixed"] = base(OverflowFixed, 5)32 v["fixed"] = base(OverflowFixed, 5)
33 v["fixed-spiral"] = base(OverflowFixed, 7)33 v["fixed-spiral"] = base(OverflowFixed, 7)
34 34
35- m := base(OverflowFixed, 4)35+ m := base(OverflowFixed, 3)
36- m.Current().Columns[1].Expanded = true36+ m.Current().Columns[1].Cells = 2
37- v["fixed-expanded"] = m37+ v["fixed-cells"] = m
38 38
39 m = base(OverflowScroll, 4)39 m = base(OverflowScroll, 4)
40 w := m.Current()40 w := m.Current()
@@ -78,12 +78,12 @@ func layoutVariants(t *testing.T) map[string]*Monitor {
78 // The variants must hold what they are named for, on the workspace on78 // The variants must hold what they are named for, on the workspace on
79 // screen (the one whose scratch the tests exercise).79 // screen (the one whose scratch the tests exercise).
80 for name, has := range map[string]func(*Workspace) bool{80 for name, has := range map[string]func(*Workspace) bool{
81- "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },81+ "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82- "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },82+ "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83- "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },83+ "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84- "fixed-expanded": func(w *Workspace) bool { return w.Columns[1].Expanded },84+ "fixed-cells": func(w *Workspace) bool { return w.Columns[1].Cells == 2 },
85- "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },85+ "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86- "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },86+ "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
87 } {87 } {
88 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {88 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {
89 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)89 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)
M internal/core/layout_test.go +140 -104
@@ -15,30 +15,95 @@ func workspace() *Workspace {
1515 w.SetMaxColumns(2)
1616 return w
1717 }
18-func TestWidths(t *testing.T) {
19- for _, tc := range []struct {
20- s string
21- ok bool
22- pixels int
23- }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {
24- v, e := ParseWidth(tc.s)
25- if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
26- t.Errorf("%q: %v %v", tc.s, v, e)
18+
19+// Columns of a lane share the usable width by their cells; unset columns
20+// share what the others leave.
21+func TestLaneWidths(t *testing.T) {
22+ cells := func(w *Workspace, c ...int) []int {
23+ for i, n := range c {
24+ w.Columns[i].Cells = n
2725 }
26+ var got []int
27+ for i := range w.Columns {
28+ got = append(got, w.cellWidth(i))
29+ }
30+ return got
2831 }
29- if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {
30- t.Fatal(n)
31- }
32- if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {
33- t.Fatal(n)
32+ for _, overflow := range []Overflow{OverflowCascade, OverflowFixed} {
33+ t.Run(string(overflow), func(t *testing.T) {
34+ mk := func(n int) *Workspace {
35+ w := &Workspace{MaxColumns: 4, Overflow: overflow, Gaps: 8}
36+ w.SetOutput(1000, 500)
37+ for id := 1; id <= n; id++ {
38+ w.AddWindow(WindowID(id))
39+ }
40+ return w
41+ }
42+ avail := 1000 - 3*8
43+ if got, want := cells(mk(2), 1, 3), []int{avail / 4, avail * 3 / 4}; !reflect.DeepEqual(got, want) {
44+ t.Errorf("1,3: got %v want %v", got, want)
45+ }
46+ // Auto columns share what the set ones leave.
47+ w := mk(3)
48+ avail = 1000 - 4*8
49+ if got, want := cells(w, 2, 0, 0), []int{avail * 2 / 4, avail / 4, avail / 4}; !reflect.DeepEqual(got, want) {
50+ t.Errorf("2,auto,auto: got %v want %v", got, want)
51+ }
52+ // No room for them: an auto column weighs one cell.
53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
54+ t.Errorf("3,auto,auto: got %v want %v", got, want)
55+ }
56+ // All auto: the equal share of n columns, pixel for pixel.
57+ for n := 1; n <= 4; n++ {
58+ w := mk(n)
59+ for i := range w.Columns {
60+ if got := w.cellWidth(i); got != w.equalShare(n) {
61+ t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
62+ }
63+ }
64+ }
65+ // Positions follow the widths.
66+ w = mk(2)
67+ cells(w, 1, 3)
68+ r := w.columnRects()
69+ if r[0].X != 8 || r[1].X != 8+r[0].W+8 {
70+ t.Errorf("rects %v", r)
71+ }
72+ })
3473 }
74+ t.Run("scroll", func(t *testing.T) {
75+ w := &Workspace{MaxColumns: 4, Gaps: 8}
76+ w.SetOutput(1000, 500)
77+ w.AddWindow(1)
78+ w.AddWindow(2)
79+ w.Columns[1].Cells = 2
80+ if got, want := w.columnWidth(1), 2*w.equalShare(4)+8; got != want {
81+ t.Errorf("2 cells: %d want %d", got, want)
82+ }
83+ if got, want := w.columnWidth(0), w.equalShare(2); got != want {
84+ t.Errorf("auto: %d want %d", got, want)
85+ }
86+ w.Columns[1].Cells = 9
87+ if got, want := w.columnWidth(1), 4*w.equalShare(4)+3*8; got != want {
88+ t.Errorf("above max-columns: %d want %d", got, want)
89+ }
90+ })
91+ t.Run("fixed past max-columns ignores cells", func(t *testing.T) {
92+ w := fixedWorkspace(2, 0, 3)
93+ before := w.columnRects()
94+ w.Columns[0].Cells = 1
95+ w.Columns[1].Cells = 3
96+ if after := w.columnRects(); !reflect.DeepEqual(before, after) {
97+ t.Errorf("%v != %v", after, before)
98+ }
99+ })
35100 }
36101 func TestOperations(t *testing.T) {
37102 w := workspace()
38103 w.FocusColumn(1)
39104 w.FocusWindow(-1)
40105 w.MoveColumn(1)
41- w.CycleWidth()
106+ w.GrowColumn(true)
42107 w.ToggleFullscreen()
43108 w.RemoveWindow(1)
44109 if _, ok := w.Focused(); ok {
@@ -112,17 +177,17 @@ func TestMaximizeColumnScroll(t *testing.T) {
112177 for id := WindowID(1); id <= 3; id++ {
113178 w.AddWindow(id)
114179 }
115- w.Columns[1].Width = Width{Pixels: 30}
180+ w.Columns[1].Cells = 2
116181 w.FocusID(2)
117182 w.Apply(ActionMaximizeColumn)
118- if !w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
183+ if !w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
119184 t.Fatal(w.Columns, w.Layout())
120185 }
121186 if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {
122187 t.Fatal(w.Layout())
123188 }
124189 w.Apply(ActionMaximizeColumn)
125- if w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect.W != 30 {
190+ if w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect.W != 89 {
126191 t.Fatal(w.Columns, w.Layout())
127192 }
128193 }
@@ -159,24 +224,17 @@ func TestMaximizeColumnFixedAddWindow(t *testing.T) {
159224 }
160225 }
161226
162-func TestPresetsAndActions(t *testing.T) {
227+func TestCellsAndActions(t *testing.T) {
163228 w := workspace()
164229 w.AddWindow(1)
165- p := []Width{{Pixels: 30}, {Pixels: 40}}
166- w.SetPresets(p)
167- p[0].Pixels = 99
168230 w.AddWindow(2)
231+ // Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto.
169232 w.Apply(ActionCycleColumnWidth)
170- if w.Columns[1].Width.Pixels != 30 {
233+ if w.Columns[1].Cells != 2 {
171234 t.Fatal(w.Columns)
172235 }
173- w.CycleWidth()
174- if w.Columns[1].Width.Pixels != 40 {
175- t.Fatal(w.Columns)
176- }
177- // After the last preset, back to the automatic share.
178- w.CycleWidth()
179- if w.Columns[1].Width != (Width{}) {
236+ w.GrowColumn(true)
237+ if w.Columns[1].Cells != 0 {
180238 t.Fatal(w.Columns)
181239 }
182240 w.RemoveWindow(2)
@@ -188,18 +246,11 @@ func TestPresetsAndActions(t *testing.T) {
188246 t.Errorf("%s: %+v", tc.a, got)
189247 }
190248 }
191- w.SetPresets(nil)
192- old := w.Columns[0].Width
193- w.CycleWidth()
194- if w.Columns[0].Width != old {
195- t.Fatal("empty presets")
196- }
197249 }
198250 func FuzzWorkspaceOps(f *testing.F) {
199251 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
200252 f.Fuzz(func(t *testing.T, ops []byte) {
201253 w := workspace()
202- w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
203254 next := WindowID(1)
204255 for _, op := range ops {
205256 switch op % 18 {
@@ -223,7 +274,7 @@ func FuzzWorkspaceOps(f *testing.F) {
223274 case 7:
224275 w.MoveColumn(1)
225276 case 8:
226- w.CycleWidth()
277+ w.GrowColumn(op%2 == 0)
227278 case 9:
228279 w.ToggleFullscreen()
229280 case 10:
@@ -240,7 +291,7 @@ func FuzzWorkspaceOps(f *testing.F) {
240291 }
241292 case 15:
242293 if len(w.Columns) > 0 {
243- w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}
294+ w.Columns[w.Focus].Cells = math.MaxInt
244295 w.scroll()
245296 }
246297 case 16:
@@ -324,20 +375,6 @@ func stackFocused(w *Workspace) {
324375 }
325376
326377 func TestLargeWidthAndBounds(t *testing.T) {
327- for _, tc := range []struct {
328- width Width
329- usable, gaps, want int
330- }{
331- {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
332- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
333- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
334- {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
335- {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
336- } {
337- if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
338- t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
339- }
340- }
341378 w := workspace()
342379 w.AddWindow(1)
343380 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
@@ -441,9 +478,9 @@ func TestFixedOverflowSpiral(t *testing.T) {
441478 }
442479 }
443480
444-// In fixed overflow, cycle-column-width expands the focused column over
445-// max-columns - 1 cells in its place; the others stack beside it.
446-func TestFixedExpandColumn(t *testing.T) {
481+// In fixed overflow, cycle-column-width grows the focused column by one
482+// cell, taking it from another column of the row.
483+func TestFixedGrowColumn(t *testing.T) {
447484 rects := func(w *Workspace) map[WindowID]Rect {
448485 got := map[WindowID]Rect{}
449486 for id, p := range placements(w) {
@@ -460,70 +497,67 @@ func TestFixedExpandColumn(t *testing.T) {
460497 t.Run("free cell", func(t *testing.T) {
461498 w := fixedWorkspace(4, 0, 2)
462499 w.FocusID(1)
463- w.CycleWidth()
500+ w.GrowColumn(true)
464501 check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})
465- w.CycleWidth()
502+ w.GrowColumn(true)
466503 check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
467504 })
468- t.Run("middle column keeps its place", func(t *testing.T) {
469- w := fixedWorkspace(4, 0, 4)
505+ t.Run("middle column", func(t *testing.T) {
506+ w := fixedWorkspace(4, 0, 3)
470507 w.FocusID(2)
471- w.CycleWidth()
472- check(t, w, map[WindowID]Rect{
473- 1: {W: 12, H: 80},
474- 2: {X: 12, W: 75, H: 80},
475- 3: {X: 87, W: 13, H: 40},
476- 4: {X: 87, Y: 40, W: 13, H: 40},
477- })
508+ w.GrowColumn(true)
509+ // The remainder cell was the step.
510+ check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
511+ // No donor is left: back to equal shares.
512+ w.GrowColumn(true)
513+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
478514 })
479515 t.Run("with gaps", func(t *testing.T) {
480- w := fixedWorkspace(2, 4, 3)
516+ w := fixedWorkspace(4, 4, 2)
481517 w.FocusID(1)
482- w.CycleWidth()
483- // Two cells of 44: the wide one keeps one, the others stack in the other.
518+ w.GrowColumn(true)
519+ // Three cells and one of the 88 px left by the three gaps.
484520 check(t, w, map[WindowID]Rect{
485- 1: {X: 4, Y: 4, W: 44, H: 72},
486- 2: {X: 52, Y: 4, W: 44, H: 34},
487- 3: {X: 52, Y: 42, W: 44, H: 34},
521+ 1: {X: 4, Y: 4, W: 66, H: 72},
522+ 2: {X: 74, Y: 4, W: 22, H: 72},
488523 })
489524 })
490525 t.Run("stays on focus change and new windows", func(t *testing.T) {
491526 w := fixedWorkspace(4, 0, 2)
492527 w.FocusID(1)
493- w.CycleWidth()
528+ w.GrowColumn(true)
494529 w.FocusColumn(1)
495530 w.AddWindow(3)
496- 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}})
531+ // 3, 1 and an auto column that weighs one cell.
532+ check(t, w, map[WindowID]Rect{1: {W: 60, H: 80}, 2: {X: 60, W: 20, H: 80}, 3: {X: 80, W: 20, H: 80}})
497533 })
498- t.Run("moves to the focused column", func(t *testing.T) {
534+ t.Run("the wider column gives a cell to the focused one", func(t *testing.T) {
499535 w := fixedWorkspace(4, 0, 2)
500536 w.FocusID(1)
501- w.CycleWidth()
537+ w.GrowColumn(true)
502538 w.FocusID(2)
503- w.CycleWidth()
504- check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 75, H: 80}})
539+ w.GrowColumn(true)
540+ check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
505541 })
506542 t.Run("closing it shares again", func(t *testing.T) {
507- w := fixedWorkspace(3, 0, 3)
543+ w := fixedWorkspace(4, 0, 3)
508544 w.FocusID(1)
509- w.CycleWidth()
545+ w.GrowColumn(true)
510546 w.RemoveWindow(1)
511547 check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})
512548 })
513549 t.Run("no-op when it cannot grow", func(t *testing.T) {
514- for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(1, 0, 2)} {
550+ for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(2, 0, 2), fixedWorkspace(3, 0, 5)} {
515551 before := rects(w)
516- w.CycleWidth()
552+ w.GrowColumn(true)
517553 check(t, w, before)
518554 }
519555 })
520556 t.Run("stays on screen when gaps do not fit", func(t *testing.T) {
521- for _, w := range []*Workspace{fixedWorkspace(2, 4, 30), fixedWorkspace(4, 4, 4)} {
522- if w.MaxColumns == 4 {
523- w.SetOutput(12, 80)
524- }
557+ for _, w := range []*Workspace{fixedWorkspace(4, 4, 2), fixedWorkspace(4, 4, 3)} {
558+ w.SetOutput(12, 80)
525559 w.FocusID(2)
526- w.CycleWidth()
560+ w.GrowColumn(true)
527561 for id, p := range placements(w) {
528562 r := p.Rect
529563 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 {
@@ -535,7 +569,7 @@ func TestFixedExpandColumn(t *testing.T) {
535569 t.Run("maximize wins and gives it back", func(t *testing.T) {
536570 w := fixedWorkspace(4, 0, 2)
537571 w.FocusID(1)
538- w.CycleWidth()
572+ w.GrowColumn(true)
539573 w.ToggleFullWidth()
540574 if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {
541575 t.Fatal(p)
@@ -545,7 +579,7 @@ func TestFixedExpandColumn(t *testing.T) {
545579 })
546580 }
547581
548-// Up and down cross the columns the spiral or the expanded strips stack.
582+// Up and down cross the columns the spiral stacks.
549583 func TestFixedFocusStackedColumns(t *testing.T) {
550584 focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }
551585 t.Run("spiral", func(t *testing.T) {
@@ -585,18 +619,21 @@ func TestFixedFocusStackedColumns(t *testing.T) {
585619 t.Fatal("right edge has a neighbor")
586620 }
587621 })
588- t.Run("expanded strip", func(t *testing.T) {
589- // 1 wide; 2, 3 and 4 stacked on its right.
590- w := fixedWorkspace(3, 0, 4)
591- w.FocusID(1)
592- w.CycleWidth()
622+ t.Run("a row of wide and narrow columns", func(t *testing.T) {
623+ // 1 wide; 2, 3 and 4 beside it in one row.
624+ w := fixedWorkspace(4, 0, 4)
625+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells, w.Columns[3].Cells = 2, 1, 0, 0
593626 w.FocusID(2)
594- for _, want := range []WindowID{3, 4} {
595- if !w.FocusWindow(1) || focused(w) != want {
596- t.Fatal(focused(w), want)
597- }
627+ if w.FocusWindow(1) || w.FocusWindow(-1) || focused(w) != 2 {
628+ t.Fatal("a single row has no window above or below", focused(w))
598629 }
599- if !w.FocusWindow(-1) || focused(w) != 3 {
630+ w.FocusColumn(1)
631+ if focused(w) != 3 {
632+ t.Fatal(focused(w))
633+ }
634+ w.FocusColumn(-1)
635+ w.FocusColumn(-1)
636+ if focused(w) != 1 {
600637 t.Fatal(focused(w))
601638 }
602639 })
@@ -653,12 +690,11 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
653690 }
654691 })
655692 }
656- t.Run("presets ignored", func(t *testing.T) {
693+ t.Run("a full row does not grow", func(t *testing.T) {
657694 w := fixedWorkspace(2, 0, 2)
658- w.SetPresets([]Width{{Num: 2, Den: 3}})
659- w.CycleWidth()
695+ w.GrowColumn(true)
660696 w.FocusColumn(-1)
661- w.CycleWidth()
697+ w.GrowColumn(true)
662698 for id, p := range placements(w) {
663699 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
664700 t.Fatal(id, p)
@@ -15,30 +15,95 @@ func workspace() *Workspace {
15 w.SetMaxColumns(2)15 w.SetMaxColumns(2)
16 return w16 return w
17 }17 }
18-func TestWidths(t *testing.T) {18+
19- for _, tc := range []struct {19+// Columns of a lane share the usable width by their cells; unset columns
20- s string20+// share what the others leave.
21- ok bool21+func TestLaneWidths(t *testing.T) {
22- pixels int22+ cells := func(w *Workspace, c ...int) []int {
23- }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {23+ for i, n := range c {
24- v, e := ParseWidth(tc.s)24+ w.Columns[i].Cells = n
25- if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
26- t.Errorf("%q: %v %v", tc.s, v, e)
27 }25 }
26+ var got []int
27+ for i := range w.Columns {
28+ got = append(got, w.cellWidth(i))
29+ }
30+ return got
28 }31 }
29- if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {32+ for _, overflow := range []Overflow{OverflowCascade, OverflowFixed} {
30- t.Fatal(n)33+ t.Run(string(overflow), func(t *testing.T) {
31- }34+ mk := func(n int) *Workspace {
32- if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {35+ w := &Workspace{MaxColumns: 4, Overflow: overflow, Gaps: 8}
33- t.Fatal(n)36+ w.SetOutput(1000, 500)
37+ for id := 1; id <= n; id++ {
38+ w.AddWindow(WindowID(id))
39+ }
40+ return w
41+ }
42+ avail := 1000 - 3*8
43+ if got, want := cells(mk(2), 1, 3), []int{avail / 4, avail * 3 / 4}; !reflect.DeepEqual(got, want) {
44+ t.Errorf("1,3: got %v want %v", got, want)
45+ }
46+ // Auto columns share what the set ones leave.
47+ w := mk(3)
48+ avail = 1000 - 4*8
49+ if got, want := cells(w, 2, 0, 0), []int{avail * 2 / 4, avail / 4, avail / 4}; !reflect.DeepEqual(got, want) {
50+ t.Errorf("2,auto,auto: got %v want %v", got, want)
51+ }
52+ // No room for them: an auto column weighs one cell.
53+ if got, want := cells(w, 3, 0, 0), []int{avail * 3 / 5, avail / 5, avail / 5}; !reflect.DeepEqual(got, want) {
54+ t.Errorf("3,auto,auto: got %v want %v", got, want)
55+ }
56+ // All auto: the equal share of n columns, pixel for pixel.
57+ for n := 1; n <= 4; n++ {
58+ w := mk(n)
59+ for i := range w.Columns {
60+ if got := w.cellWidth(i); got != w.equalShare(n) {
61+ t.Errorf("%d auto columns: width %d want %d", n, got, w.equalShare(n))
62+ }
63+ }
64+ }
65+ // Positions follow the widths.
66+ w = mk(2)
67+ cells(w, 1, 3)
68+ r := w.columnRects()
69+ if r[0].X != 8 || r[1].X != 8+r[0].W+8 {
70+ t.Errorf("rects %v", r)
71+ }
72+ })
34 }73 }
74+ t.Run("scroll", func(t *testing.T) {
75+ w := &Workspace{MaxColumns: 4, Gaps: 8}
76+ w.SetOutput(1000, 500)
77+ w.AddWindow(1)
78+ w.AddWindow(2)
79+ w.Columns[1].Cells = 2
80+ if got, want := w.columnWidth(1), 2*w.equalShare(4)+8; got != want {
81+ t.Errorf("2 cells: %d want %d", got, want)
82+ }
83+ if got, want := w.columnWidth(0), w.equalShare(2); got != want {
84+ t.Errorf("auto: %d want %d", got, want)
85+ }
86+ w.Columns[1].Cells = 9
87+ if got, want := w.columnWidth(1), 4*w.equalShare(4)+3*8; got != want {
88+ t.Errorf("above max-columns: %d want %d", got, want)
89+ }
90+ })
91+ t.Run("fixed past max-columns ignores cells", func(t *testing.T) {
92+ w := fixedWorkspace(2, 0, 3)
93+ before := w.columnRects()
94+ w.Columns[0].Cells = 1
95+ w.Columns[1].Cells = 3
96+ if after := w.columnRects(); !reflect.DeepEqual(before, after) {
97+ t.Errorf("%v != %v", after, before)
98+ }
99+ })
35 }100 }
36 func TestOperations(t *testing.T) {101 func TestOperations(t *testing.T) {
37 w := workspace()102 w := workspace()
38 w.FocusColumn(1)103 w.FocusColumn(1)
39 w.FocusWindow(-1)104 w.FocusWindow(-1)
40 w.MoveColumn(1)105 w.MoveColumn(1)
41- w.CycleWidth()106+ w.GrowColumn(true)
42 w.ToggleFullscreen()107 w.ToggleFullscreen()
43 w.RemoveWindow(1)108 w.RemoveWindow(1)
44 if _, ok := w.Focused(); ok {109 if _, ok := w.Focused(); ok {
@@ -112,17 +177,17 @@ func TestMaximizeColumnScroll(t *testing.T) {
112 for id := WindowID(1); id <= 3; id++ {177 for id := WindowID(1); id <= 3; id++ {
113 w.AddWindow(id)178 w.AddWindow(id)
114 }179 }
115- w.Columns[1].Width = Width{Pixels: 30}180+ w.Columns[1].Cells = 2
116 w.FocusID(2)181 w.FocusID(2)
117 w.Apply(ActionMaximizeColumn)182 w.Apply(ActionMaximizeColumn)
118- if !w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {183+ if !w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect != (Rect{X: 5, Y: 5, W: 90, H: 71}) {
119 t.Fatal(w.Columns, w.Layout())184 t.Fatal(w.Columns, w.Layout())
120 }185 }
121 if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {186 if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen {
122 t.Fatal(w.Layout())187 t.Fatal(w.Layout())
123 }188 }
124 w.Apply(ActionMaximizeColumn)189 w.Apply(ActionMaximizeColumn)
125- if w.Columns[1].FullWidth || w.Columns[1].Width.Pixels != 30 || w.Layout()[1].Rect.W != 30 {190+ if w.Columns[1].FullWidth || w.Columns[1].Cells != 2 || w.Layout()[1].Rect.W != 89 {
126 t.Fatal(w.Columns, w.Layout())191 t.Fatal(w.Columns, w.Layout())
127 }192 }
128 }193 }
@@ -159,24 +224,17 @@ func TestMaximizeColumnFixedAddWindow(t *testing.T) {
159 }224 }
160 }225 }
161 226
162-func TestPresetsAndActions(t *testing.T) {227+func TestCellsAndActions(t *testing.T) {
163 w := workspace()228 w := workspace()
164 w.AddWindow(1)229 w.AddWindow(1)
165- p := []Width{{Pixels: 30}, {Pixels: 40}}
166- w.SetPresets(p)
167- p[0].Pixels = 99
168 w.AddWindow(2)230 w.AddWindow(2)
231+ // Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto.
169 w.Apply(ActionCycleColumnWidth)232 w.Apply(ActionCycleColumnWidth)
170- if w.Columns[1].Width.Pixels != 30 {233+ if w.Columns[1].Cells != 2 {
171 t.Fatal(w.Columns)234 t.Fatal(w.Columns)
172 }235 }
173- w.CycleWidth()236+ w.GrowColumn(true)
174- if w.Columns[1].Width.Pixels != 40 {237+ if w.Columns[1].Cells != 0 {
175- t.Fatal(w.Columns)
176- }
177- // After the last preset, back to the automatic share.
178- w.CycleWidth()
179- if w.Columns[1].Width != (Width{}) {
180 t.Fatal(w.Columns)238 t.Fatal(w.Columns)
181 }239 }
182 w.RemoveWindow(2)240 w.RemoveWindow(2)
@@ -188,18 +246,11 @@ func TestPresetsAndActions(t *testing.T) {
188 t.Errorf("%s: %+v", tc.a, got)246 t.Errorf("%s: %+v", tc.a, got)
189 }247 }
190 }248 }
191- w.SetPresets(nil)
192- old := w.Columns[0].Width
193- w.CycleWidth()
194- if w.Columns[0].Width != old {
195- t.Fatal("empty presets")
196- }
197 }249 }
198 func FuzzWorkspaceOps(f *testing.F) {250 func FuzzWorkspaceOps(f *testing.F) {
199 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})251 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
200 f.Fuzz(func(t *testing.T, ops []byte) {252 f.Fuzz(func(t *testing.T, ops []byte) {
201 w := workspace()253 w := workspace()
202- w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
203 next := WindowID(1)254 next := WindowID(1)
204 for _, op := range ops {255 for _, op := range ops {
205 switch op % 18 {256 switch op % 18 {
@@ -223,7 +274,7 @@ func FuzzWorkspaceOps(f *testing.F) {
223 case 7:274 case 7:
224 w.MoveColumn(1)275 w.MoveColumn(1)
225 case 8:276 case 8:
226- w.CycleWidth()277+ w.GrowColumn(op%2 == 0)
227 case 9:278 case 9:
228 w.ToggleFullscreen()279 w.ToggleFullscreen()
229 case 10:280 case 10:
@@ -240,7 +291,7 @@ func FuzzWorkspaceOps(f *testing.F) {
240 }291 }
241 case 15:292 case 15:
242 if len(w.Columns) > 0 {293 if len(w.Columns) > 0 {
243- w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}294+ w.Columns[w.Focus].Cells = math.MaxInt
244 w.scroll()295 w.scroll()
245 }296 }
246 case 16:297 case 16:
@@ -324,20 +375,6 @@ func stackFocused(w *Workspace) {
324 }375 }
325 376
326 func TestLargeWidthAndBounds(t *testing.T) {377 func TestLargeWidthAndBounds(t *testing.T) {
327- for _, tc := range []struct {
328- width Width
329- usable, gaps, want int
330- }{
331- {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
332- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
333- {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
334- {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
335- {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
336- } {
337- if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
338- t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
339- }
340- }
341 w := workspace()378 w := workspace()
342 w.AddWindow(1)379 w.AddWindow(1)
343 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})380 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
@@ -441,9 +478,9 @@ func TestFixedOverflowSpiral(t *testing.T) {
441 }478 }
442 }479 }
443 480
444-// In fixed overflow, cycle-column-width expands the focused column over481+// In fixed overflow, cycle-column-width grows the focused column by one
445-// max-columns - 1 cells in its place; the others stack beside it.482+// cell, taking it from another column of the row.
446-func TestFixedExpandColumn(t *testing.T) {483+func TestFixedGrowColumn(t *testing.T) {
447 rects := func(w *Workspace) map[WindowID]Rect {484 rects := func(w *Workspace) map[WindowID]Rect {
448 got := map[WindowID]Rect{}485 got := map[WindowID]Rect{}
449 for id, p := range placements(w) {486 for id, p := range placements(w) {
@@ -460,70 +497,67 @@ func TestFixedExpandColumn(t *testing.T) {
460 t.Run("free cell", func(t *testing.T) {497 t.Run("free cell", func(t *testing.T) {
461 w := fixedWorkspace(4, 0, 2)498 w := fixedWorkspace(4, 0, 2)
462 w.FocusID(1)499 w.FocusID(1)
463- w.CycleWidth()500+ w.GrowColumn(true)
464 check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})501 check(t, w, map[WindowID]Rect{1: {W: 75, H: 80}, 2: {X: 75, W: 25, H: 80}})
465- w.CycleWidth()502+ w.GrowColumn(true)
466 check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})503 check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
467 })504 })
468- t.Run("middle column keeps its place", func(t *testing.T) {505+ t.Run("middle column", func(t *testing.T) {
469- w := fixedWorkspace(4, 0, 4)506+ w := fixedWorkspace(4, 0, 3)
470 w.FocusID(2)507 w.FocusID(2)
471- w.CycleWidth()508+ w.GrowColumn(true)
472- check(t, w, map[WindowID]Rect{509+ // The remainder cell was the step.
473- 1: {W: 12, H: 80},510+ check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 50, H: 80}, 3: {X: 75, W: 25, H: 80}})
474- 2: {X: 12, W: 75, H: 80},511+ // No donor is left: back to equal shares.
475- 3: {X: 87, W: 13, H: 40},512+ w.GrowColumn(true)
476- 4: {X: 87, Y: 40, W: 13, H: 40},513+ check(t, w, map[WindowID]Rect{1: {W: 33, H: 80}, 2: {X: 33, W: 33, H: 80}, 3: {X: 66, W: 33, H: 80}})
477- })
478 })514 })
479 t.Run("with gaps", func(t *testing.T) {515 t.Run("with gaps", func(t *testing.T) {
480- w := fixedWorkspace(2, 4, 3)516+ w := fixedWorkspace(4, 4, 2)
481 w.FocusID(1)517 w.FocusID(1)
482- w.CycleWidth()518+ w.GrowColumn(true)
483- // Two cells of 44: the wide one keeps one, the others stack in the other.519+ // Three cells and one of the 88 px left by the three gaps.
484 check(t, w, map[WindowID]Rect{520 check(t, w, map[WindowID]Rect{
485- 1: {X: 4, Y: 4, W: 44, H: 72},521+ 1: {X: 4, Y: 4, W: 66, H: 72},
486- 2: {X: 52, Y: 4, W: 44, H: 34},522+ 2: {X: 74, Y: 4, W: 22, H: 72},
487- 3: {X: 52, Y: 42, W: 44, H: 34},
488 })523 })
489 })524 })
490 t.Run("stays on focus change and new windows", func(t *testing.T) {525 t.Run("stays on focus change and new windows", func(t *testing.T) {
491 w := fixedWorkspace(4, 0, 2)526 w := fixedWorkspace(4, 0, 2)
492 w.FocusID(1)527 w.FocusID(1)
493- w.CycleWidth()528+ w.GrowColumn(true)
494 w.FocusColumn(1)529 w.FocusColumn(1)
495 w.AddWindow(3)530 w.AddWindow(3)
496- 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}})531+ // 3, 1 and an auto column that weighs one cell.
532+ check(t, w, map[WindowID]Rect{1: {W: 60, H: 80}, 2: {X: 60, W: 20, H: 80}, 3: {X: 80, W: 20, H: 80}})
497 })533 })
498- t.Run("moves to the focused column", func(t *testing.T) {534+ t.Run("the wider column gives a cell to the focused one", func(t *testing.T) {
499 w := fixedWorkspace(4, 0, 2)535 w := fixedWorkspace(4, 0, 2)
500 w.FocusID(1)536 w.FocusID(1)
501- w.CycleWidth()537+ w.GrowColumn(true)
502 w.FocusID(2)538 w.FocusID(2)
503- w.CycleWidth()539+ w.GrowColumn(true)
504- check(t, w, map[WindowID]Rect{1: {W: 25, H: 80}, 2: {X: 25, W: 75, H: 80}})540+ check(t, w, map[WindowID]Rect{1: {W: 50, H: 80}, 2: {X: 50, W: 50, H: 80}})
505 })541 })
506 t.Run("closing it shares again", func(t *testing.T) {542 t.Run("closing it shares again", func(t *testing.T) {
507- w := fixedWorkspace(3, 0, 3)543+ w := fixedWorkspace(4, 0, 3)
508 w.FocusID(1)544 w.FocusID(1)
509- w.CycleWidth()545+ w.GrowColumn(true)
510 w.RemoveWindow(1)546 w.RemoveWindow(1)
511 check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})547 check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}})
512 })548 })
513 t.Run("no-op when it cannot grow", func(t *testing.T) {549 t.Run("no-op when it cannot grow", func(t *testing.T) {
514- for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(1, 0, 2)} {550+ for _, w := range []*Workspace{fixedWorkspace(3, 0, 1), fixedWorkspace(2, 0, 2), fixedWorkspace(3, 0, 5)} {
515 before := rects(w)551 before := rects(w)
516- w.CycleWidth()552+ w.GrowColumn(true)
517 check(t, w, before)553 check(t, w, before)
518 }554 }
519 })555 })
520 t.Run("stays on screen when gaps do not fit", func(t *testing.T) {556 t.Run("stays on screen when gaps do not fit", func(t *testing.T) {
521- for _, w := range []*Workspace{fixedWorkspace(2, 4, 30), fixedWorkspace(4, 4, 4)} {557+ for _, w := range []*Workspace{fixedWorkspace(4, 4, 2), fixedWorkspace(4, 4, 3)} {
522- if w.MaxColumns == 4 {558+ w.SetOutput(12, 80)
523- w.SetOutput(12, 80)
524- }
525 w.FocusID(2)559 w.FocusID(2)
526- w.CycleWidth()560+ w.GrowColumn(true)
527 for id, p := range placements(w) {561 for id, p := range placements(w) {
528 r := p.Rect562 r := p.Rect
529 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 {563 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 {
@@ -535,7 +569,7 @@ func TestFixedExpandColumn(t *testing.T) {
535 t.Run("maximize wins and gives it back", func(t *testing.T) {569 t.Run("maximize wins and gives it back", func(t *testing.T) {
536 w := fixedWorkspace(4, 0, 2)570 w := fixedWorkspace(4, 0, 2)
537 w.FocusID(1)571 w.FocusID(1)
538- w.CycleWidth()572+ w.GrowColumn(true)
539 w.ToggleFullWidth()573 w.ToggleFullWidth()
540 if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {574 if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden {
541 t.Fatal(p)575 t.Fatal(p)
@@ -545,7 +579,7 @@ func TestFixedExpandColumn(t *testing.T) {
545 })579 })
546 }580 }
547 581
548-// Up and down cross the columns the spiral or the expanded strips stack.582+// Up and down cross the columns the spiral stacks.
549 func TestFixedFocusStackedColumns(t *testing.T) {583 func TestFixedFocusStackedColumns(t *testing.T) {
550 focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }584 focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id }
551 t.Run("spiral", func(t *testing.T) {585 t.Run("spiral", func(t *testing.T) {
@@ -585,18 +619,21 @@ func TestFixedFocusStackedColumns(t *testing.T) {
585 t.Fatal("right edge has a neighbor")619 t.Fatal("right edge has a neighbor")
586 }620 }
587 })621 })
588- t.Run("expanded strip", func(t *testing.T) {622+ t.Run("a row of wide and narrow columns", func(t *testing.T) {
589- // 1 wide; 2, 3 and 4 stacked on its right.623+ // 1 wide; 2, 3 and 4 beside it in one row.
590- w := fixedWorkspace(3, 0, 4)624+ w := fixedWorkspace(4, 0, 4)
591- w.FocusID(1)625+ w.Columns[0].Cells, w.Columns[1].Cells, w.Columns[2].Cells, w.Columns[3].Cells = 2, 1, 0, 0
592- w.CycleWidth()
593 w.FocusID(2)626 w.FocusID(2)
594- for _, want := range []WindowID{3, 4} {627+ if w.FocusWindow(1) || w.FocusWindow(-1) || focused(w) != 2 {
595- if !w.FocusWindow(1) || focused(w) != want {628+ t.Fatal("a single row has no window above or below", focused(w))
596- t.Fatal(focused(w), want)
597- }
598 }629 }
599- if !w.FocusWindow(-1) || focused(w) != 3 {630+ w.FocusColumn(1)
631+ if focused(w) != 3 {
632+ t.Fatal(focused(w))
633+ }
634+ w.FocusColumn(-1)
635+ w.FocusColumn(-1)
636+ if focused(w) != 1 {
600 t.Fatal(focused(w))637 t.Fatal(focused(w))
601 }638 }
602 })639 })
@@ -653,12 +690,11 @@ func TestFixedOverflowEdgeCases(t *testing.T) {
653 }690 }
654 })691 })
655 }692 }
656- t.Run("presets ignored", func(t *testing.T) {693+ t.Run("a full row does not grow", func(t *testing.T) {
657 w := fixedWorkspace(2, 0, 2)694 w := fixedWorkspace(2, 0, 2)
658- w.SetPresets([]Width{{Num: 2, Den: 3}})695+ w.GrowColumn(true)
659- w.CycleWidth()
660 w.FocusColumn(-1)696 w.FocusColumn(-1)
661- w.CycleWidth()697+ w.GrowColumn(true)
662 for id, p := range placements(w) {698 for id, p := range placements(w) {
663 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {699 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
664 t.Fatal(id, p)700 t.Fatal(id, p)
M internal/core/monitor.go +5 -8
@@ -126,7 +126,6 @@ func (m *Monitor) take(w *Workspace) {
126126 // (clamped above the trailing empty workspace). It takes this monitor's
127127 // output-wide settings and does not change what is on screen.
128128 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
129- w.presets = append([]Width(nil), m.template.presets...)
130129 w.Gaps = m.template.Gaps
131130 w.border = m.template.border
132131 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
@@ -164,7 +163,6 @@ func (m *Monitor) Current() *Workspace {
164163 func (m *Monitor) newWorkspace() *Workspace {
165164 w := m.template
166165 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
167- w.presets = append([]Width(nil), m.template.presets...)
168166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
169167 w.overviewAfter = nil
170168 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
@@ -304,15 +302,16 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
304302
305303 // AddWindow places a new window on the workspace on screen; with
306304 // stash.capture, in its stash when shown.
307-func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }
305+func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
308306
309-// addWindow is AddWindow with the width of the new column (zero: automatic).
310-func (m *Monitor) addWindow(id WindowID, width Width) {
307+// addWindow is AddWindow with the width in cells of the new column (zero:
308+// automatic).
309+func (m *Monitor) addWindow(id WindowID, cells int) {
311310 if w, _ := m.find(id); w != nil {
312311 return
313312 }
314313 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
315- w.addWindow(id, width)
314+ w.addWindow(id, cells)
316315 }
317316 m.normalize()
318317 }
@@ -502,8 +501,6 @@ func (m *Monitor) SetStash(width, gap int) {
502501 // SetStashCapture makes new windows join the shown stash (stash.capture).
503502 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
504503
505-func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
506-
507504 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
508505 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
509506
@@ -126,7 +126,6 @@ func (m *Monitor) take(w *Workspace) {
126 // (clamped above the trailing empty workspace). It takes this monitor's126 // (clamped above the trailing empty workspace). It takes this monitor's
127 // output-wide settings and does not change what is on screen.127 // output-wide settings and does not change what is on screen.
128 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {128 func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) {
129- w.presets = append([]Width(nil), m.template.presets...)
130 w.Gaps = m.template.Gaps129 w.Gaps = m.template.Gaps
131 w.border = m.template.border130 w.border = m.template.border
132 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap131 w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap
@@ -164,7 +163,6 @@ func (m *Monitor) Current() *Workspace {
164 func (m *Monitor) newWorkspace() *Workspace {163 func (m *Monitor) newWorkspace() *Workspace {
165 w := m.template164 w := m.template
166 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil165 w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil
167- w.presets = append([]Width(nil), m.template.presets...)
168 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""166 w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, ""
169 w.overviewAfter = nil167 w.overviewAfter = nil
170 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0168 w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0
@@ -304,15 +302,16 @@ func (m *Monitor) find(id WindowID) (*Workspace, int) {
304 302
305 // AddWindow places a new window on the workspace on screen; with303 // AddWindow places a new window on the workspace on screen; with
306 // stash.capture, in its stash when shown.304 // stash.capture, in its stash when shown.
307-func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, Width{}) }305+func (m *Monitor) AddWindow(id WindowID) { m.addWindow(id, 0) }
308 306
309-// addWindow is AddWindow with the width of the new column (zero: automatic).307+// addWindow is AddWindow with the width in cells of the new column (zero:
310-func (m *Monitor) addWindow(id WindowID, width Width) {308+// automatic).
309+func (m *Monitor) addWindow(id WindowID, cells int) {
311 if w, _ := m.find(id); w != nil {310 if w, _ := m.find(id); w != nil {
312 return311 return
313 }312 }
314 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {313 if w := m.Current(); !m.stashCapture || !w.captureStash(id) {
315- w.addWindow(id, width)314+ w.addWindow(id, cells)
316 }315 }
317 m.normalize()316 m.normalize()
318 }317 }
@@ -502,8 +501,6 @@ func (m *Monitor) SetStash(width, gap int) {
502 // SetStashCapture makes new windows join the shown stash (stash.capture).501 // SetStashCapture makes new windows join the shown stash (stash.capture).
503 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }502 func (m *Monitor) SetStashCapture(on bool) { m.stashCapture = on }
504 503
505-func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) }
506-
507 // SetFollowMove makes moves to another workspace show it (focus.follow-move).504 // SetFollowMove makes moves to another workspace show it (focus.follow-move).
508 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }505 func (m *Monitor) SetFollowMove(on bool) { m.followMove = on }
509 506
M internal/core/monitor_test.go +9 -8
@@ -199,10 +199,10 @@ func TestEqualSharesAndBorderless(t *testing.T) {
199199 if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {
200200 t.Fatal(w.View)
201201 }
202- // A preset on one column is kept; auto columns keep their share.
203- m.SetPresets([]Width{{Num: 1, Den: 2}})
202+ // Growing one column gives it 2 of the 3 cells; auto columns keep
203+ // their share.
204204 m.Apply(ActionCycleColumnWidth)
205- if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {
205+ if w := m.Current(); w.columnWidth(w.Focus) != 66 || w.columnWidth(0) != 33 {
206206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
207207 }
208208 }
@@ -294,11 +294,11 @@ func TestWorkspaceArg(t *testing.T) {
294294 }
295295 }
296296
297-func TestLonePresetColumnKeepsWidth(t *testing.T) {
297+func TestLoneCellColumnKeepsWidth(t *testing.T) {
298298 m := monitor()
299- m.SetPresets([]Width{{Num: 1, Den: 2}})
300299 m.AddWindow(1)
301- m.Apply(ActionCycleColumnWidth)
300+ // One cell of the two: half the width, still no neighbor.
301+ m.Apply("set-column-width -1")
302302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
303303 t.Fatal(p)
304304 }
@@ -475,13 +475,14 @@ func TestMonitorAllAllocations(t *testing.T) {
475475 // exit restores the tiles with the user's focus where it was.
476476 func TestFixedFullscreenStaysInPlace(t *testing.T) {
477477 m := monitor()
478+ m.SetMaxColumns(4)
478479 m.SetOverflow(OverflowFixed)
479480 for id := WindowID(1); id <= 3; id++ {
480481 m.AddWindow(id)
481482 }
482483 w := m.Current()
483484 w.FocusID(2)
484- m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen
485+ m.Apply(ActionCycleColumnWidth) // wider: kept through fullscreen
485486 m.ToggleFullscreen()
486487 m.AddWindow(4)
487488 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {
@@ -491,7 +492,7 @@ func TestFixedFullscreenStaysInPlace(t *testing.T) {
491492 t.Fatal("focus", id)
492493 }
493494 m.ToggleFullscreen()
494- if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded {
495+ if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || w.Columns[1].Cells != 2 {
495496 t.Fatal("after bind", id, w.Columns)
496497 }
497498 }
@@ -199,10 +199,10 @@ func TestEqualSharesAndBorderless(t *testing.T) {
199 if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {199 if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 {
200 t.Fatal(w.View)200 t.Fatal(w.View)
201 }201 }
202- // A preset on one column is kept; auto columns keep their share.202+ // Growing one column gives it 2 of the 3 cells; auto columns keep
203- m.SetPresets([]Width{{Num: 1, Den: 2}})203+ // their share.
204 m.Apply(ActionCycleColumnWidth)204 m.Apply(ActionCycleColumnWidth)
205- if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {205+ if w := m.Current(); w.columnWidth(w.Focus) != 66 || w.columnWidth(0) != 33 {
206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
207 }207 }
208 }208 }
@@ -294,11 +294,11 @@ func TestWorkspaceArg(t *testing.T) {
294 }294 }
295 }295 }
296 296
297-func TestLonePresetColumnKeepsWidth(t *testing.T) {297+func TestLoneCellColumnKeepsWidth(t *testing.T) {
298 m := monitor()298 m := monitor()
299- m.SetPresets([]Width{{Num: 1, Den: 2}})
300 m.AddWindow(1)299 m.AddWindow(1)
301- m.Apply(ActionCycleColumnWidth)300+ // One cell of the two: half the width, still no neighbor.
301+ m.Apply("set-column-width -1")
302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
303 t.Fatal(p)303 t.Fatal(p)
304 }304 }
@@ -475,13 +475,14 @@ func TestMonitorAllAllocations(t *testing.T) {
475 // exit restores the tiles with the user's focus where it was.475 // exit restores the tiles with the user's focus where it was.
476 func TestFixedFullscreenStaysInPlace(t *testing.T) {476 func TestFixedFullscreenStaysInPlace(t *testing.T) {
477 m := monitor()477 m := monitor()
478+ m.SetMaxColumns(4)
478 m.SetOverflow(OverflowFixed)479 m.SetOverflow(OverflowFixed)
479 for id := WindowID(1); id <= 3; id++ {480 for id := WindowID(1); id <= 3; id++ {
480 m.AddWindow(id)481 m.AddWindow(id)
481 }482 }
482 w := m.Current()483 w := m.Current()
483 w.FocusID(2)484 w.FocusID(2)
484- m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen485+ m.Apply(ActionCycleColumnWidth) // wider: kept through fullscreen
485 m.ToggleFullscreen()486 m.ToggleFullscreen()
486 m.AddWindow(4)487 m.AddWindow(4)
487 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {488 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {
@@ -491,7 +492,7 @@ func TestFixedFullscreenStaysInPlace(t *testing.T) {
491 t.Fatal("focus", id)492 t.Fatal("focus", id)
492 }493 }
493 m.ToggleFullscreen()494 m.ToggleFullscreen()
494- if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded {495+ if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || w.Columns[1].Cells != 2 {
495 t.Fatal("after bind", id, w.Columns)496 t.Fatal("after bind", id, w.Columns)
496 }497 }
497 }498 }
M internal/core/motion_test.go +3 -2
@@ -156,9 +156,10 @@ func TestSnapPointsMergeClose(t *testing.T) {
156156 // Columns narrower than the view: one point.
157157 {[]int{300, 300}, []float64{0}},
158158 } {
159- w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}
159+ // 100 cells of 10 px, no gaps: a width in px is px/10 cells.
160+ w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}, MaxColumns: 100}
160161 for _, px := range tc.widths {
161- w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162+ w.Columns = append(w.Columns, Column{Cells: px / 10, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162163 }
163164 if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164165 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
@@ -156,9 +156,10 @@ func TestSnapPointsMergeClose(t *testing.T) {
156 // Columns narrower than the view: one point.156 // Columns narrower than the view: one point.
157 {[]int{300, 300}, []float64{0}},157 {[]int{300, 300}, []float64{0}},
158 } {158 } {
159- w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}159+ // 100 cells of 10 px, no gaps: a width in px is px/10 cells.
160+ w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}, MaxColumns: 100}
160 for _, px := range tc.widths {161 for _, px := range tc.widths {
161- w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})162+ w.Columns = append(w.Columns, Column{Cells: px / 10, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162 }163 }
163 if got := w.snapPoints(); !slices.Equal(got, tc.want) {164 if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)165 t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
M internal/core/overview_test.go +5 -4
@@ -467,8 +467,8 @@ func TestOverviewPileOnly(t *testing.T) {
467467 // Fixed overflow previews use the actual buffer geometry, including the
468468 // spiral and window stacks, rather than laying full-height columns in a row.
469469 func TestOverviewFixedOverflowGeometry(t *testing.T) {
470- for _, expanded := range []bool{false, true} {
471- t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) {
470+ for _, cells := range []bool{false, true} {
471+ t.Run(map[bool]string{false: "spiral", true: "cells ignored"}[cells], func(t *testing.T) {
472472 m := newMonitor("", "")
473473 m.SetOutput(600, 400)
474474 m.SetOverflow(OverflowFixed)
@@ -477,8 +477,9 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
477477 m.AddWindow(id)
478478 }
479479 w := m.Current()
480- if expanded {
481- w.Columns[2].Expanded = true
480+ if cells {
481+ // The spiral ignores cells: the preview must still match.
482+ w.Columns[0].Cells, w.Columns[2].Cells = 1, 3
482483 }
483484 w.Columns[3].Windows = append(w.Columns[3].Windows, 6)
484485 w.Columns[3].Focus = 1
@@ -467,8 +467,8 @@ func TestOverviewPileOnly(t *testing.T) {
467 // Fixed overflow previews use the actual buffer geometry, including the467 // Fixed overflow previews use the actual buffer geometry, including the
468 // spiral and window stacks, rather than laying full-height columns in a row.468 // spiral and window stacks, rather than laying full-height columns in a row.
469 func TestOverviewFixedOverflowGeometry(t *testing.T) {469 func TestOverviewFixedOverflowGeometry(t *testing.T) {
470- for _, expanded := range []bool{false, true} {470+ for _, cells := range []bool{false, true} {
471- t.Run(map[bool]string{false: "spiral", true: "expanded"}[expanded], func(t *testing.T) {471+ t.Run(map[bool]string{false: "spiral", true: "cells ignored"}[cells], func(t *testing.T) {
472 m := newMonitor("", "")472 m := newMonitor("", "")
473 m.SetOutput(600, 400)473 m.SetOutput(600, 400)
474 m.SetOverflow(OverflowFixed)474 m.SetOverflow(OverflowFixed)
@@ -477,8 +477,9 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) {
477 m.AddWindow(id)477 m.AddWindow(id)
478 }478 }
479 w := m.Current()479 w := m.Current()
480- if expanded {480+ if cells {
481- w.Columns[2].Expanded = true481+ // The spiral ignores cells: the preview must still match.
482+ w.Columns[0].Cells, w.Columns[2].Cells = 1, 3
482 }483 }
483 w.Columns[3].Windows = append(w.Columns[3].Windows, 6)484 w.Columns[3].Windows = append(w.Columns[3].Windows, 6)
484 w.Columns[3].Focus = 1485 w.Columns[3].Focus = 1
M internal/core/rules.go +9 -12
@@ -29,7 +29,7 @@ type rule struct {
2929 number int
3030 workspace string
3131 monitor string
32- width Width // zero: automatic
32+ cells int // zero: automatic
3333 }
3434
3535 // ruleEffect is the merge of every rule matching a window: per field, the
@@ -39,7 +39,7 @@ type ruleEffect struct {
3939 number int
4040 workspace string
4141 monitor string
42- width Width // zero: automatic
42+ cells int // zero: automatic
4343 }
4444
4545 func parseRules(rs []ports.WindowRule) ([]rule, error) {
@@ -59,13 +59,10 @@ func parseRules(rs []ports.WindowRule) ([]rule, error) {
5959 p.workspace = r.Workspace
6060 }
6161 }
62- if r.Width != "" {
63- width, err := ParseWidth(r.Width)
64- if err != nil {
65- return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66- }
67- p.width = width
62+ if r.Cells < 0 || r.Cells > maxCells {
63+ return nil, fmt.Errorf("rule %s: invalid width %d", r.Name, r.Cells)
6864 }
65+ p.cells = r.Cells
6966 out = append(out, p)
7067 }
7168 return out, nil
@@ -93,8 +90,8 @@ func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
9390 if r.monitor != "" {
9491 eff.monitor = r.monitor
9592 }
96- if r.width != (Width{}) {
97- eff.width = r.width
93+ if r.cells != 0 {
94+ eff.cells = r.cells
9895 }
9996 }
10097 return eff, ok
@@ -140,9 +137,9 @@ func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
140137 // floated by a rule becomes a free float, core sizing it.
141138 w.addFloat(w.ruleFloat(v), quiet)
142139 case quiet:
143- w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})
140+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Cells: eff.cells})
144141 default:
145- sc.mon.addWindow(v.ID, eff.width)
142+ sc.mon.addWindow(v.ID, eff.cells)
146143 return
147144 }
148145 sc.mon.normalize()
@@ -29,7 +29,7 @@ type rule struct {
29 number int29 number int
30 workspace string30 workspace string
31 monitor string31 monitor string
32- width Width // zero: automatic32+ cells int // zero: automatic
33 }33 }
34 34
35 // ruleEffect is the merge of every rule matching a window: per field, the35 // ruleEffect is the merge of every rule matching a window: per field, the
@@ -39,7 +39,7 @@ type ruleEffect struct {
39 number int39 number int
40 workspace string40 workspace string
41 monitor string41 monitor string
42- width Width // zero: automatic42+ cells int // zero: automatic
43 }43 }
44 44
45 func parseRules(rs []ports.WindowRule) ([]rule, error) {45 func parseRules(rs []ports.WindowRule) ([]rule, error) {
@@ -59,13 +59,10 @@ func parseRules(rs []ports.WindowRule) ([]rule, error) {
59 p.workspace = r.Workspace59 p.workspace = r.Workspace
60 }60 }
61 }61 }
62- if r.Width != "" {62+ if r.Cells < 0 || r.Cells > maxCells {
63- width, err := ParseWidth(r.Width)63+ return nil, fmt.Errorf("rule %s: invalid width %d", r.Name, r.Cells)
64- if err != nil {
65- return nil, fmt.Errorf("rule %s: %w", r.Name, err)
66- }
67- p.width = width
68 }64 }
65+ p.cells = r.Cells
69 out = append(out, p)66 out = append(out, p)
70 }67 }
71 return out, nil68 return out, nil
@@ -93,8 +90,8 @@ func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) {
93 if r.monitor != "" {90 if r.monitor != "" {
94 eff.monitor = r.monitor91 eff.monitor = r.monitor
95 }92 }
96- if r.width != (Width{}) {93+ if r.cells != 0 {
97- eff.width = r.width94+ eff.cells = r.cells
98 }95 }
99 }96 }
100 return eff, ok97 return eff, ok
@@ -140,9 +137,9 @@ func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) {
140 // floated by a rule becomes a free float, core sizing it.137 // floated by a rule becomes a free float, core sizing it.
141 w.addFloat(w.ruleFloat(v), quiet)138 w.addFloat(w.ruleFloat(v), quiet)
142 case quiet:139 case quiet:
143- w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Width: eff.width})140+ w.addColumnQuiet(Column{Windows: []WindowID{v.ID}, Cells: eff.cells})
144 default:141 default:
145- sc.mon.addWindow(v.ID, eff.width)142+ sc.mon.addWindow(v.ID, eff.cells)
146 return143 return
147 }144 }
148 sc.mon.normalize()145 sc.mon.normalize()
M internal/core/rules_test.go +10 -8
@@ -225,8 +225,8 @@ func TestRulesMerge(t *testing.T) {
225225 r := startMulti(t, func(c *ports.Config) {
226226 c.Layout.MaxColumns = 4
227227 c.Rules = []ports.WindowRule{
228- winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }),
229- winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }),
228+ winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Cells = 1 }),
229+ winRule("chat", "chat", func(r *ports.WindowRule) { r.Cells = 2 }),
230230 }
231231 }, left, right)
232232 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
@@ -260,7 +260,7 @@ func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Sce
260260 func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261261 r := startMulti(t, func(c *ports.Config) {
262262 c.Layout.MaxColumns = 4
263- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })}
263+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 2 })}
264264 }, left)
265265 r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266266 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -271,7 +271,7 @@ func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
271271
272272 func TestSlotWinsOverRule(t *testing.T) {
273273 r := startMulti(t, func(c *ports.Config) {
274- c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}}
274+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Cells: 2, Argv: []string{"code"}}}}}
275275 c.Binds["Alt+d"] = "workspace dev"
276276 c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277277 }, left)
@@ -396,16 +396,18 @@ func TestRuleQuietPlacementWithAnimations(t *testing.T) {
396396 }
397397 }
398398
399-func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {
399+// A fixed row shares its width by cells: the auto column takes the cell
400+// the wide one leaves.
401+func TestRuleWidthWithFixedOverflow(t *testing.T) {
400402 r := startMulti(t, func(c *ports.Config) {
401- c.Layout.MaxColumns = 2
403+ c.Layout.MaxColumns = 4
402404 c.Layout.Overflow = "fixed"
403- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}
405+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 3 })}
404406 }, left)
405407 r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406408 r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407409 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408- if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {
410+ if s.Windows[0].Rect.W != 50 || s.Windows[1].Rect.W != 150 {
409411 t.Fatalf("%+v", s.Windows)
410412 }
411413 }
@@ -225,8 +225,8 @@ func TestRulesMerge(t *testing.T) {
225 r := startMulti(t, func(c *ports.Config) {225 r := startMulti(t, func(c *ports.Config) {
226 c.Layout.MaxColumns = 4226 c.Layout.MaxColumns = 4
227 c.Rules = []ports.WindowRule{227 c.Rules = []ports.WindowRule{
228- winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Width = "1/4" }),228+ winRule("all", ".*", func(r *ports.WindowRule) { r.Monitor = "DP-2"; r.Cells = 1 }),
229- winRule("chat", "chat", func(r *ports.WindowRule) { r.Width = "1/2" }),229+ winRule("chat", "chat", func(r *ports.WindowRule) { r.Cells = 2 }),
230 }230 }
231 }, left, right)231 }, left, right)
232 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)232 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
@@ -260,7 +260,7 @@ func sceneMatch2(t *testing.T, r *multiRig, ok func(ports.Scene) bool) ports.Sce
260 func TestRuleWidthOnCurrentWorkspace(t *testing.T) {260 func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
261 r := startMulti(t, func(c *ports.Config) {261 r := startMulti(t, func(c *ports.Config) {
262 c.Layout.MaxColumns = 4262 c.Layout.MaxColumns = 4
263- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "50%" })}263+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 2 })}
264 }, left)264 }, left)
265 r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}265 r.client <- ports.WindowMapped{ID: 1, AppID: "wide"}
266 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })266 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 1 })
@@ -271,7 +271,7 @@ func TestRuleWidthOnCurrentWorkspace(t *testing.T) {
271 271
272 func TestSlotWinsOverRule(t *testing.T) {272 func TestSlotWinsOverRule(t *testing.T) {
273 r := startMulti(t, func(c *ports.Config) {273 r := startMulti(t, func(c *ports.Config) {
274- c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Width: "50%", Argv: []string{"code"}}}}}274+ c.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{{Index: 1, Cells: 2, Argv: []string{"code"}}}}}
275 c.Binds["Alt+d"] = "workspace dev"275 c.Binds["Alt+d"] = "workspace dev"
276 c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}276 c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })}
277 }, left)277 }, left)
@@ -396,16 +396,18 @@ func TestRuleQuietPlacementWithAnimations(t *testing.T) {
396 }396 }
397 }397 }
398 398
399-func TestRuleWidthIgnoredWithFixedOverflow(t *testing.T) {399+// A fixed row shares its width by cells: the auto column takes the cell
400+// the wide one leaves.
401+func TestRuleWidthWithFixedOverflow(t *testing.T) {
400 r := startMulti(t, func(c *ports.Config) {402 r := startMulti(t, func(c *ports.Config) {
401- c.Layout.MaxColumns = 2403+ c.Layout.MaxColumns = 4
402 c.Layout.Overflow = "fixed"404 c.Layout.Overflow = "fixed"
403- c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Width = "10%" })}405+ c.Rules = []ports.WindowRule{winRule("w", "wide", func(r *ports.WindowRule) { r.Cells = 3 })}
404 }, left)406 }, left)
405 r.client <- ports.WindowMapped{ID: 1, AppID: "term"}407 r.client <- ports.WindowMapped{ID: 1, AppID: "term"}
406 r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}408 r.client <- ports.WindowMapped{ID: 2, AppID: "wide"}
407 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })409 s := sceneMatch2(t, r, func(s ports.Scene) bool { return len(s.Windows) == 2 })
408- if s.Windows[0].Rect.W != 100 || s.Windows[1].Rect.W != 100 {410+ if s.Windows[0].Rect.W != 50 || s.Windows[1].Rect.W != 150 {
409 t.Fatalf("%+v", s.Windows)411 t.Fatalf("%+v", s.Windows)
410 }412 }
411 }413 }
M internal/core/scene_seq_test.go +4 -1
@@ -40,7 +40,10 @@ func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
4040 // same: the key's scene draws what the previous one did.
4141 same bool
4242 }{
43- {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },
43+ {"width change", func(c *ports.Config) {
44+ c.Layout.MaxColumns = 4
45+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
46+ },
4447 func(t *testing.T, r *swipeRig) ports.Scene {
4548 twoColumns(t, r)
4649 return r.key(t, "Left", ports.ModAlt)[0]
@@ -40,7 +40,10 @@ func TestAnimatingKeyGetsFreshSeq(t *testing.T) {
40 // same: the key's scene draws what the previous one did.40 // same: the key's scene draws what the previous one did.
41 same bool41 same bool
42 }{42 }{
43- {"width change", func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" },43+ {"width change", func(c *ports.Config) {
44+ c.Layout.MaxColumns = 4
45+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
46+ },
44 func(t *testing.T, r *swipeRig) ports.Scene {47 func(t *testing.T, r *swipeRig) ports.Scene {
45 twoColumns(t, r)48 twoColumns(t, r)
46 return r.key(t, "Left", ports.ModAlt)[0]49 return r.key(t, "Left", ports.ModAlt)[0]
M internal/core/screens.go +0 -1
@@ -524,7 +524,6 @@ func (c *Core) settings(m *Monitor) {
524524 }
525525 m.SetOverflow(Overflow(rules.Overflow))
526526 m.SetMaxColumns(rules.MaxColumns)
527- m.SetPresets(c.presets)
528527 m.SetGaps(c.cfg.Layout.Gaps)
529528 m.SetBorder(c.cfg.Border.Width)
530529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
@@ -524,7 +524,6 @@ func (c *Core) settings(m *Monitor) {
524 }524 }
525 m.SetOverflow(Overflow(rules.Overflow))525 m.SetOverflow(Overflow(rules.Overflow))
526 m.SetMaxColumns(rules.MaxColumns)526 m.SetMaxColumns(rules.MaxColumns)
527- m.SetPresets(c.presets)
528 m.SetGaps(c.cfg.Layout.Gaps)527 m.SetGaps(c.cfg.Layout.Gaps)
529 m.SetBorder(c.cfg.Border.Width)528 m.SetBorder(c.cfg.Border.Width)
530 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)529 m.SetStash(c.cfg.Stash.Width, c.cfg.Stash.Gap)
M internal/core/slots.go +9 -10
@@ -26,7 +26,7 @@ type slotKey struct {
2626
2727 type slotState struct {
2828 argv []string
29- width Width
29+ cells int
3030 window WindowID // 0 while empty
3131 // stale is set when the workspace is shown while the spawn is pending.
3232 // Shown again still pending, the command is deemed failed and respawned;
@@ -38,21 +38,20 @@ type slotState struct {
3838 type slotSpec struct {
3939 key slotKey
4040 argv []string
41- width Width
41+ cells int
4242 }
4343
4444 func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
4545 var specs []slotSpec
4646 for _, w := range ws {
4747 for _, s := range w.Slots {
48- width, err := ParseWidth(s.Width)
49- if err != nil {
50- return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
48+ if s.Cells < 0 || s.Cells > maxCells {
49+ return nil, fmt.Errorf("workspace %s column %d: invalid width %d", w.Name, s.Index, s.Cells)
5150 }
5251 if len(s.Argv) == 0 || s.Index < 1 {
5352 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
5453 }
55- specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
54+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), cells: s.Cells})
5655 }
5756 }
5857 return specs, nil
@@ -81,14 +80,14 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
8180 for _, s := range specs {
8281 st, ok := c.slots[s.key]
8382 if !ok {
84- c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
83+ c.slots[s.key] = &slotState{argv: s.argv, cells: s.cells}
8584 spawn = append(spawn, s.key)
8685 continue
8786 }
88- if st.width != s.width {
89- st.width = s.width
87+ if st.cells != s.cells {
88+ st.cells = s.cells
9089 if _, w := c.byName(s.key.workspace); w != nil {
91- w.setSlotWidth(s.key.index, s.width)
90+ w.setSlotCells(s.key.index, s.cells)
9291 }
9392 }
9493 if !slices.Equal(st.argv, s.argv) {
@@ -26,7 +26,7 @@ type slotKey struct {
26 26
27 type slotState struct {27 type slotState struct {
28 argv []string28 argv []string
29- width Width29+ cells int
30 window WindowID // 0 while empty30 window WindowID // 0 while empty
31 // stale is set when the workspace is shown while the spawn is pending.31 // stale is set when the workspace is shown while the spawn is pending.
32 // Shown again still pending, the command is deemed failed and respawned;32 // Shown again still pending, the command is deemed failed and respawned;
@@ -38,21 +38,20 @@ type slotState struct {
38 type slotSpec struct {38 type slotSpec struct {
39 key slotKey39 key slotKey
40 argv []string40 argv []string
41- width Width41+ cells int
42 }42 }
43 43
44 func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {44 func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
45 var specs []slotSpec45 var specs []slotSpec
46 for _, w := range ws {46 for _, w := range ws {
47 for _, s := range w.Slots {47 for _, s := range w.Slots {
48- width, err := ParseWidth(s.Width)48+ if s.Cells < 0 || s.Cells > maxCells {
49- if err != nil {49+ return nil, fmt.Errorf("workspace %s column %d: invalid width %d", w.Name, s.Index, s.Cells)
50- return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
51 }50 }
52 if len(s.Argv) == 0 || s.Index < 1 {51 if len(s.Argv) == 0 || s.Index < 1 {
53 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)52 return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
54 }53 }
55- specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})54+ specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), cells: s.Cells})
56 }55 }
57 }56 }
58 return specs, nil57 return specs, nil
@@ -81,14 +80,14 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey {
81 for _, s := range specs {80 for _, s := range specs {
82 st, ok := c.slots[s.key]81 st, ok := c.slots[s.key]
83 if !ok {82 if !ok {
84- c.slots[s.key] = &slotState{argv: s.argv, width: s.width}83+ c.slots[s.key] = &slotState{argv: s.argv, cells: s.cells}
85 spawn = append(spawn, s.key)84 spawn = append(spawn, s.key)
86 continue85 continue
87 }86 }
88- if st.width != s.width {87+ if st.cells != s.cells {
89- st.width = s.width88+ st.cells = s.cells
90 if _, w := c.byName(s.key.workspace); w != nil {89 if _, w := c.byName(s.key.workspace); w != nil {
91- w.setSlotWidth(s.key.index, s.width)90+ w.setSlotCells(s.key.index, s.cells)
92 }91 }
93 }92 }
94 if !slices.Equal(st.argv, s.argv) {93 if !slices.Equal(st.argv, s.argv) {
M internal/core/slots_test.go +9 -7
@@ -45,9 +45,11 @@ func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
4545 cfg.Animations.On = *animated
4646 }
4747 cfg.Border.Width = 0
48+ // A cell is a tenth of the width: the slots take 7 and 3 cells.
49+ cfg.Layout.MaxColumns = 10
4850 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
49- {Index: 1, Width: "70%", Argv: []string{"code"}},
50- {Index: 2, Width: "30%", Argv: []string{"foot"}},
51+ {Index: 1, Cells: 7, Argv: []string{"code"}},
52+ {Index: 2, Cells: 3, Argv: []string{"foot"}},
5153 }}}
5254 cfg.Binds["Alt+d"] = "workspace dev"
5355 if len(numbered) > 1 {
@@ -285,9 +287,9 @@ func TestSlotReload(t *testing.T) {
285287 cfg := r.cfg
286288 ws := cfg.Workspaces[0]
287289 ws.Slots = []ports.SlotConfig{
288- {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
289- {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
290- {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
290+ {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only
291+ {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command
292+ {Index: 3, Cells: 2, Argv: []string{"btop"}}, // new slot
291293 }
292294 cfg.Workspaces = []ports.WorkspaceConfig{ws}
293295 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -329,7 +331,7 @@ func TestInvalidSlotWidthRejected(t *testing.T) {
329331 receive(t, r.spawn)
330332 cfg := r.cfg
331333 ws := cfg.Workspaces[0]
332- ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
334+ ws.Slots = []ports.SlotConfig{{Index: 1, Cells: 17, Argv: []string{"x"}}}
333335 cfg.Workspaces = []ports.WorkspaceConfig{ws}
334336 r.reload <- ports.ConfigChanged{Config: cfg}
335337 if err := receive(t, r.errs); err == nil {
@@ -346,7 +348,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
346348 r.press(t, "d")
347349 cfg := r.cfg
348350 ws := cfg.Workspaces[0]
349- ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
351+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Cells: 2, Argv: []string{"btop"}})
350352 cfg.Workspaces = []ports.WorkspaceConfig{ws}
351353 r.reload <- ports.ConfigChanged{Config: cfg}
352354 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
@@ -45,9 +45,11 @@ func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
45 cfg.Animations.On = *animated45 cfg.Animations.On = *animated
46 }46 }
47 cfg.Border.Width = 047 cfg.Border.Width = 0
48+ // A cell is a tenth of the width: the slots take 7 and 3 cells.
49+ cfg.Layout.MaxColumns = 10
48 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{50 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
49- {Index: 1, Width: "70%", Argv: []string{"code"}},51+ {Index: 1, Cells: 7, Argv: []string{"code"}},
50- {Index: 2, Width: "30%", Argv: []string{"foot"}},52+ {Index: 2, Cells: 3, Argv: []string{"foot"}},
51 }}}53 }}}
52 cfg.Binds["Alt+d"] = "workspace dev"54 cfg.Binds["Alt+d"] = "workspace dev"
53 if len(numbered) > 1 {55 if len(numbered) > 1 {
@@ -285,9 +287,9 @@ func TestSlotReload(t *testing.T) {
285 cfg := r.cfg287 cfg := r.cfg
286 ws := cfg.Workspaces[0]288 ws := cfg.Workspaces[0]
287 ws.Slots = []ports.SlotConfig{289 ws.Slots = []ports.SlotConfig{
288- {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only290+ {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only
289- {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command291+ {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command
290- {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot292+ {Index: 3, Cells: 2, Argv: []string{"btop"}}, // new slot
291 }293 }
292 cfg.Workspaces = []ports.WorkspaceConfig{ws}294 cfg.Workspaces = []ports.WorkspaceConfig{ws}
293 r.reload <- ports.ConfigChanged{Config: cfg}295 r.reload <- ports.ConfigChanged{Config: cfg}
@@ -329,7 +331,7 @@ func TestInvalidSlotWidthRejected(t *testing.T) {
329 receive(t, r.spawn)331 receive(t, r.spawn)
330 cfg := r.cfg332 cfg := r.cfg
331 ws := cfg.Workspaces[0]333 ws := cfg.Workspaces[0]
332- ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}334+ ws.Slots = []ports.SlotConfig{{Index: 1, Cells: 17, Argv: []string{"x"}}}
333 cfg.Workspaces = []ports.WorkspaceConfig{ws}335 cfg.Workspaces = []ports.WorkspaceConfig{ws}
334 r.reload <- ports.ConfigChanged{Config: cfg}336 r.reload <- ports.ConfigChanged{Config: cfg}
335 if err := receive(t, r.errs); err == nil {337 if err := receive(t, r.errs); err == nil {
@@ -346,7 +348,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
346 r.press(t, "d")348 r.press(t, "d")
347 cfg := r.cfg349 cfg := r.cfg
348 ws := cfg.Workspaces[0]350 ws := cfg.Workspaces[0]
349- ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})351+ ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Cells: 2, Argv: []string{"btop"}})
350 cfg.Workspaces = []ports.WorkspaceConfig{ws}352 cfg.Workspaces = []ports.WorkspaceConfig{ws}
351 r.reload <- ports.ConfigChanged{Config: cfg}353 r.reload <- ports.ConfigChanged{Config: cfg}
352 if v := receive(t, r.spawn); v.Argv[0] != "btop" {354 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
M internal/core/spawn_placement.go +1 -1
@@ -108,7 +108,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108108 if sc, w := c.byName(target.slot.workspace); w != nil {
109109 p.dropSlot(target.slot)
110110 st.window = v.ID
111- w.AddSlotWindow(v.ID, target.slot.index, st.width)
111+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
112112 sc.mon.normalize()
113113 return
114114 }
@@ -108,7 +108,7 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
108 if sc, w := c.byName(target.slot.workspace); w != nil {108 if sc, w := c.byName(target.slot.workspace); w != nil {
109 p.dropSlot(target.slot)109 p.dropSlot(target.slot)
110 st.window = v.ID110 st.window = v.ID
111- w.AddSlotWindow(v.ID, target.slot.index, st.width)111+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
112 sc.mon.normalize()112 sc.mon.normalize()
113 return113 return
114 }114 }
M internal/core/stash.go +4 -6
@@ -51,7 +51,7 @@ func (w *Workspace) stashWindow(id WindowID) {
5151 func (w *Workspace) tilePlace(id WindowID) *origPlace {
5252 c := w.Columns[w.Focus]
5353 lone := len(c.Windows) == 1
54- back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone}
54+ back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, cells: c.Cells, fullWidth: c.FullWidth && lone}
5555 for _, v := range c.Windows {
5656 if v != id {
5757 back.stacked = append(back.stacked, v)
@@ -139,10 +139,8 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
139139 w.scroll()
140140 return
141141 }
142- // Its column is gone: it comes back expanded, unless another column
143- // was expanded meanwhile.
144- expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
145- w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
142+ // Its column is gone: it comes back at its width.
143+ w.insertColumn(at, Column{Windows: []WindowID{id}, Cells: back.cells, Slot: back.slot})
146144 if back.fullWidth {
147145 w.maximize(w.Focus)
148146 }
@@ -153,7 +151,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
153151 func (f Float) rehome() Float {
154152 back := &origPlace{col: math.MaxInt}
155153 if f.back != nil {
156- back.width = f.back.width
154+ back.cells = f.back.cells
157155 }
158156 f.back = back
159157 return f
@@ -51,7 +51,7 @@ func (w *Workspace) stashWindow(id WindowID) {
51 func (w *Workspace) tilePlace(id WindowID) *origPlace {51 func (w *Workspace) tilePlace(id WindowID) *origPlace {
52 c := w.Columns[w.Focus]52 c := w.Columns[w.Focus]
53 lone := len(c.Windows) == 153 lone := len(c.Windows) == 1
54- back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, width: c.Width, fullWidth: c.FullWidth && lone, expanded: c.Expanded && lone}54+ back := &origPlace{col: w.Focus, row: c.Focus, slot: c.Slot, cells: c.Cells, fullWidth: c.FullWidth && lone}
55 for _, v := range c.Windows {55 for _, v := range c.Windows {
56 if v != id {56 if v != id {
57 back.stacked = append(back.stacked, v)57 back.stacked = append(back.stacked, v)
@@ -139,10 +139,8 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
139 w.scroll()139 w.scroll()
140 return140 return
141 }141 }
142- // Its column is gone: it comes back expanded, unless another column142+ // Its column is gone: it comes back at its width.
143- // was expanded meanwhile.143+ w.insertColumn(at, Column{Windows: []WindowID{id}, Cells: back.cells, Slot: back.slot})
144- expanded := back.expanded && !slices.ContainsFunc(w.Columns, func(c Column) bool { return c.Expanded })
145- w.insertColumn(at, Column{Windows: []WindowID{id}, Width: back.width, Slot: back.slot, Expanded: expanded})
146 if back.fullWidth {144 if back.fullWidth {
147 w.maximize(w.Focus)145 w.maximize(w.Focus)
148 }146 }
@@ -153,7 +151,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) {
153 func (f Float) rehome() Float {151 func (f Float) rehome() Float {
154 back := &origPlace{col: math.MaxInt}152 back := &origPlace{col: math.MaxInt}
155 if f.back != nil {153 if f.back != nil {
156- back.width = f.back.width154+ back.cells = f.back.cells
157 }155 }
158 f.back = back156 f.back = back
159 return f157 return f
M internal/core/switcher_test.go +1 -1
@@ -637,7 +637,7 @@ func TestSwitcherLayoutCards(t *testing.T) {
637637 c, _, _ := switcherCore(t, 3)
638638 c.cfg.Floating.Dim = 0.4
639639 // A wider focused column: the cards have different aspect ratios.
640- c.cur().mon.Current().ResizeColumn(30)
640+ c.cur().mon.Current().GrowColumn(false)
641641 indicatorScene(t, c)
642642 area := c.cur().mon.Current().overviewArea()
643643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
@@ -637,7 +637,7 @@ func TestSwitcherLayoutCards(t *testing.T) {
637 c, _, _ := switcherCore(t, 3)637 c, _, _ := switcherCore(t, 3)
638 c.cfg.Floating.Dim = 0.4638 c.cfg.Floating.Dim = 0.4
639 // A wider focused column: the cards have different aspect ratios.639 // A wider focused column: the cards have different aspect ratios.
640- c.cur().mon.Current().ResizeColumn(30)640+ c.cur().mon.Current().GrowColumn(false)
641 indicatorScene(t, c)641 indicatorScene(t, c)
642 area := c.cur().mon.Current().overviewArea()642 area := c.cur().mon.Current().overviewArea()
643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))643 sendKeys(t, c, superKey(ports.ModSuper, true), tab(ports.ModSuper, true))
M internal/core/transition_test.go +26 -19
@@ -482,7 +482,10 @@ func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
482482 // A width change animates the frame while the client gets one configure to
483483 // the final size.
484484 func TestSetColumnWidthRectTransition(t *testing.T) {
485- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
485+ r := startSwipe(t, func(c *ports.Config) {
486+ c.Layout.MaxColumns = 4
487+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
488+ })
486489 twoColumns(t, r)
487490 r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
488491 for len(r.commands) > 0 {
@@ -497,18 +500,18 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
497500 t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
498501 }
499502 s = r.frame(t, 16*time.Millisecond)
500- if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {
501- t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)
503+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 600 {
504+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 600", got)
502505 }
503- if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {
504- t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)
506+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 600 {
507+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 600", got)
505508 }
506509 s = r.settle(t)
507- if got := rectNow(t, s, 1).W; got != 480 {
508- t.Fatalf("window 1 is %d wide settled, want 480", got)
510+ if got := rectNow(t, s, 1).W; got != 600 {
511+ t.Fatalf("window 1 is %d wide settled, want 600", got)
509512 }
510- if got := rectNow(t, s, 2).X; got != 480 {
511- t.Fatalf("window 2 at %d settled, want 480", got)
513+ if got := rectNow(t, s, 2).X; got != 600 {
514+ t.Fatalf("window 2 at %d settled, want 600", got)
512515 }
513516 var widths []int
514517 for len(r.commands) > 0 {
@@ -516,8 +519,8 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
516519 widths = append(widths, v.Width)
517520 }
518521 }
519- if len(widths) != 1 || widths[0] != 480 {
520- t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)
522+ if len(widths) != 1 || widths[0] != 600 {
523+ t.Fatalf("configures of window 1 carried widths %v, want exactly [600]", widths)
521524 }
522525 }
523526
@@ -594,12 +597,16 @@ func TestChainedMovesStartFromShownRects(t *testing.T) {
594597 // after the click still shows the old view, a frame an intermediate one and
595598 // the settled scene the new one.
596599 func TestClickFocusTransition(t *testing.T) {
597- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })
600+ r := startSwipe(t, func(c *ports.Config) {
601+ c.Layout.MaxColumns = 4
602+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +2"
603+ })
598604 threeColumns(t, r)
599- // Column 3 grows to 480: column 2 sticks out 80 px on the left.
605+ // Column 3 grows from 1 to 3 cells of 200 px: column 2 sticks out 66 px
606+ // on the left.
600607 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
601- if got := xOf(t, r.settle(t), 2); got != -80 {
602- t.Fatalf("column 2 at %d, want -80", got)
608+ if got := xOf(t, r.settle(t), 2); got != -66 {
609+ t.Fatalf("column 2 at %d, want -66", got)
603610 }
604611 // The release of the modifiers: a plain click, not a window drag.
605612 r.input <- ports.KeyEvent{Keysym: "Alt_L"}
@@ -607,12 +614,12 @@ func TestClickFocusTransition(t *testing.T) {
607614 r.drain()
608615 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
609616 s := scene(t, r.scenes)
610- if got := xOf(t, s, 2); got != -80 {
611- t.Fatalf("column 2 at %d right after the click, want the old -80", got)
617+ if got := xOf(t, s, 2); got != -66 {
618+ t.Fatalf("column 2 at %d right after the click, want the old -66", got)
612619 }
613620 got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
614- if got <= -80 || got >= 0 {
615- t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)
621+ if got <= -66 || got >= 0 {
622+ t.Fatalf("column 2 at %d on the first frame, want between -66 and 0", got)
616623 }
617624 if got := xOf(t, r.settle(t), 2); got != 0 {
618625 t.Fatalf("column 2 at %d settled, want 0", got)
@@ -482,7 +482,10 @@ func TestMoveColumnThatScrollsKeepsCameraFrame(t *testing.T) {
482 // A width change animates the frame while the client gets one configure to482 // A width change animates the frame while the client gets one configure to
483 // the final size.483 // the final size.
484 func TestSetColumnWidthRectTransition(t *testing.T) {484 func TestSetColumnWidthRectTransition(t *testing.T) {
485- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })485+ r := startSwipe(t, func(c *ports.Config) {
486+ c.Layout.MaxColumns = 4
487+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +1"
488+ })
486 twoColumns(t, r)489 twoColumns(t, r)
487 r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll490 r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll
488 for len(r.commands) > 0 {491 for len(r.commands) > 0 {
@@ -497,18 +500,18 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
497 t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)500 t.Fatalf("window 2 at %d right after the key, want the old 400", got.X)
498 }501 }
499 s = r.frame(t, 16*time.Millisecond)502 s = r.frame(t, 16*time.Millisecond)
500- if got := rectNow(t, s, 1).W; got <= 400 || got >= 480 {503+ if got := rectNow(t, s, 1).W; got <= 400 || got >= 600 {
501- t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 480", got)504+ t.Fatalf("window 1 is %d wide on the first frame, want between 400 and 600", got)
502 }505 }
503- if got := rectNow(t, s, 2).X; got <= 400 || got >= 480 {506+ if got := rectNow(t, s, 2).X; got <= 400 || got >= 600 {
504- t.Fatalf("window 2 at %d on the first frame, want between 400 and 480", got)507+ t.Fatalf("window 2 at %d on the first frame, want between 400 and 600", got)
505 }508 }
506 s = r.settle(t)509 s = r.settle(t)
507- if got := rectNow(t, s, 1).W; got != 480 {510+ if got := rectNow(t, s, 1).W; got != 600 {
508- t.Fatalf("window 1 is %d wide settled, want 480", got)511+ t.Fatalf("window 1 is %d wide settled, want 600", got)
509 }512 }
510- if got := rectNow(t, s, 2).X; got != 480 {513+ if got := rectNow(t, s, 2).X; got != 600 {
511- t.Fatalf("window 2 at %d settled, want 480", got)514+ t.Fatalf("window 2 at %d settled, want 600", got)
512 }515 }
513 var widths []int516 var widths []int
514 for len(r.commands) > 0 {517 for len(r.commands) > 0 {
@@ -516,8 +519,8 @@ func TestSetColumnWidthRectTransition(t *testing.T) {
516 widths = append(widths, v.Width)519 widths = append(widths, v.Width)
517 }520 }
518 }521 }
519- if len(widths) != 1 || widths[0] != 480 {522+ if len(widths) != 1 || widths[0] != 600 {
520- t.Fatalf("configures of window 1 carried widths %v, want exactly [480]", widths)523+ t.Fatalf("configures of window 1 carried widths %v, want exactly [600]", widths)
521 }524 }
522 }525 }
523 526
@@ -594,12 +597,16 @@ func TestChainedMovesStartFromShownRects(t *testing.T) {
594 // after the click still shows the old view, a frame an intermediate one and597 // after the click still shows the old view, a frame an intermediate one and
595 // the settled scene the new one.598 // the settled scene the new one.
596 func TestClickFocusTransition(t *testing.T) {599 func TestClickFocusTransition(t *testing.T) {
597- r := startSwipe(t, func(c *ports.Config) { c.Binds["Cmd+Ctrl+Right"] = "set-column-width +10%" })600+ r := startSwipe(t, func(c *ports.Config) {
601+ c.Layout.MaxColumns = 4
602+ c.Binds["Cmd+Ctrl+Right"] = "set-column-width +2"
603+ })
598 threeColumns(t, r)604 threeColumns(t, r)
599- // Column 3 grows to 480: column 2 sticks out 80 px on the left.605+ // Column 3 grows from 1 to 3 cells of 200 px: column 2 sticks out 66 px
606+ // on the left.
600 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)607 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
601- if got := xOf(t, r.settle(t), 2); got != -80 {608+ if got := xOf(t, r.settle(t), 2); got != -66 {
602- t.Fatalf("column 2 at %d, want -80", got)609+ t.Fatalf("column 2 at %d, want -66", got)
603 }610 }
604 // The release of the modifiers: a plain click, not a window drag.611 // The release of the modifiers: a plain click, not a window drag.
605 r.input <- ports.KeyEvent{Keysym: "Alt_L"}612 r.input <- ports.KeyEvent{Keysym: "Alt_L"}
@@ -607,12 +614,12 @@ func TestClickFocusTransition(t *testing.T) {
607 r.drain()614 r.drain()
608 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}615 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
609 s := scene(t, r.scenes)616 s := scene(t, r.scenes)
610- if got := xOf(t, s, 2); got != -80 {617+ if got := xOf(t, s, 2); got != -66 {
611- t.Fatalf("column 2 at %d right after the click, want the old -80", got)618+ t.Fatalf("column 2 at %d right after the click, want the old -66", got)
612 }619 }
613 got := xOf(t, r.frame(t, 16*time.Millisecond), 2)620 got := xOf(t, r.frame(t, 16*time.Millisecond), 2)
614- if got <= -80 || got >= 0 {621+ if got <= -66 || got >= 0 {
615- t.Fatalf("column 2 at %d on the first frame, want between -80 and 0", got)622+ t.Fatalf("column 2 at %d on the first frame, want between -66 and 0", got)
616 }623 }
617 if got := xOf(t, r.settle(t), 2); got != 0 {624 if got := xOf(t, r.settle(t), 2); got != 0 {
618 t.Fatalf("column 2 at %d settled, want 0", got)625 t.Fatalf("column 2 at %d settled, want 0", got)
M internal/core/window_actions_test.go +71 -64
@@ -66,101 +66,108 @@ func TestMoveWindowOnFloat(t *testing.T) {
6666 }
6767 }
6868
69-func TestResizeColumn(t *testing.T) {
69+// set-column-width +N/-N moves cells with no cycle, and a column never
70+// goes below one cell.
71+func TestSetColumnWidth(t *testing.T) {
7072 w := workspace()
7173 w.SetOutput(1000, 800)
7274 w.SetGaps(10)
75+ w.SetMaxColumns(4)
76+ w.Overflow = OverflowFixed
7377 w.AddWindow(1)
7478 w.AddWindow(2)
7579 before := w.columnWidth(w.Focus)
76- w.Apply("set-column-width +10%")
77- if w.Columns[w.Focus].Width.Den != 100 || w.columnWidth(w.Focus) <= before {
78- t.Fatal(w.Columns[w.Focus].Width, w.columnWidth(w.Focus), before)
79- }
80- w.Apply("set-column-width -10%")
81- if d := w.columnWidth(w.Focus) - before; d < -1 || d > 1 {
82- t.Fatal(w.columnWidth(w.Focus), before)
83- }
84- for range 20 {
85- w.Apply("set-column-width +10%")
86- }
87- if w.Columns[w.Focus].Width != (Width{Num: 100, Den: 100}) {
88- t.Fatal(w.Columns[w.Focus].Width)
89- }
90- for range 20 {
91- w.Apply("set-column-width -10%")
92- }
93- if w.Columns[w.Focus].Width != (Width{Num: 10, Den: 100}) {
94- t.Fatal(w.Columns[w.Focus].Width)
95- }
96- // A preset snaps to the nearest whole percent once; steps then
97- // round-trip exactly.
98- for _, p := range []Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 2, Den: 3}} {
99- w.Columns[w.Focus].Width = p
100- px := w.columnWidth(w.Focus)
101- w.Apply("set-column-width +7%")
102- w.Apply("set-column-width -7%")
103- snapped := w.columnWidth(w.Focus)
104- if d := snapped - px; d < -w.Usable.W/100 || d > w.Usable.W/100 {
105- t.Fatal(p, snapped, px)
106- }
107- w.Apply("set-column-width +7%")
108- w.Apply("set-column-width -7%")
109- if w.columnWidth(w.Focus) != snapped {
110- t.Fatal(p, w.columnWidth(w.Focus), snapped)
111- }
80+ w.Apply("set-column-width +1")
81+ if w.Columns[w.Focus].Cells != 3 || w.columnWidth(w.Focus) <= before {
82+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
83+ }
84+ // No donor is left: the width stays, it does not cycle back.
85+ w.Apply("set-column-width +1")
86+ if w.Columns[0].Cells != 1 || w.Columns[1].Cells != 3 {
87+ t.Fatal(w.Columns)
88+ }
89+ w.Apply("set-column-width -1")
90+ if w.Columns[0].Cells != 2 || w.Columns[1].Cells != 2 || w.columnWidth(w.Focus) != before {
91+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
92+ }
93+ for range 5 {
94+ w.Apply("set-column-width -1")
95+ }
96+ if w.Columns[1].Cells != 1 || w.Columns[0].Cells != 3 {
97+ t.Fatal(w.Columns)
98+ }
99+ w.Apply("set-column-width +2")
100+ if w.Columns[1].Cells != 3 || w.Columns[0].Cells != 1 {
101+ t.Fatal(w.Columns)
112102 }
113103 }
114104
115-func TestResizeColumnUnmaximizesAndCycles(t *testing.T) {
105+func TestSetColumnWidthScroll(t *testing.T) {
116106 w := workspace()
117107 w.SetOutput(1000, 800)
118- w.SetPresets([]Width{{Num: 1, Den: 2}, {Num: 1, Den: 1}})
108+ w.SetMaxColumns(4)
109+ w.Overflow = OverflowScroll
119110 w.AddWindow(1)
120- w.ToggleFullWidth()
121- w.Apply("set-column-width -10%")
122- if w.Columns[0].FullWidth {
123- t.Fatal("still maximized")
111+ w.AddWindow(2)
112+ w.Apply("set-column-width +1")
113+ if w.Columns[w.Focus].Cells != 3 {
114+ t.Fatal(w.Columns)
115+ }
116+ for range 5 {
117+ w.Apply("set-column-width +1")
118+ }
119+ if w.Columns[w.Focus].Cells != 4 {
120+ t.Fatal("grows up to max-columns", w.Columns)
124121 }
125- // A width outside the presets restarts the cycle at the first preset.
126- w.CycleWidth()
127- if w.Columns[0].Width != (Width{Num: 1, Den: 2}) {
128- t.Fatal(w.Columns[0].Width)
122+ for range 9 {
123+ w.Apply("set-column-width -1")
124+ }
125+ if w.Columns[w.Focus].Cells != 1 {
126+ t.Fatal("never below one cell", w.Columns)
129127 }
130128 }
131129
132-func TestResizeColumnSameValueAsPreset(t *testing.T) {
133- w := workspace()
134- w.SetOutput(1000, 800)
135- w.SetPresets([]Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 1, Den: 1}})
136- w.AddWindow(1)
137- w.Columns[0].Width = Width{Num: 50, Den: 100}
138- w.CycleWidth()
139- if w.Columns[0].Width != (Width{Num: 1, Den: 1}) {
140- t.Fatal(w.Columns[0].Width)
130+func TestSetColumnWidthUnmaximizes(t *testing.T) {
131+ for _, a := range []Action{"set-column-width +1", "set-column-width -1", ActionCycleColumnWidth} {
132+ w := workspace()
133+ w.SetOutput(1000, 800)
134+ w.SetMaxColumns(4)
135+ w.AddWindow(1)
136+ w.ToggleFullWidth()
137+ w.Apply(a)
138+ if w.Columns[0].FullWidth {
139+ t.Fatalf("%s: still maximized", a)
140+ }
141141 }
142142 }
143143
144144 func TestColumnEditsUnderFullscreen(t *testing.T) {
145145 w := workspace()
146146 w.SetOutput(1000, 800)
147- w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: Width{Num: 1, Den: 2}}}
147+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Cells: 1}}
148148 w.SetFullscreen(1, true)
149- w.Apply("set-column-width -10%")
149+ w.Apply("set-column-width -1")
150+ w.Apply(ActionCycleColumnWidth)
150151 w.Apply(ActionMoveWindowDown)
151152 c := w.Columns[0]
152- if c.Width != (Width{Num: 1, Den: 2}) || !slices.Equal(c.Windows, []WindowID{1, 2}) {
153+ if c.Cells != 1 || !slices.Equal(c.Windows, []WindowID{1, 2}) {
153154 t.Fatal(c)
154155 }
155156 }
156157
157-func TestResizeColumnFixedNoop(t *testing.T) {
158+func TestSetColumnWidthFixedPastMax(t *testing.T) {
158159 w := workspace()
159160 w.Overflow = OverflowFixed
160- w.AddWindow(1)
161- w.Apply("set-column-width +10%")
162- if w.Columns[0].Width != (Width{}) {
163- t.Fatal(w.Columns[0].Width)
161+ for id := WindowID(1); id <= 3; id++ {
162+ w.AddWindow(id)
163+ }
164+ w.Apply("set-column-width +1")
165+ w.Apply("set-column-width -1")
166+ w.Apply(ActionCycleColumnWidth)
167+ for _, c := range w.Columns {
168+ if c.Cells != 0 {
169+ t.Fatal(w.Columns)
170+ }
164171 }
165172 }
166173
@@ -66,101 +66,108 @@ func TestMoveWindowOnFloat(t *testing.T) {
66 }66 }
67 }67 }
68 68
69-func TestResizeColumn(t *testing.T) {69+// set-column-width +N/-N moves cells with no cycle, and a column never
70+// goes below one cell.
71+func TestSetColumnWidth(t *testing.T) {
70 w := workspace()72 w := workspace()
71 w.SetOutput(1000, 800)73 w.SetOutput(1000, 800)
72 w.SetGaps(10)74 w.SetGaps(10)
75+ w.SetMaxColumns(4)
76+ w.Overflow = OverflowFixed
73 w.AddWindow(1)77 w.AddWindow(1)
74 w.AddWindow(2)78 w.AddWindow(2)
75 before := w.columnWidth(w.Focus)79 before := w.columnWidth(w.Focus)
76- w.Apply("set-column-width +10%")80+ w.Apply("set-column-width +1")
77- if w.Columns[w.Focus].Width.Den != 100 || w.columnWidth(w.Focus) <= before {81+ if w.Columns[w.Focus].Cells != 3 || w.columnWidth(w.Focus) <= before {
78- t.Fatal(w.Columns[w.Focus].Width, w.columnWidth(w.Focus), before)82+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
79- }83+ }
80- w.Apply("set-column-width -10%")84+ // No donor is left: the width stays, it does not cycle back.
81- if d := w.columnWidth(w.Focus) - before; d < -1 || d > 1 {85+ w.Apply("set-column-width +1")
82- t.Fatal(w.columnWidth(w.Focus), before)86+ if w.Columns[0].Cells != 1 || w.Columns[1].Cells != 3 {
83- }87+ t.Fatal(w.Columns)
84- for range 20 {88+ }
85- w.Apply("set-column-width +10%")89+ w.Apply("set-column-width -1")
86- }90+ if w.Columns[0].Cells != 2 || w.Columns[1].Cells != 2 || w.columnWidth(w.Focus) != before {
87- if w.Columns[w.Focus].Width != (Width{Num: 100, Den: 100}) {91+ t.Fatal(w.Columns, w.columnWidth(w.Focus), before)
88- t.Fatal(w.Columns[w.Focus].Width)92+ }
89- }93+ for range 5 {
90- for range 20 {94+ w.Apply("set-column-width -1")
91- w.Apply("set-column-width -10%")95+ }
92- }96+ if w.Columns[1].Cells != 1 || w.Columns[0].Cells != 3 {
93- if w.Columns[w.Focus].Width != (Width{Num: 10, Den: 100}) {97+ t.Fatal(w.Columns)
94- t.Fatal(w.Columns[w.Focus].Width)98+ }
95- }99+ w.Apply("set-column-width +2")
96- // A preset snaps to the nearest whole percent once; steps then100+ if w.Columns[1].Cells != 3 || w.Columns[0].Cells != 1 {
97- // round-trip exactly.101+ t.Fatal(w.Columns)
98- for _, p := range []Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 2, Den: 3}} {
99- w.Columns[w.Focus].Width = p
100- px := w.columnWidth(w.Focus)
101- w.Apply("set-column-width +7%")
102- w.Apply("set-column-width -7%")
103- snapped := w.columnWidth(w.Focus)
104- if d := snapped - px; d < -w.Usable.W/100 || d > w.Usable.W/100 {
105- t.Fatal(p, snapped, px)
106- }
107- w.Apply("set-column-width +7%")
108- w.Apply("set-column-width -7%")
109- if w.columnWidth(w.Focus) != snapped {
110- t.Fatal(p, w.columnWidth(w.Focus), snapped)
111- }
112 }102 }
113 }103 }
114 104
115-func TestResizeColumnUnmaximizesAndCycles(t *testing.T) {105+func TestSetColumnWidthScroll(t *testing.T) {
116 w := workspace()106 w := workspace()
117 w.SetOutput(1000, 800)107 w.SetOutput(1000, 800)
118- w.SetPresets([]Width{{Num: 1, Den: 2}, {Num: 1, Den: 1}})108+ w.SetMaxColumns(4)
109+ w.Overflow = OverflowScroll
119 w.AddWindow(1)110 w.AddWindow(1)
120- w.ToggleFullWidth()111+ w.AddWindow(2)
121- w.Apply("set-column-width -10%")112+ w.Apply("set-column-width +1")
122- if w.Columns[0].FullWidth {113+ if w.Columns[w.Focus].Cells != 3 {
123- t.Fatal("still maximized")114+ t.Fatal(w.Columns)
115+ }
116+ for range 5 {
117+ w.Apply("set-column-width +1")
118+ }
119+ if w.Columns[w.Focus].Cells != 4 {
120+ t.Fatal("grows up to max-columns", w.Columns)
124 }121 }
125- // A width outside the presets restarts the cycle at the first preset.122+ for range 9 {
126- w.CycleWidth()123+ w.Apply("set-column-width -1")
127- if w.Columns[0].Width != (Width{Num: 1, Den: 2}) {124+ }
128- t.Fatal(w.Columns[0].Width)125+ if w.Columns[w.Focus].Cells != 1 {
126+ t.Fatal("never below one cell", w.Columns)
129 }127 }
130 }128 }
131 129
132-func TestResizeColumnSameValueAsPreset(t *testing.T) {130+func TestSetColumnWidthUnmaximizes(t *testing.T) {
133- w := workspace()131+ for _, a := range []Action{"set-column-width +1", "set-column-width -1", ActionCycleColumnWidth} {
134- w.SetOutput(1000, 800)132+ w := workspace()
135- w.SetPresets([]Width{{Num: 1, Den: 3}, {Num: 1, Den: 2}, {Num: 1, Den: 1}})133+ w.SetOutput(1000, 800)
136- w.AddWindow(1)134+ w.SetMaxColumns(4)
137- w.Columns[0].Width = Width{Num: 50, Den: 100}135+ w.AddWindow(1)
138- w.CycleWidth()136+ w.ToggleFullWidth()
139- if w.Columns[0].Width != (Width{Num: 1, Den: 1}) {137+ w.Apply(a)
140- t.Fatal(w.Columns[0].Width)138+ if w.Columns[0].FullWidth {
139+ t.Fatalf("%s: still maximized", a)
140+ }
141 }141 }
142 }142 }
143 143
144 func TestColumnEditsUnderFullscreen(t *testing.T) {144 func TestColumnEditsUnderFullscreen(t *testing.T) {
145 w := workspace()145 w := workspace()
146 w.SetOutput(1000, 800)146 w.SetOutput(1000, 800)
147- w.Columns = []Column{{Windows: []WindowID{1, 2}, Width: Width{Num: 1, Den: 2}}}147+ w.Columns = []Column{{Windows: []WindowID{1, 2}, Cells: 1}}
148 w.SetFullscreen(1, true)148 w.SetFullscreen(1, true)
149- w.Apply("set-column-width -10%")149+ w.Apply("set-column-width -1")
150+ w.Apply(ActionCycleColumnWidth)
150 w.Apply(ActionMoveWindowDown)151 w.Apply(ActionMoveWindowDown)
151 c := w.Columns[0]152 c := w.Columns[0]
152- if c.Width != (Width{Num: 1, Den: 2}) || !slices.Equal(c.Windows, []WindowID{1, 2}) {153+ if c.Cells != 1 || !slices.Equal(c.Windows, []WindowID{1, 2}) {
153 t.Fatal(c)154 t.Fatal(c)
154 }155 }
155 }156 }
156 157
157-func TestResizeColumnFixedNoop(t *testing.T) {158+func TestSetColumnWidthFixedPastMax(t *testing.T) {
158 w := workspace()159 w := workspace()
159 w.Overflow = OverflowFixed160 w.Overflow = OverflowFixed
160- w.AddWindow(1)161+ for id := WindowID(1); id <= 3; id++ {
161- w.Apply("set-column-width +10%")162+ w.AddWindow(id)
162- if w.Columns[0].Width != (Width{}) {163+ }
163- t.Fatal(w.Columns[0].Width)164+ w.Apply("set-column-width +1")
165+ w.Apply("set-column-width -1")
166+ w.Apply(ActionCycleColumnWidth)
167+ for _, c := range w.Columns {
168+ if c.Cells != 0 {
169+ t.Fatal(w.Columns)
170+ }
164 }171 }
165 }172 }
166 173
M internal/ports/config.go +7 -6
@@ -134,8 +134,7 @@ type Config struct {
134134 Inactive string
135135 }
136136 Layout struct {
137- Gaps int
138- Presets []string
137+ Gaps int
139138 // LayoutRules are the defaults; Outputs override them per screen and
140139 // named workspaces override them again.
141140 LayoutRules
@@ -254,10 +253,11 @@ func SplitCommands(v string) [][]string {
254253
255254 // SlotConfig reserves column N of a workspace for the window of one command.
256255 // neferwl spawns the command at startup; the window it opens goes to the
257-// slot. Width is a layout width (fraction, percentage or pixels).
256+// slot. Cells is its width in cells, a cell being one max-columns-th of
257+// the usable width (1 to 16; 0 shares the width with the other columns).
258258 type SlotConfig struct {
259259 Index int
260- Width string
260+ Cells int
261261 Argv []string
262262 }
263263
@@ -274,6 +274,7 @@ type WindowRule struct {
274274 Workspace string
275275 // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276276 Monitor string
277- // Width is the layout width of the new column of a tiled window.
278- Width string
277+ // Cells is the width in cells of the new column of a tiled window
278+ // (1 to 16; 0 leaves it automatic).
279+ Cells int
279280 }
@@ -134,8 +134,7 @@ type Config struct {
134 Inactive string134 Inactive string
135 }135 }
136 Layout struct {136 Layout struct {
137- Gaps int137+ Gaps int
138- Presets []string
139 // LayoutRules are the defaults; Outputs override them per screen and138 // LayoutRules are the defaults; Outputs override them per screen and
140 // named workspaces override them again.139 // named workspaces override them again.
141 LayoutRules140 LayoutRules
@@ -254,10 +253,11 @@ func SplitCommands(v string) [][]string {
254 253
255 // SlotConfig reserves column N of a workspace for the window of one command.254 // SlotConfig reserves column N of a workspace for the window of one command.
256 // neferwl spawns the command at startup; the window it opens goes to the255 // neferwl spawns the command at startup; the window it opens goes to the
257-// slot. Width is a layout width (fraction, percentage or pixels).256+// slot. Cells is its width in cells, a cell being one max-columns-th of
257+// the usable width (1 to 16; 0 shares the width with the other columns).
258 type SlotConfig struct {258 type SlotConfig struct {
259 Index int259 Index int
260- Width string260+ Cells int
261 Argv []string261 Argv []string
262 }262 }
263 263
@@ -274,6 +274,7 @@ type WindowRule struct {
274 Workspace string274 Workspace string
275 // Monitor is a connector (DP-2) or a monitor key ("make model serial").275 // Monitor is a connector (DP-2) or a monitor key ("make model serial").
276 Monitor string276 Monitor string
277- // Width is the layout width of the new column of a tiled window.277+ // Cells is the width in cells of the new column of a tiled window
278- Width string278+ // (1 to 16; 0 leaves it automatic).
279+ Cells int
279 }280 }

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