detached f89a222

refactor(ui): move input kinds and modifiers to input.go, add ModAlt and ModSuper

platformInput carries a local keyEvent instead of keyboard.Key.
commit
f89a222c4d3bd500b8d1c466734ea84184f82c38
author
brice · · 2026-10-01 16:28 UTC
parent
e5cd51b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +44 -38
M internal/ui/controls_extra_test.go +2 -3
@@ -3,7 +3,6 @@ package ui
33 import (
44 "github.com/bnema/nefergui/internal/css"
55 "github.com/bnema/nefergui/internal/edit"
6- "github.com/bnema/nefergui/internal/keyboard"
76 "testing"
87 )
98
@@ -85,7 +84,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) {
8584 if r.state.focus != nil {
8685 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))
8786 }
88- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})
87+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
8988 r.Build(view)
9089 if value != "keep" {
9190 t.Fatalf("disabled input edited: %q", value)
@@ -101,7 +100,7 @@ func TestEditFocusIMEAndClipboard(t *testing.T) {
101100 r.clipboard = &clip{data: []byte("paste")}
102101 r.route(key("Tab"))
103102 r.Build(view)
104- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})
103+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
105104 r.Build(view)
106105 if value != "x" || r.committed.children[0].text != "•" {
107106 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))
@@ -3,7 +3,6 @@ package ui
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
5 "github.com/bnema/nefergui/internal/edit"5 "github.com/bnema/nefergui/internal/edit"
6- "github.com/bnema/nefergui/internal/keyboard"
7 "testing"6 "testing"
8 )7 )
9 8
@@ -85,7 +84,7 @@ func TestDisabledEditorRejectsFocusAndText(t *testing.T) {
85 if r.state.focus != nil {84 if r.state.focus != nil {
86 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))85 t.Fatalf("disabled input focused: %s", idKey(r.state.focus))
87 }86 }
88- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})87+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
89 r.Build(view)88 r.Build(view)
90 if value != "keep" {89 if value != "keep" {
91 t.Fatalf("disabled input edited: %q", value)90 t.Fatalf("disabled input edited: %q", value)
@@ -101,7 +100,7 @@ func TestEditFocusIMEAndClipboard(t *testing.T) {
101 r.clipboard = &clip{data: []byte("paste")}100 r.clipboard = &clip{data: []byte("paste")}
102 r.route(key("Tab"))101 r.route(key("Tab"))
103 r.Build(view)102 r.Build(view)
104- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "q", Text: "x", Pressed: true}})103+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "q", Text: "x", Pressed: true}})
105 r.Build(view)104 r.Build(view)
106 if value != "x" || r.committed.children[0].text != "•" {105 if value != "x" || r.committed.children[0].text != "•" {
107 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))106 t.Fatalf("value=%q display=%q focus=%s", value, r.committed.children[0].text, idKey(r.state.focus))
M internal/ui/hooks.go +0 -23
@@ -12,29 +12,6 @@ import (
1212 "github.com/bnema/nefergui/internal/platform/wayland"
1313 )
1414
15-// InputKind classifies an InputEvent.
16-type InputKind uint8
17-
18-const (
19- InputPointerMotion InputKind = iota + 1
20- InputPointerPress
21- InputPointerRelease
22- InputPointerAxis
23- InputPointerLeave
24- InputKey
25- InputFocusIn
26- InputFocusOut
27- InputReset // platform dropped queued input; treat held state as released
28-)
29-
30-// Modifiers is a bit set of keyboard modifiers held during a key event.
31-type Modifiers uint8
32-
33-const (
34- ModShift Modifiers = 1 << iota
35- ModCtrl
36-)
37-
3815 // InputEvent is a raw platform event, delivered on the owner loop before the
3916 // normal control routing. Coordinates are logical, surface-local pixels.
4017 // Size changes are not input; use OnResize and Frame.Size. Which fields are set
@@ -12,29 +12,6 @@ import (
12 "github.com/bnema/nefergui/internal/platform/wayland"12 "github.com/bnema/nefergui/internal/platform/wayland"
13 )13 )
14 14
15-// InputKind classifies an InputEvent.
16-type InputKind uint8
17-
18-const (
19- InputPointerMotion InputKind = iota + 1
20- InputPointerPress
21- InputPointerRelease
22- InputPointerAxis
23- InputPointerLeave
24- InputKey
25- InputFocusIn
26- InputFocusOut
27- InputReset // platform dropped queued input; treat held state as released
28-)
29-
30-// Modifiers is a bit set of keyboard modifiers held during a key event.
31-type Modifiers uint8
32-
33-const (
34- ModShift Modifiers = 1 << iota
35- ModCtrl
36-)
37-
38 // InputEvent is a raw platform event, delivered on the owner loop before the15 // InputEvent is a raw platform event, delivered on the owner loop before the
39 // normal control routing. Coordinates are logical, surface-local pixels.16 // normal control routing. Coordinates are logical, surface-local pixels.
40 // Size changes are not input; use OnResize and Frame.Size. Which fields are set17 // Size changes are not input; use OnResize and Frame.Size. Which fields are set
M internal/ui/ime_return_test.go +1 -2
@@ -4,11 +4,10 @@ import (
44 "testing"
55
66 "github.com/bnema/nefergui/internal/edit"
7- "github.com/bnema/nefergui/internal/keyboard"
87 )
98
109 func typed(name, text string) platformInput {
11- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Text: text, Pressed: true}}
10+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Text: text, Pressed: true}}
1211 }
1312
1413 // With text-input v3 the compositor routes keys to a grabbing input method;
@@ -4,11 +4,10 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/nefergui/internal/edit"6 "github.com/bnema/nefergui/internal/edit"
7- "github.com/bnema/nefergui/internal/keyboard"
8 )7 )
9 8
10 func typed(name, text string) platformInput {9 func typed(name, text string) platformInput {
11- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Text: text, Pressed: true}}10+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Text: text, Pressed: true}}
12 }11 }
13 12
14 // With text-input v3 the compositor routes keys to a grabbing input method;13 // With text-input v3 the compositor routes keys to a grabbing input method;
A internal/ui/input.go +26 -0
@@ -0,0 +1,26 @@
1+package ui
2+
3+// InputKind classifies an InputEvent.
4+type InputKind uint8
5+
6+const (
7+ InputPointerMotion InputKind = iota + 1
8+ InputPointerPress
9+ InputPointerRelease
10+ InputPointerAxis
11+ InputPointerLeave
12+ InputKey
13+ InputFocusIn
14+ InputFocusOut
15+ InputReset // platform dropped queued input; treat held state as released
16+)
17+
18+// Modifiers is a bit set of keyboard modifiers held during a key event.
19+type Modifiers uint8
20+
21+const (
22+ ModShift Modifiers = 1 << iota
23+ ModCtrl
24+ ModAlt
25+ ModSuper
26+)
@@ -0,0 +1,26 @@
1+package ui
2+
3+// InputKind classifies an InputEvent.
4+type InputKind uint8
5+
6+const (
7+ InputPointerMotion InputKind = iota + 1
8+ InputPointerPress
9+ InputPointerRelease
10+ InputPointerAxis
11+ InputPointerLeave
12+ InputKey
13+ InputFocusIn
14+ InputFocusOut
15+ InputReset // platform dropped queued input; treat held state as released
16+)
17+
18+// Modifiers is a bit set of keyboard modifiers held during a key event.
19+type Modifiers uint8
20+
21+const (
22+ ModShift Modifiers = 1 << iota
23+ ModCtrl
24+ ModAlt
25+ ModSuper
26+)
M internal/ui/interaction.go +8 -2
@@ -8,7 +8,6 @@ import (
88
99 "github.com/bnema/nefergui/internal/css"
1010 "github.com/bnema/nefergui/internal/edit"
11- "github.com/bnema/nefergui/internal/keyboard"
1211 "github.com/bnema/nefergui/internal/layout"
1312 "github.com/bnema/nefergui/internal/text"
1413 )
@@ -19,13 +18,20 @@ type platformInput struct {
1918 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize
2019 X, Y, DX, DY float64
2120 Width, Height, Scale float64
22- Key keyboard.Key
21+ Key keyEvent
2322 Button uint32 // evdev button code (BTN_LEFT is primary)
2423 Shift, Ctrl bool
2524 IME edit.IMEBatch
2625 Time time.Time // optional event timestamp; zero uses receipt time
2726 }
2827
28+// keyEvent is the key part of a platformInput.
29+type keyEvent struct {
30+ Name, Text string
31+ Pressed, Repeat bool
32+ Shift, Ctrl bool
33+}
34+
2935 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT
3036
3137 type interactionState struct {
@@ -8,7 +8,6 @@ import (
8 8
9 "github.com/bnema/nefergui/internal/css"9 "github.com/bnema/nefergui/internal/css"
10 "github.com/bnema/nefergui/internal/edit"10 "github.com/bnema/nefergui/internal/edit"
11- "github.com/bnema/nefergui/internal/keyboard"
12 "github.com/bnema/nefergui/internal/layout"11 "github.com/bnema/nefergui/internal/layout"
13 "github.com/bnema/nefergui/internal/text"12 "github.com/bnema/nefergui/internal/text"
14 )13 )
@@ -19,13 +18,20 @@ type platformInput struct {
19 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize18 Kind string // motion, press, release, axis, leave, key, focus-in, focus-out, resize
20 X, Y, DX, DY float6419 X, Y, DX, DY float64
21 Width, Height, Scale float6420 Width, Height, Scale float64
22- Key keyboard.Key21+ Key keyEvent
23 Button uint32 // evdev button code (BTN_LEFT is primary)22 Button uint32 // evdev button code (BTN_LEFT is primary)
24 Shift, Ctrl bool23 Shift, Ctrl bool
25 IME edit.IMEBatch24 IME edit.IMEBatch
26 Time time.Time // optional event timestamp; zero uses receipt time25 Time time.Time // optional event timestamp; zero uses receipt time
27 }26 }
28 27
28+// keyEvent is the key part of a platformInput.
29+type keyEvent struct {
30+ Name, Text string
31+ Pressed, Repeat bool
32+ Shift, Ctrl bool
33+}
34+
29 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT35 const pointerPrimary uint32 = 0x110 // Linux BTN_LEFT
30 36
31 type interactionState struct {37 type interactionState struct {
M internal/ui/interaction_test.go +6 -7
@@ -6,13 +6,12 @@ import (
66 "testing"
77
88 "github.com/bnema/nefergui/internal/css"
9- "github.com/bnema/nefergui/internal/keyboard"
109 "github.com/bnema/nefergui/internal/platform/wayland"
1110 "github.com/bnema/nefergui/internal/text"
1211 )
1312
1413 func key(name string) platformInput {
15- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}}
14+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Pressed: true}}
1615 }
1716 func shell(r *runtime, activated *int) func(*Frame) {
1817 return func(f *Frame) {
@@ -67,7 +66,7 @@ func TestShellStyleAndFocus(t *testing.T) {
6766 if !r.state.focus.same(r.Target(3)) {
6867 t.Fatal("disabled included in tab order")
6968 }
70- r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}})
69+ r.route(platformInput{Kind: "key", Shift: true, Key: keyEvent{Name: "Tab", Pressed: true}})
7170 r.Build(view)
7271 if !r.state.focus.same(r.Target(1)) {
7372 t.Fatal("reverse tab")
@@ -361,7 +360,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
361360 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
362361 t.Fatal("space press should only set active")
363362 }
364- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
363+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
365364 r.Build(view)
366365 if count != 2 || r.state.space != nil {
367366 t.Fatal("space release should activate once", count)
@@ -369,7 +368,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
369368 r.route(space)
370369 r.route(key("Escape"))
371370 r.Build(view)
372- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
371+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
373372 if r.Build(view) {
374373 t.Fatal("cancelled release redrew")
375374 }
@@ -379,7 +378,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
379378 r.route(space)
380379 r.route(platformInput{Kind: "focus-out"})
381380 r.Build(view)
382- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
381+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
383382 if r.Build(view) || count != 2 {
384383 t.Fatal("focus loss failed to cancel")
385384 }
@@ -388,7 +387,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
388387 r.route(space)
389388 r.route(key("Tab"))
390389 r.Build(view)
391- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})
390+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
392391 if r.Build(view) || count != 2 {
393392 t.Fatal("focus change failed to cancel")
394393 }
@@ -6,13 +6,12 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/bnema/nefergui/internal/css"8 "github.com/bnema/nefergui/internal/css"
9- "github.com/bnema/nefergui/internal/keyboard"
10 "github.com/bnema/nefergui/internal/platform/wayland"9 "github.com/bnema/nefergui/internal/platform/wayland"
11 "github.com/bnema/nefergui/internal/text"10 "github.com/bnema/nefergui/internal/text"
12 )11 )
13 12
14 func key(name string) platformInput {13 func key(name string) platformInput {
15- return platformInput{Kind: "key", Key: keyboard.Key{Name: name, Pressed: true}}14+ return platformInput{Kind: "key", Key: keyEvent{Name: name, Pressed: true}}
16 }15 }
17 func shell(r *runtime, activated *int) func(*Frame) {16 func shell(r *runtime, activated *int) func(*Frame) {
18 return func(f *Frame) {17 return func(f *Frame) {
@@ -67,7 +66,7 @@ func TestShellStyleAndFocus(t *testing.T) {
67 if !r.state.focus.same(r.Target(3)) {66 if !r.state.focus.same(r.Target(3)) {
68 t.Fatal("disabled included in tab order")67 t.Fatal("disabled included in tab order")
69 }68 }
70- r.route(platformInput{Kind: "key", Shift: true, Key: keyboard.Key{Name: "Tab", Pressed: true}})69+ r.route(platformInput{Kind: "key", Shift: true, Key: keyEvent{Name: "Tab", Pressed: true}})
71 r.Build(view)70 r.Build(view)
72 if !r.state.focus.same(r.Target(1)) {71 if !r.state.focus.same(r.Target(1)) {
73 t.Fatal("reverse tab")72 t.Fatal("reverse tab")
@@ -361,7 +360,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
361 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {360 if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
362 t.Fatal("space press should only set active")361 t.Fatal("space press should only set active")
363 }362 }
364- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})363+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
365 r.Build(view)364 r.Build(view)
366 if count != 2 || r.state.space != nil {365 if count != 2 || r.state.space != nil {
367 t.Fatal("space release should activate once", count)366 t.Fatal("space release should activate once", count)
@@ -369,7 +368,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
369 r.route(space)368 r.route(space)
370 r.route(key("Escape"))369 r.route(key("Escape"))
371 r.Build(view)370 r.Build(view)
372- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})371+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
373 if r.Build(view) {372 if r.Build(view) {
374 t.Fatal("cancelled release redrew")373 t.Fatal("cancelled release redrew")
375 }374 }
@@ -379,7 +378,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
379 r.route(space)378 r.route(space)
380 r.route(platformInput{Kind: "focus-out"})379 r.route(platformInput{Kind: "focus-out"})
381 r.Build(view)380 r.Build(view)
382- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})381+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
383 if r.Build(view) || count != 2 {382 if r.Build(view) || count != 2 {
384 t.Fatal("focus loss failed to cancel")383 t.Fatal("focus loss failed to cancel")
385 }384 }
@@ -388,7 +387,7 @@ func TestKeyboardActivationLifecycle(t *testing.T) {
388 r.route(space)387 r.route(space)
389 r.route(key("Tab"))388 r.route(key("Tab"))
390 r.Build(view)389 r.Build(view)
391- r.route(platformInput{Kind: "key", Key: keyboard.Key{Name: "space"}})390+ r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
392 if r.Build(view) || count != 2 {391 if r.Build(view) || count != 2 {
393 t.Fatal("focus change failed to cancel")392 t.Fatal("focus change failed to cancel")
394 }393 }
M internal/ui/platform.go +1 -1
@@ -28,5 +28,5 @@ func (r *runtime) routeInput(in wayland.Input) {
2828 if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {
2929 return
3030 }
31- r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: in.Key, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
31+ r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: keyEvent{Name: in.Key.Name, Text: in.Key.Text, Pressed: in.Key.Pressed, Repeat: in.Key.Repeat, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl}, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
3232 }
@@ -28,5 +28,5 @@ func (r *runtime) routeInput(in wayland.Input) {
28 if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {28 if in.Kind == "axis" && in.DX == 0 && in.DY == 0 {
29 return29 return
30 }30 }
31- r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: in.Key, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})31+ r.route(platformInput{Kind: in.Kind, X: in.X, Y: in.Y, DX: in.DX, DY: in.DY, Button: in.Button, Key: keyEvent{Name: in.Key.Name, Text: in.Key.Text, Pressed: in.Key.Pressed, Repeat: in.Key.Repeat, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl}, Shift: in.Key.Shift, Ctrl: in.Key.Ctrl})
32 }32 }

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