feat(output): place outputs relative to another with an offset
- commit
- 29fcc7b454fb47ff025e2e1fac789c7924718fbe
- author
- brice · · 2026-10-03 14:01 UTC
- parent
- 67280b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 224 | 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 | 281 | // Parse reads `key = value` lines on top of the defaults. | |
| 228 | 282 | func Parse(r io.Reader) (ports.Config, []Warning, error) { | |
| 229 | 283 | c, _, w, err := parse(r) | |
| @@ -237,6 +291,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 237 | 291 | raw := map[string]string{} | |
| 238 | 292 | seen := map[string]int{} | |
| 239 | 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 | 296 | workspaces := map[string]int{} | |
| 241 | 297 | scanner := bufio.NewScanner(r) | |
| 242 | 298 | for n := 1; scanner.Scan(); n++ { | |
| @@ -325,6 +381,38 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 325 | 381 | entry().SDRBrightness = nits | |
| 326 | 382 | 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 | 416 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |
| 329 | 417 | name = base | |
| 330 | 418 | s, err := parseScale(value) | |
| @@ -347,7 +435,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 347 | 435 | } | |
| 348 | 436 | override() | |
| 349 | 437 | 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 | |
| 351 | 439 | *e = o | |
| 352 | 440 | continue | |
| 353 | 441 | } | |
| @@ -389,6 +477,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 389 | 477 | if err := scanner.Err(); err != nil { | |
| 390 | 478 | return c, raw, warnings, err | |
| 391 | 479 | } | |
| 480 | + | warnings = append(warnings, checkOutputAnchors(c.Outputs, relKeys, offsetLines, seen)...) | |
| 392 | 481 | warnings = append(warnings, checkWorkspaceBinds(c, seen)...) | |
| 393 | 482 | sort.SliceStable(warnings, func(i, j int) bool { return warnings[i].Line < warnings[j].Line }) | |
| 394 | 483 | // 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 = nits | 381 | entry().SDRBrightness = nits | ||
| 326 | continue | 382 | 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 = base | 417 | 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.SDRBrightness | 438 | + | o.Scale, o.Primary, o.HDR, o.SDRBrightness, o.Anchor = e.Scale, e.Primary, e.HDR, e.SDRBrightness, e.Anchor |
| 351 | *e = o | 439 | *e = o | ||
| 352 | continue | 440 | 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, err | 478 | 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 bound | 483 | // 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) { | |||
| 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 | + | } | |
| @@ -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) { | |||
| 306 | 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 | + | } | |
| @@ -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 @@ | |||
| 1 | 1 | 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, 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. | |
| 7 | 13 | 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 | |
| 11 | 18 | } | |
| 12 | 19 | ||
| 13 | 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, 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. | |
| 16 | 26 | func placeOutputs(items []placeItem) []image.Point { | |
| 17 | 27 | out := make([]image.Point, len(items)) | |
| 18 | - | x := 0 | |
| 28 | + | done := make([]bool, len(items)) | |
| 29 | + | left := len(items) | |
| 19 | 30 | for i, it := range items { | |
| 20 | 31 | 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-- | |
| 23 | 34 | } | |
| 24 | 35 | } | |
| 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 | + | } | |
| 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 | 73 | 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 | + | } | |
| @@ -1,32 +1,89 @@ | |||||
| 1 | package core | 1 | 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, the | 6 | + | "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 string | 14 | + | name string |
| 9 | - | w, h int | 15 | + | w, h int |
| 10 | - | pos *image.Point | 16 | + | 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, from | 21 | + | // 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 := 0 | 28 | + | 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.pos | 32 | + | 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.w | 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 | + | } | |||
| 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 out | 73 | 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 ( | |||
| 4 | 4 | "image" | |
| 5 | 5 | "slices" | |
| 6 | 6 | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | func TestPlaceOutputs(t *testing.T) { | |
| 10 | 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 | 22 | tests := []struct { | |
| 12 | 23 | name string | |
| 13 | 24 | items []placeItem | |
| @@ -35,6 +46,118 @@ func TestPlaceOutputs(t *testing.T) { | |||
| 35 | 46 | []placeItem{{name: "A", w: 400, h: 300, pos: at(-200, 12)}, {name: "B", w: 400, h: 300, pos: at(20, -120)}}, | |
| 36 | 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 | 162 | for _, tc := range tests { | |
| 40 | 163 | 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 string | 23 | name string | ||
| 13 | items []placeItem | 24 | 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() { | |||
| 53 | 53 | // in the global layout (placeOutputs). | |
| 54 | 54 | func (c *Core) order() { | |
| 55 | 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 | 57 | return i | |
| 58 | 58 | } | |
| 59 | 59 | return len(c.cfg.Outputs) | |
| @@ -66,7 +66,7 @@ func (c *Core) order() { | |||
| 66 | 66 | o := s.mon.Output() | |
| 67 | 67 | items[i].name, items[i].w, items[i].h = s.name(), o.W, o.H | |
| 68 | 68 | 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 | |
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | 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 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. | |
| 79 | 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 | 86 | if i < 0 { | |
| 82 | 87 | return ports.OutputConfig{}, false | |
| 83 | 88 | } | |
| @@ -400,7 +405,7 @@ func (c *Core) settings(m *Monitor) { | |||
| 400 | 405 | type direction uint8 | |
| 401 | 406 | ||
| 402 | 407 | const ( | |
| 403 | - | dirLeft direction = iota | |
| 408 | + | dirLeft direction = iota + 1 | |
| 404 | 409 | dirRight | |
| 405 | 410 | dirUp | |
| 406 | 411 | 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 i | 57 | 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.H | 67 | 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.Pos | 69 | + | 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 the | 77 | + | // 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{}, false | 87 | return ports.OutputConfig{}, false | ||
| 83 | } | 88 | } | ||
| @@ -400,7 +405,7 @@ func (c *Core) settings(m *Monitor) { | |||||
| 400 | type direction uint8 | 405 | type direction uint8 | ||
| 401 | 406 | ||||
| 402 | const ( | 407 | const ( | ||
| 403 | - | dirLeft direction = iota | 408 | + | dirLeft direction = iota + 1 |
| 404 | dirRight | 409 | dirRight | ||
| 405 | dirUp | 410 | dirUp | ||
| 406 | dirDown | 411 | dirDown | ||
M internal/core/screens_test.go +54 -0
| @@ -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 | 264 | func TestWindowsOpenOnFocusedOutput(t *testing.T) { | |
| 211 | 265 | r := startMulti(t, nil, left, right) | |
| 212 | 266 | 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 | |||
| 11 | 11 | // OutputConfig configures one connector. Name is the connector (e.g. "DP-2"). | |
| 12 | 12 | // Mode is "WxH" (highest refresh) or "WxH@Hz" (closest refresh); empty picks the | |
| 13 | 13 | // 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. | |
| 16 | 19 | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 17 | 20 | // physical = logical × Scale. | |
| 18 | 21 | // Primary gets the focus and the pointer at startup, wherever it is placed. | |
| @@ -23,14 +26,36 @@ type OutputConfig struct { | |||
| 23 | 26 | Mode string | |
| 24 | 27 | Off bool | |
| 25 | 28 | 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 | |
| 28 | 33 | Primary bool | |
| 29 | 34 | HDR bool // opt-in to HDR10 on capable outputs | |
| 30 | 35 | SDRBrightness int // nits; default DefaultSDRBrightness | |
| 31 | 36 | 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 | 59 | // LayoutRules are the layout settings shared by the defaults, screens and | |
| 35 | 60 | // named workspaces. Zero MaxColumns and an empty Overflow inherit. | |
| 36 | 61 | 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 the | 12 | // 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 to | 14 | + | // 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 string | 26 | Mode string | ||
| 24 | Off bool | 27 | Off bool | ||
| 25 | Scale float64 | 28 | 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.Point | 30 | + | Pos *image.Point |
| 31 | + | // Anchor places the output against another connected one. | |||
| 32 | + | Anchor OutputAnchor // RelationNone: automatic | |||
| 28 | Primary bool | 33 | Primary bool | ||
| 29 | HDR bool // opt-in to HDR10 on capable outputs | 34 | HDR bool // opt-in to HDR10 on capable outputs | ||
| 30 | SDRBrightness int // nits; default DefaultSDRBrightness | 35 | SDRBrightness int // nits; default DefaultSDRBrightness | ||
| 31 | ScaleOnly bool | 36 | 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 and | 59 | // 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 { | ||