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 Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.5.0
6 files · +77 -132
M README.md +1 -1
| @@ -81,7 +81,7 @@ auth.mode = password | |||
| 81 | 81 | ||
| 82 | 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 | 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 | ||
| @@ -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 | |||
| 9 | 9 | screens.off-after = 7m | |
| 10 | 10 | sleep.after = 20m | |
| 11 | 11 | ||
| 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. | |
| 14 | 14 | # lock.output = DP-2 | |
| 15 | 15 | ||
| 16 | 16 | # password (PAM service /etc/pam.d/neferafk) or pin. | |
| @@ -9,8 +9,8 @@ lock.after = 5m | |||||
| 9 | screens.off-after = 7m | 9 | screens.off-after = 7m | ||
| 10 | sleep.after = 20m | 10 | sleep.after = 20m | ||
| 11 | 11 | ||||
| 12 | - | # Output that shows the lock prompt (names from `wlr-randr`). Without it, the | 12 | + | # 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-2 | 14 | # 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 { | |||
| 36 | 36 | // lockConfig wires the session lock to its caller. Every callback runs on the | |
| 37 | 37 | // lock's owner goroutine. | |
| 38 | 38 | 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. | |
| 42 | 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 | 43 | Output string | |
| 45 | 44 | // OnLocked runs once when the compositor confirms the lock. | |
| 46 | 45 | OnLocked func() | |
| @@ -109,6 +108,7 @@ func (guiLocker) RunLock(ctx context.Context, cfg lockConfig) (err error) { | |||
| 109 | 108 | return fmt.Errorf("lock: %w", err) | |
| 110 | 109 | } | |
| 111 | 110 | outputs := conn.Outputs() | |
| 111 | + | // A stable order keeps the first reported setup failure deterministic. | |
| 112 | 112 | slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) }) | |
| 113 | 113 | for _, o := range outputs { | |
| 114 | 114 | if err = l.cover(o); err != nil { | |
| @@ -146,8 +146,7 @@ type locker struct { | |||
| 146 | 146 | lock *neferclient.Lock | |
| 147 | 147 | seat *neferclient.Seat | |
| 148 | 148 | 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 | |
| 151 | 150 | initial map[uint32]bool // outputs present at acquisition: their failures abort | |
| 152 | 151 | status lockStatus | |
| 153 | 152 | unlock bool // requested; honoured once locked | |
| @@ -171,12 +170,12 @@ func (l *locker) wait(ctx context.Context, retry <-chan time.Time) error { | |||
| 171 | 170 | case st, ok := <-l.cfg.Status: | |
| 172 | 171 | if ok { | |
| 173 | 172 | l.status = st | |
| 174 | - | l.invalidateHost() | |
| 173 | + | l.invalidateContent() | |
| 175 | 174 | } else { | |
| 176 | 175 | l.cfg.Status = nil | |
| 177 | 176 | } | |
| 178 | 177 | case <-l.cfg.Wake: | |
| 179 | - | l.invalidateHost() | |
| 178 | + | l.invalidateContent() | |
| 180 | 179 | case <-retry: | |
| 181 | 180 | } | |
| 182 | 181 | return l.err | |
| @@ -191,46 +190,49 @@ func (l *locker) cover(o neferclient.Output) error { | |||
| 191 | 190 | s := newScreen(l.conn, surf, o, false) | |
| 192 | 191 | s.view = func(f *nefergui.Frame) { l.view(f, s) } | |
| 193 | 192 | 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() | |
| 199 | 194 | return nil | |
| 200 | 195 | } | |
| 201 | 196 | ||
| 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 | + | } | |
| 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 | 214 | func (l *locker) view(f *nefergui.Frame, s *screen) { | |
| 209 | - | if s != l.host { | |
| 215 | + | if !l.shows(s) { | |
| 210 | 216 | f.Root(nefergui.Inline(cssBlack)) | |
| 211 | 217 | return | |
| 212 | 218 | } | |
| 213 | 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) { | |
| 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 | + | } | |
| 224 | 232 | } | |
| 225 | - | l.host = s | |
| 226 | - | s.invalidate() | |
| 227 | 233 | } | |
| 228 | 234 | ||
| 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" | |
| 234 | 236 | ||
| 235 | 237 | // setupPolicy: the first renderer failure on an output present at acquisition | |
| 236 | 238 | // 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 | 261 | return nil | |
| 260 | 262 | } | |
| 261 | 263 | ||
| 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. | |
| 285 | 265 | func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) { | |
| 286 | 266 | l.screens.Configure(id, w, h) | |
| 287 | - | l.forgetHost() | |
| 267 | + | l.rebind() | |
| 288 | 268 | } | |
| 289 | 269 | ||
| 290 | 270 | func (l *locker) FeedbackDone(id neferclient.SurfaceID) { | |
| 291 | 271 | l.screens.FeedbackDone(id) | |
| 292 | - | l.forgetHost() | |
| 272 | + | l.rebind() | |
| 293 | 273 | } | |
| 294 | 274 | ||
| 295 | 275 | func (l *locker) OutputRemoved(global uint32) { | |
| 296 | 276 | l.screens.OutputRemoved(global) | |
| 297 | 277 | delete(l.initial, global) | |
| 298 | - | l.forgetHost() | |
| 278 | + | l.rebind() | |
| 299 | 279 | } | |
| 300 | 280 | ||
| 301 | 281 | // OutputAdded covers an output that appeared after the lock was requested. | |
| @@ -314,7 +294,7 @@ func (l *locker) Locked() { | |||
| 314 | 294 | if l.err != nil { | |
| 315 | 295 | return | |
| 316 | 296 | } | |
| 317 | - | l.invalidateHost() | |
| 297 | + | l.invalidateContent() | |
| 318 | 298 | if l.cfg.OnLocked != nil { | |
| 319 | 299 | l.cfg.OnLocked() | |
| 320 | 300 | } | |
| @@ -324,20 +304,9 @@ func (l *locker) LockFinished() { | |||
| 324 | 304 | l.fail(errors.Join(errLockFinished, l.lock.Close())) | |
| 325 | 305 | } | |
| 326 | 306 | ||
| 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() } | |
| 341 | 310 | ||
| 342 | 311 | // Key handles the keys that are not secret text: Enter submits, Escape and | |
| 343 | 312 | // Ctrl+U clear. Before locked, Enter only clears. | |
| @@ -364,5 +333,5 @@ func (l *locker) Key(ev *neferclient.KeyEvent) { | |||
| 364 | 333 | ||
| 365 | 334 | func (l *locker) clearSecret() { | |
| 366 | 335 | l.secret.Wipe() | |
| 367 | - | l.invalidateHost() | |
| 336 | + | l.invalidateContent() | |
| 368 | 337 | } | |
| @@ -36,11 +36,10 @@ type lockState struct { | |||||
| 36 | // lockConfig wires the session lock to its caller. Every callback runs on the | 36 | // 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 it | 39 | + | // 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 string | 43 | 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.Lock | 146 | lock *neferclient.Lock | ||
| 147 | seat *neferclient.Seat | 147 | seat *neferclient.Seat | ||
| 148 | secret *neferclient.SecretBuffer | 148 | secret *neferclient.SecretBuffer | ||
| 149 | - | host *screen | 149 | + | 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 abort | 150 | initial map[uint32]bool // outputs present at acquisition: their failures abort | ||
| 152 | status lockStatus | 151 | status lockStatus | ||
| 153 | unlock bool // requested; honoured once locked | 152 | 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 = st | 172 | l.status = st | ||
| 174 | - | l.invalidateHost() | 173 | + | l.invalidateContent() |
| 175 | } else { | 174 | } else { | ||
| 176 | l.cfg.Status = nil | 175 | 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.err | 181 | 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 the | 193 | + | l.rebind() |
| 195 | - | // preferred one appears. | |||
| 196 | - | if l.host == nil || l.preferred(s) { | |||
| 197 | - | l.setHost(s) | |||
| 198 | - | } | |||
| 199 | return nil | 194 | 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.Output | 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 | + | } | |||
| 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 | return | 217 | 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 | - | return | 227 | + | 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 acquisition | 237 | // 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 a | 238 | // 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 nil | 261 | return nil | ||
| 260 | } | 262 | } | ||
| 261 | 263 | ||||
| 262 | - | // forgetHost picks a new host when the host screen goes away: the preferred | 264 | + | // 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 | return | 295 | 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 whichever | 308 | + | // 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 and | 311 | // 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 ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 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 | |
| 11 | 11 | // 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 | |
| 14 | 16 | } | |
| 15 | 17 | ||
| 16 | - | func TestKeyboardFocusMovesHostOnlyWithoutPreferredOutput(t *testing.T) { | |
| 18 | + | func TestLockContentShowsOnEveryOutputByDefault(t *testing.T) { | |
| 17 | 19 | hdmi := &screen{output: 49, name: "HDMI-A-1"} | |
| 18 | 20 | dp := &screen{output: 50, name: "DP-2"} | |
| 19 | 21 | ||
| 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)) | |
| 24 | 25 | ||
| 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)) | |
| 34 | 29 | } | |
| 35 | 30 | ||
| 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) { | |
| 39 | 32 | hdmi := &screen{output: 49, name: "HDMI-A-1"} | |
| 40 | 33 | 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)) | |
| 62 | 48 | } | |
| @@ -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 host | 10 | + | // 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 = usb | 41 | + | 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 = usb | 47 | + | 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) { | |||
| 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 | 336 | // covers reports whether an output already has a screen. | |
| 347 | 337 | func (ss *screens) covers(global uint32) bool { | |
| 348 | 338 | 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 { | |||
| 15 | 15 | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | |
| 16 | 16 | FadeDuration time.Duration | |
| 17 | 17 | 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. | |
| 22 | 21 | LockOutput string | |
| 23 | 22 | } | |
| 24 | 23 | ||
| 25 | 24 | // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output | |
| 26 | 25 | // names, which could never match. | |
| 27 | 26 | const MaxOutputName = 64 | |
| 27 | + | ||
| 28 | 28 | type AuthMode string | |
| 29 | 29 | ||
| 30 | 30 | const ( | |
| @@ -15,16 +15,16 @@ type Config struct { | |||||
| 15 | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | 15 | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | ||
| 16 | FadeDuration time.Duration | 16 | FadeDuration time.Duration | ||
| 17 | Auth AuthConfig | 17 | Auth AuthConfig | ||
| 18 | - | // LockOutput names the output (wl_output name, e.g. DP-2) that shows the | 18 | + | // LockOutput names the only output (wl_output name, e.g. DP-2) that |
| 19 | - | // lock prompt. Empty, or absent: the output whose lock surface has | 19 | + | // shows the lock prompt while it is connected. Empty, or absent: every |
| 20 | - | // keyboard focus, initially the first advertised one. A change applies | 20 | + | // output shows it. A change applies from the next lock. |
| 21 | - | // from the next lock. | |||
| 22 | LockOutput string | 21 | LockOutput string | ||
| 23 | } | 22 | } | ||
| 24 | 23 | ||||
| 25 | // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output | 24 | // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output | ||
| 26 | // names, which could never match. | 25 | // names, which could never match. | ||
| 27 | const MaxOutputName = 64 | 26 | const MaxOutputName = 64 | ||
| 27 | + | ||||
| 28 | type AuthMode string | 28 | type AuthMode string | ||
| 29 | 29 | ||||
| 30 | const ( | 30 | const ( | ||