refactor(core): size columns in max-columns cells
- commit
- 25f53bf322ada4e05c1723396d8258940aa45e79
- author
- brice · · 2026-10-12 09:45 UTC
- parent
- 0e0d396
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
34 files · +791 -643
M examples/config +15 -15
| @@ -37,15 +37,14 @@ border.inactive = #111111 | |||
| 37 | 37 | floating.dim = 0.3 | |
| 38 | 38 | ||
| 39 | 39 | 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. | |
| 42 | 43 | layout.max-columns = 2 | |
| 43 | 44 | # Past max-columns: cascade starts a new band of columns below, scrolling | |
| 44 | 45 | # vertically; scroll opens columns to the right; fixed keeps everything on | |
| 45 | 46 | # screen, each new window splitting the newest one (spiral). | |
| 46 | 47 | 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 | 49 | # Touchpad. A three-finger swipe up or down slides between workspaces, left | |
| 51 | 50 | # or right scrolls the columns (cascade swaps the two axes). natural-scroll | |
| @@ -96,21 +95,20 @@ focus.strength = 0.04 | |||
| 96 | 95 | # workspace.web.max-columns = 3 | |
| 97 | 96 | # workspace.web.overflow = fixed | |
| 98 | 97 | # | |
| 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 | |
| 100 | 99 | # background and puts its first window in column N, without taking focus. | |
| 101 | 100 | # 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. | |
| 104 | 102 | # 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 | |
| 107 | 105 | # bind.cmd+d = workspace dev | |
| 108 | 106 | ||
| 109 | 107 | # Window rules place an app's windows when they first open, without moving | |
| 110 | 108 | # the focus or the view. app-id is a regular expression matching the whole | |
| 111 | 109 | # app ID (see `neferwl state`); matching rules merge, a later one winning. | |
| 112 | 110 | # 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 | 112 | # rule.games.app-id = steam_app_.* | |
| 115 | 113 | # rule.games.workspace = 3 | |
| 116 | 114 | # rule.games.monitor = DP-2 | |
| @@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up | |||
| 172 | 170 | bind.cmd+shift+down = move-window-down | |
| 173 | 171 | bind.cmd+shift+k = move-window-up | |
| 174 | 172 | 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 | |
| 178 | 177 | bind.cmd+alt+up = set-window-height -10% | |
| 179 | 178 | 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 | |
| 182 | 181 | bind.cmd+alt+k = set-window-height -10% | |
| 183 | 182 | bind.cmd+alt+j = set-window-height +10% | |
| 184 | 183 | # A tile becomes a free floating window, or returns to its column. | |
| 185 | 184 | bind.cmd+shift+space = toggle-floating | |
| 185 | + | # Grow the focused column by one cell, then back to equal shares. | |
| 186 | 186 | bind.cmd+r = cycle-column-width | |
| 187 | 187 | bind.cmd+f = maximize-column | |
| 188 | 188 | bind.cmd+shift+f = toggle-fullscreen | |
| @@ -37,15 +37,14 @@ border.inactive = #111111 | |||||
| 37 | floating.dim = 0.3 | 37 | floating.dim = 0.3 | ||
| 38 | 38 | ||||
| 39 | layout.gaps = 0 | 39 | 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 = 2 | 43 | layout.max-columns = 2 | ||
| 43 | # Past max-columns: cascade starts a new band of columns below, scrolling | 44 | # Past max-columns: cascade starts a new band of columns below, scrolling | ||
| 44 | # vertically; scroll opens columns to the right; fixed keeps everything on | 45 | # 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 = cascade | 47 | 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, left | 49 | # Touchpad. A three-finger swipe up or down slides between workspaces, left | ||
| 51 | # or right scrolls the columns (cascade swaps the two axes). natural-scroll | 50 | # 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 = 3 | 95 | # workspace.web.max-columns = 3 | ||
| 97 | # workspace.web.overflow = fixed | 96 | # workspace.web.overflow = fixed | ||
| 98 | # | 97 | # | ||
| 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 |
| 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 that | 100 | # 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 | 101 | + | # reuse a running instance need a new-instance flag. |
| 103 | - | # overflow = scroll. | |||
| 104 | # workspace.dev.monitor = DP-2 | 102 | # workspace.dev.monitor = DP-2 | ||
| 105 | - | # workspace.dev.column.1 = 67%, code --new-window | 103 | + | # workspace.dev.column.1 = 2, code --new-window |
| 106 | - | # workspace.dev.column.2 = 33%, foot | 104 | + | # workspace.dev.column.2 = 1, foot |
| 107 | # bind.cmd+d = workspace dev | 105 | # bind.cmd+d = workspace dev | ||
| 108 | 106 | ||||
| 109 | # Window rules place an app's windows when they first open, without moving | 107 | # 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 whole | 108 | # 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 a | 110 | # 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 = 3 | 113 | # rule.games.workspace = 3 | ||
| 116 | # rule.games.monitor = DP-2 | 114 | # rule.games.monitor = DP-2 | ||
| @@ -172,17 +170,19 @@ bind.cmd+shift+up = move-window-up | |||||
| 172 | bind.cmd+shift+down = move-window-down | 170 | bind.cmd+shift+down = move-window-down | ||
| 173 | bind.cmd+shift+k = move-window-up | 171 | bind.cmd+shift+k = move-window-up | ||
| 174 | bind.cmd+shift+j = move-window-down | 172 | 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-floating | 184 | 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-width | 186 | bind.cmd+r = cycle-column-width | ||
| 187 | bind.cmd+f = maximize-column | 187 | bind.cmd+f = maximize-column | ||
| 188 | bind.cmd+shift+f = toggle-fullscreen | 188 | bind.cmd+shift+f = toggle-fullscreen | ||
M internal/adapters/config/cascade_test.go +8 -3
| @@ -21,9 +21,14 @@ func TestCascadeOverflowInherited(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 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") { | |
| 27 | 32 | t.Fatalf("warnings=%v", w) | |
| 28 | 33 | } | |
| 29 | 34 | } | |
| @@ -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 }{ | |||
| 64 | 64 | {"cmd+ctrl+shift+right", "move-workspace-to-monitor-right"}, | |
| 65 | 65 | {"cmd+shift+up", "move-window-up"}, | |
| 66 | 66 | {"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"}, | |
| 69 | 69 | {"cmd+alt+up", "set-window-height -10%"}, | |
| 70 | 70 | {"cmd+alt+down", "set-window-height +10%"}, | |
| 71 | 71 | {"cmd+ctrl+shift+up", "move-workspace-prev"}, | |
| @@ -118,9 +118,13 @@ var actions = map[string]bool{ | |||
| 118 | 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 | 119 | var color = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 120 | 120 | ||
| 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 | + | ) | |
| 124 | 128 | ||
| 125 | 129 | // namedKeys maps lowercase names to xkb keysym names so binds are case-insensitive. | |
| 126 | 130 | var namedKeys = map[string]string{ | |
| @@ -170,7 +174,6 @@ func Defaults() ports.Config { | |||
| 170 | 174 | c.Border.Active = "#808080" | |
| 171 | 175 | c.Border.Inactive = "#111111" | |
| 172 | 176 | c.Layout.MaxColumns = 2 | |
| 173 | - | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 174 | 177 | c.Layout.Overflow = "cascade" | |
| 175 | 178 | c.Touchpad.Tap = true | |
| 176 | 179 | c.Touchpad.AccelProfile = ports.AccelAdaptive | |
| @@ -785,17 +788,6 @@ func set(c *ports.Config, key, v string) error { | |||
| 785 | 788 | return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v) | |
| 786 | 789 | case "layout.overflow": | |
| 787 | 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 | 791 | case "touchpad.tap": | |
| 800 | 792 | b, err := onOff(v) | |
| 801 | 793 | if err != nil { | |
| @@ -1018,10 +1010,6 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 1018 | 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 | 1013 | if !bound[w.Name] { | |
| 1026 | 1014 | line := 0 | |
| 1027 | 1015 | for key, n := range seen { | |
| @@ -1089,10 +1077,11 @@ func setRule(r *ports.WindowRule, field, v string) error { | |||
| 1089 | 1077 | } | |
| 1090 | 1078 | r.Monitor = v | |
| 1091 | 1079 | 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 | |
| 1094 | 1083 | } | |
| 1095 | - | r.Width = v | |
| 1084 | + | r.Cells = n | |
| 1096 | 1085 | default: | |
| 1097 | 1086 | return errUnknownRuleField | |
| 1098 | 1087 | } | |
| @@ -1159,10 +1148,11 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||
| 1159 | 1148 | size, cmd, _ := strings.Cut(v, ",") | |
| 1160 | 1149 | size = strings.TrimSpace(size) | |
| 1161 | 1150 | 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)") | |
| 1164 | 1154 | } | |
| 1165 | - | slot := ports.SlotConfig{Index: index, Width: size, Argv: argv} | |
| 1155 | + | slot := ports.SlotConfig{Index: index, Cells: count, Argv: argv} | |
| 1166 | 1156 | i := sort.Search(len(w.Slots), func(i int) bool { return w.Slots[i].Index >= index }) | |
| 1167 | 1157 | if i < len(w.Slots) && w.Slots[i].Index == index { | |
| 1168 | 1158 | w.Slots[i] = slot | |
| @@ -1231,13 +1221,17 @@ func checkAction(v string) error { | |||
| 1231 | 1221 | return nil | |
| 1232 | 1222 | } | |
| 1233 | 1223 | } | |
| 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)") | |
| 1240 | 1233 | } | |
| 1234 | + | return nil | |
| 1241 | 1235 | } | |
| 1242 | 1236 | for old, repl := range map[string]string{"-workspace-up": "-workspace-prev", "-workspace-down": "-workspace-next"} { | |
| 1243 | 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 | 1389 | return w, h, hz, nil | |
| 1396 | 1390 | } | |
| 1397 | 1391 | ||
| 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 | |
| 1413 | 1399 | } | |
| 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 | |
| 1417 | 1402 | } | |
| @@ -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 and | 121 | + | // 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 = 2 | 176 | 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 = true | 178 | c.Touchpad.Tap = true | ||
| 176 | c.Touchpad.AccelProfile = ports.AccelAdaptive | 179 | 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 := 0 | 1014 | 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 = v | 1078 | 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 = v | 1084 | + | r.Cells = n |
| 1096 | default: | 1085 | default: | ||
| 1097 | return errUnknownRuleField | 1086 | 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] = slot | 1158 | w.Slots[i] = slot | ||
| @@ -1231,13 +1221,17 @@ func checkAction(v string) error { | |||||
| 1231 | return nil | 1221 | 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 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)") | |||
| 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, nil | 1389 | 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 true | 1394 | + | |
| 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 <= 100 | 1398 | + | 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) { | |||
| 32 | 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 | 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 | 36 | t.Fatal(d.Binds) | |
| 37 | 37 | } | |
| 38 | 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 | 224 | terminal = foot --server | |
| 225 | 225 | background = #000000 | |
| 226 | 226 | border.inactive = | |
| 227 | - | layout.presets = 1/3, 1/2 ,1 | |
| 228 | 227 | log.debug = core, input | |
| 229 | 228 | output.DP-2.scale = 4/3 | |
| 230 | 229 | output.DP-2 = 5120x2160@165.058 | |
| @@ -248,7 +247,7 @@ startup = wl-paste --primary --watch cliphist store; waybar ;mako; | |||
| 248 | 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 | 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 | 251 | t.Fatalf("%+v", c) | |
| 253 | 252 | } | |
| 254 | 253 | // Scale and mode lines combine in either order. | |
| @@ -270,7 +269,7 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 270 | 269 | {"layout.gaps = 201", "layout.gaps"}, | |
| 271 | 270 | {"layout.max-columns = 0", "layout.max-columns"}, | |
| 272 | 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 | 273 | {"log.level = trace", "log.level"}, | |
| 275 | 274 | {"log.debug = nope", "log.debug"}, | |
| 276 | 275 | {"output.DP-2 = big", "output.DP-2"}, | |
| @@ -287,8 +286,12 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 287 | 286 | {"bind.cmd+x = focus-workspace-up", `renamed to "focus-workspace-prev"`}, | |
| 288 | 287 | {"bind.cmd+x = move-workspace-down", `renamed to "move-workspace-next"`}, | |
| 289 | 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"}, | |
| 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"}, | |
| 292 | 295 | {"bind.cmd+x = set-window-height -101%", "set-window-height needs"}, | |
| 293 | 296 | {"bind.cmd+x = set-window-height +5", "set-window-height needs"}, | |
| 294 | 297 | {"bind.cmd+x = spawn ", "unknown action"}, | |
| @@ -523,17 +526,22 @@ workspace.web.column.1 = 50%, foot | |||
| 523 | 526 | func TestWorkspaceSlots(t *testing.T) { | |
| 524 | 527 | c, w := parseString(t, `layout.overflow = scroll | |
| 525 | 528 | 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 | |
| 531 | 534 | 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 | 540 | bind.cmd+d = workspace dev | |
| 533 | 541 | `) | |
| 534 | 542 | 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"}}, | |
| 537 | 545 | } | |
| 538 | 546 | if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) { | |
| 539 | 547 | t.Fatalf("%+v", c.Workspaces) | |
| @@ -542,17 +550,19 @@ bind.cmd+d = workspace dev | |||
| 542 | 550 | for _, x := range w { | |
| 543 | 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 | 554 | t.Fatal(w) | |
| 547 | 555 | } | |
| 548 | 556 | } | |
| 549 | 557 | ||
| 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 | |
| 553 | 563 | `) | |
| 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) | |
| 556 | 566 | } | |
| 557 | 567 | } | |
| 558 | 568 | ||
| @@ -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 --server | 224 | terminal = foot --server | ||
| 225 | background = #000000 | 225 | background = #000000 | ||
| 226 | border.inactive = | 226 | border.inactive = | ||
| 227 | - | layout.presets = 1/3, 1/2 ,1 | |||
| 228 | log.debug = core, input | 227 | log.debug = core, input | ||
| 229 | output.DP-2.scale = 4/3 | 228 | output.DP-2.scale = 4/3 | ||
| 230 | output.DP-2 = 5120x2160@165.058 | 229 | 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 = scroll | 527 | c, w := parseString(t, `layout.overflow = scroll | ||
| 525 | workspace.dev.monitor = DP-2 | 528 | workspace.dev.monitor = DP-2 | ||
| 526 | - | workspace.dev.column.2 = 33%, foot --title x | 529 | + | workspace.dev.column.2 = 1, foot --title x |
| 527 | - | workspace.dev.column.1 = 67%, code --new-window | 530 | + | workspace.dev.column.1 = 3, code --new-window |
| 528 | - | workspace.dev.column.2 = 1/3, kitty | 531 | + | workspace.dev.column.2 = 16, kitty |
| 529 | - | workspace.dev.column.0 = 50%, foot | 532 | + | workspace.dev.column.0 = 2, foot |
| 530 | - | workspace.dev.column.3 = 50% | 533 | + | workspace.dev.column.3 = 2 |
| 531 | workspace.dev.column.4 = huge, foot | 534 | 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 dev | 540 | 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 = fixed | 559 | + | func TestSlotWidthsKeptWithFixedOverflow(t *testing.T) { |
| 552 | - | workspace.dev.column.1 = 67%, foot | 560 | + | 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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -17,8 +18,8 @@ rule.games.workspace = 3 | |||
| 17 | 18 | rule.chat.app-id = org\.telegram\..*|discord | |
| 18 | 19 | rule.chat.workspace = chat | |
| 19 | 20 | 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 | |
| 22 | 23 | `) | |
| 23 | 24 | if len(w) != 0 { | |
| 24 | 25 | t.Fatal(w) | |
| @@ -27,10 +28,10 @@ rule.games.width = 50% | |||
| 27 | 28 | t.Fatalf("%+v", c.Rules) | |
| 28 | 29 | } | |
| 29 | 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 | 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 | 35 | t.Fatalf("%+v", ch) | |
| 35 | 36 | } | |
| 36 | 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 | 72 | // An invalid duplicate keeps the earlier value and the rule. | |
| 72 | 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 | 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 | 81 | {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0}, | |
| 76 | 82 | {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0}, | |
| 77 | 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) { | |
| 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) | |
| 98 | 104 | } | |
| 99 | 105 | } | |
| 100 | 106 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | 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\..*|discord | 18 | rule.chat.app-id = org\.telegram\..*|discord | ||
| 18 | rule.chat.workspace = chat | 19 | rule.chat.workspace = chat | ||
| 19 | rule.chat.monitor = DP-2 | 20 | rule.chat.monitor = DP-2 | ||
| 20 | - | rule.chat.width = 1/3 | 21 | + | 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{ | |||
| 91 | 91 | ResizeHeight: "set-window-height ", | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 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) { | |
| 98 | 99 | for i, prefix := range resizePrefixes { | |
| 99 | 100 | rest, found := strings.CutPrefix(string(a), prefix) | |
| 100 | 101 | if !found { | |
| 101 | 102 | continue | |
| 102 | 103 | } | |
| 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 | + | } | |
| 105 | 115 | } | |
| 106 | 116 | return 0, 0, false | |
| 107 | 117 | } | |
| 108 | 118 | return 0, 0, false | |
| 109 | 119 | } | |
| 110 | 120 | ||
| 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] != '-') { | |
| 114 | 124 | return 0, false | |
| 115 | 125 | } | |
| 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] == '-' { | |
| 118 | 128 | return 0, false | |
| 119 | 129 | } | |
| 120 | 130 | if s[0] == '-' { | |
| @@ -418,11 +428,17 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 418 | 428 | if w.freeAction(a) { | |
| 419 | 429 | return Effect{} | |
| 420 | 430 | } | |
| 421 | - | if axis, pct, ok := ResizeArg(a); ok { | |
| 431 | + | if axis, step, ok := ResizeArg(a); ok { | |
| 422 | 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 | 440 | } else { | |
| 425 | - | w.ResizeRow(pct) | |
| 441 | + | w.ResizeRow(step) | |
| 426 | 442 | } | |
| 427 | 443 | return Effect{} | |
| 428 | 444 | } | |
| @@ -446,7 +462,7 @@ func (w *Workspace) Apply(a Action) Effect { | |||
| 446 | 462 | case ActionMoveWindowDown: | |
| 447 | 463 | w.MoveWindow(1) | |
| 448 | 464 | case ActionCycleColumnWidth: | |
| 449 | - | w.CycleWidth() | |
| 465 | + | w.GrowColumn(true) | |
| 450 | 466 | case ActionMaximizeColumn: | |
| 451 | 467 | w.ToggleFullWidth() | |
| 452 | 468 | 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) and | 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 | 95 | + | // "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 | continue | 102 | continue | ||
| 102 | } | 103 | } | ||
| 103 | - | if p, ok := parseResizeStep(strings.TrimSpace(rest)); ok { | 104 | + | rest = strings.TrimSpace(rest) |
| 104 | - | return i, p, true | 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 | + | } | |||
| 105 | } | 115 | } | ||
| 106 | return 0, 0, false | 116 | return 0, 0, false | ||
| 107 | } | 117 | } | ||
| 108 | return 0, 0, false | 118 | 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, false | 124 | 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, false | 128 | 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) { | |||
| 38 | 38 | ||
| 39 | 39 | func TestResizeArg(t *testing.T) { | |
| 40 | 40 | 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 | |
| 44 | 44 | }{ | |
| 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}, | |
| 47 | 48 | {"set-window-height +100%", core.ResizeHeight, 100, true}, | |
| 48 | 49 | {"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}, | |
| 55 | 59 | {"set-window-height", 0, 0, false}, | |
| 56 | 60 | {"cycle-column-width", 0, 0, false}, | |
| 57 | 61 | } { | |
| 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) | |
| 61 | 65 | } | |
| 62 | 66 | } | |
| 63 | 67 | } | |
| @@ -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.Action | 41 | + | a core.Action |
| 42 | - | axis, pct int | 42 | + | axis, step int |
| 43 | - | ok bool | 43 | + | 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" | |||
| 4 | 4 | ||
| 5 | 5 | func (w *Workspace) band(i int) int { return i / max(w.MaxColumns, 1) } | |
| 6 | 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 | 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 | 18 | func (w *Workspace) cascadeBands() (n, spacing int) { | |
| 11 | 19 | if len(w.Columns) == 0 || w.Usable.H <= 0 { | |
| @@ -14,12 +22,64 @@ func (w *Workspace) cascadeBands() (n, spacing int) { | |||
| 14 | 22 | 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 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. | |
| 20 | 28 | 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 | |
| 23 | 83 | } | |
| 24 | 84 | ||
| 25 | 85 | // 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 | 111 | g := w.gap() | |
| 52 | 112 | rects := slices.Grow(dst[:0], len(w.Columns))[:len(w.Columns)] | |
| 53 | 113 | 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 | |
| 59 | 130 | } | |
| 60 | - | rects[i] = r | |
| 61 | 131 | } | |
| 62 | 132 | return rects | |
| 63 | 133 | } | |
| @@ -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.H | 22 | 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 band | 25 | + | // 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 column | 26 | + | // (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 width | 85 | // 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 + g | 117 | + | 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 rects | 132 | return rects | ||
| 63 | } | 133 | } | ||
M internal/core/core.go +1 -10
| @@ -116,7 +116,6 @@ type Core struct { | |||
| 116 | 116 | layerChanged bool | |
| 117 | 117 | sent sentCache | |
| 118 | 118 | specs []NamedWorkspace | |
| 119 | - | presets []Width | |
| 120 | 119 | slots map[slotKey]*slotState | |
| 121 | 120 | rules []rule | |
| 122 | 121 | placement spawnPlacement | |
| @@ -227,14 +226,6 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 227 | 226 | if cfg.Layout.MaxColumns < 1 { | |
| 228 | 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 | 229 | if cfg.Layout.Gaps < 0 { | |
| 239 | 230 | return fmt.Errorf("invalid gaps") | |
| 240 | 231 | } | |
| @@ -328,7 +319,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 328 | 319 | c.cfg = cfg | |
| 329 | 320 | c.binds = binds | |
| 330 | 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, rules | |
| 322 | + | c.specs, c.rules = named, rules | |
| 332 | 323 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 333 | 324 | c.named() | |
| 334 | 325 | for _, s := range c.screens { | |
| @@ -116,7 +116,6 @@ type Core struct { | |||||
| 116 | layerChanged bool | 116 | layerChanged bool | ||
| 117 | sent sentCache | 117 | sent sentCache | ||
| 118 | specs []NamedWorkspace | 118 | specs []NamedWorkspace | ||
| 119 | - | presets []Width | |||
| 120 | slots map[slotKey]*slotState | 119 | slots map[slotKey]*slotState | ||
| 121 | rules []rule | 120 | rules []rule | ||
| 122 | placement spawnPlacement | 121 | 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 = cfg | 319 | c.cfg = cfg | ||
| 329 | c.binds = binds | 320 | 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, rules | 322 | + | 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) { | |||
| 463 | 463 | w.Columns[ci], w.Columns[to] = w.Columns[to], w.Columns[ci] | |
| 464 | 464 | w.FocusID(id) | |
| 465 | 465 | } else { | |
| 466 | - | width := Width{} | |
| 466 | + | cells := 0 | |
| 467 | 467 | if lone { | |
| 468 | - | width = src.Columns[ci].Width | |
| 468 | + | cells = src.Columns[ci].Cells | |
| 469 | 469 | } | |
| 470 | 470 | srcIndex := ci | |
| 471 | 471 | src.dropSlotOf(id) | |
| 472 | 472 | src.RemoveWindow(id) | |
| 473 | - | col := Column{Windows: []WindowID{id}, Width: width} | |
| 473 | + | col := Column{Windows: []WindowID{id}, Cells: cells} | |
| 474 | 474 | switch t.kind { | |
| 475 | 475 | case dropAbove, dropBelow: | |
| 476 | 476 | 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].Width | 468 | + | cells = src.Columns[ci].Cells |
| 469 | } | 469 | } | ||
| 470 | srcIndex := ci | 470 | 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 { | |||
| 40 | 40 | // The column goes away, and a slot with it. | |
| 41 | 41 | w.stackWindow(id, w.Columns[next].Windows[0]) | |
| 42 | 42 | case !w.full(): | |
| 43 | - | c := Column{Windows: []WindowID{id}, Width: col.Width} | |
| 43 | + | c := Column{Windows: []WindowID{id}, Cells: col.Cells} | |
| 44 | 44 | if col.Focus == 0 && col.Slot != 0 { | |
| 45 | 45 | // The slot and its width follow the slot window. | |
| 46 | 46 | 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 | 48 | } else if col.Slot != 0 { | |
| 49 | - | c.Width = Width{} | |
| 49 | + | c.Cells = 0 | |
| 50 | 50 | } | |
| 51 | 51 | w.RemoveWindow(id) | |
| 52 | 52 | 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.Slot | 46 | 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) { | |||
| 100 | 100 | } | |
| 101 | 101 | t.Run("the slot follows its window", func(t *testing.T) { | |
| 102 | 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 | 104 | w.Columns[0].Focus = 0 | |
| 105 | 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 | 107 | t.Fatal(got, slots(w), w.Columns) | |
| 108 | 108 | } | |
| 109 | 109 | }) | |
| @@ -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 = 0 | 104 | 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) { | |||
| 15 | 15 | w.border = 2 | |
| 16 | 16 | w.AddWindow(1) | |
| 17 | 17 | w.AddWindow(2) | |
| 18 | - | w.Columns[1].Width = Width{Pixels: 35} | |
| 18 | + | w.Columns[1].Cells = 3 | |
| 19 | 19 | if stacked { | |
| 20 | 20 | w.Columns[1].Windows = []WindowID{3, 2} | |
| 21 | 21 | w.Columns[1].Focus = 1 | |
| @@ -34,7 +34,7 @@ func TestToggleWindowStash(t *testing.T) { | |||
| 34 | 34 | } | |
| 35 | 35 | w.Apply(ActionToggleWindowStash) | |
| 36 | 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 | 38 | t.Fatalf("round trip: %+v", w) | |
| 39 | 39 | } | |
| 40 | 40 | }) | |
| @@ -103,7 +103,7 @@ func TestStashStrip(t *testing.T) { | |||
| 103 | 103 | // Column actions do nothing while the stash has the focus. | |
| 104 | 104 | w.Apply(ActionMoveColumnLeft) | |
| 105 | 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 | 107 | t.Fatalf("column changed: %+v", w.Columns) | |
| 108 | 108 | } | |
| 109 | 109 | // A gap as wide as the margin leaves nothing to show: neighbors wait | |
| @@ -263,7 +263,7 @@ func TestFullscreenPinsFocus(t *testing.T) { | |||
| 263 | 263 | w.focusCover() | |
| 264 | 264 | w.Apply(ActionMoveColumnLeft) | |
| 265 | 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 | 267 | t.Fatalf("focused %d fullscreen %d columns %+v", id, w.fullscreen, w.Columns) | |
| 268 | 268 | } | |
| 269 | 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. | |
| 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) { | |
| 358 | 358 | w := workspace() | |
| 359 | 359 | w.Overflow = OverflowFixed | |
| 360 | 360 | w.SetMaxColumns(3) | |
| 361 | 361 | w.AddWindow(1) | |
| 362 | 362 | 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) | |
| 366 | 366 | } | |
| 367 | 367 | w.Apply(ActionToggleWindowStash) | |
| 368 | 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 | 370 | t.Fatalf("focused %d, column %+v", id, w.Columns[w.Focus]) | |
| 371 | 371 | } | |
| 372 | 372 | } | |
| @@ -15,7 +15,7 @@ func TestToggleWindowStash(t *testing.T) { | |||||
| 15 | w.border = 2 | 15 | 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 = 1 | 21 | 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 wait | 109 | // 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 = OverflowFixed | 359 | 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 { | |||
| 74 | 74 | } | |
| 75 | 75 | f := &w.Floats[i] | |
| 76 | 76 | u := w.Usable | |
| 77 | - | if axis, pct, ok := ResizeArg(a); ok { | |
| 77 | + | if axis, step, ok := ResizeArg(a); ok { | |
| 78 | 78 | if u.W <= 0 || u.H <= 0 { | |
| 79 | 79 | 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 | 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 | 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 | 88 | w.clampFree(f) | |
| 88 | 89 | 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.Usable | 76 | 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 true | 79 | 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 true | 89 | return true | ||
M internal/core/free_test.go +7 -7
| @@ -33,7 +33,7 @@ func TestFreeFloatRect(t *testing.T) { | |||
| 33 | 33 | ||
| 34 | 34 | func TestToggleFloatingRoundTrip(t *testing.T) { | |
| 35 | 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 | 37 | w.Focus = 1 | |
| 38 | 38 | r, _ := w.placed(3) | |
| 39 | 39 | w.Apply(ActionToggleFloating) | |
| @@ -58,11 +58,11 @@ func TestToggleFloatingRoundTrip(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | // A lone column comes back at its width and index. | |
| 60 | 60 | 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 | 62 | w.Columns = append(w.Columns, Column{Windows: []WindowID{3}}) | |
| 63 | 63 | w.Apply(ActionToggleFloating) | |
| 64 | 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 | 66 | t.Fatal(w.Columns, w.Focus) | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| @@ -188,7 +188,7 @@ func TestFreeFloatNoUsableArea(t *testing.T) { | |||
| 188 | 188 | t.Fatal(fx) | |
| 189 | 189 | } | |
| 190 | 190 | w.Apply(ActionMoveColumnLeft) | |
| 191 | - | w.Apply("set-column-width +10%") | |
| 191 | + | w.Apply("set-column-width +1") | |
| 192 | 192 | if w.floatIndex(1) < 0 { | |
| 193 | 193 | t.Fatal(w.Floats) | |
| 194 | 194 | } | |
| @@ -213,13 +213,13 @@ func TestFreeFloatKeyboard(t *testing.T) { | |||
| 213 | 213 | if got := w.floatRect(w.Floats[i]); got.X != w.Usable.X { | |
| 214 | 214 | t.Fatal(got) | |
| 215 | 215 | } | |
| 216 | - | w.Apply("set-column-width +10%") | |
| 216 | + | w.Apply("set-column-width +1") | |
| 217 | 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 | 219 | t.Fatal(w.Floats[i]) | |
| 220 | 220 | } | |
| 221 | 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 | 223 | t.Fatal(w.Columns) | |
| 224 | 224 | } | |
| 225 | 225 | } | |
| @@ -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 = 1 | 37 | 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 = 1 | 60 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "fmt" | |
| 5 | 4 | "math" | |
| 6 | - | "math/bits" | |
| 7 | 5 | "slices" | |
| 8 | - | "strconv" | |
| 9 | - | "strings" | |
| 10 | 6 | "time" | |
| 11 | 7 | ||
| 12 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -16,87 +12,17 @@ type WindowID = ports.WindowID | |||
| 16 | 12 | ||
| 17 | 13 | 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) { | |
| 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. | |
| 92 | 21 | type Column struct { | |
| 93 | 22 | Windows []WindowID | |
| 94 | - | Width Width | |
| 23 | + | Cells int | |
| 95 | 24 | 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 | |
| 100 | 26 | // Slot is the declared column number (workspace.<name>.column.N) of a | |
| 101 | 27 | // slot window; 0 for normal columns. | |
| 102 | 28 | Slot int | |
| @@ -192,7 +118,6 @@ type Workspace struct { | |||
| 192 | 118 | MaxColumns int | |
| 193 | 119 | // border is drawn around floating windows, inside their rect. | |
| 194 | 120 | border int | |
| 195 | - | presets []Width | |
| 196 | 121 | fullscreen WindowID | |
| 197 | 122 | // left is the window whose fullscreen the user left for another | |
| 198 | 123 | // window: its client requests are refused until it is focused again, | |
| @@ -242,14 +167,13 @@ type Workspace struct { | |||
| 242 | 167 | } | |
| 243 | 168 | ||
| 244 | 169 | // 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 | 171 | type origPlace struct { | |
| 247 | 172 | col, row, slot int | |
| 248 | 173 | // stacked holds the windows left in the column: the window returns to | |
| 249 | 174 | // 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 | |
| 253 | 177 | // fullWidth is the column's maximize-column state. | |
| 254 | 178 | fullWidth bool | |
| 255 | 179 | } | |
| @@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||
| 552 | 476 | } | |
| 553 | 477 | 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). | |
| 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) { | |
| 559 | 484 | if id == 0 || w.has(id) { | |
| 560 | 485 | return | |
| 561 | 486 | } | |
| @@ -567,10 +492,10 @@ func (w *Workspace) addWindow(id WindowID, width Width) { | |||
| 567 | 492 | // A covering float or stashed window may have no column under it. | |
| 568 | 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 | 496 | 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 | 501 | // addColumn places a new column like a new window and focuses it. | |
| @@ -595,7 +520,7 @@ func (w *Workspace) addColumn(col Column) { | |||
| 595 | 520 | // AddSlotWindow places a slot window in its own column, ordered by slot | |
| 596 | 521 | // number before the other columns. Focus stays on the focused window: slot | |
| 597 | 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 | 524 | if id == 0 || w.has(id) { | |
| 600 | 525 | return | |
| 601 | 526 | } | |
| @@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) { | |||
| 606 | 531 | 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 | 535 | if len(w.Columns) > 1 && at <= w.Focus { | |
| 611 | 536 | w.Focus++ | |
| 612 | 537 | } | |
| 613 | 538 | w.scroll() | |
| 614 | 539 | } | |
| 615 | 540 | ||
| 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) { | |
| 618 | 543 | for i := range w.Columns { | |
| 619 | 544 | if w.Columns[i].Slot == n { | |
| 620 | - | w.Columns[i].Width = width | |
| 545 | + | w.Columns[i].Cells = cells | |
| 621 | 546 | } | |
| 622 | 547 | } | |
| 623 | 548 | w.scroll() | |
| @@ -939,25 +864,6 @@ func (w *Workspace) MoveWindow(dir int) { | |||
| 939 | 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 | 867 | // minShare is the smallest row height set-window-height leaves, percent. | |
| 962 | 868 | const minShare = 10 | |
| 963 | 869 | ||
| @@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||
| 1038 | 944 | return Column{}, false | |
| 1039 | 945 | } | |
| 1040 | 946 | col := w.Columns[w.Focus] | |
| 1041 | - | col.Slot, col.Expanded = 0, false | |
| 947 | + | col.Slot = 0 | |
| 1042 | 948 | if slices.Contains(col.Windows, w.fullscreen) { | |
| 1043 | 949 | w.fullscreen = 0 | |
| 1044 | 950 | } | |
| @@ -1071,50 +977,163 @@ func (w *Workspace) insertColumn(at int, col Column) { | |||
| 1071 | 977 | w.scroll() | |
| 1072 | 978 | } | |
| 1073 | 979 | ||
| 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() { | |
| 1079 | 988 | return | |
| 1080 | 989 | } | |
| 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 | + | } | |
| 1083 | 1014 | 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 | 1031 | return | |
| 1087 | 1032 | } | |
| 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 | |
| 1094 | 1055 | } | |
| 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 | |
| 1099 | 1084 | } | |
| 1100 | 1085 | } | |
| 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 | |
| 1104 | 1100 | } | |
| 1105 | 1101 | ||
| 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 | |
| 1111 | 1106 | } | |
| 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 | + | } | |
| 1115 | 1127 | } | |
| 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) | |
| 1118 | 1137 | } | |
| 1119 | 1138 | ||
| 1120 | 1139 | // maximize sets FullWidth on column i; unlike ToggleFullWidth it never toggles. | |
| @@ -1320,7 +1339,6 @@ func (w *Workspace) SetGaps(g int) { | |||
| 1320 | 1339 | w.Gaps = g | |
| 1321 | 1340 | w.scroll() | |
| 1322 | 1341 | } | |
| 1323 | - | func (w *Workspace) SetPresets(v []Width) { w.presets = append([]Width(nil), v...) } | |
| 1324 | 1342 | func (w *Workspace) SetMaxColumns(n int) { | |
| 1325 | 1343 | w.MaxColumns = n | |
| 1326 | 1344 | w.scroll() | |
| @@ -1457,21 +1475,48 @@ func (w *Workspace) columnWidth(i int) int { | |||
| 1457 | 1475 | } | |
| 1458 | 1476 | ||
| 1459 | 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 | 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 | 1492 | if w.fullscreenColumn(i) { | |
| 1464 | - | return w.Output.W | |
| 1493 | + | return w.Output.W, true | |
| 1465 | 1494 | } | |
| 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) | |
| 1470 | 1513 | } | |
| 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)) | |
| 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 | 1521 | func (w *Workspace) columnX(i int) int { | |
| 1477 | 1522 | x := w.Usable.X + w.gap() | |
| @@ -1542,10 +1587,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 1542 | 1587 | return rects | |
| 1543 | 1588 | } | |
| 1544 | 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 | 1590 | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 1550 | 1591 | return rects | |
| 1551 | 1592 | } | |
| @@ -1558,38 +1599,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||
| 1558 | 1599 | 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 | 1602 | // stackRects cuts r in n rows with a gap between them; the last row takes | |
| 1594 | 1603 | // the rounding remainder. Rows past the bottom (gaps taller than r) are | |
| 1595 | 1604 | // clamped to it, like windows stacked in a column. | |
| @@ -1,12 +1,8 @@ | |||||
| 1 | package core | 1 | 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.Rect | 13 | 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}, nil | 20 | + | // 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 []WindowID | 22 | Windows []WindowID | ||
| 94 | - | Width Width | 23 | + | Cells int |
| 95 | FullWidth bool | 24 | 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 a | 26 | // 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 int | 28 | Slot int | ||
| @@ -192,7 +118,6 @@ type Workspace struct { | |||||
| 192 | MaxColumns int | 118 | MaxColumns int | ||
| 193 | // border is drawn around floating windows, inside their rect. | 119 | // border is drawn around floating windows, inside their rect. | ||
| 194 | border int | 120 | border int | ||
| 195 | - | presets []Width | |||
| 196 | fullscreen WindowID | 121 | fullscreen WindowID | ||
| 197 | // left is the window whose fullscreen the user left for another | 122 | // 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 a | 169 | // 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 int | 172 | col, row, slot int | ||
| 248 | // stacked holds the windows left in the column: the window returns to | 173 | // 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 []WindowID | 175 | + | stacked []WindowID |
| 251 | - | width Width | 176 | + | 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 bool | 178 | fullWidth bool | ||
| 255 | } | 179 | } | ||
| @@ -552,10 +476,11 @@ func (w *Workspace) Focused() (WindowID, bool) { | |||||
| 552 | } | 476 | } | ||
| 553 | return c.Windows[c.Focus], true | 477 | 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 | return | 485 | 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 | return | 496 | 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 slot | 520 | // AddSlotWindow places a slot window in its own column, ordered by slot | ||
| 596 | // number before the other columns. Focus stays on the focused window: slot | 521 | // 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 | return | 525 | return | ||
| 601 | } | 526 | } | ||
| @@ -606,18 +531,18 @@ func (w *Workspace) AddSlotWindow(id WindowID, slot int, width Width) { | |||||
| 606 | at = i + 1 | 531 | 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 = width | 545 | + | 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 = 10 | 868 | const minShare = 10 | ||
| 963 | 869 | ||||
| @@ -1038,7 +944,7 @@ func (w *Workspace) takeColumn() (Column, bool) { | |||||
| 1038 | return Column{}, false | 944 | return Column{}, false | ||
| 1039 | } | 945 | } | ||
| 1040 | col := w.Columns[w.Focus] | 946 | col := w.Columns[w.Focus] | ||
| 1041 | - | col.Slot, col.Expanded = 0, false | 947 | + | col.Slot = 0 |
| 1042 | if slices.Contains(col.Windows, w.fullscreen) { | 948 | if slices.Contains(col.Windows, w.fullscreen) { | ||
| 1043 | w.fullscreen = 0 | 949 | 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 overflow | 980 | + | // GrowColumn widens the focused column by one cell, taking it from another |
| 1075 | - | // ignores presets: it toggles the expanded column instead; cascade does | 981 | + | // 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 | return | 988 | 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 | return | 1014 | 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 | return | 1031 | return | ||
| 1087 | } | 1032 | } | ||
| 1088 | - | // auto → presets in order → auto; a width set by set-column-width | 1033 | + | 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 | - | break | 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 | |||
| 1099 | } | 1084 | } | ||
| 1100 | } | 1085 | } | ||
| 1101 | - | c.Width = next | 1086 | + | 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 it | 1102 | + | // 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].Expanded | 1107 | + | } |
| 1113 | - | for i := range w.Columns { | 1108 | + | |
| 1114 | - | w.Columns[i].Expanded = false | 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 | + | } | |||
| 1115 | } | 1127 | } | ||
| 1116 | - | w.Columns[w.Focus].Expanded = on | 1128 | + | 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 = g | 1339 | 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 = n | 1343 | 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.W | 1493 | + | 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 rects | 1587 | 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 rects | 1591 | return rects | ||
| 1551 | } | 1592 | } | ||
| @@ -1558,38 +1599,6 @@ func (w *Workspace) columnRectsInto(dst []Rect, ignoreFullWidth bool) []Rect { | |||||
| 1558 | return rects | 1599 | 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 takes | 1602 | // 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) are | 1603 | // 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 ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 15 | // a fullscreen window, a hidden neighbor workspace, the overview and a slide | |
| 16 | 16 | // toward a neighbor workspace. | |
| 17 | 17 | func layoutVariants(t *testing.T) map[string]*Monitor { | |
| @@ -32,9 +32,9 @@ func layoutVariants(t *testing.T) map[string]*Monitor { | |||
| 32 | 32 | v["fixed"] = base(OverflowFixed, 5) | |
| 33 | 33 | v["fixed-spiral"] = base(OverflowFixed, 7) | |
| 34 | 34 | ||
| 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 | |
| 38 | 38 | ||
| 39 | 39 | m = base(OverflowScroll, 4) | |
| 40 | 40 | w := m.Current() | |
| @@ -78,12 +78,12 @@ func layoutVariants(t *testing.T) map[string]*Monitor { | |||
| 78 | 78 | // The variants must hold what they are named for, on the workspace on | |
| 79 | 79 | // screen (the one whose scratch the tests exercise). | |
| 80 | 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 }, | |
| 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 }, | |
| 87 | 87 | } { | |
| 88 | 88 | if w := v[name].Current(); len(w.Columns) == 0 || !has(w) { | |
| 89 | 89 | 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 slide | 15 | // 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 = true | 36 | + | m.Current().Columns[1].Cells = 2 |
| 37 | - | v["fixed-expanded"] = m | 37 | + | 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 on | 78 | // 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 { | |||
| 15 | 15 | w.SetMaxColumns(2) | |
| 16 | 16 | return w | |
| 17 | 17 | } | |
| 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 | |
| 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 { | |
| 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 | + | }) | |
| 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 | 101 | func TestOperations(t *testing.T) { | |
| 37 | 102 | w := workspace() | |
| 38 | 103 | w.FocusColumn(1) | |
| 39 | 104 | w.FocusWindow(-1) | |
| 40 | 105 | w.MoveColumn(1) | |
| 41 | - | w.CycleWidth() | |
| 106 | + | w.GrowColumn(true) | |
| 42 | 107 | w.ToggleFullscreen() | |
| 43 | 108 | w.RemoveWindow(1) | |
| 44 | 109 | if _, ok := w.Focused(); ok { | |
| @@ -112,17 +177,17 @@ func TestMaximizeColumnScroll(t *testing.T) { | |||
| 112 | 177 | for id := WindowID(1); id <= 3; id++ { | |
| 113 | 178 | w.AddWindow(id) | |
| 114 | 179 | } | |
| 115 | - | w.Columns[1].Width = Width{Pixels: 30} | |
| 180 | + | w.Columns[1].Cells = 2 | |
| 116 | 181 | w.FocusID(2) | |
| 117 | 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 | 184 | t.Fatal(w.Columns, w.Layout()) | |
| 120 | 185 | } | |
| 121 | 186 | if w.Layout()[0].Rect.X >= 0 || w.Layout()[2].Rect.X < 100 || w.Layout()[1].Fullscreen { | |
| 122 | 187 | t.Fatal(w.Layout()) | |
| 123 | 188 | } | |
| 124 | 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 | 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 | 228 | w := workspace() | |
| 164 | 229 | w.AddWindow(1) | |
| 165 | - | p := []Width{{Pixels: 30}, {Pixels: 40}} | |
| 166 | - | w.SetPresets(p) | |
| 167 | - | p[0].Pixels = 99 | |
| 168 | 230 | w.AddWindow(2) | |
| 231 | + | // Scroll overflow: auto is 1 cell of 2, then 2 (the whole width), then auto. | |
| 169 | 232 | w.Apply(ActionCycleColumnWidth) | |
| 170 | - | if w.Columns[1].Width.Pixels != 30 { | |
| 233 | + | if w.Columns[1].Cells != 2 { | |
| 171 | 234 | t.Fatal(w.Columns) | |
| 172 | 235 | } | |
| 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 { | |
| 180 | 238 | t.Fatal(w.Columns) | |
| 181 | 239 | } | |
| 182 | 240 | w.RemoveWindow(2) | |
| @@ -188,18 +246,11 @@ func TestPresetsAndActions(t *testing.T) { | |||
| 188 | 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 | 250 | func FuzzWorkspaceOps(f *testing.F) { | |
| 199 | 251 | f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) | |
| 200 | 252 | f.Fuzz(func(t *testing.T, ops []byte) { | |
| 201 | 253 | w := workspace() | |
| 202 | - | w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}}) | |
| 203 | 254 | next := WindowID(1) | |
| 204 | 255 | for _, op := range ops { | |
| 205 | 256 | switch op % 18 { | |
| @@ -223,7 +274,7 @@ func FuzzWorkspaceOps(f *testing.F) { | |||
| 223 | 274 | case 7: | |
| 224 | 275 | w.MoveColumn(1) | |
| 225 | 276 | case 8: | |
| 226 | - | w.CycleWidth() | |
| 277 | + | w.GrowColumn(op%2 == 0) | |
| 227 | 278 | case 9: | |
| 228 | 279 | w.ToggleFullscreen() | |
| 229 | 280 | case 10: | |
| @@ -240,7 +291,7 @@ func FuzzWorkspaceOps(f *testing.F) { | |||
| 240 | 291 | } | |
| 241 | 292 | case 15: | |
| 242 | 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 | 295 | w.scroll() | |
| 245 | 296 | } | |
| 246 | 297 | case 16: | |
| @@ -324,20 +375,6 @@ func stackFocused(w *Workspace) { | |||
| 324 | 375 | } | |
| 325 | 376 | ||
| 326 | 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 | 378 | w := workspace() | |
| 342 | 379 | w.AddWindow(1) | |
| 343 | 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 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) { | |
| 447 | 484 | rects := func(w *Workspace) map[WindowID]Rect { | |
| 448 | 485 | got := map[WindowID]Rect{} | |
| 449 | 486 | for id, p := range placements(w) { | |
| @@ -460,70 +497,67 @@ func TestFixedExpandColumn(t *testing.T) { | |||
| 460 | 497 | t.Run("free cell", func(t *testing.T) { | |
| 461 | 498 | w := fixedWorkspace(4, 0, 2) | |
| 462 | 499 | w.FocusID(1) | |
| 463 | - | w.CycleWidth() | |
| 500 | + | w.GrowColumn(true) | |
| 464 | 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 | 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) { | |
| 469 | - | w := fixedWorkspace(4, 0, 4) | |
| 505 | + | t.Run("middle column", func(t *testing.T) { | |
| 506 | + | w := fixedWorkspace(4, 0, 3) | |
| 470 | 507 | 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}}) | |
| 478 | 514 | }) | |
| 479 | 515 | t.Run("with gaps", func(t *testing.T) { | |
| 480 | - | w := fixedWorkspace(2, 4, 3) | |
| 516 | + | w := fixedWorkspace(4, 4, 2) | |
| 481 | 517 | 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. | |
| 484 | 520 | 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}, | |
| 488 | 523 | }) | |
| 489 | 524 | }) | |
| 490 | 525 | t.Run("stays on focus change and new windows", func(t *testing.T) { | |
| 491 | 526 | w := fixedWorkspace(4, 0, 2) | |
| 492 | 527 | w.FocusID(1) | |
| 493 | - | w.CycleWidth() | |
| 528 | + | w.GrowColumn(true) | |
| 494 | 529 | w.FocusColumn(1) | |
| 495 | 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 | 535 | w := fixedWorkspace(4, 0, 2) | |
| 500 | 536 | w.FocusID(1) | |
| 501 | - | w.CycleWidth() | |
| 537 | + | w.GrowColumn(true) | |
| 502 | 538 | 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}}) | |
| 505 | 541 | }) | |
| 506 | 542 | t.Run("closing it shares again", func(t *testing.T) { | |
| 507 | - | w := fixedWorkspace(3, 0, 3) | |
| 543 | + | w := fixedWorkspace(4, 0, 3) | |
| 508 | 544 | w.FocusID(1) | |
| 509 | - | w.CycleWidth() | |
| 545 | + | w.GrowColumn(true) | |
| 510 | 546 | w.RemoveWindow(1) | |
| 511 | 547 | check(t, w, map[WindowID]Rect{2: {W: 50, H: 80}, 3: {X: 50, W: 50, H: 80}}) | |
| 512 | 548 | }) | |
| 513 | 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 | 551 | before := rects(w) | |
| 516 | - | w.CycleWidth() | |
| 552 | + | w.GrowColumn(true) | |
| 517 | 553 | check(t, w, before) | |
| 518 | 554 | } | |
| 519 | 555 | }) | |
| 520 | 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)} { | |
| 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) | |
| 525 | 559 | w.FocusID(2) | |
| 526 | - | w.CycleWidth() | |
| 560 | + | w.GrowColumn(true) | |
| 527 | 561 | for id, p := range placements(w) { | |
| 528 | 562 | r := p.Rect | |
| 529 | 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 | 569 | t.Run("maximize wins and gives it back", func(t *testing.T) { | |
| 536 | 570 | w := fixedWorkspace(4, 0, 2) | |
| 537 | 571 | w.FocusID(1) | |
| 538 | - | w.CycleWidth() | |
| 572 | + | w.GrowColumn(true) | |
| 539 | 573 | w.ToggleFullWidth() | |
| 540 | 574 | if p := placements(w); p[1].Rect != (Rect{W: 100, H: 80}) || !p[2].Hidden { | |
| 541 | 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 | 583 | func TestFixedFocusStackedColumns(t *testing.T) { | |
| 550 | 584 | focused := func(w *Workspace) WindowID { id, _ := w.Focused(); return id } | |
| 551 | 585 | t.Run("spiral", func(t *testing.T) { | |
| @@ -585,18 +619,21 @@ func TestFixedFocusStackedColumns(t *testing.T) { | |||
| 585 | 619 | t.Fatal("right edge has a neighbor") | |
| 586 | 620 | } | |
| 587 | 621 | }) | |
| 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 | |
| 593 | 626 | 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)) | |
| 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 | 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 | 694 | w := fixedWorkspace(2, 0, 2) | |
| 658 | - | w.SetPresets([]Width{{Num: 2, Den: 3}}) | |
| 659 | - | w.CycleWidth() | |
| 695 | + | w.GrowColumn(true) | |
| 660 | 696 | w.FocusColumn(-1) | |
| 661 | - | w.CycleWidth() | |
| 697 | + | w.GrowColumn(true) | |
| 662 | 698 | for id, p := range placements(w) { | |
| 663 | 699 | if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 { | |
| 664 | 700 | t.Fatal(id, p) | |
| @@ -15,30 +15,95 @@ func workspace() *Workspace { | |||||
| 15 | w.SetMaxColumns(2) | 15 | w.SetMaxColumns(2) | ||
| 16 | return w | 16 | 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 string | 20 | + | // share what the others leave. |
| 21 | - | ok bool | 21 | + | func TestLaneWidths(t *testing.T) { |
| 22 | - | pixels int | 22 | + | 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 over | 481 | + | // 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.Rect | 562 | 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) { | |||
| 126 | 126 | // (clamped above the trailing empty workspace). It takes this monitor's | |
| 127 | 127 | // output-wide settings and does not change what is on screen. | |
| 128 | 128 | func (m *Monitor) adopt(w *Workspace, hidden bool, pos int) { | |
| 129 | - | w.presets = append([]Width(nil), m.template.presets...) | |
| 130 | 129 | w.Gaps = m.template.Gaps | |
| 131 | 130 | w.border = m.template.border | |
| 132 | 131 | w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap | |
| @@ -164,7 +163,6 @@ func (m *Monitor) Current() *Workspace { | |||
| 164 | 163 | func (m *Monitor) newWorkspace() *Workspace { | |
| 165 | 164 | w := m.template | |
| 166 | 165 | w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil | |
| 167 | - | w.presets = append([]Width(nil), m.template.presets...) | |
| 168 | 166 | w.Columns, w.Floats, w.maximized, w.floatFocus, w.home = nil, nil, nil, false, "" | |
| 169 | 167 | w.overviewAfter = nil | |
| 170 | 168 | 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 | 303 | // AddWindow places a new window on the workspace on screen; with | |
| 306 | 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). | |
| 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) { | |
| 311 | 310 | if w, _ := m.find(id); w != nil { | |
| 312 | 311 | return | |
| 313 | 312 | } | |
| 314 | 313 | if w := m.Current(); !m.stashCapture || !w.captureStash(id) { | |
| 315 | - | w.addWindow(id, width) | |
| 314 | + | w.addWindow(id, cells) | |
| 316 | 315 | } | |
| 317 | 316 | m.normalize() | |
| 318 | 317 | } | |
| @@ -502,8 +501,6 @@ func (m *Monitor) SetStash(width, gap int) { | |||
| 502 | 501 | // SetStashCapture makes new windows join the shown stash (stash.capture). | |
| 503 | 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 | 504 | // SetFollowMove makes moves to another workspace show it (focus.follow-move). | |
| 508 | 505 | func (m *Monitor) SetFollowMove(on bool) { m.followMove = on } | |
| 509 | 506 | ||
| @@ -126,7 +126,6 @@ func (m *Monitor) take(w *Workspace) { | |||||
| 126 | // (clamped above the trailing empty workspace). It takes this monitor's | 126 | // (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.Gaps | 129 | w.Gaps = m.template.Gaps | ||
| 131 | w.border = m.template.border | 130 | w.border = m.template.border | ||
| 132 | w.stashWidth, w.stashGap = m.template.stashWidth, m.template.stashGap | 131 | 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.template | 164 | w := m.template | ||
| 166 | w.colBuf, w.rowBuf, w.tileBuf, w.zoomBuf = nil, nil, nil, nil | 165 | 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 = nil | 167 | w.overviewAfter = nil | ||
| 170 | w.Stash, w.stashAt, w.stashFocus, w.stashHidden, w.hiddenFullscreen = nil, 0, false, false, 0 | 168 | 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; with | 303 | // 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 | return | 311 | 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) { | |||
| 199 | 199 | if w := m.Current(); w.columnWidth(3) != 33 || w.View == 0 { | |
| 200 | 200 | t.Fatal(w.View) | |
| 201 | 201 | } | |
| 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. | |
| 204 | 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 | 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 | 298 | m := monitor() | |
| 299 | - | m.SetPresets([]Width{{Num: 1, Den: 2}}) | |
| 300 | 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 | 302 | if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 { | |
| 303 | 303 | t.Fatal(p) | |
| 304 | 304 | } | |
| @@ -475,13 +475,14 @@ func TestMonitorAllAllocations(t *testing.T) { | |||
| 475 | 475 | // exit restores the tiles with the user's focus where it was. | |
| 476 | 476 | func TestFixedFullscreenStaysInPlace(t *testing.T) { | |
| 477 | 477 | m := monitor() | |
| 478 | + | m.SetMaxColumns(4) | |
| 478 | 479 | m.SetOverflow(OverflowFixed) | |
| 479 | 480 | for id := WindowID(1); id <= 3; id++ { | |
| 480 | 481 | m.AddWindow(id) | |
| 481 | 482 | } | |
| 482 | 483 | w := m.Current() | |
| 483 | 484 | w.FocusID(2) | |
| 484 | - | m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen | |
| 485 | + | m.Apply(ActionCycleColumnWidth) // wider: kept through fullscreen | |
| 485 | 486 | m.ToggleFullscreen() | |
| 486 | 487 | m.AddWindow(4) | |
| 487 | 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 | 492 | t.Fatal("focus", id) | |
| 492 | 493 | } | |
| 493 | 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 | 496 | t.Fatal("after bind", id, w.Columns) | |
| 496 | 497 | } | |
| 497 | 498 | } | |
| @@ -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 fullscreen | 485 | + | 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) { | |||
| 156 | 156 | // Columns narrower than the view: one point. | |
| 157 | 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 | 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 | 164 | if got := w.snapPoints(); !slices.Equal(got, tc.want) { | |
| 164 | 165 | 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) { | |||
| 467 | 467 | // Fixed overflow previews use the actual buffer geometry, including the | |
| 468 | 468 | // spiral and window stacks, rather than laying full-height columns in a row. | |
| 469 | 469 | 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) { | |
| 472 | 472 | m := newMonitor("", "") | |
| 473 | 473 | m.SetOutput(600, 400) | |
| 474 | 474 | m.SetOverflow(OverflowFixed) | |
| @@ -477,8 +477,9 @@ func TestOverviewFixedOverflowGeometry(t *testing.T) { | |||
| 477 | 477 | m.AddWindow(id) | |
| 478 | 478 | } | |
| 479 | 479 | 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 | |
| 482 | 483 | } | |
| 483 | 484 | w.Columns[3].Windows = append(w.Columns[3].Windows, 6) | |
| 484 | 485 | w.Columns[3].Focus = 1 | |
| @@ -467,8 +467,8 @@ func TestOverviewPileOnly(t *testing.T) { | |||||
| 467 | // Fixed overflow previews use the actual buffer geometry, including the | 467 | // 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 = true | 481 | + | // 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 = 1 | 485 | w.Columns[3].Focus = 1 | ||
M internal/core/rules.go +9 -12
| @@ -29,7 +29,7 @@ type rule struct { | |||
| 29 | 29 | number int | |
| 30 | 30 | workspace string | |
| 31 | 31 | monitor string | |
| 32 | - | width Width // zero: automatic | |
| 32 | + | cells int // zero: automatic | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // ruleEffect is the merge of every rule matching a window: per field, the | |
| @@ -39,7 +39,7 @@ type ruleEffect struct { | |||
| 39 | 39 | number int | |
| 40 | 40 | workspace string | |
| 41 | 41 | monitor string | |
| 42 | - | width Width // zero: automatic | |
| 42 | + | cells int // zero: automatic | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func parseRules(rs []ports.WindowRule) ([]rule, error) { | |
| @@ -59,13 +59,10 @@ func parseRules(rs []ports.WindowRule) ([]rule, error) { | |||
| 59 | 59 | p.workspace = r.Workspace | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 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) | |
| 68 | 64 | } | |
| 65 | + | p.cells = r.Cells | |
| 69 | 66 | out = append(out, p) | |
| 70 | 67 | } | |
| 71 | 68 | return out, nil | |
| @@ -93,8 +90,8 @@ func (c *Core) ruleFor(v ports.WindowMapped) (eff ruleEffect, ok bool) { | |||
| 93 | 90 | if r.monitor != "" { | |
| 94 | 91 | eff.monitor = r.monitor | |
| 95 | 92 | } | |
| 96 | - | if r.width != (Width{}) { | |
| 97 | - | eff.width = r.width | |
| 93 | + | if r.cells != 0 { | |
| 94 | + | eff.cells = r.cells | |
| 98 | 95 | } | |
| 99 | 96 | } | |
| 100 | 97 | return eff, ok | |
| @@ -140,9 +137,9 @@ func (c *Core) placeByRule(v ports.WindowMapped, eff ruleEffect) { | |||
| 140 | 137 | // floated by a rule becomes a free float, core sizing it. | |
| 141 | 138 | w.addFloat(w.ruleFloat(v), quiet) | |
| 142 | 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 | 141 | default: | |
| 145 | - | sc.mon.addWindow(v.ID, eff.width) | |
| 142 | + | sc.mon.addWindow(v.ID, eff.cells) | |
| 146 | 143 | return | |
| 147 | 144 | } | |
| 148 | 145 | sc.mon.normalize() | |
| @@ -29,7 +29,7 @@ type rule struct { | |||||
| 29 | number int | 29 | number int | ||
| 30 | workspace string | 30 | workspace string | ||
| 31 | monitor string | 31 | monitor string | ||
| 32 | - | width Width // zero: automatic | 32 | + | cells int // zero: automatic |
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | // ruleEffect is the merge of every rule matching a window: per field, the | 35 | // ruleEffect is the merge of every rule matching a window: per field, the | ||
| @@ -39,7 +39,7 @@ type ruleEffect struct { | |||||
| 39 | number int | 39 | number int | ||
| 40 | workspace string | 40 | workspace string | ||
| 41 | monitor string | 41 | monitor string | ||
| 42 | - | width Width // zero: automatic | 42 | + | 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.Workspace | 59 | 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, nil | 68 | 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.monitor | 91 | eff.monitor = r.monitor | ||
| 95 | } | 92 | } | ||
| 96 | - | if r.width != (Width{}) { | 93 | + | if r.cells != 0 { |
| 97 | - | eff.width = r.width | 94 | + | eff.cells = r.cells |
| 98 | } | 95 | } | ||
| 99 | } | 96 | } | ||
| 100 | return eff, ok | 97 | 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 | return | 143 | 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) { | |||
| 225 | 225 | r := startMulti(t, func(c *ports.Config) { | |
| 226 | 226 | c.Layout.MaxColumns = 4 | |
| 227 | 227 | 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 }), | |
| 230 | 230 | } | |
| 231 | 231 | }, left, right) | |
| 232 | 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 | 260 | func TestRuleWidthOnCurrentWorkspace(t *testing.T) { | |
| 261 | 261 | r := startMulti(t, func(c *ports.Config) { | |
| 262 | 262 | 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 | 264 | }, left) | |
| 265 | 265 | r.client <- ports.WindowMapped{ID: 1, AppID: "wide"} | |
| 266 | 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 | 272 | func TestSlotWinsOverRule(t *testing.T) { | |
| 273 | 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 | 275 | c.Binds["Alt+d"] = "workspace dev" | |
| 276 | 276 | c.Rules = []ports.WindowRule{winRule("x", "code", func(r *ports.WindowRule) { r.Workspace = "1"; r.Floating = ruleOn() })} | |
| 277 | 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 | 402 | r := startMulti(t, func(c *ports.Config) { | |
| 401 | - | c.Layout.MaxColumns = 2 | |
| 403 | + | c.Layout.MaxColumns = 4 | |
| 402 | 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 | 406 | }, left) | |
| 405 | 407 | r.client <- ports.WindowMapped{ID: 1, AppID: "term"} | |
| 406 | 408 | r.client <- ports.WindowMapped{ID: 2, AppID: "wide"} | |
| 407 | 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 | 411 | t.Fatalf("%+v", s.Windows) | |
| 410 | 412 | } | |
| 411 | 413 | } | |
| @@ -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 = 4 | 226 | 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 = 4 | 262 | 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 = 2 | 403 | + | 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) { | |||
| 40 | 40 | // same: the key's scene draws what the previous one did. | |
| 41 | 41 | 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 | 47 | func(t *testing.T, r *swipeRig) ports.Scene { | |
| 45 | 48 | twoColumns(t, r) | |
| 46 | 49 | 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 bool | 41 | 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) { | |||
| 524 | 524 | } | |
| 525 | 525 | m.SetOverflow(Overflow(rules.Overflow)) | |
| 526 | 526 | m.SetMaxColumns(rules.MaxColumns) | |
| 527 | - | m.SetPresets(c.presets) | |
| 528 | 527 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 529 | 528 | m.SetBorder(c.cfg.Border.Width) | |
| 530 | 529 | 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 { | |||
| 26 | 26 | ||
| 27 | 27 | type slotState struct { | |
| 28 | 28 | argv []string | |
| 29 | - | width Width | |
| 29 | + | cells int | |
| 30 | 30 | window WindowID // 0 while empty | |
| 31 | 31 | // stale is set when the workspace is shown while the spawn is pending. | |
| 32 | 32 | // Shown again still pending, the command is deemed failed and respawned; | |
| @@ -38,21 +38,20 @@ type slotState struct { | |||
| 38 | 38 | type slotSpec struct { | |
| 39 | 39 | key slotKey | |
| 40 | 40 | argv []string | |
| 41 | - | width Width | |
| 41 | + | cells int | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) { | |
| 45 | 45 | var specs []slotSpec | |
| 46 | 46 | for _, w := range ws { | |
| 47 | 47 | 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) | |
| 51 | 50 | } | |
| 52 | 51 | if len(s.Argv) == 0 || s.Index < 1 { | |
| 53 | 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 | 57 | return specs, nil | |
| @@ -81,14 +80,14 @@ func (c *Core) updateSlots(specs []slotSpec) []slotKey { | |||
| 81 | 80 | for _, s := range specs { | |
| 82 | 81 | st, ok := c.slots[s.key] | |
| 83 | 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 | 84 | spawn = append(spawn, s.key) | |
| 86 | 85 | continue | |
| 87 | 86 | } | |
| 88 | - | if st.width != s.width { | |
| 89 | - | st.width = s.width | |
| 87 | + | if st.cells != s.cells { | |
| 88 | + | st.cells = s.cells | |
| 90 | 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 | 93 | 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 []string | 28 | argv []string | ||
| 29 | - | width Width | 29 | + | cells int |
| 30 | window WindowID // 0 while empty | 30 | 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 slotKey | 39 | key slotKey | ||
| 40 | argv []string | 40 | argv []string | ||
| 41 | - | width Width | 41 | + | 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 []slotSpec | 45 | 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, nil | 57 | 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 | continue | 85 | continue | ||
| 87 | } | 86 | } | ||
| 88 | - | if st.width != s.width { | 87 | + | if st.cells != s.cells { |
| 89 | - | st.width = s.width | 88 | + | 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 { | |||
| 45 | 45 | cfg.Animations.On = *animated | |
| 46 | 46 | } | |
| 47 | 47 | 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 | 50 | 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"}}, | |
| 51 | 53 | }}} | |
| 52 | 54 | cfg.Binds["Alt+d"] = "workspace dev" | |
| 53 | 55 | if len(numbered) > 1 { | |
| @@ -285,9 +287,9 @@ func TestSlotReload(t *testing.T) { | |||
| 285 | 287 | cfg := r.cfg | |
| 286 | 288 | ws := cfg.Workspaces[0] | |
| 287 | 289 | 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 | |
| 291 | 293 | } | |
| 292 | 294 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 293 | 295 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| @@ -329,7 +331,7 @@ func TestInvalidSlotWidthRejected(t *testing.T) { | |||
| 329 | 331 | receive(t, r.spawn) | |
| 330 | 332 | cfg := r.cfg | |
| 331 | 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 | 335 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 334 | 336 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 335 | 337 | if err := receive(t, r.errs); err == nil { | |
| @@ -346,7 +348,7 @@ func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) { | |||
| 346 | 348 | r.press(t, "d") | |
| 347 | 349 | cfg := r.cfg | |
| 348 | 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 | 352 | cfg.Workspaces = []ports.WorkspaceConfig{ws} | |
| 351 | 353 | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 352 | 354 | 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 = *animated | 45 | cfg.Animations.On = *animated | ||
| 46 | } | 46 | } | ||
| 47 | cfg.Border.Width = 0 | 47 | 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.cfg | 287 | 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 only | 290 | + | {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only |
| 289 | - | {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command | 291 | + | {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command |
| 290 | - | {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot | 292 | + | {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.cfg | 332 | 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.cfg | 349 | 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) { | |||
| 108 | 108 | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| 109 | 109 | p.dropSlot(target.slot) | |
| 110 | 110 | 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 | 112 | sc.mon.normalize() | |
| 113 | 113 | return | |
| 114 | 114 | } | |
| @@ -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.ID | 110 | 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 | return | 113 | return | ||
| 114 | } | 114 | } | ||
M internal/core/stash.go +4 -6
| @@ -51,7 +51,7 @@ func (w *Workspace) stashWindow(id WindowID) { | |||
| 51 | 51 | func (w *Workspace) tilePlace(id WindowID) *origPlace { | |
| 52 | 52 | c := w.Columns[w.Focus] | |
| 53 | 53 | 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 | 55 | for _, v := range c.Windows { | |
| 56 | 56 | if v != id { | |
| 57 | 57 | back.stacked = append(back.stacked, v) | |
| @@ -139,10 +139,8 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 139 | 139 | w.scroll() | |
| 140 | 140 | return | |
| 141 | 141 | } | |
| 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}) | |
| 146 | 144 | if back.fullWidth { | |
| 147 | 145 | w.maximize(w.Focus) | |
| 148 | 146 | } | |
| @@ -153,7 +151,7 @@ func (w *Workspace) restore(id WindowID, back *origPlace) { | |||
| 153 | 151 | func (f Float) rehome() Float { | |
| 154 | 152 | back := &origPlace{col: math.MaxInt} | |
| 155 | 153 | if f.back != nil { | |
| 156 | - | back.width = f.back.width | |
| 154 | + | back.cells = f.back.cells | |
| 157 | 155 | } | |
| 158 | 156 | f.back = back | |
| 159 | 157 | 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) == 1 | 53 | 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 | return | 140 | return | ||
| 141 | } | 141 | } | ||
| 142 | - | // Its column is gone: it comes back expanded, unless another column | 142 | + | // 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.width | 154 | + | back.cells = f.back.cells |
| 157 | } | 155 | } | ||
| 158 | f.back = back | 156 | f.back = back | ||
| 159 | return f | 157 | return f | ||
M internal/core/switcher_test.go +1 -1
| @@ -637,7 +637,7 @@ func TestSwitcherLayoutCards(t *testing.T) { | |||
| 637 | 637 | c, _, _ := switcherCore(t, 3) | |
| 638 | 638 | c.cfg.Floating.Dim = 0.4 | |
| 639 | 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 | 641 | indicatorScene(t, c) | |
| 642 | 642 | area := c.cur().mon.Current().overviewArea() | |
| 643 | 643 | 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.4 | 638 | 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) { | |||
| 482 | 482 | // A width change animates the frame while the client gets one configure to | |
| 483 | 483 | // the final size. | |
| 484 | 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 | 489 | twoColumns(t, r) | |
| 487 | 490 | r.key(t, "Left", ports.ModAlt) // focus window 1: both columns fit, no scroll | |
| 488 | 491 | for len(r.commands) > 0 { | |
| @@ -497,18 +500,18 @@ func TestSetColumnWidthRectTransition(t *testing.T) { | |||
| 497 | 500 | t.Fatalf("window 2 at %d right after the key, want the old 400", got.X) | |
| 498 | 501 | } | |
| 499 | 502 | 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) | |
| 502 | 505 | } | |
| 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) | |
| 505 | 508 | } | |
| 506 | 509 | 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) | |
| 509 | 512 | } | |
| 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) | |
| 512 | 515 | } | |
| 513 | 516 | var widths []int | |
| 514 | 517 | for len(r.commands) > 0 { | |
| @@ -516,8 +519,8 @@ func TestSetColumnWidthRectTransition(t *testing.T) { | |||
| 516 | 519 | widths = append(widths, v.Width) | |
| 517 | 520 | } | |
| 518 | 521 | } | |
| 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) | |
| 521 | 524 | } | |
| 522 | 525 | } | |
| 523 | 526 | ||
| @@ -594,12 +597,16 @@ func TestChainedMovesStartFromShownRects(t *testing.T) { | |||
| 594 | 597 | // after the click still shows the old view, a frame an intermediate one and | |
| 595 | 598 | // the settled scene the new one. | |
| 596 | 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 | 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 | 607 | 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) | |
| 603 | 610 | } | |
| 604 | 611 | // The release of the modifiers: a plain click, not a window drag. | |
| 605 | 612 | r.input <- ports.KeyEvent{Keysym: "Alt_L"} | |
| @@ -607,12 +614,12 @@ func TestClickFocusTransition(t *testing.T) { | |||
| 607 | 614 | r.drain() | |
| 608 | 615 | r.input <- ports.PointerButton{Button: 0x110, Pressed: true} | |
| 609 | 616 | 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) | |
| 612 | 619 | } | |
| 613 | 620 | 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) | |
| 616 | 623 | } | |
| 617 | 624 | if got := xOf(t, r.settle(t), 2); got != 0 { | |
| 618 | 625 | 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 to | 482 | // 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 scroll | 490 | 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 []int | 516 | 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 and | 597 | // 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) { | |||
| 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 | 72 | w := workspace() | |
| 71 | 73 | w.SetOutput(1000, 800) | |
| 72 | 74 | w.SetGaps(10) | |
| 75 | + | w.SetMaxColumns(4) | |
| 76 | + | w.Overflow = OverflowFixed | |
| 73 | 77 | w.AddWindow(1) | |
| 74 | 78 | w.AddWindow(2) | |
| 75 | 79 | 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) | |
| 112 | 102 | } | |
| 113 | 103 | } | |
| 114 | 104 | ||
| 115 | - | func TestResizeColumnUnmaximizesAndCycles(t *testing.T) { | |
| 105 | + | func TestSetColumnWidthScroll(t *testing.T) { | |
| 116 | 106 | w := workspace() | |
| 117 | 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 | 110 | 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) | |
| 124 | 121 | } | |
| 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) | |
| 129 | 127 | } | |
| 130 | 128 | } | |
| 131 | 129 | ||
| 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 | + | } | |
| 141 | 141 | } | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | 144 | func TestColumnEditsUnderFullscreen(t *testing.T) { | |
| 145 | 145 | w := workspace() | |
| 146 | 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 | 148 | w.SetFullscreen(1, true) | |
| 149 | - | w.Apply("set-column-width -10%") | |
| 149 | + | w.Apply("set-column-width -1") | |
| 150 | + | w.Apply(ActionCycleColumnWidth) | |
| 150 | 151 | w.Apply(ActionMoveWindowDown) | |
| 151 | 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 | 154 | t.Fatal(c) | |
| 154 | 155 | } | |
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | - | func TestResizeColumnFixedNoop(t *testing.T) { | |
| 158 | + | func TestSetColumnWidthFixedPastMax(t *testing.T) { | |
| 158 | 159 | w := workspace() | |
| 159 | 160 | 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 | + | } | |
| 164 | 171 | } | |
| 165 | 172 | } | |
| 166 | 173 | ||
| @@ -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 then | 100 | + | 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 = OverflowFixed | 160 | 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 { | |||
| 134 | 134 | Inactive string | |
| 135 | 135 | } | |
| 136 | 136 | Layout struct { | |
| 137 | - | Gaps int | |
| 138 | - | Presets []string | |
| 137 | + | Gaps int | |
| 139 | 138 | // LayoutRules are the defaults; Outputs override them per screen and | |
| 140 | 139 | // named workspaces override them again. | |
| 141 | 140 | LayoutRules | |
| @@ -254,10 +253,11 @@ func SplitCommands(v string) [][]string { | |||
| 254 | 253 | ||
| 255 | 254 | // SlotConfig reserves column N of a workspace for the window of one command. | |
| 256 | 255 | // 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 | 258 | type SlotConfig struct { | |
| 259 | 259 | Index int | |
| 260 | - | Width string | |
| 260 | + | Cells int | |
| 261 | 261 | Argv []string | |
| 262 | 262 | } | |
| 263 | 263 | ||
| @@ -274,6 +274,7 @@ type WindowRule struct { | |||
| 274 | 274 | Workspace string | |
| 275 | 275 | // Monitor is a connector (DP-2) or a monitor key ("make model serial"). | |
| 276 | 276 | 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 | |
| 279 | 280 | } | |
| @@ -134,8 +134,7 @@ type Config struct { | |||||
| 134 | Inactive string | 134 | Inactive string | ||
| 135 | } | 135 | } | ||
| 136 | Layout struct { | 136 | Layout struct { | ||
| 137 | - | Gaps int | 137 | + | Gaps int |
| 138 | - | Presets []string | |||
| 139 | // LayoutRules are the defaults; Outputs override them per screen and | 138 | // LayoutRules are the defaults; Outputs override them per screen and | ||
| 140 | // named workspaces override them again. | 139 | // named workspaces override them again. | ||
| 141 | LayoutRules | 140 | 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 the | 255 | // 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 int | 259 | Index int | ||
| 260 | - | Width string | 260 | + | Cells int |
| 261 | Argv []string | 261 | Argv []string | ||
| 262 | } | 262 | } | ||
| 263 | 263 | ||||
| @@ -274,6 +274,7 @@ type WindowRule struct { | |||||
| 274 | Workspace string | 274 | 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 string | 276 | 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 string | 278 | + | // (1 to 16; 0 leaves it automatic). |
| 279 | + | Cells int | |||
| 279 | } | 280 | } | ||