1// Package usecase contains application use cases that orchestrate domain logic.
2package usecase
3
4import (
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// Compile-time check: InsertAccentUseCase must satisfy port.AccentKeyHandler.
15var _ port.AccentKeyHandler = (*InsertAccentUseCase)(nil)
16
17// LongPressDelay is the duration a key must be held to trigger the accent picker.
18const LongPressDelay = 400 * time.Millisecond
19
20// InsertAccentUseCase handles long-press detection and accent selection.
21// When a key with accent variants is held for 400ms, it shows an accent picker
22// and inserts the selected accent into the focused text input.
23type InsertAccentUseCase struct {
24 focusProvider port.FocusedInputProvider
25 accentPicker port.AccentPickerUI
26
27 // Current long-press state
28 pressedChar rune
29 shiftHeld bool
30 timer *time.Timer
31 pickerVisible bool
32 repeatCount int // Number of key repeats while timer is pending
33 targetInput port.TextInputTarget // The input that was focused when long-press started
34
35 // Callback to show picker on GTK main thread
36 showPickerOnMainThread func(fn func())
37
38 mu sync.Mutex
39}
40
41// NewInsertAccentUseCase creates a new accent insertion use case.
42// showOnMainThread is a callback that runs a function on the GTK main thread
43// (typically using glib.IdleAdd). This is required because the timer fires
44// on a background goroutine.
45func NewInsertAccentUseCase(
46 focusProvider port.FocusedInputProvider,
47 accentPicker port.AccentPickerUI,
48 showOnMainThread func(fn func()),
49) *InsertAccentUseCase {
50 return &InsertAccentUseCase{
51 focusProvider: focusProvider,
52 accentPicker: accentPicker,
53 showPickerOnMainThread: showOnMainThread,
54 }
55}
56
57// OnKeyPressed handles a key press event.
58// Returns true if the use case is handling this key and the caller should suppress it.
59// Note: Key suppression may not be fully effective for all widget types.
60// When the picker shows, we delete any repeated characters that slipped through.
61// The accent picker is available for both GTK Entry widgets and WebView inputs
62// as an accessibility feature: users with motor disabilities or without a
63// dead-key keyboard layout can long-press to access accented characters.
64func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
65 log := logging.FromContext(ctx)
66
67 uc.mu.Lock()
68 defer uc.mu.Unlock()
69
70 // If picker is visible, block all keys (picker handles them)
71 if uc.pickerVisible {
72 return true
73 }
74
75 // If the same key is still pressed (key repeat), count it
76 // We try to block it but also track repeats in case blocking doesn't work
77 if uc.pressedChar == char && uc.timer != nil {
78 uc.repeatCount++
79 log.Debug().
80 Str("char", string(char)).
81 Int("repeat_count", uc.repeatCount).
82 Msg("key repeat detected")
83 return true // Try to block repeat keys
84 }
85
86 // Cancel any pending timer for a different key
87 uc.cancelTimerLocked()
88
89 // Check if this character has accents
90 if !entity.HasAccents(char) {
91 return false
92 }
93
94 // Capture the currently focused input - we'll insert into this target
95 // even if focus changes when the picker appears
96 uc.targetInput = uc.focusProvider.GetFocusedInput()
97 if uc.targetInput == nil {
98 log.Debug().Msg("no focused input, skipping accent detection")
99 return false
100 }
101
102 // Start long-press detection
103 uc.pressedChar = char
104 uc.shiftHeld = shiftHeld
105 uc.repeatCount = 0 // Reset repeat counter
106
107 log.Debug().
108 Str("char", string(char)).
109 Bool("shift", shiftHeld).
110 Msg("starting long-press detection")
111
112 // Use context.Background() since timer fires after delay and original ctx may be canceled
113 uc.timer = time.AfterFunc(LongPressDelay, func() {
114 uc.onLongPressTriggered(context.Background())
115 })
116
117 // Allow the first key press through (user might just want to type quickly)
118 return false
119}
120
121// OnKeyReleased handles a key release event.
122// This cancels any pending long-press detection.
123func (uc *InsertAccentUseCase) OnKeyReleased(ctx context.Context, char rune) {
124 log := logging.FromContext(ctx)
125
126 uc.mu.Lock()
127 defer uc.mu.Unlock()
128
129 // Only cancel if it's the same key that was pressed
130 if uc.pressedChar == char && uc.timer != nil {
131 log.Debug().
132 Str("char", string(char)).
133 Msg("key released before long-press threshold")
134 uc.cancelTimerLocked()
135 }
136}
137
138// Cancel cancels any pending long-press detection and hides the picker.
139func (uc *InsertAccentUseCase) Cancel(ctx context.Context) {
140 log := logging.FromContext(ctx)
141
142 uc.mu.Lock()
143 target := uc.targetInput
144 wasVisible := uc.pickerVisible
145 uc.cancelTimerLocked()
146 if wasVisible {
147 uc.accentPicker.Hide()
148 uc.pickerVisible = false
149 }
150 uc.mu.Unlock()
151
152 if wasVisible {
153 log.Debug().Msg("canceling accent picker")
154 }
155
156 if wasVisible {
157 if focusable, ok := target.(port.Focusable); ok {
158 focusable.Focus(ctx)
159 }
160 }
161}
162
163// IsPickerVisible returns true if the accent picker is currently visible.
164func (uc *InsertAccentUseCase) IsPickerVisible() bool {
165 uc.mu.Lock()
166 defer uc.mu.Unlock()
167 return uc.pickerVisible
168}
169
170// onLongPressTriggered is called when the long-press timer fires.
171// This runs on a background goroutine, so we use showPickerOnMainThread.
172func (uc *InsertAccentUseCase) onLongPressTriggered(ctx context.Context) {
173 log := logging.FromContext(ctx)
174
175 uc.mu.Lock()
176 char := uc.pressedChar
177 uppercase := uc.shiftHeld
178 repeatCount := uc.repeatCount
179 uc.timer = nil
180 uc.mu.Unlock()
181
182 accents := entity.GetAccents(char, uppercase)
183 if len(accents) == 0 {
184 return
185 }
186
187 log.Debug().
188 Str("char", string(char)).
189 Bool("uppercase", uppercase).
190 Int("accents", len(accents)).
191 Int("repeat_count", repeatCount).
192 Msg("long-press triggered, showing accent picker")
193
194 // Show picker on main thread
195 uc.showPickerOnMainThread(func() {
196 uc.showPicker(ctx, accents, repeatCount)
197 })
198}
199
200// showPicker displays the accent picker UI.
201// Must be called on the GTK main thread.
202// repeatCount is the number of repeated characters that may have been typed
203// (despite our attempt to block them) and need to be deleted.
204func (uc *InsertAccentUseCase) showPicker(ctx context.Context, accents []rune, repeatCount int) {
205 log := logging.FromContext(ctx)
206
207 uc.mu.Lock()
208 target := uc.targetInput // Use the captured target, not current focus
209 uc.mu.Unlock()
210
211 // Delete any repeated characters that slipped through
212 // +1 for the initial character that was typed
213 charsToDelete := repeatCount + 1
214 if charsToDelete > 0 && target != nil {
215 log.Debug().
216 Int("chars_to_delete", charsToDelete).
217 Msg("deleting repeated characters before showing picker")
218 if err := target.DeleteBeforeCursor(ctx, charsToDelete); err != nil {
219 log.Warn().Err(err).Msg("failed to delete repeated characters")
220 }
221 }
222
223 uc.mu.Lock()
224 uc.pickerVisible = true
225 uc.mu.Unlock()
226
227 uc.accentPicker.Show(accents, func(accent rune) {
228 uc.onAccentSelected(ctx, accent)
229 }, func() {
230 uc.onPickerCanceled(ctx)
231 })
232
233 log.Debug().Msg("accent picker shown")
234}
235
236// onAccentSelected is called when the user selects an accent.
237func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune) {
238 log := logging.FromContext(ctx)
239
240 uc.mu.Lock()
241 target := uc.targetInput // Use the captured target, not current focus
242 uc.pickerVisible = false
243 uc.pressedChar = 0
244 uc.repeatCount = 0
245 uc.targetInput = nil
246 uc.mu.Unlock()
247
248 uc.accentPicker.Hide()
249
250 if target == nil {
251 log.Warn().Msg("no target input for accent insertion")
252 return
253 }
254
255 log.Debug().
256 Str("accent", string(accent)).
257 Msg("inserting accent")
258
259 if err := target.InsertText(ctx, string(accent)); err != nil {
260 log.Error().Err(err).Msg("failed to insert accent")
261 }
262
263 if focusable, ok := target.(port.Focusable); ok {
264 focusable.Focus(ctx)
265 }
266}
267
268// onPickerCanceled is called when the user cancels the picker (Escape).
269func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
270 log := logging.FromContext(ctx)
271
272 uc.mu.Lock()
273 target := uc.targetInput
274 uc.pickerVisible = false
275 uc.pressedChar = 0
276 uc.repeatCount = 0
277 uc.targetInput = nil
278 uc.mu.Unlock()
279
280 uc.accentPicker.Hide()
281
282 if focusable, ok := target.(port.Focusable); ok {
283 focusable.Focus(ctx)
284 }
285
286 log.Debug().Msg("accent picker canceled")
287}
288
289// cancelTimerLocked cancels the long-press timer.
290// Must be called with uc.mu held.
291func (uc *InsertAccentUseCase) cancelTimerLocked() {
292 if uc.timer != nil {
293 uc.timer.Stop()
294 uc.timer = nil
295 }
296 uc.pressedChar = 0
297 uc.repeatCount = 0
298 uc.targetInput = nil
299}