feat(config): per-output layout rules (#7)
* feat(config): per-output layout rules via layout.<output>.max-columns and .overflow * fix(layout): apply per-output rules on monitor change and in slot width warnings
- commit
- f7d8fc2903adb7db028a0191ca39d7ff47b79f2c
- author
- bnema · · 2026-09-27 06:03 UTC
- committer
- GitHub · · 2026-09-27 06:03 UTC
- parent
- 75c4828
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
8 files · +221 -36
M docs/config.md +2 -1
| @@ -25,11 +25,12 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 25 | 25 | | `layout.gaps` | `0` | Space between windows | | |
| 26 | 26 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | |
| 27 | 27 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | |
| 28 | + | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |
| 28 | 29 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` | | |
| 29 | 30 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 30 | 31 | | `workspace.<name>.hidden` | `off` | Reachable only through its bind, which toggles it | | |
| 31 | 32 | | `workspace.<name>.monitor` | focused | Home monitor: connector (`DP-2`) or monitor key | | |
| 32 | - | | `workspace.<name>.max-columns`, `.overflow` | layout values | Per-workspace layout | | |
| 33 | + | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | | |
| 33 | 34 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | |
| 34 | 35 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | |
| 35 | 36 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | |
| @@ -25,11 +25,12 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 25 | | `layout.gaps` | `0` | Space between windows | | 25 | | `layout.gaps` | `0` | Space between windows | | ||
| 26 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | 26 | | `layout.max-columns` | `2` | Columns that share the screen before scrolling | | ||
| 27 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | 27 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | ||
| 28 | + | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |||
| 28 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` | | 29 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` | | ||
| 29 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 30 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 30 | | `workspace.<name>.hidden` | `off` | Reachable only through its bind, which toggles it | | 31 | | `workspace.<name>.hidden` | `off` | Reachable only through its bind, which toggles it | | ||
| 31 | | `workspace.<name>.monitor` | focused | Home monitor: connector (`DP-2`) or monitor key | | 32 | | `workspace.<name>.monitor` | focused | Home monitor: connector (`DP-2`) or monitor key | | ||
| 32 | - | | `workspace.<name>.max-columns`, `.overflow` | layout values | Per-workspace layout | | 33 | + | | `workspace.<name>.max-columns`, `.overflow` | screen values | Per-workspace layout | |
| 33 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | 34 | | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` | | ||
| 34 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | 35 | | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` | | ||
| 35 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | 36 | | `output.<name>.scale` | `1` | 1 to 4, e.g. `1.5` or `4/3` | | ||
M internal/adapters/config/config.go +51 -16
| @@ -464,12 +464,9 @@ func set(c *ports.Config, key, v string) error { | |||
| 464 | 464 | case "layout.default-width": | |
| 465 | 465 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | |
| 466 | 466 | case "layout.max-columns": | |
| 467 | - | return positive(&c.Layout.MaxColumns, v, 16) | |
| 467 | + | return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v) | |
| 468 | 468 | case "layout.overflow": | |
| 469 | - | if v != "scroll" && v != "fixed" { | |
| 470 | - | return fmt.Errorf("must be scroll or fixed") | |
| 471 | - | } | |
| 472 | - | c.Layout.Overflow = v | |
| 469 | + | return setLayoutRule(&c.Layout.LayoutRules, "overflow", v) | |
| 473 | 470 | case "layout.presets": | |
| 474 | 471 | list := splitList(v) | |
| 475 | 472 | if len(list) == 0 { | |
| @@ -519,11 +516,50 @@ func set(c *ports.Config, key, v string) error { | |||
| 519 | 516 | } | |
| 520 | 517 | c.Log.Debug = list | |
| 521 | 518 | default: | |
| 519 | + | if rest, ok := strings.CutPrefix(key, "layout."); ok { | |
| 520 | + | if i := strings.LastIndex(rest, "."); i > 0 { | |
| 521 | + | return setOutputLayout(c, rest[:i], rest[i+1:], v) | |
| 522 | + | } | |
| 523 | + | } | |
| 522 | 524 | return fmt.Errorf("unknown key") | |
| 523 | 525 | } | |
| 524 | 526 | return nil | |
| 525 | 527 | } | |
| 526 | 528 | ||
| 529 | + | // setOutputLayout applies one layout.<output>.<field> key. | |
| 530 | + | func setOutputLayout(c *ports.Config, output, field, v string) error { | |
| 531 | + | i := slices.IndexFunc(c.Layout.Outputs, func(o ports.OutputLayout) bool { return o.Output == output }) | |
| 532 | + | o := ports.OutputLayout{Output: output} | |
| 533 | + | if i >= 0 { | |
| 534 | + | o = c.Layout.Outputs[i] | |
| 535 | + | } | |
| 536 | + | if err := setLayoutRule(&o.LayoutRules, field, v); err != nil { | |
| 537 | + | return err | |
| 538 | + | } | |
| 539 | + | if i < 0 { | |
| 540 | + | c.Layout.Outputs = append(c.Layout.Outputs, o) | |
| 541 | + | } else { | |
| 542 | + | c.Layout.Outputs[i] = o | |
| 543 | + | } | |
| 544 | + | return nil | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // setLayoutRule applies max-columns or overflow, for layout.*, | |
| 548 | + | // layout.<output>.* and workspace.<name>.*. | |
| 549 | + | func setLayoutRule(r *ports.LayoutRules, field, v string) error { | |
| 550 | + | switch field { | |
| 551 | + | case "max-columns": | |
| 552 | + | return positive(&r.MaxColumns, v, 16) | |
| 553 | + | case "overflow": | |
| 554 | + | if v != "scroll" && v != "fixed" { | |
| 555 | + | return fmt.Errorf("must be scroll or fixed") | |
| 556 | + | } | |
| 557 | + | r.Overflow = v | |
| 558 | + | return nil | |
| 559 | + | } | |
| 560 | + | return fmt.Errorf("unknown key (max-columns, overflow)") | |
| 561 | + | } | |
| 562 | + | ||
| 527 | 563 | // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared | |
| 528 | 564 | // workspace (it does nothing) and a hidden workspace no bind reaches (its | |
| 529 | 565 | // windows could only come back by editing the config). | |
| @@ -546,11 +582,15 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||
| 546 | 582 | } | |
| 547 | 583 | } | |
| 548 | 584 | for _, w := range c.Workspaces { | |
| 549 | - | overflow := w.Overflow | |
| 550 | - | if overflow == "" { | |
| 551 | - | overflow = c.Layout.Overflow | |
| 585 | + | // The config only knows the monitor as written: a connector home | |
| 586 | + | // does not match a key rule, and the reverse. | |
| 587 | + | rules := c.Layout.LayoutRules | |
| 588 | + | for _, o := range c.Layout.Outputs { | |
| 589 | + | if w.Monitor != "" && o.Output == w.Monitor { | |
| 590 | + | rules = o.Over(rules) | |
| 591 | + | } | |
| 552 | 592 | } | |
| 553 | - | if overflow == "fixed" && len(w.Slots) > 0 { | |
| 593 | + | if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 { | |
| 554 | 594 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | |
| 555 | 595 | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)}) | |
| 556 | 596 | } | |
| @@ -576,13 +616,8 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||
| 576 | 616 | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | |
| 577 | 617 | } | |
| 578 | 618 | w.Monitor = v | |
| 579 | - | case "max-columns": | |
| 580 | - | return positive(&w.MaxColumns, v, 16) | |
| 581 | - | case "overflow": | |
| 582 | - | if v != "scroll" && v != "fixed" { | |
| 583 | - | return fmt.Errorf("must be scroll or fixed") | |
| 584 | - | } | |
| 585 | - | w.Overflow = v | |
| 619 | + | case "max-columns", "overflow": | |
| 620 | + | return setLayoutRule(&w.LayoutRules, field, v) | |
| 586 | 621 | default: | |
| 587 | 622 | n, ok := strings.CutPrefix(field, "column.") | |
| 588 | 623 | if !ok { | |
| @@ -464,12 +464,9 @@ func set(c *ports.Config, key, v string) error { | |||||
| 464 | case "layout.default-width": | 464 | case "layout.default-width": | ||
| 465 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | 465 | return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)") | ||
| 466 | case "layout.max-columns": | 466 | case "layout.max-columns": | ||
| 467 | - | return positive(&c.Layout.MaxColumns, v, 16) | 467 | + | return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v) |
| 468 | case "layout.overflow": | 468 | case "layout.overflow": | ||
| 469 | - | if v != "scroll" && v != "fixed" { | 469 | + | return setLayoutRule(&c.Layout.LayoutRules, "overflow", v) |
| 470 | - | return fmt.Errorf("must be scroll or fixed") | |||
| 471 | - | } | |||
| 472 | - | c.Layout.Overflow = v | |||
| 473 | case "layout.presets": | 470 | case "layout.presets": | ||
| 474 | list := splitList(v) | 471 | list := splitList(v) | ||
| 475 | if len(list) == 0 { | 472 | if len(list) == 0 { | ||
| @@ -519,11 +516,50 @@ func set(c *ports.Config, key, v string) error { | |||||
| 519 | } | 516 | } | ||
| 520 | c.Log.Debug = list | 517 | c.Log.Debug = list | ||
| 521 | default: | 518 | default: | ||
| 519 | + | if rest, ok := strings.CutPrefix(key, "layout."); ok { | |||
| 520 | + | if i := strings.LastIndex(rest, "."); i > 0 { | |||
| 521 | + | return setOutputLayout(c, rest[:i], rest[i+1:], v) | |||
| 522 | + | } | |||
| 523 | + | } | |||
| 522 | return fmt.Errorf("unknown key") | 524 | return fmt.Errorf("unknown key") | ||
| 523 | } | 525 | } | ||
| 524 | return nil | 526 | return nil | ||
| 525 | } | 527 | } | ||
| 526 | 528 | ||||
| 529 | + | // setOutputLayout applies one layout.<output>.<field> key. | |||
| 530 | + | func setOutputLayout(c *ports.Config, output, field, v string) error { | |||
| 531 | + | i := slices.IndexFunc(c.Layout.Outputs, func(o ports.OutputLayout) bool { return o.Output == output }) | |||
| 532 | + | o := ports.OutputLayout{Output: output} | |||
| 533 | + | if i >= 0 { | |||
| 534 | + | o = c.Layout.Outputs[i] | |||
| 535 | + | } | |||
| 536 | + | if err := setLayoutRule(&o.LayoutRules, field, v); err != nil { | |||
| 537 | + | return err | |||
| 538 | + | } | |||
| 539 | + | if i < 0 { | |||
| 540 | + | c.Layout.Outputs = append(c.Layout.Outputs, o) | |||
| 541 | + | } else { | |||
| 542 | + | c.Layout.Outputs[i] = o | |||
| 543 | + | } | |||
| 544 | + | return nil | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | // setLayoutRule applies max-columns or overflow, for layout.*, | |||
| 548 | + | // layout.<output>.* and workspace.<name>.*. | |||
| 549 | + | func setLayoutRule(r *ports.LayoutRules, field, v string) error { | |||
| 550 | + | switch field { | |||
| 551 | + | case "max-columns": | |||
| 552 | + | return positive(&r.MaxColumns, v, 16) | |||
| 553 | + | case "overflow": | |||
| 554 | + | if v != "scroll" && v != "fixed" { | |||
| 555 | + | return fmt.Errorf("must be scroll or fixed") | |||
| 556 | + | } | |||
| 557 | + | r.Overflow = v | |||
| 558 | + | return nil | |||
| 559 | + | } | |||
| 560 | + | return fmt.Errorf("unknown key (max-columns, overflow)") | |||
| 561 | + | } | |||
| 562 | + | ||||
| 527 | // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared | 563 | // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared | ||
| 528 | // workspace (it does nothing) and a hidden workspace no bind reaches (its | 564 | // workspace (it does nothing) and a hidden workspace no bind reaches (its | ||
| 529 | // windows could only come back by editing the config). | 565 | // windows could only come back by editing the config). | ||
| @@ -546,11 +582,15 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning { | |||||
| 546 | } | 582 | } | ||
| 547 | } | 583 | } | ||
| 548 | for _, w := range c.Workspaces { | 584 | for _, w := range c.Workspaces { | ||
| 549 | - | overflow := w.Overflow | 585 | + | // The config only knows the monitor as written: a connector home |
| 550 | - | if overflow == "" { | 586 | + | // does not match a key rule, and the reverse. |
| 551 | - | overflow = c.Layout.Overflow | 587 | + | rules := c.Layout.LayoutRules |
| 588 | + | for _, o := range c.Layout.Outputs { | |||
| 589 | + | if w.Monitor != "" && o.Output == w.Monitor { | |||
| 590 | + | rules = o.Over(rules) | |||
| 591 | + | } | |||
| 552 | } | 592 | } | ||
| 553 | - | if overflow == "fixed" && len(w.Slots) > 0 { | 593 | + | if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 { |
| 554 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | 594 | line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)] | ||
| 555 | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)}) | 595 | warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)}) | ||
| 556 | } | 596 | } | ||
| @@ -576,13 +616,8 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error { | |||||
| 576 | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | 616 | return fmt.Errorf("needs a connector (DP-2) or a monitor key") | ||
| 577 | } | 617 | } | ||
| 578 | w.Monitor = v | 618 | w.Monitor = v | ||
| 579 | - | case "max-columns": | 619 | + | case "max-columns", "overflow": |
| 580 | - | return positive(&w.MaxColumns, v, 16) | 620 | + | return setLayoutRule(&w.LayoutRules, field, v) |
| 581 | - | case "overflow": | |||
| 582 | - | if v != "scroll" && v != "fixed" { | |||
| 583 | - | return fmt.Errorf("must be scroll or fixed") | |||
| 584 | - | } | |||
| 585 | - | w.Overflow = v | |||
| 586 | default: | 621 | default: | ||
| 587 | n, ok := strings.CutPrefix(field, "column.") | 622 | n, ok := strings.CutPrefix(field, "column.") | ||
| 588 | if !ok { | 623 | if !ok { | ||
M internal/adapters/config/config_test.go +28 -0
| @@ -304,6 +304,34 @@ workspace.lost.hidden = on | |||
| 304 | 304 | } | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | + | func TestLayoutPerOutput(t *testing.T) { | |
| 308 | + | c, w := parseString(t, `layout.max-columns = 2 | |
| 309 | + | layout.HDMI-A-1.max-columns = 3 | |
| 310 | + | layout.HDMI-A-1.overflow = fixed | |
| 311 | + | layout.LG Electronics 27GR95UM 123.overflow = scroll | |
| 312 | + | layout.DP-2.overflow = spiral | |
| 313 | + | layout.DP-2.color = red | |
| 314 | + | `) | |
| 315 | + | want := []ports.OutputLayout{ | |
| 316 | + | {Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"}, | |
| 317 | + | {Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"}, | |
| 318 | + | } | |
| 319 | + | if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 { | |
| 320 | + | t.Fatalf("%+v %v", c.Layout.Outputs, w) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func TestFixedOutputWarnsSlotWidths(t *testing.T) { | |
| 325 | + | _, w := parseString(t, `layout.DP-2.overflow = fixed | |
| 326 | + | workspace.dev.monitor = DP-2 | |
| 327 | + | workspace.dev.column.1 = 50%, foot | |
| 328 | + | workspace.web.column.1 = 50%, foot | |
| 329 | + | `) | |
| 330 | + | if len(w) != 1 || w[0].Line != 3 { | |
| 331 | + | t.Fatal(w) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 307 | 335 | func TestWorkspaceSlots(t *testing.T) { | |
| 308 | 336 | c, w := parseString(t, `workspace.dev.hidden = on | |
| 309 | 337 | workspace.dev.column.2 = 33%, foot --title x | |
| @@ -304,6 +304,34 @@ workspace.lost.hidden = on | |||||
| 304 | } | 304 | } | ||
| 305 | } | 305 | } | ||
| 306 | 306 | ||||
| 307 | + | func TestLayoutPerOutput(t *testing.T) { | |||
| 308 | + | c, w := parseString(t, `layout.max-columns = 2 | |||
| 309 | + | layout.HDMI-A-1.max-columns = 3 | |||
| 310 | + | layout.HDMI-A-1.overflow = fixed | |||
| 311 | + | layout.LG Electronics 27GR95UM 123.overflow = scroll | |||
| 312 | + | layout.DP-2.overflow = spiral | |||
| 313 | + | layout.DP-2.color = red | |||
| 314 | + | `) | |||
| 315 | + | want := []ports.OutputLayout{ | |||
| 316 | + | {Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"}, | |||
| 317 | + | {Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"}, | |||
| 318 | + | } | |||
| 319 | + | if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 { | |||
| 320 | + | t.Fatalf("%+v %v", c.Layout.Outputs, w) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func TestFixedOutputWarnsSlotWidths(t *testing.T) { | |||
| 325 | + | _, w := parseString(t, `layout.DP-2.overflow = fixed | |||
| 326 | + | workspace.dev.monitor = DP-2 | |||
| 327 | + | workspace.dev.column.1 = 50%, foot | |||
| 328 | + | workspace.web.column.1 = 50%, foot | |||
| 329 | + | `) | |||
| 330 | + | if len(w) != 1 || w[0].Line != 3 { | |||
| 331 | + | t.Fatal(w) | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 307 | func TestWorkspaceSlots(t *testing.T) { | 335 | func TestWorkspaceSlots(t *testing.T) { | ||
| 308 | c, w := parseString(t, `workspace.dev.hidden = on | 336 | c, w := parseString(t, `workspace.dev.hidden = on | ||
| 309 | workspace.dev.column.2 = 33%, foot --title x | 337 | workspace.dev.column.2 = 33%, foot --title x | ||
M internal/core/core.go +12 -6
| @@ -69,7 +69,6 @@ type Core struct { | |||
| 69 | 69 | sentOutputs ports.SetOutputs | |
| 70 | 70 | specs []NamedWorkspace | |
| 71 | 71 | presets []Width | |
| 72 | - | overflow Overflow | |
| 73 | 72 | slots map[slotKey]*slotState | |
| 74 | 73 | // toSpawn holds slots to start; Run sends them (apply has no context). | |
| 75 | 74 | toSpawn []slotKey | |
| @@ -198,12 +197,19 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 198 | 197 | } | |
| 199 | 198 | return "", fmt.Errorf("invalid overflow %q", v) | |
| 200 | 199 | } | |
| 201 | - | defOverflow, err := overflow(cfg.Layout.Overflow) | |
| 202 | - | if err != nil { | |
| 200 | + | if _, err := overflow(cfg.Layout.Overflow); err != nil { | |
| 203 | 201 | return err | |
| 204 | 202 | } | |
| 205 | - | if defOverflow == "" { | |
| 206 | - | defOverflow = OverflowScroll | |
| 203 | + | if cfg.Layout.Overflow == "" { | |
| 204 | + | cfg.Layout.Overflow = string(OverflowScroll) | |
| 205 | + | } | |
| 206 | + | for _, o := range cfg.Layout.Outputs { | |
| 207 | + | if _, err := overflow(o.Overflow); err != nil { | |
| 208 | + | return err | |
| 209 | + | } | |
| 210 | + | if o.Output == "" || o.MaxColumns < 0 { | |
| 211 | + | return fmt.Errorf("invalid layout for output %q", o.Output) | |
| 212 | + | } | |
| 207 | 213 | } | |
| 208 | 214 | named := make([]NamedWorkspace, 0, len(cfg.Workspaces)) | |
| 209 | 215 | seen := map[string]bool{} | |
| @@ -224,7 +230,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||
| 224 | 230 | } | |
| 225 | 231 | c.cfg = cfg | |
| 226 | 232 | c.binds = binds | |
| 227 | - | c.specs, c.presets, c.overflow = named, presets, defOverflow | |
| 233 | + | c.specs, c.presets = named, presets | |
| 228 | 234 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | |
| 229 | 235 | c.named() | |
| 230 | 236 | for _, s := range c.screens { | |
| @@ -69,7 +69,6 @@ type Core struct { | |||||
| 69 | sentOutputs ports.SetOutputs | 69 | sentOutputs ports.SetOutputs | ||
| 70 | specs []NamedWorkspace | 70 | specs []NamedWorkspace | ||
| 71 | presets []Width | 71 | presets []Width | ||
| 72 | - | overflow Overflow | |||
| 73 | slots map[slotKey]*slotState | 72 | slots map[slotKey]*slotState | ||
| 74 | // toSpawn holds slots to start; Run sends them (apply has no context). | 73 | // toSpawn holds slots to start; Run sends them (apply has no context). | ||
| 75 | toSpawn []slotKey | 74 | toSpawn []slotKey | ||
| @@ -198,12 +197,19 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 198 | } | 197 | } | ||
| 199 | return "", fmt.Errorf("invalid overflow %q", v) | 198 | return "", fmt.Errorf("invalid overflow %q", v) | ||
| 200 | } | 199 | } | ||
| 201 | - | defOverflow, err := overflow(cfg.Layout.Overflow) | 200 | + | if _, err := overflow(cfg.Layout.Overflow); err != nil { |
| 202 | - | if err != nil { | |||
| 203 | return err | 201 | return err | ||
| 204 | } | 202 | } | ||
| 205 | - | if defOverflow == "" { | 203 | + | if cfg.Layout.Overflow == "" { |
| 206 | - | defOverflow = OverflowScroll | 204 | + | cfg.Layout.Overflow = string(OverflowScroll) |
| 205 | + | } | |||
| 206 | + | for _, o := range cfg.Layout.Outputs { | |||
| 207 | + | if _, err := overflow(o.Overflow); err != nil { | |||
| 208 | + | return err | |||
| 209 | + | } | |||
| 210 | + | if o.Output == "" || o.MaxColumns < 0 { | |||
| 211 | + | return fmt.Errorf("invalid layout for output %q", o.Output) | |||
| 212 | + | } | |||
| 207 | } | 213 | } | ||
| 208 | named := make([]NamedWorkspace, 0, len(cfg.Workspaces)) | 214 | named := make([]NamedWorkspace, 0, len(cfg.Workspaces)) | ||
| 209 | seen := map[string]bool{} | 215 | seen := map[string]bool{} | ||
| @@ -224,7 +230,7 @@ func (c *Core) apply(cfg ports.Config) error { | |||||
| 224 | } | 230 | } | ||
| 225 | c.cfg = cfg | 231 | c.cfg = cfg | ||
| 226 | c.binds = binds | 232 | c.binds = binds | ||
| 227 | - | c.specs, c.presets, c.overflow = named, presets, defOverflow | 233 | + | c.specs, c.presets = named, presets |
| 228 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | 234 | c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...) | ||
| 229 | c.named() | 235 | c.named() | ||
| 230 | for _, s := range c.screens { | 236 | for _, s := range c.screens { | ||
M internal/core/screens.go +14 -3
| @@ -152,6 +152,11 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 152 | 152 | if i := c.screenIndex(info.Name); i >= 0 { | |
| 153 | 153 | s := c.screens[i] | |
| 154 | 154 | s.info = info | |
| 155 | + | if s.mon.Key != info.Key() { | |
| 156 | + | // Another monitor on the same connector: key rules change. | |
| 157 | + | s.mon.Key = info.Key() | |
| 158 | + | c.settings(s.mon) | |
| 159 | + | } | |
| 155 | 160 | s.setScale(s.scale) | |
| 156 | 161 | c.order() | |
| 157 | 162 | return | |
| @@ -162,10 +167,10 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 162 | 167 | s.info = info | |
| 163 | 168 | } else { | |
| 164 | 169 | s = &screen{info: info, mon: newMonitor(info.Name, info.Key())} | |
| 165 | - | c.settings(s.mon) | |
| 166 | 170 | c.screens = append(c.screens, s) | |
| 167 | 171 | } | |
| 168 | 172 | s.mon.Name, s.mon.Key = info.Name, info.Key() | |
| 173 | + | c.settings(s.mon) | |
| 169 | 174 | s.cfgScale = c.configScale(info.Name) | |
| 170 | 175 | s.setScale(s.cfgScale) | |
| 171 | 176 | s.primary = c.isPrimary(info.Name) | |
| @@ -300,8 +305,14 @@ func (c *Core) named() { | |||
| 300 | 305 | ||
| 301 | 306 | // settings applies output-wide config to a monitor. | |
| 302 | 307 | func (c *Core) settings(m *Monitor) { | |
| 303 | - | m.SetOverflow(c.overflow) | |
| 304 | - | m.SetMaxColumns(c.cfg.Layout.MaxColumns) | |
| 308 | + | rules := c.cfg.Layout.LayoutRules | |
| 309 | + | for _, o := range c.cfg.Layout.Outputs { | |
| 310 | + | if m.matches(o.Output) { | |
| 311 | + | rules = o.Over(rules) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | m.SetOverflow(Overflow(rules.Overflow)) | |
| 315 | + | m.SetMaxColumns(rules.MaxColumns) | |
| 305 | 316 | m.SetPresets(c.presets) | |
| 306 | 317 | m.SetGaps(c.cfg.Layout.Gaps) | |
| 307 | 318 | m.SetBorder(c.cfg.Border.Width) | |
| @@ -152,6 +152,11 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||||
| 152 | if i := c.screenIndex(info.Name); i >= 0 { | 152 | if i := c.screenIndex(info.Name); i >= 0 { | ||
| 153 | s := c.screens[i] | 153 | s := c.screens[i] | ||
| 154 | s.info = info | 154 | s.info = info | ||
| 155 | + | if s.mon.Key != info.Key() { | |||
| 156 | + | // Another monitor on the same connector: key rules change. | |||
| 157 | + | s.mon.Key = info.Key() | |||
| 158 | + | c.settings(s.mon) | |||
| 159 | + | } | |||
| 155 | s.setScale(s.scale) | 160 | s.setScale(s.scale) | ||
| 156 | c.order() | 161 | c.order() | ||
| 157 | return | 162 | return | ||
| @@ -162,10 +167,10 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||||
| 162 | s.info = info | 167 | s.info = info | ||
| 163 | } else { | 168 | } else { | ||
| 164 | s = &screen{info: info, mon: newMonitor(info.Name, info.Key())} | 169 | s = &screen{info: info, mon: newMonitor(info.Name, info.Key())} | ||
| 165 | - | c.settings(s.mon) | |||
| 166 | c.screens = append(c.screens, s) | 170 | c.screens = append(c.screens, s) | ||
| 167 | } | 171 | } | ||
| 168 | s.mon.Name, s.mon.Key = info.Name, info.Key() | 172 | s.mon.Name, s.mon.Key = info.Name, info.Key() | ||
| 173 | + | c.settings(s.mon) | |||
| 169 | s.cfgScale = c.configScale(info.Name) | 174 | s.cfgScale = c.configScale(info.Name) | ||
| 170 | s.setScale(s.cfgScale) | 175 | s.setScale(s.cfgScale) | ||
| 171 | s.primary = c.isPrimary(info.Name) | 176 | s.primary = c.isPrimary(info.Name) | ||
| @@ -300,8 +305,14 @@ func (c *Core) named() { | |||||
| 300 | 305 | ||||
| 301 | // settings applies output-wide config to a monitor. | 306 | // settings applies output-wide config to a monitor. | ||
| 302 | func (c *Core) settings(m *Monitor) { | 307 | func (c *Core) settings(m *Monitor) { | ||
| 303 | - | m.SetOverflow(c.overflow) | 308 | + | rules := c.cfg.Layout.LayoutRules |
| 304 | - | m.SetMaxColumns(c.cfg.Layout.MaxColumns) | 309 | + | for _, o := range c.cfg.Layout.Outputs { |
| 310 | + | if m.matches(o.Output) { | |||
| 311 | + | rules = o.Over(rules) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | m.SetOverflow(Overflow(rules.Overflow)) | |||
| 315 | + | m.SetMaxColumns(rules.MaxColumns) | |||
| 305 | m.SetPresets(c.presets) | 316 | m.SetPresets(c.presets) | ||
| 306 | m.SetGaps(c.cfg.Layout.Gaps) | 317 | m.SetGaps(c.cfg.Layout.Gaps) | ||
| 307 | m.SetBorder(c.cfg.Border.Width) | 318 | m.SetBorder(c.cfg.Border.Width) | ||
M internal/core/screens_test.go +64 -0
| @@ -125,6 +125,70 @@ func TestOutputsLeftToRight(t *testing.T) { | |||
| 125 | 125 | } | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | func TestLayoutPerOutput(t *testing.T) { | |
| 129 | + | r := startMulti(t, func(c *ports.Config) { | |
| 130 | + | c.Layout.MaxColumns = 2 | |
| 131 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | |
| 132 | + | }, left, right) | |
| 133 | + | r.mapWindow(t, 1) | |
| 134 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 135 | + | var set []ports.Scene | |
| 136 | + | for id := ports.WindowID(2); id <= 6; id++ { | |
| 137 | + | set = r.mapWindow(t, id) | |
| 138 | + | } | |
| 139 | + | // DP-2: four columns of 100; the fifth window splits the last one | |
| 140 | + | // (fixed) instead of scrolling. | |
| 141 | + | if got := shown(set); len(got["DP-2"]) != 5 { | |
| 142 | + | t.Fatal(got) | |
| 143 | + | } | |
| 144 | + | for _, s := range set { | |
| 145 | + | for _, w := range s.Windows { | |
| 146 | + | // DP-1 keeps the default: one window of two columns fills it. | |
| 147 | + | if want := map[string]int{"DP-1": 200, "DP-2": 100}[s.Output]; w.Rect.W != want { | |
| 148 | + | t.Fatalf("%s: %+v", s.Output, w) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // widths returns the window widths on one output. | |
| 155 | + | func widths(set []ports.Scene, output string) []int { | |
| 156 | + | var out []int | |
| 157 | + | for _, s := range set { | |
| 158 | + | for _, w := range s.Windows { | |
| 159 | + | if s.Output == output { | |
| 160 | + | out = append(out, w.Rect.W) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return out | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | |
| 168 | + | r := startMulti(t, func(c *ports.Config) { | |
| 169 | + | c.Layout.MaxColumns = 1 | |
| 170 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | |
| 171 | + | }, right) | |
| 172 | + | r.mapWindow(t, 1) | |
| 173 | + | if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |
| 174 | + | t.Fatal(got) | |
| 175 | + | } | |
| 176 | + | // Another monitor on DP-2: the key rule no longer applies. | |
| 177 | + | other := right | |
| 178 | + | other.Serial = "3" | |
| 179 | + | if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |
| 180 | + | t.Fatal(got) | |
| 181 | + | } | |
| 182 | + | // Back to the first monitor, then a reload drops the rule. | |
| 183 | + | r.plug(t, right) | |
| 184 | + | cfg := r.cfg | |
| 185 | + | cfg.Layout.Outputs = nil | |
| 186 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |
| 187 | + | if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |
| 188 | + | t.Fatal(got) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 128 | 192 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | |
| 129 | 193 | r := startMulti(t, nil, left, right) | |
| 130 | 194 | r.mapWindow(t, 1) | |
| @@ -125,6 +125,70 @@ func TestOutputsLeftToRight(t *testing.T) { | |||||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | + | func TestLayoutPerOutput(t *testing.T) { | |||
| 129 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 130 | + | c.Layout.MaxColumns = 2 | |||
| 131 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", MaxColumns: 4, Overflow: "fixed"}} | |||
| 132 | + | }, left, right) | |||
| 133 | + | r.mapWindow(t, 1) | |||
| 134 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 135 | + | var set []ports.Scene | |||
| 136 | + | for id := ports.WindowID(2); id <= 6; id++ { | |||
| 137 | + | set = r.mapWindow(t, id) | |||
| 138 | + | } | |||
| 139 | + | // DP-2: four columns of 100; the fifth window splits the last one | |||
| 140 | + | // (fixed) instead of scrolling. | |||
| 141 | + | if got := shown(set); len(got["DP-2"]) != 5 { | |||
| 142 | + | t.Fatal(got) | |||
| 143 | + | } | |||
| 144 | + | for _, s := range set { | |||
| 145 | + | for _, w := range s.Windows { | |||
| 146 | + | // DP-1 keeps the default: one window of two columns fills it. | |||
| 147 | + | if want := map[string]int{"DP-1": 200, "DP-2": 100}[s.Output]; w.Rect.W != want { | |||
| 148 | + | t.Fatalf("%s: %+v", s.Output, w) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // widths returns the window widths on one output. | |||
| 155 | + | func widths(set []ports.Scene, output string) []int { | |||
| 156 | + | var out []int | |||
| 157 | + | for _, s := range set { | |||
| 158 | + | for _, w := range s.Windows { | |||
| 159 | + | if s.Output == output { | |||
| 160 | + | out = append(out, w.Rect.W) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return out | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestLayoutPerOutputFollowsMonitorAndReload(t *testing.T) { | |||
| 168 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 169 | + | c.Layout.MaxColumns = 1 | |||
| 170 | + | c.Layout.Outputs = []ports.OutputLayout{{Output: "Acme B 2", LayoutRules: ports.LayoutRules{MaxColumns: 2}}} | |||
| 171 | + | }, right) | |||
| 172 | + | r.mapWindow(t, 1) | |||
| 173 | + | if got := widths(r.mapWindow(t, 2), "DP-2"); !slices.Equal(got, []int{200, 200}) { | |||
| 174 | + | t.Fatal(got) | |||
| 175 | + | } | |||
| 176 | + | // Another monitor on DP-2: the key rule no longer applies. | |||
| 177 | + | other := right | |||
| 178 | + | other.Serial = "3" | |||
| 179 | + | if got := widths(r.plug(t, other), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |||
| 180 | + | t.Fatal(got) | |||
| 181 | + | } | |||
| 182 | + | // Back to the first monitor, then a reload drops the rule. | |||
| 183 | + | r.plug(t, right) | |||
| 184 | + | cfg := r.cfg | |||
| 185 | + | cfg.Layout.Outputs = nil | |||
| 186 | + | r.reload <- ports.ConfigChanged{Config: cfg} | |||
| 187 | + | if got := widths(receive(t, r.scenes), "DP-2"); !slices.Equal(got, []int{400, 400}) { | |||
| 188 | + | t.Fatal(got) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 128 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | 192 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | ||
| 129 | r := startMulti(t, nil, left, right) | 193 | r := startMulti(t, nil, left, right) | ||
| 130 | r.mapWindow(t, 1) | 194 | r.mapWindow(t, 1) | ||
M internal/ports/config.go +37 -10
| @@ -24,6 +24,34 @@ type OutputConfig struct { | |||
| 24 | 24 | ScaleOnly bool | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | + | // LayoutRules are the layout settings shared by the defaults, screens and | |
| 28 | + | // named workspaces. Zero MaxColumns and an empty Overflow inherit. | |
| 29 | + | type LayoutRules struct { | |
| 30 | + | // MaxColumns is how many columns share the screen at once. | |
| 31 | + | MaxColumns int | |
| 32 | + | // Overflow is "scroll" (further columns scroll) or "fixed" (they split | |
| 33 | + | // the last one). | |
| 34 | + | Overflow string | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Over fills the unset rules of r from base. | |
| 38 | + | func (r LayoutRules) Over(base LayoutRules) LayoutRules { | |
| 39 | + | if r.MaxColumns == 0 { | |
| 40 | + | r.MaxColumns = base.MaxColumns | |
| 41 | + | } | |
| 42 | + | if r.Overflow == "" { | |
| 43 | + | r.Overflow = base.Overflow | |
| 44 | + | } | |
| 45 | + | return r | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // OutputLayout is the layout of one screen, matched by connector (DP-2) or | |
| 49 | + | // monitor key. | |
| 50 | + | type OutputLayout struct { | |
| 51 | + | Output string | |
| 52 | + | LayoutRules | |
| 53 | + | } | |
| 54 | + | ||
| 27 | 55 | // Config is the parsed compositor configuration (see the config adapter for keys). | |
| 28 | 56 | type Config struct { | |
| 29 | 57 | Keyboard struct { | |
| @@ -46,12 +74,12 @@ type Config struct { | |||
| 46 | 74 | Inactive string | |
| 47 | 75 | } | |
| 48 | 76 | Layout struct { | |
| 49 | - | Gaps int | |
| 50 | - | // MaxColumns is how many columns share the screen before scrolling. | |
| 51 | - | MaxColumns int | |
| 52 | - | Presets []string | |
| 53 | - | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | |
| 54 | - | Overflow string | |
| 77 | + | Gaps int | |
| 78 | + | Presets []string | |
| 79 | + | // LayoutRules are the defaults; Outputs override them per screen and | |
| 80 | + | // named workspaces override them again. | |
| 81 | + | LayoutRules | |
| 82 | + | Outputs []OutputLayout | |
| 55 | 83 | } | |
| 56 | 84 | Focus struct { | |
| 57 | 85 | // FollowMove shows the target workspace after a column or window | |
| @@ -84,10 +112,9 @@ type WorkspaceConfig struct { | |||
| 84 | 112 | // ("make model serial"); empty means the first output. | |
| 85 | 113 | Monitor string | |
| 86 | 114 | // Slots are the declared columns (workspace.<name>.column.N), by N. | |
| 87 | - | Slots []SlotConfig | |
| 88 | - | Hidden bool | |
| 89 | - | MaxColumns int | |
| 90 | - | Overflow string | |
| 115 | + | Slots []SlotConfig | |
| 116 | + | Hidden bool | |
| 117 | + | LayoutRules | |
| 91 | 118 | } | |
| 92 | 119 | ||
| 93 | 120 | // SlotConfig reserves column N of a workspace for the window of one command. | |
| @@ -24,6 +24,34 @@ type OutputConfig struct { | |||||
| 24 | ScaleOnly bool | 24 | ScaleOnly bool | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | + | // LayoutRules are the layout settings shared by the defaults, screens and | |||
| 28 | + | // named workspaces. Zero MaxColumns and an empty Overflow inherit. | |||
| 29 | + | type LayoutRules struct { | |||
| 30 | + | // MaxColumns is how many columns share the screen at once. | |||
| 31 | + | MaxColumns int | |||
| 32 | + | // Overflow is "scroll" (further columns scroll) or "fixed" (they split | |||
| 33 | + | // the last one). | |||
| 34 | + | Overflow string | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Over fills the unset rules of r from base. | |||
| 38 | + | func (r LayoutRules) Over(base LayoutRules) LayoutRules { | |||
| 39 | + | if r.MaxColumns == 0 { | |||
| 40 | + | r.MaxColumns = base.MaxColumns | |||
| 41 | + | } | |||
| 42 | + | if r.Overflow == "" { | |||
| 43 | + | r.Overflow = base.Overflow | |||
| 44 | + | } | |||
| 45 | + | return r | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // OutputLayout is the layout of one screen, matched by connector (DP-2) or | |||
| 49 | + | // monitor key. | |||
| 50 | + | type OutputLayout struct { | |||
| 51 | + | Output string | |||
| 52 | + | LayoutRules | |||
| 53 | + | } | |||
| 54 | + | ||||
| 27 | // Config is the parsed compositor configuration (see the config adapter for keys). | 55 | // Config is the parsed compositor configuration (see the config adapter for keys). | ||
| 28 | type Config struct { | 56 | type Config struct { | ||
| 29 | Keyboard struct { | 57 | Keyboard struct { | ||
| @@ -46,12 +74,12 @@ type Config struct { | |||||
| 46 | Inactive string | 74 | Inactive string | ||
| 47 | } | 75 | } | ||
| 48 | Layout struct { | 76 | Layout struct { | ||
| 49 | - | Gaps int | 77 | + | Gaps int |
| 50 | - | // MaxColumns is how many columns share the screen before scrolling. | 78 | + | Presets []string |
| 51 | - | MaxColumns int | 79 | + | // LayoutRules are the defaults; Outputs override them per screen and |
| 52 | - | Presets []string | 80 | + | // named workspaces override them again. |
| 53 | - | // Overflow is "scroll" or "fixed" (see WorkspaceConfig). | 81 | + | LayoutRules |
| 54 | - | Overflow string | 82 | + | Outputs []OutputLayout |
| 55 | } | 83 | } | ||
| 56 | Focus struct { | 84 | Focus struct { | ||
| 57 | // FollowMove shows the target workspace after a column or window | 85 | // FollowMove shows the target workspace after a column or window | ||
| @@ -84,10 +112,9 @@ type WorkspaceConfig struct { | |||||
| 84 | // ("make model serial"); empty means the first output. | 112 | // ("make model serial"); empty means the first output. | ||
| 85 | Monitor string | 113 | Monitor string | ||
| 86 | // Slots are the declared columns (workspace.<name>.column.N), by N. | 114 | // Slots are the declared columns (workspace.<name>.column.N), by N. | ||
| 87 | - | Slots []SlotConfig | 115 | + | Slots []SlotConfig |
| 88 | - | Hidden bool | 116 | + | Hidden bool |
| 89 | - | MaxColumns int | 117 | + | LayoutRules |
| 90 | - | Overflow string | |||
| 91 | } | 118 | } | ||
| 92 | 119 | ||||
| 93 | // SlotConfig reserves column N of a workspace for the window of one command. | 120 | // SlotConfig reserves column N of a workspace for the window of one command. | ||
A internal/ports/config_test.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestLayoutRulesOver(t *testing.T) { | |
| 6 | + | defaults := LayoutRules{MaxColumns: 2, Overflow: "scroll"} | |
| 7 | + | screen := LayoutRules{MaxColumns: 3} | |
| 8 | + | ws := LayoutRules{Overflow: "fixed"} | |
| 9 | + | got := ws.Over(screen.Over(defaults)) | |
| 10 | + | if got != (LayoutRules{MaxColumns: 3, Overflow: "fixed"}) { | |
| 11 | + | t.Fatal(got) | |
| 12 | + | } | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestLayoutRulesOver(t *testing.T) { | |||
| 6 | + | defaults := LayoutRules{MaxColumns: 2, Overflow: "scroll"} | |||
| 7 | + | screen := LayoutRules{MaxColumns: 3} | |||
| 8 | + | ws := LayoutRules{Overflow: "fixed"} | |||
| 9 | + | got := ws.Over(screen.Over(defaults)) | |||
| 10 | + | if got != (LayoutRules{MaxColumns: 3, Overflow: "fixed"}) { | |||
| 11 | + | t.Fatal(got) | |||
| 12 | + | } | |||
| 13 | + | } | |||