feat(outputs): output.<name>.primary picks the startup output
- commit
- 4b8183a112432614f8fec910530375787560062b
- author
- brice · · 2026-09-25 04:20 UTC
- parent
- 6b65e8e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +94 -12
M internal/adapters/config/config.go +12 -1
| @@ -197,6 +197,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 197 | 197 | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) | |
| 198 | 198 | return &c.Outputs[len(c.Outputs)-1] | |
| 199 | 199 | } | |
| 200 | + | if base, ok := strings.CutSuffix(name, ".primary"); ok { | |
| 201 | + | name = base | |
| 202 | + | b, err := onOff(value) | |
| 203 | + | if err != nil { | |
| 204 | + | warn("%s: %v", key, err) | |
| 205 | + | continue | |
| 206 | + | } | |
| 207 | + | override() | |
| 208 | + | entry().Primary = b | |
| 209 | + | continue | |
| 210 | + | } | |
| 200 | 211 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | |
| 201 | 212 | name = base | |
| 202 | 213 | s, err := parseScale(value) | |
| @@ -219,7 +230,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 219 | 230 | } | |
| 220 | 231 | override() | |
| 221 | 232 | e := entry() | |
| 222 | - | o.Scale = e.Scale | |
| 233 | + | o.Scale, o.Primary = e.Scale, e.Primary | |
| 223 | 234 | *e = o | |
| 224 | 235 | continue | |
| 225 | 236 | } | |
| @@ -197,6 +197,17 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 197 | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) | 197 | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true}) | ||
| 198 | return &c.Outputs[len(c.Outputs)-1] | 198 | return &c.Outputs[len(c.Outputs)-1] | ||
| 199 | } | 199 | } | ||
| 200 | + | if base, ok := strings.CutSuffix(name, ".primary"); ok { | |||
| 201 | + | name = base | |||
| 202 | + | b, err := onOff(value) | |||
| 203 | + | if err != nil { | |||
| 204 | + | warn("%s: %v", key, err) | |||
| 205 | + | continue | |||
| 206 | + | } | |||
| 207 | + | override() | |||
| 208 | + | entry().Primary = b | |||
| 209 | + | continue | |||
| 210 | + | } | |||
| 200 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | 211 | if base, ok := strings.CutSuffix(name, ".scale"); ok { | ||
| 201 | name = base | 212 | name = base | ||
| 202 | s, err := parseScale(value) | 213 | s, err := parseScale(value) | ||
| @@ -219,7 +230,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 219 | } | 230 | } | ||
| 220 | override() | 231 | override() | ||
| 221 | e := entry() | 232 | e := entry() | ||
| 222 | - | o.Scale = e.Scale | 233 | + | o.Scale, o.Primary = e.Scale, e.Primary |
| 223 | *e = o | 234 | *e = o | ||
| 224 | continue | 235 | continue | ||
| 225 | } | 236 | } | ||
M internal/adapters/config/config_test.go +3 -1
| @@ -59,6 +59,7 @@ output.DP-2 = 5120x2160@165.058 | |||
| 59 | 59 | output.HDMI-A-1 = off | |
| 60 | 60 | output.DP-1 = preferred | |
| 61 | 61 | output.DP-1.scale = 1.5 | |
| 62 | + | output.DP-1.primary = on | |
| 62 | 63 | bind.cmd+code:30 = quit | |
| 63 | 64 | render.direct-scanout = off | |
| 64 | 65 | `) | |
| @@ -72,7 +73,7 @@ render.direct-scanout = off | |||
| 72 | 73 | t.Fatalf("%+v", c) | |
| 73 | 74 | } | |
| 74 | 75 | // Scale and mode lines combine in either order. | |
| 75 | - | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 { | |
| 76 | + | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 || !c.Outputs[2].Primary || c.Outputs[0].Primary { | |
| 76 | 77 | t.Fatalf("%+v", c.Outputs) | |
| 77 | 78 | } | |
| 78 | 79 | if c.Binds["Cmd+code:30"] != "quit" { | |
| @@ -95,6 +96,7 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||
| 95 | 96 | {"output.DP-2 = big", "output.DP-2"}, | |
| 96 | 97 | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, | |
| 97 | 98 | {"output.DP-2.scale = 0.5", "between 1 and 4"}, | |
| 99 | + | {"output.DP-2.primary = yes", "output.DP-2.primary"}, | |
| 98 | 100 | {"output.DP-2.scale = 3/0", "between 1 and 4"}, | |
| 99 | 101 | {"bind.cmd+code:x = quit", "evdev key code"}, | |
| 100 | 102 | {"nope = 1", "nope"}, | |
| @@ -59,6 +59,7 @@ output.DP-2 = 5120x2160@165.058 | |||||
| 59 | output.HDMI-A-1 = off | 59 | output.HDMI-A-1 = off | ||
| 60 | output.DP-1 = preferred | 60 | output.DP-1 = preferred | ||
| 61 | output.DP-1.scale = 1.5 | 61 | output.DP-1.scale = 1.5 | ||
| 62 | + | output.DP-1.primary = on | |||
| 62 | bind.cmd+code:30 = quit | 63 | bind.cmd+code:30 = quit | ||
| 63 | render.direct-scanout = off | 64 | render.direct-scanout = off | ||
| 64 | `) | 65 | `) | ||
| @@ -72,7 +73,7 @@ render.direct-scanout = off | |||||
| 72 | t.Fatalf("%+v", c) | 73 | t.Fatalf("%+v", c) | ||
| 73 | } | 74 | } | ||
| 74 | // Scale and mode lines combine in either order. | 75 | // Scale and mode lines combine in either order. | ||
| 75 | - | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 { | 76 | + | if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 || !c.Outputs[2].Primary || c.Outputs[0].Primary { |
| 76 | t.Fatalf("%+v", c.Outputs) | 77 | t.Fatalf("%+v", c.Outputs) | ||
| 77 | } | 78 | } | ||
| 78 | if c.Binds["Cmd+code:30"] != "quit" { | 79 | if c.Binds["Cmd+code:30"] != "quit" { | ||
| @@ -95,6 +96,7 @@ func TestWarningsKeepDefaults(t *testing.T) { | |||||
| 95 | {"output.DP-2 = big", "output.DP-2"}, | 96 | {"output.DP-2 = big", "output.DP-2"}, | ||
| 96 | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, | 97 | {"output.DP-2 = 1920x1080@0", "output.DP-2"}, | ||
| 97 | {"output.DP-2.scale = 0.5", "between 1 and 4"}, | 98 | {"output.DP-2.scale = 0.5", "between 1 and 4"}, | ||
| 99 | + | {"output.DP-2.primary = yes", "output.DP-2.primary"}, | |||
| 98 | {"output.DP-2.scale = 3/0", "between 1 and 4"}, | 100 | {"output.DP-2.scale = 3/0", "between 1 and 4"}, | ||
| 99 | {"bind.cmd+code:x = quit", "evdev key code"}, | 101 | {"bind.cmd+code:x = quit", "evdev key code"}, | ||
| 100 | {"nope = 1", "nope"}, | 102 | {"nope = 1", "nope"}, | ||
M internal/adapters/libinput/libinput.go +19 -7
| @@ -310,22 +310,33 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | |||
| 310 | 310 | // is divided by the scale of the output under the pointer, so it moves the | |
| 311 | 311 | // same number of physical pixels on every output. | |
| 312 | 312 | type pointer struct { | |
| 313 | - | x, y float64 | |
| 314 | - | layout ports.Layout | |
| 313 | + | x, y float64 | |
| 314 | + | layout ports.Layout | |
| 315 | + | touched bool // false until the user moves it: it follows the primary output | |
| 315 | 316 | } | |
| 316 | 317 | ||
| 317 | - | // newPointer starts at the centre of the first output. | |
| 318 | + | // newPointer starts at the centre of the primary output, else the first. | |
| 318 | 319 | func newPointer(l ports.Layout) *pointer { | |
| 319 | - | p := &pointer{layout: l} | |
| 320 | - | if len(l) > 0 { | |
| 321 | - | p.x, p.y = float64(l[0].X)+float64(l[0].Width)/2, float64(l[0].Y)+float64(l[0].Height)/2 | |
| 322 | - | } | |
| 320 | + | p := &pointer{} | |
| 321 | + | p.setLayout(l) | |
| 323 | 322 | return p | |
| 324 | 323 | } | |
| 325 | 324 | ||
| 326 | 325 | // setLayout keeps the pointer where it is, or clamps it onto an output. | |
| 326 | + | // Until it is first moved it stays centred on the primary output, which | |
| 327 | + | // may be plugged in after the others. | |
| 327 | 328 | func (p *pointer) setLayout(l ports.Layout) { | |
| 328 | 329 | p.layout = l | |
| 330 | + | if !p.touched && len(l) > 0 { | |
| 331 | + | o := l[0] | |
| 332 | + | for _, v := range l { | |
| 333 | + | if v.Primary { | |
| 334 | + | o = v | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | p.x, p.y = float64(o.X)+float64(o.Width)/2, float64(o.Y)+float64(o.Height)/2 | |
| 338 | + | return | |
| 339 | + | } | |
| 329 | 340 | p.x, p.y = l.Clamp(p.x, p.y, p.x, p.y) | |
| 330 | 341 | } | |
| 331 | 342 | ||
| @@ -342,6 +353,7 @@ func (p *pointer) move(dx, dy float64) (float64, float64) { | |||
| 342 | 353 | } | |
| 343 | 354 | ||
| 344 | 355 | func (p *pointer) set(x, y float64) (float64, float64) { | |
| 356 | + | p.touched = true | |
| 345 | 357 | p.x, p.y = p.layout.Clamp(p.x, p.y, x, y) | |
| 346 | 358 | return p.x, p.y | |
| 347 | 359 | } | |
| @@ -310,22 +310,33 @@ func hotkey(ke ports.KeyEvent) (hotkeyAction, int) { | |||||
| 310 | // is divided by the scale of the output under the pointer, so it moves the | 310 | // is divided by the scale of the output under the pointer, so it moves the | ||
| 311 | // same number of physical pixels on every output. | 311 | // same number of physical pixels on every output. | ||
| 312 | type pointer struct { | 312 | type pointer struct { | ||
| 313 | - | x, y float64 | 313 | + | x, y float64 |
| 314 | - | layout ports.Layout | 314 | + | layout ports.Layout |
| 315 | + | touched bool // false until the user moves it: it follows the primary output | |||
| 315 | } | 316 | } | ||
| 316 | 317 | ||||
| 317 | - | // newPointer starts at the centre of the first output. | 318 | + | // newPointer starts at the centre of the primary output, else the first. |
| 318 | func newPointer(l ports.Layout) *pointer { | 319 | func newPointer(l ports.Layout) *pointer { | ||
| 319 | - | p := &pointer{layout: l} | 320 | + | p := &pointer{} |
| 320 | - | if len(l) > 0 { | 321 | + | p.setLayout(l) |
| 321 | - | p.x, p.y = float64(l[0].X)+float64(l[0].Width)/2, float64(l[0].Y)+float64(l[0].Height)/2 | |||
| 322 | - | } | |||
| 323 | return p | 322 | return p | ||
| 324 | } | 323 | } | ||
| 325 | 324 | ||||
| 326 | // setLayout keeps the pointer where it is, or clamps it onto an output. | 325 | // setLayout keeps the pointer where it is, or clamps it onto an output. | ||
| 326 | + | // Until it is first moved it stays centred on the primary output, which | |||
| 327 | + | // may be plugged in after the others. | |||
| 327 | func (p *pointer) setLayout(l ports.Layout) { | 328 | func (p *pointer) setLayout(l ports.Layout) { | ||
| 328 | p.layout = l | 329 | p.layout = l | ||
| 330 | + | if !p.touched && len(l) > 0 { | |||
| 331 | + | o := l[0] | |||
| 332 | + | for _, v := range l { | |||
| 333 | + | if v.Primary { | |||
| 334 | + | o = v | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | p.x, p.y = float64(o.X)+float64(o.Width)/2, float64(o.Y)+float64(o.Height)/2 | |||
| 338 | + | return | |||
| 339 | + | } | |||
| 329 | p.x, p.y = l.Clamp(p.x, p.y, p.x, p.y) | 340 | p.x, p.y = l.Clamp(p.x, p.y, p.x, p.y) | ||
| 330 | } | 341 | } | ||
| 331 | 342 | ||||
| @@ -342,6 +353,7 @@ func (p *pointer) move(dx, dy float64) (float64, float64) { | |||||
| 342 | } | 353 | } | ||
| 343 | 354 | ||||
| 344 | func (p *pointer) set(x, y float64) (float64, float64) { | 355 | func (p *pointer) set(x, y float64) (float64, float64) { | ||
| 356 | + | p.touched = true | |||
| 345 | p.x, p.y = p.layout.Clamp(p.x, p.y, x, y) | 357 | p.x, p.y = p.layout.Clamp(p.x, p.y, x, y) | ||
| 346 | return p.x, p.y | 358 | return p.x, p.y | ||
| 347 | } | 359 | } | ||
M internal/adapters/libinput/libinput_test.go +10 -0
| @@ -59,3 +59,13 @@ func TestPointerAcrossOutputs(t *testing.T) { | |||
| 59 | 59 | t.Fatal(p.x, p.y) | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | + | ||
| 63 | + | func TestPointerStartsOnPrimary(t *testing.T) { | |
| 64 | + | p := newPointer(ports.Layout{ | |
| 65 | + | {Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 50, Scale: 1}, | |
| 66 | + | {Info: ports.OutputInfo{Name: "B"}, X: 100, Width: 200, Height: 100, Scale: 1, Primary: true}, | |
| 67 | + | }) | |
| 68 | + | if p.x != 200 || p.y != 50 { | |
| 69 | + | t.Fatal(p.x, p.y) | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -59,3 +59,13 @@ func TestPointerAcrossOutputs(t *testing.T) { | |||||
| 59 | t.Fatal(p.x, p.y) | 59 | t.Fatal(p.x, p.y) | ||
| 60 | } | 60 | } | ||
| 61 | } | 61 | } | ||
| 62 | + | ||||
| 63 | + | func TestPointerStartsOnPrimary(t *testing.T) { | |||
| 64 | + | p := newPointer(ports.Layout{ | |||
| 65 | + | {Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 50, Scale: 1}, | |||
| 66 | + | {Info: ports.OutputInfo{Name: "B"}, X: 100, Width: 200, Height: 100, Scale: 1, Primary: true}, | |||
| 67 | + | }) | |||
| 68 | + | if p.x != 200 || p.y != 50 { | |||
| 69 | + | t.Fatal(p.x, p.y) | |||
| 70 | + | } | |||
| 71 | + | } | |||
M internal/core/screens.go +29 -1
| @@ -13,6 +13,7 @@ type screen struct { | |||
| 13 | 13 | info ports.OutputInfo | |
| 14 | 14 | mon *Monitor | |
| 15 | 15 | scale, cfgScale float64 | |
| 16 | + | primary bool | |
| 16 | 17 | x int // logical left edge in the global layout | |
| 17 | 18 | layers []ports.LayerSurface | |
| 18 | 19 | placed []ports.SceneLayer | |
| @@ -22,7 +23,7 @@ func (s *screen) name() string { return s.info.Name } | |||
| 22 | 23 | ||
| 23 | 24 | func (s *screen) placement() ports.OutputPlacement { | |
| 24 | 25 | o := s.mon.Output() | |
| 25 | - | return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale} | |
| 26 | + | return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary} | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | // setScale resizes the logical output; layer placement follows. | |
| @@ -99,6 +100,27 @@ func (c *Core) byName(name string) (*screen, *Workspace) { | |||
| 99 | 100 | } | |
| 100 | 101 | ||
| 101 | 102 | // configScale is output.<name>.scale, else 1. | |
| 103 | + | func (c *Core) isPrimary(name string) bool { | |
| 104 | + | for _, o := range c.cfg.Outputs { | |
| 105 | + | if o.Name == name && o.Primary { | |
| 106 | + | return true | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // anyWindow reports whether any workspace holds a window. | |
| 113 | + | func (c *Core) anyWindow() bool { | |
| 114 | + | for _, s := range c.screens { | |
| 115 | + | for _, w := range s.mon.all() { | |
| 116 | + | if !w.empty() { | |
| 117 | + | return true | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | ||
| 102 | 124 | func (c *Core) configScale(name string) float64 { | |
| 103 | 125 | v := 1.0 | |
| 104 | 126 | for _, o := range c.cfg.Outputs { | |
| @@ -144,7 +166,13 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 144 | 166 | s.mon.Name, s.mon.Key = info.Name, info.Key() | |
| 145 | 167 | s.cfgScale = c.configScale(info.Name) | |
| 146 | 168 | s.setScale(s.cfgScale) | |
| 169 | + | s.primary = c.isPrimary(info.Name) | |
| 147 | 170 | c.order() | |
| 171 | + | // The primary output takes the focus while nothing is open yet | |
| 172 | + | // (startup). Later, plugging it in never moves the user. | |
| 173 | + | if s.primary && !c.anyWindow() { | |
| 174 | + | c.focusScreen = slices.Index(c.screens, s) | |
| 175 | + | } | |
| 148 | 176 | c.named() | |
| 149 | 177 | c.settleGuests() | |
| 150 | 178 | } | |
| @@ -13,6 +13,7 @@ type screen struct { | |||||
| 13 | info ports.OutputInfo | 13 | info ports.OutputInfo | ||
| 14 | mon *Monitor | 14 | mon *Monitor | ||
| 15 | scale, cfgScale float64 | 15 | scale, cfgScale float64 | ||
| 16 | + | primary bool | |||
| 16 | x int // logical left edge in the global layout | 17 | x int // logical left edge in the global layout | ||
| 17 | layers []ports.LayerSurface | 18 | layers []ports.LayerSurface | ||
| 18 | placed []ports.SceneLayer | 19 | placed []ports.SceneLayer | ||
| @@ -22,7 +23,7 @@ func (s *screen) name() string { return s.info.Name } | |||||
| 22 | 23 | ||||
| 23 | func (s *screen) placement() ports.OutputPlacement { | 24 | func (s *screen) placement() ports.OutputPlacement { | ||
| 24 | o := s.mon.Output() | 25 | o := s.mon.Output() | ||
| 25 | - | return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale} | 26 | + | return ports.OutputPlacement{Info: s.info, X: s.x, Width: o.W, Height: o.H, Scale: s.scale, Primary: s.primary} |
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 28 | // setScale resizes the logical output; layer placement follows. | 29 | // setScale resizes the logical output; layer placement follows. | ||
| @@ -99,6 +100,27 @@ func (c *Core) byName(name string) (*screen, *Workspace) { | |||||
| 99 | } | 100 | } | ||
| 100 | 101 | ||||
| 101 | // configScale is output.<name>.scale, else 1. | 102 | // configScale is output.<name>.scale, else 1. | ||
| 103 | + | func (c *Core) isPrimary(name string) bool { | |||
| 104 | + | for _, o := range c.cfg.Outputs { | |||
| 105 | + | if o.Name == name && o.Primary { | |||
| 106 | + | return true | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | return false | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // anyWindow reports whether any workspace holds a window. | |||
| 113 | + | func (c *Core) anyWindow() bool { | |||
| 114 | + | for _, s := range c.screens { | |||
| 115 | + | for _, w := range s.mon.all() { | |||
| 116 | + | if !w.empty() { | |||
| 117 | + | return true | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | ||||
| 102 | func (c *Core) configScale(name string) float64 { | 124 | func (c *Core) configScale(name string) float64 { | ||
| 103 | v := 1.0 | 125 | v := 1.0 | ||
| 104 | for _, o := range c.cfg.Outputs { | 126 | for _, o := range c.cfg.Outputs { | ||
| @@ -144,7 +166,13 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||||
| 144 | s.mon.Name, s.mon.Key = info.Name, info.Key() | 166 | s.mon.Name, s.mon.Key = info.Name, info.Key() | ||
| 145 | s.cfgScale = c.configScale(info.Name) | 167 | s.cfgScale = c.configScale(info.Name) | ||
| 146 | s.setScale(s.cfgScale) | 168 | s.setScale(s.cfgScale) | ||
| 169 | + | s.primary = c.isPrimary(info.Name) | |||
| 147 | c.order() | 170 | c.order() | ||
| 171 | + | // The primary output takes the focus while nothing is open yet | |||
| 172 | + | // (startup). Later, plugging it in never moves the user. | |||
| 173 | + | if s.primary && !c.anyWindow() { | |||
| 174 | + | c.focusScreen = slices.Index(c.screens, s) | |||
| 175 | + | } | |||
| 148 | c.named() | 176 | c.named() | ||
| 149 | c.settleGuests() | 177 | c.settleGuests() | ||
| 150 | } | 178 | } | ||
M internal/core/screens_test.go +15 -0
| @@ -324,3 +324,18 @@ func TestModeChangeKeepsScreen(t *testing.T) { | |||
| 324 | 324 | t.Fatalf("%+v", out) | |
| 325 | 325 | } | |
| 326 | 326 | } | |
| 327 | + | ||
| 328 | + | func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) { | |
| 329 | + | r := startMulti(t, func(c *ports.Config) { | |
| 330 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}} | |
| 331 | + | }, left, right) | |
| 332 | + | out := lastOutputs(t, r.commands) | |
| 333 | + | // Placed right of DP-1, but focused. | |
| 334 | + | if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary { | |
| 335 | + | t.Fatalf("%+v", out) | |
| 336 | + | } | |
| 337 | + | set := r.mapWindow(t, 1) | |
| 338 | + | if got := shown(set); len(got["DP-2"]) != 1 { | |
| 339 | + | t.Fatal(got) | |
| 340 | + | } | |
| 341 | + | } | |
| @@ -324,3 +324,18 @@ func TestModeChangeKeepsScreen(t *testing.T) { | |||||
| 324 | t.Fatalf("%+v", out) | 324 | t.Fatalf("%+v", out) | ||
| 325 | } | 325 | } | ||
| 326 | } | 326 | } | ||
| 327 | + | ||||
| 328 | + | func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) { | |||
| 329 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 330 | + | c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}} | |||
| 331 | + | }, left, right) | |||
| 332 | + | out := lastOutputs(t, r.commands) | |||
| 333 | + | // Placed right of DP-1, but focused. | |||
| 334 | + | if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary { | |||
| 335 | + | t.Fatalf("%+v", out) | |||
| 336 | + | } | |||
| 337 | + | set := r.mapWindow(t, 1) | |||
| 338 | + | if got := shown(set); len(got["DP-2"]) != 1 { | |||
| 339 | + | t.Fatal(got) | |||
| 340 | + | } | |||
| 341 | + | } | |||
M internal/ports/config.go +4 -2
| @@ -7,13 +7,15 @@ package ports | |||
| 7 | 7 | // right in config order, then in connection order. | |
| 8 | 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | |
| 9 | 9 | // physical = logical × Scale. | |
| 10 | - | // ScaleOnly marks an entry set by output.<name>.scale alone: it does not | |
| 11 | - | // select the connector. | |
| 10 | + | // Primary gets the focus and the pointer at startup, wherever it is placed. | |
| 11 | + | // ScaleOnly marks an entry set by output.<name>.scale or .primary alone: it | |
| 12 | + | // does not select the connector. | |
| 12 | 13 | type OutputConfig struct { | |
| 13 | 14 | Name string | |
| 14 | 15 | Mode string | |
| 15 | 16 | Off bool | |
| 16 | 17 | Scale float64 | |
| 18 | + | Primary bool | |
| 17 | 19 | ScaleOnly bool | |
| 18 | 20 | } | |
| 19 | 21 | ||
| @@ -7,13 +7,15 @@ package ports | |||||
| 7 | // right in config order, then in connection order. | 7 | // right in config order, then in connection order. | ||
| 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | 8 | // Scale is the output scale (0 means 1); layout works in logical pixels, | ||
| 9 | // physical = logical × Scale. | 9 | // physical = logical × Scale. | ||
| 10 | - | // ScaleOnly marks an entry set by output.<name>.scale alone: it does not | 10 | + | // Primary gets the focus and the pointer at startup, wherever it is placed. |
| 11 | - | // select the connector. | 11 | + | // ScaleOnly marks an entry set by output.<name>.scale or .primary alone: it |
| 12 | + | // does not select the connector. | |||
| 12 | type OutputConfig struct { | 13 | type OutputConfig struct { | ||
| 13 | Name string | 14 | Name string | ||
| 14 | Mode string | 15 | Mode string | ||
| 15 | Off bool | 16 | Off bool | ||
| 16 | Scale float64 | 17 | Scale float64 | ||
| 18 | + | Primary bool | |||
| 17 | ScaleOnly bool | 19 | ScaleOnly bool | ||
| 18 | } | 20 | } | ||
| 19 | 21 | ||||
M internal/ports/messages.go +2 -0
| @@ -178,6 +178,8 @@ type OutputPlacement struct { | |||
| 178 | 178 | Info OutputInfo | |
| 179 | 179 | X, Y, Width, Height int | |
| 180 | 180 | Scale float64 | |
| 181 | + | // Primary is output.<name>.primary: the pointer starts on it. | |
| 182 | + | Primary bool | |
| 181 | 183 | } | |
| 182 | 184 | ||
| 183 | 185 | // Contains reports whether the logical point is on the output. | |
| @@ -178,6 +178,8 @@ type OutputPlacement struct { | |||||
| 178 | Info OutputInfo | 178 | Info OutputInfo | ||
| 179 | X, Y, Width, Height int | 179 | X, Y, Width, Height int | ||
| 180 | Scale float64 | 180 | Scale float64 | ||
| 181 | + | // Primary is output.<name>.primary: the pointer starts on it. | |||
| 182 | + | Primary bool | |||
| 181 | } | 183 | } | ||
| 182 | 184 | ||||
| 183 | // Contains reports whether the logical point is on the output. | 185 | // Contains reports whether the logical point is on the output. | ||