detached 0b4c780

fix(core): pin lock focus on any click or key press

commit
0b4c7808b4ea9644d9804af634a75c97d660c73f
author
brice · · 2026-10-02 10:05 UTC
parent
34fd4a7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +61 -38
M internal/core/core.go +1 -1
@@ -70,7 +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
73+ lockPinned bool // lockFocus was clicked or typed into: it keeps focus
7474 inputKeys map[string]bool
7575 inputActive bool
7676 startup [][]string
@@ -70,7 +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 focus73+ lockPinned bool // lockFocus was clicked or typed into: it keeps focus
74 inputKeys map[string]bool74 inputKeys map[string]bool
75 inputActive bool75 inputActive bool
76 startup [][]string76 startup [][]string
M internal/core/security.go +16 -11
@@ -21,7 +21,7 @@ func (c *Core) syncSecurity() bool {
2121 return false
2222 }
2323 c.security = state
24- c.lockSurfaces, c.lockFocus, c.lockClicked = nil, 0, false
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
@@ -136,24 +136,25 @@ func (c *Core) validLockID(id WindowID) bool {
136136 }
137137
138138 // 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.
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.
142143 func (c *Core) lockKeyboardFocus() WindowID {
143144 valid := c.validLockID(c.lockFocus)
144- if valid && c.lockClicked {
145+ if valid && c.lockPinned {
145146 return c.lockFocus
146147 }
147148 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
148149 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
149- c.lockFocus, c.lockClicked = s.ID, false
150+ c.lockFocus, c.lockPinned = s.ID, false
150151 return c.lockFocus
151152 }
152153 }
153154 if valid {
154155 return c.lockFocus
155156 }
156- c.lockFocus, c.lockClicked = 0, false
157+ c.lockFocus, c.lockPinned = 0, false
157158 for _, sc := range c.screens {
158159 if s, ok := c.lockSurface(sc); ok {
159160 c.lockFocus = s.ID
@@ -246,6 +247,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
246247 switch v := ev.(type) {
247248 case ports.KeyEvent:
248249 if id := c.lockKeyboardFocus(); id != 0 {
250+ c.lockPinned = true // typing must not move focus mid-secret
249251 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})
250252 }
251253 case ports.PointerMotion:
@@ -279,10 +281,13 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
279281 delete(c.buttons, v.Button)
280282 }
281283 if c.validLockID(id) {
282- if v.Pressed && c.lockFocus != id {
283- c.lockFocus, c.lockClicked = id, true
284- if err := c.publishProtected(ctx); err != nil {
285- 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+ }
286291 }
287292 }
288293 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, c.lockClicked = nil, 0, false24+ 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
@@ -136,24 +136,25 @@ func (c *Core) validLockID(id WindowID) bool {
136 }136 }
137 137
138 // lockKeyboardFocus prefers the lock surface of the focused output, where the138 // 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.139+// user is looking, then any other. A surface the user clicked or typed into is
140-// Without a click, focus moves to the focused output's surface once it maps:140+// pinned and keeps focus. Until then, focus moves to the focused output's
141-// lock clients map their surfaces one by one, in any order.141+// surface once it maps: lock clients map their surfaces one by one, in any
142+// order.
142 func (c *Core) lockKeyboardFocus() WindowID {143 func (c *Core) lockKeyboardFocus() WindowID {
143 valid := c.validLockID(c.lockFocus)144 valid := c.validLockID(c.lockFocus)
144- if valid && c.lockClicked {145+ if valid && c.lockPinned {
145 return c.lockFocus146 return c.lockFocus
146 }147 }
147 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {148 if c.focusScreen >= 0 && c.focusScreen < len(c.screens) {
148 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {149 if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok {
149- c.lockFocus, c.lockClicked = s.ID, false150+ c.lockFocus, c.lockPinned = s.ID, false
150 return c.lockFocus151 return c.lockFocus
151 }152 }
152 }153 }
153 if valid {154 if valid {
154 return c.lockFocus155 return c.lockFocus
155 }156 }
156- c.lockFocus, c.lockClicked = 0, false157+ c.lockFocus, c.lockPinned = 0, false
157 for _, sc := range c.screens {158 for _, sc := range c.screens {
158 if s, ok := c.lockSurface(sc); ok {159 if s, ok := c.lockSurface(sc); ok {
159 c.lockFocus = s.ID160 c.lockFocus = s.ID
@@ -246,6 +247,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
246 switch v := ev.(type) {247 switch v := ev.(type) {
247 case ports.KeyEvent:248 case ports.KeyEvent:
248 if id := c.lockKeyboardFocus(); id != 0 {249 if id := c.lockKeyboardFocus(); id != 0 {
250+ c.lockPinned = true // typing must not move focus mid-secret
249 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})251 return c.command(ctx, ports.ForwardKey{ID: id, Key: v})
250 }252 }
251 case ports.PointerMotion:253 case ports.PointerMotion:
@@ -279,10 +281,13 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error {
279 delete(c.buttons, v.Button)281 delete(c.buttons, v.Button)
280 }282 }
281 if c.validLockID(id) {283 if c.validLockID(id) {
282- if v.Pressed && c.lockFocus != id {284+ if v.Pressed {
283- c.lockFocus, c.lockClicked = id, true285+ changed := c.lockFocus != id
284- if err := c.publishProtected(ctx); err != nil {286+ c.lockFocus, c.lockPinned = id, true
285- return err287+ if changed {
288+ if err := c.publishProtected(ctx); err != nil {
289+ return err
290+ }
286 }291 }
287 }292 }
288 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 +44 -26
@@ -74,33 +74,51 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
7474
7575 // Lock clients map their surfaces one by one: the focused output's surface
7676 // 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- }
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+ }
99105
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)
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+ })
104122 }
105123 }
106124
@@ -74,33 +74,51 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) {
74 74
75 // Lock clients map their surfaces one by one: the focused output's surface75 // 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 user76 // takes keyboard focus when it maps after another one, unless the user
77-// clicked a surface.77+// already clicked or typed into a surface.
78-func TestLockFocusMovesToFocusedOutputSurfaceUntilClicked(t *testing.T) {78+func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) {
79- c, _, _ := hiddenCaptureCommands(t)79+ for _, tc := range []struct {
80- c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})80+ name string
81- c.focusScreen = c.screenIndex("B")81+ input []ports.InputEvent // on A, before B maps
82- state := ports.SecurityState{Generation: 1, Protected: true}82+ want WindowID
83- gate := portsmocks.NewMockSessionSecurity(t)83+ }{
84- gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })84+ {"no input", nil, 11},
85- c.ch.Security = gate85+ {"click on focused A", []ports.InputEvent{ports.PointerMotion{X: 10, Y: 10}, ports.PointerButton{Button: 0x110, Pressed: true}}, 10},
86- c.syncSecurity()86+ {"typing", []ports.InputEvent{ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}}, 10},
87- focusB := c.focusScreen87+ } {
88-88+ t.Run(tc.name, func(t *testing.T) {
89- a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200}89+ c, _, _ := hiddenCaptureCommands(t)
90- b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300}90+ c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
91- c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a}})91+ c.focusScreen = c.screenIndex("B")
92- if got := c.lockKeyboardFocus(); got != 10 {92+ state := ports.SecurityState{Generation: 1, Protected: true}
93- t.Fatalf("only mapped surface not focused: %d", got)93+ gate := portsmocks.NewMockSessionSecurity(t)
94- }94+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
95- c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}})95+ c.ch.Security = gate
96- if got := c.lockKeyboardFocus(); got != 11 {96+ c.syncSecurity()
97- t.Fatalf("focused output surface not focused: %d", got)97+ ctx := context.Background()
98- }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+ }
99 105
100- c.lockFocus, c.lockClicked = 10, true // as a click on A does106+ a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200}
101- c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}})107+ b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300}
102- if got := c.lockKeyboardFocus(); got != 10 || c.focusScreen != focusB {108+ mapped(a)
103- t.Fatalf("clicked surface lost focus: %d", got)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+ })
104 }122 }
105 }123 }
106 124

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