detached a261e52

Merge pull request #97 from bnema/next

Release v0.24.0
commit
a261e52c2a2ac94ea674fae31997d4df34a3d5b3
author
bnema · · 2026-01-06 13:08 UTC
committer
GitHub · · 2026-01-06 13:08 UTC
merge of
c2f6690 + a0d51a2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.24.0
40 files · +2226 -478
M .mockery.yaml +1 -0
@@ -26,6 +26,7 @@ packages:
2626 TextInputTarget: {}
2727 FocusedInputProvider: {}
2828 AccentPickerUI: {}
29+ FontDetector: {}
2930 github.com/bnema/dumber/internal/domain/repository:
3031 config:
3132 dir: '{{.InterfaceDir}}/mocks'
@@ -26,6 +26,7 @@ packages:
26 TextInputTarget: {}26 TextInputTarget: {}
27 FocusedInputProvider: {}27 FocusedInputProvider: {}
28 AccentPickerUI: {}28 AccentPickerUI: {}
29+ FontDetector: {}
29 github.com/bnema/dumber/internal/domain/repository:30 github.com/bnema/dumber/internal/domain/repository:
30 config:31 config:
31 dir: '{{.InterfaceDir}}/mocks'32 dir: '{{.InterfaceDir}}/mocks'
M CHANGELOG.md +14 -0
@@ -4,11 +4,25 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+## [0.24.0] - 2026-01-06
8+
79 ### Added
10+- **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.
11+- **Font availability detection**: On first run, detects available system fonts using fontconfig (`fc-list`) and selects the best font from a fallback chain instead of hardcoding `Fira Sans` / `Fira Code`. Fallback chains: sans-serif (Fira Sans, Noto Sans, DejaVu Sans, Liberation Sans, FreeSans), serif (Noto Serif, DejaVu Serif, Liberation Serif, FreeSerif), monospace (Fira Code, JetBrains Mono, Noto Sans Mono, DejaVu Sans Mono, Liberation Mono, FreeMono). Falls back to generic CSS fonts if none available. Fixes #85.
12+- **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal.
13+- **AUR git package CI**: Automated CI workflow to publish `dumber-browser-git` AUR package on push to main branch.
814
915 ### Changed
16+- **Flatpak runtime**: Bumped to GNOME 49 runtime.
17+- **Focus method refactored**: `Focus()` now delegates to `ApplyFocusChange()` to ensure stack index is updated when focusing stacked panes.
1018
1119 ### Fixed
20+- **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle.
21+- **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.
22+- **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus. Fixes #89.
23+- **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).
24+- **Link status overlay auto-hide**: Link URL overlay now automatically hides after 10 seconds of inactivity instead of staying visible indefinitely. Timer resets when hovering new links. Fixes #82.
25+- **Omnibox multi-word search**: History search now matches all words in query (AND logic) and searches both URL and title fields. Prefix matching enabled (e.g., "git iss" matches "github.com/issues"). FTS5 special characters are now sanitized to prevent syntax errors. Results interleave URL and title matches for better balance. Fixes #87.
1226
1327 ## [0.23.2] - 2025-12-31
1428
@@ -4,11 +4,25 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+## [0.24.0] - 2026-01-06
8+
7 ### Added9 ### Added
10+- **Auto-copy on selection**: Text selected in web pages is automatically copied to the clipboard (like zellij/tmux). Shows brief "Copied to clipboard" toast notification. Configurable via `clipboard.auto_copy_on_selection` (default: true). Fixes #83.
11+- **Font availability detection**: On first run, detects available system fonts using fontconfig (`fc-list`) and selects the best font from a fallback chain instead of hardcoding `Fira Sans` / `Fira Code`. Fallback chains: sans-serif (Fira Sans, Noto Sans, DejaVu Sans, Liberation Sans, FreeSans), serif (Noto Serif, DejaVu Serif, Liberation Serif, FreeSerif), monospace (Fira Code, JetBrains Mono, Noto Sans Mono, DejaVu Sans Mono, Liberation Mono, FreeMono). Falls back to generic CSS fonts if none available. Fixes #85.
12+- **Stacked pane close buttons**: Close buttons in stacked pane title bars allow closing individual panes directly. Includes proper signal cleanup on removal.
13+- **AUR git package CI**: Automated CI workflow to publish `dumber-browser-git` AUR package on push to main branch.
8 14
9 ### Changed15 ### Changed
16+- **Flatpak runtime**: Bumped to GNOME 49 runtime.
17+- **Focus method refactored**: `Focus()` now delegates to `ApplyFocusChange()` to ensure stack index is updated when focusing stacked panes.
10 18
11 ### Fixed19 ### Fixed
20+- **Flatpak locale crash**: Attempted fix for crash on systems with certain locale configurations by adding proper locale data to the Flatpak bundle.
21+- **Stacked pane vertical splits**: Vertical splits from stacked panes now correctly split around the entire stack instead of just the active pane.
22+- **Pane border focus**: Fixed stale border remaining on previously hovered pane when using keyboard navigation to switch focus. Fixes #89.
23+- **Geometric focus navigation**: Fixed focus jumping to wrong pane when navigating left/right in complex layouts. Navigation now prioritizes panes at the same vertical level (for left/right) or horizontal level (for up/down).
24+- **Link status overlay auto-hide**: Link URL overlay now automatically hides after 10 seconds of inactivity instead of staying visible indefinitely. Timer resets when hovering new links. Fixes #82.
25+- **Omnibox multi-word search**: History search now matches all words in query (AND logic) and searches both URL and title fields. Prefix matching enabled (e.g., "git iss" matches "github.com/issues"). FTS5 special characters are now sanitized to prevent syntax errors. Results interleave URL and title matches for better balance. Fixes #87.
12 26
13 ## [0.23.2] - 2025-12-3127 ## [0.23.2] - 2025-12-31
14 28
M docs/CONFIG.md +14 -0
@@ -458,6 +458,20 @@ If set, dumber prepends prefix-derived paths to:
458458 prefix = "/opt/webkitgtk"
459459 ```
460460
461+## Clipboard
462+
463+| Key | Type | Default | Description |
464+|-----|------|---------|-------------|
465+| `clipboard.auto_copy_on_selection` | bool | `true` | Automatically copy selected text to clipboard (zellij/tmux-style) |
466+
467+When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.
468+
469+**Example:**
470+```toml
471+[clipboard]
472+auto_copy_on_selection = true # Enabled by default
473+```
474+
461475 ## Content Filtering
462476
463477 | Key | Type | Default | Description |
@@ -458,6 +458,20 @@ If set, dumber prepends prefix-derived paths to:
458 prefix = "/opt/webkitgtk"458 prefix = "/opt/webkitgtk"
459 ```459 ```
460 460
461+## Clipboard
462+
463+| Key | Type | Default | Description |
464+|-----|------|---------|-------------|
465+| `clipboard.auto_copy_on_selection` | bool | `true` | Automatically copy selected text to clipboard (zellij/tmux-style) |
466+
467+When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.
468+
469+**Example:**
470+```toml
471+[clipboard]
472+auto_copy_on_selection = true # Enabled by default
473+```
474+
461 ## Content Filtering475 ## Content Filtering
462 476
463 | Key | Type | Default | Description |477 | Key | Type | Default | Description |
A internal/application/port/font_detector.go +32 -0
@@ -0,0 +1,32 @@
1+package port
2+
3+import "context"
4+
5+// FontCategory represents a category of fonts for fallback selection.
6+type FontCategory string
7+
8+const (
9+ // FontCategorySansSerif is for sans-serif fonts (UI text).
10+ FontCategorySansSerif FontCategory = "sans-serif"
11+ // FontCategorySerif is for serif fonts (reading content).
12+ FontCategorySerif FontCategory = "serif"
13+ // FontCategoryMonospace is for monospace fonts (code, fixed-width).
14+ FontCategoryMonospace FontCategory = "monospace"
15+)
16+
17+// FontDetector detects available system fonts and selects the best
18+// font from a fallback chain. This is used during first-run config
19+// creation to ensure configured fonts are actually installed.
20+type FontDetector interface {
21+ // GetAvailableFonts returns a list of font family names installed on the system.
22+ // Returns an error if font detection is not available (e.g., fc-list missing).
23+ GetAvailableFonts(ctx context.Context) ([]string, error)
24+
25+ // SelectBestFont returns the first available font from the fallback chain,
26+ // or the generic CSS fallback (e.g., "sans-serif") if none are installed.
27+ // The category is used to determine the generic fallback.
28+ SelectBestFont(ctx context.Context, category FontCategory, fallbackChain []string) string
29+
30+ // IsAvailable returns true if font detection is available on this system.
31+ IsAvailable(ctx context.Context) bool
32+}
@@ -0,0 +1,32 @@
1+package port
2+
3+import "context"
4+
5+// FontCategory represents a category of fonts for fallback selection.
6+type FontCategory string
7+
8+const (
9+ // FontCategorySansSerif is for sans-serif fonts (UI text).
10+ FontCategorySansSerif FontCategory = "sans-serif"
11+ // FontCategorySerif is for serif fonts (reading content).
12+ FontCategorySerif FontCategory = "serif"
13+ // FontCategoryMonospace is for monospace fonts (code, fixed-width).
14+ FontCategoryMonospace FontCategory = "monospace"
15+)
16+
17+// FontDetector detects available system fonts and selects the best
18+// font from a fallback chain. This is used during first-run config
19+// creation to ensure configured fonts are actually installed.
20+type FontDetector interface {
21+ // GetAvailableFonts returns a list of font family names installed on the system.
22+ // Returns an error if font detection is not available (e.g., fc-list missing).
23+ GetAvailableFonts(ctx context.Context) ([]string, error)
24+
25+ // SelectBestFont returns the first available font from the fallback chain,
26+ // or the generic CSS fallback (e.g., "sans-serif") if none are installed.
27+ // The category is used to determine the generic fallback.
28+ SelectBestFont(ctx context.Context, category FontCategory, fallbackChain []string) string
29+
30+ // IsAvailable returns true if font detection is available on this system.
31+ IsAvailable(ctx context.Context) bool
32+}
M internal/application/port/mocks/mocks.go +203 -0
@@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
12141214 return _c
12151215 }
12161216
1217+// NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1218+// The first argument is typically a *testing.T value.
1219+func NewMockFontDetector(t interface {
1220+ mock.TestingT
1221+ Cleanup(func())
1222+}) *MockFontDetector {
1223+ mock := &MockFontDetector{}
1224+ mock.Mock.Test(t)
1225+
1226+ t.Cleanup(func() { mock.AssertExpectations(t) })
1227+
1228+ return mock
1229+}
1230+
1231+// MockFontDetector is an autogenerated mock type for the FontDetector type
1232+type MockFontDetector struct {
1233+ mock.Mock
1234+}
1235+
1236+type MockFontDetector_Expecter struct {
1237+ mock *mock.Mock
1238+}
1239+
1240+func (_m *MockFontDetector) EXPECT() *MockFontDetector_Expecter {
1241+ return &MockFontDetector_Expecter{mock: &_m.Mock}
1242+}
1243+
1244+// GetAvailableFonts provides a mock function for the type MockFontDetector
1245+func (_mock *MockFontDetector) GetAvailableFonts(ctx context.Context) ([]string, error) {
1246+ ret := _mock.Called(ctx)
1247+
1248+ if len(ret) == 0 {
1249+ panic("no return value specified for GetAvailableFonts")
1250+ }
1251+
1252+ var r0 []string
1253+ var r1 error
1254+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
1255+ return returnFunc(ctx)
1256+ }
1257+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
1258+ r0 = returnFunc(ctx)
1259+ } else {
1260+ if ret.Get(0) != nil {
1261+ r0 = ret.Get(0).([]string)
1262+ }
1263+ }
1264+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1265+ r1 = returnFunc(ctx)
1266+ } else {
1267+ r1 = ret.Error(1)
1268+ }
1269+ return r0, r1
1270+}
1271+
1272+// MockFontDetector_GetAvailableFonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvailableFonts'
1273+type MockFontDetector_GetAvailableFonts_Call struct {
1274+ *mock.Call
1275+}
1276+
1277+// GetAvailableFonts is a helper method to define mock.On call
1278+// - ctx context.Context
1279+func (_e *MockFontDetector_Expecter) GetAvailableFonts(ctx interface{}) *MockFontDetector_GetAvailableFonts_Call {
1280+ return &MockFontDetector_GetAvailableFonts_Call{Call: _e.mock.On("GetAvailableFonts", ctx)}
1281+}
1282+
1283+func (_c *MockFontDetector_GetAvailableFonts_Call) Run(run func(ctx context.Context)) *MockFontDetector_GetAvailableFonts_Call {
1284+ _c.Call.Run(func(args mock.Arguments) {
1285+ var arg0 context.Context
1286+ if args[0] != nil {
1287+ arg0 = args[0].(context.Context)
1288+ }
1289+ run(
1290+ arg0,
1291+ )
1292+ })
1293+ return _c
1294+}
1295+
1296+func (_c *MockFontDetector_GetAvailableFonts_Call) Return(strings []string, err error) *MockFontDetector_GetAvailableFonts_Call {
1297+ _c.Call.Return(strings, err)
1298+ return _c
1299+}
1300+
1301+func (_c *MockFontDetector_GetAvailableFonts_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockFontDetector_GetAvailableFonts_Call {
1302+ _c.Call.Return(run)
1303+ return _c
1304+}
1305+
1306+// IsAvailable provides a mock function for the type MockFontDetector
1307+func (_mock *MockFontDetector) IsAvailable(ctx context.Context) bool {
1308+ ret := _mock.Called(ctx)
1309+
1310+ if len(ret) == 0 {
1311+ panic("no return value specified for IsAvailable")
1312+ }
1313+
1314+ var r0 bool
1315+ if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {
1316+ r0 = returnFunc(ctx)
1317+ } else {
1318+ r0 = ret.Get(0).(bool)
1319+ }
1320+ return r0
1321+}
1322+
1323+// MockFontDetector_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'
1324+type MockFontDetector_IsAvailable_Call struct {
1325+ *mock.Call
1326+}
1327+
1328+// IsAvailable is a helper method to define mock.On call
1329+// - ctx context.Context
1330+func (_e *MockFontDetector_Expecter) IsAvailable(ctx interface{}) *MockFontDetector_IsAvailable_Call {
1331+ return &MockFontDetector_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)}
1332+}
1333+
1334+func (_c *MockFontDetector_IsAvailable_Call) Run(run func(ctx context.Context)) *MockFontDetector_IsAvailable_Call {
1335+ _c.Call.Run(func(args mock.Arguments) {
1336+ var arg0 context.Context
1337+ if args[0] != nil {
1338+ arg0 = args[0].(context.Context)
1339+ }
1340+ run(
1341+ arg0,
1342+ )
1343+ })
1344+ return _c
1345+}
1346+
1347+func (_c *MockFontDetector_IsAvailable_Call) Return(b bool) *MockFontDetector_IsAvailable_Call {
1348+ _c.Call.Return(b)
1349+ return _c
1350+}
1351+
1352+func (_c *MockFontDetector_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockFontDetector_IsAvailable_Call {
1353+ _c.Call.Return(run)
1354+ return _c
1355+}
1356+
1357+// SelectBestFont provides a mock function for the type MockFontDetector
1358+func (_mock *MockFontDetector) SelectBestFont(ctx context.Context, category port.FontCategory, fallbackChain []string) string {
1359+ ret := _mock.Called(ctx, category, fallbackChain)
1360+
1361+ if len(ret) == 0 {
1362+ panic("no return value specified for SelectBestFont")
1363+ }
1364+
1365+ var r0 string
1366+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FontCategory, []string) string); ok {
1367+ r0 = returnFunc(ctx, category, fallbackChain)
1368+ } else {
1369+ r0 = ret.Get(0).(string)
1370+ }
1371+ return r0
1372+}
1373+
1374+// MockFontDetector_SelectBestFont_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectBestFont'
1375+type MockFontDetector_SelectBestFont_Call struct {
1376+ *mock.Call
1377+}
1378+
1379+// SelectBestFont is a helper method to define mock.On call
1380+// - ctx context.Context
1381+// - category port.FontCategory
1382+// - fallbackChain []string
1383+func (_e *MockFontDetector_Expecter) SelectBestFont(ctx interface{}, category interface{}, fallbackChain interface{}) *MockFontDetector_SelectBestFont_Call {
1384+ return &MockFontDetector_SelectBestFont_Call{Call: _e.mock.On("SelectBestFont", ctx, category, fallbackChain)}
1385+}
1386+
1387+func (_c *MockFontDetector_SelectBestFont_Call) Run(run func(ctx context.Context, category port.FontCategory, fallbackChain []string)) *MockFontDetector_SelectBestFont_Call {
1388+ _c.Call.Run(func(args mock.Arguments) {
1389+ var arg0 context.Context
1390+ if args[0] != nil {
1391+ arg0 = args[0].(context.Context)
1392+ }
1393+ var arg1 port.FontCategory
1394+ if args[1] != nil {
1395+ arg1 = args[1].(port.FontCategory)
1396+ }
1397+ var arg2 []string
1398+ if args[2] != nil {
1399+ arg2 = args[2].([]string)
1400+ }
1401+ run(
1402+ arg0,
1403+ arg1,
1404+ arg2,
1405+ )
1406+ })
1407+ return _c
1408+}
1409+
1410+func (_c *MockFontDetector_SelectBestFont_Call) Return(s string) *MockFontDetector_SelectBestFont_Call {
1411+ _c.Call.Return(s)
1412+ return _c
1413+}
1414+
1415+func (_c *MockFontDetector_SelectBestFont_Call) RunAndReturn(run func(ctx context.Context, category port.FontCategory, fallbackChain []string) string) *MockFontDetector_SelectBestFont_Call {
1416+ _c.Call.Return(run)
1417+ return _c
1418+}
1419+
12171420 // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12181421 // The first argument is typically a *testing.T value.
12191422 func NewMockHardwareSurveyor(t interface {
@@ -1214,6 +1214,209 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
1214 return _c1214 return _c
1215 }1215 }
1216 1216
1217+// NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1218+// The first argument is typically a *testing.T value.
1219+func NewMockFontDetector(t interface {
1220+ mock.TestingT
1221+ Cleanup(func())
1222+}) *MockFontDetector {
1223+ mock := &MockFontDetector{}
1224+ mock.Mock.Test(t)
1225+
1226+ t.Cleanup(func() { mock.AssertExpectations(t) })
1227+
1228+ return mock
1229+}
1230+
1231+// MockFontDetector is an autogenerated mock type for the FontDetector type
1232+type MockFontDetector struct {
1233+ mock.Mock
1234+}
1235+
1236+type MockFontDetector_Expecter struct {
1237+ mock *mock.Mock
1238+}
1239+
1240+func (_m *MockFontDetector) EXPECT() *MockFontDetector_Expecter {
1241+ return &MockFontDetector_Expecter{mock: &_m.Mock}
1242+}
1243+
1244+// GetAvailableFonts provides a mock function for the type MockFontDetector
1245+func (_mock *MockFontDetector) GetAvailableFonts(ctx context.Context) ([]string, error) {
1246+ ret := _mock.Called(ctx)
1247+
1248+ if len(ret) == 0 {
1249+ panic("no return value specified for GetAvailableFonts")
1250+ }
1251+
1252+ var r0 []string
1253+ var r1 error
1254+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
1255+ return returnFunc(ctx)
1256+ }
1257+ if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
1258+ r0 = returnFunc(ctx)
1259+ } else {
1260+ if ret.Get(0) != nil {
1261+ r0 = ret.Get(0).([]string)
1262+ }
1263+ }
1264+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1265+ r1 = returnFunc(ctx)
1266+ } else {
1267+ r1 = ret.Error(1)
1268+ }
1269+ return r0, r1
1270+}
1271+
1272+// MockFontDetector_GetAvailableFonts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAvailableFonts'
1273+type MockFontDetector_GetAvailableFonts_Call struct {
1274+ *mock.Call
1275+}
1276+
1277+// GetAvailableFonts is a helper method to define mock.On call
1278+// - ctx context.Context
1279+func (_e *MockFontDetector_Expecter) GetAvailableFonts(ctx interface{}) *MockFontDetector_GetAvailableFonts_Call {
1280+ return &MockFontDetector_GetAvailableFonts_Call{Call: _e.mock.On("GetAvailableFonts", ctx)}
1281+}
1282+
1283+func (_c *MockFontDetector_GetAvailableFonts_Call) Run(run func(ctx context.Context)) *MockFontDetector_GetAvailableFonts_Call {
1284+ _c.Call.Run(func(args mock.Arguments) {
1285+ var arg0 context.Context
1286+ if args[0] != nil {
1287+ arg0 = args[0].(context.Context)
1288+ }
1289+ run(
1290+ arg0,
1291+ )
1292+ })
1293+ return _c
1294+}
1295+
1296+func (_c *MockFontDetector_GetAvailableFonts_Call) Return(strings []string, err error) *MockFontDetector_GetAvailableFonts_Call {
1297+ _c.Call.Return(strings, err)
1298+ return _c
1299+}
1300+
1301+func (_c *MockFontDetector_GetAvailableFonts_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *MockFontDetector_GetAvailableFonts_Call {
1302+ _c.Call.Return(run)
1303+ return _c
1304+}
1305+
1306+// IsAvailable provides a mock function for the type MockFontDetector
1307+func (_mock *MockFontDetector) IsAvailable(ctx context.Context) bool {
1308+ ret := _mock.Called(ctx)
1309+
1310+ if len(ret) == 0 {
1311+ panic("no return value specified for IsAvailable")
1312+ }
1313+
1314+ var r0 bool
1315+ if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok {
1316+ r0 = returnFunc(ctx)
1317+ } else {
1318+ r0 = ret.Get(0).(bool)
1319+ }
1320+ return r0
1321+}
1322+
1323+// MockFontDetector_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable'
1324+type MockFontDetector_IsAvailable_Call struct {
1325+ *mock.Call
1326+}
1327+
1328+// IsAvailable is a helper method to define mock.On call
1329+// - ctx context.Context
1330+func (_e *MockFontDetector_Expecter) IsAvailable(ctx interface{}) *MockFontDetector_IsAvailable_Call {
1331+ return &MockFontDetector_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)}
1332+}
1333+
1334+func (_c *MockFontDetector_IsAvailable_Call) Run(run func(ctx context.Context)) *MockFontDetector_IsAvailable_Call {
1335+ _c.Call.Run(func(args mock.Arguments) {
1336+ var arg0 context.Context
1337+ if args[0] != nil {
1338+ arg0 = args[0].(context.Context)
1339+ }
1340+ run(
1341+ arg0,
1342+ )
1343+ })
1344+ return _c
1345+}
1346+
1347+func (_c *MockFontDetector_IsAvailable_Call) Return(b bool) *MockFontDetector_IsAvailable_Call {
1348+ _c.Call.Return(b)
1349+ return _c
1350+}
1351+
1352+func (_c *MockFontDetector_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockFontDetector_IsAvailable_Call {
1353+ _c.Call.Return(run)
1354+ return _c
1355+}
1356+
1357+// SelectBestFont provides a mock function for the type MockFontDetector
1358+func (_mock *MockFontDetector) SelectBestFont(ctx context.Context, category port.FontCategory, fallbackChain []string) string {
1359+ ret := _mock.Called(ctx, category, fallbackChain)
1360+
1361+ if len(ret) == 0 {
1362+ panic("no return value specified for SelectBestFont")
1363+ }
1364+
1365+ var r0 string
1366+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.FontCategory, []string) string); ok {
1367+ r0 = returnFunc(ctx, category, fallbackChain)
1368+ } else {
1369+ r0 = ret.Get(0).(string)
1370+ }
1371+ return r0
1372+}
1373+
1374+// MockFontDetector_SelectBestFont_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectBestFont'
1375+type MockFontDetector_SelectBestFont_Call struct {
1376+ *mock.Call
1377+}
1378+
1379+// SelectBestFont is a helper method to define mock.On call
1380+// - ctx context.Context
1381+// - category port.FontCategory
1382+// - fallbackChain []string
1383+func (_e *MockFontDetector_Expecter) SelectBestFont(ctx interface{}, category interface{}, fallbackChain interface{}) *MockFontDetector_SelectBestFont_Call {
1384+ return &MockFontDetector_SelectBestFont_Call{Call: _e.mock.On("SelectBestFont", ctx, category, fallbackChain)}
1385+}
1386+
1387+func (_c *MockFontDetector_SelectBestFont_Call) Run(run func(ctx context.Context, category port.FontCategory, fallbackChain []string)) *MockFontDetector_SelectBestFont_Call {
1388+ _c.Call.Run(func(args mock.Arguments) {
1389+ var arg0 context.Context
1390+ if args[0] != nil {
1391+ arg0 = args[0].(context.Context)
1392+ }
1393+ var arg1 port.FontCategory
1394+ if args[1] != nil {
1395+ arg1 = args[1].(port.FontCategory)
1396+ }
1397+ var arg2 []string
1398+ if args[2] != nil {
1399+ arg2 = args[2].([]string)
1400+ }
1401+ run(
1402+ arg0,
1403+ arg1,
1404+ arg2,
1405+ )
1406+ })
1407+ return _c
1408+}
1409+
1410+func (_c *MockFontDetector_SelectBestFont_Call) Return(s string) *MockFontDetector_SelectBestFont_Call {
1411+ _c.Call.Return(s)
1412+ return _c
1413+}
1414+
1415+func (_c *MockFontDetector_SelectBestFont_Call) RunAndReturn(run func(ctx context.Context, category port.FontCategory, fallbackChain []string) string) *MockFontDetector_SelectBestFont_Call {
1416+ _c.Call.Return(run)
1417+ return _c
1418+}
1419+
1217 // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1420 // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1218 // The first argument is typically a *testing.T value.1421 // The first argument is typically a *testing.T value.
1219 func NewMockHardwareSurveyor(t interface {1422 func NewMockHardwareSurveyor(t interface {
M internal/application/port/rendering_env.go +0 -4
@@ -27,10 +27,6 @@ type RenderingEnvSettings struct {
2727 GLRenderingMode string
2828 // GStreamerDebugLevel sets GStreamer debug verbosity (0-5).
2929 GStreamerDebugLevel int
30- // VideoBufferSizeMB sets buffer size in megabytes for video streaming.
31- VideoBufferSizeMB int
32- // QueueBufferTimeSec sets queue buffer time in seconds for prebuffering.
33- QueueBufferTimeSec int
3430
3531 // --- WebKit compositor settings (from RenderingConfig) ---
3632 DisableDMABufRenderer bool
@@ -27,10 +27,6 @@ type RenderingEnvSettings struct {
27 GLRenderingMode string27 GLRenderingMode string
28 // GStreamerDebugLevel sets GStreamer debug verbosity (0-5).28 // GStreamerDebugLevel sets GStreamer debug verbosity (0-5).
29 GStreamerDebugLevel int29 GStreamerDebugLevel int
30- // VideoBufferSizeMB sets buffer size in megabytes for video streaming.
31- VideoBufferSizeMB int
32- // QueueBufferTimeSec sets queue buffer time in seconds for prebuffering.
33- QueueBufferTimeSec int
34 30
35 // --- WebKit compositor settings (from RenderingConfig) ---31 // --- WebKit compositor settings (from RenderingConfig) ---
36 DisableDMABufRenderer bool32 DisableDMABufRenderer bool
M internal/application/usecase/manage_panes.go +126 -75
@@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
112112 return nil, fmt.Errorf("target pane is required")
113113 }
114114
115- // If target is inside a stack and splitting left/right, split around the entire stack
115+ // If target is inside a stack, split around the entire stack
116116 // (i.e., create the new pane as a sibling to the stack, not inside it)
117+ // This applies to all directions - we don't want to nest split containers inside stacks.
117118 targetNode := input.TargetPane
118- if input.Direction == SplitLeft || input.Direction == SplitRight {
119- if targetNode.Parent != nil && targetNode.Parent.IsStacked {
120- // Promote to the stack container so split happens outside the stack
121- targetNode = targetNode.Parent
122- log.Debug().Msg("target is inside a stack, splitting around stack container")
123- }
119+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
120+ // Promote to the stack container so split happens outside the stack
121+ targetNode = targetNode.Parent
122+ log.Debug().Msg("target is inside a stack, splitting around stack container")
124123 }
125124
126125 // Create new pane
@@ -541,31 +540,13 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
541540 }
542541
543542 // Focus sets the active pane in the workspace.
543+// Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544544 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
545545 log := logging.FromContext(ctx)
546- if uc == nil {
547- return fmt.Errorf("manage panes use case is nil")
548- }
549546 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
550547
551- if ws == nil {
552- return fmt.Errorf("workspace is required")
553- }
554-
555- node := ws.FindPane(paneID)
556- if node == nil {
557- return fmt.Errorf("pane not found: %s", paneID)
558- }
559-
560- oldActive := ws.ActivePaneID
561- ws.ActivePaneID = paneID
562-
563- log.Info().
564- Str("from", string(oldActive)).
565- Str("to", string(paneID)).
566- Msg("focus changed")
567-
568- return nil
548+ _, err := uc.ApplyFocusChange(ctx, ws, paneID)
549+ return err
569550 }
570551
571552 // NavigateFocus moves focus to an adjacent pane.
@@ -619,12 +600,61 @@ type GeometricNavigationOutput struct {
619600 Found bool
620601 }
621602
603+// ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack.
604+// This is the canonical way to change focus to a pane, ensuring stack state is kept in sync.
605+func (uc *ManagePanesUseCase) ApplyFocusChange(
606+ ctx context.Context,
607+ ws *entity.Workspace,
608+ targetPaneID entity.PaneID,
609+) (*entity.PaneNode, error) {
610+ log := logging.FromContext(ctx)
611+ if uc == nil {
612+ return nil, fmt.Errorf("manage panes use case is nil")
613+ }
614+ if ws == nil {
615+ return nil, fmt.Errorf("workspace is required")
616+ }
617+
618+ targetNode := ws.FindPane(targetPaneID)
619+ if targetNode == nil {
620+ return nil, fmt.Errorf("pane not found: %s", targetPaneID)
621+ }
622+
623+ oldActive := ws.ActivePaneID
624+ ws.ActivePaneID = targetPaneID
625+
626+ // Update stack index if target is in a stack
627+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
628+ oldIndex := targetNode.Parent.ActiveStackIndex
629+ for i, child := range targetNode.Parent.Children {
630+ if child == targetNode {
631+ targetNode.Parent.ActiveStackIndex = i
632+ log.Debug().
633+ Str("target_pane_id", string(targetPaneID)).
634+ Int("old_stack_index", oldIndex).
635+ Int("new_stack_index", i).
636+ Int("num_children", len(targetNode.Parent.Children)).
637+ Msg("updated stack index for focus change")
638+ break
639+ }
640+ }
641+ }
642+
643+ log.Info().
644+ Str("from", string(oldActive)).
645+ Str("to", string(targetPaneID)).
646+ Msg("focus changed")
647+
648+ return targetNode, nil
649+}
650+
622651 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
623652 // Algorithm:
624-// 1. Get center of active pane
653+// 1. Get active pane rectangle
625654 // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
626-// 3. Score by: primary_distance * 1000 + perpendicular_distance
627-// 4. Return lowest scoring candidate
655+// 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
656+// 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
657+// 5. Return lowest scoring candidate
628658 func (uc *ManagePanesUseCase) NavigateFocusGeometric(
629659 ctx context.Context,
630660 input GeometricNavigationInput,
@@ -652,50 +682,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
652682 return &GeometricNavigationOutput{Found: false}, nil
653683 }
654684
655- acx, acy := activeRect.Center()
656-
657- type candidate struct {
658- paneID entity.PaneID
659- score int
660- }
661- var candidates []candidate
662-
663- for _, rect := range input.PaneRects {
664- if rect.PaneID == input.ActivePaneID {
665- continue
666- }
667-
668- cx, cy := rect.Center()
669- dx := cx - acx
670- dy := cy - acy
671-
672- var inDirection bool
673- var primaryDist, perpDist int
674-
675- switch input.Direction {
676- case NavLeft:
677- inDirection = dx < 0
678- primaryDist = abs(dx)
679- perpDist = abs(dy)
680- case NavRight:
681- inDirection = dx > 0
682- primaryDist = abs(dx)
683- perpDist = abs(dy)
684- case NavUp:
685- inDirection = dy < 0
686- primaryDist = abs(dy)
687- perpDist = abs(dx)
688- case NavDown:
689- inDirection = dy > 0
690- primaryDist = abs(dy)
691- perpDist = abs(dx)
692- }
693-
694- if inDirection {
695- score := primaryDist*1000 + perpDist
696- candidates = append(candidates, candidate{rect.PaneID, score})
697- }
698- }
685+ candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
699686
700687 if len(candidates) == 0 {
701688 log.Debug().Msg("no candidates in direction")
@@ -718,6 +705,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
718705 }, nil
719706 }
720707
708+// navCandidate represents a pane candidate for navigation with its score.
709+type navCandidate struct {
710+ paneID entity.PaneID
711+ score int
712+}
713+
714+// scoreNavigationCandidates scores all panes in the given direction from activeRect.
715+// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
716+func scoreNavigationCandidates(
717+ activeRect entity.PaneRect,
718+ paneRects []entity.PaneRect,
719+ direction NavigateDirection,
720+) []navCandidate {
721+ // Large penalty for panes without perpendicular overlap.
722+ // This ensures panes at the same level are always preferred.
723+ const noOverlapPenalty = 10_000_000
724+
725+ acx, acy := activeRect.Center()
726+ var candidates []navCandidate
727+
728+ for _, rect := range paneRects {
729+ if rect.PaneID == activeRect.PaneID {
730+ continue
731+ }
732+
733+ cx, cy := rect.Center()
734+ dx := cx - acx
735+ dy := cy - acy
736+
737+ inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
738+
739+ if inDirection {
740+ score := primaryDist*1000 + perpDist
741+ if !hasOverlap {
742+ score += noOverlapPenalty
743+ }
744+ candidates = append(candidates, navCandidate{rect.PaneID, score})
745+ }
746+ }
747+
748+ return candidates
749+}
750+
751+// evalDirection determines if a candidate rect is in the given direction from activeRect.
752+// Returns: inDirection, primaryDist, perpDist, hasOverlap
753+func evalDirection(
754+ activeRect, rect entity.PaneRect,
755+ dx, dy int,
756+ direction NavigateDirection,
757+) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
758+ switch direction {
759+ case NavLeft:
760+ return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
761+ case NavRight:
762+ return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
763+ case NavUp:
764+ return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
765+ case NavDown:
766+ return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
767+ default:
768+ return false, 0, 0, false
769+ }
770+}
771+
721772 // abs returns the absolute value of an integer.
722773 func abs(x int) int {
723774 if x < 0 {
@@ -112,15 +112,14 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
112 return nil, fmt.Errorf("target pane is required")112 return nil, fmt.Errorf("target pane is required")
113 }113 }
114 114
115- // If target is inside a stack and splitting left/right, split around the entire stack115+ // If target is inside a stack, split around the entire stack
116 // (i.e., create the new pane as a sibling to the stack, not inside it)116 // (i.e., create the new pane as a sibling to the stack, not inside it)
117+ // This applies to all directions - we don't want to nest split containers inside stacks.
117 targetNode := input.TargetPane118 targetNode := input.TargetPane
118- if input.Direction == SplitLeft || input.Direction == SplitRight {119+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
119- if targetNode.Parent != nil && targetNode.Parent.IsStacked {120+ // Promote to the stack container so split happens outside the stack
120- // Promote to the stack container so split happens outside the stack121+ targetNode = targetNode.Parent
121- targetNode = targetNode.Parent122+ log.Debug().Msg("target is inside a stack, splitting around stack container")
122- log.Debug().Msg("target is inside a stack, splitting around stack container")
123- }
124 }123 }
125 124
126 // Create new pane125 // Create new pane
@@ -541,31 +540,13 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
541 }540 }
542 541
543 // Focus sets the active pane in the workspace.542 // Focus sets the active pane in the workspace.
543+// Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {544 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
545 log := logging.FromContext(ctx)545 log := logging.FromContext(ctx)
546- if uc == nil {
547- return fmt.Errorf("manage panes use case is nil")
548- }
549 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")546 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
550 547
551- if ws == nil {548+ _, err := uc.ApplyFocusChange(ctx, ws, paneID)
552- return fmt.Errorf("workspace is required")549+ return err
553- }
554-
555- node := ws.FindPane(paneID)
556- if node == nil {
557- return fmt.Errorf("pane not found: %s", paneID)
558- }
559-
560- oldActive := ws.ActivePaneID
561- ws.ActivePaneID = paneID
562-
563- log.Info().
564- Str("from", string(oldActive)).
565- Str("to", string(paneID)).
566- Msg("focus changed")
567-
568- return nil
569 }550 }
570 551
571 // NavigateFocus moves focus to an adjacent pane.552 // NavigateFocus moves focus to an adjacent pane.
@@ -619,12 +600,61 @@ type GeometricNavigationOutput struct {
619 Found bool600 Found bool
620 }601 }
621 602
603+// ApplyFocusChange sets the active pane and updates the stack index if the target pane is in a stack.
604+// This is the canonical way to change focus to a pane, ensuring stack state is kept in sync.
605+func (uc *ManagePanesUseCase) ApplyFocusChange(
606+ ctx context.Context,
607+ ws *entity.Workspace,
608+ targetPaneID entity.PaneID,
609+) (*entity.PaneNode, error) {
610+ log := logging.FromContext(ctx)
611+ if uc == nil {
612+ return nil, fmt.Errorf("manage panes use case is nil")
613+ }
614+ if ws == nil {
615+ return nil, fmt.Errorf("workspace is required")
616+ }
617+
618+ targetNode := ws.FindPane(targetPaneID)
619+ if targetNode == nil {
620+ return nil, fmt.Errorf("pane not found: %s", targetPaneID)
621+ }
622+
623+ oldActive := ws.ActivePaneID
624+ ws.ActivePaneID = targetPaneID
625+
626+ // Update stack index if target is in a stack
627+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
628+ oldIndex := targetNode.Parent.ActiveStackIndex
629+ for i, child := range targetNode.Parent.Children {
630+ if child == targetNode {
631+ targetNode.Parent.ActiveStackIndex = i
632+ log.Debug().
633+ Str("target_pane_id", string(targetPaneID)).
634+ Int("old_stack_index", oldIndex).
635+ Int("new_stack_index", i).
636+ Int("num_children", len(targetNode.Parent.Children)).
637+ Msg("updated stack index for focus change")
638+ break
639+ }
640+ }
641+ }
642+
643+ log.Info().
644+ Str("from", string(oldActive)).
645+ Str("to", string(targetPaneID)).
646+ Msg("focus changed")
647+
648+ return targetNode, nil
649+}
650+
622 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.651 // NavigateFocusGeometric finds the nearest pane in the given direction using geometry.
623 // Algorithm:652 // Algorithm:
624-// 1. Get center of active pane653+// 1. Get active pane rectangle
625 // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)654 // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.)
626-// 3. Score by: primary_distance * 1000 + perpendicular_distance655+// 3. Prioritize panes with perpendicular overlap (same row for left/right, same column for up/down)
627-// 4. Return lowest scoring candidate656+// 4. Score by: overlap_penalty + primary_distance * 1000 + perpendicular_distance
657+// 5. Return lowest scoring candidate
628 func (uc *ManagePanesUseCase) NavigateFocusGeometric(658 func (uc *ManagePanesUseCase) NavigateFocusGeometric(
629 ctx context.Context,659 ctx context.Context,
630 input GeometricNavigationInput,660 input GeometricNavigationInput,
@@ -652,50 +682,7 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
652 return &GeometricNavigationOutput{Found: false}, nil682 return &GeometricNavigationOutput{Found: false}, nil
653 }683 }
654 684
655- acx, acy := activeRect.Center()685+ candidates := scoreNavigationCandidates(*activeRect, input.PaneRects, input.Direction)
656-
657- type candidate struct {
658- paneID entity.PaneID
659- score int
660- }
661- var candidates []candidate
662-
663- for _, rect := range input.PaneRects {
664- if rect.PaneID == input.ActivePaneID {
665- continue
666- }
667-
668- cx, cy := rect.Center()
669- dx := cx - acx
670- dy := cy - acy
671-
672- var inDirection bool
673- var primaryDist, perpDist int
674-
675- switch input.Direction {
676- case NavLeft:
677- inDirection = dx < 0
678- primaryDist = abs(dx)
679- perpDist = abs(dy)
680- case NavRight:
681- inDirection = dx > 0
682- primaryDist = abs(dx)
683- perpDist = abs(dy)
684- case NavUp:
685- inDirection = dy < 0
686- primaryDist = abs(dy)
687- perpDist = abs(dx)
688- case NavDown:
689- inDirection = dy > 0
690- primaryDist = abs(dy)
691- perpDist = abs(dx)
692- }
693-
694- if inDirection {
695- score := primaryDist*1000 + perpDist
696- candidates = append(candidates, candidate{rect.PaneID, score})
697- }
698- }
699 686
700 if len(candidates) == 0 {687 if len(candidates) == 0 {
701 log.Debug().Msg("no candidates in direction")688 log.Debug().Msg("no candidates in direction")
@@ -718,6 +705,70 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
718 }, nil705 }, nil
719 }706 }
720 707
708+// navCandidate represents a pane candidate for navigation with its score.
709+type navCandidate struct {
710+ paneID entity.PaneID
711+ score int
712+}
713+
714+// scoreNavigationCandidates scores all panes in the given direction from activeRect.
715+// Panes with perpendicular overlap are heavily preferred (same row for left/right, same column for up/down).
716+func scoreNavigationCandidates(
717+ activeRect entity.PaneRect,
718+ paneRects []entity.PaneRect,
719+ direction NavigateDirection,
720+) []navCandidate {
721+ // Large penalty for panes without perpendicular overlap.
722+ // This ensures panes at the same level are always preferred.
723+ const noOverlapPenalty = 10_000_000
724+
725+ acx, acy := activeRect.Center()
726+ var candidates []navCandidate
727+
728+ for _, rect := range paneRects {
729+ if rect.PaneID == activeRect.PaneID {
730+ continue
731+ }
732+
733+ cx, cy := rect.Center()
734+ dx := cx - acx
735+ dy := cy - acy
736+
737+ inDirection, primaryDist, perpDist, hasOverlap := evalDirection(activeRect, rect, dx, dy, direction)
738+
739+ if inDirection {
740+ score := primaryDist*1000 + perpDist
741+ if !hasOverlap {
742+ score += noOverlapPenalty
743+ }
744+ candidates = append(candidates, navCandidate{rect.PaneID, score})
745+ }
746+ }
747+
748+ return candidates
749+}
750+
751+// evalDirection determines if a candidate rect is in the given direction from activeRect.
752+// Returns: inDirection, primaryDist, perpDist, hasOverlap
753+func evalDirection(
754+ activeRect, rect entity.PaneRect,
755+ dx, dy int,
756+ direction NavigateDirection,
757+) (inDirection bool, primaryDist, perpDist int, hasOverlap bool) {
758+ switch direction {
759+ case NavLeft:
760+ return dx < 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
761+ case NavRight:
762+ return dx > 0, abs(dx), abs(dy), activeRect.OverlapsVertically(rect)
763+ case NavUp:
764+ return dy < 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
765+ case NavDown:
766+ return dy > 0, abs(dy), abs(dx), activeRect.OverlapsHorizontally(rect)
767+ default:
768+ return false, 0, 0, false
769+ }
770+}
771+
721 // abs returns the absolute value of an integer.772 // abs returns the absolute value of an integer.
722 func abs(x int) int {773 func abs(x int) int {
723 if x < 0 {774 if x < 0 {
M internal/bootstrap/webkit_stack.go +5 -2
@@ -120,6 +120,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
120120 settings := webkit.NewSettingsManager(ctx, cfg)
121121 injector := webkit.NewContentInjector(colorResolver)
122122
123+ // Set up auto-copy on selection config getter
124+ injector.SetAutoCopyConfigGetter(func() bool {
125+ return config.Get().Clipboard.AutoCopyOnSelection
126+ })
127+
123128 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
124129 themeCSSText := themeManager.GetWebUIThemeCSS()
125130 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -189,8 +194,6 @@ func configureRenderingEnvironment(
189194 ForceVSync: cfg.Media.ForceVSync,
190195 GLRenderingMode: string(cfg.Media.GLRenderingMode),
191196 GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
192- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
193- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
194197
195198 // WebKit compositor settings
196199 DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
@@ -120,6 +120,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
120 settings := webkit.NewSettingsManager(ctx, cfg)120 settings := webkit.NewSettingsManager(ctx, cfg)
121 injector := webkit.NewContentInjector(colorResolver)121 injector := webkit.NewContentInjector(colorResolver)
122 122
123+ // Set up auto-copy on selection config getter
124+ injector.SetAutoCopyConfigGetter(func() bool {
125+ return config.Get().Clipboard.AutoCopyOnSelection
126+ })
127+
123 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)128 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
124 themeCSSText := themeManager.GetWebUIThemeCSS()129 themeCSSText := themeManager.GetWebUIThemeCSS()
125 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {130 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -189,8 +194,6 @@ func configureRenderingEnvironment(
189 ForceVSync: cfg.Media.ForceVSync,194 ForceVSync: cfg.Media.ForceVSync,
190 GLRenderingMode: string(cfg.Media.GLRenderingMode),195 GLRenderingMode: string(cfg.Media.GLRenderingMode),
191 GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,196 GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
192- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
193- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
194 197
195 // WebKit compositor settings198 // WebKit compositor settings
196 DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,199 DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
M internal/domain/entity/geometry.go +14 -0
@@ -13,3 +13,17 @@ type PaneRect struct {
1313 func (r PaneRect) Center() (cx, cy int) {
1414 return r.X + r.W/2, r.Y + r.H/2
1515 }
16+
17+// OverlapsVertically returns true if the two rectangles overlap in the Y axis.
18+// Used for left/right navigation to prefer panes at the same vertical level.
19+func (r PaneRect) OverlapsVertically(other PaneRect) bool {
20+ // r spans [r.Y, r.Y+r.H), other spans [other.Y, other.Y+other.H)
21+ return r.Y < other.Y+other.H && other.Y < r.Y+r.H
22+}
23+
24+// OverlapsHorizontally returns true if the two rectangles overlap in the X axis.
25+// Used for up/down navigation to prefer panes at the same horizontal level.
26+func (r PaneRect) OverlapsHorizontally(other PaneRect) bool {
27+ // r spans [r.X, r.X+r.W), other spans [other.X, other.X+other.W)
28+ return r.X < other.X+other.W && other.X < r.X+r.W
29+}
@@ -13,3 +13,17 @@ type PaneRect struct {
13 func (r PaneRect) Center() (cx, cy int) {13 func (r PaneRect) Center() (cx, cy int) {
14 return r.X + r.W/2, r.Y + r.H/214 return r.X + r.W/2, r.Y + r.H/2
15 }15 }
16+
17+// OverlapsVertically returns true if the two rectangles overlap in the Y axis.
18+// Used for left/right navigation to prefer panes at the same vertical level.
19+func (r PaneRect) OverlapsVertically(other PaneRect) bool {
20+ // r spans [r.Y, r.Y+r.H), other spans [other.Y, other.Y+other.H)
21+ return r.Y < other.Y+other.H && other.Y < r.Y+r.H
22+}
23+
24+// OverlapsHorizontally returns true if the two rectangles overlap in the X axis.
25+// Used for up/down navigation to prefer panes at the same horizontal level.
26+func (r PaneRect) OverlapsHorizontally(other PaneRect) bool {
27+ // r spans [r.X, r.X+r.W), other spans [other.X, other.X+other.W)
28+ return r.X < other.X+other.W && other.X < r.X+r.W
29+}
M internal/infrastructure/config/defaults.go +3 -2
@@ -253,6 +253,9 @@ func DefaultConfig() *Config {
253253 Enabled: true, // Ad blocking enabled by default
254254 AutoUpdate: true, // Auto-update filters from GitHub releases
255255 },
256+ Clipboard: ClipboardConfig{
257+ AutoCopyOnSelection: true, // Enabled by default (zellij-style)
258+ },
256259 Omnibox: OmniboxConfig{
257260 InitialBehavior: defaultOmniboxInitialBehavior,
258261 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
@@ -279,8 +282,6 @@ func DefaultConfig() *Config {
279282 ForceVSync: false, // Let compositor handle VSync
280283 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
281284 GStreamerDebugLevel: 0, // Disabled by default
282- VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed
283- QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed
284285 },
285286 Runtime: RuntimeConfig{
286287 Prefix: "",
@@ -253,6 +253,9 @@ func DefaultConfig() *Config {
253 Enabled: true, // Ad blocking enabled by default253 Enabled: true, // Ad blocking enabled by default
254 AutoUpdate: true, // Auto-update filters from GitHub releases254 AutoUpdate: true, // Auto-update filters from GitHub releases
255 },255 },
256+ Clipboard: ClipboardConfig{
257+ AutoCopyOnSelection: true, // Enabled by default (zellij-style)
258+ },
256 Omnibox: OmniboxConfig{259 Omnibox: OmniboxConfig{
257 InitialBehavior: defaultOmniboxInitialBehavior,260 InitialBehavior: defaultOmniboxInitialBehavior,
258 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,261 AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
@@ -279,8 +282,6 @@ func DefaultConfig() *Config {
279 ForceVSync: false, // Let compositor handle VSync282 ForceVSync: false, // Let compositor handle VSync
280 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API283 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
281 GStreamerDebugLevel: 0, // Disabled by default284 GStreamerDebugLevel: 0, // Disabled by default
282- VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed
283- QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed
284 },285 },
285 Runtime: RuntimeConfig{286 Runtime: RuntimeConfig{
286 Prefix: "",287 Prefix: "",
M internal/infrastructure/config/loader.go +35 -2
@@ -1,6 +1,7 @@
11 package config
22
33 import (
4+ "context"
45 "errors"
56 "fmt"
67 "os"
@@ -8,6 +9,9 @@ import (
89 "strings"
910 "sync"
1011
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/infrastructure/fonts"
14+ "github.com/bnema/dumber/internal/logging"
1115 "github.com/spf13/viper"
1216 )
1317
@@ -302,6 +306,9 @@ func (m *Manager) createDefaultConfig() error {
302306 return err
303307 }
304308
309+ // Detect available fonts and override defaults before writing config.
310+ m.detectAndSetFonts()
311+
305312 // Ensure TOML format and write config
306313 m.viper.SetConfigType("toml")
307314 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
@@ -313,6 +320,29 @@ func (m *Manager) createDefaultConfig() error {
313320 return nil
314321 }
315322
323+// detectAndSetFonts detects available system fonts and sets the best available
324+// fonts from the fallback chains. This runs only during first-run config creation.
325+func (m *Manager) detectAndSetFonts() {
326+ // Create context with logger for debugging first-run font detection.
327+ logger := logging.NewFromEnv()
328+ ctx := logging.WithContext(context.Background(), logger)
329+
330+ detector := fonts.NewDetector()
331+
332+ if !detector.IsAvailable(ctx) {
333+ // fc-list not available, keep hardcoded defaults
334+ return
335+ }
336+
337+ sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
338+ serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
339+ monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
340+
341+ m.viper.Set("appearance.sans_font", sansFont)
342+ m.viper.Set("appearance.serif_font", serifFont)
343+ m.viper.Set("appearance.monospace_font", monoFont)
344+}
345+
316346 // setDefaults sets default configuration values in Viper.
317347 func (m *Manager) setDefaults() {
318348 defaults := DefaultConfig()
@@ -328,6 +358,7 @@ func (m *Manager) setDefaults() {
328358 m.setRenderingDefaults(defaults)
329359 m.setWorkspaceDefaults(defaults)
330360 m.setContentFilteringDefaults(defaults)
361+ m.setClipboardDefaults(defaults)
331362 m.setOmniboxDefaults(defaults)
332363 m.setMediaDefaults(defaults)
333364 m.setRuntimeDefaults(defaults)
@@ -443,6 +474,10 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) {
443474 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
444475 }
445476
477+func (m *Manager) setClipboardDefaults(defaults *Config) {
478+ m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
479+}
480+
446481 func (m *Manager) setOmniboxDefaults(defaults *Config) {
447482 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
448483 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
@@ -455,8 +490,6 @@ func (m *Manager) setMediaDefaults(defaults *Config) {
455490 m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync)
456491 m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode))
457492 m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel)
458- m.viper.SetDefault("media.video_buffer_size_mb", defaults.Media.VideoBufferSizeMB)
459- m.viper.SetDefault("media.queue_buffer_time_sec", defaults.Media.QueueBufferTimeSec)
460493 }
461494
462495 func (m *Manager) setRuntimeDefaults(defaults *Config) {
@@ -1,6 +1,7 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
6 "os"7 "os"
@@ -8,6 +9,9 @@ import (
8 "strings"9 "strings"
9 "sync"10 "sync"
10 11
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/infrastructure/fonts"
14+ "github.com/bnema/dumber/internal/logging"
11 "github.com/spf13/viper"15 "github.com/spf13/viper"
12 )16 )
13 17
@@ -302,6 +306,9 @@ func (m *Manager) createDefaultConfig() error {
302 return err306 return err
303 }307 }
304 308
309+ // Detect available fonts and override defaults before writing config.
310+ m.detectAndSetFonts()
311+
305 // Ensure TOML format and write config312 // Ensure TOML format and write config
306 m.viper.SetConfigType("toml")313 m.viper.SetConfigType("toml")
307 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {314 if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
@@ -313,6 +320,29 @@ func (m *Manager) createDefaultConfig() error {
313 return nil320 return nil
314 }321 }
315 322
323+// detectAndSetFonts detects available system fonts and sets the best available
324+// fonts from the fallback chains. This runs only during first-run config creation.
325+func (m *Manager) detectAndSetFonts() {
326+ // Create context with logger for debugging first-run font detection.
327+ logger := logging.NewFromEnv()
328+ ctx := logging.WithContext(context.Background(), logger)
329+
330+ detector := fonts.NewDetector()
331+
332+ if !detector.IsAvailable(ctx) {
333+ // fc-list not available, keep hardcoded defaults
334+ return
335+ }
336+
337+ sansFont := detector.SelectBestFont(ctx, port.FontCategorySansSerif, fonts.SansSerifFallbackChain())
338+ serifFont := detector.SelectBestFont(ctx, port.FontCategorySerif, fonts.SerifFallbackChain())
339+ monoFont := detector.SelectBestFont(ctx, port.FontCategoryMonospace, fonts.MonospaceFallbackChain())
340+
341+ m.viper.Set("appearance.sans_font", sansFont)
342+ m.viper.Set("appearance.serif_font", serifFont)
343+ m.viper.Set("appearance.monospace_font", monoFont)
344+}
345+
316 // setDefaults sets default configuration values in Viper.346 // setDefaults sets default configuration values in Viper.
317 func (m *Manager) setDefaults() {347 func (m *Manager) setDefaults() {
318 defaults := DefaultConfig()348 defaults := DefaultConfig()
@@ -328,6 +358,7 @@ func (m *Manager) setDefaults() {
328 m.setRenderingDefaults(defaults)358 m.setRenderingDefaults(defaults)
329 m.setWorkspaceDefaults(defaults)359 m.setWorkspaceDefaults(defaults)
330 m.setContentFilteringDefaults(defaults)360 m.setContentFilteringDefaults(defaults)
361+ m.setClipboardDefaults(defaults)
331 m.setOmniboxDefaults(defaults)362 m.setOmniboxDefaults(defaults)
332 m.setMediaDefaults(defaults)363 m.setMediaDefaults(defaults)
333 m.setRuntimeDefaults(defaults)364 m.setRuntimeDefaults(defaults)
@@ -443,6 +474,10 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) {
443 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)474 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
444 }475 }
445 476
477+func (m *Manager) setClipboardDefaults(defaults *Config) {
478+ m.viper.SetDefault("clipboard.auto_copy_on_selection", defaults.Clipboard.AutoCopyOnSelection)
479+}
480+
446 func (m *Manager) setOmniboxDefaults(defaults *Config) {481 func (m *Manager) setOmniboxDefaults(defaults *Config) {
447 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)482 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
448 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)483 m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
@@ -455,8 +490,6 @@ func (m *Manager) setMediaDefaults(defaults *Config) {
455 m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync)490 m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync)
456 m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode))491 m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode))
457 m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel)492 m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel)
458- m.viper.SetDefault("media.video_buffer_size_mb", defaults.Media.VideoBufferSizeMB)
459- m.viper.SetDefault("media.queue_buffer_time_sec", defaults.Media.QueueBufferTimeSec)
460 }493 }
461 494
462 func (m *Manager) setRuntimeDefaults(defaults *Config) {495 func (m *Manager) setRuntimeDefaults(defaults *Config) {
M internal/infrastructure/config/schema.go +11 -6
@@ -21,6 +21,8 @@ type Config struct {
2121 Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
2222 // ContentFiltering controls ad blocking and content filtering
2323 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
24+ // Clipboard controls clipboard-related behavior
25+ Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"`
2426 // Omnibox controls the omnibox behavior (initial history display)
2527 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
2628 // Rendering controls WebKit, GTK and compositor behavior.
@@ -165,12 +167,6 @@ type MediaConfig struct {
165167 GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"`
166168 // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity)
167169 GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"`
168- // VideoBufferSizeMB sets the video buffer size in megabytes for smoother streaming
169- // Higher values reduce rebuffering but use more memory. Default: 64 MB
170- VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb" toml:"video_buffer_size_mb"`
171- // QueueBufferTimeSec sets the queue buffer time in seconds
172- // Higher values allow more prebuffering for bursty streams. Default: 20 seconds
173- QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec" toml:"queue_buffer_time_sec"`
174170 }
175171
176172 // RuntimeConfig holds optional runtime overrides.
@@ -337,6 +333,15 @@ type ContentFilteringConfig struct {
337333 // Note: Whitelist is managed via database (content_whitelist table)
338334 }
339335
336+// ClipboardConfig holds clipboard-related behavior preferences
337+type ClipboardConfig struct {
338+ // AutoCopyOnSelection automatically copies selected text to clipboard (zellij-style).
339+ // When enabled, selecting text in a webview immediately copies it to clipboard.
340+ // Does not apply to text selection in input fields or textareas.
341+ // Default: true
342+ AutoCopyOnSelection bool `mapstructure:"auto_copy_on_selection" yaml:"auto_copy_on_selection" toml:"auto_copy_on_selection" json:"autoCopyOnSelection"` //nolint:lll // struct tags must stay on one line
343+}
344+
340345 // OmniboxConfig holds omnibox behavior preferences
341346 type OmniboxConfig struct {
342347 // InitialBehavior controls what to show when omnibox opens with empty input
@@ -21,6 +21,8 @@ type Config struct {
21 Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`21 Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
22 // ContentFiltering controls ad blocking and content filtering22 // ContentFiltering controls ad blocking and content filtering
23 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`23 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
24+ // Clipboard controls clipboard-related behavior
25+ Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"`
24 // Omnibox controls the omnibox behavior (initial history display)26 // Omnibox controls the omnibox behavior (initial history display)
25 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`27 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
26 // Rendering controls WebKit, GTK and compositor behavior.28 // Rendering controls WebKit, GTK and compositor behavior.
@@ -165,12 +167,6 @@ type MediaConfig struct {
165 GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"`167 GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"`
166 // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity)168 // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity)
167 GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"`169 GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"`
168- // VideoBufferSizeMB sets the video buffer size in megabytes for smoother streaming
169- // Higher values reduce rebuffering but use more memory. Default: 64 MB
170- VideoBufferSizeMB int `mapstructure:"video_buffer_size_mb" yaml:"video_buffer_size_mb" toml:"video_buffer_size_mb"`
171- // QueueBufferTimeSec sets the queue buffer time in seconds
172- // Higher values allow more prebuffering for bursty streams. Default: 20 seconds
173- QueueBufferTimeSec int `mapstructure:"queue_buffer_time_sec" yaml:"queue_buffer_time_sec" toml:"queue_buffer_time_sec"`
174 }170 }
175 171
176 // RuntimeConfig holds optional runtime overrides.172 // RuntimeConfig holds optional runtime overrides.
@@ -337,6 +333,15 @@ type ContentFilteringConfig struct {
337 // Note: Whitelist is managed via database (content_whitelist table)333 // Note: Whitelist is managed via database (content_whitelist table)
338 }334 }
339 335
336+// ClipboardConfig holds clipboard-related behavior preferences
337+type ClipboardConfig struct {
338+ // AutoCopyOnSelection automatically copies selected text to clipboard (zellij-style).
339+ // When enabled, selecting text in a webview immediately copies it to clipboard.
340+ // Does not apply to text selection in input fields or textareas.
341+ // Default: true
342+ AutoCopyOnSelection bool `mapstructure:"auto_copy_on_selection" yaml:"auto_copy_on_selection" toml:"auto_copy_on_selection" json:"autoCopyOnSelection"` //nolint:lll // struct tags must stay on one line
343+}
344+
340 // OmniboxConfig holds omnibox behavior preferences345 // OmniboxConfig holds omnibox behavior preferences
341 type OmniboxConfig struct {346 type OmniboxConfig struct {
342 // InitialBehavior controls what to show when omnibox opens with empty input347 // InitialBehavior controls what to show when omnibox opens with empty input
M internal/infrastructure/config/schema_provider.go +139 -0
@@ -14,6 +14,7 @@ const (
1414 SectionWorkspace = "Workspace"
1515 SectionSession = "Session"
1616 SectionOmnibox = "Omnibox"
17+ SectionClipboard = "Clipboard"
1718 SectionContentFiltering = "Content Filtering"
1819 SectionRendering = "Rendering"
1920 SectionMedia = "Media"
@@ -64,6 +65,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
6465 // Omnibox section
6566 keys = append(keys, p.getOmniboxKeys(defaults)...)
6667
68+ // Clipboard section
69+ keys = append(keys, p.getClipboardKeys(defaults)...)
70+
6771 // Content Filtering section
6872 keys = append(keys, p.getContentFilteringKeys(defaults)...)
6973
@@ -459,6 +463,34 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
459463 Description: "Global shortcut to switch to previous tab",
460464 Section: SectionWorkspace,
461465 },
466+ {
467+ Key: "workspace.shortcuts.consume_or_expel_left",
468+ Type: "string",
469+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft,
470+ Description: "Consume into left sibling stack or expel left (alpha)",
471+ Section: SectionWorkspace,
472+ },
473+ {
474+ Key: "workspace.shortcuts.consume_or_expel_right",
475+ Type: "string",
476+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight,
477+ Description: "Consume into right sibling stack or expel right (alpha)",
478+ Section: SectionWorkspace,
479+ },
480+ {
481+ Key: "workspace.shortcuts.consume_or_expel_up",
482+ Type: "string",
483+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp,
484+ Description: "Consume into upper sibling stack or expel up (alpha)",
485+ Section: SectionWorkspace,
486+ },
487+ {
488+ Key: "workspace.shortcuts.consume_or_expel_down",
489+ Type: "string",
490+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown,
491+ Description: "Consume into lower sibling stack or expel down (alpha)",
492+ Section: SectionWorkspace,
493+ },
462494 // Popups
463495 {
464496 Key: "workspace.popups.behavior",
@@ -679,6 +711,18 @@ func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.Config
679711 }
680712 }
681713
714+func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo {
715+ return []entity.ConfigKeyInfo{
716+ {
717+ Key: "clipboard.auto_copy_on_selection",
718+ Type: "bool",
719+ Default: fmt.Sprintf("%t", defaults.Clipboard.AutoCopyOnSelection),
720+ Description: "Auto-copy selected text to clipboard (zellij-style)",
721+ Section: SectionClipboard,
722+ },
723+ }
724+}
725+
682726 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {
683727 return []entity.ConfigKeyInfo{
684728 {
@@ -853,6 +897,14 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
853897
854898 func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo {
855899 return []entity.ConfigKeyInfo{
900+ {
901+ Key: "performance.profile",
902+ Type: "string",
903+ Default: string(defaults.Performance.Profile),
904+ Description: "Performance profile preset (custom enables manual tuning)",
905+ Values: []string{"default", "lite", "max", "custom"},
906+ Section: SectionPerformance,
907+ },
856908 {
857909 Key: "performance.zoom_cache_size",
858910 Type: "int",
@@ -869,6 +921,93 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
869921 Range: ">=0",
870922 Section: SectionPerformance,
871923 },
924+ {
925+ Key: "performance.skia_cpu_painting_threads",
926+ Type: "int",
927+ Default: fmt.Sprintf("%d", defaults.Performance.SkiaCPUPaintingThreads),
928+ Description: "Skia CPU rendering threads (0=unset, custom profile only)",
929+ Range: ">=0",
930+ Section: SectionPerformance,
931+ },
932+ {
933+ Key: "performance.skia_gpu_painting_threads",
934+ Type: "int",
935+ Default: fmt.Sprintf("%d", defaults.Performance.SkiaGPUPaintingThreads),
936+ Description: "Skia GPU rendering threads (-1=unset, 0=disable, custom profile only)",
937+ Range: ">=-1",
938+ Section: SectionPerformance,
939+ },
940+ {
941+ Key: "performance.skia_enable_cpu_rendering",
942+ Type: "bool",
943+ Default: fmt.Sprintf("%t", defaults.Performance.SkiaEnableCPURendering),
944+ Description: "Force CPU rendering (custom profile only)",
945+ Section: SectionPerformance,
946+ },
947+ {
948+ Key: "performance.web_process_memory_limit_mb",
949+ Type: "int",
950+ Default: fmt.Sprintf("%d", defaults.Performance.WebProcessMemoryLimitMB),
951+ Description: "Web process memory limit in MB (0=unset, custom profile only)",
952+ Range: ">=0",
953+ Section: SectionPerformance,
954+ },
955+ {
956+ Key: "performance.web_process_memory_poll_interval_sec",
957+ Type: "float64",
958+ Default: fmt.Sprintf("%.1f", defaults.Performance.WebProcessMemoryPollIntervalSec),
959+ Description: "Memory check interval in seconds (0=default 30s, custom profile only)",
960+ Range: ">=0",
961+ Section: SectionPerformance,
962+ },
963+ {
964+ Key: "performance.web_process_memory_conservative_threshold",
965+ Type: "float64",
966+ Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryConservativeThreshold),
967+ Description: "Conservative memory cleanup threshold (0-1, custom profile only)",
968+ Range: "0-1",
969+ Section: SectionPerformance,
970+ },
971+ {
972+ Key: "performance.web_process_memory_strict_threshold",
973+ Type: "float64",
974+ Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryStrictThreshold),
975+ Description: "Strict memory cleanup threshold (0-1, custom profile only)",
976+ Range: "0-1",
977+ Section: SectionPerformance,
978+ },
979+ {
980+ Key: "performance.network_process_memory_limit_mb",
981+ Type: "int",
982+ Default: fmt.Sprintf("%d", defaults.Performance.NetworkProcessMemoryLimitMB),
983+ Description: "Network process memory limit in MB (0=unset, custom profile only)",
984+ Range: ">=0",
985+ Section: SectionPerformance,
986+ },
987+ {
988+ Key: "performance.network_process_memory_poll_interval_sec",
989+ Type: "float64",
990+ Default: fmt.Sprintf("%.1f", defaults.Performance.NetworkProcessMemoryPollIntervalSec),
991+ Description: "Network memory check interval in seconds (custom profile only)",
992+ Range: ">=0",
993+ Section: SectionPerformance,
994+ },
995+ {
996+ Key: "performance.network_process_memory_conservative_threshold",
997+ Type: "float64",
998+ Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryConservativeThreshold),
999+ Description: "Network conservative memory threshold (0-1, custom profile only)",
1000+ Range: "0-1",
1001+ Section: SectionPerformance,
1002+ },
1003+ {
1004+ Key: "performance.network_process_memory_strict_threshold",
1005+ Type: "float64",
1006+ Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryStrictThreshold),
1007+ Description: "Network strict memory threshold (0-1, custom profile only)",
1008+ Range: "0-1",
1009+ Section: SectionPerformance,
1010+ },
8721011 }
8731012 }
8741013
@@ -14,6 +14,7 @@ const (
14 SectionWorkspace = "Workspace"14 SectionWorkspace = "Workspace"
15 SectionSession = "Session"15 SectionSession = "Session"
16 SectionOmnibox = "Omnibox"16 SectionOmnibox = "Omnibox"
17+ SectionClipboard = "Clipboard"
17 SectionContentFiltering = "Content Filtering"18 SectionContentFiltering = "Content Filtering"
18 SectionRendering = "Rendering"19 SectionRendering = "Rendering"
19 SectionMedia = "Media"20 SectionMedia = "Media"
@@ -64,6 +65,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
64 // Omnibox section65 // Omnibox section
65 keys = append(keys, p.getOmniboxKeys(defaults)...)66 keys = append(keys, p.getOmniboxKeys(defaults)...)
66 67
68+ // Clipboard section
69+ keys = append(keys, p.getClipboardKeys(defaults)...)
70+
67 // Content Filtering section71 // Content Filtering section
68 keys = append(keys, p.getContentFilteringKeys(defaults)...)72 keys = append(keys, p.getContentFilteringKeys(defaults)...)
69 73
@@ -459,6 +463,34 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
459 Description: "Global shortcut to switch to previous tab",463 Description: "Global shortcut to switch to previous tab",
460 Section: SectionWorkspace,464 Section: SectionWorkspace,
461 },465 },
466+ {
467+ Key: "workspace.shortcuts.consume_or_expel_left",
468+ Type: "string",
469+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelLeft,
470+ Description: "Consume into left sibling stack or expel left (alpha)",
471+ Section: SectionWorkspace,
472+ },
473+ {
474+ Key: "workspace.shortcuts.consume_or_expel_right",
475+ Type: "string",
476+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelRight,
477+ Description: "Consume into right sibling stack or expel right (alpha)",
478+ Section: SectionWorkspace,
479+ },
480+ {
481+ Key: "workspace.shortcuts.consume_or_expel_up",
482+ Type: "string",
483+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelUp,
484+ Description: "Consume into upper sibling stack or expel up (alpha)",
485+ Section: SectionWorkspace,
486+ },
487+ {
488+ Key: "workspace.shortcuts.consume_or_expel_down",
489+ Type: "string",
490+ Default: defaults.Workspace.Shortcuts.ConsumeOrExpelDown,
491+ Description: "Consume into lower sibling stack or expel down (alpha)",
492+ Section: SectionWorkspace,
493+ },
462 // Popups494 // Popups
463 {495 {
464 Key: "workspace.popups.behavior",496 Key: "workspace.popups.behavior",
@@ -679,6 +711,18 @@ func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.Config
679 }711 }
680 }712 }
681 713
714+func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo {
715+ return []entity.ConfigKeyInfo{
716+ {
717+ Key: "clipboard.auto_copy_on_selection",
718+ Type: "bool",
719+ Default: fmt.Sprintf("%t", defaults.Clipboard.AutoCopyOnSelection),
720+ Description: "Auto-copy selected text to clipboard (zellij-style)",
721+ Section: SectionClipboard,
722+ },
723+ }
724+}
725+
682 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {726 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {
683 return []entity.ConfigKeyInfo{727 return []entity.ConfigKeyInfo{
684 {728 {
@@ -853,6 +897,14 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
853 897
854 func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo {898 func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo {
855 return []entity.ConfigKeyInfo{899 return []entity.ConfigKeyInfo{
900+ {
901+ Key: "performance.profile",
902+ Type: "string",
903+ Default: string(defaults.Performance.Profile),
904+ Description: "Performance profile preset (custom enables manual tuning)",
905+ Values: []string{"default", "lite", "max", "custom"},
906+ Section: SectionPerformance,
907+ },
856 {908 {
857 Key: "performance.zoom_cache_size",909 Key: "performance.zoom_cache_size",
858 Type: "int",910 Type: "int",
@@ -869,6 +921,93 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
869 Range: ">=0",921 Range: ">=0",
870 Section: SectionPerformance,922 Section: SectionPerformance,
871 },923 },
924+ {
925+ Key: "performance.skia_cpu_painting_threads",
926+ Type: "int",
927+ Default: fmt.Sprintf("%d", defaults.Performance.SkiaCPUPaintingThreads),
928+ Description: "Skia CPU rendering threads (0=unset, custom profile only)",
929+ Range: ">=0",
930+ Section: SectionPerformance,
931+ },
932+ {
933+ Key: "performance.skia_gpu_painting_threads",
934+ Type: "int",
935+ Default: fmt.Sprintf("%d", defaults.Performance.SkiaGPUPaintingThreads),
936+ Description: "Skia GPU rendering threads (-1=unset, 0=disable, custom profile only)",
937+ Range: ">=-1",
938+ Section: SectionPerformance,
939+ },
940+ {
941+ Key: "performance.skia_enable_cpu_rendering",
942+ Type: "bool",
943+ Default: fmt.Sprintf("%t", defaults.Performance.SkiaEnableCPURendering),
944+ Description: "Force CPU rendering (custom profile only)",
945+ Section: SectionPerformance,
946+ },
947+ {
948+ Key: "performance.web_process_memory_limit_mb",
949+ Type: "int",
950+ Default: fmt.Sprintf("%d", defaults.Performance.WebProcessMemoryLimitMB),
951+ Description: "Web process memory limit in MB (0=unset, custom profile only)",
952+ Range: ">=0",
953+ Section: SectionPerformance,
954+ },
955+ {
956+ Key: "performance.web_process_memory_poll_interval_sec",
957+ Type: "float64",
958+ Default: fmt.Sprintf("%.1f", defaults.Performance.WebProcessMemoryPollIntervalSec),
959+ Description: "Memory check interval in seconds (0=default 30s, custom profile only)",
960+ Range: ">=0",
961+ Section: SectionPerformance,
962+ },
963+ {
964+ Key: "performance.web_process_memory_conservative_threshold",
965+ Type: "float64",
966+ Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryConservativeThreshold),
967+ Description: "Conservative memory cleanup threshold (0-1, custom profile only)",
968+ Range: "0-1",
969+ Section: SectionPerformance,
970+ },
971+ {
972+ Key: "performance.web_process_memory_strict_threshold",
973+ Type: "float64",
974+ Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryStrictThreshold),
975+ Description: "Strict memory cleanup threshold (0-1, custom profile only)",
976+ Range: "0-1",
977+ Section: SectionPerformance,
978+ },
979+ {
980+ Key: "performance.network_process_memory_limit_mb",
981+ Type: "int",
982+ Default: fmt.Sprintf("%d", defaults.Performance.NetworkProcessMemoryLimitMB),
983+ Description: "Network process memory limit in MB (0=unset, custom profile only)",
984+ Range: ">=0",
985+ Section: SectionPerformance,
986+ },
987+ {
988+ Key: "performance.network_process_memory_poll_interval_sec",
989+ Type: "float64",
990+ Default: fmt.Sprintf("%.1f", defaults.Performance.NetworkProcessMemoryPollIntervalSec),
991+ Description: "Network memory check interval in seconds (custom profile only)",
992+ Range: ">=0",
993+ Section: SectionPerformance,
994+ },
995+ {
996+ Key: "performance.network_process_memory_conservative_threshold",
997+ Type: "float64",
998+ Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryConservativeThreshold),
999+ Description: "Network conservative memory threshold (0-1, custom profile only)",
1000+ Range: "0-1",
1001+ Section: SectionPerformance,
1002+ },
1003+ {
1004+ Key: "performance.network_process_memory_strict_threshold",
1005+ Type: "float64",
1006+ Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryStrictThreshold),
1007+ Description: "Network strict memory threshold (0-1, custom profile only)",
1008+ Range: "0-1",
1009+ Section: SectionPerformance,
1010+ },
872 }1011 }
873 }1012 }
874 1013
A internal/infrastructure/fonts/detector.go +217 -0
@@ -0,0 +1,217 @@
1+package fonts
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os/exec"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Fallback chains for each font category (unexported to prevent modification).
15+var (
16+ sansSerifFallbackChain = []string{
17+ "Fira Sans",
18+ "Noto Sans",
19+ "DejaVu Sans",
20+ "Liberation Sans",
21+ "FreeSans",
22+ }
23+
24+ serifFallbackChain = []string{
25+ "Noto Serif",
26+ "DejaVu Serif",
27+ "Liberation Serif",
28+ "FreeSerif",
29+ }
30+
31+ monospaceFallbackChain = []string{
32+ "Fira Code",
33+ "JetBrains Mono",
34+ "Noto Sans Mono",
35+ "DejaVu Sans Mono",
36+ "Liberation Mono",
37+ "FreeMono",
38+ }
39+)
40+
41+// SansSerifFallbackChain returns the fallback chain for sans-serif fonts.
42+func SansSerifFallbackChain() []string {
43+ result := make([]string, len(sansSerifFallbackChain))
44+ copy(result, sansSerifFallbackChain)
45+ return result
46+}
47+
48+// SerifFallbackChain returns the fallback chain for serif fonts.
49+func SerifFallbackChain() []string {
50+ result := make([]string, len(serifFallbackChain))
51+ copy(result, serifFallbackChain)
52+ return result
53+}
54+
55+// MonospaceFallbackChain returns the fallback chain for monospace fonts.
56+func MonospaceFallbackChain() []string {
57+ result := make([]string, len(monospaceFallbackChain))
58+ copy(result, monospaceFallbackChain)
59+ return result
60+}
61+
62+// Generic CSS fallbacks when no fonts from the chain are found.
63+const (
64+ genericSansSerif = "sans-serif"
65+ genericSerif = "serif"
66+ genericMonospace = "monospace"
67+)
68+
69+// Detector implements port.FontDetector using fontconfig's fc-list command.
70+type Detector struct {
71+ mu sync.RWMutex
72+ cachedFonts []string
73+ cachePopulated bool
74+}
75+
76+// NewDetector creates a new font detector.
77+func NewDetector() *Detector {
78+ return &Detector{}
79+}
80+
81+// IsAvailable implements port.FontDetector.
82+// Returns true if fc-list command is available on the system.
83+func (*Detector) IsAvailable(_ context.Context) bool {
84+ _, err := exec.LookPath("fc-list")
85+ return err == nil
86+}
87+
88+// GetAvailableFonts implements port.FontDetector.
89+// Returns a list of font family names installed on the system.
90+func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) {
91+ log := logging.FromContext(ctx)
92+
93+ d.mu.RLock()
94+ if d.cachePopulated {
95+ fonts := d.cachedFonts
96+ d.mu.RUnlock()
97+ return fonts, nil
98+ }
99+ d.mu.RUnlock()
100+
101+ d.mu.Lock()
102+ defer d.mu.Unlock()
103+
104+ // Double-check after acquiring write lock.
105+ if d.cachePopulated {
106+ return d.cachedFonts, nil
107+ }
108+
109+ fonts, err := d.queryFonts(ctx)
110+ if err != nil {
111+ log.Debug().Err(err).Msg("failed to query system fonts")
112+ return nil, err
113+ }
114+
115+ d.cachedFonts = fonts
116+ d.cachePopulated = true
117+ log.Debug().Int("count", len(fonts)).Msg("cached system fonts")
118+
119+ return fonts, nil
120+}
121+
122+// SelectBestFont implements port.FontDetector.
123+// Returns the first available font from the fallback chain for the given category.
124+func (d *Detector) SelectBestFont(
125+ ctx context.Context,
126+ category port.FontCategory,
127+ fallbackChain []string,
128+) string {
129+ log := logging.FromContext(ctx)
130+
131+ availableFonts, err := d.GetAvailableFonts(ctx)
132+ if err != nil {
133+ log.Debug().
134+ Str("category", string(category)).
135+ Err(err).
136+ Msg("font detection unavailable, using generic fallback")
137+ return d.genericFallback(category)
138+ }
139+
140+ // Build a set for O(1) lookup.
141+ fontSet := make(map[string]struct{}, len(availableFonts))
142+ for _, f := range availableFonts {
143+ fontSet[f] = struct{}{}
144+ }
145+
146+ // Find the first available font in the chain.
147+ for _, font := range fallbackChain {
148+ if _, exists := fontSet[font]; exists {
149+ log.Debug().
150+ Str("category", string(category)).
151+ Str("font", font).
152+ Msg("selected font from fallback chain")
153+ return font
154+ }
155+ }
156+
157+ // No fonts from chain found, use generic fallback.
158+ generic := d.genericFallback(category)
159+ log.Debug().
160+ Str("category", string(category)).
161+ Str("fallback", generic).
162+ Msg("no fonts from fallback chain available, using generic")
163+ return generic
164+}
165+
166+// queryFonts executes fc-list and parses the output.
167+func (*Detector) queryFonts(ctx context.Context) ([]string, error) {
168+ cmd := exec.CommandContext(ctx, "fc-list", ":", "family")
169+ output, err := cmd.Output()
170+ if err != nil {
171+ return nil, err
172+ }
173+
174+ fontSet := make(map[string]struct{})
175+ scanner := bufio.NewScanner(strings.NewReader(string(output)))
176+ for scanner.Scan() {
177+ line := strings.TrimSpace(scanner.Text())
178+ if line == "" {
179+ continue
180+ }
181+ // fc-list may return comma-separated families for fonts with aliases.
182+ // e.g., "DejaVu Sans,DejaVu Sans Light"
183+ families := strings.Split(line, ",")
184+ for _, family := range families {
185+ family = strings.TrimSpace(family)
186+ if family != "" {
187+ fontSet[family] = struct{}{}
188+ }
189+ }
190+ }
191+
192+ if err := scanner.Err(); err != nil {
193+ return nil, err
194+ }
195+
196+ // Convert set to slice.
197+ fonts := make([]string, 0, len(fontSet))
198+ for font := range fontSet {
199+ fonts = append(fonts, font)
200+ }
201+
202+ return fonts, nil
203+}
204+
205+// genericFallback returns the CSS generic fallback for the given category.
206+func (*Detector) genericFallback(category port.FontCategory) string {
207+ switch category {
208+ case port.FontCategorySansSerif:
209+ return genericSansSerif
210+ case port.FontCategorySerif:
211+ return genericSerif
212+ case port.FontCategoryMonospace:
213+ return genericMonospace
214+ default:
215+ return genericSansSerif
216+ }
217+}
@@ -0,0 +1,217 @@
1+package fonts
2+
3+import (
4+ "bufio"
5+ "context"
6+ "os/exec"
7+ "strings"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// Fallback chains for each font category (unexported to prevent modification).
15+var (
16+ sansSerifFallbackChain = []string{
17+ "Fira Sans",
18+ "Noto Sans",
19+ "DejaVu Sans",
20+ "Liberation Sans",
21+ "FreeSans",
22+ }
23+
24+ serifFallbackChain = []string{
25+ "Noto Serif",
26+ "DejaVu Serif",
27+ "Liberation Serif",
28+ "FreeSerif",
29+ }
30+
31+ monospaceFallbackChain = []string{
32+ "Fira Code",
33+ "JetBrains Mono",
34+ "Noto Sans Mono",
35+ "DejaVu Sans Mono",
36+ "Liberation Mono",
37+ "FreeMono",
38+ }
39+)
40+
41+// SansSerifFallbackChain returns the fallback chain for sans-serif fonts.
42+func SansSerifFallbackChain() []string {
43+ result := make([]string, len(sansSerifFallbackChain))
44+ copy(result, sansSerifFallbackChain)
45+ return result
46+}
47+
48+// SerifFallbackChain returns the fallback chain for serif fonts.
49+func SerifFallbackChain() []string {
50+ result := make([]string, len(serifFallbackChain))
51+ copy(result, serifFallbackChain)
52+ return result
53+}
54+
55+// MonospaceFallbackChain returns the fallback chain for monospace fonts.
56+func MonospaceFallbackChain() []string {
57+ result := make([]string, len(monospaceFallbackChain))
58+ copy(result, monospaceFallbackChain)
59+ return result
60+}
61+
62+// Generic CSS fallbacks when no fonts from the chain are found.
63+const (
64+ genericSansSerif = "sans-serif"
65+ genericSerif = "serif"
66+ genericMonospace = "monospace"
67+)
68+
69+// Detector implements port.FontDetector using fontconfig's fc-list command.
70+type Detector struct {
71+ mu sync.RWMutex
72+ cachedFonts []string
73+ cachePopulated bool
74+}
75+
76+// NewDetector creates a new font detector.
77+func NewDetector() *Detector {
78+ return &Detector{}
79+}
80+
81+// IsAvailable implements port.FontDetector.
82+// Returns true if fc-list command is available on the system.
83+func (*Detector) IsAvailable(_ context.Context) bool {
84+ _, err := exec.LookPath("fc-list")
85+ return err == nil
86+}
87+
88+// GetAvailableFonts implements port.FontDetector.
89+// Returns a list of font family names installed on the system.
90+func (d *Detector) GetAvailableFonts(ctx context.Context) ([]string, error) {
91+ log := logging.FromContext(ctx)
92+
93+ d.mu.RLock()
94+ if d.cachePopulated {
95+ fonts := d.cachedFonts
96+ d.mu.RUnlock()
97+ return fonts, nil
98+ }
99+ d.mu.RUnlock()
100+
101+ d.mu.Lock()
102+ defer d.mu.Unlock()
103+
104+ // Double-check after acquiring write lock.
105+ if d.cachePopulated {
106+ return d.cachedFonts, nil
107+ }
108+
109+ fonts, err := d.queryFonts(ctx)
110+ if err != nil {
111+ log.Debug().Err(err).Msg("failed to query system fonts")
112+ return nil, err
113+ }
114+
115+ d.cachedFonts = fonts
116+ d.cachePopulated = true
117+ log.Debug().Int("count", len(fonts)).Msg("cached system fonts")
118+
119+ return fonts, nil
120+}
121+
122+// SelectBestFont implements port.FontDetector.
123+// Returns the first available font from the fallback chain for the given category.
124+func (d *Detector) SelectBestFont(
125+ ctx context.Context,
126+ category port.FontCategory,
127+ fallbackChain []string,
128+) string {
129+ log := logging.FromContext(ctx)
130+
131+ availableFonts, err := d.GetAvailableFonts(ctx)
132+ if err != nil {
133+ log.Debug().
134+ Str("category", string(category)).
135+ Err(err).
136+ Msg("font detection unavailable, using generic fallback")
137+ return d.genericFallback(category)
138+ }
139+
140+ // Build a set for O(1) lookup.
141+ fontSet := make(map[string]struct{}, len(availableFonts))
142+ for _, f := range availableFonts {
143+ fontSet[f] = struct{}{}
144+ }
145+
146+ // Find the first available font in the chain.
147+ for _, font := range fallbackChain {
148+ if _, exists := fontSet[font]; exists {
149+ log.Debug().
150+ Str("category", string(category)).
151+ Str("font", font).
152+ Msg("selected font from fallback chain")
153+ return font
154+ }
155+ }
156+
157+ // No fonts from chain found, use generic fallback.
158+ generic := d.genericFallback(category)
159+ log.Debug().
160+ Str("category", string(category)).
161+ Str("fallback", generic).
162+ Msg("no fonts from fallback chain available, using generic")
163+ return generic
164+}
165+
166+// queryFonts executes fc-list and parses the output.
167+func (*Detector) queryFonts(ctx context.Context) ([]string, error) {
168+ cmd := exec.CommandContext(ctx, "fc-list", ":", "family")
169+ output, err := cmd.Output()
170+ if err != nil {
171+ return nil, err
172+ }
173+
174+ fontSet := make(map[string]struct{})
175+ scanner := bufio.NewScanner(strings.NewReader(string(output)))
176+ for scanner.Scan() {
177+ line := strings.TrimSpace(scanner.Text())
178+ if line == "" {
179+ continue
180+ }
181+ // fc-list may return comma-separated families for fonts with aliases.
182+ // e.g., "DejaVu Sans,DejaVu Sans Light"
183+ families := strings.Split(line, ",")
184+ for _, family := range families {
185+ family = strings.TrimSpace(family)
186+ if family != "" {
187+ fontSet[family] = struct{}{}
188+ }
189+ }
190+ }
191+
192+ if err := scanner.Err(); err != nil {
193+ return nil, err
194+ }
195+
196+ // Convert set to slice.
197+ fonts := make([]string, 0, len(fontSet))
198+ for font := range fontSet {
199+ fonts = append(fonts, font)
200+ }
201+
202+ return fonts, nil
203+}
204+
205+// genericFallback returns the CSS generic fallback for the given category.
206+func (*Detector) genericFallback(category port.FontCategory) string {
207+ switch category {
208+ case port.FontCategorySansSerif:
209+ return genericSansSerif
210+ case port.FontCategorySerif:
211+ return genericSerif
212+ case port.FontCategoryMonospace:
213+ return genericMonospace
214+ default:
215+ return genericSansSerif
216+ }
217+}
A internal/infrastructure/fonts/detector_test.go +207 -0
@@ -0,0 +1,207 @@
1+package fonts
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/rs/zerolog"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func testContext() context.Context {
14+ logger := zerolog.Nop()
15+ return logger.WithContext(context.Background())
16+}
17+
18+func TestDetector_IsAvailable(t *testing.T) {
19+ ctx := testContext()
20+ detector := NewDetector()
21+
22+ // fc-list should be available on most Linux systems
23+ // This test documents the behavior rather than asserting a specific value
24+ available := detector.IsAvailable(ctx)
25+ t.Logf("fc-list available: %v", available)
26+}
27+
28+func TestDetector_GetAvailableFonts(t *testing.T) {
29+ ctx := testContext()
30+ detector := NewDetector()
31+
32+ if !detector.IsAvailable(ctx) {
33+ t.Skip("fc-list not available on this system")
34+ }
35+
36+ fonts, err := detector.GetAvailableFonts(ctx)
37+ require.NoError(t, err)
38+ assert.NotEmpty(t, fonts, "expected at least some fonts to be installed")
39+
40+ t.Logf("Found %d font families", len(fonts))
41+}
42+
43+func TestDetector_GetAvailableFonts_Caching(t *testing.T) {
44+ ctx := testContext()
45+ detector := NewDetector()
46+
47+ if !detector.IsAvailable(ctx) {
48+ t.Skip("fc-list not available on this system")
49+ }
50+
51+ // First call populates cache
52+ fonts1, err := detector.GetAvailableFonts(ctx)
53+ require.NoError(t, err)
54+
55+ // Second call should return cached result
56+ fonts2, err := detector.GetAvailableFonts(ctx)
57+ require.NoError(t, err)
58+
59+ assert.Len(t, fonts2, len(fonts1), "cached result should match original")
60+}
61+
62+func TestDetector_SelectBestFont_SansSerif(t *testing.T) {
63+ ctx := testContext()
64+ detector := NewDetector()
65+
66+ if !detector.IsAvailable(ctx) {
67+ t.Skip("fc-list not available on this system")
68+ }
69+
70+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, SansSerifFallbackChain())
71+ assert.NotEmpty(t, font)
72+
73+ // Should either be a font from the chain or the generic fallback
74+ isFromChain := false
75+ for _, f := range SansSerifFallbackChain() {
76+ if font == f {
77+ isFromChain = true
78+ break
79+ }
80+ }
81+ if !isFromChain {
82+ assert.Equal(t, "sans-serif", font, "should fall back to generic if no chain fonts available")
83+ }
84+
85+ t.Logf("Selected sans-serif font: %s", font)
86+}
87+
88+func TestDetector_SelectBestFont_Serif(t *testing.T) {
89+ ctx := testContext()
90+ detector := NewDetector()
91+
92+ if !detector.IsAvailable(ctx) {
93+ t.Skip("fc-list not available on this system")
94+ }
95+
96+ font := detector.SelectBestFont(ctx, port.FontCategorySerif, SerifFallbackChain())
97+ assert.NotEmpty(t, font)
98+
99+ isFromChain := false
100+ for _, f := range SerifFallbackChain() {
101+ if font == f {
102+ isFromChain = true
103+ break
104+ }
105+ }
106+ if !isFromChain {
107+ assert.Equal(t, "serif", font, "should fall back to generic if no chain fonts available")
108+ }
109+
110+ t.Logf("Selected serif font: %s", font)
111+}
112+
113+func TestDetector_SelectBestFont_Monospace(t *testing.T) {
114+ ctx := testContext()
115+ detector := NewDetector()
116+
117+ if !detector.IsAvailable(ctx) {
118+ t.Skip("fc-list not available on this system")
119+ }
120+
121+ font := detector.SelectBestFont(ctx, port.FontCategoryMonospace, MonospaceFallbackChain())
122+ assert.NotEmpty(t, font)
123+
124+ isFromChain := false
125+ for _, f := range MonospaceFallbackChain() {
126+ if font == f {
127+ isFromChain = true
128+ break
129+ }
130+ }
131+ if !isFromChain {
132+ assert.Equal(t, "monospace", font, "should fall back to generic if no chain fonts available")
133+ }
134+
135+ t.Logf("Selected monospace font: %s", font)
136+}
137+
138+func TestDetector_SelectBestFont_EmptyChain(t *testing.T) {
139+ ctx := testContext()
140+ detector := NewDetector()
141+
142+ if !detector.IsAvailable(ctx) {
143+ t.Skip("fc-list not available on this system")
144+ }
145+
146+ // Empty chain should return generic fallback
147+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, []string{})
148+ assert.Equal(t, "sans-serif", font)
149+
150+ font = detector.SelectBestFont(ctx, port.FontCategorySerif, []string{})
151+ assert.Equal(t, "serif", font)
152+
153+ font = detector.SelectBestFont(ctx, port.FontCategoryMonospace, []string{})
154+ assert.Equal(t, "monospace", font)
155+}
156+
157+func TestDetector_SelectBestFont_NoMatchingFonts(t *testing.T) {
158+ ctx := testContext()
159+ detector := NewDetector()
160+
161+ if !detector.IsAvailable(ctx) {
162+ t.Skip("fc-list not available on this system")
163+ }
164+
165+ // Chain with fonts that definitely don't exist
166+ chain := []string{
167+ "NonExistentFont1234",
168+ "AnotherFakeFont5678",
169+ }
170+
171+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, chain)
172+ assert.Equal(t, "sans-serif", font, "should fall back to generic when no fonts match")
173+}
174+
175+func TestDetector_genericFallback(t *testing.T) {
176+ detector := NewDetector()
177+
178+ tests := []struct {
179+ category port.FontCategory
180+ expected string
181+ }{
182+ {port.FontCategorySansSerif, "sans-serif"},
183+ {port.FontCategorySerif, "serif"},
184+ {port.FontCategoryMonospace, "monospace"},
185+ {port.FontCategory("unknown"), "sans-serif"}, // default case
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(string(tt.category), func(t *testing.T) {
190+ result := detector.genericFallback(tt.category)
191+ assert.Equal(t, tt.expected, result)
192+ })
193+ }
194+}
195+
196+func TestFallbackChains_NotEmpty(t *testing.T) {
197+ assert.NotEmpty(t, SansSerifFallbackChain(), "sans-serif fallback chain should not be empty")
198+ assert.NotEmpty(t, SerifFallbackChain(), "serif fallback chain should not be empty")
199+ assert.NotEmpty(t, MonospaceFallbackChain(), "monospace fallback chain should not be empty")
200+}
201+
202+func TestFallbackChains_PreferredFontsFirst(t *testing.T) {
203+ // Verify preferred fonts are first in chain
204+ assert.Equal(t, "Fira Sans", SansSerifFallbackChain()[0], "Fira Sans should be first in sans-serif chain")
205+ assert.Equal(t, "Noto Serif", SerifFallbackChain()[0], "Noto Serif should be first in serif chain")
206+ assert.Equal(t, "Fira Code", MonospaceFallbackChain()[0], "Fira Code should be first in monospace chain")
207+}
@@ -0,0 +1,207 @@
1+package fonts
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/rs/zerolog"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func testContext() context.Context {
14+ logger := zerolog.Nop()
15+ return logger.WithContext(context.Background())
16+}
17+
18+func TestDetector_IsAvailable(t *testing.T) {
19+ ctx := testContext()
20+ detector := NewDetector()
21+
22+ // fc-list should be available on most Linux systems
23+ // This test documents the behavior rather than asserting a specific value
24+ available := detector.IsAvailable(ctx)
25+ t.Logf("fc-list available: %v", available)
26+}
27+
28+func TestDetector_GetAvailableFonts(t *testing.T) {
29+ ctx := testContext()
30+ detector := NewDetector()
31+
32+ if !detector.IsAvailable(ctx) {
33+ t.Skip("fc-list not available on this system")
34+ }
35+
36+ fonts, err := detector.GetAvailableFonts(ctx)
37+ require.NoError(t, err)
38+ assert.NotEmpty(t, fonts, "expected at least some fonts to be installed")
39+
40+ t.Logf("Found %d font families", len(fonts))
41+}
42+
43+func TestDetector_GetAvailableFonts_Caching(t *testing.T) {
44+ ctx := testContext()
45+ detector := NewDetector()
46+
47+ if !detector.IsAvailable(ctx) {
48+ t.Skip("fc-list not available on this system")
49+ }
50+
51+ // First call populates cache
52+ fonts1, err := detector.GetAvailableFonts(ctx)
53+ require.NoError(t, err)
54+
55+ // Second call should return cached result
56+ fonts2, err := detector.GetAvailableFonts(ctx)
57+ require.NoError(t, err)
58+
59+ assert.Len(t, fonts2, len(fonts1), "cached result should match original")
60+}
61+
62+func TestDetector_SelectBestFont_SansSerif(t *testing.T) {
63+ ctx := testContext()
64+ detector := NewDetector()
65+
66+ if !detector.IsAvailable(ctx) {
67+ t.Skip("fc-list not available on this system")
68+ }
69+
70+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, SansSerifFallbackChain())
71+ assert.NotEmpty(t, font)
72+
73+ // Should either be a font from the chain or the generic fallback
74+ isFromChain := false
75+ for _, f := range SansSerifFallbackChain() {
76+ if font == f {
77+ isFromChain = true
78+ break
79+ }
80+ }
81+ if !isFromChain {
82+ assert.Equal(t, "sans-serif", font, "should fall back to generic if no chain fonts available")
83+ }
84+
85+ t.Logf("Selected sans-serif font: %s", font)
86+}
87+
88+func TestDetector_SelectBestFont_Serif(t *testing.T) {
89+ ctx := testContext()
90+ detector := NewDetector()
91+
92+ if !detector.IsAvailable(ctx) {
93+ t.Skip("fc-list not available on this system")
94+ }
95+
96+ font := detector.SelectBestFont(ctx, port.FontCategorySerif, SerifFallbackChain())
97+ assert.NotEmpty(t, font)
98+
99+ isFromChain := false
100+ for _, f := range SerifFallbackChain() {
101+ if font == f {
102+ isFromChain = true
103+ break
104+ }
105+ }
106+ if !isFromChain {
107+ assert.Equal(t, "serif", font, "should fall back to generic if no chain fonts available")
108+ }
109+
110+ t.Logf("Selected serif font: %s", font)
111+}
112+
113+func TestDetector_SelectBestFont_Monospace(t *testing.T) {
114+ ctx := testContext()
115+ detector := NewDetector()
116+
117+ if !detector.IsAvailable(ctx) {
118+ t.Skip("fc-list not available on this system")
119+ }
120+
121+ font := detector.SelectBestFont(ctx, port.FontCategoryMonospace, MonospaceFallbackChain())
122+ assert.NotEmpty(t, font)
123+
124+ isFromChain := false
125+ for _, f := range MonospaceFallbackChain() {
126+ if font == f {
127+ isFromChain = true
128+ break
129+ }
130+ }
131+ if !isFromChain {
132+ assert.Equal(t, "monospace", font, "should fall back to generic if no chain fonts available")
133+ }
134+
135+ t.Logf("Selected monospace font: %s", font)
136+}
137+
138+func TestDetector_SelectBestFont_EmptyChain(t *testing.T) {
139+ ctx := testContext()
140+ detector := NewDetector()
141+
142+ if !detector.IsAvailable(ctx) {
143+ t.Skip("fc-list not available on this system")
144+ }
145+
146+ // Empty chain should return generic fallback
147+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, []string{})
148+ assert.Equal(t, "sans-serif", font)
149+
150+ font = detector.SelectBestFont(ctx, port.FontCategorySerif, []string{})
151+ assert.Equal(t, "serif", font)
152+
153+ font = detector.SelectBestFont(ctx, port.FontCategoryMonospace, []string{})
154+ assert.Equal(t, "monospace", font)
155+}
156+
157+func TestDetector_SelectBestFont_NoMatchingFonts(t *testing.T) {
158+ ctx := testContext()
159+ detector := NewDetector()
160+
161+ if !detector.IsAvailable(ctx) {
162+ t.Skip("fc-list not available on this system")
163+ }
164+
165+ // Chain with fonts that definitely don't exist
166+ chain := []string{
167+ "NonExistentFont1234",
168+ "AnotherFakeFont5678",
169+ }
170+
171+ font := detector.SelectBestFont(ctx, port.FontCategorySansSerif, chain)
172+ assert.Equal(t, "sans-serif", font, "should fall back to generic when no fonts match")
173+}
174+
175+func TestDetector_genericFallback(t *testing.T) {
176+ detector := NewDetector()
177+
178+ tests := []struct {
179+ category port.FontCategory
180+ expected string
181+ }{
182+ {port.FontCategorySansSerif, "sans-serif"},
183+ {port.FontCategorySerif, "serif"},
184+ {port.FontCategoryMonospace, "monospace"},
185+ {port.FontCategory("unknown"), "sans-serif"}, // default case
186+ }
187+
188+ for _, tt := range tests {
189+ t.Run(string(tt.category), func(t *testing.T) {
190+ result := detector.genericFallback(tt.category)
191+ assert.Equal(t, tt.expected, result)
192+ })
193+ }
194+}
195+
196+func TestFallbackChains_NotEmpty(t *testing.T) {
197+ assert.NotEmpty(t, SansSerifFallbackChain(), "sans-serif fallback chain should not be empty")
198+ assert.NotEmpty(t, SerifFallbackChain(), "serif fallback chain should not be empty")
199+ assert.NotEmpty(t, MonospaceFallbackChain(), "monospace fallback chain should not be empty")
200+}
201+
202+func TestFallbackChains_PreferredFontsFirst(t *testing.T) {
203+ // Verify preferred fonts are first in chain
204+ assert.Equal(t, "Fira Sans", SansSerifFallbackChain()[0], "Fira Sans should be first in sans-serif chain")
205+ assert.Equal(t, "Noto Serif", SerifFallbackChain()[0], "Noto Serif should be first in serif chain")
206+ assert.Equal(t, "Fira Code", MonospaceFallbackChain()[0], "Fira Code should be first in monospace chain")
207+}
M internal/infrastructure/persistence/sqlite/history_repo.go +77 -15
@@ -5,6 +5,7 @@ import (
55 "database/sql"
66 "errors"
77 "fmt"
8+ "strings"
89 "time"
910
1011 "github.com/bnema/dumber/internal/domain/entity"
@@ -47,31 +48,92 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
4748 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
4849 log := logging.FromContext(ctx)
4950
50- // Use FTS5 full-text search for better accuracy
51- // FTS5 query syntax: use * for prefix matching
52- ftsQuery := query + "*"
51+ // Split query into words and add prefix matching to each
52+ // This enables multi-word searches like "github issues" -> "github* issues*"
53+ words := strings.Fields(query)
54+ if len(words) == 0 {
55+ return []entity.HistoryMatch{}, nil
56+ }
5357
54- rows, err := r.queries.SearchHistoryFTS(ctx, sqlc.SearchHistoryFTSParams{
55- Url: ftsQuery,
58+ // Build FTS5 query: "word1* word2*" (implicit AND between terms)
59+ // Sanitize each word to remove FTS5 special characters
60+ parts := make([]string, len(words))
61+ for i, word := range words {
62+ parts[i] = sanitizeFTS5Word(word) + "*"
63+ }
64+ ftsQuery := strings.Join(parts, " ")
65+
66+ // Search both URL and title columns, then merge results
67+ urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{
68+ Query: ftsQuery,
5669 Limit: int64(limit),
5770 })
58- if err != nil {
59- log.Debug().Err(err).Str("query", query).Msg("FTS search failed, no results")
60- // FTS5 returns error on no match or invalid query, return empty results
61- return []entity.HistoryMatch{}, nil
71+ if urlErr != nil {
72+ log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
73+ urlRows = []sqlc.History{}
6274 }
6375
64- matches := make([]entity.HistoryMatch, len(rows))
65- for i := range rows {
66- row := rows[i]
67- matches[i] = entity.HistoryMatch{
68- Entry: historyFromRow(row),
69- Score: 1.0, // FTS5 already ranked by bm25
76+ titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
77+ Query: ftsQuery,
78+ Limit: int64(limit),
79+ })
80+ if titleErr != nil {
81+ log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")
82+ titleRows = []sqlc.History{}
83+ }
84+
85+ // Merge results by interleaving URL and title matches for balanced results
86+ seen := make(map[int64]bool)
87+ var matches []entity.HistoryMatch
88+
89+ i, j := 0, 0
90+ for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) {
91+ // Take next URL match if available
92+ if i < len(urlRows) {
93+ row := urlRows[i]
94+ i++
95+ if !seen[row.ID] {
96+ seen[row.ID] = true
97+ matches = append(matches, entity.HistoryMatch{
98+ Entry: historyFromRow(row),
99+ Score: 1.0,
100+ })
101+ }
102+ }
103+
104+ // Take next title match if available
105+ if j < len(titleRows) && len(matches) < limit {
106+ row := titleRows[j]
107+ j++
108+ if !seen[row.ID] {
109+ seen[row.ID] = true
110+ matches = append(matches, entity.HistoryMatch{
111+ Entry: historyFromRow(row),
112+ Score: 1.0,
113+ })
114+ }
70115 }
71116 }
117+
72118 return matches, nil
73119 }
74120
121+// sanitizeFTS5Word removes FTS5 special characters from a search word.
122+// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
123+func sanitizeFTS5Word(word string) string {
124+ // Remove characters that have special meaning in FTS5
125+ var result strings.Builder
126+ for _, r := range word {
127+ switch r {
128+ case '"', '*', '(', ')', ':', '^', '-':
129+ // Skip FTS5 special characters
130+ default:
131+ result.WriteRune(r)
132+ }
133+ }
134+ return result.String()
135+}
136+
75137 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
76138 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
77139 Limit: int64(limit),
@@ -5,6 +5,7 @@ import (
5 "database/sql"5 "database/sql"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "time"9 "time"
9 10
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -47,31 +48,92 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
47 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {48 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
48 log := logging.FromContext(ctx)49 log := logging.FromContext(ctx)
49 50
50- // Use FTS5 full-text search for better accuracy51+ // Split query into words and add prefix matching to each
51- // FTS5 query syntax: use * for prefix matching52+ // This enables multi-word searches like "github issues" -> "github* issues*"
52- ftsQuery := query + "*"53+ words := strings.Fields(query)
54+ if len(words) == 0 {
55+ return []entity.HistoryMatch{}, nil
56+ }
53 57
54- rows, err := r.queries.SearchHistoryFTS(ctx, sqlc.SearchHistoryFTSParams{58+ // Build FTS5 query: "word1* word2*" (implicit AND between terms)
55- Url: ftsQuery,59+ // Sanitize each word to remove FTS5 special characters
60+ parts := make([]string, len(words))
61+ for i, word := range words {
62+ parts[i] = sanitizeFTS5Word(word) + "*"
63+ }
64+ ftsQuery := strings.Join(parts, " ")
65+
66+ // Search both URL and title columns, then merge results
67+ urlRows, urlErr := r.queries.SearchHistoryFTSUrl(ctx, sqlc.SearchHistoryFTSUrlParams{
68+ Query: ftsQuery,
56 Limit: int64(limit),69 Limit: int64(limit),
57 })70 })
58- if err != nil {71+ if urlErr != nil {
59- log.Debug().Err(err).Str("query", query).Msg("FTS search failed, no results")72+ log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
60- // FTS5 returns error on no match or invalid query, return empty results73+ urlRows = []sqlc.History{}
61- return []entity.HistoryMatch{}, nil
62 }74 }
63 75
64- matches := make([]entity.HistoryMatch, len(rows))76+ titleRows, titleErr := r.queries.SearchHistoryFTSTitle(ctx, sqlc.SearchHistoryFTSTitleParams{
65- for i := range rows {77+ Query: ftsQuery,
66- row := rows[i]78+ Limit: int64(limit),
67- matches[i] = entity.HistoryMatch{79+ })
68- Entry: historyFromRow(row),80+ if titleErr != nil {
69- Score: 1.0, // FTS5 already ranked by bm2581+ log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")
82+ titleRows = []sqlc.History{}
83+ }
84+
85+ // Merge results by interleaving URL and title matches for balanced results
86+ seen := make(map[int64]bool)
87+ var matches []entity.HistoryMatch
88+
89+ i, j := 0, 0
90+ for len(matches) < limit && (i < len(urlRows) || j < len(titleRows)) {
91+ // Take next URL match if available
92+ if i < len(urlRows) {
93+ row := urlRows[i]
94+ i++
95+ if !seen[row.ID] {
96+ seen[row.ID] = true
97+ matches = append(matches, entity.HistoryMatch{
98+ Entry: historyFromRow(row),
99+ Score: 1.0,
100+ })
101+ }
102+ }
103+
104+ // Take next title match if available
105+ if j < len(titleRows) && len(matches) < limit {
106+ row := titleRows[j]
107+ j++
108+ if !seen[row.ID] {
109+ seen[row.ID] = true
110+ matches = append(matches, entity.HistoryMatch{
111+ Entry: historyFromRow(row),
112+ Score: 1.0,
113+ })
114+ }
70 }115 }
71 }116 }
117+
72 return matches, nil118 return matches, nil
73 }119 }
74 120
121+// sanitizeFTS5Word removes FTS5 special characters from a search word.
122+// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR
123+func sanitizeFTS5Word(word string) string {
124+ // Remove characters that have special meaning in FTS5
125+ var result strings.Builder
126+ for _, r := range word {
127+ switch r {
128+ case '"', '*', '(', ')', ':', '^', '-':
129+ // Skip FTS5 special characters
130+ default:
131+ result.WriteRune(r)
132+ }
133+ }
134+ return result.String()
135+}
136+
75 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {137 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
76 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{138 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
77 Limit: int64(limit),139 Limit: int64(limit),
M internal/infrastructure/persistence/sqlite/history_repo_test.go +161 -0
@@ -323,3 +323,164 @@ func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
323323 assert.Equal(t, "https://github.com", results[0].URL)
324324 assert.Equal(t, int64(4), results[0].VisitCount)
325325 }
326+
327+func TestHistoryRepository_Search_SingleWord(t *testing.T) {
328+ ctx := historyTestCtx()
329+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
330+
331+ db, err := sqlite.NewConnection(ctx, dbPath)
332+ require.NoError(t, err)
333+ t.Cleanup(func() { _ = db.Close() })
334+
335+ repo := sqlite.NewHistoryRepository(db)
336+
337+ // Save entries
338+ entries := []*entity.HistoryEntry{
339+ {URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
340+ {URL: "https://gitlab.com/project", Title: "GitLab Project"},
341+ {URL: "https://example.com", Title: "Example Site"},
342+ }
343+ for _, e := range entries {
344+ require.NoError(t, repo.Save(ctx, e))
345+ }
346+
347+ // Search for "github" - should find the github entry
348+ results, err := repo.Search(ctx, "github", 10)
349+ require.NoError(t, err)
350+ require.Len(t, results, 1)
351+ assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
352+}
353+
354+func TestHistoryRepository_Search_MultiWord(t *testing.T) {
355+ ctx := historyTestCtx()
356+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
357+
358+ db, err := sqlite.NewConnection(ctx, dbPath)
359+ require.NoError(t, err)
360+ t.Cleanup(func() { _ = db.Close() })
361+
362+ repo := sqlite.NewHistoryRepository(db)
363+
364+ // Save entries
365+ entries := []*entity.HistoryEntry{
366+ {URL: "https://github.com/issues", Title: "GitHub Issues"},
367+ {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
368+ {URL: "https://jira.com/issues", Title: "Jira Issues"},
369+ }
370+ for _, e := range entries {
371+ require.NoError(t, repo.Save(ctx, e))
372+ }
373+
374+ // Search for "github issues" - should find only the github issues entry
375+ results, err := repo.Search(ctx, "github issues", 10)
376+ require.NoError(t, err)
377+ require.Len(t, results, 1)
378+ assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
379+}
380+
381+func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
382+ ctx := historyTestCtx()
383+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
384+
385+ db, err := sqlite.NewConnection(ctx, dbPath)
386+ require.NoError(t, err)
387+ t.Cleanup(func() { _ = db.Close() })
388+
389+ repo := sqlite.NewHistoryRepository(db)
390+
391+ // Save entry with specific title that doesn't appear in URL
392+ entry := &entity.HistoryEntry{
393+ URL: "https://docs.example.com/guide",
394+ Title: "Comprehensive Documentation Guide",
395+ }
396+ require.NoError(t, repo.Save(ctx, entry))
397+
398+ // Search for "Documentation" - should find by title
399+ results, err := repo.Search(ctx, "Documentation", 10)
400+ require.NoError(t, err)
401+ require.Len(t, results, 1)
402+ assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
403+}
404+
405+func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
406+ ctx := historyTestCtx()
407+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
408+
409+ db, err := sqlite.NewConnection(ctx, dbPath)
410+ require.NoError(t, err)
411+ t.Cleanup(func() { _ = db.Close() })
412+
413+ repo := sqlite.NewHistoryRepository(db)
414+
415+ // Save an entry
416+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
417+ URL: "https://example.com",
418+ Title: "Example",
419+ }))
420+
421+ // Empty query should return empty results, no error
422+ results, err := repo.Search(ctx, "", 10)
423+ require.NoError(t, err)
424+ assert.Empty(t, results)
425+
426+ // Whitespace-only query should also return empty results
427+ results, err = repo.Search(ctx, " ", 10)
428+ require.NoError(t, err)
429+ assert.Empty(t, results)
430+}
431+
432+func TestHistoryRepository_Search_NoResults(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ // Save an entry
443+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
444+ URL: "https://example.com",
445+ Title: "Example",
446+ }))
447+
448+ // Search for something that doesn't exist
449+ results, err := repo.Search(ctx, "nonexistent", 10)
450+ require.NoError(t, err)
451+ assert.Empty(t, results)
452+}
453+
454+func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
455+ ctx := historyTestCtx()
456+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
457+
458+ db, err := sqlite.NewConnection(ctx, dbPath)
459+ require.NoError(t, err)
460+ t.Cleanup(func() { _ = db.Close() })
461+
462+ repo := sqlite.NewHistoryRepository(db)
463+
464+ // Save entries
465+ entries := []*entity.HistoryEntry{
466+ {URL: "https://github.com", Title: "GitHub"},
467+ {URL: "https://gitlab.com", Title: "GitLab"},
468+ {URL: "https://example.com", Title: "Example"},
469+ }
470+ for _, e := range entries {
471+ require.NoError(t, repo.Save(ctx, e))
472+ }
473+
474+ // Search for "git" - should find both github and gitlab (prefix match)
475+ results, err := repo.Search(ctx, "git", 10)
476+ require.NoError(t, err)
477+ require.Len(t, results, 2)
478+
479+ // Verify both git* entries are found
480+ urls := make(map[string]bool)
481+ for _, r := range results {
482+ urls[r.Entry.URL] = true
483+ }
484+ assert.True(t, urls["https://github.com"])
485+ assert.True(t, urls["https://gitlab.com"])
486+}
@@ -323,3 +323,164 @@ func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
323 assert.Equal(t, "https://github.com", results[0].URL)323 assert.Equal(t, "https://github.com", results[0].URL)
324 assert.Equal(t, int64(4), results[0].VisitCount)324 assert.Equal(t, int64(4), results[0].VisitCount)
325 }325 }
326+
327+func TestHistoryRepository_Search_SingleWord(t *testing.T) {
328+ ctx := historyTestCtx()
329+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
330+
331+ db, err := sqlite.NewConnection(ctx, dbPath)
332+ require.NoError(t, err)
333+ t.Cleanup(func() { _ = db.Close() })
334+
335+ repo := sqlite.NewHistoryRepository(db)
336+
337+ // Save entries
338+ entries := []*entity.HistoryEntry{
339+ {URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
340+ {URL: "https://gitlab.com/project", Title: "GitLab Project"},
341+ {URL: "https://example.com", Title: "Example Site"},
342+ }
343+ for _, e := range entries {
344+ require.NoError(t, repo.Save(ctx, e))
345+ }
346+
347+ // Search for "github" - should find the github entry
348+ results, err := repo.Search(ctx, "github", 10)
349+ require.NoError(t, err)
350+ require.Len(t, results, 1)
351+ assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
352+}
353+
354+func TestHistoryRepository_Search_MultiWord(t *testing.T) {
355+ ctx := historyTestCtx()
356+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
357+
358+ db, err := sqlite.NewConnection(ctx, dbPath)
359+ require.NoError(t, err)
360+ t.Cleanup(func() { _ = db.Close() })
361+
362+ repo := sqlite.NewHistoryRepository(db)
363+
364+ // Save entries
365+ entries := []*entity.HistoryEntry{
366+ {URL: "https://github.com/issues", Title: "GitHub Issues"},
367+ {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
368+ {URL: "https://jira.com/issues", Title: "Jira Issues"},
369+ }
370+ for _, e := range entries {
371+ require.NoError(t, repo.Save(ctx, e))
372+ }
373+
374+ // Search for "github issues" - should find only the github issues entry
375+ results, err := repo.Search(ctx, "github issues", 10)
376+ require.NoError(t, err)
377+ require.Len(t, results, 1)
378+ assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
379+}
380+
381+func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
382+ ctx := historyTestCtx()
383+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
384+
385+ db, err := sqlite.NewConnection(ctx, dbPath)
386+ require.NoError(t, err)
387+ t.Cleanup(func() { _ = db.Close() })
388+
389+ repo := sqlite.NewHistoryRepository(db)
390+
391+ // Save entry with specific title that doesn't appear in URL
392+ entry := &entity.HistoryEntry{
393+ URL: "https://docs.example.com/guide",
394+ Title: "Comprehensive Documentation Guide",
395+ }
396+ require.NoError(t, repo.Save(ctx, entry))
397+
398+ // Search for "Documentation" - should find by title
399+ results, err := repo.Search(ctx, "Documentation", 10)
400+ require.NoError(t, err)
401+ require.Len(t, results, 1)
402+ assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
403+}
404+
405+func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
406+ ctx := historyTestCtx()
407+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
408+
409+ db, err := sqlite.NewConnection(ctx, dbPath)
410+ require.NoError(t, err)
411+ t.Cleanup(func() { _ = db.Close() })
412+
413+ repo := sqlite.NewHistoryRepository(db)
414+
415+ // Save an entry
416+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
417+ URL: "https://example.com",
418+ Title: "Example",
419+ }))
420+
421+ // Empty query should return empty results, no error
422+ results, err := repo.Search(ctx, "", 10)
423+ require.NoError(t, err)
424+ assert.Empty(t, results)
425+
426+ // Whitespace-only query should also return empty results
427+ results, err = repo.Search(ctx, " ", 10)
428+ require.NoError(t, err)
429+ assert.Empty(t, results)
430+}
431+
432+func TestHistoryRepository_Search_NoResults(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ // Save an entry
443+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
444+ URL: "https://example.com",
445+ Title: "Example",
446+ }))
447+
448+ // Search for something that doesn't exist
449+ results, err := repo.Search(ctx, "nonexistent", 10)
450+ require.NoError(t, err)
451+ assert.Empty(t, results)
452+}
453+
454+func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
455+ ctx := historyTestCtx()
456+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
457+
458+ db, err := sqlite.NewConnection(ctx, dbPath)
459+ require.NoError(t, err)
460+ t.Cleanup(func() { _ = db.Close() })
461+
462+ repo := sqlite.NewHistoryRepository(db)
463+
464+ // Save entries
465+ entries := []*entity.HistoryEntry{
466+ {URL: "https://github.com", Title: "GitHub"},
467+ {URL: "https://gitlab.com", Title: "GitLab"},
468+ {URL: "https://example.com", Title: "Example"},
469+ }
470+ for _, e := range entries {
471+ require.NoError(t, repo.Save(ctx, e))
472+ }
473+
474+ // Search for "git" - should find both github and gitlab (prefix match)
475+ results, err := repo.Search(ctx, "git", 10)
476+ require.NoError(t, err)
477+ require.Len(t, results, 2)
478+
479+ // Verify both git* entries are found
480+ urls := make(map[string]bool)
481+ for _, r := range results {
482+ urls[r.Entry.URL] = true
483+ }
484+ assert.True(t, urls["https://github.com"])
485+ assert.True(t, urls["https://gitlab.com"])
486+}
M internal/infrastructure/persistence/sqlite/queries/history.sql +12 -4
@@ -49,13 +49,21 @@ SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visit
4949 -- name: DeleteHistoryByDomain :exec
5050 DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
5151
52--- name: SearchHistoryFTS :many
52+-- name: SearchHistoryFTSUrl :many
5353 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
5454 FROM history_fts fts
5555 JOIN history h ON fts.rowid = h.id
56-WHERE fts.url MATCH ?
57-ORDER BY bm25(history_fts)
58-LIMIT ?;
56+WHERE fts.url MATCH @query
57+ORDER BY h.visit_count DESC, h.last_visited DESC
58+LIMIT @limit;
59+
60+-- name: SearchHistoryFTSTitle :many
61+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
62+FROM history_fts fts
63+JOIN history h ON fts.rowid = h.id
64+WHERE fts.title MATCH @query
65+ORDER BY h.visit_count DESC, h.last_visited DESC
66+LIMIT @limit;
5967
6068 -- name: GetRecentHistorySince :many
6169 SELECT * FROM history
@@ -49,13 +49,21 @@ SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visit
49 -- name: DeleteHistoryByDomain :exec49 -- name: DeleteHistoryByDomain :exec
50 DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;50 DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
51 51
52--- name: SearchHistoryFTS :many52+-- name: SearchHistoryFTSUrl :many
53 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at53 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
54 FROM history_fts fts54 FROM history_fts fts
55 JOIN history h ON fts.rowid = h.id55 JOIN history h ON fts.rowid = h.id
56-WHERE fts.url MATCH ?56+WHERE fts.url MATCH @query
57-ORDER BY bm25(history_fts)57+ORDER BY h.visit_count DESC, h.last_visited DESC
58-LIMIT ?;58+LIMIT @limit;
59+
60+-- name: SearchHistoryFTSTitle :many
61+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
62+FROM history_fts fts
63+JOIN history h ON fts.rowid = h.id
64+WHERE fts.title MATCH @query
65+ORDER BY h.visit_count DESC, h.last_visited DESC
66+LIMIT @limit;
59 67
60 -- name: GetRecentHistorySince :many68 -- name: GetRecentHistorySince :many
61 SELECT * FROM history69 SELECT * FROM history
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +53 -8
@@ -439,22 +439,67 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
439439 return items, nil
440440 }
441441
442-const SearchHistoryFTS = `-- name: SearchHistoryFTS :many
442+const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
443443 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
444444 FROM history_fts fts
445445 JOIN history h ON fts.rowid = h.id
446-WHERE fts.url MATCH ?
447-ORDER BY bm25(history_fts)
448-LIMIT ?
446+WHERE fts.title MATCH ?1
447+ORDER BY h.visit_count DESC, h.last_visited DESC
448+LIMIT ?2
449+`
450+
451+type SearchHistoryFTSTitleParams struct {
452+ Query string `json:"query"`
453+ Limit int64 `json:"limit"`
454+}
455+
456+func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {
457+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit)
458+ if err != nil {
459+ return nil, err
460+ }
461+ defer rows.Close()
462+ items := []History{}
463+ for rows.Next() {
464+ var i History
465+ if err := rows.Scan(
466+ &i.ID,
467+ &i.Url,
468+ &i.Title,
469+ &i.FaviconUrl,
470+ &i.VisitCount,
471+ &i.LastVisited,
472+ &i.CreatedAt,
473+ ); err != nil {
474+ return nil, err
475+ }
476+ items = append(items, i)
477+ }
478+ if err := rows.Close(); err != nil {
479+ return nil, err
480+ }
481+ if err := rows.Err(); err != nil {
482+ return nil, err
483+ }
484+ return items, nil
485+}
486+
487+const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
488+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
489+FROM history_fts fts
490+JOIN history h ON fts.rowid = h.id
491+WHERE fts.url MATCH ?1
492+ORDER BY h.visit_count DESC, h.last_visited DESC
493+LIMIT ?2
449494 `
450495
451-type SearchHistoryFTSParams struct {
452- Url string `json:"url"`
496+type SearchHistoryFTSUrlParams struct {
497+ Query string `json:"query"`
453498 Limit int64 `json:"limit"`
454499 }
455500
456-func (q *Queries) SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) {
457- rows, err := q.db.QueryContext(ctx, SearchHistoryFTS, arg.Url, arg.Limit)
501+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
502+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
458503 if err != nil {
459504 return nil, err
460505 }
@@ -439,22 +439,67 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
439 return items, nil439 return items, nil
440 }440 }
441 441
442-const SearchHistoryFTS = `-- name: SearchHistoryFTS :many442+const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
443 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at443 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
444 FROM history_fts fts444 FROM history_fts fts
445 JOIN history h ON fts.rowid = h.id445 JOIN history h ON fts.rowid = h.id
446-WHERE fts.url MATCH ?446+WHERE fts.title MATCH ?1
447-ORDER BY bm25(history_fts)447+ORDER BY h.visit_count DESC, h.last_visited DESC
448-LIMIT ?448+LIMIT ?2
449+`
450+
451+type SearchHistoryFTSTitleParams struct {
452+ Query string `json:"query"`
453+ Limit int64 `json:"limit"`
454+}
455+
456+func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) {
457+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSTitle, arg.Query, arg.Limit)
458+ if err != nil {
459+ return nil, err
460+ }
461+ defer rows.Close()
462+ items := []History{}
463+ for rows.Next() {
464+ var i History
465+ if err := rows.Scan(
466+ &i.ID,
467+ &i.Url,
468+ &i.Title,
469+ &i.FaviconUrl,
470+ &i.VisitCount,
471+ &i.LastVisited,
472+ &i.CreatedAt,
473+ ); err != nil {
474+ return nil, err
475+ }
476+ items = append(items, i)
477+ }
478+ if err := rows.Close(); err != nil {
479+ return nil, err
480+ }
481+ if err := rows.Err(); err != nil {
482+ return nil, err
483+ }
484+ return items, nil
485+}
486+
487+const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
488+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
489+FROM history_fts fts
490+JOIN history h ON fts.rowid = h.id
491+WHERE fts.url MATCH ?1
492+ORDER BY h.visit_count DESC, h.last_visited DESC
493+LIMIT ?2
449 `494 `
450 495
451-type SearchHistoryFTSParams struct {496+type SearchHistoryFTSUrlParams struct {
452- Url string `json:"url"`497+ Query string `json:"query"`
453 Limit int64 `json:"limit"`498 Limit int64 `json:"limit"`
454 }499 }
455 500
456-func (q *Queries) SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error) {501+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
457- rows, err := q.db.QueryContext(ctx, SearchHistoryFTS, arg.Url, arg.Limit)502+ rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
458 if err != nil {503 if err != nil {
459 return nil, err504 return nil, err
460 }505 }
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
@@ -70,7 +70,8 @@ type Querier interface {
7070 RemoveFromWhitelist(ctx context.Context, domain string) error
7171 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
7272 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
73- SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error)
73+ SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
74+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
7475 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
7576 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
7677 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
@@ -70,7 +70,8 @@ type Querier interface {
70 RemoveFromWhitelist(ctx context.Context, domain string) error70 RemoveFromWhitelist(ctx context.Context, domain string) error
71 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error71 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
72 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)72 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
73- SearchHistoryFTS(ctx context.Context, arg SearchHistoryFTSParams) ([]History, error)73+ SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
74+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
74 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error75 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
75 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error76 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
76 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error77 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
A internal/infrastructure/webkit/handlers/clipboard.go +92 -0
@@ -0,0 +1,92 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ClipboardHandler handles clipboard-related messages from webviews.
14+type ClipboardHandler struct {
15+ clipboard port.Clipboard
16+ configGetter func() *config.Config
17+ onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
18+}
19+
20+// NewClipboardHandler creates a new ClipboardHandler.
21+func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler {
22+ return &ClipboardHandler{
23+ clipboard: clipboard,
24+ configGetter: configGetter,
25+ onCopied: onCopied,
26+ }
27+}
28+
29+// autoCopyRequest represents the payload for auto-copy selection messages.
30+type autoCopyRequest struct {
31+ Text string `json:"text"`
32+}
33+
34+// HandleAutoCopySelection handles the auto_copy_selection message from JS.
35+// It copies the selected text to the clipboard if the feature is enabled.
36+func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {
37+ return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
38+ log := logging.FromContext(ctx)
39+
40+ // Check if feature is enabled
41+ cfg := h.configGetter()
42+ if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection {
43+ return nil, nil
44+ }
45+
46+ var req autoCopyRequest
47+ if err := json.Unmarshal(payload, &req); err != nil {
48+ log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
49+ return nil, nil // Silently ignore malformed requests
50+ }
51+
52+ // Skip empty or very short selections (< 2 chars)
53+ if len(req.Text) < 2 {
54+ return nil, nil
55+ }
56+
57+ if err := h.clipboard.WriteText(ctx, req.Text); err != nil {
58+ log.Debug().Err(err).Msg("failed to write selection to clipboard")
59+ return nil, nil // Don't propagate error to JS
60+ }
61+
62+ log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")
63+
64+ // Notify UI for toast feedback (if callback is set)
65+ if h.onCopied != nil {
66+ h.onCopied(len(req.Text))
67+ }
68+
69+ return nil, nil
70+ })
71+}
72+
73+// RegisterClipboardHandlers registers clipboard handlers with the router.
74+func RegisterClipboardHandlers(
75+ ctx context.Context,
76+ router *webkit.MessageRouter,
77+ clipboard port.Clipboard,
78+ configGetter func() *config.Config,
79+ onCopied func(textLen int),
80+) error {
81+ handler := NewClipboardHandler(clipboard, configGetter, onCopied)
82+
83+ // Register auto_copy_selection handler
84+ if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
85+ return err
86+ }
87+
88+ log := logging.FromContext(ctx)
89+ log.Info().Msg("registered clipboard handlers")
90+
91+ return nil
92+}
@@ -0,0 +1,92 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ClipboardHandler handles clipboard-related messages from webviews.
14+type ClipboardHandler struct {
15+ clipboard port.Clipboard
16+ configGetter func() *config.Config
17+ onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
18+}
19+
20+// NewClipboardHandler creates a new ClipboardHandler.
21+func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler {
22+ return &ClipboardHandler{
23+ clipboard: clipboard,
24+ configGetter: configGetter,
25+ onCopied: onCopied,
26+ }
27+}
28+
29+// autoCopyRequest represents the payload for auto-copy selection messages.
30+type autoCopyRequest struct {
31+ Text string `json:"text"`
32+}
33+
34+// HandleAutoCopySelection handles the auto_copy_selection message from JS.
35+// It copies the selected text to the clipboard if the feature is enabled.
36+func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {
37+ return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
38+ log := logging.FromContext(ctx)
39+
40+ // Check if feature is enabled
41+ cfg := h.configGetter()
42+ if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection {
43+ return nil, nil
44+ }
45+
46+ var req autoCopyRequest
47+ if err := json.Unmarshal(payload, &req); err != nil {
48+ log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
49+ return nil, nil // Silently ignore malformed requests
50+ }
51+
52+ // Skip empty or very short selections (< 2 chars)
53+ if len(req.Text) < 2 {
54+ return nil, nil
55+ }
56+
57+ if err := h.clipboard.WriteText(ctx, req.Text); err != nil {
58+ log.Debug().Err(err).Msg("failed to write selection to clipboard")
59+ return nil, nil // Don't propagate error to JS
60+ }
61+
62+ log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")
63+
64+ // Notify UI for toast feedback (if callback is set)
65+ if h.onCopied != nil {
66+ h.onCopied(len(req.Text))
67+ }
68+
69+ return nil, nil
70+ })
71+}
72+
73+// RegisterClipboardHandlers registers clipboard handlers with the router.
74+func RegisterClipboardHandlers(
75+ ctx context.Context,
76+ router *webkit.MessageRouter,
77+ clipboard port.Clipboard,
78+ configGetter func() *config.Config,
79+ onCopied func(textLen int),
80+) error {
81+ handler := NewClipboardHandler(clipboard, configGetter, onCopied)
82+
83+ // Register auto_copy_selection handler
84+ if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
85+ return err
86+ }
87+
88+ log := logging.FromContext(ctx)
89+ log.Info().Msg("registered clipboard handlers")
90+
91+ return nil
92+}
M internal/infrastructure/webkit/handlers/register.go +14 -2
@@ -3,15 +3,20 @@ package handlers
33 import (
44 "context"
55
6+ "github.com/bnema/dumber/internal/application/port"
67 "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
79 "github.com/bnema/dumber/internal/infrastructure/webkit"
810 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"
911 )
1012
1113 // Config holds all dependencies for message handlers.
1214 type Config struct {
13- HistoryUC *usecase.SearchHistoryUseCase
14- FavoritesUC *usecase.ManageFavoritesUseCase
15+ HistoryUC *usecase.SearchHistoryUseCase
16+ FavoritesUC *usecase.ManageFavoritesUseCase
17+ Clipboard port.Clipboard
18+ ConfigGetter func() *config.Config
19+ OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
1520 }
1621
1722 // RegisterAll registers all message handlers with the router.
@@ -31,6 +36,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
3136 return err
3237 }
3338
39+ // Clipboard handlers (for auto-copy on selection feature)
40+ if cfg.Clipboard != nil && cfg.ConfigGetter != nil {
41+ if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil {
42+ return err
43+ }
44+ }
45+
3446 // Future handler groups go here:
3547 // - settings handlers
3648 // - downloads handlers
@@ -3,15 +3,20 @@ package handlers
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/port"
6 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
7 "github.com/bnema/dumber/internal/infrastructure/webkit"9 "github.com/bnema/dumber/internal/infrastructure/webkit"
8 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"10 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"
9 )11 )
10 12
11 // Config holds all dependencies for message handlers.13 // Config holds all dependencies for message handlers.
12 type Config struct {14 type Config struct {
13- HistoryUC *usecase.SearchHistoryUseCase15+ HistoryUC *usecase.SearchHistoryUseCase
14- FavoritesUC *usecase.ManageFavoritesUseCase16+ FavoritesUC *usecase.ManageFavoritesUseCase
17+ Clipboard port.Clipboard
18+ ConfigGetter func() *config.Config
19+ OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
15 }20 }
16 21
17 // RegisterAll registers all message handlers with the router.22 // RegisterAll registers all message handlers with the router.
@@ -31,6 +36,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
31 return err36 return err
32 }37 }
33 38
39+ // Clipboard handlers (for auto-copy on selection feature)
40+ if cfg.Clipboard != nil && cfg.ConfigGetter != nil {
41+ if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil {
42+ return err
43+ }
44+ }
45+
34 // Future handler groups go here:46 // Future handler groups go here:
35 // - settings handlers47 // - settings handlers
36 // - downloads handlers48 // - downloads handlers
M internal/infrastructure/webkit/injector.go +54 -4
@@ -114,15 +114,43 @@ const internalDarkModeScriptTemplate = `(function() {
114114 // internalPageAllowList restricts script injection to internal dumb:// pages only.
115115 var internalPageAllowList = []string{"dumb://*"}
116116
117+// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
118+// It listens for selection changes, debounces them, and posts to the Go message handler.
119+// Skips selections in input fields/textareas and requires minimum 2 characters.
120+const autoCopySelectionScript = `(function() {
121+ var debounceTimer = null;
122+ var MIN_LENGTH = 2;
123+
124+ document.addEventListener('selectionchange', function() {
125+ // Skip if selection is inside input/textarea/contenteditable
126+ var activeEl = document.activeElement;
127+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
128+ return;
129+ }
130+
131+ clearTimeout(debounceTimer);
132+ debounceTimer = setTimeout(function() {
133+ var text = window.getSelection().toString().trim();
134+ if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
135+ window.webkit.messageHandlers.dumber.postMessage({
136+ type: 'auto_copy_selection',
137+ payload: { text: text }
138+ });
139+ }
140+ }, 300);
141+ });
142+})();`
143+
117144 // ContentInjector encapsulates script injection into WebViews.
118145 // It injects dark mode detection scripts for internal pages (dumb://)
119146 // and theme CSS variables for WebUI styling.
120147 // External pages receive dark mode preference via libadwaita's StyleManager.
121148 // Implements port.ContentInjector interface.
122149 type ContentInjector struct {
123- colorResolver port.ColorSchemeResolver
124- themeCSSVars string // CSS custom property declarations for WebUI
125- findCSS string // CSS for find-in-page highlight styling
150+ colorResolver port.ColorSchemeResolver
151+ themeCSSVars string // CSS custom property declarations for WebUI
152+ findCSS string // CSS for find-in-page highlight styling
153+ autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
126154 }
127155
128156 // NewContentInjector creates a new injector instance.
@@ -133,6 +161,12 @@ func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
133161 }
134162 }
135163
164+// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
165+// This is called during script injection to determine whether to inject the selection listener.
166+func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
167+ ci.autoCopyConfigGetter = getter
168+}
169+
136170 // InjectThemeCSS stores CSS variables for injection into internal pages.
137171 // Implements port.ContentInjector interface.
138172 // The CSS will be injected when InjectScripts is called on WebView creation.
@@ -252,5 +286,21 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
252286 }
253287 }
254288
255- log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
289+ // 5. Inject auto-copy selection script for all pages (if enabled)
290+ autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
291+ if autoCopyEnabled {
292+ addScript(
293+ webkit.NewUserScript(
294+ autoCopySelectionScript,
295+ webkit.UserContentInjectTopFrameValue,
296+ webkit.UserScriptInjectAtDocumentEndValue,
297+ nil, // All pages
298+ nil,
299+ ),
300+ "auto-copy-selection",
301+ )
302+ log.Debug().Msg("auto-copy selection script injected")
303+ }
304+
305+ log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
256306 }
@@ -114,15 +114,43 @@ const internalDarkModeScriptTemplate = `(function() {
114 // internalPageAllowList restricts script injection to internal dumb:// pages only.114 // internalPageAllowList restricts script injection to internal dumb:// pages only.
115 var internalPageAllowList = []string{"dumb://*"}115 var internalPageAllowList = []string{"dumb://*"}
116 116
117+// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
118+// It listens for selection changes, debounces them, and posts to the Go message handler.
119+// Skips selections in input fields/textareas and requires minimum 2 characters.
120+const autoCopySelectionScript = `(function() {
121+ var debounceTimer = null;
122+ var MIN_LENGTH = 2;
123+
124+ document.addEventListener('selectionchange', function() {
125+ // Skip if selection is inside input/textarea/contenteditable
126+ var activeEl = document.activeElement;
127+ if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
128+ return;
129+ }
130+
131+ clearTimeout(debounceTimer);
132+ debounceTimer = setTimeout(function() {
133+ var text = window.getSelection().toString().trim();
134+ if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
135+ window.webkit.messageHandlers.dumber.postMessage({
136+ type: 'auto_copy_selection',
137+ payload: { text: text }
138+ });
139+ }
140+ }, 300);
141+ });
142+})();`
143+
117 // ContentInjector encapsulates script injection into WebViews.144 // ContentInjector encapsulates script injection into WebViews.
118 // It injects dark mode detection scripts for internal pages (dumb://)145 // It injects dark mode detection scripts for internal pages (dumb://)
119 // and theme CSS variables for WebUI styling.146 // and theme CSS variables for WebUI styling.
120 // External pages receive dark mode preference via libadwaita's StyleManager.147 // External pages receive dark mode preference via libadwaita's StyleManager.
121 // Implements port.ContentInjector interface.148 // Implements port.ContentInjector interface.
122 type ContentInjector struct {149 type ContentInjector struct {
123- colorResolver port.ColorSchemeResolver150+ colorResolver port.ColorSchemeResolver
124- themeCSSVars string // CSS custom property declarations for WebUI151+ themeCSSVars string // CSS custom property declarations for WebUI
125- findCSS string // CSS for find-in-page highlight styling152+ findCSS string // CSS for find-in-page highlight styling
153+ autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
126 }154 }
127 155
128 // NewContentInjector creates a new injector instance.156 // NewContentInjector creates a new injector instance.
@@ -133,6 +161,12 @@ func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
133 }161 }
134 }162 }
135 163
164+// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
165+// This is called during script injection to determine whether to inject the selection listener.
166+func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
167+ ci.autoCopyConfigGetter = getter
168+}
169+
136 // InjectThemeCSS stores CSS variables for injection into internal pages.170 // InjectThemeCSS stores CSS variables for injection into internal pages.
137 // Implements port.ContentInjector interface.171 // Implements port.ContentInjector interface.
138 // The CSS will be injected when InjectScripts is called on WebView creation.172 // The CSS will be injected when InjectScripts is called on WebView creation.
@@ -252,5 +286,21 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
252 }286 }
253 }287 }
254 288
255- log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")289+ // 5. Inject auto-copy selection script for all pages (if enabled)
290+ autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
291+ if autoCopyEnabled {
292+ addScript(
293+ webkit.NewUserScript(
294+ autoCopySelectionScript,
295+ webkit.UserContentInjectTopFrameValue,
296+ webkit.UserScriptInjectAtDocumentEndValue,
297+ nil, // All pages
298+ nil,
299+ ),
300+ "auto-copy-selection",
301+ )
302+ log.Debug().Msg("auto-copy selection script injected")
303+ }
304+
305+ log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
256 }306 }
M internal/ui/app.go +18 -2
@@ -146,8 +146,24 @@ func New(deps *Dependencies) (*App, error) {
146146 // Register message handlers
147147 if app.router != nil {
148148 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
149- HistoryUC: deps.HistoryUC,
150- FavoritesUC: deps.FavoritesUC,
149+ HistoryUC: deps.HistoryUC,
150+ FavoritesUC: deps.FavoritesUC,
151+ Clipboard: deps.Clipboard,
152+ ConfigGetter: config.Get,
153+ OnClipboardCopied: func(textLen int) {
154+ // Show brief toast on auto-copy (similar to zellij footer notification)
155+ // Must schedule on GTK main thread since this is called from WebKit handler
156+ cb := glib.SourceFunc(func(_ uintptr) bool {
157+ if app.appToaster != nil {
158+ app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
159+ component.WithDuration(component.ToastBriefDurationMs),
160+ component.WithPosition(component.ToastPositionBottomRight),
161+ )
162+ }
163+ return false
164+ })
165+ glib.IdleAdd(&cb, 0)
166+ },
151167 }); err != nil {
152168 return nil, err
153169 }
@@ -146,8 +146,24 @@ func New(deps *Dependencies) (*App, error) {
146 // Register message handlers146 // Register message handlers
147 if app.router != nil {147 if app.router != nil {
148 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{148 if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
149- HistoryUC: deps.HistoryUC,149+ HistoryUC: deps.HistoryUC,
150- FavoritesUC: deps.FavoritesUC,150+ FavoritesUC: deps.FavoritesUC,
151+ Clipboard: deps.Clipboard,
152+ ConfigGetter: config.Get,
153+ OnClipboardCopied: func(textLen int) {
154+ // Show brief toast on auto-copy (similar to zellij footer notification)
155+ // Must schedule on GTK main thread since this is called from WebKit handler
156+ cb := glib.SourceFunc(func(_ uintptr) bool {
157+ if app.appToaster != nil {
158+ app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
159+ component.WithDuration(component.ToastBriefDurationMs),
160+ component.WithPosition(component.ToastPositionBottomRight),
161+ )
162+ }
163+ return false
164+ })
165+ glib.IdleAdd(&cb, 0)
166+ },
151 }); err != nil {167 }); err != nil {
152 return nil, err168 return nil, err
153 }169 }
M internal/ui/component/link_status.go +40 -4
@@ -13,6 +13,8 @@ import (
1313 const (
1414 // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
1515 linkStatusShowDelayMs = 100
16+ // linkStatusAutoHideMs is the delay before auto-hiding the overlay (e.g., during fullscreen video).
17+ linkStatusAutoHideMs = 10000
1618 // linkStatusMaxChars is the maximum characters before truncation.
1719 linkStatusMaxChars = 80
1820 )
@@ -24,10 +26,11 @@ type LinkStatusOverlay struct {
2426 container layout.BoxWidget
2527 label layout.LabelWidget
2628
27- pendingURI string // URI waiting to be shown after delay
28- showTimer uint // GLib timer for delayed show
29- visible bool // Whether overlay is currently visible (has .visible class)
30- mu sync.Mutex
29+ pendingURI string // URI waiting to be shown after delay
30+ showTimer uint // GLib timer for delayed show
31+ autoHideTimer uint // GLib timer for auto-hide after timeout
32+ visible bool // Whether overlay is currently visible (has .visible class)
33+ mu sync.Mutex
3134 }
3235
3336 // NewLinkStatusOverlay creates a new link status overlay component.
@@ -96,6 +99,8 @@ func (l *LinkStatusOverlay) Show(uri string) {
9699 l.visible = true
97100 l.container.AddCssClass("visible")
98101 }
102+ // Start or reset auto-hide timer
103+ l.resetAutoHideTimerLocked()
99104 }
100105 l.showTimer = 0
101106 return false // Don't repeat
@@ -118,6 +123,11 @@ func (l *LinkStatusOverlay) hide() {
118123 glib.SourceRemove(l.showTimer)
119124 l.showTimer = 0
120125 }
126+ // Cancel auto-hide timer
127+ if l.autoHideTimer != 0 {
128+ glib.SourceRemove(l.autoHideTimer)
129+ l.autoHideTimer = 0
130+ }
121131 l.pendingURI = ""
122132
123133 if l.visible {
@@ -126,6 +136,27 @@ func (l *LinkStatusOverlay) hide() {
126136 }
127137 }
128138
139+// resetAutoHideTimerLocked starts or resets the auto-hide timer (must be called with lock held).
140+func (l *LinkStatusOverlay) resetAutoHideTimerLocked() {
141+ // Cancel existing auto-hide timer
142+ if l.autoHideTimer != 0 {
143+ glib.SourceRemove(l.autoHideTimer)
144+ l.autoHideTimer = 0
145+ }
146+
147+ // Start new auto-hide timer
148+ cb := glib.SourceFunc(func(_ uintptr) bool {
149+ l.mu.Lock()
150+ defer l.mu.Unlock()
151+
152+ l.autoHideTimer = 0
153+ l.hide()
154+ return false // Don't repeat
155+ })
156+
157+ l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0)
158+}
159+
129160 // Widget returns the underlying widget for embedding in overlays.
130161 func (l *LinkStatusOverlay) Widget() layout.Widget {
131162 return l.container
@@ -149,6 +180,11 @@ func (l *LinkStatusOverlay) Cleanup() {
149180 glib.SourceRemove(l.showTimer)
150181 l.showTimer = 0
151182 }
183+ // Cancel auto-hide timer
184+ if l.autoHideTimer != 0 {
185+ glib.SourceRemove(l.autoHideTimer)
186+ l.autoHideTimer = 0
187+ }
152188 l.pendingURI = ""
153189 l.visible = false
154190 }
@@ -13,6 +13,8 @@ import (
13 const (13 const (
14 // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.14 // linkStatusShowDelayMs is the delay before showing the overlay to avoid flicker.
15 linkStatusShowDelayMs = 10015 linkStatusShowDelayMs = 100
16+ // linkStatusAutoHideMs is the delay before auto-hiding the overlay (e.g., during fullscreen video).
17+ linkStatusAutoHideMs = 10000
16 // linkStatusMaxChars is the maximum characters before truncation.18 // linkStatusMaxChars is the maximum characters before truncation.
17 linkStatusMaxChars = 8019 linkStatusMaxChars = 80
18 )20 )
@@ -24,10 +26,11 @@ type LinkStatusOverlay struct {
24 container layout.BoxWidget26 container layout.BoxWidget
25 label layout.LabelWidget27 label layout.LabelWidget
26 28
27- pendingURI string // URI waiting to be shown after delay29+ pendingURI string // URI waiting to be shown after delay
28- showTimer uint // GLib timer for delayed show30+ showTimer uint // GLib timer for delayed show
29- visible bool // Whether overlay is currently visible (has .visible class)31+ autoHideTimer uint // GLib timer for auto-hide after timeout
30- mu sync.Mutex32+ visible bool // Whether overlay is currently visible (has .visible class)
33+ mu sync.Mutex
31 }34 }
32 35
33 // NewLinkStatusOverlay creates a new link status overlay component.36 // NewLinkStatusOverlay creates a new link status overlay component.
@@ -96,6 +99,8 @@ func (l *LinkStatusOverlay) Show(uri string) {
96 l.visible = true99 l.visible = true
97 l.container.AddCssClass("visible")100 l.container.AddCssClass("visible")
98 }101 }
102+ // Start or reset auto-hide timer
103+ l.resetAutoHideTimerLocked()
99 }104 }
100 l.showTimer = 0105 l.showTimer = 0
101 return false // Don't repeat106 return false // Don't repeat
@@ -118,6 +123,11 @@ func (l *LinkStatusOverlay) hide() {
118 glib.SourceRemove(l.showTimer)123 glib.SourceRemove(l.showTimer)
119 l.showTimer = 0124 l.showTimer = 0
120 }125 }
126+ // Cancel auto-hide timer
127+ if l.autoHideTimer != 0 {
128+ glib.SourceRemove(l.autoHideTimer)
129+ l.autoHideTimer = 0
130+ }
121 l.pendingURI = ""131 l.pendingURI = ""
122 132
123 if l.visible {133 if l.visible {
@@ -126,6 +136,27 @@ func (l *LinkStatusOverlay) hide() {
126 }136 }
127 }137 }
128 138
139+// resetAutoHideTimerLocked starts or resets the auto-hide timer (must be called with lock held).
140+func (l *LinkStatusOverlay) resetAutoHideTimerLocked() {
141+ // Cancel existing auto-hide timer
142+ if l.autoHideTimer != 0 {
143+ glib.SourceRemove(l.autoHideTimer)
144+ l.autoHideTimer = 0
145+ }
146+
147+ // Start new auto-hide timer
148+ cb := glib.SourceFunc(func(_ uintptr) bool {
149+ l.mu.Lock()
150+ defer l.mu.Unlock()
151+
152+ l.autoHideTimer = 0
153+ l.hide()
154+ return false // Don't repeat
155+ })
156+
157+ l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0)
158+}
159+
129 // Widget returns the underlying widget for embedding in overlays.160 // Widget returns the underlying widget for embedding in overlays.
130 func (l *LinkStatusOverlay) Widget() layout.Widget {161 func (l *LinkStatusOverlay) Widget() layout.Widget {
131 return l.container162 return l.container
@@ -149,6 +180,11 @@ func (l *LinkStatusOverlay) Cleanup() {
149 glib.SourceRemove(l.showTimer)180 glib.SourceRemove(l.showTimer)
150 l.showTimer = 0181 l.showTimer = 0
151 }182 }
183+ // Cancel auto-hide timer
184+ if l.autoHideTimer != 0 {
185+ glib.SourceRemove(l.autoHideTimer)
186+ l.autoHideTimer = 0
187+ }
152 l.pendingURI = ""188 l.pendingURI = ""
153 l.visible = false189 l.visible = false
154 }190 }
M internal/ui/component/pane_view.go +12 -0
@@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
244244 }
245245 }
246246
247+// CancelPendingHover cancels any pending hover focus timer.
248+// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
249+func (pv *PaneView) CancelPendingHover() {
250+ pv.mu.RLock()
251+ handler := pv.hoverHandler
252+ pv.mu.RUnlock()
253+
254+ if handler != nil {
255+ handler.Cancel()
256+ }
257+}
258+
247259 // Widget returns the underlying overlay widget for embedding in containers.
248260 func (pv *PaneView) Widget() layout.Widget {
249261 return pv.overlay
@@ -244,6 +244,18 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
244 }244 }
245 }245 }
246 246
247+// CancelPendingHover cancels any pending hover focus timer.
248+// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
249+func (pv *PaneView) CancelPendingHover() {
250+ pv.mu.RLock()
251+ handler := pv.hoverHandler
252+ pv.mu.RUnlock()
253+
254+ if handler != nil {
255+ handler.Cancel()
256+ }
257+}
258+
247 // Widget returns the underlying overlay widget for embedding in containers.259 // Widget returns the underlying overlay widget for embedding in containers.
248 func (pv *PaneView) Widget() layout.Widget {260 func (pv *PaneView) Widget() layout.Widget {
249 return pv.overlay261 return pv.overlay
M internal/ui/component/toaster.go +2 -0
@@ -16,6 +16,8 @@ import (
1616 const (
1717 // Default auto-dismiss timeout in milliseconds.
1818 toastDismissTimeoutMs = 1500
19+ // ToastBriefDurationMs is the duration for brief notifications (e.g., clipboard copy).
20+ ToastBriefDurationMs = 800
1921 )
2022
2123 // ToastLevel indicates the visual style of a toast notification.
@@ -16,6 +16,8 @@ import (
16 const (16 const (
17 // Default auto-dismiss timeout in milliseconds.17 // Default auto-dismiss timeout in milliseconds.
18 toastDismissTimeoutMs = 150018 toastDismissTimeoutMs = 1500
19+ // ToastBriefDurationMs is the duration for brief notifications (e.g., clipboard copy).
20+ ToastBriefDurationMs = 800
19 )21 )
20 22
21 // ToastLevel indicates the visual style of a toast notification.23 // ToastLevel indicates the visual style of a toast notification.
M internal/ui/component/workspace_view.go +54 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "sync"
9+ "time"
910
1011 "github.com/bnema/dumber/internal/domain/entity"
1112 "github.com/bnema/dumber/internal/logging"
@@ -50,6 +51,10 @@ type WorkspaceView struct {
5051 onPaneFocused func(paneID entity.PaneID)
5152 onSplitRatioDragged func(nodeID string, ratio float64)
5253
54+ // Hover suppression for keyboard navigation (Issue #89)
55+ // Prevents hover focus from overriding keyboard-initiated focus changes
56+ hoverSuppressedUntil time.Time
57+
5358 mu sync.RWMutex
5459 }
5560
@@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
8691
8792 // Set up hover callback for focus-follows-mouse
8893 pv.SetOnHover(func(paneID entity.PaneID) {
94+ // Skip if hover is suppressed (keyboard navigation just happened)
95+ if a.wv.IsHoverSuppressed() {
96+ return
97+ }
98+
8999 // Skip if this pane is already active
90100 if a.wv.GetActivePaneID() == paneID {
91101 return
@@ -347,6 +357,18 @@ func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
347357 return pv.GrabFocus()
348358 }
349359
360+// DeactivatePane removes the active visual indicator from a pane.
361+// This is used when keyboard navigation overrides mouse hover focus.
362+func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
363+ wv.mu.RLock()
364+ pv, ok := wv.paneViews[paneID]
365+ wv.mu.RUnlock()
366+
367+ if ok && pv != nil {
368+ pv.SetActive(false)
369+ }
370+}
371+
350372 // SetWebViewWidget attaches a WebView widget to a specific pane.
351373 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
352374 wv.mu.RLock()
@@ -755,3 +777,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView {
755777 defer wv.mu.RUnlock()
756778 return wv.paneViews[wv.getActivePaneIDInternal()]
757779 }
780+
781+// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
782+const KeyboardFocusSuppressDuration = 300 * time.Millisecond
783+
784+// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
785+// This prevents the race condition where a pending hover timer overrides
786+// an explicit keyboard focus change (Issue #89).
787+func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
788+ wv.mu.Lock()
789+ wv.hoverSuppressedUntil = time.Now().Add(duration)
790+ wv.mu.Unlock()
791+}
792+
793+// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
794+func (wv *WorkspaceView) IsHoverSuppressed() bool {
795+ wv.mu.RLock()
796+ defer wv.mu.RUnlock()
797+ return time.Now().Before(wv.hoverSuppressedUntil)
798+}
799+
800+// CancelAllPendingHovers cancels any pending hover timers on all pane views.
801+// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
802+func (wv *WorkspaceView) CancelAllPendingHovers() {
803+ wv.mu.RLock()
804+ defer wv.mu.RUnlock()
805+
806+ for _, pv := range wv.paneViews {
807+ if pv != nil {
808+ pv.CancelPendingHover()
809+ }
810+ }
811+}
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "sync"8 "sync"
9+ "time"
9 10
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
@@ -50,6 +51,10 @@ type WorkspaceView struct {
50 onPaneFocused func(paneID entity.PaneID)51 onPaneFocused func(paneID entity.PaneID)
51 onSplitRatioDragged func(nodeID string, ratio float64)52 onSplitRatioDragged func(nodeID string, ratio float64)
52 53
54+ // Hover suppression for keyboard navigation (Issue #89)
55+ // Prevents hover focus from overriding keyboard-initiated focus changes
56+ hoverSuppressedUntil time.Time
57+
53 mu sync.RWMutex58 mu sync.RWMutex
54 }59 }
55 60
@@ -86,6 +91,11 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
86 91
87 // Set up hover callback for focus-follows-mouse92 // Set up hover callback for focus-follows-mouse
88 pv.SetOnHover(func(paneID entity.PaneID) {93 pv.SetOnHover(func(paneID entity.PaneID) {
94+ // Skip if hover is suppressed (keyboard navigation just happened)
95+ if a.wv.IsHoverSuppressed() {
96+ return
97+ }
98+
89 // Skip if this pane is already active99 // Skip if this pane is already active
90 if a.wv.GetActivePaneID() == paneID {100 if a.wv.GetActivePaneID() == paneID {
91 return101 return
@@ -347,6 +357,18 @@ func (wv *WorkspaceView) FocusPane(paneID entity.PaneID) bool {
347 return pv.GrabFocus()357 return pv.GrabFocus()
348 }358 }
349 359
360+// DeactivatePane removes the active visual indicator from a pane.
361+// This is used when keyboard navigation overrides mouse hover focus.
362+func (wv *WorkspaceView) DeactivatePane(paneID entity.PaneID) {
363+ wv.mu.RLock()
364+ pv, ok := wv.paneViews[paneID]
365+ wv.mu.RUnlock()
366+
367+ if ok && pv != nil {
368+ pv.SetActive(false)
369+ }
370+}
371+
350 // SetWebViewWidget attaches a WebView widget to a specific pane.372 // SetWebViewWidget attaches a WebView widget to a specific pane.
351 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {373 func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Widget) error {
352 wv.mu.RLock()374 wv.mu.RLock()
@@ -755,3 +777,35 @@ func (wv *WorkspaceView) GetActivePaneView() *PaneView {
755 defer wv.mu.RUnlock()777 defer wv.mu.RUnlock()
756 return wv.paneViews[wv.getActivePaneIDInternal()]778 return wv.paneViews[wv.getActivePaneIDInternal()]
757 }779 }
780+
781+// KeyboardFocusSuppressDuration is the time to suppress hover focus after keyboard navigation.
782+const KeyboardFocusSuppressDuration = 300 * time.Millisecond
783+
784+// SuppressHover temporarily blocks hover focus changes after keyboard navigation.
785+// This prevents the race condition where a pending hover timer overrides
786+// an explicit keyboard focus change (Issue #89).
787+func (wv *WorkspaceView) SuppressHover(duration time.Duration) {
788+ wv.mu.Lock()
789+ wv.hoverSuppressedUntil = time.Now().Add(duration)
790+ wv.mu.Unlock()
791+}
792+
793+// IsHoverSuppressed returns true if hover focus is temporarily suppressed.
794+func (wv *WorkspaceView) IsHoverSuppressed() bool {
795+ wv.mu.RLock()
796+ defer wv.mu.RUnlock()
797+ return time.Now().Before(wv.hoverSuppressedUntil)
798+}
799+
800+// CancelAllPendingHovers cancels any pending hover timers on all pane views.
801+// Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
802+func (wv *WorkspaceView) CancelAllPendingHovers() {
803+ wv.mu.RLock()
804+ defer wv.mu.RUnlock()
805+
806+ for _, pv := range wv.paneViews {
807+ if pv != nil {
808+ pv.CancelPendingHover()
809+ }
810+ }
811+}
M internal/ui/component/workspace_view_test.go +25 -10
@@ -128,15 +128,18 @@ func setupStackedLeafMocks(
128128 mockTitleBar := mocks.NewMockBoxWidget(t)
129129 mockFavicon := mocks.NewMockImageWidget(t)
130130 mockLabel := mocks.NewMockLabelWidget(t)
131- mockButton := mocks.NewMockButtonWidget(t)
131+ mockCloseButton := mocks.NewMockButtonWidget(t)
132132
133133 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
134134 mockStackBox.EXPECT().SetHexpand(true).Once()
135135 mockStackBox.EXPECT().SetVexpand(true).Once()
136136
137+ // Title bar creation - now directly used without button wrapper
137138 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
138139 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
140+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
139141 mockTitleBar.EXPECT().SetVexpand(false).Once()
142+ mockTitleBar.EXPECT().SetHexpand(true).Once()
140143
141144 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
142145 mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
@@ -150,18 +153,30 @@ func setupStackedLeafMocks(
150153 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
151154 mockTitleBar.EXPECT().Append(mockLabel).Once()
152155
153- mockFactory.EXPECT().NewButton().Return(mockButton).Once()
154- mockButton.EXPECT().SetChild(mockTitleBar).Once()
155- mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
156- mockButton.EXPECT().SetFocusOnClick(false).Once()
157- mockButton.EXPECT().SetVexpand(false).Once()
158- mockButton.EXPECT().SetHexpand(true).Once()
159- mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
156+ // Close button (uses SetIconName directly instead of child image)
157+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
158+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
159+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
160+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
161+ mockCloseButton.EXPECT().SetVexpand(false).Once()
162+ mockCloseButton.EXPECT().SetHexpand(false).Once()
163+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
160164
161- mockStackBox.EXPECT().Append(mockButton).Once()
165+ // GestureClick is added to titleBar via AddController
166+ mockTitleBar.EXPECT().AddController(mock.Anything).Once()
167+
168+ // Close button click handler
169+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
170+
171+ // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
172+ mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
173+
174+ // Adding to main box - now titleBar is added directly (no button wrapper)
175+ mockStackBox.EXPECT().Append(mockTitleBar).Once()
162176 mockStackBox.EXPECT().Append(container).Once()
163177
164- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
178+ // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
179+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
165180 container.EXPECT().SetVisible(true).Once()
166181 mockTitleBar.EXPECT().AddCssClass("active").Once()
167182
@@ -128,15 +128,18 @@ func setupStackedLeafMocks(
128 mockTitleBar := mocks.NewMockBoxWidget(t)128 mockTitleBar := mocks.NewMockBoxWidget(t)
129 mockFavicon := mocks.NewMockImageWidget(t)129 mockFavicon := mocks.NewMockImageWidget(t)
130 mockLabel := mocks.NewMockLabelWidget(t)130 mockLabel := mocks.NewMockLabelWidget(t)
131- mockButton := mocks.NewMockButtonWidget(t)131+ mockCloseButton := mocks.NewMockButtonWidget(t)
132 132
133 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()133 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockStackBox).Once()
134 mockStackBox.EXPECT().SetHexpand(true).Once()134 mockStackBox.EXPECT().SetHexpand(true).Once()
135 mockStackBox.EXPECT().SetVexpand(true).Once()135 mockStackBox.EXPECT().SetVexpand(true).Once()
136 136
137+ // Title bar creation - now directly used without button wrapper
137 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()138 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
138 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()139 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
140+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
139 mockTitleBar.EXPECT().SetVexpand(false).Once()141 mockTitleBar.EXPECT().SetVexpand(false).Once()
142+ mockTitleBar.EXPECT().SetHexpand(true).Once()
140 143
141 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()144 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
142 mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()145 mockFavicon.EXPECT().SetFromIconName(mock.Anything).Once()
@@ -150,18 +153,30 @@ func setupStackedLeafMocks(
150 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()153 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
151 mockTitleBar.EXPECT().Append(mockLabel).Once()154 mockTitleBar.EXPECT().Append(mockLabel).Once()
152 155
153- mockFactory.EXPECT().NewButton().Return(mockButton).Once()156+ // Close button (uses SetIconName directly instead of child image)
154- mockButton.EXPECT().SetChild(mockTitleBar).Once()157+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
155- mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()158+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
156- mockButton.EXPECT().SetFocusOnClick(false).Once()159+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
157- mockButton.EXPECT().SetVexpand(false).Once()160+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
158- mockButton.EXPECT().SetHexpand(true).Once()161+ mockCloseButton.EXPECT().SetVexpand(false).Once()
159- mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()162+ mockCloseButton.EXPECT().SetHexpand(false).Once()
163+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
160 164
161- mockStackBox.EXPECT().Append(mockButton).Once()165+ // GestureClick is added to titleBar via AddController
166+ mockTitleBar.EXPECT().AddController(mock.Anything).Once()
167+
168+ // Close button click handler
169+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
170+
171+ // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
172+ mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
173+
174+ // Adding to main box - now titleBar is added directly (no button wrapper)
175+ mockStackBox.EXPECT().Append(mockTitleBar).Once()
162 mockStackBox.EXPECT().Append(container).Once()176 mockStackBox.EXPECT().Append(container).Once()
163 177
164- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()178+ // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
179+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
165 container.EXPECT().SetVisible(true).Once()180 container.EXPECT().SetVisible(true).Once()
166 mockTitleBar.EXPECT().AddCssClass("active").Once()181 mockTitleBar.EXPECT().AddCssClass("active").Once()
167 182
M internal/ui/coordinator/workspace.go +53 -16
@@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit(
179179 return nil, false
180180 }
181181
182- isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
183- activePane.Parent != nil && activePane.Parent.IsStacked
182+ // Check if active pane is inside a stack
183+ isInStack := activePane.Parent != nil && activePane.Parent.IsStacked
184+ // isStackSplit is only true for horizontal splits - these use a special code path
185+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack
184186
185187 var existingWidget layout.Widget
186188 if wsView != nil {
187- existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)
189+ existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack)
188190 }
189191
190192 return &splitContext{
@@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit(
199201 func (c *WorkspaceCoordinator) resolveSplitWidget(
200202 wsView *component.WorkspaceView,
201203 activePane *entity.PaneNode,
202- isStackSplit bool,
204+ isInStack bool,
203205 ) layout.Widget {
204206 if wsView == nil {
205207 return nil
206208 }
207209
208- if !isStackSplit {
209- return wsView.GetRootWidget()
210- }
210+ // When splitting from inside a stack (any direction), we need the stack widget
211+ // because the domain model promotes the split to happen around the stack container
212+ if isInStack {
213+ tr := wsView.TreeRenderer()
214+ if tr == nil {
215+ return nil
216+ }
211217
212- tr := wsView.TreeRenderer()
213- if tr == nil {
214- return nil
215- }
218+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
219+ if stackedView == nil {
220+ return nil
221+ }
216222
217- stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
218- if stackedView == nil {
219- return nil
223+ return stackedView.Widget()
220224 }
221225
222- return stackedView.Widget()
226+ return wsView.GetRootWidget()
223227 }
224228
225229 func (c *WorkspaceCoordinator) applySplitToView(
@@ -1137,6 +1141,9 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.
11371141 return nil
11381142 }
11391143
1144+ // Save old active pane ID before navigation updates the domain model
1145+ oldActivePaneID := ws.ActivePaneID
1146+
11401147 // Use geometric navigation if focus manager is available
11411148 var newPane *entity.PaneNode
11421149 var err error
@@ -1161,6 +1168,15 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.
11611168 return nil
11621169 }
11631170
1171+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1172+ wsView.CancelAllPendingHovers()
1173+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1174+
1175+ // Deactivate old pane explicitly (domain model already updated by NavigateGeometric)
1176+ if oldActivePaneID != "" && oldActivePaneID != newPane.Pane.ID {
1177+ wsView.DeactivatePane(oldActivePaneID)
1178+ }
1179+
11641180 // Update the workspace view's active pane
11651181 if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
11661182 log.Warn().Err(err).Msg("failed to update active pane in view")
@@ -1277,7 +1293,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12771293 stackCtx.wsView.ShowOmnibox(ctx, "")
12781294 }
12791295
1280- // Set up title bar click callback
1296+ // Set up title bar click and close callbacks
12811297 tr := stackCtx.wsView.TreeRenderer()
12821298 if tr != nil {
12831299 stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
@@ -1286,6 +1302,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12861302 stackedView.SetOnActivate(func(index int) {
12871303 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
12881304 })
1305+ stackedView.SetOnClosePane(func(paneID string) {
1306+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1307+ })
12891308 }
12901309 }
12911310
@@ -1531,6 +1550,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
15311550 // Update workspace view (also updates domain model)
15321551 _, wsView := c.getActiveWS()
15331552 if wsView != nil {
1553+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1554+ wsView.CancelAllPendingHovers()
1555+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1556+
15341557 if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
15351558 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
15361559 }
@@ -1543,6 +1566,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
15431566 Msg("switched active pane via title bar click")
15441567 }
15451568
1569+// onStackedPaneClose handles close button clicks on stacked pane title bars.
1570+func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) {
1571+ log := logging.FromContext(ctx)
1572+
1573+ log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button")
1574+
1575+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1576+ log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane")
1577+ }
1578+}
1579+
15461580 // --- Popup Insertion ---
15471581
15481582 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1836,6 +1870,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
18361870 stackedView.SetOnActivate(func(index int) {
18371871 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
18381872 })
1873+ stackedView.SetOnClosePane(func(paneID string) {
1874+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1875+ })
18391876 }
18401877 }
18411878 return nil
@@ -179,12 +179,14 @@ func (c *WorkspaceCoordinator) prepareSplit(
179 return nil, false179 return nil, false
180 }180 }
181 181
182- isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&182+ // Check if active pane is inside a stack
183- activePane.Parent != nil && activePane.Parent.IsStacked183+ isInStack := activePane.Parent != nil && activePane.Parent.IsStacked
184+ // isStackSplit is only true for horizontal splits - these use a special code path
185+ isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && isInStack
184 186
185 var existingWidget layout.Widget187 var existingWidget layout.Widget
186 if wsView != nil {188 if wsView != nil {
187- existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)189+ existingWidget = c.resolveSplitWidget(wsView, activePane, isInStack)
188 }190 }
189 191
190 return &splitContext{192 return &splitContext{
@@ -199,27 +201,29 @@ func (c *WorkspaceCoordinator) prepareSplit(
199 func (c *WorkspaceCoordinator) resolveSplitWidget(201 func (c *WorkspaceCoordinator) resolveSplitWidget(
200 wsView *component.WorkspaceView,202 wsView *component.WorkspaceView,
201 activePane *entity.PaneNode,203 activePane *entity.PaneNode,
202- isStackSplit bool,204+ isInStack bool,
203 ) layout.Widget {205 ) layout.Widget {
204 if wsView == nil {206 if wsView == nil {
205 return nil207 return nil
206 }208 }
207 209
208- if !isStackSplit {210+ // When splitting from inside a stack (any direction), we need the stack widget
209- return wsView.GetRootWidget()211+ // because the domain model promotes the split to happen around the stack container
210- }212+ if isInStack {
213+ tr := wsView.TreeRenderer()
214+ if tr == nil {
215+ return nil
216+ }
211 217
212- tr := wsView.TreeRenderer()218+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
213- if tr == nil {219+ if stackedView == nil {
214- return nil220+ return nil
215- }221+ }
216 222
217- stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))223+ return stackedView.Widget()
218- if stackedView == nil {
219- return nil
220 }224 }
221 225
222- return stackedView.Widget()226+ return wsView.GetRootWidget()
223 }227 }
224 228
225 func (c *WorkspaceCoordinator) applySplitToView(229 func (c *WorkspaceCoordinator) applySplitToView(
@@ -1137,6 +1141,9 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.
1137 return nil1141 return nil
1138 }1142 }
1139 1143
1144+ // Save old active pane ID before navigation updates the domain model
1145+ oldActivePaneID := ws.ActivePaneID
1146+
1140 // Use geometric navigation if focus manager is available1147 // Use geometric navigation if focus manager is available
1141 var newPane *entity.PaneNode1148 var newPane *entity.PaneNode
1142 var err error1149 var err error
@@ -1161,6 +1168,15 @@ func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.
1161 return nil1168 return nil
1162 }1169 }
1163 1170
1171+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1172+ wsView.CancelAllPendingHovers()
1173+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1174+
1175+ // Deactivate old pane explicitly (domain model already updated by NavigateGeometric)
1176+ if oldActivePaneID != "" && oldActivePaneID != newPane.Pane.ID {
1177+ wsView.DeactivatePane(oldActivePaneID)
1178+ }
1179+
1164 // Update the workspace view's active pane1180 // Update the workspace view's active pane
1165 if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {1181 if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil {
1166 log.Warn().Err(err).Msg("failed to update active pane in view")1182 log.Warn().Err(err).Msg("failed to update active pane in view")
@@ -1277,7 +1293,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1277 stackCtx.wsView.ShowOmnibox(ctx, "")1293 stackCtx.wsView.ShowOmnibox(ctx, "")
1278 }1294 }
1279 1295
1280- // Set up title bar click callback1296+ // Set up title bar click and close callbacks
1281 tr := stackCtx.wsView.TreeRenderer()1297 tr := stackCtx.wsView.TreeRenderer()
1282 if tr != nil {1298 if tr != nil {
1283 stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))1299 stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
@@ -1286,6 +1302,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1286 stackedView.SetOnActivate(func(index int) {1302 stackedView.SetOnActivate(func(index int) {
1287 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)1303 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1288 })1304 })
1305+ stackedView.SetOnClosePane(func(paneID string) {
1306+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1307+ })
1289 }1308 }
1290 }1309 }
1291 1310
@@ -1531,6 +1550,10 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
1531 // Update workspace view (also updates domain model)1550 // Update workspace view (also updates domain model)
1532 _, wsView := c.getActiveWS()1551 _, wsView := c.getActiveWS()
1533 if wsView != nil {1552 if wsView != nil {
1553+ // Cancel pending hover timers and suppress future hover focus (Issue #89)
1554+ wsView.CancelAllPendingHovers()
1555+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
1556+
1534 if err := wsView.SetActivePaneID(clickedPaneID); err != nil {1557 if err := wsView.SetActivePaneID(clickedPaneID); err != nil {
1535 log.Warn().Err(err).Msg("failed to set active pane in workspace view")1558 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1536 }1559 }
@@ -1543,6 +1566,17 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
1543 Msg("switched active pane via title bar click")1566 Msg("switched active pane via title bar click")
1544 }1567 }
1545 1568
1569+// onStackedPaneClose handles close button clicks on stacked pane title bars.
1570+func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID entity.PaneID) {
1571+ log := logging.FromContext(ctx)
1572+
1573+ log.Debug().Str("pane_id", string(paneID)).Msg("closing stacked pane via close button")
1574+
1575+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1576+ log.Error().Err(err).Str("pane_id", string(paneID)).Msg("failed to close stacked pane")
1577+ }
1578+}
1579+
1546 // --- Popup Insertion ---1580 // --- Popup Insertion ---
1547 1581
1548 // SetOnCreatePopupTab sets the callback for creating popup tabs.1582 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1836,6 +1870,9 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
1836 stackedView.SetOnActivate(func(index int) {1870 stackedView.SetOnActivate(func(index int) {
1837 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)1871 c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1838 })1872 })
1873+ stackedView.SetOnClosePane(func(paneID string) {
1874+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1875+ })
1839 }1876 }
1840 }1877 }
1841 return nil1878 return nil
M internal/ui/focus/manager.go +35 -10
@@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
3838 // For panes in stacks, uses the stack container's geometry since individual
3939 // collapsed panes have no allocated size.
4040 // Only visible panes are included - for stacked panes, only the active one is visible.
41-func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {
41+func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect {
42+ log := logging.FromContext(ctx)
4243 var rects []entity.PaneRect
4344
4445 container := provider.ContainerWidget()
4546
46- for _, paneID := range provider.GetPaneIDs() {
47+ allPaneIDs := provider.GetPaneIDs()
48+ log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting")
49+
50+ for _, paneID := range allPaneIDs {
4751 paneWidget := provider.GetPaneWidget(paneID)
4852
4953 // Skip panes that aren't visible (includes inactive stacked panes)
5054 if paneWidget == nil || !paneWidget.IsVisible() {
55+ log.Debug().
56+ Str("pane_id", string(paneID)).
57+ Bool("widget_nil", paneWidget == nil).
58+ Msg("CollectPaneRects: skipping invisible pane")
5159 continue
5260 }
5361
@@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
7785 continue
7886 }
7987
88+ log.Debug().
89+ Str("pane_id", string(paneID)).
90+ Int("x", int(x)).
91+ Int("y", int(y)).
92+ Int("w", w).
93+ Int("h", h).
94+ Msg("CollectPaneRects: added visible pane")
95+
8096 rects = append(rects, entity.PaneRect{
8197 PaneID: paneID,
8298 X: int(x),
@@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
86102 })
87103 }
88104
105+ log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done")
106+
89107 return rects
90108 }
91109
@@ -119,8 +137,12 @@ func (m *Manager) NavigateGeometric(
119137 stackNode := activeNode.Parent
120138 canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
121139 if canNavigate {
122- ws.ActivePaneID = newPaneID
123- return ws.FindPane(newPaneID), nil
140+ // Apply focus change through use case (handles stack index updates)
141+ targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID)
142+ if err != nil {
143+ return nil, err
144+ }
145+ return targetNode, nil
124146 }
125147 // At stack boundary - fall through to geometric navigation
126148 log.Debug().Msg("at stack boundary, using geometric navigation")
@@ -128,7 +150,7 @@ func (m *Manager) NavigateGeometric(
128150 }
129151
130152 // Collect geometry from visible panes
131- rects := m.CollectPaneRects(provider)
153+ rects := m.CollectPaneRects(ctx, provider)
132154 if len(rects) == 0 {
133155 return nil, nil
134156 }
@@ -146,16 +168,20 @@ func (m *Manager) NavigateGeometric(
146168 return nil, nil
147169 }
148170
149- // Update workspace
150- ws.ActivePaneID = output.TargetPaneID
171+ // Apply focus change through use case (handles stack index updates)
172+ targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID)
173+ if err != nil {
174+ return nil, err
175+ }
151176
152- // Return target node
153- return ws.FindPane(output.TargetPaneID), nil
177+ return targetNode, nil
154178 }
155179
156180 // navigateWithinStack tries to navigate within a stack.
157181 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
158182 // boundary and navigation should escape the stack.
183+// Note: This method does not modify domain state; the caller should use
184+// ApplyFocusChange to apply the focus change.
159185 func (m *Manager) navigateWithinStack(
160186 stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
161187 ) (bool, entity.PaneID) {
@@ -173,7 +199,6 @@ func (m *Manager) navigateWithinStack(
173199 return false, ""
174200 }
175201
176- stackNode.ActiveStackIndex = newIdx
177202 return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
178203 }
179204
@@ -38,16 +38,24 @@ func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager {
38 // For panes in stacks, uses the stack container's geometry since individual38 // For panes in stacks, uses the stack container's geometry since individual
39 // collapsed panes have no allocated size.39 // collapsed panes have no allocated size.
40 // Only visible panes are included - for stacked panes, only the active one is visible.40 // Only visible panes are included - for stacked panes, only the active one is visible.
41-func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect {41+func (m *Manager) CollectPaneRects(ctx context.Context, provider PaneGeometryProvider) []entity.PaneRect {
42+ log := logging.FromContext(ctx)
42 var rects []entity.PaneRect43 var rects []entity.PaneRect
43 44
44 container := provider.ContainerWidget()45 container := provider.ContainerWidget()
45 46
46- for _, paneID := range provider.GetPaneIDs() {47+ allPaneIDs := provider.GetPaneIDs()
48+ log.Debug().Int("total_panes", len(allPaneIDs)).Msg("CollectPaneRects: starting")
49+
50+ for _, paneID := range allPaneIDs {
47 paneWidget := provider.GetPaneWidget(paneID)51 paneWidget := provider.GetPaneWidget(paneID)
48 52
49 // Skip panes that aren't visible (includes inactive stacked panes)53 // Skip panes that aren't visible (includes inactive stacked panes)
50 if paneWidget == nil || !paneWidget.IsVisible() {54 if paneWidget == nil || !paneWidget.IsVisible() {
55+ log.Debug().
56+ Str("pane_id", string(paneID)).
57+ Bool("widget_nil", paneWidget == nil).
58+ Msg("CollectPaneRects: skipping invisible pane")
51 continue59 continue
52 }60 }
53 61
@@ -77,6 +85,14 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
77 continue85 continue
78 }86 }
79 87
88+ log.Debug().
89+ Str("pane_id", string(paneID)).
90+ Int("x", int(x)).
91+ Int("y", int(y)).
92+ Int("w", w).
93+ Int("h", h).
94+ Msg("CollectPaneRects: added visible pane")
95+
80 rects = append(rects, entity.PaneRect{96 rects = append(rects, entity.PaneRect{
81 PaneID: paneID,97 PaneID: paneID,
82 X: int(x),98 X: int(x),
@@ -86,6 +102,8 @@ func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneR
86 })102 })
87 }103 }
88 104
105+ log.Debug().Int("visible_panes", len(rects)).Msg("CollectPaneRects: done")
106+
89 return rects107 return rects
90 }108 }
91 109
@@ -119,8 +137,12 @@ func (m *Manager) NavigateGeometric(
119 stackNode := activeNode.Parent137 stackNode := activeNode.Parent
120 canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)138 canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction)
121 if canNavigate {139 if canNavigate {
122- ws.ActivePaneID = newPaneID140+ // Apply focus change through use case (handles stack index updates)
123- return ws.FindPane(newPaneID), nil141+ targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, newPaneID)
142+ if err != nil {
143+ return nil, err
144+ }
145+ return targetNode, nil
124 }146 }
125 // At stack boundary - fall through to geometric navigation147 // At stack boundary - fall through to geometric navigation
126 log.Debug().Msg("at stack boundary, using geometric navigation")148 log.Debug().Msg("at stack boundary, using geometric navigation")
@@ -128,7 +150,7 @@ func (m *Manager) NavigateGeometric(
128 }150 }
129 151
130 // Collect geometry from visible panes152 // Collect geometry from visible panes
131- rects := m.CollectPaneRects(provider)153+ rects := m.CollectPaneRects(ctx, provider)
132 if len(rects) == 0 {154 if len(rects) == 0 {
133 return nil, nil155 return nil, nil
134 }156 }
@@ -146,16 +168,20 @@ func (m *Manager) NavigateGeometric(
146 return nil, nil168 return nil, nil
147 }169 }
148 170
149- // Update workspace171+ // Apply focus change through use case (handles stack index updates)
150- ws.ActivePaneID = output.TargetPaneID172+ targetNode, err := m.panesUC.ApplyFocusChange(ctx, ws, output.TargetPaneID)
173+ if err != nil {
174+ return nil, err
175+ }
151 176
152- // Return target node177+ return targetNode, nil
153- return ws.FindPane(output.TargetPaneID), nil
154 }178 }
155 179
156 // navigateWithinStack tries to navigate within a stack.180 // navigateWithinStack tries to navigate within a stack.
157 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a181 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
158 // boundary and navigation should escape the stack.182 // boundary and navigation should escape the stack.
183+// Note: This method does not modify domain state; the caller should use
184+// ApplyFocusChange to apply the focus change.
159 func (m *Manager) navigateWithinStack(185 func (m *Manager) navigateWithinStack(
160 stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,186 stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
161 ) (bool, entity.PaneID) {187 ) (bool, entity.PaneID) {
@@ -173,7 +199,6 @@ func (m *Manager) navigateWithinStack(
173 return false, ""199 return false, ""
174 }200 }
175 201
176- stackNode.ActiveStackIndex = newIdx
177 return m.getPaneIDFromStackChild(stackNode.Children[newIdx])202 return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
178 }203 }
179 204
M internal/ui/focus/manager_test.go +57 -61
@@ -11,6 +11,8 @@ import (
1111
1212 // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses
1313 // ActiveStackIndex from the domain model instead of pointer comparison.
14+// Note: navigateWithinStack is a pure function that returns the target pane ID
15+// without modifying state. The actual state mutation happens in ApplyFocusChange.
1416 func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
1517 // Create a stack with 3 children
1618 pane0 := &entity.Pane{ID: "pane0"}
@@ -36,60 +38,53 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
3638 m := NewManager(nil)
3739
3840 tests := []struct {
39- name string
40- activeIndex int
41- direction usecase.NavigateDirection
42- wantCanNav bool
43- wantPaneID entity.PaneID
44- wantNewIndex int
41+ name string
42+ activeIndex int
43+ direction usecase.NavigateDirection
44+ wantCanNav bool
45+ wantPaneID entity.PaneID
4546 }{
4647 {
47- name: "navigate down from middle",
48- activeIndex: 1,
49- direction: usecase.NavDown,
50- wantCanNav: true,
51- wantPaneID: "pane2",
52- wantNewIndex: 2,
48+ name: "navigate down from middle",
49+ activeIndex: 1,
50+ direction: usecase.NavDown,
51+ wantCanNav: true,
52+ wantPaneID: "pane2",
5353 },
5454 {
55- name: "navigate up from middle",
56- activeIndex: 1,
57- direction: usecase.NavUp,
58- wantCanNav: true,
59- wantPaneID: "pane0",
60- wantNewIndex: 0,
55+ name: "navigate up from middle",
56+ activeIndex: 1,
57+ direction: usecase.NavUp,
58+ wantCanNav: true,
59+ wantPaneID: "pane0",
6160 },
6261 {
63- name: "navigate down from last - boundary",
64- activeIndex: 2,
65- direction: usecase.NavDown,
66- wantCanNav: false,
67- wantPaneID: "",
68- wantNewIndex: 2, // unchanged
62+ name: "navigate down from last - boundary",
63+ activeIndex: 2,
64+ direction: usecase.NavDown,
65+ wantCanNav: false,
66+ wantPaneID: "",
6967 },
7068 {
71- name: "navigate up from first - boundary",
72- activeIndex: 0,
73- direction: usecase.NavUp,
74- wantCanNav: false,
75- wantPaneID: "",
76- wantNewIndex: 0, // unchanged
69+ name: "navigate up from first - boundary",
70+ activeIndex: 0,
71+ direction: usecase.NavUp,
72+ wantCanNav: false,
73+ wantPaneID: "",
7774 },
7875 {
79- name: "navigate down from first",
80- activeIndex: 0,
81- direction: usecase.NavDown,
82- wantCanNav: true,
83- wantPaneID: "pane1",
84- wantNewIndex: 1,
76+ name: "navigate down from first",
77+ activeIndex: 0,
78+ direction: usecase.NavDown,
79+ wantCanNav: true,
80+ wantPaneID: "pane1",
8581 },
8682 {
87- name: "navigate up from last",
88- activeIndex: 2,
89- direction: usecase.NavUp,
90- wantCanNav: true,
91- wantPaneID: "pane1",
92- wantNewIndex: 1,
83+ name: "navigate up from last",
84+ activeIndex: 2,
85+ direction: usecase.NavUp,
86+ wantCanNav: true,
87+ wantPaneID: "pane1",
9388 },
9489 }
9590
@@ -98,16 +93,11 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
9893 // Set up the active index
9994 stackNode.ActiveStackIndex = tt.activeIndex
10095
101- // Call the private function through reflection or test it indirectly
102- // Since navigateWithinStack is private, we test its behavior
96+ // navigateWithinStack is a pure function - it does not modify state
10397 canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction)
10498
10599 assert.Equal(t, tt.wantCanNav, canNav)
106100 assert.Equal(t, tt.wantPaneID, paneID)
107-
108- if canNav {
109- assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex)
110- }
111101 })
112102 }
113103 }
@@ -193,9 +183,10 @@ func TestNavigateWithinStack_InvalidDirection(t *testing.T) {
193183 assert.Empty(t, paneID)
194184 }
195185
196-// TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly
197-// updates the ActiveStackIndex on the stack node.
198-func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) {
186+// TestNavigateWithinStack_ReturnsCorrectPaneID tests that navigation returns
187+// the correct target pane ID for sequential navigation.
188+// Note: navigateWithinStack is now a pure function - state updates happen in ApplyFocusChange.
189+func TestNavigateWithinStack_ReturnsCorrectPaneID(t *testing.T) {
199190 pane0 := &entity.Pane{ID: "pane0"}
200191 pane1 := &entity.Pane{ID: "pane1"}
201192 pane2 := &entity.Pane{ID: "pane2"}
@@ -213,22 +204,27 @@ func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) {
213204
214205 m := NewManager(nil)
215206
216- // Navigate down twice
207+ // Navigate down from first - should return pane1
217208 stackNode.ActiveStackIndex = 0
218- canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
209+ canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
219210 assert.True(t, canNav)
220- assert.Equal(t, 1, stackNode.ActiveStackIndex)
211+ assert.Equal(t, entity.PaneID("pane1"), paneID)
221212
222- canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown)
213+ // Navigate down from middle - should return pane2
214+ stackNode.ActiveStackIndex = 1
215+ canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavDown)
223216 assert.True(t, canNav)
224- assert.Equal(t, 2, stackNode.ActiveStackIndex)
217+ assert.Equal(t, entity.PaneID("pane2"), paneID)
225218
226- // Navigate up twice
227- canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp)
219+ // Navigate up from last - should return pane1
220+ stackNode.ActiveStackIndex = 2
221+ canNav, paneID = m.navigateWithinStack(stackNode, child2, usecase.NavUp)
228222 assert.True(t, canNav)
229- assert.Equal(t, 1, stackNode.ActiveStackIndex)
223+ assert.Equal(t, entity.PaneID("pane1"), paneID)
230224
231- canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp)
225+ // Navigate up from middle - should return pane0
226+ stackNode.ActiveStackIndex = 1
227+ canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavUp)
232228 assert.True(t, canNav)
233- assert.Equal(t, 0, stackNode.ActiveStackIndex)
229+ assert.Equal(t, entity.PaneID("pane0"), paneID)
234230 }
@@ -11,6 +11,8 @@ import (
11 11
12 // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses12 // TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses
13 // ActiveStackIndex from the domain model instead of pointer comparison.13 // ActiveStackIndex from the domain model instead of pointer comparison.
14+// Note: navigateWithinStack is a pure function that returns the target pane ID
15+// without modifying state. The actual state mutation happens in ApplyFocusChange.
14 func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {16 func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
15 // Create a stack with 3 children17 // Create a stack with 3 children
16 pane0 := &entity.Pane{ID: "pane0"}18 pane0 := &entity.Pane{ID: "pane0"}
@@ -36,60 +38,53 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
36 m := NewManager(nil)38 m := NewManager(nil)
37 39
38 tests := []struct {40 tests := []struct {
39- name string41+ name string
40- activeIndex int42+ activeIndex int
41- direction usecase.NavigateDirection43+ direction usecase.NavigateDirection
42- wantCanNav bool44+ wantCanNav bool
43- wantPaneID entity.PaneID45+ wantPaneID entity.PaneID
44- wantNewIndex int
45 }{46 }{
46 {47 {
47- name: "navigate down from middle",48+ name: "navigate down from middle",
48- activeIndex: 1,49+ activeIndex: 1,
49- direction: usecase.NavDown,50+ direction: usecase.NavDown,
50- wantCanNav: true,51+ wantCanNav: true,
51- wantPaneID: "pane2",52+ wantPaneID: "pane2",
52- wantNewIndex: 2,
53 },53 },
54 {54 {
55- name: "navigate up from middle",55+ name: "navigate up from middle",
56- activeIndex: 1,56+ activeIndex: 1,
57- direction: usecase.NavUp,57+ direction: usecase.NavUp,
58- wantCanNav: true,58+ wantCanNav: true,
59- wantPaneID: "pane0",59+ wantPaneID: "pane0",
60- wantNewIndex: 0,
61 },60 },
62 {61 {
63- name: "navigate down from last - boundary",62+ name: "navigate down from last - boundary",
64- activeIndex: 2,63+ activeIndex: 2,
65- direction: usecase.NavDown,64+ direction: usecase.NavDown,
66- wantCanNav: false,65+ wantCanNav: false,
67- wantPaneID: "",66+ wantPaneID: "",
68- wantNewIndex: 2, // unchanged
69 },67 },
70 {68 {
71- name: "navigate up from first - boundary",69+ name: "navigate up from first - boundary",
72- activeIndex: 0,70+ activeIndex: 0,
73- direction: usecase.NavUp,71+ direction: usecase.NavUp,
74- wantCanNav: false,72+ wantCanNav: false,
75- wantPaneID: "",73+ wantPaneID: "",
76- wantNewIndex: 0, // unchanged
77 },74 },
78 {75 {
79- name: "navigate down from first",76+ name: "navigate down from first",
80- activeIndex: 0,77+ activeIndex: 0,
81- direction: usecase.NavDown,78+ direction: usecase.NavDown,
82- wantCanNav: true,79+ wantCanNav: true,
83- wantPaneID: "pane1",80+ wantPaneID: "pane1",
84- wantNewIndex: 1,
85 },81 },
86 {82 {
87- name: "navigate up from last",83+ name: "navigate up from last",
88- activeIndex: 2,84+ activeIndex: 2,
89- direction: usecase.NavUp,85+ direction: usecase.NavUp,
90- wantCanNav: true,86+ wantCanNav: true,
91- wantPaneID: "pane1",87+ wantPaneID: "pane1",
92- wantNewIndex: 1,
93 },88 },
94 }89 }
95 90
@@ -98,16 +93,11 @@ func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
98 // Set up the active index93 // Set up the active index
99 stackNode.ActiveStackIndex = tt.activeIndex94 stackNode.ActiveStackIndex = tt.activeIndex
100 95
101- // Call the private function through reflection or test it indirectly96+ // navigateWithinStack is a pure function - it does not modify state
102- // Since navigateWithinStack is private, we test its behavior
103 canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction)97 canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction)
104 98
105 assert.Equal(t, tt.wantCanNav, canNav)99 assert.Equal(t, tt.wantCanNav, canNav)
106 assert.Equal(t, tt.wantPaneID, paneID)100 assert.Equal(t, tt.wantPaneID, paneID)
107-
108- if canNav {
109- assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex)
110- }
111 })101 })
112 }102 }
113 }103 }
@@ -193,9 +183,10 @@ func TestNavigateWithinStack_InvalidDirection(t *testing.T) {
193 assert.Empty(t, paneID)183 assert.Empty(t, paneID)
194 }184 }
195 185
196-// TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly186+// TestNavigateWithinStack_ReturnsCorrectPaneID tests that navigation returns
197-// updates the ActiveStackIndex on the stack node.187+// the correct target pane ID for sequential navigation.
198-func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) {188+// Note: navigateWithinStack is now a pure function - state updates happen in ApplyFocusChange.
189+func TestNavigateWithinStack_ReturnsCorrectPaneID(t *testing.T) {
199 pane0 := &entity.Pane{ID: "pane0"}190 pane0 := &entity.Pane{ID: "pane0"}
200 pane1 := &entity.Pane{ID: "pane1"}191 pane1 := &entity.Pane{ID: "pane1"}
201 pane2 := &entity.Pane{ID: "pane2"}192 pane2 := &entity.Pane{ID: "pane2"}
@@ -213,22 +204,27 @@ func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) {
213 204
214 m := NewManager(nil)205 m := NewManager(nil)
215 206
216- // Navigate down twice207+ // Navigate down from first - should return pane1
217 stackNode.ActiveStackIndex = 0208 stackNode.ActiveStackIndex = 0
218- canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown)209+ canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
219 assert.True(t, canNav)210 assert.True(t, canNav)
220- assert.Equal(t, 1, stackNode.ActiveStackIndex)211+ assert.Equal(t, entity.PaneID("pane1"), paneID)
221 212
222- canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown)213+ // Navigate down from middle - should return pane2
214+ stackNode.ActiveStackIndex = 1
215+ canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavDown)
223 assert.True(t, canNav)216 assert.True(t, canNav)
224- assert.Equal(t, 2, stackNode.ActiveStackIndex)217+ assert.Equal(t, entity.PaneID("pane2"), paneID)
225 218
226- // Navigate up twice219+ // Navigate up from last - should return pane1
227- canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp)220+ stackNode.ActiveStackIndex = 2
221+ canNav, paneID = m.navigateWithinStack(stackNode, child2, usecase.NavUp)
228 assert.True(t, canNav)222 assert.True(t, canNav)
229- assert.Equal(t, 1, stackNode.ActiveStackIndex)223+ assert.Equal(t, entity.PaneID("pane1"), paneID)
230 224
231- canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp)225+ // Navigate up from middle - should return pane0
226+ stackNode.ActiveStackIndex = 1
227+ canNav, paneID = m.navigateWithinStack(stackNode, child1, usecase.NavUp)
232 assert.True(t, canNav)228 assert.True(t, canNav)
233- assert.Equal(t, 0, stackNode.ActiveStackIndex)229+ assert.Equal(t, entity.PaneID("pane0"), paneID)
234 }230 }
M internal/ui/input/hover.go +6 -0
@@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() {
115115
116116 // handleLeave cancels any pending focus switch.
117117 func (h *HoverHandler) handleLeave() {
118+ h.Cancel()
119+}
120+
121+// Cancel cancels any pending focus switch.
122+// This can be called externally to cancel hover when keyboard navigation occurs.
123+func (h *HoverHandler) Cancel() {
118124 h.timerMu.Lock()
119125 defer h.timerMu.Unlock()
120126
@@ -115,6 +115,12 @@ func (h *HoverHandler) handleEnter() {
115 115
116 // handleLeave cancels any pending focus switch.116 // handleLeave cancels any pending focus switch.
117 func (h *HoverHandler) handleLeave() {117 func (h *HoverHandler) handleLeave() {
118+ h.Cancel()
119+}
120+
121+// Cancel cancels any pending focus switch.
122+// This can be called externally to cancel hover when keyboard navigation occurs.
123+func (h *HoverHandler) Cancel() {
118 h.timerMu.Lock()124 h.timerMu.Lock()
119 defer h.timerMu.Unlock()125 defer h.timerMu.Unlock()
120 126
M internal/ui/layout/gtk_widgets.go +3 -2
@@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget {
519519 return &gtkWidget{inner: parent}
520520 }
521521
522-func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }
523-func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }
522+func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }
523+func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }
524+func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) }
524525
525526 func (btn *gtkButton) SetChild(child Widget) {
526527 if child == nil {
@@ -519,8 +519,9 @@ func (btn *gtkButton) GetParent() Widget {
519 return &gtkWidget{inner: parent}519 return &gtkWidget{inner: parent}
520 }520 }
521 521
522-func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }522+func (btn *gtkButton) SetLabel(label string) { btn.inner.SetLabel(label) }
523-func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }523+func (btn *gtkButton) GetLabel() string { return btn.inner.GetLabel() }
524+func (btn *gtkButton) SetIconName(name string) { btn.inner.SetIconName(name) }
524 525
525 func (btn *gtkButton) SetChild(child Widget) {526 func (btn *gtkButton) SetChild(child Widget) {
526 if child == nil {527 if child == nil {
M internal/ui/layout/mocks/mocks.go +40 -0
@@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
89518951 return _c
89528952 }
89538953
8954+// SetIconName provides a mock function for the type MockButtonWidget
8955+func (_mock *MockButtonWidget) SetIconName(iconName string) {
8956+ _mock.Called(iconName)
8957+ return
8958+}
8959+
8960+// MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName'
8961+type MockButtonWidget_SetIconName_Call struct {
8962+ *mock.Call
8963+}
8964+
8965+// SetIconName is a helper method to define mock.On call
8966+// - iconName string
8967+func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call {
8968+ return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)}
8969+}
8970+
8971+func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8972+ _c.Call.Run(func(args mock.Arguments) {
8973+ var arg0 string
8974+ if args[0] != nil {
8975+ arg0 = args[0].(string)
8976+ }
8977+ run(
8978+ arg0,
8979+ )
8980+ })
8981+ return _c
8982+}
8983+
8984+func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call {
8985+ _c.Call.Return()
8986+ return _c
8987+}
8988+
8989+func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8990+ _c.Run(run)
8991+ return _c
8992+}
8993+
89548994 // SetLabel provides a mock function for the type MockButtonWidget
89558995 func (_mock *MockButtonWidget) SetLabel(label string) {
89568996 _mock.Called(label)
@@ -8951,6 +8951,46 @@ func (_c *MockButtonWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
8951 return _c8951 return _c
8952 }8952 }
8953 8953
8954+// SetIconName provides a mock function for the type MockButtonWidget
8955+func (_mock *MockButtonWidget) SetIconName(iconName string) {
8956+ _mock.Called(iconName)
8957+ return
8958+}
8959+
8960+// MockButtonWidget_SetIconName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetIconName'
8961+type MockButtonWidget_SetIconName_Call struct {
8962+ *mock.Call
8963+}
8964+
8965+// SetIconName is a helper method to define mock.On call
8966+// - iconName string
8967+func (_e *MockButtonWidget_Expecter) SetIconName(iconName interface{}) *MockButtonWidget_SetIconName_Call {
8968+ return &MockButtonWidget_SetIconName_Call{Call: _e.mock.On("SetIconName", iconName)}
8969+}
8970+
8971+func (_c *MockButtonWidget_SetIconName_Call) Run(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8972+ _c.Call.Run(func(args mock.Arguments) {
8973+ var arg0 string
8974+ if args[0] != nil {
8975+ arg0 = args[0].(string)
8976+ }
8977+ run(
8978+ arg0,
8979+ )
8980+ })
8981+ return _c
8982+}
8983+
8984+func (_c *MockButtonWidget_SetIconName_Call) Return() *MockButtonWidget_SetIconName_Call {
8985+ _c.Call.Return()
8986+ return _c
8987+}
8988+
8989+func (_c *MockButtonWidget_SetIconName_Call) RunAndReturn(run func(iconName string)) *MockButtonWidget_SetIconName_Call {
8990+ _c.Run(run)
8991+ return _c
8992+}
8993+
8954 // SetLabel provides a mock function for the type MockButtonWidget8994 // SetLabel provides a mock function for the type MockButtonWidget
8955 func (_mock *MockButtonWidget) SetLabel(label string) {8995 func (_mock *MockButtonWidget) SetLabel(label string) {
8956 _mock.Called(label)8996 _mock.Called(label)
M internal/ui/layout/stacked.go +239 -124
@@ -6,6 +6,8 @@ import (
66 "sync"
77
88 "github.com/bnema/dumber/internal/logging"
9+ "github.com/jwijenbergh/puregotk/v4/gobject"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
911 )
1012
1113 // ErrStackEmpty is returned when operating on an empty stack.
@@ -17,7 +19,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds")
1719 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
1820 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
1921
20-const stackedTitleMaxWidthChars = 30
22+const (
23+ stackedTitleMaxWidthChars = 30
24+ stackedPaneCloseIcon = "window-close-symbolic"
25+)
2126
2227 // stackedPane represents a single pane within a stacked container.
2328 type stackedPane struct {
@@ -28,6 +33,13 @@ type stackedPane struct {
2833 favicon ImageWidget
2934 label LabelWidget
3035 isActive bool
36+
37+ // Signal handler IDs for cleanup on removal
38+ closeClickSignalID uint32
39+ closeButton ButtonWidget // stored for signal disconnection
40+
41+ // Retained callback for GestureClick to prevent GC
42+ titleClickCallback interface{}
3143 }
3244
3345 // StackedView manages a stack of panes where only one is visible at a time.
@@ -38,7 +50,8 @@ type StackedView struct {
3850 panes []*stackedPane
3951 activeIndex int
4052
41- onActivate func(index int) // called when a pane is activated via title bar click
53+ onActivate func(index int) // called when a pane is activated via title bar click
54+ onClosePane func(paneID string) // called when a pane's close button is clicked
4255
4356 mu sync.RWMutex
4457 }
@@ -57,23 +70,23 @@ func NewStackedView(factory WidgetFactory) *StackedView {
5770 }
5871 }
5972
60-// AddPane adds a new pane to the stack.
61-// The new pane becomes active (visible).
62-func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
63- log := logging.FromContext(ctx)
64- sv.mu.Lock()
65- defer sv.mu.Unlock()
66-
67- log.Debug().
68- Str("title", title).
69- Bool("container_nil", container == nil).
70- Int("current_count", len(sv.panes)).
71- Msg("StackedView.AddPane called")
73+// titleBarComponents holds the widgets created for a pane's title bar.
74+type titleBarComponents struct {
75+ titleBar BoxWidget
76+ closeBtn ButtonWidget
77+ favicon ImageWidget
78+ label LabelWidget
79+}
7280
73- // Create title bar - must not expand vertically
81+// createTitleBar creates the title bar widgets for a pane.
82+func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents {
83+ // Create title bar container - must not expand vertically
84+ // This is now the clickable container directly (using GestureClick), not wrapped in a button
7485 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
7586 titleBar.AddCssClass("stacked-pane-titlebar")
87+ titleBar.AddCssClass("stacked-pane-title-clickable") // For hover styling
7688 titleBar.SetVexpand(false)
89+ titleBar.SetHexpand(true) // Fill horizontal space
7790
7891 // Create favicon image
7992 favicon := sv.factory.NewImage()
@@ -93,43 +106,63 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
93106 label.SetXalign(0.0)
94107 titleBar.Append(label)
95108
96- // Make title bar clickable - ensure it doesn't expand vertically
97- titleButton := sv.factory.NewButton()
98- titleButton.SetChild(titleBar)
99- titleButton.AddCssClass("stacked-pane-title-button")
100- titleButton.SetFocusOnClick(false)
101- titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically
102- titleButton.SetHexpand(true) // But fill horizontal space
109+ // Create close button using GTK's native icon button support
110+ closeBtn := sv.factory.NewButton()
111+ closeBtn.SetIconName(stackedPaneCloseIcon)
112+ closeBtn.AddCssClass("stacked-pane-close-button")
113+ closeBtn.SetFocusOnClick(false)
114+ closeBtn.SetVexpand(false)
115+ closeBtn.SetHexpand(false)
116+ titleBar.Append(closeBtn)
117+
118+ return titleBarComponents{
119+ titleBar: titleBar,
120+ closeBtn: closeBtn,
121+ favicon: favicon,
122+ label: label,
123+ }
124+}
125+
126+// AddPane adds a new pane to the stack.
127+// The new pane becomes active (visible).
128+func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
129+ log := logging.FromContext(ctx)
130+ sv.mu.Lock()
131+ defer sv.mu.Unlock()
132+
133+ log.Debug().
134+ Str("title", title).
135+ Bool("container_nil", container == nil).
136+ Int("current_count", len(sv.panes)).
137+ Msg("StackedView.AddPane called")
138+
139+ // Create title bar components
140+ tb := sv.createTitleBar(title, faviconIconName)
141+
142+ // Connect click handlers using paneID (not index, to handle removals)
143+ titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID)
103144
104145 pane := &stackedPane{
105- paneID: paneID,
106- titleBar: titleBar,
107- container: container,
108- title: title,
109- favicon: favicon,
110- label: label,
111- isActive: false,
146+ paneID: paneID,
147+ titleBar: tb.titleBar,
148+ container: container,
149+ title: title,
150+ favicon: tb.favicon,
151+ label: tb.label,
152+ isActive: false,
153+ closeClickSignalID: closeSignalID,
154+ closeButton: tb.closeBtn,
155+ titleClickCallback: titleClickCb,
112156 }
113157
114158 index := len(sv.panes)
115159 sv.panes = append(sv.panes, pane)
116160
117- // Connect click handler
118- titleButton.ConnectClicked(func() {
119- sv.mu.RLock()
120- callback := sv.onActivate
121- sv.mu.RUnlock()
122-
123- if callback != nil {
124- callback(index)
125- }
126- })
127-
128- // Add to container
161+ // Add to container - now we add the titleBar directly (not wrapped in a button)
129162 log.Debug().
130163 Int("index", index).
131- Msg("StackedView: appending titleButton and container to box")
132- sv.box.Append(titleButton)
164+ Msg("StackedView: appending titleBar and container to box")
165+ sv.box.Append(tb.titleBar)
133166 if container != nil {
134167 sv.box.Append(container)
135168 }
@@ -145,6 +178,106 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
145178 return index
146179 }
147180
181+// connectTitleBarHandlers connects the click handlers for a title bar.
182+// Uses paneID lookup instead of captured index to handle pane removals correctly.
183+// Returns the retained callback (to prevent GC) and close signal ID for disconnection.
184+func (sv *StackedView) connectTitleBarHandlers(
185+ tb titleBarComponents, paneID string,
186+) (titleClickCallback interface{}, closeSignalID uint32) {
187+ // Connect title bar click handler using GestureClick
188+ // This prevents event propagation issues with nested buttons
189+ clickCtrl := gtk.NewGestureClick()
190+
191+ // Store reference to close button for hit testing
192+ closeBtn := tb.closeBtn
193+
194+ clickCb := func(_ gtk.GestureClick, _ int, clickX float64, clickY float64) {
195+ // Check if click is on the close button - if so, don't activate
196+ // The close button handles its own click event
197+ if closeBtn != nil {
198+ closeBtnWidget := closeBtn.GtkWidget()
199+ if closeBtnWidget != nil {
200+ // Get close button's allocated position and size
201+ btnWidth := closeBtn.GetAllocatedWidth()
202+ btnHeight := closeBtn.GetAllocatedHeight()
203+
204+ // Get close button's position relative to the title bar
205+ btnX, btnY, ok := closeBtn.ComputePoint(tb.titleBar)
206+ if ok && btnWidth > 0 && btnHeight > 0 {
207+ // Check if click is within close button bounds
208+ if clickX >= btnX && clickX <= btnX+float64(btnWidth) &&
209+ clickY >= btnY && clickY <= btnY+float64(btnHeight) {
210+ // Click is on close button, don't activate
211+ return
212+ }
213+ }
214+ }
215+ }
216+
217+ sv.mu.RLock()
218+ callback := sv.onActivate
219+ currentIndex := sv.findPaneIndexInternal(paneID)
220+ sv.mu.RUnlock()
221+
222+ if callback != nil && currentIndex >= 0 {
223+ callback(currentIndex)
224+ }
225+ }
226+ clickCtrl.ConnectPressed(&clickCb)
227+ tb.titleBar.AddController(&clickCtrl.EventController)
228+
229+ // Connect close button click handler
230+ closeSignalID = tb.closeBtn.ConnectClicked(func() {
231+ sv.mu.RLock()
232+ onClose := sv.onClosePane
233+ sv.mu.RUnlock()
234+
235+ if onClose != nil {
236+ onClose(paneID)
237+ }
238+ })
239+
240+ return clickCb, closeSignalID
241+}
242+
243+// disconnectPaneSignals disconnects signal handlers from a pane's buttons.
244+// This prevents memory leaks when panes are removed from the stack.
245+// Note: This is a no-op when using mock widgets in tests (GtkWidget returns nil).
246+// Note: GestureClick callbacks are cleaned up automatically when the widget is destroyed.
247+func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) {
248+ if pane == nil {
249+ return
250+ }
251+
252+ // Clear retained callback reference to allow GC
253+ // The GestureClick controller is owned by the widget and will be cleaned up when the widget is destroyed
254+ pane.titleClickCallback = nil
255+
256+ // Disconnect close button click signal
257+ disconnectButtonSignal(pane.closeButton, pane.closeClickSignalID)
258+}
259+
260+// disconnectButtonSignal safely disconnects a signal from a button widget.
261+// Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations.
262+func disconnectButtonSignal(btn ButtonWidget, signalID uint32) {
263+ if btn == nil || signalID == 0 {
264+ return
265+ }
266+
267+ gtkWidget := btn.GtkWidget()
268+ if gtkWidget == nil {
269+ return
270+ }
271+
272+ ptr := gtkWidget.GoPointer()
273+ if ptr == 0 {
274+ return
275+ }
276+
277+ obj := gobject.ObjectNewFromInternalPtr(ptr)
278+ gobject.SignalHandlerDisconnect(obj, signalID)
279+}
280+
148281 // InsertPaneAfter inserts a new pane after the specified index position.
149282 // Use afterIndex=-1 to insert at the beginning.
150283 // The new pane becomes active (visible).
@@ -172,45 +305,23 @@ func (sv *StackedView) InsertPaneAfter(
172305 Int("current_count", len(sv.panes)).
173306 Msg("StackedView.InsertPaneAfter called")
174307
175- // Create title bar - must not expand vertically
176- titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
177- titleBar.AddCssClass("stacked-pane-titlebar")
178- titleBar.SetVexpand(false)
179-
180- // Create favicon image
181- favicon := sv.factory.NewImage()
182- if faviconIconName != "" {
183- favicon.SetFromIconName(faviconIconName)
184- } else {
185- favicon.SetFromIconName("web-browser-symbolic")
186- }
187- favicon.SetPixelSize(16)
188- titleBar.Append(favicon)
189-
190- // Create title label
191- label := sv.factory.NewLabel(title)
192- label.SetEllipsize(EllipsizeEnd)
193- label.SetMaxWidthChars(stackedTitleMaxWidthChars)
194- label.SetHexpand(true)
195- label.SetXalign(0.0)
196- titleBar.Append(label)
308+ // Create title bar components
309+ tb := sv.createTitleBar(title, faviconIconName)
197310
198- // Make title bar clickable - ensure it doesn't expand vertically
199- titleButton := sv.factory.NewButton()
200- titleButton.SetChild(titleBar)
201- titleButton.AddCssClass("stacked-pane-title-button")
202- titleButton.SetFocusOnClick(false)
203- titleButton.SetVexpand(false)
204- titleButton.SetHexpand(true)
311+ // Connect click handlers using paneID (not index, to handle removals)
312+ titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID)
205313
206314 pane := &stackedPane{
207- paneID: paneID,
208- titleBar: titleBar,
209- container: container,
210- title: title,
211- favicon: favicon,
212- label: label,
213- isActive: false,
315+ paneID: paneID,
316+ titleBar: tb.titleBar,
317+ container: container,
318+ title: title,
319+ favicon: tb.favicon,
320+ label: tb.label,
321+ isActive: false,
322+ closeClickSignalID: closeSignalID,
323+ closeButton: tb.closeBtn,
324+ titleClickCallback: titleClickCb,
214325 }
215326
216327 // Insert into slice at correct position
@@ -218,38 +329,40 @@ func (sv *StackedView) InsertPaneAfter(
218329 copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
219330 sv.panes[insertIndex] = pane
220331
221- // Connect click handler - use captured index
222- capturedIndex := insertIndex
223- titleButton.ConnectClicked(func() {
224- sv.mu.RLock()
225- callback := sv.onActivate
226- sv.mu.RUnlock()
332+ // Insert widgets at correct position in GTK box
333+ sv.insertTitleBarWidgets(tb.titleBar, container, insertIndex)
227334
228- if callback != nil {
229- callback(capturedIndex)
230- }
231- })
335+ // Set this pane as active
336+ sv.setActiveInternal(ctx, insertIndex)
232337
233- // Insert widgets at correct position in GTK box
338+ log.Debug().
339+ Int("insert_index", insertIndex).
340+ Int("new_count", len(sv.panes)).
341+ Msg("StackedView.InsertPaneAfter completed")
342+
343+ return insertIndex
344+}
345+
346+// insertTitleBarWidgets inserts the title bar and container at the correct position.
347+func (sv *StackedView) insertTitleBarWidgets(titleBar BoxWidget, container Widget, insertIndex int) {
234348 if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
235349 // Insert after the previous pane's container
236350 prevPane := sv.panes[insertIndex-1]
237351 if prevPane.container != nil {
238- sv.box.InsertChildAfter(titleButton, prevPane.container)
352+ sv.box.InsertChildAfter(titleBar, prevPane.container)
239353 if container != nil {
240- sv.box.InsertChildAfter(container, titleButton)
354+ sv.box.InsertChildAfter(container, titleBar)
241355 }
242356 } else {
243- // No container, insert after title bar's parent (the button)
244- prevTitleParent := prevPane.titleBar.GetParent()
245- if prevTitleParent != nil {
246- sv.box.InsertChildAfter(titleButton, prevTitleParent)
357+ // No container, insert after the previous pane's title bar directly
358+ if prevPane.titleBar != nil {
359+ sv.box.InsertChildAfter(titleBar, prevPane.titleBar)
247360 if container != nil {
248- sv.box.InsertChildAfter(container, titleButton)
361+ sv.box.InsertChildAfter(container, titleBar)
249362 }
250363 } else {
251364 // Fallback to append
252- sv.box.Append(titleButton)
365+ sv.box.Append(titleBar)
253366 if container != nil {
254367 sv.box.Append(container)
255368 }
@@ -260,18 +373,8 @@ func (sv *StackedView) InsertPaneAfter(
260373 if container != nil {
261374 sv.box.Prepend(container)
262375 }
263- sv.box.Prepend(titleButton)
376+ sv.box.Prepend(titleBar)
264377 }
265-
266- // Set this pane as active
267- sv.setActiveInternal(ctx, insertIndex)
268-
269- log.Debug().
270- Int("insert_index", insertIndex).
271- Int("new_count", len(sv.panes)).
272- Msg("StackedView.InsertPaneAfter completed")
273-
274- return insertIndex
275378 }
276379
277380 // RemovePane removes a pane from the stack by index.
@@ -298,13 +401,13 @@ func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
298401
299402 pane := sv.panes[index]
300403
404+ // Disconnect signal handlers before removing widgets to prevent memory leaks
405+ sv.disconnectPaneSignals(pane)
406+
301407 // Remove from container
302408 if pane.titleBar != nil {
303- // The title bar is wrapped in a button, need to get parent
304- parent := pane.titleBar.GetParent()
305- if parent != nil {
306- sv.box.Remove(parent)
307- }
409+ // The title bar is now added directly to the box (no button wrapper)
410+ sv.box.Remove(pane.titleBar)
308411 }
309412 if pane.container != nil {
310413 sv.box.Remove(pane.container)
@@ -371,17 +474,15 @@ func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
371474 pane.isActive = isActive
372475
373476 // Title bar is always visible for inactive panes, hidden for active
477+ // The title bar is now added directly to the box (no button wrapper)
374478 if pane.titleBar != nil {
375- parent := pane.titleBar.GetParent()
376- if parent != nil {
377- parent.SetVisible(!isActive)
378- log.Debug().
379- Int("pane_index", i).
380- Str("title", pane.title).
381- Bool("is_active", isActive).
382- Bool("titlebar_visible", !isActive).
383- Msg("StackedView: set titlebar visibility")
384- }
479+ pane.titleBar.SetVisible(!isActive)
480+ log.Debug().
481+ Int("pane_index", i).
482+ Str("title", pane.title).
483+ Bool("is_active", isActive).
484+ Bool("titlebar_visible", !isActive).
485+ Msg("StackedView: set titlebar visibility")
385486 }
386487
387488 // Container is visible only for active pane
@@ -429,6 +530,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int {
429530 sv.mu.RLock()
430531 defer sv.mu.RUnlock()
431532
533+ return sv.findPaneIndexInternal(paneID)
534+}
535+
536+// findPaneIndexInternal returns the index of a pane by ID.
537+// Caller must hold at least a read lock on sv.mu.
538+func (sv *StackedView) findPaneIndexInternal(paneID string) int {
432539 for i, pane := range sv.panes {
433540 if pane.paneID == paneID {
434541 return i
@@ -445,6 +552,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) {
445552 sv.onActivate = fn
446553 }
447554
555+// SetOnClosePane sets the callback for when a pane's close button is clicked.
556+func (sv *StackedView) SetOnClosePane(fn func(paneID string)) {
557+ sv.mu.Lock()
558+ defer sv.mu.Unlock()
559+
560+ sv.onClosePane = fn
561+}
562+
448563 // UpdateTitle updates the title of a pane at the given index.
449564 func (sv *StackedView) UpdateTitle(index int, title string) error {
450565 sv.mu.Lock()
@@ -6,6 +6,8 @@ import (
6 "sync"6 "sync"
7 7
8 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
9+ "github.com/jwijenbergh/puregotk/v4/gobject"
10+ "github.com/jwijenbergh/puregotk/v4/gtk"
9 )11 )
10 12
11 // ErrStackEmpty is returned when operating on an empty stack.13 // ErrStackEmpty is returned when operating on an empty stack.
@@ -17,7 +19,10 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds")
17 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.19 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
18 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")20 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
19 21
20-const stackedTitleMaxWidthChars = 3022+const (
23+ stackedTitleMaxWidthChars = 30
24+ stackedPaneCloseIcon = "window-close-symbolic"
25+)
21 26
22 // stackedPane represents a single pane within a stacked container.27 // stackedPane represents a single pane within a stacked container.
23 type stackedPane struct {28 type stackedPane struct {
@@ -28,6 +33,13 @@ type stackedPane struct {
28 favicon ImageWidget33 favicon ImageWidget
29 label LabelWidget34 label LabelWidget
30 isActive bool35 isActive bool
36+
37+ // Signal handler IDs for cleanup on removal
38+ closeClickSignalID uint32
39+ closeButton ButtonWidget // stored for signal disconnection
40+
41+ // Retained callback for GestureClick to prevent GC
42+ titleClickCallback interface{}
31 }43 }
32 44
33 // StackedView manages a stack of panes where only one is visible at a time.45 // StackedView manages a stack of panes where only one is visible at a time.
@@ -38,7 +50,8 @@ type StackedView struct {
38 panes []*stackedPane50 panes []*stackedPane
39 activeIndex int51 activeIndex int
40 52
41- onActivate func(index int) // called when a pane is activated via title bar click53+ onActivate func(index int) // called when a pane is activated via title bar click
54+ onClosePane func(paneID string) // called when a pane's close button is clicked
42 55
43 mu sync.RWMutex56 mu sync.RWMutex
44 }57 }
@@ -57,23 +70,23 @@ func NewStackedView(factory WidgetFactory) *StackedView {
57 }70 }
58 }71 }
59 72
60-// AddPane adds a new pane to the stack.73+// titleBarComponents holds the widgets created for a pane's title bar.
61-// The new pane becomes active (visible).74+type titleBarComponents struct {
62-func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {75+ titleBar BoxWidget
63- log := logging.FromContext(ctx)76+ closeBtn ButtonWidget
64- sv.mu.Lock()77+ favicon ImageWidget
65- defer sv.mu.Unlock()78+ label LabelWidget
66-79+}
67- log.Debug().
68- Str("title", title).
69- Bool("container_nil", container == nil).
70- Int("current_count", len(sv.panes)).
71- Msg("StackedView.AddPane called")
72 80
73- // Create title bar - must not expand vertically81+// createTitleBar creates the title bar widgets for a pane.
82+func (sv *StackedView) createTitleBar(title, faviconIconName string) titleBarComponents {
83+ // Create title bar container - must not expand vertically
84+ // This is now the clickable container directly (using GestureClick), not wrapped in a button
74 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)85 titleBar := sv.factory.NewBox(OrientationHorizontal, 4)
75 titleBar.AddCssClass("stacked-pane-titlebar")86 titleBar.AddCssClass("stacked-pane-titlebar")
87+ titleBar.AddCssClass("stacked-pane-title-clickable") // For hover styling
76 titleBar.SetVexpand(false)88 titleBar.SetVexpand(false)
89+ titleBar.SetHexpand(true) // Fill horizontal space
77 90
78 // Create favicon image91 // Create favicon image
79 favicon := sv.factory.NewImage()92 favicon := sv.factory.NewImage()
@@ -93,43 +106,63 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
93 label.SetXalign(0.0)106 label.SetXalign(0.0)
94 titleBar.Append(label)107 titleBar.Append(label)
95 108
96- // Make title bar clickable - ensure it doesn't expand vertically109+ // Create close button using GTK's native icon button support
97- titleButton := sv.factory.NewButton()110+ closeBtn := sv.factory.NewButton()
98- titleButton.SetChild(titleBar)111+ closeBtn.SetIconName(stackedPaneCloseIcon)
99- titleButton.AddCssClass("stacked-pane-title-button")112+ closeBtn.AddCssClass("stacked-pane-close-button")
100- titleButton.SetFocusOnClick(false)113+ closeBtn.SetFocusOnClick(false)
101- titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically114+ closeBtn.SetVexpand(false)
102- titleButton.SetHexpand(true) // But fill horizontal space115+ closeBtn.SetHexpand(false)
116+ titleBar.Append(closeBtn)
117+
118+ return titleBarComponents{
119+ titleBar: titleBar,
120+ closeBtn: closeBtn,
121+ favicon: favicon,
122+ label: label,
123+ }
124+}
125+
126+// AddPane adds a new pane to the stack.
127+// The new pane becomes active (visible).
128+func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconName string, container Widget) int {
129+ log := logging.FromContext(ctx)
130+ sv.mu.Lock()
131+ defer sv.mu.Unlock()
132+
133+ log.Debug().
134+ Str("title", title).
135+ Bool("container_nil", container == nil).
136+ Int("current_count", len(sv.panes)).
137+ Msg("StackedView.AddPane called")
138+
139+ // Create title bar components
140+ tb := sv.createTitleBar(title, faviconIconName)
141+
142+ // Connect click handlers using paneID (not index, to handle removals)
143+ titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID)
103 144
104 pane := &stackedPane{145 pane := &stackedPane{
105- paneID: paneID,146+ paneID: paneID,
106- titleBar: titleBar,147+ titleBar: tb.titleBar,
107- container: container,148+ container: container,
108- title: title,149+ title: title,
109- favicon: favicon,150+ favicon: tb.favicon,
110- label: label,151+ label: tb.label,
111- isActive: false,152+ isActive: false,
153+ closeClickSignalID: closeSignalID,
154+ closeButton: tb.closeBtn,
155+ titleClickCallback: titleClickCb,
112 }156 }
113 157
114 index := len(sv.panes)158 index := len(sv.panes)
115 sv.panes = append(sv.panes, pane)159 sv.panes = append(sv.panes, pane)
116 160
117- // Connect click handler161+ // Add to container - now we add the titleBar directly (not wrapped in a button)
118- titleButton.ConnectClicked(func() {
119- sv.mu.RLock()
120- callback := sv.onActivate
121- sv.mu.RUnlock()
122-
123- if callback != nil {
124- callback(index)
125- }
126- })
127-
128- // Add to container
129 log.Debug().162 log.Debug().
130 Int("index", index).163 Int("index", index).
131- Msg("StackedView: appending titleButton and container to box")164+ Msg("StackedView: appending titleBar and container to box")
132- sv.box.Append(titleButton)165+ sv.box.Append(tb.titleBar)
133 if container != nil {166 if container != nil {
134 sv.box.Append(container)167 sv.box.Append(container)
135 }168 }
@@ -145,6 +178,106 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
145 return index178 return index
146 }179 }
147 180
181+// connectTitleBarHandlers connects the click handlers for a title bar.
182+// Uses paneID lookup instead of captured index to handle pane removals correctly.
183+// Returns the retained callback (to prevent GC) and close signal ID for disconnection.
184+func (sv *StackedView) connectTitleBarHandlers(
185+ tb titleBarComponents, paneID string,
186+) (titleClickCallback interface{}, closeSignalID uint32) {
187+ // Connect title bar click handler using GestureClick
188+ // This prevents event propagation issues with nested buttons
189+ clickCtrl := gtk.NewGestureClick()
190+
191+ // Store reference to close button for hit testing
192+ closeBtn := tb.closeBtn
193+
194+ clickCb := func(_ gtk.GestureClick, _ int, clickX float64, clickY float64) {
195+ // Check if click is on the close button - if so, don't activate
196+ // The close button handles its own click event
197+ if closeBtn != nil {
198+ closeBtnWidget := closeBtn.GtkWidget()
199+ if closeBtnWidget != nil {
200+ // Get close button's allocated position and size
201+ btnWidth := closeBtn.GetAllocatedWidth()
202+ btnHeight := closeBtn.GetAllocatedHeight()
203+
204+ // Get close button's position relative to the title bar
205+ btnX, btnY, ok := closeBtn.ComputePoint(tb.titleBar)
206+ if ok && btnWidth > 0 && btnHeight > 0 {
207+ // Check if click is within close button bounds
208+ if clickX >= btnX && clickX <= btnX+float64(btnWidth) &&
209+ clickY >= btnY && clickY <= btnY+float64(btnHeight) {
210+ // Click is on close button, don't activate
211+ return
212+ }
213+ }
214+ }
215+ }
216+
217+ sv.mu.RLock()
218+ callback := sv.onActivate
219+ currentIndex := sv.findPaneIndexInternal(paneID)
220+ sv.mu.RUnlock()
221+
222+ if callback != nil && currentIndex >= 0 {
223+ callback(currentIndex)
224+ }
225+ }
226+ clickCtrl.ConnectPressed(&clickCb)
227+ tb.titleBar.AddController(&clickCtrl.EventController)
228+
229+ // Connect close button click handler
230+ closeSignalID = tb.closeBtn.ConnectClicked(func() {
231+ sv.mu.RLock()
232+ onClose := sv.onClosePane
233+ sv.mu.RUnlock()
234+
235+ if onClose != nil {
236+ onClose(paneID)
237+ }
238+ })
239+
240+ return clickCb, closeSignalID
241+}
242+
243+// disconnectPaneSignals disconnects signal handlers from a pane's buttons.
244+// This prevents memory leaks when panes are removed from the stack.
245+// Note: This is a no-op when using mock widgets in tests (GtkWidget returns nil).
246+// Note: GestureClick callbacks are cleaned up automatically when the widget is destroyed.
247+func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) {
248+ if pane == nil {
249+ return
250+ }
251+
252+ // Clear retained callback reference to allow GC
253+ // The GestureClick controller is owned by the widget and will be cleaned up when the widget is destroyed
254+ pane.titleClickCallback = nil
255+
256+ // Disconnect close button click signal
257+ disconnectButtonSignal(pane.closeButton, pane.closeClickSignalID)
258+}
259+
260+// disconnectButtonSignal safely disconnects a signal from a button widget.
261+// Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations.
262+func disconnectButtonSignal(btn ButtonWidget, signalID uint32) {
263+ if btn == nil || signalID == 0 {
264+ return
265+ }
266+
267+ gtkWidget := btn.GtkWidget()
268+ if gtkWidget == nil {
269+ return
270+ }
271+
272+ ptr := gtkWidget.GoPointer()
273+ if ptr == 0 {
274+ return
275+ }
276+
277+ obj := gobject.ObjectNewFromInternalPtr(ptr)
278+ gobject.SignalHandlerDisconnect(obj, signalID)
279+}
280+
148 // InsertPaneAfter inserts a new pane after the specified index position.281 // InsertPaneAfter inserts a new pane after the specified index position.
149 // Use afterIndex=-1 to insert at the beginning.282 // Use afterIndex=-1 to insert at the beginning.
150 // The new pane becomes active (visible).283 // The new pane becomes active (visible).
@@ -172,45 +305,23 @@ func (sv *StackedView) InsertPaneAfter(
172 Int("current_count", len(sv.panes)).305 Int("current_count", len(sv.panes)).
173 Msg("StackedView.InsertPaneAfter called")306 Msg("StackedView.InsertPaneAfter called")
174 307
175- // Create title bar - must not expand vertically308+ // Create title bar components
176- titleBar := sv.factory.NewBox(OrientationHorizontal, 4)309+ tb := sv.createTitleBar(title, faviconIconName)
177- titleBar.AddCssClass("stacked-pane-titlebar")
178- titleBar.SetVexpand(false)
179-
180- // Create favicon image
181- favicon := sv.factory.NewImage()
182- if faviconIconName != "" {
183- favicon.SetFromIconName(faviconIconName)
184- } else {
185- favicon.SetFromIconName("web-browser-symbolic")
186- }
187- favicon.SetPixelSize(16)
188- titleBar.Append(favicon)
189-
190- // Create title label
191- label := sv.factory.NewLabel(title)
192- label.SetEllipsize(EllipsizeEnd)
193- label.SetMaxWidthChars(stackedTitleMaxWidthChars)
194- label.SetHexpand(true)
195- label.SetXalign(0.0)
196- titleBar.Append(label)
197 310
198- // Make title bar clickable - ensure it doesn't expand vertically311+ // Connect click handlers using paneID (not index, to handle removals)
199- titleButton := sv.factory.NewButton()312+ titleClickCb, closeSignalID := sv.connectTitleBarHandlers(tb, paneID)
200- titleButton.SetChild(titleBar)
201- titleButton.AddCssClass("stacked-pane-title-button")
202- titleButton.SetFocusOnClick(false)
203- titleButton.SetVexpand(false)
204- titleButton.SetHexpand(true)
205 313
206 pane := &stackedPane{314 pane := &stackedPane{
207- paneID: paneID,315+ paneID: paneID,
208- titleBar: titleBar,316+ titleBar: tb.titleBar,
209- container: container,317+ container: container,
210- title: title,318+ title: title,
211- favicon: favicon,319+ favicon: tb.favicon,
212- label: label,320+ label: tb.label,
213- isActive: false,321+ isActive: false,
322+ closeClickSignalID: closeSignalID,
323+ closeButton: tb.closeBtn,
324+ titleClickCallback: titleClickCb,
214 }325 }
215 326
216 // Insert into slice at correct position327 // Insert into slice at correct position
@@ -218,38 +329,40 @@ func (sv *StackedView) InsertPaneAfter(
218 copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])329 copy(sv.panes[insertIndex+1:], sv.panes[insertIndex:])
219 sv.panes[insertIndex] = pane330 sv.panes[insertIndex] = pane
220 331
221- // Connect click handler - use captured index332+ // Insert widgets at correct position in GTK box
222- capturedIndex := insertIndex333+ sv.insertTitleBarWidgets(tb.titleBar, container, insertIndex)
223- titleButton.ConnectClicked(func() {
224- sv.mu.RLock()
225- callback := sv.onActivate
226- sv.mu.RUnlock()
227 334
228- if callback != nil {335+ // Set this pane as active
229- callback(capturedIndex)336+ sv.setActiveInternal(ctx, insertIndex)
230- }
231- })
232 337
233- // Insert widgets at correct position in GTK box338+ log.Debug().
339+ Int("insert_index", insertIndex).
340+ Int("new_count", len(sv.panes)).
341+ Msg("StackedView.InsertPaneAfter completed")
342+
343+ return insertIndex
344+}
345+
346+// insertTitleBarWidgets inserts the title bar and container at the correct position.
347+func (sv *StackedView) insertTitleBarWidgets(titleBar BoxWidget, container Widget, insertIndex int) {
234 if insertIndex > 0 && sv.panes[insertIndex-1] != nil {348 if insertIndex > 0 && sv.panes[insertIndex-1] != nil {
235 // Insert after the previous pane's container349 // Insert after the previous pane's container
236 prevPane := sv.panes[insertIndex-1]350 prevPane := sv.panes[insertIndex-1]
237 if prevPane.container != nil {351 if prevPane.container != nil {
238- sv.box.InsertChildAfter(titleButton, prevPane.container)352+ sv.box.InsertChildAfter(titleBar, prevPane.container)
239 if container != nil {353 if container != nil {
240- sv.box.InsertChildAfter(container, titleButton)354+ sv.box.InsertChildAfter(container, titleBar)
241 }355 }
242 } else {356 } else {
243- // No container, insert after title bar's parent (the button)357+ // No container, insert after the previous pane's title bar directly
244- prevTitleParent := prevPane.titleBar.GetParent()358+ if prevPane.titleBar != nil {
245- if prevTitleParent != nil {359+ sv.box.InsertChildAfter(titleBar, prevPane.titleBar)
246- sv.box.InsertChildAfter(titleButton, prevTitleParent)
247 if container != nil {360 if container != nil {
248- sv.box.InsertChildAfter(container, titleButton)361+ sv.box.InsertChildAfter(container, titleBar)
249 }362 }
250 } else {363 } else {
251 // Fallback to append364 // Fallback to append
252- sv.box.Append(titleButton)365+ sv.box.Append(titleBar)
253 if container != nil {366 if container != nil {
254 sv.box.Append(container)367 sv.box.Append(container)
255 }368 }
@@ -260,18 +373,8 @@ func (sv *StackedView) InsertPaneAfter(
260 if container != nil {373 if container != nil {
261 sv.box.Prepend(container)374 sv.box.Prepend(container)
262 }375 }
263- sv.box.Prepend(titleButton)376+ sv.box.Prepend(titleBar)
264 }377 }
265-
266- // Set this pane as active
267- sv.setActiveInternal(ctx, insertIndex)
268-
269- log.Debug().
270- Int("insert_index", insertIndex).
271- Int("new_count", len(sv.panes)).
272- Msg("StackedView.InsertPaneAfter completed")
273-
274- return insertIndex
275 }378 }
276 379
277 // RemovePane removes a pane from the stack by index.380 // RemovePane removes a pane from the stack by index.
@@ -298,13 +401,13 @@ func (sv *StackedView) RemovePane(ctx context.Context, index int) error {
298 401
299 pane := sv.panes[index]402 pane := sv.panes[index]
300 403
404+ // Disconnect signal handlers before removing widgets to prevent memory leaks
405+ sv.disconnectPaneSignals(pane)
406+
301 // Remove from container407 // Remove from container
302 if pane.titleBar != nil {408 if pane.titleBar != nil {
303- // The title bar is wrapped in a button, need to get parent409+ // The title bar is now added directly to the box (no button wrapper)
304- parent := pane.titleBar.GetParent()410+ sv.box.Remove(pane.titleBar)
305- if parent != nil {
306- sv.box.Remove(parent)
307- }
308 }411 }
309 if pane.container != nil {412 if pane.container != nil {
310 sv.box.Remove(pane.container)413 sv.box.Remove(pane.container)
@@ -371,17 +474,15 @@ func (sv *StackedView) updateVisibilityInternal(ctx context.Context) {
371 pane.isActive = isActive474 pane.isActive = isActive
372 475
373 // Title bar is always visible for inactive panes, hidden for active476 // Title bar is always visible for inactive panes, hidden for active
477+ // The title bar is now added directly to the box (no button wrapper)
374 if pane.titleBar != nil {478 if pane.titleBar != nil {
375- parent := pane.titleBar.GetParent()479+ pane.titleBar.SetVisible(!isActive)
376- if parent != nil {480+ log.Debug().
377- parent.SetVisible(!isActive)481+ Int("pane_index", i).
378- log.Debug().482+ Str("title", pane.title).
379- Int("pane_index", i).483+ Bool("is_active", isActive).
380- Str("title", pane.title).484+ Bool("titlebar_visible", !isActive).
381- Bool("is_active", isActive).485+ Msg("StackedView: set titlebar visibility")
382- Bool("titlebar_visible", !isActive).
383- Msg("StackedView: set titlebar visibility")
384- }
385 }486 }
386 487
387 // Container is visible only for active pane488 // Container is visible only for active pane
@@ -429,6 +530,12 @@ func (sv *StackedView) FindPaneIndex(paneID string) int {
429 sv.mu.RLock()530 sv.mu.RLock()
430 defer sv.mu.RUnlock()531 defer sv.mu.RUnlock()
431 532
533+ return sv.findPaneIndexInternal(paneID)
534+}
535+
536+// findPaneIndexInternal returns the index of a pane by ID.
537+// Caller must hold at least a read lock on sv.mu.
538+func (sv *StackedView) findPaneIndexInternal(paneID string) int {
432 for i, pane := range sv.panes {539 for i, pane := range sv.panes {
433 if pane.paneID == paneID {540 if pane.paneID == paneID {
434 return i541 return i
@@ -445,6 +552,14 @@ func (sv *StackedView) SetOnActivate(fn func(index int)) {
445 sv.onActivate = fn552 sv.onActivate = fn
446 }553 }
447 554
555+// SetOnClosePane sets the callback for when a pane's close button is clicked.
556+func (sv *StackedView) SetOnClosePane(fn func(paneID string)) {
557+ sv.mu.Lock()
558+ defer sv.mu.Unlock()
559+
560+ sv.onClosePane = fn
561+}
562+
448 // UpdateTitle updates the title of a pane at the given index.563 // UpdateTitle updates the title of a pane at the given index.
449 func (sv *StackedView) UpdateTitle(index int, title string) error {564 func (sv *StackedView) UpdateTitle(index int, title string) error {
450 sv.mu.Lock()565 sv.mu.Lock()
M internal/ui/layout/stacked_test.go +109 -104
@@ -25,19 +25,23 @@ func setupMockFactory(t *testing.T) (*mocks.MockWidgetFactory, *mocks.MockBoxWid
2525 }
2626
2727 // setupPaneMocks creates mocks needed for AddPane
28+// Note: The stacked view now uses GestureClick on the titleBar directly instead of wrapping it in a button.
29+// The GestureClick is added via AddController which we mock to accept any EventController.
2830 func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) (
29- *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockButtonWidget, *mocks.MockWidget,
31+ *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockWidget,
3032 ) {
3133 mockTitleBar := mocks.NewMockBoxWidget(t)
3234 mockFavicon := mocks.NewMockImageWidget(t)
3335 mockLabel := mocks.NewMockLabelWidget(t)
34- mockButton := mocks.NewMockButtonWidget(t)
36+ mockCloseButton := mocks.NewMockButtonWidget(t)
3537 mockContainer := mocks.NewMockWidget(t)
3638
37- // Title bar creation
39+ // Title bar creation - now directly used without button wrapper
3840 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
3941 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
42+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
4043 mockTitleBar.EXPECT().SetVexpand(false).Once()
44+ mockTitleBar.EXPECT().SetHexpand(true).Once()
4145
4246 // Favicon
4347 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
@@ -53,20 +57,29 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
5357 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
5458 mockTitleBar.EXPECT().Append(mockLabel).Once()
5559
56- // Button wrapping title bar
57- mockFactory.EXPECT().NewButton().Return(mockButton).Once()
58- mockButton.EXPECT().SetChild(mockTitleBar).Once()
59- mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
60- mockButton.EXPECT().SetFocusOnClick(false).Once()
61- mockButton.EXPECT().SetVexpand(false).Once()
62- mockButton.EXPECT().SetHexpand(true).Once()
63- mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
64-
65- // Adding to main box
66- mockBox.EXPECT().Append(mockButton).Once()
60+ // Close button (uses SetIconName directly instead of child image)
61+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
62+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
63+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
64+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
65+ mockCloseButton.EXPECT().SetVexpand(false).Once()
66+ mockCloseButton.EXPECT().SetHexpand(false).Once()
67+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
68+
69+ // GestureClick is added to titleBar via AddController
70+ mockTitleBar.EXPECT().AddController(mock.Anything).Once()
71+
72+ // Close button click handler
73+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
74+
75+ // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
76+ mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
77+
78+ // Adding to main box - now titleBar is added directly (no button wrapper)
79+ mockBox.EXPECT().Append(mockTitleBar).Once()
6780 mockBox.EXPECT().Append(mockContainer).Once()
6881
69- return mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer
82+ return mockTitleBar, mockFavicon, mockLabel, mockContainer
7083 }
7184
7285 func TestNewStackedView_EmptyStack(t *testing.T) {
@@ -86,13 +99,12 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) {
8699 // Arrange
87100 ctx := context.Background()
88101 mockFactory, mockBox := setupMockFactory(t)
89- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
102+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
90103 _ = mockFavicon
91104 _ = mockLabel
92- _ = mockButton
93105
94- // Visibility updates for active pane
95- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
106+ // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
107+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
96108 mockContainer.EXPECT().SetVisible(true).Once()
97109 mockTitleBar.EXPECT().AddCssClass("active").Once()
98110
@@ -113,26 +125,24 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) {
113125 mockFactory, mockBox := setupMockFactory(t)
114126
115127 // First pane
116- mockTitleBar1, mockFavicon1, mockLabel1, mockButton1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
128+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
117129 _ = mockFavicon1
118130 _ = mockLabel1
119- _ = mockButton1
120- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
131+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
121132 mockContainer1.EXPECT().SetVisible(true).Once()
122133 mockTitleBar1.EXPECT().AddCssClass("active").Once()
123134
124135 // Second pane - first pane becomes inactive
125- mockTitleBar2, mockFavicon2, mockLabel2, mockButton2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox)
136+ mockTitleBar2, mockFavicon2, mockLabel2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox)
126137 _ = mockFavicon2
127138 _ = mockLabel2
128- _ = mockButton2
129139
130140 // When second pane is added, update visibility for both
131- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
141+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
132142 mockContainer1.EXPECT().SetVisible(false).Once()
133143 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
134144
135- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
145+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
136146 mockContainer2.EXPECT().SetVisible(true).Once()
137147 mockTitleBar2.EXPECT().AddCssClass("active").Once()
138148
@@ -156,22 +166,17 @@ func TestRemovePane_MiddlePane(t *testing.T) {
156166 // Setup 3 panes (simplified - just track the key behaviors)
157167 containers := make([]*mocks.MockWidget, 3)
158168 titleBars := make([]*mocks.MockBoxWidget, 3)
159- buttons := make([]*mocks.MockButtonWidget, 3)
160169
161170 for i := 0; i < 3; i++ {
162- var btn *mocks.MockButtonWidget
163- titleBars[i], _, _, btn, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
164- buttons[i] = btn
171+ titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
165172 }
166173
167174 // Allow any visibility calls during setup and removal
168175 for i := 0; i < 3; i++ {
169- titleBars[i].EXPECT().GetParent().Return(buttons[i]).Maybe()
170176 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
177+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
171178 titleBars[i].EXPECT().AddCssClass("active").Maybe()
172179 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
173- // Button visibility changes when panes are activated/deactivated
174- buttons[i].EXPECT().SetVisible(mock.Anything).Maybe()
175180 }
176181
177182 sv := layout.NewStackedView(mockFactory)
@@ -180,8 +185,8 @@ func TestRemovePane_MiddlePane(t *testing.T) {
180185 sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2])
181186
182187 // Remove middle pane (index 1)
183- // The parent of titleBar is the button widget
184- mockBox.EXPECT().Remove(buttons[1]).Once()
188+ // The titleBar is now directly in the box (no button wrapper)
189+ mockBox.EXPECT().Remove(titleBars[1]).Once()
185190 mockBox.EXPECT().Remove(containers[1]).Once()
186191
187192 // Act
@@ -196,12 +201,11 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
196201 // Arrange
197202 ctx := context.Background()
198203 mockFactory, mockBox := setupMockFactory(t)
199- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
204+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
200205 _ = mockFavicon
201206 _ = mockLabel
202- _ = mockButton
203207
204- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
208+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
205209 mockContainer.EXPECT().SetVisible(true).Once()
206210 mockTitleBar.EXPECT().AddCssClass("active").Once()
207211
@@ -233,12 +237,11 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) {
233237 // Arrange
234238 ctx := context.Background()
235239 mockFactory, mockBox := setupMockFactory(t)
236- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
240+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
237241 _ = mockFavicon
238242 _ = mockLabel
239- _ = mockButton
240243
241- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
244+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
242245 mockContainer.EXPECT().SetVisible(true).Once()
243246 mockTitleBar.EXPECT().AddCssClass("active").Once()
244247
@@ -261,15 +264,14 @@ func TestSetActive_ValidIndex(t *testing.T) {
261264 titleBars := make([]*mocks.MockBoxWidget, 2)
262265
263266 for i := 0; i < 2; i++ {
264- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
267+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
265268 _ = mockFavicon
266- _ = mockOverlay
267- _ = mockSpinner
269+ _ = mockLabel
268270
269271 titleBars[i] = titleBar
270272 containers[i] = container
271273
272- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
274+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
273275 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
274276 titleBars[i].EXPECT().AddCssClass("active").Maybe()
275277 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -291,12 +293,11 @@ func TestSetActive_OutOfBounds(t *testing.T) {
291293 // Arrange
292294 ctx := context.Background()
293295 mockFactory, mockBox := setupMockFactory(t)
294- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
296+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
295297 _ = mockFavicon
296298 _ = mockLabel
297- _ = mockButton
298299
299- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
300+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
300301 mockContainer.EXPECT().SetVisible(true).Once()
301302 mockTitleBar.EXPECT().AddCssClass("active").Once()
302303
@@ -327,9 +328,9 @@ func TestUpdateTitle(t *testing.T) {
327328 // Arrange
328329 ctx := context.Background()
329330 mockFactory, mockBox := setupMockFactory(t)
330- mockTitleBar, _, mockLabel, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
331+ mockTitleBar, _, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
331332
332- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
333+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
333334 mockContainer.EXPECT().SetVisible(true).Once()
334335 mockTitleBar.EXPECT().AddCssClass("active").Once()
335336
@@ -361,9 +362,9 @@ func TestUpdateFavicon(t *testing.T) {
361362 // Arrange
362363 ctx := context.Background()
363364 mockFactory, mockBox := setupMockFactory(t)
364- mockTitleBar, mockFavicon, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
365+ mockTitleBar, mockFavicon, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
365366
366- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
367+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
367368 mockContainer.EXPECT().SetVisible(true).Once()
368369 mockTitleBar.EXPECT().AddCssClass("active").Once()
369370
@@ -383,12 +384,11 @@ func TestGetContainer(t *testing.T) {
383384 // Arrange
384385 ctx := context.Background()
385386 mockFactory, mockBox := setupMockFactory(t)
386- mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
387+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
387388 _ = mockFavicon
388- _ = mockOverlay
389- _ = mockSpinner
389+ _ = mockLabel
390390
391- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
391+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
392392 mockContainer.EXPECT().SetVisible(true).Once()
393393 mockTitleBar.EXPECT().AddCssClass("active").Once()
394394
@@ -437,15 +437,14 @@ func TestNavigateNext_WrapsAround(t *testing.T) {
437437 titleBars := make([]*mocks.MockBoxWidget, 2)
438438
439439 for i := 0; i < 2; i++ {
440- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
440+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
441441 _ = mockFavicon
442- _ = mockOverlay
443- _ = mockSpinner
442+ _ = mockLabel
444443
445444 titleBars[i] = titleBar
446445 containers[i] = container
447446
448- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
447+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
449448 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
450449 titleBars[i].EXPECT().AddCssClass("active").Maybe()
451450 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -475,15 +474,14 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) {
475474 titleBars := make([]*mocks.MockBoxWidget, 2)
476475
477476 for i := 0; i < 2; i++ {
478- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
477+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
479478 _ = mockFavicon
480- _ = mockOverlay
481- _ = mockSpinner
479+ _ = mockLabel
482480
483481 titleBars[i] = titleBar
484482 containers[i] = container
485483
486- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
484+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
487485 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
488486 titleBars[i].EXPECT().AddCssClass("active").Maybe()
489487 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -558,13 +556,15 @@ func setupInsertPaneMocks(
558556 mockTitleBar := mocks.NewMockBoxWidget(t)
559557 mockFavicon := mocks.NewMockImageWidget(t)
560558 mockLabel := mocks.NewMockLabelWidget(t)
561- mockButton := mocks.NewMockButtonWidget(t)
559+ mockCloseButton := mocks.NewMockButtonWidget(t)
562560 mockContainer := mocks.NewMockWidget(t)
563561
564- // Title bar creation
562+ // Title bar creation - now directly used without button wrapper
565563 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
566564 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
565+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
567566 mockTitleBar.EXPECT().SetVexpand(false).Once()
567+ mockTitleBar.EXPECT().SetHexpand(true).Once()
568568
569569 // Favicon
570570 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
@@ -580,23 +580,32 @@ func setupInsertPaneMocks(
580580 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
581581 mockTitleBar.EXPECT().Append(mockLabel).Once()
582582
583- // Button wrapping title bar
584- mockFactory.EXPECT().NewButton().Return(mockButton).Once()
585- mockButton.EXPECT().SetChild(mockTitleBar).Once()
586- mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()
587- mockButton.EXPECT().SetFocusOnClick(false).Once()
588- mockButton.EXPECT().SetVexpand(false).Once()
589- mockButton.EXPECT().SetHexpand(true).Once()
590- mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()
583+ // Close button (uses SetIconName directly instead of child image)
584+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
585+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
586+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
587+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
588+ mockCloseButton.EXPECT().SetVexpand(false).Once()
589+ mockCloseButton.EXPECT().SetHexpand(false).Once()
590+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
591+
592+ // GestureClick is added to titleBar via AddController
593+ mockTitleBar.EXPECT().AddController(mock.Anything).Once()
591594
592- // Position-aware insertion using InsertChildAfter
595+ // Close button click handler
596+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
597+
598+ // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
599+ mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
600+
601+ // Position-aware insertion using InsertChildAfter - now using titleBar directly
593602 if siblingContainer != nil {
594- mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once()
595- mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once()
603+ mockBox.EXPECT().InsertChildAfter(mockTitleBar, siblingContainer).Once()
604+ mockBox.EXPECT().InsertChildAfter(mockContainer, mockTitleBar).Once()
596605 } else {
597606 // Insert at beginning using Prepend
598607 mockBox.EXPECT().Prepend(mockContainer).Once()
599- mockBox.EXPECT().Prepend(mockButton).Once()
608+ mockBox.EXPECT().Prepend(mockTitleBar).Once()
600609 }
601610
602611 return mockTitleBar, mockContainer
@@ -608,11 +617,10 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
608617 mockFactory, mockBox := setupMockFactory(t)
609618
610619 // First pane - appended normally
611- mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
620+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
612621 _ = mockFavicon1
613- _ = mockOverlay1
614- _ = mockSpinner1
615- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
622+ _ = mockLabel1
623+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
616624 mockContainer1.EXPECT().SetVisible(true).Once()
617625 mockTitleBar1.EXPECT().AddCssClass("active").Once()
618626
@@ -623,11 +631,11 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
623631 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
624632
625633 // Visibility updates
626- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
634+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
627635 mockContainer1.EXPECT().SetVisible(false).Once()
628636 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
629637
630- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
638+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
631639 mockContainer2.EXPECT().SetVisible(true).Once()
632640 mockTitleBar2.EXPECT().AddCssClass("active").Once()
633641
@@ -650,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
650658 titleBars := make([]*mocks.MockBoxWidget, 2)
651659
652660 for i := 0; i < 2; i++ {
653- titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
654- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
661+ titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
662+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
655663 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
656664 titleBars[i].EXPECT().AddCssClass("active").Maybe()
657665 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -666,7 +674,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
666674
667675 // Insert new pane after index 0 (should become index 1)
668676 mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0])
669- mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe()
677+ mockTitleBar3.EXPECT().SetVisible(mock.Anything).Maybe()
670678 mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe()
671679 mockTitleBar3.EXPECT().AddCssClass("active").Maybe()
672680 mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
@@ -686,11 +694,10 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
686694 mockFactory, mockBox := setupMockFactory(t)
687695
688696 // First pane
689- mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
697+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
690698 _ = mockFavicon1
691- _ = mockOverlay1
692- _ = mockSpinner1
693- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
699+ _ = mockLabel1
700+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
694701 mockContainer1.EXPECT().SetVisible(true).Once()
695702 mockTitleBar1.EXPECT().AddCssClass("active").Once()
696703
@@ -700,11 +707,11 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
700707 // Insert after last pane (afterIndex=0, becomes index 1)
701708 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
702709
703- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
710+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
704711 mockContainer1.EXPECT().SetVisible(false).Once()
705712 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
706713
707- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
714+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
708715 mockContainer2.EXPECT().SetVisible(true).Once()
709716 mockTitleBar2.EXPECT().AddCssClass("active").Once()
710717
@@ -727,15 +734,14 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
727734 titleBars := make([]*mocks.MockBoxWidget, 3)
728735
729736 for i := 0; i < 3; i++ {
730- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)
737+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
731738 _ = mockFavicon
732- _ = mockOverlay
733- _ = mockSpinner
739+ _ = mockLabel
734740
735741 titleBars[i] = titleBar
736742 containers[i] = container
737743
738- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
744+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
739745 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
740746 titleBars[i].EXPECT().AddCssClass("active").Maybe()
741747 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -751,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
751757
752758 // Insert D after B (should be at index 2, C moves to index 3)
753759 mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1])
754- mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe()
760+ mockTitleBar4.EXPECT().SetVisible(mock.Anything).Maybe()
755761 mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe()
756762 mockTitleBar4.EXPECT().AddCssClass("active").Maybe()
757763 mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
@@ -782,11 +788,10 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
782788 mockFactory, mockBox := setupMockFactory(t)
783789
784790 // First pane
785- mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
791+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
786792 _ = mockFavicon1
787- _ = mockOverlay1
788- _ = mockSpinner1
789- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
793+ _ = mockLabel1
794+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
790795 mockContainer1.EXPECT().SetVisible(true).Once()
791796 mockTitleBar1.EXPECT().AddCssClass("active").Once()
792797
@@ -796,11 +801,11 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
796801 // Try to insert at invalid index (100) - should clamp to end
797802 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
798803
799- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
804+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
800805 mockContainer1.EXPECT().SetVisible(false).Once()
801806 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
802807
803- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()
808+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
804809 mockContainer2.EXPECT().SetVisible(true).Once()
805810 mockTitleBar2.EXPECT().AddCssClass("active").Once()
806811
@@ -25,19 +25,23 @@ func setupMockFactory(t *testing.T) (*mocks.MockWidgetFactory, *mocks.MockBoxWid
25 }25 }
26 26
27 // setupPaneMocks creates mocks needed for AddPane27 // setupPaneMocks creates mocks needed for AddPane
28+// Note: The stacked view now uses GestureClick on the titleBar directly instead of wrapping it in a button.
29+// The GestureClick is added via AddController which we mock to accept any EventController.
28 func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) (30 func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox *mocks.MockBoxWidget) (
29- *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockButtonWidget, *mocks.MockWidget,31+ *mocks.MockBoxWidget, *mocks.MockImageWidget, *mocks.MockLabelWidget, *mocks.MockWidget,
30 ) {32 ) {
31 mockTitleBar := mocks.NewMockBoxWidget(t)33 mockTitleBar := mocks.NewMockBoxWidget(t)
32 mockFavicon := mocks.NewMockImageWidget(t)34 mockFavicon := mocks.NewMockImageWidget(t)
33 mockLabel := mocks.NewMockLabelWidget(t)35 mockLabel := mocks.NewMockLabelWidget(t)
34- mockButton := mocks.NewMockButtonWidget(t)36+ mockCloseButton := mocks.NewMockButtonWidget(t)
35 mockContainer := mocks.NewMockWidget(t)37 mockContainer := mocks.NewMockWidget(t)
36 38
37- // Title bar creation39+ // Title bar creation - now directly used without button wrapper
38 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()40 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
39 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()41 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
42+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
40 mockTitleBar.EXPECT().SetVexpand(false).Once()43 mockTitleBar.EXPECT().SetVexpand(false).Once()
44+ mockTitleBar.EXPECT().SetHexpand(true).Once()
41 45
42 // Favicon46 // Favicon
43 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()47 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
@@ -53,20 +57,29 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
53 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()57 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
54 mockTitleBar.EXPECT().Append(mockLabel).Once()58 mockTitleBar.EXPECT().Append(mockLabel).Once()
55 59
56- // Button wrapping title bar60+ // Close button (uses SetIconName directly instead of child image)
57- mockFactory.EXPECT().NewButton().Return(mockButton).Once()61+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
58- mockButton.EXPECT().SetChild(mockTitleBar).Once()62+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
59- mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()63+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
60- mockButton.EXPECT().SetFocusOnClick(false).Once()64+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
61- mockButton.EXPECT().SetVexpand(false).Once()65+ mockCloseButton.EXPECT().SetVexpand(false).Once()
62- mockButton.EXPECT().SetHexpand(true).Once()66+ mockCloseButton.EXPECT().SetHexpand(false).Once()
63- mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()67+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
64-68+
65- // Adding to main box69+ // GestureClick is added to titleBar via AddController
66- mockBox.EXPECT().Append(mockButton).Once()70+ mockTitleBar.EXPECT().AddController(mock.Anything).Once()
71+
72+ // Close button click handler
73+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
74+
75+ // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
76+ mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
77+
78+ // Adding to main box - now titleBar is added directly (no button wrapper)
79+ mockBox.EXPECT().Append(mockTitleBar).Once()
67 mockBox.EXPECT().Append(mockContainer).Once()80 mockBox.EXPECT().Append(mockContainer).Once()
68 81
69- return mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer82+ return mockTitleBar, mockFavicon, mockLabel, mockContainer
70 }83 }
71 84
72 func TestNewStackedView_EmptyStack(t *testing.T) {85 func TestNewStackedView_EmptyStack(t *testing.T) {
@@ -86,13 +99,12 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) {
86 // Arrange99 // Arrange
87 ctx := context.Background()100 ctx := context.Background()
88 mockFactory, mockBox := setupMockFactory(t)101 mockFactory, mockBox := setupMockFactory(t)
89- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)102+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
90 _ = mockFavicon103 _ = mockFavicon
91 _ = mockLabel104 _ = mockLabel
92- _ = mockButton
93 105
94- // Visibility updates for active pane106+ // Visibility updates for active pane - titleBar is now directly in box, SetVisible called on it
95- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()107+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
96 mockContainer.EXPECT().SetVisible(true).Once()108 mockContainer.EXPECT().SetVisible(true).Once()
97 mockTitleBar.EXPECT().AddCssClass("active").Once()109 mockTitleBar.EXPECT().AddCssClass("active").Once()
98 110
@@ -113,26 +125,24 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) {
113 mockFactory, mockBox := setupMockFactory(t)125 mockFactory, mockBox := setupMockFactory(t)
114 126
115 // First pane127 // First pane
116- mockTitleBar1, mockFavicon1, mockLabel1, mockButton1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)128+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
117 _ = mockFavicon1129 _ = mockFavicon1
118 _ = mockLabel1130 _ = mockLabel1
119- _ = mockButton1131+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
120- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
121 mockContainer1.EXPECT().SetVisible(true).Once()132 mockContainer1.EXPECT().SetVisible(true).Once()
122 mockTitleBar1.EXPECT().AddCssClass("active").Once()133 mockTitleBar1.EXPECT().AddCssClass("active").Once()
123 134
124 // Second pane - first pane becomes inactive135 // Second pane - first pane becomes inactive
125- mockTitleBar2, mockFavicon2, mockLabel2, mockButton2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox)136+ mockTitleBar2, mockFavicon2, mockLabel2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox)
126 _ = mockFavicon2137 _ = mockFavicon2
127 _ = mockLabel2138 _ = mockLabel2
128- _ = mockButton2
129 139
130 // When second pane is added, update visibility for both140 // When second pane is added, update visibility for both
131- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()141+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
132 mockContainer1.EXPECT().SetVisible(false).Once()142 mockContainer1.EXPECT().SetVisible(false).Once()
133 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()143 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
134 144
135- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()145+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
136 mockContainer2.EXPECT().SetVisible(true).Once()146 mockContainer2.EXPECT().SetVisible(true).Once()
137 mockTitleBar2.EXPECT().AddCssClass("active").Once()147 mockTitleBar2.EXPECT().AddCssClass("active").Once()
138 148
@@ -156,22 +166,17 @@ func TestRemovePane_MiddlePane(t *testing.T) {
156 // Setup 3 panes (simplified - just track the key behaviors)166 // Setup 3 panes (simplified - just track the key behaviors)
157 containers := make([]*mocks.MockWidget, 3)167 containers := make([]*mocks.MockWidget, 3)
158 titleBars := make([]*mocks.MockBoxWidget, 3)168 titleBars := make([]*mocks.MockBoxWidget, 3)
159- buttons := make([]*mocks.MockButtonWidget, 3)
160 169
161 for i := 0; i < 3; i++ {170 for i := 0; i < 3; i++ {
162- var btn *mocks.MockButtonWidget171+ titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
163- titleBars[i], _, _, btn, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
164- buttons[i] = btn
165 }172 }
166 173
167 // Allow any visibility calls during setup and removal174 // Allow any visibility calls during setup and removal
168 for i := 0; i < 3; i++ {175 for i := 0; i < 3; i++ {
169- titleBars[i].EXPECT().GetParent().Return(buttons[i]).Maybe()
170 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()176 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
177+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
171 titleBars[i].EXPECT().AddCssClass("active").Maybe()178 titleBars[i].EXPECT().AddCssClass("active").Maybe()
172 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()179 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
173- // Button visibility changes when panes are activated/deactivated
174- buttons[i].EXPECT().SetVisible(mock.Anything).Maybe()
175 }180 }
176 181
177 sv := layout.NewStackedView(mockFactory)182 sv := layout.NewStackedView(mockFactory)
@@ -180,8 +185,8 @@ func TestRemovePane_MiddlePane(t *testing.T) {
180 sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2])185 sv.AddPane(ctx, "pane-3", "Page 3", "", containers[2])
181 186
182 // Remove middle pane (index 1)187 // Remove middle pane (index 1)
183- // The parent of titleBar is the button widget188+ // The titleBar is now directly in the box (no button wrapper)
184- mockBox.EXPECT().Remove(buttons[1]).Once()189+ mockBox.EXPECT().Remove(titleBars[1]).Once()
185 mockBox.EXPECT().Remove(containers[1]).Once()190 mockBox.EXPECT().Remove(containers[1]).Once()
186 191
187 // Act192 // Act
@@ -196,12 +201,11 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
196 // Arrange201 // Arrange
197 ctx := context.Background()202 ctx := context.Background()
198 mockFactory, mockBox := setupMockFactory(t)203 mockFactory, mockBox := setupMockFactory(t)
199- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)204+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
200 _ = mockFavicon205 _ = mockFavicon
201 _ = mockLabel206 _ = mockLabel
202- _ = mockButton
203 207
204- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()208+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
205 mockContainer.EXPECT().SetVisible(true).Once()209 mockContainer.EXPECT().SetVisible(true).Once()
206 mockTitleBar.EXPECT().AddCssClass("active").Once()210 mockTitleBar.EXPECT().AddCssClass("active").Once()
207 211
@@ -233,12 +237,11 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) {
233 // Arrange237 // Arrange
234 ctx := context.Background()238 ctx := context.Background()
235 mockFactory, mockBox := setupMockFactory(t)239 mockFactory, mockBox := setupMockFactory(t)
236- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)240+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
237 _ = mockFavicon241 _ = mockFavicon
238 _ = mockLabel242 _ = mockLabel
239- _ = mockButton
240 243
241- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()244+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
242 mockContainer.EXPECT().SetVisible(true).Once()245 mockContainer.EXPECT().SetVisible(true).Once()
243 mockTitleBar.EXPECT().AddCssClass("active").Once()246 mockTitleBar.EXPECT().AddCssClass("active").Once()
244 247
@@ -261,15 +264,14 @@ func TestSetActive_ValidIndex(t *testing.T) {
261 titleBars := make([]*mocks.MockBoxWidget, 2)264 titleBars := make([]*mocks.MockBoxWidget, 2)
262 265
263 for i := 0; i < 2; i++ {266 for i := 0; i < 2; i++ {
264- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)267+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
265 _ = mockFavicon268 _ = mockFavicon
266- _ = mockOverlay269+ _ = mockLabel
267- _ = mockSpinner
268 270
269 titleBars[i] = titleBar271 titleBars[i] = titleBar
270 containers[i] = container272 containers[i] = container
271 273
272- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()274+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
273 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()275 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
274 titleBars[i].EXPECT().AddCssClass("active").Maybe()276 titleBars[i].EXPECT().AddCssClass("active").Maybe()
275 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()277 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -291,12 +293,11 @@ func TestSetActive_OutOfBounds(t *testing.T) {
291 // Arrange293 // Arrange
292 ctx := context.Background()294 ctx := context.Background()
293 mockFactory, mockBox := setupMockFactory(t)295 mockFactory, mockBox := setupMockFactory(t)
294- mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox)296+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
295 _ = mockFavicon297 _ = mockFavicon
296 _ = mockLabel298 _ = mockLabel
297- _ = mockButton
298 299
299- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()300+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
300 mockContainer.EXPECT().SetVisible(true).Once()301 mockContainer.EXPECT().SetVisible(true).Once()
301 mockTitleBar.EXPECT().AddCssClass("active").Once()302 mockTitleBar.EXPECT().AddCssClass("active").Once()
302 303
@@ -327,9 +328,9 @@ func TestUpdateTitle(t *testing.T) {
327 // Arrange328 // Arrange
328 ctx := context.Background()329 ctx := context.Background()
329 mockFactory, mockBox := setupMockFactory(t)330 mockFactory, mockBox := setupMockFactory(t)
330- mockTitleBar, _, mockLabel, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)331+ mockTitleBar, _, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
331 332
332- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()333+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
333 mockContainer.EXPECT().SetVisible(true).Once()334 mockContainer.EXPECT().SetVisible(true).Once()
334 mockTitleBar.EXPECT().AddCssClass("active").Once()335 mockTitleBar.EXPECT().AddCssClass("active").Once()
335 336
@@ -361,9 +362,9 @@ func TestUpdateFavicon(t *testing.T) {
361 // Arrange362 // Arrange
362 ctx := context.Background()363 ctx := context.Background()
363 mockFactory, mockBox := setupMockFactory(t)364 mockFactory, mockBox := setupMockFactory(t)
364- mockTitleBar, mockFavicon, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)365+ mockTitleBar, mockFavicon, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
365 366
366- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()367+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
367 mockContainer.EXPECT().SetVisible(true).Once()368 mockContainer.EXPECT().SetVisible(true).Once()
368 mockTitleBar.EXPECT().AddCssClass("active").Once()369 mockTitleBar.EXPECT().AddCssClass("active").Once()
369 370
@@ -383,12 +384,11 @@ func TestGetContainer(t *testing.T) {
383 // Arrange384 // Arrange
384 ctx := context.Background()385 ctx := context.Background()
385 mockFactory, mockBox := setupMockFactory(t)386 mockFactory, mockBox := setupMockFactory(t)
386- mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockBox)387+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockBox)
387 _ = mockFavicon388 _ = mockFavicon
388- _ = mockOverlay389+ _ = mockLabel
389- _ = mockSpinner
390 390
391- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()391+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
392 mockContainer.EXPECT().SetVisible(true).Once()392 mockContainer.EXPECT().SetVisible(true).Once()
393 mockTitleBar.EXPECT().AddCssClass("active").Once()393 mockTitleBar.EXPECT().AddCssClass("active").Once()
394 394
@@ -437,15 +437,14 @@ func TestNavigateNext_WrapsAround(t *testing.T) {
437 titleBars := make([]*mocks.MockBoxWidget, 2)437 titleBars := make([]*mocks.MockBoxWidget, 2)
438 438
439 for i := 0; i < 2; i++ {439 for i := 0; i < 2; i++ {
440- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)440+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
441 _ = mockFavicon441 _ = mockFavicon
442- _ = mockOverlay442+ _ = mockLabel
443- _ = mockSpinner
444 443
445 titleBars[i] = titleBar444 titleBars[i] = titleBar
446 containers[i] = container445 containers[i] = container
447 446
448- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()447+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
449 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()448 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
450 titleBars[i].EXPECT().AddCssClass("active").Maybe()449 titleBars[i].EXPECT().AddCssClass("active").Maybe()
451 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()450 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -475,15 +474,14 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) {
475 titleBars := make([]*mocks.MockBoxWidget, 2)474 titleBars := make([]*mocks.MockBoxWidget, 2)
476 475
477 for i := 0; i < 2; i++ {476 for i := 0; i < 2; i++ {
478- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)477+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
479 _ = mockFavicon478 _ = mockFavicon
480- _ = mockOverlay479+ _ = mockLabel
481- _ = mockSpinner
482 480
483 titleBars[i] = titleBar481 titleBars[i] = titleBar
484 containers[i] = container482 containers[i] = container
485 483
486- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()484+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
487 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()485 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
488 titleBars[i].EXPECT().AddCssClass("active").Maybe()486 titleBars[i].EXPECT().AddCssClass("active").Maybe()
489 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()487 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -558,13 +556,15 @@ func setupInsertPaneMocks(
558 mockTitleBar := mocks.NewMockBoxWidget(t)556 mockTitleBar := mocks.NewMockBoxWidget(t)
559 mockFavicon := mocks.NewMockImageWidget(t)557 mockFavicon := mocks.NewMockImageWidget(t)
560 mockLabel := mocks.NewMockLabelWidget(t)558 mockLabel := mocks.NewMockLabelWidget(t)
561- mockButton := mocks.NewMockButtonWidget(t)559+ mockCloseButton := mocks.NewMockButtonWidget(t)
562 mockContainer := mocks.NewMockWidget(t)560 mockContainer := mocks.NewMockWidget(t)
563 561
564- // Title bar creation562+ // Title bar creation - now directly used without button wrapper
565 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()563 mockFactory.EXPECT().NewBox(layout.OrientationHorizontal, 4).Return(mockTitleBar).Once()
566 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()564 mockTitleBar.EXPECT().AddCssClass("stacked-pane-titlebar").Once()
565+ mockTitleBar.EXPECT().AddCssClass("stacked-pane-title-clickable").Once()
567 mockTitleBar.EXPECT().SetVexpand(false).Once()566 mockTitleBar.EXPECT().SetVexpand(false).Once()
567+ mockTitleBar.EXPECT().SetHexpand(true).Once()
568 568
569 // Favicon569 // Favicon
570 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()570 mockFactory.EXPECT().NewImage().Return(mockFavicon).Once()
@@ -580,23 +580,32 @@ func setupInsertPaneMocks(
580 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()580 mockLabel.EXPECT().SetXalign(float32(0.0)).Once()
581 mockTitleBar.EXPECT().Append(mockLabel).Once()581 mockTitleBar.EXPECT().Append(mockLabel).Once()
582 582
583- // Button wrapping title bar583+ // Close button (uses SetIconName directly instead of child image)
584- mockFactory.EXPECT().NewButton().Return(mockButton).Once()584+ mockFactory.EXPECT().NewButton().Return(mockCloseButton).Once()
585- mockButton.EXPECT().SetChild(mockTitleBar).Once()585+ mockCloseButton.EXPECT().SetIconName("window-close-symbolic").Once()
586- mockButton.EXPECT().AddCssClass("stacked-pane-title-button").Once()586+ mockCloseButton.EXPECT().AddCssClass("stacked-pane-close-button").Once()
587- mockButton.EXPECT().SetFocusOnClick(false).Once()587+ mockCloseButton.EXPECT().SetFocusOnClick(false).Once()
588- mockButton.EXPECT().SetVexpand(false).Once()588+ mockCloseButton.EXPECT().SetVexpand(false).Once()
589- mockButton.EXPECT().SetHexpand(true).Once()589+ mockCloseButton.EXPECT().SetHexpand(false).Once()
590- mockButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(1)).Once()590+ mockTitleBar.EXPECT().Append(mockCloseButton).Once()
591+
592+ // GestureClick is added to titleBar via AddController
593+ mockTitleBar.EXPECT().AddController(mock.Anything).Once()
591 594
592- // Position-aware insertion using InsertChildAfter595+ // Close button click handler
596+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
597+
598+ // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
599+ mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
600+
601+ // Position-aware insertion using InsertChildAfter - now using titleBar directly
593 if siblingContainer != nil {602 if siblingContainer != nil {
594- mockBox.EXPECT().InsertChildAfter(mockButton, siblingContainer).Once()603+ mockBox.EXPECT().InsertChildAfter(mockTitleBar, siblingContainer).Once()
595- mockBox.EXPECT().InsertChildAfter(mockContainer, mockButton).Once()604+ mockBox.EXPECT().InsertChildAfter(mockContainer, mockTitleBar).Once()
596 } else {605 } else {
597 // Insert at beginning using Prepend606 // Insert at beginning using Prepend
598 mockBox.EXPECT().Prepend(mockContainer).Once()607 mockBox.EXPECT().Prepend(mockContainer).Once()
599- mockBox.EXPECT().Prepend(mockButton).Once()608+ mockBox.EXPECT().Prepend(mockTitleBar).Once()
600 }609 }
601 610
602 return mockTitleBar, mockContainer611 return mockTitleBar, mockContainer
@@ -608,11 +617,10 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
608 mockFactory, mockBox := setupMockFactory(t)617 mockFactory, mockBox := setupMockFactory(t)
609 618
610 // First pane - appended normally619 // First pane - appended normally
611- mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)620+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
612 _ = mockFavicon1621 _ = mockFavicon1
613- _ = mockOverlay1622+ _ = mockLabel1
614- _ = mockSpinner1623+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
615- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
616 mockContainer1.EXPECT().SetVisible(true).Once()624 mockContainer1.EXPECT().SetVisible(true).Once()
617 mockTitleBar1.EXPECT().AddCssClass("active").Once()625 mockTitleBar1.EXPECT().AddCssClass("active").Once()
618 626
@@ -623,11 +631,11 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
623 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)631 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, nil)
624 632
625 // Visibility updates633 // Visibility updates
626- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()634+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
627 mockContainer1.EXPECT().SetVisible(false).Once()635 mockContainer1.EXPECT().SetVisible(false).Once()
628 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()636 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
629 637
630- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()638+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
631 mockContainer2.EXPECT().SetVisible(true).Once()639 mockContainer2.EXPECT().SetVisible(true).Once()
632 mockTitleBar2.EXPECT().AddCssClass("active").Once()640 mockTitleBar2.EXPECT().AddCssClass("active").Once()
633 641
@@ -650,8 +658,8 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
650 titleBars := make([]*mocks.MockBoxWidget, 2)658 titleBars := make([]*mocks.MockBoxWidget, 2)
651 659
652 for i := 0; i < 2; i++ {660 for i := 0; i < 2; i++ {
653- titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)661+ titleBars[i], _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox)
654- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()662+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
655 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()663 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
656 titleBars[i].EXPECT().AddCssClass("active").Maybe()664 titleBars[i].EXPECT().AddCssClass("active").Maybe()
657 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()665 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -666,7 +674,7 @@ func TestInsertPaneAfter_InMiddle(t *testing.T) {
666 674
667 // Insert new pane after index 0 (should become index 1)675 // Insert new pane after index 0 (should become index 1)
668 mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0])676 mockTitleBar3, mockContainer3 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[0])
669- mockTitleBar3.EXPECT().GetParent().Return(nil).Maybe()677+ mockTitleBar3.EXPECT().SetVisible(mock.Anything).Maybe()
670 mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe()678 mockContainer3.EXPECT().SetVisible(mock.Anything).Maybe()
671 mockTitleBar3.EXPECT().AddCssClass("active").Maybe()679 mockTitleBar3.EXPECT().AddCssClass("active").Maybe()
672 mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()680 mockTitleBar3.EXPECT().RemoveCssClass("active").Maybe()
@@ -686,11 +694,10 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
686 mockFactory, mockBox := setupMockFactory(t)694 mockFactory, mockBox := setupMockFactory(t)
687 695
688 // First pane696 // First pane
689- mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)697+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
690 _ = mockFavicon1698 _ = mockFavicon1
691- _ = mockOverlay1699+ _ = mockLabel1
692- _ = mockSpinner1700+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
693- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
694 mockContainer1.EXPECT().SetVisible(true).Once()701 mockContainer1.EXPECT().SetVisible(true).Once()
695 mockTitleBar1.EXPECT().AddCssClass("active").Once()702 mockTitleBar1.EXPECT().AddCssClass("active").Once()
696 703
@@ -700,11 +707,11 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
700 // Insert after last pane (afterIndex=0, becomes index 1)707 // Insert after last pane (afterIndex=0, becomes index 1)
701 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)708 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
702 709
703- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()710+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
704 mockContainer1.EXPECT().SetVisible(false).Once()711 mockContainer1.EXPECT().SetVisible(false).Once()
705 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()712 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
706 713
707- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()714+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
708 mockContainer2.EXPECT().SetVisible(true).Once()715 mockContainer2.EXPECT().SetVisible(true).Once()
709 mockTitleBar2.EXPECT().AddCssClass("active").Once()716 mockTitleBar2.EXPECT().AddCssClass("active").Once()
710 717
@@ -727,15 +734,14 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
727 titleBars := make([]*mocks.MockBoxWidget, 3)734 titleBars := make([]*mocks.MockBoxWidget, 3)
728 735
729 for i := 0; i < 3; i++ {736 for i := 0; i < 3; i++ {
730- titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox)737+ titleBar, mockFavicon, mockLabel, container := setupPaneMocks(t, mockFactory, mockBox)
731 _ = mockFavicon738 _ = mockFavicon
732- _ = mockOverlay739+ _ = mockLabel
733- _ = mockSpinner
734 740
735 titleBars[i] = titleBar741 titleBars[i] = titleBar
736 containers[i] = container742 containers[i] = container
737 743
738- titleBars[i].EXPECT().GetParent().Return(nil).Maybe()744+ titleBars[i].EXPECT().SetVisible(mock.Anything).Maybe()
739 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()745 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
740 titleBars[i].EXPECT().AddCssClass("active").Maybe()746 titleBars[i].EXPECT().AddCssClass("active").Maybe()
741 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()747 titleBars[i].EXPECT().RemoveCssClass("active").Maybe()
@@ -751,7 +757,7 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
751 757
752 // Insert D after B (should be at index 2, C moves to index 3)758 // Insert D after B (should be at index 2, C moves to index 3)
753 mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1])759 mockTitleBar4, mockContainer4 := setupInsertPaneMocks(t, mockFactory, mockBox, containers[1])
754- mockTitleBar4.EXPECT().GetParent().Return(nil).Maybe()760+ mockTitleBar4.EXPECT().SetVisible(mock.Anything).Maybe()
755 mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe()761 mockContainer4.EXPECT().SetVisible(mock.Anything).Maybe()
756 mockTitleBar4.EXPECT().AddCssClass("active").Maybe()762 mockTitleBar4.EXPECT().AddCssClass("active").Maybe()
757 mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()763 mockTitleBar4.EXPECT().RemoveCssClass("active").Maybe()
@@ -782,11 +788,10 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
782 mockFactory, mockBox := setupMockFactory(t)788 mockFactory, mockBox := setupMockFactory(t)
783 789
784 // First pane790 // First pane
785- mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)791+ mockTitleBar1, mockFavicon1, mockLabel1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox)
786 _ = mockFavicon1792 _ = mockFavicon1
787- _ = mockOverlay1793+ _ = mockLabel1
788- _ = mockSpinner1794+ mockTitleBar1.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
789- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
790 mockContainer1.EXPECT().SetVisible(true).Once()795 mockContainer1.EXPECT().SetVisible(true).Once()
791 mockTitleBar1.EXPECT().AddCssClass("active").Once()796 mockTitleBar1.EXPECT().AddCssClass("active").Once()
792 797
@@ -796,11 +801,11 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
796 // Try to insert at invalid index (100) - should clamp to end801 // Try to insert at invalid index (100) - should clamp to end
797 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)802 mockTitleBar2, mockContainer2 := setupInsertPaneMocks(t, mockFactory, mockBox, mockContainer1)
798 803
799- mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()804+ mockTitleBar1.EXPECT().SetVisible(true).Once() // First pane becomes inactive, show its title bar
800 mockContainer1.EXPECT().SetVisible(false).Once()805 mockContainer1.EXPECT().SetVisible(false).Once()
801 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()806 mockTitleBar1.EXPECT().RemoveCssClass("active").Once()
802 807
803- mockTitleBar2.EXPECT().GetParent().Return(nil).Maybe()808+ mockTitleBar2.EXPECT().SetVisible(false).Once() // Second pane is active, hide its title bar
804 mockContainer2.EXPECT().SetVisible(true).Once()809 mockContainer2.EXPECT().SetVisible(true).Once()
805 mockTitleBar2.EXPECT().AddCssClass("active").Once()810 mockTitleBar2.EXPECT().AddCssClass("active").Once()
806 811
M internal/ui/layout/tree_test.go +3 -4
@@ -19,11 +19,10 @@ func setupStackedLeafMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (
1919 mockStackBox.EXPECT().SetHexpand(true).Once()
2020 mockStackBox.EXPECT().SetVexpand(true).Once()
2121
22- mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox)
22+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox)
2323 _ = mockFavicon
24- _ = mockOverlay
25- _ = mockSpinner
26- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
24+ _ = mockLabel
25+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
2726 mockContainer.EXPECT().SetVisible(true).Once()
2827 mockTitleBar.EXPECT().AddCssClass("active").Once()
2928
@@ -19,11 +19,10 @@ func setupStackedLeafMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (
19 mockStackBox.EXPECT().SetHexpand(true).Once()19 mockStackBox.EXPECT().SetHexpand(true).Once()
20 mockStackBox.EXPECT().SetVexpand(true).Once()20 mockStackBox.EXPECT().SetVexpand(true).Once()
21 21
22- mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox)22+ mockTitleBar, mockFavicon, mockLabel, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox)
23 _ = mockFavicon23 _ = mockFavicon
24- _ = mockOverlay24+ _ = mockLabel
25- _ = mockSpinner25+ mockTitleBar.EXPECT().SetVisible(false).Once() // Active pane hides its title bar
26- mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
27 mockContainer.EXPECT().SetVisible(true).Once()26 mockContainer.EXPECT().SetVisible(true).Once()
28 mockTitleBar.EXPECT().AddCssClass("active").Once()27 mockTitleBar.EXPECT().AddCssClass("active").Once()
29 28
M internal/ui/layout/widgets.go +1 -0
@@ -179,6 +179,7 @@ type ButtonWidget interface {
179179 GetLabel() string
180180 SetChild(child Widget)
181181 GetChild() Widget
182+ SetIconName(iconName string)
182183
183184 // Connect click handler, returns signal ID for disconnection
184185 ConnectClicked(callback func()) uint32
@@ -179,6 +179,7 @@ type ButtonWidget interface {
179 GetLabel() string179 GetLabel() string
180 SetChild(child Widget)180 SetChild(child Widget)
181 GetChild() Widget181 GetChild() Widget
182+ SetIconName(iconName string)
182 183
183 // Connect click handler, returns signal ID for disconnection184 // Connect click handler, returns signal ID for disconnection
184 ConnectClicked(callback func()) uint32185 ConnectClicked(callback func()) uint32
M internal/ui/theme/css.go +43 -16
@@ -572,6 +572,7 @@ func generateStackedPaneCSS(p Palette) string {
572572 return `/* ===== Stacked Pane Styling ===== */
573573
574574 /* Title bar for inactive panes in a stack */
575+/* The title bar is now a Box with GestureClick, not wrapped in a button */
575576 .stacked-pane-titlebar {
576577 background-color: var(--surface-variant);
577578 border-bottom: 0.0625em solid var(--border);
@@ -579,27 +580,15 @@ func generateStackedPaneCSS(p Palette) string {
579580 min-height: 1.5em;
580581 }
581582
582-/* Title bar button wrapper - clickable area */
583-button.stacked-pane-title-button {
584- background-color: var(--surface-variant);
585- background-image: none;
586- border: none;
587- border-bottom: 0.0625em solid var(--border);
588- border-radius: 0;
589- padding: 0;
590- margin: 0;
583+/* Clickable title bar - hover styling */
584+.stacked-pane-titlebar.stacked-pane-title-clickable {
591585 transition: background-color 150ms ease-in-out;
592586 }
593587
594-button.stacked-pane-title-button:hover {
588+.stacked-pane-titlebar.stacked-pane-title-clickable:hover {
595589 background-color: shade(var(--surface-variant), 1.15);
596590 }
597591
598-button.stacked-pane-title-button:focus {
599- outline: none;
600- box-shadow: none;
601-}
602-
603592 /* Title bar content box */
604593 .stacked-pane-titlebar.active {
605594 background-color: shade(var(--surface-variant), 1.2);
@@ -620,9 +609,47 @@ button.stacked-pane-title-button:focus {
620609 font-weight: 400;
621610 }
622611
623-button.stacked-pane-title-button:hover .stacked-pane-titlebar label {
612+/* Hover effect on title bar label */
613+.stacked-pane-titlebar.stacked-pane-title-clickable:hover label {
624614 color: var(--accent);
625615 }
616+
617+/* Close button in stacked pane title bar */
618+.stacked-pane-close-button {
619+ background: transparent;
620+ border: none;
621+ border-radius: 4px;
622+ padding: 4px;
623+ margin: 0 2px;
624+ min-width: 20px;
625+ min-height: 20px;
626+ opacity: 0.4;
627+ transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out;
628+}
629+
630+.stacked-pane-close-button > image {
631+ margin: 0;
632+ padding: 0;
633+}
634+
635+.stacked-pane-close-button:hover {
636+ opacity: 1.0;
637+ background: alpha(var(--destructive), 0.2);
638+}
639+
640+.stacked-pane-close-button:hover > image {
641+ color: var(--destructive);
642+}
643+
644+/* Show close button more prominently when hovering title bar */
645+.stacked-pane-titlebar.stacked-pane-title-clickable:hover .stacked-pane-close-button {
646+ opacity: 0.7;
647+}
648+
649+.stacked-pane-close-button:focus {
650+ outline: none;
651+ box-shadow: none;
652+}
626653 `
627654 }
628655
@@ -572,6 +572,7 @@ func generateStackedPaneCSS(p Palette) string {
572 return `/* ===== Stacked Pane Styling ===== */572 return `/* ===== Stacked Pane Styling ===== */
573 573
574 /* Title bar for inactive panes in a stack */574 /* Title bar for inactive panes in a stack */
575+/* The title bar is now a Box with GestureClick, not wrapped in a button */
575 .stacked-pane-titlebar {576 .stacked-pane-titlebar {
576 background-color: var(--surface-variant);577 background-color: var(--surface-variant);
577 border-bottom: 0.0625em solid var(--border);578 border-bottom: 0.0625em solid var(--border);
@@ -579,27 +580,15 @@ func generateStackedPaneCSS(p Palette) string {
579 min-height: 1.5em;580 min-height: 1.5em;
580 }581 }
581 582
582-/* Title bar button wrapper - clickable area */583+/* Clickable title bar - hover styling */
583-button.stacked-pane-title-button {584+.stacked-pane-titlebar.stacked-pane-title-clickable {
584- background-color: var(--surface-variant);
585- background-image: none;
586- border: none;
587- border-bottom: 0.0625em solid var(--border);
588- border-radius: 0;
589- padding: 0;
590- margin: 0;
591 transition: background-color 150ms ease-in-out;585 transition: background-color 150ms ease-in-out;
592 }586 }
593 587
594-button.stacked-pane-title-button:hover {588+.stacked-pane-titlebar.stacked-pane-title-clickable:hover {
595 background-color: shade(var(--surface-variant), 1.15);589 background-color: shade(var(--surface-variant), 1.15);
596 }590 }
597 591
598-button.stacked-pane-title-button:focus {
599- outline: none;
600- box-shadow: none;
601-}
602-
603 /* Title bar content box */592 /* Title bar content box */
604 .stacked-pane-titlebar.active {593 .stacked-pane-titlebar.active {
605 background-color: shade(var(--surface-variant), 1.2);594 background-color: shade(var(--surface-variant), 1.2);
@@ -620,9 +609,47 @@ button.stacked-pane-title-button:focus {
620 font-weight: 400;609 font-weight: 400;
621 }610 }
622 611
623-button.stacked-pane-title-button:hover .stacked-pane-titlebar label {612+/* Hover effect on title bar label */
613+.stacked-pane-titlebar.stacked-pane-title-clickable:hover label {
624 color: var(--accent);614 color: var(--accent);
625 }615 }
616+
617+/* Close button in stacked pane title bar */
618+.stacked-pane-close-button {
619+ background: transparent;
620+ border: none;
621+ border-radius: 4px;
622+ padding: 4px;
623+ margin: 0 2px;
624+ min-width: 20px;
625+ min-height: 20px;
626+ opacity: 0.4;
627+ transition: opacity 100ms ease-in-out, background-color 100ms ease-in-out;
628+}
629+
630+.stacked-pane-close-button > image {
631+ margin: 0;
632+ padding: 0;
633+}
634+
635+.stacked-pane-close-button:hover {
636+ opacity: 1.0;
637+ background: alpha(var(--destructive), 0.2);
638+}
639+
640+.stacked-pane-close-button:hover > image {
641+ color: var(--destructive);
642+}
643+
644+/* Show close button more prominently when hovering title bar */
645+.stacked-pane-titlebar.stacked-pane-title-clickable:hover .stacked-pane-close-button {
646+ opacity: 0.7;
647+}
648+
649+.stacked-pane-close-button:focus {
650+ outline: none;
651+ box-shadow: none;
652+}
626 `653 `
627 }654 }
628 655

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