detached 9f6447c

Merge pull request #165 from bnema/feat/floating-panes

feat(ui): floating pane sessions + profiles; installer prerelease support
commit
9f6447c109c6b8f45eaf6d033af52d982fb1379e
author
bnema · · 2026-02-10 06:04 UTC
committer
GitHub · · 2026-02-10 06:04 UTC
merge of
72ba574 + bfcbf1a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
39 files · +3862 -78
M CHANGELOG.md +7 -0
@@ -13,6 +13,10 @@ All notable changes to this project will be documented in this file.
13 - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.13 - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.
14 - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.14 - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.
15 - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior.15 - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior.
16+- **Floating pane (persistent overlay pane)**: `Alt+F` toggles a workspace floating pane that preserves its session state while hidden.
17+- **Floating pane profiles**: Optional URL profiles under `workspace.floating_pane.profiles` with user-defined shortcut keys to open/focus named floating sessions.
18+- **Floating pane documentation**: Added reference docs describing floating behavior, shortcuts, and configuration.
19+- **Installer prerelease/version selection**: `install.sh` supports `DUMBER_PRERELEASE=1` to install the latest prerelease and `DUMBER_VERSION=<tag>` to install a specific release.
16 20
17 ### Changed21 ### Changed
18 22
@@ -24,6 +28,9 @@ All notable changes to this project will be documented in this file.
24 - **Config normalization hardening**: Normalization now coerces invalid profile/appearance values to safe defaults.28 - **Config normalization hardening**: Normalization now coerces invalid profile/appearance values to safe defaults.
25 - **Loading skeleton redesign**: Reduced logo from 512px to 256px, added version label below a discrete 32px spinner. Sizes extracted to constants so tests use `mock.Anything` and don't break on dimension changes.29 - **Loading skeleton redesign**: Reduced logo from 512px to 256px, added version label below a discrete 32px spinner. Sizes extracted to constants so tests use `mock.Anything` and don't break on dimension changes.
26 - **Aggressive WebView reuse policy**: `reusePolicyAggressive` now actually differs from `reusePolicySafe` by allowing reuse regardless of navigation activity, relying on `ResetForPoolReuse` to clear state.30 - **Aggressive WebView reuse policy**: `reusePolicyAggressive` now actually differs from `reusePolicySafe` by allowing reuse regardless of navigation activity, relying on `ResetForPoolReuse` to clear state.
31+- **Multiple GUI instances**: GTK app is now non-unique, allowing independent `dumber` launches in parallel.
32+- **Config schema/validation**: Added `workspace.floating_pane.*` config keys to schema and validation, including URL/profile shortcut validation.
33+- **Config migration for shortcut actions**: Migration now merges newly added `workspace.shortcuts.actions.*` defaults into existing user action maps without overwriting user-defined entries.
27 34
28 ### Fixed35 ### Fixed
29 36
M README.md +10 -0
@@ -23,6 +23,7 @@ https://github.com/user-attachments/assets/232822af-08e4-4a74-9416-87f79c96b118
23 23
24 **Features:**24 **Features:**
25 - Zellij-style panes with modal keyboard navigation25 - Zellij-style panes with modal keyboard navigation
26+- Floating workspace pane with profile shortcuts
26 - Wayland native (Sway, Hyprland, River, Niri)27 - Wayland native (Sway, Hyprland, River, Niri)
27 - Session resurrection with auto-snapshots28 - Session resurrection with auto-snapshots
28 - Built-in ad blocking (UBlock-based)29 - Built-in ad blocking (UBlock-based)
@@ -90,6 +91,15 @@ Four modal modes. Enter a mode, take action, escape out. Vim and Zellij users wi
90 | Resize | `Ctrl+N` | Grow · Shrink · hjkl/arrows |91 | Resize | `Ctrl+N` | Grow · Shrink · hjkl/arrows |
91 | Session | `Ctrl+O` | Save · Restore · Browse |92 | Session | `Ctrl+O` | Save · Restore · Browse |
92 93
94+## Floating Pane
95+
96+- `Alt+F` toggles the floating pane and preserves its state while hidden.
97+- `Ctrl+W` closes the active pane; if the floating pane is active, it is fully released for a fresh next open.
98+- Profile shortcuts like `Alt+G` are optional and configured under `workspace.floating_pane.profiles`.
99+- Some `Alt+<key>` bindings may conflict with default WebKit shortcuts or desktop-level handlers.
100+
101+See [Floating Pane Reference](https://dumber.bnema.dev/docs/reference/floating-pane) for setup and behavior details.
102+
93 ## Development103 ## Development
94 104
95 Dumber uses pure-Go bindings (no CGO) but requires GTK4/WebKitGTK runtime libraries for the GUI.105 Dumber uses pure-Go bindings (no CGO) but requires GTK4/WebKitGTK runtime libraries for the GUI.
M docs/config/index.md +50 -1
@@ -300,7 +300,8 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
300 300
301 | Action | Default Keys | Description |301 | Action | Default Keys | Description |
302 |--------|--------------|-------------|302 |--------|--------------|-------------|
303-| `close_pane` | `ctrl+w` | Close active pane (closes tab if last pane) |303+| `toggle_floating_pane` | `alt+f` | Toggle the workspace floating pane (persistent session) |
304+| `close_pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) |
304 | `next_tab` | `ctrl+tab` | Switch to next tab |305 | `next_tab` | `ctrl+tab` | Switch to next tab |
305 | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |306 | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab |
306 | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |307 | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked |
@@ -317,6 +318,10 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
317 [workspace.shortcuts.actions.next_tab]318 [workspace.shortcuts.actions.next_tab]
318 keys = ["ctrl+tab", "alt+l"]319 keys = ["ctrl+tab", "alt+l"]
319 desc = "Switch to next tab"320 desc = "Switch to next tab"
321+
322+[workspace.shortcuts.actions.toggle_floating_pane]
323+ keys = ["alt+f"]
324+ desc = "Toggle floating pane"
320 ```325 ```
321 326
322 > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.327 > **Tip:** Use `dumb://config` → Keybindings tab to edit shortcuts visually.
@@ -324,6 +329,50 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
324 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.329 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
325 >330 >
326 > **Note:** Consume-or-expel is experimental and may change behavior between releases.331 > **Note:** Consume-or-expel is experimental and may change behavior between releases.
332+>
333+> **Warning:** Some `Alt+<key>` shortcuts may conflict with WebKit defaults, website handlers, or your desktop environment.
334+> If a binding does not fire, rebind it to a different key.
335+
336+### Floating Pane
337+
338+The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.
339+
340+- Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle_floating_pane`).
341+- URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.
342+- `Alt+F` toggles visibility only and preserves floating state.
343+- `Ctrl+W` on an active floating pane closes and releases that floating session for a fresh next open.
344+- Floating profile keybindings support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
345+
346+| Key | Type | Default | Description |
347+|-----|------|---------|-------------|
348+| `workspace.floating_pane.width_pct` | float | `0.82` | Floating pane width as fraction of workspace width (`(0,1]`) |
349+| `workspace.floating_pane.height_pct` | float | `0.72` | Floating pane height as fraction of workspace height (`(0,1]`) |
350+| `workspace.floating_pane.profiles` | map | `{}` | Named URL profiles, each with `keys[]`, `url`, optional `desc` |
351+
352+**Example:**
353+
354+```toml
355+[workspace.shortcuts.actions.toggle_floating_pane]
356+keys = ["alt+f"]
357+desc = "Toggle floating pane"
358+
359+[workspace.floating_pane]
360+width_pct = 0.82
361+height_pct = 0.72
362+
363+[workspace.floating_pane.profiles]
364+# Empty by default. Add as many entries as needed.
365+
366+[workspace.floating_pane.profiles.google]
367+keys = ["alt+g"]
368+url = "https://google.com"
369+desc = "Open floating pane on Google"
370+
371+[workspace.floating_pane.profiles.github]
372+keys = ["alt+h"]
373+url = "https://github.com"
374+desc = "Open floating pane on GitHub"
375+```
327 376
328 ### Popup Behavior377 ### Popup Behavior
329 378
M docs/getting-started.md +8 -0
@@ -35,6 +35,14 @@ Press `Ctrl+L` to open the omnibox for:
35 - Search (uses default search engine)35 - Search (uses default search engine)
36 - Bang shortcuts (`!g query` for Google, `!gh query` for GitHub)36 - Bang shortcuts (`!g query` for Google, `!gh query` for GitHub)
37 37
38+## Floating Pane
39+
40+- Press `Alt+F` to toggle the floating pane.
41+- Press `Ctrl+W` to close the active pane; if the floating pane is active, this fully releases it so the next open starts fresh.
42+- Some `Alt+<key>` bindings can conflict with default WebKit shortcuts or desktop-level handlers.
43+
44+See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.
45+
38 ## Configuration46 ## Configuration
39 47
40 Edit `~/.config/dumber/config.toml` or use:48 Edit `~/.config/dumber/config.toml` or use:
M docs/index.md +1 -0
@@ -33,6 +33,7 @@ A browser designed for Wayland compositors that works like your favorite termina
33 33
34 - [Dependencies](./reference/dependencies.md) - System dependencies for Arch and Debian/Ubuntu34 - [Dependencies](./reference/dependencies.md) - System dependencies for Arch and Debian/Ubuntu
35 - [Keybindings](./reference/keybindings.md) - Keyboard shortcuts reference35 - [Keybindings](./reference/keybindings.md) - Keyboard shortcuts reference
36+- [Floating Pane](./reference/floating-pane.md) - Floating pane behavior, shortcuts, and profiles
36 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions37 - [Troubleshooting](./reference/troubleshooting.md) - Common issues and solutions
37 38
38 ## Requirements39 ## Requirements
M docs/reference/configuration.md +7 -0
@@ -69,6 +69,13 @@
69 | `workspace.resize_mode.timeout_ms` | int | `3000` | |69 | `workspace.resize_mode.timeout_ms` | int | `3000` | |
70 | `workspace.resize_mode.step_percent` | float | `5.0` | |70 | `workspace.resize_mode.step_percent` | float | `5.0` | |
71 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |71 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
72+| `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings |
73+| `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | |
74+| `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
75+| `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |
76+| `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
77+| `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
78+| `workspace.floating_pane.profiles.<name>.desc` | string | | |
72 | `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |79 | `workspace.popups.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
73 | `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |80 | `workspace.popups.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
74 | `workspace.popups.open_in_new_pane` | bool | `true` | |81 | `workspace.popups.open_in_new_pane` | bool | `true` | |
A docs/reference/floating-pane.md +57 -0
@@ -0,0 +1,57 @@
1+# Floating Pane
2+
3+The floating pane is an overlay workspace pane for quick-access pages like search, chat, email, or docs.
4+
5+## Core Behavior
6+
7+| Action | Default Key | Result |
8+|--------|-------------|--------|
9+| Toggle floating pane | `Alt+F` | Show or hide the floating pane while keeping its current page and state |
10+| Open profile in floating pane | User-defined (for example `Alt+G`) | Open or focus a named floating profile URL |
11+| Close active pane | `Ctrl+W` | Close active pane; if floating is active, fully release floating session/WebView |
12+
13+### Toggle vs Close
14+
15+- `Alt+F` is a visibility toggle. It hides or shows the floating pane without resetting content.
16+- `Ctrl+W` on an active floating pane is a full close. It releases that floating session and its WebView.
17+- After a floating pane is closed with `Ctrl+W`, opening it again starts from a fresh state.
18+
19+## Configuration
20+
21+| Key | Type | Default | Description |
22+|-----|------|---------|-------------|
23+| `workspace.floating_pane.width_pct` | float | `0.82` | Floating pane width as a fraction of workspace width (`(0,1]`) |
24+| `workspace.floating_pane.height_pct` | float | `0.72` | Floating pane height as a fraction of workspace height (`(0,1]`) |
25+| `workspace.floating_pane.profiles.<name>.keys` | `[]string` | - | Shortcut keys for a profile |
26+| `workspace.floating_pane.profiles.<name>.url` | string | - | URL to load for the profile |
27+| `workspace.floating_pane.profiles.<name>.desc` | string | - | Optional description |
28+
29+## Example
30+
31+```toml
32+[workspace.shortcuts.actions.toggle_floating_pane]
33+keys = ["alt+f"]
34+desc = "Toggle floating pane"
35+
36+[workspace.floating_pane]
37+width_pct = 0.82
38+height_pct = 0.72
39+
40+[workspace.floating_pane.profiles.gmail]
41+keys = ["alt+m"]
42+url = "https://mail.google.com"
43+desc = "Open Gmail in floating pane"
44+
45+[workspace.floating_pane.profiles.google]
46+keys = ["alt+g"]
47+url = "https://google.com"
48+desc = "Open Google in floating pane"
49+```
50+
51+## Practical Notes
52+
53+- The default install only binds `Alt+F` for floating pane toggle.
54+- Profile shortcuts are opt-in and empty by default.
55+- Profile shortcuts support multi-modifier combos using `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
56+- Some `Alt+<key>` shortcuts can conflict with default WebKit shortcuts, website handlers, or your desktop environment.
57+- If a shortcut does not fire in Dumber, bind a different key.
M docs/reference/index.md +1 -0
@@ -6,6 +6,7 @@
6 - [Permissions](./permissions.md) - Website permission handling (mic, camera, screen sharing)6 - [Permissions](./permissions.md) - Website permission handling (mic, camera, screen sharing)
7 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu7 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
8 - [Keybindings](./keybindings.md) - All keyboard shortcuts8 - [Keybindings](./keybindings.md) - All keyboard shortcuts
9+- [Floating Pane](./floating-pane.md) - Floating-pane behavior, profiles, and shortcut semantics
9 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions10 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
10 - [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation11 - [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation
11 12
M docs/reference/keybindings.md +28 -1
@@ -79,7 +79,8 @@ These work outside modal modes:
79 79
80 | Action | Keys |80 | Action | Keys |
81 |--------|------|81 |--------|------|
82-| Close pane | `Ctrl+W` |82+| Toggle floating pane | `Alt+F` |
83+| Close pane (or release floating pane) | `Ctrl+W` |
83 | Next tab | `Ctrl+Tab` |84 | Next tab | `Ctrl+Tab` |
84 | Previous tab | `Ctrl+Shift+Tab` |85 | Previous tab | `Ctrl+Shift+Tab` |
85 | Consume/expel left | `Alt+[` |86 | Consume/expel left | `Alt+[` |
@@ -87,6 +88,17 @@ These work outside modal modes:
87 | Consume/expel up | `Alt+{` |88 | Consume/expel up | `Alt+{` |
88 | Consume/expel down | `Alt+}` |89 | Consume/expel down | `Alt+}` |
89 90
91+- `Alt+F` is the only floating-pane shortcut enabled by default.
92+- `Alt+F` toggles floating visibility and keeps floating pane state intact.
93+- `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
94+- Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
95+- Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
96+
97+Warning: some `Alt+<key>` combinations may conflict with default WebKit shortcuts, website handlers, or your desktop environment.
98+If a shortcut does not trigger in Dumber, choose a different keybinding.
99+
100+For details, see [Floating Pane](./floating-pane.md).
101+
90 ## Customization102 ## Customization
91 103
92 All keybindings can be customized in `~/.config/dumber/config.toml`:104 All keybindings can be customized in `~/.config/dumber/config.toml`:
@@ -98,6 +110,21 @@ close-pane = ["x", "q"]
98 110
99 [workspace.shortcuts.actions.close_pane]111 [workspace.shortcuts.actions.close_pane]
100 keys = ["ctrl+w"]112 keys = ["ctrl+w"]
113+
114+[workspace.shortcuts.actions.toggle_floating_pane]
115+keys = ["alt+f"]
116+
117+[workspace.floating_pane]
118+width_pct = 0.82
119+height_pct = 0.72
120+
121+[workspace.floating_pane.profiles.google]
122+keys = ["alt+g"]
123+url = "https://google.com"
124+
125+[workspace.floating_pane.profiles.github]
126+keys = ["alt+h"]
127+url = "https://github.com"
101 ```128 ```
102 129
103 See [Configuration](../config/index.md) for full details.130 See [Configuration](../config/index.md) for full details.
M install.sh +70 -2
@@ -3,8 +3,10 @@ set -e
3 3
4 # Dumber installer script4 # Dumber installer script
5 # Usage: curl -fsSL https://dumber.bnema.dev/install | sh5 # Usage: curl -fsSL https://dumber.bnema.dev/install | sh
6+# Usage with pre-release: curl -fsSL https://dumber.bnema.dev/install | DUMBER_PRERELEASE=1 sh
6 7
7 REPO="bnema/dumber"8 REPO="bnema/dumber"
9+VERSION="${DUMBER_VERSION:-latest}"
8 10
9 # Prefer ~/.local/bin if it exists and is in PATH, otherwise /usr/local/bin11 # Prefer ~/.local/bin if it exists and is in PATH, otherwise /usr/local/bin
10 if [ -d "$HOME/.local/bin" ]; then12 if [ -d "$HOME/.local/bin" ]; then
@@ -36,14 +38,80 @@ fi
36 echo "Detected: linux/x86_64"38 echo "Detected: linux/x86_64"
37 39
38 TARBALL="dumber_linux_x86_64.tar.gz"40 TARBALL="dumber_linux_x86_64.tar.gz"
39-DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${TARBALL}"41+
42+# Determine version to download
43+if [ "$VERSION" = "latest" ] && [ -n "$DUMBER_PRERELEASE" ]; then
44+ echo "Finding latest pre-release..."
45+ RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=100" 2>/dev/null || echo "")
46+ if [ -z "$RELEASE_DATA" ]; then
47+ echo "Error: Failed to fetch release information from GitHub API"
48+ exit 1
49+ fi
50+ # Prefer jq for reliable JSON parsing of RELEASE_DATA into VERSION.
51+ # The awk branch is a portability fallback that assumes prerelease and
52+ # tag_name appear in the same '{' fragment; JSON strings containing '{'
53+ # or API shape changes can break this extraction.
54+ if command -v jq >/dev/null 2>&1; then
55+ VERSION=$(echo "$RELEASE_DATA" | jq -r '.[] | select(.prerelease == true) | .tag_name' | head -1)
56+ else
57+ VERSION=$(echo "$RELEASE_DATA" | awk '
58+ BEGIN { RS = "{" }
59+ /"prerelease"[[:space:]]*:[[:space:]]*true/ {
60+ tag = ""
61+ if (match($0, /"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
62+ tag = substr($0, RSTART, RLENGTH)
63+ sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag)
64+ sub(/".*/, "", tag)
65+ }
66+ if (tag != "") {
67+ print tag
68+ exit
69+ }
70+ }
71+ ')
72+ fi
73+ if [ -z "$VERSION" ]; then
74+ echo "Error: Could not determine latest pre-release version"
75+ exit 1
76+ fi
77+ echo "Using pre-release version: ${VERSION}"
78+elif [ "$VERSION" = "latest" ]; then
79+ echo "Using latest stable release"
80+ RELEASE_DATA=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null || echo "")
81+ if [ -z "$RELEASE_DATA" ]; then
82+ echo "Error: Failed to fetch latest release information from GitHub API"
83+ exit 1
84+ fi
85+ if command -v jq >/dev/null 2>&1; then
86+ VERSION=$(echo "$RELEASE_DATA" | jq -r '.tag_name')
87+ else
88+ VERSION=$(echo "$RELEASE_DATA" | awk '
89+ /"tag_name"[[:space:]]*:/ {
90+ tag = $0
91+ sub(/.*"tag_name"[[:space:]]*:[[:space:]]*"/, "", tag)
92+ sub(/".*/, "", tag)
93+ print tag
94+ exit
95+ }
96+ ')
97+ fi
98+ if [ -z "$VERSION" ]; then
99+ echo "Error: Could not determine latest stable version"
100+ exit 1
101+ fi
102+ echo "Resolved version: ${VERSION}"
103+else
104+ echo "Using version: ${VERSION}"
105+fi
106+
107+DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${TARBALL}"
40 108
41 # Create temporary directory109 # Create temporary directory
42 TMP_DIR=$(mktemp -d)110 TMP_DIR=$(mktemp -d)
43 trap 'rm -rf "$TMP_DIR"' EXIT111 trap 'rm -rf "$TMP_DIR"' EXIT
44 112
45 # Download checksums file113 # Download checksums file
46-CHECKSUMS_URL="https://github.com/${REPO}/releases/latest/download/checksums.txt"114+CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
47 echo "Downloading checksums..."115 echo "Downloading checksums..."
48 if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then116 if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then
49 echo "Error: Failed to download checksums file"117 echo "Error: Failed to download checksums file"
M internal/infrastructure/config/defaults.go +8 -0
@@ -33,6 +33,8 @@ const (
33 defaultResizeMinPanePercent = 10.033 defaultResizeMinPanePercent = 10.0
34 defaultTabBarPosition = "bottom"34 defaultTabBarPosition = "bottom"
35 defaultPopupPlacement = "right"35 defaultPopupPlacement = "right"
36+ defaultFloatingPaneWidthPct = 0.82
37+ defaultFloatingPaneHeightPct = 0.72
36 38
37 // Session defaults39 // Session defaults
38 defaultSessionActivationShortcut = "ctrl+o"40 defaultSessionActivationShortcut = "ctrl+o"
@@ -223,6 +225,7 @@ func DefaultConfig() *Config {
223 },225 },
224 Shortcuts: GlobalShortcutsConfig{226 Shortcuts: GlobalShortcutsConfig{
225 Actions: map[string]ActionBinding{227 Actions: map[string]ActionBinding{
228+ "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
226 "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},229 "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
227 "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},230 "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
228 "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},231 "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
@@ -232,6 +235,11 @@ func DefaultConfig() *Config {
232 "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},235 "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
233 },236 },
234 },237 },
238+ FloatingPane: FloatingPaneConfig{
239+ WidthPct: defaultFloatingPaneWidthPct,
240+ HeightPct: defaultFloatingPaneHeightPct,
241+ Profiles: map[string]FloatingPaneProfile{},
242+ },
235 TabBarPosition: defaultTabBarPosition,243 TabBarPosition: defaultTabBarPosition,
236 HideTabBarWhenSingleTab: true,244 HideTabBarWhenSingleTab: true,
237 Popups: PopupBehaviorConfig{245 Popups: PopupBehaviorConfig{
A internal/infrastructure/config/floating_pane_config_test.go +180 -0
@@ -0,0 +1,180 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFloatingPaneConfig_Defaults(t *testing.T) {
12+ cfg := DefaultConfig()
13+
14+ assert.InDelta(t, 0.82, cfg.Workspace.FloatingPane.WidthPct, 0.0001)
15+ assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
16+ assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
17+
18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
19+ require.True(t, ok)
20+ assert.Equal(t, []string{"alt+f"}, binding.Keys)
21+}
22+
23+func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
24+ mgr := &Manager{viper: viper.New()}
25+ mgr.setDefaults()
26+
27+ cfg, err := mgr.unmarshalConfig()
28+ require.NoError(t, err)
29+
30+ assert.InDelta(t, 0.82, cfg.Workspace.FloatingPane.WidthPct, 0.0001)
31+ assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
32+ assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
33+
34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
35+ require.True(t, ok)
36+ assert.Equal(t, []string{"alt+f"}, binding.Keys)
37+}
38+
39+func TestFloatingPaneConfig_ValidationRanges(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ mutate func(cfg *Config)
43+ errorField string
44+ }{
45+ {
46+ name: "width must be > 0",
47+ mutate: func(cfg *Config) {
48+ cfg.Workspace.FloatingPane.WidthPct = 0
49+ },
50+ errorField: "workspace.floating_pane.width_pct",
51+ },
52+ {
53+ name: "width must be <= 1",
54+ mutate: func(cfg *Config) {
55+ cfg.Workspace.FloatingPane.WidthPct = 1.5
56+ },
57+ errorField: "workspace.floating_pane.width_pct",
58+ },
59+ {
60+ name: "height must be > 0",
61+ mutate: func(cfg *Config) {
62+ cfg.Workspace.FloatingPane.HeightPct = 0
63+ },
64+ errorField: "workspace.floating_pane.height_pct",
65+ },
66+ {
67+ name: "height must be <= 1",
68+ mutate: func(cfg *Config) {
69+ cfg.Workspace.FloatingPane.HeightPct = 1.1
70+ },
71+ errorField: "workspace.floating_pane.height_pct",
72+ },
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ cfg := DefaultConfig()
78+ tt.mutate(cfg)
79+
80+ err := validateConfig(cfg)
81+ require.Error(t, err)
82+ assert.Contains(t, err.Error(), tt.errorField)
83+ })
84+ }
85+}
86+
87+func TestFloatingPaneConfig_ProfileValidation(t *testing.T) {
88+ t.Run("missing URL", func(t *testing.T) {
89+ cfg := DefaultConfig()
90+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
91+ "google": {
92+ URL: "",
93+ Keys: []string{"alt+g"},
94+ },
95+ }
96+
97+ err := validateConfig(cfg)
98+ require.Error(t, err)
99+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")
100+ })
101+
102+ t.Run("missing keys", func(t *testing.T) {
103+ cfg := DefaultConfig()
104+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
105+ "google": {
106+ URL: "https://google.com",
107+ Keys: nil,
108+ },
109+ }
110+
111+ err := validateConfig(cfg)
112+ require.Error(t, err)
113+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")
114+ })
115+
116+ t.Run("whitespace-only key", func(t *testing.T) {
117+ cfg := DefaultConfig()
118+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
119+ "google": {
120+ URL: "https://google.com",
121+ Keys: []string{" ", "alt+g"},
122+ },
123+ }
124+
125+ err := validateConfig(cfg)
126+ require.Error(t, err)
127+ assert.Contains(t, err.Error(), "empty or whitespace-only key binding")
128+ })
129+
130+ t.Run("duplicate key binding", func(t *testing.T) {
131+ cfg := DefaultConfig()
132+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
133+ "google": {
134+ URL: "https://google.com",
135+ Keys: []string{"alt+g"},
136+ },
137+ "github": {
138+ URL: "https://github.com",
139+ Keys: []string{"alt+g"},
140+ },
141+ }
142+
143+ err := validateConfig(cfg)
144+ require.Error(t, err)
145+ assert.Contains(t, err.Error(), "duplicate key binding")
146+ })
147+
148+ t.Run("invalid URL scheme", func(t *testing.T) {
149+ cfg := DefaultConfig()
150+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
151+ "invalid-scheme": {
152+ URL: "ftp://example.com",
153+ Keys: []string{"alt+i"},
154+ },
155+ }
156+
157+ err := validateConfig(cfg)
158+ require.Error(t, err)
159+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")
160+ assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")
161+ })
162+}
163+
164+func TestFloatingPaneConfig_ProfileMultiplicity(t *testing.T) {
165+ cfg := DefaultConfig()
166+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
167+ "google": {
168+ URL: "https://google.com",
169+ Keys: []string{"alt+g"},
170+ },
171+ "github": {
172+ URL: "https://github.com",
173+ Keys: []string{"alt+h"},
174+ },
175+ }
176+
177+ err := validateConfig(cfg)
178+ require.NoError(t, err)
179+ assert.Len(t, cfg.Workspace.FloatingPane.Profiles, 2)
180+}
M internal/infrastructure/config/loader.go +3 -0
@@ -496,6 +496,9 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
496 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)496 m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
497 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)497 m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
498 m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)498 m.viper.SetDefault("workspace.shortcuts.actions", defaults.Workspace.Shortcuts.Actions)
499+ m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
500+ m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
501+ m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
499 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)502 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
500 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)503 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
501 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)504 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
M internal/infrastructure/config/migrate.go +136 -7
@@ -113,6 +113,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
113 113
114 // Find missing keys (in defaults but not in user config)114 // Find missing keys (in defaults but not in user config)
115 missingKeys := m.findMissingKeys(defaultKeys, userKeySet)115 missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
116+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingWorkspaceShortcutActions(userKeysWithValues))
116 117
117 // Try to match deprecated keys with missing keys (detect renames)118 // Try to match deprecated keys with missing keys (detect renames)
118 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)119 renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
@@ -120,7 +121,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
120 // Add rename changes121 // Add rename changes
121 for oldKey, newKey := range renames {122 for oldKey, newKey := range renames {
122 oldValue := m.formatValue(userKeysWithValues[oldKey])123 oldValue := m.formatValue(userKeysWithValues[oldKey])
123- newValue := m.formatValue(m.defaultViper.Get(newKey))124+ newValue := m.formatValue(m.defaultValueForKey(newKey))
124 changes = append(changes, port.KeyChange{125 changes = append(changes, port.KeyChange{
125 Type: port.KeyChangeRenamed,126 Type: port.KeyChangeRenamed,
126 OldKey: oldKey,127 OldKey: oldKey,
@@ -144,7 +145,7 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
144 changes = append(changes, port.KeyChange{145 changes = append(changes, port.KeyChange{
145 Type: port.KeyChangeAdded,146 Type: port.KeyChangeAdded,
146 NewKey: newKey,147 NewKey: newKey,
147- NewValue: m.formatValue(m.defaultViper.Get(newKey)),148+ NewValue: m.formatValue(m.defaultValueForKey(newKey)),
148 })149 })
149 }150 }
150 151
@@ -172,6 +173,9 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
172 func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {173 func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
173 var deprecated []string174 var deprecated []string
174 for userKey := range userKeys {175 for userKey := range userKeys {
176+ if userKey == databasePathKey {
177+ continue
178+ }
175 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {179 if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
176 deprecated = append(deprecated, userKey)180 deprecated = append(deprecated, userKey)
177 }181 }
@@ -257,9 +261,11 @@ const (
257 261
258 // Config format constants.262 // Config format constants.
259 const (263 const (
260- configFormatTOML = "toml"264+ configFormatTOML = "toml"
261- configFormatYAML = "yaml"265+ configFormatYAML = "yaml"
262- configFormatJSON = "json"266+ configFormatJSON = "json"
267+ databasePathKey = "database.path"
268+ workspaceShortcutsActionsKey = "workspace.shortcuts.actions"
263 )269 )
264 270
265 // typesAreCompatible checks if the default types for two keys are compatible.271 // typesAreCompatible checks if the default types for two keys are compatible.
@@ -405,6 +411,7 @@ func (m *Migrator) Migrate() ([]string, error) {
405 for key := range keysToRemove {411 for key := range keysToRemove {
406 m.deleteNestedKey(rawConfig, key)412 m.deleteNestedKey(rawConfig, key)
407 }413 }
414+ m.mergeMissingWorkspaceShortcutActions(rawConfig)
408 415
409 // Create a new Viper instance with defaults for added keys416 // Create a new Viper instance with defaults for added keys
410 userViper := viper.New()417 userViper := viper.New()
@@ -579,7 +586,7 @@ func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []stri
579 586
580 // GetKeyInfo returns detailed information about a config key.587 // GetKeyInfo returns detailed information about a config key.
581 func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {588 func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
582- value := m.defaultViper.Get(key)589+ value := m.defaultValueForKey(key)
583 if value == nil {590 if value == nil {
584 return port.KeyInfo{591 return port.KeyInfo{
585 Key: key,592 Key: key,
@@ -600,6 +607,21 @@ func (*Migrator) GetConfigFile() (string, error) {
600 return GetConfigFile()607 return GetConfigFile()
601 }608 }
602 609
610+func (m *Migrator) defaultValueForKey(key string) any {
611+ if value := m.defaultViper.Get(key); value != nil {
612+ return value
613+ }
614+
615+ if strings.HasPrefix(key, workspaceShortcutsActionsKey+".") {
616+ actionName := strings.TrimPrefix(key, workspaceShortcutsActionsKey+".")
617+ if action, ok := m.defaultConfig.Workspace.Shortcuts.Actions[actionName]; ok {
618+ return action
619+ }
620+ }
621+
622+ return nil
623+}
624+
603 // getAllDefaultKeys returns all keys from the default configuration.625 // getAllDefaultKeys returns all keys from the default configuration.
604 func (m *Migrator) getAllDefaultKeys() []string {626 func (m *Migrator) getAllDefaultKeys() []string {
605 keys := m.defaultViper.AllKeys()627 keys := m.defaultViper.AllKeys()
@@ -608,7 +630,7 @@ func (m *Migrator) getAllDefaultKeys() []string {
608 filtered := make([]string, 0, len(keys))630 filtered := make([]string, 0, len(keys))
609 for _, key := range keys {631 for _, key := range keys {
610 // Skip database.path as it's set dynamically632 // Skip database.path as it's set dynamically
611- if key == "database.path" {633+ if key == databasePathKey {
612 continue634 continue
613 }635 }
614 filtered = append(filtered, key)636 filtered = append(filtered, key)
@@ -727,6 +749,113 @@ func (*Migrator) isUserDataSection(keyPath string) bool {
727 return false749 return false
728 }750 }
729 751
752+// detectMissingWorkspaceShortcutActions finds default global shortcut actions not present in user config.
753+func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[string]any) []string {
754+ if userKeysWithValues == nil {
755+ return nil
756+ }
757+
758+ userActionsValue, ok := userKeysWithValues[workspaceShortcutsActionsKey]
759+ if !ok {
760+ return nil
761+ }
762+
763+ userActions, ok := m.toStringAnyMap(userActionsValue)
764+ if !ok {
765+ return nil
766+ }
767+
768+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
769+ if len(defaultActions) == 0 {
770+ return nil
771+ }
772+
773+ missing := make([]string, 0)
774+ for actionName := range defaultActions {
775+ if _, exists := userActions[actionName]; exists {
776+ continue
777+ }
778+ missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
779+ }
780+
781+ sort.Strings(missing)
782+ return missing
783+}
784+
785+// mergeMissingWorkspaceShortcutActions injects new default shortcut actions while preserving user overrides.
786+func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any) {
787+ actionsValue := m.getNestedValue(rawConfig, workspaceShortcutsActionsKey)
788+ if actionsValue == nil {
789+ return
790+ }
791+
792+ userActions, ok := m.toStringAnyMap(actionsValue)
793+ if !ok {
794+ return
795+ }
796+
797+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
798+ for actionName, defaultAction := range defaultActions {
799+ if _, exists := userActions[actionName]; exists {
800+ continue
801+ }
802+ userActions[actionName] = defaultAction
803+ }
804+
805+ m.setNestedValue(rawConfig, workspaceShortcutsActionsKey, userActions)
806+}
807+
808+func appendUniqueKeys(base, extra []string) []string {
809+ if len(extra) == 0 {
810+ return base
811+ }
812+
813+ seen := make(map[string]bool, len(base)+len(extra))
814+ for _, key := range base {
815+ seen[key] = true
816+ }
817+
818+ for _, key := range extra {
819+ if seen[key] {
820+ continue
821+ }
822+ base = append(base, key)
823+ seen[key] = true
824+ }
825+
826+ sort.Strings(base)
827+ return base
828+}
829+
830+func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
831+ if value == nil {
832+ return nil, false
833+ }
834+
835+ if m, ok := value.(map[string]any); ok {
836+ return m, true
837+ }
838+
839+ // Some TOML/YAML parser paths yield non-map[string]any map implementations
840+ // for nested tables, so keep a reflect.Map coercion fallback here.
841+ rv := reflect.ValueOf(value)
842+ if rv.Kind() != reflect.Map {
843+ return nil, false
844+ }
845+
846+ out := make(map[string]any, rv.Len())
847+ iter := rv.MapRange()
848+ for iter.Next() {
849+ k := iter.Key()
850+ if k.Kind() != reflect.String {
851+ return nil, false
852+ }
853+ out[k.String()] = iter.Value().Interface()
854+ }
855+
856+ return out, true
857+}
858+
730 // findMissingKeys returns keys that are in defaults but not in user config.859 // findMissingKeys returns keys that are in defaults but not in user config.
731 func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {860 func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
732 missing := make([]string, 0)861 missing := make([]string, 0)
M internal/infrastructure/config/migrate_test.go +101 -0
@@ -539,6 +539,16 @@ func TestMigrator_FindDeprecatedKeys(t *testing.T) {
539 },539 },
540 expected: []string{},540 expected: []string{},
541 },541 },
542+ {
543+ name: "database.path ignored as deprecated",
544+ userKeys: map[string]any{
545+ "database.path": "",
546+ },
547+ defaultKeys: map[string]bool{
548+ "history.max_entries": true,
549+ },
550+ expected: []string{},
551+ },
542 }552 }
543 553
544 for _, tt := range tests {554 for _, tt := range tests {
@@ -677,6 +687,97 @@ func TestMigrator_GetConfigType(t *testing.T) {
677 }687 }
678 }688 }
679 689
690+func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
691+ m := NewMigrator()
692+
693+ defaultActions := m.defaultConfig.Workspace.Shortcuts.Actions
694+ userActions := make(map[string]any, len(defaultActions))
695+ for actionName, value := range defaultActions {
696+ userActions[actionName] = value
697+ }
698+ delete(userActions, "toggle_floating_pane")
699+
700+ missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701+ "workspace.shortcuts.actions": userActions,
702+ })
703+
704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)
705+}
706+
707+func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
708+ m := NewMigrator()
709+
710+ rawConfig := map[string]any{
711+ "workspace": map[string]any{
712+ "shortcuts": map[string]any{
713+ "actions": map[string]any{
714+ "close_pane": map[string]any{
715+ "keys": []string{"ctrl+w", "ctrl+i"},
716+ "desc": "Custom close pane",
717+ },
718+ "next_tab": map[string]any{
719+ "keys": []string{"ctrl+tab"},
720+ "desc": "Switch to next tab",
721+ },
722+ "previous_tab": map[string]any{
723+ "keys": []string{"ctrl+shift+tab"},
724+ "desc": "Switch to previous tab",
725+ },
726+ "consume_or_expel_left": map[string]any{
727+ "keys": []string{"alt+["},
728+ "desc": "Consume/expel pane left",
729+ },
730+ "consume_or_expel_right": map[string]any{
731+ "keys": []string{"alt+]"},
732+ "desc": "Consume/expel pane right",
733+ },
734+ "consume_or_expel_up": map[string]any{
735+ "keys": []string{"alt+{"},
736+ "desc": "Consume/expel pane up",
737+ },
738+ "consume_or_expel_down": map[string]any{
739+ "keys": []string{"alt+}"},
740+ "desc": "Consume/expel pane down",
741+ },
742+ },
743+ },
744+ },
745+ }
746+
747+ m.mergeMissingWorkspaceShortcutActions(rawConfig)
748+
749+ actionsAny := m.getNestedValue(rawConfig, "workspace.shortcuts.actions")
750+ actions, ok := actionsAny.(map[string]any)
751+ require.True(t, ok)
752+
753+ toggleAny, hasToggle := actions["toggle_floating_pane"]
754+ assert.True(t, hasToggle)
755+ assert.NotNil(t, toggleAny)
756+
757+ closePaneAny, hasClosePane := actions["close_pane"]
758+ require.True(t, hasClosePane)
759+ closePane, ok := closePaneAny.(map[string]any)
760+ require.True(t, ok)
761+ assert.Equal(t, "Custom close pane", closePane["desc"])
762+}
763+
764+func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765+ m := NewMigrator()
766+
767+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")
768+
769+ action, ok := value.(ActionBinding)
770+ require.True(t, ok)
771+ assert.Equal(t, []string{"alt+f"}, action.Keys)
772+}
773+
774+func TestMigrator_DefaultValueForKey_RegularKey(t *testing.T) {
775+ m := NewMigrator()
776+
777+ value := m.defaultValueForKey("history.max_entries")
778+ assert.Equal(t, 10000, value)
779+}
780+
680 func TestMigrator_GetNestedValue(t *testing.T) {781 func TestMigrator_GetNestedValue(t *testing.T) {
681 m := NewMigrator()782 m := NewMigrator()
682 783
M internal/infrastructure/config/schema.go +16 -0
@@ -420,6 +420,8 @@ type WorkspaceConfig struct {
420 ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`420 ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
421 // Shortcuts holds global (non-modal) keyboard shortcuts.421 // Shortcuts holds global (non-modal) keyboard shortcuts.
422 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`422 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
423+ // FloatingPane configures workspace-level floating pane behavior.
424+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
423 // TabBarPosition determines tab bar placement: "top" or "bottom".425 // TabBarPosition determines tab bar placement: "top" or "bottom".
424 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`426 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
425 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.427 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
@@ -503,6 +505,20 @@ type GlobalShortcutsConfig struct {
503 Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`505 Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
504 }506 }
505 507
508+// FloatingPaneConfig defines persistent floating pane behavior.
509+type FloatingPaneConfig struct {
510+ WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
511+ HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
512+ Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
513+}
514+
515+// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
516+type FloatingPaneProfile struct {
517+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
518+ URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
519+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
520+}
521+
506 // GetKeyBindings returns an inverted map for O(1) key→action lookup.522 // GetKeyBindings returns an inverted map for O(1) key→action lookup.
507 func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {523 func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
508 keyToAction := make(map[string]string)524 keyToAction := make(map[string]string)
M internal/infrastructure/config/schema_provider.go +24 -0
@@ -453,6 +453,30 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
453 Description: "Global keyboard shortcuts (use Keybindings tab to edit)",453 Description: "Global keyboard shortcuts (use Keybindings tab to edit)",
454 Section: SectionWorkspace,454 Section: SectionWorkspace,
455 },455 },
456+ // Floating pane
457+ {
458+ Key: "workspace.floating_pane.width_pct",
459+ Type: "float64",
460+ Default: fmt.Sprintf("%.2f", defaults.Workspace.FloatingPane.WidthPct),
461+ Description: "Floating pane width as a fraction of workspace width",
462+ Range: "(0,1]",
463+ Section: SectionWorkspace,
464+ },
465+ {
466+ Key: "workspace.floating_pane.height_pct",
467+ Type: "float64",
468+ Default: fmt.Sprintf("%.2f", defaults.Workspace.FloatingPane.HeightPct),
469+ Description: "Floating pane height as a fraction of workspace height",
470+ Range: "(0,1]",
471+ Section: SectionWorkspace,
472+ },
473+ {
474+ Key: "workspace.floating_pane.profiles.<name>",
475+ Type: "object",
476+ Default: "(empty)",
477+ Description: "Named floating pane URL profiles with keys, url, and optional desc",
478+ Section: SectionWorkspace,
479+ },
456 // Popups480 // Popups
457 {481 {
458 Key: "workspace.popups.behavior",482 Key: "workspace.popups.behavior",
M internal/infrastructure/config/validation.go +98 -13
@@ -3,6 +3,7 @@ package config
3 import (3 import (
4 "fmt"4 "fmt"
5 "net/url"5 "net/url"
6+ "sort"
6 "strings"7 "strings"
7 8
8 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -22,6 +23,7 @@ func validateConfig(config *Config) error {
22 validationErrors = append(validationErrors, validatePaneMode(config)...)23 validationErrors = append(validationErrors, validatePaneMode(config)...)
23 validationErrors = append(validationErrors, validateTabBar(config)...)24 validationErrors = append(validationErrors, validateTabBar(config)...)
24 validationErrors = append(validationErrors, validateTabMode(config)...)25 validationErrors = append(validationErrors, validateTabMode(config)...)
26+ validationErrors = append(validationErrors, validateFloatingPane(config)...)
25 validationErrors = append(validationErrors, validateLogging(config)...)27 validationErrors = append(validationErrors, validateLogging(config)...)
26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)28 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
27 validationErrors = append(validationErrors, validateOmnibox(config)...)29 validationErrors = append(validationErrors, validateOmnibox(config)...)
@@ -224,6 +226,80 @@ func validateTabMode(config *Config) []string {
224 return validationErrors226 return validationErrors
225 }227 }
226 228
229+func validateFloatingPane(config *Config) []string {
230+ var validationErrors []string
231+ seenProfileKeys := make(map[string]string)
232+
233+ if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
234+ validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
235+ }
236+
237+ if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
238+ validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
239+ }
240+
241+ profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles))
242+ for profileName := range config.Workspace.FloatingPane.Profiles {
243+ profileNames = append(profileNames, profileName)
244+ }
245+ sort.Strings(profileNames)
246+
247+ for _, profileName := range profileNames {
248+ profile := config.Workspace.FloatingPane.Profiles[profileName]
249+ if strings.TrimSpace(profile.URL) == "" {
250+ validationErrors = append(validationErrors, fmt.Sprintf(
251+ "workspace.floating_pane.profiles.%s.url is required",
252+ profileName,
253+ ))
254+ } else {
255+ validationErrors = append(validationErrors, validateWorkspaceURLValue(
256+ fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
257+ profile.URL,
258+ )...)
259+ }
260+ if len(profile.Keys) == 0 {
261+ validationErrors = append(validationErrors, fmt.Sprintf(
262+ "workspace.floating_pane.profiles.%s.keys must have at least one entry",
263+ profileName,
264+ ))
265+ }
266+
267+ for _, key := range profile.Keys {
268+ normalizedKey := normalizeBindingKeyString(key)
269+ if normalizedKey == "" {
270+ validationErrors = append(validationErrors, fmt.Sprintf(
271+ "workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
272+ profileName,
273+ key,
274+ ))
275+ continue
276+ }
277+
278+ if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
279+ validationErrors = append(validationErrors, fmt.Sprintf(
280+ "workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
281+ profileName,
282+ key,
283+ existingProfile,
284+ ))
285+ continue
286+ }
287+
288+ seenProfileKeys[normalizedKey] = profileName
289+ }
290+ }
291+
292+ return validationErrors
293+}
294+
295+func normalizeBindingKeyString(key string) string {
296+ normalized := strings.ToLower(strings.TrimSpace(key))
297+ if normalized == "" {
298+ return ""
299+ }
300+ return strings.Join(strings.Fields(normalized), "")
301+}
302+
227 func validateLogging(config *Config) []string {303 func validateLogging(config *Config) []string {
228 var validationErrors []string304 var validationErrors []string
229 if config.Logging.MaxAge < 0 {305 if config.Logging.MaxAge < 0 {
@@ -256,27 +332,36 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
256 return validationErrors332 return validationErrors
257 }333 }
258 334
259- normalized := domainurl.Normalize(config.Workspace.NewPaneURL)335+ validationErrors = append(validationErrors, validateWorkspaceURLValue(
260- parsed, err := url.Parse(normalized)336+ "workspace.new_pane_url",
337+ config.Workspace.NewPaneURL,
338+ )...)
339+
340+ return validationErrors
341+}
342+
343+func validateWorkspaceURLValue(fieldPath, value string) []string {
344+ trimmed := strings.TrimSpace(value)
345+ candidate := trimmed
346+ parsed, err := url.Parse(candidate)
347+ if err != nil || parsed.Scheme == "" {
348+ candidate = domainurl.Normalize(trimmed)
349+ parsed, err = url.Parse(candidate)
350+ }
261 if err != nil {351 if err != nil {
262- validationErrors = append(validationErrors, fmt.Sprintf(352+ return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
263- "workspace.new_pane_url must be a valid URL (got: %s)",
264- config.Workspace.NewPaneURL,
265- ))
266- return validationErrors
267 }353 }
268 354
269 switch parsed.Scheme {355 switch parsed.Scheme {
270 case "http", "https", "dumb", "file", "about":356 case "http", "https", "dumb", "file", "about":
271- // ok357+ return nil
272 default:358 default:
273- validationErrors = append(validationErrors, fmt.Sprintf(359+ return []string{fmt.Sprintf(
274- "workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)",360+ "%s must use one of: http, https, dumb, file, about (got: %s)",
361+ fieldPath,
275 parsed.Scheme,362 parsed.Scheme,
276- ))363+ )}
277 }364 }
278-
279- return validationErrors
280 }365 }
281 366
282 func validateOmnibox(config *Config) []string {367 func validateOmnibox(config *Config) []string {
M internal/ui/app.go +851 -21
@@ -6,7 +6,9 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "sync"10 "sync"
11+ "unsafe"
10 12
11 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
@@ -44,8 +46,17 @@ const (
44 AppID = "com.github.bnema.dumber"46 AppID = "com.github.bnema.dumber"
45 // crashReportToastDurationMs keeps startup crash-report toast visible longer.47 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
46 crashReportToastDurationMs = 500048 crashReportToastDurationMs = 5000
49+ floatingPaneFallbackWidth = 1200
50+ floatingPaneFallbackHeight = 800
51+ floatingPaneIDPrefix = "floating-pane:"
52+ floatingSessionIDDefault = "default"
53+ floatingPaneVisibleClass = "floating-pane-visible"
47 )54 )
48 55
56+func gtkApplicationFlags() gio.ApplicationFlags {
57+ return gio.GApplicationNonUniqueValue
58+}
59+
49 // App wraps the GTK Application and manages the browser lifecycle.60 // App wraps the GTK Application and manages the browser lifecycle.
50 type App struct {61 type App struct {
51 deps *Dependencies62 deps *Dependencies
@@ -81,9 +92,12 @@ type App struct {
81 resizeModeBorderTarget layout.Widget92 resizeModeBorderTarget layout.Widget
82 93
83 // Omnibox configuration (omnibox is created per workspace view)94 // Omnibox configuration (omnibox is created per workspace view)
84- omniboxCfg component.OmniboxConfig95+ omniboxCfg component.OmniboxConfig
96+ omniboxNavigateFn func(url string)
85 // Find bar configuration (find bar is created per workspace view)97 // Find bar configuration (find bar is created per workspace view)
86 findBarCfg component.FindBarConfig98 findBarCfg component.FindBarConfig
99+ // Floating pane sessions keyed by tab and profile session ID.
100+ floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
87 101
88 // Web content (managed by ContentCoordinator)102 // Web content (managed by ContentCoordinator)
89 pool *webkit.WebViewPool103 pool *webkit.WebViewPool
@@ -130,6 +144,27 @@ type App struct {
130 cancel context.CancelCauseFunc144 cancel context.CancelCauseFunc
131 }145 }
132 146
147+type floatingWorkspaceSession struct {
148+ paneID entity.PaneID
149+ pane *component.FloatingPane
150+ paneView *component.PaneView
151+ webView *webkit.WebView
152+ overlay layout.OverlayWidget
153+ widget layout.Widget
154+ focusWidget layout.Widget
155+ omnibox *component.Omnibox
156+ omniboxWidget layout.Widget
157+ resizeWatcherActive bool
158+ resizeTickID uint
159+ appliedWidth int
160+ appliedHeight int
161+}
162+
163+type floatingSessionKey struct {
164+ tabID entity.TabID
165+ sessionID string
166+}
167+
133 // New creates a new App with the given dependencies.168 // New creates a new App with the given dependencies.
134 func New(deps *Dependencies) (*App, error) {169 func New(deps *Dependencies) (*App, error) {
135 if err := deps.Validate(); err != nil {170 if err := deps.Validate(); err != nil {
@@ -138,17 +173,18 @@ func New(deps *Dependencies) (*App, error) {
138 ctx, cancel := context.WithCancelCause(deps.Ctx)173 ctx, cancel := context.WithCancelCause(deps.Ctx)
139 174
140 app := &App{175 app := &App{
141- deps: deps,176+ deps: deps,
142- tabs: entity.NewTabList(),177+ tabs: entity.NewTabList(),
143- tabsUC: deps.TabsUC,178+ tabsUC: deps.TabsUC,
144- panesUC: deps.PanesUC,179+ panesUC: deps.PanesUC,
145- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),180+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
146- pool: deps.Pool,181+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
147- injector: deps.Injector,182+ pool: deps.Pool,
148- router: deps.MessageRouter,183+ injector: deps.Injector,
149- settings: deps.Settings,184+ router: deps.MessageRouter,
150- configManager: config.GetManager(),185+ settings: deps.Settings,
151- cancel: cancel,186+ configManager: config.GetManager(),
187+ cancel: cancel,
152 }188 }
153 if app.router == nil {189 if app.router == nil {
154 app.router = webkit.NewMessageRouter(ctx)190 app.router = webkit.NewMessageRouter(ctx)
@@ -208,7 +244,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
208 }244 }
209 245
210 appID := AppID246 appID := AppID
211- a.gtkApp = gtk.NewApplication(&appID, gio.GApplicationFlagsNoneValue)247+ a.gtkApp = gtk.NewApplication(&appID, gtkApplicationFlags())
212 if a.gtkApp == nil {248 if a.gtkApp == nil {
213 log.Error().Msg("failed to create GTK application")249 log.Error().Msg("failed to create GTK application")
214 return 1250 return 1
@@ -582,6 +618,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
582 // Create keyboard handler and wire to dispatcher.618 // Create keyboard handler and wire to dispatcher.
583 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)619 a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
584 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {620 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
621+ if action == input.ActionClosePane {
622+ if a.closeAndReleaseActiveFloatingPane(ctx) {
623+ return nil
624+ }
625+ }
585 return a.kbDispatcher.Dispatch(ctx, action)626 return a.kbDispatcher.Dispatch(ctx, action)
586 })627 })
587 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {628 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
@@ -656,7 +697,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
656 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,697 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
657 UIScale: a.deps.Config.DefaultUIScale,698 UIScale: a.deps.Config.DefaultUIScale,
658 OnNavigate: func(url string) {699 OnNavigate: func(url string) {
659- if err := a.navCoord.Navigate(ctx, url); err != nil {700+ if err := a.navigateFromOmnibox(ctx, url); err != nil {
660 log.Error().Err(err).Str("url", url).Msg("navigation failed")701 log.Error().Err(err).Str("url", url).Msg("navigation failed")
661 }702 }
662 },703 },
@@ -967,6 +1008,7 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
967 }1008 }
968 1009
969 func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {1010 func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1011+ a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
970 delete(a.workspaceViews, sourceTabID)1012 delete(a.workspaceViews, sourceTabID)
971 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {1013 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
972 a.mainWindow.TabBar().RemoveTab(sourceTabID)1014 a.mainWindow.TabBar().RemoveTab(sourceTabID)
@@ -989,6 +1031,8 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID,
989 return1031 return
990 }1032 }
991 _ = wsView.Rebuild(ctx)1033 _ = wsView.Rebuild(ctx)
1034+ a.reattachFloatingSessions(tabID, wsView)
1035+ a.syncFloatingFocus()
992 1036
993 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {1037 if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
994 return1038 return
@@ -999,6 +1043,31 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID,
999 }1043 }
1000 }1044 }
1001 1045
1046+func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.WorkspaceView) {
1047+ if wsView == nil {
1048+ return
1049+ }
1050+
1051+ for key, session := range a.floatingSessions {
1052+ if key.tabID != tabID || session == nil || session.pane == nil {
1053+ continue
1054+ }
1055+
1056+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
1057+ if session.widget != nil {
1058+ wsView.AddWorkspaceOverlayWidget(session.widget)
1059+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
1060+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
1061+ }
1062+ if session.paneView != nil {
1063+ wsView.RegisterPaneView(session.paneID, session.paneView)
1064+ if session.focusWidget == nil {
1065+ session.focusWidget = session.paneView.WebViewWidget()
1066+ }
1067+ }
1068+ }
1069+}
1070+
1002 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {1071 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
1003 if out == nil || out.TargetTab == nil {1072 if out == nil || out.TargetTab == nil {
1004 return1073 return
@@ -1309,6 +1378,17 @@ func (a *App) onShutdown(ctx context.Context) {
1309 if a.faviconAdapter != nil {1378 if a.faviconAdapter != nil {
1310 a.faviconAdapter.Close()1379 a.faviconAdapter.Close()
1311 }1380 }
1381+ tabIDSet := make(map[entity.TabID]struct{}, len(a.floatingSessions))
1382+ for key := range a.floatingSessions {
1383+ tabIDSet[key.tabID] = struct{}{}
1384+ }
1385+ tabIDs := make([]entity.TabID, 0, len(tabIDSet))
1386+ for tabID := range tabIDSet {
1387+ tabIDs = append(tabIDs, tabID)
1388+ }
1389+ for _, tabID := range tabIDs {
1390+ a.releaseFloatingSessionsForTab(ctx, tabID)
1391+ }
1312 if a.deps.Pool != nil {1392 if a.deps.Pool != nil {
1313 a.deps.Pool.Close(ctx)1393 a.deps.Pool.Close(ctx)
1314 }1394 }
@@ -1484,6 +1564,7 @@ func (a *App) initCoordinators(ctx context.Context) {
1484 // Wire pane URI updates for session snapshots (searches all tabs)1564 // Wire pane URI updates for session snapshots (searches all tabs)
1485 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {1565 a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
1486 a.updatePaneURIInAllTabs(paneID, url)1566 a.updatePaneURIInAllTabs(paneID, url)
1567+ a.updateFloatingSessionURI(paneID, url)
1487 // Mark dirty so snapshot captures the new URI1568 // Mark dirty so snapshot captures the new URI
1488 a.MarkDirty()1569 a.MarkDirty()
1489 })1570 })
@@ -1522,6 +1603,12 @@ func (a *App) initCoordinators(ctx context.Context) {
1522 a.deps.ZoomUC,1603 a.deps.ZoomUC,
1523 a.deps.CopyURLUC,1604 a.deps.CopyURLUC,
1524 )1605 )
1606+ a.wireKeyboardActions()
1607+
1608+ log.Debug().Msg("coordinators initialized")
1609+}
1610+
1611+func (a *App) wireKeyboardActions() {
1525 a.kbDispatcher.SetOnQuit(a.Quit)1612 a.kbDispatcher.SetOnQuit(a.Quit)
1526 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {1613 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
1527 a.ToggleFindBar(ctx)1614 a.ToggleFindBar(ctx)
@@ -1545,13 +1632,17 @@ func (a *App) initCoordinators(ctx context.Context) {
1545 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {1632 a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1546 return a.HandleMovePaneToNextTab(ctx)1633 return a.HandleMovePaneToNextTab(ctx)
1547 })1634 })
1635+ a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
1636+ return a.ToggleFloatingPane(ctx)
1637+ })
1638+ a.kbDispatcher.SetOnOpenFloatingTarget(func(ctx context.Context, target input.FloatingProfileTarget) error {
1639+ return a.OpenFloatingPaneProfileURL(ctx, target.SessionID, target.URL)
1640+ })
1548 1641
1549 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)1642 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
1550 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {1643 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
1551 return a.kbDispatcher.Dispatch(ctx, action)1644 return a.kbDispatcher.Dispatch(ctx, action)
1552 })1645 })
1553-
1554- log.Debug().Msg("coordinators initialized")
1555 }1646 }
1556 1647
1557 func (a *App) wireWebRTCPermissionIndicator() {1648 func (a *App) wireWebRTCPermissionIndicator() {
@@ -1849,6 +1940,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
1849 log.Error().Msg("failed to create workspace view")1940 log.Error().Msg("failed to create workspace view")
1850 return1941 return
1851 }1942 }
1943+ a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
1852 1944
1853 // Set the workspace1945 // Set the workspace
1854 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {1946 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
@@ -1889,6 +1981,8 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
1889 1981
1890 // Store in map1982 // Store in map
1891 a.workspaceViews[tab.ID] = wsView1983 a.workspaceViews[tab.ID] = wsView
1984+ a.reattachFloatingSessions(tab.ID, wsView)
1985+ a.syncFloatingFocus()
1892 1986
1893 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")1987 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
1894 }1988 }
@@ -1929,12 +2023,11 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
1929 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.2023 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
1930 // Used by the accent picker to insert accented characters into web content.2024 // Used by the accent picker to insert accented characters into web content.
1931 func (a *App) getActiveWebViewTarget() port.TextInputTarget {2025 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
1932- ws := a.activeWorkspace()2026+ if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1933- if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
1934 return nil2027 return nil
1935 }2028 }
1936 2029
1937- wv := a.contentCoord.GetWebView(ws.ActivePaneID)2030+ wv := a.contentCoord.ActiveWebView(context.Background())
1938 if wv == nil {2031 if wv == nil {
1939 return nil2032 return nil
1940 }2033 }
@@ -2006,14 +2099,752 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2006 2099
2007 // Update window title with the new active pane's title2100 // Update window title with the new active pane's title
2008 a.updateWindowTitleFromActivePane()2101 a.updateWindowTitleFromActivePane()
2102+ a.syncFloatingFocus()
2009 2103
2010 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")2104 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
2011 }2105 }
2012 2106
2107+func normalizeFloatingSessionID(sessionID string) string {
2108+ sessionID = strings.TrimSpace(sessionID)
2109+ if sessionID == "" {
2110+ return floatingSessionIDDefault
2111+ }
2112+ return sessionID
2113+}
2114+
2115+func floatingPaneIDForSession(tabID entity.TabID, sessionID string) entity.PaneID {
2116+ return entity.PaneID(floatingPaneIDPrefix + string(tabID) + ":" + normalizeFloatingSessionID(sessionID))
2117+}
2118+
2119+func floatingSessionMapKey(tabID entity.TabID, sessionID string) floatingSessionKey {
2120+ return floatingSessionKey{tabID: tabID, sessionID: normalizeFloatingSessionID(sessionID)}
2121+}
2122+
2123+func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2124+ if overlay == nil {
2125+ return
2126+ }
2127+
2128+ overlay.SetHexpand(false)
2129+ overlay.SetVexpand(false)
2130+ overlay.AddCssClass("floating-pane-container")
2131+ overlay.AddCssClass("pane-border")
2132+ overlay.AddCssClass("pane-active")
2133+}
2134+
2135+// showFloatingWidget makes a floating pane widget interactive and visible.
2136+// Visibility is controlled by CSS class to animate opacity while keeping the
2137+// widget mapped so WebKit can redraw without a hard pop.
2138+func showFloatingWidget(w layout.Widget) {
2139+ if w == nil {
2140+ return
2141+ }
2142+ w.AddCssClass(floatingPaneVisibleClass)
2143+ w.SetCanTarget(true)
2144+ w.SetCanFocus(true)
2145+}
2146+
2147+// hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2148+// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
2149+// widget mapped and WebKit retains its rendered surface in GPU memory.
2150+func hideFloatingWidget(w layout.Widget) {
2151+ if w == nil {
2152+ return
2153+ }
2154+ w.RemoveCssClass(floatingPaneVisibleClass)
2155+ w.SetCanTarget(false)
2156+ w.SetCanFocus(false)
2157+}
2158+
2159+// setFloatingWidgetShown applies show or hide based on the visible flag.
2160+func setFloatingWidgetShown(w layout.Widget, visible bool) {
2161+ if visible {
2162+ showFloatingWidget(w)
2163+ } else {
2164+ hideFloatingWidget(w)
2165+ }
2166+}
2167+
2168+func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, floatingOverlay layout.Widget) {
2169+ if workspaceOverlay == nil || floatingOverlay == nil {
2170+ return
2171+ }
2172+
2173+ workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
2174+ workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2175+}
2176+
2177+func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
2178+ if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
2179+ return 0, 0, 0, 0, false
2180+ }
2181+
2182+ width = desiredWidth
2183+ height = desiredHeight
2184+ if width > overlayWidth {
2185+ width = overlayWidth
2186+ }
2187+ if height > overlayHeight {
2188+ height = overlayHeight
2189+ }
2190+
2191+ x = (overlayWidth - width) / 2
2192+ y = (overlayHeight - height) / 2
2193+ if x < 0 {
2194+ x = 0
2195+ }
2196+ if y < 0 {
2197+ y = 0
2198+ }
2199+
2200+ return x, y, width, height, true
2201+}
2202+
2203+type gtkAllocationLayout [4]int32
2204+
2205+const gtkAllocationLayoutSize = 16
2206+
2207+var (
2208+ _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
2209+ _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
2210+)
2211+
2212+// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
2213+// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
2214+// 32-bit signed integers {x, y, width, height} in native endianness.
2215+// If GTK struct layout changes, revisit offsets and writes below.
2216+//
2217+//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
2218+func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2219+ if allocationPtr == nil {
2220+ return false
2221+ }
2222+
2223+ const (
2224+ gtkAllocationOffsetX = uintptr(0)
2225+ gtkAllocationOffsetY = uintptr(4)
2226+ gtkAllocationOffsetWidth = uintptr(8)
2227+ gtkAllocationOffsetHeight = uintptr(12)
2228+ )
2229+
2230+ // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
2231+ base := unsafe.Pointer(allocationPtr)
2232+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetX)) = int32(x)
2233+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetY)) = int32(y)
2234+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetWidth)) = int32(width)
2235+ *(*int32)(unsafe.Pointer(uintptr(base) + gtkAllocationOffsetHeight)) = int32(height)
2236+ return true
2237+}
2238+
2239+func (a *App) floatingAllocationForWidget(
2240+ tabID entity.TabID,
2241+ widgetPtr uintptr,
2242+ overlayWidth, overlayHeight int,
2243+) (x, y, width, height int, ok bool) {
2244+ for key, session := range a.floatingSessions {
2245+ if key.tabID != tabID || session == nil || session.widget == nil {
2246+ continue
2247+ }
2248+
2249+ gtkWidget := session.widget.GtkWidget()
2250+ if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
2251+ continue
2252+ }
2253+
2254+ desiredWidth := session.appliedWidth
2255+ desiredHeight := session.appliedHeight
2256+ if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
2257+ session.pane.Resize()
2258+ desiredWidth, desiredHeight = session.pane.Dimensions()
2259+ }
2260+
2261+ return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
2262+ }
2263+
2264+ return 0, 0, 0, 0, false
2265+}
2266+
2267+func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
2268+ if workspaceOverlay == nil {
2269+ return
2270+ }
2271+
2272+ workspaceWidget := workspaceOverlay.GtkWidget()
2273+ if workspaceWidget == nil {
2274+ return
2275+ }
2276+
2277+ gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
2278+ if gtkOverlay == nil {
2279+ return
2280+ }
2281+
2282+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2283+ overlayWidth := workspaceOverlay.GetAllocatedWidth()
2284+ overlayHeight := workspaceOverlay.GetAllocatedHeight()
2285+ x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
2286+ if !ok {
2287+ return false
2288+ }
2289+ return writeOverlayAllocation(allocationPtr, x, y, width, height)
2290+ }
2291+
2292+ gtkOverlay.ConnectGetChildPosition(&cb)
2293+}
2294+
2295+func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2296+ if a.deps != nil && a.deps.Config != nil {
2297+ return a.deps.Config.Workspace.FloatingPane
2298+ }
2299+ return config.Get().Workspace.FloatingPane
2300+}
2301+
2302+func (a *App) ensureFloatingSession(
2303+ ctx context.Context,
2304+ tabID entity.TabID,
2305+ sessionID string,
2306+ wsView *component.WorkspaceView,
2307+) (*floatingWorkspaceSession, error) {
2308+ key := floatingSessionMapKey(tabID, sessionID)
2309+ if session, ok := a.floatingSessions[key]; ok && session != nil {
2310+ if wsView == nil {
2311+ return session, nil
2312+ }
2313+ session.pane.SetParentOverlay(wsView.WorkspaceOverlayWidget())
2314+ if session.widget != nil {
2315+ wsView.AddWorkspaceOverlayWidget(session.widget)
2316+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), session.widget)
2317+ }
2318+ if session.paneView != nil {
2319+ wsView.RegisterPaneView(session.paneID, session.paneView)
2320+ if session.focusWidget == nil {
2321+ session.focusWidget = session.paneView.WebViewWidget()
2322+ }
2323+ }
2324+ return session, nil
2325+ }
2326+
2327+ if wsView == nil {
2328+ return nil, fmt.Errorf("workspace view not found for tab %s", tabID)
2329+ }
2330+ if a.contentCoord == nil {
2331+ return nil, fmt.Errorf("content coordinator not initialized")
2332+ }
2333+ if a.widgetFactory == nil {
2334+ return nil, fmt.Errorf("widget factory not initialized")
2335+ }
2336+
2337+ paneID := floatingPaneIDForSession(tabID, sessionID)
2338+ wv, err := a.contentCoord.EnsureWebView(ctx, paneID)
2339+ if err != nil {
2340+ return nil, fmt.Errorf("ensure floating webview: %w", err)
2341+ }
2342+
2343+ webViewWidget := a.contentCoord.WrapWidget(ctx, wv)
2344+ if webViewWidget == nil {
2345+ return nil, fmt.Errorf("wrap floating webview widget")
2346+ }
2347+ webViewWidget.SetHexpand(true)
2348+ webViewWidget.SetVexpand(true)
2349+
2350+ // Wrap the WebView in a PaneView so it gets progress bar, loading
2351+ // skeleton, and link status overlays — the same widget hierarchy as
2352+ // regular workspace panes. This allows the content coordinator's
2353+ // existing callbacks (onLoadStarted, onProgressChanged, etc.) to find
2354+ // and update the floating pane automatically.
2355+ pv := component.NewPaneView(a.widgetFactory, paneID, webViewWidget)
2356+ pv.SetActive(true)
2357+ // Hide loading skeleton — floating panes have their own themed container.
2358+ pv.HideLoadingSkeleton()
2359+ wsView.RegisterPaneView(paneID, pv)
2360+
2361+ // Use the PaneView's overlay directly as the floating overlay.
2362+ // This avoids nesting two overlays which causes GTK4 allocation
2363+ // issues (inner overlay expanding to fill the parent's allocation).
2364+ pvOverlay := pv.Overlay()
2365+ decorateFloatingOverlay(pvOverlay)
2366+ pvOverlay.SetHalign(gtk.AlignCenterValue)
2367+ pvOverlay.SetValign(gtk.AlignCenterValue)
2368+ hideFloatingWidget(pvOverlay)
2369+ wsView.AddWorkspaceOverlayWidget(pvOverlay)
2370+ configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
2371+
2372+ floatingCfg := a.currentFloatingConfig()
2373+ floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2374+ WidthPct: floatingCfg.WidthPct,
2375+ HeightPct: floatingCfg.HeightPct,
2376+ FallbackWidth: floatingPaneFallbackWidth,
2377+ FallbackHeight: floatingPaneFallbackHeight,
2378+ OnNavigate: func(navCtx context.Context, url string) error {
2379+ return wv.LoadURI(navCtx, url)
2380+ },
2381+ })
2382+
2383+ session := &floatingWorkspaceSession{
2384+ paneID: paneID,
2385+ pane: floatingPane,
2386+ paneView: pv,
2387+ webView: wv,
2388+ overlay: pvOverlay,
2389+ widget: pvOverlay,
2390+ focusWidget: webViewWidget,
2391+ }
2392+ a.floatingSessions[key] = session
2393+ return session, nil
2394+}
2395+
2396+func (a *App) floatingSessionsForTab(tabID entity.TabID) map[floatingSessionKey]*floatingWorkspaceSession {
2397+ sessions := make(map[floatingSessionKey]*floatingWorkspaceSession)
2398+ for key, session := range a.floatingSessions {
2399+ if key.tabID != tabID || session == nil {
2400+ continue
2401+ }
2402+ sessions[key] = session
2403+ }
2404+ return sessions
2405+}
2406+
2407+func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.TabID) {
2408+ sessions := a.floatingSessionsForTab(tabID)
2409+ if len(sessions) == 0 {
2410+ return
2411+ }
2412+
2413+ for key, session := range sessions {
2414+ a.releaseFloatingSession(ctx, key, session)
2415+ }
2416+ a.syncFloatingFocus()
2417+}
2418+
2419+func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
2420+ activeTab := a.tabs.ActiveTab()
2421+ if activeTab == nil {
2422+ return floatingSessionKey{}, nil, false
2423+ }
2424+ for key, session := range a.floatingSessions {
2425+ if key.tabID != activeTab.ID || session == nil || session.pane == nil {
2426+ continue
2427+ }
2428+ if session.pane.IsVisible() {
2429+ return key, session, true
2430+ }
2431+ }
2432+ return floatingSessionKey{tabID: activeTab.ID}, nil, false
2433+}
2434+
2435+func (a *App) activeFloatingSession() (*floatingWorkspaceSession, entity.TabID) {
2436+ key, session, ok := a.activeFloatingSessionEntry()
2437+ if !ok {
2438+ return nil, key.tabID
2439+ }
2440+ return session, key.tabID
2441+}
2442+
2443+func (a *App) releaseFloatingSession(ctx context.Context, key floatingSessionKey, session *floatingWorkspaceSession) {
2444+ if session == nil {
2445+ return
2446+ }
2447+
2448+ a.stopFloatingResizeWatcher(session)
2449+ if session.pane != nil {
2450+ session.pane.Hide(ctx)
2451+ }
2452+ a.hideFloatingOmnibox(ctx, session)
2453+ if wsView := a.workspaceViews[key.tabID]; wsView != nil {
2454+ if session.widget != nil {
2455+ wsView.RemoveWorkspaceOverlayWidget(session.widget)
2456+ }
2457+ if session.paneID != "" {
2458+ wsView.UnregisterPaneView(session.paneID)
2459+ }
2460+ }
2461+ if a.contentCoord != nil && session.paneID != "" {
2462+ a.contentCoord.ReleaseWebView(ctx, session.paneID)
2463+ }
2464+ delete(a.floatingSessions, key)
2465+
2466+ session.appliedWidth = 0
2467+ session.appliedHeight = 0
2468+ session.focusWidget = nil
2469+ session.paneView = nil
2470+ session.widget = nil
2471+ session.overlay = nil
2472+ session.webView = nil
2473+ session.pane = nil
2474+ // Session is fully released/zeroed; caller must drop this reference and not reuse it.
2475+}
2476+
2477+func (a *App) updateFloatingSessionURI(paneID entity.PaneID, url string) {
2478+ if paneID == "" {
2479+ return
2480+ }
2481+
2482+ for _, session := range a.floatingSessions {
2483+ if session == nil || session.pane == nil {
2484+ continue
2485+ }
2486+ if session.paneID != paneID {
2487+ continue
2488+ }
2489+ session.pane.RecordLoadedURL(url)
2490+ return
2491+ }
2492+}
2493+
2494+func (a *App) syncFloatingFocus() {
2495+ for _, wsView := range a.workspaceViews {
2496+ if wsView != nil {
2497+ wsView.SetHoverFocusLocked(false)
2498+ }
2499+ }
2500+
2501+ if a.contentCoord == nil {
2502+ return
2503+ }
2504+
2505+ activeTab := a.tabs.ActiveTab()
2506+ if activeTab == nil {
2507+ a.contentCoord.ClearActivePaneOverride()
2508+ return
2509+ }
2510+
2511+ if session, _ := a.activeFloatingSession(); session != nil {
2512+ if wsView := a.workspaceViews[activeTab.ID]; wsView != nil {
2513+ wsView.SetHoverFocusLocked(true)
2514+ }
2515+ a.startFloatingResizeWatcher(session)
2516+ a.contentCoord.SetActivePaneOverride(session.paneID)
2517+ return
2518+ }
2519+
2520+ a.contentCoord.ClearActivePaneOverride()
2521+}
2522+
2523+func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2524+ if session == nil || session.overlay == nil || a.widgetFactory == nil {
2525+ return
2526+ }
2527+
2528+ if session.omnibox == nil {
2529+ cfg := a.omniboxCfg
2530+ cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2531+ if a.appToaster != nil {
2532+ a.appToaster.Show(toastCtx, message, level)
2533+ }
2534+ }
2535+
2536+ omnibox := component.NewOmnibox(ctx, cfg)
2537+ if omnibox == nil {
2538+ return
2539+ }
2540+
2541+ omnibox.SetParentOverlay(session.overlay)
2542+ omniboxWidget := omnibox.WidgetAsLayout(a.widgetFactory)
2543+ if omniboxWidget == nil {
2544+ return
2545+ }
2546+
2547+ session.overlay.AddOverlay(omniboxWidget)
2548+ session.overlay.SetClipOverlay(omniboxWidget, false)
2549+ session.overlay.SetMeasureOverlay(omniboxWidget, false)
2550+
2551+ session.omnibox = omnibox
2552+ session.omniboxWidget = omniboxWidget
2553+ }
2554+
2555+ session.pane.SetOmniboxVisible(true)
2556+ session.omnibox.Show(ctx, "")
2557+}
2558+
2559+func (a *App) hideFloatingOmnibox(ctx context.Context, session *floatingWorkspaceSession) {
2560+ if session == nil || session.pane == nil {
2561+ return
2562+ }
2563+
2564+ session.pane.SetOmniboxVisible(false)
2565+ if session.omnibox == nil {
2566+ return
2567+ }
2568+
2569+ session.omnibox.Hide(ctx)
2570+ if session.overlay != nil && session.omniboxWidget != nil {
2571+ if parent := session.omniboxWidget.GetParent(); parent == session.overlay {
2572+ session.overlay.RemoveOverlay(session.omniboxWidget)
2573+ } else if parent != nil {
2574+ session.omniboxWidget.Unparent()
2575+ }
2576+ }
2577+ session.omnibox = nil
2578+ session.omniboxWidget = nil
2579+}
2580+
2581+func floatingFocusWidget(session *floatingWorkspaceSession) layout.Widget {
2582+ if session == nil {
2583+ return nil
2584+ }
2585+ if session.focusWidget != nil {
2586+ return session.focusWidget
2587+ }
2588+ if session.paneView != nil {
2589+ return session.paneView.WebViewWidget()
2590+ }
2591+ return nil
2592+}
2593+
2594+func focusFloatingSessionContent(session *floatingWorkspaceSession) {
2595+ if session == nil || session.pane == nil {
2596+ return
2597+ }
2598+ if !session.pane.IsVisible() || session.pane.IsOmniboxVisible() {
2599+ return
2600+ }
2601+ w := floatingFocusWidget(session)
2602+ if w == nil {
2603+ return
2604+ }
2605+ w.GrabFocus()
2606+}
2607+
2608+func (a *App) resizeFloatingWidget(session *floatingWorkspaceSession) {
2609+ if session == nil || session.pane == nil {
2610+ return
2611+ }
2612+ session.pane.Resize()
2613+ if session.widget == nil {
2614+ return
2615+ }
2616+ width, height := session.pane.Dimensions()
2617+ if session.appliedWidth == width && session.appliedHeight == height {
2618+ return
2619+ }
2620+ session.widget.SetSizeRequest(width, height)
2621+ session.appliedWidth = width
2622+ session.appliedHeight = height
2623+}
2624+
2625+func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool {
2626+ if session == nil || session.pane == nil {
2627+ return false
2628+ }
2629+ if !session.pane.IsVisible() {
2630+ session.resizeWatcherActive = false
2631+ session.resizeTickID = 0
2632+ return false
2633+ }
2634+
2635+ a.resizeFloatingWidget(session)
2636+ return true
2637+}
2638+
2639+func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
2640+ if paneID == "" {
2641+ return nil
2642+ }
2643+ for _, session := range a.floatingSessions {
2644+ if session == nil || session.paneID != paneID {
2645+ continue
2646+ }
2647+ return session
2648+ }
2649+ return nil
2650+}
2651+
2652+func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2653+ if session == nil || session.overlay == nil || session.resizeWatcherActive {
2654+ return
2655+ }
2656+
2657+ overlayWidget := session.overlay.GtkWidget()
2658+ if overlayWidget == nil {
2659+ return
2660+ }
2661+
2662+ session.resizeWatcherActive = true
2663+ paneID := session.paneID
2664+ tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2665+ liveSession := a.floatingSessionByPaneID(paneID)
2666+ if liveSession == nil {
2667+ session.resizeWatcherActive = false
2668+ session.resizeTickID = 0
2669+ return false
2670+ }
2671+
2672+ keepRunning := a.handleFloatingViewportTick(liveSession)
2673+ if !keepRunning {
2674+ liveSession.resizeWatcherActive = false
2675+ liveSession.resizeTickID = 0
2676+ }
2677+ return keepRunning
2678+ })
2679+ session.resizeTickID = overlayWidget.AddTickCallback(&tickCallback, 0, nil)
2680+}
2681+
2682+func (a *App) stopFloatingResizeWatcher(session *floatingWorkspaceSession) {
2683+ if session == nil {
2684+ return
2685+ }
2686+
2687+ if session.resizeTickID != 0 && session.overlay != nil {
2688+ if overlayWidget := session.overlay.GtkWidget(); overlayWidget != nil {
2689+ overlayWidget.RemoveTickCallback(session.resizeTickID)
2690+ }
2691+ }
2692+ session.resizeTickID = 0
2693+ session.resizeWatcherActive = false
2694+}
2695+
2696+func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
2697+ if session == nil || session.pane == nil {
2698+ return
2699+ }
2700+
2701+ a.stopFloatingResizeWatcher(session)
2702+ session.pane.Hide(ctx)
2703+ a.hideFloatingOmnibox(ctx, session)
2704+ hideFloatingWidget(session.widget)
2705+ // Force a fresh size request on next show so WebKit gets a new
2706+ // allocation cycle and repaints content after rapid toggles.
2707+ session.appliedWidth = 0
2708+ session.appliedHeight = 0
2709+}
2710+
2711+func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
2712+ for key, session := range a.floatingSessions {
2713+ if key.tabID != tabID || session == nil || session == except || session.pane == nil {
2714+ continue
2715+ }
2716+ if !session.pane.IsVisible() {
2717+ continue
2718+ }
2719+ a.hideFloatingSession(ctx, session)
2720+ }
2721+}
2722+
2723+func (a *App) closeActiveFloatingPane(ctx context.Context) bool {
2724+ session, _ := a.activeFloatingSession()
2725+ if session == nil {
2726+ return false
2727+ }
2728+ a.hideFloatingSession(ctx, session)
2729+ a.syncFloatingFocus()
2730+ return true
2731+}
2732+
2733+func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
2734+ key, session, ok := a.activeFloatingSessionEntry()
2735+ if !ok {
2736+ return false
2737+ }
2738+ a.releaseFloatingSession(ctx, key, session)
2739+ a.syncFloatingFocus()
2740+ return true
2741+}
2742+
2743+// ToggleFloatingPane toggles the active workspace floating pane visibility.
2744+func (a *App) ToggleFloatingPane(ctx context.Context) error {
2745+ activeTab := a.tabs.ActiveTab()
2746+ if activeTab == nil {
2747+ return nil
2748+ }
2749+ if a.closeActiveFloatingPane(ctx) {
2750+ return nil
2751+ }
2752+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault)
2753+}
2754+
2755+// OpenFloatingPaneURL opens the active workspace floating pane directly to a URL.
2756+func (a *App) OpenFloatingPaneURL(ctx context.Context, url string) error {
2757+ return a.openFloatingPaneSession(ctx, floatingSessionIDDefault, url)
2758+}
2759+
2760+// OpenFloatingPaneProfileURL opens a named floating pane profile session directly to a URL.
2761+func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url string) error {
2762+ return a.openFloatingPaneSession(ctx, sessionID, url)
2763+}
2764+
2765+func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
2766+ activeTab := a.tabs.ActiveTab()
2767+ if activeTab == nil {
2768+ return nil
2769+ }
2770+
2771+ wsView := a.workspaceViews[activeTab.ID]
2772+ session, err := a.ensureFloatingSession(ctx, activeTab.ID, sessionID, wsView)
2773+ if err != nil {
2774+ return err
2775+ }
2776+ hasURL := len(url) > 0 && strings.TrimSpace(url[0]) != ""
2777+ isProfileSession := normalizeFloatingSessionID(sessionID) != floatingSessionIDDefault
2778+
2779+ if hasURL && isProfileSession && session.pane.IsVisible() {
2780+ a.hideFloatingSession(ctx, session)
2781+ a.syncFloatingFocus()
2782+ return nil
2783+ }
2784+
2785+ a.hideVisibleFloatingSessions(ctx, activeTab.ID, session)
2786+
2787+ if hasURL {
2788+ if isProfileSession && session.pane.SessionStarted() {
2789+ session.pane.Show()
2790+ } else {
2791+ if err := session.pane.ShowURL(ctx, url[0]); err != nil {
2792+ return err
2793+ }
2794+ }
2795+ } else {
2796+ if err := session.pane.ShowToggle(ctx); err != nil {
2797+ return err
2798+ }
2799+ }
2800+
2801+ a.resizeFloatingWidget(session)
2802+ setFloatingWidgetShown(session.widget, session.pane.IsVisible())
2803+ if session.pane.IsOmniboxVisible() {
2804+ a.showFloatingOmnibox(ctx, session)
2805+ } else {
2806+ a.hideFloatingOmnibox(ctx, session)
2807+ focusFloatingSessionContent(session)
2808+ }
2809+ a.startFloatingResizeWatcher(session)
2810+ a.syncFloatingFocus()
2811+
2812+ return nil
2813+}
2814+
2815+func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
2816+ session, _ := a.activeFloatingSession()
2817+ if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
2818+ return session.pane.Navigate(ctx, url)
2819+ }
2820+ if a.omniboxNavigateFn != nil {
2821+ a.omniboxNavigateFn(url)
2822+ return nil
2823+ }
2824+ if a.navCoord == nil {
2825+ return fmt.Errorf("navigation coordinator not initialized")
2826+ }
2827+ return a.navCoord.Navigate(ctx, url)
2828+}
2829+
2013 // ToggleOmnibox implements OmniboxProvider.2830 // ToggleOmnibox implements OmniboxProvider.
2014 // Toggles the omnibox visibility in the active workspace view.2831 // Toggles the omnibox visibility in the active workspace view.
2015 func (a *App) ToggleOmnibox(ctx context.Context) {2832 func (a *App) ToggleOmnibox(ctx context.Context) {
2016 log := logging.FromContext(ctx)2833 log := logging.FromContext(ctx)
2834+ if session, _ := a.activeFloatingSession(); session != nil && session.pane != nil && session.pane.IsVisible() {
2835+ wsView := a.activeWorkspaceView()
2836+ if wsView != nil && wsView.IsOmniboxVisible() {
2837+ wsView.HideOmnibox()
2838+ }
2839+
2840+ if session.omnibox != nil && session.pane.IsOmniboxVisible() {
2841+ a.hideFloatingOmnibox(ctx, session)
2842+ } else {
2843+ a.showFloatingOmnibox(ctx, session)
2844+ }
2845+ a.syncFloatingFocus()
2846+ return
2847+ }
2017 2848
2018 wsView := a.activeWorkspaceView()2849 wsView := a.activeWorkspaceView()
2019 if wsView == nil {2850 if wsView == nil {
@@ -2098,8 +2929,7 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
2098 // This is called to set the navigation callback on new omniboxes.2929 // This is called to set the navigation callback on new omniboxes.
2099 // Since omniboxes are created per-pane, we store the config with the callback.2930 // Since omniboxes are created per-pane, we store the config with the callback.
2100 func (a *App) SetOmniboxOnNavigate(fn func(url string)) {2931 func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
2101- // The navigate callback is set when the omnibox is created via WorkspaceView.ShowOmnibox2932+ a.omniboxNavigateFn = fn
2102- // The WorkspaceView uses the stored omniboxCfg and sets up navigation via the omnibox's SetOnNavigate
2103 }2933 }
2104 2934
2105 // initFilteringAsync starts background filter loading with toast feedback.2935 // initFilteringAsync starts background filter loading with toast feedback.
A internal/ui/app_flags_test.go +15 -0
@@ -0,0 +1,15 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gio"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestGTKApplicationFlags_AreNonUnique(t *testing.T) {
11+ flags := gtkApplicationFlags()
12+
13+ assert.Equal(t, gio.GApplicationNonUniqueValue, flags)
14+ assert.NotEqual(t, gio.GApplicationFlagsNoneValue, flags)
15+}
A internal/ui/component/floating_pane.go +266 -0
@@ -0,0 +1,266 @@
1+package component
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/ui/layout"
9+)
10+
11+const defaultFloatingPaneURL = "about:blank"
12+
13+// FloatingPaneOptions controls floating pane behavior.
14+type FloatingPaneOptions struct {
15+ WidthPct float64
16+ HeightPct float64
17+ FallbackWidth int
18+ FallbackHeight int
19+ OnNavigate func(ctx context.Context, url string) error
20+}
21+
22+// FloatingPane tracks persistent floating workspace pane state.
23+type FloatingPane struct {
24+ parent layout.OverlayWidget
25+ widthPct float64
26+ heightPct float64
27+ fallbackWidth int
28+ fallbackHeight int
29+ onNavigate func(ctx context.Context, url string) error
30+
31+ mu sync.RWMutex
32+ visible bool
33+ omniboxVisible bool
34+ sessionStarted bool
35+ currentURL string
36+ stateVersion uint64
37+ calculatedWidth int
38+ calculatedHeight int
39+}
40+
41+// NewFloatingPane creates a floating pane state container.
42+func NewFloatingPane(parent layout.OverlayWidget, opts FloatingPaneOptions) *FloatingPane {
43+ if opts.WidthPct <= 0 || opts.WidthPct > 1 {
44+ opts.WidthPct = 0.82
45+ }
46+ if opts.HeightPct <= 0 || opts.HeightPct > 1 {
47+ opts.HeightPct = 0.72
48+ }
49+ if opts.FallbackWidth <= 0 {
50+ opts.FallbackWidth = 1200
51+ }
52+ if opts.FallbackHeight <= 0 {
53+ opts.FallbackHeight = 800
54+ }
55+
56+ fp := &FloatingPane{
57+ parent: parent,
58+ widthPct: opts.WidthPct,
59+ heightPct: opts.HeightPct,
60+ fallbackWidth: opts.FallbackWidth,
61+ fallbackHeight: opts.FallbackHeight,
62+ onNavigate: opts.OnNavigate,
63+ }
64+ fp.Resize()
65+ return fp
66+}
67+
68+// ShowToggle toggles pane visibility; first open initializes a blank session.
69+func (fp *FloatingPane) ShowToggle(ctx context.Context) error {
70+ fp.mu.Lock()
71+ previousVisible := fp.visible
72+ previousOmniboxVisible := fp.omniboxVisible
73+ if fp.visible {
74+ fp.visible = false
75+ fp.omniboxVisible = false
76+ fp.stateVersion++
77+ fp.mu.Unlock()
78+ return nil
79+ }
80+
81+ navigateToBlank := !fp.sessionStarted
82+ showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL
83+ fp.visible = true
84+ fp.omniboxVisible = showOmnibox
85+ fp.stateVersion++
86+ rollbackVersion := fp.stateVersion
87+ // Release lock before Navigate: callbacks may block and must not run while
88+ // holding fp.mu. On error, rollback below restores visibility state.
89+ fp.mu.Unlock()
90+
91+ if navigateToBlank {
92+ if err := fp.Navigate(ctx, defaultFloatingPaneURL); err != nil {
93+ fp.mu.Lock()
94+ if fp.stateVersion == rollbackVersion {
95+ fp.visible = previousVisible
96+ fp.omniboxVisible = previousOmniboxVisible
97+ fp.stateVersion++
98+ }
99+ fp.mu.Unlock()
100+ return err
101+ }
102+ }
103+
104+ return nil
105+}
106+
107+// Show makes the pane visible without navigation.
108+// Use for profile sessions that already have a loaded URL.
109+func (fp *FloatingPane) Show() {
110+ fp.mu.Lock()
111+ defer fp.mu.Unlock()
112+ fp.visible = true
113+ fp.omniboxVisible = false
114+ fp.stateVersion++
115+}
116+
117+// SessionStarted reports whether the floating pane has navigated at least once.
118+func (fp *FloatingPane) SessionStarted() bool {
119+ fp.mu.RLock()
120+ defer fp.mu.RUnlock()
121+ return fp.sessionStarted
122+}
123+
124+// ShowURL shows the pane and navigates to a target URL.
125+func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error {
126+ url = strings.TrimSpace(url)
127+ if url == "" {
128+ fp.mu.Lock()
129+ fp.visible = true
130+ fp.omniboxVisible = true
131+ fp.stateVersion++
132+ fp.mu.Unlock()
133+ return nil
134+ }
135+
136+ fp.mu.Lock()
137+ previousVisible := fp.visible
138+ previousOmniboxVisible := fp.omniboxVisible
139+ fp.visible = true
140+ fp.omniboxVisible = false
141+ shouldNavigate := !fp.sessionStarted || fp.currentURL != url
142+ fp.stateVersion++
143+ rollbackVersion := fp.stateVersion
144+ fp.mu.Unlock()
145+
146+ if shouldNavigate {
147+ if err := fp.Navigate(ctx, url); err != nil {
148+ fp.mu.Lock()
149+ if fp.stateVersion == rollbackVersion {
150+ fp.visible = previousVisible
151+ fp.omniboxVisible = previousOmniboxVisible
152+ fp.stateVersion++
153+ }
154+ fp.mu.Unlock()
155+ return err
156+ }
157+ }
158+
159+ return nil
160+}
161+
162+// Hide hides the pane while preserving web session state.
163+func (fp *FloatingPane) Hide(_ context.Context) {
164+ fp.mu.Lock()
165+ defer fp.mu.Unlock()
166+ fp.visible = false
167+ fp.omniboxVisible = false
168+ fp.stateVersion++
169+}
170+
171+// Navigate loads a URL into the floating pane's persistent session.
172+func (fp *FloatingPane) Navigate(ctx context.Context, url string) error {
173+ url = strings.TrimSpace(url)
174+ if url == "" {
175+ return nil
176+ }
177+
178+ fp.mu.RLock()
179+ onNavigate := fp.onNavigate
180+ fp.mu.RUnlock()
181+
182+ if onNavigate != nil {
183+ if err := onNavigate(ctx, url); err != nil {
184+ return err
185+ }
186+ }
187+
188+ fp.mu.Lock()
189+ fp.currentURL = url
190+ fp.sessionStarted = true
191+ fp.stateVersion++
192+ fp.mu.Unlock()
193+
194+ return nil
195+}
196+
197+// Resize recalculates pane dimensions from parent overlay allocation.
198+func (fp *FloatingPane) Resize() {
199+ fp.mu.Lock()
200+ defer fp.mu.Unlock()
201+ fp.calculatedWidth, fp.calculatedHeight = CalculateOverlayDimensions(
202+ fp.parent,
203+ fp.widthPct,
204+ fp.heightPct,
205+ fp.fallbackWidth,
206+ fp.fallbackHeight,
207+ )
208+}
209+
210+// SetParentOverlay updates the workspace overlay used for sizing calculations.
211+func (fp *FloatingPane) SetParentOverlay(parent layout.OverlayWidget) {
212+ fp.mu.Lock()
213+ fp.parent = parent
214+ fp.mu.Unlock()
215+ fp.Resize()
216+}
217+
218+// Dimensions returns the last calculated pane dimensions.
219+func (fp *FloatingPane) Dimensions() (width, height int) {
220+ fp.mu.RLock()
221+ defer fp.mu.RUnlock()
222+ return fp.calculatedWidth, fp.calculatedHeight
223+}
224+
225+// CurrentURL returns the current URL loaded in the floating session.
226+func (fp *FloatingPane) CurrentURL() string {
227+ fp.mu.RLock()
228+ defer fp.mu.RUnlock()
229+ return fp.currentURL
230+}
231+
232+// IsVisible reports whether the floating pane is currently visible.
233+func (fp *FloatingPane) IsVisible() bool {
234+ fp.mu.RLock()
235+ defer fp.mu.RUnlock()
236+ return fp.visible
237+}
238+
239+// IsOmniboxVisible reports whether the floating pane omnibox is visible.
240+func (fp *FloatingPane) IsOmniboxVisible() bool {
241+ fp.mu.RLock()
242+ defer fp.mu.RUnlock()
243+ return fp.omniboxVisible
244+}
245+
246+// SetOmniboxVisible updates omnibox visibility state for the floating pane.
247+func (fp *FloatingPane) SetOmniboxVisible(visible bool) {
248+ fp.mu.Lock()
249+ fp.omniboxVisible = visible
250+ fp.stateVersion++
251+ fp.mu.Unlock()
252+}
253+
254+// RecordLoadedURL updates floating pane URL state from external navigation events.
255+func (fp *FloatingPane) RecordLoadedURL(url string) {
256+ url = strings.TrimSpace(url)
257+ if url == "" {
258+ return
259+ }
260+
261+ fp.mu.Lock()
262+ fp.currentURL = url
263+ fp.sessionStarted = true
264+ fp.stateVersion++
265+ fp.mu.Unlock()
266+}
A internal/ui/component/floating_pane_test.go +204 -0
@@ -0,0 +1,204 @@
1+package component_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/ui/component"
10+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestFloatingPane_HideShowPreservesState(t *testing.T) {
16+ overlay := layoutmocks.NewMockOverlayWidget(t)
17+ overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
18+ overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
19+
20+ navigated := make([]string, 0)
21+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
22+ WidthPct: 0.82,
23+ HeightPct: 0.72,
24+ FallbackWidth: 1200,
25+ FallbackHeight: 800,
26+ OnNavigate: func(_ context.Context, url string) error {
27+ navigated = append(navigated, url)
28+ return nil
29+ },
30+ })
31+
32+ require.NoError(t, fp.ShowToggle(context.Background()))
33+ require.Equal(t, "about:blank", fp.CurrentURL())
34+
35+ require.NoError(t, fp.Navigate(context.Background(), "https://google.com"))
36+ fp.Hide(context.Background())
37+ require.NoError(t, fp.ShowToggle(context.Background()))
38+
39+ assert.Equal(t, "https://google.com", fp.CurrentURL())
40+ assert.False(t, fp.IsOmniboxVisible())
41+ assert.Equal(t, []string{"about:blank", "https://google.com"}, navigated)
42+}
43+
44+func TestFloatingPane_ShowURLLoadsTarget(t *testing.T) {
45+ overlay := layoutmocks.NewMockOverlayWidget(t)
46+ overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
47+ overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
48+
49+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
50+ WidthPct: 0.82,
51+ HeightPct: 0.72,
52+ FallbackWidth: 1000,
53+ FallbackHeight: 700,
54+ })
55+
56+ require.NoError(t, fp.ShowURL(context.Background(), "https://github.com"))
57+
58+ assert.True(t, fp.IsVisible())
59+ assert.False(t, fp.IsOmniboxVisible())
60+ assert.Equal(t, "https://github.com", fp.CurrentURL())
61+}
62+
63+func TestFloatingPane_ResizeUsesWorkspacePercentages(t *testing.T) {
64+ overlay := layoutmocks.NewMockOverlayWidget(t)
65+ overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
66+ overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
67+
68+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
69+ WidthPct: 0.82,
70+ HeightPct: 0.72,
71+ FallbackWidth: 1000,
72+ FallbackHeight: 700,
73+ })
74+
75+ fp.Resize()
76+ width, height := fp.Dimensions()
77+
78+ assert.Equal(t, 820, width)
79+ assert.Equal(t, 504, height)
80+}
81+
82+func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
83+ overlay := layoutmocks.NewMockOverlayWidget(t)
84+ overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
85+ overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
86+
87+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
88+ WidthPct: 0.82,
89+ HeightPct: 0.72,
90+ FallbackWidth: 1200,
91+ FallbackHeight: 800,
92+ })
93+
94+ require.NoError(t, fp.ShowToggle(context.Background()))
95+
96+ assert.True(t, fp.IsVisible())
97+ assert.True(t, fp.IsOmniboxVisible())
98+ assert.Equal(t, "about:blank", fp.CurrentURL())
99+}
100+
101+func TestFloatingPane_SetOmniboxVisible(t *testing.T) {
102+ fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{
103+ WidthPct: 0.82,
104+ HeightPct: 0.72,
105+ FallbackWidth: 1200,
106+ FallbackHeight: 800,
107+ })
108+
109+ fp.SetOmniboxVisible(true)
110+ assert.True(t, fp.IsOmniboxVisible())
111+
112+ fp.SetOmniboxVisible(false)
113+ assert.False(t, fp.IsOmniboxVisible())
114+}
115+
116+func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) {
117+ overlayA := layoutmocks.NewMockOverlayWidget(t)
118+ overlayA.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
119+ overlayA.EXPECT().GetAllocatedHeight().Return(800).Maybe()
120+
121+ overlayB := layoutmocks.NewMockOverlayWidget(t)
122+ overlayB.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
123+ overlayB.EXPECT().GetAllocatedHeight().Return(700).Maybe()
124+
125+ fp := component.NewFloatingPane(overlayA, component.FloatingPaneOptions{
126+ WidthPct: 0.82,
127+ HeightPct: 0.72,
128+ FallbackWidth: 1200,
129+ FallbackHeight: 800,
130+ })
131+
132+ var wg sync.WaitGroup
133+ for i := 0; i < 200; i++ {
134+ wg.Add(2)
135+ go func() {
136+ defer wg.Done()
137+ fp.Resize()
138+ }()
139+ go func(iter int) {
140+ defer wg.Done()
141+ if iter%2 == 0 {
142+ fp.SetParentOverlay(overlayA)
143+ return
144+ }
145+ fp.SetParentOverlay(overlayB)
146+ }(i)
147+ }
148+ wg.Wait()
149+
150+ width, height := fp.Dimensions()
151+ assert.Positive(t, width)
152+ assert.Positive(t, height)
153+}
154+
155+func TestFloatingPane_ShowURL_RollsBackVisibilityOnNavigateError(t *testing.T) {
156+ expectedErr := errors.New("navigate failed")
157+ fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{
158+ WidthPct: 0.82,
159+ HeightPct: 0.72,
160+ FallbackWidth: 1200,
161+ FallbackHeight: 800,
162+ OnNavigate: func(context.Context, string) error {
163+ return expectedErr
164+ },
165+ })
166+
167+ err := fp.ShowURL(context.Background(), "https://example.com")
168+ require.ErrorIs(t, err, expectedErr)
169+ assert.False(t, fp.IsVisible())
170+ assert.False(t, fp.IsOmniboxVisible())
171+ assert.Empty(t, fp.CurrentURL())
172+}
173+
174+func TestFloatingPane_ShowToggleNavigateError_DoesNotClobberConcurrentStateChange(t *testing.T) {
175+ navigationStarted := make(chan struct{})
176+ releaseNavigation := make(chan struct{})
177+ expectedErr := errors.New("navigate failed")
178+
179+ fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{
180+ WidthPct: 0.82,
181+ HeightPct: 0.72,
182+ FallbackWidth: 1200,
183+ FallbackHeight: 800,
184+ OnNavigate: func(context.Context, string) error {
185+ close(navigationStarted)
186+ <-releaseNavigation
187+ return expectedErr
188+ },
189+ })
190+
191+ errCh := make(chan error, 1)
192+ go func() {
193+ errCh <- fp.ShowToggle(context.Background())
194+ }()
195+
196+ <-navigationStarted
197+ fp.Show()
198+ close(releaseNavigation)
199+
200+ err := <-errCh
201+ require.ErrorIs(t, err, expectedErr)
202+ assert.True(t, fp.IsVisible())
203+ assert.False(t, fp.IsOmniboxVisible())
204+}
M internal/ui/component/modal_sizing.go +30 -0
@@ -138,6 +138,36 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
138 return width, marginTop138 return width, marginTop
139 }139 }
140 140
141+// CalculateOverlayDimensions computes width and height from overlay allocation percentages.
142+func CalculateOverlayDimensions(
143+ parent layout.OverlayWidget,
144+ widthPct, heightPct float64,
145+ fallbackWidth, fallbackHeight int,
146+) (width, height int) {
147+ parentWidth := fallbackWidth
148+ parentHeight := fallbackHeight
149+
150+ if parent != nil {
151+ if allocatedWidth := parent.GetAllocatedWidth(); allocatedWidth >= 100 {
152+ parentWidth = allocatedWidth
153+ }
154+ if allocatedHeight := parent.GetAllocatedHeight(); allocatedHeight >= 100 {
155+ parentHeight = allocatedHeight
156+ }
157+ }
158+
159+ if widthPct <= 0 || widthPct > 1 {
160+ widthPct = 1.0
161+ }
162+ if heightPct <= 0 || heightPct > 1 {
163+ heightPct = 1.0
164+ }
165+
166+ width = int(float64(parentWidth) * widthPct)
167+ height = int(float64(parentHeight) * heightPct)
168+ return width, height
169+}
170+
141 // SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.171 // SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.
142 // Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.172 // Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.
143 func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {173 func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {
M internal/ui/component/workspace_view.go +71 -0
@@ -56,6 +56,7 @@ type WorkspaceView struct {
56 // Hover suppression for keyboard navigation (Issue #89)56 // Hover suppression for keyboard navigation (Issue #89)
57 // Prevents hover focus from overriding keyboard-initiated focus changes57 // Prevents hover focus from overriding keyboard-initiated focus changes
58 hoverSuppressedUntil time.Time58 hoverSuppressedUntil time.Time
59+ hoverFocusLocked atomic.Bool
59 60
60 // newPaneFocusLock suppresses hover focus after a new pane is created.61 // newPaneFocusLock suppresses hover focus after a new pane is created.
61 // Rule: new pane creation always wins focus. Hover focus is blocked62 // Rule: new pane creation always wins focus. Hover focus is blocked
@@ -116,6 +117,10 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
116 return117 return
117 }118 }
118 119
120+ if a.wv.IsHoverFocusLocked() {
121+ return
122+ }
123+
119 // Skip if new pane was just created — focus stays on new pane124 // Skip if new pane was just created — focus stays on new pane
120 // until the user intentionally moves the mouse (see SetOnMouseMotion).125 // until the user intentionally moves the mouse (see SetOnMouseMotion).
121 if a.wv.newPaneFocusLock.Load() {126 if a.wv.newPaneFocusLock.Load() {
@@ -529,6 +534,62 @@ func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) {
529 }534 }
530 }535 }
531 536
537+// WorkspaceOverlayWidget returns the workspace-level overlay.
538+func (wv *WorkspaceView) WorkspaceOverlayWidget() layout.OverlayWidget {
539+ wv.mu.RLock()
540+ defer wv.mu.RUnlock()
541+ return wv.overlay
542+}
543+
544+// AddWorkspaceOverlayWidget adds a widget to the workspace-level overlay safely.
545+func (wv *WorkspaceView) AddWorkspaceOverlayWidget(widget layout.Widget) {
546+ if widget == nil {
547+ return
548+ }
549+
550+ wv.mu.Lock()
551+ defer wv.mu.Unlock()
552+ if wv.overlay == nil {
553+ return
554+ }
555+
556+ if parent := widget.GetParent(); parent != nil {
557+ if parent == wv.overlay {
558+ return
559+ }
560+
561+ parentGTK := parent.GtkWidget()
562+ overlayGTK := wv.overlay.GtkWidget()
563+ if parentGTK != nil && overlayGTK != nil && parentGTK.GoPointer() == overlayGTK.GoPointer() {
564+ return
565+ }
566+ widget.Unparent()
567+ }
568+
569+ wv.overlay.AddOverlay(widget)
570+ wv.overlay.SetClipOverlay(widget, false)
571+ wv.overlay.SetMeasureOverlay(widget, false)
572+}
573+
574+// RemoveWorkspaceOverlayWidget removes a widget from the workspace-level overlay safely.
575+func (wv *WorkspaceView) RemoveWorkspaceOverlayWidget(widget layout.Widget) {
576+ if widget == nil {
577+ return
578+ }
579+
580+ wv.mu.Lock()
581+ defer wv.mu.Unlock()
582+ if wv.overlay == nil {
583+ return
584+ }
585+
586+ if parent := widget.GetParent(); parent == wv.overlay {
587+ wv.overlay.RemoveOverlay(widget)
588+ } else if parent != nil {
589+ widget.Unparent()
590+ }
591+}
592+
532 // GetPaneWidget returns the widget for a pane ID.593 // GetPaneWidget returns the widget for a pane ID.
533 // Implements focus.PaneGeometryProvider.594 // Implements focus.PaneGeometryProvider.
534 func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {595 func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget {
@@ -860,6 +921,16 @@ func (wv *WorkspaceView) IsHoverSuppressed() bool {
860 return time.Now().Before(wv.hoverSuppressedUntil)921 return time.Now().Before(wv.hoverSuppressedUntil)
861 }922 }
862 923
924+// SetHoverFocusLocked enables or disables hover-to-focus behavior.
925+func (wv *WorkspaceView) SetHoverFocusLocked(locked bool) {
926+ wv.hoverFocusLocked.Store(locked)
927+}
928+
929+// IsHoverFocusLocked returns whether hover-to-focus is currently locked.
930+func (wv *WorkspaceView) IsHoverFocusLocked() bool {
931+ return wv.hoverFocusLocked.Load()
932+}
933+
863 // CancelAllPendingHovers cancels any pending hover timers on all pane views.934 // CancelAllPendingHovers cancels any pending hover timers on all pane views.
864 // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.935 // Called when keyboard navigation occurs to ensure hover doesn't override keyboard focus.
865 func (wv *WorkspaceView) CancelAllPendingHovers() {936 func (wv *WorkspaceView) CancelAllPendingHovers() {
M internal/ui/component/workspace_view_test.go +98 -0
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/jwijenbergh/puregotk/v4/gtk"
7 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/mock"9 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -258,6 +259,103 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
258 assert.NotNil(t, wv.GetPaneView(pane.ID))259 assert.NotNil(t, wv.GetPaneView(pane.ID))
259 }260 }
260 261
262+func TestWorkspaceView_HoverFocusLockState(t *testing.T) {
263+ // Intentional zero-value check: hoverFocusLock is an atomic.Bool and should
264+ // be safe with its language-level zero value before constructor wiring.
265+ var wv component.WorkspaceView
266+
267+ assert.False(t, wv.IsHoverFocusLocked())
268+ wv.SetHoverFocusLocked(true)
269+ assert.True(t, wv.IsHoverFocusLocked())
270+
271+ wv.SetHoverFocusLocked(false)
272+ assert.False(t, wv.IsHoverFocusLocked())
273+}
274+
275+func TestAddWorkspaceOverlayWidget_DoesNotReaddWhenAlreadyAttached(t *testing.T) {
276+ mockFactory := mocks.NewMockWidgetFactory(t)
277+ ctx := context.Background()
278+
279+ mockContainer := mocks.NewMockBoxWidget(t)
280+ mockOverlay := mocks.NewMockOverlayWidget(t)
281+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
282+ mockContainer.EXPECT().SetHexpand(true).Once()
283+ mockContainer.EXPECT().SetVexpand(true).Once()
284+ mockContainer.EXPECT().SetVisible(true).Once()
285+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
286+ mockOverlay.EXPECT().SetHexpand(true).Once()
287+ mockOverlay.EXPECT().SetVexpand(true).Once()
288+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
289+ mockOverlay.EXPECT().SetVisible(true).Once()
290+
291+ wv := component.NewWorkspaceView(ctx, mockFactory)
292+
293+ widget := mocks.NewMockWidget(t)
294+ widget.EXPECT().GetParent().Return(mockOverlay).Once()
295+
296+ wv.AddWorkspaceOverlayWidget(widget)
297+}
298+
299+func TestAddWorkspaceOverlayWidget_UnparentsBeforeAdding(t *testing.T) {
300+ mockFactory := mocks.NewMockWidgetFactory(t)
301+ ctx := context.Background()
302+
303+ mockContainer := mocks.NewMockBoxWidget(t)
304+ mockOverlay := mocks.NewMockOverlayWidget(t)
305+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
306+ mockContainer.EXPECT().SetHexpand(true).Once()
307+ mockContainer.EXPECT().SetVexpand(true).Once()
308+ mockContainer.EXPECT().SetVisible(true).Once()
309+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
310+ mockOverlay.EXPECT().SetHexpand(true).Once()
311+ mockOverlay.EXPECT().SetVexpand(true).Once()
312+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
313+ mockOverlay.EXPECT().SetVisible(true).Once()
314+
315+ wv := component.NewWorkspaceView(ctx, mockFactory)
316+
317+ widget := mocks.NewMockWidget(t)
318+ otherParent := mocks.NewMockWidget(t)
319+ widget.EXPECT().GetParent().Return(otherParent).Once()
320+ otherParent.EXPECT().GtkWidget().Return(nil).Once()
321+ mockOverlay.EXPECT().GtkWidget().Return(nil).Once()
322+ widget.EXPECT().Unparent().Once()
323+ mockOverlay.EXPECT().AddOverlay(widget).Once()
324+ mockOverlay.EXPECT().SetClipOverlay(widget, false).Once()
325+ mockOverlay.EXPECT().SetMeasureOverlay(widget, false).Once()
326+
327+ wv.AddWorkspaceOverlayWidget(widget)
328+}
329+
330+func TestAddWorkspaceOverlayWidget_DoesNotReaddWhenParentGTKMatchesOverlay(t *testing.T) {
331+ mockFactory := mocks.NewMockWidgetFactory(t)
332+ ctx := context.Background()
333+
334+ mockContainer := mocks.NewMockBoxWidget(t)
335+ mockOverlay := mocks.NewMockOverlayWidget(t)
336+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
337+ mockContainer.EXPECT().SetHexpand(true).Once()
338+ mockContainer.EXPECT().SetVexpand(true).Once()
339+ mockContainer.EXPECT().SetVisible(true).Once()
340+ mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
341+ mockOverlay.EXPECT().SetHexpand(true).Once()
342+ mockOverlay.EXPECT().SetVexpand(true).Once()
343+ mockOverlay.EXPECT().SetChild(mockContainer).Once()
344+ mockOverlay.EXPECT().SetVisible(true).Once()
345+
346+ wv := component.NewWorkspaceView(ctx, mockFactory)
347+
348+ widget := mocks.NewMockWidget(t)
349+ otherParent := mocks.NewMockWidget(t)
350+ gtkWidget := &gtk.Widget{}
351+
352+ widget.EXPECT().GetParent().Return(otherParent).Once()
353+ otherParent.EXPECT().GtkWidget().Return(gtkWidget).Once()
354+ mockOverlay.EXPECT().GtkWidget().Return(gtkWidget).Once()
355+
356+ wv.AddWorkspaceOverlayWidget(widget)
357+}
358+
261 func TestSetActivePaneID_UpdatesStyling(t *testing.T) {359 func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
262 // Arrange360 // Arrange
263 mockFactory := mocks.NewMockWidgetFactory(t)361 mockFactory := mocks.NewMockWidgetFactory(t)
M internal/ui/coordinator/content.go +90 -10
@@ -95,6 +95,10 @@ type ContentCoordinator struct {
95 95
96 webViews map[entity.PaneID]*webkit.WebView96 webViews map[entity.PaneID]*webkit.WebView
97 webViewsMu sync.RWMutex97 webViewsMu sync.RWMutex
98+
99+ activePaneOverride entity.PaneID
100+ activePaneOverrideMu sync.RWMutex
101+
98 paneTitles map[entity.PaneID]string102 paneTitles map[entity.PaneID]string
99 titleMu sync.RWMutex103 titleMu sync.RWMutex
100 104
@@ -440,6 +444,18 @@ func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView)
440 func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {444 func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
441 log := logging.FromContext(ctx)445 log := logging.FromContext(ctx)
442 446
447+ if paneID, ok := c.activePaneOverrideID(); ok {
448+ wv := c.getWebViewLocked(paneID)
449+ if wv != nil {
450+ return wv
451+ }
452+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
453+ }
454+ if c.getActiveWS == nil {
455+ log.Debug().Msg("no active workspace resolver")
456+ return nil
457+ }
458+
443 ws, _ := c.getActiveWS()459 ws, _ := c.getActiveWS()
444 if ws == nil {460 if ws == nil {
445 log.Debug().Msg("no active workspace")461 log.Debug().Msg("no active workspace")
@@ -455,6 +471,45 @@ func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView
455 return c.getWebViewLocked(pane.Pane.ID)471 return c.getWebViewLocked(pane.Pane.ID)
456 }472 }
457 473
474+// ActivePaneID returns the currently active pane ID used by navigation.
475+func (c *ContentCoordinator) ActivePaneID(ctx context.Context) entity.PaneID {
476+ if paneID, ok := c.activePaneOverrideID(); ok {
477+ return paneID
478+ }
479+ if c.getActiveWS == nil {
480+ return ""
481+ }
482+
483+ ws, _ := c.getActiveWS()
484+ if ws == nil {
485+ return ""
486+ }
487+ return ws.ActivePaneID
488+}
489+
490+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
491+func (c *ContentCoordinator) SetActivePaneOverride(paneID entity.PaneID) {
492+ c.activePaneOverrideMu.Lock()
493+ c.activePaneOverride = paneID
494+ c.activePaneOverrideMu.Unlock()
495+}
496+
497+// ClearActivePaneOverride removes any forced active pane override.
498+func (c *ContentCoordinator) ClearActivePaneOverride() {
499+ c.activePaneOverrideMu.Lock()
500+ c.activePaneOverride = ""
501+ c.activePaneOverrideMu.Unlock()
502+}
503+
504+func (c *ContentCoordinator) activePaneOverrideID() (entity.PaneID, bool) {
505+ c.activePaneOverrideMu.RLock()
506+ defer c.activePaneOverrideMu.RUnlock()
507+ if c.activePaneOverride == "" {
508+ return "", false
509+ }
510+ return c.activePaneOverride, true
511+}
512+
458 // GetWebView returns the WebView for a specific pane.513 // GetWebView returns the WebView for a specific pane.
459 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {514 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
460 return c.getWebViewLocked(paneID)515 return c.getWebViewLocked(paneID)
@@ -1008,22 +1063,47 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
1008 if c.faviconAdapter == nil || uri == "" {1063 if c.faviconAdapter == nil || uri == "" {
1009 return1064 return
1010 }1065 }
1066+ select {
1067+ case <-ctx.Done():
1068+ return
1069+ default:
1070+ }
1011 1071
1012 // Check memory and disk cache (no external fetch)1072 // Check memory and disk cache (no external fetch)
1013 texture := c.faviconAdapter.PreloadFromCache(uri)1073 texture := c.faviconAdapter.PreloadFromCache(uri)
1074+ select {
1075+ case <-ctx.Done():
1076+ return
1077+ default:
1078+ }
1014 1079
1015- // Update stacked pane favicon if applicable.1080+ // Update stacked pane favicon on GTK main loop.
1016- // A nil texture triggers the default icon fallback, which avoids stale favicons.1081+ cb := glib.SourceFunc(func(_ uintptr) bool {
1017- _, wsView := c.getActiveWS()1082+ select {
1018- if wsView != nil {1083+ case <-ctx.Done():
1084+ return false
1085+ default:
1086+ }
1087+ if c.getActiveWS == nil {
1088+ return false
1089+ }
1090+ // A nil texture triggers the default icon fallback, which avoids stale favicons.
1091+ _, wsView := c.getActiveWS()
1092+ if wsView == nil {
1093+ return false
1094+ }
1019 tr := wsView.TreeRenderer()1095 tr := wsView.TreeRenderer()
1020- if tr != nil {1096+ if tr == nil {
1021- stackedView := tr.GetStackedViewForPane(string(paneID))1097+ return false
1022- if stackedView != nil {
1023- c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1024- }
1025 }1098 }
1026- }1099+ stackedView := tr.GetStackedViewForPane(string(paneID))
1100+ if stackedView == nil {
1101+ return false
1102+ }
1103+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1104+ return false
1105+ })
1106+ glib.IdleAdd(&cb, 0)
1027 }1107 }
1028 1108
1029 // onLoadCommitted re-applies zoom when page content starts loading and records history.1109 // onLoadCommitted re-applies zoom when page content starts loading and records history.
A internal/ui/coordinator/content_active_override_test.go +73 -0
@@ -0,0 +1,73 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
15+ t.Parallel()
16+
17+ mainWV := &webkit.WebView{}
18+ floatingWV := &webkit.WebView{}
19+
20+ c := &ContentCoordinator{
21+ webViews: map[entity.PaneID]*webkit.WebView{
22+ "main-pane": mainWV,
23+ "floating-pane": floatingWV,
24+ },
25+ }
26+
27+ c.SetActivePaneOverride("floating-pane")
28+
29+ assert.Equal(t, floatingWV, c.ActiveWebView(context.Background()))
30+ assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
31+}
32+
33+func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
34+ t.Parallel()
35+
36+ mainWV := &webkit.WebView{}
37+
38+ c := &ContentCoordinator{
39+ webViews: map[entity.PaneID]*webkit.WebView{
40+ "main-pane": mainWV,
41+ },
42+ }
43+
44+ c.SetActivePaneOverride("main-pane")
45+ c.ClearActivePaneOverride()
46+
47+ assert.Nil(t, c.ActiveWebView(context.Background()))
48+ assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
49+}
50+
51+func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
52+ t.Parallel()
53+
54+ ctx := context.Background()
55+ mainWV := &webkit.WebView{}
56+ mainPane := entity.NewPane(entity.PaneID("main-pane"))
57+ ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
58+
59+ c := &ContentCoordinator{
60+ webViews: map[entity.PaneID]*webkit.WebView{
61+ mainPane.ID: mainWV,
62+ },
63+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
64+ return ws, nil
65+ },
66+ }
67+
68+ c.SetActivePaneOverride("floating-pane")
69+ c.ClearActivePaneOverride()
70+
71+ assert.Equal(t, mainWV, c.ActiveWebView(ctx))
72+ assert.Equal(t, ws.ActivePaneID, c.ActivePaneID(ctx))
73+}
M internal/ui/coordinator/navigation.go +34 -10
@@ -3,6 +3,7 @@ package coordinator
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
@@ -19,11 +20,14 @@ type OmniboxProvider interface {
19 20
20 // NavigationCoordinator handles URL navigation, history, and browser controls.21 // NavigationCoordinator handles URL navigation, history, and browser controls.
21 type NavigationCoordinator struct {22 type NavigationCoordinator struct {
23+ contextProvider func() context.Context
22 navigateUC *usecase.NavigateUseCase24 navigateUC *usecase.NavigateUseCase
23 contentCoord *ContentCoordinator25 contentCoord *ContentCoordinator
24 omniboxProvider OmniboxProvider26 omniboxProvider OmniboxProvider
25 }27 }
26 28
29+const faviconPreloadTimeout = 300 * time.Millisecond
30+
27 // NewNavigationCoordinator creates a new NavigationCoordinator.31 // NewNavigationCoordinator creates a new NavigationCoordinator.
28 func NewNavigationCoordinator(32 func NewNavigationCoordinator(
29 ctx context.Context,33 ctx context.Context,
@@ -32,8 +36,12 @@ func NewNavigationCoordinator(
32 ) *NavigationCoordinator {36 ) *NavigationCoordinator {
33 log := logging.FromContext(ctx)37 log := logging.FromContext(ctx)
34 log.Debug().Msg("creating navigation coordinator")38 log.Debug().Msg("creating navigation coordinator")
39+ callbackLogger := *log
35 40
36 return &NavigationCoordinator{41 return &NavigationCoordinator{
42+ contextProvider: func() context.Context {
43+ return logging.WithContext(context.Background(), callbackLogger)
44+ },
37 navigateUC: navigateUC,45 navigateUC: navigateUC,
38 contentCoord: contentCoord,46 contentCoord: contentCoord,
39 }47 }
@@ -42,12 +50,27 @@ func NewNavigationCoordinator(
42 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.50 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
43 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {51 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
44 c.omniboxProvider = provider52 c.omniboxProvider = provider
53+ if c.omniboxProvider != nil {
54+ c.omniboxProvider.SetOmniboxOnNavigate(func(url string) {
55+ navCtx := context.Background()
56+ if c.contextProvider != nil {
57+ navCtx = c.contextProvider()
58+ }
59+ if err := c.Navigate(navCtx, url); err != nil {
60+ logging.FromContext(navCtx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed")
61+ }
62+ })
63+ }
45 }64 }
46 65
47 // Navigate loads a URL in the active pane using NavigateUseCase.66 // Navigate loads a URL in the active pane using NavigateUseCase.
48 // This properly handles history recording and zoom application.67 // This properly handles history recording and zoom application.
49 func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {68 func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {
50 log := logging.FromContext(ctx)69 log := logging.FromContext(ctx)
70+ if c.contentCoord == nil {
71+ log.Warn().Str("url", url).Msg("content coordinator not initialized")
72+ return fmt.Errorf("content coordinator not initialized")
73+ }
51 74
52 wv := c.contentCoord.ActiveWebView(ctx)75 wv := c.contentCoord.ActiveWebView(ctx)
53 if wv == nil {76 if wv == nil {
@@ -56,16 +79,17 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
56 }79 }
57 80
58 // Get active pane ID for tracking81 // Get active pane ID for tracking
59- ws, _ := c.contentCoord.getActiveWS()82+ activePaneID := c.contentCoord.ActivePaneID(ctx)
60- var paneID string83+ paneID := string(activePaneID)
61- if ws != nil {84+ if activePaneID != "" {
62- if pane := ws.ActivePane(); pane != nil && pane.Pane != nil {85+ // Track original URL for cross-domain redirect favicon caching
63- paneID = string(pane.Pane.ID)86+ c.contentCoord.SetNavigationOrigin(activePaneID, url)
64- // Track original URL for cross-domain redirect favicon caching87+ // Pre-load cached favicon asynchronously (don't block navigation start)
65- c.contentCoord.SetNavigationOrigin(pane.Pane.ID, url)88+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
66- // Pre-load cached favicon asynchronously (don't block navigation start)89+ go func() {
67- go c.contentCoord.PreloadCachedFavicon(ctx, pane.Pane.ID, url)90+ defer cancelPreload()
68- }91+ c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
92+ }()
69 }93 }
70 94
71 // Use NavigateUseCase which handles history + zoom95 // Use NavigateUseCase which handles history + zoom
A internal/ui/coordinator/navigation_test.go +41 -0
@@ -0,0 +1,41 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+type fakeOmniboxProvider struct {
12+ onNavigate func(string)
13+}
14+
15+func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) {}
16+
17+func (f *fakeOmniboxProvider) UpdateOmniboxZoom(float64) {}
18+
19+func (f *fakeOmniboxProvider) SetOmniboxOnNavigate(fn func(url string)) {
20+ f.onNavigate = fn
21+}
22+
23+func TestNavigationCoordinator_NavigateWithoutContentCoordinatorReturnsError(t *testing.T) {
24+ c := &NavigationCoordinator{}
25+
26+ err := c.Navigate(context.Background(), "https://example.com")
27+ require.Error(t, err)
28+ assert.Contains(t, err.Error(), "content coordinator")
29+}
30+
31+func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) {
32+ c := &NavigationCoordinator{}
33+ provider := &fakeOmniboxProvider{}
34+
35+ c.SetOmniboxProvider(provider)
36+ require.NotNil(t, provider.onNavigate)
37+
38+ assert.NotPanics(t, func() {
39+ provider.onNavigate("https://example.com")
40+ })
41+}
M internal/ui/dispatcher/keyboard.go +34 -0
@@ -31,6 +31,8 @@ type KeyboardDispatcher struct {
31 onSessionOpen func(ctx context.Context) error31 onSessionOpen func(ctx context.Context) error
32 onMovePaneToTab func(ctx context.Context) error32 onMovePaneToTab func(ctx context.Context) error
33 onMovePaneToNext func(ctx context.Context) error33 onMovePaneToNext func(ctx context.Context) error
34+ onToggleFloating func(ctx context.Context) error
35+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
34 }36 }
35 37
36 // NewKeyboardDispatcher creates a new KeyboardDispatcher.38 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -94,6 +96,24 @@ func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context)
94 d.onMovePaneToNext = fn96 d.onMovePaneToNext = fn
95 }97 }
96 98
99+func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
100+ d.onToggleFloating = fn
101+}
102+
103+func (d *KeyboardDispatcher) SetOnOpenFloatingURL(fn func(ctx context.Context, url string) error) {
104+ if fn == nil {
105+ d.onOpenFloating = nil
106+ return
107+ }
108+ d.onOpenFloating = func(ctx context.Context, target input.FloatingProfileTarget) error {
109+ return fn(ctx, target.URL)
110+ }
111+}
112+
113+func (d *KeyboardDispatcher) SetOnOpenFloatingTarget(fn func(ctx context.Context, target input.FloatingProfileTarget) error) {
114+ d.onOpenFloating = fn
115+}
116+
97 func (d *KeyboardDispatcher) initActionHandlers() {117 func (d *KeyboardDispatcher) initActionHandlers() {
98 const (118 const (
99 firstTabIndex = 0119 firstTabIndex = 0
@@ -191,6 +211,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
191 input.ActionFindPrev: d.handleFindPrev,211 input.ActionFindPrev: d.handleFindPrev,
192 input.ActionCloseFind: d.handleFindClose,212 input.ActionCloseFind: d.handleFindClose,
193 input.ActionOpenDevTools: d.navCoord.OpenDevTools,213 input.ActionOpenDevTools: d.navCoord.OpenDevTools,
214+ input.ActionToggleFloatingPane: func(ctx context.Context) error {
215+ if d.onToggleFloating != nil {
216+ return d.onToggleFloating(ctx)
217+ }
218+ return d.logNoop(ctx, "toggle floating pane action (no handler)")
219+ },
194 input.ActionToggleFullscreen: func(ctx context.Context) error {220 input.ActionToggleFullscreen: func(ctx context.Context) error {
195 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")221 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
196 },222 },
@@ -208,6 +234,14 @@ func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action)
208 log := logging.FromContext(ctx)234 log := logging.FromContext(ctx)
209 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")235 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
210 236
237+ if target, ok := input.ParseFloatingProfileTarget(action); ok {
238+ if d.onOpenFloating != nil {
239+ return d.onOpenFloating(ctx, target)
240+ }
241+ log.Debug().Str("url", target.URL).Msg("floating profile action ignored (no handler)")
242+ return nil
243+ }
244+
211 if handler, ok := d.actionHandlers[action]; ok {245 if handler, ok := d.actionHandlers[action]; ok {
212 return handler(ctx)246 return handler(ctx)
213 }247 }
A internal/ui/floating_pane_test.go +385 -0
@@ -0,0 +1,385 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+ "unsafe"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
16+ overlay := layoutmocks.NewMockOverlayWidget(t)
17+ overlay.EXPECT().SetHexpand(false).Once()
18+ overlay.EXPECT().SetVexpand(false).Once()
19+ overlay.EXPECT().AddCssClass("floating-pane-container").Once()
20+ overlay.EXPECT().AddCssClass("pane-border").Once()
21+ overlay.EXPECT().AddCssClass("pane-active").Once()
22+
23+ decorateFloatingOverlay(overlay)
24+}
25+
26+func TestConfigureFloatingOverlayMeasurement_DoesNotMeasureOverlay(t *testing.T) {
27+ workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
28+ floatingOverlay := layoutmocks.NewMockWidget(t)
29+
30+ workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, false).Once()
31+ workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
32+
33+ configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
34+}
35+
36+func TestShowFloatingWidget_AddsVisibleClassAndEnablesInteraction(t *testing.T) {
37+ widget := layoutmocks.NewMockWidget(t)
38+
39+ widget.EXPECT().AddCssClass(floatingPaneVisibleClass).Once()
40+ widget.EXPECT().SetCanTarget(true).Once()
41+ widget.EXPECT().SetCanFocus(true).Once()
42+
43+ showFloatingWidget(widget)
44+}
45+
46+func TestHideFloatingWidget_RemovesVisibleClassAndDisablesInteraction(t *testing.T) {
47+ widget := layoutmocks.NewMockWidget(t)
48+
49+ widget.EXPECT().RemoveCssClass(floatingPaneVisibleClass).Once()
50+ widget.EXPECT().SetCanTarget(false).Once()
51+ widget.EXPECT().SetCanFocus(false).Once()
52+
53+ hideFloatingWidget(widget)
54+}
55+
56+func TestFocusFloatingSessionContent_GrabsWebViewFocusWhenOmniboxHidden(t *testing.T) {
57+ session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:google")
58+ session.pane.Show()
59+
60+ widget := layoutmocks.NewMockWidget(t)
61+ widget.EXPECT().GrabFocus().Return(true).Once()
62+ session.focusWidget = widget
63+
64+ focusFloatingSessionContent(session)
65+}
66+
67+func TestFocusFloatingSessionContent_SkipsWhenOmniboxVisible(t *testing.T) {
68+ session := newFloatingPaneSession(entity.TabID("tab-1"), floatingSessionIDDefault)
69+ require.NoError(t, session.pane.ShowToggle(context.Background()))
70+ require.True(t, session.pane.IsOmniboxVisible())
71+
72+ widget := layoutmocks.NewMockWidget(t)
73+ session.focusWidget = widget
74+
75+ focusFloatingSessionContent(session)
76+}
77+
78+func TestFloatingAllocationRect_CentersRequestedSize(t *testing.T) {
79+ x, y, width, height, ok := floatingAllocationRect(1000, 700, 820, 504)
80+
81+ require.True(t, ok)
82+ assert.Equal(t, 90, x)
83+ assert.Equal(t, 98, y)
84+ assert.Equal(t, 820, width)
85+ assert.Equal(t, 504, height)
86+}
87+
88+func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) {
89+ x, y, width, height, ok := floatingAllocationRect(1000, 700, 1200, 900)
90+
91+ require.True(t, ok)
92+ assert.Equal(t, 0, x)
93+ assert.Equal(t, 0, y)
94+ assert.Equal(t, 1000, width)
95+ assert.Equal(t, 700, height)
96+}
97+
98+func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) {
99+ // cGtkAllocation mirrors GTK's GdkRectangle/GtkAllocation layout used by
100+ // writeOverlayAllocation: four int32 fields in order. allocationPtr uses
101+ // unsafe.Pointer to simulate the C allocation callback memory.
102+ type cGtkAllocation struct {
103+ X int32
104+ Y int32
105+ Width int32
106+ Height int32
107+ }
108+
109+ rect := cGtkAllocation{}
110+ allocationPtr := (*uintptr)(unsafe.Pointer(&rect))
111+
112+ ok := writeOverlayAllocation(allocationPtr, 11, 22, 33, 44)
113+
114+ require.True(t, ok)
115+ assert.Equal(t, int32(11), rect.X)
116+ assert.Equal(t, int32(22), rect.Y)
117+ assert.Equal(t, int32(33), rect.Width)
118+ assert.Equal(t, int32(44), rect.Height)
119+}
120+
121+func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
122+ app, tabID, session := newFloatingPaneTestApp(t)
123+
124+ err := app.ToggleFloatingPane(context.Background())
125+ require.NoError(t, err)
126+ assert.True(t, session.pane.IsVisible())
127+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
128+
129+ err = app.ToggleFloatingPane(context.Background())
130+ require.NoError(t, err)
131+ assert.False(t, session.pane.IsVisible())
132+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
133+
134+ _, gotTabID := app.activeFloatingSession()
135+ assert.Equal(t, tabID, gotTabID)
136+}
137+
138+func TestFloatingPane_ProfileShortcutOpensConfiguredURL(t *testing.T) {
139+ app, _, session := newFloatingPaneTestApp(t)
140+
141+ err := app.OpenFloatingPaneURL(context.Background(), "https://google.com")
142+ require.NoError(t, err)
143+
144+ assert.True(t, session.pane.IsVisible())
145+ assert.False(t, session.pane.IsOmniboxVisible())
146+ assert.Equal(t, "https://google.com", session.pane.CurrentURL())
147+}
148+
149+func TestFloatingPane_ActivePaneSwitchPreservesSession(t *testing.T) {
150+ app, tabID, session := newFloatingPaneTestApp(t)
151+
152+ err := app.OpenFloatingPaneURL(context.Background(), "https://example.com/start")
153+ require.NoError(t, err)
154+
155+ activeTab := app.tabs.Find(tabID)
156+ require.NotNil(t, activeTab)
157+ activeTab.Workspace.ActivePaneID = entity.PaneID("pane-two")
158+
159+ err = app.ToggleFloatingPane(context.Background())
160+ require.NoError(t, err)
161+ err = app.ToggleFloatingPane(context.Background())
162+ require.NoError(t, err)
163+
164+ assert.Equal(t, "https://example.com/start", session.pane.CurrentURL())
165+}
166+
167+func TestFloatingPane_OmniboxNavigationTargetsFloatingSession(t *testing.T) {
168+ app, _, session := newFloatingPaneTestApp(t)
169+
170+ require.NoError(t, session.pane.ShowToggle(context.Background()))
171+ require.True(t, session.pane.IsOmniboxVisible())
172+
173+ err := app.navigateFromOmnibox(context.Background(), "https://example.com/omnibox")
174+ require.NoError(t, err)
175+ assert.Equal(t, "https://example.com/omnibox", session.pane.CurrentURL())
176+}
177+
178+func TestFloatingPane_HandleViewportTick_RecalculatesOnWorkspaceResize(t *testing.T) {
179+ ctx := context.Background()
180+ overlay := layoutmocks.NewMockOverlayWidget(t)
181+ width := 1000
182+ height := 700
183+ overlay.EXPECT().GetAllocatedWidth().RunAndReturn(func() int { return width }).Maybe()
184+ overlay.EXPECT().GetAllocatedHeight().RunAndReturn(func() int { return height }).Maybe()
185+
186+ widget := layoutmocks.NewMockWidget(t)
187+ pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
188+ WidthPct: 0.82,
189+ HeightPct: 0.72,
190+ FallbackWidth: floatingPaneFallbackWidth,
191+ FallbackHeight: floatingPaneFallbackHeight,
192+ })
193+ require.NoError(t, pane.ShowURL(ctx, "https://example.com"))
194+
195+ session := &floatingWorkspaceSession{pane: pane, overlay: overlay, widget: widget}
196+ app := &App{}
197+
198+ widget.EXPECT().SetSizeRequest(820, 504).Once()
199+ assert.True(t, app.handleFloatingViewportTick(session))
200+
201+ width = 1200
202+ height = 900
203+ widget.EXPECT().SetSizeRequest(983, 648).Once()
204+ assert.True(t, app.handleFloatingViewportTick(session))
205+
206+ pane.Hide(ctx)
207+ assert.False(t, app.handleFloatingViewportTick(session))
208+}
209+
210+func TestFloatingPane_HideShowAfterURIUpdateKeepsOmniboxHidden(t *testing.T) {
211+ app, _, session := newFloatingPaneTestApp(t)
212+
213+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
214+ require.True(t, session.pane.IsOmniboxVisible())
215+
216+ app.updateFloatingSessionURI(session.paneID, "https://google.com")
217+
218+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
219+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
220+
221+ assert.Equal(t, "https://google.com", session.pane.CurrentURL())
222+ assert.False(t, session.pane.IsOmniboxVisible())
223+}
224+
225+func TestFloatingPane_ProfileSessionsRemainIndependent(t *testing.T) {
226+ app, tabID, _ := newFloatingPaneTestApp(t)
227+
228+ gmailSession := newFloatingPaneSession(tabID, "profile:gmail")
229+ blankSession := newFloatingPaneSession(tabID, "profile:blank")
230+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = gmailSession
231+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:blank"}] = blankSession
232+
233+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
234+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
235+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:blank", "about:blank"))
236+
237+ assert.Equal(t, "https://mail.google.com", gmailSession.pane.CurrentURL())
238+ assert.False(t, gmailSession.pane.IsVisible())
239+ assert.Equal(t, "about:blank", blankSession.pane.CurrentURL())
240+ assert.True(t, blankSession.pane.IsVisible())
241+ assert.NotEqual(t, gmailSession.paneID, blankSession.paneID)
242+}
243+
244+func TestFloatingPane_ProfileShortcutTogglesAndPreservesSessionState(t *testing.T) {
245+ app, tabID, _ := newFloatingPaneTestApp(t)
246+ session := newFloatingPaneSession(tabID, "profile:gmail")
247+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
248+
249+ baseURL := "https://mail.google.com"
250+ draftURL := "https://mail.google.com/mail/u/0/#inbox?compose=new"
251+
252+ // First open: navigates to base URL
253+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
254+ assert.True(t, session.pane.IsVisible())
255+ assert.Equal(t, baseURL, session.pane.CurrentURL())
256+
257+ // Simulate user navigating within gmail (compose draft etc.)
258+ app.updateFloatingSessionURI(session.paneID, draftURL)
259+ assert.Equal(t, draftURL, session.pane.CurrentURL())
260+
261+ // Second press: hides (toggle off), preserves draft URL
262+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
263+ assert.False(t, session.pane.IsVisible())
264+ assert.Equal(t, draftURL, session.pane.CurrentURL())
265+
266+ // Third press: shows again without re-navigating, draft URL preserved
267+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
268+ assert.True(t, session.pane.IsVisible())
269+ assert.Equal(t, draftURL, session.pane.CurrentURL())
270+}
271+
272+func TestFloatingPane_ProfileRapidToggleNeverShowsAboutBlank(t *testing.T) {
273+ app, tabID, _ := newFloatingPaneTestApp(t)
274+ session := newFloatingPaneSession(tabID, "profile:gmail")
275+ app.floatingSessions[floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}] = session
276+
277+ baseURL := "https://mail.google.com"
278+
279+ // Rapid toggle 10 times: should never end up on about:blank
280+ for i := 0; i < 10; i++ {
281+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", baseURL))
282+ assert.NotEqual(t, "about:blank", session.pane.CurrentURL(),
283+ "iteration %d: profile session should never show about:blank", i)
284+ if session.pane.IsVisible() {
285+ assert.Equal(t, baseURL, session.pane.CurrentURL())
286+ }
287+ }
288+}
289+
290+func TestFloatingPane_DefaultSessionStillUsesAboutBlank(t *testing.T) {
291+ app, _, session := newFloatingPaneTestApp(t)
292+
293+ require.NoError(t, app.ToggleFloatingPane(context.Background()))
294+ assert.True(t, session.pane.IsVisible())
295+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
296+}
297+
298+func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) {
299+ app, tabID, _ := newFloatingPaneTestApp(t)
300+ session := newFloatingPaneSession(tabID, "profile:gmail")
301+ key := floatingSessionKey{tabID: tabID, sessionID: "profile:gmail"}
302+ app.floatingSessions[key] = session
303+
304+ require.NoError(t, app.OpenFloatingPaneProfileURL(context.Background(), "profile:gmail", "https://mail.google.com"))
305+
306+ handled := app.closeAndReleaseActiveFloatingPane(context.Background())
307+ assert.True(t, handled)
308+ _, stillExists := app.floatingSessions[key]
309+ assert.False(t, stillExists)
310+
311+ handled = app.closeAndReleaseActiveFloatingPane(context.Background())
312+ assert.False(t, handled)
313+}
314+
315+func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) {
316+ app, _, session := newFloatingPaneTestApp(t)
317+
318+ require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault))
319+ assert.True(t, session.pane.IsVisible())
320+ assert.Equal(t, "about:blank", session.pane.CurrentURL())
321+
322+ require.NoError(t, app.openFloatingPaneSession(context.Background(), floatingSessionIDDefault, "https://example.com"))
323+ assert.True(t, session.pane.IsVisible())
324+ assert.Equal(t, "https://example.com", session.pane.CurrentURL())
325+}
326+
327+func TestFloatingPane_EnsureFloatingSession_CachedAllowsNilWorkspaceView(t *testing.T) {
328+ app, tabID, session := newFloatingPaneTestApp(t)
329+
330+ got, err := app.ensureFloatingSession(context.Background(), tabID, floatingSessionIDDefault, nil)
331+ require.NoError(t, err)
332+ assert.Same(t, session, got)
333+}
334+
335+func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
336+ session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
337+ session.resizeWatcherActive = true
338+ session.resizeTickID = 42
339+ session.appliedWidth = 960
340+ session.appliedHeight = 640
341+
342+ app := &App{}
343+ app.hideFloatingSession(context.Background(), session)
344+
345+ assert.False(t, session.resizeWatcherActive)
346+ assert.Equal(t, uint(0), session.resizeTickID)
347+ assert.Equal(t, 0, session.appliedWidth)
348+ assert.Equal(t, 0, session.appliedHeight)
349+}
350+
351+func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
352+ t.Helper()
353+
354+ tabID := entity.TabID("tab-1")
355+ workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
356+ tabs := entity.NewTabList()
357+ tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})
358+
359+ session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
360+
361+ app := &App{
362+ tabs: tabs,
363+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
364+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
365+ {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
366+ },
367+ }
368+
369+ return app, tabID, session
370+}
371+
372+func newFloatingPaneSession(tabID entity.TabID, sessionID string) *floatingWorkspaceSession {
373+ return &floatingWorkspaceSession{
374+ paneID: floatingPaneIDForSession(tabID, sessionID),
375+ pane: component.NewFloatingPane(nil, component.FloatingPaneOptions{
376+ WidthPct: 0.82,
377+ HeightPct: 0.72,
378+ FallbackWidth: floatingPaneFallbackWidth,
379+ FallbackHeight: floatingPaneFallbackHeight,
380+ OnNavigate: func(context.Context, string) error {
381+ return nil
382+ },
383+ }),
384+ }
385+}
A internal/ui/input/floating_shortcuts_test.go +203 -0
@@ -0,0 +1,203 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
14+ cfg := config.DefaultConfig()
15+ set := NewShortcutSet(context.Background(), cfg)
16+
17+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)
18+ require.True(t, ok)
19+ assert.Equal(t, ActionToggleFloatingPane, action)
20+}
21+
22+func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
23+ cfg := config.DefaultConfig()
24+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
25+ "google": {
26+ Keys: []string{"alt+g"},
27+ URL: "https://google.com",
28+ },
29+ "github": {
30+ Keys: []string{"alt+y"},
31+ URL: "https://github.com",
32+ },
33+ }
34+
35+ set := NewShortcutSet(context.Background(), cfg)
36+
37+ googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
38+ require.True(t, ok)
39+ googleTarget, ok := ParseFloatingProfileTarget(googleAction)
40+ require.True(t, ok)
41+ assert.Equal(t, "google", googleTarget.SessionID)
42+ assert.Equal(t, "https://google.com", googleTarget.URL)
43+
44+ githubAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_y), Modifiers: ModAlt}, ModeNormal)
45+ require.True(t, ok)
46+ githubTarget, ok := ParseFloatingProfileTarget(githubAction)
47+ require.True(t, ok)
48+ assert.Equal(t, "github", githubTarget.SessionID)
49+ assert.Equal(t, "https://github.com", githubTarget.URL)
50+}
51+
52+func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
53+ cfg := config.DefaultConfig()
54+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
55+ "tab-conflict": {
56+ Keys: []string{"alt+1"},
57+ URL: "https://example.com/tab",
58+ },
59+ "pane-conflict": {
60+ Keys: []string{"alt+["},
61+ URL: "https://example.com/pane",
62+ },
63+ "ok": {
64+ Keys: []string{"alt+g"},
65+ URL: "https://example.com/ok",
66+ },
67+ }
68+
69+ occupied := map[KeyBinding]Action{
70+ {Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}: ActionSwitchTabIndex1,
71+ {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,
72+ }
73+
74+ shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied)
75+ require.Len(t, shortcuts, 1)
76+ assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)
77+ target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)
78+ require.True(t, ok)
79+ assert.Equal(t, "ok", target.SessionID)
80+ assert.Equal(t, "https://example.com/ok", target.URL)
81+}
82+
83+func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {
84+ cfg := config.DefaultConfig()
85+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
86+ "conflict": {
87+ Keys: []string{"alt+["},
88+ URL: "https://example.com/override-attempt",
89+ },
90+ }
91+
92+ set := NewShortcutSet(context.Background(), cfg)
93+
94+ tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)
95+ require.True(t, ok)
96+ assert.Equal(t, ActionConsumeOrExpelLeft, tabAction)
97+
98+ omniboxAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_l), Modifiers: ModCtrl}, ModeNormal)
99+ require.True(t, ok)
100+ assert.Equal(t, ActionOpenOmnibox, omniboxAction)
101+}
102+
103+func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
104+ cfg := config.DefaultConfig()
105+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
106+ "alt-one-conflict": {
107+ Keys: []string{"alt+1"},
108+ URL: "https://example.com/one",
109+ },
110+ "alt-tab-conflict": {
111+ Keys: []string{"alt+tab"},
112+ URL: "https://example.com/tab",
113+ },
114+ "ok": {
115+ Keys: []string{"alt+g"},
116+ URL: "https://example.com/ok",
117+ },
118+ }
119+
120+ set := NewShortcutSet(context.Background(), cfg)
121+
122+ _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)
123+ assert.False(t, ok)
124+
125+ _, ok = set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}, ModeNormal)
126+ assert.False(t, ok)
127+
128+ action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
129+ require.True(t, ok)
130+ target, ok := ParseFloatingProfileTarget(action)
131+ require.True(t, ok)
132+ assert.Equal(t, "ok", target.SessionID)
133+ assert.Equal(t, "https://example.com/ok", target.URL)
134+}
135+
136+func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {
137+ cfg := config.DefaultConfig()
138+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
139+ "work-mail": {
140+ Keys: []string{"alt+w"},
141+ URL: "https://mail.google.com",
142+ },
143+ "personal-mail": {
144+ Keys: []string{"alt+p"},
145+ URL: "https://mail.google.com",
146+ },
147+ }
148+
149+ set := NewShortcutSet(context.Background(), cfg)
150+
151+ workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)
152+ require.True(t, ok)
153+ workTarget, ok := ParseFloatingProfileTarget(workAction)
154+ require.True(t, ok)
155+
156+ personalAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_p), Modifiers: ModAlt}, ModeNormal)
157+ require.True(t, ok)
158+ personalTarget, ok := ParseFloatingProfileTarget(personalAction)
159+ require.True(t, ok)
160+
161+ assert.Equal(t, "https://mail.google.com", workTarget.URL)
162+ assert.Equal(t, "https://mail.google.com", personalTarget.URL)
163+ assert.NotEqual(t, workTarget.SessionID, personalTarget.SessionID)
164+}
165+
166+func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
167+ cfg := config.DefaultConfig()
168+ cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
169+ "mail": {
170+ Keys: []string{"ctrl+alt+m"},
171+ URL: "https://mail.example.com",
172+ },
173+ "search": {
174+ Keys: []string{"ctrl+shift+y"},
175+ URL: "https://search.example.com",
176+ },
177+ }
178+
179+ set := NewShortcutSet(context.Background(), cfg)
180+
181+ mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal)
182+ require.True(t, ok)
183+ mailTarget, ok := ParseFloatingProfileTarget(mailAction)
184+ require.True(t, ok)
185+ assert.Equal(t, "mail", mailTarget.SessionID)
186+ assert.Equal(t, "https://mail.example.com", mailTarget.URL)
187+
188+ searchAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_y), Modifiers: ModCtrl | ModShift}, ModeNormal)
189+ require.True(t, ok)
190+ searchTarget, ok := ParseFloatingProfileTarget(searchAction)
191+ require.True(t, ok)
192+ assert.Equal(t, "search", searchTarget.SessionID)
193+ assert.Equal(t, "https://search.example.com", searchTarget.URL)
194+}
195+
196+func TestParseFloatingProfileTarget_AllowsPipeInURL(t *testing.T) {
197+ action := NewFloatingProfileAction("pipe-test", "https://example.com/a|b")
198+
199+ target, ok := ParseFloatingProfileTarget(action)
200+ require.True(t, ok)
201+ assert.Equal(t, "pipe-test", target.SessionID)
202+ assert.Equal(t, "https://example.com/a|b", target.URL)
203+}
M internal/ui/input/global_shortcuts.go +61 -6
@@ -3,6 +3,8 @@ package input
3 3
4 import (4 import (
5 "context"5 "context"
6+ "fmt"
7+ "strings"
6 8
7 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
8 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
@@ -18,6 +20,7 @@ type GlobalShortcutHandler struct {
18 controller *gtk.ShortcutController20 controller *gtk.ShortcutController
19 onAction ActionHandler21 onAction ActionHandler
20 ctx context.Context22 ctx context.Context
23+ registered map[KeyBinding]Action
21 24
22 // Keep references to callbacks to prevent GC from collecting them25 // Keep references to callbacks to prevent GC from collecting them
23 callbacks []gtk.ShortcutFunc26 callbacks []gtk.ShortcutFunc
@@ -39,6 +42,7 @@ func NewGlobalShortcutHandler(
39 onAction: onAction,42 onAction: onAction,
40 ctx: ctx,43 ctx: ctx,
41 callbacks: make([]gtk.ShortcutFunc, 0),44 callbacks: make([]gtk.ShortcutFunc, 0),
45+ registered: make(map[KeyBinding]Action),
42 }46 }
43 47
44 if h.controller == nil {48 if h.controller == nil {
@@ -96,6 +100,8 @@ func NewGlobalShortcutHandler(
96 if cfg != nil {100 if cfg != nil {
97 // Map action names to action constants for global shortcuts101 // Map action names to action constants for global shortcuts
98 actionMap := map[string]Action{102 actionMap := map[string]Action{
103+ "toggle_floating_pane": ActionToggleFloatingPane,
104+ "toggle-floating-pane": ActionToggleFloatingPane,
99 "consume_or_expel_left": ActionConsumeOrExpelLeft,105 "consume_or_expel_left": ActionConsumeOrExpelLeft,
100 "consume_or_expel_right": ActionConsumeOrExpelRight,106 "consume_or_expel_right": ActionConsumeOrExpelRight,
101 "consume_or_expel_up": ActionConsumeOrExpelUp,107 "consume_or_expel_up": ActionConsumeOrExpelUp,
@@ -113,8 +119,25 @@ func NewGlobalShortcutHandler(
113 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")119 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
114 continue120 continue
115 }121 }
116- h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)122+ if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
117- log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")123+ log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
124+ }
125+ }
126+ }
127+
128+ occupied := make(map[KeyBinding]Action, len(h.registered))
129+ for binding, action := range h.registered {
130+ occupied[binding] = action
131+ }
132+ for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
133+ if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
134+ continue
135+ }
136+ if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
137+ log.Trace().
138+ Str("shortcut", formatBinding(shortcut.Binding)).
139+ Str("url", url).
140+ Msg("registered floating profile global shortcut")
118 }141 }
119 }142 }
120 }143 }
@@ -130,14 +153,24 @@ func NewGlobalShortcutHandler(
130 }153 }
131 154
132 // registerShortcut creates and registers a single shortcut with the controller.155 // registerShortcut creates and registers a single shortcut with the controller.
133-func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {156+func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
157+ binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
158+ if existing, exists := h.registered[binding]; exists {
159+ logging.FromContext(h.ctx).Warn().
160+ Str("existing_action", string(existing)).
161+ Str("new_action", string(action)).
162+ Str("shortcut", formatBinding(binding)).
163+ Msg("global shortcut conflict, skipping")
164+ return false
165+ }
166+
134 // Create trigger for this key combination167 // Create trigger for this key combination
135 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)168 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
136 if trigger == nil {169 if trigger == nil {
137 logging.FromContext(h.ctx).Error().170 logging.FromContext(h.ctx).Error().
138 Uint("keyval", keyval).171 Uint("keyval", keyval).
139 Msg("failed to create keyval trigger")172 Msg("failed to create keyval trigger")
140- return173+ return false
141 }174 }
142 175
143 // Create callback action176 // Create callback action
@@ -169,7 +202,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
169 logging.FromContext(h.ctx).Error().202 logging.FromContext(h.ctx).Error().
170 Uint("keyval", keyval).203 Uint("keyval", keyval).
171 Msg("failed to create callback action")204 Msg("failed to create callback action")
172- return205+ return false
173 }206 }
174 207
175 // Create the shortcut combining trigger and action208 // Create the shortcut combining trigger and action
@@ -178,11 +211,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
178 logging.FromContext(h.ctx).Error().211 logging.FromContext(h.ctx).Error().
179 Uint("keyval", keyval).212 Uint("keyval", keyval).
180 Msg("failed to create shortcut")213 Msg("failed to create shortcut")
181- return214+ return false
182 }215 }
183 216
184 // Add to controller217 // Add to controller
185 h.controller.AddShortcut(shortcut)218 h.controller.AddShortcut(shortcut)
219+ h.registered[binding] = action
220+ return true
186 }221 }
187 222
188 // Detach removes the global shortcut handler from the window.223 // Detach removes the global shortcut handler from the window.
@@ -191,4 +226,24 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191 func (h *GlobalShortcutHandler) Detach() {226 func (h *GlobalShortcutHandler) Detach() {
192 h.controller = nil227 h.controller = nil
193 h.callbacks = nil228 h.callbacks = nil
229+ h.registered = nil
230+}
231+
232+func formatBinding(binding KeyBinding) string {
233+ parts := make([]string, 0, 3)
234+ if binding.Modifiers&ModCtrl != 0 {
235+ parts = append(parts, "ctrl")
236+ }
237+ if binding.Modifiers&ModShift != 0 {
238+ parts = append(parts, "shift")
239+ }
240+ if binding.Modifiers&ModAlt != 0 {
241+ parts = append(parts, "alt")
242+ }
243+ keyName := gdk.KeyvalName(binding.Keyval)
244+ if keyName == "" {
245+ keyName = fmt.Sprintf("0x%x", binding.Keyval)
246+ }
247+ parts = append(parts, strings.ToLower(keyName))
248+ return strings.Join(parts, "+")
194 }249 }
M internal/ui/input/shortcuts.go +176 -7
@@ -3,6 +3,7 @@ package input
3 3
4 import (4 import (
5 "context"5 "context"
6+ "sort"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -178,13 +179,14 @@ const (
178 ActionZoomReset Action = "zoom_reset"179 ActionZoomReset Action = "zoom_reset"
179 180
180 // UI181 // UI
181- ActionOpenOmnibox Action = "open_omnibox"182+ ActionOpenOmnibox Action = "open_omnibox"
182- ActionOpenFind Action = "open_find"183+ ActionOpenFind Action = "open_find"
183- ActionFindNext Action = "find_next"184+ ActionFindNext Action = "find_next"
184- ActionFindPrev Action = "find_prev"185+ ActionFindPrev Action = "find_prev"
185- ActionCloseFind Action = "close_find"186+ ActionCloseFind Action = "close_find"
186- ActionOpenDevTools Action = "open_devtools"187+ ActionOpenDevTools Action = "open_devtools"
187- ActionToggleFullscreen Action = "toggle_fullscreen"188+ ActionToggleFullscreen Action = "toggle_fullscreen"
189+ ActionToggleFloatingPane Action = "toggle_floating_pane"
188 190
189 // Clipboard191 // Clipboard
190 ActionCopyURL Action = "copy_url"192 ActionCopyURL Action = "copy_url"
@@ -196,6 +198,9 @@ const (
196 ActionQuit Action = "quit"198 ActionQuit Action = "quit"
197 )199 )
198 200
201+const floatingProfileActionPrefix = "open_floating_profile:"
202+const floatingProfileActionSeparator = "\u241F"
203+
199 // ShortcutTable maps KeyBinding to Action.204 // ShortcutTable maps KeyBinding to Action.
200 type ShortcutTable map[KeyBinding]Action205 type ShortcutTable map[KeyBinding]Action
201 206
@@ -248,6 +253,7 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Conf
248 s.registerStandardShortcuts()253 s.registerStandardShortcuts()
249 s.registerPaneNavigationShortcuts()254 s.registerPaneNavigationShortcuts()
250 s.registerTabSwitchShortcuts()255 s.registerTabSwitchShortcuts()
256+ s.registerFloatingProfileShortcuts(ctx, cfg)
251 }257 }
252 258
253 // buildTabModeShortcuts populates tab mode shortcuts from config.259 // buildTabModeShortcuts populates tab mode shortcuts from config.
@@ -327,6 +333,8 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
327 // However, these standard browser shortcuts ARE global:333 // However, these standard browser shortcuts ARE global:
328 // Map action names to action constants334 // Map action names to action constants
329 actionMap := map[string]Action{335 actionMap := map[string]Action{
336+ "toggle_floating_pane": ActionToggleFloatingPane,
337+ "toggle-floating-pane": ActionToggleFloatingPane,
330 "close_pane": ActionClosePane,338 "close_pane": ActionClosePane,
331 "next_tab": ActionNextTab,339 "next_tab": ActionNextTab,
332 "previous_tab": ActionPreviousTab,340 "previous_tab": ActionPreviousTab,
@@ -349,6 +357,113 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
349 }357 }
350 }358 }
351 359
360+func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {
361+ occupied := make(map[KeyBinding]Action, len(s.Global))
362+ for binding, action := range s.Global {
363+ occupied[binding] = action
364+ }
365+
366+ for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
367+ s.Global[shortcut.Binding] = shortcut.Action
368+ }
369+}
370+
371+type floatingProfileShortcut struct {
372+ Binding KeyBinding
373+ Action Action
374+}
375+
376+func collectFloatingProfileShortcuts(
377+ ctx context.Context,
378+ cfg *config.Config,
379+ occupied map[KeyBinding]Action,
380+) []floatingProfileShortcut {
381+ if cfg == nil {
382+ return nil
383+ }
384+
385+ log := logging.FromContext(ctx)
386+ reserveGlobalOnlyShortcutBindings(occupied)
387+ result := make([]floatingProfileShortcut, 0)
388+ profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))
389+ for profileName := range cfg.Workspace.FloatingPane.Profiles {
390+ profileNames = append(profileNames, profileName)
391+ }
392+ sort.Strings(profileNames)
393+
394+ for _, profileName := range profileNames {
395+ profile := cfg.Workspace.FloatingPane.Profiles[profileName]
396+ url := strings.TrimSpace(profile.URL)
397+ if url == "" {
398+ log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
399+ continue
400+ }
401+
402+ action := NewFloatingProfileAction(profileName, url)
403+ keys := append([]string(nil), profile.Keys...)
404+ sort.Strings(keys)
405+ for _, keyStr := range keys {
406+ binding, ok := ParseKeyString(keyStr)
407+ if !ok {
408+ log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
409+ continue
410+ }
411+
412+ if existing, exists := occupied[binding]; exists {
413+ log.Warn().
414+ Str("profile", profileName).
415+ Str("shortcut", keyStr).
416+ Str("existing_action", string(existing)).
417+ Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
418+ continue
419+ }
420+
421+ occupied[binding] = action
422+ result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
423+ }
424+ }
425+
426+ return result
427+}
428+
429+func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
430+ if occupied == nil {
431+ return
432+ }
433+
434+ tabIndexActions := []Action{
435+ ActionSwitchTabIndex1,
436+ ActionSwitchTabIndex2,
437+ ActionSwitchTabIndex3,
438+ ActionSwitchTabIndex4,
439+ ActionSwitchTabIndex5,
440+ ActionSwitchTabIndex6,
441+ ActionSwitchTabIndex7,
442+ ActionSwitchTabIndex8,
443+ ActionSwitchTabIndex9,
444+ }
445+
446+ // Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
447+ // If that ever changes on a platform, map digits explicitly.
448+ for i, action := range tabIndexActions {
449+ binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
450+ if _, exists := occupied[binding]; exists {
451+ continue
452+ }
453+ occupied[binding] = action
454+ }
455+
456+ altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
457+ if _, exists := occupied[altZero]; !exists {
458+ occupied[altZero] = ActionSwitchTabIndex10
459+ }
460+
461+ altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
462+ if _, exists := occupied[altTab]; !exists {
463+ occupied[altTab] = ActionSwitchLastTab
464+ }
465+}
466+
352 func (s *ShortcutSet) registerStandardShortcuts() {467 func (s *ShortcutSet) registerStandardShortcuts() {
353 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox468 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
354 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind469 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
@@ -428,6 +543,9 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
428 }543 }
429 544
430 var configActionToAction = map[string]Action{545 var configActionToAction = map[string]Action{
546+ "toggle_floating_pane": ActionToggleFloatingPane,
547+ "toggle-floating-pane": ActionToggleFloatingPane,
548+
431 // Tab actions549 // Tab actions
432 "new-tab": ActionNewTab,550 "new-tab": ActionNewTab,
433 "close-tab": ActionCloseTab,551 "close-tab": ActionCloseTab,
@@ -478,6 +596,57 @@ var configActionToAction = map[string]Action{
478 "session-manager": ActionOpenSessionManager,596 "session-manager": ActionOpenSessionManager,
479 }597 }
480 598
599+// FloatingProfileTarget carries the session identity and URL for a floating profile action.
600+type FloatingProfileTarget struct {
601+ SessionID string
602+ URL string
603+}
604+
605+// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
606+func NewFloatingProfileAction(profileName, url string) Action {
607+ sessionID := strings.TrimSpace(profileName)
608+ trimmedURL := strings.TrimSpace(url)
609+ if sessionID == "" {
610+ sessionID = trimmedURL
611+ }
612+ return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
613+}
614+
615+// ParseFloatingProfileTarget extracts the session identity and target URL.
616+func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
617+ value := string(action)
618+ if !strings.HasPrefix(value, floatingProfileActionPrefix) {
619+ return FloatingProfileTarget{}, false
620+ }
621+
622+ payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
623+ sessionID := ""
624+ url := payload
625+ if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
626+ sessionID = strings.TrimSpace(parts[0])
627+ url = parts[1]
628+ }
629+
630+ url = strings.TrimSpace(url)
631+ if url == "" {
632+ return FloatingProfileTarget{}, false
633+ }
634+ if sessionID == "" {
635+ sessionID = url
636+ }
637+
638+ return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
639+}
640+
641+// ParseFloatingProfileAction extracts the target URL from a floating profile action.
642+func ParseFloatingProfileAction(action Action) (string, bool) {
643+ target, ok := ParseFloatingProfileTarget(action)
644+ if !ok {
645+ return "", false
646+ }
647+ return target.URL, true
648+}
649+
481 // mapConfigAction maps config action names to Action constants.650 // mapConfigAction maps config action names to Action constants.
482 func mapConfigAction(configAction string) Action {651 func mapConfigAction(configAction string) Action {
483 if configAction == "cancel" || configAction == "confirm" {652 if configAction == "cancel" || configAction == "confirm" {
M internal/ui/input/shortcuts_test.go +14 -0
@@ -375,3 +375,17 @@ func TestShouldAutoExitMode(t *testing.T) {
375 }375 }
376 }376 }
377 }377 }
378+
379+func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
380+ action := mapConfigAction("toggle_floating_pane")
381+ if action != ActionToggleFloatingPane {
382+ t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)
383+ }
384+}
385+
386+func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
387+ action := mapConfigAction("toggle-floating-pane")
388+ if action != ActionToggleFloatingPane {
389+ t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
390+ }
391+}
M internal/ui/layout/gtk_widgets.go +9 -0
@@ -31,6 +31,7 @@ func (w *gtkWidget) Show() { w.inner.Show() }
31 func (w *gtkWidget) Hide() { w.inner.Hide() }31 func (w *gtkWidget) Hide() { w.inner.Hide() }
32 func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }32 func (w *gtkWidget) SetVisible(visible bool) { w.inner.SetVisible(visible) }
33 func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }33 func (w *gtkWidget) IsVisible() bool { return w.inner.GetVisible() }
34+func (w *gtkWidget) SetOpacity(opacity float64) { w.inner.SetOpacity(opacity) }
34 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }35 func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() }
35 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }36 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
36 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }37 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
@@ -87,6 +88,7 @@ func (p *gtkPaned) Show() { p.inner.Show() }
87 func (p *gtkPaned) Hide() { p.inner.Hide() }88 func (p *gtkPaned) Hide() { p.inner.Hide() }
88 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }89 func (p *gtkPaned) SetVisible(visible bool) { p.inner.SetVisible(visible) }
89 func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible() }90 func (p *gtkPaned) IsVisible() bool { return p.inner.GetVisible() }
91+func (p *gtkPaned) SetOpacity(opacity float64) { p.inner.SetOpacity(opacity) }
90 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }92 func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
91 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }93 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
92 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }94 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
@@ -209,6 +211,7 @@ func (b *gtkBox) Show() { b.inner.Show() }
209 func (b *gtkBox) Hide() { b.inner.Hide() }211 func (b *gtkBox) Hide() { b.inner.Hide() }
210 func (b *gtkBox) SetVisible(visible bool) { b.inner.SetVisible(visible) }212 func (b *gtkBox) SetVisible(visible bool) { b.inner.SetVisible(visible) }
211 func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }213 func (b *gtkBox) IsVisible() bool { return b.inner.GetVisible() }
214+func (b *gtkBox) SetOpacity(opacity float64) { b.inner.SetOpacity(opacity) }
212 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }215 func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
213 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }216 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
214 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }217 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
@@ -315,6 +318,7 @@ func (o *gtkOverlay) Show() { o.inner.Show() }
315 func (o *gtkOverlay) Hide() { o.inner.Hide() }318 func (o *gtkOverlay) Hide() { o.inner.Hide() }
316 func (o *gtkOverlay) SetVisible(visible bool) { o.inner.SetVisible(visible) }319 func (o *gtkOverlay) SetVisible(visible bool) { o.inner.SetVisible(visible) }
317 func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible() }320 func (o *gtkOverlay) IsVisible() bool { return o.inner.GetVisible() }
321+func (o *gtkOverlay) SetOpacity(opacity float64) { o.inner.SetOpacity(opacity) }
318 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }322 func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() }
319 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }323 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
320 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }324 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
@@ -429,6 +433,7 @@ func (l *gtkLabel) Show() { l.inner.Show() }
429 func (l *gtkLabel) Hide() { l.inner.Hide() }433 func (l *gtkLabel) Hide() { l.inner.Hide() }
430 func (l *gtkLabel) SetVisible(visible bool) { l.inner.SetVisible(visible) }434 func (l *gtkLabel) SetVisible(visible bool) { l.inner.SetVisible(visible) }
431 func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible() }435 func (l *gtkLabel) IsVisible() bool { return l.inner.GetVisible() }
436+func (l *gtkLabel) SetOpacity(opacity float64) { l.inner.SetOpacity(opacity) }
432 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }437 func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
433 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }438 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
434 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }439 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
@@ -492,6 +497,7 @@ func (btn *gtkButton) Show() { btn.inner.Show() }
492 func (btn *gtkButton) Hide() { btn.inner.Hide() }497 func (btn *gtkButton) Hide() { btn.inner.Hide() }
493 func (btn *gtkButton) SetVisible(visible bool) { btn.inner.SetVisible(visible) }498 func (btn *gtkButton) SetVisible(visible bool) { btn.inner.SetVisible(visible) }
494 func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisible() }499 func (btn *gtkButton) IsVisible() bool { return btn.inner.GetVisible() }
500+func (btn *gtkButton) SetOpacity(opacity float64) { btn.inner.SetOpacity(opacity) }
495 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }501 func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocus() }
496 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }502 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
497 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }503 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
@@ -575,6 +581,7 @@ func (img *gtkImage) Show() { img.inner.Show() }
575 func (img *gtkImage) Hide() { img.inner.Hide() }581 func (img *gtkImage) Hide() { img.inner.Hide() }
576 func (img *gtkImage) SetVisible(visible bool) { img.inner.SetVisible(visible) }582 func (img *gtkImage) SetVisible(visible bool) { img.inner.SetVisible(visible) }
577 func (img *gtkImage) IsVisible() bool { return img.inner.GetVisible() }583 func (img *gtkImage) IsVisible() bool { return img.inner.GetVisible() }
584+func (img *gtkImage) SetOpacity(opacity float64) { img.inner.SetOpacity(opacity) }
578 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }585 func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus() }
579 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }586 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
580 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }587 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
@@ -647,6 +654,7 @@ func (p *gtkProgressBar) Show() { p.inner.Show() }
647 func (p *gtkProgressBar) Hide() { p.inner.Hide() }654 func (p *gtkProgressBar) Hide() { p.inner.Hide() }
648 func (p *gtkProgressBar) SetVisible(visible bool) { p.inner.SetVisible(visible) }655 func (p *gtkProgressBar) SetVisible(visible bool) { p.inner.SetVisible(visible) }
649 func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisible() }656 func (p *gtkProgressBar) IsVisible() bool { return p.inner.GetVisible() }
657+func (p *gtkProgressBar) SetOpacity(opacity float64) { p.inner.SetOpacity(opacity) }
650 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }658 func (p *gtkProgressBar) GrabFocus() bool { return p.inner.GrabFocus() }
651 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }659 func (p *gtkProgressBar) HasFocus() bool { return p.inner.HasFocus() }
652 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }660 func (p *gtkProgressBar) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
@@ -706,6 +714,7 @@ func (s *gtkSpinner) Show() { s.inner.Show() }
706 func (s *gtkSpinner) Hide() { s.inner.Hide() }714 func (s *gtkSpinner) Hide() { s.inner.Hide() }
707 func (s *gtkSpinner) SetVisible(visible bool) { s.inner.SetVisible(visible) }715 func (s *gtkSpinner) SetVisible(visible bool) { s.inner.SetVisible(visible) }
708 func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible() }716 func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible() }
717+func (s *gtkSpinner) SetOpacity(opacity float64) { s.inner.SetOpacity(opacity) }
709 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }718 func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
710 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }719 func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
711 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }720 func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
M internal/ui/layout/mocks/mocks.go +360 -0
@@ -1028,6 +1028,46 @@ func (_c *MockWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *MockW
1028 return _c1028 return _c
1029 }1029 }
1030 1030
1031+// SetOpacity provides a mock function for the type MockWidget
1032+func (_mock *MockWidget) SetOpacity(opacity float64) {
1033+ _mock.Called(opacity)
1034+ return
1035+}
1036+
1037+// MockWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
1038+type MockWidget_SetOpacity_Call struct {
1039+ *mock.Call
1040+}
1041+
1042+// SetOpacity is a helper method to define mock.On call
1043+// - opacity float64
1044+func (_e *MockWidget_Expecter) SetOpacity(opacity interface{}) *MockWidget_SetOpacity_Call {
1045+ return &MockWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
1046+}
1047+
1048+func (_c *MockWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockWidget_SetOpacity_Call {
1049+ _c.Call.Run(func(args mock.Arguments) {
1050+ var arg0 float64
1051+ if args[0] != nil {
1052+ arg0 = args[0].(float64)
1053+ }
1054+ run(
1055+ arg0,
1056+ )
1057+ })
1058+ return _c
1059+}
1060+
1061+func (_c *MockWidget_SetOpacity_Call) Return() *MockWidget_SetOpacity_Call {
1062+ _c.Call.Return()
1063+ return _c
1064+}
1065+
1066+func (_c *MockWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockWidget_SetOpacity_Call {
1067+ _c.Run(run)
1068+ return _c
1069+}
1070+
1031 // SetSizeRequest provides a mock function for the type MockWidget1071 // SetSizeRequest provides a mock function for the type MockWidget
1032 func (_mock *MockWidget) SetSizeRequest(width int, height int) {1072 func (_mock *MockWidget) SetSizeRequest(width int, height int) {
1033 _mock.Called(width, height)1073 _mock.Called(width, height)
@@ -2746,6 +2786,46 @@ func (_c *MockPanedWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *
2746 return _c2786 return _c
2747 }2787 }
2748 2788
2789+// SetOpacity provides a mock function for the type MockPanedWidget
2790+func (_mock *MockPanedWidget) SetOpacity(opacity float64) {
2791+ _mock.Called(opacity)
2792+ return
2793+}
2794+
2795+// MockPanedWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
2796+type MockPanedWidget_SetOpacity_Call struct {
2797+ *mock.Call
2798+}
2799+
2800+// SetOpacity is a helper method to define mock.On call
2801+// - opacity float64
2802+func (_e *MockPanedWidget_Expecter) SetOpacity(opacity interface{}) *MockPanedWidget_SetOpacity_Call {
2803+ return &MockPanedWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
2804+}
2805+
2806+func (_c *MockPanedWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockPanedWidget_SetOpacity_Call {
2807+ _c.Call.Run(func(args mock.Arguments) {
2808+ var arg0 float64
2809+ if args[0] != nil {
2810+ arg0 = args[0].(float64)
2811+ }
2812+ run(
2813+ arg0,
2814+ )
2815+ })
2816+ return _c
2817+}
2818+
2819+func (_c *MockPanedWidget_SetOpacity_Call) Return() *MockPanedWidget_SetOpacity_Call {
2820+ _c.Call.Return()
2821+ return _c
2822+}
2823+
2824+func (_c *MockPanedWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockPanedWidget_SetOpacity_Call {
2825+ _c.Run(run)
2826+ return _c
2827+}
2828+
2749 // SetPosition provides a mock function for the type MockPanedWidget2829 // SetPosition provides a mock function for the type MockPanedWidget
2750 func (_mock *MockPanedWidget) SetPosition(position int) {2830 func (_mock *MockPanedWidget) SetPosition(position int) {
2751 _mock.Called(position)2831 _mock.Called(position)
@@ -4579,6 +4659,46 @@ func (_c *MockBoxWidget_SetHomogeneous_Call) RunAndReturn(run func(homogeneous b
4579 return _c4659 return _c
4580 }4660 }
4581 4661
4662+// SetOpacity provides a mock function for the type MockBoxWidget
4663+func (_mock *MockBoxWidget) SetOpacity(opacity float64) {
4664+ _mock.Called(opacity)
4665+ return
4666+}
4667+
4668+// MockBoxWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
4669+type MockBoxWidget_SetOpacity_Call struct {
4670+ *mock.Call
4671+}
4672+
4673+// SetOpacity is a helper method to define mock.On call
4674+// - opacity float64
4675+func (_e *MockBoxWidget_Expecter) SetOpacity(opacity interface{}) *MockBoxWidget_SetOpacity_Call {
4676+ return &MockBoxWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
4677+}
4678+
4679+func (_c *MockBoxWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockBoxWidget_SetOpacity_Call {
4680+ _c.Call.Run(func(args mock.Arguments) {
4681+ var arg0 float64
4682+ if args[0] != nil {
4683+ arg0 = args[0].(float64)
4684+ }
4685+ run(
4686+ arg0,
4687+ )
4688+ })
4689+ return _c
4690+}
4691+
4692+func (_c *MockBoxWidget_SetOpacity_Call) Return() *MockBoxWidget_SetOpacity_Call {
4693+ _c.Call.Return()
4694+ return _c
4695+}
4696+
4697+func (_c *MockBoxWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockBoxWidget_SetOpacity_Call {
4698+ _c.Run(run)
4699+ return _c
4700+}
4701+
4582 // SetOrientation provides a mock function for the type MockBoxWidget4702 // SetOrientation provides a mock function for the type MockBoxWidget
4583 func (_mock *MockBoxWidget) SetOrientation(orientation layout.Orientation) {4703 func (_mock *MockBoxWidget) SetOrientation(orientation layout.Orientation) {
4584 _mock.Called(orientation)4704 _mock.Called(orientation)
@@ -6188,6 +6308,46 @@ func (_c *MockOverlayWidget_SetMeasureOverlay_Call) RunAndReturn(run func(overla
6188 return _c6308 return _c
6189 }6309 }
6190 6310
6311+// SetOpacity provides a mock function for the type MockOverlayWidget
6312+func (_mock *MockOverlayWidget) SetOpacity(opacity float64) {
6313+ _mock.Called(opacity)
6314+ return
6315+}
6316+
6317+// MockOverlayWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
6318+type MockOverlayWidget_SetOpacity_Call struct {
6319+ *mock.Call
6320+}
6321+
6322+// SetOpacity is a helper method to define mock.On call
6323+// - opacity float64
6324+func (_e *MockOverlayWidget_Expecter) SetOpacity(opacity interface{}) *MockOverlayWidget_SetOpacity_Call {
6325+ return &MockOverlayWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
6326+}
6327+
6328+func (_c *MockOverlayWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockOverlayWidget_SetOpacity_Call {
6329+ _c.Call.Run(func(args mock.Arguments) {
6330+ var arg0 float64
6331+ if args[0] != nil {
6332+ arg0 = args[0].(float64)
6333+ }
6334+ run(
6335+ arg0,
6336+ )
6337+ })
6338+ return _c
6339+}
6340+
6341+func (_c *MockOverlayWidget_SetOpacity_Call) Return() *MockOverlayWidget_SetOpacity_Call {
6342+ _c.Call.Return()
6343+ return _c
6344+}
6345+
6346+func (_c *MockOverlayWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockOverlayWidget_SetOpacity_Call {
6347+ _c.Run(run)
6348+ return _c
6349+}
6350+
6191 // SetSizeRequest provides a mock function for the type MockOverlayWidget6351 // SetSizeRequest provides a mock function for the type MockOverlayWidget
6192 func (_mock *MockOverlayWidget) SetSizeRequest(width int, height int) {6352 func (_mock *MockOverlayWidget) SetSizeRequest(width int, height int) {
6193 _mock.Called(width, height)6353 _mock.Called(width, height)
@@ -7521,6 +7681,46 @@ func (_c *MockLabelWidget_SetMaxWidthChars_Call) RunAndReturn(run func(nChars in
7521 return _c7681 return _c
7522 }7682 }
7523 7683
7684+// SetOpacity provides a mock function for the type MockLabelWidget
7685+func (_mock *MockLabelWidget) SetOpacity(opacity float64) {
7686+ _mock.Called(opacity)
7687+ return
7688+}
7689+
7690+// MockLabelWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
7691+type MockLabelWidget_SetOpacity_Call struct {
7692+ *mock.Call
7693+}
7694+
7695+// SetOpacity is a helper method to define mock.On call
7696+// - opacity float64
7697+func (_e *MockLabelWidget_Expecter) SetOpacity(opacity interface{}) *MockLabelWidget_SetOpacity_Call {
7698+ return &MockLabelWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
7699+}
7700+
7701+func (_c *MockLabelWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockLabelWidget_SetOpacity_Call {
7702+ _c.Call.Run(func(args mock.Arguments) {
7703+ var arg0 float64
7704+ if args[0] != nil {
7705+ arg0 = args[0].(float64)
7706+ }
7707+ run(
7708+ arg0,
7709+ )
7710+ })
7711+ return _c
7712+}
7713+
7714+func (_c *MockLabelWidget_SetOpacity_Call) Return() *MockLabelWidget_SetOpacity_Call {
7715+ _c.Call.Return()
7716+ return _c
7717+}
7718+
7719+func (_c *MockLabelWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockLabelWidget_SetOpacity_Call {
7720+ _c.Run(run)
7721+ return _c
7722+}
7723+
7524 // SetSizeRequest provides a mock function for the type MockLabelWidget7724 // SetSizeRequest provides a mock function for the type MockLabelWidget
7525 func (_mock *MockLabelWidget) SetSizeRequest(width int, height int) {7725 func (_mock *MockLabelWidget) SetSizeRequest(width int, height int) {
7526 _mock.Called(width, height)7726 _mock.Called(width, height)
@@ -9031,6 +9231,46 @@ func (_c *MockButtonWidget_SetLabel_Call) RunAndReturn(run func(label string)) *
9031 return _c9231 return _c
9032 }9232 }
9033 9233
9234+// SetOpacity provides a mock function for the type MockButtonWidget
9235+func (_mock *MockButtonWidget) SetOpacity(opacity float64) {
9236+ _mock.Called(opacity)
9237+ return
9238+}
9239+
9240+// MockButtonWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
9241+type MockButtonWidget_SetOpacity_Call struct {
9242+ *mock.Call
9243+}
9244+
9245+// SetOpacity is a helper method to define mock.On call
9246+// - opacity float64
9247+func (_e *MockButtonWidget_Expecter) SetOpacity(opacity interface{}) *MockButtonWidget_SetOpacity_Call {
9248+ return &MockButtonWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
9249+}
9250+
9251+func (_c *MockButtonWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockButtonWidget_SetOpacity_Call {
9252+ _c.Call.Run(func(args mock.Arguments) {
9253+ var arg0 float64
9254+ if args[0] != nil {
9255+ arg0 = args[0].(float64)
9256+ }
9257+ run(
9258+ arg0,
9259+ )
9260+ })
9261+ return _c
9262+}
9263+
9264+func (_c *MockButtonWidget_SetOpacity_Call) Return() *MockButtonWidget_SetOpacity_Call {
9265+ _c.Call.Return()
9266+ return _c
9267+}
9268+
9269+func (_c *MockButtonWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockButtonWidget_SetOpacity_Call {
9270+ _c.Run(run)
9271+ return _c
9272+}
9273+
9034 // SetSizeRequest provides a mock function for the type MockButtonWidget9274 // SetSizeRequest provides a mock function for the type MockButtonWidget
9035 func (_mock *MockButtonWidget) SetSizeRequest(width int, height int) {9275 func (_mock *MockButtonWidget) SetSizeRequest(width int, height int) {
9036 _mock.Called(width, height)9276 _mock.Called(width, height)
@@ -10353,6 +10593,46 @@ func (_c *MockImageWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *
10353 return _c10593 return _c
10354 }10594 }
10355 10595
10596+// SetOpacity provides a mock function for the type MockImageWidget
10597+func (_mock *MockImageWidget) SetOpacity(opacity float64) {
10598+ _mock.Called(opacity)
10599+ return
10600+}
10601+
10602+// MockImageWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
10603+type MockImageWidget_SetOpacity_Call struct {
10604+ *mock.Call
10605+}
10606+
10607+// SetOpacity is a helper method to define mock.On call
10608+// - opacity float64
10609+func (_e *MockImageWidget_Expecter) SetOpacity(opacity interface{}) *MockImageWidget_SetOpacity_Call {
10610+ return &MockImageWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
10611+}
10612+
10613+func (_c *MockImageWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockImageWidget_SetOpacity_Call {
10614+ _c.Call.Run(func(args mock.Arguments) {
10615+ var arg0 float64
10616+ if args[0] != nil {
10617+ arg0 = args[0].(float64)
10618+ }
10619+ run(
10620+ arg0,
10621+ )
10622+ })
10623+ return _c
10624+}
10625+
10626+func (_c *MockImageWidget_SetOpacity_Call) Return() *MockImageWidget_SetOpacity_Call {
10627+ _c.Call.Return()
10628+ return _c
10629+}
10630+
10631+func (_c *MockImageWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockImageWidget_SetOpacity_Call {
10632+ _c.Run(run)
10633+ return _c
10634+}
10635+
10356 // SetPixelSize provides a mock function for the type MockImageWidget10636 // SetPixelSize provides a mock function for the type MockImageWidget
10357 func (_mock *MockImageWidget) SetPixelSize(pixelSize int) {10637 func (_mock *MockImageWidget) SetPixelSize(pixelSize int) {
10358 _mock.Called(pixelSize)10638 _mock.Called(pixelSize)
@@ -11646,6 +11926,46 @@ func (_c *MockProgressBarWidget_SetHexpand_Call) RunAndReturn(run func(expand bo
11646 return _c11926 return _c
11647 }11927 }
11648 11928
11929+// SetOpacity provides a mock function for the type MockProgressBarWidget
11930+func (_mock *MockProgressBarWidget) SetOpacity(opacity float64) {
11931+ _mock.Called(opacity)
11932+ return
11933+}
11934+
11935+// MockProgressBarWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
11936+type MockProgressBarWidget_SetOpacity_Call struct {
11937+ *mock.Call
11938+}
11939+
11940+// SetOpacity is a helper method to define mock.On call
11941+// - opacity float64
11942+func (_e *MockProgressBarWidget_Expecter) SetOpacity(opacity interface{}) *MockProgressBarWidget_SetOpacity_Call {
11943+ return &MockProgressBarWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
11944+}
11945+
11946+func (_c *MockProgressBarWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockProgressBarWidget_SetOpacity_Call {
11947+ _c.Call.Run(func(args mock.Arguments) {
11948+ var arg0 float64
11949+ if args[0] != nil {
11950+ arg0 = args[0].(float64)
11951+ }
11952+ run(
11953+ arg0,
11954+ )
11955+ })
11956+ return _c
11957+}
11958+
11959+func (_c *MockProgressBarWidget_SetOpacity_Call) Return() *MockProgressBarWidget_SetOpacity_Call {
11960+ _c.Call.Return()
11961+ return _c
11962+}
11963+
11964+func (_c *MockProgressBarWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockProgressBarWidget_SetOpacity_Call {
11965+ _c.Run(run)
11966+ return _c
11967+}
11968+
11649 // SetSizeRequest provides a mock function for the type MockProgressBarWidget11969 // SetSizeRequest provides a mock function for the type MockProgressBarWidget
11650 func (_mock *MockProgressBarWidget) SetSizeRequest(width int, height int) {11970 func (_mock *MockProgressBarWidget) SetSizeRequest(width int, height int) {
11651 _mock.Called(width, height)11971 _mock.Called(width, height)
@@ -12859,6 +13179,46 @@ func (_c *MockSpinnerWidget_SetHexpand_Call) RunAndReturn(run func(expand bool))
12859 return _c13179 return _c
12860 }13180 }
12861 13181
13182+// SetOpacity provides a mock function for the type MockSpinnerWidget
13183+func (_mock *MockSpinnerWidget) SetOpacity(opacity float64) {
13184+ _mock.Called(opacity)
13185+ return
13186+}
13187+
13188+// MockSpinnerWidget_SetOpacity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetOpacity'
13189+type MockSpinnerWidget_SetOpacity_Call struct {
13190+ *mock.Call
13191+}
13192+
13193+// SetOpacity is a helper method to define mock.On call
13194+// - opacity float64
13195+func (_e *MockSpinnerWidget_Expecter) SetOpacity(opacity interface{}) *MockSpinnerWidget_SetOpacity_Call {
13196+ return &MockSpinnerWidget_SetOpacity_Call{Call: _e.mock.On("SetOpacity", opacity)}
13197+}
13198+
13199+func (_c *MockSpinnerWidget_SetOpacity_Call) Run(run func(opacity float64)) *MockSpinnerWidget_SetOpacity_Call {
13200+ _c.Call.Run(func(args mock.Arguments) {
13201+ var arg0 float64
13202+ if args[0] != nil {
13203+ arg0 = args[0].(float64)
13204+ }
13205+ run(
13206+ arg0,
13207+ )
13208+ })
13209+ return _c
13210+}
13211+
13212+func (_c *MockSpinnerWidget_SetOpacity_Call) Return() *MockSpinnerWidget_SetOpacity_Call {
13213+ _c.Call.Return()
13214+ return _c
13215+}
13216+
13217+func (_c *MockSpinnerWidget_SetOpacity_Call) RunAndReturn(run func(opacity float64)) *MockSpinnerWidget_SetOpacity_Call {
13218+ _c.Run(run)
13219+ return _c
13220+}
13221+
12862 // SetSizeRequest provides a mock function for the type MockSpinnerWidget13222 // SetSizeRequest provides a mock function for the type MockSpinnerWidget
12863 func (_mock *MockSpinnerWidget) SetSizeRequest(width int, height int) {13223 func (_mock *MockSpinnerWidget) SetSizeRequest(width int, height int) {
12864 _mock.Called(width, height)13224 _mock.Called(width, height)
M internal/ui/layout/widgets.go +1 -0
@@ -23,6 +23,7 @@ type Widget interface {
23 Hide()23 Hide()
24 SetVisible(visible bool)24 SetVisible(visible bool)
25 IsVisible() bool25 IsVisible() bool
26+ SetOpacity(opacity float64)
26 27
27 // Focus28 // Focus
28 GrabFocus() bool29 GrabFocus() bool
M internal/ui/theme/css.go +41 -0
@@ -132,6 +132,10 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
132 sb.WriteString(generatePermissionPopupCSS(p))132 sb.WriteString(generatePermissionPopupCSS(p))
133 sb.WriteString("\n")133 sb.WriteString("\n")
134 134
135+ // Floating pane styling
136+ sb.WriteString(generateFloatingPaneCSS(p))
137+ sb.WriteString("\n")
138+
135 // WebRTC permission activity indicator styling139 // WebRTC permission activity indicator styling
136 sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))140 sb.WriteString(generateWebRTCPermissionIndicatorCSS(p))
137 sb.WriteString("\n")141 sb.WriteString("\n")
@@ -968,3 +972,40 @@ func generateLinkStatusCSS(p Palette) string {
968 }972 }
969 `973 `
970 }974 }
975+
976+func generateFloatingPaneCSS(p Palette) string {
977+ _ = p
978+ return `/* ===== Floating Pane Styling ===== */
979+.floating-pane-outer {
980+ /* Positioning handled in Go via overlay alignment and margins. */
981+}
982+
983+.floating-pane-container {
984+ background-color: var(--surface-variant);
985+ border: 0.0625em solid var(--border);
986+ border-radius: 0.25em;
987+ box-shadow: 0 0.5em 1.5em alpha(black, 0.25);
988+ opacity: 0;
989+ transition: opacity 110ms ease-out;
990+}
991+
992+.floating-pane-container.floating-pane-visible {
993+ opacity: 1;
994+}
995+
996+.floating-pane-container.pane-active {
997+ border-color: var(--accent);
998+}
999+
1000+.floating-pane-omnibox {
1001+ background-color: shade(var(--surface-variant), 1.08);
1002+ border-bottom: 0.0625em solid var(--border);
1003+ padding: 0.375em 0.5em;
1004+}
1005+
1006+.floating-pane-content {
1007+ background-color: var(--bg);
1008+ min-height: 12em;
1009+}
1010+`
1011+}

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