detached f7d8fc2

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 UnverifiedB5690EEEBB952194 The 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`).
2525 | `layout.gaps` | `0` | Space between windows |
2626 | `layout.max-columns` | `2` | Columns that share the screen before scrolling |
2727 | `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 |
2829 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` |
2930 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
3031 | `workspace.<name>.hidden` | `off` | Reachable only through its bind, which toggles it |
3132 | `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 |
3334 | `workspace.<name>.column.<N>` | none | Slot: `<width>, <command>` |
3435 | `output.<name>` | preferred | `WxH`, `WxH@Hz`, `preferred` or `off` |
3536 | `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 {
464464 case "layout.default-width":
465465 return fmt.Errorf("replaced by layout.max-columns (columns share the width equally)")
466466 case "layout.max-columns":
467- return positive(&c.Layout.MaxColumns, v, 16)
467+ return setLayoutRule(&c.Layout.LayoutRules, "max-columns", v)
468468 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)
473470 case "layout.presets":
474471 list := splitList(v)
475472 if len(list) == 0 {
@@ -519,11 +516,50 @@ func set(c *ports.Config, key, v string) error {
519516 }
520517 c.Log.Debug = list
521518 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+ }
522524 return fmt.Errorf("unknown key")
523525 }
524526 return nil
525527 }
526528
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+
527563 // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
528564 // workspace (it does nothing) and a hidden workspace no bind reaches (its
529565 // windows could only come back by editing the config).
@@ -546,11 +582,15 @@ func checkWorkspaceBinds(c ports.Config, seen map[string]int) []Warning {
546582 }
547583 }
548584 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+ }
552592 }
553- if overflow == "fixed" && len(w.Slots) > 0 {
593+ if w.Over(rules).Overflow == "fixed" && len(w.Slots) > 0 {
554594 line := seen["workspace."+w.Name+".column."+strconv.Itoa(w.Slots[0].Index)]
555595 warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("workspace.%s: column widths are ignored with overflow = fixed (columns share the width)", w.Name)})
556596 }
@@ -576,13 +616,8 @@ func setWorkspace(w *ports.WorkspaceConfig, field, v string) error {
576616 return fmt.Errorf("needs a connector (DP-2) or a monitor key")
577617 }
578618 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)
586621 default:
587622 n, ok := strings.CutPrefix(field, "column.")
588623 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 = list517 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 nil526 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 undeclared563 // checkWorkspaceBinds warns about a `workspace <name>` bind to an undeclared
528 // workspace (it does nothing) and a hidden workspace no bind reaches (its564 // 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.Overflow585+ // 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.Overflow587+ 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 = v618 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
304304 }
305305 }
306306
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+
307335 func TestWorkspaceSlots(t *testing.T) {
308336 c, w := parseString(t, `workspace.dev.hidden = on
309337 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 = on336 c, w := parseString(t, `workspace.dev.hidden = on
309 workspace.dev.column.2 = 33%, foot --title x337 workspace.dev.column.2 = 33%, foot --title x
M internal/core/core.go +12 -6
@@ -69,7 +69,6 @@ type Core struct {
6969 sentOutputs ports.SetOutputs
7070 specs []NamedWorkspace
7171 presets []Width
72- overflow Overflow
7372 slots map[slotKey]*slotState
7473 // toSpawn holds slots to start; Run sends them (apply has no context).
7574 toSpawn []slotKey
@@ -198,12 +197,19 @@ func (c *Core) apply(cfg ports.Config) error {
198197 }
199198 return "", fmt.Errorf("invalid overflow %q", v)
200199 }
201- defOverflow, err := overflow(cfg.Layout.Overflow)
202- if err != nil {
200+ if _, err := overflow(cfg.Layout.Overflow); err != nil {
203201 return err
204202 }
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+ }
207213 }
208214 named := make([]NamedWorkspace, 0, len(cfg.Workspaces))
209215 seen := map[string]bool{}
@@ -224,7 +230,7 @@ func (c *Core) apply(cfg ports.Config) error {
224230 }
225231 c.cfg = cfg
226232 c.binds = binds
227- c.specs, c.presets, c.overflow = named, presets, defOverflow
233+ c.specs, c.presets = named, presets
228234 c.toSpawn = append(c.toSpawn, c.updateSlots(specs)...)
229235 c.named()
230236 for _, s := range c.screens {
@@ -69,7 +69,6 @@ type Core struct {
69 sentOutputs ports.SetOutputs69 sentOutputs ports.SetOutputs
70 specs []NamedWorkspace70 specs []NamedWorkspace
71 presets []Width71 presets []Width
72- overflow Overflow
73 slots map[slotKey]*slotState72 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 []slotKey74 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 err201 return err
204 }202 }
205- if defOverflow == "" {203+ if cfg.Layout.Overflow == "" {
206- defOverflow = OverflowScroll204+ 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 = cfg231 c.cfg = cfg
226 c.binds = binds232 c.binds = binds
227- c.specs, c.presets, c.overflow = named, presets, defOverflow233+ 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) {
152152 if i := c.screenIndex(info.Name); i >= 0 {
153153 s := c.screens[i]
154154 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+ }
155160 s.setScale(s.scale)
156161 c.order()
157162 return
@@ -162,10 +167,10 @@ func (c *Core) addScreen(info ports.OutputInfo) {
162167 s.info = info
163168 } else {
164169 s = &screen{info: info, mon: newMonitor(info.Name, info.Key())}
165- c.settings(s.mon)
166170 c.screens = append(c.screens, s)
167171 }
168172 s.mon.Name, s.mon.Key = info.Name, info.Key()
173+ c.settings(s.mon)
169174 s.cfgScale = c.configScale(info.Name)
170175 s.setScale(s.cfgScale)
171176 s.primary = c.isPrimary(info.Name)
@@ -300,8 +305,14 @@ func (c *Core) named() {
300305
301306 // settings applies output-wide config to a monitor.
302307 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)
305316 m.SetPresets(c.presets)
306317 m.SetGaps(c.cfg.Layout.Gaps)
307318 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 = info154 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 return162 return
@@ -162,10 +167,10 @@ func (c *Core) addScreen(info ports.OutputInfo) {
162 s.info = info167 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) {
125125 }
126126 }
127127
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+
128192 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
129193 r := startMulti(t, nil, left, right)
130194 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 {
2424 ScaleOnly bool
2525 }
2626
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+
2755 // Config is the parsed compositor configuration (see the config adapter for keys).
2856 type Config struct {
2957 Keyboard struct {
@@ -46,12 +74,12 @@ type Config struct {
4674 Inactive string
4775 }
4876 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
5583 }
5684 Focus struct {
5785 // FollowMove shows the target workspace after a column or window
@@ -84,10 +112,9 @@ type WorkspaceConfig struct {
84112 // ("make model serial"); empty means the first output.
85113 Monitor string
86114 // 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
91118 }
92119
93120 // SlotConfig reserves column N of a workspace for the window of one command.
@@ -24,6 +24,34 @@ type OutputConfig struct {
24 ScaleOnly bool24 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 string74 Inactive string
47 }75 }
48 Layout struct {76 Layout struct {
49- Gaps int77+ Gaps int
50- // MaxColumns is how many columns share the screen before scrolling.78+ Presets []string
51- MaxColumns int79+ // LayoutRules are the defaults; Outputs override them per screen and
52- Presets []string80+ // named workspaces override them again.
53- // Overflow is "scroll" or "fixed" (see WorkspaceConfig).81+ LayoutRules
54- Overflow string82+ Outputs []OutputLayout
55 }83 }
56 Focus struct {84 Focus struct {
57 // FollowMove shows the target workspace after a column or window85 // 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 string113 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 []SlotConfig115+ Slots []SlotConfig
88- Hidden bool116+ Hidden bool
89- MaxColumns int117+ 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close