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// LongPressDelay is the duration a key must be held to trigger the accent picker.
15const 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.
20type 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.
42func 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.
58func (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 // Use context.Background() since timer fires after delay and original ctx may be canceled
107 uc.timer = time.AfterFunc(LongPressDelay, func() {
108 uc.onLongPressTriggered(context.Background())
109 })
110
111 // Allow the first key press through (user might just want to type quickly)
112 return false
113}
114
115// OnKeyReleased handles a key release event.
116// This cancels any pending long-press detection.
117func (uc *InsertAccentUseCase) OnKeyReleased(ctx context.Context, char rune) {
118 log := logging.FromContext(ctx)
119
120 uc.mu.Lock()
121 defer uc.mu.Unlock()
122
123 // Only cancel if it's the same key that was pressed
124 if uc.pressedChar == char && uc.timer != nil {
125 log.Debug().
126 Str("char", string(char)).
127 Msg("key released before long-press threshold")
128 uc.cancelTimerLocked()
129 }
130}
131
132// Cancel cancels any pending long-press detection and hides the picker.
133func (uc *InsertAccentUseCase) Cancel(ctx context.Context) {
134 log := logging.FromContext(ctx)
135
136 uc.mu.Lock()
137 defer uc.mu.Unlock()
138
139 if uc.timer != nil || uc.pickerVisible {
140 log.Debug().Msg("canceling accent picker")
141 }
142
143 uc.cancelTimerLocked()
144 if uc.pickerVisible {
145 uc.accentPicker.Hide()
146 uc.pickerVisible = false
147 }
148}
149
150// IsPickerVisible returns true if the accent picker is currently visible.
151func (uc *InsertAccentUseCase) IsPickerVisible() bool {
152 uc.mu.Lock()
153 defer uc.mu.Unlock()
154 return uc.pickerVisible
155}
156
157// onLongPressTriggered is called when the long-press timer fires.
158// This runs on a background goroutine, so we use showPickerOnMainThread.
159func (uc *InsertAccentUseCase) onLongPressTriggered(ctx context.Context) {
160 log := logging.FromContext(ctx)
161
162 uc.mu.Lock()
163 char := uc.pressedChar
164 uppercase := uc.shiftHeld
165 repeatCount := uc.repeatCount
166 uc.timer = nil
167 uc.mu.Unlock()
168
169 accents := entity.GetAccents(char, uppercase)
170 if len(accents) == 0 {
171 return
172 }
173
174 log.Debug().
175 Str("char", string(char)).
176 Bool("uppercase", uppercase).
177 Int("accents", len(accents)).
178 Int("repeat_count", repeatCount).
179 Msg("long-press triggered, showing accent picker")
180
181 // Show picker on main thread
182 uc.showPickerOnMainThread(func() {
183 uc.showPicker(ctx, accents, repeatCount)
184 })
185}
186
187// showPicker displays the accent picker UI.
188// Must be called on the GTK main thread.
189// repeatCount is the number of repeated characters that may have been typed
190// (despite our attempt to block them) and need to be deleted.
191func (uc *InsertAccentUseCase) showPicker(ctx context.Context, accents []rune, repeatCount int) {
192 log := logging.FromContext(ctx)
193
194 uc.mu.Lock()
195 target := uc.targetInput // Use the captured target, not current focus
196 uc.mu.Unlock()
197
198 // Delete any repeated characters that slipped through
199 // +1 for the initial character that was typed
200 charsToDelete := repeatCount + 1
201 if charsToDelete > 0 && target != nil {
202 log.Debug().
203 Int("chars_to_delete", charsToDelete).
204 Msg("deleting repeated characters before showing picker")
205 if err := target.DeleteBeforeCursor(ctx, charsToDelete); err != nil {
206 log.Warn().Err(err).Msg("failed to delete repeated characters")
207 }
208 }
209
210 uc.mu.Lock()
211 uc.pickerVisible = true
212 uc.mu.Unlock()
213
214 uc.accentPicker.Show(accents, func(accent rune) {
215 uc.onAccentSelected(ctx, accent)
216 }, func() {
217 uc.onPickerCanceled(ctx)
218 })
219
220 log.Debug().Msg("accent picker shown")
221}
222
223// onAccentSelected is called when the user selects an accent.
224func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune) {
225 log := logging.FromContext(ctx)
226
227 uc.mu.Lock()
228 target := uc.targetInput // Use the captured target, not current focus
229 uc.pickerVisible = false
230 uc.pressedChar = 0
231 uc.repeatCount = 0
232 uc.targetInput = nil
233 uc.mu.Unlock()
234
235 uc.accentPicker.Hide()
236
237 if target == nil {
238 log.Warn().Msg("no target input for accent insertion")
239 return
240 }
241
242 log.Debug().
243 Str("accent", string(accent)).
244 Msg("inserting accent")
245
246 if err := target.InsertText(ctx, string(accent)); err != nil {
247 log.Error().Err(err).Msg("failed to insert accent")
248 }
249}
250
251// onPickerCanceled is called when the user cancels the picker (Escape).
252func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
253 log := logging.FromContext(ctx)
254
255 uc.mu.Lock()
256 uc.pickerVisible = false
257 uc.pressedChar = 0
258 uc.repeatCount = 0
259 uc.targetInput = nil
260 uc.mu.Unlock()
261
262 uc.accentPicker.Hide()
263
264 log.Debug().Msg("accent picker canceled")
265}
266
267// cancelTimerLocked cancels the long-press timer.
268// Must be called with uc.mu held.
269func (uc *InsertAccentUseCase) cancelTimerLocked() {
270 if uc.timer != nil {
271 uc.timer.Stop()
272 uc.timer = nil
273 }
274 uc.pressedChar = 0
275 uc.repeatCount = 0
276 uc.targetInput = nil
277}