detached b87e882

Merge pull request 'fix(core): focus the lock surface of the focused output' (#1) from fix/lock-focus-output into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/1
commit
b87e8821267fa80891d2b81265d9a0cbdfd16ecf
author
bnema · · 2026-10-02 11:50 UTC
merge of
9a9b02b + 534f575 · diff against the first parent
signature
unsigned
4 files · +86 -11
M internal/adapters/wayland/presentation_test.go +14 -2
@@ -166,8 +166,20 @@ func TestPresentationFeedback(t *testing.T) {
166166 t.Fatalf("hidden feedback: %+v", ev)
167167 }
168168 commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
169- // Synchronize the resume before committing the next feedback.
170- s.display.Do(func() {})
169+ // Wait for the resume before committing the next feedback: the command
170+ // goroutine applies it in its own display round trip, which an empty Do
171+ // here may overtake.
172+ for deadline := time.Now().Add(2 * time.Second); ; {
173+ var visible bool
174+ s.display.Do(func() { visible = s.windows[w.ID].last.Visible })
175+ if visible {
176+ break
177+ }
178+ if time.Now().After(deadline) {
179+ t.Fatal("resume not applied")
180+ }
181+ time.Sleep(2 * time.Millisecond)
182+ }
171183 // Presented with the flip's time, counter and flags.
172184 a := feedback()
173185 commit()
@@ -166,8 +166,20 @@ func TestPresentationFeedback(t *testing.T) {
166 t.Fatalf("hidden feedback: %+v", ev)166 t.Fatalf("hidden feedback: %+v", ev)
167 }167 }
168 commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}168 commands <- ports.ConfigureWindow{ID: w.ID, Width: 100, Height: 100, Output: "HEADLESS-1", Visible: true}
169- // Synchronize the resume before committing the next feedback.169+ // Wait for the resume before committing the next feedback: the command
170- s.display.Do(func() {})170+ // goroutine applies it in its own display round trip, which an empty Do
171+ // here may overtake.
172+ for deadline := time.Now().Add(2 * time.Second); ; {
173+ var visible bool
174+ s.display.Do(func() { visible = s.windows[w.ID].last.Visible })
175+ if visible {
176+ break
177+ }
178+ if time.Now().After(deadline) {
179+ t.Fatal("resume not applied")
180+ }
181+ time.Sleep(2 * time.Millisecond)
182+ }
171 // Presented with the flip's time, counter and flags.183 // Presented with the flip's time, counter and flags.
172 a := feedback()184 a := feedback()
173 commit()185 commit()
M internal/core/core.go +1 -0
@@ -70,6 +70,7 @@ type Core struct {
7070 security ports.SecurityState
7171 lockSurfaces []ports.LockSurfacePlacement
7272 lockFocus WindowID
73+ lockPinned bool // lockFocus was clicked or typed into: it keeps focus
7374 inputKeys map[string]bool
7475 inputActive bool
7576 startup [][]string
@@ -70,6 +70,7 @@ type Core struct {
70 security ports.SecurityState70 security ports.SecurityState
71 lockSurfaces []ports.LockSurfacePlacement71 lockSurfaces []ports.LockSurfacePlacement
72 lockFocus WindowID72 lockFocus WindowID
73+ lockPinned bool // lockFocus was clicked or typed into: it keeps focus
73 inputKeys map[string]bool74 inputKeys map[string]bool
74 inputActive bool75 inputActive bool
75 startup [][]string76 startup [][]string
M internal/core/security.go +21 -9
@@ -21,7 +21,7 @@ func (c *Core) syncSecurity() bool {
2121 return false
2222 }
2323 c.security = state
24- c.lockSurfaces, c.lockFocus = nil, 0
24+ c.lockSurfaces, c.lockFocus, c.lockPinned = nil, 0, false
2525 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
2626 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
2727 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
@@ -135,18 +135,26 @@ func (c *Core) validLockID(id WindowID) bool {
135135 return false
136136 }
137137
138+// lockKeyboardFocus prefers the lock surface of the focused output, where the
139+// user is looking, then any other. A surface the user clicked or typed into is
140+// pinned and keeps focus. Until then, focus moves to the focused output's
141+// surface once it maps: lock clients map their surfaces one by one, in any
142+// order.
138143 func (c *Core) lockKeyboardFocus() WindowID {
139- if c.validLockID(c.lockFocus) {
144+ valid := c.validLockID(c.lockFocus)
145+ if valid && c.lockPinned {
140146 return c.lockFocus
141147 }
142- c.lockFocus = 0
143- // Prefer the focused output, where the user is looking, then any other.
144148 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
145149 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
146- c.lockFocus = s.ID
150+ c.lockFocus, c.lockPinned = s.ID, false
147151 return c.lockFocus
148152 }
149153 }
154+ if valid {
155+ return c.lockFocus
156+ }
157+ c.lockFocus, c.lockPinned = 0, false
150158 for _, sc := range c.screens {
151159 if s, ok := c.lockSurface(sc); ok {
152160 c.lockFocus = s.ID
@@ -239,6 +247,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
239247 switch v := ev.(type) {
240248 case ports.KeyEvent:
241249 if id := c.lockKeyboardFocus(); id != 0 {
250+ c.lockPinned = true // typing must not move focus mid-secret
242251 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})
243252 }
244253 case ports.PointerMotion:
@@ -272,10 +281,13 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
272281 delete(c.buttons, v.Button)
273282 }
274283 if c.validLockID(id) {
275- if v.Pressed && c.lockFocus != id {
276- c.lockFocus = id
277- if err := c.publishProtected(ctx); err != nil {
278- return err
284+ if v.Pressed {
285+ changed := c.lockFocus != id
286+ c.lockFocus, c.lockPinned = id, true
287+ if changed {
288+ if err := c.publishProtected(ctx); err != nil {
289+ return err
290+ }
279291 }
280292 }
281293 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
@@ -21,7 +21,7 @@ func (c *Core) syncSecurity() bool {
21 return false21 return false
22 }22 }
23 c.security = state23 c.security = state
24- c.lockSurfaces, c.lockFocus = nil, 024+ c.lockSurfaces, c.lockFocus, c.lockPinned = nil, 0, false
25 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}25 c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{}
26 c.pointer, c.grab, c.pointerOutput = 0, 0, ""26 c.pointer, c.grab, c.pointerOutput = 0, 0, ""
27 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 027 c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0
@@ -135,18 +135,26 @@ func (c *Core) validLockID(id WindowID) bool {
135 return false135 return false
136 }136 }
137 137
138+// lockKeyboardFocus prefers the lock surface of the focused output, where the
139+// user is looking, then any other. A surface the user clicked or typed into is
140+// pinned and keeps focus. Until then, focus moves to the focused output's
141+// surface once it maps: lock clients map their surfaces one by one, in any
142+// order.
138 func (c *Core) lockKeyboardFocus() WindowID {143 func (c *Core) lockKeyboardFocus() WindowID {
139- if c.validLockID(c.lockFocus) {144+ valid := c.validLockID(c.lockFocus)
145+ if valid && c.lockPinned {
140 return c.lockFocus146 return c.lockFocus
141 }147 }
142- c.lockFocus = 0
143- // Prefer the focused output, where the user is looking, then any other.
144 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {148 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
145 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {149 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
146- c.lockFocus = s.ID150+ c.lockFocus, c.lockPinned = s.ID, false
147 return c.lockFocus151 return c.lockFocus
148 }152 }
149 }153 }
154+ if valid {
155+ return c.lockFocus
156+ }
157+ c.lockFocus, c.lockPinned = 0, false
150 for _, sc := range c.screens {158 for _, sc := range c.screens {
151 if s, ok := c.lockSurface(sc); ok {159 if s, ok := c.lockSurface(sc); ok {
152 c.lockFocus = s.ID160 c.lockFocus = s.ID
@@ -239,6 +247,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
239 switch v := ev.(type) {247 switch v := ev.(type) {
240 case ports.KeyEvent:248 case ports.KeyEvent:
241 if id := c.lockKeyboardFocus(); id != 0 {249 if id := c.lockKeyboardFocus(); id != 0 {
250+ c.lockPinned = true // typing must not move focus mid-secret
242 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})251 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})
243 }252 }
244 case ports.PointerMotion:253 case ports.PointerMotion:
@@ -272,10 +281,13 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
272 delete(c.buttons, v.Button)281 delete(c.buttons, v.Button)
273 }282 }
274 if c.validLockID(id) {283 if c.validLockID(id) {
275- if v.Pressed && c.lockFocus != id {284+ if v.Pressed {
276- c.lockFocus = id285+ changed := c.lockFocus != id
277- if err := c.publishProtected(ctx); err != nil {286+ c.lockFocus, c.lockPinned = id, true
278- return err287+ if changed {
288+ if err := c.publishProtected(ctx); err != nil {
289+ return err
290+ }
279 }291 }
280 }292 }
281 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {293 if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil {
M internal/core/security_internal_test.go +50 -0
@@ -72,6 +72,56 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
7272 }
7373 }
7474
75+// Lock clients map their surfaces one by one: the focused output's surface
76+// takes keyboard focus when it maps after another one, unless the user
77+// already clicked or typed into a surface.
78+func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) {
79+ for _, tc := range []struct {
80+ name string
81+ input []ports.InputEvent // on A, before B maps
82+ want WindowID
83+ }{
84+ {"no input", nil, 11},
85+ {"click on focused A", []ports.InputEvent{ports.PointerMotion{X: 10, Y: 10}, ports.PointerButton{Button: 0x110, Pressed: true}}, 10},
86+ {"typing", []ports.InputEvent{ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}}, 10},
87+ } {
88+ t.Run(tc.name, func(t *testing.T) {
89+ c, _, _ := hiddenCaptureCommands(t)
90+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
91+ c.focusScreen = c.screenIndex("B")
92+ state := ports.SecurityState{Generation: 1, Protected: true}
93+ gate := portsmocks.NewMockSessionSecurity(t)
94+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
95+ c.ch.Security = gate
96+ c.syncSecurity()
97+ ctx := context.Background()
98+ mapped := func(s ...ports.LockSurfacePlacement) {
99+ t.Helper()
100+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: s})
101+ if err := c.publishProtected(ctx); err != nil {
102+ t.Fatal(err)
103+ }
104+ }
105+
106+ a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200}
107+ b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300}
108+ mapped(a)
109+ if c.keyboard.sent != 10 {
110+ t.Fatalf("only mapped surface not focused: %d", c.keyboard.sent)
111+ }
112+ for _, ev := range tc.input {
113+ if err := c.protectedInput(ctx, ev); err != nil {
114+ t.Fatal(err)
115+ }
116+ }
117+ mapped(a, b)
118+ if c.keyboard.sent != tc.want {
119+ t.Fatalf("keyboard focus %d, want %d", c.keyboard.sent, tc.want)
120+ }
121+ })
122+ }
123+}
124+
75125 func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
76126 c, _, commands := hiddenCaptureCommands(t)
77127 gate := portsmocks.NewMockSessionSecurity(t)
@@ -72,6 +72,56 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
72 }72 }
73 }73 }
74 74
75+// Lock clients map their surfaces one by one: the focused output's surface
76+// takes keyboard focus when it maps after another one, unless the user
77+// already clicked or typed into a surface.
78+func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) {
79+ for _, tc := range []struct {
80+ name string
81+ input []ports.InputEvent // on A, before B maps
82+ want WindowID
83+ }{
84+ {"no input", nil, 11},
85+ {"click on focused A", []ports.InputEvent{ports.PointerMotion{X: 10, Y: 10}, ports.PointerButton{Button: 0x110, Pressed: true}}, 10},
86+ {"typing", []ports.InputEvent{ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}}, 10},
87+ } {
88+ t.Run(tc.name, func(t *testing.T) {
89+ c, _, _ := hiddenCaptureCommands(t)
90+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
91+ c.focusScreen = c.screenIndex("B")
92+ state := ports.SecurityState{Generation: 1, Protected: true}
93+ gate := portsmocks.NewMockSessionSecurity(t)
94+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
95+ c.ch.Security = gate
96+ c.syncSecurity()
97+ ctx := context.Background()
98+ mapped := func(s ...ports.LockSurfacePlacement) {
99+ t.Helper()
100+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: s})
101+ if err := c.publishProtected(ctx); err != nil {
102+ t.Fatal(err)
103+ }
104+ }
105+
106+ a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200}
107+ b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300}
108+ mapped(a)
109+ if c.keyboard.sent != 10 {
110+ t.Fatalf("only mapped surface not focused: %d", c.keyboard.sent)
111+ }
112+ for _, ev := range tc.input {
113+ if err := c.protectedInput(ctx, ev); err != nil {
114+ t.Fatal(err)
115+ }
116+ }
117+ mapped(a, b)
118+ if c.keyboard.sent != tc.want {
119+ t.Fatalf("keyboard focus %d, want %d", c.keyboard.sent, tc.want)
120+ }
121+ })
122+ }
123+}
124+
75 func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {125 func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
76 c, _, commands := hiddenCaptureCommands(t)126 c, _, commands := hiddenCaptureCommands(t)
77 gate := portsmocks.NewMockSessionSecurity(t)127 gate := portsmocks.NewMockSessionSecurity(t)

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