merge: custom-workspaces (named/hidden workspaces, toggle bind, fixed overflow spiral)
10 files · +789 -55
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 +93 -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 | |
| @@ -225,6 +251,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 225 | 251 | if err := scanner.Err(); err != nil { | |
| 226 | 252 | return c, raw, warnings, err | |
| 227 | 253 | } | |
| 254 | + | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 228 | 255 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | |
| 229 | 256 | // to the same physical key (cmd+code:2), so older configs keep working. | |
| 230 | 257 | for combo := range c.Binds { | |
| @@ -365,6 +392,11 @@ func set(c *ports.Config, key, v string) error { | |||
| 365 | 392 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | |
| 366 | 393 | case "layout.max-columns": | |
| 367 | 394 | return positive(&c.Layout.MaxColumns, v, 16) | |
| 395 | + | case "layout.overflow": | |
| 396 | + | if v != "scroll" && v != "fixed" { | |
| 397 | + | return fmt.Errorf("must be scroll or fixed") | |
| 398 | + | } | |
| 399 | + | c.Layout.Overflow = v | |
| 368 | 400 | case "layout.presets": | |
| 369 | 401 | list := splitList(v) | |
| 370 | 402 | if len(list) == 0 { | |
| @@ -401,6 +433,60 @@ func set(c *ports.Config, key, v string) error { | |||
| 401 | 433 | return nil | |
| 402 | 434 | } | |
| 403 | 435 | ||
| 436 | + | // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared | |
| 437 | + | // workspace (it does nothing) and a hidden workspace no bind reaches (its | |
| 438 | + | // windows could only come back by editing the config). | |
| 439 | + | func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |
| 440 | + | declared := map[string]bool{} | |
| 441 | + | for _, w := range c.Workspaces { | |
| 442 | + | declared[w.Name] = true | |
| 443 | + | } | |
| 444 | + | bound := map[string]bool{} | |
| 445 | + | var warnings []Warning | |
| 446 | + | for combo, a := range c.Binds { | |
| 447 | + | name, ok := strings.CutPrefix(a, "workspace ") | |
| 448 | + | if !ok { | |
| 449 | + | continue | |
| 450 | + | } | |
| 451 | + | name = strings.TrimSpace(name) | |
| 452 | + | bound[name] = true | |
| 453 | + | if !declared[name] { | |
| 454 | + | warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)}) | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | for _, w := range c.Workspaces { | |
| 458 | + | if w.Hidden && !bound[w.Name] { | |
| 459 | + | warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)}) | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |
| 463 | + | return warnings | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |
| 467 | + | ||
| 468 | + | // setWorkspace applies one workspace.<name>.<field> key. | |
| 469 | + | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |
| 470 | + | switch field { | |
| 471 | + | case "hidden": | |
| 472 | + | b, err := onOff(v) | |
| 473 | + | w.Hidden = b | |
| 474 | + | return err | |
| 475 | + | case "monitor": | |
| 476 | + | return fmt.Errorf("not supported yet: one monitor only") | |
| 477 | + | case "max-columns": | |
| 478 | + | return positive(&w.MaxColumns, v, 16) | |
| 479 | + | case "overflow": | |
| 480 | + | if v != "scroll" && v != "fixed" { | |
| 481 | + | return fmt.Errorf("must be scroll or fixed") | |
| 482 | + | } | |
| 483 | + | w.Overflow = v | |
| 484 | + | default: | |
| 485 | + | return fmt.Errorf("unknown key (hidden, max-columns, overflow)") | |
| 486 | + | } | |
| 487 | + | return nil | |
| 488 | + | } | |
| 489 | + | ||
| 404 | 490 | func positive(dst *int, v string, max int) error { | |
| 405 | 491 | n, err := strconv.Atoi(v) | |
| 406 | 492 | if err != nil || n <= 0 || n > max { | |
| @@ -448,6 +534,12 @@ func checkAction(v string) error { | |||
| 448 | 534 | } | |
| 449 | 535 | return nil | |
| 450 | 536 | } | |
| 537 | + | if rest, ok := strings.CutPrefix(v, "workspace "); ok { | |
| 538 | + | if !workspaceName.MatchString(strings.TrimSpace(rest)) { | |
| 539 | + | return fmt.Errorf("workspace needs a name (letters, digits, - or _)") | |
| 540 | + | } | |
| 541 | + | return nil | |
| 542 | + | } | |
| 451 | 543 | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | |
| 452 | 544 | if rest, ok := strings.CutPrefix(v, prefix); ok { | |
| 453 | 545 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | |
| @@ -457,7 +549,7 @@ func checkAction(v string) error { | |||
| 457 | 549 | } | |
| 458 | 550 | } | |
| 459 | 551 | if !actions[v] { | |
| 460 | - | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | |
| 552 | + | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) | |
| 461 | 553 | } | |
| 462 | 554 | return nil | |
| 463 | 555 | } | |
| @@ -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 | ||
| @@ -225,6 +251,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 225 | if err := scanner.Err(); err != nil { | 251 | if err := scanner.Err(); err != nil { | ||
| 226 | return c, raw, warnings, err | 252 | return c, raw, warnings, err | ||
| 227 | } | 253 | } | ||
| 254 | + | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |||
| 228 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | 255 | // A user bind on a digit (cmd+1, even "none") replaces the default bound | ||
| 229 | // to the same physical key (cmd+code:2), so older configs keep working. | 256 | // to the same physical key (cmd+code:2), so older configs keep working. | ||
| 230 | for combo := range c.Binds { | 257 | for combo := range c.Binds { | ||
| @@ -365,6 +392,11 @@ func set(c *ports.Config, key, v string) error { | |||||
| 365 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | 392 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | ||
| 366 | case "layout.max-columns": | 393 | case "layout.max-columns": | ||
| 367 | return positive(&c.Layout.MaxColumns, v, 16) | 394 | return positive(&c.Layout.MaxColumns, v, 16) | ||
| 395 | + | case "layout.overflow": | |||
| 396 | + | if v != "scroll" && v != "fixed" { | |||
| 397 | + | return fmt.Errorf("must be scroll or fixed") | |||
| 398 | + | } | |||
| 399 | + | c.Layout.Overflow = v | |||
| 368 | case "layout.presets": | 400 | case "layout.presets": | ||
| 369 | list := splitList(v) | 401 | list := splitList(v) | ||
| 370 | if len(list) == 0 { | 402 | if len(list) == 0 { | ||
| @@ -401,6 +433,60 @@ func set(c *ports.Config, key, v string) error { | |||||
| 401 | return nil | 433 | return nil | ||
| 402 | } | 434 | } | ||
| 403 | 435 | ||||
| 436 | + | // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared | |||
| 437 | + | // workspace (it does nothing) and a hidden workspace no bind reaches (its | |||
| 438 | + | // windows could only come back by editing the config). | |||
| 439 | + | func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 440 | + | declared := map[string]bool{} | |||
| 441 | + | for _, w := range c.Workspaces { | |||
| 442 | + | declared[w.Name] = true | |||
| 443 | + | } | |||
| 444 | + | bound := map[string]bool{} | |||
| 445 | + | var warnings []Warning | |||
| 446 | + | for combo, a := range c.Binds { | |||
| 447 | + | name, ok := strings.CutPrefix(a, "workspace ") | |||
| 448 | + | if !ok { | |||
| 449 | + | continue | |||
| 450 | + | } | |||
| 451 | + | name = strings.TrimSpace(name) | |||
| 452 | + | bound[name] = true | |||
| 453 | + | if !declared[name] { | |||
| 454 | + | warnings = append(warnings, Warning{Line: seen["bind."+combo], Msg: fmt.Sprintf("bind.%s: no workspace.%s.* declared", combo, name)}) | |||
| 455 | + | } | |||
| 456 | + | } | |||
| 457 | + | for _, w := range c.Workspaces { | |||
| 458 | + | if w.Hidden && !bound[w.Name] { | |||
| 459 | + | warnings = append(warnings, Warning{Line: seen["workspace."+w.Name+".hidden"], Msg: fmt.Sprintf("workspace.%s.hidden: no bind shows it (add bind.<keys> = workspace %s)", w.Name, w.Name)}) | |||
| 460 | + | } | |||
| 461 | + | } | |||
| 462 | + | sort.Slice(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |||
| 463 | + | return warnings | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) | |||
| 467 | + | ||||
| 468 | + | // setWorkspace applies one workspace.<name>.<field> key. | |||
| 469 | + | func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||
| 470 | + | switch field { | |||
| 471 | + | case "hidden": | |||
| 472 | + | b, err := onOff(v) | |||
| 473 | + | w.Hidden = b | |||
| 474 | + | return err | |||
| 475 | + | case "monitor": | |||
| 476 | + | return fmt.Errorf("not supported yet: one monitor only") | |||
| 477 | + | case "max-columns": | |||
| 478 | + | return positive(&w.MaxColumns, v, 16) | |||
| 479 | + | case "overflow": | |||
| 480 | + | if v != "scroll" && v != "fixed" { | |||
| 481 | + | return fmt.Errorf("must be scroll or fixed") | |||
| 482 | + | } | |||
| 483 | + | w.Overflow = v | |||
| 484 | + | default: | |||
| 485 | + | return fmt.Errorf("unknown key (hidden, max-columns, overflow)") | |||
| 486 | + | } | |||
| 487 | + | return nil | |||
| 488 | + | } | |||
| 489 | + | ||||
| 404 | func positive(dst *int, v string, max int) error { | 490 | func positive(dst *int, v string, max int) error { | ||
| 405 | n, err := strconv.Atoi(v) | 491 | n, err := strconv.Atoi(v) | ||
| 406 | if err != nil || n <= 0 || n > max { | 492 | if err != nil || n <= 0 || n > max { | ||
| @@ -448,6 +534,12 @@ func checkAction(v string) error { | |||||
| 448 | } | 534 | } | ||
| 449 | return nil | 535 | return nil | ||
| 450 | } | 536 | } | ||
| 537 | + | if rest, ok := strings.CutPrefix(v, "workspace "); ok { | |||
| 538 | + | if !workspaceName.MatchString(strings.TrimSpace(rest)) { | |||
| 539 | + | return fmt.Errorf("workspace needs a name (letters, digits, - or _)") | |||
| 540 | + | } | |||
| 541 | + | return nil | |||
| 542 | + | } | |||
| 451 | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | 543 | for _, prefix := range []string{"focus-workspace ", "move-to-workspace "} { | ||
| 452 | if rest, ok := strings.CutPrefix(v, prefix); ok { | 544 | if rest, ok := strings.CutPrefix(v, prefix); ok { | ||
| 453 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | 545 | if n, err := strconv.Atoi(strings.TrimSpace(rest)); err != nil || n < 1 || n > 99 { | ||
| @@ -457,7 +549,7 @@ func checkAction(v string) error { | |||||
| 457 | } | 549 | } | ||
| 458 | } | 550 | } | ||
| 459 | if !actions[v] { | 551 | if !actions[v] { | ||
| 460 | - | return fmt.Errorf(`unknown action %q (or "spawn <command>")`, v) | 552 | + | return fmt.Errorf(`unknown action %q (or "spawn <command>", "workspace <name>")`, v) |
| 461 | } | 553 | } | ||
| 462 | return nil | 554 | return nil | ||
| 463 | } | 555 | } | ||
M internal/adapters/config/config_test.go +34 -0
| @@ -233,3 +233,37 @@ 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 | + | bind.cmd+x = workspace typo | |
| 249 | + | workspace.lost.hidden = on | |
| 250 | + | `) | |
| 251 | + | want := []ports.WorkspaceConfig{ | |
| 252 | + | {Name: "dev", Hidden: true, MaxColumns: 3}, | |
| 253 | + | {Name: "web", Overflow: "scroll"}, | |
| 254 | + | {Name: "lost", Hidden: true}, | |
| 255 | + | } | |
| 256 | + | if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) { | |
| 257 | + | t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces) | |
| 258 | + | } | |
| 259 | + | if c.Binds["Cmd+d"] != "workspace dev" { | |
| 260 | + | t.Fatal(c.Binds["Cmd+d"]) | |
| 261 | + | } | |
| 262 | + | lines := []int{} | |
| 263 | + | for _, x := range w { | |
| 264 | + | lines = append(lines, x.Line) | |
| 265 | + | } | |
| 266 | + | if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) { | |
| 267 | + | t.Fatal(w) | |
| 268 | + | } | |
| 269 | + | } | |
| @@ -233,3 +233,37 @@ 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 | + | bind.cmd+x = workspace typo | |||
| 249 | + | workspace.lost.hidden = on | |||
| 250 | + | `) | |||
| 251 | + | want := []ports.WorkspaceConfig{ | |||
| 252 | + | {Name: "dev", Hidden: true, MaxColumns: 3}, | |||
| 253 | + | {Name: "web", Overflow: "scroll"}, | |||
| 254 | + | {Name: "lost", Hidden: true}, | |||
| 255 | + | } | |||
| 256 | + | if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) { | |||
| 257 | + | t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces) | |||
| 258 | + | } | |||
| 259 | + | if c.Binds["Cmd+d"] != "workspace dev" { | |||
| 260 | + | t.Fatal(c.Binds["Cmd+d"]) | |||
| 261 | + | } | |||
| 262 | + | lines := []int{} | |||
| 263 | + | for _, x := range w { | |||
| 264 | + | lines = append(lines, x.Line) | |||
| 265 | + | } | |||
| 266 | + | if !reflect.DeepEqual(lines, []int{5, 6, 7, 8, 10, 11, 12}) { | |||
| 267 | + | t.Fatal(w) | |||
| 268 | + | } | |||
| 269 | + | } | |||
M internal/core/action.go +26 -8
| @@ -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,23 +98,27 @@ 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 | - | if m.Active > 0 { | |
| 114 | + | if m.shown == nil && m.Active > 0 { | |
| 99 | 115 | m.MoveToWorkspace(m.Active - 1) | |
| 100 | 116 | } | |
| 101 | 117 | return Effect{} | |
| 102 | 118 | case ActionMoveToWorkspaceDown: | |
| 103 | - | m.MoveToWorkspace(m.Active + 1) | |
| 119 | + | if m.shown == nil { | |
| 120 | + | m.MoveToWorkspace(m.Active + 1) | |
| 121 | + | } | |
| 104 | 122 | return Effect{} | |
| 105 | 123 | } | |
| 106 | 124 | return m.Current().Apply(a) | |
| @@ -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,23 +98,27 @@ 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.shown == nil && m.Active > 0 { |
| 99 | m.MoveToWorkspace(m.Active - 1) | 115 | m.MoveToWorkspace(m.Active - 1) | ||
| 100 | } | 116 | } | ||
| 101 | return Effect{} | 117 | return Effect{} | ||
| 102 | case ActionMoveToWorkspaceDown: | 118 | case ActionMoveToWorkspaceDown: | ||
| 103 | - | m.MoveToWorkspace(m.Active + 1) | 119 | + | if m.shown == nil { |
| 120 | + | m.MoveToWorkspace(m.Active + 1) | |||
| 121 | + | } | |||
| 104 | return Effect{} | 122 | return Effect{} | ||
| 105 | } | 123 | } | ||
| 106 | return m.Current().Apply(a) | 124 | return m.Current().Apply(a) | ||
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 +78 -14
| @@ -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 | |
| @@ -126,7 +142,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||
| 126 | 142 | return | |
| 127 | 143 | } | |
| 128 | 144 | at := 0 | |
| 129 | - | if len(w.Columns) > 0 { | |
| 145 | + | if w.Overflow == OverflowFixed { | |
| 146 | + | // The spiral depends only on window order: new windows go last. | |
| 147 | + | at = len(w.Columns) | |
| 148 | + | } else if len(w.Columns) > 0 { | |
| 130 | 149 | at = w.Focus + 1 | |
| 131 | 150 | } | |
| 132 | 151 | w.Columns = append(w.Columns, Column{}) | |
| @@ -311,7 +330,8 @@ func (w *Workspace) columnWidth(i int) int { | |||
| 311 | 330 | return w.Output.W | |
| 312 | 331 | } | |
| 313 | 332 | g := w.gap() | |
| 314 | - | if w.Columns[i].Width == (Width{}) { | |
| 333 | + | // Fixed overflow never scrolls, so presets would push columns off screen. | |
| 334 | + | if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed { | |
| 315 | 335 | if len(w.Columns) == 1 { | |
| 316 | 336 | return max(w.Usable.W-2*g, 0) | |
| 317 | 337 | } | |
| @@ -329,6 +349,10 @@ func (w *Workspace) columnX(i int) int { | |||
| 329 | 349 | return x | |
| 330 | 350 | } | |
| 331 | 351 | func (w *Workspace) scroll() { | |
| 352 | + | if w.Overflow == OverflowFixed { | |
| 353 | + | w.ViewX = 0 | |
| 354 | + | return | |
| 355 | + | } | |
| 332 | 356 | if len(w.Columns) == 0 { | |
| 333 | 357 | return | |
| 334 | 358 | } | |
| @@ -346,39 +370,79 @@ func (w *Workspace) scroll() { | |||
| 346 | 370 | w.ViewX += left + width - maxX | |
| 347 | 371 | } | |
| 348 | 372 | } | |
| 373 | + | ||
| 374 | + | // columnRects returns each column's area on screen, before stacking windows. | |
| 375 | + | func (w *Workspace) columnRects() []Rect { | |
| 376 | + | g := w.gap() | |
| 377 | + | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 378 | + | rects := make([]Rect, len(w.Columns)) | |
| 379 | + | for i := range w.Columns { | |
| 380 | + | rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h} | |
| 381 | + | } | |
| 382 | + | k := max(w.MaxColumns, 1) | |
| 383 | + | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |
| 384 | + | return rects | |
| 385 | + | } | |
| 386 | + | // The last slot holds the spiral; the columns before it keep their place. | |
| 387 | + | area := rects[k-1] | |
| 388 | + | for i := k - 1; i < len(w.Columns)-1; i++ { | |
| 389 | + | rects[i], area = split(area, g, (i-k+1)%2 == 0) | |
| 390 | + | } | |
| 391 | + | rects[len(rects)-1] = area | |
| 392 | + | return rects | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | |
| 396 | + | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | |
| 397 | + | if vertical { | |
| 398 | + | h := max((r.H-gap)/2, 0) | |
| 399 | + | 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)} | |
| 400 | + | } | |
| 401 | + | w := max((r.W-gap)/2, 0) | |
| 402 | + | 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} | |
| 403 | + | } | |
| 404 | + | ||
| 349 | 405 | func (w *Workspace) Layout() []Placement { | |
| 350 | 406 | var result []Placement | |
| 351 | 407 | gap := w.gap() | |
| 408 | + | cols := w.columnRects() | |
| 352 | 409 | for i, c := range w.Columns { | |
| 353 | - | x := w.columnX(i) - w.ViewX | |
| 354 | - | width := w.columnWidth(i) | |
| 410 | + | col := cols[i] | |
| 355 | 411 | n := len(c.Windows) | |
| 356 | 412 | if n == 0 { | |
| 357 | 413 | continue | |
| 358 | 414 | } | |
| 359 | 415 | fullColumn := w.fullscreenColumn(i) | |
| 360 | - | available := max(w.Usable.H-(n+1)*gap, 0) | |
| 416 | + | if fullColumn { | |
| 417 | + | col.W = w.Output.W | |
| 418 | + | } | |
| 419 | + | available := max(col.H-(n-1)*gap, 0) | |
| 361 | 420 | height := available / n | |
| 362 | - | y := w.Usable.Y + gap | |
| 421 | + | y := col.Y | |
| 422 | + | bottom := col.Y + col.H | |
| 363 | 423 | for j, id := range c.Windows { | |
| 364 | 424 | h := height | |
| 365 | 425 | if j == n-1 { | |
| 366 | 426 | h = available - height*(n-1) | |
| 367 | 427 | } | |
| 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} | |
| 428 | + | y = min(y, bottom) | |
| 429 | + | h = min(h, bottom-y) | |
| 430 | + | r := Rect{X: col.X, Y: y, W: col.W, H: h} | |
| 373 | 431 | full := w.fullscreen == id && id != 0 | |
| 374 | - | hidden := fullColumn && !full | |
| 432 | + | // Fixed overflow keeps every column on screen: hide them all | |
| 433 | + | // behind a fullscreen window. | |
| 434 | + | hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full | |
| 375 | 435 | if full { | |
| 376 | - | r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H} | |
| 436 | + | // Scroll mode aligns the view on the column; fixed never scrolls. | |
| 437 | + | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} | |
| 438 | + | if w.Overflow == OverflowFixed { | |
| 439 | + | r.X = 0 | |
| 440 | + | } | |
| 377 | 441 | } | |
| 378 | 442 | if hidden { | |
| 379 | 443 | r = Rect{} | |
| 380 | 444 | } | |
| 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}) | |
| 445 | + | 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 && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) | |
| 382 | 446 | y += h + gap | |
| 383 | 447 | } | |
| 384 | 448 | } | |
| @@ -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 | ||
| @@ -126,7 +142,10 @@ func (w *Workspace) AddWindow(id WindowID) { | |||||
| 126 | return | 142 | return | ||
| 127 | } | 143 | } | ||
| 128 | at := 0 | 144 | at := 0 | ||
| 129 | - | if len(w.Columns) > 0 { | 145 | + | if w.Overflow == OverflowFixed { |
| 146 | + | // The spiral depends only on window order: new windows go last. | |||
| 147 | + | at = len(w.Columns) | |||
| 148 | + | } else if len(w.Columns) > 0 { | |||
| 130 | at = w.Focus + 1 | 149 | at = w.Focus + 1 | ||
| 131 | } | 150 | } | ||
| 132 | w.Columns = append(w.Columns, Column{}) | 151 | w.Columns = append(w.Columns, Column{}) | ||
| @@ -311,7 +330,8 @@ func (w *Workspace) columnWidth(i int) int { | |||||
| 311 | return w.Output.W | 330 | return w.Output.W | ||
| 312 | } | 331 | } | ||
| 313 | g := w.gap() | 332 | g := w.gap() | ||
| 314 | - | if w.Columns[i].Width == (Width{}) { | 333 | + | // Fixed overflow never scrolls, so presets would push columns off screen. |
| 334 | + | if w.Columns[i].Width == (Width{}) || w.Overflow == OverflowFixed { | |||
| 315 | if len(w.Columns) == 1 { | 335 | if len(w.Columns) == 1 { | ||
| 316 | return max(w.Usable.W-2*g, 0) | 336 | return max(w.Usable.W-2*g, 0) | ||
| 317 | } | 337 | } | ||
| @@ -329,6 +349,10 @@ func (w *Workspace) columnX(i int) int { | |||||
| 329 | return x | 349 | return x | ||
| 330 | } | 350 | } | ||
| 331 | func (w *Workspace) scroll() { | 351 | func (w *Workspace) scroll() { | ||
| 352 | + | if w.Overflow == OverflowFixed { | |||
| 353 | + | w.ViewX = 0 | |||
| 354 | + | return | |||
| 355 | + | } | |||
| 332 | if len(w.Columns) == 0 { | 356 | if len(w.Columns) == 0 { | ||
| 333 | return | 357 | return | ||
| 334 | } | 358 | } | ||
| @@ -346,39 +370,79 @@ func (w *Workspace) scroll() { | |||||
| 346 | w.ViewX += left + width - maxX | 370 | w.ViewX += left + width - maxX | ||
| 347 | } | 371 | } | ||
| 348 | } | 372 | } | ||
| 373 | + | ||||
| 374 | + | // columnRects returns each column's area on screen, before stacking windows. | |||
| 375 | + | func (w *Workspace) columnRects() []Rect { | |||
| 376 | + | g := w.gap() | |||
| 377 | + | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |||
| 378 | + | rects := make([]Rect, len(w.Columns)) | |||
| 379 | + | for i := range w.Columns { | |||
| 380 | + | rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h} | |||
| 381 | + | } | |||
| 382 | + | k := max(w.MaxColumns, 1) | |||
| 383 | + | if w.Overflow != OverflowFixed || len(w.Columns) <= k { | |||
| 384 | + | return rects | |||
| 385 | + | } | |||
| 386 | + | // The last slot holds the spiral; the columns before it keep their place. | |||
| 387 | + | area := rects[k-1] | |||
| 388 | + | for i := k - 1; i < len(w.Columns)-1; i++ { | |||
| 389 | + | rects[i], area = split(area, g, (i-k+1)%2 == 0) | |||
| 390 | + | } | |||
| 391 | + | rects[len(rects)-1] = area | |||
| 392 | + | return rects | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // split cuts r in two halves with a gap: top/bottom when vertical, else left/right. | |||
| 396 | + | func split(r Rect, gap int, vertical bool) (Rect, Rect) { | |||
| 397 | + | if vertical { | |||
| 398 | + | h := max((r.H-gap)/2, 0) | |||
| 399 | + | 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)} | |||
| 400 | + | } | |||
| 401 | + | w := max((r.W-gap)/2, 0) | |||
| 402 | + | 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} | |||
| 403 | + | } | |||
| 404 | + | ||||
| 349 | func (w *Workspace) Layout() []Placement { | 405 | func (w *Workspace) Layout() []Placement { | ||
| 350 | var result []Placement | 406 | var result []Placement | ||
| 351 | gap := w.gap() | 407 | gap := w.gap() | ||
| 408 | + | cols := w.columnRects() | |||
| 352 | for i, c := range w.Columns { | 409 | for i, c := range w.Columns { | ||
| 353 | - | x := w.columnX(i) - w.ViewX | 410 | + | col := cols[i] |
| 354 | - | width := w.columnWidth(i) | |||
| 355 | n := len(c.Windows) | 411 | n := len(c.Windows) | ||
| 356 | if n == 0 { | 412 | if n == 0 { | ||
| 357 | continue | 413 | continue | ||
| 358 | } | 414 | } | ||
| 359 | fullColumn := w.fullscreenColumn(i) | 415 | fullColumn := w.fullscreenColumn(i) | ||
| 360 | - | available := max(w.Usable.H-(n+1)*gap, 0) | 416 | + | if fullColumn { |
| 417 | + | col.W = w.Output.W | |||
| 418 | + | } | |||
| 419 | + | available := max(col.H-(n-1)*gap, 0) | |||
| 361 | height := available / n | 420 | height := available / n | ||
| 362 | - | y := w.Usable.Y + gap | 421 | + | y := col.Y |
| 422 | + | bottom := col.Y + col.H | |||
| 363 | for j, id := range c.Windows { | 423 | for j, id := range c.Windows { | ||
| 364 | h := height | 424 | h := height | ||
| 365 | if j == n-1 { | 425 | if j == n-1 { | ||
| 366 | h = available - height*(n-1) | 426 | h = available - height*(n-1) | ||
| 367 | } | 427 | } | ||
| 368 | - | if y > w.Usable.Y+w.Usable.H { | 428 | + | y = min(y, bottom) |
| 369 | - | y = w.Usable.Y + w.Usable.H | 429 | + | h = min(h, bottom-y) |
| 370 | - | } | 430 | + | 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 | 431 | full := w.fullscreen == id && id != 0 | ||
| 374 | - | hidden := fullColumn && !full | 432 | + | // Fixed overflow keeps every column on screen: hide them all |
| 433 | + | // behind a fullscreen window. | |||
| 434 | + | hidden := (fullColumn || w.Overflow == OverflowFixed && w.fullscreen != 0) && !full | |||
| 375 | if full { | 435 | if full { | ||
| 376 | - | r = Rect{X: x, Y: 0, W: w.Output.W, H: w.Output.H} | 436 | + | // Scroll mode aligns the view on the column; fixed never scrolls. |
| 437 | + | r = Rect{X: col.X, Y: 0, W: w.Output.W, H: w.Output.H} | |||
| 438 | + | if w.Overflow == OverflowFixed { | |||
| 439 | + | r.X = 0 | |||
| 440 | + | } | |||
| 377 | } | 441 | } | ||
| 378 | if hidden { | 442 | if hidden { | ||
| 379 | r = Rect{} | 443 | r = Rect{} | ||
| 380 | } | 444 | } | ||
| 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}) | 445 | + | 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 && (w.Overflow != OverflowFixed || len(w.Columns) == 1)}) |
| 382 | y += h + gap | 446 | y += h + gap | ||
| 383 | } | 447 | } | ||
| 384 | } | 448 | } | ||
M internal/core/layout_test.go +99 -0
| @@ -350,3 +350,102 @@ 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 | + | } | |
| 362 | + | got := map[WindowID]Rect{} | |
| 363 | + | for _, p := range w.Layout() { | |
| 364 | + | got[p.ID] = p.Rect | |
| 365 | + | } | |
| 366 | + | want := map[WindowID]Rect{ | |
| 367 | + | 1: {X: 0, Y: 0, W: 50, H: 80}, | |
| 368 | + | 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2 | |
| 369 | + | 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half | |
| 370 | + | 4: {X: 75, Y: 40, W: 25, H: 20}, | |
| 371 | + | 5: {X: 75, Y: 60, W: 25, H: 20}, | |
| 372 | + | } | |
| 373 | + | if !reflect.DeepEqual(got, want) { | |
| 374 | + | t.Fatalf("got %v\nwant %v", got, want) | |
| 375 | + | } | |
| 376 | + | if w.ViewX != 0 { | |
| 377 | + | t.Fatal("fixed overflow scrolled", w.ViewX) | |
| 378 | + | } | |
| 379 | + | // Up to max-columns it is the plain equal split. | |
| 380 | + | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |
| 381 | + | w2.SetOutput(100, 80) | |
| 382 | + | w2.AddWindow(1) | |
| 383 | + | w2.AddWindow(2) | |
| 384 | + | if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) { | |
| 385 | + | t.Fatal(p) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func fixedWorkspace(maxCols, gaps, n int) *Workspace { | |
| 390 | + | w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps} | |
| 391 | + | w.SetOutput(100, 80) | |
| 392 | + | for id := 1; id <= n; id++ { | |
| 393 | + | w.AddWindow(WindowID(id)) | |
| 394 | + | } | |
| 395 | + | return w | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func placements(w *Workspace) map[WindowID]Placement { | |
| 399 | + | got := map[WindowID]Placement{} | |
| 400 | + | for _, p := range w.Layout() { | |
| 401 | + | got[p.ID] = p | |
| 402 | + | } | |
| 403 | + | return got | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | func TestFixedOverflowEdgeCases(t *testing.T) { | |
| 407 | + | t.Run("new windows go last whatever the focus", func(t *testing.T) { | |
| 408 | + | w := fixedWorkspace(2, 0, 3) | |
| 409 | + | w.FocusID(1) | |
| 410 | + | w.AddWindow(4) | |
| 411 | + | if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 { | |
| 412 | + | t.Fatal(got) | |
| 413 | + | } | |
| 414 | + | }) | |
| 415 | + | for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot | |
| 416 | + | t.Run("fullscreen covers the output", func(t *testing.T) { | |
| 417 | + | w := fixedWorkspace(2, 4, 4) | |
| 418 | + | w.FocusID(col) | |
| 419 | + | w.ToggleFullscreen() | |
| 420 | + | for id, p := range placements(w) { | |
| 421 | + | if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) { | |
| 422 | + | t.Fatal(p) | |
| 423 | + | } | |
| 424 | + | if id != col && !p.Hidden { | |
| 425 | + | t.Fatalf("window %d visible over fullscreen: %+v", id, p) | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | }) | |
| 429 | + | } | |
| 430 | + | t.Run("presets ignored", func(t *testing.T) { | |
| 431 | + | w := fixedWorkspace(2, 0, 2) | |
| 432 | + | w.SetPresets([]Width{{Num: 2, Den: 3}}) | |
| 433 | + | w.CycleWidth() | |
| 434 | + | w.FocusColumn(-1) | |
| 435 | + | w.CycleWidth() | |
| 436 | + | for id, p := range placements(w) { | |
| 437 | + | if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 { | |
| 438 | + | t.Fatal(id, p) | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | }) | |
| 442 | + | t.Run("gaps between spiral halves", func(t *testing.T) { | |
| 443 | + | got := placements(fixedWorkspace(1, 4, 2)) | |
| 444 | + | if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) { | |
| 445 | + | t.Fatal(got[1].Rect, got[2].Rect) | |
| 446 | + | } | |
| 447 | + | if got[1].Borderless || got[2].Borderless { | |
| 448 | + | t.Fatal("spiral windows must keep borders to show focus") | |
| 449 | + | } | |
| 450 | + | }) | |
| 451 | + | } | |
| @@ -350,3 +350,102 @@ 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 | + | } | |||
| 362 | + | got := map[WindowID]Rect{} | |||
| 363 | + | for _, p := range w.Layout() { | |||
| 364 | + | got[p.ID] = p.Rect | |||
| 365 | + | } | |||
| 366 | + | want := map[WindowID]Rect{ | |||
| 367 | + | 1: {X: 0, Y: 0, W: 50, H: 80}, | |||
| 368 | + | 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2 | |||
| 369 | + | 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half | |||
| 370 | + | 4: {X: 75, Y: 40, W: 25, H: 20}, | |||
| 371 | + | 5: {X: 75, Y: 60, W: 25, H: 20}, | |||
| 372 | + | } | |||
| 373 | + | if !reflect.DeepEqual(got, want) { | |||
| 374 | + | t.Fatalf("got %v\nwant %v", got, want) | |||
| 375 | + | } | |||
| 376 | + | if w.ViewX != 0 { | |||
| 377 | + | t.Fatal("fixed overflow scrolled", w.ViewX) | |||
| 378 | + | } | |||
| 379 | + | // Up to max-columns it is the plain equal split. | |||
| 380 | + | w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed} | |||
| 381 | + | w2.SetOutput(100, 80) | |||
| 382 | + | w2.AddWindow(1) | |||
| 383 | + | w2.AddWindow(2) | |||
| 384 | + | if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) { | |||
| 385 | + | t.Fatal(p) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func fixedWorkspace(maxCols, gaps, n int) *Workspace { | |||
| 390 | + | w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps} | |||
| 391 | + | w.SetOutput(100, 80) | |||
| 392 | + | for id := 1; id <= n; id++ { | |||
| 393 | + | w.AddWindow(WindowID(id)) | |||
| 394 | + | } | |||
| 395 | + | return w | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func placements(w *Workspace) map[WindowID]Placement { | |||
| 399 | + | got := map[WindowID]Placement{} | |||
| 400 | + | for _, p := range w.Layout() { | |||
| 401 | + | got[p.ID] = p | |||
| 402 | + | } | |||
| 403 | + | return got | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestFixedOverflowEdgeCases(t *testing.T) { | |||
| 407 | + | t.Run("new windows go last whatever the focus", func(t *testing.T) { | |||
| 408 | + | w := fixedWorkspace(2, 0, 3) | |||
| 409 | + | w.FocusID(1) | |||
| 410 | + | w.AddWindow(4) | |||
| 411 | + | if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 { | |||
| 412 | + | t.Fatal(got) | |||
| 413 | + | } | |||
| 414 | + | }) | |||
| 415 | + | for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot | |||
| 416 | + | t.Run("fullscreen covers the output", func(t *testing.T) { | |||
| 417 | + | w := fixedWorkspace(2, 4, 4) | |||
| 418 | + | w.FocusID(col) | |||
| 419 | + | w.ToggleFullscreen() | |||
| 420 | + | for id, p := range placements(w) { | |||
| 421 | + | if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) { | |||
| 422 | + | t.Fatal(p) | |||
| 423 | + | } | |||
| 424 | + | if id != col && !p.Hidden { | |||
| 425 | + | t.Fatalf("window %d visible over fullscreen: %+v", id, p) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | }) | |||
| 429 | + | } | |||
| 430 | + | t.Run("presets ignored", func(t *testing.T) { | |||
| 431 | + | w := fixedWorkspace(2, 0, 2) | |||
| 432 | + | w.SetPresets([]Width{{Num: 2, Den: 3}}) | |||
| 433 | + | w.CycleWidth() | |||
| 434 | + | w.FocusColumn(-1) | |||
| 435 | + | w.CycleWidth() | |||
| 436 | + | for id, p := range placements(w) { | |||
| 437 | + | if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 { | |||
| 438 | + | t.Fatal(id, p) | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | }) | |||
| 442 | + | t.Run("gaps between spiral halves", func(t *testing.T) { | |||
| 443 | + | got := placements(fixedWorkspace(1, 4, 2)) | |||
| 444 | + | if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) { | |||
| 445 | + | t.Fatal(got[1].Rect, got[2].Rect) | |||
| 446 | + | } | |||
| 447 | + | if got[1].Borderless || got[2].Borderless { | |||
| 448 | + | t.Fatal("spiral windows must keep borders to show focus") | |||
| 449 | + | } | |||
| 450 | + | }) | |||
| 451 | + | } | |||
M internal/core/monitor.go +219 -29
| @@ -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 | |
| @@ -81,18 +160,6 @@ func (m *Monitor) RemoveWindow(id WindowID) { | |||
| 81 | 160 | } | |
| 82 | 161 | } | |
| 83 | 162 | ||
| 84 | - | // FocusID shows the window's workspace and focuses it. | |
| 85 | - | func (m *Monitor) FocusID(id WindowID) bool { | |
| 86 | - | w, i := m.find(id) | |
| 87 | - | if w == nil { | |
| 88 | - | return false | |
| 89 | - | } | |
| 90 | - | m.Active = i | |
| 91 | - | w.FocusID(id) | |
| 92 | - | m.normalize() | |
| 93 | - | return true | |
| 94 | - | } | |
| 95 | - | ||
| 96 | 163 | // SetFullscreen applies a client request on the window's own workspace. It does | |
| 97 | 164 | // not switch workspaces: client requests never move the user. | |
| 98 | 165 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |
| @@ -101,12 +168,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||
| 101 | 168 | } | |
| 102 | 169 | } | |
| 103 | 170 | ||
| 104 | - | // MoveToWorkspace moves the focused window to workspace index i; focus stays here. | |
| 171 | + | // MoveToWorkspace moves the focused window to numbered workspace index i; | |
| 172 | + | // focus stays here. | |
| 105 | 173 | func (m *Monitor) MoveToWorkspace(i int) { | |
| 106 | 174 | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 107 | 175 | cur := m.Current() | |
| 108 | 176 | id, ok := cur.Focused() | |
| 109 | - | if !ok || i == m.Active { | |
| 177 | + | if !ok || m.Workspaces[i] == cur { | |
| 110 | 178 | return | |
| 111 | 179 | } | |
| 112 | 180 | cur.RemoveWindow(id) | |
| @@ -116,11 +184,12 @@ func (m *Monitor) MoveToWorkspace(i int) { | |||
| 116 | 184 | ||
| 117 | 185 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | |
| 118 | 186 | ||
| 119 | - | // Layout places every window: the active workspace on screen, others hidden. | |
| 187 | + | // Layout places every window: the workspace on screen laid out, others hidden. | |
| 120 | 188 | func (m *Monitor) Layout() []Placement { | |
| 121 | 189 | var result []Placement | |
| 122 | - | for i, w := range m.Workspaces { | |
| 123 | - | if i == m.Active { | |
| 190 | + | cur := m.Current() | |
| 191 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| 192 | + | if w == cur { | |
| 124 | 193 | result = append(result, w.Layout()...) | |
| 125 | 194 | continue | |
| 126 | 195 | } | |
| @@ -140,6 +209,9 @@ func (m *Monitor) each(f func(*Workspace)) { | |||
| 140 | 209 | for _, w := range m.Workspaces { | |
| 141 | 210 | f(w) | |
| 142 | 211 | } | |
| 212 | + | for _, w := range m.hidden { | |
| 213 | + | f(w) | |
| 214 | + | } | |
| 143 | 215 | } | |
| 144 | 216 | func (m *Monitor) SetOutput(width, height int) { | |
| 145 | 217 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | |
| @@ -147,4 +219,122 @@ func (m *Monitor) SetOutput(width, height int) { | |||
| 147 | 219 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | |
| 148 | 220 | func (m *Monitor) SetGaps(g int) { m.each(func(w *Workspace) { w.SetGaps(g) }) } | |
| 149 | 221 | 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) }) } | |
| 222 | + | ||
| 223 | + | // SetMaxColumns sets the default; named workspaces may override it. | |
| 224 | + | func (m *Monitor) SetMaxColumns(n int) { | |
| 225 | + | m.template.MaxColumns = n | |
| 226 | + | m.applyNamed() | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // SetOverflow sets the default; named workspaces may override it. | |
| 230 | + | func (m *Monitor) SetOverflow(o Overflow) { | |
| 231 | + | m.template.Overflow = o | |
| 232 | + | m.applyNamed() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // applyNamed gives every workspace the defaults or its named overrides. | |
| 236 | + | func (m *Monitor) applyNamed() { | |
| 237 | + | specs := map[string]NamedWorkspace{} | |
| 238 | + | for _, s := range m.named { | |
| 239 | + | specs[s.Name] = s | |
| 240 | + | } | |
| 241 | + | m.each(func(w *Workspace) { | |
| 242 | + | s := specs[w.Name] | |
| 243 | + | w.Overflow = m.template.Overflow | |
| 244 | + | if s.Overflow != "" { | |
| 245 | + | w.Overflow = s.Overflow | |
| 246 | + | } | |
| 247 | + | w.SetMaxColumns(m.template.MaxColumns) | |
| 248 | + | if s.MaxColumns > 0 { | |
| 249 | + | w.SetMaxColumns(s.MaxColumns) | |
| 250 | + | } | |
| 251 | + | }) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // SetNamed applies the named workspaces from config. New numbered ones are | |
| 255 | + | // inserted above the trailing empty workspace (never before the first one). | |
| 256 | + | // A workspace dropped from config becomes a normal one; a dropped hidden one | |
| 257 | + | // hands its windows to the active numbered workspace. The workspace on | |
| 258 | + | // screen stays on screen. | |
| 259 | + | func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |
| 260 | + | cur := m.Current() | |
| 261 | + | want := map[string]NamedWorkspace{} | |
| 262 | + | for _, s := range specs { | |
| 263 | + | want[s.Name] = s | |
| 264 | + | } | |
| 265 | + | byName := map[string]*Workspace{} | |
| 266 | + | for _, w := range m.Workspaces { | |
| 267 | + | if _, ok := want[w.Name]; w.Name != "" && !ok { | |
| 268 | + | w.Name = "" | |
| 269 | + | } | |
| 270 | + | if w.Name != "" { | |
| 271 | + | byName[w.Name] = w | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | var hidden []*Workspace | |
| 275 | + | for _, w := range m.hidden { | |
| 276 | + | if _, ok := want[w.Name]; ok { | |
| 277 | + | byName[w.Name] = w | |
| 278 | + | hidden = append(hidden, w) | |
| 279 | + | continue | |
| 280 | + | } | |
| 281 | + | if w == cur { | |
| 282 | + | cur = nil | |
| 283 | + | } | |
| 284 | + | for _, c := range w.Columns { | |
| 285 | + | for _, id := range c.Windows { | |
| 286 | + | m.Workspaces[m.Active].AddWindow(id) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | m.hidden = hidden | |
| 291 | + | m.named = append([]NamedWorkspace(nil), specs...) | |
| 292 | + | var nextHidden []*Workspace | |
| 293 | + | for _, s := range specs { | |
| 294 | + | w := byName[s.Name] | |
| 295 | + | if w == nil { | |
| 296 | + | w = m.newWorkspace() | |
| 297 | + | w.Name = s.Name | |
| 298 | + | } | |
| 299 | + | i := indexOf(m.Workspaces, w) | |
| 300 | + | switch { | |
| 301 | + | case s.Hidden: | |
| 302 | + | if i >= 0 { | |
| 303 | + | m.removeNumbered(i) | |
| 304 | + | } | |
| 305 | + | nextHidden = append(nextHidden, w) | |
| 306 | + | case i < 0: | |
| 307 | + | at := max(len(m.Workspaces)-1, 1) | |
| 308 | + | m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...) | |
| 309 | + | if at <= m.Active { | |
| 310 | + | m.Active++ | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | m.hidden = nextHidden | |
| 315 | + | if m.back != nil && !m.has(m.back) { | |
| 316 | + | m.back = nil | |
| 317 | + | } | |
| 318 | + | m.shown = nil | |
| 319 | + | switch { | |
| 320 | + | case cur == nil: | |
| 321 | + | case indexOf(m.hidden, cur) >= 0: | |
| 322 | + | m.shown = cur | |
| 323 | + | case indexOf(m.Workspaces, cur) >= 0: | |
| 324 | + | m.Active = indexOf(m.Workspaces, cur) | |
| 325 | + | } | |
| 326 | + | m.applyNamed() | |
| 327 | + | m.normalize() | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // removeNumbered drops Workspaces[i] and keeps Active on the same workspace | |
| 331 | + | // when possible. | |
| 332 | + | func (m *Monitor) removeNumbered(i int) { | |
| 333 | + | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |
| 334 | + | if i < m.Active || m.Active >= len(m.Workspaces) { | |
| 335 | + | m.Active = max(m.Active-1, 0) | |
| 336 | + | } | |
| 337 | + | if len(m.Workspaces) == 0 { | |
| 338 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |
| 339 | + | } | |
| 340 | + | } | |
| @@ -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 | ||
| @@ -81,18 +160,6 @@ func (m *Monitor) RemoveWindow(id WindowID) { | |||||
| 81 | } | 160 | } | ||
| 82 | } | 161 | } | ||
| 83 | 162 | ||||
| 84 | - | // FocusID shows the window's workspace and focuses it. | |||
| 85 | - | func (m *Monitor) FocusID(id WindowID) bool { | |||
| 86 | - | w, i := m.find(id) | |||
| 87 | - | if w == nil { | |||
| 88 | - | return false | |||
| 89 | - | } | |||
| 90 | - | m.Active = i | |||
| 91 | - | w.FocusID(id) | |||
| 92 | - | m.normalize() | |||
| 93 | - | return true | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | // SetFullscreen applies a client request on the window's own workspace. It does | 163 | // SetFullscreen applies a client request on the window's own workspace. It does | ||
| 97 | // not switch workspaces: client requests never move the user. | 164 | // not switch workspaces: client requests never move the user. | ||
| 98 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | 165 | func (m *Monitor) SetFullscreen(id WindowID, on bool) { | ||
| @@ -101,12 +168,13 @@ func (m *Monitor) SetFullscreen(id WindowID, on bool) { | |||||
| 101 | } | 168 | } | ||
| 102 | } | 169 | } | ||
| 103 | 170 | ||||
| 104 | - | // MoveToWorkspace moves the focused window to workspace index i; focus stays here. | 171 | + | // MoveToWorkspace moves the focused window to numbered workspace index i; |
| 172 | + | // focus stays here. | |||
| 105 | func (m *Monitor) MoveToWorkspace(i int) { | 173 | func (m *Monitor) MoveToWorkspace(i int) { | ||
| 106 | i = min(max(i, 0), len(m.Workspaces)-1) | 174 | i = min(max(i, 0), len(m.Workspaces)-1) | ||
| 107 | cur := m.Current() | 175 | cur := m.Current() | ||
| 108 | id, ok := cur.Focused() | 176 | id, ok := cur.Focused() | ||
| 109 | - | if !ok || i == m.Active { | 177 | + | if !ok || m.Workspaces[i] == cur { |
| 110 | return | 178 | return | ||
| 111 | } | 179 | } | ||
| 112 | cur.RemoveWindow(id) | 180 | cur.RemoveWindow(id) | ||
| @@ -116,11 +184,12 @@ func (m *Monitor) MoveToWorkspace(i int) { | |||||
| 116 | 184 | ||||
| 117 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | 185 | func (m *Monitor) Focused() (WindowID, bool) { return m.Current().Focused() } | ||
| 118 | 186 | ||||
| 119 | - | // Layout places every window: the active workspace on screen, others hidden. | 187 | + | // Layout places every window: the workspace on screen laid out, others hidden. |
| 120 | func (m *Monitor) Layout() []Placement { | 188 | func (m *Monitor) Layout() []Placement { | ||
| 121 | var result []Placement | 189 | var result []Placement | ||
| 122 | - | for i, w := range m.Workspaces { | 190 | + | cur := m.Current() |
| 123 | - | if i == m.Active { | 191 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { |
| 192 | + | if w == cur { | |||
| 124 | result = append(result, w.Layout()...) | 193 | result = append(result, w.Layout()...) | ||
| 125 | continue | 194 | continue | ||
| 126 | } | 195 | } | ||
| @@ -140,6 +209,9 @@ func (m *Monitor) each(f func(*Workspace)) { | |||||
| 140 | for _, w := range m.Workspaces { | 209 | for _, w := range m.Workspaces { | ||
| 141 | f(w) | 210 | f(w) | ||
| 142 | } | 211 | } | ||
| 212 | + | for _, w := range m.hidden { | |||
| 213 | + | f(w) | |||
| 214 | + | } | |||
| 143 | } | 215 | } | ||
| 144 | func (m *Monitor) SetOutput(width, height int) { | 216 | func (m *Monitor) SetOutput(width, height int) { | ||
| 145 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | 217 | m.each(func(w *Workspace) { w.SetOutput(width, height) }) | ||
| @@ -147,4 +219,122 @@ func (m *Monitor) SetOutput(width, height int) { | |||||
| 147 | func (m *Monitor) SetUsable(r Rect) { m.each(func(w *Workspace) { w.SetUsable(r) }) } | 219 | 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) }) } | 220 | 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) }) } | 221 | 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) }) } | 222 | + | |
| 223 | + | // SetMaxColumns sets the default; named workspaces may override it. | |||
| 224 | + | func (m *Monitor) SetMaxColumns(n int) { | |||
| 225 | + | m.template.MaxColumns = n | |||
| 226 | + | m.applyNamed() | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // SetOverflow sets the default; named workspaces may override it. | |||
| 230 | + | func (m *Monitor) SetOverflow(o Overflow) { | |||
| 231 | + | m.template.Overflow = o | |||
| 232 | + | m.applyNamed() | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // applyNamed gives every workspace the defaults or its named overrides. | |||
| 236 | + | func (m *Monitor) applyNamed() { | |||
| 237 | + | specs := map[string]NamedWorkspace{} | |||
| 238 | + | for _, s := range m.named { | |||
| 239 | + | specs[s.Name] = s | |||
| 240 | + | } | |||
| 241 | + | m.each(func(w *Workspace) { | |||
| 242 | + | s := specs[w.Name] | |||
| 243 | + | w.Overflow = m.template.Overflow | |||
| 244 | + | if s.Overflow != "" { | |||
| 245 | + | w.Overflow = s.Overflow | |||
| 246 | + | } | |||
| 247 | + | w.SetMaxColumns(m.template.MaxColumns) | |||
| 248 | + | if s.MaxColumns > 0 { | |||
| 249 | + | w.SetMaxColumns(s.MaxColumns) | |||
| 250 | + | } | |||
| 251 | + | }) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // SetNamed applies the named workspaces from config. New numbered ones are | |||
| 255 | + | // inserted above the trailing empty workspace (never before the first one). | |||
| 256 | + | // A workspace dropped from config becomes a normal one; a dropped hidden one | |||
| 257 | + | // hands its windows to the active numbered workspace. The workspace on | |||
| 258 | + | // screen stays on screen. | |||
| 259 | + | func (m *Monitor) SetNamed(specs []NamedWorkspace) { | |||
| 260 | + | cur := m.Current() | |||
| 261 | + | want := map[string]NamedWorkspace{} | |||
| 262 | + | for _, s := range specs { | |||
| 263 | + | want[s.Name] = s | |||
| 264 | + | } | |||
| 265 | + | byName := map[string]*Workspace{} | |||
| 266 | + | for _, w := range m.Workspaces { | |||
| 267 | + | if _, ok := want[w.Name]; w.Name != "" && !ok { | |||
| 268 | + | w.Name = "" | |||
| 269 | + | } | |||
| 270 | + | if w.Name != "" { | |||
| 271 | + | byName[w.Name] = w | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | var hidden []*Workspace | |||
| 275 | + | for _, w := range m.hidden { | |||
| 276 | + | if _, ok := want[w.Name]; ok { | |||
| 277 | + | byName[w.Name] = w | |||
| 278 | + | hidden = append(hidden, w) | |||
| 279 | + | continue | |||
| 280 | + | } | |||
| 281 | + | if w == cur { | |||
| 282 | + | cur = nil | |||
| 283 | + | } | |||
| 284 | + | for _, c := range w.Columns { | |||
| 285 | + | for _, id := range c.Windows { | |||
| 286 | + | m.Workspaces[m.Active].AddWindow(id) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | m.hidden = hidden | |||
| 291 | + | m.named = append([]NamedWorkspace(nil), specs...) | |||
| 292 | + | var nextHidden []*Workspace | |||
| 293 | + | for _, s := range specs { | |||
| 294 | + | w := byName[s.Name] | |||
| 295 | + | if w == nil { | |||
| 296 | + | w = m.newWorkspace() | |||
| 297 | + | w.Name = s.Name | |||
| 298 | + | } | |||
| 299 | + | i := indexOf(m.Workspaces, w) | |||
| 300 | + | switch { | |||
| 301 | + | case s.Hidden: | |||
| 302 | + | if i >= 0 { | |||
| 303 | + | m.removeNumbered(i) | |||
| 304 | + | } | |||
| 305 | + | nextHidden = append(nextHidden, w) | |||
| 306 | + | case i < 0: | |||
| 307 | + | at := max(len(m.Workspaces)-1, 1) | |||
| 308 | + | m.Workspaces = append(m.Workspaces[:at], append([]*Workspace{w}, m.Workspaces[at:]...)...) | |||
| 309 | + | if at <= m.Active { | |||
| 310 | + | m.Active++ | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | m.hidden = nextHidden | |||
| 315 | + | if m.back != nil && !m.has(m.back) { | |||
| 316 | + | m.back = nil | |||
| 317 | + | } | |||
| 318 | + | m.shown = nil | |||
| 319 | + | switch { | |||
| 320 | + | case cur == nil: | |||
| 321 | + | case indexOf(m.hidden, cur) >= 0: | |||
| 322 | + | m.shown = cur | |||
| 323 | + | case indexOf(m.Workspaces, cur) >= 0: | |||
| 324 | + | m.Active = indexOf(m.Workspaces, cur) | |||
| 325 | + | } | |||
| 326 | + | m.applyNamed() | |||
| 327 | + | m.normalize() | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // removeNumbered drops Workspaces[i] and keeps Active on the same workspace | |||
| 331 | + | // when possible. | |||
| 332 | + | func (m *Monitor) removeNumbered(i int) { | |||
| 333 | + | m.Workspaces = append(m.Workspaces[:i], m.Workspaces[i+1:]...) | |||
| 334 | + | if i < m.Active || m.Active >= len(m.Workspaces) { | |||
| 335 | + | m.Active = max(m.Active-1, 0) | |||
| 336 | + | } | |||
| 337 | + | if len(m.Workspaces) == 0 { | |||
| 338 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) | |||
| 339 | + | } | |||
| 340 | + | } | |||
M internal/core/monitor_test.go +181 -3
| @@ -118,9 +118,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) { | |||
| 118 | 118 | t.Fatal(m.Layout()) | |
| 119 | 119 | } | |
| 120 | 120 | } | |
| 121 | - | if !m.FocusID(1) || m.Active != 0 { | |
| 122 | - | t.Fatal(m.Active) | |
| 123 | - | } | |
| 124 | 121 | } | |
| 125 | 122 | ||
| 126 | 123 | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | |
| @@ -239,3 +236,184 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||
| 239 | 236 | t.Fatal(p) | |
| 240 | 237 | } | |
| 241 | 238 | } | |
| 239 | + | ||
| 240 | + | func named(m *Monitor, specs ...NamedWorkspace) *Monitor { | |
| 241 | + | m.SetNamed(specs) | |
| 242 | + | return m | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestHiddenWorkspaceToggle(t *testing.T) { | |
| 246 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}) | |
| 247 | + | m.AddWindow(1) | |
| 248 | + | if len(m.Workspaces) != 2 { | |
| 249 | + | t.Fatal("hidden workspace is numbered", windows(m)) | |
| 250 | + | } | |
| 251 | + | m.Apply("workspace dev") | |
| 252 | + | if m.Current().Name != "dev" { | |
| 253 | + | t.Fatal(m.Current().Name) | |
| 254 | + | } | |
| 255 | + | m.AddWindow(2) | |
| 256 | + | // Up/down do not leave a hidden workspace. | |
| 257 | + | m.Apply(ActionFocusWorkspaceDown) | |
| 258 | + | m.Apply(ActionFocusWindowDown) | |
| 259 | + | if m.Current().Name != "dev" { | |
| 260 | + | t.Fatal("left the hidden workspace") | |
| 261 | + | } | |
| 262 | + | for _, p := range m.Layout() { | |
| 263 | + | if hidden := p.ID == 1; p.Hidden != hidden { | |
| 264 | + | t.Fatal(m.Layout()) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | // The same bind returns to the previous workspace. | |
| 268 | + | m.Apply("workspace dev") | |
| 269 | + | if id, _ := m.Focused(); id != 1 || m.Current().Name != "" { | |
| 270 | + | t.Fatal(id, m.Current().Name) | |
| 271 | + | } | |
| 272 | + | // Cmd+N from the hidden workspace goes to that number. | |
| 273 | + | m.Apply("workspace dev") | |
| 274 | + | m.FocusNumber(1) | |
| 275 | + | if id, _ := m.Focused(); id != 1 { | |
| 276 | + | t.Fatal(id) | |
| 277 | + | } | |
| 278 | + | // Moving a window up/down from a hidden workspace does nothing. | |
| 279 | + | m.Apply("workspace dev") | |
| 280 | + | m.Apply(ActionMoveToWorkspaceDown) | |
| 281 | + | m.Apply(ActionMoveToWorkspaceUp) | |
| 282 | + | if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" { | |
| 283 | + | t.Fatal(id, windows(m)) | |
| 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 | + | } | |
| 349 | + | ||
| 350 | + | // checkInvariants fails when the monitor is in a state no action should reach. | |
| 351 | + | func checkInvariants(t *testing.T, m *Monitor, want []WindowID) { | |
| 352 | + | t.Helper() | |
| 353 | + | if m.Active < 0 || m.Active >= len(m.Workspaces) { | |
| 354 | + | t.Fatalf("active %d of %d", m.Active, len(m.Workspaces)) | |
| 355 | + | } | |
| 356 | + | if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" { | |
| 357 | + | t.Fatal("no trailing empty unnamed workspace") | |
| 358 | + | } | |
| 359 | + | if m.shown != nil && indexOf(m.hidden, m.shown) < 0 { | |
| 360 | + | t.Fatal("shown is not a hidden workspace") | |
| 361 | + | } | |
| 362 | + | if m.back != nil && !m.has(m.back) { | |
| 363 | + | t.Fatal("back dangles") | |
| 364 | + | } | |
| 365 | + | seen := map[WindowID]int{} | |
| 366 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |
| 367 | + | for _, c := range w.Columns { | |
| 368 | + | for _, id := range c.Windows { | |
| 369 | + | seen[id]++ | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | for _, id := range want { | |
| 374 | + | if seen[id] != 1 { | |
| 375 | + | t.Fatalf("window %d present %d times", id, seen[id]) | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | if len(seen) != len(want) { | |
| 379 | + | t.Fatalf("windows %v, want %v", seen, want) | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestSetNamedTransitions(t *testing.T) { | |
| 384 | + | dev := NamedWorkspace{Name: "dev"} | |
| 385 | + | devHidden := NamedWorkspace{Name: "dev", Hidden: true} | |
| 386 | + | web := NamedWorkspace{Name: "web"} | |
| 387 | + | for _, tc := range []struct { | |
| 388 | + | name string | |
| 389 | + | before []NamedWorkspace | |
| 390 | + | after []NamedWorkspace | |
| 391 | + | onDev bool // show dev before the reload | |
| 392 | + | want string // workspace name on screen after | |
| 393 | + | }{ | |
| 394 | + | {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"}, | |
| 395 | + | {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"}, | |
| 396 | + | {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""}, | |
| 397 | + | {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""}, | |
| 398 | + | {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""}, | |
| 399 | + | {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""}, | |
| 400 | + | } { | |
| 401 | + | t.Run(tc.name, func(t *testing.T) { | |
| 402 | + | m := named(monitor(), tc.before...) | |
| 403 | + | m.AddWindow(1) | |
| 404 | + | if tc.onDev { | |
| 405 | + | m.Apply("workspace dev") | |
| 406 | + | } | |
| 407 | + | m.AddWindow(2) | |
| 408 | + | checkInvariants(t, m, []WindowID{1, 2}) | |
| 409 | + | m.SetNamed(tc.after) | |
| 410 | + | checkInvariants(t, m, []WindowID{1, 2}) | |
| 411 | + | if got := m.Current().Name; got != tc.want { | |
| 412 | + | t.Fatalf("on %q, want %q", got, tc.want) | |
| 413 | + | } | |
| 414 | + | if id, _ := m.Focused(); id != 2 { | |
| 415 | + | t.Fatalf("focus moved to %d", id) | |
| 416 | + | } | |
| 417 | + | }) | |
| 418 | + | } | |
| 419 | + | } | |
| @@ -118,9 +118,6 @@ func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) { | |||||
| 118 | t.Fatal(m.Layout()) | 118 | t.Fatal(m.Layout()) | ||
| 119 | } | 119 | } | ||
| 120 | } | 120 | } | ||
| 121 | - | if !m.FocusID(1) || m.Active != 0 { | |||
| 122 | - | t.Fatal(m.Active) | |||
| 123 | - | } | |||
| 124 | } | 121 | } | ||
| 125 | 122 | ||||
| 126 | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | 123 | func TestMonitorFullscreenRequestStaysPut(t *testing.T) { | ||
| @@ -239,3 +236,184 @@ func TestLonePresetColumnKeepsWidth(t *testing.T) { | |||||
| 239 | t.Fatal(p) | 236 | t.Fatal(p) | ||
| 240 | } | 237 | } | ||
| 241 | } | 238 | } | ||
| 239 | + | ||||
| 240 | + | func named(m *Monitor, specs ...NamedWorkspace) *Monitor { | |||
| 241 | + | m.SetNamed(specs) | |||
| 242 | + | return m | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestHiddenWorkspaceToggle(t *testing.T) { | |||
| 246 | + | m := named(monitor(), NamedWorkspace{Name: "dev", Hidden: true}) | |||
| 247 | + | m.AddWindow(1) | |||
| 248 | + | if len(m.Workspaces) != 2 { | |||
| 249 | + | t.Fatal("hidden workspace is numbered", windows(m)) | |||
| 250 | + | } | |||
| 251 | + | m.Apply("workspace dev") | |||
| 252 | + | if m.Current().Name != "dev" { | |||
| 253 | + | t.Fatal(m.Current().Name) | |||
| 254 | + | } | |||
| 255 | + | m.AddWindow(2) | |||
| 256 | + | // Up/down do not leave a hidden workspace. | |||
| 257 | + | m.Apply(ActionFocusWorkspaceDown) | |||
| 258 | + | m.Apply(ActionFocusWindowDown) | |||
| 259 | + | if m.Current().Name != "dev" { | |||
| 260 | + | t.Fatal("left the hidden workspace") | |||
| 261 | + | } | |||
| 262 | + | for _, p := range m.Layout() { | |||
| 263 | + | if hidden := p.ID == 1; p.Hidden != hidden { | |||
| 264 | + | t.Fatal(m.Layout()) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | // The same bind returns to the previous workspace. | |||
| 268 | + | m.Apply("workspace dev") | |||
| 269 | + | if id, _ := m.Focused(); id != 1 || m.Current().Name != "" { | |||
| 270 | + | t.Fatal(id, m.Current().Name) | |||
| 271 | + | } | |||
| 272 | + | // Cmd+N from the hidden workspace goes to that number. | |||
| 273 | + | m.Apply("workspace dev") | |||
| 274 | + | m.FocusNumber(1) | |||
| 275 | + | if id, _ := m.Focused(); id != 1 { | |||
| 276 | + | t.Fatal(id) | |||
| 277 | + | } | |||
| 278 | + | // Moving a window up/down from a hidden workspace does nothing. | |||
| 279 | + | m.Apply("workspace dev") | |||
| 280 | + | m.Apply(ActionMoveToWorkspaceDown) | |||
| 281 | + | m.Apply(ActionMoveToWorkspaceUp) | |||
| 282 | + | if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" { | |||
| 283 | + | t.Fatal(id, windows(m)) | |||
| 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 | + | } | |||
| 349 | + | ||||
| 350 | + | // checkInvariants fails when the monitor is in a state no action should reach. | |||
| 351 | + | func checkInvariants(t *testing.T, m *Monitor, want []WindowID) { | |||
| 352 | + | t.Helper() | |||
| 353 | + | if m.Active < 0 || m.Active >= len(m.Workspaces) { | |||
| 354 | + | t.Fatalf("active %d of %d", m.Active, len(m.Workspaces)) | |||
| 355 | + | } | |||
| 356 | + | if last := m.Workspaces[len(m.Workspaces)-1]; !last.empty() || last.Name != "" { | |||
| 357 | + | t.Fatal("no trailing empty unnamed workspace") | |||
| 358 | + | } | |||
| 359 | + | if m.shown != nil && indexOf(m.hidden, m.shown) < 0 { | |||
| 360 | + | t.Fatal("shown is not a hidden workspace") | |||
| 361 | + | } | |||
| 362 | + | if m.back != nil && !m.has(m.back) { | |||
| 363 | + | t.Fatal("back dangles") | |||
| 364 | + | } | |||
| 365 | + | seen := map[WindowID]int{} | |||
| 366 | + | for _, w := range append(append([]*Workspace(nil), m.Workspaces...), m.hidden...) { | |||
| 367 | + | for _, c := range w.Columns { | |||
| 368 | + | for _, id := range c.Windows { | |||
| 369 | + | seen[id]++ | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | for _, id := range want { | |||
| 374 | + | if seen[id] != 1 { | |||
| 375 | + | t.Fatalf("window %d present %d times", id, seen[id]) | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | if len(seen) != len(want) { | |||
| 379 | + | t.Fatalf("windows %v, want %v", seen, want) | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func TestSetNamedTransitions(t *testing.T) { | |||
| 384 | + | dev := NamedWorkspace{Name: "dev"} | |||
| 385 | + | devHidden := NamedWorkspace{Name: "dev", Hidden: true} | |||
| 386 | + | web := NamedWorkspace{Name: "web"} | |||
| 387 | + | for _, tc := range []struct { | |||
| 388 | + | name string | |||
| 389 | + | before []NamedWorkspace | |||
| 390 | + | after []NamedWorkspace | |||
| 391 | + | onDev bool // show dev before the reload | |||
| 392 | + | want string // workspace name on screen after | |||
| 393 | + | }{ | |||
| 394 | + | {"numbered active becomes hidden", []NamedWorkspace{dev}, []NamedWorkspace{devHidden}, true, "dev"}, | |||
| 395 | + | {"hidden shown becomes numbered", []NamedWorkspace{devHidden}, []NamedWorkspace{dev}, true, "dev"}, | |||
| 396 | + | {"rename drops the old name", []NamedWorkspace{dev}, []NamedWorkspace{web}, true, ""}, | |||
| 397 | + | {"hidden shown is removed", []NamedWorkspace{devHidden}, nil, true, ""}, | |||
| 398 | + | {"hidden not shown is removed", []NamedWorkspace{devHidden, web}, []NamedWorkspace{web}, false, ""}, | |||
| 399 | + | {"add while on numbered", nil, []NamedWorkspace{devHidden, web}, false, ""}, | |||
| 400 | + | } { | |||
| 401 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 402 | + | m := named(monitor(), tc.before...) | |||
| 403 | + | m.AddWindow(1) | |||
| 404 | + | if tc.onDev { | |||
| 405 | + | m.Apply("workspace dev") | |||
| 406 | + | } | |||
| 407 | + | m.AddWindow(2) | |||
| 408 | + | checkInvariants(t, m, []WindowID{1, 2}) | |||
| 409 | + | m.SetNamed(tc.after) | |||
| 410 | + | checkInvariants(t, m, []WindowID{1, 2}) | |||
| 411 | + | if got := m.Current().Name; got != tc.want { | |||
| 412 | + | t.Fatalf("on %q, want %q", got, tc.want) | |||
| 413 | + | } | |||
| 414 | + | if id, _ := m.Focused(); id != 2 { | |||
| 415 | + | t.Fatalf("focus moved to %d", id) | |||
| 416 | + | } | |||
| 417 | + | }) | |||
| 418 | + | } | |||
| 419 | + | } | |||
M internal/ports/config.go +14 -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,13 @@ 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 | + | type WorkspaceConfig struct { | |
| 62 | + | Name string | |
| 63 | + | Hidden bool | |
| 64 | + | MaxColumns int | |
| 65 | + | Overflow string | |
| 66 | + | } | |
| @@ -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,13 @@ 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 | + | type WorkspaceConfig struct { | |||
| 62 | + | Name string | |||
| 63 | + | Hidden bool | |||
| 64 | + | MaxColumns int | |||
| 65 | + | Overflow string | |||
| 66 | + | } | |||