main 5041353

insert_accent.go RawHistoryFolder

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}

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