detached 6bf928d

feat(accent): add TextInput abstraction and implementations

commit
6bf928d10d3a508e815f1d5bfa4cde0f02310934
author
brice · · 2025-12-29 18:05 UTC
committer
brice · · 2025-12-30 13:48 UTC
parent
1e8b52f
signature
unsigned
4 files · +292 -0
A internal/application/port/text_input.go +25 -0
@@ -0,0 +1,25 @@
1+package port
2+
3+import "context"
4+
5+// TextInputTarget represents a text input field that can receive text insertions.
6+// Implementations include GTK Entry widgets and WebView text fields.
7+type TextInputTarget interface {
8+ // InsertText inserts text at the current cursor position.
9+ InsertText(ctx context.Context, text string) error
10+
11+ // DeleteBeforeCursor deletes n characters before the cursor position.
12+ DeleteBeforeCursor(ctx context.Context, n int) error
13+}
14+
15+// FocusedInputProvider tracks which text input target currently has focus.
16+// This allows the accent picker to insert text into the correct input field.
17+type FocusedInputProvider interface {
18+ // GetFocusedInput returns the currently focused text input target.
19+ // Returns nil if no text input has focus.
20+ GetFocusedInput() TextInputTarget
21+
22+ // SetFocusedInput sets the currently focused text input target.
23+ // Pass nil to clear focus.
24+ SetFocusedInput(target TextInputTarget)
25+}
@@ -0,0 +1,25 @@
1+package port
2+
3+import "context"
4+
5+// TextInputTarget represents a text input field that can receive text insertions.
6+// Implementations include GTK Entry widgets and WebView text fields.
7+type TextInputTarget interface {
8+ // InsertText inserts text at the current cursor position.
9+ InsertText(ctx context.Context, text string) error
10+
11+ // DeleteBeforeCursor deletes n characters before the cursor position.
12+ DeleteBeforeCursor(ctx context.Context, n int) error
13+}
14+
15+// FocusedInputProvider tracks which text input target currently has focus.
16+// This allows the accent picker to insert text into the correct input field.
17+type FocusedInputProvider interface {
18+ // GetFocusedInput returns the currently focused text input target.
19+ // Returns nil if no text input has focus.
20+ GetFocusedInput() TextInputTarget
21+
22+ // SetFocusedInput sets the currently focused text input target.
23+ // Pass nil to clear focus.
24+ SetFocusedInput(target TextInputTarget)
25+}
A internal/infrastructure/textinput/focus_provider.go +39 -0
@@ -0,0 +1,39 @@
1+// Package textinput provides implementations for text input targets and focus tracking.
2+package textinput
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// FocusProvider tracks which text input target currently has focus.
11+// It implements port.FocusedInputProvider.
12+type FocusProvider struct {
13+ target port.TextInputTarget
14+ mu sync.RWMutex
15+}
16+
17+// Compile-time interface check.
18+var _ port.FocusedInputProvider = (*FocusProvider)(nil)
19+
20+// NewFocusProvider creates a new focus provider.
21+func NewFocusProvider() *FocusProvider {
22+ return &FocusProvider{}
23+}
24+
25+// GetFocusedInput returns the currently focused text input target.
26+// Returns nil if no text input has focus.
27+func (fp *FocusProvider) GetFocusedInput() port.TextInputTarget {
28+ fp.mu.RLock()
29+ defer fp.mu.RUnlock()
30+ return fp.target
31+}
32+
33+// SetFocusedInput sets the currently focused text input target.
34+// Pass nil to clear focus.
35+func (fp *FocusProvider) SetFocusedInput(target port.TextInputTarget) {
36+ fp.mu.Lock()
37+ defer fp.mu.Unlock()
38+ fp.target = target
39+}
@@ -0,0 +1,39 @@
1+// Package textinput provides implementations for text input targets and focus tracking.
2+package textinput
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// FocusProvider tracks which text input target currently has focus.
11+// It implements port.FocusedInputProvider.
12+type FocusProvider struct {
13+ target port.TextInputTarget
14+ mu sync.RWMutex
15+}
16+
17+// Compile-time interface check.
18+var _ port.FocusedInputProvider = (*FocusProvider)(nil)
19+
20+// NewFocusProvider creates a new focus provider.
21+func NewFocusProvider() *FocusProvider {
22+ return &FocusProvider{}
23+}
24+
25+// GetFocusedInput returns the currently focused text input target.
26+// Returns nil if no text input has focus.
27+func (fp *FocusProvider) GetFocusedInput() port.TextInputTarget {
28+ fp.mu.RLock()
29+ defer fp.mu.RUnlock()
30+ return fp.target
31+}
32+
33+// SetFocusedInput sets the currently focused text input target.
34+// Pass nil to clear focus.
35+func (fp *FocusProvider) SetFocusedInput(target port.TextInputTarget) {
36+ fp.mu.Lock()
37+ defer fp.mu.Unlock()
38+ fp.target = target
39+}
A internal/infrastructure/textinput/gtk_entry_target.go +96 -0
@@ -0,0 +1,96 @@
1+package textinput
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+)
10+
11+// GTKEntryTarget implements TextInputTarget for GTK SearchEntry widgets.
12+// It uses the Editable interface methods to insert and delete text.
13+type GTKEntryTarget struct {
14+ entry *gtk.SearchEntry
15+}
16+
17+// Compile-time interface check.
18+var _ port.TextInputTarget = (*GTKEntryTarget)(nil)
19+
20+// NewGTKEntryTarget creates a new GTK entry target for a SearchEntry.
21+func NewGTKEntryTarget(entry *gtk.SearchEntry) *GTKEntryTarget {
22+ return &GTKEntryTarget{entry: entry}
23+}
24+
25+// InsertText inserts text at the current cursor position.
26+func (t *GTKEntryTarget) InsertText(ctx context.Context, text string) error {
27+ log := logging.FromContext(ctx)
28+
29+ if t.entry == nil {
30+ log.Warn().Msg("GTKEntryTarget: entry is nil")
31+ return nil
32+ }
33+
34+ // Get current text and cursor position
35+ currentText := t.entry.GetText()
36+ pos := t.entry.GetPosition()
37+
38+ // Convert to runes for proper Unicode handling
39+ runes := []rune(currentText)
40+ textRunes := []rune(text)
41+
42+ // Clamp position to valid range
43+ if pos < 0 {
44+ pos = 0
45+ }
46+ if pos > len(runes) {
47+ pos = len(runes)
48+ }
49+
50+ // Build new text with insertion
51+ newRunes := make([]rune, 0, len(runes)+len(textRunes))
52+ newRunes = append(newRunes, runes[:pos]...)
53+ newRunes = append(newRunes, textRunes...)
54+ newRunes = append(newRunes, runes[pos:]...)
55+
56+ // Set new text and cursor position
57+ t.entry.SetText(string(newRunes))
58+ newPos := pos + len(textRunes)
59+ t.entry.SetPosition(newPos)
60+
61+ log.Debug().
62+ Str("text", text).
63+ Int("pos", pos).
64+ Msg("inserted text into GTK entry")
65+
66+ return nil
67+}
68+
69+// DeleteBeforeCursor deletes n characters before the cursor position.
70+func (t *GTKEntryTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
71+ log := logging.FromContext(ctx)
72+
73+ if t.entry == nil {
74+ log.Warn().Msg("GTKEntryTarget: entry is nil")
75+ return nil
76+ }
77+
78+ pos := t.entry.GetPosition()
79+ if pos < n {
80+ n = pos // Don't delete more than available
81+ }
82+
83+ if n <= 0 {
84+ return nil
85+ }
86+
87+ // DeleteText takes start and end positions
88+ t.entry.DeleteText(pos-n, pos)
89+
90+ log.Debug().
91+ Int("n", n).
92+ Int("pos", pos).
93+ Msg("deleted text before cursor in GTK entry")
94+
95+ return nil
96+}
@@ -0,0 +1,96 @@
1+package textinput
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+ "github.com/jwijenbergh/puregotk/v4/gtk"
9+)
10+
11+// GTKEntryTarget implements TextInputTarget for GTK SearchEntry widgets.
12+// It uses the Editable interface methods to insert and delete text.
13+type GTKEntryTarget struct {
14+ entry *gtk.SearchEntry
15+}
16+
17+// Compile-time interface check.
18+var _ port.TextInputTarget = (*GTKEntryTarget)(nil)
19+
20+// NewGTKEntryTarget creates a new GTK entry target for a SearchEntry.
21+func NewGTKEntryTarget(entry *gtk.SearchEntry) *GTKEntryTarget {
22+ return &GTKEntryTarget{entry: entry}
23+}
24+
25+// InsertText inserts text at the current cursor position.
26+func (t *GTKEntryTarget) InsertText(ctx context.Context, text string) error {
27+ log := logging.FromContext(ctx)
28+
29+ if t.entry == nil {
30+ log.Warn().Msg("GTKEntryTarget: entry is nil")
31+ return nil
32+ }
33+
34+ // Get current text and cursor position
35+ currentText := t.entry.GetText()
36+ pos := t.entry.GetPosition()
37+
38+ // Convert to runes for proper Unicode handling
39+ runes := []rune(currentText)
40+ textRunes := []rune(text)
41+
42+ // Clamp position to valid range
43+ if pos < 0 {
44+ pos = 0
45+ }
46+ if pos > len(runes) {
47+ pos = len(runes)
48+ }
49+
50+ // Build new text with insertion
51+ newRunes := make([]rune, 0, len(runes)+len(textRunes))
52+ newRunes = append(newRunes, runes[:pos]...)
53+ newRunes = append(newRunes, textRunes...)
54+ newRunes = append(newRunes, runes[pos:]...)
55+
56+ // Set new text and cursor position
57+ t.entry.SetText(string(newRunes))
58+ newPos := pos + len(textRunes)
59+ t.entry.SetPosition(newPos)
60+
61+ log.Debug().
62+ Str("text", text).
63+ Int("pos", pos).
64+ Msg("inserted text into GTK entry")
65+
66+ return nil
67+}
68+
69+// DeleteBeforeCursor deletes n characters before the cursor position.
70+func (t *GTKEntryTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
71+ log := logging.FromContext(ctx)
72+
73+ if t.entry == nil {
74+ log.Warn().Msg("GTKEntryTarget: entry is nil")
75+ return nil
76+ }
77+
78+ pos := t.entry.GetPosition()
79+ if pos < n {
80+ n = pos // Don't delete more than available
81+ }
82+
83+ if n <= 0 {
84+ return nil
85+ }
86+
87+ // DeleteText takes start and end positions
88+ t.entry.DeleteText(pos-n, pos)
89+
90+ log.Debug().
91+ Int("n", n).
92+ Int("pos", pos).
93+ Msg("deleted text before cursor in GTK entry")
94+
95+ return nil
96+}
A internal/infrastructure/textinput/webview_target.go +132 -0
@@ -0,0 +1,132 @@
1+package textinput
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk-webkit/webkit"
11+)
12+
13+// clipboardRestoreDelay is the delay before restoring the clipboard after paste.
14+const clipboardRestoreDelay = 50 * time.Millisecond
15+
16+// deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
17+// It handles both input/textarea elements and contenteditable elements.
18+const deleteBeforeCursorJS = `
19+(function() {
20+ const n = %d;
21+ const el = document.activeElement;
22+ if (!el) return;
23+
24+ // Handle input and textarea elements
25+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
26+ const start = el.selectionStart;
27+ const end = el.selectionEnd;
28+ if (start === end && start >= n) {
29+ el.value = el.value.slice(0, start - n) + el.value.slice(end);
30+ el.selectionStart = el.selectionEnd = start - n;
31+ // Trigger input event for frameworks like React
32+ el.dispatchEvent(new Event('input', { bubbles: true }));
33+ }
34+ return;
35+ }
36+
37+ // Handle contenteditable elements
38+ if (el.isContentEditable) {
39+ const sel = window.getSelection();
40+ if (!sel.rangeCount) return;
41+ const range = sel.getRangeAt(0);
42+ for (let i = 0; i < n; i++) {
43+ sel.modify('extend', 'backward', 'character');
44+ }
45+ sel.deleteFromDocument();
46+ }
47+})();
48+`
49+
50+// WebViewTarget implements TextInputTarget for WebView text fields.
51+// It uses the clipboard-paste pattern to insert text:
52+// 1. Save current clipboard
53+// 2. Write text to clipboard
54+// 3. Execute paste command
55+// 4. Restore original clipboard after a short delay
56+type WebViewTarget struct {
57+ webView *webkit.WebView
58+ clipboard port.Clipboard
59+}
60+
61+// Compile-time interface check.
62+var _ port.TextInputTarget = (*WebViewTarget)(nil)
63+
64+// NewWebViewTarget creates a new WebView target.
65+func NewWebViewTarget(webView *webkit.WebView, clipboard port.Clipboard) *WebViewTarget {
66+ return &WebViewTarget{
67+ webView: webView,
68+ clipboard: clipboard,
69+ }
70+}
71+
72+// InsertText inserts text at the current cursor position in the WebView.
73+// Uses clipboard-paste pattern since WebView doesn't expose direct text insertion.
74+func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
75+ log := logging.FromContext(ctx)
76+
77+ if t.webView == nil {
78+ log.Warn().Msg("WebViewTarget: webView is nil")
79+ return nil
80+ }
81+
82+ // Save current clipboard contents
83+ originalClipboard, _ := t.clipboard.ReadText(ctx)
84+
85+ // Write the accent to clipboard
86+ if err := t.clipboard.WriteText(ctx, text); err != nil {
87+ log.Error().Err(err).Msg("failed to write text to clipboard")
88+ return err
89+ }
90+
91+ // Execute paste command
92+ t.webView.ExecuteEditingCommand(webkit.EDITING_COMMAND_PASTE)
93+
94+ log.Debug().
95+ Str("text", text).
96+ Msg("inserted text into WebView via clipboard paste")
97+
98+ // Restore original clipboard after a short delay
99+ // This allows the paste operation to complete first
100+ go func() {
101+ time.Sleep(clipboardRestoreDelay)
102+ if originalClipboard != "" {
103+ if err := t.clipboard.WriteText(ctx, originalClipboard); err != nil {
104+ log.Debug().Err(err).Msg("failed to restore clipboard")
105+ }
106+ }
107+ }()
108+
109+ return nil
110+}
111+
112+// DeleteBeforeCursor deletes n characters before the cursor in the WebView.
113+// Uses JavaScript to manipulate the focused input element.
114+func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
115+ log := logging.FromContext(ctx)
116+
117+ if t.webView == nil {
118+ log.Warn().Msg("WebViewTarget: webView is nil")
119+ return nil
120+ }
121+
122+ if n <= 0 {
123+ return nil
124+ }
125+
126+ // Execute JavaScript to delete characters
127+ script := fmt.Sprintf(deleteBeforeCursorJS, n)
128+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
129+
130+ log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
131+ return nil
132+}
@@ -0,0 +1,132 @@
1+package textinput
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk-webkit/webkit"
11+)
12+
13+// clipboardRestoreDelay is the delay before restoring the clipboard after paste.
14+const clipboardRestoreDelay = 50 * time.Millisecond
15+
16+// deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
17+// It handles both input/textarea elements and contenteditable elements.
18+const deleteBeforeCursorJS = `
19+(function() {
20+ const n = %d;
21+ const el = document.activeElement;
22+ if (!el) return;
23+
24+ // Handle input and textarea elements
25+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
26+ const start = el.selectionStart;
27+ const end = el.selectionEnd;
28+ if (start === end && start >= n) {
29+ el.value = el.value.slice(0, start - n) + el.value.slice(end);
30+ el.selectionStart = el.selectionEnd = start - n;
31+ // Trigger input event for frameworks like React
32+ el.dispatchEvent(new Event('input', { bubbles: true }));
33+ }
34+ return;
35+ }
36+
37+ // Handle contenteditable elements
38+ if (el.isContentEditable) {
39+ const sel = window.getSelection();
40+ if (!sel.rangeCount) return;
41+ const range = sel.getRangeAt(0);
42+ for (let i = 0; i < n; i++) {
43+ sel.modify('extend', 'backward', 'character');
44+ }
45+ sel.deleteFromDocument();
46+ }
47+})();
48+`
49+
50+// WebViewTarget implements TextInputTarget for WebView text fields.
51+// It uses the clipboard-paste pattern to insert text:
52+// 1. Save current clipboard
53+// 2. Write text to clipboard
54+// 3. Execute paste command
55+// 4. Restore original clipboard after a short delay
56+type WebViewTarget struct {
57+ webView *webkit.WebView
58+ clipboard port.Clipboard
59+}
60+
61+// Compile-time interface check.
62+var _ port.TextInputTarget = (*WebViewTarget)(nil)
63+
64+// NewWebViewTarget creates a new WebView target.
65+func NewWebViewTarget(webView *webkit.WebView, clipboard port.Clipboard) *WebViewTarget {
66+ return &WebViewTarget{
67+ webView: webView,
68+ clipboard: clipboard,
69+ }
70+}
71+
72+// InsertText inserts text at the current cursor position in the WebView.
73+// Uses clipboard-paste pattern since WebView doesn't expose direct text insertion.
74+func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
75+ log := logging.FromContext(ctx)
76+
77+ if t.webView == nil {
78+ log.Warn().Msg("WebViewTarget: webView is nil")
79+ return nil
80+ }
81+
82+ // Save current clipboard contents
83+ originalClipboard, _ := t.clipboard.ReadText(ctx)
84+
85+ // Write the accent to clipboard
86+ if err := t.clipboard.WriteText(ctx, text); err != nil {
87+ log.Error().Err(err).Msg("failed to write text to clipboard")
88+ return err
89+ }
90+
91+ // Execute paste command
92+ t.webView.ExecuteEditingCommand(webkit.EDITING_COMMAND_PASTE)
93+
94+ log.Debug().
95+ Str("text", text).
96+ Msg("inserted text into WebView via clipboard paste")
97+
98+ // Restore original clipboard after a short delay
99+ // This allows the paste operation to complete first
100+ go func() {
101+ time.Sleep(clipboardRestoreDelay)
102+ if originalClipboard != "" {
103+ if err := t.clipboard.WriteText(ctx, originalClipboard); err != nil {
104+ log.Debug().Err(err).Msg("failed to restore clipboard")
105+ }
106+ }
107+ }()
108+
109+ return nil
110+}
111+
112+// DeleteBeforeCursor deletes n characters before the cursor in the WebView.
113+// Uses JavaScript to manipulate the focused input element.
114+func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
115+ log := logging.FromContext(ctx)
116+
117+ if t.webView == nil {
118+ log.Warn().Msg("WebViewTarget: webView is nil")
119+ return nil
120+ }
121+
122+ if n <= 0 {
123+ return nil
124+ }
125+
126+ // Execute JavaScript to delete characters
127+ script := fmt.Sprintf(deleteBeforeCursorJS, n)
128+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
129+
130+ log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
131+ return nil
132+}

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