detached 1e8b52f

feat(accent): implement core accent picker domain and UI component

commit
1e8b52f01f8d588a9bb64957656408dc8e5e2e81
author
brice · · 2025-12-29 17:38 UTC
committer
brice · · 2025-12-30 13:48 UTC
parent
6c70122
signature
unsigned
6 files · +520 -0
A internal/application/port/accent_picker.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+// AccentPickerUI defines the interface for displaying an accent selection overlay.
4+// The picker shows a list of accented characters and allows the user to select one
5+// via arrow keys, number keys (1-9), or Enter.
6+type AccentPickerUI interface {
7+ // Show displays the accent picker with the given accent variants.
8+ // selectedCallback is called when the user selects an accent.
9+ // cancelCallback is called when the user cancels (Escape key).
10+ Show(accents []rune, selectedCallback func(accent rune), cancelCallback func())
11+
12+ // Hide hides the accent picker.
13+ Hide()
14+
15+ // IsVisible returns true if the accent picker is currently visible.
16+ IsVisible() bool
17+}
@@ -0,0 +1,17 @@
1+package port
2+
3+// AccentPickerUI defines the interface for displaying an accent selection overlay.
4+// The picker shows a list of accented characters and allows the user to select one
5+// via arrow keys, number keys (1-9), or Enter.
6+type AccentPickerUI interface {
7+ // Show displays the accent picker with the given accent variants.
8+ // selectedCallback is called when the user selects an accent.
9+ // cancelCallback is called when the user cancels (Escape key).
10+ Show(accents []rune, selectedCallback func(accent rune), cancelCallback func())
11+
12+ // Hide hides the accent picker.
13+ Hide()
14+
15+ // IsVisible returns true if the accent picker is currently visible.
16+ IsVisible() bool
17+}
A internal/domain/entity/accent.go +46 -0
@@ -0,0 +1,46 @@
1+package entity
2+
3+import "unicode"
4+
5+// AccentMap maps base characters to their accented variants.
6+// The order determines display order in the accent picker.
7+var AccentMap = map[rune][]rune{
8+ 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'},
9+ 'c': {'ç', 'ć', 'č'},
10+ 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'},
11+ 'i': {'ì', 'í', 'î', 'ï', 'į'},
12+ 'n': {'ñ', 'ń'},
13+ 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'},
14+ 's': {'ß', 'ś', 'š'},
15+ 'u': {'ù', 'ú', 'û', 'ü', 'ū'},
16+ 'y': {'ÿ', 'ý'},
17+}
18+
19+// HasAccents returns true if the given character has accent variants.
20+func HasAccents(char rune) bool {
21+ lower := unicode.ToLower(char)
22+ _, ok := AccentMap[lower]
23+ return ok
24+}
25+
26+// GetAccents returns the accent variants for a character.
27+// If uppercase is true, returns uppercase variants.
28+// Returns nil if the character has no accent variants.
29+func GetAccents(char rune, uppercase bool) []rune {
30+ lower := unicode.ToLower(char)
31+ accents, ok := AccentMap[lower]
32+ if !ok {
33+ return nil
34+ }
35+
36+ if !uppercase {
37+ return accents
38+ }
39+
40+ // Return uppercase versions
41+ result := make([]rune, len(accents))
42+ for i, a := range accents {
43+ result[i] = unicode.ToUpper(a)
44+ }
45+ return result
46+}
@@ -0,0 +1,46 @@
1+package entity
2+
3+import "unicode"
4+
5+// AccentMap maps base characters to their accented variants.
6+// The order determines display order in the accent picker.
7+var AccentMap = map[rune][]rune{
8+ 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'},
9+ 'c': {'ç', 'ć', 'č'},
10+ 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'},
11+ 'i': {'ì', 'í', 'î', 'ï', 'į'},
12+ 'n': {'ñ', 'ń'},
13+ 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'},
14+ 's': {'ß', 'ś', 'š'},
15+ 'u': {'ù', 'ú', 'û', 'ü', 'ū'},
16+ 'y': {'ÿ', 'ý'},
17+}
18+
19+// HasAccents returns true if the given character has accent variants.
20+func HasAccents(char rune) bool {
21+ lower := unicode.ToLower(char)
22+ _, ok := AccentMap[lower]
23+ return ok
24+}
25+
26+// GetAccents returns the accent variants for a character.
27+// If uppercase is true, returns uppercase variants.
28+// Returns nil if the character has no accent variants.
29+func GetAccents(char rune, uppercase bool) []rune {
30+ lower := unicode.ToLower(char)
31+ accents, ok := AccentMap[lower]
32+ if !ok {
33+ return nil
34+ }
35+
36+ if !uppercase {
37+ return accents
38+ }
39+
40+ // Return uppercase versions
41+ result := make([]rune, len(accents))
42+ for i, a := range accents {
43+ result[i] = unicode.ToUpper(a)
44+ }
45+ return result
46+}
A internal/domain/entity/accent_test.go +107 -0
@@ -0,0 +1,107 @@
1+package entity
2+
3+import (
4+ "testing"
5+)
6+
7+func TestHasAccents(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ char rune
11+ want bool
12+ }{
13+ {"lowercase e", 'e', true},
14+ {"uppercase E", 'E', true},
15+ {"lowercase a", 'a', true},
16+ {"uppercase A", 'A', true},
17+ {"lowercase c", 'c', true},
18+ {"lowercase n", 'n', true},
19+ {"lowercase x (no accents)", 'x', false},
20+ {"uppercase X (no accents)", 'X', false},
21+ {"digit 1 (no accents)", '1', false},
22+ {"space (no accents)", ' ', false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := HasAccents(tt.char)
28+ if got != tt.want {
29+ t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestGetAccents(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ char rune
39+ uppercase bool
40+ wantLen int
41+ wantFirst rune
42+ }{
43+ {"lowercase e", 'e', false, 6, 'è'},
44+ {"uppercase E", 'E', true, 6, 'È'},
45+ {"lowercase a", 'a', false, 7, 'à'},
46+ {"uppercase A", 'A', true, 7, 'À'},
47+ {"lowercase c", 'c', false, 3, 'ç'},
48+ {"uppercase C with shift", 'c', true, 3, 'Ç'},
49+ {"no accents x", 'x', false, 0, 0},
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ got := GetAccents(tt.char, tt.uppercase)
55+
56+ if len(got) != tt.wantLen {
57+ t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen)
58+ }
59+
60+ if tt.wantLen > 0 && got[0] != tt.wantFirst {
61+ t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst)
62+ }
63+ })
64+ }
65+}
66+
67+func TestGetAccents_UppercaseFromLowercase(t *testing.T) {
68+ // When passing lowercase char with uppercase=true, should return uppercase accents
69+ got := GetAccents('e', true)
70+ if len(got) == 0 {
71+ t.Fatal("expected accents for 'e' with uppercase=true")
72+ }
73+
74+ // Verify all returned accents are uppercase
75+ for _, accent := range got {
76+ lower := GetAccents('e', false)
77+ found := false
78+ for _, l := range lower {
79+ if accent != l {
80+ found = true
81+ break
82+ }
83+ }
84+ if !found && accent == got[0] {
85+ // First accent should be uppercase version of first lowercase accent
86+ if accent != 'È' {
87+ t.Errorf("expected uppercase accent È, got %q", accent)
88+ }
89+ }
90+ }
91+}
92+
93+func TestAccentMapCoverage(t *testing.T) {
94+ // Verify all expected characters have accents
95+ expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'}
96+
97+ for _, char := range expectedChars {
98+ if !HasAccents(char) {
99+ t.Errorf("expected %q to have accents", char)
100+ }
101+
102+ accents := GetAccents(char, false)
103+ if len(accents) == 0 {
104+ t.Errorf("expected %q to have at least one accent variant", char)
105+ }
106+ }
107+}
@@ -0,0 +1,107 @@
1+package entity
2+
3+import (
4+ "testing"
5+)
6+
7+func TestHasAccents(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ char rune
11+ want bool
12+ }{
13+ {"lowercase e", 'e', true},
14+ {"uppercase E", 'E', true},
15+ {"lowercase a", 'a', true},
16+ {"uppercase A", 'A', true},
17+ {"lowercase c", 'c', true},
18+ {"lowercase n", 'n', true},
19+ {"lowercase x (no accents)", 'x', false},
20+ {"uppercase X (no accents)", 'X', false},
21+ {"digit 1 (no accents)", '1', false},
22+ {"space (no accents)", ' ', false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := HasAccents(tt.char)
28+ if got != tt.want {
29+ t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestGetAccents(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ char rune
39+ uppercase bool
40+ wantLen int
41+ wantFirst rune
42+ }{
43+ {"lowercase e", 'e', false, 6, 'è'},
44+ {"uppercase E", 'E', true, 6, 'È'},
45+ {"lowercase a", 'a', false, 7, 'à'},
46+ {"uppercase A", 'A', true, 7, 'À'},
47+ {"lowercase c", 'c', false, 3, 'ç'},
48+ {"uppercase C with shift", 'c', true, 3, 'Ç'},
49+ {"no accents x", 'x', false, 0, 0},
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ got := GetAccents(tt.char, tt.uppercase)
55+
56+ if len(got) != tt.wantLen {
57+ t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen)
58+ }
59+
60+ if tt.wantLen > 0 && got[0] != tt.wantFirst {
61+ t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst)
62+ }
63+ })
64+ }
65+}
66+
67+func TestGetAccents_UppercaseFromLowercase(t *testing.T) {
68+ // When passing lowercase char with uppercase=true, should return uppercase accents
69+ got := GetAccents('e', true)
70+ if len(got) == 0 {
71+ t.Fatal("expected accents for 'e' with uppercase=true")
72+ }
73+
74+ // Verify all returned accents are uppercase
75+ for _, accent := range got {
76+ lower := GetAccents('e', false)
77+ found := false
78+ for _, l := range lower {
79+ if accent != l {
80+ found = true
81+ break
82+ }
83+ }
84+ if !found && accent == got[0] {
85+ // First accent should be uppercase version of first lowercase accent
86+ if accent != 'È' {
87+ t.Errorf("expected uppercase accent È, got %q", accent)
88+ }
89+ }
90+ }
91+}
92+
93+func TestAccentMapCoverage(t *testing.T) {
94+ // Verify all expected characters have accents
95+ expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'}
96+
97+ for _, char := range expectedChars {
98+ if !HasAccents(char) {
99+ t.Errorf("expected %q to have accents", char)
100+ }
101+
102+ accents := GetAccents(char, false)
103+ if len(accents) == 0 {
104+ t.Errorf("expected %q to have at least one accent variant", char)
105+ }
106+ }
107+}
A internal/ui/component/accent_picker.go +301 -0
@@ -0,0 +1,301 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/layout"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Accent picker layout constants.
13+const (
14+ accentPickerSpacing = 8 // Spacing between accent items in pixels.
15+ maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9).
16+ accentIndex6 = 6 // Index for accent at position 7 (0-indexed).
17+ accentIndex7 = 7 // Index for accent at position 8 (0-indexed).
18+ accentIndex8 = 8 // Index for accent at position 9 (0-indexed).
19+)
20+
21+// AccentPicker displays an overlay with accent character options.
22+// The user can select an accent using:
23+// - Left/Right arrow keys to navigate
24+// - Number keys 1-9 to select directly
25+// - Enter to confirm selection
26+// - Escape to cancel
27+type AccentPicker struct {
28+ factory layout.WidgetFactory
29+
30+ // Widgets
31+ container layout.BoxWidget // Outer container for positioning
32+ accentLabels []layout.LabelWidget // Individual accent character labels
33+
34+ // State
35+ accents []rune
36+ selectedIndex int
37+ selectedCb func(rune)
38+ cancelCb func()
39+ visible bool
40+ keyController *gtk.EventControllerKey
41+ retainedClicked []interface{} // Keep click callbacks alive
42+
43+ mu sync.Mutex
44+}
45+
46+// Compile-time interface check.
47+var _ port.AccentPickerUI = (*AccentPicker)(nil)
48+
49+// NewAccentPicker creates a new accent picker component.
50+func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker {
51+ // Create container box
52+ container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing)
53+ container.AddCssClass("accent-picker")
54+
55+ // Position at bottom-center
56+ container.SetHalign(gtk.AlignCenterValue)
57+ container.SetValign(gtk.AlignEndValue)
58+
59+ // Don't expand
60+ container.SetHexpand(false)
61+ container.SetVexpand(false)
62+
63+ // Make focusable so it can receive keyboard events
64+ container.SetFocusable(true)
65+ container.SetCanFocus(true)
66+
67+ // Hidden by default
68+ container.SetVisible(false)
69+
70+ ap := &AccentPicker{
71+ factory: factory,
72+ container: container,
73+ selectedIndex: 0,
74+ }
75+
76+ // Set up key event handling
77+ ap.setupKeyboardHandling()
78+
79+ return ap
80+}
81+
82+// Widget returns the accent picker's container widget.
83+func (ap *AccentPicker) Widget() layout.Widget {
84+ return ap.container
85+}
86+
87+// Show displays the accent picker with the given accent options.
88+func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) {
89+ ap.mu.Lock()
90+ defer ap.mu.Unlock()
91+
92+ ap.accents = accents
93+ ap.selectedCb = selectedCb
94+ ap.cancelCb = cancelCb
95+ ap.selectedIndex = 0
96+ ap.visible = true
97+
98+ // Clear existing labels
99+ ap.clearLabels()
100+
101+ // Create new labels for each accent
102+ ap.accentLabels = make([]layout.LabelWidget, len(accents))
103+ ap.retainedClicked = make([]interface{}, len(accents))
104+
105+ for i, accent := range accents {
106+ label := ap.factory.NewLabel(string(accent))
107+ label.AddCssClass("accent-picker-item")
108+
109+ // Add number hint for first maxNumberedAccentItems items
110+ if i < maxNumberedAccentItems {
111+ label.AddCssClass("accent-picker-numbered")
112+ }
113+
114+ // Set up click handling
115+ clickCtrl := gtk.NewGestureClick()
116+ idx := i // Capture for closure
117+ clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) {
118+ ap.selectAccent(idx)
119+ }
120+ clickCtrl.ConnectPressed(&clickCb)
121+ ap.retainedClicked[i] = clickCb
122+ label.AddController(&clickCtrl.EventController)
123+
124+ ap.accentLabels[i] = label
125+ ap.container.Append(label)
126+ }
127+
128+ // Highlight first item
129+ ap.updateSelection()
130+
131+ ap.container.SetVisible(true)
132+ ap.container.GrabFocus()
133+}
134+
135+// Hide hides the accent picker.
136+func (ap *AccentPicker) Hide() {
137+ ap.mu.Lock()
138+ defer ap.mu.Unlock()
139+
140+ ap.visible = false
141+ ap.container.SetVisible(false)
142+ ap.clearLabels()
143+}
144+
145+// IsVisible returns true if the accent picker is currently visible.
146+func (ap *AccentPicker) IsVisible() bool {
147+ ap.mu.Lock()
148+ defer ap.mu.Unlock()
149+ return ap.visible
150+}
151+
152+// clearLabels removes all accent labels from the container.
153+func (ap *AccentPicker) clearLabels() {
154+ for _, label := range ap.accentLabels {
155+ label.Unparent()
156+ }
157+ ap.accentLabels = nil
158+ ap.retainedClicked = nil
159+}
160+
161+// updateSelection updates the visual selection highlight.
162+func (ap *AccentPicker) updateSelection() {
163+ for i, label := range ap.accentLabels {
164+ if i == ap.selectedIndex {
165+ label.AddCssClass("accent-picker-selected")
166+ } else {
167+ label.RemoveCssClass("accent-picker-selected")
168+ }
169+ }
170+}
171+
172+// selectAccent selects the accent at the given index.
173+func (ap *AccentPicker) selectAccent(index int) {
174+ ap.mu.Lock()
175+ if index < 0 || index >= len(ap.accents) {
176+ ap.mu.Unlock()
177+ return
178+ }
179+ accent := ap.accents[index]
180+ cb := ap.selectedCb
181+ ap.mu.Unlock()
182+
183+ if cb != nil {
184+ cb(accent)
185+ }
186+}
187+
188+// cancel cancels the accent picker.
189+func (ap *AccentPicker) cancel() {
190+ ap.mu.Lock()
191+ cb := ap.cancelCb
192+ ap.mu.Unlock()
193+
194+ if cb != nil {
195+ cb()
196+ }
197+}
198+
199+// setupKeyboardHandling sets up key event handling for navigation.
200+func (ap *AccentPicker) setupKeyboardHandling() {
201+ keyCtrl := gtk.NewEventControllerKey()
202+
203+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
204+ return ap.handleKeyPress(keyval)
205+ }
206+
207+ keyCtrl.ConnectKeyPressed(&keyPressedCb)
208+ ap.keyController = keyCtrl
209+ ap.container.AddController(&keyCtrl.EventController)
210+}
211+
212+// handleKeyPress processes a key press event.
213+// Returns true if the key was handled.
214+func (ap *AccentPicker) handleKeyPress(keyval uint) bool {
215+ ap.mu.Lock()
216+ if !ap.visible {
217+ ap.mu.Unlock()
218+ return false
219+ }
220+
221+ // Handle navigation keys
222+ if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn {
223+ return handled
224+ }
225+
226+ // Handle number keys for direct selection
227+ if idx, ok := ap.numberKeyToIndex(keyval); ok {
228+ ap.mu.Unlock()
229+ ap.selectAccent(idx)
230+ return true
231+ }
232+
233+ ap.mu.Unlock()
234+ return false
235+}
236+
237+// handleNavigationKey handles arrow keys, Enter, and Escape.
238+// Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled.
239+func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) {
240+ switch keyval {
241+ case uint(gdk.KEY_Left):
242+ if ap.selectedIndex > 0 {
243+ ap.selectedIndex--
244+ ap.updateSelection()
245+ }
246+ ap.mu.Unlock()
247+ return true, true
248+
249+ case uint(gdk.KEY_Right):
250+ if ap.selectedIndex < len(ap.accents)-1 {
251+ ap.selectedIndex++
252+ ap.updateSelection()
253+ }
254+ ap.mu.Unlock()
255+ return true, true
256+
257+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
258+ idx := ap.selectedIndex
259+ ap.mu.Unlock()
260+ ap.selectAccent(idx)
261+ return true, true
262+
263+ case uint(gdk.KEY_Escape):
264+ ap.mu.Unlock()
265+ ap.cancel()
266+ return true, true
267+
268+ default:
269+ return false, false
270+ }
271+}
272+
273+// numberKeyToIndex converts a number key (1-9) to an accent index.
274+// Returns the index and true if the key is a valid number key.
275+func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) {
276+ keyMap := map[uint]int{
277+ uint(gdk.KEY_1): 0,
278+ uint(gdk.KEY_KP_1): 0,
279+ uint(gdk.KEY_2): 1,
280+ uint(gdk.KEY_KP_2): 1,
281+ uint(gdk.KEY_3): 2,
282+ uint(gdk.KEY_KP_3): 2,
283+ uint(gdk.KEY_4): 3,
284+ uint(gdk.KEY_KP_4): 3,
285+ uint(gdk.KEY_5): 4,
286+ uint(gdk.KEY_KP_5): 4,
287+ uint(gdk.KEY_6): 5,
288+ uint(gdk.KEY_KP_6): 5,
289+ uint(gdk.KEY_7): accentIndex6,
290+ uint(gdk.KEY_KP_7): accentIndex6,
291+ uint(gdk.KEY_8): accentIndex7,
292+ uint(gdk.KEY_KP_8): accentIndex7,
293+ uint(gdk.KEY_9): accentIndex8,
294+ uint(gdk.KEY_KP_9): accentIndex8,
295+ }
296+
297+ if idx, ok := keyMap[keyval]; ok {
298+ return idx, true
299+ }
300+ return 0, false
301+}
@@ -0,0 +1,301 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/layout"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Accent picker layout constants.
13+const (
14+ accentPickerSpacing = 8 // Spacing between accent items in pixels.
15+ maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9).
16+ accentIndex6 = 6 // Index for accent at position 7 (0-indexed).
17+ accentIndex7 = 7 // Index for accent at position 8 (0-indexed).
18+ accentIndex8 = 8 // Index for accent at position 9 (0-indexed).
19+)
20+
21+// AccentPicker displays an overlay with accent character options.
22+// The user can select an accent using:
23+// - Left/Right arrow keys to navigate
24+// - Number keys 1-9 to select directly
25+// - Enter to confirm selection
26+// - Escape to cancel
27+type AccentPicker struct {
28+ factory layout.WidgetFactory
29+
30+ // Widgets
31+ container layout.BoxWidget // Outer container for positioning
32+ accentLabels []layout.LabelWidget // Individual accent character labels
33+
34+ // State
35+ accents []rune
36+ selectedIndex int
37+ selectedCb func(rune)
38+ cancelCb func()
39+ visible bool
40+ keyController *gtk.EventControllerKey
41+ retainedClicked []interface{} // Keep click callbacks alive
42+
43+ mu sync.Mutex
44+}
45+
46+// Compile-time interface check.
47+var _ port.AccentPickerUI = (*AccentPicker)(nil)
48+
49+// NewAccentPicker creates a new accent picker component.
50+func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker {
51+ // Create container box
52+ container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing)
53+ container.AddCssClass("accent-picker")
54+
55+ // Position at bottom-center
56+ container.SetHalign(gtk.AlignCenterValue)
57+ container.SetValign(gtk.AlignEndValue)
58+
59+ // Don't expand
60+ container.SetHexpand(false)
61+ container.SetVexpand(false)
62+
63+ // Make focusable so it can receive keyboard events
64+ container.SetFocusable(true)
65+ container.SetCanFocus(true)
66+
67+ // Hidden by default
68+ container.SetVisible(false)
69+
70+ ap := &AccentPicker{
71+ factory: factory,
72+ container: container,
73+ selectedIndex: 0,
74+ }
75+
76+ // Set up key event handling
77+ ap.setupKeyboardHandling()
78+
79+ return ap
80+}
81+
82+// Widget returns the accent picker's container widget.
83+func (ap *AccentPicker) Widget() layout.Widget {
84+ return ap.container
85+}
86+
87+// Show displays the accent picker with the given accent options.
88+func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) {
89+ ap.mu.Lock()
90+ defer ap.mu.Unlock()
91+
92+ ap.accents = accents
93+ ap.selectedCb = selectedCb
94+ ap.cancelCb = cancelCb
95+ ap.selectedIndex = 0
96+ ap.visible = true
97+
98+ // Clear existing labels
99+ ap.clearLabels()
100+
101+ // Create new labels for each accent
102+ ap.accentLabels = make([]layout.LabelWidget, len(accents))
103+ ap.retainedClicked = make([]interface{}, len(accents))
104+
105+ for i, accent := range accents {
106+ label := ap.factory.NewLabel(string(accent))
107+ label.AddCssClass("accent-picker-item")
108+
109+ // Add number hint for first maxNumberedAccentItems items
110+ if i < maxNumberedAccentItems {
111+ label.AddCssClass("accent-picker-numbered")
112+ }
113+
114+ // Set up click handling
115+ clickCtrl := gtk.NewGestureClick()
116+ idx := i // Capture for closure
117+ clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) {
118+ ap.selectAccent(idx)
119+ }
120+ clickCtrl.ConnectPressed(&clickCb)
121+ ap.retainedClicked[i] = clickCb
122+ label.AddController(&clickCtrl.EventController)
123+
124+ ap.accentLabels[i] = label
125+ ap.container.Append(label)
126+ }
127+
128+ // Highlight first item
129+ ap.updateSelection()
130+
131+ ap.container.SetVisible(true)
132+ ap.container.GrabFocus()
133+}
134+
135+// Hide hides the accent picker.
136+func (ap *AccentPicker) Hide() {
137+ ap.mu.Lock()
138+ defer ap.mu.Unlock()
139+
140+ ap.visible = false
141+ ap.container.SetVisible(false)
142+ ap.clearLabels()
143+}
144+
145+// IsVisible returns true if the accent picker is currently visible.
146+func (ap *AccentPicker) IsVisible() bool {
147+ ap.mu.Lock()
148+ defer ap.mu.Unlock()
149+ return ap.visible
150+}
151+
152+// clearLabels removes all accent labels from the container.
153+func (ap *AccentPicker) clearLabels() {
154+ for _, label := range ap.accentLabels {
155+ label.Unparent()
156+ }
157+ ap.accentLabels = nil
158+ ap.retainedClicked = nil
159+}
160+
161+// updateSelection updates the visual selection highlight.
162+func (ap *AccentPicker) updateSelection() {
163+ for i, label := range ap.accentLabels {
164+ if i == ap.selectedIndex {
165+ label.AddCssClass("accent-picker-selected")
166+ } else {
167+ label.RemoveCssClass("accent-picker-selected")
168+ }
169+ }
170+}
171+
172+// selectAccent selects the accent at the given index.
173+func (ap *AccentPicker) selectAccent(index int) {
174+ ap.mu.Lock()
175+ if index < 0 || index >= len(ap.accents) {
176+ ap.mu.Unlock()
177+ return
178+ }
179+ accent := ap.accents[index]
180+ cb := ap.selectedCb
181+ ap.mu.Unlock()
182+
183+ if cb != nil {
184+ cb(accent)
185+ }
186+}
187+
188+// cancel cancels the accent picker.
189+func (ap *AccentPicker) cancel() {
190+ ap.mu.Lock()
191+ cb := ap.cancelCb
192+ ap.mu.Unlock()
193+
194+ if cb != nil {
195+ cb()
196+ }
197+}
198+
199+// setupKeyboardHandling sets up key event handling for navigation.
200+func (ap *AccentPicker) setupKeyboardHandling() {
201+ keyCtrl := gtk.NewEventControllerKey()
202+
203+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
204+ return ap.handleKeyPress(keyval)
205+ }
206+
207+ keyCtrl.ConnectKeyPressed(&keyPressedCb)
208+ ap.keyController = keyCtrl
209+ ap.container.AddController(&keyCtrl.EventController)
210+}
211+
212+// handleKeyPress processes a key press event.
213+// Returns true if the key was handled.
214+func (ap *AccentPicker) handleKeyPress(keyval uint) bool {
215+ ap.mu.Lock()
216+ if !ap.visible {
217+ ap.mu.Unlock()
218+ return false
219+ }
220+
221+ // Handle navigation keys
222+ if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn {
223+ return handled
224+ }
225+
226+ // Handle number keys for direct selection
227+ if idx, ok := ap.numberKeyToIndex(keyval); ok {
228+ ap.mu.Unlock()
229+ ap.selectAccent(idx)
230+ return true
231+ }
232+
233+ ap.mu.Unlock()
234+ return false
235+}
236+
237+// handleNavigationKey handles arrow keys, Enter, and Escape.
238+// Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled.
239+func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) {
240+ switch keyval {
241+ case uint(gdk.KEY_Left):
242+ if ap.selectedIndex > 0 {
243+ ap.selectedIndex--
244+ ap.updateSelection()
245+ }
246+ ap.mu.Unlock()
247+ return true, true
248+
249+ case uint(gdk.KEY_Right):
250+ if ap.selectedIndex < len(ap.accents)-1 {
251+ ap.selectedIndex++
252+ ap.updateSelection()
253+ }
254+ ap.mu.Unlock()
255+ return true, true
256+
257+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
258+ idx := ap.selectedIndex
259+ ap.mu.Unlock()
260+ ap.selectAccent(idx)
261+ return true, true
262+
263+ case uint(gdk.KEY_Escape):
264+ ap.mu.Unlock()
265+ ap.cancel()
266+ return true, true
267+
268+ default:
269+ return false, false
270+ }
271+}
272+
273+// numberKeyToIndex converts a number key (1-9) to an accent index.
274+// Returns the index and true if the key is a valid number key.
275+func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) {
276+ keyMap := map[uint]int{
277+ uint(gdk.KEY_1): 0,
278+ uint(gdk.KEY_KP_1): 0,
279+ uint(gdk.KEY_2): 1,
280+ uint(gdk.KEY_KP_2): 1,
281+ uint(gdk.KEY_3): 2,
282+ uint(gdk.KEY_KP_3): 2,
283+ uint(gdk.KEY_4): 3,
284+ uint(gdk.KEY_KP_4): 3,
285+ uint(gdk.KEY_5): 4,
286+ uint(gdk.KEY_KP_5): 4,
287+ uint(gdk.KEY_6): 5,
288+ uint(gdk.KEY_KP_6): 5,
289+ uint(gdk.KEY_7): accentIndex6,
290+ uint(gdk.KEY_KP_7): accentIndex6,
291+ uint(gdk.KEY_8): accentIndex7,
292+ uint(gdk.KEY_KP_8): accentIndex7,
293+ uint(gdk.KEY_9): accentIndex8,
294+ uint(gdk.KEY_KP_9): accentIndex8,
295+ }
296+
297+ if idx, ok := keyMap[keyval]; ok {
298+ return idx, true
299+ }
300+ return 0, false
301+}
A internal/ui/theme/accent_picker_css.go +45 -0
@@ -0,0 +1,45 @@
1+package theme
2+
3+// generateAccentPickerCSS creates accent picker overlay styles.
4+// The picker displays a row of accented characters that the user can select.
5+func generateAccentPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Accent Picker Styling ===== */
8+
9+/* Main container - horizontal row of accent characters */
10+.accent-picker {
11+ background-color: alpha(var(--surface-variant), 0.95);
12+ border: 0.0625em solid var(--border);
13+ border-radius: 0.25em;
14+ padding: 0.375em 0.5em;
15+ margin-bottom: 1em;
16+ box-shadow: 0 2px 8px alpha(black, 0.2);
17+}
18+
19+/* Individual accent character label */
20+.accent-picker-item {
21+ font-size: 1.5em;
22+ font-weight: 500;
23+ color: var(--text);
24+ padding: 0.25em 0.5em;
25+ border-radius: 0.125em;
26+ min-width: 1.5em;
27+ transition: background-color 100ms ease-in-out;
28+}
29+
30+.accent-picker-item:hover {
31+ background-color: alpha(var(--accent), 0.15);
32+}
33+
34+/* Selected accent character */
35+.accent-picker-selected {
36+ background-color: alpha(var(--accent), 0.25);
37+ color: var(--accent);
38+}
39+
40+/* Numbered items (1-9) could show subtle number hints */
41+.accent-picker-numbered {
42+ /* Could add ::after pseudo-element for number hint if needed */
43+}
44+`
45+}
@@ -0,0 +1,45 @@
1+package theme
2+
3+// generateAccentPickerCSS creates accent picker overlay styles.
4+// The picker displays a row of accented characters that the user can select.
5+func generateAccentPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Accent Picker Styling ===== */
8+
9+/* Main container - horizontal row of accent characters */
10+.accent-picker {
11+ background-color: alpha(var(--surface-variant), 0.95);
12+ border: 0.0625em solid var(--border);
13+ border-radius: 0.25em;
14+ padding: 0.375em 0.5em;
15+ margin-bottom: 1em;
16+ box-shadow: 0 2px 8px alpha(black, 0.2);
17+}
18+
19+/* Individual accent character label */
20+.accent-picker-item {
21+ font-size: 1.5em;
22+ font-weight: 500;
23+ color: var(--text);
24+ padding: 0.25em 0.5em;
25+ border-radius: 0.125em;
26+ min-width: 1.5em;
27+ transition: background-color 100ms ease-in-out;
28+}
29+
30+.accent-picker-item:hover {
31+ background-color: alpha(var(--accent), 0.15);
32+}
33+
34+/* Selected accent character */
35+.accent-picker-selected {
36+ background-color: alpha(var(--accent), 0.25);
37+ color: var(--accent);
38+}
39+
40+/* Numbered items (1-9) could show subtle number hints */
41+.accent-picker-numbered {
42+ /* Could add ::after pseudo-element for number hint if needed */
43+}
44+`
45+}
M internal/ui/theme/css.go +4 -0
@@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
126126
127127 // Tab picker styling
128128 sb.WriteString(generateTabPickerCSS(p))
129+ sb.WriteString("\n")
130+
131+ // Accent picker styling
132+ sb.WriteString(generateAccentPickerCSS(p))
129133
130134 return sb.String()
131135 }
@@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
126 126
127 // Tab picker styling127 // Tab picker styling
128 sb.WriteString(generateTabPickerCSS(p))128 sb.WriteString(generateTabPickerCSS(p))
129+ sb.WriteString("\n")
130+
131+ // Accent picker styling
132+ sb.WriteString(generateAccentPickerCSS(p))
129 133
130 return sb.String()134 return sb.String()
131 }135 }

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 8

Tags 42

↑↓ moveenter openesc close