v0.23.2 bfd5eb4

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// 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}

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