detached 29fcc7b

feat(output): place outputs relative to another with an offset

commit
29fcc7b454fb47ff025e2e1fac789c7924718fbe
author
brice · · 2026-10-03 14:01 UTC
parent
67280b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +536 -26
M internal/adapters/config/config.go +90 -1
@@ -224,6 +224,60 @@ func Load(path string) (ports.Config, []Warning, error) {
224224 return Parse(f)
225225 }
226226
227+// outputRelation splits "<output>.right-of" (left-of, above, below) into the
228+// relation and the output name.
229+func outputRelation(name string) (ports.OutputRelation, string, bool) {
230+ for suffix, rel := range map[string]ports.OutputRelation{
231+ ".right-of": ports.RelationRightOf,
232+ ".left-of": ports.RelationLeftOf,
233+ ".above": ports.RelationAbove,
234+ ".below": ports.RelationBelow,
235+ } {
236+ if base, ok := strings.CutSuffix(name, suffix); ok {
237+ return rel, base, true
238+ }
239+ }
240+ return ports.RelationNone, "", false
241+}
242+
243+// checkOutputAnchors warns about an offset without a relation, then drops the
244+// relations that form a cycle (every output of the cycle, one warning each).
245+// Core places a cycle automatically anyway; runtime overrides never create
246+// relations, so this is the only place to detect them.
247+func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {
248+ var warnings []Warning
249+ for _, o := range outputs {
250+ if line, ok := offsetLines[o.Name]; ok && o.Anchor.Relation == ports.RelationNone {
251+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("output.%s.offset: ignored without a relation (right-of, left-of, above, below)", o.Name)})
252+ }
253+ }
254+ byName := make(map[string]*ports.OutputConfig, len(outputs))
255+ for i := range outputs {
256+ byName[outputs[i].Name] = &outputs[i]
257+ }
258+ var cycle []*ports.OutputConfig
259+ for i := range outputs {
260+ for cur, steps := &outputs[i], 0; steps <= len(outputs); steps++ {
261+ if cur.Anchor.Relation == ports.RelationNone {
262+ break
263+ }
264+ if cur = byName[cur.Anchor.To]; cur == nil {
265+ break
266+ }
267+ if cur == &outputs[i] {
268+ cycle = append(cycle, cur)
269+ break
270+ }
271+ }
272+ }
273+ for _, o := range cycle {
274+ key := relKeys[o.Name]
275+ warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})
276+ o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""
277+ }
278+ return warnings
279+}
280+
227281 // Parse reads `key = value` lines on top of the defaults.
228282 func Parse(r io.Reader) (ports.Config, []Warning, error) {
229283 c, _, w, err := parse(r)
@@ -237,6 +291,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
237291 raw := map[string]string{}
238292 seen := map[string]int{}
239293 outputs := map[string]int{}
294+ relKeys := map[string]string{} // output -> key of its relation
295+ offsetLines := map[string]int{} // output -> line of its offset
240296 workspaces := map[string]int{}
241297 scanner := bufio.NewScanner(r)
242298 for n := 1; scanner.Scan(); n++ {
@@ -325,6 +381,38 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
325381 entry().SDRBrightness = nits
326382 continue
327383 }
384+ if base, ok := strings.CutSuffix(name, ".offset"); ok {
385+ name = base
386+ off, err := strconv.Atoi(value)
387+ if err != nil {
388+ warn("%s: must be an integer", key)
389+ continue
390+ }
391+ override()
392+ entry().Anchor.Offset = off
393+ offsetLines[name] = n
394+ continue
395+ }
396+ if rel, base, ok := outputRelation(name); ok {
397+ name = base
398+ if value == "" {
399+ warn("%s: expected an output name", key)
400+ continue
401+ }
402+ if value == name {
403+ warn("%s: an output cannot be placed relative to itself", key)
404+ continue
405+ }
406+ if prev, ok := relKeys[name]; ok && prev != key {
407+ warn("%s: overrides line %d (%s)", key, seen[prev], prev)
408+ delete(raw, prev)
409+ }
410+ override()
411+ relKeys[name] = key
412+ a := &entry().Anchor
413+ a.Relation, a.To = rel, value
414+ continue
415+ }
328416 if base, ok := strings.CutSuffix(name, ".scale"); ok {
329417 name = base
330418 s, err := parseScale(value)
@@ -347,7 +435,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
347435 }
348436 override()
349437 e := entry()
350- o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness
438+ o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
351439 *e = o
352440 continue
353441 }
@@ -389,6 +477,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
389477 if err := scanner.Err(); err != nil {
390478 return c, raw, warnings, err
391479 }
480+ warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
392481 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
393482 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
394483 // A user bind on a digit (cmd+1, even "none") replaces the default bound
@@ -224,6 +224,60 @@ func Load(path string) (ports.Config, []Warning, error) {
224 return Parse(f)224 return Parse(f)
225 }225 }
226 226
227+// outputRelation splits "<output>.right-of" (left-of, above, below) into the
228+// relation and the output name.
229+func outputRelation(name string) (ports.OutputRelation, string, bool) {
230+ for suffix, rel := range map[string]ports.OutputRelation{
231+ ".right-of": ports.RelationRightOf,
232+ ".left-of": ports.RelationLeftOf,
233+ ".above": ports.RelationAbove,
234+ ".below": ports.RelationBelow,
235+ } {
236+ if base, ok := strings.CutSuffix(name, suffix); ok {
237+ return rel, base, true
238+ }
239+ }
240+ return ports.RelationNone, "", false
241+}
242+
243+// checkOutputAnchors warns about an offset without a relation, then drops the
244+// relations that form a cycle (every output of the cycle, one warning each).
245+// Core places a cycle automatically anyway; runtime overrides never create
246+// relations, so this is the only place to detect them.
247+func checkOutputAnchors(outputs []ports.OutputConfig, relKeys map[string]string, offsetLines, seen map[string]int) []Warning {
248+ var warnings []Warning
249+ for _, o := range outputs {
250+ if line, ok := offsetLines[o.Name]; ok && o.Anchor.Relation == ports.RelationNone {
251+ warnings = append(warnings, Warning{Line: line, Msg: fmt.Sprintf("output.%s.offset: ignored without a relation (right-of, left-of, above, below)", o.Name)})
252+ }
253+ }
254+ byName := make(map[string]*ports.OutputConfig, len(outputs))
255+ for i := range outputs {
256+ byName[outputs[i].Name] = &outputs[i]
257+ }
258+ var cycle []*ports.OutputConfig
259+ for i := range outputs {
260+ for cur, steps := &outputs[i], 0; steps <= len(outputs); steps++ {
261+ if cur.Anchor.Relation == ports.RelationNone {
262+ break
263+ }
264+ if cur = byName[cur.Anchor.To]; cur == nil {
265+ break
266+ }
267+ if cur == &outputs[i] {
268+ cycle = append(cycle, cur)
269+ break
270+ }
271+ }
272+ }
273+ for _, o := range cycle {
274+ key := relKeys[o.Name]
275+ warnings = append(warnings, Warning{Line: seen[key], Msg: fmt.Sprintf("%s: relation cycle, ignored", key)})
276+ o.Anchor.Relation, o.Anchor.To = ports.RelationNone, ""
277+ }
278+ return warnings
279+}
280+
227 // Parse reads `key = value` lines on top of the defaults.281 // Parse reads `key = value` lines on top of the defaults.
228 func Parse(r io.Reader) (ports.Config, []Warning, error) {282 func Parse(r io.Reader) (ports.Config, []Warning, error) {
229 c, _, w, err := parse(r)283 c, _, w, err := parse(r)
@@ -237,6 +291,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
237 raw := map[string]string{}291 raw := map[string]string{}
238 seen := map[string]int{}292 seen := map[string]int{}
239 outputs := map[string]int{}293 outputs := map[string]int{}
294+ relKeys := map[string]string{} // output -> key of its relation
295+ offsetLines := map[string]int{} // output -> line of its offset
240 workspaces := map[string]int{}296 workspaces := map[string]int{}
241 scanner := bufio.NewScanner(r)297 scanner := bufio.NewScanner(r)
242 for n := 1; scanner.Scan(); n++ {298 for n := 1; scanner.Scan(); n++ {
@@ -325,6 +381,38 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
325 entry().SDRBrightness = nits381 entry().SDRBrightness = nits
326 continue382 continue
327 }383 }
384+ if base, ok := strings.CutSuffix(name, ".offset"); ok {
385+ name = base
386+ off, err := strconv.Atoi(value)
387+ if err != nil {
388+ warn("%s: must be an integer", key)
389+ continue
390+ }
391+ override()
392+ entry().Anchor.Offset = off
393+ offsetLines[name] = n
394+ continue
395+ }
396+ if rel, base, ok := outputRelation(name); ok {
397+ name = base
398+ if value == "" {
399+ warn("%s: expected an output name", key)
400+ continue
401+ }
402+ if value == name {
403+ warn("%s: an output cannot be placed relative to itself", key)
404+ continue
405+ }
406+ if prev, ok := relKeys[name]; ok && prev != key {
407+ warn("%s: overrides line %d (%s)", key, seen[prev], prev)
408+ delete(raw, prev)
409+ }
410+ override()
411+ relKeys[name] = key
412+ a := &entry().Anchor
413+ a.Relation, a.To = rel, value
414+ continue
415+ }
328 if base, ok := strings.CutSuffix(name, ".scale"); ok {416 if base, ok := strings.CutSuffix(name, ".scale"); ok {
329 name = base417 name = base
330 s, err := parseScale(value)418 s, err := parseScale(value)
@@ -347,7 +435,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
347 }435 }
348 override()436 override()
349 e := entry()437 e := entry()
350- o.Scale, o.Primary, o.HDR, o.SDRBrightness = e.Scale, e.Primary, e.HDR, e.SDRBrightness438+ o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor
351 *e = o439 *e = o
352 continue440 continue
353 }441 }
@@ -389,6 +477,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
389 if err := scanner.Err(); err != nil {477 if err := scanner.Err(); err != nil {
390 return c, raw, warnings, err478 return c, raw, warnings, err
391 }479 }
480+ warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...)
392 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)481 warnings = append(warnings, checkWorkspaceBinds(c, seen)...)
393 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })482 sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line })
394 // A user bind on a digit (cmd+1, even "none") replaces the default bound483 // A user bind on a digit (cmd+1, even "none") replaces the default bound
M internal/adapters/config/config_test.go +102 -0
@@ -610,3 +610,105 @@ func TestFloatingDim(t *testing.T) {
610610 })
611611 }
612612 }
613+
614+func TestOutputRelations(t *testing.T) {
615+ c, w := parseString(t, `
616+output.DP-2.right-of = DP-1
617+output.DP-2.offset = 180
618+output.DP-3.left-of = DP-1
619+output.DP-3.offset = -40
620+output.DP-4.above = DP-1
621+output.DP-5.below = DP-1
622+output.DP-5.offset = 7
623+`)
624+ if len(w) != 0 {
625+ t.Fatal(w)
626+ }
627+ want := map[string]ports.OutputAnchor{
628+ "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
629+ "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
630+ "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
631+ "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
632+ }
633+ if len(c.Outputs) != len(want) {
634+ t.Fatalf("%+v", c.Outputs)
635+ }
636+ for _, o := range c.Outputs {
637+ if o.Anchor != want[o.Name] || !o.ScaleOnly {
638+ t.Fatalf("%s: %+v", o.Name, o)
639+ }
640+ }
641+}
642+
643+func TestOutputRelationOverride(t *testing.T) {
644+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
646+ t.Fatalf("warnings %v", w)
647+ }
648+ if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
649+ t.Fatalf("%+v", got)
650+ }
651+}
652+
653+func TestOutputRelationWarnings(t *testing.T) {
654+ cases := []struct {
655+ name, text, want string
656+ line int
657+ anchored []string // outputs that keep a relation
658+ }{
659+ {"self", "output.DP-2.right-of = DP-2", "relative to itself", 1, nil},
660+ {"empty", "output.DP-2.above =", "expected an output name", 1, nil},
661+ {"bad offset", "output.DP-2.right-of = DP-1\noutput.DP-2.offset = abc", "must be an integer", 2, []string{"DP-2"}},
662+ {"offset alone", "output.DP-2.offset = 10", "without a relation", 1, nil},
663+ {"two cycle", "output.A.right-of = B\noutput.B.below = A", "relation cycle", 1, nil},
664+ {"three cycle", "output.A.right-of = B\noutput.B.right-of = C\noutput.C.above = A", "relation cycle", 1, nil},
665+ {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", "relation cycle", 1, []string{"C"}},
666+ }
667+ for _, tc := range cases {
668+ t.Run(tc.name, func(t *testing.T) {
669+ c, w := parseString(t, tc.text)
670+ if len(w) == 0 || w[len(w)-1].Line == 0 {
671+ t.Fatalf("warnings %v", w)
672+ }
673+ n := 0
674+ for _, x := range w {
675+ if strings.Contains(x.Msg, tc.want) {
676+ n++
677+ }
678+ }
679+ cycle := strings.Contains(tc.want, "cycle")
680+ if n == 0 || (!cycle && len(w) != 1) || w[0].Line != tc.line {
681+ t.Fatalf("warnings %v, want %q at line %d", w, tc.want, tc.line)
682+ }
683+ if cycle {
684+ members := strings.Count(tc.text, "\n") + 1
685+ if tc.name == "tail into cycle" {
686+ members = 2
687+ }
688+ if n != members {
689+ t.Fatalf("%d cycle warnings, want %d: %v", n, members, w)
690+ }
691+ }
692+ var got []string
693+ for _, o := range c.Outputs {
694+ if o.Anchor.Relation != ports.RelationNone {
695+ got = append(got, o.Name)
696+ }
697+ }
698+ if !reflect.DeepEqual(got, tc.anchored) {
699+ t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
700+ }
701+ })
702+ }
703+}
704+
705+func TestOutputModeLineKeepsAnchor(t *testing.T) {
706+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
707+ if len(w) != 0 {
708+ t.Fatal(w)
709+ }
710+ o := c.Outputs[0]
711+ if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
712+ t.Fatalf("%+v", o)
713+ }
714+}
@@ -610,3 +610,105 @@ func TestFloatingDim(t *testing.T) {
610 })610 })
611 }611 }
612 }612 }
613+
614+func TestOutputRelations(t *testing.T) {
615+ c, w := parseString(t, `
616+output.DP-2.right-of = DP-1
617+output.DP-2.offset = 180
618+output.DP-3.left-of = DP-1
619+output.DP-3.offset = -40
620+output.DP-4.above = DP-1
621+output.DP-5.below = DP-1
622+output.DP-5.offset = 7
623+`)
624+ if len(w) != 0 {
625+ t.Fatal(w)
626+ }
627+ want := map[string]ports.OutputAnchor{
628+ "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
629+ "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
630+ "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
631+ "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
632+ }
633+ if len(c.Outputs) != len(want) {
634+ t.Fatalf("%+v", c.Outputs)
635+ }
636+ for _, o := range c.Outputs {
637+ if o.Anchor != want[o.Name] || !o.ScaleOnly {
638+ t.Fatalf("%s: %+v", o.Name, o)
639+ }
640+ }
641+}
642+
643+func TestOutputRelationOverride(t *testing.T) {
644+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
645+ if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
646+ t.Fatalf("warnings %v", w)
647+ }
648+ if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
649+ t.Fatalf("%+v", got)
650+ }
651+}
652+
653+func TestOutputRelationWarnings(t *testing.T) {
654+ cases := []struct {
655+ name, text, want string
656+ line int
657+ anchored []string // outputs that keep a relation
658+ }{
659+ {"self", "output.DP-2.right-of = DP-2", "relative to itself", 1, nil},
660+ {"empty", "output.DP-2.above =", "expected an output name", 1, nil},
661+ {"bad offset", "output.DP-2.right-of = DP-1\noutput.DP-2.offset = abc", "must be an integer", 2, []string{"DP-2"}},
662+ {"offset alone", "output.DP-2.offset = 10", "without a relation", 1, nil},
663+ {"two cycle", "output.A.right-of = B\noutput.B.below = A", "relation cycle", 1, nil},
664+ {"three cycle", "output.A.right-of = B\noutput.B.right-of = C\noutput.C.above = A", "relation cycle", 1, nil},
665+ {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", "relation cycle", 1, []string{"C"}},
666+ }
667+ for _, tc := range cases {
668+ t.Run(tc.name, func(t *testing.T) {
669+ c, w := parseString(t, tc.text)
670+ if len(w) == 0 || w[len(w)-1].Line == 0 {
671+ t.Fatalf("warnings %v", w)
672+ }
673+ n := 0
674+ for _, x := range w {
675+ if strings.Contains(x.Msg, tc.want) {
676+ n++
677+ }
678+ }
679+ cycle := strings.Contains(tc.want, "cycle")
680+ if n == 0 || (!cycle && len(w) != 1) || w[0].Line != tc.line {
681+ t.Fatalf("warnings %v, want %q at line %d", w, tc.want, tc.line)
682+ }
683+ if cycle {
684+ members := strings.Count(tc.text, "\n") + 1
685+ if tc.name == "tail into cycle" {
686+ members = 2
687+ }
688+ if n != members {
689+ t.Fatalf("%d cycle warnings, want %d: %v", n, members, w)
690+ }
691+ }
692+ var got []string
693+ for _, o := range c.Outputs {
694+ if o.Anchor.Relation != ports.RelationNone {
695+ got = append(got, o.Name)
696+ }
697+ }
698+ if !reflect.DeepEqual(got, tc.anchored) {
699+ t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
700+ }
701+ })
702+ }
703+}
704+
705+func TestOutputModeLineKeepsAnchor(t *testing.T) {
706+ c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
707+ if len(w) != 0 {
708+ t.Fatal(w)
709+ }
710+ o := c.Outputs[0]
711+ if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
712+ t.Fatalf("%+v", o)
713+ }
714+}
M internal/app/outputmanagement_test.go +55 -0
@@ -306,3 +306,58 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
306306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307307 }
308308 }
309+
310+// A runtime position never replaces a file relation: it only takes
311+// precedence over it in core, and the relation is followed again once the
312+// override is dropped.
313+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
314+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
315+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
316+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
317+ o := newOutputOverrides(file, false)
318+ head := func(name string) ports.OutputHead {
319+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
320+ }
321+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
322+ change := func(name string, x int) ports.HeadChange {
323+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
324+ }
325+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
326+ t.Helper()
327+ var found []ports.OutputConfig
328+ for _, v := range cfg.Outputs {
329+ if v.Name == name {
330+ found = append(found, v)
331+ }
332+ }
333+ if len(found) != 1 {
334+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
335+ }
336+ return found[0]
337+ }
338+
339+ cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
340+ if err != nil {
341+ t.Fatal(err)
342+ }
343+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
344+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
345+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
346+ }
347+
348+ cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
353+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
354+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
355+ }
356+
357+ // A non scale-only reload drops the overrides: the file relation is back alone.
358+ file.Keyboard.RepeatRate = 33
359+ cfg = o.reload(file)
360+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
361+ t.Fatalf("after reload: %+v", cfg.Outputs)
362+ }
363+}
@@ -306,3 +306,58 @@ func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307 }307 }
308 }308 }
309+
310+// A runtime position never replaces a file relation: it only takes
311+// precedence over it in core, and the relation is followed again once the
312+// override is dropped.
313+func TestOutputApplyKeepsFileAnchor(t *testing.T) {
314+ mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
315+ anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
316+ file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
317+ o := newOutputOverrides(file, false)
318+ head := func(name string) ports.OutputHead {
319+ return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
320+ }
321+ o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
322+ change := func(name string, x int) ports.HeadChange {
323+ return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
324+ }
325+ byName := func(cfg ports.Config, name string) ports.OutputConfig {
326+ t.Helper()
327+ var found []ports.OutputConfig
328+ for _, v := range cfg.Outputs {
329+ if v.Name == name {
330+ found = append(found, v)
331+ }
332+ }
333+ if len(found) != 1 {
334+ t.Fatalf("%s entries: %+v", name, cfg.Outputs)
335+ }
336+ return found[0]
337+ }
338+
339+ cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
340+ if err != nil {
341+ t.Fatal(err)
342+ }
343+ dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
344+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
345+ t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
346+ }
347+
348+ cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
353+ if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
354+ t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
355+ }
356+
357+ // A non scale-only reload drops the overrides: the file relation is back alone.
358+ file.Keyboard.RepeatRate = 33
359+ cfg = o.reload(file)
360+ if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
361+ t.Fatalf("after reload: %+v", cfg.Outputs)
362+ }
363+}
M internal/core/placement.go +72 -15
@@ -1,32 +1,89 @@
11 package core
22
3-import "image"
3+import (
4+ "image"
45
5-// placeItem is one output to place: its connector name, logical size and, when set, the
6-// explicit origin from output management (output.<name> position).
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// placeItem is one output to place: its connector name, logical size, the
10+// explicit origin when one is set (pos: explicit origin set at runtime by
11+// wlr-output-management, not a config key) and the output.<name> relation to
12+// another output.
713 type placeItem struct {
8- name string
9- w, h int
10- pos *image.Point
14+ name string
15+ w, h int
16+ pos *image.Point
17+ anchor ports.OutputAnchor
1118 }
1219
1320 // placeOutputs returns the logical origin of every item, in item order.
14-// Items with an explicit position keep it; the others follow, in order, from
15-// the rightmost edge placed so far (never left of 0), at y=0.
21+// Precedence: an explicit position, then the relation to another placed
22+// output, then automatic placement: from the rightmost edge placed so far
23+// (never left of 0), at y=0. Automatic placement runs only when no relation
24+// can be resolved, so an anchored output never lands on an automatic one.
25+// A relation to an absent output, or in a cycle, falls back to automatic.
1626 func placeOutputs(items []placeItem) []image.Point {
1727 out := make([]image.Point, len(items))
18- x := 0
28+ done := make([]bool, len(items))
29+ left := len(items)
1930 for i, it := range items {
2031 if it.pos != nil {
21- out[i] = *it.pos
22- x = max(x, it.pos.X+it.w)
32+ out[i], done[i] = *it.pos, true
33+ left--
2334 }
2435 }
25- for i, it := range items {
26- if it.pos == nil {
27- out[i] = image.Point{X: x}
28- x += it.w
36+ anchored := func(i int) bool { return !done[i] && items[i].anchor.Relation != ports.RelationNone }
37+ for left > 0 {
38+ for progress := true; progress; {
39+ progress = false
40+ for i, it := range items {
41+ if !anchored(i) {
42+ continue
43+ }
44+ for j, ref := range items {
45+ if !done[j] || ref.name == "" || ref.name != it.anchor.To {
46+ continue
47+ }
48+ out[i], done[i] = relativeTo(it, ref, out[j]), true
49+ left--
50+ progress = true
51+ break
52+ }
53+ }
54+ }
55+ if left == 0 {
56+ break
57+ }
58+ next := -1
59+ for i := range items {
60+ if !done[i] && (next < 0 || (anchored(next) && !anchored(i))) {
61+ next = i
62+ }
2963 }
64+ x := 0
65+ for i, it := range items {
66+ if done[i] {
67+ x = max(x, out[i].X+it.w)
68+ }
69+ }
70+ out[next], done[next] = image.Point{X: x}, true
71+ left--
3072 }
3173 return out
3274 }
75+
76+// relativeTo is the origin of it placed against ref, whose origin is at.
77+func relativeTo(it, ref placeItem, at image.Point) image.Point {
78+ off := it.anchor.Offset
79+ switch it.anchor.Relation {
80+ case ports.RelationRightOf:
81+ return image.Point{X: at.X + ref.w, Y: at.Y + off}
82+ case ports.RelationLeftOf:
83+ return image.Point{X: at.X - it.w, Y: at.Y + off}
84+ case ports.RelationAbove:
85+ return image.Point{X: at.X + off, Y: at.Y - it.h}
86+ default: // RelationBelow
87+ return image.Point{X: at.X + off, Y: at.Y + ref.h}
88+ }
89+}
@@ -1,32 +1,89 @@
1 package core1 package core
2 2
3-import "image"3+import (
4+ "image"
4 5
5-// placeItem is one output to place: its connector name, logical size and, when set, the6+ "github.com/bnema/neferwl/internal/ports"
6-// explicit origin from output management (output.<name> position).7+)
8+
9+// placeItem is one output to place: its connector name, logical size, the
10+// explicit origin when one is set (pos: explicit origin set at runtime by
11+// wlr-output-management, not a config key) and the output.<name> relation to
12+// another output.
7 type placeItem struct {13 type placeItem struct {
8- name string14+ name string
9- w, h int15+ w, h int
10- pos *image.Point16+ pos *image.Point
17+ anchor ports.OutputAnchor
11 }18 }
12 19
13 // placeOutputs returns the logical origin of every item, in item order.20 // placeOutputs returns the logical origin of every item, in item order.
14-// Items with an explicit position keep it; the others follow, in order, from21+// Precedence: an explicit position, then the relation to another placed
15-// the rightmost edge placed so far (never left of 0), at y=0.22+// output, then automatic placement: from the rightmost edge placed so far
23+// (never left of 0), at y=0. Automatic placement runs only when no relation
24+// can be resolved, so an anchored output never lands on an automatic one.
25+// A relation to an absent output, or in a cycle, falls back to automatic.
16 func placeOutputs(items []placeItem) []image.Point {26 func placeOutputs(items []placeItem) []image.Point {
17 out := make([]image.Point, len(items))27 out := make([]image.Point, len(items))
18- x := 028+ done := make([]bool, len(items))
29+ left := len(items)
19 for i, it := range items {30 for i, it := range items {
20 if it.pos != nil {31 if it.pos != nil {
21- out[i] = *it.pos32+ out[i], done[i] = *it.pos, true
22- x = max(x, it.pos.X+it.w)33+ left--
23 }34 }
24 }35 }
25- for i, it := range items {36+ anchored := func(i int) bool { return !done[i] && items[i].anchor.Relation != ports.RelationNone }
26- if it.pos == nil {37+ for left > 0 {
27- out[i] = image.Point{X: x}38+ for progress := true; progress; {
28- x += it.w39+ progress = false
40+ for i, it := range items {
41+ if !anchored(i) {
42+ continue
43+ }
44+ for j, ref := range items {
45+ if !done[j] || ref.name == "" || ref.name != it.anchor.To {
46+ continue
47+ }
48+ out[i], done[i] = relativeTo(it, ref, out[j]), true
49+ left--
50+ progress = true
51+ break
52+ }
53+ }
54+ }
55+ if left == 0 {
56+ break
57+ }
58+ next := -1
59+ for i := range items {
60+ if !done[i] && (next < 0 || (anchored(next) && !anchored(i))) {
61+ next = i
62+ }
29 }63 }
64+ x := 0
65+ for i, it := range items {
66+ if done[i] {
67+ x = max(x, out[i].X+it.w)
68+ }
69+ }
70+ out[next], done[next] = image.Point{X: x}, true
71+ left--
30 }72 }
31 return out73 return out
32 }74 }
75+
76+// relativeTo is the origin of it placed against ref, whose origin is at.
77+func relativeTo(it, ref placeItem, at image.Point) image.Point {
78+ off := it.anchor.Offset
79+ switch it.anchor.Relation {
80+ case ports.RelationRightOf:
81+ return image.Point{X: at.X + ref.w, Y: at.Y + off}
82+ case ports.RelationLeftOf:
83+ return image.Point{X: at.X - it.w, Y: at.Y + off}
84+ case ports.RelationAbove:
85+ return image.Point{X: at.X + off, Y: at.Y - it.h}
86+ default: // RelationBelow
87+ return image.Point{X: at.X + off, Y: at.Y + ref.h}
88+ }
89+}
M internal/core/placement_test.go +123 -0
@@ -4,10 +4,21 @@ import (
44 "image"
55 "slices"
66 "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
79 )
810
911 func TestPlaceOutputs(t *testing.T) {
1012 at := func(x, y int) *image.Point { return &image.Point{X: x, Y: y} }
13+ rel := func(r ports.OutputRelation, to string, off int) ports.OutputAnchor {
14+ return ports.OutputAnchor{Relation: r, To: to, Offset: off}
15+ }
16+ const (
17+ rightOf = ports.RelationRightOf
18+ leftOf = ports.RelationLeftOf
19+ above = ports.RelationAbove
20+ below = ports.RelationBelow
21+ )
1122 tests := []struct {
1223 name string
1324 items []placeItem
@@ -35,6 +46,118 @@ func TestPlaceOutputs(t *testing.T) {
3546 []placeItem{{name: "A", w: 400, h: 300, pos: at(-200, 12)}, {name: "B", w: 400, h: 300, pos: at(20, -120)}},
3647 []image.Point{{X: -200, Y: 12}, {X: 20, Y: -120}},
3748 },
49+ {
50+ "automatic after explicit origins at both ends",
51+ []placeItem{{name: "A", w: 400, h: 300, pos: at(1000, 0)}, {name: "B", w: 400, h: 300, pos: at(0, 0)}, {name: "C", w: 300, h: 300}},
52+ []image.Point{{X: 1000, Y: 0}, {X: 0, Y: 0}, {X: 1400, Y: 0}},
53+ },
54+ {
55+ "automatic around an explicit origin",
56+ []placeItem{{name: "A", w: 400, h: 300}, {name: "B", w: 800, h: 300, pos: at(0, 500)}, {name: "C", w: 300, h: 300}},
57+ []image.Point{{X: 800, Y: 0}, {X: 0, Y: 500}, {X: 1200, Y: 0}},
58+ },
59+ {
60+ "right-of with offset",
61+ []placeItem{
62+ {name: "DP-1", w: 2560, h: 1440},
63+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 180)},
64+ },
65+ []image.Point{{X: 0, Y: 0}, {X: 2560, Y: 180}},
66+ },
67+ {
68+ "left-of with negative offset",
69+ []placeItem{
70+ {name: "DP-1", w: 2560, h: 1440, pos: at(100, 50)},
71+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", -30)},
72+ },
73+ []image.Point{{X: 100, Y: 50}, {X: -1820, Y: 20}},
74+ },
75+ {
76+ "above",
77+ []placeItem{
78+ {name: "DP-1", w: 2560, h: 1440},
79+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(above, "DP-1", 40)},
80+ },
81+ []image.Point{{X: 0, Y: 0}, {X: 40, Y: -1080}},
82+ },
83+ {
84+ "below",
85+ []placeItem{
86+ {name: "DP-1", w: 2560, h: 1440},
87+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(below, "DP-1", -40)},
88+ },
89+ []image.Point{{X: 0, Y: 0}, {X: -40, Y: 1440}},
90+ },
91+ {
92+ "chain resolved against item order",
93+ []placeItem{
94+ {name: "C", w: 100, h: 100, anchor: rel(rightOf, "B", 0)},
95+ {name: "B", w: 200, h: 100, anchor: rel(below, "A", 10)},
96+ {name: "A", w: 300, h: 150, pos: at(0, 0)},
97+ },
98+ []image.Point{{X: 210, Y: 150}, {X: 10, Y: 150}, {X: 0, Y: 0}},
99+ },
100+ {
101+ "two by three grid",
102+ []placeItem{
103+ {name: "DP-1", w: 2560, h: 1440},
104+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", 360)},
105+ {name: "DP-3", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 360)},
106+ {name: "HDMI-1", w: 2560, h: 1440, anchor: rel(above, "DP-1", 0)},
107+ {name: "DP-4", w: 1920, h: 1200, anchor: rel(leftOf, "HDMI-1", 240)},
108+ {name: "DP-5", w: 1920, h: 1200, anchor: rel(rightOf, "HDMI-1", 240)},
109+ },
110+ []image.Point{{X: 0, Y: 0}, {X: -1920, Y: 360}, {X: 2560, Y: 360}, {X: 0, Y: -1440}, {X: -1920, Y: -1200}, {X: 2560, Y: -1200}},
111+ },
112+ {
113+ "missing reference is automatic, after the plain automatic ones",
114+ []placeItem{
115+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "GONE", 50)},
116+ {name: "B", w: 200, h: 300},
117+ },
118+ []image.Point{{X: 200, Y: 0}, {X: 0, Y: 0}},
119+ },
120+ {
121+ "missing reference left of a negative origin stays right of zero",
122+ []placeItem{
123+ {name: "A", w: 400, h: 300, pos: at(-900, 0)},
124+ {name: "B", w: 200, h: 300, anchor: rel(above, "GONE", 0)},
125+ },
126+ []image.Point{{X: -900, Y: 0}, {X: 0, Y: 0}},
127+ },
128+ {
129+ "cycle is broken at its first output, the rest follows it",
130+ []placeItem{
131+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "B", 0)},
132+ {name: "B", w: 200, h: 300, anchor: rel(below, "A", 0)},
133+ },
134+ []image.Point{{X: 0, Y: 0}, {X: 0, Y: 300}},
135+ },
136+ {
137+ "explicit origin beats relation",
138+ []placeItem{
139+ {name: "A", w: 400, h: 300},
140+ {name: "B", w: 200, h: 300, pos: at(7, 9), anchor: rel(rightOf, "A", 0)},
141+ },
142+ []image.Point{{X: 207, Y: 0}, {X: 7, Y: 9}},
143+ },
144+ {
145+ "relation follows an explicit origin",
146+ []placeItem{
147+ {name: "A", w: 400, h: 300, pos: at(1000, 200)},
148+ {name: "B", w: 200, h: 300, anchor: rel(rightOf, "A", 5)},
149+ },
150+ []image.Point{{X: 1000, Y: 200}, {X: 1400, Y: 205}},
151+ },
152+ {
153+ "anchored output is not overlapped by a later automatic one",
154+ []placeItem{
155+ {name: "A", w: 400, h: 300},
156+ {name: "B", w: 200, h: 300},
157+ {name: "C", w: 300, h: 300, anchor: rel(rightOf, "A", 0)},
158+ },
159+ []image.Point{{X: 0, Y: 0}, {X: 700, Y: 0}, {X: 400, Y: 0}},
160+ },
38161 }
39162 for _, tc := range tests {
40163 t.Run(tc.name, func(t *testing.T) {
@@ -4,10 +4,21 @@ import (
4 "image"4 "image"
5 "slices"5 "slices"
6 "testing"6 "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
7 )9 )
8 10
9 func TestPlaceOutputs(t *testing.T) {11 func TestPlaceOutputs(t *testing.T) {
10 at := func(x, y int) *image.Point { return &image.Point{X: x, Y: y} }12 at := func(x, y int) *image.Point { return &image.Point{X: x, Y: y} }
13+ rel := func(r ports.OutputRelation, to string, off int) ports.OutputAnchor {
14+ return ports.OutputAnchor{Relation: r, To: to, Offset: off}
15+ }
16+ const (
17+ rightOf = ports.RelationRightOf
18+ leftOf = ports.RelationLeftOf
19+ above = ports.RelationAbove
20+ below = ports.RelationBelow
21+ )
11 tests := []struct {22 tests := []struct {
12 name string23 name string
13 items []placeItem24 items []placeItem
@@ -35,6 +46,118 @@ func TestPlaceOutputs(t *testing.T) {
35 []placeItem{{name: "A", w: 400, h: 300, pos: at(-200, 12)}, {name: "B", w: 400, h: 300, pos: at(20, -120)}},46 []placeItem{{name: "A", w: 400, h: 300, pos: at(-200, 12)}, {name: "B", w: 400, h: 300, pos: at(20, -120)}},
36 []image.Point{{X: -200, Y: 12}, {X: 20, Y: -120}},47 []image.Point{{X: -200, Y: 12}, {X: 20, Y: -120}},
37 },48 },
49+ {
50+ "automatic after explicit origins at both ends",
51+ []placeItem{{name: "A", w: 400, h: 300, pos: at(1000, 0)}, {name: "B", w: 400, h: 300, pos: at(0, 0)}, {name: "C", w: 300, h: 300}},
52+ []image.Point{{X: 1000, Y: 0}, {X: 0, Y: 0}, {X: 1400, Y: 0}},
53+ },
54+ {
55+ "automatic around an explicit origin",
56+ []placeItem{{name: "A", w: 400, h: 300}, {name: "B", w: 800, h: 300, pos: at(0, 500)}, {name: "C", w: 300, h: 300}},
57+ []image.Point{{X: 800, Y: 0}, {X: 0, Y: 500}, {X: 1200, Y: 0}},
58+ },
59+ {
60+ "right-of with offset",
61+ []placeItem{
62+ {name: "DP-1", w: 2560, h: 1440},
63+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 180)},
64+ },
65+ []image.Point{{X: 0, Y: 0}, {X: 2560, Y: 180}},
66+ },
67+ {
68+ "left-of with negative offset",
69+ []placeItem{
70+ {name: "DP-1", w: 2560, h: 1440, pos: at(100, 50)},
71+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", -30)},
72+ },
73+ []image.Point{{X: 100, Y: 50}, {X: -1820, Y: 20}},
74+ },
75+ {
76+ "above",
77+ []placeItem{
78+ {name: "DP-1", w: 2560, h: 1440},
79+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(above, "DP-1", 40)},
80+ },
81+ []image.Point{{X: 0, Y: 0}, {X: 40, Y: -1080}},
82+ },
83+ {
84+ "below",
85+ []placeItem{
86+ {name: "DP-1", w: 2560, h: 1440},
87+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(below, "DP-1", -40)},
88+ },
89+ []image.Point{{X: 0, Y: 0}, {X: -40, Y: 1440}},
90+ },
91+ {
92+ "chain resolved against item order",
93+ []placeItem{
94+ {name: "C", w: 100, h: 100, anchor: rel(rightOf, "B", 0)},
95+ {name: "B", w: 200, h: 100, anchor: rel(below, "A", 10)},
96+ {name: "A", w: 300, h: 150, pos: at(0, 0)},
97+ },
98+ []image.Point{{X: 210, Y: 150}, {X: 10, Y: 150}, {X: 0, Y: 0}},
99+ },
100+ {
101+ "two by three grid",
102+ []placeItem{
103+ {name: "DP-1", w: 2560, h: 1440},
104+ {name: "DP-2", w: 1920, h: 1080, anchor: rel(leftOf, "DP-1", 360)},
105+ {name: "DP-3", w: 1920, h: 1080, anchor: rel(rightOf, "DP-1", 360)},
106+ {name: "HDMI-1", w: 2560, h: 1440, anchor: rel(above, "DP-1", 0)},
107+ {name: "DP-4", w: 1920, h: 1200, anchor: rel(leftOf, "HDMI-1", 240)},
108+ {name: "DP-5", w: 1920, h: 1200, anchor: rel(rightOf, "HDMI-1", 240)},
109+ },
110+ []image.Point{{X: 0, Y: 0}, {X: -1920, Y: 360}, {X: 2560, Y: 360}, {X: 0, Y: -1440}, {X: -1920, Y: -1200}, {X: 2560, Y: -1200}},
111+ },
112+ {
113+ "missing reference is automatic, after the plain automatic ones",
114+ []placeItem{
115+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "GONE", 50)},
116+ {name: "B", w: 200, h: 300},
117+ },
118+ []image.Point{{X: 200, Y: 0}, {X: 0, Y: 0}},
119+ },
120+ {
121+ "missing reference left of a negative origin stays right of zero",
122+ []placeItem{
123+ {name: "A", w: 400, h: 300, pos: at(-900, 0)},
124+ {name: "B", w: 200, h: 300, anchor: rel(above, "GONE", 0)},
125+ },
126+ []image.Point{{X: -900, Y: 0}, {X: 0, Y: 0}},
127+ },
128+ {
129+ "cycle is broken at its first output, the rest follows it",
130+ []placeItem{
131+ {name: "A", w: 400, h: 300, anchor: rel(rightOf, "B", 0)},
132+ {name: "B", w: 200, h: 300, anchor: rel(below, "A", 0)},
133+ },
134+ []image.Point{{X: 0, Y: 0}, {X: 0, Y: 300}},
135+ },
136+ {
137+ "explicit origin beats relation",
138+ []placeItem{
139+ {name: "A", w: 400, h: 300},
140+ {name: "B", w: 200, h: 300, pos: at(7, 9), anchor: rel(rightOf, "A", 0)},
141+ },
142+ []image.Point{{X: 207, Y: 0}, {X: 7, Y: 9}},
143+ },
144+ {
145+ "relation follows an explicit origin",
146+ []placeItem{
147+ {name: "A", w: 400, h: 300, pos: at(1000, 200)},
148+ {name: "B", w: 200, h: 300, anchor: rel(rightOf, "A", 5)},
149+ },
150+ []image.Point{{X: 1000, Y: 200}, {X: 1400, Y: 205}},
151+ },
152+ {
153+ "anchored output is not overlapped by a later automatic one",
154+ []placeItem{
155+ {name: "A", w: 400, h: 300},
156+ {name: "B", w: 200, h: 300},
157+ {name: "C", w: 300, h: 300, anchor: rel(rightOf, "A", 0)},
158+ },
159+ []image.Point{{X: 0, Y: 0}, {X: 700, Y: 0}, {X: 400, Y: 0}},
160+ },
38 }161 }
39 for _, tc := range tests {162 for _, tc := range tests {
40 t.Run(tc.name, func(t *testing.T) {163 t.Run(tc.name, func(t *testing.T) {
M internal/core/screens.go +11 -6
@@ -53,7 +53,7 @@ func (s *screen) arrange() {
5353 // in the global layout (placeOutputs).
5454 func (c *Core) order() {
5555 rank := func(s *screen) int {
56- if i := slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == s.name() }); i >= 0 {
56+ if i := c.outputIndex(s.name()); i >= 0 {
5757 return i
5858 }
5959 return len(c.cfg.Outputs)
@@ -66,7 +66,7 @@ func (c *Core) order() {
6666 o := s.mon.Output()
6767 items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H
6868 if cfg, ok := c.outputConfig(s.name()); ok {
69- items[i].pos = cfg.Pos
69+ items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
7070 }
7171 }
7272 for i, p := range placeOutputs(items) {
@@ -74,10 +74,15 @@ func (c *Core) order() {
7474 }
7575 }
7676
77-// outputConfig returns the output.<name> entry; names are unique in the
78-// config.
77+// outputIndex is the position of the output.<name> entry in the config, or
78+// -1; names are unique in the config.
79+func (c *Core) outputIndex(name string) int {
80+ return slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
81+}
82+
83+// outputConfig returns the output.<name> entry.
7984 func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
80- i := slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
85+ i := c.outputIndex(name)
8186 if i < 0 {
8287 return ports.OutputConfig{}, false
8388 }
@@ -400,7 +405,7 @@ func (c *Core) settings(m *Monitor) {
400405 type direction uint8
401406
402407 const (
403- dirLeft direction = iota
408+ dirLeft direction = iota + 1
404409 dirRight
405410 dirUp
406411 dirDown
@@ -53,7 +53,7 @@ func (s *screen) arrange() {
53 // in the global layout (placeOutputs).53 // in the global layout (placeOutputs).
54 func (c *Core) order() {54 func (c *Core) order() {
55 rank := func(s *screen) int {55 rank := func(s *screen) int {
56- if i := slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == s.name() }); i >= 0 {56+ if i := c.outputIndex(s.name()); i >= 0 {
57 return i57 return i
58 }58 }
59 return len(c.cfg.Outputs)59 return len(c.cfg.Outputs)
@@ -66,7 +66,7 @@ func (c *Core) order() {
66 o := s.mon.Output()66 o := s.mon.Output()
67 items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H67 items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H
68 if cfg, ok := c.outputConfig(s.name()); ok {68 if cfg, ok := c.outputConfig(s.name()); ok {
69- items[i].pos = cfg.Pos69+ items[i].pos, items[i].anchor = cfg.Pos, cfg.Anchor
70 }70 }
71 }71 }
72 for i, p := range placeOutputs(items) {72 for i, p := range placeOutputs(items) {
@@ -74,10 +74,15 @@ func (c *Core) order() {
74 }74 }
75 }75 }
76 76
77-// outputConfig returns the output.<name> entry; names are unique in the77+// outputIndex is the position of the output.<name> entry in the config, or
78-// config.78+// -1; names are unique in the config.
79+func (c *Core) outputIndex(name string) int {
80+ return slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })
81+}
82+
83+// outputConfig returns the output.<name> entry.
79 func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {84 func (c *Core) outputConfig(name string) (ports.OutputConfig, bool) {
80- i := slices.IndexFunc(c.cfg.Outputs, func(o ports.OutputConfig) bool { return o.Name == name })85+ i := c.outputIndex(name)
81 if i < 0 {86 if i < 0 {
82 return ports.OutputConfig{}, false87 return ports.OutputConfig{}, false
83 }88 }
@@ -400,7 +405,7 @@ func (c *Core) settings(m *Monitor) {
400 type direction uint8405 type direction uint8
401 406
402 const (407 const (
403- dirLeft direction = iota408+ dirLeft direction = iota + 1
404 dirRight409 dirRight
405 dirUp410 dirUp
406 dirDown411 dirDown
M internal/core/screens_test.go +54 -0
@@ -207,6 +207,60 @@ func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
207207 }
208208 }
209209
210+func TestRelativeOutputPlacement(t *testing.T) {
211+ r := startMulti(t, func(c *ports.Config) {
212+ c.Outputs = []ports.OutputConfig{
213+ {Name: "DP-1"},
214+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1", Offset: 30}},
215+ {Name: "DP-3", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-2", Offset: -10}},
216+ }
217+ }, left, right, third)
218+ positions := func() [][2]int {
219+ t.Helper()
220+ var got [][2]int
221+ for _, o := range lastOutputs(t, r.commands).Outputs {
222+ got = append(got, [2]int{o.X, o.Y})
223+ }
224+ return got
225+ }
226+ // DP-1 is 200x100, DP-2 400x200 logical.
227+ if got, want := positions(), [][2]int{{0, 0}, {30, 100}, {430, 90}}; !slices.Equal(got, want) {
228+ t.Fatalf("positions %v, want %v", got, want)
229+ }
230+ // A reload with another offset moves the output.
231+ r.cfg.Outputs[1].Anchor.Offset = -50
232+ r.reload <- ports.ConfigChanged{Config: r.cfg}
233+ _ = receive(t, r.scenes)
234+ if got, want := positions(), [][2]int{{0, 0}, {-50, 100}, {350, 90}}; !slices.Equal(got, want) {
235+ t.Fatalf("positions after offset reload %v, want %v", got, want)
236+ }
237+ // Scaling the reference down moves the output below it.
238+ r.cfg.Outputs[0].Scale = 2
239+ r.reload <- ports.ConfigChanged{Config: r.cfg}
240+ _ = receive(t, r.scenes)
241+ if got, want := positions(), [][2]int{{0, 0}, {-50, 50}, {350, 40}}; !slices.Equal(got, want) {
242+ t.Fatalf("positions after scale reload %v, want %v", got, want)
243+ }
244+}
245+
246+func TestRelativeOutputWithoutReferenceIsAutomatic(t *testing.T) {
247+ r := startMulti(t, func(c *ports.Config) {
248+ c.Outputs = []ports.OutputConfig{{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 25}}}
249+ }, right)
250+ if out := lastOutputs(t, r.commands).Outputs; len(out) != 1 || out[0].X != 0 || out[0].Y != 0 {
251+ t.Fatalf("without reference: %+v", out)
252+ }
253+ // Plugging the reference restores the relation (DP-1 is 200 wide).
254+ r.plug(t, left)
255+ out := lastOutputs(t, r.commands).Outputs
256+ if len(out) != 2 || out[0].Info.Name != "DP-2" || out[1].Info.Name != "DP-1" {
257+ t.Fatalf("outputs %+v", out)
258+ }
259+ if out[0].X != 200 || out[0].Y != 25 || out[1].X != 0 || out[1].Y != 0 {
260+ t.Fatalf("placement %+v", out)
261+ }
262+}
263+
210264 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
211265 r := startMulti(t, nil, left, right)
212266 r.mapWindow(t, 1)
@@ -207,6 +207,60 @@ func TestExplicitOutputPositionAndAutomaticFallback(t *testing.T) {
207 }207 }
208 }208 }
209 209
210+func TestRelativeOutputPlacement(t *testing.T) {
211+ r := startMulti(t, func(c *ports.Config) {
212+ c.Outputs = []ports.OutputConfig{
213+ {Name: "DP-1"},
214+ {Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-1", Offset: 30}},
215+ {Name: "DP-3", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-2", Offset: -10}},
216+ }
217+ }, left, right, third)
218+ positions := func() [][2]int {
219+ t.Helper()
220+ var got [][2]int
221+ for _, o := range lastOutputs(t, r.commands).Outputs {
222+ got = append(got, [2]int{o.X, o.Y})
223+ }
224+ return got
225+ }
226+ // DP-1 is 200x100, DP-2 400x200 logical.
227+ if got, want := positions(), [][2]int{{0, 0}, {30, 100}, {430, 90}}; !slices.Equal(got, want) {
228+ t.Fatalf("positions %v, want %v", got, want)
229+ }
230+ // A reload with another offset moves the output.
231+ r.cfg.Outputs[1].Anchor.Offset = -50
232+ r.reload <- ports.ConfigChanged{Config: r.cfg}
233+ _ = receive(t, r.scenes)
234+ if got, want := positions(), [][2]int{{0, 0}, {-50, 100}, {350, 90}}; !slices.Equal(got, want) {
235+ t.Fatalf("positions after offset reload %v, want %v", got, want)
236+ }
237+ // Scaling the reference down moves the output below it.
238+ r.cfg.Outputs[0].Scale = 2
239+ r.reload <- ports.ConfigChanged{Config: r.cfg}
240+ _ = receive(t, r.scenes)
241+ if got, want := positions(), [][2]int{{0, 0}, {-50, 50}, {350, 40}}; !slices.Equal(got, want) {
242+ t.Fatalf("positions after scale reload %v, want %v", got, want)
243+ }
244+}
245+
246+func TestRelativeOutputWithoutReferenceIsAutomatic(t *testing.T) {
247+ r := startMulti(t, func(c *ports.Config) {
248+ c.Outputs = []ports.OutputConfig{{Name: "DP-2", Anchor: ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 25}}}
249+ }, right)
250+ if out := lastOutputs(t, r.commands).Outputs; len(out) != 1 || out[0].X != 0 || out[0].Y != 0 {
251+ t.Fatalf("without reference: %+v", out)
252+ }
253+ // Plugging the reference restores the relation (DP-1 is 200 wide).
254+ r.plug(t, left)
255+ out := lastOutputs(t, r.commands).Outputs
256+ if len(out) != 2 || out[0].Info.Name != "DP-2" || out[1].Info.Name != "DP-1" {
257+ t.Fatalf("outputs %+v", out)
258+ }
259+ if out[0].X != 200 || out[0].Y != 25 || out[1].X != 0 || out[1].Y != 0 {
260+ t.Fatalf("placement %+v", out)
261+ }
262+}
263+
210 func TestWindowsOpenOnFocusedOutput(t *testing.T) {264 func TestWindowsOpenOnFocusedOutput(t *testing.T) {
211 r := startMulti(t, nil, left, right)265 r := startMulti(t, nil, left, right)
212 r.mapWindow(t, 1)266 r.mapWindow(t, 1)
M internal/ports/config.go +29 -4
@@ -11,8 +11,11 @@ const DefaultSDRBrightness = 203
1111 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
1212 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
1313 // monitor's preferred mode. Off disables the connector.
14-// Every connected output that is not off is used. Outputs are placed left to
15-// right in config order, then in connection order.
14+// Every connected output that is not off is used.
15+// Placement, by precedence: Pos (set at runtime by wlr-output-management),
16+// then Anchor against another connected output, then automatic: right of
17+// everything placed so far (never left of 0), at y=0, in config order, then in
18+// connection order.
1619 // Scale is the output scale (0 means 1); layout works in logical pixels,
1720 // physical = logical × Scale.
1821 // Primary gets the focus and the pointer at startup, wherever it is placed.
@@ -23,14 +26,36 @@ type OutputConfig struct {
2326 Mode string
2427 Off bool
2528 Scale float64
26- // Pos is an explicit logical placement set at runtime; nil uses automatic layout.
27- Pos *image.Point
29+ // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
30+ Pos *image.Point
31+ // Anchor places the output against another connected one.
32+ Anchor OutputAnchor // RelationNone: automatic
2833 Primary bool
2934 HDR bool // opt-in to HDR10 on capable outputs
3035 SDRBrightness int // nits; default DefaultSDRBrightness
3136 ScaleOnly bool
3237 }
3338
39+// OutputRelation is the side of another output where an output is placed.
40+type OutputRelation uint8
41+
42+const (
43+ RelationNone OutputRelation = iota
44+ RelationRightOf
45+ RelationLeftOf
46+ RelationAbove
47+ RelationBelow
48+)
49+
50+// OutputAnchor places an output against another connected one, named by To.
51+// Offset is in logical pixels, along the shared edge: y for right-of and
52+// left-of (positive is down), x for above and below (positive is right).
53+type OutputAnchor struct {
54+ Relation OutputRelation
55+ To string
56+ Offset int
57+}
58+
3459 // LayoutRules are the layout settings shared by the defaults, screens and
3560 // named workspaces. Zero MaxColumns and an empty Overflow inherit.
3661 type LayoutRules struct {
@@ -11,8 +11,11 @@ const DefaultSDRBrightness = 203
11 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").11 // OutputConfig configures one connector. Name is the connector (e.g. "DP-2").
12 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the12 // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the
13 // monitor's preferred mode. Off disables the connector.13 // monitor's preferred mode. Off disables the connector.
14-// Every connected output that is not off is used. Outputs are placed left to14+// Every connected output that is not off is used.
15-// right in config order, then in connection order.15+// Placement, by precedence: Pos (set at runtime by wlr-output-management),
16+// then Anchor against another connected output, then automatic: right of
17+// everything placed so far (never left of 0), at y=0, in config order, then in
18+// connection order.
16 // Scale is the output scale (0 means 1); layout works in logical pixels,19 // Scale is the output scale (0 means 1); layout works in logical pixels,
17 // physical = logical × Scale.20 // physical = logical × Scale.
18 // Primary gets the focus and the pointer at startup, wherever it is placed.21 // Primary gets the focus and the pointer at startup, wherever it is placed.
@@ -23,14 +26,36 @@ type OutputConfig struct {
23 Mode string26 Mode string
24 Off bool27 Off bool
25 Scale float6428 Scale float64
26- // Pos is an explicit logical placement set at runtime; nil uses automatic layout.29+ // Pos is an explicit logical placement set at runtime; nil uses Anchor, else automatic layout.
27- Pos *image.Point30+ Pos *image.Point
31+ // Anchor places the output against another connected one.
32+ Anchor OutputAnchor // RelationNone: automatic
28 Primary bool33 Primary bool
29 HDR bool // opt-in to HDR10 on capable outputs34 HDR bool // opt-in to HDR10 on capable outputs
30 SDRBrightness int // nits; default DefaultSDRBrightness35 SDRBrightness int // nits; default DefaultSDRBrightness
31 ScaleOnly bool36 ScaleOnly bool
32 }37 }
33 38
39+// OutputRelation is the side of another output where an output is placed.
40+type OutputRelation uint8
41+
42+const (
43+ RelationNone OutputRelation = iota
44+ RelationRightOf
45+ RelationLeftOf
46+ RelationAbove
47+ RelationBelow
48+)
49+
50+// OutputAnchor places an output against another connected one, named by To.
51+// Offset is in logical pixels, along the shared edge: y for right-of and
52+// left-of (positive is down), x for above and below (positive is right).
53+type OutputAnchor struct {
54+ Relation OutputRelation
55+ To string
56+ Offset int
57+}
58+
34 // LayoutRules are the layout settings shared by the defaults, screens and59 // LayoutRules are the layout settings shared by the defaults, screens and
35 // named workspaces. Zero MaxColumns and an empty Overflow inherit.60 // named workspaces. Zero MaxColumns and an empty Overflow inherit.
36 type LayoutRules struct {61 type LayoutRules struct {

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