detached 43c5a7c

feat(ui): add Caps Lock and Num Lock modifier bits (#9)

* feat(ui): add Caps Lock and Num Lock modifier bits

* test(ui): merge lock modifier tests into one table with a shared renderer helper
commit
43c5a7c8ac56fa3481ae82f0d5af88d69e893903
author
bnema · · 2026-10-01 20:45 UTC
committer
GitHub · · 2026-10-01 20:45 UTC
parent
657f658
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.6.0
4 files · +106 -16
M docs/runtime.md +1 -1
@@ -5,7 +5,7 @@
55 A client loop forwards events and presents frames:
66
77 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.
8-- `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
8+- `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
99 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.
1010 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
1111 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.
@@ -5,7 +5,7 @@
5 A client loop forwards events and presents frames:5 A client loop forwards events and presents frames:
6 6
7 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.7 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.
8-- `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.8+- `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
9 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.9 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.
10 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.10 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
11 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.11 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.
M internal/ui/input.go +8 -1
@@ -15,7 +15,12 @@ const (
1515 InputReset // platform dropped queued input; treat held state as released
1616 )
1717
18-// Modifiers is a bit set of keyboard modifiers held during a key event.
18+// Modifiers is a bit set of keyboard modifiers held during a key or pointer
19+// event. ModShift, ModCtrl, ModAlt and ModSuper report held keys; ModCapsLock
20+// and ModNumLock report the latched lock state. The Renderer reads only
21+// ModShift and ModCtrl, so lock bits never change shortcuts or text entry.
22+// The Renderer does not expose modifiers to views: an application that shows a
23+// lock indicator records the lock bits of Input.Modifiers in its own model.
1924 type Modifiers uint8
2025
2126 const (
@@ -23,4 +28,6 @@ const (
2328 ModCtrl
2429 ModAlt
2530 ModSuper
31+ ModCapsLock
32+ ModNumLock
2633 )
@@ -15,7 +15,12 @@ const (
15 InputReset // platform dropped queued input; treat held state as released15 InputReset // platform dropped queued input; treat held state as released
16 )16 )
17 17
18-// Modifiers is a bit set of keyboard modifiers held during a key event.18+// Modifiers is a bit set of keyboard modifiers held during a key or pointer
19+// event. ModShift, ModCtrl, ModAlt and ModSuper report held keys; ModCapsLock
20+// and ModNumLock report the latched lock state. The Renderer reads only
21+// ModShift and ModCtrl, so lock bits never change shortcuts or text entry.
22+// The Renderer does not expose modifiers to views: an application that shows a
23+// lock indicator records the lock bits of Input.Modifiers in its own model.
19 type Modifiers uint824 type Modifiers uint8
20 25
21 const (26 const (
@@ -23,4 +28,6 @@ const (
23 ModCtrl28 ModCtrl
24 ModAlt29 ModAlt
25 ModSuper30 ModSuper
31+ ModCapsLock
32+ ModNumLock
26 )33 )
M internal/ui/renderer_test.go +92 -13
@@ -4,6 +4,7 @@ package ui
44
55 import (
66 "errors"
7+ "fmt"
78 "testing"
89
910 "github.com/bnema/nefergui/internal/layout"
@@ -174,21 +175,9 @@ func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
174175 }
175176
176177 func TestRendererTextInputUsesTextBytes(t *testing.T) {
177- r, tg := newTestRenderer(t)
178178 value := ""
179- var out Output
180179 view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
181- tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
182- r.Resize(200, 100, 1)
183- drawOK(tg, nil).Maybe()
184- settle := func() {
185- for i := 0; i < 3; i++ {
186- if _, err := r.Render(&out, &value, view); err != nil {
187- t.Fatal(err)
188- }
189- }
190- }
191- settle()
180+ r, settle := settledRenderer(t, &value, view)
192181 r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
193182 r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
194183 settle()
@@ -275,3 +264,93 @@ func TestTextIntAndMasked(t *testing.T) {
275264 t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
276265 }
277266 }
267+
268+// settledRenderer returns a 200x100 renderer for view and a settle function
269+// that renders until queued input has been applied.
270+func settledRenderer[T any](t *testing.T, model *T, view func(*Frame, *T)) (*Renderer, func()) {
271+ t.Helper()
272+ r, tg := newTestRenderer(t)
273+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
274+ r.Resize(200, 100, 1)
275+ drawOK(tg, nil).Maybe()
276+ var out Output
277+ settle := func() {
278+ for i := 0; i < 3; i++ {
279+ if _, err := r.Render(&out, model, view); err != nil {
280+ t.Fatal(err)
281+ }
282+ }
283+ }
284+ settle()
285+ return r, settle
286+}
287+
288+func TestModifierBits(t *testing.T) {
289+ // The values match neferclient's, so applications copy the bits as is.
290+ if ModShift != 1 || ModCtrl != 2 || ModAlt != 4 || ModSuper != 8 || ModCapsLock != 16 || ModNumLock != 32 {
291+ t.Fatal("modifier bit values changed")
292+ }
293+}
294+
295+// Caps Lock and Num Lock must not change shortcuts, selection or activation.
296+func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
297+ inputView := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
298+ key := func(r *Renderer, keysym uint32, text string, mods Modifiers) {
299+ in := &Input{Kind: InputKey, Keysym: keysym, Pressed: true, Modifiers: mods}
300+ if text != "" {
301+ in.Text = []byte(text)
302+ }
303+ r.Input(in)
304+ }
305+ click := func(r *Renderer, x float64, mods Modifiers) {
306+ r.Input(&Input{Kind: InputPointerPress, X: x, Y: 5, Button: 0x110, Pressed: true, Modifiers: mods})
307+ r.Input(&Input{Kind: InputPointerRelease, X: x, Y: 5, Button: 0x110, Modifiers: mods})
308+ }
309+ for _, lock := range []Modifiers{0, ModCapsLock, ModNumLock, ModCapsLock | ModNumLock} {
310+ t.Run(fmt.Sprintf("lock=%#x", uint8(lock)), func(t *testing.T) {
311+ t.Run("CtrlA and ShiftLeft", func(t *testing.T) {
312+ value := "hello"
313+ r, settle := settledRenderer(t, &value, inputView)
314+ click(r, 5, lock)
315+ settle()
316+ key(r, 'a', "a", ModCtrl|lock) // select all
317+ settle()
318+ key(r, 'X', "X", lock)
319+ settle()
320+ if value != "X" {
321+ t.Fatalf("Ctrl+A then text: value=%q, want X", value)
322+ }
323+ key(r, 0xff51, "", ModShift|lock) // extend selection left
324+ settle()
325+ key(r, 'y', "y", lock)
326+ settle()
327+ if value != "y" {
328+ t.Fatalf("Shift+Left then text: value=%q, want y", value)
329+ }
330+ })
331+ t.Run("ShiftClick", func(t *testing.T) {
332+ value := "hello"
333+ r, settle := settledRenderer(t, &value, inputView)
334+ click(r, 1, lock)
335+ settle()
336+ click(r, 95, ModShift|lock) // select to the end
337+ settle()
338+ key(r, 'Z', "Z", lock)
339+ settle()
340+ if value != "Z" {
341+ t.Fatalf("Shift+click then text: value=%q, want Z", value)
342+ }
343+ })
344+ t.Run("TabReturn", func(t *testing.T) {
345+ m := testModel{}
346+ r, settle := settledRenderer(t, &m, testView)
347+ key(r, 0xff09, "", lock)
348+ key(r, 0xff0d, "", lock)
349+ settle()
350+ if m.clicks != 1 {
351+ t.Fatalf("Tab+Return clicks=%d, want 1", m.clicks)
352+ }
353+ })
354+ })
355+ }
356+}
@@ -4,6 +4,7 @@ package ui
4 4
5 import (5 import (
6 "errors"6 "errors"
7+ "fmt"
7 "testing"8 "testing"
8 9
9 "github.com/bnema/nefergui/internal/layout"10 "github.com/bnema/nefergui/internal/layout"
@@ -174,21 +175,9 @@ func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
174 }175 }
175 176
176 func TestRendererTextInputUsesTextBytes(t *testing.T) {177 func TestRendererTextInputUsesTextBytes(t *testing.T) {
177- r, tg := newTestRenderer(t)
178 value := ""178 value := ""
179- var out Output
180 view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }179 view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
181- tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()180+ r, settle := settledRenderer(t, &value, view)
182- r.Resize(200, 100, 1)
183- drawOK(tg, nil).Maybe()
184- settle := func() {
185- for i := 0; i < 3; i++ {
186- if _, err := r.Render(&out, &value, view); err != nil {
187- t.Fatal(err)
188- }
189- }
190- }
191- settle()
192 r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})181 r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
193 r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})182 r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
194 settle()183 settle()
@@ -275,3 +264,93 @@ func TestTextIntAndMasked(t *testing.T) {
275 t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)264 t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
276 }265 }
277 }266 }
267+
268+// settledRenderer returns a 200x100 renderer for view and a settle function
269+// that renders until queued input has been applied.
270+func settledRenderer[T any](t *testing.T, model *T, view func(*Frame, *T)) (*Renderer, func()) {
271+ t.Helper()
272+ r, tg := newTestRenderer(t)
273+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
274+ r.Resize(200, 100, 1)
275+ drawOK(tg, nil).Maybe()
276+ var out Output
277+ settle := func() {
278+ for i := 0; i < 3; i++ {
279+ if _, err := r.Render(&out, model, view); err != nil {
280+ t.Fatal(err)
281+ }
282+ }
283+ }
284+ settle()
285+ return r, settle
286+}
287+
288+func TestModifierBits(t *testing.T) {
289+ // The values match neferclient's, so applications copy the bits as is.
290+ if ModShift != 1 || ModCtrl != 2 || ModAlt != 4 || ModSuper != 8 || ModCapsLock != 16 || ModNumLock != 32 {
291+ t.Fatal("modifier bit values changed")
292+ }
293+}
294+
295+// Caps Lock and Num Lock must not change shortcuts, selection or activation.
296+func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
297+ inputView := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
298+ key := func(r *Renderer, keysym uint32, text string, mods Modifiers) {
299+ in := &Input{Kind: InputKey, Keysym: keysym, Pressed: true, Modifiers: mods}
300+ if text != "" {
301+ in.Text = []byte(text)
302+ }
303+ r.Input(in)
304+ }
305+ click := func(r *Renderer, x float64, mods Modifiers) {
306+ r.Input(&Input{Kind: InputPointerPress, X: x, Y: 5, Button: 0x110, Pressed: true, Modifiers: mods})
307+ r.Input(&Input{Kind: InputPointerRelease, X: x, Y: 5, Button: 0x110, Modifiers: mods})
308+ }
309+ for _, lock := range []Modifiers{0, ModCapsLock, ModNumLock, ModCapsLock | ModNumLock} {
310+ t.Run(fmt.Sprintf("lock=%#x", uint8(lock)), func(t *testing.T) {
311+ t.Run("CtrlA and ShiftLeft", func(t *testing.T) {
312+ value := "hello"
313+ r, settle := settledRenderer(t, &value, inputView)
314+ click(r, 5, lock)
315+ settle()
316+ key(r, 'a', "a", ModCtrl|lock) // select all
317+ settle()
318+ key(r, 'X', "X", lock)
319+ settle()
320+ if value != "X" {
321+ t.Fatalf("Ctrl+A then text: value=%q, want X", value)
322+ }
323+ key(r, 0xff51, "", ModShift|lock) // extend selection left
324+ settle()
325+ key(r, 'y', "y", lock)
326+ settle()
327+ if value != "y" {
328+ t.Fatalf("Shift+Left then text: value=%q, want y", value)
329+ }
330+ })
331+ t.Run("ShiftClick", func(t *testing.T) {
332+ value := "hello"
333+ r, settle := settledRenderer(t, &value, inputView)
334+ click(r, 1, lock)
335+ settle()
336+ click(r, 95, ModShift|lock) // select to the end
337+ settle()
338+ key(r, 'Z', "Z", lock)
339+ settle()
340+ if value != "Z" {
341+ t.Fatalf("Shift+click then text: value=%q, want Z", value)
342+ }
343+ })
344+ t.Run("TabReturn", func(t *testing.T) {
345+ m := testModel{}
346+ r, settle := settledRenderer(t, &m, testView)
347+ key(r, 0xff09, "", lock)
348+ key(r, 0xff0d, "", lock)
349+ settle()
350+ if m.clicks != 1 {
351+ t.Fatalf("Tab+Return clicks=%d, want 1", m.clicks)
352+ }
353+ })
354+ })
355+ }
356+}
M nefergui.go +5 -1
@@ -92,7 +92,8 @@ func Password(v bool) EditOption { return ui.Password(v) }
9292 // Frame.SetInputRects and Output.InputRects.
9393 type Rect = ui.Rect
9494
95-// InputKind classifies an Input; Modifiers is a bit set of held modifiers.
95+// InputKind classifies an Input; Modifiers is a bit set of held modifiers and
96+// latched Caps Lock and Num Lock state.
9697 type (
9798 InputKind = ui.InputKind
9899 Modifiers = ui.Modifiers
@@ -113,4 +114,7 @@ const (
113114 ModCtrl = ui.ModCtrl
114115 ModAlt = ui.ModAlt
115116 ModSuper = ui.ModSuper
117+
118+ ModCapsLock = ui.ModCapsLock
119+ ModNumLock = ui.ModNumLock
116120 )
@@ -92,7 +92,8 @@ func Password(v bool) EditOption { return ui.Password(v) }
92 // Frame.SetInputRects and Output.InputRects.92 // Frame.SetInputRects and Output.InputRects.
93 type Rect = ui.Rect93 type Rect = ui.Rect
94 94
95-// InputKind classifies an Input; Modifiers is a bit set of held modifiers.95+// InputKind classifies an Input; Modifiers is a bit set of held modifiers and
96+// latched Caps Lock and Num Lock state.
96 type (97 type (
97 InputKind = ui.InputKind98 InputKind = ui.InputKind
98 Modifiers = ui.Modifiers99 Modifiers = ui.Modifiers
@@ -113,4 +114,7 @@ const (
113 ModCtrl = ui.ModCtrl114 ModCtrl = ui.ModCtrl
114 ModAlt = ui.ModAlt115 ModAlt = ui.ModAlt
115 ModSuper = ui.ModSuper116 ModSuper = ui.ModSuper
117+
118+ ModCapsLock = ui.ModCapsLock
119+ ModNumLock = ui.ModNumLock
116 )120 )

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 1

Tags 12

↑↓ moveenter openesc close