feat(workspaces): named and hidden workspaces with toggle bind, fixed overflow spiral
workspace.<name>.{hidden,monitor,max-columns,overflow} and layout.overflow
config keys; bind action `workspace <name>` toggles back to the previous
workspace. overflow = fixed keeps every window on screen by splitting the
newest slot, alternating top/bottom and left/right.- commit
- c703b25452431c80e1d4cbaecd897c35b7e5f49f
- author
- brice · · 2026-09-24 16:45 UTC
- parent
- 7929f62
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +612 -38
M examples/config +12 -0
| @@ -25,9 +25,21 @@ layout.gaps = 0 | |||
| 25 | 25 | # Columns sharing the screen equally before new ones scroll off to the right. | |
| 26 | 26 | # A lone column fills the screen without a border. | |
| 27 | 27 | layout.max-columns = 2 | |
| 28 | + | # Past max-columns: scroll opens columns to the right; fixed keeps everything | |
| 29 | + | # on screen, each new window splitting the newest one (spiral). | |
| 30 | + | layout.overflow = scroll | |
| 28 | 31 | # cycle-column-width steps through these, then back to the equal share. | |
| 29 | 32 | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 30 | 33 | ||
| 34 | + | # Named workspaces. Numbered at their place like any other and never removed | |
| 35 | + | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | |
| 36 | + | # bind shows it, and pressing the bind again returns to the previous workspace. | |
| 37 | + | # max-columns and overflow override the layout.* values. | |
| 38 | + | # workspace.dev.hidden = on | |
| 39 | + | # workspace.dev.max-columns = 3 | |
| 40 | + | # workspace.dev.overflow = fixed | |
| 41 | + | # bind.cmd+d = workspace dev | |
| 42 | + | ||
| 31 | 43 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | |
| 32 | 44 | # The first listed display that is connected and not off is used. | |
| 33 | 45 | # output.DP-2 = 5120x2160@165.058 | |
| @@ -25,9 +25,21 @@ layout.gaps = 0 | |||||
| 25 | # Columns sharing the screen equally before new ones scroll off to the right. | 25 | # Columns sharing the screen equally before new ones scroll off to the right. | ||
| 26 | # A lone column fills the screen without a border. | 26 | # A lone column fills the screen without a border. | ||
| 27 | layout.max-columns = 2 | 27 | layout.max-columns = 2 | ||
| 28 | + | # Past max-columns: scroll opens columns to the right; fixed keeps everything | |||
| 29 | + | # on screen, each new window splitting the newest one (spiral). | |||
| 30 | + | layout.overflow = scroll | |||
| 28 | # cycle-column-width steps through these, then back to the equal share. | 31 | # cycle-column-width steps through these, then back to the equal share. | ||
| 29 | layout.presets = 1/3, 1/2, 2/3, 1 | 32 | layout.presets = 1/3, 1/2, 2/3, 1 | ||
| 30 | 33 | ||||
| 34 | + | # Named workspaces. Numbered at their place like any other and never removed | |||
| 35 | + | # when empty. hidden = on keeps one out of the numbers and of up/down: only its | |||
| 36 | + | # bind shows it, and pressing the bind again returns to the previous workspace. | |||
| 37 | + | # max-columns and overflow override the layout.* values. | |||
| 38 | + | # workspace.dev.hidden = on | |||
| 39 | + | # workspace.dev.max-columns = 3 | |||
| 40 | + | # workspace.dev.overflow = fixed | |||
| 41 | + | # bind.cmd+d = workspace dev | |||
| 42 | + | ||||
| 31 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | 43 | # Displays (drm backend): WxH, WxH@Hz, preferred or off. | ||
| 32 | # The first listed display that is connected and not off is used. | 44 | # The first listed display that is connected and not off is used. | ||
| 33 | # output.DP-2 = 5120x2160@165.058 | 45 | # output.DP-2 = 5120x2160@165.058 | ||
M internal/adapters/config/config.go +65 -1
| @@ -78,6 +78,7 @@ func Defaults() ports.Config { | |||
| 78 | 78 | c.Border.Active = "#b4befe" | |
| 79 | 79 | c.Layout.MaxColumns = 2 | |
| 80 | 80 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 81 | + | c.Layout.Overflow = "scroll" | |
| 81 | 82 | c.Binds = map[string]string{} | |
| 82 | 83 | for _, b := range defaultBinds { | |
| 83 | 84 | combo, err := parseCombo(b.combo) | |
| @@ -136,6 +137,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 136 | 137 | raw := map[string]string{} | |
| 137 | 138 | seen := map[string]int{} | |
| 138 | 139 | outputs := map[string]int{} | |
| 140 | + | workspaces := map[string]int{} | |
| 139 | 141 | scanner := bufio.NewScanner(r) | |
| 140 | 142 | for n := 1; scanner.Scan(); n++ { | |
| 141 | 143 | line := strings.TrimSpace(scanner.Text()) | |
| @@ -216,6 +218,30 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 216 | 218 | *e = o | |
| 217 | 219 | continue | |
| 218 | 220 | } | |
| 221 | + | if rest, ok := strings.CutPrefix(key, "workspace."); ok { | |
| 222 | + | name, field, _ := strings.Cut(rest, ".") | |
| 223 | + | if !workspaceName.MatchString(name) { | |
| 224 | + | warn("%s: workspace name must be letters, digits, - or _", key) | |
| 225 | + | continue | |
| 226 | + | } | |
| 227 | + | var ws ports.WorkspaceConfig | |
| 228 | + | if i, ok := workspaces[name]; ok { | |
| 229 | + | ws = c.Workspaces[i] | |
| 230 | + | } | |
| 231 | + | if err := setWorkspace(&ws, field, value); err != nil { | |
| 232 | + | warn("%s: %v", key, err) | |
| 233 | + | continue | |
| 234 | + | } | |
| 235 | + | override() | |
| 236 | + | if i, ok := workspaces[name]; ok { | |
| 237 | + | c.Workspaces[i] = ws | |
| 238 | + | } else { | |
| 239 | + | ws.Name = name | |
| 240 | + | workspaces[name] = len(c.Workspaces) | |
| 241 | + | c.Workspaces = append(c.Workspaces, ws) | |
| 242 | + | } | |
| 243 | + | continue | |
| 244 | + | } | |
| 219 | 245 | if err := set(&c, key, value); err != nil { | |
| 220 | 246 | warn("%s: %v", key, err) | |
| 221 | 247 | continue | |
| @@ -365,6 +391,11 @@ func set(c *ports.Config, key, v string) error { | |||
| 365 | 391 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | |
| 366 | 392 | case "layout.max-columns": | |
| 367 | 393 | return positive(&c.Layout.MaxColumns, v, 16) | |
| 394 | + | case "layout.overflow": | |
| 395 | + | if v != "scroll" && v != "fixed" { | |
| 396 | + | return fmt.Errorf("must be scroll or fixed") | |
| 397 | + | } | |
| 398 | + | c.Layout.Overflow = v | |
| 368 | 399 | case "layout.presets": | |
| 369 | 400 | list := splitList(v) | |
| 370 | 401 | if len(list) == 0 { | |
| @@ -401,6 +432,33 @@ func set(c *ports.Config, key, v string) error { | |||
| 401 | 432 | return nil | |
| 402 | 433 | } | |
| 403 | 434 | ||
| 435 | + | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |
| 436 | + | ||
| 437 | + | // setWorkspace applies one workspace.<name>.<field> key. | |
| 438 | + | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |
| 439 | + | switch field { | |
| 440 | + | case "hidden": | |
| 441 | + | b, err := onOff(v) | |
| 442 | + | w.Hidden = b | |
| 443 | + | return err | |
| 444 | + | case "monitor": | |
| 445 | + | if v == "" { | |
| 446 | + | return fmt.Errorf("must not be empty") | |
| 447 | + | } | |
| 448 | + | w.Monitor = v | |
| 449 | + | case "max-columns": | |
| 450 | + | return positive(&w.MaxColumns, v, 16) | |
| 451 | + | case "overflow": | |
| 452 | + | if v != "scroll" && v != "fixed" { | |
| 453 | + | return fmt.Errorf("must be scroll or fixed") | |
| 454 | + | } | |
| 455 | + | w.Overflow = v | |
| 456 | + | default: | |
| 457 | + | return fmt.Errorf("unknown key (hidden, monitor, max-columns, overflow)") | |
| 458 | + | } | |
| 459 | + | return nil | |
| 460 | + | } | |
| 461 | + | ||
| 404 | 462 | func positive(dst *int, v string, max int) error { | |
| 405 | 463 | n, err := strconv.Atoi(v) | |
| 406 | 464 | if err != nil || n <= 0 || n > max { | |
| @@ -448,6 +506,12 @@ func checkAction(v string) error { | |||
| 448 | 506 | } | |
| 449 | 507 | return nil | |
| 450 | 508 | } | |
| 509 | + | if rest, ok := strings.CutPrefix(v, "workspace "); ok { | |
| 510 | + | if !workspaceName.MatchString(strings.TrimSpace(rest)) { | |
| 511 | + | return fmt.Errorf("workspace needs a name (letters, digits, - or _)") | |
| 512 | + | } | |
| 513 | + | return nil | |
| 514 | + | } | |
| 451 | 515 | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | |
| 452 | 516 | if rest, ok := strings.CutPrefix(v, prefix); ok { | |
| 453 | 517 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | |
| @@ -457,7 +521,7 @@ func checkAction(v string) error { | |||
| 457 | 521 | } | |
| 458 | 522 | } | |
| 459 | 523 | if !actions[v] { | |
| 460 | - | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | |
| 524 | + | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) | |
| 461 | 525 | } | |
| 462 | 526 | return nil | |
| 463 | 527 | } | |
| @@ -78,6 +78,7 @@ func Defaults() ports.Config { | |||||
| 78 | c.Border.Active = "#b4befe" | 78 | c.Border.Active = "#b4befe" | ||
| 79 | c.Layout.MaxColumns = 2 | 79 | c.Layout.MaxColumns = 2 | ||
| 80 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | 80 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | ||
| 81 | + | c.Layout.Overflow = "scroll" | |||
| 81 | c.Binds = map[string]string{} | 82 | c.Binds = map[string]string{} | ||
| 82 | for _, b := range defaultBinds { | 83 | for _, b := range defaultBinds { | ||
| 83 | combo, err := parseCombo(b.combo) | 84 | combo, err := parseCombo(b.combo) | ||
| @@ -136,6 +137,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 136 | raw := map[string]string{} | 137 | raw := map[string]string{} | ||
| 137 | seen := map[string]int{} | 138 | seen := map[string]int{} | ||
| 138 | outputs := map[string]int{} | 139 | outputs := map[string]int{} | ||
| 140 | + | workspaces := map[string]int{} | |||
| 139 | scanner := bufio.NewScanner(r) | 141 | scanner := bufio.NewScanner(r) | ||
| 140 | for n := 1; scanner.Scan(); n++ { | 142 | for n := 1; scanner.Scan(); n++ { | ||
| 141 | line := strings.TrimSpace(scanner.Text()) | 143 | line := strings.TrimSpace(scanner.Text()) | ||
| @@ -216,6 +218,30 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 216 | *e = o | 218 | *e = o | ||
| 217 | continue | 219 | continue | ||
| 218 | } | 220 | } | ||
| 221 | + | if rest, ok := strings.CutPrefix(key, "workspace."); ok { | |||
| 222 | + | name, field, _ := strings.Cut(rest, ".") | |||
| 223 | + | if !workspaceName.MatchString(name) { | |||
| 224 | + | warn("%s: workspace name must be letters, digits, - or _", key) | |||
| 225 | + | continue | |||
| 226 | + | } | |||
| 227 | + | var ws ports.WorkspaceConfig | |||
| 228 | + | if i, ok := workspaces[name]; ok { | |||
| 229 | + | ws = c.Workspaces[i] | |||
| 230 | + | } | |||
| 231 | + | if err := setWorkspace(&ws, field, value); err != nil { | |||
| 232 | + | warn("%s: %v", key, err) | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | override() | |||
| 236 | + | if i, ok := workspaces[name]; ok { | |||
| 237 | + | c.Workspaces[i] = ws | |||
| 238 | + | } else { | |||
| 239 | + | ws.Name = name | |||
| 240 | + | workspaces[name] = len(c.Workspaces) | |||
| 241 | + | c.Workspaces = append(c.Workspaces, ws) | |||
| 242 | + | } | |||
| 243 | + | continue | |||
| 244 | + | } | |||
| 219 | if err := set(&c, key, value); err != nil { | 245 | if err := set(&c, key, value); err != nil { | ||
| 220 | warn("%s: %v", key, err) | 246 | warn("%s: %v", key, err) | ||
| 221 | continue | 247 | continue | ||
| @@ -365,6 +391,11 @@ func set(c *ports.Config, key, v string) error { | |||||
| 365 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | 391 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | ||
| 366 | case "layout.max-columns": | 392 | case "layout.max-columns": | ||
| 367 | return positive(&c.Layout.MaxColumns, v, 16) | 393 | return positive(&c.Layout.MaxColumns, v, 16) | ||
| 394 | + | case "layout.overflow": | |||
| 395 | + | if v != "scroll" && v != "fixed" { | |||
| 396 | + | return fmt.Errorf("must be scroll or fixed") | |||
| 397 | + | } | |||
| 398 | + | c.Layout.Overflow = v | |||
| 368 | case "layout.presets": | 399 | case "layout.presets": | ||
| 369 | list := splitList(v) | 400 | list := splitList(v) | ||
| 370 | if len(list) == 0 { | 401 | if len(list) == 0 { | ||
| @@ -401,6 +432,33 @@ func set(c *ports.Config, key, v string) error { | |||||
| 401 | return nil | 432 | return nil | ||
| 402 | } | 433 | } | ||
| 403 | 434 | ||||
| 435 | + | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |||
| 436 | + | ||||
| 437 | + | // setWorkspace applies one workspace.<name>.<field> key. | |||
| 438 | + | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||
| 439 | + | switch field { | |||
| 440 | + | case "hidden": | |||
| 441 | + | b, err := onOff(v) | |||
| 442 | + | w.Hidden = b | |||
| 443 | + | return err | |||
| 444 | + | case "monitor": | |||
| 445 | + | if v == "" { | |||
| 446 | + | return fmt.Errorf("must not be empty") | |||
| 447 | + | } | |||
| 448 | + | w.Monitor = v | |||
| 449 | + | case "max-columns": | |||
| 450 | + | return positive(&w.MaxColumns, v, 16) | |||
| 451 | + | case "overflow": | |||
| 452 | + | if v != "scroll" && v != "fixed" { | |||
| 453 | + | return fmt.Errorf("must be scroll or fixed") | |||
| 454 | + | } | |||
| 455 | + | w.Overflow = v | |||
| 456 | + | default: | |||
| 457 | + | return fmt.Errorf("unknown key (hidden, monitor, max-columns, overflow)") | |||
| 458 | + | } | |||
| 459 | + | return nil | |||
| 460 | + | } | |||
| 461 | + | ||||
| 404 | func positive(dst *int, v string, max int) error { | 462 | func positive(dst *int, v string, max int) error { | ||
| 405 | n, err := strconv.Atoi(v) | 463 | n, err := strconv.Atoi(v) | ||
| 406 | if err != nil || n <= 0 || n > max { | 464 | if err != nil || n <= 0 || n > max { | ||
| @@ -448,6 +506,12 @@ func checkAction(v string) error { | |||||
| 448 | } | 506 | } | ||
| 449 | return nil | 507 | return nil | ||
| 450 | } | 508 | } | ||
| 509 | + | if rest, ok := strings.CutPrefix(v, "workspace "); ok { | |||
| 510 | + | if !workspaceName.MatchString(strings.TrimSpace(rest)) { | |||
| 511 | + | return fmt.Errorf("workspace needs a name (letters, digits, - or _)") | |||
| 512 | + | } | |||
| 513 | + | return nil | |||
| 514 | + | } | |||
| 451 | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | 515 | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | ||
| 452 | if rest, ok := strings.CutPrefix(v, prefix); ok { | 516 | if rest, ok := strings.CutPrefix(v, prefix); ok { | ||
| 453 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | 517 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | ||
| @@ -457,7 +521,7 @@ func checkAction(v string) error { | |||||
| 457 | } | 521 | } | ||
| 458 | } | 522 | } | ||
| 459 | if !actions[v] { | 523 | if !actions[v] { | ||
| 460 | - | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | 524 | + | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) |
| 461 | } | 525 | } | ||
| 462 | return nil | 526 | return nil | ||
| 463 | } | 527 | } | ||
M internal/adapters/config/config_test.go +31 -0
| @@ -233,3 +233,34 @@ func TestDigitBindReplacesPhysicalDefault(t *testing.T) { | |||
| 233 | 233 | t.Fatal("other defaults must stay") | |
| 234 | 234 | } | |
| 235 | 235 | } | |
| 236 | + | ||
| 237 | + | func TestWorkspaces(t *testing.T) { | |
| 238 | + | c, w := parseString(t, `layout.overflow = fixed | |
| 239 | + | workspace.dev.hidden = on | |
| 240 | + | workspace.dev.max-columns = 3 | |
| 241 | + | workspace.web.overflow = scroll | |
| 242 | + | workspace.dev.monitor = DP-2 | |
| 243 | + | workspace.bad name.hidden = on | |
| 244 | + | workspace.dev.color = red | |
| 245 | + | workspace.web.overflow = spiral | |
| 246 | + | bind.cmd+d = workspace dev | |
| 247 | + | bind.cmd+w = workspace | |
| 248 | + | `) | |
| 249 | + | want := []ports.WorkspaceConfig{ | |
| 250 | + | {Name: "dev", Hidden: true, MaxColumns: 3, Monitor: "DP-2"}, | |
| 251 | + | {Name: "web", Overflow: "scroll"}, | |
| 252 | + | } | |
| 253 | + | if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) { | |
| 254 | + | t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces) | |
| 255 | + | } | |
| 256 | + | if c.Binds["Cmd+d"] != "workspace dev" { | |
| 257 | + | t.Fatal(c.Binds["Cmd+d"]) | |
| 258 | + | } | |
| 259 | + | lines := []int{} | |
| 260 | + | for _, x := range w { | |
| 261 | + | lines = append(lines, x.Line) | |
| 262 | + | } | |
| 263 | + | if !reflect.DeepEqual(lines, []int{6, 7, 8, 10}) { | |
| 264 | + | t.Fatal(w) | |
| 265 | + | } | |
| 266 | + | } | |
| @@ -233,3 +233,34 @@ func TestDigitBindReplacesPhysicalDefault(t *testing.T) { | |||||
| 233 | t.Fatal("other defaults must stay") | 233 | t.Fatal("other defaults must stay") | ||
| 234 | } | 234 | } | ||
| 235 | } | 235 | } | ||
| 236 | + | ||||
| 237 | + | func TestWorkspaces(t *testing.T) { | |||
| 238 | + | c, w := parseString(t, `layout.overflow = fixed | |||
| 239 | + | workspace.dev.hidden = on | |||
| 240 | + | workspace.dev.max-columns = 3 | |||
| 241 | + | workspace.web.overflow = scroll | |||
| 242 | + | workspace.dev.monitor = DP-2 | |||
| 243 | + | workspace.bad name.hidden = on | |||
| 244 | + | workspace.dev.color = red | |||
| 245 | + | workspace.web.overflow = spiral | |||
| 246 | + | bind.cmd+d = workspace dev | |||
| 247 | + | bind.cmd+w = workspace | |||
| 248 | + | `) | |||
| 249 | + | want := []ports.WorkspaceConfig{ | |||
| 250 | + | {Name: "dev", Hidden: true, MaxColumns: 3, Monitor: "DP-2"}, | |||
| 251 | + | {Name: "web", Overflow: "scroll"}, | |||
| 252 | + | } | |||
| 253 | + | if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) { | |||
| 254 | + | t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces) | |||
| 255 | + | } | |||
| 256 | + | if c.Binds["Cmd+d"] != "workspace dev" { | |||
| 257 | + | t.Fatal(c.Binds["Cmd+d"]) | |||
| 258 | + | } | |||
| 259 | + | lines := []int{} | |||
| 260 | + | for _, x := range w { | |||
| 261 | + | lines = append(lines, x.Line) | |||
| 262 | + | } | |||
| 263 | + | if !reflect.DeepEqual(lines, []int{6, 7, 8, 10}) { | |||
| 264 | + | t.Fatal(w) | |||
| 265 | + | } | |||
| 266 | + | } | |||
M internal/core/action.go +22 -6
| @@ -49,6 +49,16 @@ func WorkspaceArg(a Action) (n int, move bool, ok bool) { | |||
| 49 | 49 | return n, move, true | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | // namedPrefix toggles a named workspace: "workspace dev". | |
| 53 | + | const namedPrefix = "workspace " | |
| 54 | + | ||
| 55 | + | // NamedArg returns the name of a "workspace <name>" action. | |
| 56 | + | func NamedArg(a Action) (string, bool) { | |
| 57 | + | rest, ok := strings.CutPrefix(string(a), namedPrefix) | |
| 58 | + | name := strings.TrimSpace(rest) | |
| 59 | + | return name, ok && name != "" && !strings.ContainsAny(name, " \t") | |
| 60 | + | } | |
| 61 | + | ||
| 52 | 62 | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | |
| 53 | 63 | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | |
| 54 | 64 | const spawnPrefix = "spawn " | |
| @@ -77,6 +87,10 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 77 | 87 | } | |
| 78 | 88 | return Effect{} | |
| 79 | 89 | } | |
| 90 | + | if name, ok := NamedArg(a); ok { | |
| 91 | + | m.ToggleNamed(name) | |
| 92 | + | return Effect{} | |
| 93 | + | } | |
| 80 | 94 | switch a { | |
| 81 | 95 | case ActionFocusWindowUp, ActionFocusWindowDown: | |
| 82 | 96 | // Past the top or bottom window of the column, move to the next workspace. | |
| @@ -84,15 +98,17 @@ func (m *Monitor) Apply(a Action) Effect { | |||
| 84 | 98 | if a == ActionFocusWindowUp { | |
| 85 | 99 | dir = -1 | |
| 86 | 100 | } | |
| 87 | - | if !m.Current().FocusWindow(dir) { | |
| 101 | + | // A hidden workspace is outside the vertical list. | |
| 102 | + | if !m.Current().FocusWindow(dir) && m.shown == nil { | |
| 88 | 103 | m.Focus(m.Active + dir) | |
| 89 | 104 | } | |
| 90 | 105 | return Effect{} | |
| 91 | - | case ActionFocusWorkspaceUp: | |
| 92 | - | m.Focus(m.Active - 1) | |
| 93 | - | return Effect{} | |
| 94 | - | case ActionFocusWorkspaceDown: | |
| 95 | - | m.Focus(m.Active + 1) | |
| 106 | + | case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown: | |
| 107 | + | if m.shown == nil && a == ActionFocusWorkspaceUp { | |
| 108 | + | m.Focus(m.Active - 1) | |
| 109 | + | } else if m.shown == nil { | |
| 110 | + | m.Focus(m.Active + 1) | |
| 111 | + | } | |
| 96 | 112 | return Effect{} | |
| 97 | 113 | case ActionMoveToWorkspaceUp: | |
| 98 | 114 | if m.Active > 0 { | |
| @@ -49,6 +49,16 @@ func WorkspaceArg(a Action) (n int, move bool, ok bool) { | |||||
| 49 | return n, move, true | 49 | return n, move, true | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | // namedPrefix toggles a named workspace: "workspace dev". | |||
| 53 | + | const namedPrefix = "workspace " | |||
| 54 | + | ||||
| 55 | + | // NamedArg returns the name of a "workspace <name>" action. | |||
| 56 | + | func NamedArg(a Action) (string, bool) { | |||
| 57 | + | rest, ok := strings.CutPrefix(string(a), namedPrefix) | |||
| 58 | + | name := strings.TrimSpace(rest) | |||
| 59 | + | return name, ok && name != "" && !strings.ContainsAny(name, " \t") | |||
| 60 | + | } | |||
| 61 | + | ||||
| 52 | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | 62 | // spawnPrefix starts a bind action that runs a command: "spawn fuzzel --flag". | ||
| 53 | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | 63 | // Arguments are split on whitespace; there is no shell (use "spawn sh -c ..."). | ||
| 54 | const spawnPrefix = "spawn " | 64 | const spawnPrefix = "spawn " | ||
| @@ -77,6 +87,10 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 77 | } | 87 | } | ||
| 78 | return Effect{} | 88 | return Effect{} | ||
| 79 | } | 89 | } | ||
| 90 | + | if name, ok := NamedArg(a); ok { | |||
| 91 | + | m.ToggleNamed(name) | |||
| 92 | + | return Effect{} | |||
| 93 | + | } | |||
| 80 | switch a { | 94 | switch a { | ||
| 81 | case ActionFocusWindowUp, ActionFocusWindowDown: | 95 | case ActionFocusWindowUp, ActionFocusWindowDown: | ||
| 82 | // Past the top or bottom window of the column, move to the next workspace. | 96 | // Past the top or bottom window of the column, move to the next workspace. | ||
| @@ -84,15 +98,17 @@ func (m *Monitor) Apply(a Action) Effect { | |||||
| 84 | if a == ActionFocusWindowUp { | 98 | if a == ActionFocusWindowUp { | ||
| 85 | dir = -1 | 99 | dir = -1 | ||
| 86 | } | 100 | } | ||
| 87 | - | if !m.Current().FocusWindow(dir) { | 101 | + | // A hidden workspace is outside the vertical list. |
| 102 | + | if !m.Current().FocusWindow(dir) && m.shown == nil { | |||
| 88 | m.Focus(m.Active + dir) | 103 | m.Focus(m.Active + dir) | ||
| 89 | } | 104 | } | ||
| 90 | return Effect{} | 105 | return Effect{} | ||
| 91 | - | case ActionFocusWorkspaceUp: | 106 | + | case ActionFocusWorkspaceUp, ActionFocusWorkspaceDown: |
| 92 | - | m.Focus(m.Active - 1) | 107 | + | if m.shown == nil && a == ActionFocusWorkspaceUp { |
| 93 | - | return Effect{} | 108 | + | m.Focus(m.Active - 1) |
| 94 | - | case ActionFocusWorkspaceDown: | 109 | + | } else if m.shown == nil { |
| 95 | - | m.Focus(m.Active + 1) | 110 | + | m.Focus(m.Active + 1) |
| 111 | + | } | |||
| 96 | return Effect{} | 112 | return Effect{} | ||
| 97 | case ActionMoveToWorkspaceUp: | 113 | case ActionMoveToWorkspaceUp: | ||
| 98 | if m.Active > 0 { | 114 | if m.Active > 0 { | ||
M internal/core/core.go +33 -0
| @@ -131,6 +131,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 131 | 131 | binds[b] = Action(a) | |
| 132 | 132 | continue | |
| 133 | 133 | } | |
| 134 | + | if _, ok := NamedArg(Action(a)); ok { | |
| 135 | + | binds[b] = Action(a) | |
| 136 | + | continue | |
| 137 | + | } | |
| 134 | 138 | switch Action(a) { | |
| 135 | 139 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown: | |
| 136 | 140 | default: | |
| @@ -138,9 +142,38 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 138 | 142 | } | |
| 139 | 143 | binds[b] = Action(a) | |
| 140 | 144 | } | |
| 145 | + | overflow := func(v string) (Overflow, error) { | |
| 146 | + | switch o := Overflow(v); o { | |
| 147 | + | case "", OverflowScroll, OverflowFixed: | |
| 148 | + | return o, nil | |
| 149 | + | } | |
| 150 | + | return "", fmt.Errorf("invalid overflow %q", v) | |
| 151 | + | } | |
| 152 | + | defOverflow, err := overflow(cfg.Layout.Overflow) | |
| 153 | + | if err != nil { | |
| 154 | + | return err | |
| 155 | + | } | |
| 156 | + | if defOverflow == "" { | |
| 157 | + | defOverflow = OverflowScroll | |
| 158 | + | } | |
| 159 | + | named := make([]NamedWorkspace, 0, len(cfg.Workspaces)) | |
| 160 | + | seen := map[string]bool{} | |
| 161 | + | for _, ws := range cfg.Workspaces { | |
| 162 | + | o, err := overflow(ws.Overflow) | |
| 163 | + | if err != nil { | |
| 164 | + | return err | |
| 165 | + | } | |
| 166 | + | if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 { | |
| 167 | + | return fmt.Errorf("invalid workspace %q", ws.Name) | |
| 168 | + | } | |
| 169 | + | seen[ws.Name] = true | |
| 170 | + | named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o}) | |
| 171 | + | } | |
| 141 | 172 | c.cfg = cfg | |
| 142 | 173 | c.binds = binds | |
| 143 | 174 | c.applyConfigScale() | |
| 175 | + | c.ws.SetNamed(named) | |
| 176 | + | c.ws.SetOverflow(defOverflow) | |
| 144 | 177 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | |
| 145 | 178 | c.ws.SetPresets(presets) | |
| 146 | 179 | c.ws.SetGaps(cfg.Layout.Gaps) | |
| @@ -131,6 +131,10 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 131 | binds[b] = Action(a) | 131 | binds[b] = Action(a) | ||
| 132 | continue | 132 | continue | ||
| 133 | } | 133 | } | ||
| 134 | + | if _, ok := NamedArg(Action(a)); ok { | |||
| 135 | + | binds[b] = Action(a) | |||
| 136 | + | continue | |||
| 137 | + | } | |||
| 134 | switch Action(a) { | 138 | switch Action(a) { | ||
| 135 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown: | 139 | case "none", ActionSpawnTerminal, ActionFocusColumnLeft, ActionFocusColumnRight, ActionFocusWindowUp, ActionFocusWindowDown, ActionMoveColumnLeft, ActionMoveColumnRight, ActionCycleColumnWidth, ActionToggleFullscreen, ActionCloseWindow, ActionQuit, ActionFocusWorkspaceUp, ActionFocusWorkspaceDown, ActionMoveToWorkspaceUp, ActionMoveToWorkspaceDown: | ||
| 136 | default: | 140 | default: | ||
| @@ -138,9 +142,38 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 138 | } | 142 | } | ||
| 139 | binds[b] = Action(a) | 143 | binds[b] = Action(a) | ||
| 140 | } | 144 | } | ||
| 145 | + | overflow := func(v string) (Overflow, error) { | |||
| 146 | + | switch o := Overflow(v); o { | |||
| 147 | + | case "", OverflowScroll, OverflowFixed: | |||
| 148 | + | return o, nil | |||
| 149 | + | } | |||
| 150 | + | return "", fmt.Errorf("invalid overflow %q", v) | |||
| 151 | + | } | |||
| 152 | + | defOverflow, err := overflow(cfg.Layout.Overflow) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return err | |||
| 155 | + | } | |||
| 156 | + | if defOverflow == "" { | |||
| 157 | + | defOverflow = OverflowScroll | |||
| 158 | + | } | |||
| 159 | + | named := make([]NamedWorkspace, 0, len(cfg.Workspaces)) | |||
| 160 | + | seen := map[string]bool{} | |||
| 161 | + | for _, ws := range cfg.Workspaces { | |||
| 162 | + | o, err := overflow(ws.Overflow) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 166 | + | if ws.Name == "" || seen[ws.Name] || ws.MaxColumns < 0 { | |||
| 167 | + | return fmt.Errorf("invalid workspace %q", ws.Name) | |||
| 168 | + | } | |||
| 169 | + | seen[ws.Name] = true | |||
| 170 | + | named = append(named, NamedWorkspace{Name: ws.Name, Hidden: ws.Hidden, MaxColumns: ws.MaxColumns, Overflow: o}) | |||
| 171 | + | } | |||
| 141 | c.cfg = cfg | 172 | c.cfg = cfg | ||
| 142 | c.binds = binds | 173 | c.binds = binds | ||
| 143 | c.applyConfigScale() | 174 | c.applyConfigScale() | ||
| 175 | + | c.ws.SetNamed(named) | |||
| 176 | + | c.ws.SetOverflow(defOverflow) | |||
| 144 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | 177 | c.ws.SetMaxColumns(cfg.Layout.MaxColumns) | ||
| 145 | c.ws.SetPresets(presets) | 178 | c.ws.SetPresets(presets) | ||
| 146 | c.ws.SetGaps(cfg.Layout.Gaps) | 179 | c.ws.SetGaps(cfg.Layout.Gaps) | ||
M internal/core/layout.go +65 -11
| @@ -85,7 +85,23 @@ type Placement struct { | |||
| 85 | 85 | // column on screen, so no border marks focus. | |
| 86 | 86 | Borderless bool | |
| 87 | 87 | } | |
| 88 | + | ||
| 89 | + | // Overflow says what happens past MaxColumns columns. | |
| 90 | + | type Overflow string | |
| 91 | + | ||
| 92 | + | const ( | |
| 93 | + | // OverflowScroll opens further columns to the right; the view scrolls. | |
| 94 | + | OverflowScroll Overflow = "scroll" | |
| 95 | + | // OverflowFixed keeps every column on screen: past the max, each new | |
| 96 | + | // column splits the newest one, alternating top/bottom and left/right | |
| 97 | + | // (dwindle spiral). | |
| 98 | + | OverflowFixed Overflow = "fixed" | |
| 99 | + | ) | |
| 100 | + | ||
| 88 | 101 | type Workspace struct { | |
| 102 | + | // Name is set for workspaces declared in config; empty for dynamic ones. | |
| 103 | + | Name string | |
| 104 | + | Overflow Overflow | |
| 89 | 105 | Columns []Column | |
| 90 | 106 | Focus int | |
| 91 | 107 | ViewX int | |
| @@ -329,6 +345,10 @@ func (w *Workspace) columnX(i int) int { | |||
| 329 | 345 | return x | |
| 330 | 346 | } | |
| 331 | 347 | func (w *Workspace) scroll() { | |
| 348 | + | if w.Overflow == OverflowFixed { | |
| 349 | + | w.ViewX = 0 | |
| 350 | + | return | |
| 351 | + | } | |
| 332 | 352 | if len(w.Columns) == 0 { | |
| 333 | 353 | return | |
| 334 | 354 | } | |
| @@ -346,39 +366,73 @@ func (w *Workspace) scroll() { | |||
| 346 | 366 | w.ViewX += left + width - maxX | |
| 347 | 367 | } | |
| 348 | 368 | } | |
| 369 | + | ||
| 370 | + | // columnRects returns each column's area on screen, before stacking windows. | |
| 371 | + | func (w *Workspace) columnRects() []Rect { | |
| 372 | + | g := w.gap() | |
| 373 | + | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 374 | + | rects := make([]Rect, len(w.Columns)) | |
| 375 | + | for i := range w.Columns { | |
| 376 | + | rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h} | |
| 377 | + | } | |
| 378 | + | k := max(w.MaxColumns, 1) | |
| 379 | + | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 380 | + | return rects | |
| 381 | + | } | |
| 382 | + | // The last slot holds the spiral; the columns before it keep their place. | |
| 383 | + | area := rects[k-1] | |
| 384 | + | for i := k - 1; i < len(w.Columns)-1; i++ { | |
| 385 | + | rects[i], area = split(area, g, (i-k+1)%2 == 0) | |
| 386 | + | } | |
| 387 | + | rects[len(rects)-1] = area | |
| 388 | + | return rects | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | |
| 392 | + | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | |
| 393 | + | if vertical { | |
| 394 | + | h := max((r.H-gap)/2, 0) | |
| 395 | + | return Rect{X: r.X, Y: r.Y, W: r.W, H: h}, Rect{X: r.X, Y: r.Y + h + gap, W: r.W, H: max(r.H-gap-h, 0)} | |
| 396 | + | } | |
| 397 | + | w := max((r.W-gap)/2, 0) | |
| 398 | + | return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H} | |
| 399 | + | } | |
| 400 | + | ||
| 349 | 401 | func (w *Workspace) Layout() []Placement { | |
| 350 | 402 | var result []Placement | |
| 351 | 403 | gap := w.gap() | |
| 404 | + | cols := w.columnRects() | |
| 352 | 405 | for i, c := range w.Columns { | |
| 353 | - | x := w.columnX(i) - w.ViewX | |
| 354 | - | width := w.columnWidth(i) | |
| 406 | + | col := cols[i] | |
| 355 | 407 | n := len(c.Windows) | |
| 356 | 408 | if n == 0 { | |
| 357 | 409 | continue | |
| 358 | 410 | } | |
| 359 | 411 | fullColumn := w.fullscreenColumn(i) | |
| 360 | - | available := max(w.Usable.H-(n+1)*gap, 0) | |
| 412 | + | if fullColumn { | |
| 413 | + | col.W = w.Output.W | |
| 414 | + | } | |
| 415 | + | available := max(col.H-(n-1)*gap, 0) | |
| 361 | 416 | height := available / n | |
| 362 | - | y := w.Usable.Y + gap | |
| 417 | + | y := col.Y | |
| 418 | + | bottom := col.Y + col.H | |
| 363 | 419 | for j, id := range c.Windows { | |
| 364 | 420 | h := height | |
| 365 | 421 | if j == n-1 { | |
| 366 | 422 | h = available - height*(n-1) | |
| 367 | 423 | } | |
| 368 | - | if y > w.Usable.Y+w.Usable.H { | |
| 369 | - | y = w.Usable.Y + w.Usable.H | |
| 370 | - | } | |
| 371 | - | h = min(h, w.Usable.Y+w.Usable.H-y) | |
| 372 | - | r := Rect{X: x, Y: y, W: width, H: h} | |
| 424 | + | y = min(y, bottom) | |
| 425 | + | h = min(h, bottom-y) | |
| 426 | + | r := Rect{X: col.X, Y: y, W: col.W, H: h} | |
| 373 | 427 | full := w.fullscreen == id && id != 0 | |
| 374 | 428 | hidden := fullColumn && !full | |
| 375 | 429 | if full { | |
| 376 | - | r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H} | |
| 430 | + | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} | |
| 377 | 431 | } | |
| 378 | 432 | if hidden { | |
| 379 | 433 | r = Rect{} | |
| 380 | 434 | } | |
| 381 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap}) | |
| 435 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap}) | |
| 382 | 436 | y += h + gap | |
| 383 | 437 | } | |
| 384 | 438 | } | |
| @@ -85,7 +85,23 @@ type Placement struct { | |||||
| 85 | // column on screen, so no border marks focus. | 85 | // column on screen, so no border marks focus. | ||
| 86 | Borderless bool | 86 | Borderless bool | ||
| 87 | } | 87 | } | ||
| 88 | + | ||||
| 89 | + | // Overflow says what happens past MaxColumns columns. | |||
| 90 | + | type Overflow string | |||
| 91 | + | ||||
| 92 | + | const ( | |||
| 93 | + | // OverflowScroll opens further columns to the right; the view scrolls. | |||
| 94 | + | OverflowScroll Overflow = "scroll" | |||
| 95 | + | // OverflowFixed keeps every column on screen: past the max, each new | |||
| 96 | + | // column splits the newest one, alternating top/bottom and left/right | |||
| 97 | + | // (dwindle spiral). | |||
| 98 | + | OverflowFixed Overflow = "fixed" | |||
| 99 | + | ) | |||
| 100 | + | ||||
| 88 | type Workspace struct { | 101 | type Workspace struct { | ||
| 102 | + | // Name is set for workspaces declared in config; empty for dynamic ones. | |||
| 103 | + | Name string | |||
| 104 | + | Overflow Overflow | |||
| 89 | Columns []Column | 105 | Columns []Column | ||
| 90 | Focus int | 106 | Focus int | ||
| 91 | ViewX int | 107 | ViewX int | ||
| @@ -329,6 +345,10 @@ func (w *Workspace) columnX(i int) int { | |||||
| 329 | return x | 345 | return x | ||
| 330 | } | 346 | } | ||
| 331 | func (w *Workspace) scroll() { | 347 | func (w *Workspace) scroll() { | ||
| 348 | + | if w.Overflow == OverflowFixed { | |||
| 349 | + | w.ViewX = 0 | |||
| 350 | + | return | |||
| 351 | + | } | |||
| 332 | if len(w.Columns) == 0 { | 352 | if len(w.Columns) == 0 { | ||
| 333 | return | 353 | return | ||
| 334 | } | 354 | } | ||
| @@ -346,39 +366,73 @@ func (w *Workspace) scroll() { | |||||
| 346 | w.ViewX += left + width - maxX | 366 | w.ViewX += left + width - maxX | ||
| 347 | } | 367 | } | ||
| 348 | } | 368 | } | ||
| 369 | + | ||||
| 370 | + | // columnRects returns each column's area on screen, before stacking windows. | |||
| 371 | + | func (w *Workspace) columnRects() []Rect { | |||
| 372 | + | g := w.gap() | |||
| 373 | + | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |||
| 374 | + | rects := make([]Rect, len(w.Columns)) | |||
| 375 | + | for i := range w.Columns { | |||
| 376 | + | rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h} | |||
| 377 | + | } | |||
| 378 | + | k := max(w.MaxColumns, 1) | |||
| 379 | + | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |||
| 380 | + | return rects | |||
| 381 | + | } | |||
| 382 | + | // The last slot holds the spiral; the columns before it keep their place. | |||
| 383 | + | area := rects[k-1] | |||
| 384 | + | for i := k - 1; i < len(w.Columns)-1; i++ { | |||
| 385 | + | rects[i], area = split(area, g, (i-k+1)%2 == 0) | |||
| 386 | + | } | |||
| 387 | + | rects[len(rects)-1] = area | |||
| 388 | + | return rects | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | |||
| 392 | + | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | |||
| 393 | + | if vertical { | |||
| 394 | + | h := max((r.H-gap)/2, 0) | |||
| 395 | + | return Rect{X: r.X, Y: r.Y, W: r.W, H: h}, Rect{X: r.X, Y: r.Y + h + gap, W: r.W, H: max(r.H-gap-h, 0)} | |||
| 396 | + | } | |||
| 397 | + | w := max((r.W-gap)/2, 0) | |||
| 398 | + | return Rect{X: r.X, Y: r.Y, W: w, H: r.H}, Rect{X: r.X + w + gap, Y: r.Y, W: max(r.W-gap-w, 0), H: r.H} | |||
| 399 | + | } | |||
| 400 | + | ||||
| 349 | func (w *Workspace) Layout() []Placement { | 401 | func (w *Workspace) Layout() []Placement { | ||
| 350 | var result []Placement | 402 | var result []Placement | ||
| 351 | gap := w.gap() | 403 | gap := w.gap() | ||
| 404 | + | cols := w.columnRects() | |||
| 352 | for i, c := range w.Columns { | 405 | for i, c := range w.Columns { | ||
| 353 | - | x := w.columnX(i) - w.ViewX | 406 | + | col := cols[i] |
| 354 | - | width := w.columnWidth(i) | |||
| 355 | n := len(c.Windows) | 407 | n := len(c.Windows) | ||
| 356 | if n == 0 { | 408 | if n == 0 { | ||
| 357 | continue | 409 | continue | ||
| 358 | } | 410 | } | ||
| 359 | fullColumn := w.fullscreenColumn(i) | 411 | fullColumn := w.fullscreenColumn(i) | ||
| 360 | - | available := max(w.Usable.H-(n+1)*gap, 0) | 412 | + | if fullColumn { |
| 413 | + | col.W = w.Output.W | |||
| 414 | + | } | |||
| 415 | + | available := max(col.H-(n-1)*gap, 0) | |||
| 361 | height := available / n | 416 | height := available / n | ||
| 362 | - | y := w.Usable.Y + gap | 417 | + | y := col.Y |
| 418 | + | bottom := col.Y + col.H | |||
| 363 | for j, id := range c.Windows { | 419 | for j, id := range c.Windows { | ||
| 364 | h := height | 420 | h := height | ||
| 365 | if j == n-1 { | 421 | if j == n-1 { | ||
| 366 | h = available - height*(n-1) | 422 | h = available - height*(n-1) | ||
| 367 | } | 423 | } | ||
| 368 | - | if y > w.Usable.Y+w.Usable.H { | 424 | + | y = min(y, bottom) |
| 369 | - | y = w.Usable.Y + w.Usable.H | 425 | + | h = min(h, bottom-y) |
| 370 | - | } | 426 | + | r := Rect{X: col.X, Y: y, W: col.W, H: h} |
| 371 | - | h = min(h, w.Usable.Y+w.Usable.H-y) | |||
| 372 | - | r := Rect{X: x, Y: y, W: width, H: h} | |||
| 373 | full := w.fullscreen == id && id != 0 | 427 | full := w.fullscreen == id && id != 0 | ||
| 374 | hidden := fullColumn && !full | 428 | hidden := fullColumn && !full | ||
| 375 | if full { | 429 | if full { | ||
| 376 | - | r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H} | 430 | + | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} |
| 377 | } | 431 | } | ||
| 378 | if hidden { | 432 | if hidden { | ||
| 379 | r = Rect{} | 433 | r = Rect{} | ||
| 380 | } | 434 | } | ||
| 381 | - | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: width >= w.Usable.W-2*gap}) | 435 | + | result = append(result, Placement{ID: id, Rect: r, Fullscreen: full, Focused: i == w.Focus && j == c.Focus, Hidden: hidden, Borderless: col.W >= w.Usable.W-2*gap}) |
| 382 | y += h + gap | 436 | y += h + gap | ||
| 383 | } | 437 | } | ||
| 384 | } | 438 | } | ||
M internal/core/layout_test.go +37 -0
| @@ -350,3 +350,40 @@ func TestSetFullscreenStackDeactivation(t *testing.T) { | |||
| 350 | 350 | t.Fatal(w.Layout()) | |
| 351 | 351 | } | |
| 352 | 352 | } | |
| 353 | + | ||
| 354 | + | // Fixed overflow keeps every window on screen: past max-columns, new columns | |
| 355 | + | // split the newest one, alternating top/bottom and left/right. | |
| 356 | + | func TestFixedOverflowSpiral(t *testing.T) { | |
| 357 | + | w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |
| 358 | + | w.SetOutput(100, 80) | |
| 359 | + | for id := WindowID(1); id <= 5; id++ { | |
| 360 | + | w.AddWindow(id) | |
| 361 | + | // New columns open after the focused one; keep order by focusing the last. | |
| 362 | + | w.Focus = len(w.Columns) - 1 | |
| 363 | + | } | |
| 364 | + | got := map[WindowID]Rect{} | |
| 365 | + | for _, p := range w.Layout() { | |
| 366 | + | got[p.ID] = p.Rect | |
| 367 | + | } | |
| 368 | + | want := map[WindowID]Rect{ | |
| 369 | + | 1: {X: 0, Y: 0, W: 50, H: 80}, | |
| 370 | + | 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2 | |
| 371 | + | 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half | |
| 372 | + | 4: {X: 75, Y: 40, W: 25, H: 20}, | |
| 373 | + | 5: {X: 75, Y: 60, W: 25, H: 20}, | |
| 374 | + | } | |
| 375 | + | if !reflect.DeepEqual(got, want) { | |
| 376 | + | t.Fatalf("got %v\nwant %v", got, want) | |
| 377 | + | } | |
| 378 | + | if w.ViewX != 0 { | |
| 379 | + | t.Fatal("fixed overflow scrolled", w.ViewX) | |
| 380 | + | } | |
| 381 | + | // Up to max-columns it is the plain equal split. | |
| 382 | + | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |
| 383 | + | w2.SetOutput(100, 80) | |
| 384 | + | w2.AddWindow(1) | |
| 385 | + | w2.AddWindow(2) | |
| 386 | + | if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) { | |
| 387 | + | t.Fatal(p) | |
| 388 | + | } | |
| 389 | + | } | |
| @@ -350,3 +350,40 @@ func TestSetFullscreenStackDeactivation(t *testing.T) { | |||||
| 350 | t.Fatal(w.Layout()) | 350 | t.Fatal(w.Layout()) | ||
| 351 | } | 351 | } | ||
| 352 | } | 352 | } | ||
| 353 | + | ||||
| 354 | + | // Fixed overflow keeps every window on screen: past max-columns, new columns | |||
| 355 | + | // split the newest one, alternating top/bottom and left/right. | |||
| 356 | + | func TestFixedOverflowSpiral(t *testing.T) { | |||
| 357 | + | w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |||
| 358 | + | w.SetOutput(100, 80) | |||
| 359 | + | for id := WindowID(1); id <= 5; id++ { | |||
| 360 | + | w.AddWindow(id) | |||
| 361 | + | // New columns open after the focused one; keep order by focusing the last. | |||
| 362 | + | w.Focus = len(w.Columns) - 1 | |||
| 363 | + | } | |||
| 364 | + | got := map[WindowID]Rect{} | |||
| 365 | + | for _, p := range w.Layout() { | |||
| 366 | + | got[p.ID] = p.Rect | |||
| 367 | + | } | |||
| 368 | + | want := map[WindowID]Rect{ | |||
| 369 | + | 1: {X: 0, Y: 0, W: 50, H: 80}, | |||
| 370 | + | 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2 | |||
| 371 | + | 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half | |||
| 372 | + | 4: {X: 75, Y: 40, W: 25, H: 20}, | |||
| 373 | + | 5: {X: 75, Y: 60, W: 25, H: 20}, | |||
| 374 | + | } | |||
| 375 | + | if !reflect.DeepEqual(got, want) { | |||
| 376 | + | t.Fatalf("got %v\nwant %v", got, want) | |||
| 377 | + | } | |||
| 378 | + | if w.ViewX != 0 { | |||
| 379 | + | t.Fatal("fixed overflow scrolled", w.ViewX) | |||
| 380 | + | } | |||
| 381 | + | // Up to max-columns it is the plain equal split. | |||
| 382 | + | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |||
| 383 | + | w2.SetOutput(100, 80) | |||
| 384 | + | w2.AddWindow(1) | |||
| 385 | + | w2.AddWindow(2) | |||
| 386 | + | if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) { | |||
| 387 | + | t.Fatal(p) | |||
| 388 | + | } | |||
| 389 | + | } | |||
M internal/core/monitor.go +224 -20
| @@ -2,12 +2,29 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| 4 | 4 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |
| 5 | - | // below the last one, and an empty workspace is removed once left, except the | |
| 6 | - | // first. Output-wide settings apply to every workspace. | |
| 5 | + | // below the last one, and an empty unnamed workspace is removed once left, | |
| 6 | + | // except the first. Output-wide settings apply to every workspace. | |
| 7 | + | // | |
| 8 | + | // Named workspaces come from config and are never removed while configured. | |
| 9 | + | // A hidden one is not numbered and not reachable by up/down: only its | |
| 10 | + | // `workspace <name>` bind shows it. | |
| 7 | 11 | type Monitor struct { | |
| 8 | 12 | Workspaces []*Workspace | |
| 9 | 13 | Active int | |
| 14 | + | hidden []*Workspace | |
| 15 | + | shown *Workspace // hidden workspace on screen, nil for Workspaces[Active] | |
| 16 | + | back *Workspace // where a named workspace toggle returns to | |
| 10 | 17 | template Workspace | |
| 18 | + | named []NamedWorkspace | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |
| 22 | + | // Overflow follow the monitor defaults. | |
| 23 | + | type NamedWorkspace struct { | |
| 24 | + | Name string | |
| 25 | + | Hidden bool | |
| 26 | + | MaxColumns int | |
| 27 | + | Overflow Overflow | |
| 11 | 28 | } | |
| 12 | 29 | ||
| 13 | 30 | func NewMonitor() *Monitor { | |
| @@ -17,7 +34,12 @@ func NewMonitor() *Monitor { | |||
| 17 | 34 | } | |
| 18 | 35 | ||
| 19 | 36 | // Current is the workspace on screen. | |
| 20 | - | func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] } | |
| 37 | + | func (m *Monitor) Current() *Workspace { | |
| 38 | + | if m.shown != nil { | |
| 39 | + | return m.shown | |
| 40 | + | } | |
| 41 | + | return m.Workspaces[m.Active] | |
| 42 | + | } | |
| 21 | 43 | ||
| 22 | 44 | func (m *Monitor) newWorkspace() *Workspace { | |
| 23 | 45 | w := m.template | |
| @@ -25,28 +47,31 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 25 | 47 | return &w | |
| 26 | 48 | } | |
| 27 | 49 | ||
| 28 | - | // normalize keeps one trailing empty workspace and drops other empty ones that | |
| 29 | - | // are neither the first nor active. The active workspace never changes. | |
| 50 | + | // normalize keeps one trailing empty unnamed workspace and drops other empty | |
| 51 | + | // unnamed ones that are neither the first nor active. The active workspace | |
| 52 | + | // never changes. | |
| 30 | 53 | func (m *Monitor) normalize() { | |
| 54 | + | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | |
| 31 | 55 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | |
| 32 | - | if m.Workspaces[i].empty() && i != m.Active { | |
| 56 | + | if spare(i) && i != m.Active { | |
| 33 | 57 | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |
| 34 | 58 | if i < m.Active { | |
| 35 | 59 | m.Active-- | |
| 36 | 60 | } | |
| 37 | 61 | } | |
| 38 | 62 | } | |
| 39 | - | for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- { | |
| 63 | + | for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- { | |
| 40 | 64 | m.Workspaces = m.Workspaces[:n-1] | |
| 41 | 65 | } | |
| 42 | - | if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() { | |
| 66 | + | if n := len(m.Workspaces); n == 0 || !spare(n-1) { | |
| 43 | 67 | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 44 | 68 | } | |
| 45 | 69 | } | |
| 46 | 70 | ||
| 47 | - | // Focus switches to the workspace at index i, clamped to the list. | |
| 71 | + | // Focus switches to the numbered workspace at index i, clamped to the list. | |
| 48 | 72 | func (m *Monitor) Focus(i int) { | |
| 49 | 73 | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 74 | + | m.shown = nil | |
| 50 | 75 | m.Active = i | |
| 51 | 76 | m.normalize() | |
| 52 | 77 | } | |
| @@ -54,17 +79,71 @@ func (m *Monitor) Focus(i int) { | |||
| 54 | 79 | // FocusNumber switches to workspace n (1-based); past the end means the last. | |
| 55 | 80 | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | |
| 56 | 81 | ||
| 57 | - | // find returns the workspace holding the window. | |
| 82 | + | // show puts w on screen, numbered or hidden. | |
| 83 | + | func (m *Monitor) show(w *Workspace) { | |
| 84 | + | if i := indexOf(m.Workspaces, w); i >= 0 { | |
| 85 | + | m.Focus(i) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | m.shown = w | |
| 89 | + | m.normalize() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // ToggleNamed shows the named workspace, or returns to the previous one when | |
| 93 | + | // it is already on screen. Unknown names do nothing. | |
| 94 | + | func (m *Monitor) ToggleNamed(name string) { | |
| 95 | + | var target *Workspace | |
| 96 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| 97 | + | if w.Name == name { | |
| 98 | + | target = w | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | switch cur := m.Current(); { | |
| 102 | + | case target == nil: | |
| 103 | + | case target != cur: | |
| 104 | + | m.back = cur | |
| 105 | + | m.show(target) | |
| 106 | + | case m.back != nil && m.has(m.back): | |
| 107 | + | back := m.back | |
| 108 | + | m.back = nil | |
| 109 | + | m.show(back) | |
| 110 | + | case m.shown != nil: | |
| 111 | + | m.back = nil | |
| 112 | + | m.shown = nil | |
| 113 | + | m.normalize() | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func indexOf(list []*Workspace, w *Workspace) int { | |
| 118 | + | for i, v := range list { | |
| 119 | + | if v == w { | |
| 120 | + | return i | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return -1 | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // has reports whether w still belongs to the monitor. | |
| 127 | + | func (m *Monitor) has(w *Workspace) bool { | |
| 128 | + | return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0 | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // find returns the workspace holding the window; i is -1 for hidden ones. | |
| 58 | 132 | func (m *Monitor) find(id WindowID) (*Workspace, int) { | |
| 59 | 133 | for i, w := range m.Workspaces { | |
| 60 | 134 | if w.has(id) { | |
| 61 | 135 | return w, i | |
| 62 | 136 | } | |
| 63 | 137 | } | |
| 138 | + | for _, w := range m.hidden { | |
| 139 | + | if w.has(id) { | |
| 140 | + | return w, -1 | |
| 141 | + | } | |
| 142 | + | } | |
| 64 | 143 | return nil, -1 | |
| 65 | 144 | } | |
| 66 | 145 | ||
| 67 | - | // AddWindow places a new window on the active workspace. | |
| 146 | + | // AddWindow places a new window on the workspace on screen. | |
| 68 | 147 | func (m *Monitor) AddWindow(id WindowID) { | |
| 69 | 148 | if w, _ := m.find(id); w != nil { | |
| 70 | 149 | return | |
| @@ -83,13 +162,12 @@ func (m *Monitor) RemoveWindow(id WindowID) { | |||
| 83 | 162 | ||
| 84 | 163 | // FocusID shows the window's workspace and focuses it. | |
| 85 | 164 | func (m *Monitor) FocusID(id WindowID) bool { | |
| 86 | - | w, i := m.find(id) | |
| 165 | + | w, _ := m.find(id) | |
| 87 | 166 | if w == nil { | |
| 88 | 167 | return false | |
| 89 | 168 | } | |
| 90 | - | m.Active = i | |
| 169 | + | m.show(w) | |
| 91 | 170 | w.FocusID(id) | |
| 92 | - | m.normalize() | |
| 93 | 171 | return true | |
| 94 | 172 | } | |
| 95 | 173 | ||
| @@ -101,12 +179,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||
| 101 | 179 | } | |
| 102 | 180 | } | |
| 103 | 181 | ||
| 104 | - | // MoveToWorkspace moves the focused window to workspace index i; focus stays here. | |
| 182 | + | // MoveToWorkspace moves the focused window to numbered workspace index i; | |
| 183 | + | // focus stays here. | |
| 105 | 184 | func (m *Monitor) MoveToWorkspace(i int) { | |
| 106 | 185 | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 107 | 186 | cur := m.Current() | |
| 108 | 187 | id, ok := cur.Focused() | |
| 109 | - | if !ok || i == m.Active { | |
| 188 | + | if !ok || m.Workspaces[i] == cur { | |
| 110 | 189 | return | |
| 111 | 190 | } | |
| 112 | 191 | cur.RemoveWindow(id) | |
| @@ -116,11 +195,12 @@ func (m *Monitor) MoveToWorkspace(i int) { | |||
| 116 | 195 | ||
| 117 | 196 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | |
| 118 | 197 | ||
| 119 | - | // Layout places every window: the active workspace on screen, others hidden. | |
| 198 | + | // Layout places every window: the workspace on screen laid out, others hidden. | |
| 120 | 199 | func (m *Monitor) Layout() []Placement { | |
| 121 | 200 | var result []Placement | |
| 122 | - | for i, w := range m.Workspaces { | |
| 123 | - | if i == m.Active { | |
| 201 | + | cur := m.Current() | |
| 202 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| 203 | + | if w == cur { | |
| 124 | 204 | result = append(result, w.Layout()...) | |
| 125 | 205 | continue | |
| 126 | 206 | } | |
| @@ -140,6 +220,9 @@ func (m *Monitor) each(f func(*Workspace)) { | |||
| 140 | 220 | for _, w := range m.Workspaces { | |
| 141 | 221 | f(w) | |
| 142 | 222 | } | |
| 223 | + | for _, w := range m.hidden { | |
| 224 | + | f(w) | |
| 225 | + | } | |
| 143 | 226 | } | |
| 144 | 227 | func (m *Monitor) SetOutput(width, height int) { | |
| 145 | 228 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | |
| @@ -147,4 +230,125 @@ func (m *Monitor) SetOutput(width, height int) { | |||
| 147 | 230 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 148 | 231 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 149 | 232 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | |
| 150 | - | func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) } | |
| 233 | + | ||
| 234 | + | // SetMaxColumns sets the default; named workspaces may override it. | |
| 235 | + | func (m *Monitor) SetMaxColumns(n int) { | |
| 236 | + | m.template.MaxColumns = n | |
| 237 | + | m.applyNamed() | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // SetOverflow sets the default; named workspaces may override it. | |
| 241 | + | func (m *Monitor) SetOverflow(o Overflow) { | |
| 242 | + | m.template.Overflow = o | |
| 243 | + | m.applyNamed() | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // applyNamed gives every workspace the defaults or its named overrides. | |
| 247 | + | func (m *Monitor) applyNamed() { | |
| 248 | + | specs := map[string]NamedWorkspace{} | |
| 249 | + | for _, s := range m.named { | |
| 250 | + | specs[s.Name] = s | |
| 251 | + | } | |
| 252 | + | m.each(func(w *Workspace) { | |
| 253 | + | s := specs[w.Name] | |
| 254 | + | w.Overflow = m.template.Overflow | |
| 255 | + | if s.Overflow != "" { | |
| 256 | + | w.Overflow = s.Overflow | |
| 257 | + | } | |
| 258 | + | w.SetMaxColumns(m.template.MaxColumns) | |
| 259 | + | if s.MaxColumns > 0 { | |
| 260 | + | w.SetMaxColumns(s.MaxColumns) | |
| 261 | + | } | |
| 262 | + | }) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | // SetNamed applies the named workspaces from config. New numbered ones are | |
| 266 | + | // inserted above the trailing empty workspace (never before the first one). | |
| 267 | + | // A workspace dropped from config becomes a normal one; a dropped hidden one | |
| 268 | + | // hands its windows to the active numbered workspace. The workspace on | |
| 269 | + | // screen stays on screen. | |
| 270 | + | func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |
| 271 | + | cur := m.Current() | |
| 272 | + | want := map[string]NamedWorkspace{} | |
| 273 | + | for _, s := range specs { | |
| 274 | + | want[s.Name] = s | |
| 275 | + | } | |
| 276 | + | byName := map[string]*Workspace{} | |
| 277 | + | var keep []*Workspace | |
| 278 | + | for _, w := range m.Workspaces { | |
| 279 | + | if _, ok := want[w.Name]; w.Name != "" && !ok { | |
| 280 | + | w.Name = "" | |
| 281 | + | } | |
| 282 | + | if w.Name != "" { | |
| 283 | + | byName[w.Name] = w | |
| 284 | + | } | |
| 285 | + | keep = append(keep, w) | |
| 286 | + | } | |
| 287 | + | m.Workspaces = keep | |
| 288 | + | var hidden []*Workspace | |
| 289 | + | for _, w := range m.hidden { | |
| 290 | + | if _, ok := want[w.Name]; ok { | |
| 291 | + | byName[w.Name] = w | |
| 292 | + | hidden = append(hidden, w) | |
| 293 | + | continue | |
| 294 | + | } | |
| 295 | + | if w == cur { | |
| 296 | + | cur = nil | |
| 297 | + | } | |
| 298 | + | for _, c := range w.Columns { | |
| 299 | + | for _, id := range c.Windows { | |
| 300 | + | m.Workspaces[m.Active].AddWindow(id) | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | m.hidden = hidden | |
| 305 | + | m.named = append([]NamedWorkspace(nil), specs...) | |
| 306 | + | var nextHidden []*Workspace | |
| 307 | + | for _, s := range specs { | |
| 308 | + | w := byName[s.Name] | |
| 309 | + | if w == nil { | |
| 310 | + | w = m.newWorkspace() | |
| 311 | + | w.Name = s.Name | |
| 312 | + | } | |
| 313 | + | i := indexOf(m.Workspaces, w) | |
| 314 | + | switch { | |
| 315 | + | case s.Hidden: | |
| 316 | + | if i >= 0 { | |
| 317 | + | m.removeNumbered(i) | |
| 318 | + | } | |
| 319 | + | nextHidden = append(nextHidden, w) | |
| 320 | + | case i < 0: | |
| 321 | + | at := max(len(m.Workspaces)-1, 1) | |
| 322 | + | m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...) | |
| 323 | + | if at <= m.Active { | |
| 324 | + | m.Active++ | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | m.hidden = nextHidden | |
| 329 | + | if m.back != nil && !m.has(m.back) { | |
| 330 | + | m.back = nil | |
| 331 | + | } | |
| 332 | + | m.shown = nil | |
| 333 | + | switch { | |
| 334 | + | case cur == nil: | |
| 335 | + | case indexOf(m.hidden, cur) >= 0: | |
| 336 | + | m.shown = cur | |
| 337 | + | case indexOf(m.Workspaces, cur) >= 0: | |
| 338 | + | m.Active = indexOf(m.Workspaces, cur) | |
| 339 | + | } | |
| 340 | + | m.applyNamed() | |
| 341 | + | m.normalize() | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // removeNumbered drops Workspaces[i] and keeps Active on the same workspace | |
| 345 | + | // when possible. | |
| 346 | + | func (m *Monitor) removeNumbered(i int) { | |
| 347 | + | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |
| 348 | + | if i < m.Active || m.Active >= len(m.Workspaces) { | |
| 349 | + | m.Active = max(m.Active-1, 0) | |
| 350 | + | } | |
| 351 | + | if len(m.Workspaces) == 0 { | |
| 352 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 353 | + | } | |
| 354 | + | } | |
| @@ -2,12 +2,29 @@ package core | |||||
| 2 | 2 | ||||
| 3 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | 3 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | ||
| 4 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | 4 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | ||
| 5 | - | // below the last one, and an empty workspace is removed once left, except the | 5 | + | // below the last one, and an empty unnamed workspace is removed once left, |
| 6 | - | // first. Output-wide settings apply to every workspace. | 6 | + | // except the first. Output-wide settings apply to every workspace. |
| 7 | + | // | |||
| 8 | + | // Named workspaces come from config and are never removed while configured. | |||
| 9 | + | // A hidden one is not numbered and not reachable by up/down: only its | |||
| 10 | + | // `workspace <name>` bind shows it. | |||
| 7 | type Monitor struct { | 11 | type Monitor struct { | ||
| 8 | Workspaces []*Workspace | 12 | Workspaces []*Workspace | ||
| 9 | Active int | 13 | Active int | ||
| 14 | + | hidden []*Workspace | |||
| 15 | + | shown *Workspace // hidden workspace on screen, nil for Workspaces[Active] | |||
| 16 | + | back *Workspace // where a named workspace toggle returns to | |||
| 10 | template Workspace | 17 | template Workspace | ||
| 18 | + | named []NamedWorkspace | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |||
| 22 | + | // Overflow follow the monitor defaults. | |||
| 23 | + | type NamedWorkspace struct { | |||
| 24 | + | Name string | |||
| 25 | + | Hidden bool | |||
| 26 | + | MaxColumns int | |||
| 27 | + | Overflow Overflow | |||
| 11 | } | 28 | } | ||
| 12 | 29 | ||||
| 13 | func NewMonitor() *Monitor { | 30 | func NewMonitor() *Monitor { | ||
| @@ -17,7 +34,12 @@ func NewMonitor() *Monitor { | |||||
| 17 | } | 34 | } | ||
| 18 | 35 | ||||
| 19 | // Current is the workspace on screen. | 36 | // Current is the workspace on screen. | ||
| 20 | - | func (m *Monitor) Current() *Workspace { return m.Workspaces[m.Active] } | 37 | + | func (m *Monitor) Current() *Workspace { |
| 38 | + | if m.shown != nil { | |||
| 39 | + | return m.shown | |||
| 40 | + | } | |||
| 41 | + | return m.Workspaces[m.Active] | |||
| 42 | + | } | |||
| 21 | 43 | ||||
| 22 | func (m *Monitor) newWorkspace() *Workspace { | 44 | func (m *Monitor) newWorkspace() *Workspace { | ||
| 23 | w := m.template | 45 | w := m.template | ||
| @@ -25,28 +47,31 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 25 | return &w | 47 | return &w | ||
| 26 | } | 48 | } | ||
| 27 | 49 | ||||
| 28 | - | // normalize keeps one trailing empty workspace and drops other empty ones that | 50 | + | // normalize keeps one trailing empty unnamed workspace and drops other empty |
| 29 | - | // are neither the first nor active. The active workspace never changes. | 51 | + | // unnamed ones that are neither the first nor active. The active workspace |
| 52 | + | // never changes. | |||
| 30 | func (m *Monitor) normalize() { | 53 | func (m *Monitor) normalize() { | ||
| 54 | + | spare := func(i int) bool { return m.Workspaces[i].empty() && m.Workspaces[i].Name == "" } | |||
| 31 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | 55 | for i := len(m.Workspaces) - 2; i >= 1; i-- { | ||
| 32 | - | if m.Workspaces[i].empty() && i != m.Active { | 56 | + | if spare(i) && i != m.Active { |
| 33 | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | 57 | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | ||
| 34 | if i < m.Active { | 58 | if i < m.Active { | ||
| 35 | m.Active-- | 59 | m.Active-- | ||
| 36 | } | 60 | } | ||
| 37 | } | 61 | } | ||
| 38 | } | 62 | } | ||
| 39 | - | for n := len(m.Workspaces); n >= 2 && m.Workspaces[n-1].empty() && m.Workspaces[n-2].empty() && m.Active != n-1; n-- { | 63 | + | for n := len(m.Workspaces); n >= 2 && spare(n-1) && spare(n-2) && m.Active != n-1; n-- { |
| 40 | m.Workspaces = m.Workspaces[:n-1] | 64 | m.Workspaces = m.Workspaces[:n-1] | ||
| 41 | } | 65 | } | ||
| 42 | - | if n := len(m.Workspaces); n == 0 || !m.Workspaces[n-1].empty() { | 66 | + | if n := len(m.Workspaces); n == 0 || !spare(n-1) { |
| 43 | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | 67 | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | ||
| 44 | } | 68 | } | ||
| 45 | } | 69 | } | ||
| 46 | 70 | ||||
| 47 | - | // Focus switches to the workspace at index i, clamped to the list. | 71 | + | // Focus switches to the numbered workspace at index i, clamped to the list. |
| 48 | func (m *Monitor) Focus(i int) { | 72 | func (m *Monitor) Focus(i int) { | ||
| 49 | i = min(max(i, 0), len(m.Workspaces)-1) | 73 | i = min(max(i, 0), len(m.Workspaces)-1) | ||
| 74 | + | m.shown = nil | |||
| 50 | m.Active = i | 75 | m.Active = i | ||
| 51 | m.normalize() | 76 | m.normalize() | ||
| 52 | } | 77 | } | ||
| @@ -54,17 +79,71 @@ func (m *Monitor) Focus(i int) { | |||||
| 54 | // FocusNumber switches to workspace n (1-based); past the end means the last. | 79 | // FocusNumber switches to workspace n (1-based); past the end means the last. | ||
| 55 | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | 80 | func (m *Monitor) FocusNumber(n int) { m.Focus(n - 1) } | ||
| 56 | 81 | ||||
| 57 | - | // find returns the workspace holding the window. | 82 | + | // show puts w on screen, numbered or hidden. |
| 83 | + | func (m *Monitor) show(w *Workspace) { | |||
| 84 | + | if i := indexOf(m.Workspaces, w); i >= 0 { | |||
| 85 | + | m.Focus(i) | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | m.shown = w | |||
| 89 | + | m.normalize() | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // ToggleNamed shows the named workspace, or returns to the previous one when | |||
| 93 | + | // it is already on screen. Unknown names do nothing. | |||
| 94 | + | func (m *Monitor) ToggleNamed(name string) { | |||
| 95 | + | var target *Workspace | |||
| 96 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |||
| 97 | + | if w.Name == name { | |||
| 98 | + | target = w | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | switch cur := m.Current(); { | |||
| 102 | + | case target == nil: | |||
| 103 | + | case target != cur: | |||
| 104 | + | m.back = cur | |||
| 105 | + | m.show(target) | |||
| 106 | + | case m.back != nil && m.has(m.back): | |||
| 107 | + | back := m.back | |||
| 108 | + | m.back = nil | |||
| 109 | + | m.show(back) | |||
| 110 | + | case m.shown != nil: | |||
| 111 | + | m.back = nil | |||
| 112 | + | m.shown = nil | |||
| 113 | + | m.normalize() | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func indexOf(list []*Workspace, w *Workspace) int { | |||
| 118 | + | for i, v := range list { | |||
| 119 | + | if v == w { | |||
| 120 | + | return i | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | return -1 | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // has reports whether w still belongs to the monitor. | |||
| 127 | + | func (m *Monitor) has(w *Workspace) bool { | |||
| 128 | + | return indexOf(m.Workspaces, w) >= 0 || indexOf(m.hidden, w) >= 0 | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // find returns the workspace holding the window; i is -1 for hidden ones. | |||
| 58 | func (m *Monitor) find(id WindowID) (*Workspace, int) { | 132 | func (m *Monitor) find(id WindowID) (*Workspace, int) { | ||
| 59 | for i, w := range m.Workspaces { | 133 | for i, w := range m.Workspaces { | ||
| 60 | if w.has(id) { | 134 | if w.has(id) { | ||
| 61 | return w, i | 135 | return w, i | ||
| 62 | } | 136 | } | ||
| 63 | } | 137 | } | ||
| 138 | + | for _, w := range m.hidden { | |||
| 139 | + | if w.has(id) { | |||
| 140 | + | return w, -1 | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 64 | return nil, -1 | 143 | return nil, -1 | ||
| 65 | } | 144 | } | ||
| 66 | 145 | ||||
| 67 | - | // AddWindow places a new window on the active workspace. | 146 | + | // AddWindow places a new window on the workspace on screen. |
| 68 | func (m *Monitor) AddWindow(id WindowID) { | 147 | func (m *Monitor) AddWindow(id WindowID) { | ||
| 69 | if w, _ := m.find(id); w != nil { | 148 | if w, _ := m.find(id); w != nil { | ||
| 70 | return | 149 | return | ||
| @@ -83,13 +162,12 @@ func (m *Monitor) RemoveWindow(id WindowID) { | |||||
| 83 | 162 | ||||
| 84 | // FocusID shows the window's workspace and focuses it. | 163 | // FocusID shows the window's workspace and focuses it. | ||
| 85 | func (m *Monitor) FocusID(id WindowID) bool { | 164 | func (m *Monitor) FocusID(id WindowID) bool { | ||
| 86 | - | w, i := m.find(id) | 165 | + | w, _ := m.find(id) |
| 87 | if w == nil { | 166 | if w == nil { | ||
| 88 | return false | 167 | return false | ||
| 89 | } | 168 | } | ||
| 90 | - | m.Active = i | 169 | + | m.show(w) |
| 91 | w.FocusID(id) | 170 | w.FocusID(id) | ||
| 92 | - | m.normalize() | |||
| 93 | return true | 171 | return true | ||
| 94 | } | 172 | } | ||
| 95 | 173 | ||||
| @@ -101,12 +179,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||||
| 101 | } | 179 | } | ||
| 102 | } | 180 | } | ||
| 103 | 181 | ||||
| 104 | - | // MoveToWorkspace moves the focused window to workspace index i; focus stays here. | 182 | + | // MoveToWorkspace moves the focused window to numbered workspace index i; |
| 183 | + | // focus stays here. | |||
| 105 | func (m *Monitor) MoveToWorkspace(i int) { | 184 | func (m *Monitor) MoveToWorkspace(i int) { | ||
| 106 | i = min(max(i, 0), len(m.Workspaces)-1) | 185 | i = min(max(i, 0), len(m.Workspaces)-1) | ||
| 107 | cur := m.Current() | 186 | cur := m.Current() | ||
| 108 | id, ok := cur.Focused() | 187 | id, ok := cur.Focused() | ||
| 109 | - | if !ok || i == m.Active { | 188 | + | if !ok || m.Workspaces[i] == cur { |
| 110 | return | 189 | return | ||
| 111 | } | 190 | } | ||
| 112 | cur.RemoveWindow(id) | 191 | cur.RemoveWindow(id) | ||
| @@ -116,11 +195,12 @@ func (m *Monitor) MoveToWorkspace(i int) { | |||||
| 116 | 195 | ||||
| 117 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | 196 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | ||
| 118 | 197 | ||||
| 119 | - | // Layout places every window: the active workspace on screen, others hidden. | 198 | + | // Layout places every window: the workspace on screen laid out, others hidden. |
| 120 | func (m *Monitor) Layout() []Placement { | 199 | func (m *Monitor) Layout() []Placement { | ||
| 121 | var result []Placement | 200 | var result []Placement | ||
| 122 | - | for i, w := range m.Workspaces { | 201 | + | cur := m.Current() |
| 123 | - | if i == m.Active { | 202 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { |
| 203 | + | if w == cur { | |||
| 124 | result = append(result, w.Layout()...) | 204 | result = append(result, w.Layout()...) | ||
| 125 | continue | 205 | continue | ||
| 126 | } | 206 | } | ||
| @@ -140,6 +220,9 @@ func (m *Monitor) each(f func(*Workspace)) { | |||||
| 140 | for _, w := range m.Workspaces { | 220 | for _, w := range m.Workspaces { | ||
| 141 | f(w) | 221 | f(w) | ||
| 142 | } | 222 | } | ||
| 223 | + | for _, w := range m.hidden { | |||
| 224 | + | f(w) | |||
| 225 | + | } | |||
| 143 | } | 226 | } | ||
| 144 | func (m *Monitor) SetOutput(width, height int) { | 227 | func (m *Monitor) SetOutput(width, height int) { | ||
| 145 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | 228 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | ||
| @@ -147,4 +230,125 @@ func (m *Monitor) SetOutput(width, height int) { | |||||
| 147 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | 230 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | ||
| 148 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | 231 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | ||
| 149 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | 232 | func (m *Monitor) SetPresets(v []Width) { m.each(func(w *Workspace) { w.SetPresets(v) }) } | ||
| 150 | - | func (m *Monitor) SetMaxColumns(n int) { m.each(func(w *Workspace) { w.SetMaxColumns(n) }) } | 233 | + | |
| 234 | + | // SetMaxColumns sets the default; named workspaces may override it. | |||
| 235 | + | func (m *Monitor) SetMaxColumns(n int) { | |||
| 236 | + | m.template.MaxColumns = n | |||
| 237 | + | m.applyNamed() | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // SetOverflow sets the default; named workspaces may override it. | |||
| 241 | + | func (m *Monitor) SetOverflow(o Overflow) { | |||
| 242 | + | m.template.Overflow = o | |||
| 243 | + | m.applyNamed() | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // applyNamed gives every workspace the defaults or its named overrides. | |||
| 247 | + | func (m *Monitor) applyNamed() { | |||
| 248 | + | specs := map[string]NamedWorkspace{} | |||
| 249 | + | for _, s := range m.named { | |||
| 250 | + | specs[s.Name] = s | |||
| 251 | + | } | |||
| 252 | + | m.each(func(w *Workspace) { | |||
| 253 | + | s := specs[w.Name] | |||
| 254 | + | w.Overflow = m.template.Overflow | |||
| 255 | + | if s.Overflow != "" { | |||
| 256 | + | w.Overflow = s.Overflow | |||
| 257 | + | } | |||
| 258 | + | w.SetMaxColumns(m.template.MaxColumns) | |||
| 259 | + | if s.MaxColumns > 0 { | |||
| 260 | + | w.SetMaxColumns(s.MaxColumns) | |||
| 261 | + | } | |||
| 262 | + | }) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // SetNamed applies the named workspaces from config. New numbered ones are | |||
| 266 | + | // inserted above the trailing empty workspace (never before the first one). | |||
| 267 | + | // A workspace dropped from config becomes a normal one; a dropped hidden one | |||
| 268 | + | // hands its windows to the active numbered workspace. The workspace on | |||
| 269 | + | // screen stays on screen. | |||
| 270 | + | func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 271 | + | cur := m.Current() | |||
| 272 | + | want := map[string]NamedWorkspace{} | |||
| 273 | + | for _, s := range specs { | |||
| 274 | + | want[s.Name] = s | |||
| 275 | + | } | |||
| 276 | + | byName := map[string]*Workspace{} | |||
| 277 | + | var keep []*Workspace | |||
| 278 | + | for _, w := range m.Workspaces { | |||
| 279 | + | if _, ok := want[w.Name]; w.Name != "" && !ok { | |||
| 280 | + | w.Name = "" | |||
| 281 | + | } | |||
| 282 | + | if w.Name != "" { | |||
| 283 | + | byName[w.Name] = w | |||
| 284 | + | } | |||
| 285 | + | keep = append(keep, w) | |||
| 286 | + | } | |||
| 287 | + | m.Workspaces = keep | |||
| 288 | + | var hidden []*Workspace | |||
| 289 | + | for _, w := range m.hidden { | |||
| 290 | + | if _, ok := want[w.Name]; ok { | |||
| 291 | + | byName[w.Name] = w | |||
| 292 | + | hidden = append(hidden, w) | |||
| 293 | + | continue | |||
| 294 | + | } | |||
| 295 | + | if w == cur { | |||
| 296 | + | cur = nil | |||
| 297 | + | } | |||
| 298 | + | for _, c := range w.Columns { | |||
| 299 | + | for _, id := range c.Windows { | |||
| 300 | + | m.Workspaces[m.Active].AddWindow(id) | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | m.hidden = hidden | |||
| 305 | + | m.named = append([]NamedWorkspace(nil), specs...) | |||
| 306 | + | var nextHidden []*Workspace | |||
| 307 | + | for _, s := range specs { | |||
| 308 | + | w := byName[s.Name] | |||
| 309 | + | if w == nil { | |||
| 310 | + | w = m.newWorkspace() | |||
| 311 | + | w.Name = s.Name | |||
| 312 | + | } | |||
| 313 | + | i := indexOf(m.Workspaces, w) | |||
| 314 | + | switch { | |||
| 315 | + | case s.Hidden: | |||
| 316 | + | if i >= 0 { | |||
| 317 | + | m.removeNumbered(i) | |||
| 318 | + | } | |||
| 319 | + | nextHidden = append(nextHidden, w) | |||
| 320 | + | case i < 0: | |||
| 321 | + | at := max(len(m.Workspaces)-1, 1) | |||
| 322 | + | m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...) | |||
| 323 | + | if at <= m.Active { | |||
| 324 | + | m.Active++ | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | m.hidden = nextHidden | |||
| 329 | + | if m.back != nil && !m.has(m.back) { | |||
| 330 | + | m.back = nil | |||
| 331 | + | } | |||
| 332 | + | m.shown = nil | |||
| 333 | + | switch { | |||
| 334 | + | case cur == nil: | |||
| 335 | + | case indexOf(m.hidden, cur) >= 0: | |||
| 336 | + | m.shown = cur | |||
| 337 | + | case indexOf(m.Workspaces, cur) >= 0: | |||
| 338 | + | m.Active = indexOf(m.Workspaces, cur) | |||
| 339 | + | } | |||
| 340 | + | m.applyNamed() | |||
| 341 | + | m.normalize() | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // removeNumbered drops Workspaces[i] and keeps Active on the same workspace | |||
| 345 | + | // when possible. | |||
| 346 | + | func (m *Monitor) removeNumbered(i int) { | |||
| 347 | + | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |||
| 348 | + | if i < m.Active || m.Active >= len(m.Workspaces) { | |||
| 349 | + | m.Active = max(m.Active-1, 0) | |||
| 350 | + | } | |||
| 351 | + | if len(m.Workspaces) == 0 { | |||
| 352 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |||
| 353 | + | } | |||
| 354 | + | } | |||
M internal/core/monitor_test.go +107 -0
| @@ -239,3 +239,110 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||
| 239 | 239 | t.Fatal(p) | |
| 240 | 240 | } | |
| 241 | 241 | } | |
| 242 | + | ||
| 243 | + | func named(m *Monitor, specs ...NamedWorkspace) *Monitor { | |
| 244 | + | m.SetNamed(specs) | |
| 245 | + | return m | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestHiddenWorkspaceToggle(t *testing.T) { | |
| 249 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}) | |
| 250 | + | m.AddWindow(1) | |
| 251 | + | if len(m.Workspaces) != 2 { | |
| 252 | + | t.Fatal("hidden workspace is numbered", windows(m)) | |
| 253 | + | } | |
| 254 | + | m.Apply("workspace dev") | |
| 255 | + | if m.Current().Name != "dev" { | |
| 256 | + | t.Fatal(m.Current().Name) | |
| 257 | + | } | |
| 258 | + | m.AddWindow(2) | |
| 259 | + | // Up/down do not leave a hidden workspace. | |
| 260 | + | m.Apply(ActionFocusWorkspaceDown) | |
| 261 | + | m.Apply(ActionFocusWindowDown) | |
| 262 | + | if m.Current().Name != "dev" { | |
| 263 | + | t.Fatal("left the hidden workspace") | |
| 264 | + | } | |
| 265 | + | for _, p := range m.Layout() { | |
| 266 | + | if hidden := p.ID == 1; p.Hidden != hidden { | |
| 267 | + | t.Fatal(m.Layout()) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | // The same bind returns to the previous workspace. | |
| 271 | + | m.Apply("workspace dev") | |
| 272 | + | if id, _ := m.Focused(); id != 1 || m.Current().Name != "" { | |
| 273 | + | t.Fatal(id, m.Current().Name) | |
| 274 | + | } | |
| 275 | + | // Cmd+N from the hidden workspace goes to that number. | |
| 276 | + | m.Apply("workspace dev") | |
| 277 | + | m.FocusNumber(1) | |
| 278 | + | if id, _ := m.Focused(); id != 1 { | |
| 279 | + | t.Fatal(id) | |
| 280 | + | } | |
| 281 | + | // Clicking a window of the hidden workspace shows it (FocusID). | |
| 282 | + | if !m.FocusID(2) || m.Current().Name != "dev" { | |
| 283 | + | t.Fatal("FocusID did not show the hidden workspace") | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) { | |
| 288 | + | m := monitor() | |
| 289 | + | m.AddWindow(1) | |
| 290 | + | named(m, NamedWorkspace{Name: "web"}) | |
| 291 | + | // Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}]. | |
| 292 | + | if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 { | |
| 293 | + | t.Fatal(windows(m), m.Active) | |
| 294 | + | } | |
| 295 | + | // Empty named workspaces are not removed when left. | |
| 296 | + | m.FocusNumber(2) | |
| 297 | + | m.FocusNumber(1) | |
| 298 | + | if m.Workspaces[1].Name != "web" { | |
| 299 | + | t.Fatal(windows(m)) | |
| 300 | + | } | |
| 301 | + | m.Apply("workspace web") | |
| 302 | + | if m.Active != 1 { | |
| 303 | + | t.Fatal(m.Active) | |
| 304 | + | } | |
| 305 | + | m.Apply("workspace web") | |
| 306 | + | if m.Active != 0 { | |
| 307 | + | t.Fatal("toggle did not return", m.Active) | |
| 308 | + | } | |
| 309 | + | m.Apply("workspace nope") // unknown: nothing | |
| 310 | + | if m.Active != 0 { | |
| 311 | + | t.Fatal(m.Active) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | func TestNamedWorkspaceReload(t *testing.T) { | |
| 316 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"}) | |
| 317 | + | m.Apply("workspace dev") | |
| 318 | + | m.AddWindow(7) | |
| 319 | + | // Reload keeps windows and the workspace on screen; web becomes hidden. | |
| 320 | + | named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true}) | |
| 321 | + | if m.Current().Name != "dev" || len(m.Workspaces) != 1 { | |
| 322 | + | t.Fatal(m.Current().Name, windows(m)) | |
| 323 | + | } | |
| 324 | + | // Dropping dev from config hands its windows to the numbered workspace. | |
| 325 | + | named(m) | |
| 326 | + | if m.Current().Name != "" || !reflect.DeepEqual(windows(m), [][]WindowID{{7}, {}}) { | |
| 327 | + | t.Fatal(m.Current().Name, windows(m)) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func TestNamedWorkspaceSettings(t *testing.T) { | |
| 332 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed}) | |
| 333 | + | m.SetMaxColumns(1) | |
| 334 | + | if m.Current().MaxColumns != 1 || m.Current().Overflow != "" { | |
| 335 | + | t.Fatal(m.Current().MaxColumns, m.Current().Overflow) | |
| 336 | + | } | |
| 337 | + | m.Apply("workspace dev") | |
| 338 | + | if m.Current().MaxColumns != 3 || m.Current().Overflow != OverflowFixed { | |
| 339 | + | t.Fatal(m.Current().MaxColumns, m.Current().Overflow) | |
| 340 | + | } | |
| 341 | + | // New dynamic workspaces follow the defaults. | |
| 342 | + | m.SetOverflow(OverflowScroll) | |
| 343 | + | m.Apply("workspace dev") | |
| 344 | + | m.FocusNumber(9) | |
| 345 | + | if m.Current().MaxColumns != 1 || m.Current().Overflow != OverflowScroll { | |
| 346 | + | t.Fatal(m.Current().MaxColumns, m.Current().Overflow) | |
| 347 | + | } | |
| 348 | + | } | |
| @@ -239,3 +239,110 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||||
| 239 | t.Fatal(p) | 239 | t.Fatal(p) | ||
| 240 | } | 240 | } | ||
| 241 | } | 241 | } | ||
| 242 | + | ||||
| 243 | + | func named(m *Monitor, specs ...NamedWorkspace) *Monitor { | |||
| 244 | + | m.SetNamed(specs) | |||
| 245 | + | return m | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func TestHiddenWorkspaceToggle(t *testing.T) { | |||
| 249 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}) | |||
| 250 | + | m.AddWindow(1) | |||
| 251 | + | if len(m.Workspaces) != 2 { | |||
| 252 | + | t.Fatal("hidden workspace is numbered", windows(m)) | |||
| 253 | + | } | |||
| 254 | + | m.Apply("workspace dev") | |||
| 255 | + | if m.Current().Name != "dev" { | |||
| 256 | + | t.Fatal(m.Current().Name) | |||
| 257 | + | } | |||
| 258 | + | m.AddWindow(2) | |||
| 259 | + | // Up/down do not leave a hidden workspace. | |||
| 260 | + | m.Apply(ActionFocusWorkspaceDown) | |||
| 261 | + | m.Apply(ActionFocusWindowDown) | |||
| 262 | + | if m.Current().Name != "dev" { | |||
| 263 | + | t.Fatal("left the hidden workspace") | |||
| 264 | + | } | |||
| 265 | + | for _, p := range m.Layout() { | |||
| 266 | + | if hidden := p.ID == 1; p.Hidden != hidden { | |||
| 267 | + | t.Fatal(m.Layout()) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | // The same bind returns to the previous workspace. | |||
| 271 | + | m.Apply("workspace dev") | |||
| 272 | + | if id, _ := m.Focused(); id != 1 || m.Current().Name != "" { | |||
| 273 | + | t.Fatal(id, m.Current().Name) | |||
| 274 | + | } | |||
| 275 | + | // Cmd+N from the hidden workspace goes to that number. | |||
| 276 | + | m.Apply("workspace dev") | |||
| 277 | + | m.FocusNumber(1) | |||
| 278 | + | if id, _ := m.Focused(); id != 1 { | |||
| 279 | + | t.Fatal(id) | |||
| 280 | + | } | |||
| 281 | + | // Clicking a window of the hidden workspace shows it (FocusID). | |||
| 282 | + | if !m.FocusID(2) || m.Current().Name != "dev" { | |||
| 283 | + | t.Fatal("FocusID did not show the hidden workspace") | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestNamedWorkspaceIsNumberedAndKept(t *testing.T) { | |||
| 288 | + | m := monitor() | |||
| 289 | + | m.AddWindow(1) | |||
| 290 | + | named(m, NamedWorkspace{Name: "web"}) | |||
| 291 | + | // Inserted above the trailing empty workspace: [1:{1}] [2:web] [3:{}]. | |||
| 292 | + | if len(m.Workspaces) != 3 || m.Workspaces[1].Name != "web" || m.Active != 0 { | |||
| 293 | + | t.Fatal(windows(m), m.Active) | |||
| 294 | + | } | |||
| 295 | + | // Empty named workspaces are not removed when left. | |||
| 296 | + | m.FocusNumber(2) | |||
| 297 | + | m.FocusNumber(1) | |||
| 298 | + | if m.Workspaces[1].Name != "web" { | |||
| 299 | + | t.Fatal(windows(m)) | |||
| 300 | + | } | |||
| 301 | + | m.Apply("workspace web") | |||
| 302 | + | if m.Active != 1 { | |||
| 303 | + | t.Fatal(m.Active) | |||
| 304 | + | } | |||
| 305 | + | m.Apply("workspace web") | |||
| 306 | + | if m.Active != 0 { | |||
| 307 | + | t.Fatal("toggle did not return", m.Active) | |||
| 308 | + | } | |||
| 309 | + | m.Apply("workspace nope") // unknown: nothing | |||
| 310 | + | if m.Active != 0 { | |||
| 311 | + | t.Fatal(m.Active) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func TestNamedWorkspaceReload(t *testing.T) { | |||
| 316 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web"}) | |||
| 317 | + | m.Apply("workspace dev") | |||
| 318 | + | m.AddWindow(7) | |||
| 319 | + | // Reload keeps windows and the workspace on screen; web becomes hidden. | |||
| 320 | + | named(m, NamedWorkspace{Name: "dev", Hidden: true}, NamedWorkspace{Name: "web", Hidden: true}) | |||
| 321 | + | if m.Current().Name != "dev" || len(m.Workspaces) != 1 { | |||
| 322 | + | t.Fatal(m.Current().Name, windows(m)) | |||
| 323 | + | } | |||
| 324 | + | // Dropping dev from config hands its windows to the numbered workspace. | |||
| 325 | + | named(m) | |||
| 326 | + | if m.Current().Name != "" || !reflect.DeepEqual(windows(m), [][]WindowID{{7}, {}}) { | |||
| 327 | + | t.Fatal(m.Current().Name, windows(m)) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func TestNamedWorkspaceSettings(t *testing.T) { | |||
| 332 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true, MaxColumns: 3, Overflow: OverflowFixed}) | |||
| 333 | + | m.SetMaxColumns(1) | |||
| 334 | + | if m.Current().MaxColumns != 1 || m.Current().Overflow != "" { | |||
| 335 | + | t.Fatal(m.Current().MaxColumns, m.Current().Overflow) | |||
| 336 | + | } | |||
| 337 | + | m.Apply("workspace dev") | |||
| 338 | + | if m.Current().MaxColumns != 3 || m.Current().Overflow != OverflowFixed { | |||
| 339 | + | t.Fatal(m.Current().MaxColumns, m.Current().Overflow) | |||
| 340 | + | } | |||
| 341 | + | // New dynamic workspaces follow the defaults. | |||
| 342 | + | m.SetOverflow(OverflowScroll) | |||
| 343 | + | m.Apply("workspace dev") | |||
| 344 | + | m.FocusNumber(9) | |||
| 345 | + | if m.Current().MaxColumns != 1 || m.Current().Overflow != OverflowScroll { | |||
| 346 | + | t.Fatal(m.Current().MaxColumns, m.Current().Overflow) | |||
| 347 | + | } | |||
| 348 | + | } | |||
M internal/ports/config.go +16 -0
| @@ -38,7 +38,11 @@ type Config struct { | |||
| 38 | 38 | // MaxColumns is how many columns share the screen before scrolling. | |
| 39 | 39 | MaxColumns int | |
| 40 | 40 | Presets []string | |
| 41 | + | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | |
| 42 | + | Overflow string | |
| 41 | 43 | } | |
| 44 | + | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |
| 45 | + | Workspaces []WorkspaceConfig | |
| 42 | 46 | // Outputs selects and configures physical displays (drm backend). | |
| 43 | 47 | Outputs []OutputConfig | |
| 44 | 48 | Binds map[string]string | |
| @@ -50,3 +54,15 @@ type Config struct { | |||
| 50 | 54 | Debug []string | |
| 51 | 55 | } | |
| 52 | 56 | } | |
| 57 | + | ||
| 58 | + | // WorkspaceConfig declares a named workspace. Hidden ones are not numbered and | |
| 59 | + | // only reachable through a `workspace <name>` bind, which toggles them. | |
| 60 | + | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |
| 61 | + | // Monitor names the connector it belongs to (one monitor is supported so far). | |
| 62 | + | type WorkspaceConfig struct { | |
| 63 | + | Name string | |
| 64 | + | Monitor string | |
| 65 | + | Hidden bool | |
| 66 | + | MaxColumns int | |
| 67 | + | Overflow string | |
| 68 | + | } | |
| @@ -38,7 +38,11 @@ type Config struct { | |||||
| 38 | // MaxColumns is how many columns share the screen before scrolling. | 38 | // MaxColumns is how many columns share the screen before scrolling. | ||
| 39 | MaxColumns int | 39 | MaxColumns int | ||
| 40 | Presets []string | 40 | Presets []string | ||
| 41 | + | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | |||
| 42 | + | Overflow string | |||
| 41 | } | 43 | } | ||
| 44 | + | // Workspaces are declared with workspace.<name>.* keys, in first-seen order. | |||
| 45 | + | Workspaces []WorkspaceConfig | |||
| 42 | // Outputs selects and configures physical displays (drm backend). | 46 | // Outputs selects and configures physical displays (drm backend). | ||
| 43 | Outputs []OutputConfig | 47 | Outputs []OutputConfig | ||
| 44 | Binds map[string]string | 48 | Binds map[string]string | ||
| @@ -50,3 +54,15 @@ type Config struct { | |||||
| 50 | Debug []string | 54 | Debug []string | ||
| 51 | } | 55 | } | ||
| 52 | } | 56 | } | ||
| 57 | + | ||||
| 58 | + | // WorkspaceConfig declares a named workspace. Hidden ones are not numbered and | |||
| 59 | + | // only reachable through a `workspace <name>` bind, which toggles them. | |||
| 60 | + | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |||
| 61 | + | // Monitor names the connector it belongs to (one monitor is supported so far). | |||
| 62 | + | type WorkspaceConfig struct { | |||
| 63 | + | Name string | |||
| 64 | + | Monitor string | |||
| 65 | + | Hidden bool | |||
| 66 | + | MaxColumns int | |||
| 67 | + | Overflow string | |||
| 68 | + | } | |||