detached 7628551

feat(accent): implement InsertAccent use case with repeat tracking

commit
7628551445bbcfcf93dde5b0122040f7a987a81e
author
brice · · 2025-12-29 19:22 UTC
committer
brice · · 2025-12-30 13:48 UTC
parent
6bf928d
signature
unsigned
2 files · +495 -0
A internal/application/usecase/insert_accent.go +276 -0
@@ -0,0 +1,276 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// LongPressDelay is the duration a key must be held to trigger the accent picker.
15+const LongPressDelay = 400 * time.Millisecond
16+
17+// InsertAccentUseCase handles long-press detection and accent selection.
18+// When a key with accent variants is held for 400ms, it shows an accent picker
19+// and inserts the selected accent into the focused text input.
20+type InsertAccentUseCase struct {
21+ focusProvider port.FocusedInputProvider
22+ accentPicker port.AccentPickerUI
23+
24+ // Current long-press state
25+ pressedChar rune
26+ shiftHeld bool
27+ timer *time.Timer
28+ pickerVisible bool
29+ repeatCount int // Number of key repeats while timer is pending
30+ targetInput port.TextInputTarget // The input that was focused when long-press started
31+
32+ // Callback to show picker on GTK main thread
33+ showPickerOnMainThread func(fn func())
34+
35+ mu sync.Mutex
36+}
37+
38+// NewInsertAccentUseCase creates a new accent insertion use case.
39+// showOnMainThread is a callback that runs a function on the GTK main thread
40+// (typically using glib.IdleAdd). This is required because the timer fires
41+// on a background goroutine.
42+func NewInsertAccentUseCase(
43+ focusProvider port.FocusedInputProvider,
44+ accentPicker port.AccentPickerUI,
45+ showOnMainThread func(fn func()),
46+) *InsertAccentUseCase {
47+ return &InsertAccentUseCase{
48+ focusProvider: focusProvider,
49+ accentPicker: accentPicker,
50+ showPickerOnMainThread: showOnMainThread,
51+ }
52+}
53+
54+// OnKeyPressed handles a key press event.
55+// Returns true if the use case is handling this key and the caller should suppress it.
56+// Note: Due to WebKit's input handling, key suppression may not be fully effective.
57+// When the picker shows, we delete any repeated characters that slipped through.
58+func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
59+ log := logging.FromContext(ctx)
60+
61+ uc.mu.Lock()
62+ defer uc.mu.Unlock()
63+
64+ // If picker is visible, block all keys (picker handles them)
65+ if uc.pickerVisible {
66+ return true
67+ }
68+
69+ // If the same key is still pressed (key repeat), count it
70+ // We try to block it but also track repeats in case blocking doesn't work
71+ if uc.pressedChar == char && uc.timer != nil {
72+ uc.repeatCount++
73+ log.Debug().
74+ Str("char", string(char)).
75+ Int("repeat_count", uc.repeatCount).
76+ Msg("key repeat detected")
77+ return true // Try to block repeat keys
78+ }
79+
80+ // Cancel any pending timer for a different key
81+ uc.cancelTimerLocked()
82+
83+ // Check if this character has accents
84+ if !entity.HasAccents(char) {
85+ return false
86+ }
87+
88+ // Capture the currently focused input - we'll insert into this target
89+ // even if focus changes when the picker appears
90+ uc.targetInput = uc.focusProvider.GetFocusedInput()
91+ if uc.targetInput == nil {
92+ log.Debug().Msg("no focused input, skipping accent detection")
93+ return false
94+ }
95+
96+ // Start long-press detection
97+ uc.pressedChar = char
98+ uc.shiftHeld = shiftHeld
99+ uc.repeatCount = 0 // Reset repeat counter
100+
101+ log.Debug().
102+ Str("char", string(char)).
103+ Bool("shift", shiftHeld).
104+ Msg("starting long-press detection")
105+
106+ uc.timer = time.AfterFunc(LongPressDelay, func() {
107+ uc.onLongPressTriggered(ctx)
108+ })
109+
110+ // Allow the first key press through (user might just want to type quickly)
111+ return false
112+}
113+
114+// OnKeyReleased handles a key release event.
115+// This cancels any pending long-press detection.
116+func (uc *InsertAccentUseCase) OnKeyReleased(ctx context.Context, char rune) {
117+ log := logging.FromContext(ctx)
118+
119+ uc.mu.Lock()
120+ defer uc.mu.Unlock()
121+
122+ // Only cancel if it's the same key that was pressed
123+ if uc.pressedChar == char && uc.timer != nil {
124+ log.Debug().
125+ Str("char", string(char)).
126+ Msg("key released before long-press threshold")
127+ uc.cancelTimerLocked()
128+ }
129+}
130+
131+// Cancel cancels any pending long-press detection and hides the picker.
132+func (uc *InsertAccentUseCase) Cancel(ctx context.Context) {
133+ log := logging.FromContext(ctx)
134+
135+ uc.mu.Lock()
136+ defer uc.mu.Unlock()
137+
138+ if uc.timer != nil || uc.pickerVisible {
139+ log.Debug().Msg("canceling accent picker")
140+ }
141+
142+ uc.cancelTimerLocked()
143+ if uc.pickerVisible {
144+ uc.accentPicker.Hide()
145+ uc.pickerVisible = false
146+ }
147+}
148+
149+// IsPickerVisible returns true if the accent picker is currently visible.
150+func (uc *InsertAccentUseCase) IsPickerVisible() bool {
151+ uc.mu.Lock()
152+ defer uc.mu.Unlock()
153+ return uc.pickerVisible
154+}
155+
156+// onLongPressTriggered is called when the long-press timer fires.
157+// This runs on a background goroutine, so we use showPickerOnMainThread.
158+func (uc *InsertAccentUseCase) onLongPressTriggered(ctx context.Context) {
159+ log := logging.FromContext(ctx)
160+
161+ uc.mu.Lock()
162+ char := uc.pressedChar
163+ uppercase := uc.shiftHeld
164+ repeatCount := uc.repeatCount
165+ uc.timer = nil
166+ uc.mu.Unlock()
167+
168+ accents := entity.GetAccents(char, uppercase)
169+ if len(accents) == 0 {
170+ return
171+ }
172+
173+ log.Debug().
174+ Str("char", string(char)).
175+ Bool("uppercase", uppercase).
176+ Int("accents", len(accents)).
177+ Int("repeat_count", repeatCount).
178+ Msg("long-press triggered, showing accent picker")
179+
180+ // Show picker on main thread
181+ uc.showPickerOnMainThread(func() {
182+ uc.showPicker(ctx, accents, repeatCount)
183+ })
184+}
185+
186+// showPicker displays the accent picker UI.
187+// Must be called on the GTK main thread.
188+// repeatCount is the number of repeated characters that may have been typed
189+// (despite our attempt to block them) and need to be deleted.
190+func (uc *InsertAccentUseCase) showPicker(ctx context.Context, accents []rune, repeatCount int) {
191+ log := logging.FromContext(ctx)
192+
193+ uc.mu.Lock()
194+ target := uc.targetInput // Use the captured target, not current focus
195+ uc.mu.Unlock()
196+
197+ // Delete any repeated characters that slipped through
198+ // +1 for the initial character that was typed
199+ charsToDelete := repeatCount + 1
200+ if charsToDelete > 0 && target != nil {
201+ log.Debug().
202+ Int("chars_to_delete", charsToDelete).
203+ Msg("deleting repeated characters before showing picker")
204+ if err := target.DeleteBeforeCursor(ctx, charsToDelete); err != nil {
205+ log.Warn().Err(err).Msg("failed to delete repeated characters")
206+ }
207+ }
208+
209+ uc.mu.Lock()
210+ uc.pickerVisible = true
211+ uc.mu.Unlock()
212+
213+ uc.accentPicker.Show(accents, func(accent rune) {
214+ uc.onAccentSelected(ctx, accent)
215+ }, func() {
216+ uc.onPickerCanceled(ctx)
217+ })
218+
219+ log.Debug().Msg("accent picker shown")
220+}
221+
222+// onAccentSelected is called when the user selects an accent.
223+func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune) {
224+ log := logging.FromContext(ctx)
225+
226+ uc.mu.Lock()
227+ target := uc.targetInput // Use the captured target, not current focus
228+ uc.pickerVisible = false
229+ uc.pressedChar = 0
230+ uc.repeatCount = 0
231+ uc.targetInput = nil
232+ uc.mu.Unlock()
233+
234+ uc.accentPicker.Hide()
235+
236+ if target == nil {
237+ log.Warn().Msg("no target input for accent insertion")
238+ return
239+ }
240+
241+ log.Debug().
242+ Str("accent", string(accent)).
243+ Msg("inserting accent")
244+
245+ if err := target.InsertText(ctx, string(accent)); err != nil {
246+ log.Error().Err(err).Msg("failed to insert accent")
247+ }
248+}
249+
250+// onPickerCanceled is called when the user cancels the picker (Escape).
251+func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
252+ log := logging.FromContext(ctx)
253+
254+ uc.mu.Lock()
255+ uc.pickerVisible = false
256+ uc.pressedChar = 0
257+ uc.repeatCount = 0
258+ uc.targetInput = nil
259+ uc.mu.Unlock()
260+
261+ uc.accentPicker.Hide()
262+
263+ log.Debug().Msg("accent picker canceled")
264+}
265+
266+// cancelTimerLocked cancels the long-press timer.
267+// Must be called with uc.mu held.
268+func (uc *InsertAccentUseCase) cancelTimerLocked() {
269+ if uc.timer != nil {
270+ uc.timer.Stop()
271+ uc.timer = nil
272+ }
273+ uc.pressedChar = 0
274+ uc.repeatCount = 0
275+ uc.targetInput = nil
276+}
@@ -0,0 +1,276 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// LongPressDelay is the duration a key must be held to trigger the accent picker.
15+const LongPressDelay = 400 * time.Millisecond
16+
17+// InsertAccentUseCase handles long-press detection and accent selection.
18+// When a key with accent variants is held for 400ms, it shows an accent picker
19+// and inserts the selected accent into the focused text input.
20+type InsertAccentUseCase struct {
21+ focusProvider port.FocusedInputProvider
22+ accentPicker port.AccentPickerUI
23+
24+ // Current long-press state
25+ pressedChar rune
26+ shiftHeld bool
27+ timer *time.Timer
28+ pickerVisible bool
29+ repeatCount int // Number of key repeats while timer is pending
30+ targetInput port.TextInputTarget // The input that was focused when long-press started
31+
32+ // Callback to show picker on GTK main thread
33+ showPickerOnMainThread func(fn func())
34+
35+ mu sync.Mutex
36+}
37+
38+// NewInsertAccentUseCase creates a new accent insertion use case.
39+// showOnMainThread is a callback that runs a function on the GTK main thread
40+// (typically using glib.IdleAdd). This is required because the timer fires
41+// on a background goroutine.
42+func NewInsertAccentUseCase(
43+ focusProvider port.FocusedInputProvider,
44+ accentPicker port.AccentPickerUI,
45+ showOnMainThread func(fn func()),
46+) *InsertAccentUseCase {
47+ return &InsertAccentUseCase{
48+ focusProvider: focusProvider,
49+ accentPicker: accentPicker,
50+ showPickerOnMainThread: showOnMainThread,
51+ }
52+}
53+
54+// OnKeyPressed handles a key press event.
55+// Returns true if the use case is handling this key and the caller should suppress it.
56+// Note: Due to WebKit's input handling, key suppression may not be fully effective.
57+// When the picker shows, we delete any repeated characters that slipped through.
58+func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
59+ log := logging.FromContext(ctx)
60+
61+ uc.mu.Lock()
62+ defer uc.mu.Unlock()
63+
64+ // If picker is visible, block all keys (picker handles them)
65+ if uc.pickerVisible {
66+ return true
67+ }
68+
69+ // If the same key is still pressed (key repeat), count it
70+ // We try to block it but also track repeats in case blocking doesn't work
71+ if uc.pressedChar == char && uc.timer != nil {
72+ uc.repeatCount++
73+ log.Debug().
74+ Str("char", string(char)).
75+ Int("repeat_count", uc.repeatCount).
76+ Msg("key repeat detected")
77+ return true // Try to block repeat keys
78+ }
79+
80+ // Cancel any pending timer for a different key
81+ uc.cancelTimerLocked()
82+
83+ // Check if this character has accents
84+ if !entity.HasAccents(char) {
85+ return false
86+ }
87+
88+ // Capture the currently focused input - we'll insert into this target
89+ // even if focus changes when the picker appears
90+ uc.targetInput = uc.focusProvider.GetFocusedInput()
91+ if uc.targetInput == nil {
92+ log.Debug().Msg("no focused input, skipping accent detection")
93+ return false
94+ }
95+
96+ // Start long-press detection
97+ uc.pressedChar = char
98+ uc.shiftHeld = shiftHeld
99+ uc.repeatCount = 0 // Reset repeat counter
100+
101+ log.Debug().
102+ Str("char", string(char)).
103+ Bool("shift", shiftHeld).
104+ Msg("starting long-press detection")
105+
106+ uc.timer = time.AfterFunc(LongPressDelay, func() {
107+ uc.onLongPressTriggered(ctx)
108+ })
109+
110+ // Allow the first key press through (user might just want to type quickly)
111+ return false
112+}
113+
114+// OnKeyReleased handles a key release event.
115+// This cancels any pending long-press detection.
116+func (uc *InsertAccentUseCase) OnKeyReleased(ctx context.Context, char rune) {
117+ log := logging.FromContext(ctx)
118+
119+ uc.mu.Lock()
120+ defer uc.mu.Unlock()
121+
122+ // Only cancel if it's the same key that was pressed
123+ if uc.pressedChar == char && uc.timer != nil {
124+ log.Debug().
125+ Str("char", string(char)).
126+ Msg("key released before long-press threshold")
127+ uc.cancelTimerLocked()
128+ }
129+}
130+
131+// Cancel cancels any pending long-press detection and hides the picker.
132+func (uc *InsertAccentUseCase) Cancel(ctx context.Context) {
133+ log := logging.FromContext(ctx)
134+
135+ uc.mu.Lock()
136+ defer uc.mu.Unlock()
137+
138+ if uc.timer != nil || uc.pickerVisible {
139+ log.Debug().Msg("canceling accent picker")
140+ }
141+
142+ uc.cancelTimerLocked()
143+ if uc.pickerVisible {
144+ uc.accentPicker.Hide()
145+ uc.pickerVisible = false
146+ }
147+}
148+
149+// IsPickerVisible returns true if the accent picker is currently visible.
150+func (uc *InsertAccentUseCase) IsPickerVisible() bool {
151+ uc.mu.Lock()
152+ defer uc.mu.Unlock()
153+ return uc.pickerVisible
154+}
155+
156+// onLongPressTriggered is called when the long-press timer fires.
157+// This runs on a background goroutine, so we use showPickerOnMainThread.
158+func (uc *InsertAccentUseCase) onLongPressTriggered(ctx context.Context) {
159+ log := logging.FromContext(ctx)
160+
161+ uc.mu.Lock()
162+ char := uc.pressedChar
163+ uppercase := uc.shiftHeld
164+ repeatCount := uc.repeatCount
165+ uc.timer = nil
166+ uc.mu.Unlock()
167+
168+ accents := entity.GetAccents(char, uppercase)
169+ if len(accents) == 0 {
170+ return
171+ }
172+
173+ log.Debug().
174+ Str("char", string(char)).
175+ Bool("uppercase", uppercase).
176+ Int("accents", len(accents)).
177+ Int("repeat_count", repeatCount).
178+ Msg("long-press triggered, showing accent picker")
179+
180+ // Show picker on main thread
181+ uc.showPickerOnMainThread(func() {
182+ uc.showPicker(ctx, accents, repeatCount)
183+ })
184+}
185+
186+// showPicker displays the accent picker UI.
187+// Must be called on the GTK main thread.
188+// repeatCount is the number of repeated characters that may have been typed
189+// (despite our attempt to block them) and need to be deleted.
190+func (uc *InsertAccentUseCase) showPicker(ctx context.Context, accents []rune, repeatCount int) {
191+ log := logging.FromContext(ctx)
192+
193+ uc.mu.Lock()
194+ target := uc.targetInput // Use the captured target, not current focus
195+ uc.mu.Unlock()
196+
197+ // Delete any repeated characters that slipped through
198+ // +1 for the initial character that was typed
199+ charsToDelete := repeatCount + 1
200+ if charsToDelete > 0 && target != nil {
201+ log.Debug().
202+ Int("chars_to_delete", charsToDelete).
203+ Msg("deleting repeated characters before showing picker")
204+ if err := target.DeleteBeforeCursor(ctx, charsToDelete); err != nil {
205+ log.Warn().Err(err).Msg("failed to delete repeated characters")
206+ }
207+ }
208+
209+ uc.mu.Lock()
210+ uc.pickerVisible = true
211+ uc.mu.Unlock()
212+
213+ uc.accentPicker.Show(accents, func(accent rune) {
214+ uc.onAccentSelected(ctx, accent)
215+ }, func() {
216+ uc.onPickerCanceled(ctx)
217+ })
218+
219+ log.Debug().Msg("accent picker shown")
220+}
221+
222+// onAccentSelected is called when the user selects an accent.
223+func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune) {
224+ log := logging.FromContext(ctx)
225+
226+ uc.mu.Lock()
227+ target := uc.targetInput // Use the captured target, not current focus
228+ uc.pickerVisible = false
229+ uc.pressedChar = 0
230+ uc.repeatCount = 0
231+ uc.targetInput = nil
232+ uc.mu.Unlock()
233+
234+ uc.accentPicker.Hide()
235+
236+ if target == nil {
237+ log.Warn().Msg("no target input for accent insertion")
238+ return
239+ }
240+
241+ log.Debug().
242+ Str("accent", string(accent)).
243+ Msg("inserting accent")
244+
245+ if err := target.InsertText(ctx, string(accent)); err != nil {
246+ log.Error().Err(err).Msg("failed to insert accent")
247+ }
248+}
249+
250+// onPickerCanceled is called when the user cancels the picker (Escape).
251+func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
252+ log := logging.FromContext(ctx)
253+
254+ uc.mu.Lock()
255+ uc.pickerVisible = false
256+ uc.pressedChar = 0
257+ uc.repeatCount = 0
258+ uc.targetInput = nil
259+ uc.mu.Unlock()
260+
261+ uc.accentPicker.Hide()
262+
263+ log.Debug().Msg("accent picker canceled")
264+}
265+
266+// cancelTimerLocked cancels the long-press timer.
267+// Must be called with uc.mu held.
268+func (uc *InsertAccentUseCase) cancelTimerLocked() {
269+ if uc.timer != nil {
270+ uc.timer.Stop()
271+ uc.timer = nil
272+ }
273+ uc.pressedChar = 0
274+ uc.repeatCount = 0
275+ uc.targetInput = nil
276+}
A internal/application/usecase/insert_accent_test.go +219 -0
@@ -0,0 +1,219 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
15+ ctx := context.Background()
16+ focusProvider := mocks.NewMockFocusedInputProvider(t)
17+ accentPicker := mocks.NewMockAccentPickerUI(t)
18+
19+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
20+
21+ // Key 'x' has no accents - should return false and not start timer
22+ handled := uc.OnKeyPressed(ctx, 'x', false)
23+ assert.False(t, handled, "OnKeyPressed should return false for key without accents")
24+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible")
25+}
26+
27+func TestInsertAccentUseCase_OnKeyPressed_HasAccents(t *testing.T) {
28+ ctx := context.Background()
29+ focusProvider := mocks.NewMockFocusedInputProvider(t)
30+ accentPicker := mocks.NewMockAccentPickerUI(t)
31+ textInput := mocks.NewMockTextInputTarget(t)
32+
33+ // GetFocusedInput is called when starting long-press to capture target
34+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
35+
36+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
37+
38+ // Key 'e' has accents - should return false initially (timer started)
39+ handled := uc.OnKeyPressed(ctx, 'e', false)
40+ assert.False(t, handled, "OnKeyPressed should return false while timer is pending")
41+
42+ // Cancel to clean up timer
43+ uc.Cancel(ctx)
44+}
45+
46+func TestInsertAccentUseCase_OnKeyReleased_CancelsTimer(t *testing.T) {
47+ ctx := context.Background()
48+ focusProvider := mocks.NewMockFocusedInputProvider(t)
49+ accentPicker := mocks.NewMockAccentPickerUI(t)
50+ textInput := mocks.NewMockTextInputTarget(t)
51+
52+ // GetFocusedInput is called when starting long-press to capture target
53+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
54+
55+ timerFired := false
56+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) {
57+ timerFired = true
58+ fn()
59+ })
60+
61+ // Start long-press detection
62+ uc.OnKeyPressed(ctx, 'e', false)
63+
64+ // Release before timer fires
65+ uc.OnKeyReleased(ctx, 'e')
66+
67+ // Wait a bit longer than LongPressDelay
68+ time.Sleep(LongPressDelay + 50*time.Millisecond)
69+
70+ assert.False(t, timerFired, "timer should have been canceled by key release")
71+}
72+
73+func TestInsertAccentUseCase_LongPress_ShowsPicker(t *testing.T) {
74+ ctx := context.Background()
75+ focusProvider := mocks.NewMockFocusedInputProvider(t)
76+ accentPicker := mocks.NewMockAccentPickerUI(t)
77+ textInput := mocks.NewMockTextInputTarget(t)
78+
79+ var wg sync.WaitGroup
80+ wg.Add(1)
81+
82+ // GetFocusedInput is called when showing picker to delete repeated characters
83+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
84+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
85+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
86+
87+ // Expect Show to be called with accents for 'e'
88+ accentPicker.EXPECT().
89+ Show(mock.MatchedBy(func(accents []rune) bool {
90+ return len(accents) > 0 && accents[0] == 'è'
91+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
92+ Run(func(_ []rune, _ func(rune), _ func()) {
93+ wg.Done()
94+ }).
95+ Once()
96+
97+ // Hide will be called by Cancel
98+ accentPicker.EXPECT().Hide().Maybe()
99+
100+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
101+
102+ // Start long-press detection
103+ uc.OnKeyPressed(ctx, 'e', false)
104+
105+ // Wait for timer to fire
106+ wg.Wait()
107+ time.Sleep(50 * time.Millisecond) // Give time for state update
108+
109+ assert.True(t, uc.IsPickerVisible(), "picker should be visible after long-press")
110+
111+ // Clean up
112+ uc.Cancel(ctx)
113+}
114+
115+func TestInsertAccentUseCase_Cancel(t *testing.T) {
116+ ctx := context.Background()
117+ focusProvider := mocks.NewMockFocusedInputProvider(t)
118+ accentPicker := mocks.NewMockAccentPickerUI(t)
119+ textInput := mocks.NewMockTextInputTarget(t)
120+
121+ // GetFocusedInput is called when starting long-press to capture target
122+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
123+
124+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
125+
126+ // Start timer
127+ uc.OnKeyPressed(ctx, 'e', false)
128+
129+ // Cancel should stop timer
130+ uc.Cancel(ctx)
131+
132+ // Wait past the timer duration
133+ time.Sleep(LongPressDelay + 50*time.Millisecond)
134+
135+ // Picker should never have been shown
136+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible after cancel")
137+}
138+
139+func TestInsertAccentUseCase_UppercaseAccents(t *testing.T) {
140+ ctx := context.Background()
141+ focusProvider := mocks.NewMockFocusedInputProvider(t)
142+ accentPicker := mocks.NewMockAccentPickerUI(t)
143+ textInput := mocks.NewMockTextInputTarget(t)
144+
145+ var wg sync.WaitGroup
146+ wg.Add(1)
147+
148+ // GetFocusedInput is called when showing picker to delete repeated characters
149+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
150+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
151+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
152+
153+ // Expect Show to be called with uppercase accents when shift is held
154+ accentPicker.EXPECT().
155+ Show(mock.MatchedBy(func(accents []rune) bool {
156+ return len(accents) > 0 && accents[0] == 'È' // Uppercase È
157+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
158+ Run(func(_ []rune, _ func(rune), _ func()) {
159+ wg.Done()
160+ }).
161+ Once()
162+
163+ // Hide will be called by Cancel
164+ accentPicker.EXPECT().Hide().Maybe()
165+
166+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
167+
168+ // Start long-press with shift held
169+ uc.OnKeyPressed(ctx, 'e', true)
170+
171+ // Wait for timer
172+ wg.Wait()
173+
174+ // Clean up
175+ uc.Cancel(ctx)
176+}
177+
178+func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
179+ ctx := context.Background()
180+ focusProvider := mocks.NewMockFocusedInputProvider(t)
181+ accentPicker := mocks.NewMockAccentPickerUI(t)
182+ textInput := mocks.NewMockTextInputTarget(t)
183+
184+ var showCalled int
185+ var mu sync.Mutex
186+
187+ // GetFocusedInput is called when showing picker to delete repeated characters
188+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
189+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
190+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
191+
192+ accentPicker.EXPECT().
193+ Show(mock.Anything, mock.Anything, mock.Anything).
194+ Run(func(_ []rune, _ func(rune), _ func()) {
195+ mu.Lock()
196+ showCalled++
197+ mu.Unlock()
198+ }).
199+ Maybe()
200+
201+ accentPicker.EXPECT().Hide().Maybe()
202+
203+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
204+
205+ // Trigger first long-press
206+ uc.OnKeyPressed(ctx, 'e', false)
207+ time.Sleep(LongPressDelay + 50*time.Millisecond)
208+
209+ // Try to start another long-press while picker is visible
210+ handled := uc.OnKeyPressed(ctx, 'a', false)
211+ assert.True(t, handled, "should return true when picker is visible")
212+
213+ mu.Lock()
214+ count := showCalled
215+ mu.Unlock()
216+ assert.LessOrEqual(t, count, 1, "Show should only be called once")
217+
218+ uc.Cancel(ctx)
219+}
@@ -0,0 +1,219 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
15+ ctx := context.Background()
16+ focusProvider := mocks.NewMockFocusedInputProvider(t)
17+ accentPicker := mocks.NewMockAccentPickerUI(t)
18+
19+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
20+
21+ // Key 'x' has no accents - should return false and not start timer
22+ handled := uc.OnKeyPressed(ctx, 'x', false)
23+ assert.False(t, handled, "OnKeyPressed should return false for key without accents")
24+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible")
25+}
26+
27+func TestInsertAccentUseCase_OnKeyPressed_HasAccents(t *testing.T) {
28+ ctx := context.Background()
29+ focusProvider := mocks.NewMockFocusedInputProvider(t)
30+ accentPicker := mocks.NewMockAccentPickerUI(t)
31+ textInput := mocks.NewMockTextInputTarget(t)
32+
33+ // GetFocusedInput is called when starting long-press to capture target
34+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
35+
36+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
37+
38+ // Key 'e' has accents - should return false initially (timer started)
39+ handled := uc.OnKeyPressed(ctx, 'e', false)
40+ assert.False(t, handled, "OnKeyPressed should return false while timer is pending")
41+
42+ // Cancel to clean up timer
43+ uc.Cancel(ctx)
44+}
45+
46+func TestInsertAccentUseCase_OnKeyReleased_CancelsTimer(t *testing.T) {
47+ ctx := context.Background()
48+ focusProvider := mocks.NewMockFocusedInputProvider(t)
49+ accentPicker := mocks.NewMockAccentPickerUI(t)
50+ textInput := mocks.NewMockTextInputTarget(t)
51+
52+ // GetFocusedInput is called when starting long-press to capture target
53+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
54+
55+ timerFired := false
56+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) {
57+ timerFired = true
58+ fn()
59+ })
60+
61+ // Start long-press detection
62+ uc.OnKeyPressed(ctx, 'e', false)
63+
64+ // Release before timer fires
65+ uc.OnKeyReleased(ctx, 'e')
66+
67+ // Wait a bit longer than LongPressDelay
68+ time.Sleep(LongPressDelay + 50*time.Millisecond)
69+
70+ assert.False(t, timerFired, "timer should have been canceled by key release")
71+}
72+
73+func TestInsertAccentUseCase_LongPress_ShowsPicker(t *testing.T) {
74+ ctx := context.Background()
75+ focusProvider := mocks.NewMockFocusedInputProvider(t)
76+ accentPicker := mocks.NewMockAccentPickerUI(t)
77+ textInput := mocks.NewMockTextInputTarget(t)
78+
79+ var wg sync.WaitGroup
80+ wg.Add(1)
81+
82+ // GetFocusedInput is called when showing picker to delete repeated characters
83+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
84+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
85+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
86+
87+ // Expect Show to be called with accents for 'e'
88+ accentPicker.EXPECT().
89+ Show(mock.MatchedBy(func(accents []rune) bool {
90+ return len(accents) > 0 && accents[0] == 'è'
91+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
92+ Run(func(_ []rune, _ func(rune), _ func()) {
93+ wg.Done()
94+ }).
95+ Once()
96+
97+ // Hide will be called by Cancel
98+ accentPicker.EXPECT().Hide().Maybe()
99+
100+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
101+
102+ // Start long-press detection
103+ uc.OnKeyPressed(ctx, 'e', false)
104+
105+ // Wait for timer to fire
106+ wg.Wait()
107+ time.Sleep(50 * time.Millisecond) // Give time for state update
108+
109+ assert.True(t, uc.IsPickerVisible(), "picker should be visible after long-press")
110+
111+ // Clean up
112+ uc.Cancel(ctx)
113+}
114+
115+func TestInsertAccentUseCase_Cancel(t *testing.T) {
116+ ctx := context.Background()
117+ focusProvider := mocks.NewMockFocusedInputProvider(t)
118+ accentPicker := mocks.NewMockAccentPickerUI(t)
119+ textInput := mocks.NewMockTextInputTarget(t)
120+
121+ // GetFocusedInput is called when starting long-press to capture target
122+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
123+
124+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
125+
126+ // Start timer
127+ uc.OnKeyPressed(ctx, 'e', false)
128+
129+ // Cancel should stop timer
130+ uc.Cancel(ctx)
131+
132+ // Wait past the timer duration
133+ time.Sleep(LongPressDelay + 50*time.Millisecond)
134+
135+ // Picker should never have been shown
136+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible after cancel")
137+}
138+
139+func TestInsertAccentUseCase_UppercaseAccents(t *testing.T) {
140+ ctx := context.Background()
141+ focusProvider := mocks.NewMockFocusedInputProvider(t)
142+ accentPicker := mocks.NewMockAccentPickerUI(t)
143+ textInput := mocks.NewMockTextInputTarget(t)
144+
145+ var wg sync.WaitGroup
146+ wg.Add(1)
147+
148+ // GetFocusedInput is called when showing picker to delete repeated characters
149+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
150+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
151+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
152+
153+ // Expect Show to be called with uppercase accents when shift is held
154+ accentPicker.EXPECT().
155+ Show(mock.MatchedBy(func(accents []rune) bool {
156+ return len(accents) > 0 && accents[0] == 'È' // Uppercase È
157+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
158+ Run(func(_ []rune, _ func(rune), _ func()) {
159+ wg.Done()
160+ }).
161+ Once()
162+
163+ // Hide will be called by Cancel
164+ accentPicker.EXPECT().Hide().Maybe()
165+
166+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
167+
168+ // Start long-press with shift held
169+ uc.OnKeyPressed(ctx, 'e', true)
170+
171+ // Wait for timer
172+ wg.Wait()
173+
174+ // Clean up
175+ uc.Cancel(ctx)
176+}
177+
178+func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
179+ ctx := context.Background()
180+ focusProvider := mocks.NewMockFocusedInputProvider(t)
181+ accentPicker := mocks.NewMockAccentPickerUI(t)
182+ textInput := mocks.NewMockTextInputTarget(t)
183+
184+ var showCalled int
185+ var mu sync.Mutex
186+
187+ // GetFocusedInput is called when showing picker to delete repeated characters
188+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
189+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
190+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
191+
192+ accentPicker.EXPECT().
193+ Show(mock.Anything, mock.Anything, mock.Anything).
194+ Run(func(_ []rune, _ func(rune), _ func()) {
195+ mu.Lock()
196+ showCalled++
197+ mu.Unlock()
198+ }).
199+ Maybe()
200+
201+ accentPicker.EXPECT().Hide().Maybe()
202+
203+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
204+
205+ // Trigger first long-press
206+ uc.OnKeyPressed(ctx, 'e', false)
207+ time.Sleep(LongPressDelay + 50*time.Millisecond)
208+
209+ // Try to start another long-press while picker is visible
210+ handled := uc.OnKeyPressed(ctx, 'a', false)
211+ assert.True(t, handled, "should return true when picker is visible")
212+
213+ mu.Lock()
214+ count := showCalled
215+ mu.Unlock()
216+ assert.LessOrEqual(t, count, 1, "Show should only be called once")
217+
218+ uc.Cancel(ctx)
219+}

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