detached 131f96f

refactor(lock): show the lock prompt on every output unless lock.output binds it (#11)

* refactor(lock): show the lock prompt on every output unless lock.output binds it

* perf(lock): redraw only the outputs that show the lock content
commit
131f96fdfe4bab35928592e175f28af57321ddf1
author
bnema · · 2026-10-03 08:42 UTC
committer
GitHub · · 2026-10-03 08:42 UTC
parent
c0db34c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.5.0
6 files · +77 -132
M README.md +1 -1
@@ -81,7 +81,7 @@ auth.mode = password
8181
8282 Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.
8383
84-The lock prompt shows on `lock.output` when that output is connected. Otherwise it follows the lock surface that has keyboard focus, which the compositor chooses. The other outputs show black. A change applies from the next lock. `wlr-randr` lists the output names.
84+The lock prompt shows on every output. Set `lock.output` to show it only on that output while it is connected; the other outputs then show black. A change applies from the next lock. `wlr-randr` lists the output names.
8585
8686 PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.
8787
@@ -81,7 +81,7 @@ auth.mode = password
81 81
82 Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.82 Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.
83 83
84-The lock prompt shows on `lock.output` when that output is connected. Otherwise it follows the lock surface that has keyboard focus, which the compositor chooses. The other outputs show black. A change applies from the next lock. `wlr-randr` lists the output names.84+The lock prompt shows on every output. Set `lock.output` to show it only on that output while it is connected; the other outputs then show black. A change applies from the next lock. `wlr-randr` lists the output names.
85 85
86 PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.86 PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.
87 87
M examples/config +2 -2
@@ -9,8 +9,8 @@ lock.after = 5m
99 screens.off-after = 7m
1010 sleep.after = 20m
1111
12-# Output that shows the lock prompt (names from `wlr-randr`). Without it, the
13-# prompt follows keyboard focus.
12+# Only output that shows the lock prompt (names from `wlr-randr`). Without it,
13+# every output shows the prompt.
1414 # lock.output = DP-2
1515
1616 # password (PAM service /etc/pam.d/neferafk) or pin.
@@ -9,8 +9,8 @@ lock.after = 5m
9 screens.off-after = 7m9 screens.off-after = 7m
10 sleep.after = 20m10 sleep.after = 20m
11 11
12-# Output that shows the lock prompt (names from `wlr-randr`). Without it, the12+# Only output that shows the lock prompt (names from `wlr-randr`). Without it,
13-# prompt follows keyboard focus.13+# every output shows the prompt.
14 # lock.output = DP-214 # lock.output = DP-2
15 15
16 # password (PAM service /etc/pam.d/neferafk) or pin.16 # password (PAM service /etc/pam.d/neferafk) or pin.
M internal/adapters/visual/guilock.go +43 -74
@@ -36,11 +36,10 @@ type lockState struct {
3636 // lockConfig wires the session lock to its caller. Every callback runs on the
3737 // lock's owner goroutine.
3838 type lockConfig struct {
39- // View builds the content of the output that hosts it: Output while it
40- // is covered, otherwise the one whose surface has keyboard focus,
41- // initially the lowest output. Other outputs show plain black.
39+ // View builds the lock content. It shows on every output, or only on
40+ // Output while that output is covered; other outputs then show black.
4241 View func(*nefergui.Frame, lockState)
43- // Output is the preferred host output name; empty for none.
42+ // Output binds the lock content to one output name; empty for all.
4443 Output string
4544 // OnLocked runs once when the compositor confirms the lock.
4645 OnLocked func()
@@ -109,6 +108,7 @@ func (guiLocker) RunLock(ctx context.Context, cfg lockConfig) (err error) {
109108 return fmt.Errorf("lock: %w", err)
110109 }
111110 outputs := conn.Outputs()
111+ // A stable order keeps the first reported setup failure deterministic.
112112 slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) })
113113 for _, o := range outputs {
114114 if err = l.cover(o); err != nil {
@@ -146,8 +146,7 @@ type locker struct {
146146 lock *neferclient.Lock
147147 seat *neferclient.Seat
148148 secret *neferclient.SecretBuffer
149- host *screen
150- focus *screen // has keyboard focus; may differ from host on the preferred output
149+ bound bool // Output is covered: only it shows the lock content
151150 initial map[uint32]bool // outputs present at acquisition: their failures abort
152151 status lockStatus
153152 unlock bool // requested; honoured once locked
@@ -171,12 +170,12 @@ func (l *locker) wait(ctx context.Context, retry <-chan time.Time) error {
171170 case st, ok := <-l.cfg.Status:
172171 if ok {
173172 l.status = st
174- l.invalidateHost()
173+ l.invalidateContent()
175174 } else {
176175 l.cfg.Status = nil
177176 }
178177 case <-l.cfg.Wake:
179- l.invalidateHost()
178+ l.invalidateContent()
180179 case <-retry:
181180 }
182181 return l.err
@@ -191,46 +190,49 @@ func (l *locker) cover(o neferclient.Output) error {
191190 s := newScreen(l.conn, surf, o, false)
192191 s.view = func(f *nefergui.Frame) { l.view(f, s) }
193192 l.add(s)
194- // Outputs are covered in ascending order: the lowest hosts until the
195- // preferred one appears.
196- if l.host == nil || l.preferred(s) {
197- l.setHost(s)
198- }
193+ l.rebind()
199194 return nil
200195 }
201196
202-// preferred reports whether s is on the configured host output.
203-func (l *locker) preferred(s *screen) bool {
204- return l.cfg.Output != "" && s.name == l.cfg.Output
197+// rebind recomputes whether the configured output is covered and redraws
198+// every output when that changes.
199+func (l *locker) rebind() {
200+ bound := false
201+ if l.cfg.Output != "" {
202+ for _, s := range l.byID {
203+ bound = bound || s.name == l.cfg.Output
204+ }
205+ }
206+ if bound != l.bound {
207+ l.bound = bound
208+ l.invalidateAll()
209+ }
205210 }
206211
207-// view shows the lock content on the host output and plain black elsewhere.
212+// view shows the lock content on every output, or only on the configured
213+// output while it is covered; the others show plain black.
208214 func (l *locker) view(f *nefergui.Frame, s *screen) {
209- if s != l.host {
215+ if !l.shows(s) {
210216 f.Root(nefergui.Inline(cssBlack))
211217 return
212218 }
213219 l.cfg.View(f, lockState{Mask: l.secret.Len(), Status: l.status})
214220 }
215221
216-const cssBlack = "width:100%;height:100%;background-color:#000000"
222+// shows reports whether s shows the lock content.
223+func (l *locker) shows(s *screen) bool { return !l.bound || s.name == l.cfg.Output }
217224
218-func (l *locker) setHost(s *screen) {
219- if l.host == s {
220- return
221- }
222- if l.host != nil {
223- l.host.invalidate()
225+// invalidateContent redraws the outputs that show the lock content; black
226+// outputs never change.
227+func (l *locker) invalidateContent() {
228+ for _, s := range l.byID {
229+ if l.shows(s) {
230+ s.invalidate()
231+ }
224232 }
225- l.host = s
226- s.invalidate()
227233 }
228234
229-func (l *locker) invalidateHost() {
230- if l.host != nil {
231- l.host.invalidate()
232- }
233-}
235+const cssBlack = "width:100%;height:100%;background-color:#000000"
234236
235237 // setupPolicy: the first renderer failure on an output present at acquisition
236238 // aborts the lock. A failure on a hotplugged output, or a rebuild after a
@@ -259,43 +261,21 @@ func (l *locker) errorPolicy(err error) error {
259261 return nil
260262 }
261263
262-// forgetHost picks a new host when the host screen goes away: the preferred
263-// output, else the focused screen, else the lowest output.
264-func (l *locker) forgetHost() {
265- if !l.has(l.focus) {
266- l.focus = nil
267- }
268- if l.host == nil || l.has(l.host) {
269- return
270- }
271- l.host = l.focus
272- for _, s := range l.byID {
273- switch {
274- case l.preferred(s):
275- l.host = s
276- case l.host == nil || l.host != l.focus && !l.preferred(l.host) && s.output < l.host.output:
277- l.host = s
278- }
279- }
280- if l.host != nil {
281- l.host.invalidate()
282- }
283-}
284-
264+// Configure, FeedbackDone and OutputRemoved may drop a screen.
285265 func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) {
286266 l.screens.Configure(id, w, h)
287- l.forgetHost()
267+ l.rebind()
288268 }
289269
290270 func (l *locker) FeedbackDone(id neferclient.SurfaceID) {
291271 l.screens.FeedbackDone(id)
292- l.forgetHost()
272+ l.rebind()
293273 }
294274
295275 func (l *locker) OutputRemoved(global uint32) {
296276 l.screens.OutputRemoved(global)
297277 delete(l.initial, global)
298- l.forgetHost()
278+ l.rebind()
299279 }
300280
301281 // OutputAdded covers an output that appeared after the lock was requested.
@@ -314,7 +294,7 @@ func (l *locker) Locked() {
314294 if l.err != nil {
315295 return
316296 }
317- l.invalidateHost()
297+ l.invalidateContent()
318298 if l.cfg.OnLocked != nil {
319299 l.cfg.OnLocked()
320300 }
@@ -324,20 +304,9 @@ func (l *locker) LockFinished() {
324304 l.fail(errors.Join(errLockFinished, l.lock.Close()))
325305 }
326306
327-func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
328- // The secret buffer belongs to the seat, so typing works whichever
329- // surface has focus: the preferred output keeps the prompt.
330- s := l.byID[id]
331- if s == nil || !focused {
332- return
333- }
334- l.focus = s
335- if l.host == nil || !l.preferred(l.host) {
336- l.setHost(s)
337- }
338-}
339-
340-func (l *locker) SecretChanged(int) { l.invalidateHost() }
307+// SecretChanged redraws the mask. The secret buffer belongs to the seat, so
308+// typing works whichever lock surface has keyboard focus.
309+func (l *locker) SecretChanged(int) { l.invalidateContent() }
341310
342311 // Key handles the keys that are not secret text: Enter submits, Escape and
343312 // Ctrl+U clear. Before locked, Enter only clears.
@@ -364,5 +333,5 @@ func (l *locker) Key(ev *neferclient.KeyEvent) {
364333
365334 func (l *locker) clearSecret() {
366335 l.secret.Wipe()
367- l.invalidateHost()
336+ l.invalidateContent()
368337 }
@@ -36,11 +36,10 @@ type lockState struct {
36 // lockConfig wires the session lock to its caller. Every callback runs on the36 // lockConfig wires the session lock to its caller. Every callback runs on the
37 // lock's owner goroutine.37 // lock's owner goroutine.
38 type lockConfig struct {38 type lockConfig struct {
39- // View builds the content of the output that hosts it: Output while it39+ // View builds the lock content. It shows on every output, or only on
40- // is covered, otherwise the one whose surface has keyboard focus,40+ // Output while that output is covered; other outputs then show black.
41- // initially the lowest output. Other outputs show plain black.
42 View func(*nefergui.Frame, lockState)41 View func(*nefergui.Frame, lockState)
43- // Output is the preferred host output name; empty for none.42+ // Output binds the lock content to one output name; empty for all.
44 Output string43 Output string
45 // OnLocked runs once when the compositor confirms the lock.44 // OnLocked runs once when the compositor confirms the lock.
46 OnLocked func()45 OnLocked func()
@@ -109,6 +108,7 @@ func (guiLocker) RunLock(ctx context.Context, cfg lockConfig) (err error) {
109 return fmt.Errorf("lock: %w", err)108 return fmt.Errorf("lock: %w", err)
110 }109 }
111 outputs := conn.Outputs()110 outputs := conn.Outputs()
111+ // A stable order keeps the first reported setup failure deterministic.
112 slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) })112 slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) })
113 for _, o := range outputs {113 for _, o := range outputs {
114 if err = l.cover(o); err != nil {114 if err = l.cover(o); err != nil {
@@ -146,8 +146,7 @@ type locker struct {
146 lock *neferclient.Lock146 lock *neferclient.Lock
147 seat *neferclient.Seat147 seat *neferclient.Seat
148 secret *neferclient.SecretBuffer148 secret *neferclient.SecretBuffer
149- host *screen149+ bound bool // Output is covered: only it shows the lock content
150- focus *screen // has keyboard focus; may differ from host on the preferred output
151 initial map[uint32]bool // outputs present at acquisition: their failures abort150 initial map[uint32]bool // outputs present at acquisition: their failures abort
152 status lockStatus151 status lockStatus
153 unlock bool // requested; honoured once locked152 unlock bool // requested; honoured once locked
@@ -171,12 +170,12 @@ func (l *locker) wait(ctx context.Context, retry <-chan time.Time) error {
171 case st, ok := <-l.cfg.Status:170 case st, ok := <-l.cfg.Status:
172 if ok {171 if ok {
173 l.status = st172 l.status = st
174- l.invalidateHost()173+ l.invalidateContent()
175 } else {174 } else {
176 l.cfg.Status = nil175 l.cfg.Status = nil
177 }176 }
178 case <-l.cfg.Wake:177 case <-l.cfg.Wake:
179- l.invalidateHost()178+ l.invalidateContent()
180 case <-retry:179 case <-retry:
181 }180 }
182 return l.err181 return l.err
@@ -191,46 +190,49 @@ func (l *locker) cover(o neferclient.Output) error {
191 s := newScreen(l.conn, surf, o, false)190 s := newScreen(l.conn, surf, o, false)
192 s.view = func(f *nefergui.Frame) { l.view(f, s) }191 s.view = func(f *nefergui.Frame) { l.view(f, s) }
193 l.add(s)192 l.add(s)
194- // Outputs are covered in ascending order: the lowest hosts until the193+ l.rebind()
195- // preferred one appears.
196- if l.host == nil || l.preferred(s) {
197- l.setHost(s)
198- }
199 return nil194 return nil
200 }195 }
201 196
202-// preferred reports whether s is on the configured host output.197+// rebind recomputes whether the configured output is covered and redraws
203-func (l *locker) preferred(s *screen) bool {198+// every output when that changes.
204- return l.cfg.Output != "" && s.name == l.cfg.Output199+func (l *locker) rebind() {
200+ bound := false
201+ if l.cfg.Output != "" {
202+ for _, s := range l.byID {
203+ bound = bound || s.name == l.cfg.Output
204+ }
205+ }
206+ if bound != l.bound {
207+ l.bound = bound
208+ l.invalidateAll()
209+ }
205 }210 }
206 211
207-// view shows the lock content on the host output and plain black elsewhere.212+// view shows the lock content on every output, or only on the configured
213+// output while it is covered; the others show plain black.
208 func (l *locker) view(f *nefergui.Frame, s *screen) {214 func (l *locker) view(f *nefergui.Frame, s *screen) {
209- if s != l.host {215+ if !l.shows(s) {
210 f.Root(nefergui.Inline(cssBlack))216 f.Root(nefergui.Inline(cssBlack))
211 return217 return
212 }218 }
213 l.cfg.View(f, lockState{Mask: l.secret.Len(), Status: l.status})219 l.cfg.View(f, lockState{Mask: l.secret.Len(), Status: l.status})
214 }220 }
215 221
216-const cssBlack = "width:100%;height:100%;background-color:#000000"222+// shows reports whether s shows the lock content.
223+func (l *locker) shows(s *screen) bool { return !l.bound || s.name == l.cfg.Output }
217 224
218-func (l *locker) setHost(s *screen) {225+// invalidateContent redraws the outputs that show the lock content; black
219- if l.host == s {226+// outputs never change.
220- return227+func (l *locker) invalidateContent() {
221- }228+ for _, s := range l.byID {
222- if l.host != nil {229+ if l.shows(s) {
223- l.host.invalidate()230+ s.invalidate()
231+ }
224 }232 }
225- l.host = s
226- s.invalidate()
227 }233 }
228 234
229-func (l *locker) invalidateHost() {235+const cssBlack = "width:100%;height:100%;background-color:#000000"
230- if l.host != nil {
231- l.host.invalidate()
232- }
233-}
234 236
235 // setupPolicy: the first renderer failure on an output present at acquisition237 // setupPolicy: the first renderer failure on an output present at acquisition
236 // aborts the lock. A failure on a hotplugged output, or a rebuild after a238 // aborts the lock. A failure on a hotplugged output, or a rebuild after a
@@ -259,43 +261,21 @@ func (l *locker) errorPolicy(err error) error {
259 return nil261 return nil
260 }262 }
261 263
262-// forgetHost picks a new host when the host screen goes away: the preferred264+// Configure, FeedbackDone and OutputRemoved may drop a screen.
263-// output, else the focused screen, else the lowest output.
264-func (l *locker) forgetHost() {
265- if !l.has(l.focus) {
266- l.focus = nil
267- }
268- if l.host == nil || l.has(l.host) {
269- return
270- }
271- l.host = l.focus
272- for _, s := range l.byID {
273- switch {
274- case l.preferred(s):
275- l.host = s
276- case l.host == nil || l.host != l.focus && !l.preferred(l.host) && s.output < l.host.output:
277- l.host = s
278- }
279- }
280- if l.host != nil {
281- l.host.invalidate()
282- }
283-}
284-
285 func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) {265 func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) {
286 l.screens.Configure(id, w, h)266 l.screens.Configure(id, w, h)
287- l.forgetHost()267+ l.rebind()
288 }268 }
289 269
290 func (l *locker) FeedbackDone(id neferclient.SurfaceID) {270 func (l *locker) FeedbackDone(id neferclient.SurfaceID) {
291 l.screens.FeedbackDone(id)271 l.screens.FeedbackDone(id)
292- l.forgetHost()272+ l.rebind()
293 }273 }
294 274
295 func (l *locker) OutputRemoved(global uint32) {275 func (l *locker) OutputRemoved(global uint32) {
296 l.screens.OutputRemoved(global)276 l.screens.OutputRemoved(global)
297 delete(l.initial, global)277 delete(l.initial, global)
298- l.forgetHost()278+ l.rebind()
299 }279 }
300 280
301 // OutputAdded covers an output that appeared after the lock was requested.281 // OutputAdded covers an output that appeared after the lock was requested.
@@ -314,7 +294,7 @@ func (l *locker) Locked() {
314 if l.err != nil {294 if l.err != nil {
315 return295 return
316 }296 }
317- l.invalidateHost()297+ l.invalidateContent()
318 if l.cfg.OnLocked != nil {298 if l.cfg.OnLocked != nil {
319 l.cfg.OnLocked()299 l.cfg.OnLocked()
320 }300 }
@@ -324,20 +304,9 @@ func (l *locker) LockFinished() {
324 l.fail(errors.Join(errLockFinished, l.lock.Close()))304 l.fail(errors.Join(errLockFinished, l.lock.Close()))
325 }305 }
326 306
327-func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {307+// SecretChanged redraws the mask. The secret buffer belongs to the seat, so
328- // The secret buffer belongs to the seat, so typing works whichever308+// typing works whichever lock surface has keyboard focus.
329- // surface has focus: the preferred output keeps the prompt.309+func (l *locker) SecretChanged(int) { l.invalidateContent() }
330- s := l.byID[id]
331- if s == nil || !focused {
332- return
333- }
334- l.focus = s
335- if l.host == nil || !l.preferred(l.host) {
336- l.setHost(s)
337- }
338-}
339-
340-func (l *locker) SecretChanged(int) { l.invalidateHost() }
341 310
342 // Key handles the keys that are not secret text: Enter submits, Escape and311 // Key handles the keys that are not secret text: Enter submits, Escape and
343 // Ctrl+U clear. Before locked, Enter only clears.312 // Ctrl+U clear. Before locked, Enter only clears.
@@ -364,5 +333,5 @@ func (l *locker) Key(ev *neferclient.KeyEvent) {
364 333
365 func (l *locker) clearSecret() {334 func (l *locker) clearSecret() {
366 l.secret.Wipe()335 l.secret.Wipe()
367- l.invalidateHost()336+ l.invalidateContent()
368 }337 }
M internal/adapters/visual/guilock_test.go +27 -41
@@ -7,56 +7,42 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10-// hostLocker builds a locker over screens keyed by surface ID. Only host
10+// boundLocker builds a locker over screens keyed by surface ID. Only output
1111 // selection runs here: neither drawing nor the connection is used.
12-func hostLocker(output string, byID map[neferclient.SurfaceID]*screen) *locker {
13- return &locker{cfg: lockConfig{Output: output}, screens: &screens{byID: byID}}
12+func boundLocker(output string, byID map[neferclient.SurfaceID]*screen) *locker {
13+ l := &locker{cfg: lockConfig{Output: output}, screens: &screens{byID: byID}}
14+ l.rebind()
15+ return l
1416 }
1517
16-func TestKeyboardFocusMovesHostOnlyWithoutPreferredOutput(t *testing.T) {
18+func TestLockContentShowsOnEveryOutputByDefault(t *testing.T) {
1719 hdmi := &screen{output: 49, name: "HDMI-A-1"}
1820 dp := &screen{output: 50, name: "DP-2"}
1921
20- l := hostLocker("", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
21- l.setHost(hdmi)
22- l.KeyboardFocus(2, true)
23- require.Same(t, dp, l.host, "without lock.output the prompt follows keyboard focus")
22+ l := boundLocker("", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
23+ require.True(t, l.shows(hdmi))
24+ require.True(t, l.shows(dp))
2425
25- l = hostLocker("DP-2", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
26- l.setHost(dp)
27- l.KeyboardFocus(1, true)
28- require.Same(t, dp, l.host, "the preferred output keeps the prompt")
29-
30- l = hostLocker("DP-9", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
31- l.setHost(hdmi)
32- l.KeyboardFocus(2, true)
33- require.Same(t, dp, l.host, "an absent preferred output falls back to keyboard focus")
26+ l = boundLocker("DP-9", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
27+ require.True(t, l.shows(hdmi), "an absent lock.output shows the content everywhere")
28+ require.True(t, l.shows(dp))
3429 }
3530
36-// forgetHost runs after a screen left byID: removed is the old host.
37-func TestForgetHostPrefersConfiguredOutputThenFocusThenLowest(t *testing.T) {
38- removed := func() *screen { return &screen{output: 10, name: "GONE-1"} }
31+func TestLockOutputBindsContentWhileCovered(t *testing.T) {
3932 hdmi := &screen{output: 49, name: "HDMI-A-1"}
4033 dp := &screen{output: 50, name: "DP-2"}
41- usb := &screen{output: 51, name: "DP-3"}
42- all := func() map[neferclient.SurfaceID]*screen {
43- return map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp, 3: usb}
44- }
45-
46- l := hostLocker("DP-2", all())
47- l.host = removed()
48- l.focus = usb
49- l.forgetHost()
50- require.Same(t, dp, l.host, "the preferred output first, even over focus")
51-
52- l = hostLocker("", all())
53- l.host = removed()
54- l.focus = usb
55- l.forgetHost()
56- require.Same(t, usb, l.host, "then the focused screen")
57-
58- l = hostLocker("", all())
59- l.host = removed()
60- l.forgetHost()
61- require.Same(t, hdmi, l.host, "then the lowest output")
34+ byID := map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp}
35+
36+ l := boundLocker("DP-2", byID)
37+ require.False(t, l.shows(hdmi), "other outputs show black")
38+ require.True(t, l.shows(dp))
39+
40+ delete(byID, 2) // DP-2 unplugged
41+ l.rebind()
42+ require.True(t, l.shows(hdmi), "the content returns to every output")
43+
44+ byID[3] = dp // DP-2 back
45+ l.rebind()
46+ require.False(t, l.shows(hdmi))
47+ require.True(t, l.shows(dp))
6248 }
@@ -7,56 +7,42 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
10-// hostLocker builds a locker over screens keyed by surface ID. Only host10+// boundLocker builds a locker over screens keyed by surface ID. Only output
11 // selection runs here: neither drawing nor the connection is used.11 // selection runs here: neither drawing nor the connection is used.
12-func hostLocker(output string, byID map[neferclient.SurfaceID]*screen) *locker {12+func boundLocker(output string, byID map[neferclient.SurfaceID]*screen) *locker {
13- return &locker{cfg: lockConfig{Output: output}, screens: &screens{byID: byID}}13+ l := &locker{cfg: lockConfig{Output: output}, screens: &screens{byID: byID}}
14+ l.rebind()
15+ return l
14 }16 }
15 17
16-func TestKeyboardFocusMovesHostOnlyWithoutPreferredOutput(t *testing.T) {18+func TestLockContentShowsOnEveryOutputByDefault(t *testing.T) {
17 hdmi := &screen{output: 49, name: "HDMI-A-1"}19 hdmi := &screen{output: 49, name: "HDMI-A-1"}
18 dp := &screen{output: 50, name: "DP-2"}20 dp := &screen{output: 50, name: "DP-2"}
19 21
20- l := hostLocker("", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})22+ l := boundLocker("", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
21- l.setHost(hdmi)23+ require.True(t, l.shows(hdmi))
22- l.KeyboardFocus(2, true)24+ require.True(t, l.shows(dp))
23- require.Same(t, dp, l.host, "without lock.output the prompt follows keyboard focus")
24 25
25- l = hostLocker("DP-2", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})26+ l = boundLocker("DP-9", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
26- l.setHost(dp)27+ require.True(t, l.shows(hdmi), "an absent lock.output shows the content everywhere")
27- l.KeyboardFocus(1, true)28+ require.True(t, l.shows(dp))
28- require.Same(t, dp, l.host, "the preferred output keeps the prompt")
29-
30- l = hostLocker("DP-9", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
31- l.setHost(hdmi)
32- l.KeyboardFocus(2, true)
33- require.Same(t, dp, l.host, "an absent preferred output falls back to keyboard focus")
34 }29 }
35 30
36-// forgetHost runs after a screen left byID: removed is the old host.31+func TestLockOutputBindsContentWhileCovered(t *testing.T) {
37-func TestForgetHostPrefersConfiguredOutputThenFocusThenLowest(t *testing.T) {
38- removed := func() *screen { return &screen{output: 10, name: "GONE-1"} }
39 hdmi := &screen{output: 49, name: "HDMI-A-1"}32 hdmi := &screen{output: 49, name: "HDMI-A-1"}
40 dp := &screen{output: 50, name: "DP-2"}33 dp := &screen{output: 50, name: "DP-2"}
41- usb := &screen{output: 51, name: "DP-3"}34+ byID := map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp}
42- all := func() map[neferclient.SurfaceID]*screen {35+
43- return map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp, 3: usb}36+ l := boundLocker("DP-2", byID)
44- }37+ require.False(t, l.shows(hdmi), "other outputs show black")
45-38+ require.True(t, l.shows(dp))
46- l := hostLocker("DP-2", all())39+
47- l.host = removed()40+ delete(byID, 2) // DP-2 unplugged
48- l.focus = usb41+ l.rebind()
49- l.forgetHost()42+ require.True(t, l.shows(hdmi), "the content returns to every output")
50- require.Same(t, dp, l.host, "the preferred output first, even over focus")43+
51-44+ byID[3] = dp // DP-2 back
52- l = hostLocker("", all())45+ l.rebind()
53- l.host = removed()46+ require.False(t, l.shows(hdmi))
54- l.focus = usb47+ require.True(t, l.shows(dp))
55- l.forgetHost()
56- require.Same(t, usb, l.host, "then the focused screen")
57-
58- l = hostLocker("", all())
59- l.host = removed()
60- l.forgetHost()
61- require.Same(t, hdmi, l.host, "then the lowest output")
62 }48 }
M internal/adapters/visual/screen.go +0 -10
@@ -333,16 +333,6 @@ func (ss *screens) OutputRemoved(global uint32) {
333333 }
334334 }
335335
336-// has reports whether s is still one of the screens.
337-func (ss *screens) has(s *screen) bool {
338- for _, t := range ss.byID {
339- if t == s {
340- return true
341- }
342- }
343- return false
344-}
345-
346336 // covers reports whether an output already has a screen.
347337 func (ss *screens) covers(global uint32) bool {
348338 for _, s := range ss.byID {
@@ -333,16 +333,6 @@ func (ss *screens) OutputRemoved(global uint32) {
333 }333 }
334 }334 }
335 335
336-// has reports whether s is still one of the screens.
337-func (ss *screens) has(s *screen) bool {
338- for _, t := range ss.byID {
339- if t == s {
340- return true
341- }
342- }
343- return false
344-}
345-
346 // covers reports whether an output already has a screen.336 // covers reports whether an output already has a screen.
347 func (ss *screens) covers(global uint32) bool {337 func (ss *screens) covers(global uint32) bool {
348 for _, s := range ss.byID {338 for _, s := range ss.byID {
M internal/ports/config.go +4 -4
@@ -15,16 +15,16 @@ type Config struct {
1515 LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration
1616 FadeDuration time.Duration
1717 Auth AuthConfig
18- // LockOutput names the output (wl_output name, e.g. DP-2) that shows the
19- // lock prompt. Empty, or absent: the output whose lock surface has
20- // keyboard focus, initially the first advertised one. A change applies
21- // from the next lock.
18+ // LockOutput names the only output (wl_output name, e.g. DP-2) that
19+ // shows the lock prompt while it is connected. Empty, or absent: every
20+ // output shows it. A change applies from the next lock.
2221 LockOutput string
2322 }
2423
2524 // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output
2625 // names, which could never match.
2726 const MaxOutputName = 64
27+
2828 type AuthMode string
2929
3030 const (
@@ -15,16 +15,16 @@ type Config struct {
15 LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration15 LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration
16 FadeDuration time.Duration16 FadeDuration time.Duration
17 Auth AuthConfig17 Auth AuthConfig
18- // LockOutput names the output (wl_output name, e.g. DP-2) that shows the18+ // LockOutput names the only output (wl_output name, e.g. DP-2) that
19- // lock prompt. Empty, or absent: the output whose lock surface has19+ // shows the lock prompt while it is connected. Empty, or absent: every
20- // keyboard focus, initially the first advertised one. A change applies20+ // output shows it. A change applies from the next lock.
21- // from the next lock.
22 LockOutput string21 LockOutput string
23 }22 }
24 23
25 // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output24 // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output
26 // names, which could never match.25 // names, which could never match.
27 const MaxOutputName = 6426 const MaxOutputName = 64
27+
28 type AuthMode string28 type AuthMode string
29 29
30 const (30 const (

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 2

Tags 9

↑↓ moveenter openesc close