detached 34fd4a7

fix(core): focus the lock surface of the focused output once it maps

commit
34fd4a785b539ce19da8555a607fcb587f6464c9
author
brice · · 2026-10-02 09:58 UTC
parent
9a9b02b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +46 -6
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+ lockClicked bool // lockFocus was chosen by a click: 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+ lockClicked bool // lockFocus was chosen by a click: 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 +13 -6
@@ -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.lockClicked = 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,25 @@ 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 keeps focus.
140+// Without a click, focus moves to the focused output's surface once it maps:
141+// lock clients map their surfaces one by one, in any order.
138142 func (c *Core) lockKeyboardFocus() WindowID {
139- if c.validLockID(c.lockFocus) {
143+ valid := c.validLockID(c.lockFocus)
144+ if valid && c.lockClicked {
140145 return c.lockFocus
141146 }
142- c.lockFocus = 0
143- // Prefer the focused output, where the user is looking, then any other.
144147 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
145148 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
146- c.lockFocus = s.ID
149+ c.lockFocus, c.lockClicked = s.ID, false
147150 return c.lockFocus
148151 }
149152 }
153+ if valid {
154+ return c.lockFocus
155+ }
156+ c.lockFocus, c.lockClicked = 0, false
150157 for _, sc := range c.screens {
151158 if s, ok := c.lockSurface(sc); ok {
152159 c.lockFocus = s.ID
@@ -273,7 +280,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
273280 }
274281 if c.validLockID(id) {
275282 if v.Pressed && c.lockFocus != id {
276- c.lockFocus = id
283+ c.lockFocus, c.lockClicked = id, true
277284 if err := c.publishProtected(ctx); err != nil {
278285 return err
279286 }
@@ -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.lockClicked = 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,25 @@ 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 keeps focus.
140+// Without a click, focus moves to the focused output's surface once it maps:
141+// lock clients map their surfaces one by one, in any order.
138 func (c *Core) lockKeyboardFocus() WindowID {142 func (c *Core) lockKeyboardFocus() WindowID {
139- if c.validLockID(c.lockFocus) {143+ valid := c.validLockID(c.lockFocus)
144+ if valid && c.lockClicked {
140 return c.lockFocus145 return c.lockFocus
141 }146 }
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) {147 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
145 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {148 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
146- c.lockFocus = s.ID149+ c.lockFocus, c.lockClicked = s.ID, false
147 return c.lockFocus150 return c.lockFocus
148 }151 }
149 }152 }
153+ if valid {
154+ return c.lockFocus
155+ }
156+ c.lockFocus, c.lockClicked = 0, false
150 for _, sc := range c.screens {157 for _, sc := range c.screens {
151 if s, ok := c.lockSurface(sc); ok {158 if s, ok := c.lockSurface(sc); ok {
152 c.lockFocus = s.ID159 c.lockFocus = s.ID
@@ -273,7 +280,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
273 }280 }
274 if c.validLockID(id) {281 if c.validLockID(id) {
275 if v.Pressed && c.lockFocus != id {282 if v.Pressed && c.lockFocus != id {
276- c.lockFocus = id283+ c.lockFocus, c.lockClicked = id, true
277 if err := c.publishProtected(ctx); err != nil {284 if err := c.publishProtected(ctx); err != nil {
278 return err285 return err
279 }286 }
M internal/core/security_internal_test.go +32 -0
@@ -72,6 +72,38 @@ 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+// clicked a surface.
78+func TestLockFocusMovesToFocusedOutputSurfaceUntilClicked(t *testing.T) {
79+ c, _, _ := hiddenCaptureCommands(t)
80+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
81+ c.focusScreen = c.screenIndex("B")
82+ state := ports.SecurityState{Generation: 1, Protected: true}
83+ gate := portsmocks.NewMockSessionSecurity(t)
84+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
85+ c.ch.Security = gate
86+ c.syncSecurity()
87+ focusB := c.focusScreen
88+
89+ a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200}
90+ b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300}
91+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a}})
92+ if got := c.lockKeyboardFocus(); got != 10 {
93+ t.Fatalf("only mapped surface not focused: %d", got)
94+ }
95+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}})
96+ if got := c.lockKeyboardFocus(); got != 11 {
97+ t.Fatalf("focused output surface not focused: %d", got)
98+ }
99+
100+ c.lockFocus, c.lockClicked = 10, true // as a click on A does
101+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}})
102+ if got := c.lockKeyboardFocus(); got != 10 || c.focusScreen != focusB {
103+ t.Fatalf("clicked surface lost focus: %d", got)
104+ }
105+}
106+
75107 func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
76108 c, _, commands := hiddenCaptureCommands(t)
77109 gate := portsmocks.NewMockSessionSecurity(t)
@@ -72,6 +72,38 @@ 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+// clicked a surface.
78+func TestLockFocusMovesToFocusedOutputSurfaceUntilClicked(t *testing.T) {
79+ c, _, _ := hiddenCaptureCommands(t)
80+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
81+ c.focusScreen = c.screenIndex("B")
82+ state := ports.SecurityState{Generation: 1, Protected: true}
83+ gate := portsmocks.NewMockSessionSecurity(t)
84+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
85+ c.ch.Security = gate
86+ c.syncSecurity()
87+ focusB := c.focusScreen
88+
89+ a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200}
90+ b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300}
91+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a}})
92+ if got := c.lockKeyboardFocus(); got != 10 {
93+ t.Fatalf("only mapped surface not focused: %d", got)
94+ }
95+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}})
96+ if got := c.lockKeyboardFocus(); got != 11 {
97+ t.Fatalf("focused output surface not focused: %d", got)
98+ }
99+
100+ c.lockFocus, c.lockClicked = 10, true // as a click on A does
101+ c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}})
102+ if got := c.lockKeyboardFocus(); got != 10 || c.focusScreen != focusB {
103+ t.Fatalf("clicked surface lost focus: %d", got)
104+ }
105+}
106+
75 func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {107 func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) {
76 c, _, commands := hiddenCaptureCommands(t)108 c, _, commands := hiddenCaptureCommands(t)
77 gate := portsmocks.NewMockSessionSecurity(t)109 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