detached 4b8183a

feat(outputs): output.<name>.primary picks the startup output

commit
4b8183a112432614f8fec910530375787560062b
author
brice · · 2026-09-25 04:20 UTC
parent
6b65e8e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
197197 c.Outputs = append(c.Outputs, ports.OutputConfig{Name: name, ScaleOnly: true})
198198 return &c.Outputs[len(c.Outputs)-1]
199199 }
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+ }
200211 if base, ok := strings.CutSuffix(name, ".scale"); ok {
201212 name = base
202213 s, err := parseScale(value)
@@ -219,7 +230,7 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
219230 }
220231 override()
221232 e := entry()
222- o.Scale = e.Scale
233+ o.Scale, o.Primary = e.Scale, e.Primary
223234 *e = o
224235 continue
225236 }
@@ -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 = base212 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.Scale233+ o.Scale, o.Primary = e.Scale, e.Primary
223 *e = o234 *e = o
224 continue235 continue
225 }236 }
M internal/adapters/config/config_test.go +3 -1
@@ -59,6 +59,7 @@ output.DP-2 = 5120x2160@165.058
5959 output.HDMI-A-1 = off
6060 output.DP-1 = preferred
6161 output.DP-1.scale = 1.5
62+output.DP-1.primary = on
6263 bind.cmd+code:30 = quit
6364 render.direct-scanout = off
6465 `)
@@ -72,7 +73,7 @@ render.direct-scanout = off
7273 t.Fatalf("%+v", c)
7374 }
7475 // 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 {
7677 t.Fatalf("%+v", c.Outputs)
7778 }
7879 if c.Binds["Cmd+code:30"] != "quit" {
@@ -95,6 +96,7 @@ func TestWarningsKeepDefaults(t *testing.T) {
9596 {"output.DP-2 = big", "output.DP-2"},
9697 {"output.DP-2 = 1920x1080@0", "output.DP-2"},
9798 {"output.DP-2.scale = 0.5", "between 1 and 4"},
99+ {"output.DP-2.primary = yes", "output.DP-2.primary"},
98100 {"output.DP-2.scale = 3/0", "between 1 and 4"},
99101 {"bind.cmd+code:x = quit", "evdev key code"},
100102 {"nope = 1", "nope"},
@@ -59,6 +59,7 @@ output.DP-2 = 5120x2160@165.058
59 output.HDMI-A-1 = off59 output.HDMI-A-1 = off
60 output.DP-1 = preferred60 output.DP-1 = preferred
61 output.DP-1.scale = 1.561 output.DP-1.scale = 1.5
62+output.DP-1.primary = on
62 bind.cmd+code:30 = quit63 bind.cmd+code:30 = quit
63 render.direct-scanout = off64 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) {
310310 // is divided by the scale of the output under the pointer, so it moves the
311311 // same number of physical pixels on every output.
312312 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
315316 }
316317
317-// newPointer starts at the centre of the first output.
318+// newPointer starts at the centre of the primary output, else the first.
318319 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)
323322 return p
324323 }
325324
326325 // 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.
327328 func (p *pointer) setLayout(l ports.Layout) {
328329 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+ }
329340 p.x, p.y = l.Clamp(p.x, p.y, p.x, p.y)
330341 }
331342
@@ -342,6 +353,7 @@ func (p *pointer) move(dx, dy float64) (float64, float64) {
342353 }
343354
344355 func (p *pointer) set(x, y float64) (float64, float64) {
356+ p.touched = true
345357 p.x, p.y = p.layout.Clamp(p.x, p.y, x, y)
346358 return p.x, p.y
347359 }
@@ -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 the310 // 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 float64313+ x, y float64
314- layout ports.Layout314+ 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 p322 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 = l329 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.y358 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) {
5959 t.Fatal(p.x, p.y)
6060 }
6161 }
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 {
1313 info ports.OutputInfo
1414 mon *Monitor
1515 scale, cfgScale float64
16+ primary bool
1617 x int // logical left edge in the global layout
1718 layers []ports.LayerSurface
1819 placed []ports.SceneLayer
@@ -22,7 +23,7 @@ func (s *screen) name() string { return s.info.Name }
2223
2324 func (s *screen) placement() ports.OutputPlacement {
2425 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}
2627 }
2728
2829 // setScale resizes the logical output; layer placement follows.
@@ -99,6 +100,27 @@ func (c *Core) byName(name string) (*screen, *Workspace) {
99100 }
100101
101102 // 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+
102124 func (c *Core) configScale(name string) float64 {
103125 v := 1.0
104126 for _, o := range c.cfg.Outputs {
@@ -144,7 +166,13 @@ func (c *Core) addScreen(info ports.OutputInfo) {
144166 s.mon.Name, s.mon.Key = info.Name, info.Key()
145167 s.cfgScale = c.configScale(info.Name)
146168 s.setScale(s.cfgScale)
169+ s.primary = c.isPrimary(info.Name)
147170 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+ }
148176 c.named()
149177 c.settleGuests()
150178 }
@@ -13,6 +13,7 @@ type screen struct {
13 info ports.OutputInfo13 info ports.OutputInfo
14 mon *Monitor14 mon *Monitor
15 scale, cfgScale float6415 scale, cfgScale float64
16+ primary bool
16 x int // logical left edge in the global layout17 x int // logical left edge in the global layout
17 layers []ports.LayerSurface18 layers []ports.LayerSurface
18 placed []ports.SceneLayer19 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.0125 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) {
324324 t.Fatalf("%+v", out)
325325 }
326326 }
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
77 // right in config order, then in connection order.
88 // Scale is the output scale (0 means 1); layout works in logical pixels,
99 // 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.
1213 type OutputConfig struct {
1314 Name string
1415 Mode string
1516 Off bool
1617 Scale float64
18+ Primary bool
1719 ScaleOnly bool
1820 }
1921
@@ -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 not10+// 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 string14 Name string
14 Mode string15 Mode string
15 Off bool16 Off bool
16 Scale float6417 Scale float64
18+ Primary bool
17 ScaleOnly bool19 ScaleOnly bool
18 }20 }
19 21
M internal/ports/messages.go +2 -0
@@ -178,6 +178,8 @@ type OutputPlacement struct {
178178 Info OutputInfo
179179 X, Y, Width, Height int
180180 Scale float64
181+ // Primary is output.<name>.primary: the pointer starts on it.
182+ Primary bool
181183 }
182184
183185 // Contains reports whether the logical point is on the output.
@@ -178,6 +178,8 @@ type OutputPlacement struct {
178 Info OutputInfo178 Info OutputInfo
179 X, Y, Width, Height int179 X, Y, Width, Height int
180 Scale float64180 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.

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close