diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 00000000..7b53293a --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,58 @@ +name: Publish to AUR + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to publish (without v prefix)" + required: true + +permissions: + contents: read + +jobs: + aur-publish: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Extract version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + else + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + + - name: Generate AUR PKGBUILD + run: | + set -euo pipefail + VERSION="${{ steps.version.outputs.version }}" + TARBALL_URL="https://github.com/am-will/limux/releases/download/v${VERSION}/limux-${VERSION}-linux-x86_64.tar.gz" + + for i in 1 2 3 4 5; do + curl -fL "$TARBALL_URL" -o /tmp/limux.tar.gz && break + echo "Attempt $i failed, retrying in 30s..." + sleep 30 + done + SHA256=$(sha256sum /tmp/limux.tar.gz | awk '{print $1}') + + sed -e "s/@@VERSION@@/$VERSION/" \ + -e "s/@@SHA256@@/$SHA256/" \ + PKGBUILD.template > PKGBUILD + + - name: Publish to AUR + uses: KSXGitHub/github-actions-deploy-aur@v4.1.1 + with: + pkgname: limux-bin + pkgbuild: PKGBUILD + commit_username: "limux-aurbot" + commit_email: limux-aurbot@users.noreply.aur.archlinux.org + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "chore: bump to ${{ steps.version.outputs.version }}" + force_push: true diff --git a/Cargo.lock b/Cargo.lock index 0dbbaed3..e0de9452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -737,7 +737,7 @@ dependencies = [ [[package]] name = "limux-cli" -version = "0.1.6" +version = "0.1.11" dependencies = [ "anyhow", "clap", @@ -749,7 +749,7 @@ dependencies = [ [[package]] name = "limux-control" -version = "0.1.6" +version = "0.1.11" dependencies = [ "anyhow", "clap", @@ -763,7 +763,7 @@ dependencies = [ [[package]] name = "limux-core" -version = "0.1.6" +version = "0.1.11" dependencies = [ "anyhow", "limux-protocol", @@ -775,22 +775,25 @@ dependencies = [ [[package]] name = "limux-ghostty-sys" -version = "0.1.6" +version = "0.1.11" dependencies = [ "cc", ] [[package]] name = "limux-host-linux" -version = "0.1.6" +version = "0.1.11" dependencies = [ "dirs", "gdk4-wayland", "gtk4", "libadwaita", + "limux-control", "limux-ghostty-sys", + "limux-protocol", "serde", "serde_json", + "shell-quote", "tempfile", "uuid", "webkit6", @@ -798,7 +801,7 @@ dependencies = [ [[package]] name = "limux-protocol" -version = "0.1.6" +version = "0.1.11" dependencies = [ "serde", "serde_json", @@ -1037,6 +1040,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shell-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb502615975ae2365825521fa1529ca7648fd03ce0b0746604e0683856ecd7e4" + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 02242056..1127d2b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" authors = ["limux contributors"] edition = "2021" license = "MIT" -version = "0.1.6" +version = "0.1.11" [workspace.dependencies] anyhow = "1.0" diff --git a/PKGBUILD.template b/PKGBUILD.template new file mode 100644 index 00000000..8228d8d8 --- /dev/null +++ b/PKGBUILD.template @@ -0,0 +1,30 @@ +# Maintainer: Anton Barchukov +pkgname=limux-bin +pkgver=@@VERSION@@ +pkgrel=1 +pkgdesc="GPU-accelerated terminal workspace manager for Linux, powered by Ghostty's rendering engine (cmux port)" +arch=('x86_64') +url="https://github.com/am-will/limux" +license=('MIT') +depends=('gtk4' 'libadwaita' 'webkitgtk-6.0') +provides=('limux') +conflicts=('limux') +options=(!debug !strip) +source=("limux-${pkgver}.tar.gz::https://github.com/am-will/limux/releases/download/v${pkgver}/limux-${pkgver}-linux-x86_64.tar.gz") +sha256sums=('@@SHA256@@') + +package() { + cd "limux-${pkgver}-linux-x86_64" + + install -Dm755 limux "${pkgdir}/usr/bin/limux" + install -Dm644 lib/libghostty.so "${pkgdir}/usr/lib/limux/libghostty.so" + + install -Dm644 /dev/stdin "${pkgdir}/etc/ld.so.conf.d/limux.conf" <<< "/usr/lib/limux" + + install -Dm644 share/applications/*.desktop -t "${pkgdir}/usr/share/applications/" + install -Dm644 share/metainfo/*.xml -t "${pkgdir}/usr/share/metainfo/" + + install -dm755 "${pkgdir}/usr/share/icons" + cp -r share/icons/hicolor "${pkgdir}/usr/share/icons/" + cp -r share/limux "${pkgdir}/usr/share/limux" +} diff --git a/README.md b/README.md index a6a35067..17149235 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ Download the latest release from [GitHub Releases](https://github.com/am-will/li **Debian/Ubuntu (.deb)** — recommended: ```bash -sudo dpkg -i ./limux_0.1.6_amd64.deb +sudo dpkg -i ./limux_0.1.7_amd64.deb ``` **AppImage** — portable, no install needed: ```bash -chmod +x Limux-0.1.6-x86_64.AppImage -./Limux-0.1.6-x86_64.AppImage +chmod +x Limux-0.1.7-x86_64.AppImage +./Limux-0.1.7-x86_64.AppImage ``` **Tarball** — manual install: @@ -67,14 +67,19 @@ sudo apt install libgtk-4-1 libadwaita-1-0 libwebkitgtk-6.0-4 ### Prerequisites - Rust toolchain (stable) +- Zig - GTK4, libadwaita, WebKitGTK dev packages -- Pre-built `libghostty.so` (included in releases, or build from the Ghostty submodule with Zig) +- Initialized Ghostty submodule ```bash # Install dev dependencies (Ubuntu/Debian) sudo apt install libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev pkg-config build-essential -# Build +# Initialize the Ghostty submodule and build the embedded library +git submodule update --init --recursive +(cd ghostty && zig build -Dapp-runtime=none -Doptimize=ReleaseFast) + +# Build limux cargo build --release # Run (point to libghostty.so location) @@ -88,6 +93,7 @@ LD_LIBRARY_PATH=../ghostty/zig-out/lib:$LD_LIBRARY_PATH ./target/release/limux ``` This builds the binary, bundles `libghostty.so`, icons, and an install script into a tarball. +`package.sh` also rebuilds `libghostty.so` with `ReleaseFast`, so Zig and the initialized Ghostty submodule must be present. ## Development @@ -101,19 +107,65 @@ Repository maintainability rules live in [`docs/maintainability.md`](docs/mainta ## Keyboard shortcuts +Most default shortcuts use `Ctrl`. Fullscreen defaults to `F11`. Custom remaps may also use `Cmd`, which Limux maps to either the Linux `Meta` or `Super` modifier. `Opt` maps to `Alt`. + +### App + +| Shortcut | Action | +|---|---| +| `Ctrl+Q` | Quit Limux | +| `Ctrl+Alt+N` | Open a new Limux instance | +| `F11` | Toggle fullscreen | + +### Browser + +| Shortcut | Action | +|---|---| +| `Ctrl+Shift+L` | Open the focused browser page in a new split | +| `Ctrl+L` | Focus browser address bar | +| `Ctrl+[` | Browser back | +| `Ctrl+]` | Browser forward | +| `Ctrl+R` | Browser reload | +| `Ctrl+Alt+I` | Open Web Inspector | +| `Ctrl+Alt+C` | Open Web Inspector (console-only targeting is not exposed by WebKitGTK) | + +### Find + +| Shortcut | Action | +|---|---| +| `Ctrl+F` | Open find on the focused terminal or browser | +| `Ctrl+G` | Find next | +| `Ctrl+Shift+G` | Find previous | +| `Ctrl+Shift+F` | Hide find | +| `Ctrl+E` | Use selection for find | + +### Terminal + +| Shortcut | Action | +|---|---| +| `Ctrl+K` | Clear scrollback | +| `Ctrl+Shift+C` | Copy selection | +| `Ctrl+Shift+V` | Paste | +| `Ctrl++` | Increase font size | +| `Ctrl+-` | Decrease font size | +| `Ctrl+Shift+0` | Reset font size | + +### Workspace And Pane + | Shortcut | Action | |---|---| | `Ctrl+Shift+N` | New workspace (folder picker) | | `Ctrl+Shift+W` | Close workspace | | `Ctrl+Shift+Left/Right` | Cycle tabs in focused pane | | `Ctrl+Shift+D` | Split down | -| `Ctrl+Shift+T` | New terminal tab | +| `Ctrl+Shift+T` | New terminal tab in the focused pane | | `Ctrl+D` | Split right | | `Ctrl+W` | Close focused pane | -| `Ctrl+B` | Toggle sidebar | +| `Ctrl+M` | Toggle sidebar | +| `Ctrl+Shift+M` | Toggle top bar | | `Ctrl+T` | New terminal tab | | `Ctrl+Arrow` | Focus pane in direction | -| `Ctrl+PageDown/Up` | Next/prev workspace | +| `Ctrl+PageDown/Up` | Next or previous workspace | | `Ctrl+1-9` | Switch to workspace by number | ## Architecture diff --git a/docs/shortcut-remap-testing.md b/docs/shortcut-remap-testing.md new file mode 100644 index 00000000..016697ca --- /dev/null +++ b/docs/shortcut-remap-testing.md @@ -0,0 +1,482 @@ +# Limux Shortcut Remapping + +This document explains how the Linux host shortcut system works and how to test it manually. + +## What It Does + +Limux has a host-owned shortcut registry in `rust/limux-host-linux/src/shortcut_config.rs`. + +That registry is the single source of truth for: + +- default shortcut bindings +- user overrides from config +- GTK app accelerators +- capture-phase host shortcut dispatch +- visible tooltip text for shortcut-backed UI actions + +Ghostty config is not involved. Ghostty still owns terminal behavior once Limux decides not to intercept a key. + +## Config File Location + +Limux reads shortcuts from: + +```text +~/.config/limux/config.json +``` + +That path comes from `dirs::config_dir()/limux/config.json`. + +If the file is missing, Limux uses built-in defaults. + +## Important Runtime Behavior + +- Shortcuts are loaded at startup. +- When you change them through the terminal `Keybinds` editor, Limux writes the config, reloads it, and applies the new bindings immediately in the running app. +- If you edit `~/.config/limux/config.json` by hand outside the app, restart Limux to pick up those changes. +- If the config file is invalid or unreadable, Limux falls back to defaults and prints a warning to stderr. +- If two active shortcuts resolve to the same binding, Limux rejects the override set and falls back to defaults. +- Unknown shortcut IDs are ignored with a warning. +- `null` or `""` unbinds a shortcut. +- Host shortcuts must use `Ctrl`, `Alt`, or `Cmd` as the base modifier unless the shortcut explicitly allows a bare function key, such as the default `F11` fullscreen binding. `Shift` can be added on top of a modified shortcut. +- Most default shortcuts use `Ctrl`; fullscreen defaults to `F11`. +- `Cmd` is a logical Limux modifier that matches either Linux `Meta` or Linux `Super` for custom remaps. +- App-global shortcuts still fire inside editable widgets, but surface and browser shortcuts bypass editable widgets so native text editing keeps working. + +## Keybinds Editor + +The terminal right-click menu now includes `Keybinds`. + +Selecting it opens a popover editor that: + +- lists every host-owned shortcut +- shows the current binding +- shows the default binding +- lets you click a binding pill to enter listening mode +- closes from the top-right `×` button +- also closes when you click outside the popover + +Capture rules: + +- valid examples: + - `Ctrl+H` + - `Ctrl+Shift+H` + - `Alt+X` + - `Ctrl+L` +- rejected examples: + - plain `H` + - `Shift+H` + - modifier-only keys like `Ctrl` + +If a capture is invalid or duplicates another active shortcut, the row shows an inline error and keeps the previous working binding. + +## Config Format + +Top-level shape: + +```json +{ + "shortcuts": { + "toggle_sidebar": "b", + "split_right": null, + "new_terminal": "" + } +} +``` + +Rules: + +- Keys must be under `"shortcuts"`. +- Values must be either: + - a GTK-style accelerator string like `"n"` + - `null` to unbind + - `""` to unbind +- Omitted keys keep their defaults. + +## Supported Shortcut IDs + +These are the current supported config keys and defaults: + +| Config key | Default | +|---|---| +| `new_workspace` | `n` | +| `close_workspace` | `w` | +| `quit_app` | `q` | +| `new_instance` | `n` | +| `toggle_sidebar` | `m` | +| `toggle_top_bar` | `m` | +| `toggle_fullscreen` | `F11` | +| `next_workspace` | `Page_Down` | +| `prev_workspace` | `Page_Up` | +| `cycle_tab_prev` | `Left` | +| `cycle_tab_next` | `Right` | +| `split_down` | `d` | +| `new_terminal_in_focused_pane` | `t` | +| `split_right` | `d` | +| `close_focused_pane` | `w` | +| `new_terminal` | `t` | +| `focus_left` | `Left` | +| `focus_right` | `Right` | +| `focus_up` | `Up` | +| `focus_down` | `Down` | +| `activate_workspace_1` | `1` | +| `activate_workspace_2` | `2` | +| `activate_workspace_3` | `3` | +| `activate_workspace_4` | `4` | +| `activate_workspace_5` | `5` | +| `activate_workspace_6` | `6` | +| `activate_workspace_7` | `7` | +| `activate_workspace_8` | `8` | +| `activate_last_workspace` | `9` | +| `open_browser_in_split` | `l` | +| `browser_focus_location` | `l` | +| `browser_back` | `bracketleft` | +| `browser_forward` | `bracketright` | +| `browser_reload` | `r` | +| `browser_inspector` | `i` | +| `browser_console` | `c` | +| `surface_find` | `f` | +| `surface_find_next` | `g` | +| `surface_find_previous` | `g` | +| `surface_find_hide` | `f` | +| `surface_use_selection_for_find` | `e` | +| `terminal_clear_scrollback` | `k` | +| `terminal_copy` | `c` | +| `terminal_paste` | `v` | +| `terminal_increase_font_size` | `plus` | +| `terminal_decrease_font_size` | `minus` | +| `terminal_reset_font_size` | `0` | + +## Dispatch Model + +There are two host shortcut paths, both driven by the same resolved registry: + +1. GTK accelerators + - Used for: + - `new_workspace` + - `close_workspace` + - `quit_app` + - `new_instance` + - `toggle_sidebar` + - `toggle_top_bar` + - `toggle_fullscreen` + - `next_workspace` + - `prev_workspace` +2. Capture-phase key dispatch + - Used for everything in the table above, including the GTK-backed actions + - Surface commands resolve the focused pane target first: + - terminal target for Ghostty binding actions + - browser target for WebKit navigation, find, and inspector actions + - `None` when focus is outside a usable pane + +That means a remap changes both the GTK accelerator registration and the capture-phase match. + +## Pass-Through Behavior + +If a key combo does not match a resolved Limux shortcut, Limux does not intercept it and Ghostty receives it. + +That means terminal-native combos like these should pass through unless you explicitly bind them in Limux: + +- `Ctrl+C` +- `Ctrl+L` +- `Ctrl+R` +- plain typing +- `Enter` + +Editable browser fields should also retain native behavior for: + +- `Ctrl+C` +- `Ctrl+V` +- `Ctrl+F` +- `Ctrl+L` +- `Ctrl+R` + +This is the behavior you want when testing that unbound shortcuts stop being stolen by the host. + +## Visible Tooltip Behavior + +These UI surfaces currently reflect shortcut overrides: + +- sidebar collapse button +- sidebar expand button +- pane header buttons for: + - new terminal tab + - split right + - split down + - close pane + +These surfaces do not currently show a shortcut suffix: + +- new browser tab button +- browser navigation buttons (`Back`, `Forward`, `Reload`) +- browser find bar controls + +Note: + +- `new_terminal` and `new_terminal_in_focused_pane` both dispatch to the same terminal-tab creation command today. +- The pane header tooltip uses `new_terminal`, not `new_terminal_in_focused_pane`. + +## Launch Commands + +From the repo root: + +```bash +cargo test -p limux-host-linux +cargo build -p limux-host-linux --features webkit +cargo build -p limux-host-linux --no-default-features +``` + +Run the app for manual testing: + +```bash +LD_LIBRARY_PATH="/home/willr/Applications/cmux-linux/cmux/ghostty/zig-out/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ +cargo run -p limux-host-linux --features webkit --bin limux +``` + +## Manual Test Plan + +### 1. Baseline Defaults + +Remove or move the config file out of the way: + +```bash +trash ~/.config/limux/config.json +``` + +Launch Limux and verify: + +- `Ctrl+M` toggles the sidebar +- `Ctrl+Shift+M` toggles the top bar +- `F11` toggles fullscreen +- `Ctrl+T` opens a terminal tab +- `Ctrl+D` splits right +- `Ctrl+Shift+D` splits down +- `Ctrl+W` closes the focused pane +- `Ctrl+Page_Down` and `Ctrl+Page_Up` switch workspaces +- pane button tooltips show the default shortcut suffixes where applicable +- `Ctrl+Q` quits Limux +- `Ctrl+Alt+N` opens a second Limux instance +- `Ctrl+K` clears terminal scrollback +- `Ctrl+Shift+0` resets terminal font size + +### 2. Remap One Shortcut + +Create: + +```json +{ + "shortcuts": { + "toggle_sidebar": "b" + } +} +``` + +Restart Limux and verify: + +- `Ctrl+Alt+B` toggles the sidebar +- `Ctrl+M` no longer toggles the sidebar + +### 3. Unbind One Shortcut + +Create: + +```json +{ + "shortcuts": { + "split_right": null + } +} +``` + +Restart Limux and verify: + +- `Ctrl+D` no longer triggers split-right in Limux +- the split-right button tooltip no longer shows a shortcut suffix +- in a terminal pane, `Ctrl+D` now reaches the terminal app instead of being intercepted by Limux + +### 4. Verify Pane Tooltip Remap + +Create: + +```json +{ + "shortcuts": { + "new_terminal": "t", + "close_focused_pane": "w" + } +} +``` + +Restart Limux and verify: + +- pane button tooltips show `Ctrl+Alt+T` and `Ctrl+Alt+W` +- `Ctrl+Alt+T` opens a terminal tab +- `Ctrl+T` no longer opens a terminal tab +- `Ctrl+Alt+W` closes the focused pane +- `Ctrl+W` no longer closes the pane + +### 5. Duplicate-Binding Rejection + +Create: + +```json +{ + "shortcuts": { + "toggle_sidebar": "b", + "split_right": "b" + } +} +``` + +Restart Limux from a terminal and verify: + +- Limux prints a warning about duplicate bindings +- Limux falls back to defaults +- `Ctrl+M` toggles the sidebar +- `Ctrl+D` still splits right + +### 6. Open The Keybinds Editor + +Launch Limux, right-click inside a terminal, and verify: + +- the terminal context menu contains `Keybinds` +- clicking `Keybinds` opens the keybind editor popover +- the editor shows a row for every host-owned shortcut +- each row shows both the current binding and the default binding +- clicking the `×` button closes the popover +- clicking outside the popover also closes it + +### 7. Remap From The Editor + +Launch Limux, open terminal `Keybinds`, click the `Split Right` binding, and press `Ctrl+H`. + +Verify: + +- the `Split Right` row updates to `Ctrl+H` +- `~/.config/limux/config.json` contains the `split_right` override +- `Ctrl+H` splits right immediately without restarting Limux +- `Ctrl+D` no longer splits right +- the pane header split-right tooltip now shows `Ctrl+H` + +### 8. Editor Validation + +Launch Limux, open terminal `Keybinds`, and try these invalid captures on any row: + +- press only `Shift+H` +- press only `Ctrl` +- assign a combo already used by another shortcut + +Verify: + +- the row shows an inline error +- the previous binding remains visible after the error +- the running app keeps the old working shortcut + +### 9. Unknown ID Handling + +Create: + +```json +{ + "shortcuts": { + "toggle_sidebar": "b", + "not_a_real_shortcut": "x" + } +} +``` + +Restart and verify: + +- Limux warns that the unknown ID was ignored +- `toggle_sidebar` still remaps correctly + +### 10. Invalid JSON Fallback + +Write invalid JSON: + +```json +{ this is not valid json +``` + +Restart Limux from a terminal and verify: + +- Limux prints a warning +- Limux falls back to defaults +- default shortcuts work again + +### 11. Cmd Alias Policy + +Create: + +```json +{ + "shortcuts": { + "browser_focus_location": "l" + } +} +``` + +Restart Limux and verify: + +- the keybind editor displays `Cmd+L` +- either the physical `Meta+L` or `Super+L` combination focuses the browser address bar + +### 12. Editable Widget Bypass + +Launch a browser tab and verify: + +- `Ctrl+L` focuses the address bar when the page has focus +- `Ctrl+L` is not stolen once the address bar already has focus +- `Ctrl+R` reloads only when the page has focus +- `Ctrl+C` and `Ctrl+V` keep native copy and paste inside the address bar and browser find field +- sidebar rename entries keep native text-editing behavior for `Ctrl+C` and `Ctrl+V` + +### 13. Focused Surface Dispatch + +Verify with a terminal tab focused: + +- `Ctrl+F` opens terminal search +- `Ctrl+G` and `Ctrl+Shift+G` move through terminal search results +- `Ctrl+E` uses the current terminal selection for search +- `Ctrl+K`, `Ctrl+Shift+C`, `Ctrl+Shift+V`, `Ctrl++`, `Ctrl+-`, and `Ctrl+Shift+0` affect only the terminal + +Verify with a browser tab focused: + +- `Ctrl+F` opens the browser find bar +- `Ctrl+G` and `Ctrl+Shift+G` move through browser find results +- `Ctrl+Shift+F` hides the browser find bar and returns focus to the page +- `Ctrl+E` seeds browser find from the current DOM selection when page text is selected +- terminal shortcuts like `Ctrl+K` do not fire on the browser + +### 14. Browser Navigation And Devtools + +Verify with a browser tab focused: + +- `Ctrl+[` navigates back +- `Ctrl+]` navigates forward +- `Ctrl+R` reloads +- `Ctrl+Alt+I` opens Web Inspector +- `Ctrl+Alt+C` also opens Web Inspector because WebKitGTK does not expose a console-only shortcut target +- `Ctrl+Shift+L` opens a new split with a browser tab + +## Good Test Cases + +If you only want a short smoke test, do these three: + +1. Remap `toggle_sidebar` to `b` +2. Unbind `split_right` +3. Remap `new_terminal` to `t` + +That covers: + +- GTK accelerators +- capture-phase dispatch +- visible tooltips +- old-binding disablement +- pass-through after unbind + +## Relevant Source Files + +- `rust/limux-host-linux/src/shortcut_config.rs` +- `rust/limux-host-linux/src/main.rs` +- `rust/limux-host-linux/src/window.rs` +- `rust/limux-host-linux/src/pane.rs` diff --git a/ghostty b/ghostty index cee28ebf..81ab8ffa 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit cee28ebfe02f50a58efcc6e6f7f3bd48adde1d09 +Subproject commit 81ab8ffa90185221782baf785e85387321e16f8d diff --git a/rust/limux-cli/src/main.rs b/rust/limux-cli/src/main.rs index 454c8a91..f5674ca4 100644 --- a/rust/limux-cli/src/main.rs +++ b/rust/limux-cli/src/main.rs @@ -696,19 +696,6 @@ async fn run_new_workspace(client: &mut Client, args: &[String]) -> Result(source: &'a str, start: &str, end: &str) -> Option<&'a str> { Some(value) } -fn extract_osc99_payload(command: &str, prefix: &str) -> Option { - let (_, rest) = command.split_once(prefix)?; +fn slice_after_first_match<'a>(source: &'a str, patterns: &[&str]) -> Option<&'a str> { + patterns + .iter() + .filter_map(|pattern| source.find(pattern).map(|index| (index, pattern.len()))) + .min_by_key(|(index, _)| *index) + .map(|(index, len)| &source[index + len..]) +} + +fn first_match_index(source: &str, patterns: &[&str]) -> Option { + patterns + .iter() + .filter_map(|pattern| source.find(pattern)) + .min() +} + +fn extract_osc_payload(command: &str, prefixes: &[&str]) -> Option { + const OSC_TERMINATORS: [&str; 5] = ["\u{7}", "\u{1b}\\", "\\x07", "\\x1b\\", "\\x1b\\\\"]; + + let rest = slice_after_first_match(command, prefixes)?; let mut payload = rest; - if let Some((value, _)) = payload.split_once("\\x1b\\") { - payload = value; - } else if let Some((value, _)) = payload.split_once("\\x1b\\\\") { - payload = value; + if let Some(index) = first_match_index(payload, &OSC_TERMINATORS) { + payload = &payload[..index]; } else if let Some((value, _)) = payload.split_once('\'') { payload = value; } @@ -3526,7 +3541,18 @@ fn maybe_emit_osc_notification( surface_id: u64, command: &str, ) { - if let Some(title) = extract_osc99_payload(command, "\\x1b]99;;") { + if let Some(body) = extract_osc_payload(command, &["\u{1b}]9;", "\\x1b]9;"]) { + let _ = state.create_notification( + String::new(), + String::new(), + body, + Some(surface_id), + Some(workspace_id), + ); + return; + } + + if let Some(title) = extract_osc_payload(command, &["\u{1b}]99;;", "\\x1b]99;;"]) { let _ = state.create_notification( title, String::new(), @@ -3537,7 +3563,13 @@ fn maybe_emit_osc_notification( return; } - if let Some(title) = extract_osc99_payload(command, "\\x1b]99;i=kitty:d=0:p=title;") { + if let Some(title) = extract_osc_payload( + command, + &[ + "\u{1b}]99;i=kitty:d=0:p=title;", + "\\x1b]99;i=kitty:d=0:p=title;", + ], + ) { let entry = state .kitty_notification_chunks .entry(surface_id) @@ -3546,7 +3578,10 @@ fn maybe_emit_osc_notification( return; } - if let Some(body) = extract_osc99_payload(command, "\\x1b]99;i=kitty:p=body;") { + if let Some(body) = extract_osc_payload( + command, + &["\u{1b}]99;i=kitty:p=body;", "\\x1b]99;i=kitty:p=body;"], + ) { let entry = state .kitty_notification_chunks .remove(&surface_id) @@ -6436,6 +6471,52 @@ mod tests { ); } + #[test] + fn osc9_desktop_notifications_become_limux_notifications() { + let mut state = ControlState::default(); + let workspace_id = state.current_workspace_id; + let surface_id = state.workspaces[0].windows[0].panes[0].surfaces[0].id; + + maybe_emit_osc_notification( + &mut state, + workspace_id, + surface_id, + "\\x1b]9;Codex turn complete\\x07", + ); + + assert_eq!(state.notifications.len(), 1); + let notification = &state.notifications[0]; + assert_eq!(notification.title, ""); + assert_eq!(notification.body, "Codex turn complete"); + assert_eq!(notification.message, "Codex turn complete"); + assert_eq!(notification.surface_id, Some(surface_id)); + assert_eq!(notification.workspace_id, Some(workspace_id)); + assert!(notification.unread); + } + + #[test] + fn osc9_control_byte_notifications_become_limux_notifications() { + let mut state = ControlState::default(); + let workspace_id = state.current_workspace_id; + let surface_id = state.workspaces[0].windows[0].panes[0].surfaces[0].id; + + maybe_emit_osc_notification( + &mut state, + workspace_id, + surface_id, + "\u{1b}]9;Codex turn complete\u{7}", + ); + + assert_eq!(state.notifications.len(), 1); + let notification = &state.notifications[0]; + assert_eq!(notification.title, ""); + assert_eq!(notification.body, "Codex turn complete"); + assert_eq!(notification.message, "Codex turn complete"); + assert_eq!(notification.surface_id, Some(surface_id)); + assert_eq!(notification.workspace_id, Some(workspace_id)); + assert!(notification.unread); + } + #[tokio::test] async fn dispatcher_handles_browser_p0_flow() { let dispatcher = Dispatcher::new(); diff --git a/rust/limux-ghostty-sys/src/lib.rs b/rust/limux-ghostty-sys/src/lib.rs index 72493f17..a384b971 100644 --- a/rust/limux-ghostty-sys/src/lib.rs +++ b/rust/limux-ghostty-sys/src/lib.rs @@ -68,10 +68,48 @@ pub const GHOSTTY_ACTION_SET_TITLE: c_int = 32; pub const GHOSTTY_ACTION_PWD: c_int = 34; pub const GHOSTTY_ACTION_MOUSE_SHAPE: c_int = 35; pub const GHOSTTY_ACTION_COLOR_CHANGE: c_int = 45; +pub const GHOSTTY_ACTION_RELOAD_CONFIG: c_int = 46; +pub const GHOSTTY_ACTION_CONFIG_CHANGE: c_int = 47; pub const GHOSTTY_ACTION_CLOSE_WINDOW: c_int = 48; pub const GHOSTTY_ACTION_RING_BELL: c_int = 49; pub const GHOSTTY_ACTION_SHOW_CHILD_EXITED: c_int = 54; +// Mouse shape — values must match ghostty_action_mouse_shape_e in ghostty.h +pub const GHOSTTY_MOUSE_SHAPE_DEFAULT: c_int = 0; +pub const GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: c_int = 1; +pub const GHOSTTY_MOUSE_SHAPE_HELP: c_int = 2; +pub const GHOSTTY_MOUSE_SHAPE_POINTER: c_int = 3; +pub const GHOSTTY_MOUSE_SHAPE_PROGRESS: c_int = 4; +pub const GHOSTTY_MOUSE_SHAPE_WAIT: c_int = 5; +pub const GHOSTTY_MOUSE_SHAPE_CELL: c_int = 6; +pub const GHOSTTY_MOUSE_SHAPE_CROSSHAIR: c_int = 7; +pub const GHOSTTY_MOUSE_SHAPE_TEXT: c_int = 8; +pub const GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: c_int = 9; +pub const GHOSTTY_MOUSE_SHAPE_ALIAS: c_int = 10; +pub const GHOSTTY_MOUSE_SHAPE_COPY: c_int = 11; +pub const GHOSTTY_MOUSE_SHAPE_MOVE: c_int = 12; +pub const GHOSTTY_MOUSE_SHAPE_NO_DROP: c_int = 13; +pub const GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: c_int = 14; +pub const GHOSTTY_MOUSE_SHAPE_GRAB: c_int = 15; +pub const GHOSTTY_MOUSE_SHAPE_GRABBING: c_int = 16; +pub const GHOSTTY_MOUSE_SHAPE_ALL_SCROLL: c_int = 17; +pub const GHOSTTY_MOUSE_SHAPE_COL_RESIZE: c_int = 18; +pub const GHOSTTY_MOUSE_SHAPE_ROW_RESIZE: c_int = 19; +pub const GHOSTTY_MOUSE_SHAPE_N_RESIZE: c_int = 20; +pub const GHOSTTY_MOUSE_SHAPE_E_RESIZE: c_int = 21; +pub const GHOSTTY_MOUSE_SHAPE_S_RESIZE: c_int = 22; +pub const GHOSTTY_MOUSE_SHAPE_W_RESIZE: c_int = 23; +pub const GHOSTTY_MOUSE_SHAPE_NE_RESIZE: c_int = 24; +pub const GHOSTTY_MOUSE_SHAPE_NW_RESIZE: c_int = 25; +pub const GHOSTTY_MOUSE_SHAPE_SE_RESIZE: c_int = 26; +pub const GHOSTTY_MOUSE_SHAPE_SW_RESIZE: c_int = 27; +pub const GHOSTTY_MOUSE_SHAPE_EW_RESIZE: c_int = 28; +pub const GHOSTTY_MOUSE_SHAPE_NS_RESIZE: c_int = 29; +pub const GHOSTTY_MOUSE_SHAPE_NESW_RESIZE: c_int = 30; +pub const GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE: c_int = 31; +pub const GHOSTTY_MOUSE_SHAPE_ZOOM_IN: c_int = 32; +pub const GHOSTTY_MOUSE_SHAPE_ZOOM_OUT: c_int = 33; + // Key codes (W3C UIEvents, subset) pub const GHOSTTY_KEY_UNIDENTIFIED: c_int = 0; // Writing System Keys @@ -254,6 +292,16 @@ pub struct ghostty_clipboard_content_s { pub data: *const c_char, } +#[repr(C)] +pub struct ghostty_text_s { + pub tl_px_x: f64, + pub tl_px_y: f64, + pub offset_start: u32, + pub offset_len: u32, + pub text: *const c_char, + pub text_len: usize, +} + // Target #[repr(C)] pub struct ghostty_target_s { @@ -281,12 +329,22 @@ pub struct ghostty_action_s { // Must be exactly 24 bytes to match the C union. #[repr(C)] pub union ghostty_action_u { + pub desktop_notification: ghostty_action_desktop_notification_s, pub set_title: ghostty_action_set_title_s, pub pwd: ghostty_action_pwd_s, pub child_exited: ghostty_surface_message_childexited_s, + pub mouse_shape: c_int, + pub config_change: ghostty_action_config_change_s, _padding: [u8; 24], } +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_desktop_notification_s { + pub title: *const c_char, + pub body: *const c_char, +} + #[repr(C)] #[derive(Clone, Copy)] pub struct ghostty_action_set_title_s { @@ -306,10 +364,17 @@ pub struct ghostty_surface_message_childexited_s { pub runtime_ms: u64, } +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ghostty_action_config_change_s { + pub config: ghostty_config_t, +} + // Runtime config (callbacks) pub type ghostty_runtime_wakeup_cb = unsafe extern "C" fn(*mut c_void); pub type ghostty_runtime_action_cb = unsafe extern "C" fn(ghostty_app_t, ghostty_target_s, ghostty_action_s) -> bool; +pub type ghostty_runtime_clipboard_has_text_cb = unsafe extern "C" fn(*mut c_void, c_int) -> bool; pub type ghostty_runtime_read_clipboard_cb = unsafe extern "C" fn(*mut c_void, c_int, *mut c_void); pub type ghostty_runtime_confirm_read_clipboard_cb = unsafe extern "C" fn(*mut c_void, *const c_char, *mut c_void, c_int); @@ -323,6 +388,7 @@ pub struct ghostty_runtime_config_s { pub supports_selection_clipboard: bool, pub wakeup_cb: ghostty_runtime_wakeup_cb, pub action_cb: ghostty_runtime_action_cb, + pub clipboard_has_text_cb: ghostty_runtime_clipboard_has_text_cb, pub read_clipboard_cb: ghostty_runtime_read_clipboard_cb, pub confirm_read_clipboard_cb: ghostty_runtime_confirm_read_clipboard_cb, pub write_clipboard_cb: ghostty_runtime_write_clipboard_cb, @@ -349,6 +415,12 @@ extern "C" { pub fn ghostty_config_load_default_files(config: ghostty_config_t); pub fn ghostty_config_load_recursive_files(config: ghostty_config_t); pub fn ghostty_config_finalize(config: ghostty_config_t); + pub fn ghostty_config_get( + config: ghostty_config_t, + out: *mut c_void, + key: *const c_char, + key_len: usize, + ) -> bool; // App pub fn ghostty_app_new( @@ -357,6 +429,7 @@ extern "C" { ) -> ghostty_app_t; pub fn ghostty_app_free(app: ghostty_app_t); pub fn ghostty_app_tick(app: ghostty_app_t); + pub fn ghostty_app_update_config(app: ghostty_app_t, config: ghostty_config_t); pub fn ghostty_app_set_focus(app: ghostty_app_t, focused: bool); pub fn ghostty_app_set_color_scheme(app: ghostty_app_t, scheme: c_int); @@ -379,6 +452,7 @@ extern "C" { pub fn ghostty_surface_size(surface: ghostty_surface_t) -> ghostty_surface_size_s; pub fn ghostty_surface_key(surface: ghostty_surface_t, event: ghostty_input_key_s) -> bool; pub fn ghostty_surface_text(surface: ghostty_surface_t, text: *const c_char, len: usize); + pub fn ghostty_surface_preedit(surface: ghostty_surface_t, text: *const c_char, len: usize); pub fn ghostty_surface_mouse_button( surface: ghostty_surface_t, state: c_int, @@ -387,7 +461,15 @@ extern "C" { ) -> bool; pub fn ghostty_surface_mouse_pos(surface: ghostty_surface_t, x: f64, y: f64, mods: c_int); pub fn ghostty_surface_mouse_scroll(surface: ghostty_surface_t, x: f64, y: f64, mods: c_int); + pub fn ghostty_surface_ime_point( + surface: ghostty_surface_t, + x: *mut f64, + y: *mut f64, + width: *mut f64, + height: *mut f64, + ); pub fn ghostty_surface_request_close(surface: ghostty_surface_t); + pub fn ghostty_surface_update_config(surface: ghostty_surface_t, config: ghostty_config_t); pub fn ghostty_surface_set_color_scheme(surface: ghostty_surface_t, scheme: c_int); // Binding actions @@ -397,6 +479,11 @@ extern "C" { action_len: usize, ) -> bool; pub fn ghostty_surface_has_selection(surface: ghostty_surface_t) -> bool; + pub fn ghostty_surface_read_selection( + surface: ghostty_surface_t, + text: *mut ghostty_text_s, + ) -> bool; + pub fn ghostty_surface_free_text(surface: ghostty_surface_t, text: *mut ghostty_text_s); // Clipboard pub fn ghostty_surface_complete_clipboard_request( diff --git a/rust/limux-host-linux/Cargo.toml b/rust/limux-host-linux/Cargo.toml index 3218af4b..3f64c26e 100644 --- a/rust/limux-host-linux/Cargo.toml +++ b/rust/limux-host-linux/Cargo.toml @@ -19,10 +19,13 @@ gdk4-wayland = "0.11" libadwaita = "0.9" uuid = { version = "1", features = ["v4"] } webkit6 = { version = "0.6", optional = true } +limux-control = { path = "../limux-control" } +limux-protocol = { path = "../limux-protocol" } limux-ghostty-sys = { path = "../limux-ghostty-sys" } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +shell-quote = { version = "0.7.2", default-features = false, features = ["bash"] } [dev-dependencies] tempfile.workspace = true diff --git a/rust/limux-host-linux/icons/app/128.png b/rust/limux-host-linux/icons/app/128.png index 713a81f1..34192bba 100644 Binary files a/rust/limux-host-linux/icons/app/128.png and b/rust/limux-host-linux/icons/app/128.png differ diff --git a/rust/limux-host-linux/icons/app/16.png b/rust/limux-host-linux/icons/app/16.png index f7fc3199..20f65a4d 100644 Binary files a/rust/limux-host-linux/icons/app/16.png and b/rust/limux-host-linux/icons/app/16.png differ diff --git a/rust/limux-host-linux/icons/app/256.png b/rust/limux-host-linux/icons/app/256.png index 7028d73c..0bcb0745 100644 Binary files a/rust/limux-host-linux/icons/app/256.png and b/rust/limux-host-linux/icons/app/256.png differ diff --git a/rust/limux-host-linux/icons/app/32.png b/rust/limux-host-linux/icons/app/32.png index ae5aa984..6f3c383f 100644 Binary files a/rust/limux-host-linux/icons/app/32.png and b/rust/limux-host-linux/icons/app/32.png differ diff --git a/rust/limux-host-linux/icons/app/512.png b/rust/limux-host-linux/icons/app/512.png index b3393bcd..4c1dd600 100644 Binary files a/rust/limux-host-linux/icons/app/512.png and b/rust/limux-host-linux/icons/app/512.png differ diff --git a/rust/limux-host-linux/src/app_config.rs b/rust/limux-host-linux/src/app_config.rs new file mode 100644 index 00000000..7525f38b --- /dev/null +++ b/rust/limux-host-linux/src/app_config.rs @@ -0,0 +1,636 @@ +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::shortcut_config; + +pub const SETTINGS_FILE_NAME: &str = "settings.json"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorScheme { + #[default] + System, + Dark, + Light, +} + +impl ColorScheme { + pub fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::Dark => "dark", + Self::Light => "light", + } + } + + fn from_str(s: &str) -> Option { + match s { + "system" => Some(Self::System), + "dark" => Some(Self::Dark), + "light" => Some(Self::Light), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WindowControlsSide { + Left, + #[default] + Right, +} + +impl WindowControlsSide { + pub fn as_str(self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + } + } + + fn from_str(s: &str) -> Option { + match s { + "left" => Some(Self::Left), + "right" => Some(Self::Right), + _ => None, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct AppConfig { + #[serde(default)] + pub focus: FocusConfig, + #[serde(skip)] + pub appearance: AppearanceConfig, + #[serde(skip)] + pub interface: InterfaceConfig, + #[serde(skip)] + pub font_size: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AppearanceConfig { + pub color_scheme: ColorScheme, + pub ghostty_color_scheme: ColorScheme, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InterfaceConfig { + pub window_controls_side: WindowControlsSide, + pub show_top_bar: bool, + pub show_workspace_indicators: bool, +} + +impl Default for InterfaceConfig { + fn default() -> Self { + Self { + window_controls_side: WindowControlsSide::default(), + show_top_bar: true, + show_workspace_indicators: true, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +pub struct FocusConfig { + #[serde(default)] + pub hover_terminal_focus: bool, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LoadedAppConfig { + pub config: AppConfig, + pub warnings: Vec, +} + +pub fn load() -> LoadedAppConfig { + let Some(path) = settings_path() else { + let mut loaded = LoadedAppConfig::default(); + loaded + .warnings + .push("config_dir unavailable; using default app settings".to_string()); + return loaded; + }; + + if let Err(err) = ensure_default_config_file(&path) { + let mut loaded = LoadedAppConfig::default(); + loaded.warnings.push(format!( + "failed to create default app config `{}`: {err}", + path.display() + )); + return loaded; + } + + load_from_path(&path) +} + +pub fn settings_path() -> Option { + shortcut_config::config_dir_path().map(|dir| dir.join(SETTINGS_FILE_NAME)) +} + +#[cfg(test)] +pub fn settings_path_in(base: &Path) -> std::path::PathBuf { + shortcut_config::config_dir_path_in(base).join(SETTINGS_FILE_NAME) +} + +pub fn load_from_path(path: &Path) -> LoadedAppConfig { + if !path.exists() { + return LoadedAppConfig::default(); + } + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) => { + let mut loaded = LoadedAppConfig::default(); + loaded.warnings.push(format!( + "failed to read app config `{}`: {err}", + path.display() + )); + return loaded; + } + }; + + match serde_json::from_str::(&raw) { + Ok(root) => LoadedAppConfig { + config: parse_app_config_value(&root), + warnings: Vec::new(), + }, + Err(err) => { + let mut loaded = LoadedAppConfig::default(); + loaded.warnings.push(format!( + "failed to load app config `{}`: {err}", + path.display() + )); + loaded + } + } +} + +fn parse_app_config_value(root: &Value) -> AppConfig { + let hover_terminal_focus = root + .get("focus") + .and_then(Value::as_object) + .and_then(|focus| focus.get("hover_terminal_focus")) + .and_then(Value::as_bool) + .unwrap_or(false); + + let appearance = root.get("appearance").and_then(Value::as_object); + + let color_scheme = appearance + .and_then(|appearance| appearance.get("color_scheme")) + .and_then(Value::as_str) + .and_then(ColorScheme::from_str) + .unwrap_or_default(); + + let ghostty_color_scheme = appearance + .and_then(|appearance| appearance.get("ghostty_color_scheme")) + .and_then(Value::as_str) + .and_then(ColorScheme::from_str) + .unwrap_or(color_scheme); + + let font_size = root + .get("font_size") + .and_then(Value::as_f64) + .map(|v| v as f32) + .filter(|v| (1.0..=255.0).contains(v)); + + let interface_obj = root.get("interface").and_then(Value::as_object); + + let window_controls_side = interface_obj + .and_then(|interface| interface.get("window_controls_side")) + .and_then(Value::as_str) + .and_then(WindowControlsSide::from_str) + .unwrap_or_default(); + + let show_top_bar = interface_obj + .and_then(|interface| interface.get("show_top_bar")) + .and_then(Value::as_bool) + .unwrap_or(true); + + let show_workspace_indicators = interface_obj + .and_then(|interface| interface.get("show_workspace_indicators")) + .and_then(Value::as_bool) + .unwrap_or(true); + + AppConfig { + focus: FocusConfig { + hover_terminal_focus, + }, + appearance: AppearanceConfig { + color_scheme, + ghostty_color_scheme, + }, + interface: InterfaceConfig { + window_controls_side, + show_top_bar, + show_workspace_indicators, + }, + font_size, + } +} + +pub fn save(config: &AppConfig) -> Result<(), String> { + let Some(path) = settings_path() else { + return Err("config_dir unavailable; cannot save app settings".to_string()); + }; + + save_to_path(&path, config) + .map_err(|err| format!("failed to save app config `{}`: {err}", path.display())) +} + +fn save_to_path(path: &Path, config: &AppConfig) -> Result<(), String> { + let mut root = read_existing_config_root_for_save(path)?; + + root.insert( + "appearance".to_string(), + json!({ + "color_scheme": config.appearance.color_scheme.as_str(), + "ghostty_color_scheme": config.appearance.ghostty_color_scheme.as_str(), + }), + ); + root.insert( + "focus".to_string(), + json!({ "hover_terminal_focus": config.focus.hover_terminal_focus }), + ); + root.insert( + "interface".to_string(), + json!({ + "window_controls_side": config.interface.window_controls_side.as_str(), + "show_top_bar": config.interface.show_top_bar, + "show_workspace_indicators": config.interface.show_workspace_indicators, + }), + ); + + if let Some(size) = config.font_size { + root.insert("font_size".to_string(), json!(size)); + } else { + root.remove("font_size"); + } + + let serialized = + serde_json::to_string_pretty(&Value::Object(root)).expect("config should serialize"); + write_config_root_atomically(path, &serialized) +} + +pub fn clear_font_size() -> Result<(), String> { + let Some(path) = settings_path() else { + return Err("config_dir unavailable; cannot clear font size".to_string()); + }; + + let mut root = read_existing_config_root_for_save(&path) + .map_err(|err| format!("failed to clear font size in `{}`: {err}", path.display()))?; + + root.remove("font_size"); + + let serialized = + serde_json::to_string_pretty(&Value::Object(root)).expect("config should serialize"); + write_config_root_atomically(&path, &serialized) + .map_err(|err| format!("failed to clear font size in `{}`: {err}", path.display())) +} + +pub fn save_font_size(font_size: f32) -> Result<(), String> { + let Some(path) = settings_path() else { + return Err("config_dir unavailable; cannot save font size".to_string()); + }; + + let mut root = read_existing_config_root_for_save(&path) + .map_err(|err| format!("failed to save font size to `{}`: {err}", path.display()))?; + + root.insert("font_size".to_string(), json!(font_size)); + + let serialized = + serde_json::to_string_pretty(&Value::Object(root)).expect("config should serialize"); + write_config_root_atomically(&path, &serialized) + .map_err(|err| format!("failed to save font size to `{}`: {err}", path.display())) +} + +fn read_existing_config_root_for_save( + path: &Path, +) -> Result, String> { + if !path.exists() { + return Ok(serde_json::Map::new()); + } + + let raw = fs::read_to_string(path).map_err(|err| err.to_string())?; + match serde_json::from_str::(&raw) { + Ok(Value::Object(map)) => Ok(map), + Ok(_) => { + backup_invalid_existing_config(path)?; + Ok(serde_json::Map::new()) + } + Err(err) => { + let detail = format!("existing app config is invalid JSON: {err}"); + backup_invalid_existing_config_with_detail(path, &detail)?; + Ok(serde_json::Map::new()) + } + } +} + +fn backup_invalid_existing_config(path: &Path) -> Result<(), String> { + backup_invalid_existing_config_with_detail( + path, + "existing app config root must be a JSON object", + ) +} + +fn backup_invalid_existing_config_with_detail(path: &Path, detail: &str) -> Result<(), String> { + let backup_path = invalid_config_backup_path(path); + fs::rename(path, &backup_path).map_err(|err| { + format!( + "{detail}; failed to back up `{}` to `{}`: {err}", + path.display(), + backup_path.display() + ) + })?; + eprintln!( + "limux: {detail}; backed up `{}` to `{}` before rewriting settings", + path.display(), + backup_path.display() + ); + Ok(()) +} + +fn write_config_root_atomically(path: &Path, serialized: &str) -> Result<(), String> { + let Some(parent) = path.parent() else { + return Err("config path has no parent directory".to_string()); + }; + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + + let temp_path = temp_config_path(path); + fs::write(&temp_path, format!("{serialized}\n")).map_err(|err| err.to_string())?; + + if let Err(err) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(err.to_string()); + } + + Ok(()) +} + +fn temp_config_path(path: &Path) -> std::path::PathBuf { + timestamped_sibling_path(path, "tmp") +} + +fn invalid_config_backup_path(path: &Path) -> std::path::PathBuf { + timestamped_sibling_path(path, "bak") +} + +fn timestamped_sibling_path(path: &Path, suffix: &str) -> std::path::PathBuf { + let stem = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("settings.json"); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let file_name = format!(".{stem}.{suffix}-{}-{nonce}", std::process::id()); + path.with_file_name(file_name) +} + +fn ensure_default_config_file(path: &Path) -> std::io::Result<()> { + if path.exists() { + return Ok(()); + } + + let Some(parent) = path.parent() else { + return Ok(()); + }; + + fs::create_dir_all(parent)?; + let default_root = json!({ + "appearance": { + "color_scheme": "dark", + "ghostty_color_scheme": "dark" + }, + "focus": { + "hover_terminal_focus": false + } + }); + let serialized = serde_json::to_string_pretty(&default_root) + .expect("default app config should always serialize"); + fs::write(path, format!("{serialized}\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::ffi::OsString; + + use tempfile::TempDir; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = self.previous.as_ref() { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } + } + + #[test] + fn load_from_path_uses_defaults_when_file_is_missing() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + + let loaded = load_from_path(&path); + + assert_eq!(loaded, LoadedAppConfig::default()); + } + + #[test] + fn settings_path_in_uses_limux_settings_json() { + let path = settings_path_in(Path::new("/tmp/example")); + + assert_eq!(path, Path::new("/tmp/example/limux/settings.json")); + } + + #[test] + fn ensure_default_config_file_writes_dark_appearance_and_opt_in_false_setting() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + + ensure_default_config_file(&path).expect("write default config"); + + let raw = fs::read_to_string(&path).expect("read config"); + let parsed: Value = serde_json::from_str(&raw).expect("parse config"); + assert_eq!(parsed["focus"]["hover_terminal_focus"], Value::Bool(false)); + assert_eq!( + parsed["appearance"]["color_scheme"], + Value::String("dark".to_string()) + ); + assert_eq!( + parsed["appearance"]["ghostty_color_scheme"], + Value::String("dark".to_string()) + ); + } + + #[test] + fn load_from_path_reads_focus_settings_and_ignores_other_sections() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write( + &path, + r#"{ + "focus": { + "hover_terminal_focus": true + } +} +"#, + ) + .expect("write config"); + + let loaded = load_from_path(&path); + + assert!(loaded.warnings.is_empty()); + assert!(loaded.config.focus.hover_terminal_focus); + } + + #[test] + fn load_from_path_defaults_ghostty_scheme_to_gtk_scheme_for_legacy_configs() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write( + &path, + r#"{ + "appearance": { + "color_scheme": "dark" + } +} +"#, + ) + .expect("write config"); + + let loaded = load_from_path(&path); + + assert!(loaded.warnings.is_empty()); + assert_eq!(loaded.config.appearance.color_scheme, ColorScheme::Dark); + assert_eq!( + loaded.config.appearance.ghostty_color_scheme, + ColorScheme::Dark + ); + } + + #[test] + fn save_writes_gtk_and_ghostty_color_schemes() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + let _env_guard = EnvVarGuard::set("XDG_CONFIG_HOME", dir.path()); + + let mut config = AppConfig::default(); + config.appearance.color_scheme = ColorScheme::Light; + config.appearance.ghostty_color_scheme = ColorScheme::Dark; + save(&config).expect("save config"); + + let raw = fs::read_to_string(&path).expect("read config"); + let parsed: Value = serde_json::from_str(&raw).expect("parse config"); + assert_eq!( + parsed["appearance"]["color_scheme"], + Value::String("light".to_string()) + ); + assert_eq!( + parsed["appearance"]["ghostty_color_scheme"], + Value::String("dark".to_string()) + ); + } + + #[test] + fn save_preserves_unrelated_top_level_keys() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write( + &path, + r#"{ + "custom": { + "keep": true + }, + "focus": { + "hover_terminal_focus": false + } +} +"#, + ) + .expect("write config"); + + let mut config = AppConfig::default(); + config.appearance.color_scheme = ColorScheme::Dark; + save_to_path(&path, &config).expect("save config"); + + let raw = fs::read_to_string(&path).expect("read config"); + let parsed: Value = serde_json::from_str(&raw).expect("parse config"); + assert_eq!(parsed["custom"]["keep"], Value::Bool(true)); + assert_eq!( + parsed["appearance"]["color_scheme"], + Value::String("dark".to_string()) + ); + } + + #[test] + fn save_to_path_recovers_invalid_existing_json_by_backing_it_up() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write(&path, "not json").expect("write invalid config"); + + let config = AppConfig::default(); + save_to_path(&path, &config).expect("save should recover"); + + let raw = fs::read_to_string(&path).expect("read repaired config"); + let parsed: Value = serde_json::from_str(&raw).expect("parse repaired config"); + assert_eq!( + parsed["appearance"]["color_scheme"], + Value::String("system".to_string()) + ); + + let backup = fs::read_dir(path.parent().expect("config dir")) + .expect("list config dir") + .find_map(|entry| { + let entry = entry.expect("dir entry"); + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.contains(".settings.json.bak-").then_some(entry.path()) + }) + .expect("backup file"); + assert_eq!( + fs::read_to_string(backup).expect("read backup config"), + "not json" + ); + } + + #[test] + fn load_from_path_falls_back_to_defaults_on_invalid_json() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write(&path, "not json").expect("write config"); + + let loaded = load_from_path(&path); + + assert_eq!(loaded.config, AppConfig::default()); + assert_eq!(loaded.warnings.len(), 1); + assert!(loaded.warnings[0].contains("failed to load app config")); + } +} diff --git a/rust/limux-host-linux/src/control_bridge.rs b/rust/limux-host-linux/src/control_bridge.rs new file mode 100644 index 00000000..2d4923dc --- /dev/null +++ b/rust/limux-host-linux/src/control_bridge.rs @@ -0,0 +1,471 @@ +//! Bridge the limux control socket onto the GTK host state. + +use std::io::{self, BufRead, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::sync::mpsc; +use std::time::Duration; + +use gtk::glib; +use gtk4 as gtk; +use limux_control::socket_path::{resolve_socket_path, SocketMode}; +use limux_protocol::{parse_v1_command_envelope, V2Request, V2Response}; +use serde_json::{json, Map, Value}; + +const METHODS: &[&str] = &[ + "system.ping", + "system.identify", + "system.capabilities", + "workspace.current", + "workspace.list", + "workspace.create", + "workspace.select", + "workspace.rename", + "workspace.close", + "surface.send_text", +]; + +const PARSE_ERROR_CODE: i64 = -32700; +const INVALID_PARAMS_CODE: i64 = -32602; +const UNKNOWN_METHOD_CODE: i64 = -32601; +const INTERNAL_ERROR_CODE: i64 = -32603; +const NOT_FOUND_CODE: i64 = -32004; +const CONFLICT_CODE: i64 = -32009; + +type BridgeResult = Result; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkspaceTarget { + Active, + Handle(String), + Name(String), + Index(usize), +} + +#[derive(Debug)] +pub enum ControlCommand { + Identify { + caller: Option, + reply: mpsc::Sender, + }, + CurrentWorkspace { + reply: mpsc::Sender, + }, + ListWorkspaces { + reply: mpsc::Sender, + }, + CreateWorkspace { + name: Option, + cwd: Option, + command: Option, + reply: mpsc::Sender, + }, + SelectWorkspace { + target: WorkspaceTarget, + reply: mpsc::Sender, + }, + RenameWorkspace { + target: WorkspaceTarget, + title: String, + reply: mpsc::Sender, + }, + CloseWorkspace { + target: WorkspaceTarget, + reply: mpsc::Sender, + }, + SendText { + target: WorkspaceTarget, + surface_hint: Option, + text: String, + reply: mpsc::Sender, + }, +} + +impl ControlCommand { + pub fn respond(self, result: BridgeResult) { + match self { + Self::Identify { reply, .. } + | Self::CurrentWorkspace { reply } + | Self::ListWorkspaces { reply } + | Self::CreateWorkspace { reply, .. } + | Self::SelectWorkspace { reply, .. } + | Self::RenameWorkspace { reply, .. } + | Self::CloseWorkspace { reply, .. } + | Self::SendText { reply, .. } => { + let _ = reply.send(result); + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BridgeError { + code: i64, + message: String, + data: Option, +} + +impl BridgeError { + fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + fn with_data(mut self, data: Value) -> Self { + self.data = Some(data); + self + } + + pub fn invalid_params(message: impl Into) -> Self { + Self::new(INVALID_PARAMS_CODE, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(NOT_FOUND_CODE, message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::new(CONFLICT_CODE, message) + } + + pub fn internal(message: impl Into) -> Self { + Self::new(INTERNAL_ERROR_CODE, message) + } +} + +fn parse_request(input: &str) -> Result { + if let Ok(request) = serde_json::from_str::(input) { + return Ok(request); + } + + match parse_v1_command_envelope(input) { + Ok(v1) => Ok(v1.into_v2_request(None)), + Err(error) => Err(BridgeError::new( + PARSE_ERROR_CODE, + format!("invalid request payload: {error}"), + ) + .with_data(json!({ "raw": input }))), + } +} + +fn params_object(params: &Value) -> Result<&Map, BridgeError> { + params + .as_object() + .ok_or_else(|| BridgeError::invalid_params("params must be a JSON object")) +} + +fn optional_string(params: &Map, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + params + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn optional_index(params: &Map, key: &str) -> Result, BridgeError> { + let Some(value) = params.get(key) else { + return Ok(None); + }; + + if let Some(index) = value.as_u64() { + return Ok(Some(index as usize)); + } + + Err(BridgeError::invalid_params(format!( + "{key} must be a non-negative integer" + ))) +} + +fn parse_optional_workspace_target( + params: &Map, + allow_name: bool, +) -> Result { + if let Some(handle) = optional_string(params, &["workspace_id", "id"]) { + return Ok(WorkspaceTarget::Handle(handle)); + } + if allow_name { + if let Some(name) = optional_string(params, &["name"]) { + return Ok(WorkspaceTarget::Name(name)); + } + } + if let Some(index) = optional_index(params, "index")? { + return Ok(WorkspaceTarget::Index(index)); + } + Ok(WorkspaceTarget::Active) +} + +fn parse_required_workspace_target( + params: &Map, + allow_name: bool, + method: &str, +) -> Result { + let target = parse_optional_workspace_target(params, allow_name)?; + if matches!(target, WorkspaceTarget::Active) { + Err(BridgeError::invalid_params(format!( + "{method} requires workspace_id/id, name, or index" + ))) + } else { + Ok(target) + } +} + +fn handle_method( + id: Option, + method: &str, + params: Value, + dispatch: &dyn Fn(ControlCommand), +) -> V2Response { + let params = match params_object(¶ms) { + Ok(params) => params, + Err(error) => return error_response(id, error), + }; + + let queued = match method { + "system.ping" | "ping" => return V2Response::success(id, json!({ "pong": true })), + "system.capabilities" => { + return V2Response::success(id, json!({ "commands": METHODS, "methods": METHODS })); + } + "system.identify" => { + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::Identify { + caller: params.get("caller").cloned(), + reply, + }, + rx, + ) + } + "workspace.current" => { + let (reply, rx) = mpsc::channel(); + (ControlCommand::CurrentWorkspace { reply }, rx) + } + "workspace.list" | "list-workspaces" => { + let (reply, rx) = mpsc::channel(); + (ControlCommand::ListWorkspaces { reply }, rx) + } + "workspace.create" | "new-workspace" => { + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::CreateWorkspace { + name: optional_string(params, &["name", "title"]), + cwd: optional_string(params, &["cwd"]), + command: optional_string(params, &["command"]), + reply, + }, + rx, + ) + } + "workspace.select" | "workspace.activate" | "activate-workspace" => { + let target = match parse_required_workspace_target(params, true, method) { + Ok(target) => target, + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + (ControlCommand::SelectWorkspace { target, reply }, rx) + } + "workspace.rename" | "rename-workspace" => { + let Some(title) = optional_string(params, &["title", "name"]) else { + return error_response( + id, + BridgeError::invalid_params("workspace.rename requires title/name"), + ); + }; + let target = match parse_optional_workspace_target(params, false) { + Ok(target) => target, + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::RenameWorkspace { + target, + title, + reply, + }, + rx, + ) + } + "workspace.close" | "close-workspace" => { + let target = match parse_optional_workspace_target(params, false) { + Ok(target) => target, + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + (ControlCommand::CloseWorkspace { target, reply }, rx) + } + "surface.send_text" | "send-text" | "send" => { + let Some(text) = optional_string(params, &["text"]) else { + return error_response( + id, + BridgeError::invalid_params("surface.send_text requires text"), + ); + }; + let target = match parse_optional_workspace_target(params, false) { + Ok(target) => target, + Err(error) => return error_response(id, error), + }; + let (reply, rx) = mpsc::channel(); + ( + ControlCommand::SendText { + target, + surface_hint: optional_string(params, &["surface_id"]), + text, + reply, + }, + rx, + ) + } + _ => { + return error_response( + id, + BridgeError::new(UNKNOWN_METHOD_CODE, format!("unknown method: {method}")), + ); + } + }; + + let (command, reply_rx) = queued; + + dispatch(command); + + match reply_rx.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(result)) => V2Response::success(id, result), + Ok(Err(error)) => error_response(id, error), + Err(_) => error_response(id, BridgeError::internal("control command timed out")), + } +} + +fn error_response(id: Option, error: BridgeError) -> V2Response { + V2Response::error(id, error.code, error.message, error.data) +} + +fn dispatch_request(input: &str, dispatch: &dyn Fn(ControlCommand)) -> V2Response { + match parse_request(input) { + Ok(request) => handle_method(request.id, &request.method, request.params, dispatch), + Err(error) => error_response(None, error), + } +} + +fn handle_client( + stream: UnixStream, + dispatch: &(dyn Fn(ControlCommand) + Send + Sync + 'static), +) -> io::Result<()> { + let reader_stream = stream.try_clone()?; + let reader = io::BufReader::new(reader_stream); + let mut writer = stream; + + for line in reader.lines() { + let line = line?; + let input = line.trim(); + if input.is_empty() { + continue; + } + + let response = dispatch_request(input, dispatch); + let mut payload = serde_json::to_string(&response) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?; + payload.push('\n'); + writer.write_all(payload.as_bytes())?; + writer.flush()?; + } + + Ok(()) +} + +/// Start the control socket server in a background thread and dispatch each +/// command onto the GTK main context. +pub fn start(dispatch: fn(ControlCommand)) { + let context = glib::MainContext::default(); + let dispatch = std::sync::Arc::new(move |command: ControlCommand| { + context.invoke(move || dispatch(command)); + }); + + std::thread::Builder::new() + .name("limux-control".into()) + .spawn(move || { + let path = resolve_socket_path(None, SocketMode::Runtime); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if path.exists() { + let _ = std::fs::remove_file(&path); + } + + let listener = match UnixListener::bind(&path) { + Ok(listener) => listener, + Err(error) => { + eprintln!( + "limux: control socket bind failed ({}): {error}", + path.display() + ); + return; + } + }; + + eprintln!("limux: control socket at {}", path.display()); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let dispatch = dispatch.clone(); + std::thread::Builder::new() + .name("limux-ctrl-conn".into()) + .spawn(move || { + if let Err(error) = handle_client(stream, dispatch.as_ref()) { + eprintln!("limux: control connection error: {error}"); + } + }) + .ok(); + } + Err(error) => { + eprintln!("limux: control accept error: {error}"); + } + } + } + }) + .expect("failed to spawn control server thread"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_v2_request_directly() { + let request = parse_request(r#"{"id":"1","method":"system.ping","params":{}}"#) + .expect("v2 request should parse"); + assert_eq!(request.id, Some(Value::String("1".to_string()))); + assert_eq!(request.method, "system.ping"); + } + + #[test] + fn parses_v1_request_envelope() { + let request = parse_request(r#"{"command":"workspace.create","args":{"cwd":"/tmp"}}"#) + .expect("v1 request should parse"); + assert_eq!(request.method, "workspace.create"); + assert_eq!(request.params["cwd"], "/tmp"); + } + + #[test] + fn workspace_target_prefers_handle_over_index() { + let params = json!({ + "workspace_id": "workspace:abc", + "index": 2 + }); + let target = + parse_optional_workspace_target(params.as_object().expect("object params"), true) + .expect("target should parse"); + assert_eq!(target, WorkspaceTarget::Handle("workspace:abc".to_string())); + } + + #[test] + fn workspace_select_requires_explicit_target() { + let params = Map::new(); + let error = parse_required_workspace_target(¶ms, true, "workspace.select") + .expect_err("workspace.select should require a target"); + assert_eq!(error.code, INVALID_PARAMS_CODE); + } +} diff --git a/rust/limux-host-linux/src/ghostty_config.rs b/rust/limux-host-linux/src/ghostty_config.rs new file mode 100644 index 00000000..53266cfa --- /dev/null +++ b/rust/limux-host-linux/src/ghostty_config.rs @@ -0,0 +1,42 @@ +const DEFAULT_FONT_SIZE: f32 = 12.0; + +fn ghostty_config_contents() -> Option { + let path = dirs::config_dir() + .map(|d| d.join("ghostty/config")) + .filter(|p| p.exists())?; + std::fs::read_to_string(&path).ok() +} + +fn read_ghostty_value(contents: &str, key: &str) -> Option { + for line in contents.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix(key) { + let rest = rest.trim(); + if let Some(value) = rest.strip_prefix('=') { + return Some(value.trim().to_string()); + } + } + } + None +} + +/// Read background-opacity from the Ghostty config file. +/// Returns a value between 0.0 and 1.0 (default: 1.0 = fully opaque). +#[allow(dead_code)] +pub fn read_background_opacity() -> f64 { + ghostty_config_contents() + .and_then(|c| read_ghostty_value(&c, "background-opacity")) + .and_then(|v| v.parse::().ok()) + .map(|v| v.clamp(0.0, 1.0)) + .unwrap_or(1.0) +} + +/// Read font-size from the Ghostty config file. +/// Returns the configured size in points (default: 12.0). +pub fn read_font_size() -> f32 { + ghostty_config_contents() + .and_then(|c| read_ghostty_value(&c, "font-size")) + .and_then(|v| v.parse::().ok()) + .map(|v| v.clamp(1.0, 255.0)) + .unwrap_or(DEFAULT_FONT_SIZE) +} diff --git a/rust/limux-host-linux/src/keybind_editor.rs b/rust/limux-host-linux/src/keybind_editor.rs new file mode 100644 index 00000000..6b109de4 --- /dev/null +++ b/rust/limux-host-linux/src/keybind_editor.rs @@ -0,0 +1,508 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use gtk::prelude::*; +use gtk4 as gtk; + +use crate::shortcut_config::{ + self, NormalizedShortcut, ResolvedShortcutConfig, ShortcutConfigError, ShortcutId, +}; + +enum CaptureOutcome { + ContinueListening, + CancelListening, + CommitBinding(Option), + Error(String), +} + +pub const KEYBIND_EDITOR_LISTENING_CSS: &str = "limux-keybind-editor-listening"; + +pub const KEYBIND_EDITOR_CSS: &str = r#" +.limux-keybind-editor { + background-color: @window_bg_color; + color: @window_fg_color; + padding: 14px; +} +.limux-keybind-header { + margin-bottom: 8px; +} +.limux-keybind-title { + font-weight: 700; +} +.limux-keybind-hint { + font-size: 12px; + margin-bottom: 10px; + opacity: 0.7; +} +.limux-keybind-scroll viewport { + background: transparent; +} +.limux-keybind-row { + padding: 10px 12px; + margin-bottom: 8px; +} +.limux-keybind-action { + font-weight: 600; +} +.limux-keybind-default { + font-size: 12px; + opacity: 0.7; +} +.limux-keybind-capture { + min-width: 168px; + padding: 8px 12px; +} +.limux-keybind-capture-listening { + border-color: @accent_bg_color; + box-shadow: inset 0 0 0 1px @accent_bg_color; +} +.limux-keybind-error { + color: @error_color; + font-size: 12px; + margin-top: 6px; +} +.limux-keybind-row-hint { + font-size: 12px; + margin-top: 6px; + opacity: 0.7; +} +"#; + +#[derive(Clone)] +struct RowWidgets { + id: ShortcutId, + binding_button: gtk::Button, + hint_label: gtk::Label, + error_label: gtk::Label, +} + +pub fn build_keybind_editor( + shortcuts: &ResolvedShortcutConfig, + on_capture: Rc< + dyn Fn(ShortcutId, Option) -> Result, + >, +) -> gtk::Widget { + let state = Rc::new(RefCell::new(shortcuts.clone())); + let listening = Rc::new(RefCell::new(None::)); + let errors = Rc::new(RefCell::new(HashMap::::new())); + let rows = Rc::new(RefCell::new(Vec::::new())); + + let outer = gtk::Box::new(gtk::Orientation::Vertical, 0); + outer.add_css_class("limux-keybind-editor"); + outer.set_width_request(540); + outer.set_hexpand(true); + outer.set_vexpand(true); + outer.set_focusable(true); + outer.set_can_focus(true); + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header.add_css_class("limux-keybind-header"); + + let title = gtk::Label::builder() + .label("Keybinds") + .xalign(0.0) + .hexpand(true) + .build(); + title.add_css_class("limux-keybind-title"); + header.append(&title); + + let hint = gtk::Label::builder() + .label( + "Click a shortcut field, then press a Ctrl, Alt, or Cmd combo. Shift is allowed as an additional modifier. Press Del to unbind. Press Esc to cancel.", + ) + .wrap(true) + .xalign(0.0) + .build(); + hint.add_css_class("limux-keybind-hint"); + + let rows_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + + for definition in shortcut_config::definitions() { + let shortcut_id = definition.id; + + let row = gtk::Box::new(gtk::Orientation::Vertical, 0); + row.add_css_class("card"); + row.add_css_class("limux-keybind-row"); + + let top = gtk::Box::new(gtk::Orientation::Horizontal, 12); + + let meta = gtk::Box::new(gtk::Orientation::Vertical, 4); + meta.set_hexpand(true); + + let action_label = gtk::Label::builder() + .label(definition.label) + .xalign(0.0) + .hexpand(true) + .build(); + action_label.add_css_class("limux-keybind-action"); + + let default_label = gtk::Label::builder() + .label(format!( + "Default: {}", + shortcuts + .default_display_label_for_id(definition.id) + .unwrap_or_else(|| definition.default_display_label()) + )) + .xalign(0.0) + .wrap(true) + .build(); + default_label.add_css_class("limux-keybind-default"); + default_label.set_opacity(0.7); + + meta.append(&action_label); + meta.append(&default_label); + + let binding_button = + gtk::Button::with_label(&binding_button_label(shortcuts, definition.id, false)); + binding_button.add_css_class("limux-keybind-capture"); + binding_button.set_focusable(true); + binding_button.set_can_focus(true); + binding_button.set_focus_on_click(true); + binding_button.set_halign(gtk::Align::End); + + let error_label = gtk::Label::builder() + .xalign(0.0) + .wrap(true) + .visible(false) + .build(); + error_label.add_css_class("limux-keybind-error"); + + let hint_label = gtk::Label::builder() + .label("Press Del to unbind. Esc cancels.") + .xalign(0.0) + .wrap(true) + .visible(false) + .build(); + hint_label.add_css_class("limux-keybind-row-hint"); + hint_label.set_opacity(0.7); + + top.append(&meta); + top.append(&binding_button); + row.append(&top); + row.append(&hint_label); + row.append(&error_label); + rows_box.append(&row); + + rows.borrow_mut().push(RowWidgets { + id: definition.id, + binding_button: binding_button.clone(), + hint_label: hint_label.clone(), + error_label: error_label.clone(), + }); + + { + let listening = listening.clone(); + let errors = errors.clone(); + let rows = rows.clone(); + let state = state.clone(); + let outer = outer.clone(); + binding_button.connect_clicked(move |button| { + *listening.borrow_mut() = Some(shortcut_id); + errors.borrow_mut().remove(&shortcut_id); + sync_editor_listening_class(&outer, true); + refresh_rows( + &rows.borrow(), + &state.borrow(), + *listening.borrow(), + &errors.borrow(), + ); + button.grab_focus(); + }); + } + } + + { + let listening = listening.clone(); + let errors = errors.clone(); + let rows = rows.clone(); + let state = state.clone(); + let on_capture = on_capture.clone(); + let outer_for_controller = outer.clone(); + let key_controller = gtk::EventControllerKey::new(); + key_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + key_controller.connect_key_pressed(move |controller, keyval, keycode, modifier| { + let Some(shortcut_id) = *listening.borrow() else { + return gtk::glib::Propagation::Proceed; + }; + let Some(definition) = shortcut_config::definitions() + .iter() + .find(|definition| definition.id == shortcut_id) + else { + return gtk::glib::Propagation::Proceed; + }; + let display = controller.widget().map(|widget| widget.display()); + + match capture_outcome_for_key_press( + display.as_ref(), + keyval, + keycode, + modifier, + definition.config_key, + ) { + CaptureOutcome::ContinueListening => { + return gtk::glib::Propagation::Stop; + } + CaptureOutcome::CancelListening => { + *listening.borrow_mut() = None; + errors.borrow_mut().remove(&shortcut_id); + sync_editor_listening_class(&outer_for_controller, false); + } + CaptureOutcome::CommitBinding(binding) => match on_capture(shortcut_id, binding) { + Ok(updated) => { + *state.borrow_mut() = updated; + *listening.borrow_mut() = None; + errors.borrow_mut().remove(&shortcut_id); + sync_editor_listening_class(&outer_for_controller, false); + } + Err(err) => { + *listening.borrow_mut() = None; + errors.borrow_mut().insert(shortcut_id, err); + sync_editor_listening_class(&outer_for_controller, false); + } + }, + CaptureOutcome::Error(message) => { + *listening.borrow_mut() = None; + errors.borrow_mut().insert(shortcut_id, message); + sync_editor_listening_class(&outer_for_controller, false); + } + } + + refresh_rows( + &rows.borrow(), + &state.borrow(), + *listening.borrow(), + &errors.borrow(), + ); + gtk::glib::Propagation::Stop + }); + outer.add_controller(key_controller); + } + + refresh_rows(&rows.borrow(), shortcuts, None, &HashMap::new()); + + let scroller = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .child(&rows_box) + .build(); + scroller.add_css_class("limux-keybind-scroll"); + scroller.set_hexpand(true); + scroller.set_vexpand(true); + + outer.append(&header); + outer.append(&hint); + outer.append(&scroller); + outer.upcast() +} + +fn sync_editor_listening_class(editor: >k::Box, listening: bool) { + if listening { + editor.add_css_class(KEYBIND_EDITOR_LISTENING_CSS); + } else { + editor.remove_css_class(KEYBIND_EDITOR_LISTENING_CSS); + } +} + +fn binding_button_label( + shortcuts: &ResolvedShortcutConfig, + id: ShortcutId, + listening: bool, +) -> String { + if listening { + return "Press shortcut…".to_string(); + } + + shortcuts + .display_label_for_id(id) + .unwrap_or_else(|| "Unbound".to_string()) +} + +fn refresh_rows( + rows: &[RowWidgets], + shortcuts: &ResolvedShortcutConfig, + listening: Option, + errors: &HashMap, +) { + for row in rows { + let is_listening = listening == Some(row.id); + row.binding_button + .set_label(&binding_button_label(shortcuts, row.id, is_listening)); + row.hint_label.set_visible(is_listening); + if is_listening { + row.binding_button + .add_css_class("limux-keybind-capture-listening"); + } else { + row.binding_button + .remove_css_class("limux-keybind-capture-listening"); + } + + if let Some(error) = errors.get(&row.id) { + row.error_label.set_label(error); + row.error_label.set_visible(true); + } else { + row.error_label.set_visible(false); + } + } +} + +#[cfg(test)] +fn capture_outcome_for_key_event( + keyval: gtk::gdk::Key, + modifier: gtk::gdk::ModifierType, + config_key: &str, +) -> CaptureOutcome { + capture_outcome_for_key_press(None, keyval, 0, modifier, config_key) +} + +fn capture_outcome_for_key_press( + display: Option<>k::gdk::Display>, + keyval: gtk::gdk::Key, + keycode: u32, + modifier: gtk::gdk::ModifierType, + config_key: &str, +) -> CaptureOutcome { + if keyval == gtk::gdk::Key::Escape { + return CaptureOutcome::CancelListening; + } + + let unbind_modifiers = gtk::gdk::ModifierType::SHIFT_MASK + | gtk::gdk::ModifierType::CONTROL_MASK + | gtk::gdk::ModifierType::ALT_MASK + | gtk::gdk::ModifierType::META_MASK + | gtk::gdk::ModifierType::SUPER_MASK; + if matches!(keyval, gtk::gdk::Key::Delete | gtk::gdk::Key::KP_Delete) + && !modifier.intersects(unbind_modifiers) + { + return CaptureOutcome::CommitBinding(None); + } + + let Some(binding) = NormalizedShortcut::from_gdk_key_event(display, keyval, keycode, modifier) + else { + return CaptureOutcome::ContinueListening; + }; + + let Some(definition) = shortcut_config::definition_by_config_key(config_key) else { + return CaptureOutcome::Error("That shortcut is not valid.".to_string()); + }; + + match binding.validate_host_binding(definition) { + Ok(()) => CaptureOutcome::CommitBinding(Some(binding)), + Err(err) => CaptureOutcome::Error(validation_error_message(&err)), + } +} + +fn validation_error_message(err: &ShortcutConfigError) -> String { + match err { + ShortcutConfigError::BaseModifierRequired { .. } => { + "Use Ctrl, Alt, or Cmd together with another key.".to_string() + } + ShortcutConfigError::ModifierOnlyBinding { .. } => { + "Choose a non-modifier key for this shortcut.".to_string() + } + ShortcutConfigError::DuplicateBinding { .. } => { + "That shortcut is already assigned to another action.".to_string() + } + _ => "That shortcut is not valid.".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + binding_button_label, capture_outcome_for_key_event, validation_error_message, + CaptureOutcome, + }; + use crate::shortcut_config::{ + default_shortcuts, resolve_shortcuts_from_str, ShortcutConfigError, ShortcutId, + }; + use gtk4::gdk; + + #[test] + fn binding_button_label_prefers_current_binding_and_listening_state() { + let defaults = default_shortcuts(); + assert_eq!( + binding_button_label(&defaults, ShortcutId::SplitRight, false), + "Ctrl+D" + ); + assert_eq!( + binding_button_label(&defaults, ShortcutId::SplitRight, true), + "Press shortcut…" + ); + + let remapped = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "h" + } + }"#, + ) + .unwrap(); + assert_eq!( + binding_button_label(&remapped, ShortcutId::SplitRight, false), + "Ctrl+Alt+H" + ); + } + + #[test] + fn validation_error_message_is_user_facing() { + let err = ShortcutConfigError::BaseModifierRequired { + shortcut_id: "split_right".to_string(), + input: "h".to_string(), + }; + assert_eq!( + validation_error_message(&err), + "Use Ctrl, Alt, or Cmd together with another key." + ); + } + + #[test] + fn capture_outcome_keeps_listening_for_modifier_only_press() { + assert!(matches!( + capture_outcome_for_key_event( + gdk::Key::Control_L, + gdk::ModifierType::empty(), + "split_right" + ), + CaptureOutcome::ContinueListening + )); + } + + #[test] + fn capture_outcome_commits_first_non_modifier_with_current_modifiers() { + match capture_outcome_for_key_event( + gdk::Key::_0, + gdk::ModifierType::CONTROL_MASK, + "split_right", + ) { + CaptureOutcome::CommitBinding(Some(binding)) => { + assert_eq!(binding.to_display_label(), "Ctrl+0"); + } + _ => panic!("expected capture"), + } + } + + #[test] + fn capture_outcome_supports_delete_to_unbind() { + assert!(matches!( + capture_outcome_for_key_event( + gdk::Key::Delete, + gdk::ModifierType::empty(), + "split_right" + ), + CaptureOutcome::CommitBinding(None) + )); + } + + #[test] + fn capture_outcome_keeps_modified_delete_available_for_binding() { + assert!(matches!( + capture_outcome_for_key_event( + gdk::Key::Delete, + gdk::ModifierType::CONTROL_MASK, + "split_right" + ), + CaptureOutcome::CommitBinding(Some(_)) + )); + } +} diff --git a/rust/limux-host-linux/src/layout_state.rs b/rust/limux-host-linux/src/layout_state.rs index f52c81b6..037a648a 100644 --- a/rust/limux-host-linux/src/layout_state.rs +++ b/rust/limux-host-linux/src/layout_state.rs @@ -38,6 +38,8 @@ pub struct AppSessionState { pub version: u32, #[serde(default)] pub active_workspace_index: usize, + #[serde(default = "default_top_bar_visible")] + pub top_bar_visible: bool, #[serde(default)] pub sidebar: SidebarState, #[serde(default)] @@ -109,6 +111,8 @@ pub enum TabContentState { #[serde(default)] uri: Option, }, + Keybinds {}, + Settings {}, } #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)] @@ -135,6 +139,7 @@ impl Default for AppSessionState { Self { version: default_session_version(), active_workspace_index: 0, + top_bar_visible: default_top_bar_visible(), sidebar: SidebarState::default(), workspaces: Vec::new(), } @@ -149,6 +154,14 @@ impl PaneState { tabs: vec![tab], } } + + pub fn browser_only(uri: Option<&str>) -> Self { + let tab = TabState::browser(default_tab_id("browser"), uri); + Self { + active_tab_id: Some(tab.id.clone()), + tabs: vec![tab], + } + } } impl TabState { @@ -162,6 +175,17 @@ impl TabState { }, } } + + pub fn browser(id: impl Into, uri: Option<&str>) -> Self { + Self { + id: id.into(), + custom_name: None, + pinned: false, + content: TabContentState::Browser { + uri: uri.map(|value| value.to_string()), + }, + } + } } pub fn persistence_dir() -> PathBuf { @@ -362,6 +386,10 @@ fn default_sidebar_visible() -> bool { true } +fn default_top_bar_visible() -> bool { + true +} + fn default_sidebar_width() -> i32 { DEFAULT_SIDEBAR_WIDTH } @@ -460,6 +488,28 @@ mod tests { assert_eq!(loaded.state, AppSessionState::default()); } + #[test] + fn load_defaults_top_bar_visible_when_omitted_from_session_json() { + let dir = tempdir().expect("tempdir"); + let canonical_path = canonical_session_path_in(dir.path()); + fs::write( + &canonical_path, + r#"{ + "version": 1, + "active_workspace_index": 0, + "sidebar": { + "visible": true, + "width": 220 + }, + "workspaces": [] + }"#, + ) + .expect("write canonical"); + + let loaded = load_session_from_dir(dir.path()); + assert!(loaded.state.top_bar_visible); + } + #[test] fn save_session_atomic_writes_canonical_file() { let dir = tempdir().expect("tempdir"); @@ -526,6 +576,53 @@ mod tests { } } + #[test] + fn browser_only_pane_creates_a_single_browser_tab() { + let pane = PaneState::browser_only(Some("https://example.com")); + + assert_eq!(pane.tabs.len(), 1); + assert_eq!(pane.active_tab_id.as_deref(), Some("browser-0")); + match &pane.tabs[0].content { + TabContentState::Browser { uri } => { + assert_eq!(uri.as_deref(), Some("https://example.com")); + } + other => panic!("expected browser tab, got {other:?}"), + } + } + + #[test] + fn keybind_tab_round_trips_through_session_json() { + let state = AppSessionState { + top_bar_visible: false, + workspaces: vec![WorkspaceState { + name: "workspace".to_string(), + favorite: false, + cwd: None, + folder_path: None, + layout: LayoutNodeState::Pane(PaneState { + active_tab_id: Some("keybinds-1".to_string()), + tabs: vec![TabState { + id: "keybinds-1".to_string(), + custom_name: None, + pinned: false, + content: TabContentState::Keybinds {}, + }], + }), + }], + ..AppSessionState::default() + }; + + let raw = serde_json::to_string(&state).expect("serialize session"); + let decoded: AppSessionState = serde_json::from_str(&raw).expect("deserialize session"); + + assert!(!decoded.top_bar_visible); + let LayoutNodeState::Pane(pane) = &decoded.workspaces[0].layout else { + panic!("expected pane"); + }; + assert_eq!(pane.active_tab_id.as_deref(), Some("keybinds-1")); + assert!(matches!(pane.tabs[0].content, TabContentState::Keybinds {})); + } + #[test] fn split_ratio_helpers_clamp_invalid_values() { assert_eq!(clamp_split_ratio(f64::NAN), DEFAULT_SPLIT_RATIO); diff --git a/rust/limux-host-linux/src/main.rs b/rust/limux-host-linux/src/main.rs index 54b0fc18..17d2ab2f 100644 --- a/rust/limux-host-linux/src/main.rs +++ b/rust/limux-host-linux/src/main.rs @@ -1,5 +1,12 @@ +mod app_config; +mod control_bridge; +mod ghostty_config; +mod keybind_editor; mod layout_state; mod pane; +mod settings_editor; +mod shortcut_config; +mod split_tree; mod terminal; mod window; @@ -22,11 +29,22 @@ fn append_env(key: &str, value: &str) { } } +fn has_ghostty_terminfo(path: &Path) -> bool { + let Some(parent) = path.parent() else { + return false; + }; + + ["terminfo/g/ghostty", "terminfo/x/xterm-ghostty"] + .iter() + .any(|entry| parent.join(entry).is_file()) +} + fn is_ghostty_resources_dir(path: &Path) -> bool { path.is_dir() - && ["themes", "terminfo", "shell-integration"] + && ["themes", "shell-integration"] .iter() - .any(|entry| path.join(entry).is_dir()) + .all(|entry| path.join(entry).is_dir()) + && has_ghostty_terminfo(path) } fn ghostty_resources_candidates(exe_dir: &Path) -> Vec { @@ -97,15 +115,9 @@ fn main() { .flags(adw::gio::ApplicationFlags::NON_UNIQUE) .build(); - app.connect_activate(window::build_window); - - // Global keyboard shortcuts - app.set_accels_for_action("win.new-workspace", &["n"]); - app.set_accels_for_action("win.close-workspace", &["w"]); - app.set_accels_for_action("win.toggle-sidebar", &["b"]); - app.set_accels_for_action("win.next-workspace", &["Page_Down"]); - app.set_accels_for_action("win.prev-workspace", &["Page_Up"]); - + app.connect_activate(move |app| { + window::build_window(app); + }); app.run(); } @@ -127,9 +139,14 @@ mod tests { fn resolves_app_specific_bundled_resources_next_to_executable() { let root = temp_path("resources"); let exe_dir = root.join("bin"); - let resources_dir = root.join("share/limux/ghostty/themes"); + let themes_dir = root.join("share/limux/ghostty/themes"); + let shell_integration_dir = root.join("share/limux/ghostty/shell-integration"); + let terminfo_file = root.join("share/limux/terminfo/g/ghostty"); fs::create_dir_all(&exe_dir).unwrap(); - fs::create_dir_all(&resources_dir).unwrap(); + fs::create_dir_all(&themes_dir).unwrap(); + fs::create_dir_all(&shell_integration_dir).unwrap(); + fs::create_dir_all(terminfo_file.parent().unwrap()).unwrap(); + fs::write(&terminfo_file, b"ghostty").unwrap(); let exe = exe_dir.join("limux"); let resolved = resolve_ghostty_resources_dir(&exe).unwrap(); @@ -142,9 +159,14 @@ mod tests { fn resolves_dev_checkout_resources_from_target_binary() { let root = temp_path("dev-resources"); let exe_dir = root.join("target/release"); - let resources_dir = root.join("ghostty/zig-out/share/ghostty/terminfo"); + let themes_dir = root.join("ghostty/zig-out/share/ghostty/themes"); + let shell_integration_dir = root.join("ghostty/zig-out/share/ghostty/shell-integration"); + let terminfo_file = root.join("ghostty/zig-out/share/terminfo/x/xterm-ghostty"); fs::create_dir_all(&exe_dir).unwrap(); - fs::create_dir_all(&resources_dir).unwrap(); + fs::create_dir_all(&themes_dir).unwrap(); + fs::create_dir_all(&shell_integration_dir).unwrap(); + fs::create_dir_all(terminfo_file.parent().unwrap()).unwrap(); + fs::write(&terminfo_file, b"xterm-ghostty").unwrap(); let exe = exe_dir.join("limux"); let resolved = resolve_ghostty_resources_dir(&exe).unwrap(); @@ -152,4 +174,18 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + + #[test] + fn rejects_resource_dirs_without_sibling_terminfo() { + let root = temp_path("missing-terminfo"); + let resources_dir = root.join("ghostty/zig-out/share/ghostty"); + let themes_dir = resources_dir.join("themes"); + let shell_integration_dir = resources_dir.join("shell-integration"); + fs::create_dir_all(&themes_dir).unwrap(); + fs::create_dir_all(&shell_integration_dir).unwrap(); + + assert!(!is_ghostty_resources_dir(&resources_dir)); + + fs::remove_dir_all(root).unwrap(); + } } diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index edf3e460..931350d7 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -4,16 +4,152 @@ //! //! All on one line. Tabs left-justified, icons right-justified. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; +use std::sync::atomic::{AtomicU32, Ordering}; use gtk::glib; +#[allow(unused_imports)] use gtk::prelude::*; use gtk4 as gtk; +#[cfg(feature = "webkit")] +use webkit6::prelude::*; +use crate::app_config::AppConfig; +use crate::keybind_editor; use crate::layout_state::{PaneState, TabContentState, TabState as SavedTabState}; +use crate::shortcut_config::{NormalizedShortcut, ResolvedShortcutConfig, ShortcutId}; use crate::terminal::{self, TerminalCallbacks}; +fn next_pane_id() -> u32 { + static COUNTER: AtomicU32 = AtomicU32::new(1); + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +type TabDragCallback = dyn Fn(bool); + +thread_local! { + static TAB_DRAGGING: Cell = const { Cell::new(false) }; + static TAB_DRAG_LISTENERS: RefCell>> = + RefCell::new(std::collections::HashMap::new()); + static TAB_DRAG_NEXT_ID: Cell = const { Cell::new(1) }; + static PANE_REGISTRY: RefCell>> = + RefCell::new(std::collections::HashMap::new()); +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TabDragPayload { + pane_id: u32, + tab_id: String, +} + +impl TabDragPayload { + fn new(pane_id: u32, tab_id: impl Into) -> Self { + Self { + pane_id, + tab_id: tab_id.into(), + } + } + + fn encode(&self) -> String { + format!("{}:{}", self.pane_id, self.tab_id) + } + + fn decode(raw: &str) -> Option { + let (pane_id, tab_id) = raw.split_once(':')?; + if tab_id.is_empty() { + return None; + } + Some(Self::new(pane_id.parse::().ok()?, tab_id)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ContentDropZone { + Center, + Left, + Right, + Top, + Bottom, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaneEmptyReason { + ClosedLastTab, + MovedLastTabOut, +} + +const HOST_ENTRY_CSS_CLASS: &str = "limux-host-entry"; +const TAB_RENAME_ENTRY_CSS_CLASS: &str = "limux-tab-rename-entry"; +const TAB_RENAME_ENTRY_CSS_CLASSES: [&str; 2] = [HOST_ENTRY_CSS_CLASS, TAB_RENAME_ENTRY_CSS_CLASS]; +const BROWSER_URL_ENTRY_CSS_CLASS: &str = "limux-browser-url-entry"; +const BROWSER_URL_ENTRY_CSS_CLASSES: [&str; 2] = + [HOST_ENTRY_CSS_CLASS, BROWSER_URL_ENTRY_CSS_CLASS]; +const BROWSER_SEARCH_ENTRY_CSS_CLASS: &str = "limux-browser-search-entry"; +const BROWSER_SEARCH_ENTRY_CSS_CLASSES: [&str; 2] = + [HOST_ENTRY_CSS_CLASS, BROWSER_SEARCH_ENTRY_CSS_CLASS]; + +pub fn is_tab_dragging() -> bool { + TAB_DRAGGING.with(|value| value.get()) +} + +pub fn on_tab_drag_change(callback: impl Fn(bool) + 'static) -> usize { + TAB_DRAG_LISTENERS.with(|listeners| { + let id = TAB_DRAG_NEXT_ID.with(|next| { + let id = next.get(); + next.set(id + 1); + id + }); + listeners.borrow_mut().insert(id, Box::new(callback)); + id + }) +} + +pub fn remove_tab_drag_listener(id: usize) { + TAB_DRAG_LISTENERS.with(|listeners| { + listeners.borrow_mut().remove(&id); + }); +} + +fn set_tab_dragging(active: bool) { + TAB_DRAGGING.with(|value| value.set(active)); + TAB_DRAG_LISTENERS.with(|listeners| { + for callback in listeners.borrow().values() { + callback(active); + } + }); +} + +fn register_pane(id: u32, internals: &Rc) { + PANE_REGISTRY.with(|registry| { + registry.borrow_mut().insert(id, Rc::downgrade(internals)); + }); +} + +fn unregister_pane(id: u32) { + PANE_REGISTRY.with(|registry| { + registry.borrow_mut().remove(&id); + }); +} + +fn lookup_pane_internals(id: u32) -> Option> { + PANE_REGISTRY.with(|registry| registry.borrow().get(&id)?.upgrade()) +} + +pub fn find_pane_widget_by_id(pane_id: u32) -> Option { + lookup_pane_internals(pane_id).map(|internals| internals.pane_outer.clone().upcast()) +} + +pub fn set_workspace_dragging_all(active: bool) { + PANE_REGISTRY.with(|registry| { + for weak in registry.borrow().values() { + if let Some(internals) = weak.upgrade() { + internals.workspace_dragging.set(active); + } + } + }); +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -22,14 +158,86 @@ type PaneSplitCallback = dyn Fn(>k::Widget, gtk::Orientation); type PaneWidgetCallback = dyn Fn(>k::Widget); type PaneSignalCallback = dyn Fn(); type PanePathCallback = dyn Fn(&str); +type PaneDesktopNotificationCallback = dyn Fn(&str, &str); +type PaneEmptyCallback = dyn Fn(>k::Widget, PaneEmptyReason); +type PaneOpenBrowserHereCallback = dyn Fn(>k::Widget); +type PaneShortcutStateCallback = dyn Fn() -> Rc; +type PaneShortcutCaptureCallback = + dyn Fn(ShortcutId, Option) -> Result; +type PaneSplitWithTabCallback = dyn Fn(>k::Widget, >k::Widget, gtk::Orientation, String, bool); +type PaneConfigCallback = dyn Fn() -> Rc>; pub struct PaneCallbacks { pub on_split: Box, pub on_close_pane: Box, pub on_bell: Box, + pub on_desktop_notification: Box, + pub on_open_browser_here: Box, + pub on_open_keybinds: Box, + pub current_shortcuts: Box, + pub on_capture_shortcut: Rc, pub on_pwd_changed: Box, - pub on_empty: Box, + pub on_empty: Box, pub on_state_changed: Box, + pub on_split_with_tab: Box, + pub current_config: Box, +} + +#[derive(Clone)] +struct TerminalTabState { + cwd: Rc>>, + handle: terminal::TerminalHandle, +} + +#[derive(Clone)] +pub struct TerminalShortcutTarget { + handle: terminal::TerminalHandle, +} + +impl TerminalShortcutTarget { + pub fn perform_binding_action(&self, action: &str) -> bool { + self.handle.perform_binding_action(action) + } + + pub fn show_find(&self) -> bool { + self.handle.show_find() + } + + pub fn find_next(&self) -> bool { + self.handle.find_next() + } + + pub fn find_previous(&self) -> bool { + self.handle.find_previous() + } + + pub fn hide_find(&self) -> bool { + self.handle.hide_find() + } + + pub fn use_selection_for_find(&self) -> bool { + self.handle.use_selection_for_find() + } +} + +#[derive(Clone)] +struct BrowserTabState { + uri: Rc>>, + handles: BrowserHandles, +} + +#[derive(Clone)] +pub struct BrowserShortcutTarget { + uri: Rc>>, + handles: BrowserHandles, +} + +#[derive(Clone)] +pub enum FocusedShortcutTarget { + None, + Terminal(TerminalShortcutTarget), + Browser(BrowserShortcutTarget), + Keybinds, } #[derive(Clone)] @@ -49,8 +257,9 @@ struct TabContextMenuContext { pub const PANE_CSS: &str = r#" .limux-pane-header { - background-color: rgba(30, 30, 30, 1); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background-color: @window_bg_color; + color: @window_fg_color; + border-bottom: 1px solid alpha(@window_fg_color, 0.08); min-height: 30px; padding: 0 2px; } @@ -59,54 +268,55 @@ pub const PANE_CSS: &str = r#" border: none; border-radius: 4px 4px 0 0; padding: 4px 4px 4px 10px; - color: rgba(255, 255, 255, 0.45); + color: alpha(@window_fg_color, 0.5); min-height: 0; font-size: 12px; } .limux-tab:hover { - color: rgba(255, 255, 255, 0.7); - background: rgba(255, 255, 255, 0.04); + color: alpha(@window_fg_color, 0.72); + background: alpha(@window_fg_color, 0.04); } .limux-tab-active { - color: white; - background: rgba(255, 255, 255, 0.08); + color: @window_fg_color; + background: alpha(@window_fg_color, 0.08); } .limux-tab-close { background: none; border: none; - border-radius: 3px; - padding: 1px; + border-radius: 6px; + padding: 2px; min-height: 0; min-width: 0; - color: rgba(255, 255, 255, 0.25); - margin-left: 4px; + margin: 0 0 0 4px; + color: alpha(@window_fg_color, 0.28); } .limux-tab-close:hover { - color: rgba(255, 255, 255, 0.8); - background: rgba(255, 255, 255, 0.1); + color: alpha(@window_fg_color, 0.8); + background: alpha(@window_fg_color, 0.08); } .limux-pane-action { background: none; border: none; - border-radius: 4px; - padding: 4px 5px; + border-radius: 6px; + padding: 4px; min-height: 0; min-width: 0; - color: rgba(255, 255, 255, 0.35); + margin: 0 1px; + color: alpha(@window_fg_color, 0.4); } .limux-pane-action:hover { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.8); + background: alpha(@window_fg_color, 0.08); + color: alpha(@window_fg_color, 0.8); } .limux-split-icon { - border: 1px solid rgba(255, 255, 255, 0.4); + border: 1px solid alpha(@window_fg_color, 0.4); border-radius: 2px; min-width: 16px; min-height: 12px; padding: 0; } .limux-split-icon:hover { - border-color: rgba(255, 255, 255, 0.8); + border-color: alpha(@window_fg_color, 0.8); } .limux-split-half-v { min-width: 6px; @@ -125,21 +335,41 @@ pub const PANE_CSS: &str = r#" min-width: 0; } .limux-split-btn:hover { - background: rgba(255, 255, 255, 0.08); + background: alpha(@window_fg_color, 0.08); } .limux-pin-icon { font-size: 9px; margin-right: 2px; } .limux-tab-rename-entry { - background: rgba(255, 255, 255, 0.1); - color: white; - border: 1px solid rgba(0, 145, 255, 0.5); - border-radius: 3px; padding: 1px 4px; min-height: 0; font-size: 12px; } +.limux-browser-url-entry { + min-height: 0; + font-size: 12px; +} +.limux-browser-search-entry { + min-height: 0; + font-size: 12px; +} +.limux-tab-drop-indicator { + background-color: @accent_bg_color; + min-width: 2px; + margin: 2px 0; +} +.limux-tab-overlay:drop(active) { + box-shadow: none; +} +.limux-drop-preview { + background: alpha(@accent_bg_color, 0.24); + border: 1px solid alpha(@accent_bg_color, 0.65); + border-radius: 10px; +} +.limux-drop-preview-center { + background: alpha(@accent_bg_color, 0.14); +} "#; // --------------------------------------------------------------------------- @@ -148,50 +378,111 @@ pub const PANE_CSS: &str = r#" pub fn create_pane( callbacks: Rc, + shortcuts: Rc, working_directory: Option<&str>, initial_state: Option<&PaneState>, + skip_default_tab: bool, ) -> gtk::Box { - // Store workspace working directory for new tabs/splits to inherit - let ws_wd: Rc>> = - Rc::new(RefCell::new(working_directory.map(|s| s.to_string()))); - let outer = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .hexpand(true) .vexpand(true) .build(); - // The single header line: tabs (left) + action icons (right) + // The single header line: [leading slot] tabs (left) + action icons (right) let header = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(0) .build(); header.add_css_class("limux-pane-header"); - // Tab strip (left side, scrollable) + // Empty leading slot at the very start of the header — window.rs can + // stash the dock toggle here when the top bar is hidden and the sidebar + // is collapsed. Hidden by default (no children = no width). + let leading_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + leading_box.add_css_class("limux-pane-leading"); + header.append(&leading_box); + + let tab_overlay = gtk::Overlay::new(); + tab_overlay.add_css_class("limux-tab-overlay"); + tab_overlay.set_hexpand(true); + + // tab_strip holds the actual tab buttons (natural width). A WindowHandle + // sibling to its right soaks up the remaining space and drags the window + // when clicked, so the empty area after the last tab is also draggable. let tab_strip = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + let tab_drag_filler = gtk::WindowHandle::new(); + tab_drag_filler.set_hexpand(true); + let tab_strip_wrapper = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(0) .hexpand(true) .build(); + tab_strip_wrapper.append(&tab_strip); + tab_strip_wrapper.append(&tab_drag_filler); + tab_overlay.set_child(Some(&tab_strip_wrapper)); + + let drop_indicator = gtk::Box::new(gtk::Orientation::Vertical, 0); + drop_indicator.add_css_class("limux-tab-drop-indicator"); + drop_indicator.set_halign(gtk::Align::Start); + drop_indicator.set_valign(gtk::Align::Fill); + drop_indicator.set_visible(false); + tab_overlay.add_overlay(&drop_indicator); + tab_overlay.set_clip_overlay(&drop_indicator, false); - // Content stack for tab pages let content_stack = gtk::Stack::new(); content_stack.set_transition_type(gtk::StackTransitionType::None); content_stack.set_hexpand(true); content_stack.set_vexpand(true); + let content_overlay = gtk::Overlay::new(); + content_overlay.set_hexpand(true); + content_overlay.set_vexpand(true); + content_overlay.set_child(Some(&content_stack)); + + let content_drop_overlay = gtk::Box::new(gtk::Orientation::Horizontal, 0); + content_drop_overlay.set_halign(gtk::Align::Start); + content_drop_overlay.set_valign(gtk::Align::Start); + content_drop_overlay.set_visible(false); + content_drop_overlay.set_can_target(false); + content_overlay.add_overlay(&content_drop_overlay); + // Action icons (right side) let actions = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(1) .build(); - let new_term_btn = icon_button("utilities-terminal-symbolic", "New terminal tab"); - let new_browser_btn = icon_button("limux-globe-symbolic", "New browser tab"); - let split_h_btn = icon_button("limux-split-horizontal-symbolic", "Split right"); - let split_v_btn = icon_button("limux-split-vertical-symbolic", "Split down"); - let close_btn = icon_button("window-close-symbolic", "Close pane"); + let new_term_btn = icon_button( + "utilities-terminal-symbolic", + &pane_action_tooltip( + &shortcuts, + "New terminal tab", + Some(ShortcutId::NewTerminal), + ), + ); + let new_browser_btn = icon_button( + "limux-globe-symbolic", + &pane_action_tooltip(&shortcuts, "New browser tab", None), + ); + let split_h_btn = icon_button( + "limux-split-horizontal-symbolic", + &pane_action_tooltip(&shortcuts, "Split right", Some(ShortcutId::SplitRight)), + ); + let split_v_btn = icon_button( + "limux-split-vertical-symbolic", + &pane_action_tooltip(&shortcuts, "Split down", Some(ShortcutId::SplitDown)), + ); + let close_btn = icon_button( + "window-close-symbolic", + &pane_action_tooltip(&shortcuts, "Close pane", Some(ShortcutId::CloseFocusedPane)), + ); actions.append(&new_term_btn); actions.append(&new_browser_btn); @@ -199,61 +490,57 @@ pub fn create_pane( actions.append(&split_v_btn); actions.append(&close_btn); - header.append(&tab_strip); + header.append(&tab_overlay); header.append(&actions); outer.append(&header); - outer.append(&content_stack); + outer.append(&content_overlay); - // Shared state for tabs - let tab_state = Rc::new(std::cell::RefCell::new(TabState { + let ws_wd = Rc::new(RefCell::new( + working_directory.map(|value| value.to_string()), + )); + let tab_state = Rc::new(RefCell::new(TabState { tabs: Vec::new(), active_tab: None, })); + let workspace_dragging = Rc::new(Cell::new(false)); + let pane_id = next_pane_id(); + let internals = Rc::new(PaneInternals { + pane_id, + tab_state: tab_state.clone(), + tab_strip: tab_strip.clone(), + content_stack: content_stack.clone(), + drop_indicator: drop_indicator.clone(), + content_drop_overlay: content_drop_overlay.clone(), + pane_outer: outer.clone(), + leading_box: leading_box.clone(), + callbacks: callbacks.clone(), + working_directory: ws_wd.clone(), + workspace_dragging: workspace_dragging.clone(), + new_terminal_button: new_term_btn.clone(), + split_right_button: split_h_btn.clone(), + split_down_button: split_v_btn.clone(), + close_pane_button: close_btn.clone(), + }); if let Some(saved_state) = initial_state { - restore_tabs_from_state( - &tab_strip, - &content_stack, - &tab_state, - &callbacks, - working_directory, - &outer, - saved_state, - ); - } else { - add_terminal_tab_inner( - &tab_strip, - &content_stack, - &tab_state, - &callbacks, - working_directory, - &outer, - None, - ); + restore_tabs_from_state(&internals, working_directory, saved_state); + } else if !skip_default_tab { + add_terminal_tab_inner(&internals, working_directory, None); } - // Wire action buttons { - let ts = tab_strip.clone(); - let cs = content_stack.clone(); - let state = tab_state.clone(); - let cb = callbacks.clone(); - let ow = outer.clone(); + let internals = internals.clone(); let wd = ws_wd.clone(); new_term_btn.connect_clicked(move |_| { let dir = wd.borrow().clone(); - add_terminal_tab_inner(&ts, &cs, &state, &cb, dir.as_deref(), &ow, None); + add_terminal_tab_inner(&internals, dir.as_deref(), None); }); } { - let ts = tab_strip.clone(); - let cs = content_stack.clone(); - let state = tab_state.clone(); - let cb = callbacks.clone(); - let ow = outer.clone(); + let internals = internals.clone(); new_browser_btn.connect_clicked(move |_| { - add_browser_tab_inner(&ts, &cs, &state, &cb, &ow, None); + add_browser_tab_inner(&internals, None); }); } { @@ -277,19 +564,16 @@ pub fn create_pane( (cb.on_close_pane)(&pw.clone().upcast()); }); } + install_tab_strip_drop_target(&tab_overlay, &internals); + install_content_drop_target(&internals); - // Store internals on the outer widget so external code can cycle tabs - let internals = Rc::new(PaneInternals { - tab_state: tab_state.clone(), - tab_strip: tab_strip.clone(), - content_stack: content_stack.clone(), - pane_outer: outer.clone(), - callbacks: callbacks.clone(), - working_directory: ws_wd.clone(), - }); + register_pane(pane_id, &internals); unsafe { outer.set_data("limux-pane-internals", internals); } + outer.connect_destroy(move |_| { + unregister_pane(pane_id); + }); outer } @@ -333,20 +617,85 @@ pub fn cycle_tab_in_pane(pane_widget: >k::Widget, delta: i32) { (internals.callbacks.on_state_changed)(); } +pub fn focus_active_tab_in_pane(pane_widget: >k::Widget) -> bool { + let Some(internals) = find_pane_internals(pane_widget) else { + return false; + }; + + let target_tab_id = { + let tab_state = internals.tab_state.borrow(); + tab_state + .active_tab + .clone() + .or_else(|| tab_state.tabs.first().map(|entry| entry.id.clone())) + }; + + let Some(tab_id) = target_tab_id else { + return false; + }; + + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + &tab_id, + ); + true +} + +fn normalize_surface_hint(raw: &str) -> &str { + raw.trim() + .strip_prefix("surface:") + .unwrap_or_else(|| raw.trim()) +} + +pub fn terminal_handle_for_surface( + pane_widget: >k::Widget, + surface_hint: Option<&str>, +) -> Option<(String, terminal::TerminalHandle)> { + let internals = find_pane_internals(pane_widget)?; + let tab_state = internals.tab_state.borrow(); + let requested = surface_hint + .map(normalize_surface_hint) + .filter(|value| !value.is_empty()); + let active_tab = tab_state.active_tab.as_deref(); + let mut fallback = None; + + for entry in &tab_state.tabs { + let TabKind::Terminal { state } = &entry.kind else { + continue; + }; + + if requested == Some(entry.id.as_str()) { + return Some((entry.id.clone(), state.handle.clone())); + } + + if active_tab == Some(entry.id.as_str()) { + return Some((entry.id.clone(), state.handle.clone())); + } + + if fallback.is_none() { + fallback = Some((entry.id.clone(), state.handle.clone())); + } + } + + fallback +} + // --------------------------------------------------------------------------- // Internal tab state // --------------------------------------------------------------------------- #[derive(Clone)] enum TabKind { - Terminal { cwd: Rc>> }, - Browser { uri: Rc>> }, + Terminal { state: TerminalTabState }, + Browser { state: BrowserTabState }, + Keybinds, } struct TabEntry { id: String, tab_button: gtk::Box, - #[allow(dead_code)] title_label: gtk::Label, content: gtk::Widget, custom_name: Option, @@ -361,12 +710,21 @@ struct TabState { /// Shared internals stored on the pane outer Box for external access. pub struct PaneInternals { + pane_id: u32, tab_state: Rc>, tab_strip: gtk::Box, content_stack: gtk::Stack, + drop_indicator: gtk::Box, + content_drop_overlay: gtk::Box, pane_outer: gtk::Box, + leading_box: gtk::Box, callbacks: Rc, working_directory: Rc>>, + workspace_dragging: Rc>, + new_terminal_button: gtk::Button, + split_right_button: gtk::Button, + split_down_button: gtk::Button, + close_pane_button: gtk::Button, } impl TabState { @@ -388,11 +746,22 @@ fn icon_button(icon_name: &str, tooltip: &str) -> gtk::Button { .icon_name(icon_name) .tooltip_text(tooltip) .has_frame(false) + .valign(gtk::Align::Center) .build(); btn.add_css_class("limux-pane-action"); btn } +fn pane_action_tooltip( + shortcuts: &ResolvedShortcutConfig, + base: &str, + shortcut_id: Option, +) -> String { + shortcut_id + .map(|id| shortcuts.tooltip_text(id, base)) + .unwrap_or_else(|| base.to_string()) +} + /// Create a split-pane icon button with two rectangles separated by a divider. /// Horizontal = left|right panes, Vertical = top/bottom panes. #[allow(dead_code)] @@ -438,37 +807,33 @@ struct BrowserTabOptions<'a> { uri: Option<&'a str>, } +struct KeybindsTabOptions<'a> { + id: Option<&'a str>, + custom_name: Option<&'a str>, + pinned: bool, +} + +struct KeybindsTabInput<'a> { + shortcuts: Rc, + on_capture: Rc, + options: Option>, +} + fn restore_tabs_from_state( - tab_strip: >k::Box, - content_stack: >k::Stack, - tab_state: &Rc>, - callbacks: &Rc, + internals: &Rc, working_directory: Option<&str>, - pane_outer: >k::Box, saved_state: &PaneState, ) { if saved_state.tabs.is_empty() { - add_terminal_tab_inner( - tab_strip, - content_stack, - tab_state, - callbacks, - working_directory, - pane_outer, - None, - ); + add_terminal_tab_inner(internals, working_directory, None); return; } for saved_tab in &saved_state.tabs { match &saved_tab.content { TabContentState::Terminal { cwd } => add_terminal_tab_inner( - tab_strip, - content_stack, - tab_state, - callbacks, + internals, cwd.as_deref().or(working_directory), - pane_outer, Some(TerminalTabOptions { id: Some(saved_tab.id.as_str()), custom_name: saved_tab.custom_name.as_deref(), @@ -477,11 +842,7 @@ fn restore_tabs_from_state( }), ), TabContentState::Browser { uri } => add_browser_tab_inner( - tab_strip, - content_stack, - tab_state, - callbacks, - pane_outer, + internals, Some(BrowserTabOptions { id: Some(saved_tab.id.as_str()), custom_name: saved_tab.custom_name.as_deref(), @@ -489,140 +850,202 @@ fn restore_tabs_from_state( uri: uri.as_deref(), }), ), + TabContentState::Keybinds {} => add_keybind_editor_tab_inner( + internals, + KeybindsTabInput { + shortcuts: (internals.callbacks.current_shortcuts)(), + on_capture: internals.callbacks.on_capture_shortcut.clone(), + options: Some(KeybindsTabOptions { + id: Some(saved_tab.id.as_str()), + custom_name: saved_tab.custom_name.as_deref(), + pinned: saved_tab.pinned, + }), + }, + ), + // Settings now open in a transient dialog rather than a persisted tab. + TabContentState::Settings {} => {} } } + if internals.tab_state.borrow().tabs.is_empty() { + add_terminal_tab_inner(internals, working_directory, None); + } + let active_tab_id = saved_state .active_tab_id .as_deref() .filter(|candidate| { - tab_state + internals + .tab_state .borrow() .tabs .iter() .any(|tab| tab.id == *candidate) }) .map(|value| value.to_string()) - .or_else(|| tab_state.borrow().tabs.first().map(|tab| tab.id.clone())); + .or_else(|| { + internals + .tab_state + .borrow() + .tabs + .first() + .map(|tab| tab.id.clone()) + }); if let Some(active_tab_id) = active_tab_id { - activate_tab(tab_strip, content_stack, tab_state, &active_tab_id); + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + &active_tab_id, + ); + } +} + +fn make_terminal_callbacks( + internals: &Rc, + tab_id: &str, + title_label: >k::Label, + term_cwd: &Rc>>, +) -> TerminalCallbacks { + let tid_for_title = tab_id.to_string(); + let title_label = title_label.clone(); + let state_for_title = internals.tab_state.clone(); + let callbacks_for_bell = internals.callbacks.clone(); + let callbacks_for_pwd = internals.callbacks.clone(); + let callbacks_for_close = internals.callbacks.clone(); + let callbacks_for_browser_here = internals.callbacks.clone(); + let callbacks_for_split_right = internals.callbacks.clone(); + let callbacks_for_split_down = internals.callbacks.clone(); + let callbacks_for_keybinds = internals.callbacks.clone(); + let tab_strip = internals.tab_strip.clone(); + let content_stack = internals.content_stack.clone(); + let tab_state = internals.tab_state.clone(); + let pane_outer = internals.pane_outer.clone(); + let term_cwd_for_pwd = term_cwd.clone(); + let tid_for_close = tab_id.to_string(); + + TerminalCallbacks { + on_title_changed: Box::new(move |title: &str| { + let has_custom = state_for_title + .borrow() + .tabs + .iter() + .any(|entry| entry.id == tid_for_title && entry.custom_name.is_some()); + if has_custom || title.is_empty() { + return; + } + let display = if title.len() > 22 { + format!("{}…", &title[..21]) + } else { + title.to_string() + }; + title_label.set_label(&display); + }), + on_pwd_changed: Box::new(move |pwd: &str| { + *term_cwd_for_pwd.borrow_mut() = Some(pwd.to_string()); + (callbacks_for_pwd.on_pwd_changed)(pwd); + (callbacks_for_pwd.on_state_changed)(); + }), + on_desktop_notification: Box::new({ + let callbacks = internals.callbacks.clone(); + move |title: &str, body: &str| { + (callbacks.on_desktop_notification)(title, body); + } + }), + on_bell: Box::new(move || { + (callbacks_for_bell.on_bell)(); + }), + on_close: Box::new(move || { + let tab_strip = tab_strip.clone(); + let content_stack = content_stack.clone(); + let tab_state = tab_state.clone(); + let callbacks = callbacks_for_close.clone(); + let pane_outer = pane_outer.clone(); + let tab_id = tid_for_close.clone(); + glib::idle_add_local_once(move || { + remove_tab( + &tab_strip, + &content_stack, + &tab_state, + &tab_id, + &callbacks, + &pane_outer, + PaneEmptyReason::ClosedLastTab, + ); + }); + }), + on_open_browser_here: Box::new({ + let pane_outer = internals.pane_outer.clone(); + move || { + let pane_widget: gtk::Widget = pane_outer.clone().upcast(); + (callbacks_for_browser_here.on_open_browser_here)(&pane_widget); + } + }), + on_split_right: Box::new({ + let pane_outer = internals.pane_outer.clone(); + move || { + let pane_widget: gtk::Widget = pane_outer.clone().upcast(); + (callbacks_for_split_right.on_split)(&pane_widget, gtk::Orientation::Horizontal); + } + }), + on_split_down: Box::new({ + let pane_outer = internals.pane_outer.clone(); + move || { + let pane_widget: gtk::Widget = pane_outer.clone().upcast(); + (callbacks_for_split_down.on_split)(&pane_widget, gtk::Orientation::Vertical); + } + }), + on_open_keybinds: Box::new({ + let pane_outer = internals.pane_outer.clone(); + move |_anchor| { + let pane_widget: gtk::Widget = pane_outer.clone().upcast(); + (callbacks_for_keybinds.on_open_keybinds)(&pane_widget); + } + }), } } fn add_terminal_tab_inner( - tab_strip: >k::Box, - content_stack: >k::Stack, - tab_state: &Rc>, - callbacks: &Rc, + internals: &Rc, working_directory: Option<&str>, - pane_outer: >k::Box, options: Option>, ) { let tab_id = options .as_ref() .and_then(|value| value.id.map(|id| id.to_string())) .unwrap_or_else(next_tab_id); + let (tab_btn, title_label) = build_tab_button("Terminal", &tab_id, internals); - // Tab label button - let (tab_btn, title_label) = build_tab_button( - "Terminal", - &tab_id, - tab_strip, - content_stack, - tab_state, - callbacks, - pane_outer, - ); - - // Build Ghostty terminal callbacks for title/bell/close let term_cwd = Rc::new(RefCell::new( options .as_ref() .and_then(|value| value.cwd.map(|cwd| cwd.to_string())) .or_else(|| working_directory.map(|cwd| cwd.to_string())), )); - let term_callbacks = { - let tl = title_label.clone(); - let state_for_title = tab_state.clone(); - let tid_for_title = tab_id.clone(); - let cb_bell = callbacks.clone(); - let ts = tab_strip.clone(); - let cs = content_stack.clone(); - let state_for_close = tab_state.clone(); - let tid_for_close = tab_id.clone(); - let cb_close = callbacks.clone(); - let po = pane_outer.clone(); - let cb_state = callbacks.clone(); - let term_cwd_for_pwd = term_cwd.clone(); - - TerminalCallbacks { - on_title_changed: Box::new(move |title: &str| { - let has_custom = state_for_title - .borrow() - .tabs - .iter() - .any(|e| e.id == tid_for_title && e.custom_name.is_some()); - if has_custom { - return; - } - if !title.is_empty() { - let display = if title.len() > 22 { - format!("{}…", &title[..21]) - } else { - title.to_string() - }; - tl.set_label(&display); - } - }), - on_bell: Box::new(move || { - (cb_bell.on_bell)(); - }), - on_pwd_changed: Box::new({ - let cb_pwd = callbacks.clone(); - move |pwd: &str| { - *term_cwd_for_pwd.borrow_mut() = Some(pwd.to_string()); - (cb_pwd.on_pwd_changed)(pwd); - (cb_state.on_state_changed)(); - } - }), - on_close: Box::new(move || { - let ts = ts.clone(); - let cs = cs.clone(); - let state = state_for_close.clone(); - let tid = tid_for_close.clone(); - let cb = cb_close.clone(); - let po = po.clone(); - glib::idle_add_local_once(move || { - remove_tab(&ts, &cs, &state, &tid, &cb, &po); - }); - }), - on_split_right: Box::new({ - let cb = callbacks.clone(); - let po = pane_outer.clone(); - move || { - let w: gtk::Widget = po.clone().upcast(); - (cb.on_split)(&w, gtk::Orientation::Horizontal); - } - }), - on_split_down: Box::new({ - let cb = callbacks.clone(); - let po = pane_outer.clone(); - move || { - let w: gtk::Widget = po.clone().upcast(); - (cb.on_split)(&w, gtk::Orientation::Vertical); - } - }), - } + let term_callbacks = make_terminal_callbacks(internals, &tab_id, &title_label, &term_cwd); + let hover_focus = { + let callbacks = internals.callbacks.clone(); + Rc::new(move || { + let config = (callbacks.current_config)(); + let hover_focus = config.borrow().focus.hover_terminal_focus; + hover_focus + }) }; - let term = terminal::create_terminal(working_directory, term_callbacks); - - let widget: gtk::Widget = term.clone().upcast(); - content_stack.add_named(&widget, Some(&tab_id)); + let term = terminal::create_terminal( + working_directory, + terminal::TerminalOptions { + hover_focus, + saved_font_size: (internals.callbacks.current_config)().borrow().font_size, + }, + term_callbacks, + ); + let widget: gtk::Widget = term.overlay.clone().upcast(); + internals.content_stack.add_named(&widget, Some(&tab_id)); { - let mut ts = tab_state.borrow_mut(); + let mut ts = internals.tab_state.borrow_mut(); ts.tabs.push(TabEntry { id: tab_id.clone(), tab_button: tab_btn, @@ -633,16 +1056,30 @@ fn add_terminal_tab_inner( .and_then(|value| value.custom_name.map(|name| name.to_string())), pinned: options.as_ref().map(|value| value.pinned).unwrap_or(false), kind: TabKind::Terminal { - cwd: term_cwd.clone(), + state: TerminalTabState { + cwd: term_cwd.clone(), + handle: term.handle.clone(), + }, }, }); } + internals.tab_strip.append( + &internals + .tab_state + .borrow() + .tabs + .iter() + .find(|entry| entry.id == tab_id) + .expect("terminal tab inserted") + .tab_button, + ); if let Some(custom_name) = options.as_ref().and_then(|value| value.custom_name) { title_label.set_label(custom_name); } if options.as_ref().map(|value| value.pinned).unwrap_or(false) { - if let Some(entry) = tab_state + if let Some(entry) = internals + .tab_state .borrow() .tabs .iter() @@ -652,21 +1089,19 @@ fn add_terminal_tab_inner( } } - activate_tab(tab_strip, content_stack, tab_state, &tab_id); - term.grab_focus(); + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + &tab_id, + ); + term.overlay.grab_focus(); if options.is_none() { - (callbacks.on_state_changed)(); + (internals.callbacks.on_state_changed)(); } } -fn add_browser_tab_inner( - tab_strip: >k::Box, - content_stack: >k::Stack, - tab_state: &Rc>, - callbacks: &Rc, - pane_outer: >k::Box, - options: Option>, -) { +fn add_browser_tab_inner(internals: &Rc, options: Option>) { let tab_id = options .as_ref() .and_then(|value| value.id.map(|id| id.to_string())) @@ -676,26 +1111,18 @@ fn add_browser_tab_inner( .as_ref() .and_then(|value| value.uri.map(|uri| uri.to_string())), )); - let (widget, title) = create_browser_widget( + let (widget, title, handles) = create_browser_widget( options.as_ref().and_then(|value| value.uri), saved_uri.clone(), - callbacks.clone(), + internals.callbacks.clone(), ); - let (tab_btn, title_label) = build_tab_button( - &title, - &tab_id, - tab_strip, - content_stack, - tab_state, - callbacks, - pane_outer, - ); + let (tab_btn, title_label) = build_tab_button(&title, &tab_id, internals); - content_stack.add_named(&widget, Some(&tab_id)); + internals.content_stack.add_named(&widget, Some(&tab_id)); { - let mut ts = tab_state.borrow_mut(); + let mut ts = internals.tab_state.borrow_mut(); ts.tabs.push(TabEntry { id: tab_id.clone(), tab_button: tab_btn, @@ -706,16 +1133,30 @@ fn add_browser_tab_inner( .and_then(|value| value.custom_name.map(|name| name.to_string())), pinned: options.as_ref().map(|value| value.pinned).unwrap_or(false), kind: TabKind::Browser { - uri: saved_uri.clone(), + state: BrowserTabState { + uri: saved_uri.clone(), + handles, + }, }, }); } + internals.tab_strip.append( + &internals + .tab_state + .borrow() + .tabs + .iter() + .find(|entry| entry.id == tab_id) + .expect("browser tab inserted") + .tab_button, + ); if let Some(custom_name) = options.as_ref().and_then(|value| value.custom_name) { title_label.set_label(custom_name); } if options.as_ref().map(|value| value.pinned).unwrap_or(false) { - if let Some(entry) = tab_state + if let Some(entry) = internals + .tab_state .borrow() .tabs .iter() @@ -725,43 +1166,187 @@ fn add_browser_tab_inner( } } - activate_tab(tab_strip, content_stack, tab_state, &tab_id); - if options.is_none() { - (callbacks.on_state_changed)(); - } -} - + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + &tab_id, + ); + if options.is_none() { + (internals.callbacks.on_state_changed)(); + } +} + +fn add_keybind_editor_tab_inner(internals: &Rc, input: KeybindsTabInput<'_>) { + let tab_id = input + .options + .as_ref() + .and_then(|value| value.id.map(|id| id.to_string())) + .unwrap_or_else(next_tab_id); + + let (tab_btn, title_label) = build_tab_button("Keybinds", &tab_id, internals); + + let widget = keybind_editor::build_keybind_editor(&input.shortcuts, input.on_capture); + internals.content_stack.add_named(&widget, Some(&tab_id)); + + { + let mut ts = internals.tab_state.borrow_mut(); + ts.tabs.push(TabEntry { + id: tab_id.clone(), + tab_button: tab_btn, + title_label: title_label.clone(), + content: widget, + custom_name: input + .options + .as_ref() + .and_then(|value| value.custom_name.map(|name| name.to_string())), + pinned: input + .options + .as_ref() + .map(|value| value.pinned) + .unwrap_or(false), + kind: TabKind::Keybinds, + }); + } + internals.tab_strip.append( + &internals + .tab_state + .borrow() + .tabs + .iter() + .find(|entry| entry.id == tab_id) + .expect("keybinds tab inserted") + .tab_button, + ); + + if let Some(custom_name) = input.options.as_ref().and_then(|value| value.custom_name) { + title_label.set_label(custom_name); + } + if input + .options + .as_ref() + .map(|value| value.pinned) + .unwrap_or(false) + { + if let Some(entry) = internals + .tab_state + .borrow() + .tabs + .iter() + .find(|entry| entry.id == tab_id) + { + apply_pin_visuals(&entry.tab_button, true); + } + } + + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + &tab_id, + ); + if input.options.is_none() { + (internals.callbacks.on_state_changed)(); + } +} + // Public wrappers for keyboard shortcut use #[allow(dead_code)] pub fn add_terminal_tab_to_pane(pane_widget: >k::Widget) { if let Some(internals) = find_pane_internals(pane_widget) { let dir = internals.working_directory.borrow().clone(); - add_terminal_tab_inner( - &internals.tab_strip, - &internals.content_stack, - &internals.tab_state, - &internals.callbacks, - dir.as_deref(), - &internals.pane_outer, - None, - ); + add_terminal_tab_inner(&internals, dir.as_deref(), None); } } #[allow(dead_code)] pub fn add_browser_tab_to_pane(pane_widget: >k::Widget) { + add_browser_tab_to_pane_with_uri(pane_widget, None); +} + +#[allow(dead_code)] +pub fn add_browser_tab_to_pane_with_uri(pane_widget: >k::Widget, uri: Option<&str>) { if let Some(internals) = find_pane_internals(pane_widget) { - add_browser_tab_inner( - &internals.tab_strip, - &internals.content_stack, - &internals.tab_state, - &internals.callbacks, - &internals.pane_outer, - None, + let options = uri.map(|uri| BrowserTabOptions { + id: None, + custom_name: None, + pinned: false, + uri: Some(uri), + }); + add_browser_tab_inner(&internals, options); + } +} + +pub fn add_keybind_editor_tab_to_pane( + pane_widget: >k::Widget, + shortcuts: Rc, + on_capture: Rc, +) { + if let Some(internals) = find_pane_internals(pane_widget) { + if let Some(existing_id) = internals + .tab_state + .borrow() + .tabs + .iter() + .find(|entry| matches!(entry.kind, TabKind::Keybinds)) + .map(|entry| entry.id.clone()) + { + activate_tab( + &internals.tab_strip, + &internals.content_stack, + &internals.tab_state, + &existing_id, + ); + (internals.callbacks.on_state_changed)(); + return; + } + + add_keybind_editor_tab_inner( + &internals, + KeybindsTabInput { + shortcuts, + on_capture, + options: None, + }, ); } } +pub fn refresh_shortcut_tooltips(pane_widget: >k::Widget, shortcuts: &ResolvedShortcutConfig) { + let Some(internals) = find_pane_internals(pane_widget) else { + return; + }; + + internals + .new_terminal_button + .set_tooltip_text(Some(&pane_action_tooltip( + shortcuts, + "New terminal tab", + Some(ShortcutId::NewTerminal), + ))); + internals + .split_right_button + .set_tooltip_text(Some(&pane_action_tooltip( + shortcuts, + "Split right", + Some(ShortcutId::SplitRight), + ))); + internals + .split_down_button + .set_tooltip_text(Some(&pane_action_tooltip( + shortcuts, + "Split down", + Some(ShortcutId::SplitDown), + ))); + internals + .close_pane_button + .set_tooltip_text(Some(&pane_action_tooltip( + shortcuts, + "Close pane", + Some(ShortcutId::CloseFocusedPane), + ))); +} + pub fn snapshot_pane_state(pane_widget: >k::Widget) -> Option { let internals = find_pane_internals(pane_widget)?; let ts = internals.tab_state.borrow(); @@ -770,12 +1355,13 @@ pub fn snapshot_pane_state(pane_widget: >k::Widget) -> Option { .iter() .map(|entry| { let content = match &entry.kind { - TabKind::Terminal { cwd } => TabContentState::Terminal { - cwd: cwd.borrow().clone(), + TabKind::Terminal { state } => TabContentState::Terminal { + cwd: state.cwd.borrow().clone(), }, - TabKind::Browser { uri } => TabContentState::Browser { - uri: uri.borrow().clone(), + TabKind::Browser { state } => TabContentState::Browser { + uri: state.uri.borrow().clone(), }, + TabKind::Keybinds => TabContentState::Keybinds {}, }; SavedTabState { id: entry.id.clone(), @@ -791,7 +1377,6 @@ pub fn snapshot_pane_state(pane_widget: >k::Widget) -> Option { }) } -#[allow(dead_code)] fn find_pane_internals(pane_widget: >k::Widget) -> Option> { let outer = pane_widget.downcast_ref::()?; unsafe { @@ -801,6 +1386,105 @@ fn find_pane_internals(pane_widget: >k::Widget) -> Option> { } } +/// Returns the leading slot (at the very start of the pane header) so the +/// outer app can place widgets there (e.g. a dock toggle). The box stays +/// empty by default. +pub fn pane_leading_box(pane_widget: >k::Widget) -> Option { + find_pane_internals(pane_widget).map(|internals| internals.leading_box.clone()) +} + +pub fn is_pane_widget(widget: >k::Widget) -> bool { + let Some(container) = widget.downcast_ref::() else { + return false; + }; + + let mut child = container.first_child(); + while let Some(current) = child { + if current.has_css_class("limux-pane-header") { + return true; + } + // The header can be wrapped in a WindowHandle (used so empty space in + // the header drags the window); look through it for the real header. + if let Some(handle) = current.downcast_ref::() { + if let Some(inner) = handle.child() { + if inner.has_css_class("limux-pane-header") { + return true; + } + } + } + child = current.next_sibling(); + } + + false +} + +pub fn tab_title(pane_widget: >k::Widget, tab_id: &str) -> Option { + let internals = find_pane_internals(pane_widget)?; + let tab_state = internals.tab_state.borrow(); + let entry = tab_state.tabs.iter().find(|entry| entry.id == tab_id)?; + Some(entry.title_label.label().to_string()) +} + +pub fn tab_working_directory(pane_widget: >k::Widget, tab_id: &str) -> Option { + let internals = find_pane_internals(pane_widget)?; + let tab_state = internals.tab_state.borrow(); + let entry = tab_state.tabs.iter().find(|entry| entry.id == tab_id)?; + match &entry.kind { + TabKind::Terminal { state } => state.cwd.borrow().clone(), + TabKind::Browser { .. } | TabKind::Keybinds => None, + } +} + +pub fn move_tab_to_pane( + source_pane: >k::Widget, + tab_id: &str, + target_pane: >k::Widget, +) -> bool { + let Some(source) = find_pane_internals(source_pane) else { + return false; + }; + let Some(target) = find_pane_internals(target_pane) else { + return false; + }; + let insert_idx = target.tab_state.borrow().tabs.len(); + transfer_tab_between_panes(&source, &target, tab_id, insert_idx) +} + +pub fn focused_shortcut_target(pane_widget: >k::Widget) -> FocusedShortcutTarget { + let Some(internals) = find_pane_internals(pane_widget) else { + return FocusedShortcutTarget::None; + }; + + let target = { + let tab_state = internals.tab_state.borrow(); + let Some(active_id) = tab_state.active_tab.as_deref() else { + return FocusedShortcutTarget::None; + }; + match tab_state.tabs.iter().find(|entry| entry.id == active_id) { + Some(TabEntry { + kind: TabKind::Terminal { state }, + .. + }) => FocusedShortcutTarget::Terminal(TerminalShortcutTarget { + handle: state.handle.clone(), + }), + Some(TabEntry { + kind: TabKind::Browser { state }, + .. + }) => FocusedShortcutTarget::Browser(BrowserShortcutTarget { + uri: state.uri.clone(), + handles: state.handles.clone(), + }), + Some(TabEntry { + kind: TabKind::Keybinds, + .. + }) => FocusedShortcutTarget::Keybinds, + None => FocusedShortcutTarget::None, + } + }; + + target +} + fn apply_pin_visuals(tab_button: >k::Box, pinned: bool) { if let Some(close_widget) = tab_button.last_child() { close_widget.set_visible(!pinned); @@ -823,140 +1507,186 @@ fn apply_pin_visuals(tab_button: >k::Box, pinned: bool) { // Tab button (label + close) // --------------------------------------------------------------------------- +fn new_tab_title_label(title: &str) -> gtk::Label { + let label = gtk::Label::builder() + .label(title) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(20) + .build(); + label.set_can_target(false); + label +} + fn build_tab_button( title: &str, tab_id: &str, - tab_strip: >k::Box, - content_stack: >k::Stack, - tab_state: &Rc>, - callbacks: &Rc, - pane_outer: >k::Box, + internals: &Rc, ) -> (gtk::Box, gtk::Label) { + let label = new_tab_title_label(title); + let tab_button = build_tab_button_from_label(&label, tab_id, internals); + (tab_button, label) +} + +fn build_tab_button_from_label( + label: >k::Label, + tab_id: &str, + internals: &Rc, +) -> gtk::Box { + if let Some(parent) = label + .parent() + .and_then(|parent| parent.downcast::().ok()) + { + parent.remove(label); + } + let pin_icon = gtk::Label::new(None); pin_icon.add_css_class("limux-pin-icon"); pin_icon.set_visible(false); - pin_icon.set_can_target(false); // let clicks pass through to parent - - let label = gtk::Label::builder() - .label(title) - .ellipsize(gtk::pango::EllipsizeMode::End) - .max_width_chars(20) - .build(); - label.set_can_target(false); // let clicks pass through to parent + pin_icon.set_can_target(false); - // Close button needs its own click handling, so it stays targetable let close_btn = gtk::Button::builder() .icon_name("window-close-symbolic") .has_frame(false) + .valign(gtk::Align::Center) .build(); close_btn.add_css_class("limux-tab-close"); let inner_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); - inner_box.set_can_target(false); // pass events through + inner_box.set_can_target(false); inner_box.append(&pin_icon); - inner_box.append(&label); + inner_box.append(label); - // Use an overlay approach: the tab_btn is the event target, - // inner_box + close_btn are children let tab_btn = gtk::Box::new(gtk::Orientation::Horizontal, 0); tab_btn.add_css_class("limux-tab"); tab_btn.append(&inner_box); tab_btn.append(&close_btn); - // Left-click on the tab area (not the close button) → activate let click = gtk::GestureClick::new(); click.set_button(1); { - let tid = tab_id.to_string(); - let ts = tab_strip.clone(); - let cs = content_stack.clone(); - let state = tab_state.clone(); - let callbacks = callbacks.clone(); + let tab_id = tab_id.to_string(); + let tab_strip = internals.tab_strip.clone(); + let content_stack = internals.content_stack.clone(); + let tab_state = internals.tab_state.clone(); + let callbacks = internals.callbacks.clone(); click.connect_pressed(move |_, _, _, _| { - activate_tab(&ts, &cs, &state, &tid); + activate_tab(&tab_strip, &content_stack, &tab_state, &tab_id); (callbacks.on_state_changed)(); }); } tab_btn.add_controller(click); - // Right-click → context menu let right_click = gtk::GestureClick::new(); right_click.set_button(3); { - let tid = tab_id.to_string(); - let ts = tab_strip.clone(); - let cs = content_stack.clone(); - let state = tab_state.clone(); - let cb = callbacks.clone(); - let po = pane_outer.clone(); - let lbl = label.clone(); - let pin = pin_icon.clone(); - let tb = tab_btn.clone(); + let tab_id = tab_id.to_string(); let context = TabContextMenuContext { - tab_strip: ts, - content_stack: cs, - tab_state: state, - callbacks: cb, - pane_outer: po, - label: lbl, - pin_icon: pin, + tab_strip: internals.tab_strip.clone(), + content_stack: internals.content_stack.clone(), + tab_state: internals.tab_state.clone(), + callbacks: internals.callbacks.clone(), + pane_outer: internals.pane_outer.clone(), + label: label.clone(), + pin_icon: pin_icon.clone(), }; - right_click.connect_pressed(move |_gesture, _, _x, _y| { - show_tab_context_menu(&tb, &tid, &context); + let tab_button = tab_btn.clone(); + right_click.connect_pressed(move |_, _, _, _| { + show_tab_context_menu(&tab_button, &tab_id, &context); }); } tab_btn.add_controller(right_click); - // Drag source for reorder + // Middle-click to close the tab. + let middle_click = gtk::GestureClick::new(); + middle_click.set_button(2); + { + let tab_id = tab_id.to_string(); + let tab_strip = internals.tab_strip.clone(); + let content_stack = internals.content_stack.clone(); + let tab_state = internals.tab_state.clone(); + let callbacks = internals.callbacks.clone(); + let pane_outer = internals.pane_outer.clone(); + middle_click.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + remove_tab( + &tab_strip, + &content_stack, + &tab_state, + &tab_id, + &callbacks, + &pane_outer, + PaneEmptyReason::ClosedLastTab, + ); + }); + } + tab_btn.add_controller(middle_click); + let drag_source = gtk::DragSource::new(); drag_source.set_actions(gtk::gdk::DragAction::MOVE); { - let tid = tab_id.to_string(); + let tab_id = tab_id.to_string(); + let pane_id = internals.pane_id; drag_source.connect_prepare(move |_src, _x, _y| { - let val = glib::Value::from(&tid); - Some(gtk::gdk::ContentProvider::for_value(&val)) + let payload = glib::Value::from(&TabDragPayload::new(pane_id, &tab_id).encode()); + Some(gtk::gdk::ContentProvider::for_value(&payload)) }); } - tab_btn.add_controller(drag_source); - - // Drop target for reorder - let drop_target = gtk::DropTarget::new(glib::Type::STRING, gtk::gdk::DragAction::MOVE); { - let tid = tab_id.to_string(); - let ts = tab_strip.clone(); - let state = tab_state.clone(); - let callbacks = callbacks.clone(); - drop_target.connect_drop(move |_, value, _, _| { - if let Ok(source_id) = value.get::() { - if source_id != tid { - reorder_tab(&ts, &state, &source_id, &tid, &callbacks); - return true; - } + let drop_indicator = internals.drop_indicator.clone(); + let tab_state = internals.tab_state.clone(); + drag_source.connect_drag_begin(move |source, _drag| { + set_tab_dragging(true); + if let Some(widget) = source.widget() { + let allocation = widget.allocation(); + position_indicator( + &tab_state, + &drop_indicator, + (allocation.x() + allocation.width()) as f64, + ); + let icon = gtk::WidgetPaintable::new(Some(&widget)); + source.set_icon(Some(&icon), 0, 0); } - false }); } - tab_btn.add_controller(drop_target); + { + let drop_indicator = internals.drop_indicator.clone(); + let content_overlay = internals.content_drop_overlay.clone(); + drag_source.connect_drag_end(move |_, _, _| { + set_tab_dragging(false); + drop_indicator.set_visible(false); + clear_content_drop_zone(&content_overlay); + }); + } + tab_btn.add_controller(drag_source); - // Close button click { - let tid = tab_id.to_string(); - let ts = tab_strip.clone(); - let cs = content_stack.clone(); - let state = tab_state.clone(); - let cb = callbacks.clone(); - let po = pane_outer.clone(); + let tab_id = tab_id.to_string(); + let tab_strip = internals.tab_strip.clone(); + let content_stack = internals.content_stack.clone(); + let tab_state = internals.tab_state.clone(); + let callbacks = internals.callbacks.clone(); + let pane_outer = internals.pane_outer.clone(); close_btn.connect_clicked(move |_| { - let is_pinned = state.borrow().tabs.iter().any(|e| e.id == tid && e.pinned); + let is_pinned = tab_state + .borrow() + .tabs + .iter() + .any(|entry| entry.id == tab_id && entry.pinned); if !is_pinned { - remove_tab(&ts, &cs, &state, &tid, &cb, &po); + remove_tab( + &tab_strip, + &content_stack, + &tab_state, + &tab_id, + &callbacks, + &pane_outer, + PaneEmptyReason::ClosedLastTab, + ); } }); } - tab_strip.append(&tab_btn); - - (tab_btn, label) + tab_btn } fn show_tab_context_menu(tab_btn: >k::Box, tab_id: &str, context: &TabContextMenuContext) { @@ -1029,7 +1759,15 @@ fn show_tab_context_menu(tab_btn: >k::Box, tab_id: &str, context: &TabContextM let menu_ref = menu.clone(); close_btn.connect_clicked(move |_| { menu_ref.popdown(); - remove_tab(&ts, &cs, &state, &tid, &cb, &po); + remove_tab( + &ts, + &cs, + &state, + &tid, + &cb, + &po, + PaneEmptyReason::ClosedLastTab, + ); }); } @@ -1066,7 +1804,9 @@ fn show_rename_dialog( .text(¤t_name) .width_chars(15) .build(); - entry.add_css_class("limux-tab-rename-entry"); + for css_class in TAB_RENAME_ENTRY_CSS_CLASSES { + entry.add_css_class(css_class); + } label.set_visible(false); // Insert entry before the close button @@ -1128,88 +1868,531 @@ fn show_rename_dialog( } } -fn reorder_tab( - tab_strip: >k::Box, - tab_state: &Rc>, - source_id: &str, - target_id: &str, - callbacks: &Rc, -) { - let mut ts = tab_state.borrow_mut(); - - let Some(src_idx) = ts.tabs.iter().position(|e| e.id == source_id) else { - return; - }; - let Some(tgt_idx) = ts.tabs.iter().position(|e| e.id == target_id) else { - return; - }; +fn normalize_reorder_insert_index(source_idx: usize, insert_idx: usize) -> Option { + if source_idx == insert_idx || source_idx + 1 == insert_idx { + return None; + } + Some(if source_idx < insert_idx { + insert_idx - 1 + } else { + insert_idx + }) +} - // Move the tab entry - let entry = ts.tabs.remove(src_idx); - ts.tabs.insert(tgt_idx, entry); +fn next_active_after_tab_removal( + tab_ids: &[&str], + active_id: Option<&str>, + removed_idx: usize, +) -> Option { + if tab_ids.len() <= 1 { + return None; + } + let removed_id = tab_ids.get(removed_idx).copied()?; + if active_id != Some(removed_id) { + return active_id.map(ToOwned::to_owned); + } + let next_idx = removed_idx.min(tab_ids.len() - 2); + tab_ids + .iter() + .enumerate() + .find_map(|(idx, tab_id)| (idx != removed_idx).then_some(*tab_id)) + .and_then(|_| { + tab_ids + .iter() + .enumerate() + .filter_map(|(idx, tab_id)| (idx != removed_idx).then_some(*tab_id)) + .nth(next_idx) + }) + .map(ToOwned::to_owned) +} - // Rebuild tab strip order - // Remove all tab buttons then re-add in order - let buttons: Vec = ts.tabs.iter().map(|e| e.tab_button.clone()).collect(); - drop(ts); +fn classify_content_drop_zone(width: f64, height: f64, x: f64, y: f64) -> Option { + if width <= 0.0 || height <= 0.0 { + return None; + } + if x < width * 0.25 { + Some(ContentDropZone::Left) + } else if x > width * 0.75 { + Some(ContentDropZone::Right) + } else if y < height * 0.25 { + Some(ContentDropZone::Top) + } else if y > height * 0.75 { + Some(ContentDropZone::Bottom) + } else { + Some(ContentDropZone::Center) + } +} - for btn in &buttons { - tab_strip.remove(btn); +fn content_drop_preview_rect(zone: ContentDropZone) -> (f64, f64, f64, f64) { + match zone { + ContentDropZone::Left => (0.0, 0.0, 0.5, 1.0), + ContentDropZone::Right => (0.5, 0.0, 0.5, 1.0), + ContentDropZone::Top => (0.0, 0.0, 1.0, 0.5), + ContentDropZone::Bottom => (0.0, 0.5, 1.0, 0.5), + ContentDropZone::Center => (0.25, 0.25, 0.5, 0.5), } - for btn in &buttons { - tab_strip.append(btn); +} + +fn effective_drop_target_dimensions( + preview_width: i32, + preview_height: i32, + content_width: i32, + content_height: i32, +) -> Option<(f64, f64)> { + let width = preview_width.max(content_width); + let height = preview_height.max(content_height); + if width <= 0 || height <= 0 { + return None; } - (callbacks.on_state_changed)(); + Some((width as f64, height as f64)) } -// --------------------------------------------------------------------------- -// Tab activation / removal -// --------------------------------------------------------------------------- +fn clear_content_drop_zone(overlay: >k::Box) { + overlay.remove_css_class("limux-drop-preview"); + overlay.remove_css_class("limux-drop-preview-center"); + overlay.set_size_request(-1, -1); + overlay.set_margin_start(0); + overlay.set_margin_top(0); +} -fn activate_tab( - _tab_strip: >k::Box, - content_stack: >k::Stack, - tab_state: &Rc>, - tab_id: &str, -) { - let mut ts = tab_state.borrow_mut(); - if ts.active_tab.as_deref() == Some(tab_id) { +fn highlight_content_drop_zone(overlay: >k::Box, zone: ContentDropZone) { + clear_content_drop_zone(overlay); + overlay.add_css_class("limux-drop-preview"); + if zone == ContentDropZone::Center { + overlay.add_css_class("limux-drop-preview-center"); + } + let (x_frac, y_frac, width_frac, height_frac) = content_drop_preview_rect(zone); + let total_width = overlay + .parent() + .map(|parent| parent.allocation().width()) + .unwrap_or_else(|| overlay.width()) + .max(1); + let total_height = overlay + .parent() + .map(|parent| parent.allocation().height()) + .unwrap_or_else(|| overlay.height()) + .max(1); + overlay.set_margin_start((total_width as f64 * x_frac).round() as i32); + overlay.set_margin_top((total_height as f64 * y_frac).round() as i32); + overlay.set_size_request( + (total_width as f64 * width_frac).round() as i32, + (total_height as f64 * height_frac).round() as i32, + ); +} + +fn position_indicator(tab_state: &Rc>, indicator: >k::Box, x: f64) { + let tab_state = tab_state.borrow(); + if tab_state.tabs.is_empty() { + indicator.set_visible(false); return; } - ts.active_tab = Some(tab_id.to_string()); - // Update visual state on all tabs - for entry in &ts.tabs { - if entry.id == tab_id { - entry.tab_button.add_css_class("limux-tab-active"); - } else { - entry.tab_button.remove_css_class("limux-tab-active"); + let mut position = 0; + for entry in &tab_state.tabs { + let allocation = entry.tab_button.allocation(); + let left = allocation.x(); + let right = allocation.x() + allocation.width(); + let midpoint = allocation.x() as f64 + allocation.width() as f64 / 2.0; + if x < midpoint { + position = left; + break; } + position = right; } + indicator.set_margin_start(position); + indicator.set_visible(true); +} - content_stack.set_visible_child_name(tab_id); +fn insert_index_for_drop( + tab_state: &Rc>, + x: f64, + ignored_tab_id: Option<&str>, +) -> usize { + let tab_state = tab_state.borrow(); + for (idx, entry) in tab_state.tabs.iter().enumerate() { + if ignored_tab_id == Some(entry.id.as_str()) { + continue; + } + let allocation = entry.tab_button.allocation(); + let midpoint = allocation.x() as f64 + allocation.width() as f64 / 2.0; + if x < midpoint { + return idx; + } + } + tab_state.tabs.len() +} - // Focus the content — only grab focus on directly focusable widgets (terminals). - // For containers (browser vbox), focus the first focusable child instead. - if let Some(entry) = ts.tabs.iter().find(|e| e.id == tab_id) { - let content = entry.content.clone(); - drop(ts); - if content.is_focus() || content.can_focus() { - content.grab_focus(); - } else { - // Try to find a focusable child (e.g., the WebView inside a Box) - content.child_focus(gtk::DirectionType::TabForward); +fn rebuild_tab_strip(tab_strip: >k::Box, tab_state: &Rc>) { + let buttons: Vec = tab_state + .borrow() + .tabs + .iter() + .map(|entry| entry.tab_button.clone()) + .collect(); + for button in &buttons { + if button.parent().is_some() { + tab_strip.remove(button); } } + for button in &buttons { + tab_strip.append(button); + } } -fn remove_tab( +fn rebind_moved_tab_entry(entry: &mut TabEntry, target: &Rc) { + if let TabKind::Terminal { state } = &entry.kind { + state.handle.replace_callbacks(make_terminal_callbacks( + target, + &entry.id, + &entry.title_label, + &state.cwd, + )); + } + entry.tab_button = build_tab_button_from_label(&entry.title_label, &entry.id, target); + if entry.pinned { + apply_pin_visuals(&entry.tab_button, true); + } +} + +fn reorder_tab_to_index( tab_strip: >k::Box, - content_stack: >k::Stack, tab_state: &Rc>, - tab_id: &str, callbacks: &Rc, - pane_outer: >k::Box, + source_id: &str, + insert_idx: usize, +) -> bool { + let mut state = tab_state.borrow_mut(); + let Some(source_idx) = state.tabs.iter().position(|entry| entry.id == source_id) else { + return false; + }; + let Some(normalized_idx) = normalize_reorder_insert_index(source_idx, insert_idx) else { + return false; + }; + let entry = state.tabs.remove(source_idx); + state.tabs.insert(normalized_idx, entry); + drop(state); + rebuild_tab_strip(tab_strip, tab_state); + (callbacks.on_state_changed)(); + true +} + +fn transfer_tab_between_panes( + source: &Rc, + target: &Rc, + tab_id: &str, + insert_idx: usize, +) -> bool { + if source.pane_id == target.pane_id { + return false; + } + + let (mut entry, source_next_active) = { + let mut source_state = source.tab_state.borrow_mut(); + let Some(source_idx) = source_state.tabs.iter().position(|item| item.id == tab_id) else { + return false; + }; + let all_ids: Vec<&str> = source_state + .tabs + .iter() + .map(|item| item.id.as_str()) + .collect(); + let next_active = + next_active_after_tab_removal(&all_ids, source_state.active_tab.as_deref(), source_idx); + (source_state.tabs.remove(source_idx), next_active) + }; + + if let Some(window) = entry + .content + .root() + .and_then(|root| root.downcast::().ok()) + { + gtk::prelude::GtkWindowExt::set_focus(&window, gtk::Widget::NONE); + } + + if entry.tab_button.parent().is_some() { + source.tab_strip.remove(&entry.tab_button); + } + if entry.content.parent().is_some() { + source.content_stack.remove(&entry.content); + } + + rebind_moved_tab_entry(&mut entry, target); + let moved_tab_id = entry.id.clone(); + target + .content_stack + .add_named(&entry.content, Some(&moved_tab_id)); + + { + let mut target_state = target.tab_state.borrow_mut(); + let clamped_idx = insert_idx.min(target_state.tabs.len()); + target_state.tabs.insert(clamped_idx, entry); + } + rebuild_tab_strip(&target.tab_strip, &target.tab_state); + + let source_empty = source.tab_state.borrow().tabs.is_empty(); + if source_empty { + (source.callbacks.on_empty)( + &source.pane_outer.clone().upcast(), + PaneEmptyReason::MovedLastTabOut, + ); + } else if let Some(next_active) = source_next_active { + activate_tab( + &source.tab_strip, + &source.content_stack, + &source.tab_state, + &next_active, + ); + } + + activate_tab( + &target.tab_strip, + &target.content_stack, + &target.tab_state, + &moved_tab_id, + ); + (target.callbacks.on_state_changed)(); + true +} + +fn install_tab_strip_drop_target(tab_overlay: >k::Overlay, internals: &Rc) { + let drop_target = gtk::DropTarget::new(glib::Type::STRING, gtk::gdk::DragAction::MOVE); + drop_target.set_preload(true); + { + let tab_state = internals.tab_state.clone(); + let indicator = internals.drop_indicator.clone(); + let workspace_dragging = internals.workspace_dragging.clone(); + drop_target.connect_motion(move |_, x, _| { + if workspace_dragging.get() || !is_tab_dragging() { + indicator.set_visible(false); + return gtk::gdk::DragAction::empty(); + } + position_indicator(&tab_state, &indicator, x); + gtk::gdk::DragAction::MOVE + }); + } + { + let indicator = internals.drop_indicator.clone(); + drop_target.connect_leave(move |_| { + indicator.set_visible(false); + }); + } + { + let target = internals.clone(); + let indicator = internals.drop_indicator.clone(); + drop_target.connect_drop(move |_, value, x, _| { + indicator.set_visible(false); + let Ok(raw) = value.get::() else { + return false; + }; + let Some(payload) = TabDragPayload::decode(&raw) else { + return false; + }; + let same_pane = payload.pane_id == target.pane_id; + let insert_idx = insert_index_for_drop( + &target.tab_state, + x, + same_pane.then_some(payload.tab_id.as_str()), + ); + if same_pane { + return reorder_tab_to_index( + &target.tab_strip, + &target.tab_state, + &target.callbacks, + &payload.tab_id, + insert_idx, + ); + } + let Some(source) = lookup_pane_internals(payload.pane_id) else { + return false; + }; + transfer_tab_between_panes(&source, &target, &payload.tab_id, insert_idx) + }); + } + tab_overlay.add_controller(drop_target); +} + +fn set_browser_targeting_enabled(content_stack: >k::Stack, enabled: bool) { + let mut child = content_stack.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if !widget.has_css_class("limux-browser") { + continue; + } + let webview = widget + .first_child() + .and_then(|child| child.next_sibling()) + .and_then(|child| child.next_sibling()); + if let Some(webview) = webview { + webview.set_can_target(enabled); + } + } +} + +fn install_content_drop_target(internals: &Rc) { + let drop_target = gtk::DropTarget::new(glib::Type::STRING, gtk::gdk::DragAction::MOVE); + drop_target.set_preload(true); + { + let overlay = internals.content_drop_overlay.clone(); + let content_stack = internals.content_stack.clone(); + let workspace_dragging = internals.workspace_dragging.clone(); + drop_target.connect_motion(move |_, x, y| { + if workspace_dragging.get() || !is_tab_dragging() { + clear_content_drop_zone(&overlay); + return gtk::gdk::DragAction::empty(); + } + let Some((width, height)) = effective_drop_target_dimensions( + overlay.width(), + overlay.height(), + content_stack.allocation().width(), + content_stack.allocation().height(), + ) else { + clear_content_drop_zone(&overlay); + return gtk::gdk::DragAction::empty(); + }; + let Some(zone) = classify_content_drop_zone(width, height, x, y) else { + clear_content_drop_zone(&overlay); + return gtk::gdk::DragAction::empty(); + }; + highlight_content_drop_zone(&overlay, zone); + gtk::gdk::DragAction::MOVE + }); + } + { + let overlay = internals.content_drop_overlay.clone(); + drop_target.connect_leave(move |_| { + clear_content_drop_zone(&overlay); + }); + } + { + let target = internals.clone(); + let overlay = internals.content_drop_overlay.clone(); + let content_stack = internals.content_stack.clone(); + drop_target.connect_drop(move |_, value, x, y| { + clear_content_drop_zone(&overlay); + let Ok(raw) = value.get::() else { + return false; + }; + let Some(payload) = TabDragPayload::decode(&raw) else { + return false; + }; + let Some((width, height)) = effective_drop_target_dimensions( + overlay.width(), + overlay.height(), + content_stack.allocation().width(), + content_stack.allocation().height(), + ) else { + return false; + }; + let Some(zone) = classify_content_drop_zone(width, height, x, y) else { + return false; + }; + match zone { + ContentDropZone::Center => { + if payload.pane_id == target.pane_id { + return false; + } + let Some(source) = lookup_pane_internals(payload.pane_id) else { + return false; + }; + let insert_idx = target.tab_state.borrow().tabs.len(); + transfer_tab_between_panes(&source, &target, &payload.tab_id, insert_idx) + } + ContentDropZone::Left + | ContentDropZone::Top + | ContentDropZone::Right + | ContentDropZone::Bottom => { + let Some(source_widget) = find_pane_widget_by_id(payload.pane_id) else { + return false; + }; + let target_widget: gtk::Widget = target.pane_outer.clone().upcast(); + let (orientation, new_pane_first) = match zone { + ContentDropZone::Left => (gtk::Orientation::Horizontal, true), + ContentDropZone::Right => (gtk::Orientation::Horizontal, false), + ContentDropZone::Top => (gtk::Orientation::Vertical, true), + ContentDropZone::Bottom => (gtk::Orientation::Vertical, false), + ContentDropZone::Center => unreachable!(), + }; + (target.callbacks.on_split_with_tab)( + &source_widget, + &target_widget, + orientation, + payload.tab_id.clone(), + new_pane_first, + ); + true + } + } + }); + } + internals.content_stack.add_controller(drop_target); + + let overlay = internals.content_drop_overlay.clone(); + let content_stack = internals.content_stack.clone(); + let workspace_dragging = internals.workspace_dragging.clone(); + let listener_id = on_tab_drag_change(move |dragging| { + let visible = dragging && !workspace_dragging.get(); + overlay.set_visible(visible); + if !visible { + clear_content_drop_zone(&overlay); + } + set_browser_targeting_enabled(&content_stack, !dragging); + }); + internals.pane_outer.connect_destroy(move |_| { + remove_tab_drag_listener(listener_id); + }); +} + +// --------------------------------------------------------------------------- +// Tab activation / removal +// --------------------------------------------------------------------------- + +fn activate_tab( + _tab_strip: >k::Box, + content_stack: >k::Stack, + tab_state: &Rc>, + tab_id: &str, +) { + let mut ts = tab_state.borrow_mut(); + if ts.active_tab.as_deref() == Some(tab_id) { + return; + } + ts.active_tab = Some(tab_id.to_string()); + + // Update visual state on all tabs + for entry in &ts.tabs { + if entry.id == tab_id { + entry.tab_button.add_css_class("limux-tab-active"); + } else { + entry.tab_button.remove_css_class("limux-tab-active"); + } + } + + if content_stack.child_by_name(tab_id).is_some() { + content_stack.set_visible_child_name(tab_id); + } + + // Focus the content — only grab focus on directly focusable widgets (terminals). + // For containers (browser vbox), focus the first focusable child instead. + if let Some(entry) = ts.tabs.iter().find(|e| e.id == tab_id) { + let content = entry.content.clone(); + drop(ts); + if content.is_focus() || content.can_focus() { + content.grab_focus(); + } else { + // Try to find a focusable child (e.g., the WebView inside a Box) + content.child_focus(gtk::DirectionType::TabForward); + } + } +} + +fn remove_tab( + tab_strip: >k::Box, + content_stack: >k::Stack, + tab_state: &Rc>, + tab_id: &str, + callbacks: &Rc, + pane_outer: >k::Box, + empty_reason: PaneEmptyReason, ) { let mut ts = tab_state.borrow_mut(); let Some(idx) = ts.tabs.iter().position(|e| e.id == tab_id) else { @@ -1222,7 +2405,7 @@ fn remove_tab( if ts.tabs.is_empty() { drop(ts); - (callbacks.on_empty)(&pane_outer.clone().upcast()); + (callbacks.on_empty)(&pane_outer.clone().upcast(), empty_reason); return; } @@ -1242,19 +2425,358 @@ fn remove_tab( // Browser widget // --------------------------------------------------------------------------- +#[cfg(feature = "webkit")] +#[derive(Clone)] +struct BrowserHandles { + webview: webkit6::WebView, + url_entry: gtk::Entry, + search_bar: gtk::SearchBar, + search_entry: gtk::SearchEntry, + find_controller: webkit6::FindController, + dom_editable: Rc>, +} + +#[cfg(not(feature = "webkit"))] +#[derive(Clone)] +struct BrowserHandles; + +impl BrowserShortcutTarget { + pub fn current_uri(&self) -> Option { + self.uri.borrow().clone() + } + + pub fn focus_location(&self) -> bool { + self.handles.focus_location() + } + + pub fn go_back(&self) -> bool { + self.handles.go_back() + } + + pub fn go_forward(&self) -> bool { + self.handles.go_forward() + } + + pub fn reload(&self) -> bool { + self.handles.reload() + } + + pub fn show_inspector(&self) -> bool { + self.handles.show_inspector() + } + + pub fn show_console(&self) -> bool { + self.handles.show_console() + } + + pub fn show_find(&self) -> bool { + self.handles.show_find() + } + + pub fn find_next(&self) -> bool { + self.handles.find_next() + } + + pub fn find_previous(&self) -> bool { + self.handles.find_previous() + } + + pub fn hide_find(&self) -> bool { + self.handles.hide_find() + } + + pub fn use_selection_for_find(&self) -> bool { + self.handles.use_selection_for_find() + } + + pub fn is_find_active(&self) -> bool { + self.handles.is_find_active() + } + + pub fn is_page_editable(&self) -> bool { + self.handles.is_page_editable() + } +} + +#[cfg(feature = "webkit")] +impl BrowserHandles { + fn is_find_active(&self) -> bool { + self.search_bar.is_search_mode() + } + + fn is_page_editable(&self) -> bool { + self.dom_editable.get() + } + + fn focus_location(&self) -> bool { + self.url_entry.grab_focus(); + self.url_entry.select_region(0, -1); + true + } + + fn go_back(&self) -> bool { + self.webview.go_back(); + true + } + + fn go_forward(&self) -> bool { + self.webview.go_forward(); + true + } + + fn reload(&self) -> bool { + self.webview.reload(); + true + } + + fn show_inspector(&self) -> bool { + if let Some(inspector) = self.webview.inspector() { + inspector.show(); + return true; + } + false + } + + fn show_console(&self) -> bool { + self.show_inspector() + } + + fn show_find(&self) -> bool { + self.search_bar.set_search_mode(true); + self.search_entry.grab_focus(); + self.search_entry.select_region(0, -1); + if !self.search_entry.text().is_empty() { + self.search_for_entry_text(); + } + true + } + + fn find_next(&self) -> bool { + if self.is_find_active() { + self.find_controller.search_next(); + return true; + } + false + } + + fn find_previous(&self) -> bool { + if self.is_find_active() { + self.find_controller.search_previous(); + return true; + } + false + } + + fn hide_find(&self) -> bool { + if !self.is_find_active() { + return false; + } + self.find_controller.search_finish(); + self.search_bar.set_search_mode(false); + self.webview.grab_focus(); + true + } + + fn use_selection_for_find(&self) -> bool { + let search_entry = self.search_entry.clone(); + let search_bar = self.search_bar.clone(); + let find_controller = self.find_controller.clone(); + let webview = self.webview.clone(); + self.webview.evaluate_javascript( + "window.getSelection ? window.getSelection().toString() : '';", + None, + None, + None::<>k::gio::Cancellable>, + move |result| { + let Ok(value) = result else { + return; + }; + let selection = value.to_str(); + if selection.is_empty() { + return; + } + search_bar.set_search_mode(true); + search_entry.set_text(selection.as_str()); + find_controller.search( + selection.as_str(), + webkit6::FindOptions::CASE_INSENSITIVE.bits() + | webkit6::FindOptions::WRAP_AROUND.bits(), + u32::MAX, + ); + search_entry.grab_focus(); + search_entry.select_region(0, -1); + webview.queue_draw(); + }, + ); + true + } + + fn search_for_entry_text(&self) { + let query = self.search_entry.text(); + if query.is_empty() { + self.find_controller.search_finish(); + return; + } + self.find_controller.search( + query.as_str(), + webkit6::FindOptions::CASE_INSENSITIVE.bits() + | webkit6::FindOptions::WRAP_AROUND.bits(), + u32::MAX, + ); + } +} + +#[cfg(not(feature = "webkit"))] +impl BrowserHandles { + fn is_find_active(&self) -> bool { + false + } + + fn is_page_editable(&self) -> bool { + false + } + + fn focus_location(&self) -> bool { + false + } + + fn go_back(&self) -> bool { + false + } + + fn go_forward(&self) -> bool { + false + } + + fn reload(&self) -> bool { + false + } + + fn show_inspector(&self) -> bool { + false + } + + fn show_console(&self) -> bool { + false + } + + fn show_find(&self) -> bool { + false + } + + fn find_next(&self) -> bool { + false + } + + fn find_previous(&self) -> bool { + false + } + + fn hide_find(&self) -> bool { + false + } + + fn use_selection_for_find(&self) -> bool { + false + } +} + +#[cfg(feature = "webkit")] +const LIMUX_BROWSER_EDITABLE_STATE_HANDLER: &str = "limuxEditableState"; + +#[cfg(feature = "webkit")] +const LIMUX_BROWSER_EDITABLE_STATE_SCRIPT: &str = r#" +(() => { + const handler = globalThis.webkit?.messageHandlers?.limuxEditableState; + if (!handler || typeof handler.postMessage !== 'function') { + return; + } + + const nonTextInputTypes = new Set([ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit' + ]); + + const isEditableElement = (element) => { + if (!element) { + return false; + } + if (element.isContentEditable) { + return true; + } + + const tagName = (element.tagName || '').toUpperCase(); + if (tagName === 'TEXTAREA') { + return !element.readOnly && !element.disabled; + } + if (tagName === 'SELECT') { + return !element.disabled; + } + if (tagName !== 'INPUT') { + return false; + } + + const type = (element.type || '').toLowerCase(); + return !nonTextInputTypes.has(type) && !element.readOnly && !element.disabled; + }; + + const publish = () => { + handler.postMessage(Boolean(isEditableElement(document.activeElement))); + }; + + publish(); + document.addEventListener('focusin', publish, true); + document.addEventListener('focusout', () => queueMicrotask(publish), true); + window.addEventListener('pageshow', publish, true); +})(); +"#; + #[cfg(feature = "webkit")] fn create_browser_widget( initial_uri: Option<&str>, saved_uri: Rc>>, callbacks: Rc, -) -> (gtk::Widget, String) { +) -> (gtk::Widget, String, BrowserHandles) { use webkit6::prelude::*; // Use a NetworkSession to avoid sandbox issues let network_session = webkit6::NetworkSession::default(); let web_context = webkit6::WebContext::default(); + let user_content_manager = webkit6::UserContentManager::new(); + let dom_editable = Rc::new(Cell::new(false)); + let _ = user_content_manager + .register_script_message_handler(LIMUX_BROWSER_EDITABLE_STATE_HANDLER, None); + user_content_manager.add_script(&webkit6::UserScript::new( + LIMUX_BROWSER_EDITABLE_STATE_SCRIPT, + webkit6::UserContentInjectedFrames::AllFrames, + webkit6::UserScriptInjectionTime::Start, + &[], + &[], + )); + { + let dom_editable = dom_editable.clone(); + user_content_manager.connect_script_message_received( + Some(LIMUX_BROWSER_EDITABLE_STATE_HANDLER), + move |_, value| { + dom_editable.set(if value.is_boolean() { + value.to_boolean() + } else { + value.to_str().as_str() == "true" + }); + }, + ); + } let webview = webkit6::WebView::builder() + .user_content_manager(&user_content_manager) .hexpand(true) .vexpand(true) .build(); @@ -1269,6 +2791,9 @@ fn create_browser_widget( .placeholder_text("Enter URL...") .hexpand(true) .build(); + for css_class in BROWSER_URL_ENTRY_CSS_CLASSES { + url_entry.add_css_class(css_class); + } let back_btn = icon_button("go-previous-symbolic", "Back"); let fwd_btn = icon_button("go-next-symbolic", "Forward"); @@ -1302,14 +2827,7 @@ fn create_browser_widget( { let wv = webview.clone(); url_entry.connect_activate(move |entry| { - let mut url = entry.text().to_string(); - if !url.starts_with("http://") && !url.starts_with("https://") { - if url.contains('.') { - url = format!("https://{url}"); - } else { - url = format!("https://www.google.com/search?q={}", url.replace(' ', "+")); - } - } + let url = normalize_browser_entry_input(&entry.text()); wv.load_uri(&url); }); } @@ -1333,11 +2851,60 @@ fn create_browser_widget( }); } + let find_controller = webview + .find_controller() + .expect("webkit webview should expose a find controller"); + let search_entry = gtk::SearchEntry::builder() + .hexpand(true) + .placeholder_text("Find in page") + .build(); + for css_class in BROWSER_SEARCH_ENTRY_CSS_CLASSES { + search_entry.add_css_class(css_class); + } + let search_bar = gtk::SearchBar::new(); + search_bar.set_show_close_button(true); + search_bar.connect_entry(&search_entry); + search_bar.set_child(Some(&search_entry)); + { + let search_bar = search_bar.clone(); + let find_controller = find_controller.clone(); + let webview = webview.clone(); + search_entry.connect_stop_search(move |_| { + find_controller.search_finish(); + search_bar.set_search_mode(false); + webview.grab_focus(); + }); + } + { + let dom_editable = dom_editable.clone(); + webview.connect_load_changed(move |_, _| { + dom_editable.set(false); + }); + } + let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); vbox.append(&nav_bar); + vbox.append(&search_bar); vbox.append(&webview.clone()); vbox.set_hexpand(true); vbox.set_vexpand(true); + vbox.add_css_class("limux-browser"); + + let browser_handles = BrowserHandles { + webview: webview.clone(), + url_entry: url_entry.clone(), + search_bar: search_bar.clone(), + search_entry: search_entry.clone(), + find_controller: find_controller.clone(), + dom_editable, + }; + + { + let browser_handles = browser_handles.clone(); + search_entry.connect_search_changed(move |_| { + browser_handles.search_for_entry_text(); + }); + } // Load default URL only on the first map. The WebView preserves its // page and history across reparenting (splits), so we must not reload. @@ -1361,7 +2928,32 @@ fn create_browser_widget( let _ = network_session; let _ = web_context; - (vbox.upcast(), "Browser".to_string()) + (vbox.upcast(), "Browser".to_string(), browser_handles) +} + +fn normalize_browser_entry_input(input: &str) -> String { + if input.starts_with("http://") || input.starts_with("https://") { + return input.to_string(); + } + + if is_localhost_input(input) { + format!("http://{input}") + } else if input.contains('.') { + format!("https://{input}") + } else { + format!( + "https://www.google.com/search?q={}", + input.replace(' ', "+") + ) + } +} + +fn is_localhost_input(input: &str) -> bool { + input == "localhost" + || input + .strip_prefix("localhost") + .and_then(|rest| rest.chars().next()) + .is_some_and(|ch| matches!(ch, ':' | '/' | '?' | '#')) } #[cfg(not(feature = "webkit"))] @@ -1369,7 +2961,7 @@ fn create_browser_widget( initial_uri: Option<&str>, saved_uri: Rc>>, _callbacks: Rc, -) -> (gtk::Widget, String) { +) -> (gtk::Widget, String, BrowserHandles) { *saved_uri.borrow_mut() = initial_uri.map(|value| value.to_string()); let placeholder = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -1394,5 +2986,258 @@ fn create_browser_widget( placeholder.set_hexpand(true); placeholder.set_vexpand(true); - (placeholder.upcast(), "Browser".to_string()) + let handles = BrowserHandles; + + (placeholder.upcast(), "Browser".to_string(), handles) +} + +#[cfg(test)] +mod tests { + use super::{ + classify_content_drop_zone, content_drop_preview_rect, effective_drop_target_dimensions, + is_localhost_input, next_active_after_tab_removal, normalize_browser_entry_input, + normalize_reorder_insert_index, pane_action_tooltip, ContentDropZone, TabDragPayload, + BROWSER_SEARCH_ENTRY_CSS_CLASS, BROWSER_SEARCH_ENTRY_CSS_CLASSES, + BROWSER_URL_ENTRY_CSS_CLASS, BROWSER_URL_ENTRY_CSS_CLASSES, HOST_ENTRY_CSS_CLASS, PANE_CSS, + TAB_RENAME_ENTRY_CSS_CLASS, TAB_RENAME_ENTRY_CSS_CLASSES, + }; + use crate::shortcut_config::{default_shortcuts, resolve_shortcuts_from_str, ShortcutId}; + + #[test] + fn pane_action_tooltip_reflects_remaps_and_unbinds() { + let defaults = default_shortcuts(); + assert_eq!( + pane_action_tooltip(&defaults, "New terminal tab", Some(ShortcutId::NewTerminal)), + "New terminal tab (Ctrl+T)" + ); + assert_eq!( + pane_action_tooltip(&defaults, "New browser tab", None), + "New browser tab" + ); + + let remapped = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "d" + } + }"#, + ) + .unwrap(); + assert_eq!( + pane_action_tooltip(&remapped, "Split right", Some(ShortcutId::SplitRight)), + "Split right (Ctrl+Alt+D)" + ); + + let unbound = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "close_focused_pane": null + } + }"#, + ) + .unwrap(); + assert_eq!( + pane_action_tooltip(&unbound, "Close pane", Some(ShortcutId::CloseFocusedPane)), + "Close pane" + ); + } + + #[test] + fn pane_css_keeps_entry_layout_classes_separate_from_shared_theme() { + assert!(PANE_CSS.contains(".limux-tab-rename-entry")); + assert!(PANE_CSS.contains(".limux-browser-url-entry")); + assert!(PANE_CSS.contains(".limux-browser-search-entry")); + assert!(!PANE_CSS.contains("border: 1px solid rgba(0, 145, 255, 0.5);")); + } + + #[test] + fn pane_entries_use_shared_host_entry_class() { + assert_eq!( + TAB_RENAME_ENTRY_CSS_CLASSES, + [HOST_ENTRY_CSS_CLASS, TAB_RENAME_ENTRY_CSS_CLASS] + ); + assert_eq!( + BROWSER_URL_ENTRY_CSS_CLASSES, + [HOST_ENTRY_CSS_CLASS, BROWSER_URL_ENTRY_CSS_CLASS] + ); + assert_eq!( + BROWSER_SEARCH_ENTRY_CSS_CLASSES, + [HOST_ENTRY_CSS_CLASS, BROWSER_SEARCH_ENTRY_CSS_CLASS] + ); + } + + #[test] + fn tab_drag_payload_round_trips() { + let payload = TabDragPayload::new(17, "tab-123"); + let encoded = payload.encode(); + assert_eq!(encoded, "17:tab-123"); + assert_eq!(TabDragPayload::decode(&encoded), Some(payload)); + } + + #[test] + fn tab_drag_payload_rejects_invalid_values() { + assert_eq!(TabDragPayload::decode(""), None); + assert_eq!(TabDragPayload::decode("17"), None); + assert_eq!(TabDragPayload::decode("abc:tab"), None); + assert_eq!(TabDragPayload::decode("17:"), None); + } + + #[test] + fn normalize_reorder_insert_index_adjusts_forward_moves() { + assert_eq!(normalize_reorder_insert_index(1, 4), Some(3)); + assert_eq!(normalize_reorder_insert_index(4, 1), Some(1)); + assert_eq!(normalize_reorder_insert_index(2, 2), None); + assert_eq!(normalize_reorder_insert_index(2, 3), None); + } + + #[test] + fn next_active_after_tab_removal_prefers_neighbor_when_active_removed() { + assert_eq!( + next_active_after_tab_removal(&["a", "b", "c"], Some("b"), 1), + Some("c".to_string()) + ); + assert_eq!( + next_active_after_tab_removal(&["a", "b", "c"], Some("a"), 0), + Some("b".to_string()) + ); + assert_eq!( + next_active_after_tab_removal(&["a", "b", "c"], Some("a"), 2), + Some("a".to_string()) + ); + assert_eq!( + next_active_after_tab_removal(&["only"], Some("only"), 0), + None + ); + } + + #[test] + fn classify_content_drop_zone_prefers_edges_before_center() { + assert_eq!( + classify_content_drop_zone(100.0, 80.0, 10.0, 40.0), + Some(ContentDropZone::Left) + ); + assert_eq!( + classify_content_drop_zone(100.0, 80.0, 90.0, 40.0), + Some(ContentDropZone::Right) + ); + assert_eq!( + classify_content_drop_zone(100.0, 80.0, 50.0, 5.0), + Some(ContentDropZone::Top) + ); + assert_eq!( + classify_content_drop_zone(100.0, 80.0, 50.0, 75.0), + Some(ContentDropZone::Bottom) + ); + assert_eq!( + classify_content_drop_zone(100.0, 80.0, 50.0, 40.0), + Some(ContentDropZone::Center) + ); + assert_eq!(classify_content_drop_zone(0.0, 80.0, 50.0, 40.0), None); + } + + #[test] + fn classify_content_drop_zone_uses_quarter_bands_not_thirds() { + assert_eq!( + classify_content_drop_zone(100.0, 100.0, 24.0, 50.0), + Some(ContentDropZone::Left) + ); + assert_eq!( + classify_content_drop_zone(100.0, 100.0, 26.0, 50.0), + Some(ContentDropZone::Center) + ); + assert_eq!( + classify_content_drop_zone(100.0, 100.0, 50.0, 24.0), + Some(ContentDropZone::Top) + ); + assert_eq!( + classify_content_drop_zone(100.0, 100.0, 50.0, 26.0), + Some(ContentDropZone::Center) + ); + } + + #[test] + fn content_drop_preview_rect_uses_even_halves() { + assert_eq!( + content_drop_preview_rect(ContentDropZone::Left), + (0.0, 0.0, 0.5, 1.0) + ); + assert_eq!( + content_drop_preview_rect(ContentDropZone::Right), + (0.5, 0.0, 0.5, 1.0) + ); + assert_eq!( + content_drop_preview_rect(ContentDropZone::Top), + (0.0, 0.0, 1.0, 0.5) + ); + assert_eq!( + content_drop_preview_rect(ContentDropZone::Bottom), + (0.0, 0.5, 1.0, 0.5) + ); + assert_eq!( + content_drop_preview_rect(ContentDropZone::Center), + (0.25, 0.25, 0.5, 0.5) + ); + } + + #[test] + fn effective_drop_target_dimensions_fall_back_to_content_area() { + assert_eq!( + effective_drop_target_dimensions(0, 0, 320, 180), + Some((320.0, 180.0)) + ); + assert_eq!( + effective_drop_target_dimensions(120, 60, 320, 180), + Some((320.0, 180.0)) + ); + assert_eq!(effective_drop_target_dimensions(0, 0, 0, 180), None); + } + + #[test] + fn localhost_inputs_only_match_real_localhost_hosts() { + for input in [ + "localhost", + "localhost:3000", + "localhost/path", + "localhost?q=1", + ] { + assert!(is_localhost_input(input), "{input} should be localhost"); + } + + for input in [ + "localhost.run", + "localhost.example.com", + "localhost docs", + "mylocalhost:3000", + ] { + assert!( + !is_localhost_input(input), + "{input} should not be treated as localhost" + ); + } + } + + #[test] + fn normalize_browser_entry_input_preserves_search_and_domain_behavior() { + let cases = [ + ("https://example.com", "https://example.com"), + ("localhost", "http://localhost"), + ("localhost:3000", "http://localhost:3000"), + ("localhost/path", "http://localhost/path"), + ("localhost.run", "https://localhost.run"), + ("localhost.example.com", "https://localhost.example.com"), + ( + "localhost docs", + "https://www.google.com/search?q=localhost+docs", + ), + ("example.com", "https://example.com"), + ( + "example search", + "https://www.google.com/search?q=example+search", + ), + ]; + + for (input, expected) in cases { + assert_eq!(normalize_browser_entry_input(input), expected, "{input}"); + } + } } diff --git a/rust/limux-host-linux/src/settings_editor.rs b/rust/limux-host-linux/src/settings_editor.rs new file mode 100644 index 00000000..d5db5801 --- /dev/null +++ b/rust/limux-host-linux/src/settings_editor.rs @@ -0,0 +1,318 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use adw::prelude::*; +use gtk4 as gtk; +use libadwaita as adw; + +use crate::app_config::{AppConfig, ColorScheme, WindowControlsSide}; +use crate::keybind_editor; +use crate::shortcut_config::{NormalizedShortcut, ResolvedShortcutConfig, ShortcutId}; + +pub const SETTINGS_CSS: &str = r#" +.limux-settings-window { + background-color: @window_bg_color; + color: @window_fg_color; +} +"#; + +type OnConfigChanged = dyn Fn(&AppConfig, &AppConfig); + +pub struct SettingsEditorInput { + pub config: Rc>, + pub shortcuts: Rc, + pub on_capture: Rc< + dyn Fn(ShortcutId, Option) -> Result, + >, + pub on_config_changed: Rc, +} + +pub fn present_settings_dialog(parent: &impl IsA, input: SettingsEditorInput) { + let window = adw::Window::new(); + window.set_title(Some("Settings")); + window.set_default_size(760, 680); + window.set_modal(true); + + if let Some(parent_window) = parent + .root() + .and_then(|root| root.downcast::().ok()) + { + window.set_transient_for(Some(&parent_window)); + if let Some(app) = parent_window.application() { + window.set_application(Some(&app)); + } + } + + let content = build_settings_window_content(&window, input); + window.set_content(Some(&content)); + window.present(); +} + +fn apply_config_change(config: &Rc>, on_changed: &F, update: G) +where + F: Fn(&AppConfig, &AppConfig) + ?Sized, + G: FnOnce(&mut AppConfig), +{ + let (previous, updated) = { + let mut config_ref = config.borrow_mut(); + let previous = config_ref.clone(); + update(&mut config_ref); + let updated = config_ref.clone(); + (previous, updated) + }; + on_changed(&previous, &updated); +} + +fn build_settings_window_content(window: &adw::Window, input: SettingsEditorInput) -> gtk::Widget { + let stack = adw::ViewStack::new(); + stack.set_hexpand(true); + stack.set_vexpand(true); + + let general_page = build_general_page(&input); + let general_stack_page = stack.add_titled(&general_page, Some("general"), "General"); + general_stack_page.set_icon_name(Some("preferences-system-symbolic")); + + let keybinds_page = keybind_editor::build_keybind_editor(&input.shortcuts, input.on_capture); + let keybinds_stack_page = stack.add_titled(&keybinds_page, Some("keybindings"), "Keybindings"); + keybinds_stack_page.set_icon_name(Some("input-keyboard-symbolic")); + + let switcher = adw::ViewSwitcher::builder() + .stack(&stack) + .policy(adw::ViewSwitcherPolicy::Wide) + .build(); + + let close_button = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close settings") + .valign(gtk::Align::Center) + .build(); + close_button.add_css_class("flat"); + + { + let window = window.clone(); + close_button.connect_clicked(move |_| { + window.close(); + }); + } + + let header_bar = adw::HeaderBar::new(); + header_bar.set_show_start_title_buttons(false); + header_bar.set_show_end_title_buttons(false); + header_bar.set_title_widget(Some(&switcher)); + header_bar.pack_end(&close_button); + + let outer = gtk::Box::new(gtk::Orientation::Vertical, 0); + outer.add_css_class("limux-settings-window"); + outer.append(&header_bar); + outer.append(&stack); + outer.upcast() +} + +fn build_general_page(input: &SettingsEditorInput) -> gtk::Widget { + let page = adw::PreferencesPage::new(); + page.set_title("General"); + page.set_name(Some("general")); + page.set_icon_name(Some("preferences-system-symbolic")); + page.set_hexpand(true); + page.set_vexpand(true); + + let group = adw::PreferencesGroup::new(); + + let color_row = adw::ActionRow::builder() + .title("GTK color scheme") + .subtitle("Choose whether the GTK interface follows system, dark, or light") + .build(); + color_row.set_title_lines(1); + color_row.set_subtitle_lines(2); + let color_dropdown = gtk::DropDown::from_strings(&["System", "Dark", "Light"]); + let initial_scheme = input.config.borrow().appearance.color_scheme; + color_dropdown.set_selected(match initial_scheme { + ColorScheme::System => 0, + ColorScheme::Dark => 1, + ColorScheme::Light => 2, + }); + color_dropdown.set_valign(gtk::Align::Center); + color_row.add_suffix(&color_dropdown); + color_row.set_activatable_widget(Some(&color_dropdown)); + group.add(&color_row); + + let ghostty_row = adw::ActionRow::builder() + .title("Ghostty color scheme") + .subtitle("Choose whether terminal surfaces follow system, dark, or light") + .build(); + ghostty_row.set_title_lines(1); + ghostty_row.set_subtitle_lines(2); + let ghostty_dropdown = gtk::DropDown::from_strings(&["System", "Dark", "Light"]); + let initial_ghostty_scheme = input.config.borrow().appearance.ghostty_color_scheme; + ghostty_dropdown.set_selected(match initial_ghostty_scheme { + ColorScheme::System => 0, + ColorScheme::Dark => 1, + ColorScheme::Light => 2, + }); + ghostty_dropdown.set_valign(gtk::Align::Center); + ghostty_row.add_suffix(&ghostty_dropdown); + ghostty_row.set_activatable_widget(Some(&ghostty_dropdown)); + group.add(&ghostty_row); + + let hover_row = adw::ActionRow::builder() + .title("Hover terminal focus") + .subtitle("Focus terminal panes when the mouse pointer enters them") + .build(); + hover_row.set_title_lines(1); + hover_row.set_subtitle_lines(2); + let hover_switch = gtk::Switch::new(); + hover_switch.set_active(input.config.borrow().focus.hover_terminal_focus); + hover_switch.set_valign(gtk::Align::Center); + hover_row.add_suffix(&hover_switch); + hover_row.set_activatable_widget(Some(&hover_switch)); + group.add(&hover_row); + + let top_bar_row = adw::ActionRow::builder() + .title("Top bar") + .subtitle("Show a top bar with workspace indicators. When off, the dock toggle, settings, new workspace, and window controls move into the sidebar header (or the leading pane when the sidebar is collapsed).") + .build(); + top_bar_row.set_title_lines(1); + top_bar_row.set_subtitle_lines(4); + let top_bar_switch = gtk::Switch::new(); + top_bar_switch.set_active(input.config.borrow().interface.show_top_bar); + top_bar_switch.set_valign(gtk::Align::Center); + top_bar_row.add_suffix(&top_bar_switch); + top_bar_row.set_activatable_widget(Some(&top_bar_switch)); + group.add(&top_bar_row); + + let indicators_row = adw::ActionRow::builder() + .title("Workspace indicators on the top bar") + .subtitle("Show a clickable pill for each workspace in the top bar") + .build(); + indicators_row.set_title_lines(1); + indicators_row.set_subtitle_lines(2); + let indicators_switch = gtk::Switch::new(); + indicators_switch.set_active(input.config.borrow().interface.show_workspace_indicators); + indicators_switch.set_valign(gtk::Align::Center); + indicators_row.add_suffix(&indicators_switch); + indicators_row.set_activatable_widget(Some(&indicators_switch)); + group.add(&indicators_row); + + let controls_row = adw::ActionRow::builder() + .title("Window controls side") + .subtitle("Place close, minimize, and maximize on the left or right of the top bar (or of the sidebar header when the top bar is off)") + .build(); + controls_row.set_title_lines(1); + controls_row.set_subtitle_lines(3); + let controls_dropdown = gtk::DropDown::from_strings(&["Left", "Right"]); + let initial_side = input.config.borrow().interface.window_controls_side; + controls_dropdown.set_selected(match initial_side { + WindowControlsSide::Left => 0, + WindowControlsSide::Right => 1, + }); + controls_dropdown.set_valign(gtk::Align::Center); + controls_row.add_suffix(&controls_dropdown); + controls_row.set_activatable_widget(Some(&controls_dropdown)); + group.add(&controls_row); + + page.add(&group); + + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + color_dropdown.connect_selected_notify(move |dropdown| { + let scheme = match dropdown.selected() { + 1 => ColorScheme::Dark, + 2 => ColorScheme::Light, + _ => ColorScheme::System, + }; + apply_config_change(&config, &*on_changed, move |c| { + c.appearance.color_scheme = scheme; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + ghostty_dropdown.connect_selected_notify(move |dropdown| { + let scheme = match dropdown.selected() { + 1 => ColorScheme::Dark, + 2 => ColorScheme::Light, + _ => ColorScheme::System, + }; + apply_config_change(&config, &*on_changed, move |c| { + c.appearance.ghostty_color_scheme = scheme; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + hover_switch.connect_active_notify(move |switch| { + let hover_terminal_focus = switch.is_active(); + apply_config_change(&config, &*on_changed, move |c| { + c.focus.hover_terminal_focus = hover_terminal_focus; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + top_bar_switch.connect_active_notify(move |switch| { + let show_top_bar = switch.is_active(); + apply_config_change(&config, &*on_changed, move |c| { + c.interface.show_top_bar = show_top_bar; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + indicators_switch.connect_active_notify(move |switch| { + let show_workspace_indicators = switch.is_active(); + apply_config_change(&config, &*on_changed, move |c| { + c.interface.show_workspace_indicators = show_workspace_indicators; + }); + }); + } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + controls_dropdown.connect_selected_notify(move |dropdown| { + let side = match dropdown.selected() { + 0 => WindowControlsSide::Left, + _ => WindowControlsSide::Right, + }; + apply_config_change(&config, &*on_changed, move |c| { + c.interface.window_controls_side = side; + }); + }); + } + + let scroller = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .child(&page) + .build(); + scroller.set_hexpand(true); + scroller.set_vexpand(true); + + scroller.upcast() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_config_change_allows_reentrant_config_sync() { + let config = Rc::new(RefCell::new(AppConfig::default())); + + apply_config_change( + &config, + &|_previous, updated| { + config.borrow_mut().clone_from(updated); + }, + |current| { + current.focus.hover_terminal_focus = true; + }, + ); + + assert!(config.borrow().focus.hover_terminal_focus); + } +} diff --git a/rust/limux-host-linux/src/shortcut_config.rs b/rust/limux-host-linux/src/shortcut_config.rs new file mode 100644 index 00000000..d1a9bd6e --- /dev/null +++ b/rust/limux-host-linux/src/shortcut_config.rs @@ -0,0 +1,2176 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use gtk4::gdk; +use gtk4::gdk::prelude::DisplayExtManual; +use serde::Deserialize; +use serde_json::{Map, Value}; + +pub const CONFIG_DIR_NAME: &str = "limux"; +pub const SHORTCUTS_FILE_NAME: &str = "shortcuts.json"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShortcutId { + NewWorkspace, + CloseWorkspace, + QuitApp, + NewInstance, + ToggleSidebar, + ToggleTopBar, + ToggleFullscreen, + NextWorkspace, + PrevWorkspace, + CycleTabPrev, + CycleTabNext, + SplitDown, + NewTerminalInFocusedPane, + SplitRight, + CloseFocusedPane, + NewTerminal, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + ActivateWorkspace1, + ActivateWorkspace2, + ActivateWorkspace3, + ActivateWorkspace4, + ActivateWorkspace5, + ActivateWorkspace6, + ActivateWorkspace7, + ActivateWorkspace8, + ActivateLastWorkspace, + OpenBrowserInSplit, + BrowserFocusLocation, + BrowserBack, + BrowserForward, + BrowserReload, + BrowserInspector, + BrowserConsole, + SurfaceFind, + SurfaceFindNext, + SurfaceFindPrevious, + SurfaceFindHide, + SurfaceUseSelectionForFind, + TerminalClearScrollback, + TerminalCopy, + TerminalPaste, + TerminalIncreaseFontSize, + TerminalDecreaseFontSize, + TerminalResetFontSize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShortcutCommand { + NewWorkspace, + CloseWorkspace, + QuitApp, + NewInstance, + ToggleSidebar, + ToggleTopBar, + ToggleFullscreen, + NextWorkspace, + PrevWorkspace, + CycleTabPrev, + CycleTabNext, + SplitDown, + NewTerminal, + SplitRight, + CloseFocusedPane, + FocusLeft, + FocusRight, + FocusUp, + FocusDown, + ActivateWorkspace1, + ActivateWorkspace2, + ActivateWorkspace3, + ActivateWorkspace4, + ActivateWorkspace5, + ActivateWorkspace6, + ActivateWorkspace7, + ActivateWorkspace8, + ActivateLastWorkspace, + OpenBrowserInSplit, + BrowserFocusLocation, + BrowserBack, + BrowserForward, + BrowserReload, + BrowserInspector, + BrowserConsole, + SurfaceFind, + SurfaceFindNext, + SurfaceFindPrevious, + SurfaceFindHide, + SurfaceUseSelectionForFind, + TerminalClearScrollback, + TerminalCopy, + TerminalPaste, + TerminalIncreaseFontSize, + TerminalDecreaseFontSize, + TerminalResetFontSize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShortcutScope { + AppGlobal, + Window, + FocusedTerminal, + FocusedBrowser, + FocusedSurface, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum EditableCapturePolicy { + AlwaysCapture, + BypassInEditable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShortcutDefinition { + pub id: ShortcutId, + pub config_key: &'static str, + pub action_name: &'static str, + pub default_accel: &'static str, + pub label: &'static str, + pub registers_gtk_accel: bool, + pub command: ShortcutCommand, + pub scope: ShortcutScope, + pub editable_capture_policy: EditableCapturePolicy, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct NormalizedShortcut { + key: String, + ctrl: bool, + shift: bool, + alt: bool, + cmd: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedShortcut { + pub definition: &'static ShortcutDefinition, + pub binding: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedShortcutConfig { + pub shortcuts: Vec, + pub warnings: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ShortcutConfigError { + InvalidBindingFormat { + input: String, + }, + MissingKey { + input: String, + }, + UnknownModifier { + input: String, + modifier: String, + }, + InvalidBindingType { + shortcut_id: String, + }, + DuplicateBinding { + first: ShortcutId, + second: ShortcutId, + accel: String, + }, + BaseModifierRequired { + shortcut_id: String, + input: String, + }, + ModifierOnlyBinding { + shortcut_id: String, + input: String, + }, + InvalidJson(String), +} + +impl std::fmt::Display for ShortcutConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidBindingFormat { input } => { + write!(f, "invalid shortcut format `{input}`") + } + Self::MissingKey { input } => { + write!(f, "shortcut `{input}` is missing a key") + } + Self::UnknownModifier { input, modifier } => { + write!(f, "shortcut `{input}` uses unknown modifier `{modifier}`") + } + Self::InvalidBindingType { shortcut_id } => { + write!( + f, + "shortcut `{shortcut_id}` must be a string, empty string, or null" + ) + } + Self::DuplicateBinding { + first, + second, + accel, + } => { + let first_label = definition_by_id(*first) + .map(|definition| definition.label) + .unwrap_or("another shortcut"); + let second_label = definition_by_id(*second) + .map(|definition| definition.label) + .unwrap_or("this shortcut"); + write!( + f, + "`{accel}` is already assigned to {first_label} and conflicts with {second_label}" + ) + } + Self::BaseModifierRequired { .. } => { + write!(f, "use Ctrl, Alt, or Cmd with another key") + } + Self::ModifierOnlyBinding { .. } => { + write!(f, "choose a non-modifier key with Ctrl, Alt, or Cmd") + } + Self::InvalidJson(reason) => write!(f, "invalid shortcut config JSON: {reason}"), + } + } +} + +impl std::error::Error for ShortcutConfigError {} + +#[derive(Debug)] +pub enum ShortcutConfigWriteError { + InvalidExistingJson { + path: PathBuf, + reason: String, + }, + InvalidExistingRoot { + path: PathBuf, + }, + CreateParentDir { + path: PathBuf, + reason: String, + }, + WriteTempFile { + path: PathBuf, + reason: String, + }, + PersistTempFile { + from: PathBuf, + to: PathBuf, + reason: String, + }, +} + +impl std::fmt::Display for ShortcutConfigWriteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidExistingJson { path, reason } => { + write!(f, "invalid existing config `{}`: {reason}", path.display()) + } + Self::InvalidExistingRoot { path } => { + write!( + f, + "existing config `{}` is not a JSON object", + path.display() + ) + } + Self::CreateParentDir { path, reason } => { + write!( + f, + "failed to create config directory `{}`: {reason}", + path.display() + ) + } + Self::WriteTempFile { path, reason } => { + write!( + f, + "failed to write temp config `{}`: {reason}", + path.display() + ) + } + Self::PersistTempFile { from, to, reason } => write!( + f, + "failed to persist temp config `{}` -> `{}`: {reason}", + from.display(), + to.display() + ), + } + } +} + +impl std::error::Error for ShortcutConfigWriteError {} + +#[derive(Debug, Default, Deserialize)] +struct ShortcutConfigFile { + #[serde(default)] + shortcuts: HashMap, +} + +const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 47] = [ + ShortcutDefinition { + id: ShortcutId::NewWorkspace, + config_key: "new_workspace", + action_name: "win.new-workspace", + default_accel: "n", + label: "New Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::NewWorkspace, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::CloseWorkspace, + config_key: "close_workspace", + action_name: "win.close-workspace", + default_accel: "w", + label: "Close Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::CloseWorkspace, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::QuitApp, + config_key: "quit_app", + action_name: "app.quit", + default_accel: "q", + label: "Quit Limux", + registers_gtk_accel: true, + command: ShortcutCommand::QuitApp, + scope: ShortcutScope::AppGlobal, + editable_capture_policy: EditableCapturePolicy::AlwaysCapture, + }, + ShortcutDefinition { + id: ShortcutId::NewInstance, + config_key: "new_instance", + action_name: "app.new-instance", + default_accel: "n", + label: "New Limux Instance", + registers_gtk_accel: true, + command: ShortcutCommand::NewInstance, + scope: ShortcutScope::AppGlobal, + editable_capture_policy: EditableCapturePolicy::AlwaysCapture, + }, + ShortcutDefinition { + id: ShortcutId::ToggleSidebar, + config_key: "toggle_sidebar", + action_name: "win.toggle-sidebar", + default_accel: "m", + label: "Toggle Sidebar", + registers_gtk_accel: true, + command: ShortcutCommand::ToggleSidebar, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ToggleTopBar, + config_key: "toggle_top_bar", + action_name: "win.toggle-top-bar", + default_accel: "m", + label: "Toggle Top Bar", + registers_gtk_accel: true, + command: ShortcutCommand::ToggleTopBar, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ToggleFullscreen, + config_key: "toggle_fullscreen", + action_name: "win.toggle-fullscreen", + default_accel: "F11", + label: "Toggle Fullscreen", + registers_gtk_accel: true, + command: ShortcutCommand::ToggleFullscreen, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::AlwaysCapture, + }, + ShortcutDefinition { + id: ShortcutId::NextWorkspace, + config_key: "next_workspace", + action_name: "win.next-workspace", + default_accel: "Page_Down", + label: "Next Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::NextWorkspace, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::PrevWorkspace, + config_key: "prev_workspace", + action_name: "win.prev-workspace", + default_accel: "Page_Up", + label: "Previous Workspace", + registers_gtk_accel: true, + command: ShortcutCommand::PrevWorkspace, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::CycleTabPrev, + config_key: "cycle_tab_prev", + action_name: "win.cycle-tab-prev", + default_accel: "Tab", + label: "Previous Tab", + registers_gtk_accel: false, + command: ShortcutCommand::CycleTabPrev, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::CycleTabNext, + config_key: "cycle_tab_next", + action_name: "win.cycle-tab-next", + default_accel: "Tab", + label: "Next Tab", + registers_gtk_accel: false, + command: ShortcutCommand::CycleTabNext, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SplitDown, + config_key: "split_down", + action_name: "win.split-down", + default_accel: "d", + label: "Split Down", + registers_gtk_accel: false, + command: ShortcutCommand::SplitDown, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::NewTerminalInFocusedPane, + config_key: "new_terminal_in_focused_pane", + action_name: "win.new-terminal-in-focused-pane", + default_accel: "t", + label: "New Terminal In Focused Pane", + registers_gtk_accel: false, + command: ShortcutCommand::NewTerminal, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SplitRight, + config_key: "split_right", + action_name: "win.split-right", + default_accel: "d", + label: "Split Right", + registers_gtk_accel: false, + command: ShortcutCommand::SplitRight, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::CloseFocusedPane, + config_key: "close_focused_pane", + action_name: "win.close-focused-pane", + default_accel: "w", + label: "Close Focused Pane", + registers_gtk_accel: false, + command: ShortcutCommand::CloseFocusedPane, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::NewTerminal, + config_key: "new_terminal", + action_name: "win.new-terminal", + default_accel: "t", + label: "New Terminal", + registers_gtk_accel: false, + command: ShortcutCommand::NewTerminal, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::FocusLeft, + config_key: "focus_left", + action_name: "win.focus-left", + default_accel: "Left", + label: "Focus Left", + registers_gtk_accel: false, + command: ShortcutCommand::FocusLeft, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::FocusRight, + config_key: "focus_right", + action_name: "win.focus-right", + default_accel: "Right", + label: "Focus Right", + registers_gtk_accel: false, + command: ShortcutCommand::FocusRight, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::FocusUp, + config_key: "focus_up", + action_name: "win.focus-up", + default_accel: "Up", + label: "Focus Up", + registers_gtk_accel: false, + command: ShortcutCommand::FocusUp, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::FocusDown, + config_key: "focus_down", + action_name: "win.focus-down", + default_accel: "Down", + label: "Focus Down", + registers_gtk_accel: false, + command: ShortcutCommand::FocusDown, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace1, + config_key: "activate_workspace_1", + action_name: "win.activate-workspace-1", + default_accel: "1", + label: "Activate Workspace 1", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace1, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace2, + config_key: "activate_workspace_2", + action_name: "win.activate-workspace-2", + default_accel: "2", + label: "Activate Workspace 2", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace2, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace3, + config_key: "activate_workspace_3", + action_name: "win.activate-workspace-3", + default_accel: "3", + label: "Activate Workspace 3", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace3, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace4, + config_key: "activate_workspace_4", + action_name: "win.activate-workspace-4", + default_accel: "4", + label: "Activate Workspace 4", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace4, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace5, + config_key: "activate_workspace_5", + action_name: "win.activate-workspace-5", + default_accel: "5", + label: "Activate Workspace 5", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace5, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace6, + config_key: "activate_workspace_6", + action_name: "win.activate-workspace-6", + default_accel: "6", + label: "Activate Workspace 6", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace6, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace7, + config_key: "activate_workspace_7", + action_name: "win.activate-workspace-7", + default_accel: "7", + label: "Activate Workspace 7", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace7, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateWorkspace8, + config_key: "activate_workspace_8", + action_name: "win.activate-workspace-8", + default_accel: "8", + label: "Activate Workspace 8", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateWorkspace8, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::ActivateLastWorkspace, + config_key: "activate_last_workspace", + action_name: "win.activate-last-workspace", + default_accel: "9", + label: "Activate Last Workspace", + registers_gtk_accel: false, + command: ShortcutCommand::ActivateLastWorkspace, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::OpenBrowserInSplit, + config_key: "open_browser_in_split", + action_name: "win.open-browser-in-split", + default_accel: "l", + label: "Open Browser In Split", + registers_gtk_accel: false, + command: ShortcutCommand::OpenBrowserInSplit, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::BrowserFocusLocation, + config_key: "browser_focus_location", + action_name: "win.browser-focus-location", + default_accel: "l", + label: "Browser Focus Location", + registers_gtk_accel: false, + command: ShortcutCommand::BrowserFocusLocation, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::BrowserBack, + config_key: "browser_back", + action_name: "win.browser-back", + default_accel: "bracketleft", + label: "Browser Back", + registers_gtk_accel: false, + command: ShortcutCommand::BrowserBack, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::BrowserForward, + config_key: "browser_forward", + action_name: "win.browser-forward", + default_accel: "bracketright", + label: "Browser Forward", + registers_gtk_accel: false, + command: ShortcutCommand::BrowserForward, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::BrowserReload, + config_key: "browser_reload", + action_name: "win.browser-reload", + default_accel: "r", + label: "Browser Reload", + registers_gtk_accel: false, + command: ShortcutCommand::BrowserReload, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::BrowserInspector, + config_key: "browser_inspector", + action_name: "win.browser-inspector", + default_accel: "i", + label: "Browser Inspector", + registers_gtk_accel: false, + command: ShortcutCommand::BrowserInspector, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::BrowserConsole, + config_key: "browser_console", + action_name: "win.browser-console", + default_accel: "c", + label: "Browser JavaScript Console", + registers_gtk_accel: false, + command: ShortcutCommand::BrowserConsole, + scope: ShortcutScope::FocusedBrowser, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SurfaceFind, + config_key: "surface_find", + action_name: "win.surface-find", + default_accel: "f", + label: "Find", + registers_gtk_accel: false, + command: ShortcutCommand::SurfaceFind, + scope: ShortcutScope::FocusedSurface, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SurfaceFindNext, + config_key: "surface_find_next", + action_name: "win.surface-find-next", + default_accel: "g", + label: "Find Next", + registers_gtk_accel: false, + command: ShortcutCommand::SurfaceFindNext, + scope: ShortcutScope::FocusedSurface, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SurfaceFindPrevious, + config_key: "surface_find_previous", + action_name: "win.surface-find-previous", + default_accel: "g", + label: "Find Previous", + registers_gtk_accel: false, + command: ShortcutCommand::SurfaceFindPrevious, + scope: ShortcutScope::FocusedSurface, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SurfaceFindHide, + config_key: "surface_find_hide", + action_name: "win.surface-find-hide", + default_accel: "f", + label: "Hide Find", + registers_gtk_accel: false, + command: ShortcutCommand::SurfaceFindHide, + scope: ShortcutScope::FocusedSurface, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::SurfaceUseSelectionForFind, + config_key: "surface_use_selection_for_find", + action_name: "win.surface-use-selection-for-find", + default_accel: "e", + label: "Use Selection For Find", + registers_gtk_accel: false, + command: ShortcutCommand::SurfaceUseSelectionForFind, + scope: ShortcutScope::FocusedSurface, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::TerminalClearScrollback, + config_key: "terminal_clear_scrollback", + action_name: "win.terminal-clear-scrollback", + default_accel: "k", + label: "Terminal Clear Scrollback", + registers_gtk_accel: false, + command: ShortcutCommand::TerminalClearScrollback, + scope: ShortcutScope::FocusedTerminal, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::TerminalCopy, + config_key: "terminal_copy", + action_name: "win.terminal-copy", + default_accel: "c", + label: "Terminal Copy", + registers_gtk_accel: false, + command: ShortcutCommand::TerminalCopy, + scope: ShortcutScope::FocusedTerminal, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::TerminalPaste, + config_key: "terminal_paste", + action_name: "win.terminal-paste", + default_accel: "v", + label: "Terminal Paste", + registers_gtk_accel: false, + command: ShortcutCommand::TerminalPaste, + scope: ShortcutScope::FocusedTerminal, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::TerminalIncreaseFontSize, + config_key: "terminal_increase_font_size", + action_name: "win.terminal-increase-font-size", + default_accel: "equal", + label: "Terminal Increase Font Size", + registers_gtk_accel: false, + command: ShortcutCommand::TerminalIncreaseFontSize, + scope: ShortcutScope::FocusedTerminal, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::TerminalDecreaseFontSize, + config_key: "terminal_decrease_font_size", + action_name: "win.terminal-decrease-font-size", + default_accel: "minus", + label: "Terminal Decrease Font Size", + registers_gtk_accel: false, + command: ShortcutCommand::TerminalDecreaseFontSize, + scope: ShortcutScope::FocusedTerminal, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, + ShortcutDefinition { + id: ShortcutId::TerminalResetFontSize, + config_key: "terminal_reset_font_size", + action_name: "win.terminal-reset-font-size", + default_accel: "0", + label: "Terminal Reset Font Size", + registers_gtk_accel: false, + command: ShortcutCommand::TerminalResetFontSize, + scope: ShortcutScope::FocusedTerminal, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, +]; + +impl NormalizedShortcut { + #[cfg(test)] + pub fn from_gdk_key(keyval: gdk::Key, modifier: gdk::ModifierType) -> Option { + Self::from_gdk_key_event(None, keyval, 0, modifier) + } + + pub fn from_gdk_key_event( + display: Option<&gdk::Display>, + keyval: gdk::Key, + keycode: u32, + modifier: gdk::ModifierType, + ) -> Option { + let key = normalized_event_key(display, keyval, keycode)?; + if is_modifier_only_key(&key) { + return None; + } + + Some(Self { + key, + ctrl: modifier.contains(gdk::ModifierType::CONTROL_MASK), + shift: modifier.contains(gdk::ModifierType::SHIFT_MASK), + alt: modifier.contains(gdk::ModifierType::ALT_MASK), + cmd: modifier.intersects(gdk::ModifierType::META_MASK | gdk::ModifierType::SUPER_MASK), + }) + } + + pub fn parse(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ShortcutConfigError::MissingKey { + input: input.to_string(), + }); + } + + let mut rest = trimmed; + let mut ctrl = false; + let mut shift = false; + let mut alt = false; + let mut cmd = false; + + while let Some(stripped) = rest.strip_prefix('<') { + let Some(end) = stripped.find('>') else { + return Err(ShortcutConfigError::InvalidBindingFormat { + input: input.to_string(), + }); + }; + let modifier = stripped[..end].trim().to_ascii_lowercase(); + match modifier.as_str() { + "ctrl" | "control" => ctrl = true, + "shift" => shift = true, + "alt" | "option" => alt = true, + "meta" | "super" | "cmd" | "command" => cmd = true, + _ => { + return Err(ShortcutConfigError::UnknownModifier { + input: input.to_string(), + modifier, + }); + } + } + rest = stripped[end + 1..].trim_start(); + } + + if rest.is_empty() { + return Err(ShortcutConfigError::MissingKey { + input: input.to_string(), + }); + } + + if rest.contains('<') || rest.contains('>') { + return Err(ShortcutConfigError::InvalidBindingFormat { + input: input.to_string(), + }); + } + + Ok(Self { + key: normalize_runtime_key(rest), + ctrl, + shift, + alt, + cmd, + }) + } + + pub fn validate_host_binding( + &self, + definition: &ShortcutDefinition, + ) -> Result<(), ShortcutConfigError> { + if is_modifier_only_key(&self.key) { + return Err(ShortcutConfigError::ModifierOnlyBinding { + shortcut_id: definition.config_key.to_string(), + input: self.to_config_accel(), + }); + } + if definition.requires_base_modifier() && !self.ctrl && !self.alt && !self.cmd { + return Err(ShortcutConfigError::BaseModifierRequired { + shortcut_id: definition.config_key.to_string(), + input: self.to_config_accel(), + }); + } + Ok(()) + } + + pub fn to_config_accel(&self) -> String { + let mut accel = String::new(); + if self.ctrl { + accel.push_str(""); + } + if self.alt { + accel.push_str(""); + } + if self.shift { + accel.push_str(""); + } + if self.cmd { + accel.push_str(""); + } + accel.push_str(&runtime_key_to_gtk_key(&self.key)); + accel + } + + pub fn gtk_accel_variants(&self) -> Vec { + let mut variants = Vec::new(); + for command_prefix in self.command_prefixes() { + let mut accel = String::new(); + if self.ctrl { + accel.push_str(""); + } + if self.alt { + accel.push_str(""); + } + accel.push_str(command_prefix); + if self.shift { + accel.push_str(""); + } + accel.push_str(&runtime_key_to_gtk_key(&self.key)); + variants.push(accel); + } + variants + } + + fn command_prefixes(&self) -> &'static [&'static str] { + if self.cmd { + &["", ""] + } else { + &[""] + } + } + + pub fn to_runtime_combo(&self) -> String { + let mut parts = Vec::new(); + if self.ctrl { + parts.push("ctrl"); + } + if self.alt { + parts.push("alt"); + } + if self.shift { + parts.push("shift"); + } + if self.cmd { + parts.push("cmd"); + } + parts.push(self.key.as_str()); + parts.join("+") + } + + pub fn to_display_label(&self) -> String { + let mut parts = Vec::new(); + if self.ctrl { + parts.push("Ctrl".to_string()); + } + if self.alt { + parts.push("Alt".to_string()); + } + if self.shift { + parts.push("Shift".to_string()); + } + if self.cmd { + parts.push("Cmd".to_string()); + } + parts.push(display_key_label(&self.key)); + parts.join("+") + } +} + +impl ResolvedShortcut { + #[cfg_attr(not(test), allow(dead_code))] + pub fn gtk_accel(&self) -> Option { + self.binding + .as_ref() + .map(NormalizedShortcut::to_config_accel) + } + + pub fn gtk_accel_variants(&self) -> Vec { + self.binding + .as_ref() + .map(NormalizedShortcut::gtk_accel_variants) + .unwrap_or_default() + } + + pub fn runtime_combo(&self) -> Option { + self.binding + .as_ref() + .map(NormalizedShortcut::to_runtime_combo) + } + + pub fn display_label(&self) -> Option { + self.binding + .as_ref() + .map(NormalizedShortcut::to_display_label) + } + + pub fn default_display_label(&self) -> String { + self.definition.default_display_label() + } +} + +impl ResolvedShortcutConfig { + pub fn gtk_accel_entries(&self) -> Vec<(&'static str, Vec)> { + self.shortcuts + .iter() + .filter(|shortcut| shortcut.definition.registers_gtk_accel) + .map(|shortcut| { + let accels = shortcut.gtk_accel_variants(); + (shortcut.definition.action_name, accels) + }) + .collect() + } + + #[cfg_attr(not(test), allow(dead_code))] + pub fn command_for_runtime_combo(&self, combo: &str) -> Option { + self.find_by_runtime_combo(combo) + .map(|shortcut| shortcut.definition.command) + } + + pub fn shortcut_for_runtime_combo(&self, combo: &str) -> Option<&ResolvedShortcut> { + self.find_by_runtime_combo(combo) + } + + pub fn display_label_for_id(&self, id: ShortcutId) -> Option { + self.find_by_id(id) + .and_then(ResolvedShortcut::display_label) + } + + pub fn default_display_label_for_id(&self, id: ShortcutId) -> Option { + self.find_by_id(id) + .map(ResolvedShortcut::default_display_label) + } + + pub fn tooltip_text(&self, id: ShortcutId, base: &str) -> String { + self.display_label_for_id(id) + .map(|label| format!("{base} ({label})")) + .unwrap_or_else(|| base.to_string()) + } + + pub fn find_by_id(&self, id: ShortcutId) -> Option<&ResolvedShortcut> { + self.shortcuts + .iter() + .find(|shortcut| shortcut.definition.id == id) + } + + pub fn find_by_runtime_combo(&self, combo: &str) -> Option<&ResolvedShortcut> { + self.shortcuts + .iter() + .find(|shortcut| shortcut.runtime_combo().as_deref() == Some(combo)) + } + + pub fn override_bindings_json(&self) -> Map { + self.shortcuts + .iter() + .filter_map(|shortcut| { + let default_binding = shortcut.definition.default_binding(); + match &shortcut.binding { + Some(binding) if binding == &default_binding => None, + Some(binding) => Some(( + shortcut.definition.config_key.to_string(), + Value::String(binding.to_config_accel()), + )), + None => Some((shortcut.definition.config_key.to_string(), Value::Null)), + } + }) + .collect() + } + + pub fn with_binding( + &self, + id: ShortcutId, + binding: Option, + ) -> Result { + let mut updated = self.clone(); + if let Some(shortcut) = updated + .shortcuts + .iter_mut() + .find(|shortcut| shortcut.definition.id == id) + { + shortcut.binding = binding; + } + ensure_valid_active_bindings(&updated.shortcuts)?; + Ok(updated) + } +} + +pub fn definitions() -> &'static [ShortcutDefinition] { + &SHORTCUT_DEFINITIONS +} + +impl ShortcutDefinition { + pub fn requires_base_modifier(&self) -> bool { + !matches!(self.id, ShortcutId::ToggleFullscreen) + } + + pub fn default_binding(&self) -> NormalizedShortcut { + NormalizedShortcut::parse(self.default_accel).expect("default shortcuts should be valid") + } + + pub fn default_display_label(&self) -> String { + self.default_binding().to_display_label() + } + + pub fn action_basename(&self) -> &'static str { + self.action_name + .split_once('.') + .map(|(_, name)| name) + .unwrap_or(self.action_name) + } +} + +pub fn config_dir_path() -> Option { + dirs::config_dir().map(|base| config_dir_path_in(&base)) +} + +pub fn config_dir_path_in(base: &Path) -> PathBuf { + base.join(CONFIG_DIR_NAME) +} + +pub fn shortcuts_path() -> Option { + config_dir_path().map(|dir| dir.join(SHORTCUTS_FILE_NAME)) +} + +#[cfg(test)] +pub fn shortcuts_path_in(base: &Path) -> PathBuf { + config_dir_path_in(base).join(SHORTCUTS_FILE_NAME) +} + +pub fn default_shortcuts() -> ResolvedShortcutConfig { + ResolvedShortcutConfig { + shortcuts: definitions() + .iter() + .map(|definition| ResolvedShortcut { + definition, + binding: Some(definition.default_binding()), + }) + .collect(), + warnings: Vec::new(), + } +} + +#[cfg(test)] +pub fn resolve_shortcuts_from_str( + raw: &str, +) -> Result { + resolve_shortcuts_from_str_with_display(raw, None) +} + +pub fn resolve_shortcuts_from_str_with_display( + raw: &str, + display: Option<&gdk::Display>, +) -> Result { + let parsed: ShortcutConfigFile = serde_json::from_str(raw) + .map_err(|err| ShortcutConfigError::InvalidJson(err.to_string()))?; + resolve_shortcuts_from_file(parsed, display) +} + +#[cfg(test)] +pub fn load_shortcuts_or_default(path: &Path) -> ResolvedShortcutConfig { + load_shortcuts_or_default_with_display(path, None) +} + +pub fn load_shortcuts_or_default_with_display( + path: &Path, + display: Option<&gdk::Display>, +) -> ResolvedShortcutConfig { + if !path.exists() { + return default_shortcuts(); + } + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) => { + let mut defaults = default_shortcuts(); + defaults.warnings.push(format!( + "failed to read shortcut config `{}`: {err}", + path.display() + )); + return defaults; + } + }; + + match resolve_shortcuts_from_str_with_display(&raw, display) { + Ok(config) => config, + Err(err) => { + let mut defaults = default_shortcuts(); + defaults.warnings.push(format!( + "failed to load shortcut config `{}`: {err:?}", + path.display() + )); + defaults + } + } +} + +pub fn load_shortcuts_for_display(display: &gdk::Display) -> ResolvedShortcutConfig { + let Some(path) = shortcuts_path() else { + let mut defaults = default_shortcuts(); + defaults + .warnings + .push("config_dir unavailable; using default shortcuts".to_string()); + return defaults; + }; + load_shortcuts_or_default_with_display(&path, Some(display)) +} + +pub fn write_shortcuts( + path: &Path, + shortcuts: &ResolvedShortcutConfig, +) -> Result<(), ShortcutConfigWriteError> { + let mut root = read_existing_config_root(path)?; + let overrides = shortcuts.override_bindings_json(); + if overrides.is_empty() { + root.remove("shortcuts"); + } else { + root.insert("shortcuts".to_string(), Value::Object(overrides)); + } + write_config_root_atomically(path, root) +} + +fn resolve_shortcuts_from_file( + parsed: ShortcutConfigFile, + display: Option<&gdk::Display>, +) -> Result { + let mut resolved = default_shortcuts(); + + for (shortcut_id, value) in parsed.shortcuts { + let Some(definition) = definition_by_config_key(&shortcut_id) else { + resolved + .warnings + .push(format!("ignoring unknown shortcut id `{shortcut_id}`")); + continue; + }; + + let binding = match value { + serde_json::Value::Null => None, + serde_json::Value::String(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(canonicalize_loaded_binding( + display, + NormalizedShortcut::parse(trimmed)?, + )) + } + } + _ => { + return Err(ShortcutConfigError::InvalidBindingType { + shortcut_id: shortcut_id.clone(), + }); + } + }; + + if let Some(slot) = resolved + .shortcuts + .iter_mut() + .find(|shortcut| shortcut.definition.id == definition.id) + { + slot.binding = binding; + } + } + + ensure_valid_active_bindings(&resolved.shortcuts)?; + Ok(resolved) +} + +fn ensure_valid_active_bindings(shortcuts: &[ResolvedShortcut]) -> Result<(), ShortcutConfigError> { + let mut active: HashMap = HashMap::new(); + for shortcut in shortcuts { + let Some(binding) = shortcut.binding.clone() else { + continue; + }; + binding.validate_host_binding(shortcut.definition)?; + if let Some(existing) = active.insert(binding.clone(), shortcut.definition.id) { + return Err(ShortcutConfigError::DuplicateBinding { + first: existing, + second: shortcut.definition.id, + accel: binding.to_config_accel(), + }); + } + } + Ok(()) +} + +fn read_existing_config_root(path: &Path) -> Result, ShortcutConfigWriteError> { + if !path.exists() { + return Ok(Map::new()); + } + + let raw = + fs::read_to_string(path).map_err(|err| ShortcutConfigWriteError::InvalidExistingJson { + path: path.to_path_buf(), + reason: err.to_string(), + })?; + + let root: Value = serde_json::from_str(&raw).map_err(|err| { + ShortcutConfigWriteError::InvalidExistingJson { + path: path.to_path_buf(), + reason: err.to_string(), + } + })?; + + match root { + Value::Object(map) => Ok(map), + _ => Err(ShortcutConfigWriteError::InvalidExistingRoot { + path: path.to_path_buf(), + }), + } +} + +fn write_config_root_atomically( + path: &Path, + root: Map, +) -> Result<(), ShortcutConfigWriteError> { + let Some(parent) = path.parent() else { + return Err(ShortcutConfigWriteError::CreateParentDir { + path: path.to_path_buf(), + reason: "config path has no parent directory".to_string(), + }); + }; + fs::create_dir_all(parent).map_err(|err| ShortcutConfigWriteError::CreateParentDir { + path: parent.to_path_buf(), + reason: err.to_string(), + })?; + + let temp_path = temp_config_path(path); + let serialized = serde_json::to_string_pretty(&Value::Object(root)) + .expect("shortcut config root should always serialize"); + if let Err(err) = fs::write(&temp_path, format!("{serialized}\n")) { + return Err(ShortcutConfigWriteError::WriteTempFile { + path: temp_path, + reason: err.to_string(), + }); + } + + if let Err(err) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(ShortcutConfigWriteError::PersistTempFile { + from: temp_path, + to: path.to_path_buf(), + reason: err.to_string(), + }); + } + + Ok(()) +} + +fn temp_config_path(path: &Path) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_nanos(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(SHORTCUTS_FILE_NAME); + path.with_file_name(format!(".{file_name}.tmp-{}-{nanos}", std::process::id())) +} + +pub(crate) fn definition_by_config_key(config_key: &str) -> Option<&'static ShortcutDefinition> { + definitions() + .iter() + .find(|definition| definition.config_key == config_key) +} + +fn definition_by_id(id: ShortcutId) -> Option<&'static ShortcutDefinition> { + definitions().iter().find(|definition| definition.id == id) +} + +fn normalized_event_key( + display: Option<&gdk::Display>, + keyval: gdk::Key, + keycode: u32, +) -> Option { + let resolved = display + .and_then(|display| unshifted_keyval_for_event(display, keyval, keycode)) + .unwrap_or(keyval); + resolved + .name() + .map(|key_name| normalize_runtime_key(key_name.as_str())) +} + +fn unshifted_keyval_for_event( + display: &gdk::Display, + keyval: gdk::Key, + keycode: u32, +) -> Option { + if keycode == 0 { + return None; + } + + let keyval_mappings = display.map_keyval(keyval)?; + let keycode_mappings = display.map_keycode(keycode)?; + unshifted_keyval_from_mappings(keycode, &keyval_mappings, &keycode_mappings) +} + +fn canonicalize_loaded_binding( + display: Option<&gdk::Display>, + binding: NormalizedShortcut, +) -> NormalizedShortcut { + let Some(display) = display else { + return binding; + }; + let Some(keyval) = gdk::Key::from_name(runtime_key_to_gtk_key(&binding.key)) else { + return binding; + }; + let Some(unshifted) = unshifted_keyval_for_loaded_binding(display, keyval) else { + return binding; + }; + let Some(key_name) = unshifted.name() else { + return binding; + }; + + NormalizedShortcut { + key: normalize_runtime_key(key_name.as_str()), + ..binding + } +} + +fn unshifted_keyval_for_loaded_binding( + display: &gdk::Display, + keyval: gdk::Key, +) -> Option { + let keyval_mappings = display.map_keyval(keyval)?; + for mapping in keyval_mappings { + let keycode_mappings = display.map_keycode(mapping.keycode())?; + if let Some(unshifted) = unshifted_keyval_from_mappings( + mapping.keycode(), + std::slice::from_ref(&mapping), + &keycode_mappings, + ) { + return Some(unshifted); + } + } + None +} + +fn unshifted_keyval_from_mappings( + keycode: u32, + keyval_mappings: &[gdk::KeymapKey], + keycode_mappings: &[(gdk::KeymapKey, gdk::Key)], +) -> Option { + let group = keyval_mappings + .iter() + .find(|mapping| mapping.keycode() == keycode) + .map(gdk::KeymapKey::group)?; + + keycode_mappings + .iter() + .find(|(mapping, _)| { + mapping.keycode() == keycode && mapping.group() == group && mapping.level() == 0 + }) + .map(|(_, key)| *key) +} + +fn normalize_runtime_key(key: &str) -> String { + let normalized = key.trim().replace(['-', ' '], "_").to_ascii_lowercase(); + match normalized.as_str() { + "pageup" => "page_up".to_string(), + "pagedown" => "page_down".to_string(), + "return" => "enter".to_string(), + "esc" => "escape".to_string(), + other => other.to_string(), + } +} + +fn is_modifier_only_key(key: &str) -> bool { + matches!( + key, + "shift_l" + | "shift_r" + | "control_l" + | "control_r" + | "alt_l" + | "alt_r" + | "meta_l" + | "meta_r" + | "super_l" + | "super_r" + ) +} + +fn runtime_key_to_gtk_key(key: &str) -> String { + match key { + "page_up" => "Page_Up".to_string(), + "page_down" => "Page_Down".to_string(), + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "enter" => "Return".to_string(), + "escape" => "Escape".to_string(), + "tab" => "Tab".to_string(), + other if is_function_key(other) => other.to_ascii_uppercase(), + other => other.to_string(), + } +} + +fn display_key_label(key: &str) -> String { + match key { + "page_up" => "Page Up".to_string(), + "page_down" => "Page Down".to_string(), + "left" => "Left".to_string(), + "right" => "Right".to_string(), + "up" => "Up".to_string(), + "down" => "Down".to_string(), + "enter" => "Enter".to_string(), + "escape" => "Esc".to_string(), + "tab" => "Tab".to_string(), + other if other.chars().count() == 1 => other.to_ascii_uppercase(), + other => other + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut label = first.to_ascii_uppercase().to_string(); + label.push_str(chars.as_str()); + label + } + None => String::new(), + } + }) + .collect::>() + .join(" "), + } +} + +fn is_function_key(key: &str) -> bool { + key.strip_prefix('f') + .map(|suffix| { + !suffix.is_empty() + && suffix.chars().all(|ch| ch.is_ascii_digit()) + && suffix + .parse::() + .map(|value| value >= 1) + .unwrap_or(false) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn definitions_cover_current_host_shortcuts() { + assert_eq!(definitions().len(), 47); + } + + #[test] + fn definitions_have_unique_ids_and_action_names_and_accels() { + let defs = definitions(); + let mut ids = HashMap::new(); + let mut actions = HashMap::new(); + let mut accel_keys = HashMap::new(); + + for def in defs { + assert!(ids.insert(def.id, def.config_key).is_none()); + assert!(actions.insert(def.action_name, def.config_key).is_none()); + assert!(accel_keys + .insert(def.config_key, def.default_accel) + .is_none()); + } + } + + #[test] + fn definitions_have_expected_gtk_accel_subset() { + let gtk_actions: Vec<_> = definitions() + .iter() + .filter(|def| def.registers_gtk_accel) + .map(|def| def.action_name) + .collect(); + + assert_eq!( + gtk_actions, + vec![ + "win.new-workspace", + "win.close-workspace", + "app.quit", + "app.new-instance", + "win.toggle-sidebar", + "win.toggle-top-bar", + "win.toggle-fullscreen", + "win.next-workspace", + "win.prev-workspace", + ] + ); + } + + #[test] + fn normalized_shortcut_round_trips_between_gtk_and_runtime_forms() { + let shortcut = NormalizedShortcut::parse("Page_Down").unwrap(); + assert_eq!(shortcut.to_config_accel(), "Page_Down"); + assert_eq!(shortcut.to_runtime_combo(), "ctrl+shift+page_down"); + } + + #[test] + fn normalized_shortcut_round_trips_cmd_modifier_forms() { + let shortcut = NormalizedShortcut::parse("t").unwrap(); + assert_eq!(shortcut.to_config_accel(), "t"); + assert_eq!( + shortcut.gtk_accel_variants(), + vec!["t".to_string(), "t".to_string()] + ); + assert_eq!(shortcut.to_runtime_combo(), "shift+cmd+t"); + assert_eq!(shortcut.to_display_label(), "Shift+Cmd+T"); + } + + #[test] + fn unshifted_keyval_from_mappings_uses_same_group_level_zero_key() { + let keyval_mappings = vec![gdk::KeymapKey::new(10, 1, 1)]; + let keycode_mappings = vec![ + (gdk::KeymapKey::new(10, 0, 0), gdk::Key::_1), + (gdk::KeymapKey::new(10, 1, 0), gdk::Key::_0), + (gdk::KeymapKey::new(10, 1, 1), gdk::Key::parenleft), + ]; + + let unshifted = + unshifted_keyval_from_mappings(10, &keyval_mappings, &keycode_mappings).unwrap(); + assert_eq!(unshifted, gdk::Key::_0); + + let shortcut = NormalizedShortcut::from_gdk_key_event( + None, + unshifted, + 10, + gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK, + ) + .unwrap(); + assert_eq!(shortcut.to_display_label(), "Ctrl+Shift+0"); + } + + #[test] + fn config_dir_path_in_uses_limux_config_dir() { + let base = Path::new("/tmp/example"); + assert_eq!( + config_dir_path_in(base), + PathBuf::from("/tmp/example/limux") + ); + } + + #[test] + fn shortcuts_path_in_uses_limux_shortcuts_json() { + let base = Path::new("/tmp/example"); + assert_eq!( + shortcuts_path_in(base), + PathBuf::from("/tmp/example/limux/shortcuts.json") + ); + } + + #[test] + fn resolve_shortcuts_from_str_applies_custom_bindings_and_unbinds() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b", + "split_right": null, + "new_terminal": "" + } + }"#, + ) + .unwrap(); + + assert_eq!( + resolved + .find_by_id(ShortcutId::ToggleSidebar) + .and_then(ResolvedShortcut::gtk_accel) + .as_deref(), + Some("b") + ); + assert_eq!( + resolved + .find_by_id(ShortcutId::SplitRight) + .and_then(ResolvedShortcut::gtk_accel), + None + ); + assert_eq!( + resolved + .find_by_id(ShortcutId::NewTerminal) + .and_then(ResolvedShortcut::gtk_accel), + None + ); + } + + #[test] + fn resolve_shortcuts_from_str_warns_on_unknown_ids() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b", + "unknown_action": "x" + } + }"#, + ) + .unwrap(); + + assert_eq!(resolved.warnings.len(), 1); + assert!(resolved.warnings[0].contains("unknown shortcut id `unknown_action`")); + } + + #[test] + fn resolve_shortcuts_from_str_rejects_duplicate_active_bindings() { + let err = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b", + "split_right": "b" + } + }"#, + ) + .unwrap_err(); + + assert!(matches!(err, ShortcutConfigError::DuplicateBinding { .. })); + } + + #[test] + fn load_shortcuts_or_default_falls_back_on_invalid_json() { + let dir = tempdir().unwrap(); + let path = shortcuts_path_in(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "{ this is not json").unwrap(); + + let resolved = load_shortcuts_or_default(&path); + + assert_eq!(resolved.shortcuts.len(), definitions().len()); + assert_eq!(resolved.warnings.len(), 1); + assert!(resolved.warnings[0].contains("failed to load shortcut config")); + } + + #[test] + fn load_shortcuts_or_default_uses_defaults_when_file_is_missing() { + let dir = tempdir().unwrap(); + let path = shortcuts_path_in(dir.path()); + let resolved = load_shortcuts_or_default(&path); + assert!(resolved.warnings.is_empty()); + assert_eq!(resolved.shortcuts.len(), definitions().len()); + } + + #[test] + fn resolved_shortcuts_expose_registered_gtk_accels_and_clear_unbound_actions() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": null + } + }"#, + ) + .unwrap(); + + let gtk_accels = resolved.gtk_accel_entries(); + assert_eq!(gtk_accels.len(), 9); + assert_eq!( + gtk_accels + .iter() + .find(|(action, _)| *action == "win.toggle-sidebar") + .map(|(_, accels)| accels.clone()), + Some(Vec::::new()) + ); + } + + #[test] + fn gtk_accel_entries_keep_ctrl_defaults_single_and_expand_cmd_remaps() { + let resolved = default_shortcuts(); + let app_quit = resolved + .gtk_accel_entries() + .into_iter() + .find(|(action, _)| *action == "app.quit") + .map(|(_, accels)| accels) + .unwrap(); + assert_eq!(app_quit, vec!["q".to_string()]); + + let remapped = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "quit_app": "q" + } + }"#, + ) + .unwrap(); + let remapped_quit = remapped + .gtk_accel_entries() + .into_iter() + .find(|(action, _)| *action == "app.quit") + .map(|(_, accels)| accels) + .unwrap(); + assert_eq!( + remapped_quit, + vec!["q".to_string(), "q".to_string()] + ); + } + + #[test] + fn resolved_shortcuts_route_runtime_combos_to_canonical_commands() { + let resolved = default_shortcuts(); + + assert_eq!( + resolved.command_for_runtime_combo("ctrl+t"), + Some(ShortcutCommand::NewTerminal) + ); + assert_eq!(resolved.command_for_runtime_combo("ctrl+c"), None); + assert_eq!( + resolved.command_for_runtime_combo("ctrl+shift+c"), + Some(ShortcutCommand::TerminalCopy) + ); + assert_eq!( + resolved.command_for_runtime_combo("ctrl+shift+t"), + Some(ShortcutCommand::NewTerminal) + ); + assert_eq!( + resolved.command_for_runtime_combo("ctrl+9"), + Some(ShortcutCommand::ActivateLastWorkspace) + ); + } + + #[test] + fn resolved_shortcuts_expose_default_display_labels_for_editor_rows() { + let resolved = default_shortcuts(); + + assert_eq!( + resolved + .default_display_label_for_id(ShortcutId::SplitRight) + .as_deref(), + Some("Ctrl+D") + ); + assert_eq!( + resolved + .find_by_id(ShortcutId::SplitRight) + .map(ResolvedShortcut::default_display_label) + .as_deref(), + Some("Ctrl+D") + ); + assert_eq!( + resolved + .default_display_label_for_id(ShortcutId::TerminalPaste) + .as_deref(), + Some("Ctrl+Shift+V") + ); + } + + #[test] + fn default_terminal_paste_does_not_claim_plain_ctrl_v() { + let resolved = default_shortcuts(); + + assert_eq!(resolved.command_for_runtime_combo("ctrl+v"), None); + assert_eq!( + resolved.command_for_runtime_combo("ctrl+shift+v"), + Some(ShortcutCommand::TerminalPaste) + ); + } + + #[test] + fn override_bindings_json_only_serializes_non_default_bindings() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "h", + "close_focused_pane": null + } + }"#, + ) + .unwrap(); + + let overrides = resolved.override_bindings_json(); + assert_eq!(overrides.len(), 2); + assert_eq!( + overrides.get("split_right"), + Some(&Value::String("h".to_string())) + ); + assert_eq!(overrides.get("close_focused_pane"), Some(&Value::Null)); + assert!(!overrides.contains_key("toggle_sidebar")); + } + + #[test] + fn with_binding_updates_one_shortcut_and_revalidates() { + let updated = default_shortcuts() + .with_binding( + ShortcutId::SplitRight, + Some(NormalizedShortcut::parse("h").unwrap()), + ) + .unwrap(); + + assert_eq!( + updated + .display_label_for_id(ShortcutId::SplitRight) + .as_deref(), + Some("Ctrl+H") + ); + } + + #[test] + fn write_shortcuts_preserves_unrelated_top_level_config_keys() { + let dir = tempdir().unwrap(); + let path = shortcuts_path_in(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"{ + "appearance": { + "theme": "solarized" + }, + "shortcuts": { + "toggle_sidebar": "b" + } + }"#, + ) + .unwrap(); + + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "h" + } + }"#, + ) + .unwrap(); + write_shortcuts(&path, &resolved).unwrap(); + + let saved: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(saved["appearance"]["theme"], "solarized"); + assert_eq!(saved["shortcuts"]["split_right"], "h"); + assert!(saved["shortcuts"].get("toggle_sidebar").is_none()); + } + + #[test] + fn write_shortcuts_rejects_invalid_existing_json_without_clobbering_file() { + let dir = tempdir().unwrap(); + let path = shortcuts_path_in(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "{ invalid").unwrap(); + + let original = fs::read_to_string(&path).unwrap(); + let resolved = default_shortcuts(); + let err = write_shortcuts(&path, &resolved).unwrap_err(); + + assert!(matches!( + err, + ShortcutConfigWriteError::InvalidExistingJson { .. } + )); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + + #[test] + fn resolved_shortcuts_format_tooltip_text_and_omit_unbound_suffixes() { + let defaults = default_shortcuts(); + assert_eq!( + defaults.tooltip_text(ShortcutId::ToggleSidebar, "Toggle Sidebar"), + "Toggle Sidebar (Ctrl+M)" + ); + + let remapped = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b" + } + }"#, + ) + .unwrap(); + assert_eq!( + remapped.tooltip_text(ShortcutId::ToggleSidebar, "Toggle Sidebar"), + "Toggle Sidebar (Ctrl+Alt+B)" + ); + + let unbound = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": null + } + }"#, + ) + .unwrap(); + assert_eq!( + unbound.tooltip_text(ShortcutId::ToggleSidebar, "Toggle Sidebar"), + "Toggle Sidebar" + ); + } + + #[test] + fn resolve_shortcuts_from_str_rejects_bindings_without_ctrl_alt_or_cmd() { + let err = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "h" + } + }"#, + ) + .unwrap_err(); + + assert!(matches!( + err, + ShortcutConfigError::BaseModifierRequired { shortcut_id, .. } + if shortcut_id == "split_right" + )); + } + + #[test] + fn resolve_shortcuts_from_str_allows_unmodified_fullscreen_binding() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_fullscreen": "F11" + } + }"#, + ) + .unwrap(); + + assert_eq!( + resolved + .display_label_for_id(ShortcutId::ToggleFullscreen) + .as_deref(), + Some("F11") + ); + assert_eq!( + resolved.command_for_runtime_combo("f11"), + Some(ShortcutCommand::ToggleFullscreen) + ); + assert_eq!( + resolved + .find_by_id(ShortcutId::ToggleFullscreen) + .map(ResolvedShortcut::gtk_accel_variants), + Some(vec!["F11".to_string()]) + ); + } + + #[test] + fn resolve_shortcuts_from_str_accepts_super_based_bindings() { + let resolved = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "h" + } + }"#, + ) + .unwrap(); + + assert_eq!( + resolved + .display_label_for_id(ShortcutId::SplitRight) + .as_deref(), + Some("Cmd+H") + ); + assert_eq!( + resolved.command_for_runtime_combo("cmd+h"), + Some(ShortcutCommand::SplitRight) + ); + } + + #[test] + fn resolve_shortcuts_from_str_rejects_modifier_only_keys() { + let err = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "Control_L" + } + }"#, + ) + .unwrap_err(); + + assert!(matches!( + err, + ShortcutConfigError::ModifierOnlyBinding { shortcut_id, .. } + if shortcut_id == "split_right" + )); + } + + #[test] + fn write_shortcuts_omits_defaults_and_preserves_unrelated_settings() { + let dir = tempdir().unwrap(); + let path = shortcuts_path_in(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"{ + "theme": "nord", + "shortcuts": { + "split_right": "d" + } + }"#, + ) + .unwrap(); + + let updated = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "split_right": "h", + "close_focused_pane": null + } + }"#, + ) + .unwrap(); + + write_shortcuts(&path, &updated).unwrap(); + + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written["theme"], "nord"); + assert_eq!(written["shortcuts"]["split_right"], "h"); + assert_eq!( + written["shortcuts"]["close_focused_pane"], + serde_json::Value::Null + ); + assert!(written["shortcuts"].get("toggle_sidebar").is_none()); + } + + #[test] + fn write_shortcuts_removes_shortcuts_section_when_all_bindings_match_defaults() { + let dir = tempdir().unwrap(); + let path = shortcuts_path_in(dir.path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + r#"{ + "theme": "nord", + "shortcuts": { + "split_right": "h" + } + }"#, + ) + .unwrap(); + + write_shortcuts(&path, &default_shortcuts()).unwrap(); + + let written: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written["theme"], "nord"); + assert!(written.get("shortcuts").is_none()); + } +} diff --git a/rust/limux-host-linux/src/split_tree.rs b/rust/limux-host-linux/src/split_tree.rs new file mode 100644 index 00000000..562fb0d5 --- /dev/null +++ b/rust/limux-host-linux/src/split_tree.rs @@ -0,0 +1,525 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk::glib; +use gtk::prelude::*; +use gtk4 as gtk; + +use crate::layout_state::{self, LayoutNodeState, PaneState, SplitOrientation, SplitState}; +use crate::pane; +use crate::window::{ + apply_split_ratio_after_layout, attach_split_position_persistence, update_split_ratio_state, + State, +}; + +// --------------------------------------------------------------------------- +// SplitNode — runtime data model for the split tree +// --------------------------------------------------------------------------- + +/// Runtime split tree node. Source of truth for the split layout. +/// The widget tree is rebuilt from this on every structural change. +pub(crate) enum SplitNode { + Leaf { + pane_widget: gtk::Widget, + }, + Split { + orientation: gtk::Orientation, + /// Shared with the Paned's position_notify handler so resize drags + /// update the data model directly. + ratio: Rc>, + left: Box, + right: Box, + }, +} + +impl SplitNode { + pub(crate) fn is_leaf(&self) -> bool { + matches!(self, SplitNode::Leaf { .. }) + } + + /// Find the leaf containing `target` and replace it with `replacement`. + pub(crate) fn replace(&mut self, target: >k::Widget, replacement: SplitNode) -> bool { + match self { + SplitNode::Leaf { pane_widget } => { + if pane_widget == target { + *self = replacement; + true + } else { + false + } + } + SplitNode::Split { left, right, .. } => { + // Check containment first to route ownership to the correct subtree + if left.contains_pane(target) { + left.replace(target, replacement) + } else { + right.replace(target, replacement) + } + } + } + } + + fn contains_pane(&self, target: >k::Widget) -> bool { + match self { + SplitNode::Leaf { pane_widget } => pane_widget == target, + SplitNode::Split { left, right, .. } => { + left.contains_pane(target) || right.contains_pane(target) + } + } + } + + /// Find the leaf containing `target` and promote its sibling in place. + pub(crate) fn remove(&mut self, target: >k::Widget) -> bool { + match self { + SplitNode::Leaf { .. } => false, + SplitNode::Split { left, right, .. } => { + if matches!(left.as_ref(), SplitNode::Leaf { pane_widget } if pane_widget == target) + { + // Target is left child — promote right sibling. + *self = std::mem::replace( + right.as_mut(), + SplitNode::Leaf { + pane_widget: target.clone(), + }, + ); + return true; + } + if matches!(right.as_ref(), SplitNode::Leaf { pane_widget } if pane_widget == target) + { + // Target is right child — promote left sibling. + *self = std::mem::replace( + left.as_mut(), + SplitNode::Leaf { + pane_widget: target.clone(), + }, + ); + return true; + } + left.remove(target) || right.remove(target) + } + } + } + + /// Snapshot to the serializable layout format for session persistence. + pub(crate) fn snapshot(&self, working_directory: Option<&str>) -> LayoutNodeState { + match self { + SplitNode::Leaf { pane_widget } => pane::snapshot_pane_state(pane_widget) + .map(LayoutNodeState::Pane) + .unwrap_or_else(|| LayoutNodeState::Pane(PaneState::fallback(working_directory))), + SplitNode::Split { + orientation, + ratio, + left, + right, + } => LayoutNodeState::Split(SplitState { + orientation: if *orientation == gtk::Orientation::Horizontal { + SplitOrientation::Horizontal + } else { + SplitOrientation::Vertical + }, + ratio: *ratio.borrow(), + start: Box::new(left.snapshot(working_directory)), + end: Box::new(right.snapshot(working_directory)), + }), + } + } +} + +// --------------------------------------------------------------------------- +// SplitTreeContainer — manages async widget-tree rebuild lifecycle +// --------------------------------------------------------------------------- + +/// Manages the workspace's split layout following Ghostty's atomic rebuild +/// pattern. Holds a SplitNode data model (source of truth) and a gtk::Box +/// container for the built widget tree. On structural changes, tears down the +/// old widget tree and rebuilds from the data model on the next idle tick. +pub(crate) struct SplitTreeContainer { + tree: RefCell, + bin: gtk::Box, + rebuild_source: RefCell>, + last_focused: RefCell>, + state: State, +} + +impl SplitTreeContainer { + /// Create a new container with a single pane (no splits). + pub(crate) fn new(state: &State, initial_pane: gtk::Widget) -> Rc { + let bin = gtk::Box::new(gtk::Orientation::Vertical, 0); + bin.set_hexpand(true); + bin.set_vexpand(true); + bin.append(&initial_pane); + + Rc::new(Self { + tree: RefCell::new(SplitNode::Leaf { + pane_widget: initial_pane, + }), + bin, + rebuild_source: RefCell::new(None), + last_focused: RefCell::new(None), + state: state.clone(), + }) + } + + /// Create a container from a pre-built tree (for session restore). + pub(crate) fn new_from_tree(state: &State, node: SplitNode) -> Rc { + let bin = gtk::Box::new(gtk::Orientation::Vertical, 0); + bin.set_hexpand(true); + bin.set_vexpand(true); + + // Build the initial widget tree synchronously (no async needed on first build) + let widget = build_widget_tree(&node, state); + bin.append(&widget); + + Rc::new(Self { + tree: RefCell::new(node), + bin, + rebuild_source: RefCell::new(None), + last_focused: RefCell::new(None), + state: state.clone(), + }) + } + + /// The container widget to add to the gtk::Stack. + pub(crate) fn widget(&self) -> >k::Box { + &self.bin + } + + /// Borrow the tree for reading (e.g. session snapshot). + pub(crate) fn tree(&self) -> std::cell::Ref<'_, SplitNode> { + self.tree.borrow() + } + + /// Whether the tree is a single leaf (no splits). + pub(crate) fn is_single_pane(&self) -> bool { + self.tree.borrow().is_leaf() + } + + /// Split a pane. Mutates the data model, then triggers async rebuild. + pub(crate) fn split( + self: &Rc, + target: >k::Widget, + new_pane: gtk::Widget, + orientation: gtk::Orientation, + new_pane_first: bool, + ratio: f64, + ) { + self.save_focus(); + + let shared_ratio = Rc::new(RefCell::new(layout_state::clamp_split_ratio(ratio))); + let new_node = if new_pane_first { + SplitNode::Split { + orientation, + ratio: shared_ratio, + left: Box::new(SplitNode::Leaf { + pane_widget: new_pane, + }), + right: Box::new(SplitNode::Leaf { + pane_widget: target.clone(), + }), + } + } else { + SplitNode::Split { + orientation, + ratio: shared_ratio, + left: Box::new(SplitNode::Leaf { + pane_widget: target.clone(), + }), + right: Box::new(SplitNode::Leaf { + pane_widget: new_pane, + }), + } + }; + + let replaced = { + let mut tree = self.tree.borrow_mut(); + tree.replace(target, new_node) + }; + + if replaced { + self.trigger_rebuild(); + } + } + + /// Remove a pane. Mutates the data model, then triggers async rebuild. + pub(crate) fn remove(self: &Rc, target: >k::Widget) -> bool { + self.save_focus(); + + let removed = { + let mut tree = self.tree.borrow_mut(); + tree.remove(target) + }; + + if removed { + self.trigger_rebuild(); + } + removed + } + + /// Tear down the old widget tree and schedule a rebuild on the next idle + /// tick. The one-tick separation between unrealize (teardown) and realize + /// (rebuild) is what prevents GLArea breakage. + fn trigger_rebuild(self: &Rc) { + // Cancel any pending rebuild + if let Some(source) = self.rebuild_source.take() { + source.remove(); + } + + // Clear the bin — tears down the old widget tree. + // unrealize cascades to all GLAreas in the subtree. + while let Some(child) = self.bin.first_child() { + self.bin.remove(&child); + } + + // Rebuild on the next idle tick. The tick separation between + // unrealize (above) and realize (rebuild) is critical. + self.schedule_rebuild(); + } + + /// Schedule the actual rebuild on the next idle tick. + fn schedule_rebuild(self: &Rc) { + if self.rebuild_source.borrow().is_some() { + return; + } + let container = Rc::clone(self); + let source = glib::idle_add_local_once(move || { + container.rebuild_source.replace(None); + container.do_rebuild(); + }); + self.rebuild_source.replace(Some(source)); + } + + /// Build new widget tree from data model, attach atomically. + fn do_rebuild(&self) { + // Pane widgets may still be parented to old (floating) Paneds from + // the previous tree. GTK4 won't let us add them to new containers + // until they're unparented. Detach them all first. + let tree = self.tree.borrow(); + detach_panes_from_old_tree(&tree); + + let widget = build_widget_tree(&tree, &self.state); + self.bin.append(&widget); + + // Restore focus to the previously focused widget + if let Some(focused) = self.last_focused.borrow().as_ref() { + focused.grab_focus(); + } + } + + fn save_focus(&self) { + let focus = self + .bin + .root() + .and_then(|r| r.downcast::().ok()) + .and_then(|w| gtk::prelude::GtkWindowExt::focus(&w)); + *self.last_focused.borrow_mut() = focus; + } +} + +impl Drop for SplitTreeContainer { + fn drop(&mut self) { + if let Some(source) = self.rebuild_source.take() { + source.remove(); + } + } +} + +// --------------------------------------------------------------------------- +// Widget tree helpers +// --------------------------------------------------------------------------- + +/// Detach pane widgets from their old parents (floating Paneds left over +/// from the previous widget tree). GTK4 requires a widget to have no parent +/// before it can be added to a new container. +fn detach_panes_from_old_tree(node: &SplitNode) { + match node { + SplitNode::Leaf { pane_widget } => { + if let Some(parent) = pane_widget.parent() { + if let Some(paned) = parent.downcast_ref::() { + // Detach from the old Paned by clearing whichever slot holds us + if paned + .start_child() + .map(|c| c == *pane_widget) + .unwrap_or(false) + { + paned.set_start_child(gtk::Widget::NONE); + } else { + paned.set_end_child(gtk::Widget::NONE); + } + } + } + } + SplitNode::Split { left, right, .. } => { + detach_panes_from_old_tree(left); + detach_panes_from_old_tree(right); + } + } +} + +/// Build a GTK widget tree from the SplitNode data model. +fn build_widget_tree(node: &SplitNode, state: &State) -> gtk::Widget { + match node { + SplitNode::Leaf { pane_widget } => pane_widget.clone(), + SplitNode::Split { + orientation, + ratio, + left, + right, + } => { + let paned = gtk::Paned::builder() + .orientation(*orientation) + .hexpand(true) + .vexpand(true) + // Allow either child to be shrunk below its minimum size so + // the saved split ratio (e.g. 50/50) is honored even when one + // pane has wider tabs than the other. Without this, gtk::Paned + // clamps the position to respect the larger pane's minimum + // width, producing visibly uneven splits. + .shrink_start_child(true) + .shrink_end_child(true) + .build(); + + let ratio_val = *ratio.borrow(); + update_split_ratio_state(&paned, ratio_val); + attach_split_position_persistence(state, &paned); + + // Flag to suppress position_notify during programmatic set_position calls + // (initial layout and workspace re-map). Without this, set_position triggers + // position_notify which recalculates the ratio from the not-yet-stable pixel + // position, corrupting the stored ratio. + let applying = Rc::new(Cell::new(false)); + + // Track the width we last saw, so position_notify can distinguish + // user drags (width unchanged → recompute ratio) from width-driven + // auto-adjust (width changed → preserve ratio by re-applying + // position = ratio * new_width). Without this, opening the + // sidebar (which shrinks the inner paned's width) silently skews + // the saved ratio because GtkPaned's position is absolute pixels. + let last_size = Rc::new(Cell::new(0i32)); + let shared_ratio = ratio.clone(); + let applying_for_notify = applying.clone(); + let last_size_for_notify = last_size.clone(); + paned.connect_position_notify(move |paned| { + if applying_for_notify.get() { + return; + } + let size = if paned.orientation() == gtk::Orientation::Horizontal { + paned.width() + } else { + paned.height() + }; + if size <= 0 { + return; + } + if last_size_for_notify.get() != size { + // Width changed — this position-notify is an auto-adjust, + // not a user drag. Don't update the ratio. + last_size_for_notify.set(size); + return; + } + let new_ratio = layout_state::snapshot_split_ratio( + paned.position(), + size, + Some(*shared_ratio.borrow()), + ); + *shared_ratio.borrow_mut() = layout_state::clamp_split_ratio(new_ratio); + }); + + // Re-apply position = ratio * size whenever the paned's actual + // size changes (sidebar toggles, window resizes). GtkWidget's + // `width`/`height` properties don't reliably emit notify across + // GTK 4.x versions, so we poll via a per-frame tick callback + // (intentional: O(1) integer comparison per frame; always returns + // Continue so the paned stays reactive for its entire lifetime). + let paned_for_resize = paned.clone(); + let shared_ratio_for_resize = ratio.clone(); + let applying_for_resize = applying.clone(); + let last_size_for_resize = last_size.clone(); + let resize_orientation = *orientation; + paned.add_tick_callback(move |paned, _| { + let size = if resize_orientation == gtk::Orientation::Horizontal { + paned.width() + } else { + paned.height() + }; + if size <= 0 { + return glib::ControlFlow::Continue; + } + if last_size_for_resize.get() != size { + last_size_for_resize.set(size); + let ratio = *shared_ratio_for_resize.borrow(); + crate::window::apply_ratio_value( + &paned_for_resize, + resize_orientation, + ratio, + &applying_for_resize, + ); + } + glib::ControlFlow::Continue + }); + + let left_widget = build_widget_tree(left, state); + let right_widget = build_widget_tree(right, state); + paned.set_start_child(Some(&left_widget)); + paned.set_end_child(Some(&right_widget)); + + apply_split_ratio_after_layout(&paned, *orientation, ratio.clone(), applying); + + paned.upcast() + } + } +} + +// --------------------------------------------------------------------------- +// Conversion from serialized LayoutNodeState to runtime SplitNode +// --------------------------------------------------------------------------- + +/// Build a SplitNode tree from a persisted LayoutNodeState. +pub(crate) fn build_split_node_from_layout( + state: &State, + shortcuts: &Rc, + ws_id: &str, + working_directory: Option<&str>, + layout: &LayoutNodeState, +) -> SplitNode { + match layout { + LayoutNodeState::Pane(pane_state) => { + let pane = crate::window::create_pane_for_workspace( + state, + shortcuts, + ws_id, + working_directory, + Some(pane_state), + false, + ); + SplitNode::Leaf { + pane_widget: pane.upcast(), + } + } + LayoutNodeState::Split(split_state) => { + let orientation = match split_state.orientation { + SplitOrientation::Horizontal => gtk::Orientation::Horizontal, + SplitOrientation::Vertical => gtk::Orientation::Vertical, + }; + SplitNode::Split { + orientation, + ratio: Rc::new(RefCell::new(layout_state::clamp_split_ratio( + split_state.ratio, + ))), + left: Box::new(build_split_node_from_layout( + state, + shortcuts, + ws_id, + working_directory, + &split_state.start, + )), + right: Box::new(build_split_node_from_layout( + state, + shortcuts, + ws_id, + working_directory, + &split_state.end, + )), + } + } + } +} diff --git a/rust/limux-host-linux/src/terminal.rs b/rust/limux-host-linux/src/terminal.rs index fc15e677..54626dce 100644 --- a/rust/limux-host-linux/src/terminal.rs +++ b/rust/limux-host-linux/src/terminal.rs @@ -1,6 +1,7 @@ use gtk::glib; use gtk::prelude::*; use gtk4 as gtk; +use shell_quote::Bash; use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -9,7 +10,9 @@ use std::os::raw::{c_char, c_int, c_void}; use std::os::unix::ffi::OsStringExt; use std::ptr; use std::rc::Rc; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::OnceLock; +use std::time::Duration; use limux_ghostty_sys::*; @@ -19,6 +22,7 @@ use limux_ghostty_sys::*; struct GhosttyState { app: ghostty_app_t, + background_opacity: f64, } // Safety: ghostty_app_t is thread-safe for the operations we perform @@ -26,10 +30,14 @@ unsafe impl Send for GhosttyState {} unsafe impl Sync for GhosttyState {} static GHOSTTY: OnceLock = OnceLock::new(); +static CURRENT_COLOR_SCHEME: AtomicI32 = AtomicI32::new(GHOSTTY_COLOR_SCHEME_LIGHT); +static WAKEUP_IDLE_QUEUED: AtomicBool = AtomicBool::new(false); type TitleChangedCallback = dyn Fn(&str); type PwdChangedCallback = dyn Fn(&str); +type DesktopNotificationCallback = dyn Fn(&str, &str); type VoidCallback = dyn Fn(); +type WidgetCallback = dyn Fn(>k::Widget); /// Per-surface state, stored in a global registry keyed by surface pointer. struct SurfaceEntry { @@ -37,6 +45,7 @@ struct SurfaceEntry { toast_overlay: gtk::Overlay, on_title_changed: Option>, on_pwd_changed: Option>, + on_desktop_notification: Option>, on_bell: Option>, on_close: Option>, clipboard_context: *mut ClipboardContext, @@ -46,10 +55,292 @@ struct ClipboardContext { surface: Cell, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum ImeKeyEventPhase { + #[default] + Idle, + NotComposing, + Composing, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct TerminalImeState { + composing: bool, + key_event_phase: ImeKeyEventPhase, + pending_key_text: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ImeCommitOutcome { + BufferForKeyEvent, + CommitDirectly(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ImeFilterOutcome { + ForwardToGhostty, + ConsumeForIme, +} + +impl TerminalImeState { + fn begin_key_event(&mut self) { + self.key_event_phase = if self.composing { + ImeKeyEventPhase::Composing + } else { + ImeKeyEventPhase::NotComposing + }; + self.pending_key_text = None; + } + + fn finish_key_event(&mut self) { + self.key_event_phase = ImeKeyEventPhase::Idle; + self.pending_key_text = None; + } + + fn preedit_changed(&mut self) { + self.composing = true; + } + + fn preedit_ended(&mut self) { + self.composing = false; + } + + fn commit_text(&mut self, text: &str) -> ImeCommitOutcome { + match self.key_event_phase { + ImeKeyEventPhase::Idle | ImeKeyEventPhase::Composing => { + self.composing = false; + ImeCommitOutcome::CommitDirectly(text.to_string()) + } + ImeKeyEventPhase::NotComposing => { + self.pending_key_text = Some(text.to_string()); + ImeCommitOutcome::BufferForKeyEvent + } + } + } + + fn filter_outcome(&self, im_handled: bool) -> ImeFilterOutcome { + if !im_handled { + return ImeFilterOutcome::ForwardToGhostty; + } + + if self.composing + || self.key_event_phase == ImeKeyEventPhase::Composing + || self.pending_key_text.is_none() + { + ImeFilterOutcome::ConsumeForIme + } else { + ImeFilterOutcome::ForwardToGhostty + } + } + + fn take_event_text(&mut self, fallback: Option) -> Option { + match self.pending_key_text.take() { + Some(text) => CString::new(text).ok(), + None => fallback, + } + } +} + thread_local! { static SURFACE_MAP: RefCell> = RefCell::new(HashMap::new()); } +#[derive(Clone)] +pub struct TerminalHandle { + surface_cell: Rc>>, + gl_area: gtk::GLArea, + search_bar: gtk::SearchBar, + search_entry: gtk::SearchEntry, + callbacks: Rc>, +} + +impl TerminalHandle { + pub fn replace_callbacks(&self, callbacks: TerminalCallbacks) { + *self.callbacks.borrow_mut() = callbacks; + } + + pub fn perform_binding_action(&self, action: &str) -> bool { + let surface = *self.surface_cell.borrow(); + surface_action(surface, action); + surface.is_some() + } + + /// Inject text into the terminal surface for control-socket requests and + /// drag/drop payloads. Ghostty treats this as pasted text, which matches + /// the current control protocol semantics. + pub fn send_text(&self, text: &str) { + if let Some(surface) = *self.surface_cell.borrow() { + unsafe { + ghostty_surface_text(surface, text.as_ptr() as *const c_char, text.len()); + } + } + } + + pub fn show_find(&self) -> bool { + self.search_bar.set_search_mode(true); + self.search_entry.grab_focus(); + self.search_entry.select_region(0, -1); + if !self.search_entry.text().is_empty() { + self.apply_search_query(self.search_entry.text().as_str()); + } + true + } + + pub fn find_next(&self) -> bool { + if !self.search_bar.is_search_mode() || self.search_entry.text().is_empty() { + return false; + } + self.perform_binding_action("navigate_search:next") + } + + pub fn find_previous(&self) -> bool { + if !self.search_bar.is_search_mode() || self.search_entry.text().is_empty() { + return false; + } + self.perform_binding_action("navigate_search:previous") + } + + pub fn hide_find(&self) -> bool { + if !self.search_bar.is_search_mode() { + return false; + } + self.perform_binding_action("end_search"); + self.search_bar.set_search_mode(false); + self.gl_area.grab_focus(); + true + } + + pub fn use_selection_for_find(&self) -> bool { + let selection = self.read_selection_text(); + if selection.is_empty() { + return false; + } + + self.search_bar.set_search_mode(true); + self.search_entry.set_text(&selection); + self.search_entry.grab_focus(); + self.search_entry.select_region(0, -1); + self.apply_search_query(&selection); + true + } + + fn apply_search_query(&self, query: &str) -> bool { + let surface = *self.surface_cell.borrow(); + surface_action(surface, &terminal_search_action(query)); + surface.is_some() + } + + fn read_selection_text(&self) -> String { + let Some(surface) = *self.surface_cell.borrow() else { + return String::new(); + }; + + let mut text = ghostty_text_s { + tl_px_x: 0.0, + tl_px_y: 0.0, + offset_start: 0, + offset_len: 0, + text: ptr::null(), + text_len: 0, + }; + + let has_selection = unsafe { ghostty_surface_read_selection(surface, &mut text) }; + if !has_selection || text.text.is_null() || text.text_len == 0 { + return String::new(); + } + + let bytes = unsafe { std::slice::from_raw_parts(text.text as *const u8, text.text_len) }; + let selection = String::from_utf8_lossy(bytes).into_owned(); + unsafe { ghostty_surface_free_text(surface, &mut text) }; + selection + } +} + +pub struct TerminalWidget { + pub overlay: gtk::Overlay, + pub handle: TerminalHandle, +} + +fn terminal_search_action(query: &str) -> String { + format!("search:{query}") +} + +fn request_terminal_focus(gl_area: >k::GLArea, had_focus: &Cell) { + had_focus.set(true); + gl_area.grab_focus(); +} + +fn clear_ghostty_preedit(surface: ghostty_surface_t) { + unsafe { ghostty_surface_preedit(surface, ptr::null(), 0) }; +} + +fn update_ime_cursor_location(surface: ghostty_surface_t, im_context: >k::IMMulticontext) { + let mut x = 0.0; + let mut y = 0.0; + let mut width = 1.0; + let mut height = 1.0; + unsafe { + ghostty_surface_ime_point(surface, &mut x, &mut y, &mut width, &mut height); + } + im_context.set_cursor_location(>k::gdk::Rectangle::new( + x.round() as i32, + y.round() as i32, + width.max(1.0).round() as i32, + height.max(1.0).round() as i32, + )); +} + +fn update_ghostty_preedit( + surface_cell: &Rc>>, + im_context: >k::IMMulticontext, +) { + let Some(surface) = *surface_cell.borrow() else { + return; + }; + + let (preedit, _, cursor_pos) = im_context.preedit_string(); + if preedit.is_empty() { + clear_ghostty_preedit(surface); + return; + } + + if let Ok(text) = CString::new(preedit.as_str()) { + unsafe { + ghostty_surface_preedit(surface, text.as_ptr(), cursor_pos.max(0) as usize); + } + } +} + +fn send_committed_text(surface: ghostty_surface_t, text: &str) { + let Ok(c_text) = CString::new(text) else { + return; + }; + + let event = ghostty_input_key_s { + action: GHOSTTY_ACTION_PRESS, + mods: GHOSTTY_MODS_NONE, + consumed_mods: GHOSTTY_MODS_NONE, + keycode: 0, + text: c_text.as_ptr(), + unshifted_codepoint: 0, + composing: false, + }; + + unsafe { + ghostty_surface_key(surface, event); + } +} + +fn load_ghostty_config() -> ghostty_config_t { + unsafe { + let config = ghostty_config_new(); + ghostty_config_load_default_files(config); + ghostty_config_load_recursive_files(config); + ghostty_config_finalize(config); + config + } +} + /// Initialize the global Ghostty app. Must be called once before creating surfaces. pub fn init_ghostty() { GHOSTTY.get_or_init(|| { @@ -57,19 +348,15 @@ pub fn init_ghostty() { ghostty_init(0, ptr::null_mut()); } - let config = unsafe { - let c = ghostty_config_new(); - ghostty_config_load_default_files(c); - ghostty_config_load_recursive_files(c); - ghostty_config_finalize(c); - c - }; + let config = load_ghostty_config(); + let background_opacity = load_background_opacity(config); let runtime_config = ghostty_runtime_config_s { userdata: ptr::null_mut(), supports_selection_clipboard: true, wakeup_cb: ghostty_wakeup_cb, action_cb: ghostty_action_cb, + clipboard_has_text_cb: ghostty_clipboard_has_text_cb, read_clipboard_cb: ghostty_read_clipboard_cb, confirm_read_clipboard_cb: ghostty_confirm_read_clipboard_cb, write_clipboard_cb: ghostty_write_clipboard_cb, @@ -88,7 +375,10 @@ pub fn init_ghostty() { glib::ControlFlow::Continue }); - GhosttyState { app } + GhosttyState { + app, + background_opacity, + } }); } @@ -96,6 +386,33 @@ fn ghostty_app() -> ghostty_app_t { GHOSTTY.get().expect("ghostty not initialized").app } +pub fn ghostty_background_opacity() -> f64 { + init_ghostty(); + GHOSTTY + .get() + .map(|state| state.background_opacity) + .unwrap_or(1.0) +} + +fn load_background_opacity(config: ghostty_config_t) -> f64 { + let mut opacity = 1.0_f64; + let key = b"background-opacity"; + let loaded = unsafe { + ghostty_config_get( + config, + (&mut opacity as *mut f64).cast::(), + key.as_ptr().cast::(), + key.len(), + ) + }; + + if loaded && opacity.is_finite() { + opacity.clamp(0.0, 1.0) + } else { + 1.0 + } +} + fn ghostty_color_scheme_for_dark_mode(dark: bool) -> c_int { if dark { GHOSTTY_COLOR_SCHEME_DARK @@ -104,8 +421,13 @@ fn ghostty_color_scheme_for_dark_mode(dark: bool) -> c_int { } } +fn current_ghostty_color_scheme() -> c_int { + CURRENT_COLOR_SCHEME.load(Ordering::Relaxed) +} + pub fn sync_color_scheme(dark: bool) { let scheme = ghostty_color_scheme_for_dark_mode(dark); + CURRENT_COLOR_SCHEME.store(scheme, Ordering::Relaxed); let app = ghostty_app(); unsafe { @@ -126,15 +448,29 @@ pub fn sync_color_scheme(dark: bool) { // Runtime callbacks (C ABI) // --------------------------------------------------------------------------- +fn claim_wakeup_idle_slot(flag: &AtomicBool) -> bool { + !flag.swap(true, Ordering::AcqRel) +} + +fn release_wakeup_idle_slot(flag: &AtomicBool) { + flag.store(false, Ordering::Release); +} + unsafe extern "C" fn ghostty_wakeup_cb(_userdata: *mut c_void) { - glib::idle_add_once(|| { - let app = ghostty_app(); - unsafe { ghostty_app_tick(app) }; - }); + // Collapse renderer wakeups to a single pending idle source so text floods + // do not enqueue unbounded GTK callbacks on the main thread. + if claim_wakeup_idle_slot(&WAKEUP_IDLE_QUEUED) { + glib::idle_add_once(|| { + release_wakeup_idle_slot(&WAKEUP_IDLE_QUEUED); + let app = ghostty_app(); + unsafe { ghostty_app_tick(app) }; + }); + } + glib::MainContext::default().wakeup(); } unsafe extern "C" fn ghostty_action_cb( - _app: ghostty_app_t, + app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s, ) -> bool { @@ -172,6 +508,37 @@ unsafe extern "C" fn ghostty_action_cb( } true } + GHOSTTY_ACTION_DESKTOP_NOTIFICATION => { + if target.tag == GHOSTTY_TARGET_SURFACE { + let surface_key = unsafe { target.target.surface } as usize; + let title_ptr = unsafe { action.action.desktop_notification.title }; + let body_ptr = unsafe { action.action.desktop_notification.body }; + let title = if title_ptr.is_null() { + String::new() + } else { + unsafe { std::ffi::CStr::from_ptr(title_ptr) } + .to_str() + .unwrap_or("") + .to_string() + }; + let body = if body_ptr.is_null() { + String::new() + } else { + unsafe { std::ffi::CStr::from_ptr(body_ptr) } + .to_str() + .unwrap_or("") + .to_string() + }; + SURFACE_MAP.with(|map| { + if let Some(entry) = map.borrow().get(&surface_key) { + if let Some(cb) = &entry.on_desktop_notification { + cb(&title, &body); + } + } + }); + } + true + } GHOSTTY_ACTION_PWD => { if target.tag == GHOSTTY_TARGET_SURFACE { let surface_key = unsafe { target.target.surface } as usize; @@ -220,10 +587,89 @@ unsafe extern "C" fn ghostty_action_cb( } true } + GHOSTTY_ACTION_MOUSE_SHAPE => { + if target.tag == GHOSTTY_TARGET_SURFACE { + let surface_key = unsafe { target.target.surface } as usize; + let shape = unsafe { action.action.mouse_shape }; + let cursor_name = mouse_shape_to_cursor_name(shape); + SURFACE_MAP.with(|map| { + if let Some(entry) = map.borrow().get(&surface_key) { + entry.gl_area.set_cursor_from_name(Some(cursor_name)); + } + }); + } + true + } + GHOSTTY_ACTION_RELOAD_CONFIG => { + let config = load_ghostty_config(); + match target.tag { + GHOSTTY_TARGET_APP => unsafe { + ghostty_app_update_config(app, config); + }, + GHOSTTY_TARGET_SURFACE => { + let surface = unsafe { target.target.surface }; + unsafe { + ghostty_surface_update_config(surface, config); + } + } + _ => {} + } + unsafe { + ghostty_config_free(config); + } + true + } + GHOSTTY_ACTION_CONFIG_CHANGE => { + // Intentional no-op: forwarding the config back into + // ghostty_*_update_config re-enters this callback and recurses. + // The config is owned by Ghostty core; nothing to free here. + true + } _ => false, } } +/// Convert a Ghostty mouse shape enum value to a CSS cursor name for GTK4. +fn mouse_shape_to_cursor_name(shape: c_int) -> &'static str { + match shape { + GHOSTTY_MOUSE_SHAPE_DEFAULT => "default", + GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU => "context-menu", + GHOSTTY_MOUSE_SHAPE_HELP => "help", + GHOSTTY_MOUSE_SHAPE_POINTER => "pointer", + GHOSTTY_MOUSE_SHAPE_PROGRESS => "progress", + GHOSTTY_MOUSE_SHAPE_WAIT => "wait", + GHOSTTY_MOUSE_SHAPE_CELL => "cell", + GHOSTTY_MOUSE_SHAPE_CROSSHAIR => "crosshair", + GHOSTTY_MOUSE_SHAPE_TEXT => "text", + GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT => "vertical-text", + GHOSTTY_MOUSE_SHAPE_ALIAS => "alias", + GHOSTTY_MOUSE_SHAPE_COPY => "copy", + GHOSTTY_MOUSE_SHAPE_MOVE => "move", + GHOSTTY_MOUSE_SHAPE_NO_DROP => "no-drop", + GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED => "not-allowed", + GHOSTTY_MOUSE_SHAPE_GRAB => "grab", + GHOSTTY_MOUSE_SHAPE_GRABBING => "grabbing", + GHOSTTY_MOUSE_SHAPE_ALL_SCROLL => "all-scroll", + GHOSTTY_MOUSE_SHAPE_COL_RESIZE => "col-resize", + GHOSTTY_MOUSE_SHAPE_ROW_RESIZE => "row-resize", + GHOSTTY_MOUSE_SHAPE_N_RESIZE => "n-resize", + GHOSTTY_MOUSE_SHAPE_E_RESIZE => "e-resize", + GHOSTTY_MOUSE_SHAPE_S_RESIZE => "s-resize", + GHOSTTY_MOUSE_SHAPE_W_RESIZE => "w-resize", + GHOSTTY_MOUSE_SHAPE_NE_RESIZE => "ne-resize", + GHOSTTY_MOUSE_SHAPE_NW_RESIZE => "nw-resize", + GHOSTTY_MOUSE_SHAPE_SE_RESIZE => "se-resize", + GHOSTTY_MOUSE_SHAPE_SW_RESIZE => "sw-resize", + GHOSTTY_MOUSE_SHAPE_EW_RESIZE => "ew-resize", + GHOSTTY_MOUSE_SHAPE_NS_RESIZE => "ns-resize", + GHOSTTY_MOUSE_SHAPE_NESW_RESIZE => "nesw-resize", + GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE => "nwse-resize", + GHOSTTY_MOUSE_SHAPE_ZOOM_IN => "zoom-in", + GHOSTTY_MOUSE_SHAPE_ZOOM_OUT => "zoom-out", + _ => "default", + } +} + unsafe fn clipboard_surface_from_userdata(userdata: *mut c_void) -> Option { if userdata.is_null() { return None; @@ -251,11 +697,7 @@ unsafe extern "C" fn ghostty_read_clipboard_cb( Some(d) => d, None => return, }; - let clipboard = if clipboard_type == GHOSTTY_CLIPBOARD_SELECTION { - display.primary_clipboard() - } else { - display.clipboard() - }; + let clipboard = clipboard_from_type(&display, clipboard_type); clipboard.read_text_async(gtk::gio::Cancellable::NONE, move |result| { // Get clipboard text, defaulting to empty string on failure @@ -274,6 +716,58 @@ unsafe extern "C" fn ghostty_read_clipboard_cb( }); } +fn clipboard_from_type(display: >k::gdk::Display, clipboard_type: c_int) -> gtk::gdk::Clipboard { + if clipboard_type == GHOSTTY_CLIPBOARD_SELECTION { + display.primary_clipboard() + } else { + display.clipboard() + } +} + +fn clipboard_has_text(clipboard: >k::gdk::Clipboard) -> bool { + let formats = clipboard.formats(); + let mime_types = formats.mime_types(); + if clipboard_formats_include_image(mime_types.iter().map(|mime| mime.as_str())) { + return false; + } + + clipboard_formats_include_text( + formats.contains_type(String::static_type()), + mime_types.iter().map(|mime| mime.as_str()), + ) +} + +fn clipboard_formats_include_image<'a>(mime_types: impl IntoIterator) -> bool { + mime_types + .into_iter() + .any(|mime| mime.starts_with("image/")) +} + +fn clipboard_formats_include_text<'a>( + has_string_type: bool, + mime_types: impl IntoIterator, +) -> bool { + if !has_string_type { + return false; + } + + mime_types.into_iter().any(|mime| { + mime.eq_ignore_ascii_case("text/plain") + || mime.eq_ignore_ascii_case("text/plain;charset=utf-8") + }) +} + +unsafe extern "C" fn ghostty_clipboard_has_text_cb( + _userdata: *mut c_void, + clipboard_type: c_int, +) -> bool { + let Some(display) = gtk::gdk::Display::default() else { + return false; + }; + let clipboard = clipboard_from_type(&display, clipboard_type); + clipboard_has_text(&clipboard) +} + unsafe extern "C" fn ghostty_confirm_read_clipboard_cb( userdata: *mut c_void, text: *const c_char, @@ -365,18 +859,34 @@ unsafe extern "C" fn ghostty_close_surface_cb(userdata: *mut c_void, _process_al pub struct TerminalCallbacks { pub on_title_changed: Box, pub on_pwd_changed: Box, + pub on_desktop_notification: Box, pub on_bell: Box, pub on_close: Box, + pub on_open_browser_here: Box, pub on_split_right: Box, pub on_split_down: Box, + pub on_open_keybinds: Box, +} + +pub struct TerminalOptions { + pub hover_focus: Rc bool>, + pub saved_font_size: Option, +} + +/// Default font-size from ghostty config (cached on first access). +pub(crate) fn default_font_size() -> f32 { + use std::sync::OnceLock; + static SIZE: OnceLock = OnceLock::new(); + *SIZE.get_or_init(crate::ghostty_config::read_font_size) } /// Create a new Ghostty-powered terminal widget. /// Returns an Overlay (GLArea + toast layer) for embedding in the pane. pub fn create_terminal( working_directory: Option<&str>, + options: TerminalOptions, callbacks: TerminalCallbacks, -) -> gtk::Overlay { +) -> TerminalWidget { let gl_area = gtk::GLArea::new(); gl_area.set_hexpand(true); gl_area.set_vexpand(true); @@ -386,9 +896,14 @@ pub fn create_terminal( gl_area.set_auto_render(true); gl_area.set_focusable(true); gl_area.set_can_focus(true); + gl_area.connect_map(|gl_area| { + gl_area.queue_render(); + }); let wd = working_directory.map(|s| s.to_string()); - let callbacks = Rc::new(callbacks); + let saved_font_size = options.saved_font_size; + let hover_focus = options.hover_focus; + let callbacks = Rc::new(RefCell::new(callbacks)); let surface_cell: Rc>> = Rc::new(RefCell::new(None)); let had_focus = Rc::new(Cell::new(false)); let clipboard_context_cell: Rc> = @@ -400,6 +915,85 @@ pub fn create_terminal( overlay.set_hexpand(true); overlay.set_vexpand(true); + let search_entry = gtk::SearchEntry::builder() + .hexpand(true) + .placeholder_text("Find in terminal") + .build(); + let search_bar = gtk::SearchBar::new(); + search_bar.set_show_close_button(true); + search_bar.connect_entry(&search_entry); + search_bar.set_child(Some(&search_entry)); + search_bar.set_valign(gtk::Align::Start); + search_bar.set_halign(gtk::Align::Fill); + search_bar.set_margin_top(8); + search_bar.set_margin_start(8); + search_bar.set_margin_end(8); + overlay.add_overlay(&search_bar); + + let im_context = gtk::IMMulticontext::new(); + im_context.set_client_widget(Some(&gl_area)); + im_context.set_use_preedit(true); + let ime_state = Rc::new(RefCell::new(TerminalImeState::default())); + + let handle = TerminalHandle { + surface_cell: surface_cell.clone(), + gl_area: gl_area.clone(), + search_bar: search_bar.clone(), + search_entry: search_entry.clone(), + callbacks: callbacks.clone(), + }; + + { + let handle = handle.clone(); + search_entry.connect_search_changed(move |entry| { + handle.apply_search_query(entry.text().as_str()); + }); + } + { + let handle = handle.clone(); + search_entry.connect_stop_search(move |_| { + handle.hide_find(); + }); + } + { + let surface_cell = surface_cell.clone(); + let im_context = im_context.clone(); + let im_context_for_signal = im_context.clone(); + let ime_state = ime_state.clone(); + im_context_for_signal.connect_preedit_changed(move |_| { + ime_state.borrow_mut().preedit_changed(); + update_ghostty_preedit(&surface_cell, &im_context); + }); + } + { + let surface_cell = surface_cell.clone(); + let ime_state = ime_state.clone(); + im_context.connect_preedit_end(move |_| { + ime_state.borrow_mut().preedit_ended(); + let Some(surface) = *surface_cell.borrow() else { + return; + }; + clear_ghostty_preedit(surface); + }); + } + { + let surface_cell = surface_cell.clone(); + let ime_state = ime_state.clone(); + im_context.connect_commit(move |_, text| { + let Some(surface) = *surface_cell.borrow() else { + return; + }; + + match ime_state.borrow_mut().commit_text(text) { + ImeCommitOutcome::BufferForKeyEvent => {} + ImeCommitOutcome::CommitDirectly(text) => { + clear_ghostty_preedit(surface); + send_committed_text(surface, &text); + } + } + }); + } + // On realize: create the Ghostty surface { let gl = gl_area.clone(); @@ -456,9 +1050,22 @@ pub fn create_terminal( } unsafe { (*clipboard_context).surface.set(surface); + ghostty_surface_set_color_scheme(surface, current_ghostty_color_scheme()); } clipboard_context_cell.set(clipboard_context); + // Apply saved font size (if different from ghostty default) + if let Some(size) = saved_font_size { + let action = format!("set_font_size:{size}"); + unsafe { + ghostty_surface_binding_action( + surface, + action.as_ptr() as *const c_char, + action.len(), + ); + } + } + // Set initial size — GLArea gives unscaled CSS pixels, // Ghostty handles scaling internally via content_scale. let alloc = gl_area.allocation(); @@ -480,19 +1087,38 @@ pub fn create_terminal( toast_overlay: overlay_for_map.clone(), on_title_changed: Some(Box::new({ let cb = callbacks.clone(); - move |title| (cb.on_title_changed)(title) + move |title| { + let callbacks = cb.borrow(); + (callbacks.on_title_changed)(title); + } })), on_pwd_changed: Some(Box::new({ let cb = callbacks.clone(); - move |pwd| (cb.on_pwd_changed)(pwd) + move |pwd| { + let callbacks = cb.borrow(); + (callbacks.on_pwd_changed)(pwd); + } + })), + on_desktop_notification: Some(Box::new({ + let cb = callbacks.clone(); + move |title, body| { + let callbacks = cb.borrow(); + (callbacks.on_desktop_notification)(title, body); + } })), on_bell: Some(Box::new({ let cb = callbacks.clone(); - move || (cb.on_bell)() + move || { + let callbacks = cb.borrow(); + (callbacks.on_bell)(); + } })), on_close: Some(Box::new({ let cb = callbacks.clone(); - move || (cb.on_close)() + move || { + let callbacks = cb.borrow(); + (callbacks.on_close)(); + } })), clipboard_context, }, @@ -505,9 +1131,8 @@ pub fn create_terminal( ghostty_surface_set_focus(surface, true); } - // Grab GTK focus so key events reach this widget - had_focus.set(true); - gl_area.grab_focus(); + // Grab GTK focus so key events reach this widget. + request_terminal_focus(gl_area, &had_focus); }); } @@ -563,15 +1188,36 @@ pub fn create_terminal( { let sc_press = surface_cell.clone(); let sc_release = surface_cell.clone(); + let im_context_press = im_context.clone(); + let im_context_release = im_context.clone(); + let ime_state_press = ime_state.clone(); + let ime_state_release = ime_state.clone(); let key_controller = gtk::EventControllerKey::new(); key_controller.connect_key_pressed(move |ctrl, keyval, keycode, modifier| { if let Some(surface) = *sc_press.borrow() { - let c_text = key_event_text(keyval); - let current_event = ctrl .current_event() .and_then(|event| event.downcast::().ok()); let widget = ctrl.widget(); + let fallback_text = key_event_text(keyval); + + if let Some(current_event) = current_event.as_ref() { + { + let mut ime_state = ime_state_press.borrow_mut(); + ime_state.begin_key_event(); + } + + update_ime_cursor_location(surface, &im_context_press); + let im_handled = im_context_press.filter_keypress(current_event); + let filter_outcome = { + let ime_state = ime_state_press.borrow(); + ime_state.filter_outcome(im_handled) + }; + if filter_outcome == ImeFilterOutcome::ConsumeForIme { + ime_state_press.borrow_mut().finish_key_event(); + return glib::Propagation::Stop; + } + } let mut event = translate_key_event( GHOSTTY_ACTION_PRESS, @@ -581,11 +1227,17 @@ pub fn create_terminal( keycode, modifier, ); + let c_text = ime_state_press.borrow_mut().take_event_text(fallback_text); if let Some(ref ct) = c_text { event.text = ct.as_ptr(); } let consumed = unsafe { ghostty_surface_key(surface, event) }; + if consumed && ime_state_press.borrow().composing { + im_context_press.reset(); + clear_ghostty_preedit(surface); + } + ime_state_press.borrow_mut().finish_key_event(); if consumed { return glib::Propagation::Stop; } @@ -599,6 +1251,25 @@ pub fn create_terminal( .current_event() .and_then(|event| event.downcast::().ok()); let widget = ctrl.widget(); + + if let Some(current_event) = current_event.as_ref() { + { + let mut ime_state = ime_state_release.borrow_mut(); + ime_state.begin_key_event(); + } + + update_ime_cursor_location(surface, &im_context_release); + let im_handled = im_context_release.filter_keypress(current_event); + let filter_outcome = { + let ime_state = ime_state_release.borrow(); + ime_state.filter_outcome(im_handled) + }; + if filter_outcome == ImeFilterOutcome::ConsumeForIme { + ime_state_release.borrow_mut().finish_key_event(); + return; + } + } + let event = translate_key_event( GHOSTTY_ACTION_RELEASE, widget.as_ref(), @@ -608,6 +1279,7 @@ pub fn create_terminal( modifier, ); unsafe { ghostty_surface_key(surface, event) }; + ime_state_release.borrow_mut().finish_key_event(); } }); @@ -625,8 +1297,7 @@ pub fn create_terminal( click.connect_pressed(move |gesture, _n, x, y| { let btn = gesture.current_button(); // Grab keyboard focus on any click - had_focus.set(true); - gl_for_focus.grab_focus(); + request_terminal_focus(&gl_for_focus, &had_focus); // Skip right-click — context menu handles it if btn == 3 { return; @@ -684,7 +1355,24 @@ pub fn create_terminal( // Mouse motion { let surface_cell = surface_cell.clone(); + let surface_cell_for_enter = surface_cell.clone(); + let gl_for_focus = gl_area.clone(); + let had_focus = had_focus.clone(); let motion = gtk::EventControllerMotion::new(); + motion.connect_enter(move |ctrl, x, y| { + if (hover_focus)() { + // Match common Hyprland/Omarchy-style focus-follows-mouse behavior: + // as soon as the pointer enters a terminal, focus it so typing works + // immediately without an extra click. + request_terminal_focus(&gl_for_focus, &had_focus); + } + + if let Some(surface) = *surface_cell_for_enter.borrow() { + let mods = translate_mouse_mods(ctrl.current_event_state()); + unsafe { ghostty_surface_mouse_pos(surface, x, y, mods) }; + } + }); + let surface_cell = surface_cell.clone(); motion.connect_motion(move |ctrl, x, y| { if let Some(surface) = *surface_cell.borrow() { let mods = translate_mouse_mods(ctrl.current_event_state()); @@ -716,16 +1404,20 @@ pub fn create_terminal( let surface_cell = surface_cell.clone(); let had_focus_enter = had_focus.clone(); let had_focus_leave = had_focus.clone(); + let im_context_enter = im_context.clone(); + let im_context_leave = im_context.clone(); let focus_ctrl = gtk::EventControllerFocus::new(); let sc = surface_cell.clone(); focus_ctrl.connect_enter(move |_| { had_focus_enter.set(true); + im_context_enter.focus_in(); if let Some(surface) = *sc.borrow() { unsafe { ghostty_surface_set_focus(surface, true) }; } }); focus_ctrl.connect_leave(move |_| { had_focus_leave.set(false); + im_context_leave.focus_out(); if let Some(surface) = *surface_cell.borrow() { unsafe { ghostty_surface_set_focus(surface, false) }; } @@ -778,7 +1470,9 @@ pub fn create_terminal( { let surface_cell = surface_cell.clone(); let clipboard_context_cell = clipboard_context_cell.clone(); + let im_context = im_context.clone(); overlay.connect_destroy(move |_| { + im_context.set_client_widget(gtk::Widget::NONE); if let Some(surface) = surface_cell.borrow_mut().take() { let surface_key = surface as usize; SURFACE_MAP.with(|map| { @@ -800,13 +1494,29 @@ pub fn create_terminal( }); } - overlay + TerminalWidget { overlay, handle } } // --------------------------------------------------------------------------- // Context menu // --------------------------------------------------------------------------- +/// Send a binding action to every live surface. +pub(crate) fn broadcast_binding_action(action: &str) { + SURFACE_MAP.with(|map| { + for &key in map.borrow().keys() { + let surface = key as ghostty_surface_t; + unsafe { + ghostty_surface_binding_action( + surface, + action.as_ptr() as *const c_char, + action.len(), + ); + } + } + }); +} + fn surface_action(surface: Option, action: &str) { if let Some(surface) = surface { unsafe { @@ -818,7 +1528,7 @@ fn surface_action(surface: Option, action: &str) { fn show_terminal_context_menu( gl_area: >k::GLArea, surface: Option, - callbacks: &Rc, + callbacks: &Rc>, x: f64, y: f64, ) { @@ -836,8 +1546,10 @@ fn show_terminal_context_menu( ("Copy", has_selection), ("Paste", true), ("---", false), + ("Browser", true), ("Split Right", true), ("Split Down", true), + ("Keybinds", true), ("---", false), ("Clear", true), ]; @@ -874,14 +1586,33 @@ fn show_terminal_context_menu( let label = btn.label().unwrap_or_default().to_string(); let pop = popover.clone(); let cb = callbacks.clone(); + let gl_area = gl_area.clone(); btn.connect_clicked(move |_| { pop.popdown(); match label.as_str() { "Copy" => surface_action(surface, "copy_to_clipboard"), "Paste" => surface_action(surface, "paste_from_clipboard"), - "Split Right" => (cb.on_split_right)(), - "Split Down" => (cb.on_split_down)(), + "Browser" => { + let callbacks = cb.borrow(); + (callbacks.on_open_browser_here)(); + } + "Split Right" => { + let callbacks = cb.borrow(); + (callbacks.on_split_right)(); + } + "Split Down" => { + let callbacks = cb.borrow(); + (callbacks.on_split_down)(); + } + "Keybinds" => { + let anchor: gtk::Widget = gl_area.clone().upcast(); + let cb = cb.clone(); + glib::timeout_add_local_once(Duration::from_millis(80), move || { + let callbacks = cb.borrow(); + (callbacks.on_open_keybinds)(&anchor); + }); + } "Clear" => surface_action(surface, "clear_screen"), _ => {} } @@ -1094,25 +1825,10 @@ fn dropped_file_text(file_list: >k::gdk::FileList) -> Option { ) } -/// Shell-escape a path so it can be safely pasted into a terminal. -/// Operates on raw bytes to preserve non-UTF-8 filenames on Linux. +/// Bash-escape a path so it can be safely pasted into the terminal without +/// sending raw control bytes to Ghostty. fn shell_escape_bytes(s: &[u8]) -> Vec { - if s.iter() - .all(|&b| b.is_ascii_alphanumeric() || b == b'/' || b == b'.' || b == b'-' || b == b'_') - { - return s.to_vec(); - } - - let mut out = vec![b'\'']; - for &b in s { - if b == b'\'' { - out.extend_from_slice(b"'\\''"); - } else { - out.push(b); - } - } - out.push(b'\''); - out + Bash::quote_vec(s) } fn shell_escape_joined_bytes(paths: I) -> Option @@ -1186,6 +1902,13 @@ mod tests { assert_eq!(fallback_unshifted_codepoint(gtk::gdk::Key::A), 'a' as u32); } + #[test] + fn terminal_search_action_formats_queries_for_ghostty() { + assert_eq!(terminal_search_action(""), "search:"); + assert_eq!(terminal_search_action("needle"), "search:needle"); + assert_eq!(terminal_search_action("two words"), "search:two words"); + } + #[test] fn key_event_text_preserves_printable_chords() { let ctrl_shift_h = key_event_text(gtk::gdk::Key::H).and_then(|s| s.into_string().ok()); @@ -1197,6 +1920,55 @@ mod tests { assert!(key_event_text(gtk::gdk::Key::BackSpace).is_none()); } + #[test] + fn ime_state_consumes_composing_key_events() { + let mut state = TerminalImeState::default(); + state.preedit_changed(); + state.begin_key_event(); + + assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); + + state.finish_key_event(); + assert_eq!(state.key_event_phase, ImeKeyEventPhase::Idle); + } + + #[test] + fn ime_state_buffers_plain_commit_for_key_event_text() { + let mut state = TerminalImeState::default(); + state.begin_key_event(); + + assert_eq!(state.commit_text("a"), ImeCommitOutcome::BufferForKeyEvent); + assert_eq!( + state.filter_outcome(true), + ImeFilterOutcome::ForwardToGhostty + ); + + let text = state + .take_event_text(None) + .and_then(|text| text.into_string().ok()); + assert_eq!(text.as_deref(), Some("a")); + } + + #[test] + fn ime_state_commits_composed_text_outside_key_event() { + let mut state = TerminalImeState::default(); + state.preedit_changed(); + + assert_eq!( + state.commit_text("á"), + ImeCommitOutcome::CommitDirectly("á".to_string()) + ); + assert!(!state.composing); + } + + #[test] + fn ime_state_consumes_handled_events_without_text() { + let mut state = TerminalImeState::default(); + state.begin_key_event(); + + assert_eq!(state.filter_outcome(true), ImeFilterOutcome::ConsumeForIme); + } + #[test] fn shell_escape_preserves_simple_paths() { assert_eq!( @@ -1210,7 +1982,7 @@ mod tests { fn shell_escape_quotes_paths_with_spaces() { assert_eq!( shell_escape_bytes(b"/home/user/my file.txt"), - b"'/home/user/my file.txt'" + b"$'/home/user/my file.txt'" ); } @@ -1218,14 +1990,35 @@ mod tests { fn shell_escape_handles_single_quotes() { assert_eq!( shell_escape_bytes(b"/tmp/it's a file"), - b"'/tmp/it'\\''s a file'" + b"$'/tmp/it\\'s a file'" ); } #[test] fn shell_escape_preserves_non_utf8_bytes() { let path = b"/home/user/\xff\xfefile.txt"; - assert_eq!(shell_escape_bytes(path), b"'/home/user/\xff\xfefile.txt'"); + assert_eq!( + shell_escape_bytes(path), + b"$'/home/user/\\xFF\\xFEfile.txt'" + ); + } + + #[test] + fn shell_escape_hex_escapes_terminal_control_bytes() { + let path = b"/tmp/line\nbreak\tand\x03escape\x1b"; + assert_eq!( + shell_escape_bytes(path), + b"$'/tmp/line\\nbreak\\tand\\x03escape\\e'" + ); + } + + #[test] + fn clipboard_formats_include_text_rejects_image_clipboards() { + assert!(clipboard_formats_include_text( + true, + ["text/plain", "text/plain;charset=utf-8"] + )); + assert!(clipboard_formats_include_image(["image/png", "text/plain"])); } #[test] @@ -1235,12 +2028,13 @@ mod tests { b"/tmp/space name".as_slice(), b"/tmp/it's".as_slice(), b"/tmp/\xff\xfe".as_slice(), + b"/tmp/line\nbreak".as_slice(), ]) .expect("drop payload must be NUL-free"); assert_eq!( text.as_bytes(), - b"/tmp/plain '/tmp/space name' '/tmp/it'\\''s' '/tmp/\xff\xfe'" + b"/tmp/plain $'/tmp/space name' $'/tmp/it\\'s' $'/tmp/\\xFF\\xFE' $'/tmp/line\\nbreak'" ); } @@ -1248,4 +2042,16 @@ mod tests { fn shell_escape_joined_bytes_rejects_empty_input() { assert!(shell_escape_joined_bytes(std::iter::empty::<&[u8]>()).is_none()); } + + #[test] + fn wakeup_idle_slot_coalesces_until_released() { + let flag = AtomicBool::new(false); + + assert!(claim_wakeup_idle_slot(&flag)); + assert!(!claim_wakeup_idle_slot(&flag)); + + release_wakeup_idle_slot(&flag); + + assert!(claim_wakeup_idle_slot(&flag)); + } } diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index 61a80385..bc868587 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -1,16 +1,26 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use adw::prelude::*; +use gtk::gdk::prelude::ToplevelExt; +use gtk::gio; use gtk::glib; +use gtk::glib::variant::ToVariant; use gtk4 as gtk; use libadwaita as adw; +use crate::app_config; +use crate::control_bridge::{ControlCommand, WorkspaceTarget}; +use crate::keybind_editor; use crate::layout_state::{ - self, AppSessionState, LayoutNodeState, LoadedSession, PaneState, SplitOrientation, SplitState, - WorkspaceState, + self, AppSessionState, LayoutNodeState, LoadedSession, PaneState, WorkspaceState, }; use crate::pane::{self, PaneCallbacks}; +use crate::settings_editor; +use crate::shortcut_config::{ + self, EditableCapturePolicy, ResolvedShortcutConfig, ShortcutCommand, ShortcutId, +}; +use crate::split_tree::{self, SplitTreeContainer}; // --------------------------------------------------------------------------- // State @@ -21,6 +31,8 @@ struct Workspace { name: String, /// The root widget in the content stack for this workspace. root: gtk::Widget, + /// Manages the split tree data model and async widget rebuild. + split_container: Rc, /// The sidebar row widget. sidebar_row: gtk::ListBoxRow, /// Name label in sidebar row. @@ -42,59 +54,239 @@ struct Workspace { /// Path label shown below workspace name in sidebar. #[allow(dead_code)] path_label: gtk::Label, + /// The workspace indicator pill in the top bar. + indicator_button: gtk::Button, + /// The unread dot inside the indicator pill. + indicator_unread_dot: gtk::Label, } -struct AppState { +pub(crate) struct AppState { + app: adw::Application, + window: adw::ApplicationWindow, + top_bar: Option, + top_bar_content: Option, + top_bar_minimize_btn: Option, + top_bar_maximize_btn: Option, + top_bar_close_btn: Option, + top_bar_sidebar_toggle: Option, + top_bar_new_ws_btn_ref: Option, + top_bar_settings_btn: Option, + sidebar_box: gtk::Box, + sidebar_header: gtk::Box, + sidebar_header_handle: gtk::WindowHandle, + sidebar_drag_area: gtk::Box, + top_bar_visible: bool, + config: Rc>, + system_prefers_dark: Rc>>, workspaces: Vec, active_idx: usize, + shortcuts: Rc, stack: gtk::Stack, sidebar_list: gtk::ListBox, paned: gtk::Paned, new_ws_btn: gtk::Button, - expand_btn: gtk::Button, + indicator_box: gtk::Box, sidebar_animation: Option, sidebar_animation_epoch: u64, sidebar_expanded_width: i32, persistence_suspended: bool, save_queued: bool, + workspace_dragging: Option, + _theme_portal_signal: Option, + _theme_gnome_settings: Option, + _theme_gnome_signal: Option, } impl AppState { fn active_workspace(&self) -> Option<&Workspace> { self.workspaces.get(self.active_idx) } + + fn workspace_for_widget(&self, widget: >k::Widget) -> Option<&Workspace> { + self.workspaces + .iter() + .find(|workspace| widget.is_ancestor(&workspace.root)) + } +} + +fn workspace_ref(id: &str) -> String { + format!("workspace:{id}") +} + +fn surface_ref(id: &str) -> String { + format!("surface:{id}") +} + +fn normalize_workspace_handle(raw: &str) -> &str { + raw.trim() + .strip_prefix("workspace:") + .unwrap_or_else(|| raw.trim()) +} + +fn workspace_index_for_target(state: &AppState, target: &WorkspaceTarget) -> Option { + match target { + WorkspaceTarget::Active => (!state.workspaces.is_empty()).then_some(state.active_idx), + WorkspaceTarget::Handle(handle) => { + let normalized = normalize_workspace_handle(handle); + state + .workspaces + .iter() + .position(|workspace| workspace.id == normalized) + } + WorkspaceTarget::Name(name) => state + .workspaces + .iter() + .position(|workspace| workspace.name == *name), + WorkspaceTarget::Index(index) => (*index < state.workspaces.len()).then_some(*index), + } +} + +fn workspace_row(index: usize, selected_idx: usize, workspace: &Workspace) -> serde_json::Value { + let cwd = workspace.cwd.borrow().clone().unwrap_or_default(); + serde_json::json!({ + "index": index, + "id": workspace.id.as_str(), + "ref": workspace_ref(&workspace.id), + "workspace_id": workspace.id.as_str(), + "workspace_ref": workspace_ref(&workspace.id), + "title": workspace.name.as_str(), + "name": workspace.name.as_str(), + "selected": index == selected_idx, + "focused": index == selected_idx, + "cwd": cwd, + }) +} + +fn workspace_payload(state: &AppState, index: usize) -> Option { + let workspace = state.workspaces.get(index)?; + Some(serde_json::json!({ + "workspace_id": workspace.id.as_str(), + "workspace_ref": workspace_ref(&workspace.id), + "workspace": workspace_row(index, state.active_idx, workspace), + "title": workspace.name.as_str(), + "name": workspace.name.as_str(), + })) +} + +#[derive(Clone)] +struct WorkspaceSeedSource { + workspace_cwd: Option, + workspace_folder_path: Option, +} + +#[derive(Clone)] +struct TabDragWorkspaceSeed { + name: String, + cwd: Option, + folder_path: Option, } -type State = Rc>; +pub(crate) type State = Rc>; +thread_local! { + static CONTROL_STATE: RefCell> = const { RefCell::new(None) }; +} const SPLIT_RATIO_STATE_KEY: &str = "limux-split-ratio-state"; +const PORTAL_DESKTOP_SERVICE: &str = "org.freedesktop.portal.Desktop"; +const PORTAL_DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop"; +const PORTAL_SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings"; +const PORTAL_APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance"; +const PORTAL_COLOR_SCHEME_KEY: &str = "color-scheme"; +const GNOME_INTERFACE_SCHEMA: &str = "org.gnome.desktop.interface"; +const GNOME_COLOR_SCHEME_KEY: &str = "color-scheme"; +const PORTAL_THEME_READ_TIMEOUT_MS: i32 = 500; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum PortalColorSchemePreference { + #[default] + Unknown, + Default, + Dark, + Light, +} -fn request_session_save(state: &State) { - let should_schedule = { - let mut s = state.borrow_mut(); - if s.persistence_suspended || s.save_queued { - false - } else { - s.save_queued = true; - true +impl PortalColorSchemePreference { + fn from_raw(raw: u32) -> Option { + match raw { + 0 => Some(Self::Default), + 1 => Some(Self::Dark), + 2 => Some(Self::Light), + _ => None, } + } + + fn resolved(self, gnome_prefers_dark: Option) -> Option { + match self { + Self::Dark => Some(true), + Self::Light => Some(false), + Self::Default | Self::Unknown => gnome_prefers_dark, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SessionSaveRequest { + Ignore, + RetryOnIdle, + FlushOnIdle, +} + +trait SessionSaveAccess { + fn persistence_suspended(&self) -> bool; + fn save_queued(&self) -> bool; + fn set_save_queued(&mut self, queued: bool); +} + +impl SessionSaveAccess for AppState { + fn persistence_suspended(&self) -> bool { + self.persistence_suspended + } + + fn save_queued(&self) -> bool { + self.save_queued + } + + fn set_save_queued(&mut self, queued: bool) { + self.save_queued = queued; + } +} + +fn queue_session_save_request(state: &Rc>) -> SessionSaveRequest { + let Ok(mut s) = state.try_borrow_mut() else { + return SessionSaveRequest::RetryOnIdle; }; - if !should_schedule { - return; + if s.persistence_suspended() || s.save_queued() { + SessionSaveRequest::Ignore + } else { + s.set_save_queued(true); + SessionSaveRequest::FlushOnIdle } +} - let state = state.clone(); - glib::idle_add_local_once(move || { - let should_save = { - let mut s = state.borrow_mut(); - let should_save = s.save_queued && !s.persistence_suspended; - s.save_queued = false; - should_save - }; - if should_save { - save_session_now(&state); +fn request_session_save(state: &State) { + match queue_session_save_request(state) { + SessionSaveRequest::Ignore => {} + SessionSaveRequest::RetryOnIdle => { + let state = state.clone(); + glib::idle_add_local_once(move || { + request_session_save(&state); + }); } - }); + SessionSaveRequest::FlushOnIdle => { + let state = state.clone(); + glib::idle_add_local_once(move || { + let should_save = { + let mut s = state.borrow_mut(); + let should_save = s.save_queued && !s.persistence_suspended; + s.save_queued = false; + should_save + }; + if should_save { + save_session_now(&state); + } + }); + } + } } fn save_session_now(state: &State) { @@ -111,6 +303,8 @@ fn suspend_persistence(state: &State, suspended: bool) { fn apply_loaded_session(state: &State, loaded: LoadedSession) { suspend_persistence(state, true); + apply_top_bar_state_immediately(state, loaded.state.top_bar_visible); + let restored_any = !loaded.state.workspaces.is_empty(); if restored_any { for workspace in &loaded.state.workspaces { @@ -125,6 +319,16 @@ fn apply_loaded_session(state: &State, loaded: LoadedSession) { if restored_any || matches!(loaded.source, layout_state::SessionLoadSource::Legacy) { save_session_now(state); } + + // Defer one more apply until after the window is mapped, so the leading + // pane's widget tree is fully realized when we go to park the dock + // toggle on it. + { + let state = state.clone(); + glib::idle_add_local_once(move || { + apply_top_bar_mode(&state); + }); + } } fn restore_active_workspace(state: &State, index: usize) { @@ -149,32 +353,33 @@ fn restore_active_workspace(state: &State, index: usize) { } fn apply_sidebar_state_immediately(state: &State, sidebar_state: &layout_state::SidebarState) { - let (paned, expand_btn, sidebar, width) = { + let (paned, sidebar, width) = { let mut s = state.borrow_mut(); s.sidebar_expanded_width = sidebar_state.width.max(SIDEBAR_WIDTH); let sidebar = match s.paned.start_child() { Some(sidebar) => sidebar, None => return, }; - ( - s.paned.clone(), - s.expand_btn.clone(), - sidebar, - s.sidebar_expanded_width, - ) + (s.paned.clone(), sidebar, s.sidebar_expanded_width) }; if sidebar_state.visible { sidebar.set_visible(true); paned.set_position(width); - expand_btn.set_visible(false); } else { // Apply restored sidebar visibility directly; using the animated toggle path during // startup would create flicker and extra persistence churn while restore is suspended. sidebar.set_visible(false); paned.set_position(0); - expand_btn.set_visible(true); } + // Re-run the top-bar mode now that sidebar visibility has been restored, + // so the dock toggle / controls land in the right place on startup. + apply_top_bar_mode(state); +} + +fn apply_top_bar_state_immediately(state: &State, visible: bool) { + state.borrow_mut().top_bar_visible = visible; + sync_top_bar_visibility(state); } fn snapshot_session_state(state: &State) -> AppSessionState { @@ -199,7 +404,10 @@ fn snapshot_session_state(state: &State) -> AppSessionState { favorite: workspace.favorite, cwd, folder_path, - layout: snapshot_layout_node(&workspace.root, working_directory.as_deref()), + layout: workspace + .split_container + .tree() + .snapshot(working_directory.as_deref()), } }) .collect(); @@ -207,6 +415,7 @@ fn snapshot_session_state(state: &State) -> AppSessionState { layout_state::normalize_session(AppSessionState { version: layout_state::SESSION_VERSION, active_workspace_index: s.active_idx, + top_bar_visible: s.top_bar_visible, sidebar: layout_state::SidebarState { visible: sidebar_visible, width: sidebar_width, @@ -223,6 +432,27 @@ fn sidebar_is_visible(state: &AppState) -> bool { .unwrap_or(false) } +fn begin_window_move_from_widget( + widget: &impl IsA, + window: &adw::ApplicationWindow, + device: >k::gdk::Device, + button: i32, + x: f64, + y: f64, + timestamp: u32, +) { + let Some((surface_x, surface_y)) = widget.translate_coordinates(window, x, y) else { + return; + }; + let Some(surface) = window.surface() else { + return; + }; + let Ok(toplevel) = surface.dynamic_cast::() else { + return; + }; + toplevel.begin_move(device, button, surface_x, surface_y, timestamp); +} + fn split_ratio_state(paned: >k::Paned) -> Option>> { unsafe { paned @@ -231,7 +461,7 @@ fn split_ratio_state(paned: >k::Paned) -> Option>> { } } -fn update_split_ratio_state(paned: >k::Paned, ratio: f64) { +pub(crate) fn update_split_ratio_state(paned: >k::Paned, ratio: f64) { let ratio = layout_state::clamp_split_ratio(ratio); if let Some(stored_ratio) = split_ratio_state(paned) { *stored_ratio.borrow_mut() = ratio; @@ -242,119 +472,98 @@ fn update_split_ratio_state(paned: >k::Paned, ratio: f64) { } } -fn snapshot_layout_node(widget: >k::Widget, working_directory: Option<&str>) -> LayoutNodeState { - if let Some(paned) = widget.downcast_ref::() { - let size = if paned.orientation() == gtk::Orientation::Horizontal { - paned.allocation().width() - } else { - paned.allocation().height() - }; - let ratio = layout_state::snapshot_split_ratio( - paned.position(), - size, - split_ratio_state(paned).map(|ratio| *ratio.borrow()), - ); - update_split_ratio_state(paned, ratio); - let start = paned - .start_child() - .map(|child| snapshot_layout_node(&child, working_directory)) - .unwrap_or_else(|| LayoutNodeState::Pane(PaneState::fallback(working_directory))); - let end = paned - .end_child() - .map(|child| snapshot_layout_node(&child, working_directory)) - .unwrap_or_else(|| LayoutNodeState::Pane(PaneState::fallback(working_directory))); - return LayoutNodeState::Split(SplitState { - orientation: if paned.orientation() == gtk::Orientation::Horizontal { - SplitOrientation::Horizontal - } else { - SplitOrientation::Vertical - }, - ratio, - start: Box::new(start), - end: Box::new(end), - }); - } - - pane::snapshot_pane_state(widget) - .map(LayoutNodeState::Pane) - .unwrap_or_else(|| LayoutNodeState::Pane(PaneState::fallback(working_directory))) -} - fn build_workspace_root( state: &State, + shortcuts: &Rc, ws_id: &str, working_directory: Option<&str>, - layout: Option<&LayoutNodeState>, -) -> gtk::Widget { - match layout { - Some(layout) => build_layout_widget(state, ws_id, working_directory, layout), - None => create_pane_for_workspace(state, ws_id, working_directory, None).upcast(), - } + layout: &LayoutNodeState, +) -> (gtk::Widget, Rc) { + let tree_node = split_tree::build_split_node_from_layout( + state, + shortcuts, + ws_id, + working_directory, + layout, + ); + let container = SplitTreeContainer::new_from_tree(state, tree_node); + let root = container.widget().clone().upcast::(); + (root, container) } -fn build_layout_widget( - state: &State, - ws_id: &str, - working_directory: Option<&str>, - layout: &LayoutNodeState, -) -> gtk::Widget { - match layout { - LayoutNodeState::Pane(pane_state) => { - create_pane_for_workspace(state, ws_id, working_directory, Some(pane_state)).upcast() - } - LayoutNodeState::Split(split_state) => { - let orientation = match split_state.orientation { - SplitOrientation::Horizontal => gtk::Orientation::Horizontal, - SplitOrientation::Vertical => gtk::Orientation::Vertical, - }; - let paned = gtk::Paned::builder() - .orientation(orientation) - .hexpand(true) - .vexpand(true) - .build(); - update_split_ratio_state(&paned, split_state.ratio); - attach_split_position_persistence(state, &paned); - let start = build_layout_widget(state, ws_id, working_directory, &split_state.start); - let end = build_layout_widget(state, ws_id, working_directory, &split_state.end); - paned.set_start_child(Some(&start)); - paned.set_end_child(Some(&end)); - apply_split_ratio_after_layout(&paned, orientation, split_state.ratio); - paned.upcast() - } +pub(crate) fn apply_ratio_value( + paned: >k::Paned, + orientation: gtk::Orientation, + ratio: f64, + applying: &Rc>, +) -> bool { + let ratio = layout_state::clamp_split_ratio(ratio); + let allocation = paned.allocation(); + let size = if orientation == gtk::Orientation::Horizontal { + allocation.width() + } else { + allocation.height() + }; + if size <= 0 { + return false; } + applying.set(true); + paned.set_position(layout_state::split_position_from_ratio(ratio, size)); + update_split_ratio_state(paned, ratio); + applying.set(false); + true } -fn apply_split_ratio_after_layout(paned: >k::Paned, orientation: gtk::Orientation, ratio: f64) { - let ratio = layout_state::clamp_split_ratio(ratio); - let apply_ratio = move |paned: >k::Paned| { - let allocation = paned.allocation(); +pub(crate) fn apply_split_ratio_after_layout( + paned: >k::Paned, + orientation: gtk::Orientation, + ratio_cell: Rc>, + applying: Rc>, +) { + // Capture the ratio by value for the initial retry loop so that early + // position_notify events (which may corrupt the cell) don't affect it. + let initial_ratio = *ratio_cell.borrow(); + + // GTK doesn't expose a reliable "allocation done" signal on GtkWidget. + // Poll via add_tick_callback until the paned actually has a non-zero + // width, then apply the ratio once and stop. + let paned_tick = paned.clone(); + let applying_tick = applying.clone(); + let applied = Rc::new(Cell::new(false)); + paned.add_tick_callback(move |paned, _clock| { + if applied.get() { + return glib::ControlFlow::Break; + } let size = if orientation == gtk::Orientation::Horizontal { - allocation.width() + paned.width() } else { - allocation.height() + paned.height() }; if size <= 0 { - return false; + return glib::ControlFlow::Continue; + } + let ok = apply_ratio_value(&paned_tick, orientation, initial_ratio, &applying_tick); + if ok { + applied.set(true); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue } - paned.set_position(layout_state::split_position_from_ratio(ratio, size)); - update_split_ratio_state(paned, ratio); - true - }; - - let paned_for_idle = paned.clone(); - glib::idle_add_local_once(move || { - let _ = apply_ratio(&paned_for_idle); }); let paned_for_map = paned.clone(); - // Hidden workspaces may not have a real allocation during initial restore, so retry when the - // split is actually mapped instead of collapsing the divider to an arbitrary fallback pixel. + // Re-apply the current data model ratio on every map event (workspace switches). + // Reads from the cell so drag-adjusted ratios are restored correctly. paned.connect_map(move |_| { - let _ = apply_ratio(&paned_for_map); + let ratio = *ratio_cell.borrow(); + apply_ratio_value(&paned_for_map, orientation, ratio, &applying); }); + // Note: width/height change handling (for sidebar toggles and window + // resizes) lives on the paned in split_tree.rs, where it has direct + // access to the shared ratio cell and the position-notify guard state. } -fn attach_split_position_persistence(state: &State, paned: >k::Paned) { +pub(crate) fn attach_split_position_persistence(state: &State, paned: >k::Paned) { update_split_ratio_state(paned, layout_state::DEFAULT_SPLIT_RATIO); let state = state.clone(); paned.connect_position_notify(move |paned| { @@ -378,38 +587,217 @@ fn attach_split_position_persistence(state: &State, paned: >k::Paned) { // CSS // --------------------------------------------------------------------------- -const CSS: &str = r#" -.limux-sidebar { - background-color: rgba(25, 25, 25, 1); +const HOST_ENTRY_CSS_CLASS: &str = "limux-host-entry"; +const WORKSPACE_RENAME_ENTRY_CSS_CLASS: &str = "limux-ws-rename-entry"; +const WORKSPACE_RENAME_ENTRY_CSS_CLASSES: [&str; 2] = + [HOST_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASS]; + +const BASE_CSS: &str = r#" +:root { + --limux-host-entry-bg: rgba(255, 255, 255, 0.98); + --limux-host-entry-fg: rgba(15, 23, 42, 0.96); + --limux-host-entry-border: rgba(15, 23, 42, 0.16); + --limux-host-entry-border-focus: rgba(0, 145, 255, 0.72); + --limux-host-entry-placeholder: rgba(15, 23, 42, 0.5); } -.limux-sidebar-row-box { - padding: 8px 6px 8px 3px; +@media (prefers-color-scheme: dark) { + :root { + --limux-host-entry-bg: rgba(44, 44, 48, 0.98); + --limux-host-entry-fg: rgba(255, 255, 255, 0.96); + --limux-host-entry-border: rgba(255, 255, 255, 0.14); + --limux-host-entry-border-focus: rgba(0, 145, 255, 0.78); + --limux-host-entry-placeholder: rgba(255, 255, 255, 0.48); + } +} +.limux-host-entry { + background-color: var(--limux-host-entry-bg); + color: var(--limux-host-entry-fg); + border: 1px solid var(--limux-host-entry-border); border-radius: 6px; - margin: 2px 3px 2px 1px; + caret-color: currentColor; } -.limux-ws-name { - color: rgba(255, 255, 255, 0.7); - font-size: 15px; +.limux-host-entry:focus-within { + border-color: var(--limux-host-entry-border-focus); } -row:selected .limux-ws-name { - color: white; +.limux-host-entry text { + background-color: transparent; + color: var(--limux-host-entry-fg); } -.limux-ws-star-btn { - color: rgba(255, 255, 255, 0.45); +.limux-host-entry text placeholder { + color: var(--limux-host-entry-placeholder); +} +.limux-host-entry image { + color: var(--limux-host-entry-placeholder); +} + +/* ---------- Top bar (matches pane header height/typography) ---------- */ +.limux-top-bar { + background-color: @window_bg_color; + border-bottom: 1px solid alpha(@window_fg_color, 0.08); + min-height: 30px; + padding: 0 4px; +} +.limux-top-bar-btn { + background: none; + border: none; + border-radius: 6px; + padding: 4px; + min-height: 0; + min-width: 0; + margin: 0 1px; + color: alpha(@window_fg_color, 0.4); +} +.limux-top-bar-btn:hover { + background: alpha(@window_fg_color, 0.08); + color: alpha(@window_fg_color, 0.8); +} +.limux-top-bar-close { + border-radius: 8px; + margin: 0 2px 0 1px; +} +.limux-top-bar-close:hover { + background: alpha(#e81123, 0.85); + color: #ffffff; +} +.limux-indicator-box { + margin: 0 4px; +} +.limux-indicator-pill { + background: transparent; + color: alpha(@window_fg_color, 0.5); border: none; + border-radius: 4px; + padding: 2px 10px; min-height: 0; min-width: 0; + font-size: 12px; + font-weight: 500; + transition: all 120ms ease; +} +.limux-indicator-pill:hover { + background: alpha(@window_fg_color, 0.06); + color: alpha(@window_fg_color, 0.75); +} +.limux-indicator-pill-active { + background: alpha(@window_fg_color, 0.1); + color: @window_fg_color; + font-weight: 600; +} +.limux-indicator-pill-active:hover { + background: alpha(@window_fg_color, 0.14); +} +.limux-indicator-pill-unread { + color: @window_fg_color; + font-weight: 600; +} +.limux-indicator-unread-dot { + color: @accent_bg_color; + font-size: 7px; + margin-right: 4px; +} +.limux-indicator-unread-dot-hidden { + font-size: 7px; + margin-right: 0; + min-width: 0; +} + +/* ---------- Sidebar ---------- */ +.limux-sidebar { + background-color: @window_bg_color; + color: @window_fg_color; + border-right: 1px solid alpha(@window_fg_color, 0.06); +} +.limux-sidebar-header { padding: 0 4px; - font-size: 22px; + min-height: 30px; +} +.limux-sidebar-list { + background: transparent; + /* Make the gap above the first row match the visible gap between rows. + Adwaita's row adds its own vertical padding; give the first row the + same leading-space by adding an extra margin-top on it. */ +} +.limux-sidebar-list row:first-child .limux-sidebar-row-box { + margin-top: 4px; +} +/* Strip default ListBox row selection styling; we paint the inner row box instead. */ +.limux-sidebar-list row, +.limux-sidebar-list row:selected, +.limux-sidebar-list row:selected:hover, +.limux-sidebar-list row:focus, +.limux-sidebar-list row:focus:focus-visible { + background: transparent; + box-shadow: none; + outline: none; +} +.limux-sidebar-row-box { + padding: 8px 10px 8px 10px; + border-radius: 8px; + margin: 1px 6px; +} +.limux-sidebar-list row:hover .limux-sidebar-row-box { + background: alpha(@window_fg_color, 0.05); +} +.limux-sidebar-list row:selected .limux-sidebar-row-box { + background: alpha(@accent_bg_color, 0.14); +} +.limux-ws-name { + color: alpha(@window_fg_color, 0.65); + font-size: 13px; + font-weight: 500; +} +.limux-sidebar-list row:selected .limux-ws-name { + color: @window_fg_color; + font-weight: 600; +} +.limux-ws-star-btn { + background: transparent; + color: alpha(@window_fg_color, 0.3); + border: none; + border-radius: 4px; + min-height: 20px; + min-width: 20px; + padding: 0; + font-size: 12px; + opacity: 0; + transition: opacity 150ms ease; +} +.limux-sidebar-list row:hover .limux-ws-star-btn, +.limux-sidebar-list row:selected .limux-ws-star-btn { + opacity: 1; } .limux-ws-star-btn:hover { - color: rgba(255, 255, 255, 0.9); + color: alpha(@window_fg_color, 0.9); } -row:selected .limux-ws-star-btn { - color: rgba(255, 255, 255, 0.85); +.limux-sidebar-list row:selected .limux-ws-star-btn { + color: alpha(@window_fg_color, 0.6); } .limux-ws-star-btn-active { - color: #f7c948; + color: @accent_bg_color; + opacity: 1; +} + +/* Workspace row close X — visible on hover/selected */ +.limux-ws-close-btn { + background: transparent; + color: alpha(@window_fg_color, 0.35); + border: none; + border-radius: 4px; + min-height: 20px; + min-width: 20px; + padding: 0; + margin: 0; + opacity: 0; + -gtk-icon-size: 12px; + transition: opacity 150ms ease; +} +.limux-sidebar-list row:hover .limux-ws-close-btn, +.limux-sidebar-list row:selected .limux-ws-close-btn { + opacity: 1; +} +.limux-ws-close-btn:hover { + background: alpha(@window_fg_color, 0.1); + color: @window_fg_color; } .limux-ws-rename-entry { min-height: 0; @@ -417,135 +805,147 @@ row:selected .limux-ws-star-btn { margin: 0; } .limux-notify-dot { - color: #0091FF; - font-size: 10px; + color: @accent_bg_color; + font-size: 8px; margin-right: 6px; } .limux-notify-dot-hidden { color: transparent; - font-size: 10px; + font-size: 8px; margin-right: 6px; } .limux-notify-msg { - color: rgba(255, 255, 255, 0.35); + color: alpha(@window_fg_color, 0.3); font-size: 11px; } .limux-notify-msg-unread { - color: rgba(0, 145, 255, 0.8); + color: alpha(@accent_bg_color, 0.85); font-size: 11px; } .limux-sidebar-row-unread { - background-color: rgba(0, 145, 255, 0.18); - border-left: 3px solid #0091FF; - border-radius: 6px; - margin-left: 0; - margin-right: 0; + background-color: alpha(@accent_bg_color, 0.1); + border-left: 3px solid @accent_bg_color; + border-radius: 8px; + margin-left: 3px; } .limux-sidebar-row-unread .limux-ws-name { - color: white; - font-weight: 700; + color: @window_fg_color; + font-weight: 600; } .limux-drop-above .limux-sidebar-row-box { - border-top: 2px solid #0091FF; - border-top-left-radius: 0; - border-top-right-radius: 0; - padding-top: 4px; + border-radius: 0; + box-shadow: 0 -2px 0 0 @accent_bg_color; } .limux-drop-below .limux-sidebar-row-box { - border-bottom: 2px solid #0091FF; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - padding-bottom: 4px; + border-radius: 0; + box-shadow: 0 2px 0 0 @accent_bg_color; } -.limux-sidebar-title { - color: rgba(255, 255, 255, 0.5); - font-size: 11px; - font-weight: 600; - letter-spacing: 1px; +.limux-tab-drop-target { + background-color: alpha(@accent_bg_color, 0.18); + border-radius: 8px; +} +.limux-sidebar row:drop(active) { + box-shadow: none; } .limux-sidebar-btn { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.7); + background: alpha(@window_fg_color, 0.06); + color: alpha(@window_fg_color, 0.5); border: 1px solid transparent; - border-radius: 6px; + border-radius: 8px; padding: 6px 12px; min-height: 0; + font-size: 18px; transition: all 200ms ease; } .limux-sidebar-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: white; + background: alpha(@window_fg_color, 0.1); + color: alpha(@window_fg_color, 0.8); } .limux-sidebar-btn-trash { - background: rgba(255, 60, 60, 0.25); - color: rgba(255, 80, 80, 1); - border: 1px solid rgba(255, 80, 80, 0.5); + background: alpha(@error_color, 0.16); + color: @error_color; + border: 1px solid alpha(@error_color, 0.4); } .limux-sidebar-btn-trash-hover { - background: rgba(255, 60, 60, 0.45); - color: rgba(255, 90, 90, 1); - border: 1px solid rgba(255, 80, 80, 0.8); -} -.limux-ws-path { - color: rgba(255, 255, 255, 0.3); - font-size: 12px; + background: alpha(@error_color, 0.26); + color: @error_color; + border: 1px solid alpha(@error_color, 0.7); } -row:selected .limux-ws-path { - color: rgba(255, 255, 255, 0.5); +.limux-tab-drag-active { + background-color: alpha(@accent_bg_color, 0.12); + border-width: 1px; + border-style: dashed; + border-color: alpha(@accent_bg_color, 0.6); + border-radius: 8px; } -.limux-sidebar-collapse { - color: rgba(255, 255, 255, 0.4); - border: none; - min-height: 0; - min-width: 0; - padding: 0 6px; - font-size: 14px; -} -.limux-sidebar-collapse:hover { - color: rgba(255, 255, 255, 0.9); +.limux-sidebar-btn.limux-tab-drop-target { + background-color: alpha(@accent_bg_color, 0.28); + border-color: alpha(@accent_bg_color, 0.9); } -.limux-sidebar-expand { - background-color: rgba(25, 25, 25, 1); - color: rgba(255, 255, 255, 0.5); - border: none; - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; - min-width: 0; - padding: 8px 4px; - font-size: 13px; +.limux-ws-path { + color: alpha(@window_fg_color, 0.3); + font-size: 11px; } -.limux-sidebar-expand:hover { - background-color: rgba(40, 40, 40, 1); - color: white; +.limux-sidebar-list row:selected .limux-ws-path { + color: alpha(@window_fg_color, 0.45); } .limux-content { - background-color: rgba(23, 23, 23, 1); + background-color: @window_bg_color; } "#; +const CONTENT_BACKGROUND_RGB: (u8, u8, u8) = (23, 23, 23); + // --------------------------------------------------------------------------- // Window construction // --------------------------------------------------------------------------- pub fn build_window(app: &adw::Application) { + let display = gtk::gdk::Display::default().expect("display"); + let gnome_interface_settings = gnome_interface_settings(); + let portal_color_scheme_preference = Rc::new(Cell::new(PortalColorSchemePreference::Unknown)); + let system_prefers_dark = Rc::new(Cell::new(resolve_system_prefers_dark( + portal_color_scheme_preference.get(), + gnome_interface_settings.as_ref(), + ))); + let loaded_config = app_config::load(); + for warning in &loaded_config.warnings { + eprintln!("limux: {warning}"); + } + let config = Rc::new(RefCell::new(loaded_config.config)); + let background_opacity = + sanitize_background_opacity(crate::terminal::ghostty_background_opacity()); + + let shortcuts = Rc::new(shortcut_config::load_shortcuts_for_display(&display)); + for warning in &shortcuts.warnings { + eprintln!("limux: {warning}"); + } + // Load CSS let provider = gtk::CssProvider::new(); - let all_css = format!("{CSS}\n{}", pane::PANE_CSS); + let all_css = format!( + "{}\n{}\n{}\n{}", + build_window_css(background_opacity), + pane::PANE_CSS, + keybind_editor::KEYBIND_EDITOR_CSS, + crate::settings_editor::SETTINGS_CSS, + ); provider.load_from_data(&all_css); gtk::style_context_add_provider_for_display( - >k::gdk::Display::default().expect("display"), + &display, &provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, ); let style_manager = adw::StyleManager::default(); - crate::terminal::sync_color_scheme(style_manager.is_dark()); - style_manager.connect_dark_notify(|style_manager| { - crate::terminal::sync_color_scheme(style_manager.is_dark()); - }); + apply_appearance( + &style_manager, + system_prefers_dark.get(), + &config.borrow().appearance, + ); // Register custom icons — look for icons dir relative to the executable - let icon_theme = gtk::IconTheme::for_display(>k::gdk::Display::default().expect("display")); + let icon_theme = gtk::IconTheme::for_display(&display); let exe_dir = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.to_path_buf())); @@ -575,22 +975,112 @@ pub fn build_window(app: &adw::Application) { .default_width(1400) .default_height(900) .build(); + apply_window_background_class(&window, background_opacity); - // On Wayland compositors with xdg-decoration support, the compositor - // already provides the window chrome, so keep Limux from rendering a - // duplicate header bar. X11 continues to use the in-app header. - let provides_decorations = gtk::gdk::Display::default() - .and_then(|display| display.downcast::().ok()) - .map(|display| display.query_registry("zxdg_decoration_manager_v1")) - .unwrap_or(false); + // Workspace indicator pill container (shared between header and state) + let indicator_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(2) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .hexpand(true) + .build(); + indicator_box.add_css_class("limux-indicator-box"); - let header = if provides_decorations { - None - } else { - let bar = adw::HeaderBar::new(); - bar.set_title_widget(Some(>k::Label::builder().label(&title).build())); - Some(bar) - }; + let top_bar_sidebar_toggle: gtk::Button; + let top_bar_new_ws_btn: gtk::Button; + let top_bar_settings_btn: gtk::Button; + + // The top bar itself is a WindowHandle so empty space drags the window, + // while child buttons (sidebar toggle, workspace pills, +) stay clickable. + let top_bar_content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + top_bar_content.add_css_class("limux-top-bar"); + + // Sidebar toggle button (leftmost) — Adwaita sidebar icon + let sidebar_toggle = gtk::Button::from_icon_name("sidebar-show-symbolic"); + sidebar_toggle.add_css_class("flat"); + sidebar_toggle.add_css_class("limux-top-bar-btn"); + sidebar_toggle.set_focus_on_click(false); + sidebar_toggle.set_valign(gtk::Align::Center); + sidebar_toggle.set_tooltip_text(Some("Toggle sidebar")); + top_bar_content.append(&sidebar_toggle); + top_bar_sidebar_toggle = sidebar_toggle; + + // Settings cog — between the dock toggle and the + button. + let settings_button = gtk::Button::from_icon_name("emblem-system-symbolic"); + settings_button.add_css_class("flat"); + settings_button.add_css_class("limux-top-bar-btn"); + settings_button.set_focus_on_click(false); + settings_button.set_valign(gtk::Align::Center); + settings_button.set_tooltip_text(Some("Settings")); + top_bar_content.append(&settings_button); + top_bar_settings_btn = settings_button; + + // New workspace button + let new_ws = gtk::Button::from_icon_name("list-add-symbolic"); + new_ws.add_css_class("flat"); + new_ws.add_css_class("limux-top-bar-btn"); + new_ws.set_focus_on_click(false); + new_ws.set_valign(gtk::Align::Center); + new_ws.set_tooltip_text(Some("New workspace")); + top_bar_content.append(&new_ws); + top_bar_new_ws_btn = new_ws; + + // Workspace indicator pills (takes the rest of the space) + top_bar_content.append(&indicator_box); + + // Window controls on the right — plain buttons styled the same as top-bar + // action buttons so hover shape matches the pane bar exactly. We skip the + // stock gtk::WindowControls widget because Adwaita forces circular 24px + // bubbles that are hard to override cleanly. + let minimize_btn = gtk::Button::from_icon_name("window-minimize-symbolic"); + minimize_btn.add_css_class("flat"); + minimize_btn.add_css_class("limux-top-bar-btn"); + minimize_btn.set_focus_on_click(false); + minimize_btn.set_valign(gtk::Align::Center); + minimize_btn.set_tooltip_text(Some("Minimize")); + top_bar_content.append(&minimize_btn); + + let maximize_btn = gtk::Button::from_icon_name("window-maximize-symbolic"); + maximize_btn.add_css_class("flat"); + maximize_btn.add_css_class("limux-top-bar-btn"); + maximize_btn.set_focus_on_click(false); + maximize_btn.set_valign(gtk::Align::Center); + maximize_btn.set_tooltip_text(Some("Maximize")); + top_bar_content.append(&maximize_btn); + + let close_btn = gtk::Button::from_icon_name("window-close-symbolic"); + close_btn.add_css_class("flat"); + close_btn.add_css_class("limux-top-bar-btn"); + close_btn.add_css_class("limux-top-bar-close"); + close_btn.set_focus_on_click(false); + close_btn.set_valign(gtk::Align::Center); + close_btn.set_tooltip_text(Some("Close")); + top_bar_content.append(&close_btn); + + { + let w = window.clone(); + minimize_btn.connect_clicked(move |_| w.minimize()); + } + { + let w = window.clone(); + maximize_btn.connect_clicked(move |_| { + if gtk::prelude::GtkWindowExt::is_maximized(&w) { + w.unmaximize(); + } else { + w.maximize(); + } + }); + } + { + let w = window.clone(); + close_btn.connect_clicked(move |_| w.close()); + } + + let header = gtk::WindowHandle::builder().child(&top_bar_content).build(); let stack = gtk::Stack::new(); stack.set_transition_type(gtk::StackTransitionType::None); @@ -600,7 +1090,7 @@ pub fn build_window(app: &adw::Application) { let sidebar_list = gtk::ListBox::new(); sidebar_list.set_selection_mode(gtk::SelectionMode::Single); - sidebar_list.add_css_class("navigation-sidebar"); + sidebar_list.add_css_class("limux-sidebar-list"); let sidebar_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) @@ -609,30 +1099,30 @@ pub fn build_window(app: &adw::Application) { .child(&sidebar_list) .build(); - let sidebar_title_label = gtk::Label::builder() - .label("WORKSPACES") - .xalign(0.0) - .hexpand(true) - .margin_start(12) - .build(); - sidebar_title_label.add_css_class("limux-sidebar-title"); - - let collapse_btn = gtk::Button::with_label("\u{00AB}"); // « - collapse_btn.add_css_class("flat"); - collapse_btn.add_css_class("limux-sidebar-collapse"); - collapse_btn.set_tooltip_text(Some("Hide sidebar (Ctrl+B)")); - - let sidebar_title = gtk::Box::builder() + // Draggable spacer at the top of the sidebar (for window move) + let sidebar_drag_area = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) - .margin_top(8) - .margin_bottom(4) - .margin_end(6) + .height_request(8) .build(); - sidebar_title.append(&sidebar_title_label); - sidebar_title.append(&collapse_btn); + { + let window = window.clone(); + let drag_area = sidebar_drag_area.clone(); + let drag = gtk::GestureClick::new(); + drag.set_button(1); + drag.connect_pressed(move |gesture, _, x, y| { + let Some(device) = gesture.current_event_device() else { + return; + }; + let button = gesture.current_button() as i32; + let timestamp = gesture.current_event_time(); + begin_window_move_from_widget(&drag_area, &window, &device, button, x, y, timestamp); + gesture.set_state(gtk::EventSequenceState::Claimed); + }); + sidebar_drag_area.add_controller(drag); + } let new_ws_btn = gtk::Button::builder() - .label("New Workspace") + .label("+") .hexpand(true) .margin_start(6) .margin_end(6) @@ -640,13 +1130,17 @@ pub fn build_window(app: &adw::Application) { .build(); new_ws_btn.add_css_class("limux-sidebar-btn"); - // Drop target on the button — intensifies when dragging over it + // Drop target on the button: workspace drags delete, tab drags create a new workspace. let btn_drop = gtk::DropTarget::new(glib::Type::STRING, gtk::gdk::DragAction::MOVE); btn_drop.set_preload(true); { let btn = new_ws_btn.clone(); btn_drop.connect_motion(move |_, _, _| { - btn.add_css_class("limux-sidebar-btn-trash-hover"); + if pane::is_tab_dragging() { + btn.add_css_class("limux-tab-drop-target"); + } else { + btn.add_css_class("limux-sidebar-btn-trash-hover"); + } gtk::gdk::DragAction::MOVE }); } @@ -654,62 +1148,143 @@ pub fn build_window(app: &adw::Application) { let btn = new_ws_btn.clone(); btn_drop.connect_leave(move |_| { btn.remove_css_class("limux-sidebar-btn-trash-hover"); + btn.remove_css_class("limux-tab-drop-target"); }); } new_ws_btn.add_controller(btn_drop.clone()); + // new_ws_btn is kept in state as the drop target for workspace/tab DnD, + // but we hide it from the sidebar — the "+" in the top bar creates + // workspaces, and closing/creating via drag lands on sidebar rows / the + // top bar add button. + new_ws_btn.set_visible(false); + + // Alternate header for the sidebar, used when the top bar is hidden. + // Populated by apply_top_bar_mode() — stays empty + invisible otherwise. + // Wrapped in a WindowHandle so empty space in the header drags the window + // (same pattern as the regular top bar). + let sidebar_header = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + sidebar_header.add_css_class("limux-sidebar-header"); + let sidebar_header_handle = gtk::WindowHandle::builder() + .child(&sidebar_header) + .visible(false) + .build(); + let sidebar = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(4) + .spacing(0) .width_request(220) .build(); sidebar.add_css_class("limux-sidebar"); - sidebar.append(&sidebar_title); + sidebar.append(&sidebar_drag_area); + sidebar.append(&sidebar_header_handle); sidebar.append(&sidebar_scroll); - sidebar.append(&new_ws_btn); let main_paned = gtk::Paned::builder() .orientation(gtk::Orientation::Horizontal) .position(220) + .resize_start_child(false) + .resize_end_child(true) .shrink_start_child(false) .shrink_end_child(false) .start_child(&sidebar) .end_child(&stack) .build(); - // Expand tab — small button on the left edge when sidebar is hidden - let expand_btn = gtk::Button::with_label("\u{00BB}"); // » - expand_btn.add_css_class("limux-sidebar-expand"); - expand_btn.set_tooltip_text(Some("Show sidebar (Ctrl+B)")); - expand_btn.set_valign(gtk::Align::Center); - expand_btn.set_halign(gtk::Align::Start); - expand_btn.set_visible(false); - - let content_overlay = gtk::Overlay::new(); - content_overlay.set_child(Some(&main_paned)); - content_overlay.add_overlay(&expand_btn); - let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0); - if let Some(ref header) = header { - vbox.append(header); - } - vbox.append(&content_overlay); + vbox.append(&header); + vbox.append(&main_paned); window.set_content(Some(&vbox)); let state: State = Rc::new(RefCell::new(AppState { + app: app.clone(), + window: window.clone(), + top_bar: Some(header.clone()), + top_bar_content: Some(top_bar_content.clone()), + top_bar_minimize_btn: Some(minimize_btn.clone()), + top_bar_maximize_btn: Some(maximize_btn.clone()), + top_bar_close_btn: Some(close_btn.clone()), + top_bar_sidebar_toggle: Some(top_bar_sidebar_toggle.clone()), + top_bar_new_ws_btn_ref: Some(top_bar_new_ws_btn.clone()), + top_bar_settings_btn: Some(top_bar_settings_btn.clone()), + sidebar_box: sidebar.clone(), + sidebar_header: sidebar_header.clone(), + sidebar_header_handle: sidebar_header_handle.clone(), + sidebar_drag_area: sidebar_drag_area.clone(), + top_bar_visible: true, + config, + system_prefers_dark: system_prefers_dark.clone(), workspaces: Vec::new(), active_idx: 0, + shortcuts, stack: stack.clone(), + indicator_box: indicator_box.clone(), sidebar_list: sidebar_list.clone(), paned: main_paned.clone(), new_ws_btn: new_ws_btn.clone(), - expand_btn: expand_btn.clone(), sidebar_animation: None, sidebar_animation_epoch: 0, sidebar_expanded_width: SIDEBAR_WIDTH, persistence_suspended: false, save_queued: false, + workspace_dragging: None, + _theme_portal_signal: None, + _theme_gnome_settings: None, + _theme_gnome_signal: None, })); + CONTROL_STATE.with(|slot| { + *slot.borrow_mut() = Some(state.clone()); + }); + + { + let state = state.clone(); + let system_prefers_dark = system_prefers_dark.clone(); + style_manager.connect_dark_notify(move |style_manager| { + sync_ghostty_color_scheme_for_config( + style_manager, + system_prefers_dark.get(), + &state.borrow().config.borrow().appearance, + ); + }); + } + + let theme_gnome_signal = gnome_interface_settings.as_ref().map(|settings| { + connect_gnome_appearance_watch( + settings, + state.clone(), + style_manager.clone(), + system_prefers_dark.clone(), + portal_color_scheme_preference.clone(), + ) + }); + { + let mut s = state.borrow_mut(); + s._theme_gnome_settings = gnome_interface_settings.clone(); + s._theme_gnome_signal = theme_gnome_signal; + } + connect_portal_appearance_watch_async( + gnome_interface_settings.clone(), + state.clone(), + style_manager.clone(), + system_prefers_dark.clone(), + portal_color_scheme_preference.clone(), + ); + + apply_shortcuts_to_application(app, &state.borrow().shortcuts); + + { + let state = state.clone(); + window.connect_fullscreened_notify(move |_| { + sync_top_bar_visibility(&state); + }); + } + + // Apply the initial top-bar layout (controls side, sidebar-header mode, + // pane leading slot) based on the loaded config. + apply_top_bar_mode(&state); { let state = state.clone(); @@ -729,265 +1304,1248 @@ pub fn build_window(app: &adw::Application) { }); } - // Wire collapse button - { - let state = state.clone(); - collapse_btn.connect_clicked(move |_| { - toggle_sidebar(&state); - }); + register_app_actions(app, &state); + register_window_actions(&window, &state); + install_key_capture(&window, &state); + + // Any click anywhere in the window commits an active sidebar rename, + // UNLESS the click is inside the rename Entry itself. + { + let sl = sidebar_list.clone(); + let win = window.clone(); + let click_anywhere = gtk::GestureClick::new(); + click_anywhere.set_propagation_phase(gtk::PropagationPhase::Capture); + click_anywhere.connect_pressed(move |_, _, x, y| { + if let Some(entry) = find_active_rename_entry(&sl) { + // Translate click coords from window to the entry's coordinate space + if let Some((ex, ey)) = win.translate_coordinates(&entry, x, y) { + let alloc = entry.allocation(); + if ex >= 0.0 + && ey >= 0.0 + && ex <= alloc.width() as f64 + && ey <= alloc.height() as f64 + { + return; // click is inside the entry + } + } + commit_any_active_rename(&sl); + } + }); + window.add_controller(click_anywhere); + } + + { + let state = state.clone(); + sidebar_list.connect_row_selected(move |_, row| { + if let Some(row) = row { + let idx = row.index() as usize; + switch_workspace(&state, idx); + } + }); + } + + { + let state = state.clone(); + new_ws_btn.connect_clicked(move |_| { + add_workspace(&state, None); + }); + } + + // Wire top bar sidebar toggle button + { + let state = state.clone(); + top_bar_sidebar_toggle.connect_clicked(move |_| { + toggle_sidebar(&state); + }); + } + + // Wire top bar new workspace button + { + let state = state.clone(); + top_bar_new_ws_btn.connect_clicked(move |_| { + add_workspace(&state, None); + }); + } + + // Wire top bar settings button — opens the same settings dialog the + // pane cog used to, parented on whatever widget makes sense. + { + let state = state.clone(); + top_bar_settings_btn.connect_clicked(move |_| { + open_settings_dialog(&state); + }); + } + + { + let btn = new_ws_btn.clone(); + pane::on_tab_drag_change(move |dragging| { + if dragging { + btn.add_css_class("limux-tab-drag-active"); + } else { + btn.remove_css_class("limux-tab-drag-active"); + btn.remove_css_class("limux-tab-drop-target"); + } + }); + } + + { + let state = state.clone(); + let btn = new_ws_btn.clone(); + btn_drop.connect_drop(move |_, value, _, _| { + btn.set_label("+"); + btn.remove_css_class("limux-sidebar-btn-trash"); + btn.remove_css_class("limux-sidebar-btn-trash-hover"); + btn.remove_css_class("limux-tab-drop-target"); + if let Ok(payload) = value.get::() { + if payload.contains(':') { + return create_workspace_for_tab(&state, &payload); + } + close_workspace_by_id(&state, &payload); + return true; + } + false + }); + } + + // Save the full session on window close. + { + let state = state.clone(); + window.connect_close_request(move |_| { + save_session_now(&state); + CONTROL_STATE.with(|slot| { + slot.borrow_mut().take(); + }); + glib::Propagation::Proceed + }); + } + + apply_loaded_session(&state, layout_state::load_session()); + + crate::control_bridge::start(dispatch_control_command); + + window.present(); +} + +fn build_window_css(background_opacity: f64) -> String { + let background_opacity = sanitize_background_opacity(background_opacity); + let (r, g, b) = CONTENT_BACKGROUND_RGB; + format!( + "{BASE_CSS}\n.limux-content {{\n background-color: rgba({r}, {g}, {b}, {background_opacity:.3});\n}}\n" + ) +} + +fn sanitize_background_opacity(background_opacity: f64) -> f64 { + if background_opacity.is_finite() { + background_opacity.clamp(0.0, 1.0) + } else { + 1.0 + } +} + +fn use_opaque_window_background(background_opacity: f64) -> bool { + sanitize_background_opacity(background_opacity) >= 1.0 +} + +fn apply_window_background_class(window: &adw::ApplicationWindow, background_opacity: f64) { + if use_opaque_window_background(background_opacity) { + window.add_css_class("background"); + } else { + window.remove_css_class("background"); + } +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +fn register_window_actions(window: &adw::ApplicationWindow, state: &State) { + let action_defs: Vec<(&'static str, ShortcutCommand)> = { + let s = state.borrow(); + s.shortcuts + .shortcuts + .iter() + .filter(|shortcut| shortcut.definition.action_name.starts_with("win.")) + .map(|shortcut| { + ( + shortcut.definition.action_basename(), + shortcut.definition.command, + ) + }) + .collect() + }; + + for (name, command) in action_defs { + let action = gtk::gio::SimpleAction::new(name, None); + let state = state.clone(); + action.connect_activate(move |_, _| { + dispatch_shortcut_command(&state, command); + }); + window.add_action(&action); + } +} + +fn register_app_actions(app: &adw::Application, state: &State) { + let action_defs: Vec<(&'static str, ShortcutCommand)> = { + let s = state.borrow(); + s.shortcuts + .shortcuts + .iter() + .filter(|shortcut| shortcut.definition.action_name.starts_with("app.")) + .map(|shortcut| { + ( + shortcut.definition.action_basename(), + shortcut.definition.command, + ) + }) + .collect() + }; + + for (name, command) in action_defs { + if app.lookup_action(name).is_some() { + continue; + } + let action = gtk::gio::SimpleAction::new(name, None); + let state = state.clone(); + action.connect_activate(move |_, _| { + dispatch_shortcut_command(&state, command); + }); + app.add_action(&action); + } +} + +/// Intercept keyboard shortcuts in the CAPTURE phase for window-level bindings. +fn install_key_capture(window: &adw::ApplicationWindow, state: &State) { + let key_controller = gtk::EventControllerKey::new(); + key_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + + let state = state.clone(); + key_controller.connect_key_pressed(move |controller, keyval, keycode, modifier| { + let focused_listening_editor = controller + .widget() + .and_then(|widget| widget.downcast::().ok()) + .map(|window| focused_widget_is_listening_for_keybind_capture(&window)) + .unwrap_or(false); + if focused_listening_editor { + return glib::Propagation::Proceed; + } + + let matched = { + let s = state.borrow(); + let display = controller.widget().map(|widget| widget.display()); + shortcut_match_from_key_press(&s.shortcuts, display.as_ref(), keyval, keycode, modifier) + } + .filter(|matched| { + let context = controller + .widget() + .and_then(|widget| widget.downcast::().ok()) + .map(|window| focused_editable_capture_context(&state, &window)) + .unwrap_or_default(); + !shortcut_blocked_by_editable(matched.command, matched.editable_capture_policy, context) + }) + .map(|matched| dispatch_shortcut_command(&state, matched.command)) + .unwrap_or(false); + + shortcut_dispatch_propagation(matched) + }); + + window.add_controller(key_controller); +} + +fn focused_widget_is_listening_for_keybind_capture(window: >k::Window) -> bool { + let mut widget = gtk::prelude::GtkWindowExt::focus(window); + while let Some(current) = widget { + if current.has_css_class(keybind_editor::KEYBIND_EDITOR_LISTENING_CSS) { + return true; + } + widget = current.parent(); + } + false +} + +fn focused_widget_is_editable(window: >k::Window) -> bool { + let mut widget = gtk::prelude::GtkWindowExt::focus(window); + while let Some(current) = widget { + if current.is::() + || current.is::() + || current.is::() + { + return true; + } + widget = current.parent(); + } + false +} + +fn focused_editable_capture_context(state: &State, window: >k::Window) -> EditableCaptureContext { + let gtk_editable = focused_widget_is_editable(window); + match focused_shortcut_target(state) { + pane::FocusedShortcutTarget::Browser(target) => EditableCaptureContext { + gtk_editable, + browser_dom_editable: target.is_page_editable(), + browser_find_active: target.is_find_active(), + }, + _ => EditableCaptureContext { + gtk_editable, + ..EditableCaptureContext::default() + }, + } +} + +fn shortcut_allowed_while_browser_find_active(command: ShortcutCommand) -> bool { + matches!( + command, + ShortcutCommand::SurfaceFindNext + | ShortcutCommand::SurfaceFindPrevious + | ShortcutCommand::SurfaceFindHide + ) +} + +fn shortcut_blocked_by_editable( + command: ShortcutCommand, + policy: EditableCapturePolicy, + context: EditableCaptureContext, +) -> bool { + if policy == EditableCapturePolicy::AlwaysCapture { + return false; + } + + if context.browser_find_active && shortcut_allowed_while_browser_find_active(command) { + return false; + } + + context.gtk_editable || context.browser_dom_editable +} + +fn shortcut_dispatch_propagation(matched: bool) -> glib::Propagation { + if matched { + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } +} + +#[cfg(test)] +fn shortcut_command_from_key_event( + shortcuts: &ResolvedShortcutConfig, + keyval: gtk::gdk::Key, + modifier: gtk::gdk::ModifierType, +) -> Option { + shortcut_config::NormalizedShortcut::from_gdk_key(keyval, modifier) + .map(|shortcut| shortcut.to_runtime_combo()) + .and_then(|combo| shortcuts.command_for_runtime_combo(&combo)) +} + +struct MatchedShortcut { + command: ShortcutCommand, + editable_capture_policy: EditableCapturePolicy, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct EditableCaptureContext { + gtk_editable: bool, + browser_dom_editable: bool, + browser_find_active: bool, +} + +fn shortcut_match_from_key_press( + shortcuts: &ResolvedShortcutConfig, + display: Option<>k::gdk::Display>, + keyval: gtk::gdk::Key, + keycode: u32, + modifier: gtk::gdk::ModifierType, +) -> Option { + shortcut_config::NormalizedShortcut::from_gdk_key_event(display, keyval, keycode, modifier) + .map(|shortcut| shortcut.to_runtime_combo()) + .and_then(|combo| shortcuts.shortcut_for_runtime_combo(&combo)) + .map(|shortcut| MatchedShortcut { + command: shortcut.definition.command, + editable_capture_policy: shortcut.definition.editable_capture_policy, + }) +} + +fn dispatch_shortcut_command(state: &State, command: ShortcutCommand) -> bool { + match command { + ShortcutCommand::NewWorkspace => { + add_workspace(state, None); + true + } + ShortcutCommand::CloseWorkspace => { + close_workspace(state); + true + } + ShortcutCommand::QuitApp => { + quit_app(state); + true + } + ShortcutCommand::NewInstance => spawn_new_instance(state), + ShortcutCommand::ToggleSidebar => { + toggle_sidebar(state); + true + } + ShortcutCommand::ToggleTopBar => { + toggle_top_bar(state); + true + } + ShortcutCommand::ToggleFullscreen => { + toggle_fullscreen(state); + true + } + ShortcutCommand::NextWorkspace => { + cycle_workspace(state, 1); + true + } + ShortcutCommand::PrevWorkspace => { + cycle_workspace(state, -1); + true + } + ShortcutCommand::CycleTabPrev => { + cycle_focused_pane_tab(state, -1); + true + } + ShortcutCommand::CycleTabNext => { + cycle_focused_pane_tab(state, 1); + true + } + ShortcutCommand::SplitDown => { + split_focused_pane(state, gtk::Orientation::Vertical); + true + } + ShortcutCommand::NewTerminal => { + add_tab_to_focused_pane(state, false); + true + } + ShortcutCommand::SplitRight => { + split_focused_pane(state, gtk::Orientation::Horizontal); + true + } + ShortcutCommand::CloseFocusedPane => { + close_focused_tab(state); + true + } + ShortcutCommand::FocusLeft => { + focus_pane_in_direction(state, Direction::Left); + true + } + ShortcutCommand::FocusRight => { + focus_pane_in_direction(state, Direction::Right); + true + } + ShortcutCommand::FocusUp => { + focus_pane_in_direction(state, Direction::Up); + true + } + ShortcutCommand::FocusDown => { + focus_pane_in_direction(state, Direction::Down); + true + } + ShortcutCommand::ActivateWorkspace1 => { + activate_workspace_shortcut(state, 0); + true + } + ShortcutCommand::ActivateWorkspace2 => { + activate_workspace_shortcut(state, 1); + true + } + ShortcutCommand::ActivateWorkspace3 => { + activate_workspace_shortcut(state, 2); + true + } + ShortcutCommand::ActivateWorkspace4 => { + activate_workspace_shortcut(state, 3); + true + } + ShortcutCommand::ActivateWorkspace5 => { + activate_workspace_shortcut(state, 4); + true + } + ShortcutCommand::ActivateWorkspace6 => { + activate_workspace_shortcut(state, 5); + true + } + ShortcutCommand::ActivateWorkspace7 => { + activate_workspace_shortcut(state, 6); + true + } + ShortcutCommand::ActivateWorkspace8 => { + activate_workspace_shortcut(state, 7); + true + } + ShortcutCommand::ActivateLastWorkspace => { + activate_last_workspace_shortcut(state); + true + } + ShortcutCommand::OpenBrowserInSplit + | ShortcutCommand::BrowserFocusLocation + | ShortcutCommand::BrowserBack + | ShortcutCommand::BrowserForward + | ShortcutCommand::BrowserReload + | ShortcutCommand::BrowserInspector + | ShortcutCommand::BrowserConsole => dispatch_browser_command(state, command), + ShortcutCommand::SurfaceFind + | ShortcutCommand::SurfaceFindNext + | ShortcutCommand::SurfaceFindPrevious + | ShortcutCommand::SurfaceFindHide + | ShortcutCommand::SurfaceUseSelectionForFind => { + dispatch_terminal_command(state, command) || dispatch_browser_command(state, command) + } + ShortcutCommand::TerminalClearScrollback + | ShortcutCommand::TerminalCopy + | ShortcutCommand::TerminalPaste + | ShortcutCommand::TerminalIncreaseFontSize + | ShortcutCommand::TerminalDecreaseFontSize + | ShortcutCommand::TerminalResetFontSize => dispatch_terminal_command(state, command), + } +} + +fn apply_shortcuts_to_application(app: &adw::Application, shortcuts: &ResolvedShortcutConfig) { + for (action_name, accels) in shortcuts.gtk_accel_entries() { + let accel_refs: Vec<&str> = accels.iter().map(String::as_str).collect(); + app.set_accels_for_action(action_name, &accel_refs); + } +} + +fn apply_shortcut_config(state: &State, shortcuts: ResolvedShortcutConfig) { + let (app, workspace_roots, shortcuts_rc) = { + let mut s = state.borrow_mut(); + s.shortcuts = Rc::new(shortcuts); + ( + s.app.clone(), + s.workspaces + .iter() + .map(|ws| ws.root.clone()) + .collect::>(), + s.shortcuts.clone(), + ) + }; + + apply_shortcuts_to_application(&app, &shortcuts_rc); + for root in workspace_roots { + refresh_shortcut_tooltips_in_layout(&root, &shortcuts_rc); + } +} + +fn refresh_shortcut_tooltips_in_layout(widget: >k::Widget, shortcuts: &ResolvedShortcutConfig) { + if let Some(paned) = widget.downcast_ref::() { + if let Some(start) = paned.start_child() { + refresh_shortcut_tooltips_in_layout(&start, shortcuts); + } + if let Some(end) = paned.end_child() { + refresh_shortcut_tooltips_in_layout(&end, shortcuts); + } + return; + } + + pane::refresh_shortcut_tooltips(widget, shortcuts); +} + +/// Open the Settings dialog from the top bar (the cog used to live on the +/// pane action row). +fn open_settings_dialog(state: &State) { + let (parent, config, shortcuts) = { + let s = state.borrow(); + ( + s.window.clone().upcast::(), + s.config.clone(), + s.shortcuts.clone(), + ) + }; + + let on_capture: Rc< + dyn Fn( + ShortcutId, + Option, + ) -> Result, + > = { + let state = state.clone(); + Rc::new(move |id, binding| persist_shortcut_binding(&state, id, binding)) + }; + + #[allow(clippy::type_complexity)] + let on_config_changed: Rc = { + let state = state.clone(); + Rc::new(move |previous, updated| { + handle_config_change(&state, previous, updated); + }) + }; + + settings_editor::present_settings_dialog( + &parent, + settings_editor::SettingsEditorInput { + config, + shortcuts, + on_capture, + on_config_changed, + }, + ); +} + +/// Apply a config change (appearance + interface side effects) and persist. +/// On save error, revert the in-memory config and re-apply the previous state. +fn handle_config_change( + state: &State, + previous: &app_config::AppConfig, + updated: &app_config::AppConfig, +) { + let style_manager = adw::StyleManager::default(); + let system_prefers_dark = state.borrow().system_prefers_dark.get(); + apply_appearance(&style_manager, system_prefers_dark, &updated.appearance); + if previous.interface.window_controls_side != updated.interface.window_controls_side + || previous.interface.show_top_bar != updated.interface.show_top_bar + || previous.interface.show_workspace_indicators + != updated.interface.show_workspace_indicators + { + apply_top_bar_mode(state); + } + if let Err(err) = app_config::save(updated) { + state.borrow().config.borrow_mut().clone_from(previous); + apply_appearance(&style_manager, system_prefers_dark, &previous.appearance); + apply_top_bar_mode(state); + + let detail = format!("Failed to save Limux settings: {err}"); + eprintln!("limux: {detail}"); + show_runtime_error(state, "Failed to save settings", &detail); + } +} + +fn persist_shortcut_binding( + state: &State, + id: ShortcutId, + binding: Option, +) -> Result { + let updated = { + let s = state.borrow(); + s.shortcuts + .with_binding(id, binding) + .map_err(|err| err.to_string())? + }; + + let Some(path) = shortcut_config::shortcuts_path() else { + return Err("config directory unavailable".to_string()); + }; + + shortcut_config::write_shortcuts(&path, &updated).map_err(|err| err.to_string())?; + let display = { + let s = state.borrow(); + s.stack.display() + }; + let reloaded = shortcut_config::load_shortcuts_or_default_with_display(&path, Some(&display)); + if !reloaded.warnings.is_empty() { + return Err(reloaded.warnings.join("; ")); + } + + apply_shortcut_config(state, reloaded.clone()); + Ok(reloaded) +} + +fn adw_color_scheme_for(scheme: app_config::ColorScheme) -> adw::ColorScheme { + match scheme { + app_config::ColorScheme::System => adw::ColorScheme::Default, + app_config::ColorScheme::Dark => adw::ColorScheme::ForceDark, + app_config::ColorScheme::Light => adw::ColorScheme::ForceLight, + } +} + +fn gnome_interface_settings() -> Option { + let schema = gio::SettingsSchemaSource::default()?.lookup(GNOME_INTERFACE_SCHEMA, true)?; + if !schema.has_key(GNOME_COLOR_SCHEME_KEY) { + return None; + } + + Some(gio::Settings::new_full( + &schema, + None::<&gio::SettingsBackend>, + None::<&str>, + )) +} + +fn gnome_prefers_dark_from_raw(raw: &str) -> Option { + match raw { + "prefer-dark" => Some(true), + "default" | "prefer-light" => Some(false), + _ => None, + } +} + +fn gnome_prefers_dark(settings: &gio::Settings) -> Option { + gnome_prefers_dark_from_raw(settings.string(GNOME_COLOR_SCHEME_KEY).as_str()) +} + +#[cfg(test)] +fn gtk_system_prefers_dark_from_raw(raw: Option) -> Option { + match raw { + Some(value) if value == gtk::ffi::GTK_INTERFACE_COLOR_SCHEME_DARK => Some(true), + Some(value) + if value == gtk::ffi::GTK_INTERFACE_COLOR_SCHEME_LIGHT + || value == gtk::ffi::GTK_INTERFACE_COLOR_SCHEME_DEFAULT => + { + Some(false) + } + Some(value) if value == gtk::ffi::GTK_INTERFACE_COLOR_SCHEME_UNSUPPORTED => None, + Some(_) => Some(false), + None => None, + } +} + +fn resolve_system_prefers_dark( + portal_color_scheme_preference: PortalColorSchemePreference, + gnome_interface_settings: Option<&gio::Settings>, +) -> Option { + resolved_system_prefers_dark( + portal_color_scheme_preference, + gnome_interface_settings.and_then(gnome_prefers_dark), + ) +} + +fn resolved_system_prefers_dark( + portal_color_scheme_preference: PortalColorSchemePreference, + gnome_prefers_dark: Option, +) -> Option { + portal_color_scheme_preference.resolved(gnome_prefers_dark) +} + +fn portal_color_scheme_preference_from_response( + response: &glib::Variant, +) -> Option { + let value = response.try_child_get::(0).ok().flatten()?; + PortalColorSchemePreference::from_raw(value.try_get::().ok()?) +} + +fn portal_setting_changed_preference( + parameters: &glib::Variant, +) -> Option { + let (namespace, key, value) = parameters + .try_get::<(String, String, glib::Variant)>() + .ok()?; + if namespace != PORTAL_APPEARANCE_NAMESPACE || key != PORTAL_COLOR_SCHEME_KEY { + return None; + } + + PortalColorSchemePreference::from_raw(value.try_get::().ok()?) +} + +fn sync_system_prefers_dark_change( + state: &State, + style_manager: &adw::StyleManager, + system_prefers_dark: &Cell>, + updated_preference: Option, +) { + if updated_preference == system_prefers_dark.get() { + return; + } + + system_prefers_dark.set(updated_preference); + sync_ghostty_color_scheme_for_config( + style_manager, + updated_preference, + &state.borrow().config.borrow().appearance, + ); +} + +fn sync_portal_color_scheme_preference_change( + state: &State, + style_manager: &adw::StyleManager, + system_prefers_dark: &Cell>, + portal_color_scheme_preference: &Cell, + gnome_interface_settings: Option<&gio::Settings>, + updated_preference: PortalColorSchemePreference, +) { + if updated_preference == portal_color_scheme_preference.get() { + return; + } + + portal_color_scheme_preference.set(updated_preference); + let resolved_preference = + resolve_system_prefers_dark(updated_preference, gnome_interface_settings); + sync_system_prefers_dark_change( + state, + style_manager, + system_prefers_dark, + resolved_preference, + ); +} + +fn connect_portal_appearance_watch_async( + gnome_interface_settings: Option, + state: State, + style_manager: adw::StyleManager, + system_prefers_dark: Rc>>, + portal_color_scheme_preference: Rc>, +) { + gio::DBusProxy::for_bus( + gio::BusType::Session, + gio::DBusProxyFlags::NONE, + None::<&gio::DBusInterfaceInfo>, + PORTAL_DESKTOP_SERVICE, + PORTAL_DESKTOP_PATH, + PORTAL_SETTINGS_INTERFACE, + None::<&gio::Cancellable>, + move |result| { + let Ok(proxy) = result else { + return; + }; + + read_portal_appearance_preference_async( + &proxy, + gnome_interface_settings.clone(), + state.clone(), + style_manager.clone(), + system_prefers_dark.clone(), + portal_color_scheme_preference.clone(), + ); + + let subscription = connect_portal_appearance_watch( + &proxy, + gnome_interface_settings.clone(), + state.clone(), + style_manager.clone(), + system_prefers_dark.clone(), + portal_color_scheme_preference.clone(), + ); + state.borrow_mut()._theme_portal_signal = subscription; + }, + ); +} + +fn read_portal_appearance_preference_async( + proxy: &gio::DBusProxy, + gnome_interface_settings: Option, + state: State, + style_manager: adw::StyleManager, + system_prefers_dark: Rc>>, + portal_color_scheme_preference: Rc>, +) { + let params = (PORTAL_APPEARANCE_NAMESPACE, PORTAL_COLOR_SCHEME_KEY).to_variant(); + proxy.call( + "Read", + Some(¶ms), + gio::DBusCallFlags::NONE, + PORTAL_THEME_READ_TIMEOUT_MS, + None::<&gio::Cancellable>, + move |result| { + let Ok(response) = result else { + return; + }; + let Some(updated_preference) = portal_color_scheme_preference_from_response(&response) + else { + return; + }; + sync_portal_color_scheme_preference_change( + &state, + &style_manager, + system_prefers_dark.as_ref(), + portal_color_scheme_preference.as_ref(), + gnome_interface_settings.as_ref(), + updated_preference, + ); + }, + ); +} + +fn connect_portal_appearance_watch( + proxy: &gio::DBusProxy, + gnome_interface_settings: Option, + state: State, + style_manager: adw::StyleManager, + system_prefers_dark: Rc>>, + portal_color_scheme_preference: Rc>, +) -> Option { + let connection = proxy.connection(); + Some(connection.subscribe_to_signal( + Some(PORTAL_DESKTOP_SERVICE), + Some(PORTAL_SETTINGS_INTERFACE), + Some("SettingChanged"), + Some(PORTAL_DESKTOP_PATH), + Some(PORTAL_APPEARANCE_NAMESPACE), + gio::DBusSignalFlags::NONE, + move |signal| { + let Some(updated_preference) = portal_setting_changed_preference(signal.parameters) + else { + return; + }; + + sync_portal_color_scheme_preference_change( + &state, + &style_manager, + system_prefers_dark.as_ref(), + portal_color_scheme_preference.as_ref(), + gnome_interface_settings.as_ref(), + updated_preference, + ); + }, + )) +} + +fn connect_gnome_appearance_watch( + settings: &gio::Settings, + state: State, + style_manager: adw::StyleManager, + system_prefers_dark: Rc>>, + portal_color_scheme_preference: Rc>, +) -> glib::SignalHandlerId { + settings.connect_changed(Some(GNOME_COLOR_SCHEME_KEY), move |settings, _| { + let updated_preference = + resolve_system_prefers_dark(portal_color_scheme_preference.get(), Some(settings)); + sync_system_prefers_dark_change( + &state, + &style_manager, + system_prefers_dark.as_ref(), + updated_preference, + ); + }) +} + +fn ghostty_prefers_dark( + scheme: app_config::ColorScheme, + system_prefers_dark: Option, + fallback_dark: bool, +) -> bool { + match scheme { + app_config::ColorScheme::Dark => true, + app_config::ColorScheme::Light => false, + app_config::ColorScheme::System => system_prefers_dark.unwrap_or(fallback_dark), + } +} + +fn sync_ghostty_color_scheme_for_config( + style_manager: &adw::StyleManager, + system_prefers_dark: Option, + appearance: &app_config::AppearanceConfig, +) { + let dark = ghostty_prefers_dark( + appearance.ghostty_color_scheme, + system_prefers_dark, + style_manager.is_dark(), + ); + crate::terminal::sync_color_scheme(dark); +} + +fn apply_appearance( + style_manager: &adw::StyleManager, + system_prefers_dark: Option, + appearance: &app_config::AppearanceConfig, +) { + style_manager.set_color_scheme(adw_color_scheme_for(appearance.color_scheme)); + sync_ghostty_color_scheme_for_config(style_manager, system_prefers_dark, appearance); +} + +/// Detach a widget from its current parent, if it has one. Safe to call +/// regardless of whether the widget is currently parented or not. +fn detach(widget: &impl IsA) { + let w = widget.as_ref(); + if let Some(parent) = w.parent() { + if let Some(bx) = parent.downcast_ref::() { + bx.remove(w); + } else { + w.unparent(); + } + } +} + +/// Locate the leading pane of the currently active workspace, so we can park +/// the dock toggle there when the top bar is hidden and the sidebar is closed. +fn active_workspace_leading_pane(state: &State) -> Option { + let root = { + let s = state.borrow(); + s.active_workspace().map(|ws| ws.root.clone()) + }?; + Some(first_leaf_pane(&root)) +} + +/// Reparent the dock toggle, + button, and window-controls into the top bar +/// or the sidebar header (or, in the top-bar-off + sidebar-closed case, park +/// the dock toggle on the active workspace's leading pane). +fn apply_top_bar_mode(state: &State) { + let ( + top_bar_handle, + top_bar_content_box, + dock_toggle, + settings_btn, + new_ws_btn, + minimize, + maximize, + close, + indicator_box, + sidebar_header, + sidebar_header_handle, + sidebar_drag_area, + show_top_bar, + controls_side, + show_workspace_indicators, + sidebar_visible_now, + ) = { + let s = state.borrow(); + let config = s.config.borrow(); + ( + s.top_bar.clone(), + s.top_bar_content.clone(), + s.top_bar_sidebar_toggle.clone(), + s.top_bar_settings_btn.clone(), + s.top_bar_new_ws_btn_ref.clone(), + s.top_bar_minimize_btn.clone(), + s.top_bar_maximize_btn.clone(), + s.top_bar_close_btn.clone(), + s.indicator_box.clone(), + s.sidebar_header.clone(), + s.sidebar_header_handle.clone(), + s.sidebar_drag_area.clone(), + // The persisted setting AND the transient keyboard toggle must + // both be on for the top bar layout to apply. + config.interface.show_top_bar && s.top_bar_visible, + config.interface.window_controls_side, + config.interface.show_workspace_indicators, + // Just the widget's visible property — the paned position can be + // stale during animations or startup; we don't want to misclassify + // a set_visible(true) sidebar as closed. + s.sidebar_box.is_visible(), + ) + }; + + let ( + Some(handle), + Some(content), + Some(dock), + Some(settings), + Some(new_ws), + Some(mi), + Some(ma), + Some(cl), + ) = ( + top_bar_handle, + top_bar_content_box, + dock_toggle, + settings_btn, + new_ws_btn, + minimize, + maximize, + close, + ) + else { + return; + }; + + // Detach the mobile widgets from wherever they're parented now — this + // covers the case where a widget lives in the top bar, the sidebar + // header, or a pane's leading_box from a previous arrangement. + detach(&dock); + detach(&settings); + detach(&new_ws); + detach(&mi); + detach(&ma); + detach(&cl); + detach(&indicator_box); + + // Clear the alt sidebar header from previous arrangements (removes the + // leftover hexpand spacer child). + while let Some(child) = sidebar_header.first_child() { + sidebar_header.remove(&child); } - // Wire expand button + // Workspace indicator pills are only shown when the user opts in. + // Hide the individual pills (children) rather than the box itself so the + // box keeps its hexpand spacer role between the top bar's left group and + // the window controls on the right. { - let state = state.clone(); - expand_btn.connect_clicked(move |_| { - toggle_sidebar(&state); - }); + let s = state.borrow(); + for ws in &s.workspaces { + ws.indicator_button.set_visible(show_workspace_indicators); + } } - register_actions(&window, &state); - install_key_capture(&window, &state); - - // Any click anywhere in the window commits an active sidebar rename, - // UNLESS the click is inside the rename Entry itself. - { - let sl = sidebar_list.clone(); - let win = window.clone(); - let click_anywhere = gtk::GestureClick::new(); - click_anywhere.set_propagation_phase(gtk::PropagationPhase::Capture); - click_anywhere.connect_pressed(move |_, _, x, y| { - if let Some(entry) = find_active_rename_entry(&sl) { - // Translate click coords from window to the entry's coordinate space - if let Some((ex, ey)) = win.translate_coordinates(&entry, x, y) { - let alloc = entry.allocation(); - if ex >= 0.0 - && ey >= 0.0 - && ex <= alloc.width() as f64 - && ey <= alloc.height() as f64 - { - return; // click is inside the entry - } - } - commit_any_active_rename(&sl); + if show_top_bar { + // Classic layout: put everything back into the top bar, in order + // dock | settings | new_ws | indicator_box | [controls at side] + content.append(&dock); + content.append(&settings); + content.append(&new_ws); + content.append(&indicator_box); + + match controls_side { + app_config::WindowControlsSide::Left => { + cl.insert_before(&content, content.first_child().as_ref()); + mi.insert_after(&content, Some(&cl)); + ma.insert_after(&content, Some(&mi)); } - }); - window.add_controller(click_anywhere); - } - - { - let state = state.clone(); - sidebar_list.connect_row_selected(move |_, row| { - if let Some(row) = row { - let idx = row.index() as usize; - switch_workspace(&state, idx); + app_config::WindowControlsSide::Right => { + content.append(&mi); + content.append(&ma); + content.append(&cl); } - }); - } + } - { - let state = state.clone(); - new_ws_btn.connect_clicked(move |_| { - add_workspace(&state, None); - }); + handle.set_visible(true); + sidebar_header_handle.set_visible(false); + // Top bar already handles window drag — hide the 8px drag strip above + // the workspace list so the first row sits flush with the sidebar top, + // matching the sidebar-header mode's spacing. + sidebar_drag_area.set_visible(false); + return; } - // Wire up drop-to-delete handler on the New Workspace button - { - let state = state.clone(); - let btn = new_ws_btn.clone(); - btn_drop.connect_drop(move |_, value, _, _| { - btn.set_label("New Workspace"); - btn.remove_css_class("limux-sidebar-btn-trash"); - btn.remove_css_class("limux-sidebar-btn-trash-hover"); - if let Ok(workspace_id) = value.get::() { - close_workspace_by_id(&state, &workspace_id); - return true; + // Top bar hidden. Hide the whole top-bar widget. + handle.set_visible(false); + + if sidebar_visible_now { + // Sidebar open: left group + expanding spacer + right group, so the + // window controls sit at one end and the app buttons at the other. + let spacer = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .hexpand(true) + .build(); + + match controls_side { + app_config::WindowControlsSide::Left => { + // close | min | max || spacer || dock | settings | + (new_ws) + sidebar_header.append(&cl); + sidebar_header.append(&mi); + sidebar_header.append(&ma); + sidebar_header.append(&spacer); + sidebar_header.append(&dock); + sidebar_header.append(&settings); + sidebar_header.append(&new_ws); } - false - }); + app_config::WindowControlsSide::Right => { + // dock | settings | + || spacer || min | max | close + sidebar_header.append(&dock); + sidebar_header.append(&settings); + sidebar_header.append(&new_ws); + sidebar_header.append(&spacer); + sidebar_header.append(&mi); + sidebar_header.append(&ma); + sidebar_header.append(&cl); + } + } + sidebar_header_handle.set_visible(true); + // Sidebar header replaces the drag strip above it visually, so hide + // the 8px drag spacer to match the pane header height exactly. + sidebar_drag_area.set_visible(false); + } else { + // Sidebar collapsed: dock toggle goes on the leading pane, all other + // controls stay detached (not visible anywhere). + sidebar_header_handle.set_visible(false); + sidebar_drag_area.set_visible(true); + if let Some(pane) = active_workspace_leading_pane(state) { + if let Some(leading) = pane::pane_leading_box(&pane) { + leading.append(&dock); + } + } } +} - // Save the full session on window close. - { +fn open_keybind_editor_tab(state: &State, pane_widget: >k::Widget) { + let shortcuts = { + let s = state.borrow(); + s.shortcuts.clone() + }; + let on_capture: Rc< + dyn Fn( + ShortcutId, + Option, + ) -> Result, + > = { let state = state.clone(); - window.connect_close_request(move |_| { - save_session_now(&state); - glib::Propagation::Proceed - }); + Rc::new(move |id, binding| persist_shortcut_binding(&state, id, binding)) + }; + pane::add_keybind_editor_tab_to_pane(pane_widget, shortcuts, on_capture); +} + +fn activate_workspace_shortcut(state: &State, idx: usize) { + let row_and_list = { + let s = state.borrow(); + s.workspaces + .get(idx) + .map(|ws| (idx, ws.sidebar_row.clone(), s.sidebar_list.clone())) + }; + + if let Some((idx, row, list)) = row_and_list { + switch_workspace(state, idx); + list.select_row(Some(&row)); } +} - apply_loaded_session(&state, layout_state::load_session()); - window.present(); +fn activate_last_workspace_shortcut(state: &State) { + let last_idx = { + let s = state.borrow(); + if s.workspaces.is_empty() { + return; + } + s.workspaces.len() - 1 + }; + activate_workspace_shortcut(state, last_idx); } // --------------------------------------------------------------------------- -// Actions +// Workspace indicator pill (top bar) // --------------------------------------------------------------------------- -fn register_actions(window: &adw::ApplicationWindow, state: &State) { - let action_defs: &[&str] = &[ - "new-workspace", - "close-workspace", - "toggle-sidebar", - "next-workspace", - "prev-workspace", - ]; +fn build_workspace_indicator(name: &str) -> (gtk::Button, gtk::Label) { + let unread_dot = gtk::Label::builder() + .label("\u{25CF}") + .visible(false) + .build(); + unread_dot.add_css_class("limux-indicator-unread-dot-hidden"); - for name in action_defs { - let action = gtk::gio::SimpleAction::new(name, None); - let state = state.clone(); - let handler_name = name.to_string(); - action.connect_activate(move |_, _| match handler_name.as_str() { - "new-workspace" => add_workspace(&state, None), - "close-workspace" => close_workspace(&state), - "toggle-sidebar" => toggle_sidebar(&state), - "next-workspace" => cycle_workspace(&state, 1), - "prev-workspace" => cycle_workspace(&state, -1), - _ => {} - }); - window.add_action(&action); - } -} + let label = gtk::Label::builder() + .label(name) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(20) + .build(); -/// Intercept keyboard shortcuts in the CAPTURE phase for window-level bindings. -fn install_key_capture(window: &adw::ApplicationWindow, state: &State) { - use gtk::gdk; + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + content.append(&unread_dot); + content.append(&label); - let key_controller = gtk::EventControllerKey::new(); - key_controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let button = gtk::Button::builder() + .child(&content) + .focus_on_click(false) + .build(); + button.add_css_class("flat"); + button.add_css_class("limux-indicator-pill"); - let state = state.clone(); - key_controller.connect_key_pressed(move |_, keyval, _keycode, modifier| { - let ctrl = modifier.contains(gdk::ModifierType::CONTROL_MASK); - let shift = modifier.contains(gdk::ModifierType::SHIFT_MASK); - - let matched = match (ctrl, shift, keyval) { - // Ctrl+Shift+N → new workspace - (true, true, gdk::Key::N | gdk::Key::n) => { - add_workspace(&state, None); - true - } - // Ctrl+Shift+W → close workspace - (true, true, gdk::Key::W | gdk::Key::w) => { - close_workspace(&state); - true - } - // Ctrl+Shift+Left → prev tab - (true, true, gdk::Key::Left) => { - cycle_focused_pane_tab(&state, -1); - true - } - // Ctrl+Shift+Right → next tab - (true, true, gdk::Key::Right) => { - cycle_focused_pane_tab(&state, 1); - true - } - // Ctrl+Shift+D → split down - (true, true, gdk::Key::D | gdk::Key::d) => { - split_focused_pane(&state, gtk::Orientation::Vertical); - true - } - // Ctrl+Shift+T → new terminal tab in focused pane - (true, true, gdk::Key::T | gdk::Key::t) => { - add_tab_to_focused_pane(&state, false); - true - } - // Ctrl+D → split right - (true, false, gdk::Key::d) => { - split_focused_pane(&state, gtk::Orientation::Horizontal); - true - } - // Ctrl+W → close focused tab/pane - (true, false, gdk::Key::w) => { - close_focused_tab(&state); - true - } - // Ctrl+B → toggle sidebar - (true, false, gdk::Key::b) => { - toggle_sidebar(&state); - true - } - // Ctrl+T → new terminal tab - (true, false, gdk::Key::t) => { - add_tab_to_focused_pane(&state, false); - true - } - // Ctrl+PageDown → next workspace - (true, false, gdk::Key::Page_Down) => { - cycle_workspace(&state, 1); - true - } - // Ctrl+PageUp → prev workspace - (true, false, gdk::Key::Page_Up) => { - cycle_workspace(&state, -1); - true - } - // Ctrl+Arrow → focus pane in direction - (true, false, gdk::Key::Left) => { - focus_pane_in_direction(&state, Direction::Left); - true - } - (true, false, gdk::Key::Right) => { - focus_pane_in_direction(&state, Direction::Right); - true - } - (true, false, gdk::Key::Up) => { - focus_pane_in_direction(&state, Direction::Up); - true - } - (true, false, gdk::Key::Down) => { - focus_pane_in_direction(&state, Direction::Down); - true - } - // Ctrl+1-9 → switch to workspace by index - (true, false, key) => { - let digit = match key { - gdk::Key::_1 => Some(0usize), - gdk::Key::_2 => Some(1), - gdk::Key::_3 => Some(2), - gdk::Key::_4 => Some(3), - gdk::Key::_5 => Some(4), - gdk::Key::_6 => Some(5), - gdk::Key::_7 => Some(6), - gdk::Key::_8 => Some(7), - gdk::Key::_9 => { - // Ctrl+9 always goes to last workspace - let s = state.borrow(); - if s.workspaces.is_empty() { - None - } else { - Some(s.workspaces.len() - 1) - } - } - _ => None, - }; - if let Some(idx) = digit { - let row_and_list = { - let s = state.borrow(); - s.workspaces - .get(idx) - .map(|ws| (ws.sidebar_row.clone(), s.sidebar_list.clone())) - }; - switch_workspace(&state, idx); - if let Some((row, list)) = row_and_list { - list.select_row(Some(&row)); + (button, unread_dot) +} + +fn sync_indicator_active_state(state: &AppState) { + for (idx, ws) in state.workspaces.iter().enumerate() { + if idx == state.active_idx { + ws.indicator_button + .add_css_class("limux-indicator-pill-active"); + } else { + ws.indicator_button + .remove_css_class("limux-indicator-pill-active"); + } + } +} + +fn update_indicator_label(button: >k::Button, name: &str) { + if let Some(content) = button.child() { + if let Some(content_box) = content.downcast_ref::() { + let mut child = content_box.first_child(); + while let Some(widget) = child { + if let Some(label) = widget.downcast_ref::() { + // Skip the unread dot label (it has the dot character) + if label.label() != "\u{25CF}" { + label.set_label(name); + break; } - true - } else { - false } + child = widget.next_sibling(); } - _ => false, - }; - - if matched { - glib::Propagation::Stop - } else { - glib::Propagation::Proceed } - }); + } +} - window.add_controller(key_controller); +fn sync_indicator_order(state: &mut AppState) { + while let Some(child) = state.indicator_box.first_child() { + state.indicator_box.remove(&child); + } + for ws in &state.workspaces { + state.indicator_box.append(&ws.indicator_button); + } } // --------------------------------------------------------------------------- @@ -1004,6 +2562,7 @@ fn build_sidebar_row( gtk::Label, gtk::Label, gtk::Label, + gtk::Button, ) { let notify_dot = gtk::Label::builder().label("\u{25CF}").build(); notify_dot.add_css_class("limux-notify-dot-hidden"); @@ -1016,21 +2575,24 @@ fn build_sidebar_row( .build(); name_label.add_css_class("limux-ws-name"); - let favorite_button = gtk::Button::with_label("\u{2606}"); - favorite_button.add_css_class("flat"); - favorite_button.add_css_class("limux-ws-star-btn"); - favorite_button.set_focus_on_click(false); - favorite_button.set_valign(gtk::Align::Center); - favorite_button.set_halign(gtk::Align::End); - favorite_button.set_tooltip_text(Some("Favorite workspace")); + // Close X in the top-right of the row, replaces where the star used to be. + let close_button = gtk::Button::from_icon_name("window-close-symbolic"); + close_button.add_css_class("flat"); + close_button.add_css_class("limux-ws-close-btn"); + close_button.set_focus_on_click(false); + close_button.set_valign(gtk::Align::Center); + close_button.set_halign(gtk::Align::End); + close_button.set_tooltip_text(Some("Close workspace")); let top_row = gtk::Box::new(gtk::Orientation::Horizontal, 0); top_row.append(¬ify_dot); top_row.append(&name_label); - top_row.append(&favorite_button); + top_row.append(&close_button); + // Second row: path label on the left, favorite star right-aligned below the X. let path_label = gtk::Label::builder() .xalign(0.0) + .hexpand(true) .ellipsize(gtk::pango::EllipsizeMode::End) .margin_start(8) .build(); @@ -1038,11 +2600,22 @@ fn build_sidebar_row( if let Some(p) = folder_path { path_label.set_label(&abbreviate_path(p)); path_label.set_tooltip_text(Some(p)); - path_label.set_visible(true); } else { - path_label.set_visible(false); + path_label.set_label(""); } + let favorite_button = gtk::Button::with_label("\u{2606}"); + favorite_button.add_css_class("flat"); + favorite_button.add_css_class("limux-ws-star-btn"); + favorite_button.set_focus_on_click(false); + favorite_button.set_valign(gtk::Align::Center); + favorite_button.set_halign(gtk::Align::End); + favorite_button.set_tooltip_text(Some("Favorite workspace")); + + let path_row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + path_row.append(&path_label); + path_row.append(&favorite_button); + let notify_label = gtk::Label::builder() .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::End) @@ -1057,7 +2630,7 @@ fn build_sidebar_row( .build(); vbox.add_css_class("limux-sidebar-row-box"); vbox.append(&top_row); - vbox.append(&path_label); + vbox.append(&path_row); vbox.append(¬ify_label); let row = gtk::ListBoxRow::new(); @@ -1070,6 +2643,7 @@ fn build_sidebar_row( notify_dot, notify_label, path_label, + close_button, ) } @@ -1092,6 +2666,66 @@ fn favorites_prefix_len(flags: &[bool]) -> usize { flags.iter().take_while(|is_favorite| **is_favorite).count() } +#[cfg(test)] +fn workspace_drop_layout_path(layout: &LayoutNodeState) -> Vec { + match layout { + LayoutNodeState::Pane(_) => Vec::new(), + LayoutNodeState::Split(split) => { + let mut path = vec![true]; + path.extend(workspace_drop_layout_path(&split.start)); + path + } + } +} + +fn tab_drag_workspace_seed( + source: WorkspaceSeedSource, + title: &str, + tab_cwd: Option, +) -> TabDragWorkspaceSeed { + let name = { + let trimmed = title.trim(); + if trimmed.is_empty() { + "Workspace".to_string() + } else { + trimmed.to_string() + } + }; + let cwd = tab_cwd + .clone() + .or_else(|| source.workspace_folder_path.clone()) + .or(source.workspace_cwd.clone()); + let folder_path = tab_cwd + .filter(|cwd| !cwd.trim().is_empty()) + .or(source.workspace_folder_path) + .filter(|path| !path.trim().is_empty()); + + TabDragWorkspaceSeed { + name, + cwd, + folder_path, + } +} + +fn next_active_workspace_index( + remaining_workspace_ids: &[&str], + preferred_active_workspace_id: Option<&str>, + removed_idx: usize, +) -> usize { + if remaining_workspace_ids.is_empty() { + return 0; + } + if let Some(preferred_id) = preferred_active_workspace_id { + if let Some(idx) = remaining_workspace_ids + .iter() + .position(|workspace_id| *workspace_id == preferred_id) + { + return idx; + } + } + removed_idx.min(remaining_workspace_ids.len() - 1) +} + fn show_workspace_context_menu(state: &State, workspace_id: &str, row: >k::ListBoxRow) { let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 2); menu_box.set_margin_top(4); @@ -1161,6 +2795,7 @@ fn sync_sidebar_row_order(state: &mut AppState) { for workspace in &state.workspaces { state.sidebar_list.append(&workspace.sidebar_row); } + sync_indicator_order(state); } fn set_workspace_favorite_visual(workspace: &Workspace) { @@ -1262,7 +2897,9 @@ fn begin_workspace_inline_rename(state: &State, workspace_id: &str) { .text(¤t_name) .hexpand(true) .build(); - entry.add_css_class("limux-ws-rename-entry"); + for css_class in WORKSPACE_RENAME_ENTRY_CSS_CLASSES { + entry.add_css_class(css_class); + } label.set_visible(false); parent.insert_child_after(&entry, Some(&label)); @@ -1292,6 +2929,8 @@ fn begin_workspace_inline_rename(state: &State, workspace_id: &str) { .find(|workspace| workspace.id == workspace_id) { workspace.name = next_name; + // Update the indicator pill label + update_indicator_label(&workspace.indicator_button, &workspace.name); } drop(s); request_session_save(&state_for_commit); @@ -1452,13 +3091,172 @@ fn toggle_workspace_favorite(state: &State, workspace_id: &str) { request_session_save(state); } +fn handle_tab_drop_to_workspace(state: &State, target_workspace_id: &str, payload: &str) -> bool { + let Some((pane_id, tab_id)) = payload.split_once(':') else { + return false; + }; + let Ok(source_pane_id) = pane_id.parse::() else { + return false; + }; + let Some(source_pane) = pane::find_pane_widget_by_id(source_pane_id) else { + return false; + }; + + let target_pane = { + let app_state = state.borrow(); + let Some(workspace) = app_state + .workspaces + .iter() + .find(|workspace| workspace.id == target_workspace_id) + else { + return false; + }; + find_leaf_pane(&workspace.root, gtk::Orientation::Horizontal, true) + }; + + pane::move_tab_to_pane(&source_pane, tab_id, &target_pane) +} + +fn create_workspace_for_tab(state: &State, payload: &str) -> bool { + let Some((pane_id, tab_id)) = payload.split_once(':') else { + return false; + }; + let Ok(source_pane_id) = pane_id.parse::() else { + return false; + }; + let Some(source_pane) = pane::find_pane_widget_by_id(source_pane_id) else { + return false; + }; + + let Some(title) = pane::tab_title(&source_pane, tab_id) else { + return false; + }; + let tab_cwd = pane::tab_working_directory(&source_pane, tab_id); + let seed = { + let app_state = state.borrow(); + let source = app_state + .workspace_for_widget(&source_pane) + .map(|workspace| WorkspaceSeedSource { + workspace_cwd: workspace.cwd.borrow().clone(), + workspace_folder_path: workspace.folder_path.clone(), + }) + .unwrap_or(WorkspaceSeedSource { + workspace_cwd: None, + workspace_folder_path: None, + }); + tab_drag_workspace_seed(source, &title, tab_cwd) + }; + let previous_active_workspace_id = { + let app_state = state.borrow(); + app_state + .active_workspace() + .map(|workspace| workspace.id.clone()) + }; + + let shortcuts = { + let app_state = state.borrow(); + app_state.shortcuts.clone() + }; + let new_workspace_id = uuid::Uuid::new_v4().to_string(); + let stack_name = format!("ws-{new_workspace_id}"); + let pane = create_pane_for_workspace( + state, + &shortcuts, + &new_workspace_id, + seed.cwd.as_deref(), + None, + true, + ); + let split_container = SplitTreeContainer::new(state, pane.clone().upcast()); + let root = split_container.widget().clone(); + + let (row, name_label, favorite_button, notify_dot, notify_label, path_label, close_button) = + build_sidebar_row(&seed.name, seed.folder_path.as_deref()); + // Wire close button + { + let state = state.clone(); + let ws_id = new_workspace_id.clone(); + close_button.connect_clicked(move |_| { + close_workspace_by_id(&state, &ws_id); + }); + } + let (indicator_button, indicator_unread_dot) = build_workspace_indicator(&seed.name); + // Wire indicator pill click + { + let state = state.clone(); + let ws_id = new_workspace_id.clone(); + indicator_button.connect_clicked(move |_| { + let (idx, row, sidebar_list) = { + let s = state.borrow(); + let Some(idx) = s.workspaces.iter().position(|w| w.id == ws_id) else { + return; + }; + ( + idx, + s.workspaces[idx].sidebar_row.clone(), + s.sidebar_list.clone(), + ) + }; + switch_workspace(&state, idx); + sidebar_list.select_row(Some(&row)); + }); + } + let row_clone = row.clone(); + { + let mut app_state = state.borrow_mut(); + app_state.stack.add_named(&root, Some(&stack_name)); + app_state.sidebar_list.append(&row); + app_state.indicator_box.append(&indicator_button); + install_workspace_row_interactions(state, &new_workspace_id, &row, &favorite_button); + + app_state.workspaces.push(Workspace { + id: new_workspace_id.clone(), + name: seed.name.clone(), + root: root.clone().upcast(), + split_container, + sidebar_row: row, + name_label, + favorite_button, + notify_dot, + notify_label, + unread: false, + favorite: false, + cwd: Rc::new(RefCell::new(seed.cwd.clone())), + folder_path: seed.folder_path.clone(), + path_label, + indicator_button, + indicator_unread_dot, + }); + app_state.active_idx = app_state.workspaces.len() - 1; + sync_indicator_active_state(&app_state); + app_state.stack.set_visible_child_name(&stack_name); + } + + { + let sidebar_list = state.borrow().sidebar_list.clone(); + sidebar_list.select_row(Some(&row_clone)); + } + + if pane::move_tab_to_pane(&source_pane, tab_id, &pane.clone().upcast()) { + apply_top_bar_mode(state); + request_session_save(state); + return true; + } + close_workspace_by_id_internal( + state, + &new_workspace_id, + false, + previous_active_workspace_id.as_deref(), + ); + false +} + fn install_workspace_row_interactions( state: &State, workspace_id: &str, row: >k::ListBoxRow, favorite_button: >k::Button, ) { - // Right click shows context menu with Rename / Delete. let right_click = gtk::GestureClick::new(); right_click.set_button(3); { @@ -1471,7 +3269,21 @@ fn install_workspace_row_interactions( } row.add_controller(right_click); - // Drag source for sidebar reordering. + // Double-left-click anywhere on the row starts inline rename. + let double_click = gtk::GestureClick::new(); + double_click.set_button(1); + { + let state = state.clone(); + let workspace_id = workspace_id.to_string(); + double_click.connect_pressed(move |gesture, n_press, _, _| { + if n_press == 2 { + gesture.set_state(gtk::EventSequenceState::Claimed); + begin_workspace_inline_rename(&state, &workspace_id); + } + }); + } + row.add_controller(double_click); + let drag_source = gtk::DragSource::new(); drag_source.set_actions(gtk::gdk::DragAction::MOVE); { @@ -1483,61 +3295,140 @@ fn install_workspace_row_interactions( } { let state = state.clone(); - drag_source.connect_drag_begin(move |_, _| { - let s = state.borrow(); + let row = row.clone(); + let workspace_id = workspace_id.to_string(); + drag_source.connect_drag_begin(move |source, _| { + let mut s = state.borrow_mut(); + s.workspace_dragging = Some(workspace_id.clone()); s.new_ws_btn.set_label("\u{1F5D1}\u{FE0E}"); s.new_ws_btn.add_css_class("limux-sidebar-btn-trash"); + drop(s); + pane::set_workspace_dragging_all(true); + let icon = gtk::WidgetPaintable::new(Some(&row)); + source.set_icon(Some(&icon), 0, 0); }); } { let state = state.clone(); drag_source.connect_drag_end(move |_, _, _| { - let s = state.borrow(); + let mut s = state.borrow_mut(); + s.workspace_dragging = None; s.new_ws_btn.set_label("New Workspace"); s.new_ws_btn.remove_css_class("limux-sidebar-btn-trash"); s.new_ws_btn .remove_css_class("limux-sidebar-btn-trash-hover"); + pane::set_workspace_dragging_all(false); }); } row.add_controller(drag_source); - // Drop target for sidebar reordering with visual feedback. let drop_target = gtk::DropTarget::new(glib::Type::STRING, gtk::gdk::DragAction::MOVE); drop_target.set_preload(true); + let hover_timer: Rc>> = Rc::new(RefCell::new(None)); + let drop_handled = Rc::new(Cell::new(false)); { let r = row.clone(); + let state = state.clone(); + let hover_timer = hover_timer.clone(); + let target_workspace_id = workspace_id.to_string(); + let drop_handled = drop_handled.clone(); drop_target.connect_motion(move |_, _x, y| { + drop_handled.set(false); let h = r.height() as f64; r.remove_css_class("limux-drop-above"); r.remove_css_class("limux-drop-below"); - if y < h / 2.0 { - r.add_css_class("limux-drop-above"); - } else { - r.add_css_class("limux-drop-below"); + r.remove_css_class("limux-tab-drop-target"); + + let dragged_workspace = state.borrow().workspace_dragging.clone(); + match dragged_workspace { + Some(ref dragged_workspace_id) if dragged_workspace_id != &target_workspace_id => { + if y < h / 2.0 { + r.add_css_class("limux-drop-above"); + } else { + r.add_css_class("limux-drop-below"); + } + } + None => { + r.add_css_class("limux-tab-drop-target"); + } + _ => {} + } + + if hover_timer.borrow().is_none() { + let state = state.clone(); + let target_workspace_id = target_workspace_id.clone(); + let hover_timer = hover_timer.clone(); + let drop_handled = drop_handled.clone(); + let timer_for_callback = hover_timer.clone(); + let source = glib::timeout_add_local_once( + std::time::Duration::from_millis(500), + move || { + *timer_for_callback.borrow_mut() = None; + if drop_handled.get() { + return; + } + let (target_idx, sidebar_row, sidebar_list) = { + let app_state = state.borrow(); + let idx = app_state + .workspaces + .iter() + .position(|workspace| workspace.id == target_workspace_id); + let sidebar_row = idx.and_then(|idx| { + app_state + .workspaces + .get(idx) + .map(|workspace| workspace.sidebar_row.clone()) + }); + (idx, sidebar_row, app_state.sidebar_list.clone()) + }; + if let Some(target_idx) = target_idx { + switch_workspace(&state, target_idx); + } + if let Some(sidebar_row) = sidebar_row { + sidebar_list.select_row(Some(&sidebar_row)); + } + }, + ); + *hover_timer.borrow_mut() = Some(source); } gtk::gdk::DragAction::MOVE }); } { let r = row.clone(); + let hover_timer = hover_timer.clone(); drop_target.connect_leave(move |_| { r.remove_css_class("limux-drop-above"); r.remove_css_class("limux-drop-below"); + r.remove_css_class("limux-tab-drop-target"); + if let Some(source) = hover_timer.borrow_mut().take() { + source.remove(); + } }); } { let state = state.clone(); let target_workspace_id = workspace_id.to_string(); let r = row.clone(); + let hover_timer = hover_timer.clone(); + let drop_handled = drop_handled.clone(); drop_target.connect_drop(move |_dt, value, _, y| { + drop_handled.set(true); r.remove_css_class("limux-drop-above"); r.remove_css_class("limux-drop-below"); - let drop_below = y >= r.height() as f64 / 2.0; - if let Ok(source_workspace_id) = value.get::() { - if source_workspace_id != target_workspace_id { + r.remove_css_class("limux-tab-drop-target"); + if let Some(source) = hover_timer.borrow_mut().take() { + source.remove(); + } + if let Ok(payload) = value.get::() { + if payload.contains(':') { + return handle_tab_drop_to_workspace(&state, &target_workspace_id, &payload); + } + let drop_below = y >= r.height() as f64 / 2.0; + if payload != target_workspace_id { return reorder_workspace_by_id( &state, - &source_workspace_id, + &payload, &target_workspace_id, drop_below, ); @@ -1559,7 +3450,24 @@ fn install_workspace_row_interactions( #[allow(deprecated)] fn add_workspace(state: &State, _working_directory: Option<&str>) { - // Open a folder chooser dialog (using FileChooserDialog to avoid portal crashes) + // If there's already an active workspace, clone its folder instead of + // asking — matches cmux UX where the "+" creates a workspace in context. + let active_folder = { + let s = state.borrow(); + s.active_workspace() + .and_then(|ws| ws.folder_path.clone().or_else(|| ws.cwd.borrow().clone())) + }; + + if let Some(folder_path) = active_folder { + let folder_name = std::path::Path::new(&folder_path) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_else(|| folder_path.clone()); + create_workspace_with_folder(state, &folder_name, &folder_path); + return; + } + + // No active workspace (first-run): ask for a folder. let window: Option = { let s = state.borrow(); s.stack @@ -1616,28 +3524,335 @@ fn create_workspace_with_folder(state: &State, name: &str, folder_path: &str) { request_session_save(state); } +fn dispatch_control_command(command: ControlCommand) { + CONTROL_STATE.with(|slot| { + let state = slot.borrow().clone(); + if let Some(state) = state { + handle_control_command(&state, command); + } else { + command.respond(Err(crate::control_bridge::BridgeError::internal( + "control bridge not initialized", + ))); + } + }); +} + +fn handle_control_command(state: &State, command: ControlCommand) { + match command { + ControlCommand::Identify { caller, reply } => { + let result = { + let app_state = state.borrow(); + let focused = workspace_payload(&app_state, app_state.active_idx) + .map(|payload| { + serde_json::json!({ + "workspace_id": payload["workspace_id"], + "workspace_ref": payload["workspace_ref"], + "title": payload["title"], + "name": payload["name"], + }) + }) + .unwrap_or(serde_json::Value::Null); + serde_json::json!({ + "name": "limux-control", + "protocol": "v1+v2", + "version": env!("CARGO_PKG_VERSION"), + "focused": focused, + "caller": caller.unwrap_or_else(|| focused.clone()), + }) + }; + let _ = reply.send(Ok(result)); + } + ControlCommand::CurrentWorkspace { reply } => { + let result = { + let app_state = state.borrow(); + workspace_payload(&app_state, app_state.active_idx) + }; + let _ = reply.send(result.ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("no active workspace") + })); + } + ControlCommand::ListWorkspaces { reply } => { + let workspaces = { + let app_state = state.borrow(); + app_state + .workspaces + .iter() + .enumerate() + .map(|(index, workspace)| workspace_row(index, app_state.active_idx, workspace)) + .collect::>() + }; + let _ = reply.send(Ok(serde_json::json!({ "workspaces": workspaces }))); + } + ControlCommand::CreateWorkspace { + name, + cwd, + command, + reply, + } => { + let home = dirs::home_dir() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_default(); + let folder_path = cwd.as_deref().unwrap_or(&home); + let title = name.unwrap_or_else(|| { + std::path::Path::new(folder_path) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "workspace".to_string()) + }); + + create_workspace_with_folder(state, &title, folder_path); + + let result = { + let app_state = state.borrow(); + workspace_payload(&app_state, app_state.active_idx) + }; + + if let (Some(command), Some(workspace_id)) = ( + command, + result + .as_ref() + .and_then(|payload| payload["workspace_id"].as_str()) + .map(ToOwned::to_owned), + ) { + let state = state.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { + let target = { + let app_state = state.borrow(); + app_state + .workspaces + .iter() + .find(|workspace| workspace.id == workspace_id) + .and_then(|workspace| { + pane::terminal_handle_for_surface(&workspace.root, None) + }) + }; + if let Some((_surface_id, handle)) = target { + handle.send_text(&command); + handle.send_text("\n"); + } + }); + } + + let _ = reply.send(result.ok_or_else(|| { + crate::control_bridge::BridgeError::internal( + "workspace.create did not produce a workspace", + ) + })); + } + ControlCommand::SelectWorkspace { target, reply } => { + let resolved = { + let app_state = state.borrow(); + workspace_index_for_target(&app_state, &target) + }; + + let Some(index) = resolved else { + let _ = reply.send(Err(crate::control_bridge::BridgeError::not_found( + "workspace not found", + ))); + return; + }; + + let row = { + let app_state = state.borrow(); + app_state.workspaces[index].sidebar_row.clone() + }; + let sidebar_list = state.borrow().sidebar_list.clone(); + switch_workspace(state, index); + sidebar_list.select_row(Some(&row)); + + let result = { + let app_state = state.borrow(); + workspace_payload(&app_state, index) + }; + let _ = reply.send(result.ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("workspace not found") + })); + } + ControlCommand::RenameWorkspace { + target, + title, + reply, + } => { + let resolved = { + let app_state = state.borrow(); + workspace_index_for_target(&app_state, &target) + }; + + let Some(index) = resolved else { + let _ = reply.send(Err(crate::control_bridge::BridgeError::not_found( + "workspace not found", + ))); + return; + }; + + { + let mut app_state = state.borrow_mut(); + let workspace = &mut app_state.workspaces[index]; + workspace.name = title.clone(); + workspace.name_label.set_label(&title); + update_indicator_label(&workspace.indicator_button, &title); + } + request_session_save(state); + + let result = { + let app_state = state.borrow(); + workspace_payload(&app_state, index) + }; + let _ = reply.send(result.ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("workspace not found") + })); + } + ControlCommand::CloseWorkspace { target, reply } => { + let resolved = { + let app_state = state.borrow(); + if app_state.workspaces.len() <= 1 { + None + } else { + workspace_index_for_target(&app_state, &target) + } + }; + + let Some(index) = resolved else { + let can_close = state.borrow().workspaces.len() > 1; + let error = if can_close { + crate::control_bridge::BridgeError::not_found("workspace not found") + } else { + crate::control_bridge::BridgeError::conflict("cannot close workspace") + }; + let _ = reply.send(Err(error)); + return; + }; + + let closed_workspace = { + let app_state = state.borrow(); + workspace_payload(&app_state, index) + }; + let workspace_id = state.borrow().workspaces[index].id.clone(); + close_workspace_by_id(state, &workspace_id); + + let _ = reply.send(closed_workspace.ok_or_else(|| { + crate::control_bridge::BridgeError::not_found("workspace not found") + })); + } + ControlCommand::SendText { + target, + surface_hint, + text, + reply, + } => { + let resolved = { + let app_state = state.borrow(); + workspace_index_for_target(&app_state, &target) + }; + + let Some(index) = resolved else { + let _ = reply.send(Err(crate::control_bridge::BridgeError::not_found( + "workspace not found", + ))); + return; + }; + + let target = { + let app_state = state.borrow(); + let workspace = &app_state.workspaces[index]; + pane::terminal_handle_for_surface(&workspace.root, surface_hint.as_deref()).map( + |(surface_id, handle)| { + ( + serde_json::json!({ + "workspace_id": workspace.id.as_str(), + "workspace_ref": workspace_ref(&workspace.id), + "surface_id": surface_id.as_str(), + "surface_ref": surface_ref(&surface_id), + }), + handle, + ) + }, + ) + }; + + let Some((mut payload, handle)) = target else { + let _ = reply.send(Err(crate::control_bridge::BridgeError::not_found( + "terminal surface not found", + ))); + return; + }; + + handle.send_text(&text); + if let Some(map) = payload.as_object_mut() { + map.insert("ok".to_string(), serde_json::Value::Bool(true)); + } + let _ = reply.send(Ok(payload)); + } + } +} + fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { - let mut s = state.borrow_mut(); + let shortcuts = { + let s = state.borrow(); + s.shortcuts.clone() + }; + let (stack, sidebar_list, indicator_box) = { + let s = state.borrow(); + ( + s.stack.clone(), + s.sidebar_list.clone(), + s.indicator_box.clone(), + ) + }; let id = uuid::Uuid::new_v4().to_string(); let stack_name = format!("ws-{id}"); let working_dir = workspace .folder_path .as_deref() .or(workspace.cwd.as_deref()); - let root = build_workspace_root(state, &id, working_dir, Some(&workspace.layout)); + let (root, split_container) = + build_workspace_root(state, &shortcuts, &id, working_dir, &workspace.layout); + stack.add_named(&root, Some(&stack_name)); - s.stack.add_named(&root, Some(&stack_name)); - - let (row, name_label, favorite_button, notify_dot, notify_label, path_label) = + let (row, name_label, favorite_button, notify_dot, notify_label, path_label, close_button) = build_sidebar_row(&workspace.name, workspace.folder_path.as_deref()); - s.sidebar_list.append(&row); + sidebar_list.append(&row); install_workspace_row_interactions(state, &id, &row, &favorite_button); + // Wire close button + { + let state = state.clone(); + let ws_id = id.clone(); + close_button.connect_clicked(move |_| { + close_workspace_by_id(&state, &ws_id); + }); + } + + let (indicator_button, indicator_unread_dot) = build_workspace_indicator(&workspace.name); + indicator_box.append(&indicator_button); + + // Wire indicator pill click to switch workspace + { + let state = state.clone(); + let ws_id = id.clone(); + indicator_button.connect_clicked(move |_| { + let (idx, row, sidebar_list) = { + let s = state.borrow(); + let Some(idx) = s.workspaces.iter().position(|w| w.id == ws_id) else { + return; + }; + ( + idx, + s.workspaces[idx].sidebar_row.clone(), + s.sidebar_list.clone(), + ) + }; + switch_workspace(&state, idx); + sidebar_list.select_row(Some(&row)); + }); + } let cwd: Rc>> = Rc::new(RefCell::new(workspace.cwd.clone())); let ws = Workspace { id, name: workspace.name.clone(), root, + split_container, sidebar_row: row.clone(), name_label, favorite_button, @@ -1648,47 +3863,71 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { cwd, folder_path: workspace.folder_path.clone(), path_label, + indicator_button, + indicator_unread_dot, }; if workspace.favorite { set_workspace_favorite_visual(&ws); } - s.workspaces.push(ws); - let new_idx = s.workspaces.len() - 1; - s.active_idx = new_idx; - s.stack.set_visible_child_name(&stack_name); - - let sidebar_list = s.sidebar_list.clone(); - drop(s); + { + let mut s = state.borrow_mut(); + s.workspaces.push(ws); + s.active_idx = s.workspaces.len() - 1; + sync_indicator_active_state(&s); + } + stack.set_visible_child_name(&stack_name); sidebar_list.select_row(Some(&row)); + // Ensure the new pill's visibility honors the show_workspace_indicators + // preference, and that pane/sidebar placement is up to date. + apply_top_bar_mode(state); } /// Create a PaneWidget wired up with callbacks for a specific workspace. -fn create_pane_for_workspace( +pub(crate) fn create_pane_for_workspace( state: &State, + shortcuts: &Rc, ws_id: &str, working_directory: Option<&str>, initial_state: Option<&PaneState>, + skip_default_tab: bool, ) -> gtk::Box { let state_for_split = state.clone(); let state_for_close = state.clone(); let state_for_bell = state.clone(); + let state_for_desktop_notification = state.clone(); + let state_for_keybinds = state.clone(); let state_for_pwd = state.clone(); let state_for_empty = state.clone(); let ws_id_split = ws_id.to_string(); let ws_id_close = ws_id.to_string(); let ws_id_bell = ws_id.to_string(); + let ws_id_desktop_notification = ws_id.to_string(); let ws_id_pwd = ws_id.to_string(); let ws_id_empty = ws_id.to_string(); + let state_for_split_with_tab = state.clone(); + let state_for_config = state.clone(); + let ws_id_split_with_tab = ws_id.to_string(); let callbacks = Rc::new(PaneCallbacks { on_split: Box::new(move |pane_widget, orientation| { - split_pane(&state_for_split, &ws_id_split, pane_widget, orientation); + split_pane( + &state_for_split, + &ws_id_split, + pane_widget, + orientation, + SplitPaneOptions { + initial_state: None, + skip_default_tab: false, + new_pane_first: false, + persist: true, + }, + ); }), on_close_pane: Box::new(move |pane_widget| { - remove_pane(&state_for_close, &ws_id_close, pane_widget); + remove_pane_internal(&state_for_close, &ws_id_close, pane_widget, true); }), on_bell: Box::new(move || { // Defer to avoid RefCell borrow conflicts — bell can fire during state mutation @@ -1698,6 +3937,31 @@ fn create_pane_for_workspace( mark_workspace_unread(&state, &ws_id); }); }), + on_desktop_notification: Box::new(move |title: &str, body: &str| { + let state = state_for_desktop_notification.clone(); + let ws_id = ws_id_desktop_notification.clone(); + let message = workspace_notification_message(title, body); + glib::idle_add_local_once(move || { + mark_workspace_unread_with_message(&state, &ws_id, &message); + }); + }), + on_open_browser_here: Box::new(move |pane_widget| { + pane::add_browser_tab_to_pane(pane_widget); + }), + on_open_keybinds: Box::new(move |anchor| { + open_keybind_editor_tab(&state_for_keybinds, anchor); + }), + current_shortcuts: Box::new({ + let state = state.clone(); + move || { + let s = state.borrow(); + s.shortcuts.clone() + } + }), + on_capture_shortcut: { + let state = state.clone(); + Rc::new(move |id, binding| persist_shortcut_binding(&state, id, binding)) + }, on_pwd_changed: Box::new(move |pwd: &str| { let state = state_for_pwd.clone(); let ws_id = ws_id_pwd.clone(); @@ -1709,16 +3973,40 @@ fn create_pane_for_workspace( } }); }), - on_empty: Box::new(move |pane_widget| { - remove_pane(&state_for_empty, &ws_id_empty, pane_widget); + on_empty: Box::new(move |pane_widget, reason| { + let persist = matches!(reason, pane::PaneEmptyReason::ClosedLastTab); + remove_pane_internal(&state_for_empty, &ws_id_empty, pane_widget, persist); }), on_state_changed: Box::new({ let state = state.clone(); move || request_session_save(&state) }), + on_split_with_tab: Box::new( + move |source_pane, target_pane, orientation, tab_id, new_pane_first| { + handle_split_with_tab( + &state_for_split_with_tab, + &ws_id_split_with_tab, + source_pane, + target_pane, + orientation, + &tab_id, + new_pane_first, + ); + }, + ), + current_config: Box::new(move || { + let s = state_for_config.borrow(); + s.config.clone() + }), }); - pane::create_pane(callbacks, working_directory, initial_state) + pane::create_pane( + callbacks, + shortcuts.clone(), + working_directory, + initial_state, + skip_default_tab, + ) } fn close_workspace(state: &State) { @@ -1732,24 +4020,49 @@ fn close_workspace(state: &State) { } fn close_workspace_by_id(state: &State, id: &str) { + close_workspace_by_id_internal(state, id, true, None); +} + +fn close_workspace_by_id_internal( + state: &State, + id: &str, + persist: bool, + preferred_active_workspace_id: Option<&str>, +) { let mut s = state.borrow_mut(); let Some(idx) = s.workspaces.iter().position(|w| w.id == id) else { return; }; + let desired_active_workspace_id = preferred_active_workspace_id + .map(ToOwned::to_owned) + .or_else(|| s.active_workspace().map(|workspace| workspace.id.clone())); let ws = s.workspaces.remove(idx); s.stack.remove(&ws.root); s.sidebar_list.remove(&ws.sidebar_row); + s.indicator_box.remove(&ws.indicator_button); if s.workspaces.is_empty() { s.active_idx = 0; drop(s); - request_session_save(state); + if persist { + request_session_save(state); + } return; } - let new_idx = idx.min(s.workspaces.len() - 1); + let remaining_workspace_ids: Vec<&str> = s + .workspaces + .iter() + .map(|workspace| workspace.id.as_str()) + .collect(); + let new_idx = next_active_workspace_index( + &remaining_workspace_ids, + desired_active_workspace_id.as_deref(), + idx, + ); s.active_idx = new_idx; + sync_indicator_active_state(&s); let stack_name = format!("ws-{}", s.workspaces[new_idx].id); s.stack.set_visible_child_name(&stack_name); @@ -1759,33 +4072,66 @@ fn close_workspace_by_id(state: &State, id: &str) { drop(s); sidebar_list.select_row(Some(&row)); - request_session_save(state); + if persist { + request_session_save(state); + } } fn switch_workspace(state: &State, idx: usize) { - let mut s = state.borrow_mut(); - if idx >= s.workspaces.len() || idx == s.active_idx { - return; - } - s.active_idx = idx; - let stack_name = format!("ws-{}", s.workspaces[idx].id); - s.stack.set_visible_child_name(&stack_name); + let (stack, stack_name, unread_handles, focus_root) = { + let mut s = state.borrow_mut(); + if idx >= s.workspaces.len() || idx == s.active_idx { + return; + } + s.active_idx = idx; + sync_indicator_active_state(&s); + let stack = s.stack.clone(); + let stack_name = format!("ws-{}", s.workspaces[idx].id); + let focus_root = s.workspaces[idx].root.clone(); + + let unread_handles = if s.workspaces[idx].unread { + let ws = &mut s.workspaces[idx]; + ws.unread = false; + Some(( + ws.notify_dot.clone(), + ws.notify_label.clone(), + ws.sidebar_row.clone(), + ws.indicator_button.clone(), + ws.indicator_unread_dot.clone(), + )) + } else { + None + }; + + (stack, stack_name, unread_handles, focus_root) + }; + + stack.set_visible_child_name(&stack_name); + glib::idle_add_local_once(move || { + focus_workspace_entrypoint(&focus_root); + }); - // Clear unread - let ws = &mut s.workspaces[idx]; - if ws.unread { - ws.unread = false; - ws.notify_dot.remove_css_class("limux-notify-dot"); - ws.notify_dot.add_css_class("limux-notify-dot-hidden"); - ws.notify_label.remove_css_class("limux-notify-msg-unread"); - ws.notify_label.add_css_class("limux-notify-msg"); - ws.notify_label.set_visible(false); - // Remove glow pulse from sidebar row - if let Some(row_box) = ws.sidebar_row.child() { + if let Some((notify_dot, notify_label, sidebar_row, indicator_btn, indicator_dot)) = + unread_handles + { + notify_dot.remove_css_class("limux-notify-dot"); + notify_dot.add_css_class("limux-notify-dot-hidden"); + notify_label.remove_css_class("limux-notify-msg-unread"); + notify_label.add_css_class("limux-notify-msg"); + notify_label.set_visible(false); + if let Some(row_box) = sidebar_row.child() { row_box.remove_css_class("limux-sidebar-row-unread"); } + // Clear unread state on indicator pill + indicator_btn.remove_css_class("limux-indicator-pill-unread"); + indicator_dot.remove_css_class("limux-indicator-unread-dot"); + indicator_dot.add_css_class("limux-indicator-unread-dot-hidden"); + indicator_dot.set_visible(false); } - drop(s); + + // If the dock toggle is parked on a pane (top-bar off, sidebar closed), + // move it to the new active workspace's leading pane. + apply_top_bar_mode(state); request_session_save(state); } @@ -1807,11 +4153,89 @@ fn cycle_workspace(state: &State, direction: i32) { sidebar_list.select_row(Some(&row)); } +fn focus_workspace_entrypoint(root: >k::Widget) { + let pane = first_leaf_pane(root); + if !pane::focus_active_tab_in_pane(&pane) { + if let Some(gl) = find_gl_area(&pane) { + gl.grab_focus(); + } else if pane.is_focusable() || pane.can_focus() { + pane.grab_focus(); + } else { + pane.child_focus(gtk::DirectionType::TabForward); + } + } +} + +fn first_leaf_pane(widget: >k::Widget) -> gtk::Widget { + if pane::is_pane_widget(widget) { + return widget.clone(); + } + + if let Some(paned) = widget.downcast_ref::() { + if let Some(child) = paned.start_child().or_else(|| paned.end_child()) { + return first_leaf_pane(&child); + } + } + + if let Some(stack) = widget.downcast_ref::() { + if let Some(visible) = stack.visible_child() { + return first_leaf_pane(&visible); + } + } + + let mut child = widget.first_child(); + while let Some(current) = child { + let candidate = first_leaf_pane(¤t); + if pane::is_pane_widget(&candidate) { + return candidate; + } + child = current.next_sibling(); + } + + widget.clone() +} + /// Default sidebar width in pixels. const SIDEBAR_WIDTH: i32 = 220; +fn sync_top_bar_visibility(state: &State) { + let (top_bar, preferred_visible, fullscreened) = { + let s = state.borrow(); + ( + s.top_bar.clone(), + s.top_bar_visible, + gtk::prelude::GtkWindowExt::is_fullscreen(&s.window), + ) + }; + + if let Some(top_bar) = top_bar { + top_bar.set_visible(preferred_visible && !fullscreened); + } +} + +fn toggle_top_bar(state: &State) { + { + let mut s = state.borrow_mut(); + s.top_bar_visible = !s.top_bar_visible; + } + sync_top_bar_visibility(state); + // Also reparent the dock/settings/+/window controls so they don't get + // stranded when the user hides the top bar via the keyboard shortcut. + apply_top_bar_mode(state); + request_session_save(state); +} + +fn toggle_fullscreen(state: &State) { + let window = state.borrow().window.clone(); + if gtk::prelude::GtkWindowExt::is_fullscreen(&window) { + window.unfullscreen(); + } else { + window.fullscreen(); + } +} + fn toggle_sidebar(state: &State) { - let (paned, expand_btn, sidebar, current, is_visible, target_width, prior_animation, epoch) = { + let (paned, sidebar, current, is_visible, target_width, prior_animation, epoch) = { let mut s = state.borrow_mut(); let Some(sidebar) = s.paned.start_child() else { return; @@ -1826,7 +4250,6 @@ fn toggle_sidebar(state: &State) { s.sidebar_animation_epoch = s.sidebar_animation_epoch.wrapping_add(1); ( s.paned.clone(), - s.expand_btn.clone(), sidebar, current, is_visible, @@ -1841,8 +4264,7 @@ fn toggle_sidebar(state: &State) { } if is_visible { - // Collapse: animate position to 0, then hide sidebar, show expand button - expand_btn.set_visible(true); + // Collapse: animate position to 0, then hide sidebar. let target = adw::CallbackAnimationTarget::new({ let p = paned.clone(); move |value| { @@ -1858,7 +4280,6 @@ fn toggle_sidebar(state: &State) { .target(&target) .build(); let state_for_done = state.clone(); - let expand_btn_for_done = expand_btn.clone(); animation.connect_done(move |_| { let is_current = { let mut s = state_for_done.borrow_mut(); @@ -1871,15 +4292,16 @@ fn toggle_sidebar(state: &State) { }; if is_current { sidebar.set_visible(false); - expand_btn_for_done.set_visible(true); + apply_top_bar_mode(&state_for_done); request_session_save(&state_for_done); } }); state.borrow_mut().sidebar_animation = Some(animation.clone()); animation.play(); } else { - // Expand: make sidebar visible, then animate position from 0 to remembered width + // Expand: make sidebar visible, then animate position from 0 to remembered width. sidebar.set_visible(true); + apply_top_bar_mode(state); paned.set_position(0); let target = adw::CallbackAnimationTarget::new({ let p = paned.clone(); @@ -1896,7 +4318,6 @@ fn toggle_sidebar(state: &State) { .target(&target) .build(); let state_for_done = state.clone(); - let expand_btn_for_done = expand_btn.clone(); animation.connect_done(move |_| { let is_current = { let mut s = state_for_done.borrow_mut(); @@ -1908,7 +4329,6 @@ fn toggle_sidebar(state: &State) { } }; if is_current { - expand_btn_for_done.set_visible(false); request_session_save(&state_for_done); } }); @@ -1921,149 +4341,144 @@ fn toggle_sidebar(state: &State) { // Split / close pane operations // --------------------------------------------------------------------------- +struct SplitPaneOptions { + initial_state: Option, + skip_default_tab: bool, + new_pane_first: bool, + persist: bool, +} + fn split_pane( state: &State, ws_id: &str, pane_widget: >k::Widget, orientation: gtk::Orientation, -) { - // Use the workspace's folder_path (or current cwd) for the new pane - let wd = { + options: SplitPaneOptions, +) -> gtk::Widget { + let (shortcuts, wd, container) = { let s = state.borrow(); - s.workspaces - .iter() - .find(|w| w.id == ws_id) - .and_then(|ws| ws.folder_path.clone().or_else(|| ws.cwd.borrow().clone())) + ( + s.shortcuts.clone(), + s.workspaces + .iter() + .find(|w| w.id == ws_id) + .and_then(|ws| ws.folder_path.clone().or_else(|| ws.cwd.borrow().clone())), + s.workspaces + .iter() + .find(|w| w.id == ws_id) + .map(|ws| ws.split_container.clone()), + ) + }; + let Some(container) = container else { + return pane_widget.clone(); }; - let new_pane = create_pane_for_workspace(state, ws_id, wd.as_deref(), None); - - let parent = pane_widget.parent(); - let new_paned = gtk::Paned::builder() - .orientation(orientation) - .hexpand(true) - .vexpand(true) - .build(); - update_split_ratio_state(&new_paned, layout_state::DEFAULT_SPLIT_RATIO); - attach_split_position_persistence(state, &new_paned); - - if let Some(parent) = parent { - if let Some(paned_parent) = parent.downcast_ref::() { - let is_start = paned_parent - .start_child() - .map(|c| c == *pane_widget) - .unwrap_or(false); - if is_start { - paned_parent.set_start_child(Some(&new_paned)); - } else { - paned_parent.set_end_child(Some(&new_paned)); - } - } else if let Some(stack) = parent.downcast_ref::() { - let page_name = format!("ws-{ws_id}"); - stack.remove(pane_widget); - stack.add_named(&new_paned, Some(&page_name)); - stack.set_visible_child_name(&page_name); - // Update root reference - let mut s = state.borrow_mut(); - if let Some(ws) = s.workspaces.iter_mut().find(|w| w.id == ws_id) { - ws.root = new_paned.clone().upcast(); - } - } - } + let new_pane = create_pane_for_workspace( + state, + &shortcuts, + ws_id, + wd.as_deref(), + options.initial_state.as_ref(), + options.skip_default_tab, + ); - new_paned.set_start_child(Some(pane_widget)); - new_paned.set_end_child(Some(&new_pane)); + // Mutate the data model and trigger async widget tree rebuild. + // The existing pane's GLArea will be unrealized then re-realized + // on separate ticks, avoiding the GTK4 GLArea breakage. + container.split( + pane_widget, + new_pane.clone().upcast(), + orientation, + options.new_pane_first, + layout_state::DEFAULT_SPLIT_RATIO, + ); - // 50% split after layout + // Split may have changed which pane is the workspace's leading one. { - let np = new_paned.clone(); + let state = state.clone(); glib::idle_add_local_once(move || { - let alloc = np.allocation(); - let size = if orientation == gtk::Orientation::Horizontal { - alloc.width() - } else { - alloc.height() - }; - if size > 0 { - np.set_position(size / 2); - } + apply_top_bar_mode(&state); }); } - request_session_save(state); + + if options.persist { + request_session_save(state); + } + new_pane.upcast() } fn remove_pane(state: &State, ws_id: &str, pane_widget: >k::Widget) { - let parent = pane_widget.parent(); + remove_pane_internal(state, ws_id, pane_widget, true); +} + +fn remove_pane_internal(state: &State, ws_id: &str, pane_widget: >k::Widget, persist: bool) { + let container = { + let s = state.borrow(); + s.workspaces + .iter() + .find(|w| w.id == ws_id) + .map(|ws| ws.split_container.clone()) + }; + + let Some(container) = container else { return }; + + // If this is the only pane, close the entire workspace + if container.is_single_pane() { + close_workspace_by_id(state, ws_id); + return; + } - let Some(parent) = parent else { - return; - }; + // Mutate the data model and trigger async widget tree rebuild + container.remove(pane_widget); - if let Some(paned) = parent.downcast_ref::() { - // Find sibling - let sibling = if paned - .start_child() - .map(|c| c == *pane_widget) - .unwrap_or(false) - { - paned.end_child() - } else { - paned.start_child() - }; + // After the pane is removed, the workspace's leading pane may be a + // different widget — reapply so the dock toggle (when top bar is off and + // sidebar closed) lands on the new leading pane. Run on idle so the + // split-tree rebuild has finished allocating the new widgets. + { + let state = state.clone(); + glib::idle_add_local_once(move || { + apply_top_bar_mode(&state); + }); + } - if let Some(sibling) = sibling { - // Move focus to the sibling's GLArea before detaching to avoid - // GTK focus tracking warnings on ancestor Paneds. - if let Some(gl) = find_gl_area(&sibling) { - gl.grab_focus(); - } + if persist { + request_session_save(state); + } +} - // Walk up and clear focus_child on all ancestor Paneds - let mut ancestor = paned.parent(); - while let Some(a) = ancestor { - if let Some(ap) = a.downcast_ref::() { - ap.set_focus_child(gtk::Widget::NONE); - } - ancestor = a.parent(); - } - paned.set_focus_child(gtk::Widget::NONE); - paned.set_start_child(gtk::Widget::NONE); - paned.set_end_child(gtk::Widget::NONE); - - if let Some(grandparent) = paned.parent() { - if let Some(gp_paned) = grandparent.downcast_ref::() { - let is_start = gp_paned - .start_child() - .map(|c| c == paned.clone().upcast::()) - .unwrap_or(false); - if is_start { - gp_paned.set_start_child(Some(&sibling)); - } else { - gp_paned.set_end_child(Some(&sibling)); - } - } else if let Some(stack) = grandparent.downcast_ref::() { - let page_name = format!("ws-{ws_id}"); - stack.remove(paned); - stack.add_named(&sibling, Some(&page_name)); - stack.set_visible_child_name(&page_name); - let mut s = state.borrow_mut(); - if let Some(ws) = s.workspaces.iter_mut().find(|w| w.id == ws_id) { - ws.root = sibling.clone(); - } - } - } - } - } else if parent.downcast_ref::().is_some() { - // This is the only pane in the workspace — close the workspace - close_workspace_by_id(state, ws_id); +fn handle_split_with_tab( + state: &State, + ws_id: &str, + source_pane: >k::Widget, + target_pane: >k::Widget, + orientation: gtk::Orientation, + tab_id: &str, + new_pane_first: bool, +) { + if pane::tab_title(source_pane, tab_id).is_none() { return; } - request_session_save(state); + let new_pane = split_pane( + state, + ws_id, + target_pane, + orientation, + SplitPaneOptions { + initial_state: None, + skip_default_tab: true, + new_pane_first, + persist: false, + }, + ); + if pane::move_tab_to_pane(source_pane, tab_id, &new_pane) { + request_session_save(state); + } } /// Find the focused pane widget (a gtk::Box with class limux-pane-toolbar child) /// by walking up from the currently focused widget. -fn find_focused_pane(state: &State) -> Option<(String, gtk::Widget)> { +fn find_leaf_focused_pane(state: &State) -> Option<(String, gtk::Widget)> { let (ws_id, root, stack) = { let s = state.borrow(); let ws = s.active_workspace()?; @@ -2082,18 +4497,195 @@ fn find_focused_pane(state: &State) -> Option<(String, gtk::Widget)> { if c.has_css_class("limux-pane-header") { return Some((ws_id, w)); } + // Header may be wrapped in a WindowHandle for window dragging. + if let Some(handle) = c.downcast_ref::() { + if let Some(inner) = handle.child() { + if inner.has_css_class("limux-pane-header") { + return Some((ws_id, w)); + } + } + } child = c.next_sibling(); } } widget = w.parent(); } - Some((ws_id, root)) + let _ = root; + None +} + +fn find_focused_pane(state: &State) -> Option<(String, gtk::Widget)> { + if let Some(found) = find_leaf_focused_pane(state) { + return Some(found); + } + + let (ws_id, root) = { + let s = state.borrow(); + let ws = s.active_workspace()?; + (ws.id.clone(), ws.root.clone()) + }; + + Some((ws_id, first_leaf_pane(&root))) +} + +fn focused_shortcut_target(state: &State) -> pane::FocusedShortcutTarget { + let Some((_ws_id, pane_widget)) = find_leaf_focused_pane(state) else { + return pane::FocusedShortcutTarget::None; + }; + pane::focused_shortcut_target(&pane_widget) +} + +fn show_runtime_error(state: &State, title: &str, detail: &str) { + let window = state.borrow().window.clone(); + let dialog = gtk::AlertDialog::builder() + .modal(true) + .message(title) + .detail(detail) + .build(); + dialog.show(Some(&window)); +} + +fn quit_app(state: &State) { + save_session_now(state); + state.borrow().app.quit(); +} + +fn spawn_new_instance(state: &State) -> bool { + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(err) => { + let detail = format!("Failed to resolve the current Limux executable: {err}"); + eprintln!("limux: {detail}"); + show_runtime_error(state, "Failed to open a new Limux instance", &detail); + return false; + } + }; + + match std::process::Command::new(exe).spawn() { + Ok(_) => true, + Err(err) => { + let detail = format!("Failed to launch a new Limux instance: {err}"); + eprintln!("limux: {detail}"); + show_runtime_error(state, "Failed to open a new Limux instance", &detail); + false + } + } +} + +fn dispatch_terminal_command(state: &State, command: ShortcutCommand) -> bool { + let pane::FocusedShortcutTarget::Terminal(target) = focused_shortcut_target(state) else { + return false; + }; + + match command { + ShortcutCommand::SurfaceFind => target.show_find(), + ShortcutCommand::SurfaceFindNext => target.find_next(), + ShortcutCommand::SurfaceFindPrevious => target.find_previous(), + ShortcutCommand::SurfaceFindHide => target.hide_find(), + ShortcutCommand::SurfaceUseSelectionForFind => target.use_selection_for_find(), + ShortcutCommand::TerminalClearScrollback => target.perform_binding_action("clear_screen"), + ShortcutCommand::TerminalCopy => target.perform_binding_action("copy_to_clipboard"), + ShortcutCommand::TerminalPaste => target.perform_binding_action("paste_from_clipboard"), + ShortcutCommand::TerminalIncreaseFontSize => { + persist_font_size_delta(1.0); + broadcast_font_size(); + true + } + ShortcutCommand::TerminalDecreaseFontSize => { + persist_font_size_delta(-1.0); + broadcast_font_size(); + true + } + ShortcutCommand::TerminalResetFontSize => { + persist_font_size_reset(); + crate::terminal::broadcast_binding_action("reset_font_size"); + true + } + _ => false, + } +} + +fn persist_font_size_delta(delta: f32) { + let current = app_config::load() + .config + .font_size + .unwrap_or_else(crate::terminal::default_font_size); + let new_size = (current + delta).clamp(1.0, 255.0); + if let Err(err) = app_config::save_font_size(new_size) { + eprintln!("limux: {err}"); + } +} + +fn persist_font_size_reset() { + if let Err(err) = app_config::clear_font_size() { + eprintln!("limux: {err}"); + } +} + +fn broadcast_font_size() { + let size = app_config::load() + .config + .font_size + .unwrap_or_else(crate::terminal::default_font_size); + let action = format!("set_font_size:{size}"); + crate::terminal::broadcast_binding_action(&action); +} + +fn dispatch_browser_command(state: &State, command: ShortcutCommand) -> bool { + let pane::FocusedShortcutTarget::Browser(target) = focused_shortcut_target(state) else { + return false; + }; + + match command { + ShortcutCommand::BrowserFocusLocation => target.focus_location(), + ShortcutCommand::BrowserBack => target.go_back(), + ShortcutCommand::BrowserForward => target.go_forward(), + ShortcutCommand::BrowserReload => target.reload(), + ShortcutCommand::BrowserInspector => target.show_inspector(), + ShortcutCommand::BrowserConsole => target.show_console(), + ShortcutCommand::SurfaceFind => target.show_find(), + ShortcutCommand::SurfaceFindNext => target.find_next(), + ShortcutCommand::SurfaceFindPrevious => target.find_previous(), + ShortcutCommand::SurfaceFindHide => target.hide_find(), + ShortcutCommand::SurfaceUseSelectionForFind => target.use_selection_for_find(), + ShortcutCommand::OpenBrowserInSplit => { + let uri = target.current_uri(); + let Some((ws_id, pane_widget)) = find_leaf_focused_pane(state) else { + return false; + }; + let _ = split_pane( + state, + &ws_id, + &pane_widget, + gtk::Orientation::Horizontal, + SplitPaneOptions { + initial_state: Some(PaneState::browser_only(uri.as_deref())), + skip_default_tab: false, + new_pane_first: false, + persist: true, + }, + ); + true + } + _ => false, + } } fn split_focused_pane(state: &State, orientation: gtk::Orientation) { if let Some((ws_id, pane_widget)) = find_focused_pane(state) { - split_pane(state, &ws_id, &pane_widget, orientation); + let _ = split_pane( + state, + &ws_id, + &pane_widget, + orientation, + SplitPaneOptions { + initial_state: None, + skip_default_tab: false, + new_pane_first: false, + persist: true, + }, + ); } } @@ -2187,7 +4779,7 @@ fn focus_pane_in_direction(state: &State, direction: Direction) { /// Recursively find the first visible GLArea inside a widget tree. /// For gtk::Stack containers, only descend into the visible child. -fn find_gl_area(widget: >k::Widget) -> Option { +pub(crate) fn find_gl_area(widget: >k::Widget) -> Option { if let Some(gl) = widget.downcast_ref::() { return Some(gl.clone()); } @@ -2235,6 +4827,21 @@ fn find_leaf_pane(widget: >k::Widget, axis: gtk::Orientation, prefer_start: bo } fn mark_workspace_unread(state: &State, ws_id: &str) { + mark_workspace_unread_with_message(state, ws_id, "Process needs attention"); +} + +fn workspace_notification_message(title: &str, body: &str) -> String { + let title = title.trim(); + let body = body.trim(); + match (title.is_empty(), body.is_empty()) { + (false, false) => format!("{title}: {body}"), + (false, true) => title.to_string(), + (true, false) => body.to_string(), + (true, true) => "Process needs attention".to_string(), + } +} + +fn mark_workspace_unread_with_message(state: &State, ws_id: &str, message: &str) { let mut s = state.borrow_mut(); let active_idx = s.active_idx; if let Some((idx, ws)) = s @@ -2247,21 +4854,67 @@ fn mark_workspace_unread(state: &State, ws_id: &str) { ws.unread = true; ws.notify_dot.remove_css_class("limux-notify-dot-hidden"); ws.notify_dot.add_css_class("limux-notify-dot"); - ws.notify_label.set_label("Process needs attention"); + ws.notify_label.set_label(message); ws.notify_label.remove_css_class("limux-notify-msg"); ws.notify_label.add_css_class("limux-notify-msg-unread"); ws.notify_label.set_visible(true); - // Add glow pulse to the sidebar row box if let Some(row_box) = ws.sidebar_row.child() { row_box.add_css_class("limux-sidebar-row-unread"); } + // Show unread state on indicator pill + ws.indicator_button + .add_css_class("limux-indicator-pill-unread"); + ws.indicator_unread_dot + .remove_css_class("limux-indicator-unread-dot-hidden"); + ws.indicator_unread_dot + .add_css_class("limux-indicator-unread-dot"); + ws.indicator_unread_dot.set_visible(true); } } } #[cfg(test)] mod tests { - use super::{clamp_workspace_insert_index_for_pinning, favorites_prefix_len}; + use std::cell::RefCell; + use std::rc::Rc; + + use super::glib; + use super::gtk::ffi; + use super::gtk::gdk; + use super::{ + build_window_css, clamp_workspace_insert_index_for_pinning, favorites_prefix_len, + ghostty_prefers_dark, gtk_system_prefers_dark_from_raw, next_active_workspace_index, + queue_session_save_request, resolved_system_prefers_dark, sanitize_background_opacity, + shortcut_allowed_while_browser_find_active, shortcut_blocked_by_editable, + shortcut_command_from_key_event, shortcut_dispatch_propagation, tab_drag_workspace_seed, + use_opaque_window_background, workspace_drop_layout_path, workspace_notification_message, + EditableCaptureContext, PortalColorSchemePreference, SessionSaveAccess, SessionSaveRequest, + WorkspaceSeedSource, BASE_CSS, HOST_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASS, + WORKSPACE_RENAME_ENTRY_CSS_CLASSES, + }; + use crate::layout_state::{LayoutNodeState, PaneState, SplitOrientation, SplitState}; + use crate::shortcut_config::{ + default_shortcuts, resolve_shortcuts_from_str, EditableCapturePolicy, ShortcutCommand, + }; + #[derive(Default)] + struct TestSessionSaveState { + persistence_suspended: bool, + save_queued: bool, + } + + impl SessionSaveAccess for TestSessionSaveState { + fn persistence_suspended(&self) -> bool { + self.persistence_suspended + } + + fn save_queued(&self) -> bool { + self.save_queued + } + + fn set_save_queued(&mut self, queued: bool) { + self.save_queued = queued; + } + } #[test] fn favorites_prefix_len_counts_only_leading_favorites() { @@ -2269,6 +4922,80 @@ mod tests { assert_eq!(favorites_prefix_len(&flags), 2); } + #[test] + fn sanitize_background_opacity_clamps_invalid_values() { + assert_eq!(sanitize_background_opacity(f64::NAN), 1.0); + assert_eq!(sanitize_background_opacity(-0.2), 0.0); + assert_eq!(sanitize_background_opacity(1.7), 1.0); + assert_eq!(sanitize_background_opacity(0.42), 0.42); + } + + #[test] + fn transparent_window_background_only_applies_below_full_opacity() { + assert!(!use_opaque_window_background(0.8)); + assert!(use_opaque_window_background(1.0)); + assert!(use_opaque_window_background(5.0)); + assert!(use_opaque_window_background(f64::NAN)); + } + + #[test] + fn build_window_css_uses_resolved_background_opacity() { + let css = build_window_css(0.42); + assert!(css.contains(".limux-host-entry")); + assert!(css.contains(".limux-host-entry text")); + assert!(css.contains(".limux-host-entry text placeholder")); + assert!(css.contains(".limux-content")); + assert!(css.contains("background-color: rgba(23, 23, 23, 0.420);")); + } + + #[test] + fn base_css_defines_theme_aware_host_entry_styles() { + assert!(BASE_CSS.contains(":root")); + assert!(BASE_CSS.contains("@media (prefers-color-scheme: dark)")); + assert!(BASE_CSS.contains(".limux-host-entry")); + assert!(BASE_CSS.contains(".limux-host-entry text")); + assert!(BASE_CSS.contains(".limux-host-entry text placeholder")); + assert!(BASE_CSS.contains("caret-color: currentColor;")); + } + + #[test] + fn workspace_rename_entry_uses_shared_host_entry_class() { + assert_eq!( + WORKSPACE_RENAME_ENTRY_CSS_CLASSES, + [HOST_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASS] + ); + assert!(BASE_CSS.contains(".limux-ws-rename-entry")); + } + + #[test] + fn queue_session_save_request_sets_queued_once() { + let state = Rc::new(RefCell::new(TestSessionSaveState::default())); + + assert_eq!( + queue_session_save_request(&state), + SessionSaveRequest::FlushOnIdle + ); + assert!(state.borrow().save_queued); + assert_eq!( + queue_session_save_request(&state), + SessionSaveRequest::Ignore + ); + } + + #[test] + fn queue_session_save_request_retries_when_state_is_already_borrowed() { + let state = Rc::new(RefCell::new(TestSessionSaveState::default())); + let borrow = state.borrow_mut(); + + assert_eq!( + queue_session_save_request(&state), + SessionSaveRequest::RetryOnIdle + ); + + drop(borrow); + assert!(!state.borrow().save_queued); + } + #[test] fn unpinned_workspace_cannot_move_above_favorites() { // Remaining order after removing dragged workspace: @@ -2287,4 +5014,419 @@ mod tests { clamp_workspace_insert_index_for_pinning(&after_removal, true, after_removal.len()); assert_eq!(clamped, 2); } + + #[test] + fn system_prefers_dark_from_raw_maps_known_values() { + assert_eq!( + gtk_system_prefers_dark_from_raw(Some(ffi::GTK_INTERFACE_COLOR_SCHEME_DARK)), + Some(true) + ); + assert_eq!( + gtk_system_prefers_dark_from_raw(Some(ffi::GTK_INTERFACE_COLOR_SCHEME_LIGHT)), + Some(false) + ); + assert_eq!( + gtk_system_prefers_dark_from_raw(Some(ffi::GTK_INTERFACE_COLOR_SCHEME_DEFAULT)), + Some(false) + ); + assert_eq!( + gtk_system_prefers_dark_from_raw(Some(ffi::GTK_INTERFACE_COLOR_SCHEME_UNSUPPORTED)), + None + ); + } + + #[test] + fn portal_color_scheme_preference_resolves_with_gnome_fallback() { + assert_eq!( + PortalColorSchemePreference::from_raw(1), + Some(PortalColorSchemePreference::Dark) + ); + assert_eq!( + PortalColorSchemePreference::from_raw(2), + Some(PortalColorSchemePreference::Light) + ); + assert_eq!( + PortalColorSchemePreference::from_raw(0), + Some(PortalColorSchemePreference::Default) + ); + assert_eq!( + resolved_system_prefers_dark(PortalColorSchemePreference::Dark, Some(false)), + Some(true) + ); + assert_eq!( + resolved_system_prefers_dark(PortalColorSchemePreference::Light, Some(true)), + Some(false) + ); + assert_eq!( + resolved_system_prefers_dark(PortalColorSchemePreference::Default, Some(true)), + Some(true) + ); + assert_eq!( + resolved_system_prefers_dark(PortalColorSchemePreference::Unknown, Some(false)), + Some(false) + ); + } + + #[test] + fn ghostty_prefers_dark_uses_system_preference_when_requested() { + assert!(ghostty_prefers_dark( + crate::app_config::ColorScheme::System, + Some(true), + false + )); + assert!(!ghostty_prefers_dark( + crate::app_config::ColorScheme::System, + Some(false), + true + )); + assert!(ghostty_prefers_dark( + crate::app_config::ColorScheme::System, + None, + true + )); + } + + #[test] + fn ghostty_prefers_dark_honors_explicit_overrides() { + assert!(ghostty_prefers_dark( + crate::app_config::ColorScheme::Dark, + Some(false), + false + )); + assert!(!ghostty_prefers_dark( + crate::app_config::ColorScheme::Light, + Some(true), + true + )); + } + + #[test] + fn workspace_notification_message_prefers_title_and_body() { + assert_eq!( + workspace_notification_message("Codex", "Turn complete"), + "Codex: Turn complete" + ); + assert_eq!(workspace_notification_message("Codex", ""), "Codex"); + assert_eq!( + workspace_notification_message("", "Turn complete"), + "Turn complete" + ); + assert_eq!( + workspace_notification_message(" ", " "), + "Process needs attention" + ); + } + + #[test] + fn shortcut_command_from_key_event_uses_default_registry_bindings() { + let shortcuts = default_shortcuts(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::T, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::NewTerminal) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::Page_Down, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::NextWorkspace) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::F, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::SurfaceFind) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::C, + gdk::ModifierType::CONTROL_MASK + ), + None + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::C, + gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK + ), + Some(ShortcutCommand::TerminalCopy) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::Q, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::QuitApp) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::N, + gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK + ), + Some(ShortcutCommand::NewInstance) + ); + assert_eq!( + shortcut_command_from_key_event(&shortcuts, gdk::Key::F11, gdk::ModifierType::empty()), + Some(ShortcutCommand::ToggleFullscreen) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::M, + gdk::ModifierType::CONTROL_MASK + ), + Some(ShortcutCommand::ToggleSidebar) + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::M, + gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::SHIFT_MASK + ), + Some(ShortcutCommand::ToggleTopBar) + ); + } + + #[test] + fn shortcut_command_from_key_event_honors_remaps_and_disables_old_binding() { + let shortcuts = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b" + } + }"#, + ) + .unwrap(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::M, + gdk::ModifierType::CONTROL_MASK + ), + None + ); + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::B, + gdk::ModifierType::CONTROL_MASK | gdk::ModifierType::ALT_MASK + ), + Some(ShortcutCommand::ToggleSidebar) + ); + } + + #[test] + fn shortcut_command_from_key_event_respects_explicit_unbinds() { + let shortcuts = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": null + } + }"#, + ) + .unwrap(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::M, + gdk::ModifierType::CONTROL_MASK + ), + None + ); + } + + #[test] + fn shortcut_command_from_key_event_honors_super_remaps() { + let shortcuts = resolve_shortcuts_from_str( + r#"{ + "shortcuts": { + "toggle_sidebar": "b" + } + }"#, + ) + .unwrap(); + + assert_eq!( + shortcut_command_from_key_event( + &shortcuts, + gdk::Key::M, + gdk::ModifierType::CONTROL_MASK + ), + None + ); + assert_eq!( + shortcut_command_from_key_event(&shortcuts, gdk::Key::B, gdk::ModifierType::SUPER_MASK), + Some(ShortcutCommand::ToggleSidebar) + ); + } + + #[test] + fn shortcut_dispatch_propagation_stops_only_when_window_claims_shortcut() { + assert_eq!(shortcut_dispatch_propagation(true), glib::Propagation::Stop); + assert_eq!( + shortcut_dispatch_propagation(false), + glib::Propagation::Proceed + ); + } + + #[test] + fn shortcut_blocked_by_editable_only_bypasses_non_global_shortcuts() { + assert!(shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFind, + EditableCapturePolicy::BypassInEditable, + EditableCaptureContext { + gtk_editable: true, + ..EditableCaptureContext::default() + } + )); + assert!(!shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFind, + EditableCapturePolicy::AlwaysCapture, + EditableCaptureContext { + gtk_editable: true, + ..EditableCaptureContext::default() + } + )); + assert!(!shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFind, + EditableCapturePolicy::BypassInEditable, + EditableCaptureContext::default() + )); + } + + #[test] + fn shortcut_blocked_by_editable_blocks_dom_editable_browser_content() { + assert!(shortcut_blocked_by_editable( + ShortcutCommand::BrowserReload, + EditableCapturePolicy::BypassInEditable, + EditableCaptureContext { + browser_dom_editable: true, + ..EditableCaptureContext::default() + } + )); + } + + #[test] + fn browser_find_navigation_shortcuts_are_allowed_while_find_ui_is_active() { + let context = EditableCaptureContext { + gtk_editable: true, + browser_find_active: true, + ..EditableCaptureContext::default() + }; + + assert!(!shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFindNext, + EditableCapturePolicy::BypassInEditable, + context + )); + assert!(!shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFindPrevious, + EditableCapturePolicy::BypassInEditable, + context + )); + assert!(!shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFindHide, + EditableCapturePolicy::BypassInEditable, + context + )); + assert!(shortcut_blocked_by_editable( + ShortcutCommand::SurfaceFind, + EditableCapturePolicy::BypassInEditable, + context + )); + } + + #[test] + fn browser_find_active_exception_is_limited_to_navigation_shortcuts() { + assert!(shortcut_allowed_while_browser_find_active( + ShortcutCommand::SurfaceFindNext + )); + assert!(shortcut_allowed_while_browser_find_active( + ShortcutCommand::SurfaceFindPrevious + )); + assert!(shortcut_allowed_while_browser_find_active( + ShortcutCommand::SurfaceFindHide + )); + assert!(!shortcut_allowed_while_browser_find_active( + ShortcutCommand::SurfaceFind + )); + } + + #[test] + fn workspace_drop_layout_path_prefers_deterministic_startmost_leaf() { + let layout = LayoutNodeState::Split(SplitState { + orientation: SplitOrientation::Horizontal, + ratio: 0.5, + start: Box::new(LayoutNodeState::Split(SplitState { + orientation: SplitOrientation::Vertical, + ratio: 0.5, + start: Box::new(LayoutNodeState::Pane(PaneState::fallback(Some("/a")))), + end: Box::new(LayoutNodeState::Pane(PaneState::fallback(Some("/b")))), + })), + end: Box::new(LayoutNodeState::Pane(PaneState::fallback(Some("/c")))), + }); + + assert_eq!(workspace_drop_layout_path(&layout), vec![true, true]); + } + + #[test] + fn next_active_workspace_index_preserves_current_active_workspace() { + let remaining = ["source-b", "destination", "other"]; + assert_eq!( + next_active_workspace_index(&remaining, Some("destination"), 0), + 1 + ); + } + + #[test] + fn next_active_workspace_index_falls_back_to_removed_slot_when_active_is_gone() { + let remaining = ["left", "right"]; + assert_eq!(next_active_workspace_index(&remaining, Some("gone"), 1), 1); + } + + #[test] + fn tab_drag_workspace_seed_uses_terminal_cwd_for_folder_path() { + let seed = tab_drag_workspace_seed( + WorkspaceSeedSource { + workspace_cwd: Some("/workspace".to_string()), + workspace_folder_path: Some("/workspace".to_string()), + }, + "Project Shell", + Some("/project".to_string()), + ); + + assert_eq!(seed.name, "Project Shell"); + assert_eq!(seed.cwd.as_deref(), Some("/project")); + assert_eq!(seed.folder_path.as_deref(), Some("/project")); + } + + #[test] + fn tab_drag_workspace_seed_uses_workspace_directory_for_non_terminal_tab() { + let seed = tab_drag_workspace_seed( + WorkspaceSeedSource { + workspace_cwd: Some("/workspace-cwd".to_string()), + workspace_folder_path: Some("/workspace-folder".to_string()), + }, + "Browser", + None, + ); + + assert_eq!(seed.name, "Browser"); + assert_eq!(seed.cwd.as_deref(), Some("/workspace-folder")); + assert_eq!(seed.folder_path.as_deref(), Some("/workspace-folder")); + } } diff --git a/scripts/package.sh b/scripts/package.sh index 37096062..f7047201 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -13,6 +13,7 @@ PKG_BASE="limux-${VERSION}-linux-${ARCH}" STAGE="/tmp/limux-staging" GHOSTTY_SO="${ROOT_DIR}/ghostty/zig-out/lib/libghostty.so" GHOSTTY_SHARE_DIR="" +GHOSTTY_TERMINFO_DIR="" ICONS_DIR="${ROOT_DIR}/rust/limux-host-linux/icons" APP_ICONS_DIR="${ROOT_DIR}/rust/limux-host-linux/icons/app" DESKTOP_FILE="${ROOT_DIR}/rust/limux-host-linux/dev.limux.linux.desktop" @@ -48,14 +49,64 @@ resolve_ghostty_share_dir() { return 1 } +resolve_ghostty_terminfo_dir() { + local candidate + local parent + + parent="$(dirname "$GHOSTTY_SHARE_DIR")" + + for candidate in \ + "${parent}/terminfo" \ + "/usr/local/share/terminfo" \ + "/usr/share/terminfo" + do + if [ -f "${candidate}/g/ghostty" ] || [ -f "${candidate}/x/xterm-ghostty" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +copy_ghostty_terminfo_entries() { + local source_dir="$1" + local dest_dir="$2" + + mkdir -p "${dest_dir}/g" "${dest_dir}/x" + + if [ -f "${source_dir}/g/ghostty" ]; then + cp "${source_dir}/g/ghostty" "${dest_dir}/g/ghostty" + fi + + if [ -f "${source_dir}/x/xterm-ghostty" ]; then + cp "${source_dir}/x/xterm-ghostty" "${dest_dir}/x/xterm-ghostty" + fi +} + echo "=== Limux Packager ===" echo "Version: ${VERSION}" echo "Arch: ${ARCH}" -# Verify libghostty.so exists +if ! command -v zig >/dev/null 2>&1; then + echo "ERROR: zig not found in PATH." + echo "Install Zig, then rerun ./scripts/package.sh" + exit 1 +fi + +if [ ! -f "${ROOT_DIR}/ghostty/build.zig" ]; then + echo "ERROR: Ghostty submodule is missing or uninitialized at ${ROOT_DIR}/ghostty" + echo "Run: git submodule update --init --recursive" + exit 1 +fi + +# Always build libghostty with ReleaseFast to guarantee optimized output. +# A Debug build (Zig's default) causes ~7x slower terminal IO throughput. +echo "Building libghostty (ReleaseFast)..." +(cd "${ROOT_DIR}/ghostty" && zig build -Dapp-runtime=none -Doptimize=ReleaseFast) + if [ ! -f "$GHOSTTY_SO" ]; then - echo "ERROR: libghostty.so not found at ${GHOSTTY_SO}" - echo "Build it first: cd ghostty && zig build -Dapp-runtime=none -Doptimize=ReleaseFast" + echo "ERROR: libghostty.so not found at ${GHOSTTY_SO} after build" exit 1 fi @@ -68,6 +119,15 @@ if ! GHOSTTY_SHARE_DIR="$(resolve_ghostty_share_dir)"; then exit 1 fi +if ! GHOSTTY_TERMINFO_DIR="$(resolve_ghostty_terminfo_dir)"; then + echo "ERROR: Ghostty terminfo directory not found." + echo "Looked for:" + echo " $(dirname "$GHOSTTY_SHARE_DIR")/terminfo" + echo " /usr/local/share/terminfo" + echo " /usr/share/terminfo" + exit 1 +fi + # Build release binary echo "Building release binary..." cargo build --release --manifest-path "${ROOT_DIR}/Cargo.toml" @@ -91,7 +151,8 @@ populate_tree() { local prefix="${2:-/usr/local}" local bindir="$dest${prefix}/bin" local libdir="$dest${prefix}/lib/limux" - local ghostty_resdir="$dest${prefix}/share/limux" + local ghostty_datadir="$dest${prefix}/share/limux" + local ghostty_resdir="$ghostty_datadir/ghostty" local appdir="$dest${prefix}/share/applications" local metadatadir="$dest${prefix}/share/metainfo" local icondir="$dest${prefix}/share/icons/hicolor" @@ -108,7 +169,8 @@ populate_tree() { strip --strip-debug "$libdir/libghostty.so" # Ghostty resources required for named themes and shell integration - cp -r "$GHOSTTY_SHARE_DIR" "$ghostty_resdir/ghostty" + cp -r "$GHOSTTY_SHARE_DIR"/. "$ghostty_resdir" + copy_ghostty_terminfo_entries "$GHOSTTY_TERMINFO_DIR" "$ghostty_datadir/terminfo" # Desktop file cp "$DESKTOP_FILE" "$appdir/dev.limux.linux.desktop" @@ -141,7 +203,7 @@ echo "" echo "--- Building tarball ---" TARBALL_STAGE="/tmp/${PKG_BASE}" remove_tree "$TARBALL_STAGE" -mkdir -p "$TARBALL_STAGE"/{lib,share/limux,share/applications,share/icons/hicolor/scalable/actions} +mkdir -p "$TARBALL_STAGE"/{lib,share/limux/ghostty,share/limux/terminfo,share/applications,share/icons/hicolor/scalable/actions} mkdir -p "$TARBALL_STAGE/share/metainfo" cp "$BINARY" "$TARBALL_STAGE/limux" @@ -149,7 +211,8 @@ strip "$TARBALL_STAGE/limux" chmod 755 "$TARBALL_STAGE/limux" cp "$GHOSTTY_SO" "$TARBALL_STAGE/lib/libghostty.so" strip --strip-debug "$TARBALL_STAGE/lib/libghostty.so" -cp -r "$GHOSTTY_SHARE_DIR" "$TARBALL_STAGE/share/limux/ghostty" +cp -r "$GHOSTTY_SHARE_DIR"/. "$TARBALL_STAGE/share/limux/ghostty" +copy_ghostty_terminfo_entries "$GHOSTTY_TERMINFO_DIR" "$TARBALL_STAGE/share/limux/terminfo" cp "$DESKTOP_FILE" "$TARBALL_STAGE/share/applications/dev.limux.linux.desktop" cp "$METADATA_FILE" "$TARBALL_STAGE/share/metainfo/dev.limux.linux.metainfo.xml" diff --git a/shortcut-remap-plan.md b/shortcut-remap-plan.md new file mode 100644 index 00000000..af304fdd --- /dev/null +++ b/shortcut-remap-plan.md @@ -0,0 +1,124 @@ +# Plan: Limux Host Shortcut Remapping Config + +**Generated**: 2026-03-24 + +## Overview +Implement config-backed shortcut remapping in the Linux host without coupling it to Ghostty config or session persistence. The canonical design is: + +- Store user preferences in a dedicated Limux config file under `dirs::config_dir()`, not in Ghostty config and not in `session.json` +- Define one host-owned shortcut registry keyed by stable shortcut IDs +- Define one canonical metadata layer that maps each shortcut ID to its owner, runtime dispatch target, GTK accelerator usage, and user-visible label text +- Switch GTK application accelerators and capture-phase dispatch to that same registry in one implementation step so no broken intermediate state exists +- Treat empty bindings as explicitly unbound +- Update all visible shortcut hints in host UI surfaces, including `window.rs` and `pane.rs` + +This plan keeps the shortcut feature first-class in the Linux host while avoiding a third shortcut path. `limux-core` command-palette shortcut hints remain out of scope for this first implementation and should be treated as a follow-up only if the host-side system stabilizes cleanly. + +## Prerequisites +- Existing GTK4/libadwaita host build environment +- `dirs`, `serde`, and `serde_json` already available in the workspace +- Context7/GTK docs already checked for accelerator behavior and capture-phase shortcut handling + +## Dependency Graph + +```text +T1 ── T2 ── T3 ── T4 ──┬── T5 ── T6 + └── T6 +``` + +## Tasks + +### T1: Inventory Current Host-Owned Shortcuts and Hint Surfaces +- **depends_on**: [] +- **location**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs` +- **description**: Audit every host-owned shortcut currently implemented through `app.set_accels_for_action(...)`, `register_actions()`, and `install_key_capture()`. Produce the frozen list of shortcut IDs, current default bindings, action owners, direct helper dispatch targets, GTK-global actions, capture-only actions, and all user-visible hint surfaces that currently embed hardcoded shortcut text. Explicitly mark terminal-owned combos that must always pass through to Ghostty and are out of scope for interception. +- **validation**: The implementation has a complete checklist covering all current host shortcut paths and every visible tooltip/label surface that would drift if left hardcoded. +- **status**: Completed +- **log**: `reason_not_testable`: inventory-only task. Verified by direct code inspection. Current GTK-global actions are only `win.new-workspace`, `win.close-workspace`, `win.toggle-sidebar`, `win.next-workspace`, and `win.prev-workspace` in `rust/limux-host-linux/src/main.rs:103-107`, with matching `gio::SimpleAction` wiring in `rust/limux-host-linux/src/window.rs:827-849`. Capture-only host shortcuts are implemented in `rust/limux-host-linux/src/window.rs:864-980`: `new_workspace`, `close_workspace`, `cycle_tab_prev`, `cycle_tab_next`, `split_down`, `new_terminal`, `split_right`, `close_focused_pane`, `toggle_sidebar`, `next_workspace`, `prev_workspace`, `focus_left`, `focus_right`, `focus_up`, `focus_down`, and `activate_workspace_1` through `activate_workspace_9_or_last`. Gotchas for follow-up tasks: `Ctrl+T` and `Ctrl+Shift+T` both dispatch to `add_tab_to_focused_pane(false)` in `rust/limux-host-linux/src/window.rs:890-913`; only five actions currently exist as `gio::SimpleAction`s; pane action buttons are wired independently in `rust/limux-host-linux/src/pane.rs:244-278`; and Ghostty terminal input remains the passthrough owner for unmapped keys via `ghostty_surface_key(...)` in `rust/limux-host-linux/src/terminal.rs:566-610`. UI surfaces with hardcoded shortcut text are the sidebar collapse and expand tooltips in `rust/limux-host-linux/src/window.rs:623` and `rust/limux-host-linux/src/window.rs:683`. Pane buttons in `rust/limux-host-linux/src/pane.rs:190-194` expose action tooltips without shortcut text today and will need registry-backed labels once remapping exists. +- **files edited/created**: `shortcut-remap-plan.md` + +### T2: Define Canonical Shortcut Metadata and Dispatch Layer +- **depends_on**: [T1] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/pane.rs` +- **description**: Create the first-class host shortcut definition layer. Each definition should capture stable shortcut ID, default binding, runtime owner, whether it registers a GTK accelerator, the dispatch target used by capture-phase routing, and the human-readable label/tooltip name. This is the canonical registry that both `register_actions()` and `install_key_capture()` will consume. The layer should also decide which actions remain direct helper dispatches and which are backed by `gio::SimpleAction`. +- **validation**: There is one authoritative metadata table for host shortcuts, and every current shortcut from T1 maps to exactly one runtime dispatch target and one visibility policy. +- **status**: Completed +- **log**: Verified existing branch state rather than re-implementing. `rust/limux-host-linux/src/shortcut_config.rs` already provides the canonical host shortcut metadata layer with stable IDs, config keys, action names, labels, GTK registration policy, and runtime command targets. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed, confirming the 25-definition table, uniqueness invariants, the GTK accelerator subset, and canonical runtime command mapping. Non-blocking note for follow-up: `find_by_action_name` is currently unused and triggers a `dead_code` warning. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `shortcut-remap-plan.md` + +### T3: Implement Config Schema, Path Resolution, and Validation Rules +- **depends_on**: [T2] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` (new), `rust/limux-host-linux/Cargo.toml` +- **description**: Implement the dedicated host-side shortcut config loader and merger. The config file should live at `dirs::config_dir()/limux/config.json` with deterministic overrides for tests. The schema should support omitted values for defaults and empty-string or `null` values for explicit unbinding. Make the contract explicit for these cases: `config_dir()` returning `None`, unreadable files, invalid JSON, unknown shortcut IDs, duplicate active bindings, malformed bindings, and any binding that cannot be represented consistently across GTK accelerator registration and capture-phase normalization. Use clear logging plus fallback-to-default behavior for runtime file/load failures, and fail validation for ambiguous active duplicate bindings. +- **validation**: The loader resolves the expected config path, merges overrides over defaults, preserves explicit unbinds, warns or errors exactly as specified for invalid inputs, and always returns a deterministic effective registry. +- **status**: Completed +- **log**: Verified existing branch state rather than re-implementing. `rust/limux-host-linux/src/shortcut_config.rs` already resolves `dirs::config_dir()/limux/config.json`, loads JSON overrides under the top-level `shortcuts` key, supports explicit unbinding via `null` or empty string, warns on unknown IDs, falls back to defaults on missing files and invalid JSON, and rejects duplicate active bindings before runtime use. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `shortcut-remap-plan.md` + +### T4: Add Unit Tests for Config Loading and Normalization +- **depends_on**: [T3] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` +- **description**: Add focused unit tests for config path derivation, default loading when no file exists, override application, explicit unbinding, invalid JSON fallback, unknown shortcut IDs, duplicate-binding rejection, malformed accelerator rejection, and normalization round-trips between stored values and runtime representations. Keep these tests pure and tempdir-driven so they do not depend on GTK startup. +- **validation**: `cargo test -p limux-host-linux` covers the config contract and fails if loader behavior regresses on any supported edge case. +- **status**: Completed +- **log**: Verified existing branch state rather than re-implementing. The targeted suite in `rust/limux-host-linux/src/shortcut_config.rs` already covers path derivation, normalized shortcut round-trips, override application, explicit unbinding, unknown ID warnings, duplicate-binding rejection, invalid JSON fallback, missing-file defaults, GTK accelerator exposure for unbound actions, and runtime combo-to-command routing. Validation command: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture` passed with 12 tests. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `shortcut-remap-plan.md` + +### T5: Switch GTK Accelerators and Capture-Phase Dispatch to the Same Registry +- **depends_on**: [T4] +- **location**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs` +- **description**: Replace the current hardcoded startup accelerators and the hardcoded capture-phase `match` with one registry-driven implementation in a single change. Startup should load the effective shortcut registry once, apply GTK accelerators from that registry, and ensure explicit unbinds clear accelerators. `install_key_capture()` should normalize incoming key events, resolve them through the same registry, and dispatch the mapped host action. Preserve passthrough to Ghostty for unmapped events. Do not leave any overlapping hardcoded capture bindings behind, because that would create dual active routes during remapped states. +- **validation**: Default bindings preserve current behavior, remapped bindings trigger the correct host actions, old bindings stop working once remapped, explicitly unbound actions stop intercepting input, and unmapped keys continue through to terminal surfaces. +- **status**: Completed +- **log**: RED phase added two new regression tests in `rust/limux-host-linux/src/shortcut_config.rs` for the runtime integration seam: `resolved_shortcuts_expose_registered_gtk_accels_and_clear_unbound_actions` and `resolved_shortcuts_route_runtime_combos_to_canonical_commands`. Initial validation failed with missing helper methods on `ResolvedShortcutConfig`. GREEN changes then made the shortcut registry authoritative at runtime: `rust/limux-host-linux/src/main.rs` now loads `shortcut_config::load_shortcuts()`, prints warnings once at startup, applies GTK accelerator bindings from `ResolvedShortcutConfig::gtk_accel_entries()`, and passes the resolved registry into `window::build_window(...)`. `rust/limux-host-linux/src/window.rs` now stores the resolved registry in `AppState`, registers all window actions from shortcut metadata, resolves capture-phase key events through `NormalizedShortcut::from_gdk_key(...)`, and dispatches canonical `ShortcutCommand` values through a single `dispatch_shortcut_command(...)` helper. The old hardcoded key-combo `match` was removed, so GTK accelerator registration and capture dispatch now derive from the same registry instead of separate hardcoded tables. +- **files edited/created**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/shortcut_config.rs`, `rust/limux-host-linux/src/window.rs` + +### T6: Update All Host UI Shortcut Hints and Add Regression Coverage +- **depends_on**: [T5] +- **location**: `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs`, focused helper tests where appropriate +- **description**: Remove hardcoded visible shortcut strings and derive tooltip/label text from the same effective registry used at runtime. This includes sidebar toggle strings in `window.rs` and pane action tooltips currently constructed through `icon_button()` in `pane.rs`. Add regression tests for tooltip rendering and runtime mapping helpers, including the highest-risk behavior: remaps, explicit unbinds, malformed config fallback, duplicate rejection, unknown IDs, normalization round-trips, and proof that old bindings are no longer intercepted once remapped or unbound. +- **validation**: Tooltips and labels reflect remapped shortcuts, unbound actions omit shortcut suffixes, and tests fail if a hardcoded host shortcut hint or stale binding path is reintroduced. +- **status**: Completed +- **log**: RED phase added pure tooltip-contract tests in `rust/limux-host-linux/src/shortcut_config.rs`, `rust/limux-host-linux/src/window.rs`, and `rust/limux-host-linux/src/pane.rs`, then ran `cargo test -p limux-host-linux tooltip -- --nocapture`, which failed because the display/tooltip helpers and call sites did not exist. GREEN changes added one shared display path in `rust/limux-host-linux/src/shortcut_config.rs` (`to_display_label`, `display_label_for_id`, and `tooltip_text`), replaced the hardcoded sidebar toggle tooltip strings in `rust/limux-host-linux/src/window.rs` with `sidebar_toggle_tooltip(...)`, and threaded the resolved shortcut registry into `rust/limux-host-linux/src/pane.rs` so pane action buttons derive tooltip text from the same registry while unbound actions omit shortcut suffixes. Also removed the unused `find_by_action_name` helper to keep the branch warning-free. GREEN commands: `cargo test -p limux-host-linux tooltip -- --nocapture`, `cargo test -p limux-host-linux`, and `cargo build -p limux-host-linux --features webkit` all passed. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs`, `shortcut-remap-plan.md` + +## Parallel Execution Groups + +| Wave | Tasks | Can Start When | +|------|-------|----------------| +| 1 | T1 | Immediately | +| 2 | T2 | T1 complete | +| 3 | T3 | T2 complete | +| 4 | T4 | T3 complete | +| 5 | T5 | T4 complete | +| 6 | T6 | T5 complete | + +## Testing Strategy +- Run `cargo test -p limux-host-linux` +- Run `cargo build -p limux-host-linux --features webkit` +- Manually validate these runtime cases: + - No config file: default shortcuts still work + - Override file with one remap: new binding works and old binding no longer does + - Override file with one explicit unbind: host no longer intercepts that combo and Ghostty receives it + - Invalid JSON or unknown IDs: host logs the failure path and falls back to defaults deterministically + - Duplicate active bindings: config is rejected according to the chosen validation contract, with no ambiguous runtime interception +- Launch the host for manual verification with: + +```bash +LD_LIBRARY_PATH="/home/willr/Applications/cmux-linux/cmux/ghostty/zig-out/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ +cargo run -p limux-host-linux --features webkit --bin limux +``` + +## Risks & Mitigations +- GTK accelerator strings and capture-phase event matching use different formats. + - Mitigation: keep one logical shortcut model and maintain two explicit renderers/parsers, one for GTK accelerator strings and one for normalized runtime matching. +- Startup config load failure could silently leave the app in a confusing state. + - Mitigation: log parse and validation failures clearly, then fall back to code defaults. +- Duplicate bindings could create nondeterministic action routing. + - Mitigation: reject duplicate active bindings during config validation before they reach registration or dispatch. +- Session persistence and preferences could be accidentally mixed. + - Mitigation: keep shortcut config in a separate module and file under `config_dir`, with no additions to `AppSessionState`. +- Static tooltip strings can drift from runtime behavior. + - Mitigation: derive visible shortcut hints from the same registry used for accelerator registration and capture-phase dispatch, including pane action buttons. +- `limux-core` command-palette shortcut hints currently use a separate model. + - Mitigation: explicitly keep that out of scope for the first host implementation and do not claim single-source-of-truth beyond the Linux host until a later extraction is done. diff --git a/terminal-keybinds-settings-plan.md b/terminal-keybinds-settings-plan.md new file mode 100644 index 00000000..80310064 --- /dev/null +++ b/terminal-keybinds-settings-plan.md @@ -0,0 +1,141 @@ +# Plan: Terminal Keybinds Settings Menu + +**Generated**: 2026-03-24 +**Base Commit**: `5b334b6c` (`host: finalize configurable shortcut registry`) + +## Overview +Add a `Keybinds` entry to the existing terminal right-click context menu and route it to a dedicated shortcut-editor popover. The editor should list every host-owned shortcut from the canonical registry, show the current active binding plus the default binding, let the user click into a capture field that listens for the next combo, and persist valid changes back to `~/.config/limux/config.json`. + +The implementation should stay inside the existing Linux host shortcut system rather than inventing a second settings layer. That means the same canonical definitions in `shortcut_config.rs` should drive: + +- the keybinds editor rows +- runtime shortcut interception +- GTK application accelerators +- visible tooltip text across the window and pane chrome +- config persistence and validation + +To keep the host maintainable and reduce merge conflicts during parallel execution, the editor UI and capture-state logic should live in a dedicated module such as `rust/limux-host-linux/src/keybind_editor.rs`, with `window.rs` owning only the open/apply integration points. + +Assumptions for this plan: +- shortcut edits apply immediately after a valid capture; there is no separate Save button +- active bindings must include `Ctrl` or `Alt` as the base modifier, with optional `Shift` +- the `Keybinds` item appears only in the terminal surface context menu, not browser tabs or workspace menus + +## Prerequisites +- Existing GTK4/libadwaita host build environment +- Existing host shortcut registry in `rust/limux-host-linux/src/shortcut_config.rs` +- Context7 GTK4 docs reviewed for `GtkPopover::set_autohide()` and `GtkEventControllerKey` + +## Dependency Graph + +```text +T1 ── T2 ──┐ + ├── T5 ── T6 +T3 ── T4 ──┘ +``` + +## Tasks + +### T1: Strengthen the Canonical Shortcut Model for Editor-Backed Persistence +- **depends_on**: [] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs` +- **description**: Extend the canonical shortcut module so it can serve a settings UI, not just startup loading. Add explicit helpers for enumerating every definition in display order, looking up the current/default display labels, validating user-captured bindings, and serializing only overrides back to config. Make the `Ctrl`/`Alt` base-modifier rule part of canonical validation so file-loaded bindings and UI-captured bindings follow the same contract. Keep duplicate-binding rejection centralized here. Because the file is the general `config.json`, add a read-modify-write path that preserves unrelated top-level settings and performs atomic writes (`create_dir_all`, temp file, rename) instead of overwriting the entire file blindly. +- **validation**: Pure unit tests cover: accepted combos (`Ctrl+H`, `Ctrl+Shift+H`, `Alt+X`), rejected combos (plain `H`, `Shift+H`, modifier-only keys), duplicate detection, serialization that writes only overrides/unbinds while preserving unrelated top-level config keys, and atomic write helpers that fail cleanly without corrupting an existing config file. +- **status**: Completed +- **log**: Added canonical host-binding validation in `shortcut_config.rs` so active shortcuts must include `Ctrl` or `Alt` and cannot target modifier-only keys. Added editor-facing helpers for default/current display labels plus override-only JSON serialization. Added atomic `config.json` merge-write support that preserves unrelated top-level settings and removes the `shortcuts` section when no overrides remain. Validation: `cargo test -p limux-host-linux shortcut_config::tests -- --nocapture`. +- **files edited/created**: `rust/limux-host-linux/src/shortcut_config.rs`, `terminal-keybinds-settings-plan.md` + +### T2: Make Shortcut Updates Live at Runtime +- **depends_on**: [T1] +- **location**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/pane.rs` +- **description**: Refactor the current startup-only shortcut wiring into a live update path. `AppState` should own the effective shortcut registry in a mutable form, expose one helper that swaps in a newly validated registry, reapplies GTK accelerators through the `adw::Application`, and refreshes host-owned tooltip surfaces that show shortcuts today. Extend pane internals as needed so existing pane header buttons can refresh their tooltips instead of only reflecting shortcuts at creation time. +- **validation**: A single runtime update path exists for shortcut changes. After applying a new registry, the capture-phase handler, GTK accelerators, sidebar tooltip, and pane button tooltips all reflect the new bindings without reopening the app. +- **status**: Completed +- **log**: Moved GTK accelerator application into a reusable window-owned helper and added `apply_shortcut_config(...)` so a newly validated shortcut registry can replace the live `AppState` value, refresh GTK accelerators, update sidebar toggle tooltips, and refresh existing pane action tooltips through cached `PaneInternals` button refs. Validation: `cargo test -p limux-host-linux` and `cargo build -p limux-host-linux --features webkit`. +- **files edited/created**: `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/pane.rs`, `rust/limux-host-linux/src/window.rs`, `terminal-keybinds-settings-plan.md` + +### T3: Add a Terminal Context-Menu Entry Point for Keybind Settings +- **depends_on**: [] +- **location**: `rust/limux-host-linux/src/terminal.rs`, `rust/limux-host-linux/src/pane.rs`, `rust/limux-host-linux/src/window.rs` +- **description**: Extend the existing terminal right-click menu to include a `Keybinds` item without regressing the current Copy/Paste/Split/Clear actions. Thread a first-class `on_open_keybinds` callback through `TerminalCallbacks` and `PaneCallbacks` so the terminal surface can ask the window layer to open the keybind editor using the same primary host codepath every time. Make the handoff explicit: selecting `Keybinds` should close the small context menu first and only then open the larger editor popover, ideally via an idle callback, so the new popover does not immediately dismiss or inherit the wrong transient parent state. +- **validation**: Right-clicking a terminal still shows the current context menu items plus `Keybinds`, activating `Keybinds` routes to one window-owned open-editor function rather than embedding editor state directly in `terminal.rs`, and the editor opens reliably after the context menu closes. +- **status**: Completed +- **log**: Added a `Keybinds` action to the terminal right-click menu and threaded a first-class `on_open_keybinds` callback through `TerminalCallbacks` and `PaneCallbacks` into `window.rs`. The terminal menu now pops down first and schedules the editor open via `glib::idle_add_local_once(...)` so the larger editor popover is opened from the window layer without nested-popover dismissal glitches. Validation: `cargo test -p limux-host-linux`. +- **files edited/created**: `rust/limux-host-linux/src/terminal.rs`, `rust/limux-host-linux/src/pane.rs`, `rust/limux-host-linux/src/window.rs`, `terminal-keybinds-settings-plan.md` + +### T4: Build the Keybinds Editor Popover Shell +- **depends_on**: [T1, T3] +- **location**: `rust/limux-host-linux/src/keybind_editor.rs` (new), with thin integration hooks in `rust/limux-host-linux/src/window.rs` +- **description**: Create the actual keybind editor popover as a dedicated module anchored from the terminal surface. The shell should be a `gtk::Popover` with `set_autohide(true)` so clicking outside dismisses it, plus a header row that includes a `Keybinds` title and an explicit close button at the top right. The body should be scrollable and render one row per canonical shortcut definition, showing the human-readable action label, the current binding, and the default binding as supporting text. Each binding cell should be an entry-like capture control, not a freeform text editor, so keyboard input is always normalized through the canonical shortcut path instead of mixing raw text editing with shortcut capture semantics. +- **validation**: Opening the editor from the terminal menu shows all current shortcut definitions, the top-right close button dismisses it, outside clicks also dismiss it with no orphaned popovers or double-parenting issues, and the binding cells present a clear idle/listening/error state without allowing arbitrary text entry. +- **status**: Completed +- **log**: Added the dedicated `keybind_editor.rs` module and its CSS, then built a window-owned `gtk::Popover` editor shell anchored to the terminal surface. The editor now renders every host shortcut with current binding plus default binding text, includes a top-right close button, and uses `set_autohide(true)` so outside clicks close it. Validation: `cargo test -p limux-host-linux` including `keybind_editor::tests::binding_button_label_prefers_current_binding_and_listening_state`. +- **files edited/created**: `rust/limux-host-linux/src/keybind_editor.rs`, `rust/limux-host-linux/src/main.rs`, `rust/limux-host-linux/src/window.rs`, `terminal-keybinds-settings-plan.md` + +### T5: Implement Capture, Conflict Handling, Persistence, and Live Apply +- **depends_on**: [T2, T4] +- **location**: `rust/limux-host-linux/src/keybind_editor.rs`, `rust/limux-host-linux/src/window.rs`, `rust/limux-host-linux/src/shortcut_config.rs` +- **description**: Implement the row-level editing workflow. Clicking a shortcut field should enter a clear listening state, attach a `GtkEventControllerKey`, and capture the next non-modifier key combo. Valid captures should be normalized through the canonical shortcut model, rejected if they do not use `Ctrl` or `Alt`, rejected if they conflict with another active binding, merged atomically into the `shortcuts` section of `config.json`, reloaded through the canonical config loader, and only then applied live through the shared runtime update helper. Invalid captures or write failures should leave the previous binding intact and surface a row-local error message instead of silently failing. +- **validation**: A remap such as `Ctrl+H` for `Split Right` can be captured from the UI, written to config, reloaded, applied live, and used immediately. Conflicts, invalid combos, and disk-write failures show deterministic errors, and the previous working binding remains active until a valid replacement is both persisted and reloaded successfully. +- **status**: Completed +- **log**: The editor’s binding controls now enter a listening state, capture the next key combo through `GtkEventControllerKey`, validate it through the canonical shortcut model, persist it through `write_shortcuts(...)`, reload the effective config, and apply it live with `apply_shortcut_config(...)`. Invalid combos and duplicate bindings surface row-local errors without replacing the prior working binding. Validation: `cargo test -p limux-host-linux` and `cargo build -p limux-host-linux --features webkit`. +- **files edited/created**: `rust/limux-host-linux/src/keybind_editor.rs`, `rust/limux-host-linux/src/shortcut_config.rs`, `rust/limux-host-linux/src/window.rs`, `terminal-keybinds-settings-plan.md` + +### T6: Add Regression Coverage and Manual Verification Notes +- **depends_on**: [T5] +- **location**: `rust/limux-host-linux/src/shortcut_config.rs`, `rust/limux-host-linux/src/keybind_editor.rs`, `rust/limux-host-linux/src/window.rs`, `docs/shortcut-remap-testing.md` or a new focused settings test doc if cleaner +- **description**: Add focused regression coverage for the editor contract and update the manual verification doc to include the new settings surface. Favor pure tests around validation, serialization, and row-state helpers where possible, and add narrow UI-helper tests for row text or error formatting when GTK startup is avoidable. Document a manual smoke test that exercises: open terminal menu, open `Keybinds`, capture a valid combo, reject an invalid combo, reject a duplicate combo, close via X, and close via outside click. +- **validation**: `cargo test -p limux-host-linux` covers the new canonical validation and persistence behavior, and the manual test doc provides a deterministic checklist for the interactive GTK-only behaviors. +- **status**: Completed +- **log**: Added focused keybind-editor helper coverage for binding-label and validation-error messaging, then updated `docs/shortcut-remap-testing.md` with the new terminal `Keybinds` popover flow, immediate live-apply behavior, close affordances, remap checklist, and validation checklist. Validation: `cargo test -p limux-host-linux`, `cargo build -p limux-host-linux --features webkit`, and live host launch via `cargo run -p limux-host-linux --features webkit --bin limux`. +- **files edited/created**: `rust/limux-host-linux/src/keybind_editor.rs`, `docs/shortcut-remap-testing.md`, `terminal-keybinds-settings-plan.md` + +## Parallel Execution Groups + +| Wave | Tasks | Can Start When | +|------|-------|----------------| +| 1 | T1, T3 | Immediately | +| 2 | T2, T4 | T1 and T3 complete as required | +| 3 | T5 | T2 and T4 complete | +| 4 | T6 | T5 complete | + +## Testing Strategy +- Add pure unit tests in `shortcut_config.rs` for editor-facing validation and override serialization. +- Add pure unit tests in `shortcut_config.rs` for config merge behavior so editing `shortcuts` does not delete unrelated future settings in `config.json`. +- Add focused helper tests for any non-trivial row-state formatting or conflict messaging extracted out of GTK widget callbacks. +- Run `cargo test -p limux-host-linux`. +- Run `cargo build -p limux-host-linux --features webkit`. +- Manually validate the GTK flow with: + +```bash +LD_LIBRARY_PATH="/home/willr/Applications/cmux-linux/cmux/ghostty/zig-out/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ +cargo run -p limux-host-linux --features webkit --bin limux +``` + +- Manual acceptance checklist: + - right-click inside a terminal shows `Keybinds` + - selecting `Keybinds` opens the editor popover + - all shortcut rows show current binding and default binding + - clicking the top-right X closes the editor + - clicking outside the editor closes it + - clicking a binding field enters listening mode + - `Ctrl+H` can be assigned to `Split Right` + - invalid combos without `Ctrl` or `Alt` are rejected + - duplicate active combos are rejected + - accepted remaps persist to `~/.config/limux/config.json` + - accepted remaps take effect immediately in the running app + - reopening the editor and relaunching Limux both show the persisted remap + +## Risks & Mitigations +- The current shortcut registry is loaded once and cloned into panes at creation time. + - Mitigation: make live shortcut application a first-class `window.rs` responsibility and refresh existing tooltip surfaces from stored widget refs instead of reconstructing panes. +- `config.json` is a shared preferences file, so writing only shortcut data can accidentally erase unrelated settings. + - Mitigation: implement shortcut persistence as a merge into the existing top-level JSON object plus atomic temp-file rename. +- Nested or chained popovers can leak or double-parent if each surface owns its own editor instance. + - Mitigation: keep one window-owned open-editor entrypoint, ensure every popover unparents itself on `closed`, and open the editor only after the terminal context menu has fully popped down. +- GTK accelerator updates can drift from capture-phase updates if they are applied through separate codepaths. + - Mitigation: use one shared apply helper that updates both the `AppState` shortcut registry and the GTK action accelerators in the same step. +- Key capture can accidentally swallow input meant for Ghostty if listening state leaks outside the editor. + - Mitigation: keep capture scoped to the editor field with `GtkEventControllerKey`, and only enter capture mode after the user explicitly clicks a binding field. +- The current config system accepts more modifier combinations than the new UI contract allows. + - Mitigation: move the `Ctrl`/`Alt` requirement into canonical shortcut validation so the file format and UI stay consistent.