detached d6c3336

Merge pull request #73 from bnema/feat/accent-picker

feat: add macOS-style accent picker for dead keys support
commit
d6c33362d0ada9247742037e8e7d679ecb204a86
author
bnema · · 2025-12-30 13:51 UTC
committer
GitHub · · 2025-12-30 13:51 UTC
merge of
436a28a + 45e0e8a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
26 files · +2432 -39
M .mockery.yaml +3 -0
@@ -23,6 +23,9 @@ packages:
2323 HardwareSurveyor: {}
2424 ColorSchemeDetector: {}
2525 ColorSchemeResolver: {}
26+ TextInputTarget: {}
27+ FocusedInputProvider: {}
28+ AccentPickerUI: {}
2629 github.com/bnema/dumber/internal/domain/repository:
2730 config:
2831 dir: '{{.InterfaceDir}}/mocks'
@@ -23,6 +23,9 @@ packages:
23 HardwareSurveyor: {}23 HardwareSurveyor: {}
24 ColorSchemeDetector: {}24 ColorSchemeDetector: {}
25 ColorSchemeResolver: {}25 ColorSchemeResolver: {}
26+ TextInputTarget: {}
27+ FocusedInputProvider: {}
28+ AccentPickerUI: {}
26 github.com/bnema/dumber/internal/domain/repository:29 github.com/bnema/dumber/internal/domain/repository:
27 config:30 config:
28 dir: '{{.InterfaceDir}}/mocks'31 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +3 -0
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **macOS-style accent picker**: Long-press (400ms) on keys with accent variants (e, a, o, u, i, c, n, s, y, z) shows a popup with accented character options. Select with number keys 1-9, arrow keys + Enter, or click. Works in omnibox, find bar (Ctrl+F), and web page inputs. Implements dead keys/compose sequence alternative for users who can't use system-level input methods.
9+
710 ### Fixed
811 - **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.
912
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Added
8+- **macOS-style accent picker**: Long-press (400ms) on keys with accent variants (e, a, o, u, i, c, n, s, y, z) shows a popup with accented character options. Select with number keys 1-9, arrow keys + Enter, or click. Works in omnibox, find bar (Ctrl+F), and web page inputs. Implements dead keys/compose sequence alternative for users who can't use system-level input methods.
9+
7 ### Fixed10 ### Fixed
8 - **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.11 - **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.
9 12
M README.md +1 -0
@@ -125,6 +125,7 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
125125 | **Space** | Toggle Favorite | Omnibox mode: favorite/unfavorite selected item |
126126 | **Ctrl+1-9, 0** | Quick Navigate | Jump to result by number (1-10) |
127127 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |
128+| **Hold key 400ms** | Accent Picker | Shows accented variants in any text input (e.g., hold 'e' for è, é, ê, ë) |
128129
129130 All zoom changes are automatically persisted per-domain and restored on next visit.
130131
@@ -125,6 +125,7 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
125 | **Space** | Toggle Favorite | Omnibox mode: favorite/unfavorite selected item |125 | **Space** | Toggle Favorite | Omnibox mode: favorite/unfavorite selected item |
126 | **Ctrl+1-9, 0** | Quick Navigate | Jump to result by number (1-10) |126 | **Ctrl+1-9, 0** | Quick Navigate | Jump to result by number (1-10) |
127 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |127 | **↑/↓ Arrow** | Navigate Results | Browse suggestions/matches |
128+| **Hold key 400ms** | Accent Picker | Shows accented variants in any text input (e.g., hold 'e' for è, é, ê, ë) |
128 129
129 All zoom changes are automatically persisted per-domain and restored on next visit.130 All zoom changes are automatically persisted per-domain and restored on next visit.
130 131
A internal/application/port/accent_picker.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+// AccentPickerUI defines the interface for displaying an accent selection overlay.
4+// The picker shows a list of accented characters and allows the user to select one
5+// via arrow keys, number keys (1-9), or Enter.
6+type AccentPickerUI interface {
7+ // Show displays the accent picker with the given accent variants.
8+ // selectedCallback is called when the user selects an accent.
9+ // cancelCallback is called when the user cancels (Escape key).
10+ Show(accents []rune, selectedCallback func(accent rune), cancelCallback func())
11+
12+ // Hide hides the accent picker.
13+ Hide()
14+
15+ // IsVisible returns true if the accent picker is currently visible.
16+ IsVisible() bool
17+}
@@ -0,0 +1,17 @@
1+package port
2+
3+// AccentPickerUI defines the interface for displaying an accent selection overlay.
4+// The picker shows a list of accented characters and allows the user to select one
5+// via arrow keys, number keys (1-9), or Enter.
6+type AccentPickerUI interface {
7+ // Show displays the accent picker with the given accent variants.
8+ // selectedCallback is called when the user selects an accent.
9+ // cancelCallback is called when the user cancels (Escape key).
10+ Show(accents []rune, selectedCallback func(accent rune), cancelCallback func())
11+
12+ // Hide hides the accent picker.
13+ Hide()
14+
15+ // IsVisible returns true if the accent picker is currently visible.
16+ IsVisible() bool
17+}
M internal/application/port/mocks/mocks.go +410 -0
@@ -14,6 +14,162 @@ import (
1414 mock "github.com/stretchr/testify/mock"
1515 )
1616
17+// NewMockAccentPickerUI creates a new instance of MockAccentPickerUI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18+// The first argument is typically a *testing.T value.
19+func NewMockAccentPickerUI(t interface {
20+ mock.TestingT
21+ Cleanup(func())
22+}) *MockAccentPickerUI {
23+ mock := &MockAccentPickerUI{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() { mock.AssertExpectations(t) })
27+
28+ return mock
29+}
30+
31+// MockAccentPickerUI is an autogenerated mock type for the AccentPickerUI type
32+type MockAccentPickerUI struct {
33+ mock.Mock
34+}
35+
36+type MockAccentPickerUI_Expecter struct {
37+ mock *mock.Mock
38+}
39+
40+func (_m *MockAccentPickerUI) EXPECT() *MockAccentPickerUI_Expecter {
41+ return &MockAccentPickerUI_Expecter{mock: &_m.Mock}
42+}
43+
44+// Hide provides a mock function for the type MockAccentPickerUI
45+func (_mock *MockAccentPickerUI) Hide() {
46+ _mock.Called()
47+ return
48+}
49+
50+// MockAccentPickerUI_Hide_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hide'
51+type MockAccentPickerUI_Hide_Call struct {
52+ *mock.Call
53+}
54+
55+// Hide is a helper method to define mock.On call
56+func (_e *MockAccentPickerUI_Expecter) Hide() *MockAccentPickerUI_Hide_Call {
57+ return &MockAccentPickerUI_Hide_Call{Call: _e.mock.On("Hide")}
58+}
59+
60+func (_c *MockAccentPickerUI_Hide_Call) Run(run func()) *MockAccentPickerUI_Hide_Call {
61+ _c.Call.Run(func(args mock.Arguments) {
62+ run()
63+ })
64+ return _c
65+}
66+
67+func (_c *MockAccentPickerUI_Hide_Call) Return() *MockAccentPickerUI_Hide_Call {
68+ _c.Call.Return()
69+ return _c
70+}
71+
72+func (_c *MockAccentPickerUI_Hide_Call) RunAndReturn(run func()) *MockAccentPickerUI_Hide_Call {
73+ _c.Run(run)
74+ return _c
75+}
76+
77+// IsVisible provides a mock function for the type MockAccentPickerUI
78+func (_mock *MockAccentPickerUI) IsVisible() bool {
79+ ret := _mock.Called()
80+
81+ if len(ret) == 0 {
82+ panic("no return value specified for IsVisible")
83+ }
84+
85+ var r0 bool
86+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
87+ r0 = returnFunc()
88+ } else {
89+ r0 = ret.Get(0).(bool)
90+ }
91+ return r0
92+}
93+
94+// MockAccentPickerUI_IsVisible_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsVisible'
95+type MockAccentPickerUI_IsVisible_Call struct {
96+ *mock.Call
97+}
98+
99+// IsVisible is a helper method to define mock.On call
100+func (_e *MockAccentPickerUI_Expecter) IsVisible() *MockAccentPickerUI_IsVisible_Call {
101+ return &MockAccentPickerUI_IsVisible_Call{Call: _e.mock.On("IsVisible")}
102+}
103+
104+func (_c *MockAccentPickerUI_IsVisible_Call) Run(run func()) *MockAccentPickerUI_IsVisible_Call {
105+ _c.Call.Run(func(args mock.Arguments) {
106+ run()
107+ })
108+ return _c
109+}
110+
111+func (_c *MockAccentPickerUI_IsVisible_Call) Return(b bool) *MockAccentPickerUI_IsVisible_Call {
112+ _c.Call.Return(b)
113+ return _c
114+}
115+
116+func (_c *MockAccentPickerUI_IsVisible_Call) RunAndReturn(run func() bool) *MockAccentPickerUI_IsVisible_Call {
117+ _c.Call.Return(run)
118+ return _c
119+}
120+
121+// Show provides a mock function for the type MockAccentPickerUI
122+func (_mock *MockAccentPickerUI) Show(accents []rune, selectedCallback func(accent rune), cancelCallback func()) {
123+ _mock.Called(accents, selectedCallback, cancelCallback)
124+ return
125+}
126+
127+// MockAccentPickerUI_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'
128+type MockAccentPickerUI_Show_Call struct {
129+ *mock.Call
130+}
131+
132+// Show is a helper method to define mock.On call
133+// - accents []rune
134+// - selectedCallback func(accent rune)
135+// - cancelCallback func()
136+func (_e *MockAccentPickerUI_Expecter) Show(accents interface{}, selectedCallback interface{}, cancelCallback interface{}) *MockAccentPickerUI_Show_Call {
137+ return &MockAccentPickerUI_Show_Call{Call: _e.mock.On("Show", accents, selectedCallback, cancelCallback)}
138+}
139+
140+func (_c *MockAccentPickerUI_Show_Call) Run(run func(accents []rune, selectedCallback func(accent rune), cancelCallback func())) *MockAccentPickerUI_Show_Call {
141+ _c.Call.Run(func(args mock.Arguments) {
142+ var arg0 []rune
143+ if args[0] != nil {
144+ arg0 = args[0].([]rune)
145+ }
146+ var arg1 func(accent rune)
147+ if args[1] != nil {
148+ arg1 = args[1].(func(accent rune))
149+ }
150+ var arg2 func()
151+ if args[2] != nil {
152+ arg2 = args[2].(func())
153+ }
154+ run(
155+ arg0,
156+ arg1,
157+ arg2,
158+ )
159+ })
160+ return _c
161+}
162+
163+func (_c *MockAccentPickerUI_Show_Call) Return() *MockAccentPickerUI_Show_Call {
164+ _c.Call.Return()
165+ return _c
166+}
167+
168+func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, selectedCallback func(accent rune), cancelCallback func())) *MockAccentPickerUI_Show_Call {
169+ _c.Run(run)
170+ return _c
171+}
172+
17173 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18174 // The first argument is typically a *testing.T value.
19175 func NewMockColorSchemeDetector(t interface {
@@ -1438,6 +1594,260 @@ func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessio
14381594 return _c
14391595 }
14401596
1597+// NewMockTextInputTarget creates a new instance of MockTextInputTarget. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1598+// The first argument is typically a *testing.T value.
1599+func NewMockTextInputTarget(t interface {
1600+ mock.TestingT
1601+ Cleanup(func())
1602+}) *MockTextInputTarget {
1603+ mock := &MockTextInputTarget{}
1604+ mock.Mock.Test(t)
1605+
1606+ t.Cleanup(func() { mock.AssertExpectations(t) })
1607+
1608+ return mock
1609+}
1610+
1611+// MockTextInputTarget is an autogenerated mock type for the TextInputTarget type
1612+type MockTextInputTarget struct {
1613+ mock.Mock
1614+}
1615+
1616+type MockTextInputTarget_Expecter struct {
1617+ mock *mock.Mock
1618+}
1619+
1620+func (_m *MockTextInputTarget) EXPECT() *MockTextInputTarget_Expecter {
1621+ return &MockTextInputTarget_Expecter{mock: &_m.Mock}
1622+}
1623+
1624+// DeleteBeforeCursor provides a mock function for the type MockTextInputTarget
1625+func (_mock *MockTextInputTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
1626+ ret := _mock.Called(ctx, n)
1627+
1628+ if len(ret) == 0 {
1629+ panic("no return value specified for DeleteBeforeCursor")
1630+ }
1631+
1632+ var r0 error
1633+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) error); ok {
1634+ r0 = returnFunc(ctx, n)
1635+ } else {
1636+ r0 = ret.Error(0)
1637+ }
1638+ return r0
1639+}
1640+
1641+// MockTextInputTarget_DeleteBeforeCursor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBeforeCursor'
1642+type MockTextInputTarget_DeleteBeforeCursor_Call struct {
1643+ *mock.Call
1644+}
1645+
1646+// DeleteBeforeCursor is a helper method to define mock.On call
1647+// - ctx context.Context
1648+// - n int
1649+func (_e *MockTextInputTarget_Expecter) DeleteBeforeCursor(ctx interface{}, n interface{}) *MockTextInputTarget_DeleteBeforeCursor_Call {
1650+ return &MockTextInputTarget_DeleteBeforeCursor_Call{Call: _e.mock.On("DeleteBeforeCursor", ctx, n)}
1651+}
1652+
1653+func (_c *MockTextInputTarget_DeleteBeforeCursor_Call) Run(run func(ctx context.Context, n int)) *MockTextInputTarget_DeleteBeforeCursor_Call {
1654+ _c.Call.Run(func(args mock.Arguments) {
1655+ var arg0 context.Context
1656+ if args[0] != nil {
1657+ arg0 = args[0].(context.Context)
1658+ }
1659+ var arg1 int
1660+ if args[1] != nil {
1661+ arg1 = args[1].(int)
1662+ }
1663+ run(
1664+ arg0,
1665+ arg1,
1666+ )
1667+ })
1668+ return _c
1669+}
1670+
1671+func (_c *MockTextInputTarget_DeleteBeforeCursor_Call) Return(err error) *MockTextInputTarget_DeleteBeforeCursor_Call {
1672+ _c.Call.Return(err)
1673+ return _c
1674+}
1675+
1676+func (_c *MockTextInputTarget_DeleteBeforeCursor_Call) RunAndReturn(run func(ctx context.Context, n int) error) *MockTextInputTarget_DeleteBeforeCursor_Call {
1677+ _c.Call.Return(run)
1678+ return _c
1679+}
1680+
1681+// InsertText provides a mock function for the type MockTextInputTarget
1682+func (_mock *MockTextInputTarget) InsertText(ctx context.Context, text string) error {
1683+ ret := _mock.Called(ctx, text)
1684+
1685+ if len(ret) == 0 {
1686+ panic("no return value specified for InsertText")
1687+ }
1688+
1689+ var r0 error
1690+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
1691+ r0 = returnFunc(ctx, text)
1692+ } else {
1693+ r0 = ret.Error(0)
1694+ }
1695+ return r0
1696+}
1697+
1698+// MockTextInputTarget_InsertText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertText'
1699+type MockTextInputTarget_InsertText_Call struct {
1700+ *mock.Call
1701+}
1702+
1703+// InsertText is a helper method to define mock.On call
1704+// - ctx context.Context
1705+// - text string
1706+func (_e *MockTextInputTarget_Expecter) InsertText(ctx interface{}, text interface{}) *MockTextInputTarget_InsertText_Call {
1707+ return &MockTextInputTarget_InsertText_Call{Call: _e.mock.On("InsertText", ctx, text)}
1708+}
1709+
1710+func (_c *MockTextInputTarget_InsertText_Call) Run(run func(ctx context.Context, text string)) *MockTextInputTarget_InsertText_Call {
1711+ _c.Call.Run(func(args mock.Arguments) {
1712+ var arg0 context.Context
1713+ if args[0] != nil {
1714+ arg0 = args[0].(context.Context)
1715+ }
1716+ var arg1 string
1717+ if args[1] != nil {
1718+ arg1 = args[1].(string)
1719+ }
1720+ run(
1721+ arg0,
1722+ arg1,
1723+ )
1724+ })
1725+ return _c
1726+}
1727+
1728+func (_c *MockTextInputTarget_InsertText_Call) Return(err error) *MockTextInputTarget_InsertText_Call {
1729+ _c.Call.Return(err)
1730+ return _c
1731+}
1732+
1733+func (_c *MockTextInputTarget_InsertText_Call) RunAndReturn(run func(ctx context.Context, text string) error) *MockTextInputTarget_InsertText_Call {
1734+ _c.Call.Return(run)
1735+ return _c
1736+}
1737+
1738+// NewMockFocusedInputProvider creates a new instance of MockFocusedInputProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1739+// The first argument is typically a *testing.T value.
1740+func NewMockFocusedInputProvider(t interface {
1741+ mock.TestingT
1742+ Cleanup(func())
1743+}) *MockFocusedInputProvider {
1744+ mock := &MockFocusedInputProvider{}
1745+ mock.Mock.Test(t)
1746+
1747+ t.Cleanup(func() { mock.AssertExpectations(t) })
1748+
1749+ return mock
1750+}
1751+
1752+// MockFocusedInputProvider is an autogenerated mock type for the FocusedInputProvider type
1753+type MockFocusedInputProvider struct {
1754+ mock.Mock
1755+}
1756+
1757+type MockFocusedInputProvider_Expecter struct {
1758+ mock *mock.Mock
1759+}
1760+
1761+func (_m *MockFocusedInputProvider) EXPECT() *MockFocusedInputProvider_Expecter {
1762+ return &MockFocusedInputProvider_Expecter{mock: &_m.Mock}
1763+}
1764+
1765+// GetFocusedInput provides a mock function for the type MockFocusedInputProvider
1766+func (_mock *MockFocusedInputProvider) GetFocusedInput() port.TextInputTarget {
1767+ ret := _mock.Called()
1768+
1769+ if len(ret) == 0 {
1770+ panic("no return value specified for GetFocusedInput")
1771+ }
1772+
1773+ var r0 port.TextInputTarget
1774+ if returnFunc, ok := ret.Get(0).(func() port.TextInputTarget); ok {
1775+ r0 = returnFunc()
1776+ } else {
1777+ if ret.Get(0) != nil {
1778+ r0 = ret.Get(0).(port.TextInputTarget)
1779+ }
1780+ }
1781+ return r0
1782+}
1783+
1784+// MockFocusedInputProvider_GetFocusedInput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFocusedInput'
1785+type MockFocusedInputProvider_GetFocusedInput_Call struct {
1786+ *mock.Call
1787+}
1788+
1789+// GetFocusedInput is a helper method to define mock.On call
1790+func (_e *MockFocusedInputProvider_Expecter) GetFocusedInput() *MockFocusedInputProvider_GetFocusedInput_Call {
1791+ return &MockFocusedInputProvider_GetFocusedInput_Call{Call: _e.mock.On("GetFocusedInput")}
1792+}
1793+
1794+func (_c *MockFocusedInputProvider_GetFocusedInput_Call) Run(run func()) *MockFocusedInputProvider_GetFocusedInput_Call {
1795+ _c.Call.Run(func(args mock.Arguments) {
1796+ run()
1797+ })
1798+ return _c
1799+}
1800+
1801+func (_c *MockFocusedInputProvider_GetFocusedInput_Call) Return(textInputTarget port.TextInputTarget) *MockFocusedInputProvider_GetFocusedInput_Call {
1802+ _c.Call.Return(textInputTarget)
1803+ return _c
1804+}
1805+
1806+func (_c *MockFocusedInputProvider_GetFocusedInput_Call) RunAndReturn(run func() port.TextInputTarget) *MockFocusedInputProvider_GetFocusedInput_Call {
1807+ _c.Call.Return(run)
1808+ return _c
1809+}
1810+
1811+// SetFocusedInput provides a mock function for the type MockFocusedInputProvider
1812+func (_mock *MockFocusedInputProvider) SetFocusedInput(target port.TextInputTarget) {
1813+ _mock.Called(target)
1814+ return
1815+}
1816+
1817+// MockFocusedInputProvider_SetFocusedInput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusedInput'
1818+type MockFocusedInputProvider_SetFocusedInput_Call struct {
1819+ *mock.Call
1820+}
1821+
1822+// SetFocusedInput is a helper method to define mock.On call
1823+// - target port.TextInputTarget
1824+func (_e *MockFocusedInputProvider_Expecter) SetFocusedInput(target interface{}) *MockFocusedInputProvider_SetFocusedInput_Call {
1825+ return &MockFocusedInputProvider_SetFocusedInput_Call{Call: _e.mock.On("SetFocusedInput", target)}
1826+}
1827+
1828+func (_c *MockFocusedInputProvider_SetFocusedInput_Call) Run(run func(target port.TextInputTarget)) *MockFocusedInputProvider_SetFocusedInput_Call {
1829+ _c.Call.Run(func(args mock.Arguments) {
1830+ var arg0 port.TextInputTarget
1831+ if args[0] != nil {
1832+ arg0 = args[0].(port.TextInputTarget)
1833+ }
1834+ run(
1835+ arg0,
1836+ )
1837+ })
1838+ return _c
1839+}
1840+
1841+func (_c *MockFocusedInputProvider_SetFocusedInput_Call) Return() *MockFocusedInputProvider_SetFocusedInput_Call {
1842+ _c.Call.Return()
1843+ return _c
1844+}
1845+
1846+func (_c *MockFocusedInputProvider_SetFocusedInput_Call) RunAndReturn(run func(target port.TextInputTarget)) *MockFocusedInputProvider_SetFocusedInput_Call {
1847+ _c.Run(run)
1848+ return _c
1849+}
1850+
14411851 // NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14421852 // The first argument is typically a *testing.T value.
14431853 func NewMockMemoryPressureApplier(t interface {
@@ -14,6 +14,162 @@ import (
14 mock "github.com/stretchr/testify/mock"14 mock "github.com/stretchr/testify/mock"
15 )15 )
16 16
17+// NewMockAccentPickerUI creates a new instance of MockAccentPickerUI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18+// The first argument is typically a *testing.T value.
19+func NewMockAccentPickerUI(t interface {
20+ mock.TestingT
21+ Cleanup(func())
22+}) *MockAccentPickerUI {
23+ mock := &MockAccentPickerUI{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() { mock.AssertExpectations(t) })
27+
28+ return mock
29+}
30+
31+// MockAccentPickerUI is an autogenerated mock type for the AccentPickerUI type
32+type MockAccentPickerUI struct {
33+ mock.Mock
34+}
35+
36+type MockAccentPickerUI_Expecter struct {
37+ mock *mock.Mock
38+}
39+
40+func (_m *MockAccentPickerUI) EXPECT() *MockAccentPickerUI_Expecter {
41+ return &MockAccentPickerUI_Expecter{mock: &_m.Mock}
42+}
43+
44+// Hide provides a mock function for the type MockAccentPickerUI
45+func (_mock *MockAccentPickerUI) Hide() {
46+ _mock.Called()
47+ return
48+}
49+
50+// MockAccentPickerUI_Hide_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hide'
51+type MockAccentPickerUI_Hide_Call struct {
52+ *mock.Call
53+}
54+
55+// Hide is a helper method to define mock.On call
56+func (_e *MockAccentPickerUI_Expecter) Hide() *MockAccentPickerUI_Hide_Call {
57+ return &MockAccentPickerUI_Hide_Call{Call: _e.mock.On("Hide")}
58+}
59+
60+func (_c *MockAccentPickerUI_Hide_Call) Run(run func()) *MockAccentPickerUI_Hide_Call {
61+ _c.Call.Run(func(args mock.Arguments) {
62+ run()
63+ })
64+ return _c
65+}
66+
67+func (_c *MockAccentPickerUI_Hide_Call) Return() *MockAccentPickerUI_Hide_Call {
68+ _c.Call.Return()
69+ return _c
70+}
71+
72+func (_c *MockAccentPickerUI_Hide_Call) RunAndReturn(run func()) *MockAccentPickerUI_Hide_Call {
73+ _c.Run(run)
74+ return _c
75+}
76+
77+// IsVisible provides a mock function for the type MockAccentPickerUI
78+func (_mock *MockAccentPickerUI) IsVisible() bool {
79+ ret := _mock.Called()
80+
81+ if len(ret) == 0 {
82+ panic("no return value specified for IsVisible")
83+ }
84+
85+ var r0 bool
86+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
87+ r0 = returnFunc()
88+ } else {
89+ r0 = ret.Get(0).(bool)
90+ }
91+ return r0
92+}
93+
94+// MockAccentPickerUI_IsVisible_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsVisible'
95+type MockAccentPickerUI_IsVisible_Call struct {
96+ *mock.Call
97+}
98+
99+// IsVisible is a helper method to define mock.On call
100+func (_e *MockAccentPickerUI_Expecter) IsVisible() *MockAccentPickerUI_IsVisible_Call {
101+ return &MockAccentPickerUI_IsVisible_Call{Call: _e.mock.On("IsVisible")}
102+}
103+
104+func (_c *MockAccentPickerUI_IsVisible_Call) Run(run func()) *MockAccentPickerUI_IsVisible_Call {
105+ _c.Call.Run(func(args mock.Arguments) {
106+ run()
107+ })
108+ return _c
109+}
110+
111+func (_c *MockAccentPickerUI_IsVisible_Call) Return(b bool) *MockAccentPickerUI_IsVisible_Call {
112+ _c.Call.Return(b)
113+ return _c
114+}
115+
116+func (_c *MockAccentPickerUI_IsVisible_Call) RunAndReturn(run func() bool) *MockAccentPickerUI_IsVisible_Call {
117+ _c.Call.Return(run)
118+ return _c
119+}
120+
121+// Show provides a mock function for the type MockAccentPickerUI
122+func (_mock *MockAccentPickerUI) Show(accents []rune, selectedCallback func(accent rune), cancelCallback func()) {
123+ _mock.Called(accents, selectedCallback, cancelCallback)
124+ return
125+}
126+
127+// MockAccentPickerUI_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'
128+type MockAccentPickerUI_Show_Call struct {
129+ *mock.Call
130+}
131+
132+// Show is a helper method to define mock.On call
133+// - accents []rune
134+// - selectedCallback func(accent rune)
135+// - cancelCallback func()
136+func (_e *MockAccentPickerUI_Expecter) Show(accents interface{}, selectedCallback interface{}, cancelCallback interface{}) *MockAccentPickerUI_Show_Call {
137+ return &MockAccentPickerUI_Show_Call{Call: _e.mock.On("Show", accents, selectedCallback, cancelCallback)}
138+}
139+
140+func (_c *MockAccentPickerUI_Show_Call) Run(run func(accents []rune, selectedCallback func(accent rune), cancelCallback func())) *MockAccentPickerUI_Show_Call {
141+ _c.Call.Run(func(args mock.Arguments) {
142+ var arg0 []rune
143+ if args[0] != nil {
144+ arg0 = args[0].([]rune)
145+ }
146+ var arg1 func(accent rune)
147+ if args[1] != nil {
148+ arg1 = args[1].(func(accent rune))
149+ }
150+ var arg2 func()
151+ if args[2] != nil {
152+ arg2 = args[2].(func())
153+ }
154+ run(
155+ arg0,
156+ arg1,
157+ arg2,
158+ )
159+ })
160+ return _c
161+}
162+
163+func (_c *MockAccentPickerUI_Show_Call) Return() *MockAccentPickerUI_Show_Call {
164+ _c.Call.Return()
165+ return _c
166+}
167+
168+func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, selectedCallback func(accent rune), cancelCallback func())) *MockAccentPickerUI_Show_Call {
169+ _c.Run(run)
170+ return _c
171+}
172+
17 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.173 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18 // The first argument is typically a *testing.T value.174 // The first argument is typically a *testing.T value.
19 func NewMockColorSchemeDetector(t interface {175 func NewMockColorSchemeDetector(t interface {
@@ -1438,6 +1594,260 @@ func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessio
1438 return _c1594 return _c
1439 }1595 }
1440 1596
1597+// NewMockTextInputTarget creates a new instance of MockTextInputTarget. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1598+// The first argument is typically a *testing.T value.
1599+func NewMockTextInputTarget(t interface {
1600+ mock.TestingT
1601+ Cleanup(func())
1602+}) *MockTextInputTarget {
1603+ mock := &MockTextInputTarget{}
1604+ mock.Mock.Test(t)
1605+
1606+ t.Cleanup(func() { mock.AssertExpectations(t) })
1607+
1608+ return mock
1609+}
1610+
1611+// MockTextInputTarget is an autogenerated mock type for the TextInputTarget type
1612+type MockTextInputTarget struct {
1613+ mock.Mock
1614+}
1615+
1616+type MockTextInputTarget_Expecter struct {
1617+ mock *mock.Mock
1618+}
1619+
1620+func (_m *MockTextInputTarget) EXPECT() *MockTextInputTarget_Expecter {
1621+ return &MockTextInputTarget_Expecter{mock: &_m.Mock}
1622+}
1623+
1624+// DeleteBeforeCursor provides a mock function for the type MockTextInputTarget
1625+func (_mock *MockTextInputTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
1626+ ret := _mock.Called(ctx, n)
1627+
1628+ if len(ret) == 0 {
1629+ panic("no return value specified for DeleteBeforeCursor")
1630+ }
1631+
1632+ var r0 error
1633+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) error); ok {
1634+ r0 = returnFunc(ctx, n)
1635+ } else {
1636+ r0 = ret.Error(0)
1637+ }
1638+ return r0
1639+}
1640+
1641+// MockTextInputTarget_DeleteBeforeCursor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBeforeCursor'
1642+type MockTextInputTarget_DeleteBeforeCursor_Call struct {
1643+ *mock.Call
1644+}
1645+
1646+// DeleteBeforeCursor is a helper method to define mock.On call
1647+// - ctx context.Context
1648+// - n int
1649+func (_e *MockTextInputTarget_Expecter) DeleteBeforeCursor(ctx interface{}, n interface{}) *MockTextInputTarget_DeleteBeforeCursor_Call {
1650+ return &MockTextInputTarget_DeleteBeforeCursor_Call{Call: _e.mock.On("DeleteBeforeCursor", ctx, n)}
1651+}
1652+
1653+func (_c *MockTextInputTarget_DeleteBeforeCursor_Call) Run(run func(ctx context.Context, n int)) *MockTextInputTarget_DeleteBeforeCursor_Call {
1654+ _c.Call.Run(func(args mock.Arguments) {
1655+ var arg0 context.Context
1656+ if args[0] != nil {
1657+ arg0 = args[0].(context.Context)
1658+ }
1659+ var arg1 int
1660+ if args[1] != nil {
1661+ arg1 = args[1].(int)
1662+ }
1663+ run(
1664+ arg0,
1665+ arg1,
1666+ )
1667+ })
1668+ return _c
1669+}
1670+
1671+func (_c *MockTextInputTarget_DeleteBeforeCursor_Call) Return(err error) *MockTextInputTarget_DeleteBeforeCursor_Call {
1672+ _c.Call.Return(err)
1673+ return _c
1674+}
1675+
1676+func (_c *MockTextInputTarget_DeleteBeforeCursor_Call) RunAndReturn(run func(ctx context.Context, n int) error) *MockTextInputTarget_DeleteBeforeCursor_Call {
1677+ _c.Call.Return(run)
1678+ return _c
1679+}
1680+
1681+// InsertText provides a mock function for the type MockTextInputTarget
1682+func (_mock *MockTextInputTarget) InsertText(ctx context.Context, text string) error {
1683+ ret := _mock.Called(ctx, text)
1684+
1685+ if len(ret) == 0 {
1686+ panic("no return value specified for InsertText")
1687+ }
1688+
1689+ var r0 error
1690+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
1691+ r0 = returnFunc(ctx, text)
1692+ } else {
1693+ r0 = ret.Error(0)
1694+ }
1695+ return r0
1696+}
1697+
1698+// MockTextInputTarget_InsertText_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InsertText'
1699+type MockTextInputTarget_InsertText_Call struct {
1700+ *mock.Call
1701+}
1702+
1703+// InsertText is a helper method to define mock.On call
1704+// - ctx context.Context
1705+// - text string
1706+func (_e *MockTextInputTarget_Expecter) InsertText(ctx interface{}, text interface{}) *MockTextInputTarget_InsertText_Call {
1707+ return &MockTextInputTarget_InsertText_Call{Call: _e.mock.On("InsertText", ctx, text)}
1708+}
1709+
1710+func (_c *MockTextInputTarget_InsertText_Call) Run(run func(ctx context.Context, text string)) *MockTextInputTarget_InsertText_Call {
1711+ _c.Call.Run(func(args mock.Arguments) {
1712+ var arg0 context.Context
1713+ if args[0] != nil {
1714+ arg0 = args[0].(context.Context)
1715+ }
1716+ var arg1 string
1717+ if args[1] != nil {
1718+ arg1 = args[1].(string)
1719+ }
1720+ run(
1721+ arg0,
1722+ arg1,
1723+ )
1724+ })
1725+ return _c
1726+}
1727+
1728+func (_c *MockTextInputTarget_InsertText_Call) Return(err error) *MockTextInputTarget_InsertText_Call {
1729+ _c.Call.Return(err)
1730+ return _c
1731+}
1732+
1733+func (_c *MockTextInputTarget_InsertText_Call) RunAndReturn(run func(ctx context.Context, text string) error) *MockTextInputTarget_InsertText_Call {
1734+ _c.Call.Return(run)
1735+ return _c
1736+}
1737+
1738+// NewMockFocusedInputProvider creates a new instance of MockFocusedInputProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1739+// The first argument is typically a *testing.T value.
1740+func NewMockFocusedInputProvider(t interface {
1741+ mock.TestingT
1742+ Cleanup(func())
1743+}) *MockFocusedInputProvider {
1744+ mock := &MockFocusedInputProvider{}
1745+ mock.Mock.Test(t)
1746+
1747+ t.Cleanup(func() { mock.AssertExpectations(t) })
1748+
1749+ return mock
1750+}
1751+
1752+// MockFocusedInputProvider is an autogenerated mock type for the FocusedInputProvider type
1753+type MockFocusedInputProvider struct {
1754+ mock.Mock
1755+}
1756+
1757+type MockFocusedInputProvider_Expecter struct {
1758+ mock *mock.Mock
1759+}
1760+
1761+func (_m *MockFocusedInputProvider) EXPECT() *MockFocusedInputProvider_Expecter {
1762+ return &MockFocusedInputProvider_Expecter{mock: &_m.Mock}
1763+}
1764+
1765+// GetFocusedInput provides a mock function for the type MockFocusedInputProvider
1766+func (_mock *MockFocusedInputProvider) GetFocusedInput() port.TextInputTarget {
1767+ ret := _mock.Called()
1768+
1769+ if len(ret) == 0 {
1770+ panic("no return value specified for GetFocusedInput")
1771+ }
1772+
1773+ var r0 port.TextInputTarget
1774+ if returnFunc, ok := ret.Get(0).(func() port.TextInputTarget); ok {
1775+ r0 = returnFunc()
1776+ } else {
1777+ if ret.Get(0) != nil {
1778+ r0 = ret.Get(0).(port.TextInputTarget)
1779+ }
1780+ }
1781+ return r0
1782+}
1783+
1784+// MockFocusedInputProvider_GetFocusedInput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFocusedInput'
1785+type MockFocusedInputProvider_GetFocusedInput_Call struct {
1786+ *mock.Call
1787+}
1788+
1789+// GetFocusedInput is a helper method to define mock.On call
1790+func (_e *MockFocusedInputProvider_Expecter) GetFocusedInput() *MockFocusedInputProvider_GetFocusedInput_Call {
1791+ return &MockFocusedInputProvider_GetFocusedInput_Call{Call: _e.mock.On("GetFocusedInput")}
1792+}
1793+
1794+func (_c *MockFocusedInputProvider_GetFocusedInput_Call) Run(run func()) *MockFocusedInputProvider_GetFocusedInput_Call {
1795+ _c.Call.Run(func(args mock.Arguments) {
1796+ run()
1797+ })
1798+ return _c
1799+}
1800+
1801+func (_c *MockFocusedInputProvider_GetFocusedInput_Call) Return(textInputTarget port.TextInputTarget) *MockFocusedInputProvider_GetFocusedInput_Call {
1802+ _c.Call.Return(textInputTarget)
1803+ return _c
1804+}
1805+
1806+func (_c *MockFocusedInputProvider_GetFocusedInput_Call) RunAndReturn(run func() port.TextInputTarget) *MockFocusedInputProvider_GetFocusedInput_Call {
1807+ _c.Call.Return(run)
1808+ return _c
1809+}
1810+
1811+// SetFocusedInput provides a mock function for the type MockFocusedInputProvider
1812+func (_mock *MockFocusedInputProvider) SetFocusedInput(target port.TextInputTarget) {
1813+ _mock.Called(target)
1814+ return
1815+}
1816+
1817+// MockFocusedInputProvider_SetFocusedInput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusedInput'
1818+type MockFocusedInputProvider_SetFocusedInput_Call struct {
1819+ *mock.Call
1820+}
1821+
1822+// SetFocusedInput is a helper method to define mock.On call
1823+// - target port.TextInputTarget
1824+func (_e *MockFocusedInputProvider_Expecter) SetFocusedInput(target interface{}) *MockFocusedInputProvider_SetFocusedInput_Call {
1825+ return &MockFocusedInputProvider_SetFocusedInput_Call{Call: _e.mock.On("SetFocusedInput", target)}
1826+}
1827+
1828+func (_c *MockFocusedInputProvider_SetFocusedInput_Call) Run(run func(target port.TextInputTarget)) *MockFocusedInputProvider_SetFocusedInput_Call {
1829+ _c.Call.Run(func(args mock.Arguments) {
1830+ var arg0 port.TextInputTarget
1831+ if args[0] != nil {
1832+ arg0 = args[0].(port.TextInputTarget)
1833+ }
1834+ run(
1835+ arg0,
1836+ )
1837+ })
1838+ return _c
1839+}
1840+
1841+func (_c *MockFocusedInputProvider_SetFocusedInput_Call) Return() *MockFocusedInputProvider_SetFocusedInput_Call {
1842+ _c.Call.Return()
1843+ return _c
1844+}
1845+
1846+func (_c *MockFocusedInputProvider_SetFocusedInput_Call) RunAndReturn(run func(target port.TextInputTarget)) *MockFocusedInputProvider_SetFocusedInput_Call {
1847+ _c.Run(run)
1848+ return _c
1849+}
1850+
1441 // NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1851 // NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1442 // The first argument is typically a *testing.T value.1852 // The first argument is typically a *testing.T value.
1443 func NewMockMemoryPressureApplier(t interface {1853 func NewMockMemoryPressureApplier(t interface {
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/application/usecase/insert_accent.go +277 -0
@@ -0,0 +1,277 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
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.
15+const 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.
20+type 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.
42+func 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.
58+func (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.
117+func (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.
133+func (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.
151+func (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.
159+func (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.
191+func (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.
224+func (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).
252+func (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.
269+func (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+}
@@ -0,0 +1,277 @@
1+// Package usecase contains application use cases that orchestrate domain logic.
2+package usecase
3+
4+import (
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.
15+const 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.
20+type 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.
42+func 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.
58+func (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.
117+func (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.
133+func (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.
151+func (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.
159+func (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.
191+func (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.
224+func (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).
252+func (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.
269+func (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+}
A internal/application/usecase/insert_accent_test.go +219 -0
@@ -0,0 +1,219 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
15+ ctx := context.Background()
16+ focusProvider := mocks.NewMockFocusedInputProvider(t)
17+ accentPicker := mocks.NewMockAccentPickerUI(t)
18+
19+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
20+
21+ // Key 'x' has no accents - should return false and not start timer
22+ handled := uc.OnKeyPressed(ctx, 'x', false)
23+ assert.False(t, handled, "OnKeyPressed should return false for key without accents")
24+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible")
25+}
26+
27+func TestInsertAccentUseCase_OnKeyPressed_HasAccents(t *testing.T) {
28+ ctx := context.Background()
29+ focusProvider := mocks.NewMockFocusedInputProvider(t)
30+ accentPicker := mocks.NewMockAccentPickerUI(t)
31+ textInput := mocks.NewMockTextInputTarget(t)
32+
33+ // GetFocusedInput is called when starting long-press to capture target
34+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
35+
36+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
37+
38+ // Key 'e' has accents - should return false initially (timer started)
39+ handled := uc.OnKeyPressed(ctx, 'e', false)
40+ assert.False(t, handled, "OnKeyPressed should return false while timer is pending")
41+
42+ // Cancel to clean up timer
43+ uc.Cancel(ctx)
44+}
45+
46+func TestInsertAccentUseCase_OnKeyReleased_CancelsTimer(t *testing.T) {
47+ ctx := context.Background()
48+ focusProvider := mocks.NewMockFocusedInputProvider(t)
49+ accentPicker := mocks.NewMockAccentPickerUI(t)
50+ textInput := mocks.NewMockTextInputTarget(t)
51+
52+ // GetFocusedInput is called when starting long-press to capture target
53+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
54+
55+ timerFired := false
56+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) {
57+ timerFired = true
58+ fn()
59+ })
60+
61+ // Start long-press detection
62+ uc.OnKeyPressed(ctx, 'e', false)
63+
64+ // Release before timer fires
65+ uc.OnKeyReleased(ctx, 'e')
66+
67+ // Wait a bit longer than LongPressDelay
68+ time.Sleep(LongPressDelay + 50*time.Millisecond)
69+
70+ assert.False(t, timerFired, "timer should have been canceled by key release")
71+}
72+
73+func TestInsertAccentUseCase_LongPress_ShowsPicker(t *testing.T) {
74+ ctx := context.Background()
75+ focusProvider := mocks.NewMockFocusedInputProvider(t)
76+ accentPicker := mocks.NewMockAccentPickerUI(t)
77+ textInput := mocks.NewMockTextInputTarget(t)
78+
79+ var wg sync.WaitGroup
80+ wg.Add(1)
81+
82+ // GetFocusedInput is called when showing picker to delete repeated characters
83+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
84+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
85+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
86+
87+ // Expect Show to be called with accents for 'e'
88+ accentPicker.EXPECT().
89+ Show(mock.MatchedBy(func(accents []rune) bool {
90+ return len(accents) > 0 && accents[0] == 'è'
91+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
92+ Run(func(_ []rune, _ func(rune), _ func()) {
93+ wg.Done()
94+ }).
95+ Once()
96+
97+ // Hide will be called by Cancel
98+ accentPicker.EXPECT().Hide().Maybe()
99+
100+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
101+
102+ // Start long-press detection
103+ uc.OnKeyPressed(ctx, 'e', false)
104+
105+ // Wait for timer to fire
106+ wg.Wait()
107+ time.Sleep(50 * time.Millisecond) // Give time for state update
108+
109+ assert.True(t, uc.IsPickerVisible(), "picker should be visible after long-press")
110+
111+ // Clean up
112+ uc.Cancel(ctx)
113+}
114+
115+func TestInsertAccentUseCase_Cancel(t *testing.T) {
116+ ctx := context.Background()
117+ focusProvider := mocks.NewMockFocusedInputProvider(t)
118+ accentPicker := mocks.NewMockAccentPickerUI(t)
119+ textInput := mocks.NewMockTextInputTarget(t)
120+
121+ // GetFocusedInput is called when starting long-press to capture target
122+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
123+
124+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
125+
126+ // Start timer
127+ uc.OnKeyPressed(ctx, 'e', false)
128+
129+ // Cancel should stop timer
130+ uc.Cancel(ctx)
131+
132+ // Wait past the timer duration
133+ time.Sleep(LongPressDelay + 50*time.Millisecond)
134+
135+ // Picker should never have been shown
136+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible after cancel")
137+}
138+
139+func TestInsertAccentUseCase_UppercaseAccents(t *testing.T) {
140+ ctx := context.Background()
141+ focusProvider := mocks.NewMockFocusedInputProvider(t)
142+ accentPicker := mocks.NewMockAccentPickerUI(t)
143+ textInput := mocks.NewMockTextInputTarget(t)
144+
145+ var wg sync.WaitGroup
146+ wg.Add(1)
147+
148+ // GetFocusedInput is called when showing picker to delete repeated characters
149+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
150+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
151+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
152+
153+ // Expect Show to be called with uppercase accents when shift is held
154+ accentPicker.EXPECT().
155+ Show(mock.MatchedBy(func(accents []rune) bool {
156+ return len(accents) > 0 && accents[0] == 'È' // Uppercase È
157+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
158+ Run(func(_ []rune, _ func(rune), _ func()) {
159+ wg.Done()
160+ }).
161+ Once()
162+
163+ // Hide will be called by Cancel
164+ accentPicker.EXPECT().Hide().Maybe()
165+
166+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
167+
168+ // Start long-press with shift held
169+ uc.OnKeyPressed(ctx, 'e', true)
170+
171+ // Wait for timer
172+ wg.Wait()
173+
174+ // Clean up
175+ uc.Cancel(ctx)
176+}
177+
178+func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
179+ ctx := context.Background()
180+ focusProvider := mocks.NewMockFocusedInputProvider(t)
181+ accentPicker := mocks.NewMockAccentPickerUI(t)
182+ textInput := mocks.NewMockTextInputTarget(t)
183+
184+ var showCalled int
185+ var mu sync.Mutex
186+
187+ // GetFocusedInput is called when showing picker to delete repeated characters
188+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
189+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
190+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
191+
192+ accentPicker.EXPECT().
193+ Show(mock.Anything, mock.Anything, mock.Anything).
194+ Run(func(_ []rune, _ func(rune), _ func()) {
195+ mu.Lock()
196+ showCalled++
197+ mu.Unlock()
198+ }).
199+ Maybe()
200+
201+ accentPicker.EXPECT().Hide().Maybe()
202+
203+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
204+
205+ // Trigger first long-press
206+ uc.OnKeyPressed(ctx, 'e', false)
207+ time.Sleep(LongPressDelay + 50*time.Millisecond)
208+
209+ // Try to start another long-press while picker is visible
210+ handled := uc.OnKeyPressed(ctx, 'a', false)
211+ assert.True(t, handled, "should return true when picker is visible")
212+
213+ mu.Lock()
214+ count := showCalled
215+ mu.Unlock()
216+ assert.LessOrEqual(t, count, 1, "Show should only be called once")
217+
218+ uc.Cancel(ctx)
219+}
@@ -0,0 +1,219 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+)
13+
14+func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
15+ ctx := context.Background()
16+ focusProvider := mocks.NewMockFocusedInputProvider(t)
17+ accentPicker := mocks.NewMockAccentPickerUI(t)
18+
19+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
20+
21+ // Key 'x' has no accents - should return false and not start timer
22+ handled := uc.OnKeyPressed(ctx, 'x', false)
23+ assert.False(t, handled, "OnKeyPressed should return false for key without accents")
24+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible")
25+}
26+
27+func TestInsertAccentUseCase_OnKeyPressed_HasAccents(t *testing.T) {
28+ ctx := context.Background()
29+ focusProvider := mocks.NewMockFocusedInputProvider(t)
30+ accentPicker := mocks.NewMockAccentPickerUI(t)
31+ textInput := mocks.NewMockTextInputTarget(t)
32+
33+ // GetFocusedInput is called when starting long-press to capture target
34+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
35+
36+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
37+
38+ // Key 'e' has accents - should return false initially (timer started)
39+ handled := uc.OnKeyPressed(ctx, 'e', false)
40+ assert.False(t, handled, "OnKeyPressed should return false while timer is pending")
41+
42+ // Cancel to clean up timer
43+ uc.Cancel(ctx)
44+}
45+
46+func TestInsertAccentUseCase_OnKeyReleased_CancelsTimer(t *testing.T) {
47+ ctx := context.Background()
48+ focusProvider := mocks.NewMockFocusedInputProvider(t)
49+ accentPicker := mocks.NewMockAccentPickerUI(t)
50+ textInput := mocks.NewMockTextInputTarget(t)
51+
52+ // GetFocusedInput is called when starting long-press to capture target
53+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
54+
55+ timerFired := false
56+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) {
57+ timerFired = true
58+ fn()
59+ })
60+
61+ // Start long-press detection
62+ uc.OnKeyPressed(ctx, 'e', false)
63+
64+ // Release before timer fires
65+ uc.OnKeyReleased(ctx, 'e')
66+
67+ // Wait a bit longer than LongPressDelay
68+ time.Sleep(LongPressDelay + 50*time.Millisecond)
69+
70+ assert.False(t, timerFired, "timer should have been canceled by key release")
71+}
72+
73+func TestInsertAccentUseCase_LongPress_ShowsPicker(t *testing.T) {
74+ ctx := context.Background()
75+ focusProvider := mocks.NewMockFocusedInputProvider(t)
76+ accentPicker := mocks.NewMockAccentPickerUI(t)
77+ textInput := mocks.NewMockTextInputTarget(t)
78+
79+ var wg sync.WaitGroup
80+ wg.Add(1)
81+
82+ // GetFocusedInput is called when showing picker to delete repeated characters
83+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
84+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
85+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
86+
87+ // Expect Show to be called with accents for 'e'
88+ accentPicker.EXPECT().
89+ Show(mock.MatchedBy(func(accents []rune) bool {
90+ return len(accents) > 0 && accents[0] == 'è'
91+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
92+ Run(func(_ []rune, _ func(rune), _ func()) {
93+ wg.Done()
94+ }).
95+ Once()
96+
97+ // Hide will be called by Cancel
98+ accentPicker.EXPECT().Hide().Maybe()
99+
100+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
101+
102+ // Start long-press detection
103+ uc.OnKeyPressed(ctx, 'e', false)
104+
105+ // Wait for timer to fire
106+ wg.Wait()
107+ time.Sleep(50 * time.Millisecond) // Give time for state update
108+
109+ assert.True(t, uc.IsPickerVisible(), "picker should be visible after long-press")
110+
111+ // Clean up
112+ uc.Cancel(ctx)
113+}
114+
115+func TestInsertAccentUseCase_Cancel(t *testing.T) {
116+ ctx := context.Background()
117+ focusProvider := mocks.NewMockFocusedInputProvider(t)
118+ accentPicker := mocks.NewMockAccentPickerUI(t)
119+ textInput := mocks.NewMockTextInputTarget(t)
120+
121+ // GetFocusedInput is called when starting long-press to capture target
122+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
123+
124+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
125+
126+ // Start timer
127+ uc.OnKeyPressed(ctx, 'e', false)
128+
129+ // Cancel should stop timer
130+ uc.Cancel(ctx)
131+
132+ // Wait past the timer duration
133+ time.Sleep(LongPressDelay + 50*time.Millisecond)
134+
135+ // Picker should never have been shown
136+ assert.False(t, uc.IsPickerVisible(), "picker should not be visible after cancel")
137+}
138+
139+func TestInsertAccentUseCase_UppercaseAccents(t *testing.T) {
140+ ctx := context.Background()
141+ focusProvider := mocks.NewMockFocusedInputProvider(t)
142+ accentPicker := mocks.NewMockAccentPickerUI(t)
143+ textInput := mocks.NewMockTextInputTarget(t)
144+
145+ var wg sync.WaitGroup
146+ wg.Add(1)
147+
148+ // GetFocusedInput is called when showing picker to delete repeated characters
149+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
150+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
151+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
152+
153+ // Expect Show to be called with uppercase accents when shift is held
154+ accentPicker.EXPECT().
155+ Show(mock.MatchedBy(func(accents []rune) bool {
156+ return len(accents) > 0 && accents[0] == 'È' // Uppercase È
157+ }), mock.AnythingOfType("func(int32)"), mock.AnythingOfType("func()")).
158+ Run(func(_ []rune, _ func(rune), _ func()) {
159+ wg.Done()
160+ }).
161+ Once()
162+
163+ // Hide will be called by Cancel
164+ accentPicker.EXPECT().Hide().Maybe()
165+
166+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
167+
168+ // Start long-press with shift held
169+ uc.OnKeyPressed(ctx, 'e', true)
170+
171+ // Wait for timer
172+ wg.Wait()
173+
174+ // Clean up
175+ uc.Cancel(ctx)
176+}
177+
178+func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
179+ ctx := context.Background()
180+ focusProvider := mocks.NewMockFocusedInputProvider(t)
181+ accentPicker := mocks.NewMockAccentPickerUI(t)
182+ textInput := mocks.NewMockTextInputTarget(t)
183+
184+ var showCalled int
185+ var mu sync.Mutex
186+
187+ // GetFocusedInput is called when showing picker to delete repeated characters
188+ focusProvider.EXPECT().GetFocusedInput().Return(textInput).Maybe()
189+ // DeleteBeforeCursor will be called to delete the initial character + any repeats
190+ textInput.EXPECT().DeleteBeforeCursor(mock.Anything, mock.AnythingOfType("int")).Return(nil).Maybe()
191+
192+ accentPicker.EXPECT().
193+ Show(mock.Anything, mock.Anything, mock.Anything).
194+ Run(func(_ []rune, _ func(rune), _ func()) {
195+ mu.Lock()
196+ showCalled++
197+ mu.Unlock()
198+ }).
199+ Maybe()
200+
201+ accentPicker.EXPECT().Hide().Maybe()
202+
203+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
204+
205+ // Trigger first long-press
206+ uc.OnKeyPressed(ctx, 'e', false)
207+ time.Sleep(LongPressDelay + 50*time.Millisecond)
208+
209+ // Try to start another long-press while picker is visible
210+ handled := uc.OnKeyPressed(ctx, 'a', false)
211+ assert.True(t, handled, "should return true when picker is visible")
212+
213+ mu.Lock()
214+ count := showCalled
215+ mu.Unlock()
216+ assert.LessOrEqual(t, count, 1, "Show should only be called once")
217+
218+ uc.Cancel(ctx)
219+}
A internal/domain/entity/accent.go +46 -0
@@ -0,0 +1,46 @@
1+package entity
2+
3+import "unicode"
4+
5+// AccentMap maps base characters to their accented variants.
6+// The order determines display order in the accent picker.
7+var AccentMap = map[rune][]rune{
8+ 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'},
9+ 'c': {'ç', 'ć', 'č'},
10+ 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'},
11+ 'i': {'ì', 'í', 'î', 'ï', 'į'},
12+ 'n': {'ñ', 'ń'},
13+ 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'},
14+ 's': {'ß', 'ś', 'š'},
15+ 'u': {'ù', 'ú', 'û', 'ü', 'ū'},
16+ 'y': {'ÿ', 'ý'},
17+}
18+
19+// HasAccents returns true if the given character has accent variants.
20+func HasAccents(char rune) bool {
21+ lower := unicode.ToLower(char)
22+ _, ok := AccentMap[lower]
23+ return ok
24+}
25+
26+// GetAccents returns the accent variants for a character.
27+// If uppercase is true, returns uppercase variants.
28+// Returns nil if the character has no accent variants.
29+func GetAccents(char rune, uppercase bool) []rune {
30+ lower := unicode.ToLower(char)
31+ accents, ok := AccentMap[lower]
32+ if !ok {
33+ return nil
34+ }
35+
36+ if !uppercase {
37+ return accents
38+ }
39+
40+ // Return uppercase versions
41+ result := make([]rune, len(accents))
42+ for i, a := range accents {
43+ result[i] = unicode.ToUpper(a)
44+ }
45+ return result
46+}
@@ -0,0 +1,46 @@
1+package entity
2+
3+import "unicode"
4+
5+// AccentMap maps base characters to their accented variants.
6+// The order determines display order in the accent picker.
7+var AccentMap = map[rune][]rune{
8+ 'a': {'à', 'á', 'â', 'ä', 'ã', 'å', 'æ'},
9+ 'c': {'ç', 'ć', 'č'},
10+ 'e': {'è', 'é', 'ê', 'ë', 'ę', 'ė'},
11+ 'i': {'ì', 'í', 'î', 'ï', 'į'},
12+ 'n': {'ñ', 'ń'},
13+ 'o': {'ò', 'ó', 'ô', 'ö', 'õ', 'ø', 'œ'},
14+ 's': {'ß', 'ś', 'š'},
15+ 'u': {'ù', 'ú', 'û', 'ü', 'ū'},
16+ 'y': {'ÿ', 'ý'},
17+}
18+
19+// HasAccents returns true if the given character has accent variants.
20+func HasAccents(char rune) bool {
21+ lower := unicode.ToLower(char)
22+ _, ok := AccentMap[lower]
23+ return ok
24+}
25+
26+// GetAccents returns the accent variants for a character.
27+// If uppercase is true, returns uppercase variants.
28+// Returns nil if the character has no accent variants.
29+func GetAccents(char rune, uppercase bool) []rune {
30+ lower := unicode.ToLower(char)
31+ accents, ok := AccentMap[lower]
32+ if !ok {
33+ return nil
34+ }
35+
36+ if !uppercase {
37+ return accents
38+ }
39+
40+ // Return uppercase versions
41+ result := make([]rune, len(accents))
42+ for i, a := range accents {
43+ result[i] = unicode.ToUpper(a)
44+ }
45+ return result
46+}
A internal/domain/entity/accent_test.go +108 -0
@@ -0,0 +1,108 @@
1+package entity
2+
3+import (
4+ "testing"
5+)
6+
7+func TestHasAccents(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ char rune
11+ want bool
12+ }{
13+ {"lowercase e", 'e', true},
14+ {"uppercase E", 'E', true},
15+ {"lowercase a", 'a', true},
16+ {"uppercase A", 'A', true},
17+ {"lowercase c", 'c', true},
18+ {"lowercase n", 'n', true},
19+ {"lowercase x (no accents)", 'x', false},
20+ {"uppercase X (no accents)", 'X', false},
21+ {"digit 1 (no accents)", '1', false},
22+ {"space (no accents)", ' ', false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := HasAccents(tt.char)
28+ if got != tt.want {
29+ t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestGetAccents(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ char rune
39+ uppercase bool
40+ wantLen int
41+ wantFirst rune
42+ }{
43+ {"lowercase e", 'e', false, 6, 'è'},
44+ {"uppercase E", 'E', true, 6, 'È'},
45+ {"lowercase a", 'a', false, 7, 'à'},
46+ {"uppercase A", 'A', true, 7, 'À'},
47+ {"lowercase c", 'c', false, 3, 'ç'},
48+ {"uppercase C with shift", 'c', true, 3, 'Ç'},
49+ {"no accents x", 'x', false, 0, 0},
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ got := GetAccents(tt.char, tt.uppercase)
55+
56+ if len(got) != tt.wantLen {
57+ t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen)
58+ }
59+
60+ if tt.wantLen > 0 && got[0] != tt.wantFirst {
61+ t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst)
62+ }
63+ })
64+ }
65+}
66+
67+func TestGetAccents_UppercaseFromLowercase(t *testing.T) {
68+ // When passing lowercase char with uppercase=true, should return uppercase accents
69+ uppercase := GetAccents('e', true)
70+ lowercase := GetAccents('e', false)
71+
72+ if len(uppercase) == 0 {
73+ t.Fatal("expected accents for 'e' with uppercase=true")
74+ }
75+
76+ if len(uppercase) != len(lowercase) {
77+ t.Fatalf("uppercase and lowercase accent counts differ: %d vs %d", len(uppercase), len(lowercase))
78+ }
79+
80+ // Verify each uppercase accent is different from the corresponding lowercase accent
81+ for i, upper := range uppercase {
82+ lower := lowercase[i]
83+ if upper == lower {
84+ t.Errorf("accent at index %d is same for uppercase and lowercase: %q", i, upper)
85+ }
86+ }
87+
88+ // Verify first accent is È (uppercase of è)
89+ if uppercase[0] != 'È' {
90+ t.Errorf("expected first uppercase accent to be È, got %q", uppercase[0])
91+ }
92+}
93+
94+func TestAccentMapCoverage(t *testing.T) {
95+ // Verify all expected characters have accents
96+ expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'}
97+
98+ for _, char := range expectedChars {
99+ if !HasAccents(char) {
100+ t.Errorf("expected %q to have accents", char)
101+ }
102+
103+ accents := GetAccents(char, false)
104+ if len(accents) == 0 {
105+ t.Errorf("expected %q to have at least one accent variant", char)
106+ }
107+ }
108+}
@@ -0,0 +1,108 @@
1+package entity
2+
3+import (
4+ "testing"
5+)
6+
7+func TestHasAccents(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ char rune
11+ want bool
12+ }{
13+ {"lowercase e", 'e', true},
14+ {"uppercase E", 'E', true},
15+ {"lowercase a", 'a', true},
16+ {"uppercase A", 'A', true},
17+ {"lowercase c", 'c', true},
18+ {"lowercase n", 'n', true},
19+ {"lowercase x (no accents)", 'x', false},
20+ {"uppercase X (no accents)", 'X', false},
21+ {"digit 1 (no accents)", '1', false},
22+ {"space (no accents)", ' ', false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got := HasAccents(tt.char)
28+ if got != tt.want {
29+ t.Errorf("HasAccents(%q) = %v, want %v", tt.char, got, tt.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestGetAccents(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ char rune
39+ uppercase bool
40+ wantLen int
41+ wantFirst rune
42+ }{
43+ {"lowercase e", 'e', false, 6, 'è'},
44+ {"uppercase E", 'E', true, 6, 'È'},
45+ {"lowercase a", 'a', false, 7, 'à'},
46+ {"uppercase A", 'A', true, 7, 'À'},
47+ {"lowercase c", 'c', false, 3, 'ç'},
48+ {"uppercase C with shift", 'c', true, 3, 'Ç'},
49+ {"no accents x", 'x', false, 0, 0},
50+ }
51+
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ got := GetAccents(tt.char, tt.uppercase)
55+
56+ if len(got) != tt.wantLen {
57+ t.Errorf("GetAccents(%q, %v) length = %d, want %d", tt.char, tt.uppercase, len(got), tt.wantLen)
58+ }
59+
60+ if tt.wantLen > 0 && got[0] != tt.wantFirst {
61+ t.Errorf("GetAccents(%q, %v)[0] = %q, want %q", tt.char, tt.uppercase, got[0], tt.wantFirst)
62+ }
63+ })
64+ }
65+}
66+
67+func TestGetAccents_UppercaseFromLowercase(t *testing.T) {
68+ // When passing lowercase char with uppercase=true, should return uppercase accents
69+ uppercase := GetAccents('e', true)
70+ lowercase := GetAccents('e', false)
71+
72+ if len(uppercase) == 0 {
73+ t.Fatal("expected accents for 'e' with uppercase=true")
74+ }
75+
76+ if len(uppercase) != len(lowercase) {
77+ t.Fatalf("uppercase and lowercase accent counts differ: %d vs %d", len(uppercase), len(lowercase))
78+ }
79+
80+ // Verify each uppercase accent is different from the corresponding lowercase accent
81+ for i, upper := range uppercase {
82+ lower := lowercase[i]
83+ if upper == lower {
84+ t.Errorf("accent at index %d is same for uppercase and lowercase: %q", i, upper)
85+ }
86+ }
87+
88+ // Verify first accent is È (uppercase of è)
89+ if uppercase[0] != 'È' {
90+ t.Errorf("expected first uppercase accent to be È, got %q", uppercase[0])
91+ }
92+}
93+
94+func TestAccentMapCoverage(t *testing.T) {
95+ // Verify all expected characters have accents
96+ expectedChars := []rune{'a', 'c', 'e', 'i', 'n', 'o', 's', 'u', 'y'}
97+
98+ for _, char := range expectedChars {
99+ if !HasAccents(char) {
100+ t.Errorf("expected %q to have accents", char)
101+ }
102+
103+ accents := GetAccents(char, false)
104+ if len(accents) == 0 {
105+ t.Errorf("expected %q to have at least one accent variant", char)
106+ }
107+ }
108+}
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 +133 -0
@@ -0,0 +1,133 @@
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+ // Use context.Background() since this cleanup should complete regardless of ctx cancellation
101+ go func() {
102+ time.Sleep(clipboardRestoreDelay)
103+ if originalClipboard != "" {
104+ if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil {
105+ log.Debug().Err(err).Msg("failed to restore clipboard")
106+ }
107+ }
108+ }()
109+
110+ return nil
111+}
112+
113+// DeleteBeforeCursor deletes n characters before the cursor in the WebView.
114+// Uses JavaScript to manipulate the focused input element.
115+func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
116+ log := logging.FromContext(ctx)
117+
118+ if t.webView == nil {
119+ log.Warn().Msg("WebViewTarget: webView is nil")
120+ return nil
121+ }
122+
123+ if n <= 0 {
124+ return nil
125+ }
126+
127+ // Execute JavaScript to delete characters
128+ script := fmt.Sprintf(deleteBeforeCursorJS, n)
129+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
130+
131+ log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
132+ return nil
133+}
@@ -0,0 +1,133 @@
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+ // Use context.Background() since this cleanup should complete regardless of ctx cancellation
101+ go func() {
102+ time.Sleep(clipboardRestoreDelay)
103+ if originalClipboard != "" {
104+ if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil {
105+ log.Debug().Err(err).Msg("failed to restore clipboard")
106+ }
107+ }
108+ }()
109+
110+ return nil
111+}
112+
113+// DeleteBeforeCursor deletes n characters before the cursor in the WebView.
114+// Uses JavaScript to manipulate the focused input element.
115+func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
116+ log := logging.FromContext(ctx)
117+
118+ if t.webView == nil {
119+ log.Warn().Msg("WebViewTarget: webView is nil")
120+ return nil
121+ }
122+
123+ if n <= 0 {
124+ return nil
125+ }
126+
127+ // Execute JavaScript to delete characters
128+ script := fmt.Sprintf(deleteBeforeCursorJS, n)
129+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
130+
131+ log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
132+ return nil
133+}
M internal/infrastructure/webkit/messaging.go +2 -2
@@ -60,8 +60,8 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
6060 }
6161
6262 return &MessageRouter{
63- handlers: make(map[string]handlerEntry),
64- baseCtx: ctx,
63+ handlers: make(map[string]handlerEntry),
64+ baseCtx: ctx,
6565 syncedIDs: make(map[WebViewID]bool),
6666 }
6767 }
@@ -60,8 +60,8 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
60 }60 }
61 61
62 return &MessageRouter{62 return &MessageRouter{
63- handlers: make(map[string]handlerEntry),63+ handlers: make(map[string]handlerEntry),
64- baseCtx: ctx,64+ baseCtx: ctx,
65 syncedIDs: make(map[WebViewID]bool),65 syncedIDs: make(map[WebViewID]bool),
66 }66 }
67 }67 }
M internal/ui/app.go +101 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/dumber/internal/infrastructure/desktop"
1515 "github.com/bnema/dumber/internal/infrastructure/filtering"
1616 "github.com/bnema/dumber/internal/infrastructure/snapshot"
17+ "github.com/bnema/dumber/internal/infrastructure/textinput"
1718 "github.com/bnema/dumber/internal/infrastructure/webkit"
1819
1920 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -108,6 +109,11 @@ type App struct {
108109
109110 movePaneToTabUC *usecase.MovePaneToTabUseCase
110111
112+ // Accent picker for dead keys support
113+ accentPicker *component.AccentPicker
114+ insertAccentUC *usecase.InsertAccentUseCase
115+ accentFocusProvider *textinput.FocusProvider
116+
111117 // lifecycle
112118 cancel context.CancelCauseFunc
113119 }
@@ -217,6 +223,7 @@ func (a *App) onActivate(ctx context.Context) {
217223 a.initLayoutInfrastructure()
218224 a.initAppToasterOverlay()
219225 a.initFocusAndBorderOverlay()
226+ a.initAccentPicker(ctx)
220227
221228 a.initCoordinators(ctx)
222229 a.initKeyboardHandler(ctx)
@@ -387,6 +394,49 @@ func (a *App) initFocusAndBorderOverlay() {
387394 a.mainWindow.AddOverlay(gtkWidget)
388395 }
389396
397+func (a *App) initAccentPicker(ctx context.Context) {
398+ log := logging.FromContext(ctx)
399+
400+ if a.widgetFactory == nil || a.mainWindow == nil {
401+ log.Debug().Msg("widget factory or main window not available, skipping accent picker")
402+ return
403+ }
404+
405+ // Create accent picker component
406+ a.accentPicker = component.NewAccentPicker(a.widgetFactory)
407+ if a.accentPicker == nil {
408+ log.Warn().Msg("failed to create accent picker")
409+ return
410+ }
411+
412+ // Add to main window overlay
413+ pickerWidget := a.accentPicker.Widget()
414+ if pickerWidget != nil {
415+ gtkWidget := pickerWidget.GtkWidget()
416+ if gtkWidget != nil {
417+ a.mainWindow.AddOverlay(gtkWidget)
418+ }
419+ }
420+
421+ // Create focus provider (tracks which input has focus)
422+ a.accentFocusProvider = textinput.NewFocusProvider()
423+
424+ // Create the use case with glib.IdleAdd for thread-safe GTK calls
425+ a.insertAccentUC = usecase.NewInsertAccentUseCase(
426+ a.accentFocusProvider,
427+ a.accentPicker,
428+ func(fn func()) {
429+ cb := glib.SourceFunc(func(_ uintptr) bool {
430+ fn()
431+ return false // Don't repeat
432+ })
433+ glib.IdleAdd(&cb, 0)
434+ },
435+ )
436+
437+ log.Debug().Msg("accent picker initialized")
438+}
439+
390440 func (a *App) initKeyboardHandler(ctx context.Context) {
391441 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
392442 return
@@ -414,6 +464,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
414464 }
415465 return wsView.IsOmniboxVisible()
416466 })
467+ // Wire accent handler for dead keys support
468+ if a.insertAccentUC != nil {
469+ a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
470+ }
417471 a.keyboardHandler.AttachTo(a.mainWindow.Window())
418472
419473 // Create global shortcut handler for Alt+1-9 tab switching.
@@ -461,6 +515,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
461515 log.Error().Err(err).Str("url", url).Msg("navigation failed")
462516 }
463517 },
518+ OnFocusIn: func(entry *gtk.SearchEntry) {
519+ // Set omnibox entry as the focused input for accent picker
520+ if a.accentFocusProvider != nil && entry != nil {
521+ a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
522+ }
523+ },
524+ OnFocusOut: func() {
525+ // When omnibox loses focus, set WebView as the focused input
526+ if a.accentFocusProvider != nil {
527+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
528+ }
529+ },
464530 }
465531 a.navCoord.SetOmniboxProvider(a)
466532 log.Debug().Msg("omnibox config stored, provider set")
@@ -479,6 +545,18 @@ func (a *App) initFindBarConfig() {
479545 }
480546 return wv.GetFindController()
481547 },
548+ OnFocusIn: func(entry *gtk.SearchEntry) {
549+ // Set find bar entry as the focused input for accent picker
550+ if a.accentFocusProvider != nil && entry != nil {
551+ a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
552+ }
553+ },
554+ OnFocusOut: func() {
555+ // When find bar loses focus, set WebView as the focused input
556+ if a.accentFocusProvider != nil {
557+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
558+ }
559+ },
482560 }
483561 }
484562
@@ -1219,6 +1297,12 @@ func (a *App) initCoordinators(ctx context.Context) {
12191297 a.deps.OnFirstWebViewShown(ctx)
12201298 })
12211299 }
1300+
1301+ // Set the WebView as the focused input for accent picker (if this is the active pane)
1302+ ws := a.activeWorkspace()
1303+ if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1304+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1305+ }
12221306 })
12231307
12241308 // 5. Keyboard Dispatcher
@@ -1538,6 +1622,23 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
15381622 return a.workspaceViews[activeTab.ID]
15391623 }
15401624
1625+// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1626+// Used by the accent picker to insert accented characters into web content.
1627+func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1628+ ws := a.activeWorkspace()
1629+ if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1630+ return nil
1631+ }
1632+
1633+ wv := a.contentCoord.GetWebView(ws.ActivePaneID)
1634+ if wv == nil {
1635+ return nil
1636+ }
1637+
1638+ // Get the underlying webkit.WebView for the text input target
1639+ return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
1640+}
1641+
15411642 // attachPopupToTab attaches a popup WebView to a newly created tab.
15421643 // This is called when a popup uses tabbed behavior.
15431644 func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/dumber/internal/infrastructure/desktop"14 "github.com/bnema/dumber/internal/infrastructure/desktop"
15 "github.com/bnema/dumber/internal/infrastructure/filtering"15 "github.com/bnema/dumber/internal/infrastructure/filtering"
16 "github.com/bnema/dumber/internal/infrastructure/snapshot"16 "github.com/bnema/dumber/internal/infrastructure/snapshot"
17+ "github.com/bnema/dumber/internal/infrastructure/textinput"
17 "github.com/bnema/dumber/internal/infrastructure/webkit"18 "github.com/bnema/dumber/internal/infrastructure/webkit"
18 19
19 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"20 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -108,6 +109,11 @@ type App struct {
108 109
109 movePaneToTabUC *usecase.MovePaneToTabUseCase110 movePaneToTabUC *usecase.MovePaneToTabUseCase
110 111
112+ // Accent picker for dead keys support
113+ accentPicker *component.AccentPicker
114+ insertAccentUC *usecase.InsertAccentUseCase
115+ accentFocusProvider *textinput.FocusProvider
116+
111 // lifecycle117 // lifecycle
112 cancel context.CancelCauseFunc118 cancel context.CancelCauseFunc
113 }119 }
@@ -217,6 +223,7 @@ func (a *App) onActivate(ctx context.Context) {
217 a.initLayoutInfrastructure()223 a.initLayoutInfrastructure()
218 a.initAppToasterOverlay()224 a.initAppToasterOverlay()
219 a.initFocusAndBorderOverlay()225 a.initFocusAndBorderOverlay()
226+ a.initAccentPicker(ctx)
220 227
221 a.initCoordinators(ctx)228 a.initCoordinators(ctx)
222 a.initKeyboardHandler(ctx)229 a.initKeyboardHandler(ctx)
@@ -387,6 +394,49 @@ func (a *App) initFocusAndBorderOverlay() {
387 a.mainWindow.AddOverlay(gtkWidget)394 a.mainWindow.AddOverlay(gtkWidget)
388 }395 }
389 396
397+func (a *App) initAccentPicker(ctx context.Context) {
398+ log := logging.FromContext(ctx)
399+
400+ if a.widgetFactory == nil || a.mainWindow == nil {
401+ log.Debug().Msg("widget factory or main window not available, skipping accent picker")
402+ return
403+ }
404+
405+ // Create accent picker component
406+ a.accentPicker = component.NewAccentPicker(a.widgetFactory)
407+ if a.accentPicker == nil {
408+ log.Warn().Msg("failed to create accent picker")
409+ return
410+ }
411+
412+ // Add to main window overlay
413+ pickerWidget := a.accentPicker.Widget()
414+ if pickerWidget != nil {
415+ gtkWidget := pickerWidget.GtkWidget()
416+ if gtkWidget != nil {
417+ a.mainWindow.AddOverlay(gtkWidget)
418+ }
419+ }
420+
421+ // Create focus provider (tracks which input has focus)
422+ a.accentFocusProvider = textinput.NewFocusProvider()
423+
424+ // Create the use case with glib.IdleAdd for thread-safe GTK calls
425+ a.insertAccentUC = usecase.NewInsertAccentUseCase(
426+ a.accentFocusProvider,
427+ a.accentPicker,
428+ func(fn func()) {
429+ cb := glib.SourceFunc(func(_ uintptr) bool {
430+ fn()
431+ return false // Don't repeat
432+ })
433+ glib.IdleAdd(&cb, 0)
434+ },
435+ )
436+
437+ log.Debug().Msg("accent picker initialized")
438+}
439+
390 func (a *App) initKeyboardHandler(ctx context.Context) {440 func (a *App) initKeyboardHandler(ctx context.Context) {
391 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {441 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
392 return442 return
@@ -414,6 +464,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
414 }464 }
415 return wsView.IsOmniboxVisible()465 return wsView.IsOmniboxVisible()
416 })466 })
467+ // Wire accent handler for dead keys support
468+ if a.insertAccentUC != nil {
469+ a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
470+ }
417 a.keyboardHandler.AttachTo(a.mainWindow.Window())471 a.keyboardHandler.AttachTo(a.mainWindow.Window())
418 472
419 // Create global shortcut handler for Alt+1-9 tab switching.473 // Create global shortcut handler for Alt+1-9 tab switching.
@@ -461,6 +515,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
461 log.Error().Err(err).Str("url", url).Msg("navigation failed")515 log.Error().Err(err).Str("url", url).Msg("navigation failed")
462 }516 }
463 },517 },
518+ OnFocusIn: func(entry *gtk.SearchEntry) {
519+ // Set omnibox entry as the focused input for accent picker
520+ if a.accentFocusProvider != nil && entry != nil {
521+ a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
522+ }
523+ },
524+ OnFocusOut: func() {
525+ // When omnibox loses focus, set WebView as the focused input
526+ if a.accentFocusProvider != nil {
527+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
528+ }
529+ },
464 }530 }
465 a.navCoord.SetOmniboxProvider(a)531 a.navCoord.SetOmniboxProvider(a)
466 log.Debug().Msg("omnibox config stored, provider set")532 log.Debug().Msg("omnibox config stored, provider set")
@@ -479,6 +545,18 @@ func (a *App) initFindBarConfig() {
479 }545 }
480 return wv.GetFindController()546 return wv.GetFindController()
481 },547 },
548+ OnFocusIn: func(entry *gtk.SearchEntry) {
549+ // Set find bar entry as the focused input for accent picker
550+ if a.accentFocusProvider != nil && entry != nil {
551+ a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
552+ }
553+ },
554+ OnFocusOut: func() {
555+ // When find bar loses focus, set WebView as the focused input
556+ if a.accentFocusProvider != nil {
557+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
558+ }
559+ },
482 }560 }
483 }561 }
484 562
@@ -1219,6 +1297,12 @@ func (a *App) initCoordinators(ctx context.Context) {
1219 a.deps.OnFirstWebViewShown(ctx)1297 a.deps.OnFirstWebViewShown(ctx)
1220 })1298 })
1221 }1299 }
1300+
1301+ // Set the WebView as the focused input for accent picker (if this is the active pane)
1302+ ws := a.activeWorkspace()
1303+ if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
1304+ a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
1305+ }
1222 })1306 })
1223 1307
1224 // 5. Keyboard Dispatcher1308 // 5. Keyboard Dispatcher
@@ -1538,6 +1622,23 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
1538 return a.workspaceViews[activeTab.ID]1622 return a.workspaceViews[activeTab.ID]
1539 }1623 }
1540 1624
1625+// getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1626+// Used by the accent picker to insert accented characters into web content.
1627+func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1628+ ws := a.activeWorkspace()
1629+ if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1630+ return nil
1631+ }
1632+
1633+ wv := a.contentCoord.GetWebView(ws.ActivePaneID)
1634+ if wv == nil {
1635+ return nil
1636+ }
1637+
1638+ // Get the underlying webkit.WebView for the text input target
1639+ return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
1640+}
1641+
1541 // attachPopupToTab attaches a popup WebView to a newly created tab.1642 // attachPopupToTab attaches a popup WebView to a newly created tab.
1542 // This is called when a popup uses tabbed behavior.1643 // This is called when a popup uses tabbed behavior.
1543 func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {1644 func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
A internal/ui/component/accent_picker.go +319 -0
@@ -0,0 +1,319 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/layout"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Accent picker layout constants.
13+const (
14+ accentPickerSpacing = 8 // Spacing between accent items in pixels.
15+ maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9).
16+)
17+
18+// AccentPicker displays an overlay with accent character options.
19+// The user can select an accent using:
20+// - Left/Right arrow keys to navigate
21+// - Number keys 1-9 to select directly
22+// - Enter to confirm selection
23+// - Escape to cancel
24+type AccentPicker struct {
25+ factory layout.WidgetFactory
26+
27+ // Widgets
28+ container layout.BoxWidget // Outer container for positioning
29+ accentLabels []layout.LabelWidget // Individual accent character labels
30+
31+ // State
32+ accents []rune
33+ selectedIndex int
34+ selectedCb func(rune)
35+ cancelCb func()
36+ visible bool
37+ keyController *gtk.EventControllerKey
38+ retainedClicked []interface{} // Keep click callbacks alive
39+
40+ mu sync.Mutex
41+}
42+
43+// Compile-time interface check.
44+var _ port.AccentPickerUI = (*AccentPicker)(nil)
45+
46+// NewAccentPicker creates a new accent picker component.
47+func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker {
48+ // Create container box
49+ container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing)
50+ container.AddCssClass("accent-picker")
51+
52+ // Position at bottom-center
53+ container.SetHalign(gtk.AlignCenterValue)
54+ container.SetValign(gtk.AlignEndValue)
55+
56+ // Don't expand
57+ container.SetHexpand(false)
58+ container.SetVexpand(false)
59+
60+ // Make focusable so it can receive keyboard events
61+ container.SetFocusable(true)
62+ container.SetCanFocus(true)
63+
64+ // Hidden by default
65+ container.SetVisible(false)
66+
67+ ap := &AccentPicker{
68+ factory: factory,
69+ container: container,
70+ selectedIndex: 0,
71+ }
72+
73+ // Set up key event handling
74+ ap.setupKeyboardHandling()
75+
76+ // Set up hover-to-focus: regain focus when mouse enters
77+ ap.setupHoverFocus()
78+
79+ return ap
80+}
81+
82+// Widget returns the accent picker's container widget.
83+func (ap *AccentPicker) Widget() layout.Widget {
84+ return ap.container
85+}
86+
87+// Show displays the accent picker with the given accent options.
88+func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) {
89+ ap.mu.Lock()
90+ defer ap.mu.Unlock()
91+
92+ ap.accents = accents
93+ ap.selectedCb = selectedCb
94+ ap.cancelCb = cancelCb
95+ ap.selectedIndex = 0
96+ ap.visible = true
97+
98+ // Clear existing labels
99+ ap.clearLabels()
100+
101+ // Create new labels for each accent
102+ ap.accentLabels = make([]layout.LabelWidget, len(accents))
103+ ap.retainedClicked = make([]interface{}, len(accents))
104+
105+ for i, accent := range accents {
106+ label := ap.factory.NewLabel(string(accent))
107+ label.AddCssClass("accent-picker-item")
108+
109+ // Add number hint for first maxNumberedAccentItems items
110+ if i < maxNumberedAccentItems {
111+ label.AddCssClass("accent-picker-numbered")
112+ }
113+
114+ // Set up click handling
115+ clickCtrl := gtk.NewGestureClick()
116+ idx := i // Capture for closure
117+ clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) {
118+ ap.selectAccent(idx)
119+ }
120+ clickCtrl.ConnectPressed(&clickCb)
121+ ap.retainedClicked[i] = clickCb
122+ label.AddController(&clickCtrl.EventController)
123+
124+ ap.accentLabels[i] = label
125+ ap.container.Append(label)
126+ }
127+
128+ // Highlight first item
129+ ap.updateSelection()
130+
131+ ap.container.SetVisible(true)
132+ ap.container.GrabFocus()
133+}
134+
135+// Hide hides the accent picker.
136+func (ap *AccentPicker) Hide() {
137+ ap.mu.Lock()
138+ defer ap.mu.Unlock()
139+
140+ ap.visible = false
141+ ap.container.SetVisible(false)
142+ ap.clearLabels()
143+}
144+
145+// IsVisible returns true if the accent picker is currently visible.
146+func (ap *AccentPicker) IsVisible() bool {
147+ ap.mu.Lock()
148+ defer ap.mu.Unlock()
149+ return ap.visible
150+}
151+
152+// clearLabels removes all accent labels from the container.
153+func (ap *AccentPicker) clearLabels() {
154+ for _, label := range ap.accentLabels {
155+ label.Unparent()
156+ }
157+ ap.accentLabels = nil
158+ ap.retainedClicked = nil
159+}
160+
161+// updateSelection updates the visual selection highlight.
162+func (ap *AccentPicker) updateSelection() {
163+ for i, label := range ap.accentLabels {
164+ if i == ap.selectedIndex {
165+ label.AddCssClass("accent-picker-selected")
166+ } else {
167+ label.RemoveCssClass("accent-picker-selected")
168+ }
169+ }
170+}
171+
172+// selectAccent selects the accent at the given index.
173+func (ap *AccentPicker) selectAccent(index int) {
174+ ap.mu.Lock()
175+ if index < 0 || index >= len(ap.accents) || ap.selectedCb == nil {
176+ ap.mu.Unlock()
177+ return
178+ }
179+ accent := ap.accents[index]
180+ // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
181+ ap.selectedCb(accent)
182+ ap.mu.Unlock()
183+}
184+
185+// cancel cancels the accent picker.
186+func (ap *AccentPicker) cancel() {
187+ ap.mu.Lock()
188+ if ap.cancelCb == nil {
189+ ap.mu.Unlock()
190+ return
191+ }
192+ // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
193+ ap.cancelCb()
194+ ap.mu.Unlock()
195+}
196+
197+// setupKeyboardHandling sets up key event handling for navigation.
198+func (ap *AccentPicker) setupKeyboardHandling() {
199+ keyCtrl := gtk.NewEventControllerKey()
200+
201+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
202+ return ap.handleKeyPress(keyval)
203+ }
204+
205+ keyCtrl.ConnectKeyPressed(&keyPressedCb)
206+ ap.keyController = keyCtrl
207+ ap.container.AddController(&keyCtrl.EventController)
208+}
209+
210+// setupHoverFocus sets up mouse hover to regain focus.
211+// This ensures the picker regains focus when the mouse enters,
212+// so keyboard navigation (including Escape) continues to work.
213+func (ap *AccentPicker) setupHoverFocus() {
214+ motionCtrl := gtk.NewEventControllerMotion()
215+
216+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
217+ ap.mu.Lock()
218+ visible := ap.visible
219+ ap.mu.Unlock()
220+
221+ if visible {
222+ ap.container.GrabFocus()
223+ }
224+ }
225+
226+ motionCtrl.ConnectEnter(&enterCb)
227+ ap.container.AddController(&motionCtrl.EventController)
228+}
229+
230+// handleKeyPress processes a key press event.
231+// Returns true if the key was handled.
232+func (ap *AccentPicker) handleKeyPress(keyval uint) bool {
233+ ap.mu.Lock()
234+ if !ap.visible {
235+ ap.mu.Unlock()
236+ return false
237+ }
238+
239+ // Handle navigation keys
240+ if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn {
241+ return handled
242+ }
243+
244+ // Handle number keys for direct selection
245+ if idx, ok := ap.numberKeyToIndex(keyval); ok {
246+ ap.mu.Unlock()
247+ ap.selectAccent(idx)
248+ return true
249+ }
250+
251+ ap.mu.Unlock()
252+ return false
253+}
254+
255+// handleNavigationKey handles arrow keys, Enter, and Escape.
256+// Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled.
257+func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) {
258+ switch keyval {
259+ case uint(gdk.KEY_Left):
260+ if ap.selectedIndex > 0 {
261+ ap.selectedIndex--
262+ ap.updateSelection()
263+ }
264+ ap.mu.Unlock()
265+ return true, true
266+
267+ case uint(gdk.KEY_Right):
268+ if ap.selectedIndex < len(ap.accents)-1 {
269+ ap.selectedIndex++
270+ ap.updateSelection()
271+ }
272+ ap.mu.Unlock()
273+ return true, true
274+
275+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
276+ idx := ap.selectedIndex
277+ ap.mu.Unlock()
278+ ap.selectAccent(idx)
279+ return true, true
280+
281+ case uint(gdk.KEY_Escape):
282+ ap.mu.Unlock()
283+ ap.cancel()
284+ return true, true
285+
286+ default:
287+ return false, false
288+ }
289+}
290+
291+// numberKeyToIndex converts a number key (1-9) to an accent index.
292+// Returns the index and true if the key is a valid number key.
293+func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) {
294+ keyMap := map[uint]int{
295+ uint(gdk.KEY_1): 0,
296+ uint(gdk.KEY_KP_1): 0,
297+ uint(gdk.KEY_2): 1,
298+ uint(gdk.KEY_KP_2): 1,
299+ uint(gdk.KEY_3): 2,
300+ uint(gdk.KEY_KP_3): 2,
301+ uint(gdk.KEY_4): 3,
302+ uint(gdk.KEY_KP_4): 3,
303+ uint(gdk.KEY_5): 4,
304+ uint(gdk.KEY_KP_5): 4,
305+ uint(gdk.KEY_6): 5,
306+ uint(gdk.KEY_KP_6): 5,
307+ uint(gdk.KEY_7): 6,
308+ uint(gdk.KEY_KP_7): 6,
309+ uint(gdk.KEY_8): 7,
310+ uint(gdk.KEY_KP_8): 7,
311+ uint(gdk.KEY_9): 8,
312+ uint(gdk.KEY_KP_9): 8,
313+ }
314+
315+ if idx, ok := keyMap[keyval]; ok {
316+ return idx, true
317+ }
318+ return 0, false
319+}
@@ -0,0 +1,319 @@
1+package component
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/ui/layout"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/gtk"
10+)
11+
12+// Accent picker layout constants.
13+const (
14+ accentPickerSpacing = 8 // Spacing between accent items in pixels.
15+ maxNumberedAccentItems = 9 // Maximum items that can be selected with number keys (1-9).
16+)
17+
18+// AccentPicker displays an overlay with accent character options.
19+// The user can select an accent using:
20+// - Left/Right arrow keys to navigate
21+// - Number keys 1-9 to select directly
22+// - Enter to confirm selection
23+// - Escape to cancel
24+type AccentPicker struct {
25+ factory layout.WidgetFactory
26+
27+ // Widgets
28+ container layout.BoxWidget // Outer container for positioning
29+ accentLabels []layout.LabelWidget // Individual accent character labels
30+
31+ // State
32+ accents []rune
33+ selectedIndex int
34+ selectedCb func(rune)
35+ cancelCb func()
36+ visible bool
37+ keyController *gtk.EventControllerKey
38+ retainedClicked []interface{} // Keep click callbacks alive
39+
40+ mu sync.Mutex
41+}
42+
43+// Compile-time interface check.
44+var _ port.AccentPickerUI = (*AccentPicker)(nil)
45+
46+// NewAccentPicker creates a new accent picker component.
47+func NewAccentPicker(factory layout.WidgetFactory) *AccentPicker {
48+ // Create container box
49+ container := factory.NewBox(layout.OrientationHorizontal, accentPickerSpacing)
50+ container.AddCssClass("accent-picker")
51+
52+ // Position at bottom-center
53+ container.SetHalign(gtk.AlignCenterValue)
54+ container.SetValign(gtk.AlignEndValue)
55+
56+ // Don't expand
57+ container.SetHexpand(false)
58+ container.SetVexpand(false)
59+
60+ // Make focusable so it can receive keyboard events
61+ container.SetFocusable(true)
62+ container.SetCanFocus(true)
63+
64+ // Hidden by default
65+ container.SetVisible(false)
66+
67+ ap := &AccentPicker{
68+ factory: factory,
69+ container: container,
70+ selectedIndex: 0,
71+ }
72+
73+ // Set up key event handling
74+ ap.setupKeyboardHandling()
75+
76+ // Set up hover-to-focus: regain focus when mouse enters
77+ ap.setupHoverFocus()
78+
79+ return ap
80+}
81+
82+// Widget returns the accent picker's container widget.
83+func (ap *AccentPicker) Widget() layout.Widget {
84+ return ap.container
85+}
86+
87+// Show displays the accent picker with the given accent options.
88+func (ap *AccentPicker) Show(accents []rune, selectedCb func(rune), cancelCb func()) {
89+ ap.mu.Lock()
90+ defer ap.mu.Unlock()
91+
92+ ap.accents = accents
93+ ap.selectedCb = selectedCb
94+ ap.cancelCb = cancelCb
95+ ap.selectedIndex = 0
96+ ap.visible = true
97+
98+ // Clear existing labels
99+ ap.clearLabels()
100+
101+ // Create new labels for each accent
102+ ap.accentLabels = make([]layout.LabelWidget, len(accents))
103+ ap.retainedClicked = make([]interface{}, len(accents))
104+
105+ for i, accent := range accents {
106+ label := ap.factory.NewLabel(string(accent))
107+ label.AddCssClass("accent-picker-item")
108+
109+ // Add number hint for first maxNumberedAccentItems items
110+ if i < maxNumberedAccentItems {
111+ label.AddCssClass("accent-picker-numbered")
112+ }
113+
114+ // Set up click handling
115+ clickCtrl := gtk.NewGestureClick()
116+ idx := i // Capture for closure
117+ clickCb := func(_ gtk.GestureClick, _ int, _ float64, _ float64) {
118+ ap.selectAccent(idx)
119+ }
120+ clickCtrl.ConnectPressed(&clickCb)
121+ ap.retainedClicked[i] = clickCb
122+ label.AddController(&clickCtrl.EventController)
123+
124+ ap.accentLabels[i] = label
125+ ap.container.Append(label)
126+ }
127+
128+ // Highlight first item
129+ ap.updateSelection()
130+
131+ ap.container.SetVisible(true)
132+ ap.container.GrabFocus()
133+}
134+
135+// Hide hides the accent picker.
136+func (ap *AccentPicker) Hide() {
137+ ap.mu.Lock()
138+ defer ap.mu.Unlock()
139+
140+ ap.visible = false
141+ ap.container.SetVisible(false)
142+ ap.clearLabels()
143+}
144+
145+// IsVisible returns true if the accent picker is currently visible.
146+func (ap *AccentPicker) IsVisible() bool {
147+ ap.mu.Lock()
148+ defer ap.mu.Unlock()
149+ return ap.visible
150+}
151+
152+// clearLabels removes all accent labels from the container.
153+func (ap *AccentPicker) clearLabels() {
154+ for _, label := range ap.accentLabels {
155+ label.Unparent()
156+ }
157+ ap.accentLabels = nil
158+ ap.retainedClicked = nil
159+}
160+
161+// updateSelection updates the visual selection highlight.
162+func (ap *AccentPicker) updateSelection() {
163+ for i, label := range ap.accentLabels {
164+ if i == ap.selectedIndex {
165+ label.AddCssClass("accent-picker-selected")
166+ } else {
167+ label.RemoveCssClass("accent-picker-selected")
168+ }
169+ }
170+}
171+
172+// selectAccent selects the accent at the given index.
173+func (ap *AccentPicker) selectAccent(index int) {
174+ ap.mu.Lock()
175+ if index < 0 || index >= len(ap.accents) || ap.selectedCb == nil {
176+ ap.mu.Unlock()
177+ return
178+ }
179+ accent := ap.accents[index]
180+ // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
181+ ap.selectedCb(accent)
182+ ap.mu.Unlock()
183+}
184+
185+// cancel cancels the accent picker.
186+func (ap *AccentPicker) cancel() {
187+ ap.mu.Lock()
188+ if ap.cancelCb == nil {
189+ ap.mu.Unlock()
190+ return
191+ }
192+ // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
193+ ap.cancelCb()
194+ ap.mu.Unlock()
195+}
196+
197+// setupKeyboardHandling sets up key event handling for navigation.
198+func (ap *AccentPicker) setupKeyboardHandling() {
199+ keyCtrl := gtk.NewEventControllerKey()
200+
201+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
202+ return ap.handleKeyPress(keyval)
203+ }
204+
205+ keyCtrl.ConnectKeyPressed(&keyPressedCb)
206+ ap.keyController = keyCtrl
207+ ap.container.AddController(&keyCtrl.EventController)
208+}
209+
210+// setupHoverFocus sets up mouse hover to regain focus.
211+// This ensures the picker regains focus when the mouse enters,
212+// so keyboard navigation (including Escape) continues to work.
213+func (ap *AccentPicker) setupHoverFocus() {
214+ motionCtrl := gtk.NewEventControllerMotion()
215+
216+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
217+ ap.mu.Lock()
218+ visible := ap.visible
219+ ap.mu.Unlock()
220+
221+ if visible {
222+ ap.container.GrabFocus()
223+ }
224+ }
225+
226+ motionCtrl.ConnectEnter(&enterCb)
227+ ap.container.AddController(&motionCtrl.EventController)
228+}
229+
230+// handleKeyPress processes a key press event.
231+// Returns true if the key was handled.
232+func (ap *AccentPicker) handleKeyPress(keyval uint) bool {
233+ ap.mu.Lock()
234+ if !ap.visible {
235+ ap.mu.Unlock()
236+ return false
237+ }
238+
239+ // Handle navigation keys
240+ if handled, shouldReturn := ap.handleNavigationKey(keyval); shouldReturn {
241+ return handled
242+ }
243+
244+ // Handle number keys for direct selection
245+ if idx, ok := ap.numberKeyToIndex(keyval); ok {
246+ ap.mu.Unlock()
247+ ap.selectAccent(idx)
248+ return true
249+ }
250+
251+ ap.mu.Unlock()
252+ return false
253+}
254+
255+// handleNavigationKey handles arrow keys, Enter, and Escape.
256+// Returns (handled, shouldReturn) - if shouldReturn is true, caller should return handled.
257+func (ap *AccentPicker) handleNavigationKey(keyval uint) (bool, bool) {
258+ switch keyval {
259+ case uint(gdk.KEY_Left):
260+ if ap.selectedIndex > 0 {
261+ ap.selectedIndex--
262+ ap.updateSelection()
263+ }
264+ ap.mu.Unlock()
265+ return true, true
266+
267+ case uint(gdk.KEY_Right):
268+ if ap.selectedIndex < len(ap.accents)-1 {
269+ ap.selectedIndex++
270+ ap.updateSelection()
271+ }
272+ ap.mu.Unlock()
273+ return true, true
274+
275+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
276+ idx := ap.selectedIndex
277+ ap.mu.Unlock()
278+ ap.selectAccent(idx)
279+ return true, true
280+
281+ case uint(gdk.KEY_Escape):
282+ ap.mu.Unlock()
283+ ap.cancel()
284+ return true, true
285+
286+ default:
287+ return false, false
288+ }
289+}
290+
291+// numberKeyToIndex converts a number key (1-9) to an accent index.
292+// Returns the index and true if the key is a valid number key.
293+func (ap *AccentPicker) numberKeyToIndex(keyval uint) (int, bool) {
294+ keyMap := map[uint]int{
295+ uint(gdk.KEY_1): 0,
296+ uint(gdk.KEY_KP_1): 0,
297+ uint(gdk.KEY_2): 1,
298+ uint(gdk.KEY_KP_2): 1,
299+ uint(gdk.KEY_3): 2,
300+ uint(gdk.KEY_KP_3): 2,
301+ uint(gdk.KEY_4): 3,
302+ uint(gdk.KEY_KP_4): 3,
303+ uint(gdk.KEY_5): 4,
304+ uint(gdk.KEY_KP_5): 4,
305+ uint(gdk.KEY_6): 5,
306+ uint(gdk.KEY_KP_6): 5,
307+ uint(gdk.KEY_7): 6,
308+ uint(gdk.KEY_KP_7): 6,
309+ uint(gdk.KEY_8): 7,
310+ uint(gdk.KEY_KP_8): 7,
311+ uint(gdk.KEY_9): 8,
312+ uint(gdk.KEY_KP_9): 8,
313+ }
314+
315+ if idx, ok := keyMap[keyval]; ok {
316+ return idx, true
317+ }
318+ return 0, false
319+}
M internal/ui/component/find_bar.go +43 -4
@@ -39,16 +39,19 @@ type FindBar struct {
3939
4040 uc *usecase.FindInPageUseCase
4141
42- visible bool
43- mu sync.RWMutex
44- ctx context.Context
45- onClose func()
42+ visible bool
43+ mu sync.RWMutex
44+ ctx context.Context
45+ onClose func()
46+ retainedCallbacks []interface{} // Keep callbacks alive for GC
4647 }
4748
4849 // FindBarConfig holds configuration for creating a FindBar.
4950 type FindBarConfig struct {
5051 OnClose func()
5152 GetFindController func(paneID entity.PaneID) port.FindController
53+ OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
54+ OnFocusOut func() // Callback when entry loses focus
5255 }
5356
5457 // NewFindBar creates a new FindBar component.
@@ -68,6 +71,7 @@ func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar {
6871
6972 fb.setupHandlers()
7073 fb.bindUseCase()
74+ fb.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
7175
7276 log.Debug().Msg("find bar created")
7377 return fb
@@ -405,3 +409,38 @@ func (fb *FindBar) bindUseCase() {
405409 glib.IdleAdd(&cb, 0)
406410 })
407411 }
412+
413+// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
414+func (fb *FindBar) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
415+ if onFocusIn == nil && onFocusOut == nil {
416+ return
417+ }
418+
419+ if fb.entry == nil {
420+ return
421+ }
422+
423+ focusController := gtk.NewEventControllerFocus()
424+ if focusController == nil {
425+ return
426+ }
427+
428+ if onFocusIn != nil {
429+ entry := fb.entry // Capture for closure
430+ focusInCb := func(_ gtk.EventControllerFocus) {
431+ onFocusIn(entry)
432+ }
433+ fb.retainedCallbacks = append(fb.retainedCallbacks, focusInCb)
434+ focusController.ConnectEnter(&focusInCb)
435+ }
436+
437+ if onFocusOut != nil {
438+ focusOutCb := func(_ gtk.EventControllerFocus) {
439+ onFocusOut()
440+ }
441+ fb.retainedCallbacks = append(fb.retainedCallbacks, focusOutCb)
442+ focusController.ConnectLeave(&focusOutCb)
443+ }
444+
445+ fb.entry.AddController(&focusController.EventController)
446+}
@@ -39,16 +39,19 @@ type FindBar struct {
39 39
40 uc *usecase.FindInPageUseCase40 uc *usecase.FindInPageUseCase
41 41
42- visible bool42+ visible bool
43- mu sync.RWMutex43+ mu sync.RWMutex
44- ctx context.Context44+ ctx context.Context
45- onClose func()45+ onClose func()
46+ retainedCallbacks []interface{} // Keep callbacks alive for GC
46 }47 }
47 48
48 // FindBarConfig holds configuration for creating a FindBar.49 // FindBarConfig holds configuration for creating a FindBar.
49 type FindBarConfig struct {50 type FindBarConfig struct {
50 OnClose func()51 OnClose func()
51 GetFindController func(paneID entity.PaneID) port.FindController52 GetFindController func(paneID entity.PaneID) port.FindController
53+ OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
54+ OnFocusOut func() // Callback when entry loses focus
52 }55 }
53 56
54 // NewFindBar creates a new FindBar component.57 // NewFindBar creates a new FindBar component.
@@ -68,6 +71,7 @@ func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar {
68 71
69 fb.setupHandlers()72 fb.setupHandlers()
70 fb.bindUseCase()73 fb.bindUseCase()
74+ fb.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
71 75
72 log.Debug().Msg("find bar created")76 log.Debug().Msg("find bar created")
73 return fb77 return fb
@@ -405,3 +409,38 @@ func (fb *FindBar) bindUseCase() {
405 glib.IdleAdd(&cb, 0)409 glib.IdleAdd(&cb, 0)
406 })410 })
407 }411 }
412+
413+// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
414+func (fb *FindBar) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
415+ if onFocusIn == nil && onFocusOut == nil {
416+ return
417+ }
418+
419+ if fb.entry == nil {
420+ return
421+ }
422+
423+ focusController := gtk.NewEventControllerFocus()
424+ if focusController == nil {
425+ return
426+ }
427+
428+ if onFocusIn != nil {
429+ entry := fb.entry // Capture for closure
430+ focusInCb := func(_ gtk.EventControllerFocus) {
431+ onFocusIn(entry)
432+ }
433+ fb.retainedCallbacks = append(fb.retainedCallbacks, focusInCb)
434+ focusController.ConnectEnter(&focusInCb)
435+ }
436+
437+ if onFocusOut != nil {
438+ focusOutCb := func(_ gtk.EventControllerFocus) {
439+ onFocusOut()
440+ }
441+ fb.retainedCallbacks = append(fb.retainedCallbacks, focusOutCb)
442+ focusController.ConnectLeave(&focusOutCb)
443+ }
444+
445+ fb.entry.AddController(&focusController.EventController)
446+}
M internal/ui/component/omnibox.go +34 -0
@@ -132,6 +132,8 @@ type OmniboxConfig struct {
132132 UIScale float64 // UI scale for favicon sizing
133133 OnNavigate func(url string) // Callback when user navigates via omnibox
134134 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
135+ OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
136+ OnFocusOut func() // Callback when entry loses focus
135137 }
136138
137139 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -166,6 +168,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
166168
167169 o.setupKeyboardHandling()
168170 o.setupEntryChanged()
171+ o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
169172
170173 // Set navigation callback if provided
171174 if cfg.OnNavigate != nil {
@@ -459,6 +462,37 @@ func (o *Omnibox) setupEntryChanged() {
459462 o.entry.ConnectSearchChanged(&changedCb)
460463 }
461464
465+// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
466+func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
467+ if onFocusIn == nil && onFocusOut == nil {
468+ return
469+ }
470+
471+ focusController := gtk.NewEventControllerFocus()
472+ if focusController == nil {
473+ return
474+ }
475+
476+ if onFocusIn != nil {
477+ entry := o.entry // Capture for closure
478+ focusInCb := func(_ gtk.EventControllerFocus) {
479+ onFocusIn(entry)
480+ }
481+ o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
482+ focusController.ConnectEnter(&focusInCb)
483+ }
484+
485+ if onFocusOut != nil {
486+ focusOutCb := func(_ gtk.EventControllerFocus) {
487+ onFocusOut()
488+ }
489+ o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
490+ focusController.ConnectLeave(&focusOutCb)
491+ }
492+
493+ o.entry.AddController(&focusController.EventController)
494+}
495+
462496 // handleKeyPress processes keyboard events.
463497 // Returns true if the event was handled.
464498 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
@@ -132,6 +132,8 @@ type OmniboxConfig struct {
132 UIScale float64 // UI scale for favicon sizing132 UIScale float64 // UI scale for favicon sizing
133 OnNavigate func(url string) // Callback when user navigates via omnibox133 OnNavigate func(url string) // Callback when user navigates via omnibox
134 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification134 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
135+ OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker)
136+ OnFocusOut func() // Callback when entry loses focus
135 }137 }
136 138
137 // NewOmnibox creates a new native GTK4 omnibox widget.139 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -166,6 +168,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
166 168
167 o.setupKeyboardHandling()169 o.setupKeyboardHandling()
168 o.setupEntryChanged()170 o.setupEntryChanged()
171+ o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
169 172
170 // Set navigation callback if provided173 // Set navigation callback if provided
171 if cfg.OnNavigate != nil {174 if cfg.OnNavigate != nil {
@@ -459,6 +462,37 @@ func (o *Omnibox) setupEntryChanged() {
459 o.entry.ConnectSearchChanged(&changedCb)462 o.entry.ConnectSearchChanged(&changedCb)
460 }463 }
461 464
465+// setupFocusCallbacks wires focus in/out callbacks for accent picker integration.
466+func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) {
467+ if onFocusIn == nil && onFocusOut == nil {
468+ return
469+ }
470+
471+ focusController := gtk.NewEventControllerFocus()
472+ if focusController == nil {
473+ return
474+ }
475+
476+ if onFocusIn != nil {
477+ entry := o.entry // Capture for closure
478+ focusInCb := func(_ gtk.EventControllerFocus) {
479+ onFocusIn(entry)
480+ }
481+ o.retainedCallbacks = append(o.retainedCallbacks, focusInCb)
482+ focusController.ConnectEnter(&focusInCb)
483+ }
484+
485+ if onFocusOut != nil {
486+ focusOutCb := func(_ gtk.EventControllerFocus) {
487+ onFocusOut()
488+ }
489+ o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb)
490+ focusController.ConnectLeave(&focusOutCb)
491+ }
492+
493+ o.entry.AddController(&focusController.EventController)
494+}
495+
462 // handleKeyPress processes keyboard events.496 // handleKeyPress processes keyboard events.
463 // Returns true if the event was handled.497 // Returns true if the event was handled.
464 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {498 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
M internal/ui/coordinator/content.go +15 -7
@@ -61,13 +61,13 @@ type ContentCoordinator struct {
6161 revealMu sync.Mutex
6262 pendingReveal map[entity.PaneID]bool
6363
64- appearanceMu sync.Mutex
65- pendingScriptRefresh map[entity.PaneID]bool
66- pendingThemePanes map[entity.PaneID]bool
67- pendingThemeUpdate pendingThemeUpdate
68- hasPendingThemeUpdate bool
69- currentTheme pendingThemeUpdate
70- hasCurrentTheme bool
64+ appearanceMu sync.Mutex
65+ pendingScriptRefresh map[entity.PaneID]bool
66+ pendingThemePanes map[entity.PaneID]bool
67+ pendingThemeUpdate pendingThemeUpdate
68+ hasPendingThemeUpdate bool
69+ currentTheme pendingThemeUpdate
70+ hasCurrentTheme bool
7171
7272 // Gesture action handler for mouse button navigation
7373 gestureActionHandler input.ActionHandler
@@ -92,6 +92,9 @@ type ContentCoordinator struct {
9292
9393 // Callback when fullscreen state changes (for hiding/showing tab bar)
9494 onFullscreenChanged func(entering bool)
95+
96+ // Callback when WebView gains focus (for accent picker text input targeting)
97+ onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
9598 }
9699
97100 type pendingThemeUpdate struct {
@@ -165,6 +168,11 @@ func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
165168 c.onFullscreenChanged = fn
166169 }
167170
171+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
172+func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
173+ c.onWebViewFocused = fn
174+}
175+
168176 // EnsureWebView acquires or reuses a WebView for the given pane.
169177 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
170178 log := logging.FromContext(ctx)
@@ -61,13 +61,13 @@ type ContentCoordinator struct {
61 revealMu sync.Mutex61 revealMu sync.Mutex
62 pendingReveal map[entity.PaneID]bool62 pendingReveal map[entity.PaneID]bool
63 63
64- appearanceMu sync.Mutex64+ appearanceMu sync.Mutex
65- pendingScriptRefresh map[entity.PaneID]bool65+ pendingScriptRefresh map[entity.PaneID]bool
66- pendingThemePanes map[entity.PaneID]bool66+ pendingThemePanes map[entity.PaneID]bool
67- pendingThemeUpdate pendingThemeUpdate67+ pendingThemeUpdate pendingThemeUpdate
68- hasPendingThemeUpdate bool68+ hasPendingThemeUpdate bool
69- currentTheme pendingThemeUpdate69+ currentTheme pendingThemeUpdate
70- hasCurrentTheme bool70+ hasCurrentTheme bool
71 71
72 // Gesture action handler for mouse button navigation72 // Gesture action handler for mouse button navigation
73 gestureActionHandler input.ActionHandler73 gestureActionHandler input.ActionHandler
@@ -92,6 +92,9 @@ type ContentCoordinator struct {
92 92
93 // Callback when fullscreen state changes (for hiding/showing tab bar)93 // Callback when fullscreen state changes (for hiding/showing tab bar)
94 onFullscreenChanged func(entering bool)94 onFullscreenChanged func(entering bool)
95+
96+ // Callback when WebView gains focus (for accent picker text input targeting)
97+ onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
95 }98 }
96 99
97 type pendingThemeUpdate struct {100 type pendingThemeUpdate struct {
@@ -165,6 +168,11 @@ func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
165 c.onFullscreenChanged = fn168 c.onFullscreenChanged = fn
166 }169 }
167 170
171+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
172+func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
173+ c.onWebViewFocused = fn
174+}
175+
168 // EnsureWebView acquires or reuses a WebView for the given pane.176 // EnsureWebView acquires or reuses a WebView for the given pane.
169 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {177 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
170 log := logging.FromContext(ctx)178 log := logging.FromContext(ctx)
M internal/ui/deps.go +3 -0
@@ -60,6 +60,9 @@ type Dependencies struct {
6060 FilterManager *filtering.Manager
6161 IdleInhibitor port.IdleInhibitor
6262
63+ // Accent picker for dead keys support
64+ InsertAccentUC *usecase.InsertAccentUseCase
65+
6366 // Session management
6467 SessionStateRepo repository.SessionStateRepository
6568 SessionRepo repository.SessionRepository
@@ -60,6 +60,9 @@ type Dependencies struct {
60 FilterManager *filtering.Manager60 FilterManager *filtering.Manager
61 IdleInhibitor port.IdleInhibitor61 IdleInhibitor port.IdleInhibitor
62 62
63+ // Accent picker for dead keys support
64+ InsertAccentUC *usecase.InsertAccentUseCase
65+
63 // Session management66 // Session management
64 SessionStateRepo repository.SessionStateRepository67 SessionStateRepo repository.SessionStateRepository
65 SessionRepo repository.SessionRepository68 SessionRepo repository.SessionRepository
M internal/ui/input/keyboard.go +108 -26
@@ -17,6 +17,20 @@ import (
1717 // Return an error if the action fails.
1818 type ActionHandler func(ctx context.Context, action Action) error
1919
20+// AccentHandler handles long-press accent detection.
21+// Called for character keys that may have accent variants.
22+type AccentHandler interface {
23+ // OnKeyPressed is called when a key is pressed.
24+ // Returns true if the accent handler is handling this key (picker visible or will show).
25+ OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
26+ // OnKeyReleased is called when a key is released.
27+ OnKeyReleased(ctx context.Context, char rune)
28+ // IsPickerVisible returns true if the accent picker is currently visible.
29+ IsPickerVisible() bool
30+ // Cancel cancels any pending long-press detection.
31+ Cancel(ctx context.Context)
32+}
33+
2034 // KeyboardHandler processes keyboard events and dispatches actions.
2135 // It manages modal input modes and routes key events to the appropriate handlers.
2236 type KeyboardHandler struct {
@@ -28,12 +42,15 @@ type KeyboardHandler struct {
2842 onAction ActionHandler
2943 // Optional bypass check (e.g., omnibox visible)
3044 shouldBypass func() bool
45+ // Optional accent handler for dead keys support
46+ accentHandler AccentHandler
3147
3248 // GTK controller (nil until attached)
3349 controller *gtk.EventControllerKey
3450
3551 // Callback retention: must stay reachable by Go GC.
36- keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
52+ keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
53+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
3754
3855 ctx context.Context
3956 mu sync.RWMutex
@@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
7592 h.shouldBypass = fn
7693 }
7794
95+// SetAccentHandler sets the handler for long-press accent detection.
96+func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
97+ h.mu.Lock()
98+ defer h.mu.Unlock()
99+ h.accentHandler = handler
100+}
101+
78102 // Mode returns the current input mode.
79103 func (h *KeyboardHandler) Mode() Mode {
80104 return h.modal.Mode()
@@ -106,6 +130,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
106130 }
107131 h.controller.ConnectKeyPressed(&h.keyPressedCb)
108132
133+ // Connect key released handler for accent detection.
134+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
135+ h.handleKeyRelease(keyval)
136+ }
137+ h.controller.ConnectKeyReleased(&h.keyReleasedCb)
138+
109139 // Add controller to window
110140 window.AddController(&h.controller.EventController)
111141
@@ -118,6 +148,7 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
118148 func (h *KeyboardHandler) Detach() {
119149 h.controller = nil
120150 h.keyPressedCb = nil
151+ h.keyReleasedCb = nil
121152 }
122153
123154 // handleKeyPress processes a key press event.
@@ -131,45 +162,71 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
131162
132163 h.mu.RLock()
133164 shouldBypass := h.shouldBypass
165+ accentHandler := h.accentHandler
134166 h.mu.RUnlock()
135- if shouldBypass != nil && shouldBypass() {
136- log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
137- return false
167+
168+ // Check if accent picker is visible - it takes priority
169+ if accentHandler != nil && accentHandler.IsPickerVisible() {
170+ return false // Let the accent picker handle the key via its own controller
138171 }
139172
140- // Build KeyBinding from event
141173 modifiers := Modifier(state) & modifierMask
142174
143- // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144- // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145- // keyvals and rely on ModShift to represent Shift.
146- if keyval >= uint('A') && keyval <= uint('Z') {
147- keyval += (uint('a') - uint('A'))
175+ // Check accent detection first - works in both normal and bypass mode
176+ // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView
177+ if h.tryAccentDetection(accentHandler, keyval, modifiers) {
178+ return true
148179 }
149180
150- binding := KeyBinding{
151- Keyval: keyval,
152- Modifiers: modifiers,
181+ if shouldBypass != nil && shouldBypass() {
182+ log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
183+ return false
153184 }
154185
155- mode := h.modal.Mode()
186+ // Normalize uppercase letters for consistent binding lookup
187+ keyval = normalizeKeyval(keyval)
156188
189+ binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
190+ mode := h.modal.Mode()
157191 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
158192
159193 if !found {
160- if mode != ModeNormal {
161- // In modal mode, consume unrecognized keys to prevent WebView handling
162- return true
163- }
164- // In normal mode, let the key pass through to WebView
194+ return mode != ModeNormal // Consume unrecognized keys in modal mode
195+ }
196+
197+ return h.dispatchAction(action, mode)
198+}
199+
200+// tryAccentDetection starts long-press detection for accent-eligible keys.
201+// Returns true if the key should be suppressed (blocked from reaching the WebView).
202+func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
203+ if h.modal.Mode() != ModeNormal || accentHandler == nil {
204+ return false
205+ }
206+ // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
207+ if modifiers != 0 && modifiers != ModShift {
165208 return false
166209 }
210+ if char := keyvalToRune(keyval); char != 0 {
211+ return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
212+ }
213+ return false
214+}
215+
216+// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
217+func normalizeKeyval(keyval uint) uint {
218+ if keyval >= uint('A') && keyval <= uint('Z') {
219+ return keyval + (uint('a') - uint('A'))
220+ }
221+ return keyval
222+}
167223
224+// dispatchAction dispatches the action and handles mode-related logic.
225+func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
168226 if h.handleModeAction(action) {
169227 return true
170228 }
171229
172- // Dispatch action to handler
173230 h.mu.RLock()
174231 handler := h.onAction
175232 h.mu.RUnlock()
@@ -177,19 +234,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
177234 if handler != nil {
178235 if err := handler(h.ctx, action); err != nil {
179236 log := logging.FromContext(h.ctx)
180- log.Error().
181- Err(err).
182- Str("action", string(action)).
183- Msg("action handler error")
237+ log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
184238 }
185239 }
186240
187- // In resize mode, any resize keypress should extend the timeout.
188241 if mode == ModeResize && isResizeAction(action) {
189242 h.modal.ResetTimeout(h.ctx)
190243 }
191244
192- // Auto-exit modal mode after certain actions
193245 if mode != ModeNormal && ShouldAutoExitMode(action) {
194246 h.modal.ExitMode(h.ctx)
195247 }
@@ -314,3 +366,33 @@ func (h *KeyboardHandler) EnterSessionMode() {
314366 func (h *KeyboardHandler) ExitMode() {
315367 h.modal.ExitMode(h.ctx)
316368 }
369+
370+// handleKeyRelease processes a key release event for accent detection.
371+func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
372+ h.mu.RLock()
373+ accentHandler := h.accentHandler
374+ h.mu.RUnlock()
375+
376+ if accentHandler == nil {
377+ return
378+ }
379+
380+ // Convert keyval to rune for the accent handler
381+ if char := keyvalToRune(keyval); char != 0 {
382+ accentHandler.OnKeyReleased(h.ctx, char)
383+ }
384+}
385+
386+// keyvalToRune converts a GTK keyval to a lowercase rune.
387+// Returns 0 if the keyval is not a printable character with potential accents.
388+func keyvalToRune(keyval uint) rune {
389+ // Handle lowercase a-z
390+ if keyval >= uint('a') && keyval <= uint('z') {
391+ return rune(keyval)
392+ }
393+ // Handle uppercase A-Z (convert to lowercase for accent lookup)
394+ if keyval >= uint('A') && keyval <= uint('Z') {
395+ return rune(keyval + ('a' - 'A'))
396+ }
397+ return 0
398+}
@@ -17,6 +17,20 @@ import (
17 // Return an error if the action fails.17 // Return an error if the action fails.
18 type ActionHandler func(ctx context.Context, action Action) error18 type ActionHandler func(ctx context.Context, action Action) error
19 19
20+// AccentHandler handles long-press accent detection.
21+// Called for character keys that may have accent variants.
22+type AccentHandler interface {
23+ // OnKeyPressed is called when a key is pressed.
24+ // Returns true if the accent handler is handling this key (picker visible or will show).
25+ OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
26+ // OnKeyReleased is called when a key is released.
27+ OnKeyReleased(ctx context.Context, char rune)
28+ // IsPickerVisible returns true if the accent picker is currently visible.
29+ IsPickerVisible() bool
30+ // Cancel cancels any pending long-press detection.
31+ Cancel(ctx context.Context)
32+}
33+
20 // KeyboardHandler processes keyboard events and dispatches actions.34 // KeyboardHandler processes keyboard events and dispatches actions.
21 // It manages modal input modes and routes key events to the appropriate handlers.35 // It manages modal input modes and routes key events to the appropriate handlers.
22 type KeyboardHandler struct {36 type KeyboardHandler struct {
@@ -28,12 +42,15 @@ type KeyboardHandler struct {
28 onAction ActionHandler42 onAction ActionHandler
29 // Optional bypass check (e.g., omnibox visible)43 // Optional bypass check (e.g., omnibox visible)
30 shouldBypass func() bool44 shouldBypass func() bool
45+ // Optional accent handler for dead keys support
46+ accentHandler AccentHandler
31 47
32 // GTK controller (nil until attached)48 // GTK controller (nil until attached)
33 controller *gtk.EventControllerKey49 controller *gtk.EventControllerKey
34 50
35 // Callback retention: must stay reachable by Go GC.51 // Callback retention: must stay reachable by Go GC.
36- keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool52+ keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
53+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
37 54
38 ctx context.Context55 ctx context.Context
39 mu sync.RWMutex56 mu sync.RWMutex
@@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
75 h.shouldBypass = fn92 h.shouldBypass = fn
76 }93 }
77 94
95+// SetAccentHandler sets the handler for long-press accent detection.
96+func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
97+ h.mu.Lock()
98+ defer h.mu.Unlock()
99+ h.accentHandler = handler
100+}
101+
78 // Mode returns the current input mode.102 // Mode returns the current input mode.
79 func (h *KeyboardHandler) Mode() Mode {103 func (h *KeyboardHandler) Mode() Mode {
80 return h.modal.Mode()104 return h.modal.Mode()
@@ -106,6 +130,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
106 }130 }
107 h.controller.ConnectKeyPressed(&h.keyPressedCb)131 h.controller.ConnectKeyPressed(&h.keyPressedCb)
108 132
133+ // Connect key released handler for accent detection.
134+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
135+ h.handleKeyRelease(keyval)
136+ }
137+ h.controller.ConnectKeyReleased(&h.keyReleasedCb)
138+
109 // Add controller to window139 // Add controller to window
110 window.AddController(&h.controller.EventController)140 window.AddController(&h.controller.EventController)
111 141
@@ -118,6 +148,7 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
118 func (h *KeyboardHandler) Detach() {148 func (h *KeyboardHandler) Detach() {
119 h.controller = nil149 h.controller = nil
120 h.keyPressedCb = nil150 h.keyPressedCb = nil
151+ h.keyReleasedCb = nil
121 }152 }
122 153
123 // handleKeyPress processes a key press event.154 // handleKeyPress processes a key press event.
@@ -131,45 +162,71 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
131 162
132 h.mu.RLock()163 h.mu.RLock()
133 shouldBypass := h.shouldBypass164 shouldBypass := h.shouldBypass
165+ accentHandler := h.accentHandler
134 h.mu.RUnlock()166 h.mu.RUnlock()
135- if shouldBypass != nil && shouldBypass() {167+
136- log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")168+ // Check if accent picker is visible - it takes priority
137- return false169+ if accentHandler != nil && accentHandler.IsPickerVisible() {
170+ return false // Let the accent picker handle the key via its own controller
138 }171 }
139 172
140- // Build KeyBinding from event
141 modifiers := Modifier(state) & modifierMask173 modifiers := Modifier(state) & modifierMask
142 174
143- // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).175+ // Check accent detection first - works in both normal and bypass mode
144- // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase176+ // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView
145- // keyvals and rely on ModShift to represent Shift.177+ if h.tryAccentDetection(accentHandler, keyval, modifiers) {
146- if keyval >= uint('A') && keyval <= uint('Z') {178+ return true
147- keyval += (uint('a') - uint('A'))
148 }179 }
149 180
150- binding := KeyBinding{181+ if shouldBypass != nil && shouldBypass() {
151- Keyval: keyval,182+ log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
152- Modifiers: modifiers,183+ return false
153 }184 }
154 185
155- mode := h.modal.Mode()186+ // Normalize uppercase letters for consistent binding lookup
187+ keyval = normalizeKeyval(keyval)
156 188
189+ binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
190+ mode := h.modal.Mode()
157 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)191 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
158 192
159 if !found {193 if !found {
160- if mode != ModeNormal {194+ return mode != ModeNormal // Consume unrecognized keys in modal mode
161- // In modal mode, consume unrecognized keys to prevent WebView handling195+ }
162- return true196+
163- }197+ return h.dispatchAction(action, mode)
164- // In normal mode, let the key pass through to WebView198+}
199+
200+// tryAccentDetection starts long-press detection for accent-eligible keys.
201+// Returns true if the key should be suppressed (blocked from reaching the WebView).
202+func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
203+ if h.modal.Mode() != ModeNormal || accentHandler == nil {
204+ return false
205+ }
206+ // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
207+ if modifiers != 0 && modifiers != ModShift {
165 return false208 return false
166 }209 }
210+ if char := keyvalToRune(keyval); char != 0 {
211+ return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
212+ }
213+ return false
214+}
215+
216+// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
217+func normalizeKeyval(keyval uint) uint {
218+ if keyval >= uint('A') && keyval <= uint('Z') {
219+ return keyval + (uint('a') - uint('A'))
220+ }
221+ return keyval
222+}
167 223
224+// dispatchAction dispatches the action and handles mode-related logic.
225+func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
168 if h.handleModeAction(action) {226 if h.handleModeAction(action) {
169 return true227 return true
170 }228 }
171 229
172- // Dispatch action to handler
173 h.mu.RLock()230 h.mu.RLock()
174 handler := h.onAction231 handler := h.onAction
175 h.mu.RUnlock()232 h.mu.RUnlock()
@@ -177,19 +234,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
177 if handler != nil {234 if handler != nil {
178 if err := handler(h.ctx, action); err != nil {235 if err := handler(h.ctx, action); err != nil {
179 log := logging.FromContext(h.ctx)236 log := logging.FromContext(h.ctx)
180- log.Error().237+ log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
181- Err(err).
182- Str("action", string(action)).
183- Msg("action handler error")
184 }238 }
185 }239 }
186 240
187- // In resize mode, any resize keypress should extend the timeout.
188 if mode == ModeResize && isResizeAction(action) {241 if mode == ModeResize && isResizeAction(action) {
189 h.modal.ResetTimeout(h.ctx)242 h.modal.ResetTimeout(h.ctx)
190 }243 }
191 244
192- // Auto-exit modal mode after certain actions
193 if mode != ModeNormal && ShouldAutoExitMode(action) {245 if mode != ModeNormal && ShouldAutoExitMode(action) {
194 h.modal.ExitMode(h.ctx)246 h.modal.ExitMode(h.ctx)
195 }247 }
@@ -314,3 +366,33 @@ func (h *KeyboardHandler) EnterSessionMode() {
314 func (h *KeyboardHandler) ExitMode() {366 func (h *KeyboardHandler) ExitMode() {
315 h.modal.ExitMode(h.ctx)367 h.modal.ExitMode(h.ctx)
316 }368 }
369+
370+// handleKeyRelease processes a key release event for accent detection.
371+func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
372+ h.mu.RLock()
373+ accentHandler := h.accentHandler
374+ h.mu.RUnlock()
375+
376+ if accentHandler == nil {
377+ return
378+ }
379+
380+ // Convert keyval to rune for the accent handler
381+ if char := keyvalToRune(keyval); char != 0 {
382+ accentHandler.OnKeyReleased(h.ctx, char)
383+ }
384+}
385+
386+// keyvalToRune converts a GTK keyval to a lowercase rune.
387+// Returns 0 if the keyval is not a printable character with potential accents.
388+func keyvalToRune(keyval uint) rune {
389+ // Handle lowercase a-z
390+ if keyval >= uint('a') && keyval <= uint('z') {
391+ return rune(keyval)
392+ }
393+ // Handle uppercase A-Z (convert to lowercase for accent lookup)
394+ if keyval >= uint('A') && keyval <= uint('Z') {
395+ return rune(keyval + ('a' - 'A'))
396+ }
397+ return 0
398+}
M internal/ui/layout/gtk_widgets.go +9 -0
@@ -34,6 +34,7 @@ func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible()
3434 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
3535 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
3636 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
37+func (w *gtkWidget) SetFocusable(focusable bool) { w.inner.SetFocusable(focusable) }
3738 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }
3839 func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) }
3940 func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) }
@@ -89,6 +90,7 @@ func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible()
8990 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
9091 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
9192 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
93+func (p *gtkPaned) SetFocusable(focusable bool) { p.inner.SetFocusable(focusable) }
9294 func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
9395 func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
9496 func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
@@ -210,6 +212,7 @@ func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }
210212 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
211213 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
212214 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
215+func (b *gtkBox) SetFocusable(focusable bool) { b.inner.SetFocusable(focusable) }
213216 func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) }
214217 func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) }
215218 func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) }
@@ -315,6 +318,7 @@ func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible(
315318 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }
316319 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
317320 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
321+func (o *gtkOverlay) SetFocusable(focusable bool) { o.inner.SetFocusable(focusable) }
318322 func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) }
319323 func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) }
320324 func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) }
@@ -428,6 +432,7 @@ func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible()
428432 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
429433 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
430434 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
435+func (l *gtkLabel) SetFocusable(focusable bool) { l.inner.SetFocusable(focusable) }
431436 func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) }
432437 func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) }
433438 func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) }
@@ -490,6 +495,7 @@ func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisib
490495 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }
491496 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
492497 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
498+func (btn *gtkButton) SetFocusable(focusable bool) { btn.inner.SetFocusable(focusable) }
493499 func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) }
494500 func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) }
495501 func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) }
@@ -571,6 +577,7 @@ func (img *gtkImage) IsVisible() bool { return img.inner.GetVisibl
571577 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }
572578 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
573579 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
580+func (img *gtkImage) SetFocusable(focusable bool) { img.inner.SetFocusable(focusable) }
574581 func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) }
575582 func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) }
576583 func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) }
@@ -642,6 +649,7 @@ func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisi
642649 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }
643650 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }
644651 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
652+func (p *gtkProgressBar) SetFocusable(focusable bool) { p.inner.SetFocusable(focusable) }
645653 func (p *gtkProgressBar) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
646654 func (p *gtkProgressBar) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
647655 func (p *gtkProgressBar) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
@@ -700,6 +708,7 @@ func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible(
700708 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
701709 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
702710 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
711+func (s *gtkSpinner) SetFocusable(focusable bool) { s.inner.SetFocusable(focusable) }
703712 func (s *gtkSpinner) SetFocusOnClick(focus bool) { s.inner.SetFocusOnClick(focus) }
704713 func (s *gtkSpinner) SetCanTarget(canTarget bool) { s.inner.SetCanTarget(canTarget) }
705714 func (s *gtkSpinner) SetHexpand(expand bool) { s.inner.SetHexpand(expand) }
@@ -34,6 +34,7 @@ func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible()
34 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }34 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
35 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }35 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
36 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }36 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
37+func (w *gtkWidget) SetFocusable(focusable bool) { w.inner.SetFocusable(focusable) }
37 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }38 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }
38 func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) }39 func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) }
39 func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) }40 func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) }
@@ -89,6 +90,7 @@ func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible()
89 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }90 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
90 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }91 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
91 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }92 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
93+func (p *gtkPaned) SetFocusable(focusable bool) { p.inner.SetFocusable(focusable) }
92 func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }94 func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
93 func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }95 func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
94 func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }96 func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
@@ -210,6 +212,7 @@ func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }
210 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }212 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
211 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }213 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
212 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }214 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
215+func (b *gtkBox) SetFocusable(focusable bool) { b.inner.SetFocusable(focusable) }
213 func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) }216 func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) }
214 func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) }217 func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) }
215 func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) }218 func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) }
@@ -315,6 +318,7 @@ func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible(
315 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }318 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }
316 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }319 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
317 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }320 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
321+func (o *gtkOverlay) SetFocusable(focusable bool) { o.inner.SetFocusable(focusable) }
318 func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) }322 func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) }
319 func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) }323 func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) }
320 func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) }324 func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) }
@@ -428,6 +432,7 @@ func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible()
428 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }432 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
429 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }433 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
430 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }434 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
435+func (l *gtkLabel) SetFocusable(focusable bool) { l.inner.SetFocusable(focusable) }
431 func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) }436 func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) }
432 func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) }437 func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) }
433 func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) }438 func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) }
@@ -490,6 +495,7 @@ func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisib
490 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }495 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }
491 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }496 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
492 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }497 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
498+func (btn *gtkButton) SetFocusable(focusable bool) { btn.inner.SetFocusable(focusable) }
493 func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) }499 func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) }
494 func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) }500 func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) }
495 func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) }501 func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) }
@@ -571,6 +577,7 @@ func (img *gtkImage) IsVisible() bool { return img.inner.GetVisibl
571 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }577 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }
572 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }578 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
573 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }579 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
580+func (img *gtkImage) SetFocusable(focusable bool) { img.inner.SetFocusable(focusable) }
574 func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) }581 func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) }
575 func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) }582 func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) }
576 func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) }583 func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) }
@@ -642,6 +649,7 @@ func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisi
642 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }649 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }
643 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }650 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }
644 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }651 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
652+func (p *gtkProgressBar) SetFocusable(focusable bool) { p.inner.SetFocusable(focusable) }
645 func (p *gtkProgressBar) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }653 func (p *gtkProgressBar) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
646 func (p *gtkProgressBar) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }654 func (p *gtkProgressBar) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
647 func (p *gtkProgressBar) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }655 func (p *gtkProgressBar) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
@@ -700,6 +708,7 @@ func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible(
700 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }708 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
701 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }709 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
702 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }710 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
711+func (s *gtkSpinner) SetFocusable(focusable bool) { s.inner.SetFocusable(focusable) }
703 func (s *gtkSpinner) SetFocusOnClick(focus bool) { s.inner.SetFocusOnClick(focus) }712 func (s *gtkSpinner) SetFocusOnClick(focus bool) { s.inner.SetFocusOnClick(focus) }
704 func (s *gtkSpinner) SetCanTarget(canTarget bool) { s.inner.SetCanTarget(canTarget) }713 func (s *gtkSpinner) SetCanTarget(canTarget bool) { s.inner.SetCanTarget(canTarget) }
705 func (s *gtkSpinner) SetHexpand(expand bool) { s.inner.SetHexpand(expand) }714 func (s *gtkSpinner) SetHexpand(expand bool) { s.inner.SetHexpand(expand) }
M internal/ui/layout/mocks/mocks.go +360 -0
@@ -908,6 +908,46 @@ func (_c *MockWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick bo
908908 return _c
909909 }
910910
911+// SetFocusable provides a mock function for the type MockWidget
912+func (_mock *MockWidget) SetFocusable(focusable bool) {
913+ _mock.Called(focusable)
914+ return
915+}
916+
917+// MockWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
918+type MockWidget_SetFocusable_Call struct {
919+ *mock.Call
920+}
921+
922+// SetFocusable is a helper method to define mock.On call
923+// - focusable bool
924+func (_e *MockWidget_Expecter) SetFocusable(focusable interface{}) *MockWidget_SetFocusable_Call {
925+ return &MockWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
926+}
927+
928+func (_c *MockWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockWidget_SetFocusable_Call {
929+ _c.Call.Run(func(args mock.Arguments) {
930+ var arg0 bool
931+ if args[0] != nil {
932+ arg0 = args[0].(bool)
933+ }
934+ run(
935+ arg0,
936+ )
937+ })
938+ return _c
939+}
940+
941+func (_c *MockWidget_SetFocusable_Call) Return() *MockWidget_SetFocusable_Call {
942+ _c.Call.Return()
943+ return _c
944+}
945+
946+func (_c *MockWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockWidget_SetFocusable_Call {
947+ _c.Run(run)
948+ return _c
949+}
950+
911951 // SetHalign provides a mock function for the type MockWidget
912952 func (_mock *MockWidget) SetHalign(align gtk.Align) {
913953 _mock.Called(align)
@@ -2586,6 +2626,46 @@ func (_c *MockPanedWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
25862626 return _c
25872627 }
25882628
2629+// SetFocusable provides a mock function for the type MockPanedWidget
2630+func (_mock *MockPanedWidget) SetFocusable(focusable bool) {
2631+ _mock.Called(focusable)
2632+ return
2633+}
2634+
2635+// MockPanedWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
2636+type MockPanedWidget_SetFocusable_Call struct {
2637+ *mock.Call
2638+}
2639+
2640+// SetFocusable is a helper method to define mock.On call
2641+// - focusable bool
2642+func (_e *MockPanedWidget_Expecter) SetFocusable(focusable interface{}) *MockPanedWidget_SetFocusable_Call {
2643+ return &MockPanedWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
2644+}
2645+
2646+func (_c *MockPanedWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockPanedWidget_SetFocusable_Call {
2647+ _c.Call.Run(func(args mock.Arguments) {
2648+ var arg0 bool
2649+ if args[0] != nil {
2650+ arg0 = args[0].(bool)
2651+ }
2652+ run(
2653+ arg0,
2654+ )
2655+ })
2656+ return _c
2657+}
2658+
2659+func (_c *MockPanedWidget_SetFocusable_Call) Return() *MockPanedWidget_SetFocusable_Call {
2660+ _c.Call.Return()
2661+ return _c
2662+}
2663+
2664+func (_c *MockPanedWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockPanedWidget_SetFocusable_Call {
2665+ _c.Run(run)
2666+ return _c
2667+}
2668+
25892669 // SetHalign provides a mock function for the type MockPanedWidget
25902670 func (_mock *MockPanedWidget) SetHalign(align gtk.Align) {
25912671 _mock.Called(align)
@@ -4339,6 +4419,46 @@ func (_c *MockBoxWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick
43394419 return _c
43404420 }
43414421
4422+// SetFocusable provides a mock function for the type MockBoxWidget
4423+func (_mock *MockBoxWidget) SetFocusable(focusable bool) {
4424+ _mock.Called(focusable)
4425+ return
4426+}
4427+
4428+// MockBoxWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
4429+type MockBoxWidget_SetFocusable_Call struct {
4430+ *mock.Call
4431+}
4432+
4433+// SetFocusable is a helper method to define mock.On call
4434+// - focusable bool
4435+func (_e *MockBoxWidget_Expecter) SetFocusable(focusable interface{}) *MockBoxWidget_SetFocusable_Call {
4436+ return &MockBoxWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
4437+}
4438+
4439+func (_c *MockBoxWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockBoxWidget_SetFocusable_Call {
4440+ _c.Call.Run(func(args mock.Arguments) {
4441+ var arg0 bool
4442+ if args[0] != nil {
4443+ arg0 = args[0].(bool)
4444+ }
4445+ run(
4446+ arg0,
4447+ )
4448+ })
4449+ return _c
4450+}
4451+
4452+func (_c *MockBoxWidget_SetFocusable_Call) Return() *MockBoxWidget_SetFocusable_Call {
4453+ _c.Call.Return()
4454+ return _c
4455+}
4456+
4457+func (_c *MockBoxWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockBoxWidget_SetFocusable_Call {
4458+ _c.Run(run)
4459+ return _c
4460+}
4461+
43424462 // SetHalign provides a mock function for the type MockBoxWidget
43434463 func (_mock *MockBoxWidget) SetHalign(align gtk.Align) {
43444464 _mock.Called(align)
@@ -5902,6 +6022,46 @@ func (_c *MockOverlayWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnC
59026022 return _c
59036023 }
59046024
6025+// SetFocusable provides a mock function for the type MockOverlayWidget
6026+func (_mock *MockOverlayWidget) SetFocusable(focusable bool) {
6027+ _mock.Called(focusable)
6028+ return
6029+}
6030+
6031+// MockOverlayWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
6032+type MockOverlayWidget_SetFocusable_Call struct {
6033+ *mock.Call
6034+}
6035+
6036+// SetFocusable is a helper method to define mock.On call
6037+// - focusable bool
6038+func (_e *MockOverlayWidget_Expecter) SetFocusable(focusable interface{}) *MockOverlayWidget_SetFocusable_Call {
6039+ return &MockOverlayWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
6040+}
6041+
6042+func (_c *MockOverlayWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockOverlayWidget_SetFocusable_Call {
6043+ _c.Call.Run(func(args mock.Arguments) {
6044+ var arg0 bool
6045+ if args[0] != nil {
6046+ arg0 = args[0].(bool)
6047+ }
6048+ run(
6049+ arg0,
6050+ )
6051+ })
6052+ return _c
6053+}
6054+
6055+func (_c *MockOverlayWidget_SetFocusable_Call) Return() *MockOverlayWidget_SetFocusable_Call {
6056+ _c.Call.Return()
6057+ return _c
6058+}
6059+
6060+func (_c *MockOverlayWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockOverlayWidget_SetFocusable_Call {
6061+ _c.Run(run)
6062+ return _c
6063+}
6064+
59056065 // SetHalign provides a mock function for the type MockOverlayWidget
59066066 func (_mock *MockOverlayWidget) SetHalign(align gtk.Align) {
59076067 _mock.Called(align)
@@ -7161,6 +7321,46 @@ func (_c *MockLabelWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
71617321 return _c
71627322 }
71637323
7324+// SetFocusable provides a mock function for the type MockLabelWidget
7325+func (_mock *MockLabelWidget) SetFocusable(focusable bool) {
7326+ _mock.Called(focusable)
7327+ return
7328+}
7329+
7330+// MockLabelWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
7331+type MockLabelWidget_SetFocusable_Call struct {
7332+ *mock.Call
7333+}
7334+
7335+// SetFocusable is a helper method to define mock.On call
7336+// - focusable bool
7337+func (_e *MockLabelWidget_Expecter) SetFocusable(focusable interface{}) *MockLabelWidget_SetFocusable_Call {
7338+ return &MockLabelWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
7339+}
7340+
7341+func (_c *MockLabelWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockLabelWidget_SetFocusable_Call {
7342+ _c.Call.Run(func(args mock.Arguments) {
7343+ var arg0 bool
7344+ if args[0] != nil {
7345+ arg0 = args[0].(bool)
7346+ }
7347+ run(
7348+ arg0,
7349+ )
7350+ })
7351+ return _c
7352+}
7353+
7354+func (_c *MockLabelWidget_SetFocusable_Call) Return() *MockLabelWidget_SetFocusable_Call {
7355+ _c.Call.Return()
7356+ return _c
7357+}
7358+
7359+func (_c *MockLabelWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockLabelWidget_SetFocusable_Call {
7360+ _c.Run(run)
7361+ return _c
7362+}
7363+
71647364 // SetHalign provides a mock function for the type MockLabelWidget
71657365 func (_mock *MockLabelWidget) SetHalign(align gtk.Align) {
71667366 _mock.Called(align)
@@ -8631,6 +8831,46 @@ func (_c *MockButtonWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCl
86318831 return _c
86328832 }
86338833
8834+// SetFocusable provides a mock function for the type MockButtonWidget
8835+func (_mock *MockButtonWidget) SetFocusable(focusable bool) {
8836+ _mock.Called(focusable)
8837+ return
8838+}
8839+
8840+// MockButtonWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
8841+type MockButtonWidget_SetFocusable_Call struct {
8842+ *mock.Call
8843+}
8844+
8845+// SetFocusable is a helper method to define mock.On call
8846+// - focusable bool
8847+func (_e *MockButtonWidget_Expecter) SetFocusable(focusable interface{}) *MockButtonWidget_SetFocusable_Call {
8848+ return &MockButtonWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
8849+}
8850+
8851+func (_c *MockButtonWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockButtonWidget_SetFocusable_Call {
8852+ _c.Call.Run(func(args mock.Arguments) {
8853+ var arg0 bool
8854+ if args[0] != nil {
8855+ arg0 = args[0].(bool)
8856+ }
8857+ run(
8858+ arg0,
8859+ )
8860+ })
8861+ return _c
8862+}
8863+
8864+func (_c *MockButtonWidget_SetFocusable_Call) Return() *MockButtonWidget_SetFocusable_Call {
8865+ _c.Call.Return()
8866+ return _c
8867+}
8868+
8869+func (_c *MockButtonWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockButtonWidget_SetFocusable_Call {
8870+ _c.Run(run)
8871+ return _c
8872+}
8873+
86348874 // SetHalign provides a mock function for the type MockButtonWidget
86358875 func (_mock *MockButtonWidget) SetHalign(align gtk.Align) {
86368876 _mock.Called(align)
@@ -9833,6 +10073,46 @@ func (_c *MockImageWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
983310073 return _c
983410074 }
983510075
10076+// SetFocusable provides a mock function for the type MockImageWidget
10077+func (_mock *MockImageWidget) SetFocusable(focusable bool) {
10078+ _mock.Called(focusable)
10079+ return
10080+}
10081+
10082+// MockImageWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
10083+type MockImageWidget_SetFocusable_Call struct {
10084+ *mock.Call
10085+}
10086+
10087+// SetFocusable is a helper method to define mock.On call
10088+// - focusable bool
10089+func (_e *MockImageWidget_Expecter) SetFocusable(focusable interface{}) *MockImageWidget_SetFocusable_Call {
10090+ return &MockImageWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
10091+}
10092+
10093+func (_c *MockImageWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockImageWidget_SetFocusable_Call {
10094+ _c.Call.Run(func(args mock.Arguments) {
10095+ var arg0 bool
10096+ if args[0] != nil {
10097+ arg0 = args[0].(bool)
10098+ }
10099+ run(
10100+ arg0,
10101+ )
10102+ })
10103+ return _c
10104+}
10105+
10106+func (_c *MockImageWidget_SetFocusable_Call) Return() *MockImageWidget_SetFocusable_Call {
10107+ _c.Call.Return()
10108+ return _c
10109+}
10110+
10111+func (_c *MockImageWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockImageWidget_SetFocusable_Call {
10112+ _c.Run(run)
10113+ return _c
10114+}
10115+
983610116 // SetFromFile provides a mock function for the type MockImageWidget
983710117 func (_mock *MockImageWidget) SetFromFile(filename string) {
983810118 _mock.Called(filename)
@@ -11166,6 +11446,46 @@ func (_c *MockProgressBarWidget_SetFocusOnClick_Call) RunAndReturn(run func(focu
1116611446 return _c
1116711447 }
1116811448
11449+// SetFocusable provides a mock function for the type MockProgressBarWidget
11450+func (_mock *MockProgressBarWidget) SetFocusable(focusable bool) {
11451+ _mock.Called(focusable)
11452+ return
11453+}
11454+
11455+// MockProgressBarWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
11456+type MockProgressBarWidget_SetFocusable_Call struct {
11457+ *mock.Call
11458+}
11459+
11460+// SetFocusable is a helper method to define mock.On call
11461+// - focusable bool
11462+func (_e *MockProgressBarWidget_Expecter) SetFocusable(focusable interface{}) *MockProgressBarWidget_SetFocusable_Call {
11463+ return &MockProgressBarWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
11464+}
11465+
11466+func (_c *MockProgressBarWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockProgressBarWidget_SetFocusable_Call {
11467+ _c.Call.Run(func(args mock.Arguments) {
11468+ var arg0 bool
11469+ if args[0] != nil {
11470+ arg0 = args[0].(bool)
11471+ }
11472+ run(
11473+ arg0,
11474+ )
11475+ })
11476+ return _c
11477+}
11478+
11479+func (_c *MockProgressBarWidget_SetFocusable_Call) Return() *MockProgressBarWidget_SetFocusable_Call {
11480+ _c.Call.Return()
11481+ return _c
11482+}
11483+
11484+func (_c *MockProgressBarWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockProgressBarWidget_SetFocusable_Call {
11485+ _c.Run(run)
11486+ return _c
11487+}
11488+
1116911489 // SetFraction provides a mock function for the type MockProgressBarWidget
1117011490 func (_mock *MockProgressBarWidget) SetFraction(fraction float64) {
1117111491 _mock.Called(fraction)
@@ -12379,6 +12699,46 @@ func (_c *MockSpinnerWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnC
1237912699 return _c
1238012700 }
1238112701
12702+// SetFocusable provides a mock function for the type MockSpinnerWidget
12703+func (_mock *MockSpinnerWidget) SetFocusable(focusable bool) {
12704+ _mock.Called(focusable)
12705+ return
12706+}
12707+
12708+// MockSpinnerWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
12709+type MockSpinnerWidget_SetFocusable_Call struct {
12710+ *mock.Call
12711+}
12712+
12713+// SetFocusable is a helper method to define mock.On call
12714+// - focusable bool
12715+func (_e *MockSpinnerWidget_Expecter) SetFocusable(focusable interface{}) *MockSpinnerWidget_SetFocusable_Call {
12716+ return &MockSpinnerWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
12717+}
12718+
12719+func (_c *MockSpinnerWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockSpinnerWidget_SetFocusable_Call {
12720+ _c.Call.Run(func(args mock.Arguments) {
12721+ var arg0 bool
12722+ if args[0] != nil {
12723+ arg0 = args[0].(bool)
12724+ }
12725+ run(
12726+ arg0,
12727+ )
12728+ })
12729+ return _c
12730+}
12731+
12732+func (_c *MockSpinnerWidget_SetFocusable_Call) Return() *MockSpinnerWidget_SetFocusable_Call {
12733+ _c.Call.Return()
12734+ return _c
12735+}
12736+
12737+func (_c *MockSpinnerWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockSpinnerWidget_SetFocusable_Call {
12738+ _c.Run(run)
12739+ return _c
12740+}
12741+
1238212742 // SetHalign provides a mock function for the type MockSpinnerWidget
1238312743 func (_mock *MockSpinnerWidget) SetHalign(align gtk.Align) {
1238412744 _mock.Called(align)
@@ -908,6 +908,46 @@ func (_c *MockWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick bo
908 return _c908 return _c
909 }909 }
910 910
911+// SetFocusable provides a mock function for the type MockWidget
912+func (_mock *MockWidget) SetFocusable(focusable bool) {
913+ _mock.Called(focusable)
914+ return
915+}
916+
917+// MockWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
918+type MockWidget_SetFocusable_Call struct {
919+ *mock.Call
920+}
921+
922+// SetFocusable is a helper method to define mock.On call
923+// - focusable bool
924+func (_e *MockWidget_Expecter) SetFocusable(focusable interface{}) *MockWidget_SetFocusable_Call {
925+ return &MockWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
926+}
927+
928+func (_c *MockWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockWidget_SetFocusable_Call {
929+ _c.Call.Run(func(args mock.Arguments) {
930+ var arg0 bool
931+ if args[0] != nil {
932+ arg0 = args[0].(bool)
933+ }
934+ run(
935+ arg0,
936+ )
937+ })
938+ return _c
939+}
940+
941+func (_c *MockWidget_SetFocusable_Call) Return() *MockWidget_SetFocusable_Call {
942+ _c.Call.Return()
943+ return _c
944+}
945+
946+func (_c *MockWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockWidget_SetFocusable_Call {
947+ _c.Run(run)
948+ return _c
949+}
950+
911 // SetHalign provides a mock function for the type MockWidget951 // SetHalign provides a mock function for the type MockWidget
912 func (_mock *MockWidget) SetHalign(align gtk.Align) {952 func (_mock *MockWidget) SetHalign(align gtk.Align) {
913 _mock.Called(align)953 _mock.Called(align)
@@ -2586,6 +2626,46 @@ func (_c *MockPanedWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
2586 return _c2626 return _c
2587 }2627 }
2588 2628
2629+// SetFocusable provides a mock function for the type MockPanedWidget
2630+func (_mock *MockPanedWidget) SetFocusable(focusable bool) {
2631+ _mock.Called(focusable)
2632+ return
2633+}
2634+
2635+// MockPanedWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
2636+type MockPanedWidget_SetFocusable_Call struct {
2637+ *mock.Call
2638+}
2639+
2640+// SetFocusable is a helper method to define mock.On call
2641+// - focusable bool
2642+func (_e *MockPanedWidget_Expecter) SetFocusable(focusable interface{}) *MockPanedWidget_SetFocusable_Call {
2643+ return &MockPanedWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
2644+}
2645+
2646+func (_c *MockPanedWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockPanedWidget_SetFocusable_Call {
2647+ _c.Call.Run(func(args mock.Arguments) {
2648+ var arg0 bool
2649+ if args[0] != nil {
2650+ arg0 = args[0].(bool)
2651+ }
2652+ run(
2653+ arg0,
2654+ )
2655+ })
2656+ return _c
2657+}
2658+
2659+func (_c *MockPanedWidget_SetFocusable_Call) Return() *MockPanedWidget_SetFocusable_Call {
2660+ _c.Call.Return()
2661+ return _c
2662+}
2663+
2664+func (_c *MockPanedWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockPanedWidget_SetFocusable_Call {
2665+ _c.Run(run)
2666+ return _c
2667+}
2668+
2589 // SetHalign provides a mock function for the type MockPanedWidget2669 // SetHalign provides a mock function for the type MockPanedWidget
2590 func (_mock *MockPanedWidget) SetHalign(align gtk.Align) {2670 func (_mock *MockPanedWidget) SetHalign(align gtk.Align) {
2591 _mock.Called(align)2671 _mock.Called(align)
@@ -4339,6 +4419,46 @@ func (_c *MockBoxWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick
4339 return _c4419 return _c
4340 }4420 }
4341 4421
4422+// SetFocusable provides a mock function for the type MockBoxWidget
4423+func (_mock *MockBoxWidget) SetFocusable(focusable bool) {
4424+ _mock.Called(focusable)
4425+ return
4426+}
4427+
4428+// MockBoxWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
4429+type MockBoxWidget_SetFocusable_Call struct {
4430+ *mock.Call
4431+}
4432+
4433+// SetFocusable is a helper method to define mock.On call
4434+// - focusable bool
4435+func (_e *MockBoxWidget_Expecter) SetFocusable(focusable interface{}) *MockBoxWidget_SetFocusable_Call {
4436+ return &MockBoxWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
4437+}
4438+
4439+func (_c *MockBoxWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockBoxWidget_SetFocusable_Call {
4440+ _c.Call.Run(func(args mock.Arguments) {
4441+ var arg0 bool
4442+ if args[0] != nil {
4443+ arg0 = args[0].(bool)
4444+ }
4445+ run(
4446+ arg0,
4447+ )
4448+ })
4449+ return _c
4450+}
4451+
4452+func (_c *MockBoxWidget_SetFocusable_Call) Return() *MockBoxWidget_SetFocusable_Call {
4453+ _c.Call.Return()
4454+ return _c
4455+}
4456+
4457+func (_c *MockBoxWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockBoxWidget_SetFocusable_Call {
4458+ _c.Run(run)
4459+ return _c
4460+}
4461+
4342 // SetHalign provides a mock function for the type MockBoxWidget4462 // SetHalign provides a mock function for the type MockBoxWidget
4343 func (_mock *MockBoxWidget) SetHalign(align gtk.Align) {4463 func (_mock *MockBoxWidget) SetHalign(align gtk.Align) {
4344 _mock.Called(align)4464 _mock.Called(align)
@@ -5902,6 +6022,46 @@ func (_c *MockOverlayWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnC
5902 return _c6022 return _c
5903 }6023 }
5904 6024
6025+// SetFocusable provides a mock function for the type MockOverlayWidget
6026+func (_mock *MockOverlayWidget) SetFocusable(focusable bool) {
6027+ _mock.Called(focusable)
6028+ return
6029+}
6030+
6031+// MockOverlayWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
6032+type MockOverlayWidget_SetFocusable_Call struct {
6033+ *mock.Call
6034+}
6035+
6036+// SetFocusable is a helper method to define mock.On call
6037+// - focusable bool
6038+func (_e *MockOverlayWidget_Expecter) SetFocusable(focusable interface{}) *MockOverlayWidget_SetFocusable_Call {
6039+ return &MockOverlayWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
6040+}
6041+
6042+func (_c *MockOverlayWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockOverlayWidget_SetFocusable_Call {
6043+ _c.Call.Run(func(args mock.Arguments) {
6044+ var arg0 bool
6045+ if args[0] != nil {
6046+ arg0 = args[0].(bool)
6047+ }
6048+ run(
6049+ arg0,
6050+ )
6051+ })
6052+ return _c
6053+}
6054+
6055+func (_c *MockOverlayWidget_SetFocusable_Call) Return() *MockOverlayWidget_SetFocusable_Call {
6056+ _c.Call.Return()
6057+ return _c
6058+}
6059+
6060+func (_c *MockOverlayWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockOverlayWidget_SetFocusable_Call {
6061+ _c.Run(run)
6062+ return _c
6063+}
6064+
5905 // SetHalign provides a mock function for the type MockOverlayWidget6065 // SetHalign provides a mock function for the type MockOverlayWidget
5906 func (_mock *MockOverlayWidget) SetHalign(align gtk.Align) {6066 func (_mock *MockOverlayWidget) SetHalign(align gtk.Align) {
5907 _mock.Called(align)6067 _mock.Called(align)
@@ -7161,6 +7321,46 @@ func (_c *MockLabelWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
7161 return _c7321 return _c
7162 }7322 }
7163 7323
7324+// SetFocusable provides a mock function for the type MockLabelWidget
7325+func (_mock *MockLabelWidget) SetFocusable(focusable bool) {
7326+ _mock.Called(focusable)
7327+ return
7328+}
7329+
7330+// MockLabelWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
7331+type MockLabelWidget_SetFocusable_Call struct {
7332+ *mock.Call
7333+}
7334+
7335+// SetFocusable is a helper method to define mock.On call
7336+// - focusable bool
7337+func (_e *MockLabelWidget_Expecter) SetFocusable(focusable interface{}) *MockLabelWidget_SetFocusable_Call {
7338+ return &MockLabelWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
7339+}
7340+
7341+func (_c *MockLabelWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockLabelWidget_SetFocusable_Call {
7342+ _c.Call.Run(func(args mock.Arguments) {
7343+ var arg0 bool
7344+ if args[0] != nil {
7345+ arg0 = args[0].(bool)
7346+ }
7347+ run(
7348+ arg0,
7349+ )
7350+ })
7351+ return _c
7352+}
7353+
7354+func (_c *MockLabelWidget_SetFocusable_Call) Return() *MockLabelWidget_SetFocusable_Call {
7355+ _c.Call.Return()
7356+ return _c
7357+}
7358+
7359+func (_c *MockLabelWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockLabelWidget_SetFocusable_Call {
7360+ _c.Run(run)
7361+ return _c
7362+}
7363+
7164 // SetHalign provides a mock function for the type MockLabelWidget7364 // SetHalign provides a mock function for the type MockLabelWidget
7165 func (_mock *MockLabelWidget) SetHalign(align gtk.Align) {7365 func (_mock *MockLabelWidget) SetHalign(align gtk.Align) {
7166 _mock.Called(align)7366 _mock.Called(align)
@@ -8631,6 +8831,46 @@ func (_c *MockButtonWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCl
8631 return _c8831 return _c
8632 }8832 }
8633 8833
8834+// SetFocusable provides a mock function for the type MockButtonWidget
8835+func (_mock *MockButtonWidget) SetFocusable(focusable bool) {
8836+ _mock.Called(focusable)
8837+ return
8838+}
8839+
8840+// MockButtonWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
8841+type MockButtonWidget_SetFocusable_Call struct {
8842+ *mock.Call
8843+}
8844+
8845+// SetFocusable is a helper method to define mock.On call
8846+// - focusable bool
8847+func (_e *MockButtonWidget_Expecter) SetFocusable(focusable interface{}) *MockButtonWidget_SetFocusable_Call {
8848+ return &MockButtonWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
8849+}
8850+
8851+func (_c *MockButtonWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockButtonWidget_SetFocusable_Call {
8852+ _c.Call.Run(func(args mock.Arguments) {
8853+ var arg0 bool
8854+ if args[0] != nil {
8855+ arg0 = args[0].(bool)
8856+ }
8857+ run(
8858+ arg0,
8859+ )
8860+ })
8861+ return _c
8862+}
8863+
8864+func (_c *MockButtonWidget_SetFocusable_Call) Return() *MockButtonWidget_SetFocusable_Call {
8865+ _c.Call.Return()
8866+ return _c
8867+}
8868+
8869+func (_c *MockButtonWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockButtonWidget_SetFocusable_Call {
8870+ _c.Run(run)
8871+ return _c
8872+}
8873+
8634 // SetHalign provides a mock function for the type MockButtonWidget8874 // SetHalign provides a mock function for the type MockButtonWidget
8635 func (_mock *MockButtonWidget) SetHalign(align gtk.Align) {8875 func (_mock *MockButtonWidget) SetHalign(align gtk.Align) {
8636 _mock.Called(align)8876 _mock.Called(align)
@@ -9833,6 +10073,46 @@ func (_c *MockImageWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnCli
9833 return _c10073 return _c
9834 }10074 }
9835 10075
10076+// SetFocusable provides a mock function for the type MockImageWidget
10077+func (_mock *MockImageWidget) SetFocusable(focusable bool) {
10078+ _mock.Called(focusable)
10079+ return
10080+}
10081+
10082+// MockImageWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
10083+type MockImageWidget_SetFocusable_Call struct {
10084+ *mock.Call
10085+}
10086+
10087+// SetFocusable is a helper method to define mock.On call
10088+// - focusable bool
10089+func (_e *MockImageWidget_Expecter) SetFocusable(focusable interface{}) *MockImageWidget_SetFocusable_Call {
10090+ return &MockImageWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
10091+}
10092+
10093+func (_c *MockImageWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockImageWidget_SetFocusable_Call {
10094+ _c.Call.Run(func(args mock.Arguments) {
10095+ var arg0 bool
10096+ if args[0] != nil {
10097+ arg0 = args[0].(bool)
10098+ }
10099+ run(
10100+ arg0,
10101+ )
10102+ })
10103+ return _c
10104+}
10105+
10106+func (_c *MockImageWidget_SetFocusable_Call) Return() *MockImageWidget_SetFocusable_Call {
10107+ _c.Call.Return()
10108+ return _c
10109+}
10110+
10111+func (_c *MockImageWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockImageWidget_SetFocusable_Call {
10112+ _c.Run(run)
10113+ return _c
10114+}
10115+
9836 // SetFromFile provides a mock function for the type MockImageWidget10116 // SetFromFile provides a mock function for the type MockImageWidget
9837 func (_mock *MockImageWidget) SetFromFile(filename string) {10117 func (_mock *MockImageWidget) SetFromFile(filename string) {
9838 _mock.Called(filename)10118 _mock.Called(filename)
@@ -11166,6 +11446,46 @@ func (_c *MockProgressBarWidget_SetFocusOnClick_Call) RunAndReturn(run func(focu
11166 return _c11446 return _c
11167 }11447 }
11168 11448
11449+// SetFocusable provides a mock function for the type MockProgressBarWidget
11450+func (_mock *MockProgressBarWidget) SetFocusable(focusable bool) {
11451+ _mock.Called(focusable)
11452+ return
11453+}
11454+
11455+// MockProgressBarWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
11456+type MockProgressBarWidget_SetFocusable_Call struct {
11457+ *mock.Call
11458+}
11459+
11460+// SetFocusable is a helper method to define mock.On call
11461+// - focusable bool
11462+func (_e *MockProgressBarWidget_Expecter) SetFocusable(focusable interface{}) *MockProgressBarWidget_SetFocusable_Call {
11463+ return &MockProgressBarWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
11464+}
11465+
11466+func (_c *MockProgressBarWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockProgressBarWidget_SetFocusable_Call {
11467+ _c.Call.Run(func(args mock.Arguments) {
11468+ var arg0 bool
11469+ if args[0] != nil {
11470+ arg0 = args[0].(bool)
11471+ }
11472+ run(
11473+ arg0,
11474+ )
11475+ })
11476+ return _c
11477+}
11478+
11479+func (_c *MockProgressBarWidget_SetFocusable_Call) Return() *MockProgressBarWidget_SetFocusable_Call {
11480+ _c.Call.Return()
11481+ return _c
11482+}
11483+
11484+func (_c *MockProgressBarWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockProgressBarWidget_SetFocusable_Call {
11485+ _c.Run(run)
11486+ return _c
11487+}
11488+
11169 // SetFraction provides a mock function for the type MockProgressBarWidget11489 // SetFraction provides a mock function for the type MockProgressBarWidget
11170 func (_mock *MockProgressBarWidget) SetFraction(fraction float64) {11490 func (_mock *MockProgressBarWidget) SetFraction(fraction float64) {
11171 _mock.Called(fraction)11491 _mock.Called(fraction)
@@ -12379,6 +12699,46 @@ func (_c *MockSpinnerWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnC
12379 return _c12699 return _c
12380 }12700 }
12381 12701
12702+// SetFocusable provides a mock function for the type MockSpinnerWidget
12703+func (_mock *MockSpinnerWidget) SetFocusable(focusable bool) {
12704+ _mock.Called(focusable)
12705+ return
12706+}
12707+
12708+// MockSpinnerWidget_SetFocusable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusable'
12709+type MockSpinnerWidget_SetFocusable_Call struct {
12710+ *mock.Call
12711+}
12712+
12713+// SetFocusable is a helper method to define mock.On call
12714+// - focusable bool
12715+func (_e *MockSpinnerWidget_Expecter) SetFocusable(focusable interface{}) *MockSpinnerWidget_SetFocusable_Call {
12716+ return &MockSpinnerWidget_SetFocusable_Call{Call: _e.mock.On("SetFocusable", focusable)}
12717+}
12718+
12719+func (_c *MockSpinnerWidget_SetFocusable_Call) Run(run func(focusable bool)) *MockSpinnerWidget_SetFocusable_Call {
12720+ _c.Call.Run(func(args mock.Arguments) {
12721+ var arg0 bool
12722+ if args[0] != nil {
12723+ arg0 = args[0].(bool)
12724+ }
12725+ run(
12726+ arg0,
12727+ )
12728+ })
12729+ return _c
12730+}
12731+
12732+func (_c *MockSpinnerWidget_SetFocusable_Call) Return() *MockSpinnerWidget_SetFocusable_Call {
12733+ _c.Call.Return()
12734+ return _c
12735+}
12736+
12737+func (_c *MockSpinnerWidget_SetFocusable_Call) RunAndReturn(run func(focusable bool)) *MockSpinnerWidget_SetFocusable_Call {
12738+ _c.Run(run)
12739+ return _c
12740+}
12741+
12382 // SetHalign provides a mock function for the type MockSpinnerWidget12742 // SetHalign provides a mock function for the type MockSpinnerWidget
12383 func (_mock *MockSpinnerWidget) SetHalign(align gtk.Align) {12743 func (_mock *MockSpinnerWidget) SetHalign(align gtk.Align) {
12384 _mock.Called(align)12744 _mock.Called(align)
M internal/ui/layout/widgets.go +1 -0
@@ -28,6 +28,7 @@ type Widget interface {
2828 GrabFocus() bool
2929 HasFocus() bool
3030 SetCanFocus(canFocus bool)
31+ SetFocusable(focusable bool) // GTK4: makes widget able to receive keyboard focus
3132 SetFocusOnClick(focusOnClick bool)
3233
3334 // Pointer events
@@ -28,6 +28,7 @@ type Widget interface {
28 GrabFocus() bool28 GrabFocus() bool
29 HasFocus() bool29 HasFocus() bool
30 SetCanFocus(canFocus bool)30 SetCanFocus(canFocus bool)
31+ SetFocusable(focusable bool) // GTK4: makes widget able to receive keyboard focus
31 SetFocusOnClick(focusOnClick bool)32 SetFocusOnClick(focusOnClick bool)
32 33
33 // Pointer events34 // Pointer events
A internal/ui/theme/accent_picker_css.go +56 -0
@@ -0,0 +1,56 @@
1+package theme
2+
3+// generateAccentPickerCSS creates accent picker overlay styles.
4+// The picker displays a row of accented characters that the user can select.
5+func generateAccentPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Accent Picker Styling ===== */
8+
9+/* Main container - horizontal row of accent characters */
10+.accent-picker {
11+ background-color: alpha(var(--surface-variant), 0.95);
12+ border: 0.0625em solid var(--border);
13+ border-radius: 0.25em;
14+ padding: 0.375em 0.5em;
15+ margin-bottom: 1em;
16+ box-shadow: 0 2px 8px alpha(black, 0.2);
17+ transition: border-color 100ms ease-in-out;
18+}
19+
20+/* Focused state - accent border like omnibox */
21+.accent-picker:focus,
22+.accent-picker:focus-within,
23+.accent-picker:focus-visible {
24+ border-color: var(--accent);
25+ outline-style: none;
26+ outline-width: 0px;
27+ outline-color: transparent;
28+}
29+
30+/* Individual accent character label */
31+.accent-picker-item {
32+ font-size: 1.5em;
33+ font-weight: 500;
34+ color: var(--text);
35+ padding: 0.25em 0.5em;
36+ border-radius: 0.125em;
37+ min-width: 1.5em;
38+ transition: background-color 100ms ease-in-out;
39+}
40+
41+.accent-picker-item:hover {
42+ background-color: alpha(var(--accent), 0.15);
43+}
44+
45+/* Selected accent character */
46+.accent-picker-selected {
47+ background-color: alpha(var(--accent), 0.25);
48+ color: var(--accent);
49+}
50+
51+/* Numbered items (1-9) could show subtle number hints */
52+.accent-picker-numbered {
53+ /* Could add ::after pseudo-element for number hint if needed */
54+}
55+`
56+}
@@ -0,0 +1,56 @@
1+package theme
2+
3+// generateAccentPickerCSS creates accent picker overlay styles.
4+// The picker displays a row of accented characters that the user can select.
5+func generateAccentPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Accent Picker Styling ===== */
8+
9+/* Main container - horizontal row of accent characters */
10+.accent-picker {
11+ background-color: alpha(var(--surface-variant), 0.95);
12+ border: 0.0625em solid var(--border);
13+ border-radius: 0.25em;
14+ padding: 0.375em 0.5em;
15+ margin-bottom: 1em;
16+ box-shadow: 0 2px 8px alpha(black, 0.2);
17+ transition: border-color 100ms ease-in-out;
18+}
19+
20+/* Focused state - accent border like omnibox */
21+.accent-picker:focus,
22+.accent-picker:focus-within,
23+.accent-picker:focus-visible {
24+ border-color: var(--accent);
25+ outline-style: none;
26+ outline-width: 0px;
27+ outline-color: transparent;
28+}
29+
30+/* Individual accent character label */
31+.accent-picker-item {
32+ font-size: 1.5em;
33+ font-weight: 500;
34+ color: var(--text);
35+ padding: 0.25em 0.5em;
36+ border-radius: 0.125em;
37+ min-width: 1.5em;
38+ transition: background-color 100ms ease-in-out;
39+}
40+
41+.accent-picker-item:hover {
42+ background-color: alpha(var(--accent), 0.15);
43+}
44+
45+/* Selected accent character */
46+.accent-picker-selected {
47+ background-color: alpha(var(--accent), 0.25);
48+ color: var(--accent);
49+}
50+
51+/* Numbered items (1-9) could show subtle number hints */
52+.accent-picker-numbered {
53+ /* Could add ::after pseudo-element for number hint if needed */
54+}
55+`
56+}
M internal/ui/theme/css.go +4 -0
@@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
126126
127127 // Tab picker styling
128128 sb.WriteString(generateTabPickerCSS(p))
129+ sb.WriteString("\n")
130+
131+ // Accent picker styling
132+ sb.WriteString(generateAccentPickerCSS(p))
129133
130134 return sb.String()
131135 }
@@ -126,6 +126,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
126 126
127 // Tab picker styling127 // Tab picker styling
128 sb.WriteString(generateTabPickerCSS(p))128 sb.WriteString(generateTabPickerCSS(p))
129+ sb.WriteString("\n")
130+
131+ // Accent picker styling
132+ sb.WriteString(generateAccentPickerCSS(p))
129 133
130 return sb.String()134 return sb.String()
131 }135 }

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