diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 708ff752..c3643efd 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -31,7 +31,15 @@ jobs: - name: Install Linux bundle tools run: | sudo apt-get update - sudo apt-get install -y meson ninja-build python3-gi xvfb libgtk-4-dev libadwaita-1-dev + sudo apt-get install -y \ + meson \ + ninja-build \ + python3-gi \ + xvfb \ + libgtk-4-dev \ + libadwaita-1-dev \ + libjavascriptcoregtk-6.0-dev \ + libwebkitgtk-6.0-dev - name: Install pinned blueprint-compiler run: | @@ -40,14 +48,23 @@ jobs: echo "${prefix}/bin" >> "$GITHUB_PATH" "${prefix}/bin/blueprint-compiler" --version + - name: Build debug app for smoke + run: cargo build -p taskers-gtk --bin taskers-gtk + + - name: Run headless app smoke + run: | + TASKERS_TERMINAL_BACKEND=mock \ + bash scripts/headless-smoke.sh \ + ./target/debug/taskers-gtk \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 + - name: Build Linux bundle run: bash scripts/build_linux_bundle.sh - - name: Run Linux smoke checks - run: | - bash scripts/smoke_taskers_ui.sh - bash scripts/smoke_taskers_focus_churn.sh - bash scripts/smoke_linux_release_launcher.sh + - name: Run launcher smoke + run: bash scripts/smoke_linux_release_launcher.sh - name: Upload Linux bundle uses: actions/upload-artifact@v4 @@ -55,104 +72,6 @@ jobs: name: linux-bundle path: dist/taskers-linux-bundle-v*.tar.xz - macos-universal-dmg: - runs-on: macos-15 - env: - TASKERS_MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.TASKERS_MACOS_CERTIFICATE_P12_BASE64 }} - TASKERS_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.TASKERS_MACOS_CERTIFICATE_PASSWORD }} - TASKERS_MACOS_CODESIGN_IDENTITY: ${{ secrets.TASKERS_MACOS_CODESIGN_IDENTITY }} - TASKERS_MACOS_NOTARY_APPLE_ID: ${{ secrets.TASKERS_MACOS_NOTARY_APPLE_ID }} - TASKERS_MACOS_NOTARY_TEAM_ID: ${{ secrets.TASKERS_MACOS_NOTARY_TEAM_ID }} - TASKERS_MACOS_NOTARY_PASSWORD: ${{ secrets.TASKERS_MACOS_NOTARY_PASSWORD }} - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Decide whether to build signed macOS release - id: release-gate - run: | - signed_release=true - missing=() - for name in \ - TASKERS_MACOS_CERTIFICATE_P12_BASE64 \ - TASKERS_MACOS_CERTIFICATE_PASSWORD \ - TASKERS_MACOS_CODESIGN_IDENTITY \ - TASKERS_MACOS_NOTARY_APPLE_ID \ - TASKERS_MACOS_NOTARY_TEAM_ID \ - TASKERS_MACOS_NOTARY_PASSWORD; do - if [[ -z "${!name:-}" ]]; then - missing+=("${name}") - signed_release=false - fi - done - - echo "signed_release=${signed_release}" >> "$GITHUB_OUTPUT" - - if [[ "${signed_release}" != "true" ]]; then - echo "Building unsigned macOS DMG; missing signing/notary secrets: ${missing[*]}" - fi - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: | - aarch64-apple-darwin - x86_64-apple-darwin - - - name: Cache Rust artifacts - uses: Swatinem/rust-cache@v2 - - - name: Install Zig - uses: goto-bus-stop/setup-zig@v2 - with: - version: 0.15.2 - - - name: Install macOS build tools - run: | - brew update - brew install xcodegen - - - name: Install Developer ID certificate - if: steps.release-gate.outputs.signed_release == 'true' - run: bash scripts/install_macos_codesign_certificate.sh - - - name: Build universal macOS dependencies - run: TASKERS_MACOS_DEP_MODE=universal bash scripts/macos-build-preview-deps.sh - - - name: Generate Xcode project - run: TASKERS_MACOS_DEP_MODE=universal bash scripts/generate_macos_project.sh - - - name: Build universal Taskers.app - run: | - TASKERS_SKIP_MACOS_PREBUILD_DEPS=1 xcodebuild build \ - -project macos/Taskers.xcodeproj \ - -scheme TaskersMac \ - -configuration Release \ - -derivedDataPath build/macos/DerivedData \ - ARCHS="arm64 x86_64" \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO - - - name: Sign universal Taskers.app - run: bash scripts/sign_macos_app.sh - - - name: Build universal DMG - run: bash scripts/build_macos_dmg.sh - - - name: Notarize and staple universal DMG - if: steps.release-gate.outputs.signed_release == 'true' - run: | - version="$(sed -n 's/^version = \"\\(.*\\)\"/\\1/p' Cargo.toml | head -n1)" - bash scripts/notarize_macos_dmg.sh "dist/Taskers-v${version}-universal2.dmg" - - - name: Upload universal DMG - uses: actions/upload-artifact@v4 - with: - name: macos-universal-dmg - path: dist/Taskers-v*-universal2.dmg - release-manifest: needs: - linux-bundle @@ -185,7 +104,6 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') needs: - release-manifest - - macos-universal-dmg runs-on: ubuntu-latest steps: @@ -206,9 +124,6 @@ jobs: echo 'files< /dev/null; then - echo 'dist/release/Taskers-v*-universal2.dmg' - fi echo 'EOF' } >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index e369eb3f..3624bae7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ dist/ build/ macos/Taskers.xcodeproj/ +greenfield/target/ diff --git a/Cargo.lock b/Cargo.lock index 509facbb..ccfbce76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,10 +65,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "arrayvec" -version = "0.7.6" +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" @@ -76,6 +87,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -93,6 +159,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -114,6 +183,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "cairo-rs" @@ -216,6 +288,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -244,6 +345,46 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -264,6 +405,284 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dioxus" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +dependencies = [ + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-hooks", + "dioxus-html", + "dioxus-signals", + "dioxus-stores", + "subsecond", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" + +[[package]] +name = "dioxus-config-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" + +[[package]] +name = "dioxus-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +dependencies = [ + "convert_case", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" + +[[package]] +name = "dioxus-devtools" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite 0.27.0", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-history" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "serde", + "serde_json", + "serde_repr", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +dependencies = [ + "dioxus-core", + "dioxus-core-types", + "dioxus-html", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "sledgehammer_bindgen", + "sledgehammer_utils", +] + +[[package]] +name = "dioxus-liveview" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" +dependencies = [ + "axum", + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "futures-channel", + "futures-util", + "generational-box", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "slab", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -281,6 +700,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -299,11 +739,12 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", + "serde", ] [[package]] @@ -360,6 +801,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -418,6 +865,12 @@ dependencies = [ "syn", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -432,6 +885,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -494,6 +948,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generational-box" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +dependencies = [ + "parking_lot", + "tracing", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -515,6 +979,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -523,7 +999,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -739,6 +1215,87 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -826,6 +1383,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -871,6 +1434,29 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "javascriptcore6" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8d4f64d976c6dc6068723b6ef7838acf954d56b675f376c826f7e773362ddb" +dependencies = [ + "glib", + "javascriptcore6-sys", + "libc", +] + +[[package]] +name = "javascriptcore6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b9787581c8949a7061c9b8593c4d1faf4b0fe5e5643c6c7793df20dbe39cf6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -882,16 +1468,21 @@ dependencies = [ ] [[package]] -name = "kurbo" -version = "0.11.3" +name = "keyboard-types" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "arrayvec", - "euclid", - "smallvec", + "bitflags 2.11.0", + "serde", ] +[[package]] +name = "lazy-js-bundle" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" + [[package]] name = "lazy_static" version = "1.5.0" @@ -960,7 +1551,7 @@ dependencies = [ "bitflags 2.11.0", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.3", ] [[package]] @@ -975,12 +1566,27 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + [[package]] name = "lzma-sys" version = "0.1.20" @@ -992,12 +1598,36 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -1007,6 +1637,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1091,18 +1727,67 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -1151,6 +1836,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1167,7 +1861,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit", ] [[package]] @@ -1179,6 +1873,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", +] + [[package]] name = "quote" version = "1.0.45" @@ -1188,12 +1894,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_syscall" version = "0.7.3" @@ -1217,6 +1967,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1280,6 +2042,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -1330,12 +2104,25 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ + "itoa", "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1347,6 +2134,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial2" version = "0.2.34" @@ -1358,6 +2157,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1398,16 +2208,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] -name = "siphasher" -version = "1.0.2" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] [[package]] -name = "slab" -version = "0.4.12" +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] [[package]] name = "smallvec" @@ -1425,6 +2267,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soup3" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d38b59ff6d302538efd337e15d04d61c5b909ec223c60ae4061d74605a962a" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d5d25225bb06f83b78ff8cc35973b56d45fcdd21af6ed6d2bbd67f5a6f9bea" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1438,21 +2306,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "subtle" -version = "2.6.1" +name = "subsecond" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] -name = "svgtypes" -version = "0.15.3" +name = "subsecond-types" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" dependencies = [ - "kurbo", - "siphasher", + "serde", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -1464,6 +2350,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1484,7 +2376,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 0.9.12+spec-1.1.0", + "toml", "version-compare", ] @@ -1585,6 +2477,7 @@ dependencies = [ "tar", "taskers-domain", "taskers-paths", + "taskers-runtime", "tempfile", "thiserror 2.0.18", "ureq", @@ -1596,37 +2489,35 @@ name = "taskers-gtk" version = "0.3.0" dependencies = [ "anyhow", + "axum", "clap", + "dioxus", + "dioxus-liveview", "gtk4", "libadwaita", - "serde", - "serde_json", - "svgtypes", "taskers-control", "taskers-core", "taskers-domain", "taskers-ghostty", + "taskers-host", "taskers-paths", "taskers-runtime", - "tempfile", - "time", + "taskers-shell", + "taskers-shell-core", "tokio", - "toml 0.8.23", + "webkit6", ] [[package]] -name = "taskers-macos-ffi" +name = "taskers-host" version = "0.3.0" dependencies = [ - "serde", - "serde_json", - "taskers-control", - "taskers-core", + "anyhow", + "gtk4", "taskers-domain", "taskers-ghostty", - "taskers-paths", - "taskers-runtime", - "tempfile", + "taskers-shell-core", + "webkit6", ] [[package]] @@ -1645,6 +2536,28 @@ dependencies = [ "taskers-paths", ] +[[package]] +name = "taskers-shell" +version = "0.3.0" +dependencies = [ + "dioxus", + "taskers-shell-core", +] + +[[package]] +name = "taskers-shell-core" +version = "0.3.0" +dependencies = [ + "parking_lot", + "taskers-control", + "taskers-core", + "taskers-domain", + "taskers-ghostty", + "taskers-runtime", + "time", + "tokio", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1766,15 +2679,40 @@ dependencies = [ ] [[package]] -name = "toml" -version = "0.8.23" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", ] [[package]] @@ -1785,22 +2723,13 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -1819,20 +2748,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow", -] - [[package]] name = "toml_edit" version = "0.25.4+spec-1.1.0" @@ -1854,18 +2769,106 @@ dependencies = [ "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -1878,6 +2881,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1918,6 +2927,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1954,6 +2969,28 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1991,6 +3028,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -2057,6 +3108,49 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webkit6" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4959dd2a92813d4b2ae134e71345a03030bcff189b4f79cd131e9218aba22b70" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "javascriptcore6", + "libc", + "soup3", + "webkit6-sys", +] + +[[package]] +name = "webkit6-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236078ce03ff041bf87904c8257e6a9b0e9e0f957267c15f9c1756aadcf02581" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "javascriptcore6-sys", + "libc", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2339,6 +3433,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index c3bd8461..a432f64b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,11 @@ members = [ "crates/taskers-control", "crates/taskers-domain", "crates/taskers-ghostty", - "crates/taskers-macos-ffi", + "crates/taskers-host", "crates/taskers-paths", "crates/taskers-runtime", + "crates/taskers-shell", + "crates/taskers-shell-core", ] resolver = "2" @@ -23,24 +25,34 @@ version = "0.3.0" [workspace.dependencies] adw = { package = "libadwaita", version = "0.9.1" } anyhow = "1" +axum = { version = "0.8.4", features = ["ws"] } base64 = "0.22" clap = { version = "4", features = ["derive"] } +dioxus = { version = "0.7.3", default-features = false, features = ["hooks", "html", "macro", "signals"] } +dioxus-liveview = { version = "0.7.3", features = ["axum"] } gtk = { package = "gtk4", version = "0.11.0" } indexmap = { version = "2", features = ["serde"] } libc = "0.2" +parking_lot = "0.12" portable-pty = "0.9.0" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -svgtypes = "0.15" tar = "0.4" tempfile = "3" -toml = "0.8" thiserror = "2" time = { version = "0.3", features = ["formatting", "macros", "parsing", "serde", "serde-human-readable"] } -tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1.50.0", features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } ureq = "2.12" uuid = { version = "1.22.0", features = ["serde", "v7"] } +webkit6 = { version = "0.6.1", features = ["v2_50"] } xz2 = "0.1" taskers-core = { version = "0.3.0", path = "crates/taskers-core" } +taskers-control = { version = "0.3.0", path = "crates/taskers-control" } +taskers-domain = { version = "0.3.0", path = "crates/taskers-domain" } +taskers-ghostty = { version = "0.3.0", path = "crates/taskers-ghostty" } +taskers-host = { version = "0.3.0", path = "crates/taskers-host" } taskers-paths = { version = "0.3.0", path = "crates/taskers-paths" } +taskers-runtime = { version = "0.3.0", path = "crates/taskers-runtime" } +taskers-shell = { version = "0.3.0", path = "crates/taskers-shell" } +taskers-shell-core = { version = "0.3.0", path = "crates/taskers-shell-core" } diff --git a/README.md b/README.md index 9821e857..44c5dabc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # taskers -Taskers is a cross-platform terminal workspace for agent-heavy work. It gives you Niri-style top-level windows, local pane splits, and an attention sidebar so active, waiting, and completed terminal work stays visible. +Taskers is a Linux-first terminal workspace for agent-heavy work. It provides +Niri-style top-level windows, local pane splits, tabs inside panes, and an +attention rail for active and completed work. -![Taskers workspace list and attention sidebar](docs/screenshots/demo-attention.png) - -![Taskers split workspace window](docs/screenshots/demo-layout.png) +The active product lives at the repo root. Archived pre-cutover GTK/AppKit code +is kept under `taskers-old/` for reference only. ## Try it @@ -12,20 +13,44 @@ Linux (`x86_64-unknown-linux-gnu`): ```bash cargo install taskers --locked -taskers --demo +taskers ``` -The first launch downloads the exact version-matched Linux bundle from the tagged GitHub release. - -macOS: +The first launch downloads the exact version-matched Linux bundle from the tagged +GitHub release. The Linux app requires GTK4/libadwaita plus the host WebKitGTK +6.0 runtime. -- Download the signed `Taskers-v-universal2.dmg` from [GitHub Releases](https://github.com/OneNoted/taskers/releases). -- Drag `Taskers.app` into `Applications`, then launch it normally from Finder or Spotlight. +Mainline macOS support is currently not shipped from this repo root. ## Develop +On Ubuntu 24.04, install the Linux UI dependencies first: + +```bash +sudo apt-get install -y libgtk-4-dev libadwaita-1-dev libjavascriptcoregtk-6.0-dev libwebkitgtk-6.0-dev xvfb +``` + +Run the app directly: + +```bash +cargo run -p taskers-gtk --bin taskers-gtk +``` + +Point the desktop launcher at the repo-local dev build: + +```bash +bash scripts/install-dev-desktop-entry.sh +``` + +Run the headless baseline smoke: + ```bash -cargo run -p taskers-gtk --bin taskers-gtk -- --demo +TASKERS_TERMINAL_BACKEND=mock \ +bash scripts/headless-smoke.sh \ + ./target/debug/taskers-gtk \ + --smoke-script baseline \ + --diagnostic-log stderr \ + --quit-after-ms 5000 ``` Release checklist: [docs/release.md](docs/release.md) diff --git a/crates/taskers-app/Cargo.toml b/crates/taskers-app/Cargo.toml index c34142fc..152e92f6 100644 --- a/crates/taskers-app/Cargo.toml +++ b/crates/taskers-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "taskers-gtk" -description = "Agent-first terminal workspace app with a Niri-like tiling model." +description = "GTK host for the mainline Taskers workspace shell." edition.workspace = true homepage.workspace = true license.workspace = true @@ -16,20 +16,19 @@ path = "src/main.rs" [dependencies] adw.workspace = true anyhow.workspace = true +axum.workspace = true clap.workspace = true +dioxus.workspace = true +dioxus-liveview.workspace = true gtk.workspace = true -serde.workspace = true -serde_json.workspace = true -svgtypes.workspace = true +taskers-control.workspace = true taskers-core.workspace = true +taskers-domain.workspace = true +taskers-ghostty.workspace = true +taskers-host.workspace = true taskers-paths.workspace = true -taskers-runtime = { version = "0.3.0", path = "../taskers-runtime" } +taskers-runtime.workspace = true +taskers-shell.workspace = true +taskers-shell-core.workspace = true tokio.workspace = true -taskers-control = { version = "0.3.0", path = "../taskers-control" } -taskers-domain = { version = "0.3.0", path = "../taskers-domain" } -taskers-ghostty = { version = "0.3.0", path = "../taskers-ghostty" } -time.workspace = true -toml.workspace = true - -[dev-dependencies] -tempfile.workspace = true +webkit6.workspace = true diff --git a/crates/taskers-app/src/main.rs b/crates/taskers-app/src/main.rs index bb4e91ec..fa0178e1 100644 --- a/crates/taskers-app/src/main.rs +++ b/crates/taskers-app/src/main.rs @@ -1,7078 +1,767 @@ -mod crash_reporter; -mod settings_store; -mod terminal_transitions; -mod theme; -mod themes; - +use adw::prelude::*; +use anyhow::{Context, Result}; +use axum::{Router, extract::ws::WebSocketUpgrade, response::Html, routing::get}; +use clap::{Parser, ValueEnum}; +use gtk::{EventControllerKey, gdk, glib}; use std::{ cell::{Cell, RefCell}, - collections::{HashMap, HashSet}, + fs::{File, OpenOptions, remove_file}, future::pending, + io::{self, Write}, + net::TcpListener, path::PathBuf, process::{Command, Stdio}, rc::Rc, - sync::Arc, + sync::{Arc, Mutex}, thread, - time::{Duration, Instant}, -}; - -use adw::prelude::*; -use clap::Parser; -use crash_reporter::CrashReporter; -use gtk::{ - Align, Box as GtkBox, Button, DrawingArea, Entry, Fixed, Label, Orientation, Overlay, Paned, - PolicyType, ScrolledWindow, Separator, TextView, Widget, WrapMode, gdk, glib, -}; -use serde_json::json; -use settings_store::{AppConfig, ShortcutAction, ShortcutPreset}; -use svgtypes::{SimplePathSegment, SimplifyingPathParser}; -use taskers_control::{ - ControlCommand, InMemoryController, bind_socket, default_socket_path, serve, -}; -use taskers_core::{AppState, PaneRuntimeSnapshot, default_session_path, load_or_bootstrap}; -use taskers_domain::{ - ActivityItem, AppModel, AttentionState, DEFAULT_WORKSPACE_WINDOW_GAP, - DEFAULT_WORKSPACE_WINDOW_HEIGHT, DEFAULT_WORKSPACE_WINDOW_WIDTH, Direction, - KEYBOARD_RESIZE_STEP, LayoutNode, MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_WIDTH, - PaneKind, PaneMetadata, PaneMetadataPatch, PaneRecord, SignalEvent, SignalKind, SurfaceId, - SurfaceRecord, WindowFrame, Workspace, WorkspaceAgentState, WorkspaceColumnId, - WorkspaceViewport, WorkspaceWindowId, -}; -use taskers_ghostty::{ - BackendChoice, BackendProbe, DefaultBackend, GhosttyHost, TerminalBackend, - ensure_runtime_installed, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use taskers_runtime::{ - ShellLaunchSpec, default_shell_program, install_shell_integration, - scrub_inherited_terminal_env, validate_shell_program, -}; -use terminal_transitions::{ - PaneSceneSnapshot, PresentedTransitionRect, TERMINAL_MOTION_SPEC, TransitionItemId, - TransitionItemKind, TransitionPhase, WorkspaceSceneSnapshot, WorkspaceWindowSnapshot, - derive_pane_frames, plan_workspace_transition, retarget_transition_plan, +use taskers_core::{AppState, default_session_path, load_or_bootstrap}; +use taskers_control::{bind_socket, default_socket_path, serve_with_handler}; +use taskers_shell_core::{ + BootstrapModel, LayoutNodeSnapshot, PixelSize, RuntimeCapability, RuntimeStatus, SharedCore, + ShellSection, ShortcutAction, ShortcutPreset, SurfaceKind, }; +use taskers_domain::AppModel; +use taskers_ghostty::{BackendChoice, GhosttyHost, GhosttyHostOptions, ensure_runtime_installed}; +use taskers_host::{DiagnosticCategory, DiagnosticRecord, DiagnosticsSink, TaskersHost}; +use taskers_runtime::{ShellLaunchSpec, install_shell_integration, scrub_inherited_terminal_env}; +use webkit6::{Settings as WebKitSettings, WebView, prelude::*}; + +const APP_ID: &str = taskers_paths::APP_ID; #[derive(Debug, Clone, Parser)] #[command(name = "taskers")] -#[command(about = "GTK shell for the taskers terminal workspace")] +#[command(about = "Taskers workspace shell")] struct Cli { + #[arg(long, value_enum)] + smoke_script: Option, #[arg(long)] - socket: Option, + diagnostic_log: Option, #[arg(long)] - session: Option, - #[arg(long, default_value_t = false)] - demo: bool, - #[arg(long, default_value_t = false, conflicts_with = "raw_shell")] - clean_shell: bool, - #[arg(long, default_value_t = false, conflicts_with = "clean_shell")] - raw_shell: bool, - #[arg(long, hide = true, default_value_t = false)] - internal_ghostty_probe: bool, -} - -struct StartupContext { - app_state: AppState, - backend_choice: BackendChoice, - config_path: PathBuf, - app_config: AppConfig, - crash_reporter: CrashReporter, - ghostty_host: Option, - startup_toast: Option, + quit_after_ms: Option, + #[arg(long, hide = true, value_enum)] + internal_ghostty_probe: Option, } -struct UiHandle { - app_state: AppState, - backend_choice: BackendChoice, - application: adw::Application, - window: adw::ApplicationWindow, - overlay: adw::ToastOverlay, - crash_reporter: CrashReporter, - ghostty_host: Option, - ghostty_surfaces: RefCell>, - shell: RefCell>, - pane_cards: RefCell>, - settings: RefCell, - config_path: PathBuf, - layout_state: RefCell, - last_rendered: RefCell>, - suppress_viewport_events: RefCell, - pending_viewport: RefCell>, - pending_viewport_source: RefCell>, - pending_focus_source: RefCell>, - desktop_notifications: RefCell>, - overview_mode: Cell, - top_level_resize_preview: RefCell>, - workspace_transition_state: RefCell, +#[derive(Debug, Clone, Copy, ValueEnum)] +enum SmokeScript { + Baseline, } -#[derive(Clone)] -struct ShellWidgets { - root: Paned, - sidebar_list: GtkBox, - workspace_name_label: Label, - overview_button: Button, - activity_list: GtkBox, - activity_empty: Label, - layout_scroll: ScrolledWindow, - layout_host: Fixed, - workspace_stage: WorkspaceStageWidgets, +#[derive(Debug, Clone, Copy, ValueEnum)] +enum GhosttyProbeMode { + Host, + Surface, } -#[derive(Clone)] -struct PaneCardWidgets { - root: GtkBox, - header: GtkBox, - agent_icon: AgentIconWidget, - title: Label, - status_dot: Label, - header_actions: GtkBox, - header_tabs: GtkBox, - resize_button: Button, - surface_tabs: SurfaceTabStripWidgets, - terminal_host: GtkBox, - displayed_surface_id: Rc>>, - focus_target: Widget, +impl GhosttyProbeMode { + fn as_arg(self) -> &'static str { + match self { + Self::Host => "host", + Self::Surface => "surface", + } + } } -#[derive(Clone)] -struct WorkspaceStageWidgets { - root: Overlay, - ghost_layer: Fixed, +struct BootstrapContext { + core: SharedCore, + ghostty_host: Option, + startup_notes: Vec, } -#[derive(Default)] -struct WorkspaceTransitionState { - presented: HashMap, - motion: Option, - tick_running: bool, - target_canvas_width: i32, - target_canvas_height: i32, +struct RuntimeBootstrap { + ghostty_runtime: RuntimeCapability, + shell_integration: RuntimeCapability, + shell_launch: ShellLaunchSpec, + host_options: GhosttyHostOptions, + socket_path: PathBuf, + startup_notes: Vec, } -#[derive(Clone, Copy, PartialEq, Eq)] -struct TopLevelResizePreview { - workspace_id: taskers_domain::WorkspaceId, - target: TopLevelResizePreviewTarget, -} +fn main() -> glib::ExitCode { + let cli = Cli::parse(); + scrub_inherited_terminal_env(); + if let Some(mode) = cli.internal_ghostty_probe { + return run_internal_ghostty_probe(mode); + } -#[derive(Clone, Copy, PartialEq, Eq)] -enum TopLevelResizePreviewTarget { - ColumnWidth { - workspace_column_id: WorkspaceColumnId, - width: i32, - }, - WindowHeight { - workspace_window_id: WorkspaceWindowId, - height: i32, - }, -} + let bootstrap = match bootstrap_runtime(None) { + Ok(bootstrap) => bootstrap, + Err(error) => { + eprintln!("failed to bootstrap Taskers host: {error:?}"); + return glib::ExitCode::FAILURE; + } + }; -#[derive(Clone)] -struct WorkspaceTransitionMotionState { - start_time: i64, - items: Vec, + let app = adw::Application::builder().application_id(APP_ID).build(); + let bootstrap = Rc::new(RefCell::new(Some(bootstrap))); + let hold_guard = Rc::new(RefCell::new(None)); + let bootstrap_for_startup = bootstrap.clone(); + let hold_guard_for_startup = hold_guard.clone(); + let cli_for_startup = cli.clone(); + app.connect_startup(move |app| { + *hold_guard_for_startup.borrow_mut() = Some(app.hold()); + if let Some(bootstrap) = bootstrap_for_startup.borrow_mut().take() { + build_ui( + app, + bootstrap, + hold_guard_for_startup.clone(), + cli_for_startup.clone(), + ); + } + }); + app.connect_activate(|app| { + if let Some(window) = app.active_window() { + window.present(); + } + }); + app.run_with_args::<&str>(&[]) } -#[derive(Clone)] -struct WorkspaceTransitionMotionItem { - id: TransitionItemId, - widget: Widget, - start_rect: PresentedTransitionRect, - end_rect: PresentedTransitionRect, - duration_us: i64, - curve: terminal_transitions::MotionCurve, - start_opacity: f64, +fn build_ui( + app: &adw::Application, + bootstrap: BootstrapContext, + hold_guard: Rc>>, + cli: Cli, +) { + if let Err(error) = build_ui_result(app, bootstrap, hold_guard, cli) { + eprintln!("failed to launch Taskers host: {error:?}"); + } } -#[derive(Clone, Default)] -struct WorkspaceSceneVisuals { - windows: HashMap, -} +fn build_ui_result( + app: &adw::Application, + bootstrap: BootstrapContext, + hold_guard: Rc>>, + cli: Cli, +) -> Result<()> { + let diagnostics = DiagnosticsWriter::from_cli(&cli); + log_runtime_status( + diagnostics.as_ref(), + &bootstrap.core.snapshot().runtime_status, + ); -#[derive(Clone, Copy)] -struct WindowGhostVisual { - active: bool, - attention: AttentionState, -} + let shell_url = launch_liveview_server(bootstrap.core.clone())?; + let settings = WebKitSettings::builder() + .enable_developer_extras(true) + .build(); + let shell_view = WebView::builder() + .hexpand(true) + .vexpand(true) + .focusable(true) + .settings(&settings) + .build(); + shell_view.set_can_target(true); + shell_view.load_uri(&shell_url); + + let core = bootstrap.core.clone(); + let event_sink = Rc::new({ + let core = core.clone(); + move |event| core.apply_host_event(event) + }); + let diagnostics_sink = diagnostics.as_ref().map(DiagnosticsWriter::sink); + let host = Rc::new(RefCell::new(TaskersHost::new( + &shell_view, + bootstrap.ghostty_host, + event_sink, + diagnostics_sink, + ))); -#[derive(Clone)] -struct SurfaceTabStripWidgets { - root: Fixed, - add_button: Button, - tabs: Rc>>, - state: Rc>, -} + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Taskers") + .default_width(1440) + .default_height(900) + .build(); + window.connect_close_request(move |_| { + drop(hold_guard.borrow_mut().take()); + glib::Propagation::Proceed + }); + let host_widget = host.borrow().widget(); + window.set_content(Some(&host_widget)); + connect_navigation_shortcuts(&window, &shell_view, &core); -#[derive(Clone)] -struct SurfaceTabWidgets { - root: GtkBox, - dot: Label, - agent_icon: AgentIconWidget, - title: Label, -} + for note in bootstrap.startup_notes { + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new(DiagnosticCategory::Startup, None, note.clone()), + ); + eprintln!("{note}"); + } + log_diagnostic( + diagnostics.as_ref(), + DiagnosticRecord::new( + DiagnosticCategory::Startup, + Some(core.revision()), + format!("shared shell listening on {shell_url}"), + ), + ); + eprintln!("shared shell listening on {shell_url}"); + + let smoke_script = cli.smoke_script; + let quit_after_ms = cli.quit_after_ms.unwrap_or(8_000); + let last_revision = Rc::new(Cell::new(0_u64)); + let last_size = Rc::new(Cell::new((0_i32, 0_i32))); + let tick_window = window.clone(); + let tick_core = core.clone(); + let tick_host = host.clone(); + let tick_revision = last_revision.clone(); + let tick_size = last_size.clone(); + let tick_diagnostics = diagnostics.clone(); + glib::timeout_add_local(Duration::from_millis(16), move || { + sync_window( + &tick_window, + &tick_core, + &tick_host, + &tick_revision, + &tick_size, + tick_diagnostics.as_ref(), + ); + glib::ControlFlow::Continue + }); -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -enum SurfaceTabItemKey { - Surface(SurfaceId), - AddButton, -} + window.present(); -#[derive(Clone, Debug, PartialEq)] -struct SurfaceTabStripLayout { - items: Vec, - add_button: SurfaceTabAuxLayoutItem, - height: i32, -} + let initial_window = window.clone(); + let initial_core = core.clone(); + let initial_host = host.clone(); + let initial_revision = last_revision.clone(); + let initial_size = last_size.clone(); + let initial_diagnostics = diagnostics.clone(); + glib::timeout_add_local_once(Duration::from_millis(80), move || { + sync_window( + &initial_window, + &initial_core, + &initial_host, + &initial_revision, + &initial_size, + initial_diagnostics.as_ref(), + ); + }); -#[derive(Clone, Debug, PartialEq)] -struct SurfaceTabLayoutItem { - surface_id: SurfaceId, - x: f64, - width: i32, - height: i32, -} + if let Some(script) = smoke_script { + spawn_smoke_script(script, core, diagnostics, quit_after_ms); + } -#[derive(Clone, Copy, Debug, PartialEq)] -struct SurfaceTabAuxLayoutItem { - x: f64, - width: i32, - height: i32, + Ok(()) } -#[derive(Clone, Copy, Debug, Default, PartialEq)] -struct PresentedSurfaceTabItem { - x: f64, - opacity: f64, - width: i32, - height: i32, +fn normalize_shortcut_modifiers(state: gdk::ModifierType) -> gdk::ModifierType { + state + & (gdk::ModifierType::CONTROL_MASK + | gdk::ModifierType::SHIFT_MASK + | gdk::ModifierType::ALT_MASK + | gdk::ModifierType::META_MASK + | gdk::ModifierType::SUPER_MASK + | gdk::ModifierType::HYPER_MASK) } -#[derive(Clone)] -struct SurfaceTabMotionState { - start_time: i64, - duration_us: i64, - start_items: HashMap, - target_items: HashMap, +fn is_modifier_key(key: gdk::Key) -> bool { + matches!( + key, + gdk::Key::Control_L + | gdk::Key::Control_R + | gdk::Key::Shift_L + | gdk::Key::Shift_R + | gdk::Key::Alt_L + | gdk::Key::Alt_R + | gdk::Key::Meta_L + | gdk::Key::Meta_R + | gdk::Key::Super_L + | gdk::Key::Super_R + | gdk::Key::Hyper_L + | gdk::Key::Hyper_R + ) } -#[derive(Clone)] -struct SurfaceTabExitAnimation { - root: GtkBox, - start_time: i64, - duration_us: i64, - start_item: PresentedSurfaceTabItem, - end_item: PresentedSurfaceTabItem, +fn shortcut_matches( + preset: ShortcutPreset, + action: ShortcutAction, + key: gdk::Key, + state: gdk::ModifierType, +) -> bool { + action + .accelerators(preset) + .iter() + .filter_map(|accelerator| gtk::accelerator_parse(*accelerator)) + .any(|(expected_key, expected_modifiers)| { + key == expected_key && normalize_shortcut_modifiers(state) == expected_modifiers + }) } -#[derive(Clone)] -struct SurfaceTabDragState { - surface_id: SurfaceId, - start_x: f64, - current_dx: f64, - preview_order: Vec, - threshold_crossed: bool, +fn focus_active_browser_address(shell_view: &WebView) { + shell_view.evaluate_javascript( + "(() => { + const address = document.querySelector('.browser-address'); + if (!(address instanceof HTMLInputElement)) return false; + address.focus(); + address.select(); + return true; + })();", + None, + None, + None::<>k::gio::Cancellable>, + |_| {}, + ); } -#[derive(Default)] -struct SurfaceTabStripState { - model_order: Vec, - layout: Option, - presented: HashMap, - motion: Option, - exiting: Vec, - drag: Option, - tick_running: bool, - suppress_click_surface: Option, - next_animation_duration_us: Option, -} +fn connect_navigation_shortcuts( + window: &adw::ApplicationWindow, + shell_view: &WebView, + core: &SharedCore, +) { + let controller = EventControllerKey::new(); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + let shortcuts_core = core.clone(); + let shortcuts_shell = shell_view.clone(); + controller.connect_key_pressed(move |_, key, _, state| { + if is_modifier_key(key) { + return glib::Propagation::Proceed; + } -#[derive(Clone, PartialEq, Eq)] -enum LayoutRenderKey { - WorkspaceWindows { - windows: Vec, - }, -} + let preset = shortcuts_core.selected_shortcut_preset(); -#[derive(Clone, Default, PartialEq, Eq)] -struct LayoutRenderState { - workspace_id: Option, - viewport_width: i32, - viewport_height: i32, - overview_mode: bool, - layout: Option, -} + if shortcut_matches(preset, ShortcutAction::FocusBrowserAddress, key, state) { + let snapshot = shortcuts_core.snapshot(); + if snapshot.section == ShellSection::Workspace && snapshot.browser_chrome.is_some() { + focus_active_browser_address(&shortcuts_shell); + return glib::Propagation::Stop; + } + return glib::Propagation::Proceed; + } -#[derive(Clone, PartialEq, Eq)] -struct WorkspaceWindowRenderKey { - window_id: WorkspaceWindowId, - frame: WindowFrame, - layout: LayoutNode, -} + for action in ShortcutAction::ALL { + if action == ShortcutAction::FocusBrowserAddress { + continue; + } + if shortcut_matches(preset, action, key, state) + && shortcuts_core.dispatch_shortcut_action(action) + { + return glib::Propagation::Stop; + } + } -#[derive(Clone, Copy)] -struct WorkspaceWindowPlacement { - window_id: WorkspaceWindowId, - column_id: WorkspaceColumnId, - frame: WindowFrame, + glib::Propagation::Proceed + }); + window.add_controller(controller); } -#[derive(Clone, Copy)] -struct CanvasMetrics { - offset_x: i32, - offset_y: i32, - width: i32, - height: i32, -} +fn bootstrap_runtime(diagnostics: Option<&DiagnosticsWriter>) -> Result { + let runtime = resolve_runtime_bootstrap(); + let mut startup_notes = runtime.startup_notes; + let session_path = default_session_path(); + let initial_model = load_or_bootstrap(&session_path, false).with_context(|| { + format!( + "failed to load or bootstrap Taskers session at {}", + session_path.display() + ) + })?; -const WORKSPACE_CANVAS_PADDING: i32 = 2; -const WORKSPACE_WINDOW_HEADER_HEIGHT: i32 = 30; -const SURFACE_TAB_GAP: i32 = 4; -const SURFACE_TAB_MIN_WIDTH: i32 = 72; -const SURFACE_TAB_MAX_WIDTH: i32 = 220; -const SIDEBAR_MIN_WIDTH: i32 = 224; -const TOOLBAR_ACTION_NEW_GLYPH: &str = "+"; -const TOOLBAR_ACTION_RESIZE_GLYPH: &str = "\u{2922}"; -const TOOLBAR_ACTION_FOCUS_GLYPH: &str = "\u{25ce}"; + let (ghostty_host, backend_choice, terminal_host, terminal_note) = + match probe_ghostty_backend_process(GhosttyProbeMode::Surface) { + Ok(()) => match GhosttyHost::new_with_options(&runtime.host_options) { + Ok(host) => { + let _ = host.tick(); + ( + Some(host), + BackendChoice::GhosttyEmbedded, + RuntimeCapability::Ready, + None, + ) + } + Err(error) => ( + None, + BackendChoice::Mock, + RuntimeCapability::Fallback { + message: format!("Ghostty host unavailable: {error}"), + }, + Some(format!("Ghostty host unavailable after probe: {error}")), + ), + }, + Err(error) => ( + None, + BackendChoice::Mock, + RuntimeCapability::Fallback { + message: format!("Ghostty surface self-probe failed: {error}"), + }, + Some(format!("Ghostty surface self-probe failed: {error}")), + ), + }; -#[derive(Clone, Copy)] -struct WorkspaceRenderContext { - overview_mode: bool, - overview_scale: f64, - top_level_resize_preview: Option, -} + if let Some(note) = terminal_note { + startup_notes.push(note); + } -#[derive(Clone)] -struct AgentIconWidget { - root: DrawingArea, - state: Rc>, -} + let runtime_status = RuntimeStatus { + ghostty_runtime: runtime.ghostty_runtime, + shell_integration: runtime.shell_integration, + terminal_host, + }; + let app_state = AppState::new( + initial_model, + session_path, + backend_choice, + runtime.shell_launch, + ) + .context("failed to initialize Taskers app state")?; + startup_notes.push(spawn_control_server( + app_state.clone(), + runtime.socket_path.clone(), + )); + let core = SharedCore::bootstrap(BootstrapModel { + app_state, + runtime_status, + selected_theme_id: "dark".into(), + selected_shortcut_preset: ShortcutPreset::PowerUser, + }); -#[derive(Clone, Copy, Default)] -struct AgentIconState { - kind: Option<&'static str>, -} + log_runtime_status(diagnostics, &core.snapshot().runtime_status); -#[derive(Clone, Copy)] -struct AgentIconSpec { - view_box_width: f64, - view_box_height: f64, - paths: &'static [AgentIconPathSpec], + Ok(BootstrapContext { + core, + ghostty_host, + startup_notes, + }) } -#[derive(Clone, Copy)] -struct AgentIconPathSpec { - data: &'static str, - fill: AgentIconFill, -} +fn resolve_runtime_bootstrap() -> RuntimeBootstrap { + scrub_inherited_terminal_env(); -use theme::AgentIconColor; + let mut startup_notes = Vec::new(); + let socket_path = default_socket_path(); + let ghostty_runtime = match ensure_runtime_installed() { + Ok(Some(runtime)) => { + startup_notes.push(format!( + "Installed Ghostty runtime assets to {}", + runtime.runtime_dir.display() + )); + RuntimeCapability::Ready + } + Ok(None) => RuntimeCapability::Ready, + Err(error) => RuntimeCapability::Fallback { + message: format!("Ghostty runtime bootstrap unavailable: {error}"), + }, + }; -#[derive(Clone, Copy)] -enum AgentIconFill { - CurrentColor, - Agent(&'static str), -} + let (mut shell_launch, shell_integration) = match install_shell_integration(None) { + Ok(integration) => (integration.launch_spec(), RuntimeCapability::Ready), + Err(error) => ( + ShellLaunchSpec::fallback(), + RuntimeCapability::Fallback { + message: format!("Shell integration unavailable: {error}"), + }, + ), + }; + shell_launch + .env + .insert("TASKERS_SOCKET".into(), socket_path.display().to_string()); -thread_local! { - static AGENT_ICON_PATHS: RefCell>>> = - RefCell::new(HashMap::new()); -} + let host_options = GhosttyHostOptions::from_shell_launch(&shell_launch); -impl UiHandle { - fn new( - app_state: AppState, - backend_choice: BackendChoice, - config_path: PathBuf, - app_config: AppConfig, - application: adw::Application, - window: adw::ApplicationWindow, - overlay: adw::ToastOverlay, - crash_reporter: CrashReporter, - ghostty_host: Option, - ) -> Rc { - Rc::new(Self { - app_state, - backend_choice, - application, - window, - overlay, - crash_reporter, - ghostty_host, - ghostty_surfaces: RefCell::new(HashMap::new()), - shell: RefCell::new(None), - pane_cards: RefCell::new(HashMap::new()), - settings: RefCell::new(app_config), - config_path, - layout_state: RefCell::new(LayoutRenderState::default()), - last_rendered: RefCell::new(None), - suppress_viewport_events: RefCell::new(false), - pending_viewport: RefCell::new(None), - pending_viewport_source: RefCell::new(None), - pending_focus_source: RefCell::new(None), - desktop_notifications: RefCell::new(HashSet::new()), - overview_mode: Cell::new(false), - top_level_resize_preview: RefCell::new(None), - workspace_transition_state: RefCell::new(WorkspaceTransitionState::default()), - }) + RuntimeBootstrap { + ghostty_runtime, + shell_integration, + shell_launch, + host_options, + socket_path, + startup_notes, } +} - fn refresh(self: &Rc, force: bool) { - let model = self.app_state.snapshot_model(); - if let Err(error) = self.app_state.runtime().sync_model(&model) { - self.toast(&error.to_string()); - } - let next_layout_state = compute_layout_render_state(self.as_ref(), &model); - if !force - && self - .last_rendered - .borrow() - .as_ref() - .is_some_and(|existing| existing == &model) - && *self.layout_state.borrow() == next_layout_state - { - return; - } +fn taskers_probe_session_path(mode: GhosttyProbeMode) -> PathBuf { + std::env::temp_dir().join(format!( + "taskers-probe-{}-{}.json", + mode.as_arg(), + std::process::id() + )) +} - if let Err(error) = self.app_state.persist_model(&model) { - self.toast(&error.to_string()); +fn run_internal_ghostty_probe(mode: GhosttyProbeMode) -> glib::ExitCode { + let runtime = resolve_runtime_bootstrap(); + let host = match GhosttyHost::new_with_options(&runtime.host_options) { + Ok(host) => { + let _ = host.tick(); + host } - - self.ensure_shell(); - self.render_model(&model); - // Clean up stale caches AFTER layout rebuild so that Ghostty surfaces - // for closed panes stay alive during the Paned tree teardown, avoiding - // shared GL context corruption. - self.cleanup_stale_panes(&model); - self.write_ui_integrity_snapshot(&model); - *self.last_rendered.borrow_mut() = Some(model); - } - - fn dispatch(self: &Rc, command: ControlCommand) { - let before = self.app_state.snapshot_model(); - if let Err(error) = self.app_state.dispatch(command) { - self.toast(&error.to_string()); - return; + Err(error) => { + eprintln!( + "ghostty {} self-probe failed during host init: {error}", + mode.as_arg() + ); + return glib::ExitCode::FAILURE; } - let after = self.app_state.snapshot_model(); - self.refresh(before != after); - } + }; - fn active_top_level_resize_preview( - &self, - workspace_id: taskers_domain::WorkspaceId, - ) -> Option { - self.top_level_resize_preview - .borrow() - .as_ref() - .copied() - .filter(|preview| preview.workspace_id == workspace_id) + if matches!(mode, GhosttyProbeMode::Surface) { + return run_internal_surface_probe(host, runtime.shell_launch, mode); } - fn top_level_resize_preview_active(&self) -> bool { - self.top_level_resize_preview.borrow().is_some() - } + spin_probe_main_context(Duration::from_millis(350)); + glib::ExitCode::SUCCESS +} - fn set_top_level_resize_preview(&self, preview: Option) { - *self.top_level_resize_preview.borrow_mut() = preview; +fn run_internal_surface_probe( + host: GhosttyHost, + shell_launch: ShellLaunchSpec, + mode: GhosttyProbeMode, +) -> glib::ExitCode { + if !gtk::is_initialized_main_thread() { + if let Err(error) = gtk::init() { + eprintln!( + "ghostty {} self-probe failed during gtk init: {error}", + mode.as_arg() + ); + return glib::ExitCode::FAILURE; + } } - fn sync_layout_state(&self, model: &AppModel) { - *self.layout_state.borrow_mut() = compute_layout_render_state(self, model); - } + let settings = WebKitSettings::builder() + .enable_developer_extras(true) + .build(); + let shell_view = WebView::builder() + .hexpand(true) + .vexpand(true) + .focusable(true) + .settings(&settings) + .build(); + shell_view.set_can_target(true); + shell_view.load_html( + "", + Some("http://127.0.0.1/"), + ); - fn apply_top_level_resize_preview(self: &Rc, model: &AppModel) { - let Some(workspace) = model.active_workspace() else { - return; - }; - if self.active_top_level_resize_preview(workspace.id).is_none() { - return; + let app_state = match AppState::new( + AppModel::new("Ghostty Probe"), + taskers_probe_session_path(mode), + BackendChoice::GhosttyEmbedded, + shell_launch, + ) { + Ok(app_state) => app_state, + Err(error) => { + eprintln!( + "ghostty {} self-probe failed during app state bootstrap: {error}", + mode.as_arg() + ); + return glib::ExitCode::FAILURE; } - let Some(shell) = self.shell.borrow().as_ref().cloned() else { - return; - }; + }; + + let core = SharedCore::bootstrap(BootstrapModel { + app_state, + runtime_status: RuntimeStatus { + ghostty_runtime: RuntimeCapability::Ready, + shell_integration: RuntimeCapability::Ready, + terminal_host: RuntimeCapability::Ready, + }, + selected_theme_id: "dark".into(), + selected_shortcut_preset: ShortcutPreset::PowerUser, + }); + core.set_window_size(PixelSize::new(1200, 800)); + + let event_sink = Rc::new(|_| {}); + let mut taskers_host = TaskersHost::new(&shell_view, Some(host), event_sink, None); + let host_widget = taskers_host.widget(); + let window = gtk::Window::builder() + .title("Taskers Ghostty Probe") + .default_width(1200) + .default_height(800) + .child(&host_widget) + .build(); + window.present(); - let render_context = workspace_render_context( - self.as_ref(), - Some(&shell), - workspace, - self.overview_mode.get(), - workspace_viewport_width(self.as_ref(), Some(&shell)), - workspace_viewport_height(self.as_ref(), Some(&shell)), + spin_probe_main_context(Duration::from_millis(80)); + if let Err(error) = taskers_host.sync_snapshot(&core.snapshot()) { + eprintln!( + "ghostty {} self-probe failed during snapshot sync: {error}", + mode.as_arg() ); - apply_workspace_preview_placements(&shell, workspace, render_context); - self.sync_layout_state(model); + return glib::ExitCode::FAILURE; } - fn toast(&self, message: &str) { - self.overlay.add_toast(adw::Toast::new(message)); + let deadline = Instant::now() + Duration::from_millis(350); + let context = glib::MainContext::default(); + while Instant::now() < deadline { + taskers_host.tick(); + while context.pending() { + let _ = context.iteration(false); + } + thread::sleep(Duration::from_millis(16)); } - fn shortcut_specs(&self, action: ShortcutAction) -> Vec<(gdk::Key, gdk::ModifierType)> { - self.settings - .borrow() - .keybindings - .accelerators(action) - .into_iter() - .filter_map(|accelerator| gtk::accelerator_parse(&accelerator)) - .collect() - } + // The probe only needs to prove that an embedded surface can initialize + // and stay alive briefly. Tearing the GTK/GL stack back down inside the + // child has been the flaky part on Linux, so exit immediately on success + // and let the parent make the real startup decision. + let _ = window; + std::process::exit(0); +} - fn shortcut_label(&self, action: ShortcutAction) -> String { - let labels = self - .settings - .borrow() - .keybindings - .accelerators(action) - .into_iter() - .map(|accelerator| { - gtk::accelerator_parse(&accelerator) - .map(|(key, modifiers)| gtk::accelerator_get_label(key, modifiers).to_string()) - .unwrap_or(accelerator) - }) - .collect::>(); - if labels.is_empty() { - "Unbound".into() - } else { - labels.join(", ") +fn spin_probe_main_context(duration: Duration) { + let deadline = Instant::now() + duration; + let context = glib::MainContext::default(); + while Instant::now() < deadline { + while context.pending() { + let _ = context.iteration(false); } + thread::sleep(Duration::from_millis(16)); } +} - fn shortcut_matches( - &self, - action: ShortcutAction, - key: gdk::Key, - state: gdk::ModifierType, - ) -> bool { - self.shortcut_specs(action) - .into_iter() - .any(|(expected_key, expected_modifiers)| { - key == expected_key && normalize_shortcut_modifiers(state) == expected_modifiers - }) - } +fn probe_ghostty_backend_process(mode: GhosttyProbeMode) -> Result<()> { + let current_exe = std::env::current_exe().context("failed to resolve current executable")?; + let log_path = ghostty_probe_log_path(mode); + let stdout = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&log_path) + .with_context(|| format!("failed to open probe log {}", log_path.display()))?; + let stderr = stdout + .try_clone() + .with_context(|| format!("failed to clone probe log {}", log_path.display()))?; - fn set_shortcut( - self: &Rc, - action: ShortcutAction, - accelerator: String, - ) -> Result { - let Some((key, modifiers)) = gtk::accelerator_parse(&accelerator) else { - return Err("shortcut is not a valid GTK accelerator".into()); - }; + let mut child = Command::new(current_exe) + .arg("--internal-ghostty-probe") + .arg(mode.as_arg()) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .context("failed to launch Ghostty self-probe")?; - if normalize_shortcut_modifiers(modifiers).is_empty() { - return Err("shortcut must include at least one modifier".into()); - } - for other_action in ShortcutAction::ALL { - if other_action == action { - continue; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => { + let _ = remove_file(&log_path); + return Ok(()); } - if self - .shortcut_specs(other_action) - .into_iter() - .any(|(other_key, other_modifiers)| { - other_key == key && other_modifiers == normalize_shortcut_modifiers(modifiers) - }) - { - return Err(format!( - "shortcut is already assigned to {}", - other_action.label() - )); + Ok(Some(status)) => { + anyhow::bail!( + "{}; probe log: {}", + describe_exit_status(status), + log_path.display() + ); + } + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!( + "Ghostty self-probe timed out; probe log: {}", + log_path.display() + ); + } + Err(error) => { + anyhow::bail!( + "failed to wait for Ghostty self-probe: {error}; probe log: {}", + log_path.display() + ); } } - - let next_label = - gtk::accelerator_get_label(key, normalize_shortcut_modifiers(modifiers)).to_string(); - let mut next_settings = self.settings.borrow().clone(); - next_settings.keybindings.set_accelerators( - action, - vec![gtk::accelerator_name(key, normalize_shortcut_modifiers(modifiers)).to_string()], - ); - settings_store::save_config(&self.config_path, &next_settings) - .map_err(|error| format!("failed to save settings: {error}"))?; - *self.settings.borrow_mut() = next_settings; - Ok(next_label) } +} - fn reset_shortcuts(self: &Rc, action: ShortcutAction) -> Result { - let mut next_settings = self.settings.borrow().clone(); - next_settings.keybindings.set_accelerators( - action, - action - .default_accelerators() - .iter() - .map(|binding| (*binding).to_string()) - .collect(), - ); - settings_store::save_config(&self.config_path, &next_settings) - .map_err(|error| format!("failed to save settings: {error}"))?; - *self.settings.borrow_mut() = next_settings; - Ok(self.shortcut_label(action)) +fn ghostty_probe_log_path(mode: GhosttyProbeMode) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + std::env::temp_dir().join(format!( + "taskers-ghostty-probe-{}-{}-{timestamp}.log", + mode.as_arg(), + std::process::id() + )) +} + +fn describe_exit_status(status: std::process::ExitStatus) -> String { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + + if let Some(signal) = status.signal() { + return format!("Ghostty self-probe crashed with signal {signal}"); + } } - fn apply_shortcut_preset(self: &Rc, preset: ShortcutPreset) -> Result<(), String> { - let mut next_settings = self.settings.borrow().clone(); - next_settings.keybindings.replace_with_preset(preset); - settings_store::save_config(&self.config_path, &next_settings) - .map_err(|error| format!("failed to save settings: {error}"))?; - *self.settings.borrow_mut() = next_settings; - Ok(()) + match status.code() { + Some(code) => format!("Ghostty self-probe exited with status {code}"), + None => "Ghostty self-probe exited unsuccessfully".into(), } +} - fn save_settings(&self, next_settings: AppConfig) -> Result<(), String> { - settings_store::save_config(&self.config_path, &next_settings) - .map_err(|error| format!("failed to save settings: {error}"))?; - *self.settings.borrow_mut() = next_settings; - Ok(()) +fn sync_window( + window: &adw::ApplicationWindow, + core: &SharedCore, + host: &Rc>, + last_revision: &Cell, + last_size: &Cell<(i32, i32)>, + diagnostics: Option<&DiagnosticsWriter>, +) { + core.sync_external_changes(); + + for command in core.drain_host_commands() { + if let Err(error) = host.borrow_mut().handle_command(command) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::HostEvent, + Some(core.revision()), + format!("host command failed: {error}"), + ), + ); + eprintln!("taskers host command failed: {error}"); + } } - fn set_shell_program(self: &Rc, shell_program: Option) -> Result<(), String> { - let normalized = shell_program.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); - validate_shell_program(normalized.as_deref()) - .map_err(|error| format!("invalid shell program: {error}"))?; - - let mut next_settings = self.settings.borrow().clone(); - next_settings.shell.program = normalized; - self.save_settings(next_settings) - } - - fn present_shortcut_capture_dialog(self: &Rc, action: ShortcutAction, label: &Label) { - let dialog = gtk::Dialog::with_buttons( - Some(action.label()), - Some(&self.window), - gtk::DialogFlags::MODAL, - &[("Cancel", gtk::ResponseType::Cancel)], - ); - dialog.set_default_size(420, -1); - dialog.connect_response(|dialog, _| dialog.close()); - - let content = dialog.content_area(); - content.set_margin_start(18); - content.set_margin_end(18); - content.set_margin_top(18); - content.set_margin_bottom(18); - content.set_spacing(12); - - let prompt = Label::new(Some("Press the new shortcut now.")); - prompt.set_xalign(0.0); - prompt.add_css_class("dialog-heading"); - content.append(&prompt); - - let detail = Label::new(Some( - "Esc cancels. The new shortcut replaces the current bindings for this action.", - )); - detail.set_xalign(0.0); - detail.set_wrap(true); - content.append(&detail); - - let current = Label::new(Some(&format!("Current: {}", self.shortcut_label(action)))); - current.set_xalign(0.0); - current.add_css_class("dim-label"); - content.append(¤t); - - let capture_ui = Rc::clone(self); - let captured_label = label.clone(); - let capture_dialog = dialog.clone(); - let controller = gtk::EventControllerKey::new(); - controller.set_propagation_phase(gtk::PropagationPhase::Capture); - controller.connect_key_pressed(move |_, key, _, state| { - if key == gdk::Key::Escape { - capture_dialog.close(); - return glib::Propagation::Stop; - } - if is_modifier_key(key) { - return glib::Propagation::Stop; - } - - let modifiers = normalize_shortcut_modifiers(state); - if modifiers.is_empty() || !gtk::accelerator_valid(key, modifiers) { - capture_ui.toast("Shortcut must use a valid modified key chord."); - return glib::Propagation::Stop; - } - - match capture_ui.set_shortcut(action, gtk::accelerator_name(key, modifiers).to_string()) - { - Ok(next_label) => { - captured_label.set_text(&next_label); - capture_dialog.close(); - } - Err(error) => capture_ui.toast(&error), - } - - glib::Propagation::Stop - }); - dialog.add_controller(controller); - dialog.present(); - } - - fn present_settings_dialog(self: &Rc) { - let dialog = gtk::Dialog::with_buttons( - Some("Settings"), - Some(&self.window), - gtk::DialogFlags::MODAL, - &[("Close", gtk::ResponseType::Close)], - ); - dialog.set_default_size(760, 660); - dialog.connect_response(|dialog, _| dialog.close()); - - let stack = gtk::Stack::new(); - stack.set_transition_type(gtk::StackTransitionType::Crossfade); - stack.set_transition_duration(150); - stack.set_vexpand(true); - - stack.add_titled(&build_settings_theme_page(self), Some("theme"), "Theme"); - stack.add_titled( - &build_settings_general_page(self), - Some("general"), - "General", - ); - stack.add_titled( - &build_settings_shortcuts_page(self), - Some("shortcuts"), - "Keyboard shortcuts", - ); - - let switcher = gtk::StackSwitcher::new(); - switcher.set_stack(Some(&stack)); - switcher.set_halign(Align::Center); - switcher.add_css_class("settings-nav"); - - let layout = GtkBox::new(Orientation::Vertical, 0); - layout.append(&switcher); - layout.append(&stack); - - dialog.content_area().append(&layout); - dialog.present(); - } - - fn send_input(self: &Rc, surface_id: SurfaceId, input: String) { - if let Err(error) = self.app_state.runtime().send_input(surface_id, &input) { - self.toast(&error.to_string()); - } - } - - fn tick_terminal_host(&self) { - if let Some(host) = &self.ghostty_host { - let _ = host.tick(); - } - } - - fn terminal_widget( - self: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - ) -> Option { - if self.backend_choice != BackendChoice::Ghostty { - return None; - } - - let surface = pane.active_surface()?; - - if let Some(widget) = self.ghostty_surfaces.borrow().get(&surface.id) { - detach_widget(widget); - return Some(widget.clone()); - } - - let host = self.ghostty_host.as_ref()?; - let widget = match self - .app_state - .surface_descriptor_for_pane(workspace_id, pane.id) - .and_then(|descriptor| { - host.create_surface(&descriptor) - .map_err(|error| anyhow::anyhow!(error.to_string())) - }) { - Ok(widget) => widget, - Err(error) => { - self.toast(&error.to_string()); - return None; - } - }; - - widget.set_hexpand(true); - widget.set_vexpand(true); - widget.add_css_class("terminal-output"); - connect_ghostty_widget(self, workspace_id, pane.id, surface.id, &widget); - self.ghostty_surfaces - .borrow_mut() - .insert(surface.id, widget.clone()); - Some(widget) - } - - fn present_window(&self) { - self.window.present(); - } - - fn ensure_shell(self: &Rc) { - if self.shell.borrow().is_some() { - return; - } - - let shell = build_shell_scaffold(self); - self.overlay.set_child(Some(&shell.root)); - *self.shell.borrow_mut() = Some(shell); - } - - fn cleanup_stale_panes(&self, model: &AppModel) { - let live: HashSet = model - .workspaces - .values() - .flat_map(|ws| { - ws.panes - .values() - .flat_map(|pane| pane.surface_ids()) - .collect::>() - }) - .collect(); - self.pane_cards.borrow_mut().retain(|id, _| { - model - .workspaces - .values() - .any(|workspace| workspace.panes.contains_key(id)) - }); - self.ghostty_surfaces - .borrow_mut() - .retain(|id, _| live.contains(id)); - } - - fn render_model(self: &Rc, model: &AppModel) { - let shell = self - .shell - .borrow() - .as_ref() - .cloned() - .expect("shell scaffold should exist before render"); - update_sidebar(self, &shell, model); - update_toolbar(&shell, model, self.overview_mode.get()); - update_activity_panel(self, &shell, model); - update_layout(self, &shell, model); - self.sync_desktop_notifications(model); - } - - fn sync_desktop_notifications(&self, model: &AppModel) { - let items = model.activity_items(); - let active_keys = items - .iter() - .map(activity_notification_key) - .collect::>(); - - let mut delivered = self.desktop_notifications.borrow_mut(); - let stale = delivered - .iter() - .filter(|key| !active_keys.contains(*key)) - .cloned() - .collect::>(); - for key in stale { - self.application.withdraw_notification(&key); - delivered.remove(&key); - } - - for item in items { - let key = activity_notification_key(&item); - if delivered.contains(&key) { - continue; - } - - let (workspace_label, pane_title) = model - .workspaces - .get(&item.workspace_id) - .map(|workspace| { - let title = workspace - .panes - .get(&item.pane_id) - .and_then(|pane| { - pane.surfaces - .get(&item.surface_id) - .or_else(|| pane.active_surface()) - .map(display_surface_title) - }) - .unwrap_or_else(|| "Terminal pane".into()); - (workspace.label.clone(), title) - }) - .unwrap_or_else(|| ("Workspace".into(), "Terminal pane".into())); - - let notification_title = - format!("{} in {}", activity_kind_label(&item.kind), workspace_label); - let notification = gtk::gio::Notification::new(¬ification_title); - notification.set_body(Some(&format!("{pane_title}: {}", item.message))); - notification.set_priority(match item.state { - AttentionState::Error => gtk::gio::NotificationPriority::Urgent, - AttentionState::WaitingInput => gtk::gio::NotificationPriority::High, - _ => gtk::gio::NotificationPriority::Normal, - }); - self.application - .send_notification(Some(&key), ¬ification); - delivered.insert(key); - } - } - - fn queue_viewport_persist( - self: &Rc, - workspace_id: taskers_domain::WorkspaceId, - viewport: WorkspaceViewport, - ) { - if self.overview_mode.get() { - return; - } - *self.pending_viewport.borrow_mut() = Some((workspace_id, viewport)); - if let Some(source) = self.pending_viewport_source.borrow_mut().take() { - source.remove(); - } - - let save_ui = Rc::clone(self); - let source = glib::timeout_add_local_once(Duration::from_millis(150), move || { - *save_ui.pending_viewport_source.borrow_mut() = None; - let Some((workspace_id, viewport)) = save_ui.pending_viewport.borrow_mut().take() - else { - return; - }; - - let current_viewport = save_ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .map(|workspace| workspace.viewport.clone()); - if current_viewport.as_ref() == Some(&viewport) { - return; - } - - save_ui.dispatch(ControlCommand::SetWorkspaceViewport { - workspace_id, - viewport, - }); - }); - *self.pending_viewport_source.borrow_mut() = Some(source); - } - - fn persist_viewport_now( - self: &Rc, - workspace_id: taskers_domain::WorkspaceId, - viewport: WorkspaceViewport, - ) { - let current_viewport = self - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .map(|workspace| workspace.viewport.clone()); - if current_viewport.as_ref() == Some(&viewport) { - return; - } - - self.dispatch(ControlCommand::SetWorkspaceViewport { - workspace_id, - viewport, - }); - } - - fn queue_active_workspace_viewport_persist(self: &Rc) { - if *self.suppress_viewport_events.borrow() - || self.overview_mode.get() - || self.top_level_resize_preview_active() - { - return; - } - - let Some(shell) = self.shell.borrow().as_ref().cloned() else { - return; - }; - let model = self.app_state.snapshot_model(); - let Some(workspace) = model.active_workspace() else { - return; - }; - self.queue_viewport_persist(workspace.id, current_workspace_viewport(&shell)); - } - - fn set_workspace_viewport(&self, shell: &ShellWidgets, viewport: &WorkspaceViewport) { - let h_adjustment = shell.layout_scroll.hadjustment(); - let v_adjustment = shell.layout_scroll.vadjustment(); - let max_x = (h_adjustment.upper() - h_adjustment.page_size()).max(h_adjustment.lower()); - let max_y = (v_adjustment.upper() - v_adjustment.page_size()).max(v_adjustment.lower()); - - *self.suppress_viewport_events.borrow_mut() = true; - h_adjustment.set_value(f64::from(viewport.x).clamp(h_adjustment.lower(), max_x)); - v_adjustment.set_value(f64::from(viewport.y).clamp(v_adjustment.lower(), max_y)); - *self.suppress_viewport_events.borrow_mut() = false; - } - - fn reveal_active_window(&self, shell: &ShellWidgets, workspace: &Workspace) { - let render_context = workspace_render_context( - self, - Some(shell), - workspace, - self.overview_mode.get(), - workspace_viewport_width(self, Some(shell)), - workspace_viewport_height(self, Some(shell)), - ); - let metrics = workspace_canvas_metrics(workspace, render_context); - let Some(active_frame) = workspace_display_window_placements(workspace, render_context) - .into_iter() - .find(|placement| placement.window_id == workspace.active_window) - .map(|placement| placement.frame) - else { - return; - }; - - let h_adjustment = shell.layout_scroll.hadjustment(); - let v_adjustment = shell.layout_scroll.vadjustment(); - let h_page = h_adjustment.page_size(); - let v_page = v_adjustment.page_size(); - if h_page <= 0.0 || v_page <= 0.0 { - return; - } - - let left = f64::from(active_frame.x + metrics.offset_x); - let top = f64::from(active_frame.y + metrics.offset_y); - let right = left + f64::from(active_frame.width); - let bottom = top + f64::from(active_frame.height); - - let current_x = h_adjustment.value(); - let current_y = v_adjustment.value(); - let max_x = (h_adjustment.upper() - h_page).max(h_adjustment.lower()); - let max_y = (v_adjustment.upper() - v_page).max(v_adjustment.lower()); - - let next_x = if left < current_x { - left - } else if right > current_x + h_page { - right - h_page - } else { - current_x - } - .clamp(h_adjustment.lower(), max_x); - - let next_y = if top < current_y { - top - } else if bottom > current_y + v_page { - bottom - v_page - } else { - current_y - } - .clamp(v_adjustment.lower(), max_y); - - *self.suppress_viewport_events.borrow_mut() = true; - h_adjustment.set_value(next_x); - v_adjustment.set_value(next_y); - *self.suppress_viewport_events.borrow_mut() = false; - } - - fn write_ui_integrity_snapshot(&self, model: &AppModel) { - let path = self.crash_reporter.ui_integrity_path().to_path_buf(); - let live_layout_host = self - .shell - .borrow() - .as_ref() - .map(|shell| shell.layout_host.clone().upcast::()); - - let (cached_pane_card_ids, attached_pane_card_ids) = { - let pane_cards = self.pane_cards.borrow(); - ( - sorted_id_strings(pane_cards.keys().copied()), - sorted_id_strings(pane_cards.iter().filter_map(|(id, card)| { - live_layout_host.as_ref().and_then(|layout_host| { - widget_is_descendant_of(card.root.upcast_ref(), layout_host).then_some(*id) - }) - })), - ) - }; - - let (cached_ghostty_surface_ids, attached_ghostty_surface_ids) = { - let ghostty_surfaces = self.ghostty_surfaces.borrow(); - ( - sorted_id_strings(ghostty_surfaces.keys().copied()), - sorted_id_strings(ghostty_surfaces.iter().filter_map(|(id, widget)| { - live_layout_host.as_ref().and_then(|layout_host| { - widget_is_descendant_of(widget, layout_host).then_some(*id) - }) - })), - ) - }; - - let ( - layout_host_child_count, - layout_root_widget_type, - viewport_page_x, - viewport_page_y, - viewport_upper_x, - viewport_upper_y, - ) = { - let shell = self.shell.borrow(); - shell - .as_ref() - .map_or((0, None, 0.0, 0.0, 0.0, 0.0), |shell| { - ( - count_widget_children(shell.layout_host.upcast_ref()), - shell - .layout_host - .first_child() - .map(|child| child.type_().name().to_string()), - shell.layout_scroll.hadjustment().page_size(), - shell.layout_scroll.vadjustment().page_size(), - shell.layout_scroll.hadjustment().upper(), - shell.layout_scroll.vadjustment().upper(), - ) - }) - }; - let display_viewport_height = self - .shell - .borrow() - .as_ref() - .map(|shell| workspace_viewport_height(self, Some(shell))) - .unwrap_or(DEFAULT_WORKSPACE_WINDOW_HEIGHT); - let display_viewport_width = self - .shell - .borrow() - .as_ref() - .map(|shell| workspace_viewport_width(self, Some(shell))) - .unwrap_or(DEFAULT_WORKSPACE_WINDOW_WIDTH); - let focused_widget_type = gtk::prelude::GtkWindowExt::focus(&self.window) - .map(|widget| widget.type_().name().to_string()); - let active_pane_focus_widget_type = model.active_workspace().and_then(|workspace| { - self.pane_cards - .borrow() - .get(&workspace.active_pane) - .map(|card| { - pane_focus_target(self, workspace, workspace.active_pane, card) - .type_() - .name() - .to_string() - }) - }); - let active_pane_focus_has_focus = model.active_workspace().is_some_and(|workspace| { - self.pane_cards - .borrow() - .get(&workspace.active_pane) - .is_some_and(|card| { - widget_contains_window_focus( - &self.window, - &pane_focus_target(self, workspace, workspace.active_pane, card), - ) - }) - }); - let active_pane_card_has_focus = model.active_workspace().is_some_and(|workspace| { - self.pane_cards - .borrow() - .get(&workspace.active_pane) - .is_some_and(|card| { - widget_contains_window_focus(&self.window, card.root.upcast_ref()) - }) - }); - - let all_live_pane_ids = sorted_id_strings( - model - .workspaces - .values() - .flat_map(|workspace| workspace.panes.keys().copied()), - ); - let ( - active_workspace_pane_ids, - active_layout_pane_ids, - active_workspace_label, - active_window_id, - active_pane_id, - active_surface_id, - active_displayed_surface_id, - active_terminal_child_count, - workspace_window_ids, - workspace_windows, - ) = model.active_workspace().map_or_else( - || { - ( - Vec::new(), - Vec::new(), - None, - None::, - None::, - None::, - None::, - 0usize, - Vec::::new(), - Vec::::new(), - ) - }, - |workspace| { - let render_context = workspace_render_context( - self, - self.shell.borrow().as_ref(), - workspace, - self.overview_mode.get(), - display_viewport_width, - display_viewport_height, - ); - ( - sorted_id_strings(workspace.panes.keys().copied()), - workspace - .columns - .values() - .flat_map(|column| column.window_order.iter()) - .filter_map(|window_id| workspace.windows.get(window_id)) - .flat_map(|window| window.layout.leaves()) - .map(|pane_id| pane_id.to_string()) - .collect(), - Some(workspace.label.clone()), - Some(workspace.active_window.to_string()), - Some(workspace.active_pane.to_string()), - workspace - .panes - .get(&workspace.active_pane) - .map(|pane| pane.active_surface.to_string()), - self.pane_cards - .borrow() - .get(&workspace.active_pane) - .and_then(|card| { - card.displayed_surface_id - .get() - .map(|surface_id| surface_id.to_string()) - }), - self.pane_cards - .borrow() - .get(&workspace.active_pane) - .map(|card| count_widget_children(card.terminal_host.upcast_ref())) - .unwrap_or(0), - sorted_id_strings(workspace.windows.keys().copied()), - workspace_display_window_placements(workspace, render_context) - .into_iter() - .filter_map(|placement| { - workspace.windows.get(&placement.window_id).map(|window| { - json!({ - "id": window.id.to_string(), - "column_id": placement.column_id.to_string(), - "x": placement.frame.x, - "y": placement.frame.y, - "width": placement.frame.width, - "height": placement.frame.height, - "active_pane": window.active_pane.to_string(), - "leaf_pane_ids": window - .layout - .leaves() - .into_iter() - .map(|pane_id| pane_id.to_string()) - .collect::>(), - }) - }) - }) - .collect(), - ) - }, - ); - let viewport = self - .shell - .borrow() - .as_ref() - .map(current_workspace_viewport) - .unwrap_or_default(); - - let payload = json!({ - "active_workspace_id": model.active_workspace_id().map(|id| id.to_string()), - "active_workspace_label": active_workspace_label, - "active_workspace_window_id": active_window_id, - "active_workspace_pane_id": active_pane_id, - "active_workspace_surface_id": active_surface_id, - "active_displayed_surface_id": active_displayed_surface_id, - "active_terminal_child_count": active_terminal_child_count, - "active_workspace_window_ids": workspace_window_ids, - "active_workspace_windows": workspace_windows, - "all_live_pane_ids": all_live_pane_ids, - "active_workspace_pane_ids": active_workspace_pane_ids, - "active_layout_pane_ids": active_layout_pane_ids, - "cached_pane_card_ids": cached_pane_card_ids, - "attached_pane_card_ids": attached_pane_card_ids, - "cached_ghostty_surface_ids": cached_ghostty_surface_ids, - "attached_ghostty_surface_ids": attached_ghostty_surface_ids, - "layout_host_child_count": layout_host_child_count, - "layout_root_widget_type": layout_root_widget_type, - "viewport_x": viewport.x, - "viewport_y": viewport.y, - "viewport_page_x": viewport_page_x, - "viewport_page_y": viewport_page_y, - "viewport_upper_x": viewport_upper_x, - "viewport_upper_y": viewport_upper_y, - "focused_widget_type": focused_widget_type, - "active_pane_focus_widget_type": active_pane_focus_widget_type, - "active_pane_focus_has_focus": active_pane_focus_has_focus, - "active_pane_card_has_focus": active_pane_card_has_focus, - "overview_mode": self.overview_mode.get(), - }); - - if let Some(parent) = path.parent() - && let Err(error) = std::fs::create_dir_all(parent) - { - eprintln!( - "failed to create taskers UI integrity directory {}: {error}", - parent.display() - ); - return; - } - - let tmp_path = path.with_extension("tmp"); - let encoded = match serde_json::to_vec_pretty(&payload) { - Ok(encoded) => encoded, - Err(error) => { - eprintln!("failed to encode taskers UI integrity snapshot: {error}"); - return; - } - }; - - if let Err(error) = std::fs::write(&tmp_path, encoded) { - eprintln!( - "failed to write taskers UI integrity snapshot to {}: {error}", - tmp_path.display() - ); - return; - } - - if let Err(error) = std::fs::rename(&tmp_path, &path) { - let _ = std::fs::remove_file(&tmp_path); - eprintln!( - "failed to publish taskers UI integrity snapshot to {}: {error}", - path.display() - ); - } - } - - fn pane_card( - self: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - ) -> PaneCardWidgets { - if let Some(card) = self.pane_cards.borrow().get(&pane.id) { - configure_pane_card_layout(&card); - return card.clone(); - } - - let root = GtkBox::new(Orientation::Vertical, 0); - root.add_css_class("pane-card"); - - let header = GtkBox::new(Orientation::Horizontal, 4); - header.add_css_class("pane-header"); - header.set_margin_start(6); - header.set_margin_end(4); - header.set_margin_top(2); - header.set_margin_bottom(2); - - let agent_icon = build_agent_icon(pane.active_surface().and_then(surface_agent_kind), 14); - agent_icon.add_css_class("pane-agent-icon"); - header.append(agent_icon.widget()); - - let title = Label::new(Some("Unnamed terminal pane")); - title.add_css_class("pane-title"); - title.set_xalign(0.0); - title.set_hexpand(true); - title.set_ellipsize(gtk::pango::EllipsizeMode::End); - title.set_cursor_from_name(Some("text")); - title.set_tooltip_text(Some("Click to rename terminal")); - let title_parent: Widget = title.clone().upcast(); - let rename_title_ui = Rc::clone(self); - let rename_title_pane_id = pane.id; - let rename_title_click = gtk::GestureClick::new(); - rename_title_click.set_button(1); - rename_title_click.connect_pressed(move |gesture, _, _, _| { - gesture.set_state(gtk::EventSequenceState::Claimed); - begin_surface_title_rename(&rename_title_ui, &title_parent, rename_title_pane_id); - }); - title.add_controller(rename_title_click); - header.append(&title); - - let header_tabs = GtkBox::new(Orientation::Horizontal, 2); - header_tabs.add_css_class("pane-header-tabs"); - header_tabs.set_hexpand(true); - header_tabs.set_visible(false); - header.append(&header_tabs); - - let status_dot = Label::new(Some("\u{25cf}")); - status_dot.add_css_class("status-dot"); - let pane_attention = pane.active_attention(); - status_dot.add_css_class(&attention_dot_class(pane_attention)); - status_dot.set_tooltip_text(Some(pane_attention.label())); - header.append(&status_dot); - - let header_actions = GtkBox::new(Orientation::Horizontal, 3); - header_actions.add_css_class("pane-action-cluster"); - header.append(&header_actions); - - let new_window_btn = Button::with_label(TOOLBAR_ACTION_NEW_GLYPH); - new_window_btn.add_css_class("pane-action"); - new_window_btn.add_css_class("pane-window-action"); - new_window_btn.set_tooltip_text(Some("Create a new top-level window")); - let nw_ui = Rc::clone(self); - let nw_btn = new_window_btn.clone(); - let nw_pane_id = pane.id; - new_window_btn.connect_clicked(move |_| { - show_new_window_popover(&nw_btn, &nw_ui, workspace_id, Some(nw_pane_id), None); - }); - header_actions.append(&new_window_btn); - - let split_right_btn = Button::with_label("\u{25eb}"); - split_right_btn.add_css_class("pane-action"); - split_right_btn.add_css_class("pane-split-action"); - split_right_btn.set_tooltip_text(Some("Split right")); - let sr_ui = Rc::clone(self); - let sr_pane_id = pane.id; - split_right_btn.connect_clicked(move |_| { - sr_ui.dispatch(ControlCommand::SplitPane { - workspace_id, - pane_id: Some(sr_pane_id), - axis: taskers_domain::SplitAxis::Horizontal, - }); - }); - header_actions.append(&split_right_btn); - - let split_down_btn = Button::with_label("\u{2501}"); - split_down_btn.add_css_class("pane-action"); - split_down_btn.add_css_class("pane-split-action"); - split_down_btn.set_tooltip_text(Some("Split down")); - let sd_ui = Rc::clone(self); - let sd_pane_id = pane.id; - split_down_btn.connect_clicked(move |_| { - sd_ui.dispatch(ControlCommand::SplitPane { - workspace_id, - pane_id: Some(sd_pane_id), - axis: taskers_domain::SplitAxis::Vertical, - }); - }); - header_actions.append(&split_down_btn); - - let resize_button = Button::with_label(TOOLBAR_ACTION_RESIZE_GLYPH); - resize_button.add_css_class("pane-action"); - resize_button.add_css_class("pane-window-action"); - resize_button.set_tooltip_text(Some("Resize this top-level window")); - resize_button.set_visible(false); - let resize_btn_ref = resize_button.clone(); - let resize_ui = Rc::clone(self); - let resize_pane_id = pane.id; - resize_button.connect_clicked(move |_| { - let model = resize_ui.app_state.snapshot_model(); - if let Some(ws) = model.workspaces.get(&workspace_id) { - if let Some(win_id) = ws.window_for_pane(resize_pane_id) { - show_resize_window_popover(&resize_btn_ref, &resize_ui, workspace_id, win_id); - } - } - }); - header_actions.append(&resize_button); - - let close_button = Button::with_label("\u{00d7}"); - close_button.add_css_class("pane-close"); - close_button.add_css_class("pane-close-action"); - close_button.set_tooltip_text(Some("Close pane")); - let close_ui = Rc::clone(self); - let close_pane_id = pane.id; - close_button.connect_clicked(move |_| { - close_ui.dispatch(ControlCommand::ClosePane { - workspace_id, - pane_id: close_pane_id, - }); - }); - header_actions.append(&close_button); - - // Right-click context menu on pane header - let header_for_ctx = header.clone(); - let ctx_click = gtk::GestureClick::new(); - ctx_click.set_button(3); - let ctx_ui = Rc::clone(self); - let ctx_pane_id = pane.id; - ctx_click.connect_pressed(move |_, _, _, _| { - let popover = gtk::Popover::new(); - popover.set_parent(&header_for_ctx); - - let content = GtkBox::new(Orientation::Vertical, 2); - content.set_margin_start(4); - content.set_margin_end(4); - content.set_margin_top(4); - content.set_margin_bottom(4); - - let rename_terminal = Button::with_label("Rename terminal"); - rename_terminal.add_css_class("flat"); - rename_terminal.add_css_class("context-item"); - let rename_ui = Rc::clone(&ctx_ui); - let rename_parent: Widget = header_for_ctx.clone().upcast(); - let rename_pop = popover.clone(); - rename_terminal.connect_clicked(move |_| { - rename_pop.popdown(); - begin_surface_title_rename(&rename_ui, &rename_parent, ctx_pane_id); - }); - content.append(&rename_terminal); - - let rename_sep = Separator::new(Orientation::Horizontal); - rename_sep.add_css_class("context-separator"); - content.append(&rename_sep); - - let new_right = Button::with_label("\u{2192} New Window Right"); - new_right.add_css_class("flat"); - new_right.add_css_class("context-item"); - let nr_ui = Rc::clone(&ctx_ui); - let nr_pop = popover.clone(); - new_right.connect_clicked(move |_| { - nr_pop.popdown(); - create_workspace_window_from_pane( - &nr_ui, - workspace_id, - ctx_pane_id, - Direction::Right, - ); - }); - content.append(&new_right); - - let new_left = Button::with_label("\u{2190} New Window Left"); - new_left.add_css_class("flat"); - new_left.add_css_class("context-item"); - let nl_ui = Rc::clone(&ctx_ui); - let nl_pop = popover.clone(); - new_left.connect_clicked(move |_| { - nl_pop.popdown(); - create_workspace_window_from_pane( - &nl_ui, - workspace_id, - ctx_pane_id, - Direction::Left, - ); - }); - content.append(&new_left); - - let new_below = Button::with_label("\u{2193} New Window Below"); - new_below.add_css_class("flat"); - new_below.add_css_class("context-item"); - let nb_ui = Rc::clone(&ctx_ui); - let nb_pop = popover.clone(); - new_below.connect_clicked(move |_| { - nb_pop.popdown(); - create_workspace_window_from_pane( - &nb_ui, - workspace_id, - ctx_pane_id, - Direction::Down, - ); - }); - content.append(&new_below); - - let new_above = Button::with_label("\u{2191} New Window Above"); - new_above.add_css_class("flat"); - new_above.add_css_class("context-item"); - let na_ui = Rc::clone(&ctx_ui); - let na_pop = popover.clone(); - new_above.connect_clicked(move |_| { - na_pop.popdown(); - create_workspace_window_from_pane(&na_ui, workspace_id, ctx_pane_id, Direction::Up); - }); - content.append(&new_above); - - let new_window_sep = Separator::new(Orientation::Horizontal); - new_window_sep.add_css_class("context-separator"); - content.append(&new_window_sep); - - let split_right = Button::with_label("\u{25eb} Split Right"); - split_right.add_css_class("flat"); - split_right.add_css_class("context-item"); - let sr_ui = Rc::clone(&ctx_ui); - let sr_pop = popover.clone(); - split_right.connect_clicked(move |_| { - sr_pop.popdown(); - sr_ui.dispatch(ControlCommand::SplitPane { - workspace_id, - pane_id: Some(ctx_pane_id), - axis: taskers_domain::SplitAxis::Horizontal, - }); - }); - content.append(&split_right); - - let split_down = Button::with_label("\u{2501} Split Down"); - split_down.add_css_class("flat"); - split_down.add_css_class("context-item"); - let sd_ui = Rc::clone(&ctx_ui); - let sd_pop = popover.clone(); - split_down.connect_clicked(move |_| { - sd_pop.popdown(); - sd_ui.dispatch(ControlCommand::SplitPane { - workspace_id, - pane_id: Some(ctx_pane_id), - axis: taskers_domain::SplitAxis::Vertical, - }); - }); - content.append(&split_down); - - let sep = Separator::new(Orientation::Horizontal); - sep.add_css_class("context-separator"); - content.append(&sep); - - let close = Button::with_label("Close Pane"); - close.add_css_class("flat"); - close.add_css_class("destructive-action"); - let cl_ui = Rc::clone(&ctx_ui); - let cl_pop = popover.clone(); - close.connect_clicked(move |_| { - cl_pop.popdown(); - cl_ui.dispatch(ControlCommand::ClosePane { - workspace_id, - pane_id: ctx_pane_id, - }); - }); - content.append(&close); - - popover.set_child(Some(&content)); - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - popover.popup(); - }); - header.add_controller(ctx_click); - - root.append(&header); - - let surface_tabs = build_surface_tab_strip(self, workspace_id, pane.id); - root.append(&surface_tabs.root); - - let terminal_host = GtkBox::new(Orientation::Vertical, 0); - terminal_host.set_hexpand(true); - terminal_host.set_vexpand(true); - root.append(&terminal_host); - - let click = gtk::GestureClick::new(); - let focus_ui = Rc::clone(self); - let pane_id = pane.id; - click.connect_pressed(move |_, _, _, _| { - focus_ui.dispatch(ControlCommand::FocusPane { - workspace_id, - pane_id, - }); - }); - root.add_controller(click); - - let card = PaneCardWidgets { - displayed_surface_id: Rc::new(Cell::new(None)), - focus_target: root.clone().upcast(), - root, - header: header.clone(), - agent_icon, - title, - status_dot, - header_actions, - header_tabs, - resize_button, - surface_tabs, - terminal_host, - }; - - configure_pane_card_layout(&card); - let card = PaneCardWidgets { ..card }; - self.pane_cards.borrow_mut().insert(pane.id, card.clone()); - sync_surface_tabs(self, workspace_id, pane, &card); - sync_terminal_body(self, workspace_id, pane, &card); - card - } - - fn sync_pane_card( - self: &Rc, - workspace_id: taskers_domain::WorkspaceId, - active_pane: taskers_domain::PaneId, - pane: &PaneRecord, - ) { - let card = self.pane_card(workspace_id, pane); - let snapshot = pane - .active_surface() - .and_then(|surface| self.app_state.runtime().snapshot(surface.id)); - - let display_title = pane - .active_surface() - .map(display_surface_title) - .unwrap_or_else(|| "Unnamed terminal pane".into()); - configure_agent_icon( - &card.agent_icon, - pane.active_surface().and_then(surface_agent_kind), - 14, - ); - card.title.set_text(&display_title); - card.title.set_tooltip_text(Some(&format!( - "{}\nClick to rename terminal", - format_pane_meta(pane, snapshot.as_ref()) - ))); - - if pane.id == active_pane { - card.root.add_css_class("pane-card-active"); - } else { - card.root.remove_css_class("pane-card-active"); - } - card.header.set_visible(pane.id == active_pane); - card.header_actions.set_visible(pane.id == active_pane); - - for cls in &[ - "status-dot-normal", - "status-dot-busy", - "status-dot-completed", - "status-dot-waiting", - "status-dot-error", - ] { - card.status_dot.remove_css_class(cls); - } - let pane_attention = pane.active_attention(); - for cls in &[ - "pane-card-state-busy", - "pane-card-state-completed", - "pane-card-state-waiting", - "pane-card-state-error", - ] { - card.root.remove_css_class(cls); - } - if pane_attention != AttentionState::Normal { - card.root.add_css_class(&format!( - "pane-card-state-{}", - attention_state_slug(pane_attention) - )); - } - card.status_dot - .add_css_class(&attention_dot_class(pane_attention)); - card.status_dot - .set_tooltip_text(Some(pane_attention.label())); - - // Show resize button only when pane is the sole pane in a single-pane window - let model = self.app_state.snapshot_model(); - let is_sole_pane = model - .workspaces - .get(&workspace_id) - .and_then(|ws| { - let win_id = ws.window_for_pane(pane.id)?; - let window = ws.windows.get(&win_id)?; - Some(window.layout.is_leaf()) - }) - .unwrap_or(false); - card.resize_button.set_visible(is_sole_pane); - - sync_surface_tabs(self, workspace_id, pane, &card); - sync_terminal_body(self, workspace_id, pane, &card); - } - - fn try_focus_pane_input( - &self, - workspace_id: taskers_domain::WorkspaceId, - pane_id: taskers_domain::PaneId, - ) -> bool { - let snapshot = self.app_state.snapshot_model(); - let Some(active_workspace) = snapshot.active_workspace() else { - return false; - }; - if active_workspace.id != workspace_id || active_workspace.active_pane != pane_id { - return false; - } - - let Some(card) = self.pane_cards.borrow().get(&pane_id).cloned() else { - return false; - }; - - let target = pane_focus_target(self, active_workspace, pane_id, &card); - - // Skip focus grab when the target (or a descendant like a Ghostty - // surface) already holds window focus. Prevents GTK4 ScrolledWindow - // from auto-scrolling to make the focused child visible on every - // render cycle, which causes workspace canvas scroll snap-back. - if widget_contains_window_focus(&self.window, &target) { - return true; - } - - target.set_focusable(true); - gtk::prelude::RootExt::set_focus(&self.window, Some(&target)); - - let focused_surface_id = snapshot - .active_workspace() - .and_then(|workspace| workspace.panes.get(&pane_id)) - .and_then(|pane| pane.active_surface().map(|surface| surface.id)); - let focused = if let Some(surface) = focused_surface_id - .and_then(|surface_id| self.ghostty_surfaces.borrow().get(&surface_id).cloned()) - { - self.ghostty_host - .as_ref() - .is_some_and(|host| host.focus_surface(&surface).is_ok()) - } else { - false - }; - if !focused { - let _ = target.grab_focus(); - } - - widget_contains_window_focus(&self.window, &target) - } - - fn queue_focus_active_pane_input(self: &Rc, model: &AppModel) { - let Some(workspace) = model.active_workspace() else { - return; - }; - let expected_workspace_id = workspace.id; - let expected_pane_id = workspace.active_pane; - - if self.try_focus_pane_input(expected_workspace_id, expected_pane_id) { - if let Some(source) = self.pending_focus_source.borrow_mut().take() { - source.remove(); - } - return; - } - - if let Some(source) = self.pending_focus_source.borrow_mut().take() { - source.remove(); - } - - let focus_ui = Rc::clone(self); - let attempts_remaining = Rc::new(Cell::new(50)); - let attempts_for_tick = Rc::clone(&attempts_remaining); - let source = glib::timeout_add_local(Duration::from_millis(10), move || { - if focus_ui.try_focus_pane_input(expected_workspace_id, expected_pane_id) { - *focus_ui.pending_focus_source.borrow_mut() = None; - return glib::ControlFlow::Break; - } - - let remaining = attempts_for_tick.get() - 1; - attempts_for_tick.set(remaining); - if remaining <= 0 { - *focus_ui.pending_focus_source.borrow_mut() = None; - return glib::ControlFlow::Break; - } - - glib::ControlFlow::Continue - }); - *self.pending_focus_source.borrow_mut() = Some(source); - } - - fn toggle_overview(self: &Rc) { - let next = !self.overview_mode.get(); - self.overview_mode.set(next); - self.refresh(true); - - let sync_ui = Rc::clone(self); - glib::timeout_add_local_once(Duration::from_millis(30), move || { - let Some(shell) = sync_ui.shell.borrow().as_ref().cloned() else { - return; - }; - let model = sync_ui.app_state.snapshot_model(); - let Some(workspace) = model.active_workspace() else { - return; - }; - - if sync_ui.overview_mode.get() { - sync_ui.set_workspace_viewport(&shell, &WorkspaceViewport::default()); - } else { - sync_ui.set_workspace_viewport(&shell, &workspace.viewport); - sync_ui.reveal_active_window(&shell, workspace); - sync_ui.queue_focus_active_pane_input(&model); - } - }); - } -} - -fn main() -> gtk::glib::ExitCode { - let cli = Cli::parse(); - scrub_inherited_terminal_env(); - if cli.internal_ghostty_probe { - return run_internal_ghostty_probe(); - } - let shell_mode_non_unique = cli.clean_shell || cli.raw_shell; - let run_non_unique = shell_mode_non_unique - || cli.socket.is_some() - || cli.session.is_some() - || std::env::var_os("TASKERS_NON_UNIQUE").is_some(); - let socket_path = cli.socket.unwrap_or_else(default_socket_path); - let session_path = cli.session.unwrap_or_else(default_session_path); - let config_path = settings_store::default_config_path(); - let crash_reporter = CrashReporter::for_session(&session_path, &config_path); - crash_reporter.install_panic_hook(); - let recovered_crash_report = match crash_reporter.recover_previous_run() { - Ok(path) => path, - Err(error) => { - eprintln!("failed to recover previous taskers crash report: {error}"); - None - } - }; - if let Err(error) = crash_reporter.mark_launch() { - eprintln!("failed to write taskers run marker: {error}"); - } - let ghostty_runtime_toast = match ensure_runtime_installed() { - Ok(Some(runtime)) => Some(format!( - "Installed Ghostty runtime assets to {}", - runtime.runtime_dir.display() - )), - Ok(None) => None, - Err(error) => Some(format!("Ghostty runtime bootstrap unavailable: {error}")), - }; - let probe = DefaultBackend::probe(BackendChoice::Auto); - if cli.clean_shell { - unsafe { - std::env::set_var("TASKERS_SHELL_PROFILE", "clean"); - } - } - if cli.raw_shell { - unsafe { - std::env::set_var("TASKERS_SHELL_PROFILE", "clean"); - std::env::set_var("TASKERS_DISABLE_SHELL_INTEGRATION", "1"); - } - } - let initial_model = match load_or_bootstrap(&session_path, cli.demo) { - Ok(model) => model, - Err(error) => { - eprintln!( - "failed to load session from {}: {error}", - session_path.display() - ); - if cli.demo { - AppModel::demo() - } else { - AppModel::new("Workspace 1") - } - } - }; - let app_config = match settings_store::load_or_default(&config_path) { - Ok(config) => config, - Err(error) => { - eprintln!( - "failed to load settings from {}: {error}", - config_path.display() - ); - AppConfig::default() - } - }; - let (mut shell_launch, shell_integration_toast) = - match install_shell_integration(app_config.shell.program.as_deref()) { - Ok(integration) => (integration.launch_spec(), None), - Err(error) => ( - ShellLaunchSpec::fallback(), - Some(format!("Shell integration unavailable: {error}")), - ), - }; - shell_launch - .env - .insert("TASKERS_SOCKET".into(), socket_path.display().to_string()); - let (backend_choice, _backend_note, ghostty_host, backend_toast) = - initialize_terminal_backend(&probe); - let runtime_toast = merge_startup_toasts( - merge_startup_toasts( - merge_startup_toasts(ghostty_runtime_toast, shell_integration_toast), - cli.clean_shell - .then(|| "Using clean shell startup".to_string()), - ), - merge_startup_toasts( - cli.raw_shell.then(|| "Using raw shell startup".to_string()), - backend_toast, - ), - ); - let startup_toast = merge_startup_toasts( - recovered_crash_report - .map(|path| format!("Recovered an unclean shutdown report at {}", path.display())), - runtime_toast, - ); - let app_state = match AppState::new( - initial_model, - session_path, - backend_choice, - shell_launch.clone(), - ) { - Ok(state) => state, - Err(error) => { - let _ = crash_reporter.mark_clean_shutdown(); - eprintln!("failed to initialize app state: {error}"); - return gtk::glib::ExitCode::FAILURE; - } - }; - let _server_note = spawn_control_server(app_state.controller(), socket_path); - - let (_theme_name, initial_theme_palette) = - theme::load_theme(app_config.theme.as_deref(), themes::builtin_theme); - - let startup = StartupContext { - app_state, - backend_choice, - config_path, - app_config, - crash_reporter, - ghostty_host, - startup_toast, - }; - - let app = adw::Application::builder() - .application_id("dev.taskers.app") - .flags(if run_non_unique { - gtk::gio::ApplicationFlags::NON_UNIQUE - } else { - gtk::gio::ApplicationFlags::empty() - }) - .build(); - - let startup = Rc::new(RefCell::new(Some(startup))); - let hold_guard = Rc::new(RefCell::new(None)); - let startup_for_build = Rc::clone(&startup); - let hold_guard_for_startup = Rc::clone(&hold_guard); - app.connect_startup(move |app| { - theme::install_theme(initial_theme_palette.clone()); - *hold_guard_for_startup.borrow_mut() = Some(app.hold()); - if let Some(startup) = startup_for_build.borrow_mut().take() { - build_ui(app, startup, Rc::clone(&hold_guard_for_startup)); - } - }); - app.connect_activate(move |app| { - if let Some(window) = app.active_window() { - window.present(); - } - }); - - app.run_with_args(&["taskers"]) -} - -fn build_ui( - app: &adw::Application, - startup: StartupContext, - hold_guard: Rc>>, -) { - let window = adw::ApplicationWindow::builder() - .application(app) - .title("taskers") - .default_width(1440) - .default_height(960) - .build(); - window.connect_close_request(move |_| { - drop(hold_guard.borrow_mut().take()); - glib::Propagation::Proceed - }); - - let overlay = adw::ToastOverlay::new(); - overlay.set_vexpand(true); - let root = GtkBox::new(Orientation::Vertical, 0); - root.append(&overlay); - window.set_content(Some(&root)); - - let crash_reporter_for_shutdown = startup.crash_reporter.clone(); - app.connect_shutdown(move |_| { - if let Err(error) = crash_reporter_for_shutdown.mark_clean_shutdown() { - eprintln!("failed to clear taskers run marker: {error}"); - } - }); - - let ui = UiHandle::new( - startup.app_state, - startup.backend_choice, - startup.config_path, - startup.app_config, - app.clone(), - window.clone(), - overlay, - startup.crash_reporter.clone(), - startup.ghostty_host, - ); - connect_navigation_shortcuts(&ui); - ui.refresh(true); - if let Some(message) = startup.startup_toast { - ui.toast(&message); - } - - let poll_ui = Rc::clone(&ui); - gtk::glib::timeout_add_local(Duration::from_millis(300), move || { - poll_ui.refresh(false); - gtk::glib::ControlFlow::Continue - }); - - let tick_ui = Rc::clone(&ui); - glib::timeout_add_local(Duration::from_millis(16), move || { - tick_ui.tick_terminal_host(); - glib::ControlFlow::Continue - }); - - ui.present_window(); - let reveal_ui = Rc::clone(&ui); - glib::timeout_add_local_once(Duration::from_millis(80), move || { - reveal_ui.refresh(false); - let model = reveal_ui.app_state.snapshot_model(); - reveal_ui.queue_focus_active_pane_input(&model); - }); -} - -fn initialize_terminal_backend( - probe: &BackendProbe, -) -> (BackendChoice, String, Option, Option) { - if probe.selected != BackendChoice::Ghostty { - return (BackendChoice::Mock, probe.notes.clone(), None, None); - } - - if let Err(error) = probe_ghostty_backend_process() { - let note = format!( - "{} Ghostty self-probe failed; using placeholder terminal surfaces.", - probe.notes - ); - let toast = format!("Ghostty backend unavailable: {error}"); - return (BackendChoice::Mock, note, None, Some(toast)); - } - - match GhosttyHost::new() { - Ok(host) => ( - BackendChoice::Ghostty, - probe.notes.clone(), - Some(host), - None, - ), - Err(error) => { - let note = format!("{} Falling back to placeholder terminal surfaces.", error); - let toast = format!("Ghostty backend unavailable: {error}"); - (BackendChoice::Mock, note, None, Some(toast)) - } - } -} - -fn run_internal_ghostty_probe() -> gtk::glib::ExitCode { - match GhosttyHost::new() { - Ok(host) => { - let _ = host.tick(); - thread::sleep(Duration::from_millis(250)); - gtk::glib::ExitCode::SUCCESS - } - Err(error) => { - eprintln!("ghostty self-probe failed: {error}"); - gtk::glib::ExitCode::FAILURE - } - } -} - -fn probe_ghostty_backend_process() -> Result<(), String> { - let current_exe = std::env::current_exe() - .map_err(|error| format!("failed to resolve current executable: {error}"))?; - let mut child = Command::new(current_exe) - .arg("--internal-ghostty-probe") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|error| format!("failed to launch Ghostty self-probe: {error}"))?; - - let deadline = Instant::now() + Duration::from_secs(5); - loop { - match child.try_wait() { - Ok(Some(status)) => { - if status.success() { - return Ok(()); - } - return Err(describe_exit_status(status)); - } - Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(50)), - Ok(None) => { - let _ = child.kill(); - let _ = child.wait(); - return Err("Ghostty self-probe timed out".into()); - } - Err(error) => return Err(format!("failed to wait for Ghostty self-probe: {error}")), - } - } -} - -fn describe_exit_status(status: std::process::ExitStatus) -> String { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - - if let Some(signal) = status.signal() { - return format!("Ghostty self-probe crashed with signal {signal}"); - } - } - - match status.code() { - Some(code) => format!("Ghostty self-probe exited with status {code}"), - None => "Ghostty self-probe exited unsuccessfully".into(), - } -} - -fn merge_startup_toasts(first: Option, second: Option) -> Option { - match (first, second) { - (Some(first), Some(second)) => Some(format!("{first}\n{second}")), - (Some(first), None) => Some(first), - (None, Some(second)) => Some(second), - (None, None) => None, - } -} - -fn normalize_shortcut_modifiers(state: gdk::ModifierType) -> gdk::ModifierType { - state - & (gdk::ModifierType::SHIFT_MASK - | gdk::ModifierType::CONTROL_MASK - | gdk::ModifierType::ALT_MASK - | gdk::ModifierType::SUPER_MASK - | gdk::ModifierType::META_MASK) -} - -fn is_modifier_key(key: gdk::Key) -> bool { - matches!( - key, - gdk::Key::Control_L - | gdk::Key::Control_R - | gdk::Key::Shift_L - | gdk::Key::Shift_R - | gdk::Key::Alt_L - | gdk::Key::Alt_R - | gdk::Key::Meta_L - | gdk::Key::Meta_R - | gdk::Key::Super_L - | gdk::Key::Super_R - | gdk::Key::Hyper_L - | gdk::Key::Hyper_R - ) -} - -fn connect_navigation_shortcuts(ui: &Rc) { - let controller = gtk::EventControllerKey::new(); - controller.set_propagation_phase(gtk::PropagationPhase::Capture); - let shortcuts_ui = Rc::clone(ui); - controller.connect_key_pressed(move |_, key, _, state| { - if shortcuts_ui.shortcut_matches(ShortcutAction::ToggleOverview, key, state) { - shortcuts_ui.toggle_overview(); - return glib::Propagation::Stop; - } - - let model = shortcuts_ui.app_state.snapshot_model(); - let Some(workspace) = model.active_workspace() else { - return glib::Propagation::Proceed; - }; - - if shortcuts_ui.shortcut_matches(ShortcutAction::CloseTerminal, key, state) { - shortcuts_ui.dispatch(ControlCommand::ClosePane { - workspace_id: workspace.id, - pane_id: workspace.active_pane, - }); - return glib::Propagation::Stop; - } - - for (action, direction) in [ - (ShortcutAction::FocusLeft, Direction::Left), - (ShortcutAction::FocusRight, Direction::Right), - (ShortcutAction::FocusUp, Direction::Up), - (ShortcutAction::FocusDown, Direction::Down), - ] { - if shortcuts_ui.shortcut_matches(action, key, state) { - shortcuts_ui.dispatch(ControlCommand::FocusPaneDirection { - workspace_id: workspace.id, - direction, - }); - return glib::Propagation::Stop; - } - } - - for (action, direction) in [ - (ShortcutAction::NewWindowLeft, Direction::Left), - (ShortcutAction::NewWindowRight, Direction::Right), - (ShortcutAction::NewWindowUp, Direction::Up), - (ShortcutAction::NewWindowDown, Direction::Down), - ] { - if shortcuts_ui.shortcut_matches(action, key, state) { - shortcuts_ui.dispatch(ControlCommand::CreateWorkspaceWindow { - workspace_id: workspace.id, - direction, - }); - return glib::Propagation::Stop; - } - } - - for (action, direction) in [ - (ShortcutAction::ResizeWindowLeft, Direction::Left), - (ShortcutAction::ResizeWindowRight, Direction::Right), - (ShortcutAction::ResizeWindowUp, Direction::Up), - (ShortcutAction::ResizeWindowDown, Direction::Down), - ] { - if shortcuts_ui.shortcut_matches(action, key, state) { - shortcuts_ui.dispatch(ControlCommand::ResizeActiveWindow { - workspace_id: workspace.id, - direction, - amount: KEYBOARD_RESIZE_STEP, - }); - return glib::Propagation::Stop; - } - } - - for (action, direction) in [ - (ShortcutAction::ResizeSplitLeft, Direction::Left), - (ShortcutAction::ResizeSplitRight, Direction::Right), - (ShortcutAction::ResizeSplitUp, Direction::Up), - (ShortcutAction::ResizeSplitDown, Direction::Down), - ] { - if shortcuts_ui.shortcut_matches(action, key, state) { - shortcuts_ui.dispatch(ControlCommand::ResizeActivePaneSplit { - workspace_id: workspace.id, - direction, - amount: KEYBOARD_RESIZE_STEP, - }); - return glib::Propagation::Stop; - } - } - - if shortcuts_ui.shortcut_matches(ShortcutAction::SplitRight, key, state) { - shortcuts_ui.dispatch(ControlCommand::SplitPane { - workspace_id: workspace.id, - pane_id: Some(workspace.active_pane), - axis: taskers_domain::SplitAxis::Horizontal, - }); - return glib::Propagation::Stop; - } - - if shortcuts_ui.shortcut_matches(ShortcutAction::SplitDown, key, state) { - shortcuts_ui.dispatch(ControlCommand::SplitPane { - workspace_id: workspace.id, - pane_id: Some(workspace.active_pane), - axis: taskers_domain::SplitAxis::Vertical, - }); - return glib::Propagation::Stop; - } - - glib::Propagation::Proceed - }); - ui.window.add_controller(controller); -} - -fn build_shell_scaffold(ui: &Rc) -> ShellWidgets { - let shell = Paned::builder() - .orientation(Orientation::Horizontal) - .wide_handle(false) - .build(); - - // --- Sidebar --- - let sidebar = GtkBox::new(Orientation::Vertical, 2); - sidebar.add_css_class("workspace-sidebar"); - sidebar.set_margin_top(4); - sidebar.set_margin_bottom(4); - - let sidebar_header = GtkBox::new(Orientation::Horizontal, 8); - sidebar_header.set_margin_bottom(6); - - let workspaces_label = Label::new(Some("Workspaces")); - workspaces_label.add_css_class("sidebar-heading"); - workspaces_label.set_xalign(0.0); - workspaces_label.set_hexpand(true); - sidebar_header.append(&workspaces_label); - - let add_workspace = Button::with_label("+"); - add_workspace.add_css_class("workspace-add"); - add_workspace.set_tooltip_text(Some("New workspace")); - let add_ui = Rc::clone(ui); - add_workspace.connect_clicked(move |_| { - let model = add_ui.app_state.snapshot_model(); - let label = format!("Workspace {}", model.workspaces.len() + 1); - add_ui.dispatch(ControlCommand::CreateWorkspace { label }); - }); - sidebar_header.append(&add_workspace); - sidebar.append(&sidebar_header); - - let sidebar_list = GtkBox::new(Orientation::Vertical, 2); - sidebar.append(&sidebar_list); - - let sidebar_scroll = ScrolledWindow::new(); - sidebar_scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - sidebar_scroll.set_child(Some(&sidebar)); - sidebar_scroll.set_min_content_width(SIDEBAR_MIN_WIDTH); - sidebar_scroll.set_size_request(SIDEBAR_MIN_WIDTH, -1); - shell.set_start_child(Some(&sidebar_scroll)); - shell.set_position(SIDEBAR_MIN_WIDTH); - shell.set_resize_start_child(false); - shell.set_shrink_start_child(false); - shell.connect_position_notify(|paned| { - let clamped = clamp_sidebar_split_position(paned.position()); - if clamped != paned.position() { - paned.set_position(clamped); - } - }); - - // --- Main content split --- - let content_split = Paned::builder() - .orientation(Orientation::Horizontal) - .wide_handle(false) - .build(); - - // --- Main column --- - let main_column = GtkBox::new(Orientation::Vertical, 0); - - // --- Workspace header bar (custom, not adw::HeaderBar) --- - let workspace_header = GtkBox::new(Orientation::Horizontal, 8); - workspace_header.add_css_class("workspace-header"); - workspace_header.set_size_request(-1, 32); - workspace_header.set_margin_start(10); - workspace_header.set_margin_end(10); - workspace_header.set_margin_top(4); - workspace_header.set_margin_bottom(2); - - let workspace_name_button = Button::new(); - workspace_name_button.add_css_class("flat"); - workspace_name_button.add_css_class("workspace-header-title-btn"); - let workspace_name_label = Label::new(Some("")); - workspace_name_label.add_css_class("workspace-header-label"); - workspace_name_label.set_ellipsize(gtk::pango::EllipsizeMode::End); - workspace_name_button.set_child(Some(&workspace_name_label)); - workspace_name_button.set_tooltip_text(Some("Click to rename workspace")); - let rename_ui = Rc::clone(ui); - let rename_parent = workspace_name_button.clone(); - workspace_name_button.connect_clicked(move |_| { - let model = rename_ui.app_state.snapshot_model(); - let Some(ws) = model.active_workspace() else { - return; - }; - let workspace_id = ws.id; - let popover = gtk::Popover::new(); - popover.set_parent(&rename_parent); - let entry = Entry::new(); - entry.set_text(&ws.label); - entry.add_css_class("workspace-rename-entry"); - popover.set_child(Some(&entry)); - let commit_ui = Rc::clone(&rename_ui); - let commit_pop = popover.clone(); - entry.connect_activate(move |entry| { - let new_label = entry.text().to_string(); - commit_pop.popdown(); - if !new_label.is_empty() { - commit_ui.dispatch(ControlCommand::RenameWorkspace { - workspace_id, - label: new_label, - }); - } - }); - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - popover.popup(); - entry.grab_focus(); - entry.select_region(0, -1); - }); - workspace_header.append(&workspace_name_button); - - // Spacer - let spacer = Label::new(None); - spacer.set_hexpand(true); - workspace_header.append(&spacer); - - let overview_button = Button::with_label("Overview"); - overview_button.add_css_class("workspace-header-action"); - overview_button.set_tooltip_text(Some("Zoom out to the full workspace strip")); - let overview_ui = Rc::clone(ui); - overview_button.connect_clicked(move |_| { - overview_ui.toggle_overview(); - }); - workspace_header.append(&overview_button); - - let new_window_btn = Button::with_label("New Window"); - new_window_btn.add_css_class("workspace-header-action"); - new_window_btn.set_tooltip_text(Some("Create a new top-level window")); - let nw_parent = new_window_btn.clone(); - let nw_ui = Rc::clone(ui); - new_window_btn.connect_clicked(move |_| { - let Some(workspace_id) = nw_ui.app_state.snapshot_model().active_workspace_id() else { - return; - }; - show_new_window_popover(&nw_parent, &nw_ui, workspace_id, None, None); - }); - workspace_header.append(&new_window_btn); - - let settings_button = Button::with_label("\u{2699}"); - settings_button.add_css_class("workspace-header-action"); - settings_button.set_tooltip_text(Some("Settings")); - let settings_ui = Rc::clone(ui); - settings_button.connect_clicked(move |_| { - settings_ui.present_settings_dialog(); - }); - workspace_header.append(&settings_button); - - // Explicit close button (CSD window controls are hidden under prefer-no-csd) - let close_button = Button::with_label("\u{00d7}"); - close_button.add_css_class("workspace-header-action"); - close_button.add_css_class("workspace-header-close"); - close_button.set_tooltip_text(Some("Close window")); - let close_window = ui.window.clone(); - close_button.connect_clicked(move |_| { - close_window.close(); - }); - workspace_header.append(&close_button); - - let workspace_handle = gtk::WindowHandle::new(); - workspace_handle.set_child(Some(&workspace_header)); - main_column.append(&workspace_handle); - - let layout_host = Fixed::new(); - layout_host.set_hexpand(true); - layout_host.set_vexpand(true); - layout_host.set_halign(Align::Start); - layout_host.set_valign(Align::Start); - - let workspace_stage_root = Overlay::new(); - workspace_stage_root.set_halign(Align::Start); - workspace_stage_root.set_valign(Align::Start); - workspace_stage_root.set_hexpand(false); - workspace_stage_root.set_vexpand(false); - - let ghost_layer = Fixed::new(); - ghost_layer.set_halign(Align::Start); - ghost_layer.set_valign(Align::Start); - ghost_layer.set_hexpand(false); - ghost_layer.set_vexpand(false); - ghost_layer.set_can_target(false); - workspace_stage_root.add_overlay(&ghost_layer); - - let workspace_stage = WorkspaceStageWidgets { - root: workspace_stage_root, - ghost_layer, - }; - - let layout_scroll = ScrolledWindow::new(); - layout_scroll.set_hexpand(true); - layout_scroll.set_vexpand(true); - layout_scroll.set_policy(PolicyType::Automatic, PolicyType::Automatic); - layout_scroll.set_child(Some(&layout_host)); - - let horizontal_ui = Rc::clone(ui); - layout_scroll.hadjustment().connect_value_changed(move |_| { - horizontal_ui.queue_active_workspace_viewport_persist(); - }); - let vertical_ui = Rc::clone(ui); - layout_scroll.vadjustment().connect_value_changed(move |_| { - vertical_ui.queue_active_workspace_viewport_persist(); - }); - - main_column.append(&layout_scroll); - - content_split.set_start_child(Some(&main_column)); - content_split.set_resize_start_child(true); - content_split.set_shrink_start_child(false); - - // --- Attention column --- - let attention_panel = GtkBox::new(Orientation::Vertical, 6); - attention_panel.add_css_class("attention-panel"); - - let attention_header = GtkBox::new(Orientation::Horizontal, 8); - let attention_label = Label::new(Some("Attention")); - attention_label.add_css_class("sidebar-heading"); - attention_label.set_xalign(0.0); - attention_label.set_hexpand(true); - attention_header.append(&attention_label); - attention_panel.append(&attention_header); - - let activity_empty = Label::new(Some("No unread items.")); - activity_empty.add_css_class("empty-state"); - activity_empty.set_wrap(true); - activity_empty.set_xalign(0.0); - attention_panel.append(&activity_empty); - - let activity_list = GtkBox::new(Orientation::Vertical, 0); - activity_list.set_vexpand(true); - attention_panel.append(&activity_list); - - let attention_scroll = ScrolledWindow::new(); - attention_scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - attention_scroll.set_size_request(264, -1); - attention_scroll.set_child(Some(&attention_panel)); - content_split.set_end_child(Some(&attention_scroll)); - content_split.set_resize_end_child(false); - content_split.set_shrink_end_child(false); - - shell.set_end_child(Some(&content_split)); - - ShellWidgets { - root: shell, - sidebar_list, - workspace_name_label, - overview_button, - activity_list, - activity_empty, - layout_scroll, - layout_host, - workspace_stage, - } -} - -fn update_sidebar(ui: &Rc, shell: &ShellWidgets, model: &AppModel) { - clear_box(&shell.sidebar_list); - - if let Ok(summaries) = model.workspace_summaries(model.active_window) { - for summary in summaries { - let outer = Overlay::new(); - outer.add_css_class("workspace-row"); - outer.set_hexpand(true); - - let button = Button::new(); - button.add_css_class("flat"); - button.add_css_class("workspace-button"); - button.set_hexpand(true); - - let item_shell = GtkBox::new(Orientation::Vertical, 0); - item_shell.add_css_class("workspace-item"); - item_shell.set_hexpand(true); - if summary.display_attention != AttentionState::Normal { - item_shell.add_css_class("workspace-item-has-attention"); - item_shell.add_css_class(&format!( - "workspace-item-state-{}", - attention_state_slug(summary.display_attention) - )); - } - if summary.unread_count > 0 { - item_shell.add_css_class("workspace-item-has-unread"); - } - item_shell.set_margin_start(4); - item_shell.set_margin_end(0); - item_shell.set_margin_top(2); - item_shell.set_margin_bottom(2); - - if model.active_workspace_id() == Some(summary.workspace_id) { - item_shell.add_css_class("workspace-item-active"); - } - - let row = GtkBox::new(Orientation::Vertical, 4); - row.set_margin_end(28); - let heading = GtkBox::new(Orientation::Horizontal, 8); - heading.set_hexpand(true); - heading.append(&build_workspace_status_widget(&summary)); - - let agent_icon = build_agent_icon( - model - .workspaces - .get(&summary.workspace_id) - .and_then(workspace_agent_kind), - 12, - ); - agent_icon.add_css_class("workspace-agent-icon"); - heading.append(agent_icon.widget()); - - let label = Label::new(Some(&summary.label)); - label.add_css_class("workspace-label"); - label.set_xalign(0.0); - label.set_hexpand(true); - label.set_ellipsize(gtk::pango::EllipsizeMode::End); - heading.append(&label); - row.append(&heading); - - if let Some(preview_text) = - workspace_preview_text(&summary).filter(|text| text.len() > 2) - { - let preview = Label::new(Some(&preview_text)); - preview.add_css_class("workspace-preview"); - preview.set_xalign(0.0); - preview.set_hexpand(true); - preview.set_ellipsize(gtk::pango::EllipsizeMode::End); - row.append(&preview); - } - - if let Some(workspace) = model.workspaces.get(&summary.workspace_id) - && let Some(meta_text) = - workspace_metadata_line(workspace).filter(|text| text.len() > 2) - { - let meta = Label::new(Some(&meta_text)); - meta.add_css_class("workspace-meta"); - meta.set_xalign(0.0); - meta.set_hexpand(true); - meta.set_ellipsize(gtk::pango::EllipsizeMode::Middle); - row.append(&meta); - } - - item_shell.append(&row); - button.set_child(Some(&item_shell)); - - let switch_ui = Rc::clone(ui); - let workspace_id = summary.workspace_id; - button.connect_clicked(move |_| { - switch_ui.dispatch(ControlCommand::SwitchWorkspace { - window_id: None, - workspace_id, - }); - }); - outer.set_child(Some(&button)); - - let close_btn = Button::with_label("\u{00d7}"); - close_btn.add_css_class("workspace-close"); - if model.active_workspace_id() == Some(summary.workspace_id) { - close_btn.add_css_class("workspace-close-visible"); - } - close_btn.set_tooltip_text(Some("Delete workspace")); - close_btn.set_halign(Align::End); - close_btn.set_valign(Align::Center); - close_btn.set_margin_end(10); - let close_ui = Rc::clone(ui); - let close_ws_id = summary.workspace_id; - close_btn.connect_clicked(move |_| { - close_ui.dispatch(ControlCommand::CloseWorkspace { - workspace_id: close_ws_id, - }); - }); - outer.add_overlay(&close_btn); - - // Double-click to rename workspace - let dbl_click = gtk::GestureClick::new(); - dbl_click.set_button(1); - let dbl_ui = Rc::clone(ui); - let dbl_ws_id = summary.workspace_id; - let dbl_label = summary.label.clone(); - let dbl_parent = button.clone(); - dbl_click.connect_pressed(move |gesture, n_press, _, _| { - if n_press != 2 { - return; - } - gesture.set_state(gtk::EventSequenceState::Claimed); - begin_inline_rename(&dbl_ui, &dbl_parent, dbl_ws_id, &dbl_label); - }); - button.add_controller(dbl_click); - - // Right-click context menu - let menu_ui = Rc::clone(ui); - let menu_ws_id = summary.workspace_id; - let menu_label = summary.label.clone(); - let menu_parent = button.clone(); - let right_click = gtk::GestureClick::new(); - right_click.set_button(3); - right_click.connect_pressed(move |_, _, _, _| { - let popover = gtk::Popover::new(); - popover.set_parent(&menu_parent); - - let content = GtkBox::new(Orientation::Vertical, 2); - content.set_margin_start(4); - content.set_margin_end(4); - content.set_margin_top(4); - content.set_margin_bottom(4); - - let rename = Button::with_label("Rename workspace"); - rename.add_css_class("flat"); - rename.add_css_class("context-item"); - let ren_ui = Rc::clone(&menu_ui); - let ren_ws = menu_ws_id; - let ren_label = menu_label.clone(); - let ren_parent = menu_parent.clone(); - let ren_pop = popover.clone(); - rename.connect_clicked(move |_| { - ren_pop.popdown(); - begin_inline_rename(&ren_ui, &ren_parent, ren_ws, &ren_label); - }); - content.append(&rename); - - let sep = Separator::new(Orientation::Horizontal); - sep.add_css_class("context-separator"); - content.append(&sep); - - let delete = Button::with_label("Delete workspace"); - delete.add_css_class("flat"); - delete.add_css_class("destructive-action"); - let del_ui = Rc::clone(&menu_ui); - let del_ws = menu_ws_id; - let pop = popover.clone(); - delete.connect_clicked(move |_| { - pop.popdown(); - del_ui.dispatch(ControlCommand::CloseWorkspace { - workspace_id: del_ws, - }); - }); - content.append(&delete); - - popover.set_child(Some(&content)); - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - popover.popup(); - }); - button.add_controller(right_click); - - shell.sidebar_list.append(&outer); - } - } -} - -fn build_workspace_status_widget(summary: &taskers_domain::WorkspaceSummary) -> Widget { - let status_text = if summary.unread_count > 0 { - summary.unread_count.min(9).to_string() - } else { - "\u{25cf}".into() - }; - let badge = Label::new(Some(&status_text)); - badge.add_css_class("workspace-status-badge"); - badge.add_css_class(&format!( - "workspace-status-badge-state-{}", - attention_state_slug(summary.display_attention) - )); - if summary.unread_count == 0 { - badge.add_css_class("workspace-status-badge-dot"); - } - if summary.display_attention == AttentionState::Normal { - badge.add_css_class("workspace-status-badge-idle"); - } - badge.set_valign(Align::Start); - badge.set_tooltip_text(Some(&format_workspace_status(summary))); - badge.upcast() -} - -fn workspace_preview_text(summary: &taskers_domain::WorkspaceSummary) -> Option { - if let Some(message) = summary.latest_notification.as_deref() { - let preview = compact_preview(message); - if !preview.is_empty() { - return Some(preview); - } - } - - if let Some(agent_summary) = workspace_agent_subtitle(summary) { - return Some(agent_summary); - } - - (summary.display_attention != AttentionState::Normal) - .then(|| format_workspace_attention(summary)) -} - -fn workspace_metadata_line(workspace: &Workspace) -> Option { - let metadata = workspace_display_metadata(workspace)?; - let mut parts = Vec::new(); - - if let Some(branch) = metadata - .git_branch - .as_deref() - .map(str::trim) - .filter(|branch| !branch.is_empty()) - { - parts.push(branch.to_string()); - } - - if let Some(cwd) = metadata - .cwd - .as_deref() - .map(str::trim) - .filter(|cwd| !cwd.is_empty()) - { - parts.push(compact_path(cwd)); - } - - if !metadata.ports.is_empty() { - parts.push(format_ports(&metadata.ports)); - } - - if parts.is_empty() { - metadata - .repo_name - .as_deref() - .map(str::trim) - .filter(|repo_name| !repo_name.is_empty()) - .map(str::to_owned) - } else { - Some(parts.join(" • ")) - } -} - -fn workspace_display_metadata(workspace: &Workspace) -> Option<&PaneMetadata> { - workspace - .panes - .get(&workspace.active_pane) - .and_then(PaneRecord::active_metadata) - .filter(|metadata| metadata_has_display_context(metadata)) - .or_else(|| { - workspace - .panes - .values() - .filter_map(PaneRecord::active_metadata) - .find(|metadata| metadata_has_display_context(metadata)) - }) -} - -fn workspace_agent_subtitle(summary: &taskers_domain::WorkspaceSummary) -> Option { - if summary.agent_summaries.is_empty() { - return None; - } - - let waiting = summary - .agent_summaries - .iter() - .filter(|agent| agent.state == WorkspaceAgentState::Waiting) - .count(); - let working = summary - .agent_summaries - .iter() - .filter(|agent| agent.state == WorkspaceAgentState::Working) - .count(); - let inactive = summary - .agent_summaries - .iter() - .filter(|agent| agent.state == WorkspaceAgentState::Inactive) - .count(); - - let mut parts = Vec::new(); - if waiting > 0 { - parts.push(format!("{waiting} waiting")); - } - if working > 0 { - parts.push(format!("{working} working")); - } - if inactive > 0 { - parts.push(format!("{inactive} inactive")); - } - - (!parts.is_empty()).then(|| parts.join(", ")) -} - -fn format_workspace_attention(summary: &taskers_domain::WorkspaceSummary) -> String { - let count = summary - .counts_by_attention - .get(&summary.display_attention) - .copied() - .filter(|count| *count > 0) - .unwrap_or(summary.unread_count.max(1)); - let noun = if count == 1 { "tab" } else { "tabs" }; - - match summary.display_attention { - AttentionState::Normal => "Idle".into(), - AttentionState::Busy => format!("{count} {noun} busy"), - AttentionState::Completed => format!("{count} {noun} completed"), - AttentionState::WaitingInput => format!("{count} {noun} waiting"), - AttentionState::Error => format!("{count} {noun} errored"), - } -} - -fn format_workspace_status(summary: &taskers_domain::WorkspaceSummary) -> String { - if summary.unread_count > 0 { - let noun = if summary.unread_count == 1 { - "unread item" - } else { - "unread items" - }; - format!("{} {noun}", summary.unread_count) - } else if let Some(agent_summary) = workspace_agent_subtitle(summary) { - agent_summary - } else { - summary.display_attention.label().to_string() - } -} - -fn update_toolbar(shell: &ShellWidgets, model: &AppModel, overview_mode: bool) { - if let Some(workspace) = model.active_workspace() { - let label = if overview_mode { - format!("{} \u{00b7} Overview", workspace.label) - } else { - workspace.label.clone() - }; - shell.workspace_name_label.set_text(&label); - shell.overview_button.set_label(if overview_mode { - "Exit Overview" - } else { - "Overview" - }); - shell - .overview_button - .set_tooltip_text(Some(if overview_mode { - "Return to the focused workspace view" - } else { - "Zoom out to the full workspace strip" - })); - if overview_mode { - shell - .overview_button - .add_css_class("workspace-header-action-active"); - } else { - shell - .overview_button - .remove_css_class("workspace-header-action-active"); - } - } else { - shell.workspace_name_label.set_text(""); - shell.overview_button.set_label("Overview"); - shell - .overview_button - .remove_css_class("workspace-header-action-active"); - } -} - -fn update_activity_panel(ui: &Rc, shell: &ShellWidgets, model: &AppModel) { - clear_box(&shell.activity_list); - - let items = model.activity_items(); - shell.activity_empty.set_visible(items.is_empty()); - - for item in items { - shell - .activity_list - .append(&build_activity_row(ui, model, &item)); - } -} - -fn build_activity_row(ui: &Rc, model: &AppModel, item: &ActivityItem) -> Widget { - let outer = GtkBox::new(Orientation::Horizontal, 6); - - let button = Button::new(); - button.add_css_class("flat"); - button.add_css_class("activity-item-button"); - button.set_focusable(false); - button.set_hexpand(true); - - let row = GtkBox::new(Orientation::Vertical, 2); - row.add_css_class("activity-item"); - row.add_css_class(&format!( - "activity-item-state-{}", - attention_state_slug(item.state) - )); - row.set_margin_start(8); - row.set_margin_end(6); - row.set_margin_top(5); - row.set_margin_bottom(5); - - let heading = GtkBox::new(Orientation::Horizontal, 6); - let dot = Label::new(Some("\u{25cf}")); - dot.add_css_class("status-dot"); - dot.add_css_class(&attention_dot_class(item.state)); - heading.append(&dot); - - let agent_icon = build_agent_icon( - activity_surface(model, item).and_then(surface_agent_kind), - 13, - ); - agent_icon.add_css_class("activity-agent-icon"); - heading.append(agent_icon.widget()); - - let title = model - .workspaces - .get(&item.workspace_id) - .and_then(|workspace| workspace.panes.get(&item.pane_id)) - .and_then(|pane| { - pane.surfaces - .get(&item.surface_id) - .or_else(|| pane.active_surface()) - .map(display_surface_title) - }) - .unwrap_or_else(|| "Terminal pane".into()); - let title_label = Label::new(Some(&title)); - title_label.add_css_class("pane-title"); - title_label.set_xalign(0.0); - title_label.set_hexpand(true); - title_label.set_ellipsize(gtk::pango::EllipsizeMode::End); - heading.append(&title_label); - - let time_label = Label::new(Some(&item.created_at.time().to_string())); - time_label.add_css_class("activity-time"); - heading.append(&time_label); - row.append(&heading); - - let meta_label = Label::new(Some(&activity_context_line(model, item))); - meta_label.add_css_class("activity-meta"); - meta_label.set_xalign(0.0); - meta_label.set_ellipsize(gtk::pango::EllipsizeMode::Middle); - row.append(&meta_label); - - let message = Label::new(Some(&compact_preview(&item.message))); - message.add_css_class("activity-preview"); - message.set_xalign(0.0); - message.set_ellipsize(gtk::pango::EllipsizeMode::End); - row.append(&message); - button.set_tooltip_text(Some(&item.message)); - - button.set_child(Some(&row)); - - let click_ui = Rc::clone(ui); - let workspace_id = item.workspace_id; - let workspace_window_id = item.workspace_window_id; - let pane_id = item.pane_id; - let surface_id = item.surface_id; - button.connect_clicked(move |_| { - focus_activity_target( - &click_ui, - workspace_id, - workspace_window_id, - pane_id, - surface_id, - ); - }); - - outer.append(&button); - - let done_button = Button::with_label("Clear"); - done_button.add_css_class("activity-action"); - done_button.set_valign(Align::Center); - done_button.set_tooltip_text(Some("Mark this item addressed")); - let done_ui = Rc::clone(ui); - let done_workspace_id = item.workspace_id; - let done_pane_id = item.pane_id; - let done_surface_id = item.surface_id; - done_button.connect_clicked(move |_| { - done_ui.dispatch(ControlCommand::MarkSurfaceCompleted { - workspace_id: done_workspace_id, - pane_id: done_pane_id, - surface_id: done_surface_id, - }); - }); - outer.append(&done_button); - - outer.upcast() -} - -fn activity_context_line(model: &AppModel, item: &ActivityItem) -> String { - let workspace_label = model - .workspaces - .get(&item.workspace_id) - .map(|workspace| workspace.label.clone()) - .unwrap_or_else(|| "Workspace".into()); - - let mut parts = vec![workspace_label]; - if let Some(metadata) = activity_metadata(model, item) { - if let Some(branch) = metadata - .git_branch - .as_deref() - .map(str::trim) - .filter(|branch| !branch.is_empty()) - { - parts.push(branch.to_string()); - } - - if let Some(cwd) = metadata - .cwd - .as_deref() - .map(str::trim) - .filter(|cwd| !cwd.is_empty()) - { - parts.push(compact_path(cwd)); - } - } - parts.push(activity_kind_label(&item.kind).to_string()); - parts.join(" • ") -} - -fn activity_surface<'a>(model: &'a AppModel, item: &ActivityItem) -> Option<&'a SurfaceRecord> { - model - .workspaces - .get(&item.workspace_id) - .and_then(|workspace| workspace.panes.get(&item.pane_id)) - .and_then(|pane| { - pane.surfaces - .get(&item.surface_id) - .or_else(|| pane.active_surface()) - }) -} - -fn activity_metadata<'a>(model: &'a AppModel, item: &ActivityItem) -> Option<&'a PaneMetadata> { - activity_surface(model, item).map(|surface| &surface.metadata) -} - -fn focus_activity_target( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_window_id: Option, - pane_id: taskers_domain::PaneId, - surface_id: SurfaceId, -) { - let model = ui.app_state.snapshot_model(); - if model.active_workspace_id() != Some(workspace_id) { - ui.dispatch(ControlCommand::SwitchWorkspace { - window_id: None, - workspace_id, - }); - } - - focus_workspace_surface(ui, workspace_id, workspace_window_id, pane_id, surface_id); -} - -fn focus_workspace_surface( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_window_id: Option, - pane_id: taskers_domain::PaneId, - surface_id: SurfaceId, -) { - let model = ui.app_state.snapshot_model(); - let Some(workspace) = model.workspaces.get(&workspace_id) else { - return; - }; - - if let Some(workspace_window_id) = - workspace_window_id.filter(|window_id| workspace.windows.contains_key(window_id)) - { - ui.dispatch(ControlCommand::FocusWorkspaceWindow { - workspace_id, - workspace_window_id, - }); - } - - let Some(pane) = workspace.panes.get(&pane_id) else { - return; - }; - - if pane.surfaces.contains_key(&surface_id) { - ui.dispatch(ControlCommand::FocusSurface { - workspace_id, - pane_id, - surface_id, - }); - } else { - ui.dispatch(ControlCommand::FocusPane { - workspace_id, - pane_id, - }); - } -} - -fn begin_inline_rename( - ui: &Rc, - button: &Button, - workspace_id: taskers_domain::WorkspaceId, - current_label: &str, -) { - let entry = Entry::new(); - entry.set_text(current_label); - entry.add_css_class("workspace-rename-entry"); - button.set_child(Some(&entry)); - entry.grab_focus(); - entry.select_region(0, -1); - - let commit_ui = Rc::clone(ui); - let committed = Rc::new(Cell::new(false)); - let committed_for_activate = Rc::clone(&committed); - entry.connect_activate(move |entry| { - if committed_for_activate.get() { - return; - } - committed_for_activate.set(true); - let new_label = entry.text().to_string(); - if !new_label.is_empty() { - commit_ui.dispatch(ControlCommand::RenameWorkspace { - workspace_id, - label: new_label, - }); - } else { - commit_ui.refresh(true); - } - }); - - let focus_ui = Rc::clone(ui); - let committed_for_focus = Rc::clone(&committed); - entry.connect_notify_local(Some("has-focus"), move |entry, _| { - if !entry.has_focus() && !committed_for_focus.get() { - committed_for_focus.set(true); - let new_label = entry.text().to_string(); - if !new_label.is_empty() { - focus_ui.dispatch(ControlCommand::RenameWorkspace { - workspace_id, - label: new_label, - }); - } else { - focus_ui.refresh(true); - } - } - }); - - let esc_ui = Rc::clone(ui); - let committed_for_esc = Rc::clone(&committed); - let key_controller = gtk::EventControllerKey::new(); - key_controller.connect_key_pressed(move |_, key, _, _| { - if key == gdk::Key::Escape { - committed_for_esc.set(true); - esc_ui.refresh(true); - return glib::Propagation::Stop; - } - glib::Propagation::Proceed - }); - entry.add_controller(key_controller); -} - -fn clamp_sidebar_split_position(position: i32) -> i32 { - position.max(SIDEBAR_MIN_WIDTH) -} - -fn begin_surface_title_rename(ui: &Rc, parent: &Widget, pane_id: taskers_domain::PaneId) { - let Some((surface_id, current_title, placeholder_title)) = ui - .app_state - .snapshot_model() - .workspaces - .values() - .find_map(|workspace| { - workspace.panes.get(&pane_id).and_then(|pane| { - pane.active_surface().map(|surface| { - ( - surface.id, - editable_surface_title(surface), - display_surface_title(surface), - ) - }) - }) - }) - else { - return; - }; - - let popover = gtk::Popover::new(); - popover.set_parent(parent); - - let entry = Entry::new(); - entry.set_text(¤t_title); - entry.set_placeholder_text(Some(&placeholder_title)); - entry.add_css_class("workspace-rename-entry"); - entry.set_width_chars(24); - popover.set_child(Some(&entry)); - - let committed = Rc::new(Cell::new(false)); - - let commit_ui = Rc::clone(ui); - let commit_popover = popover.clone(); - let committed_for_activate = Rc::clone(&committed); - entry.connect_activate(move |entry| { - if committed_for_activate.get() { - return; - } - committed_for_activate.set(true); - commit_popover.popdown(); - commit_ui.dispatch(ControlCommand::UpdateSurfaceMetadata { - surface_id, - patch: PaneMetadataPatch { - title: Some(entry.text().trim().to_string()), - ..PaneMetadataPatch::default() - }, - }); - }); - - let focus_ui = Rc::clone(ui); - let focus_popover = popover.clone(); - let committed_for_focus = Rc::clone(&committed); - entry.connect_notify_local(Some("has-focus"), move |entry, _| { - if entry.has_focus() || committed_for_focus.get() { - return; - } - - committed_for_focus.set(true); - focus_popover.popdown(); - focus_ui.dispatch(ControlCommand::UpdateSurfaceMetadata { - surface_id, - patch: PaneMetadataPatch { - title: Some(entry.text().trim().to_string()), - ..PaneMetadataPatch::default() - }, - }); - }); - - let escape_popover = popover.clone(); - let committed_for_escape = Rc::clone(&committed); - let key_controller = gtk::EventControllerKey::new(); - key_controller.connect_key_pressed(move |_, key, _, _| { - if key == gdk::Key::Escape { - committed_for_escape.set(true); - escape_popover.popdown(); - return glib::Propagation::Stop; - } - glib::Propagation::Proceed - }); - entry.add_controller(key_controller); - - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - - popover.popup(); - entry.grab_focus(); - entry.select_region(0, -1); -} - -fn layout_render_key( - workspace: &Workspace, - render_context: WorkspaceRenderContext, -) -> LayoutRenderKey { - // active_window is intentionally excluded so that focus switches - // don't trigger a full canvas rebuild. Active window styling is synced - // separately in update_layout(). - LayoutRenderKey::WorkspaceWindows { - windows: workspace_display_window_placements(workspace, render_context) - .into_iter() - .filter_map(|placement| { - workspace - .windows - .get(&placement.window_id) - .map(|window| WorkspaceWindowRenderKey { - window_id: placement.window_id, - frame: placement.frame, - layout: window.layout.clone(), - }) - }) - .collect(), - } -} - -fn compute_layout_render_state(ui: &UiHandle, model: &AppModel) -> LayoutRenderState { - let shell = ui.shell.borrow().as_ref().cloned(); - let viewport_width = workspace_viewport_width(ui, shell.as_ref()); - let viewport_height = workspace_viewport_height(ui, shell.as_ref()); - let overview_mode = ui.overview_mode.get(); - - LayoutRenderState { - workspace_id: model.active_workspace_id(), - viewport_width, - viewport_height, - overview_mode, - layout: model.active_workspace().map(|workspace| { - let render_context = workspace_render_context( - ui, - shell.as_ref(), - workspace, - overview_mode, - viewport_width, - viewport_height, - ); - layout_render_key(workspace, render_context) - }), - } -} - -fn update_layout(ui: &Rc, shell: &ShellWidgets, model: &AppModel) { - let previous_model = ui.last_rendered.borrow().clone(); - let next_state = compute_layout_render_state(ui.as_ref(), model); - let previous_layout_state = ui.layout_state.borrow().clone(); - let needs_rebuild = previous_layout_state != next_state; - let overview_mode = ui.overview_mode.get(); - let top_level_resize_preview_active = ui.top_level_resize_preview_active(); - - if needs_rebuild { - if let Some(workspace) = model.active_workspace() { - ensure_workspace_stage(shell); - let new_canvas = build_workspace_canvas_widget(ui, shell, workspace); - shell.workspace_stage.root.set_child(Some(&new_canvas)); - - let next_scene = build_workspace_scene_snapshot(ui.as_ref(), shell, workspace); - let next_visuals = build_workspace_scene_visuals(workspace); - let previous_workspace = previous_model - .as_ref() - .and_then(AppModel::active_workspace) - .filter(|candidate| candidate.id == workspace.id); - let should_animate_transition = ui.settings.borrow().animations_enabled - && !overview_mode - && !top_level_resize_preview_active - && !previous_layout_state.overview_mode - && previous_workspace - .map(|previous| has_terminal_lifecycle_change(previous, workspace)) - .unwrap_or(false); - - if should_animate_transition { - let previous_scene = previous_workspace - .map(|workspace| build_workspace_scene_snapshot(ui.as_ref(), shell, workspace)); - let previous_visuals = previous_workspace - .map(build_workspace_scene_visuals) - .unwrap_or_default(); - let mut plan = plan_workspace_transition( - previous_scene.as_ref(), - &next_scene, - TERMINAL_MOTION_SPEC, - ); - let presented = ui.workspace_transition_state.borrow().presented.clone(); - retarget_transition_plan(&mut plan, &presented); - if plan.items.is_empty() { - reset_workspace_transition( - ui.as_ref(), - shell, - (next_scene.canvas_width, next_scene.canvas_height), - ); - } else { - start_workspace_transition( - ui, - shell, - plan, - (next_scene.canvas_width, next_scene.canvas_height), - &previous_visuals, - &next_visuals, - ); - } - } else { - reset_workspace_transition( - ui.as_ref(), - shell, - (next_scene.canvas_width, next_scene.canvas_height), - ); - } - } else { - reset_workspace_transition(ui.as_ref(), shell, (1, 1)); - if shell.workspace_stage.root.parent().is_some() { - shell.layout_host.remove(&shell.workspace_stage.root); - } - shell.layout_host.set_size_request(-1, -1); - let empty = Label::new(Some("No workspace selected")); - empty.add_css_class("empty-state"); - empty.set_xalign(0.5); - empty.set_yalign(0.5); - empty.set_hexpand(true); - empty.set_vexpand(true); - clear_fixed(&shell.layout_host); - shell.layout_host.put(&empty, 0.0, 0.0); - } - *ui.layout_state.borrow_mut() = next_state; - } - - if let Some(workspace) = model.active_workspace() { - // Sync active window CSS class without a full rebuild. - let active_name = format!("ww-{}", workspace.active_window); - sync_active_window_class(&shell.workspace_stage, &active_name); - - for pane in workspace.panes.values() { - ui.sync_pane_card(workspace.id, workspace.active_pane, pane); - } - - let previous_workspace_id = previous_model - .as_ref() - .and_then(AppModel::active_workspace_id); - let previous_active_window = previous_model - .as_ref() - .and_then(AppModel::active_workspace) - .map(|workspace| workspace.active_window); - let previous_active_pane = previous_model - .as_ref() - .and_then(AppModel::active_workspace) - .map(|workspace| workspace.active_pane); - let should_restore_viewport = - !top_level_resize_preview_active && previous_workspace_id != Some(workspace.id); - let should_focus_input = !top_level_resize_preview_active - && (needs_rebuild - || previous_workspace_id != Some(workspace.id) - || previous_active_window != Some(workspace.active_window) - || previous_active_pane != Some(workspace.active_pane)); - let should_reveal = !top_level_resize_preview_active - && (needs_rebuild - || previous_workspace_id != Some(workspace.id) - || previous_active_window != Some(workspace.active_window)); - let should_recover_scroller_focus = !top_level_resize_preview_active - && active_pane_needs_scroller_focus_recovery(ui.as_ref(), shell, workspace); - if should_focus_input || should_recover_scroller_focus { - ui.queue_focus_active_pane_input(model); - } - if overview_mode { - ui.set_workspace_viewport(shell, &WorkspaceViewport::default()); - } - if should_restore_viewport || should_reveal { - let sync_ui = Rc::clone(ui); - let shell = shell.clone(); - let workspace_id = workspace.id; - let viewport = workspace.viewport.clone(); - glib::timeout_add_local_once(Duration::from_millis(30), move || { - let model = sync_ui.app_state.snapshot_model(); - let Some(active_workspace) = model - .active_workspace() - .filter(|workspace| workspace.id == workspace_id) - else { - return; - }; - if sync_ui.overview_mode.get() { - sync_ui.set_workspace_viewport(&shell, &WorkspaceViewport::default()); - return; - } - if should_restore_viewport { - sync_ui.set_workspace_viewport(&shell, &viewport); - } - if should_reveal { - sync_ui.reveal_active_window(&shell, active_workspace); - sync_ui.persist_viewport_now(workspace_id, current_workspace_viewport(&shell)); - } - }); - } - } else { - ui.set_workspace_viewport(shell, &WorkspaceViewport::default()); - } -} - -fn ensure_workspace_stage(shell: &ShellWidgets) { - if shell.workspace_stage.root.parent().is_none() { - clear_fixed(&shell.layout_host); - shell.layout_host.put(&shell.workspace_stage.root, 0.0, 0.0); - } -} - -fn set_workspace_stage_size(shell: &ShellWidgets, width: i32, height: i32) { - let width = width.max(1); - let height = height.max(1); - shell.workspace_stage.root.set_size_request(width, height); - shell - .workspace_stage - .ghost_layer - .set_size_request(width, height); - shell.layout_host.set_size_request(width, height); -} - -fn apply_workspace_preview_placements( - shell: &ShellWidgets, - workspace: &Workspace, - render_context: WorkspaceRenderContext, -) { - let Some(canvas): Option = shell.workspace_stage.root.child().and_downcast::() - else { - return; - }; - - let metrics = workspace_canvas_metrics(workspace, render_context); - let placements = workspace_display_window_placements(workspace, render_context) - .into_iter() - .map(|placement| (format!("ww-{}", placement.window_id), placement.frame)) - .collect::>(); - - set_workspace_stage_size(shell, metrics.width, metrics.height); - canvas.set_size_request(metrics.width, metrics.height); - - let mut child: Option = canvas.first_child(); - while let Some(widget) = child { - let next: Option = widget.next_sibling(); - let Ok(overlay) = widget.clone().downcast::() else { - child = next; - continue; - }; - let Some(inner): Option = overlay.child() else { - child = next; - continue; - }; - let Some(frame) = placements.get(inner.widget_name().as_str()) else { - child = next; - continue; - }; - - overlay.set_size_request(frame.width, frame.height); - inner.set_size_request(frame.width, frame.height); - canvas.move_( - &overlay, - f64::from(frame.x + metrics.offset_x), - f64::from(frame.y + metrics.offset_y), - ); - - child = next; - } -} - -fn reset_workspace_transition(ui: &UiHandle, shell: &ShellWidgets, canvas_size: (i32, i32)) { - clear_fixed(&shell.workspace_stage.ghost_layer); - let mut state = ui.workspace_transition_state.borrow_mut(); - state.motion = None; - state.presented.clear(); - state.target_canvas_width = canvas_size.0.max(1); - state.target_canvas_height = canvas_size.1.max(1); - drop(state); - if shell.workspace_stage.root.parent().is_some() { - set_workspace_stage_size(shell, canvas_size.0, canvas_size.1); - } -} - -fn build_workspace_scene_snapshot( - ui: &UiHandle, - shell: &ShellWidgets, - workspace: &Workspace, -) -> WorkspaceSceneSnapshot { - let render_context = workspace_render_context( - ui, - Some(shell), - workspace, - ui.overview_mode.get(), - workspace_viewport_width(ui, Some(shell)), - workspace_viewport_height(ui, Some(shell)), - ); - let metrics = workspace_canvas_metrics(workspace, render_context); - let placements = workspace_display_window_placements(workspace, render_context); - let windows = placements - .iter() - .map(|placement| WorkspaceWindowSnapshot { - id: placement.window_id, - rect: WindowFrame { - x: placement.frame.x + metrics.offset_x, - y: placement.frame.y + metrics.offset_y, - width: placement.frame.width, - height: placement.frame.height, - }, - }) - .collect::>(); - let panes = placements - .iter() - .filter_map(|placement| { - workspace - .windows - .get(&placement.window_id) - .map(|window| (placement, window)) - }) - .flat_map(|(placement, window)| { - derive_pane_frames(placement.frame, &window.layout) - .into_iter() - .map(move |(pane_id, pane_rect)| PaneSceneSnapshot { - id: pane_id, - window_id: placement.window_id, - rect: WindowFrame { - x: pane_rect.x + metrics.offset_x, - y: pane_rect.y + metrics.offset_y, - width: pane_rect.width, - height: pane_rect.height, - }, - }) - }) - .collect::>(); - - WorkspaceSceneSnapshot { - canvas_width: metrics.width, - canvas_height: metrics.height, - windows, - panes, - } -} - -fn build_workspace_scene_visuals(workspace: &Workspace) -> WorkspaceSceneVisuals { - let windows = workspace - .windows - .values() - .map(|window| { - ( - window.id, - WindowGhostVisual { - active: window.id == workspace.active_window, - attention: workspace_window_attention(workspace, window), - }, - ) - }) - .collect::>(); - - WorkspaceSceneVisuals { windows } -} - -fn has_terminal_lifecycle_change(previous: &Workspace, next: &Workspace) -> bool { - previous.windows.keys().copied().collect::>() - != next.windows.keys().copied().collect::>() -} - -fn start_workspace_transition( - ui: &Rc, - shell: &ShellWidgets, - plan: terminal_transitions::TransitionPlan, - target_canvas_size: (i32, i32), - previous_visuals: &WorkspaceSceneVisuals, - next_visuals: &WorkspaceSceneVisuals, -) { - clear_fixed(&shell.workspace_stage.ghost_layer); - set_workspace_stage_size(shell, plan.canvas_width, plan.canvas_height); - - let mut items = Vec::new(); - for item in plan.items { - let widget = build_workspace_transition_widget(&item, previous_visuals, next_visuals); - let start_rect = presented_transition_rect(item.start_rect); - let end_rect = presented_transition_rect(item.end_rect); - let spec = workspace_transition_spec(item.kind); - shell - .workspace_stage - .ghost_layer - .put(&widget, start_rect.x, start_rect.y); - apply_workspace_transition_widget_frame( - &shell.workspace_stage.ghost_layer, - &widget, - start_rect, - spec.ghost_start_opacity, - ); - items.push(WorkspaceTransitionMotionItem { - id: item.id, - widget, - start_rect, - end_rect, - duration_us: spec.timing.duration_us, - curve: spec.timing.curve, - start_opacity: spec.ghost_start_opacity, - }); - } - - let mut state = ui.workspace_transition_state.borrow_mut(); - state.target_canvas_width = target_canvas_size.0.max(1); - state.target_canvas_height = target_canvas_size.1.max(1); - state.presented = items - .iter() - .map(|item| (item.id, item.start_rect)) - .collect(); - state.motion = Some(WorkspaceTransitionMotionState { - start_time: glib::monotonic_time(), - items, - }); - drop(state); - - start_workspace_transition_tick(ui, shell); -} - -fn workspace_transition_spec( - kind: TransitionItemKind, -) -> terminal_transitions::LifecycleMotionSpec { - match kind { - TransitionItemKind::Window => TERMINAL_MOTION_SPEC.window, - } -} - -fn build_workspace_transition_widget( - item: &terminal_transitions::TransitionItem, - previous_visuals: &WorkspaceSceneVisuals, - next_visuals: &WorkspaceSceneVisuals, -) -> Widget { - match item.id { - TransitionItemId::Window(window_id) => { - let visual = match item.phase { - TransitionPhase::Exit => previous_visuals.windows.get(&window_id).copied(), - _ => next_visuals - .windows - .get(&window_id) - .copied() - .or_else(|| previous_visuals.windows.get(&window_id).copied()), - } - .unwrap_or(WindowGhostVisual { - active: false, - attention: AttentionState::Normal, - }); - build_workspace_window_ghost(visual) - } - } -} - -fn build_workspace_window_ghost(visual: WindowGhostVisual) -> Widget { - let root = GtkBox::new(Orientation::Vertical, 0); - root.add_css_class("workspace-window"); - root.add_css_class("workspace-window-ghost"); - if visual.attention != AttentionState::Normal { - root.add_css_class(&format!( - "workspace-window-state-{}", - attention_state_slug(visual.attention) - )); - } - if visual.active { - root.add_css_class("workspace-window-active"); - } - - let chrome = GtkBox::new(Orientation::Vertical, 0); - chrome.add_css_class("workspace-window-ghost-chrome"); - let header = GtkBox::new(Orientation::Horizontal, 4); - header.add_css_class("pane-header"); - header.add_css_class("workspace-window-ghost-header"); - let title = Label::new(Some("Terminal")); - title.add_css_class("pane-title"); - title.set_xalign(0.0); - title.set_hexpand(true); - header.append(&title); - let dot = Label::new(Some("\u{25cf}")); - dot.add_css_class("status-dot"); - dot.add_css_class(&attention_dot_class(visual.attention)); - header.append(&dot); - chrome.append(&header); - - let strip = GtkBox::new(Orientation::Horizontal, 4); - strip.add_css_class("surface-tabs"); - strip.add_css_class("workspace-window-ghost-strip"); - let tab = GtkBox::new(Orientation::Horizontal, 0); - tab.add_css_class("surface-tab"); - tab.add_css_class("workspace-window-ghost-tab"); - tab.set_size_request(132, 22); - strip.append(&tab); - chrome.append(&strip); - - let body = GtkBox::new(Orientation::Vertical, 0); - body.add_css_class("workspace-window-ghost-body"); - body.set_hexpand(true); - body.set_vexpand(true); - chrome.append(&body); - - root.append(&chrome); - root.upcast() -} - -fn start_workspace_transition_tick(ui: &Rc, shell: &ShellWidgets) { - let mut state = ui.workspace_transition_state.borrow_mut(); - if state.tick_running { - return; - } - state.tick_running = true; - drop(state); - - let ui = Rc::clone(ui); - let shell = shell.clone(); - let tick_root = shell.workspace_stage.root.clone(); - tick_root.add_tick_callback(move |_, clock| { - if advance_workspace_transition_tick(&ui, &shell, clock.frame_time()) { - glib::ControlFlow::Continue - } else { - ui.workspace_transition_state.borrow_mut().tick_running = false; - glib::ControlFlow::Break - } - }); -} - -fn advance_workspace_transition_tick(ui: &UiHandle, shell: &ShellWidgets, now: i64) -> bool { - let (frames, keep_running, target_canvas_size) = { - let mut state = ui.workspace_transition_state.borrow_mut(); - let Some(motion) = state.motion.clone() else { - return false; - }; - - state.presented.clear(); - let mut frames = Vec::new(); - let mut keep_running = false; - for item in &motion.items { - let progress = - ((now - motion.start_time) as f64 / item.duration_us as f64).clamp(0.0, 1.0); - let eased = item.curve.sample(progress); - let rect = lerp_presented_transition_rect(item.start_rect, item.end_rect, eased); - let opacity = item.start_opacity * (1.0 - eased); - state.presented.insert(item.id, rect); - frames.push((item.widget.clone(), rect, opacity)); - if progress < 1.0 { - keep_running = true; - } - } - if !keep_running { - state.motion = None; - state.presented.clear(); - } - ( - frames, - keep_running, - (state.target_canvas_width, state.target_canvas_height), - ) - }; - - for (widget, rect, opacity) in &frames { - apply_workspace_transition_widget_frame( - &shell.workspace_stage.ghost_layer, - widget, - *rect, - *opacity, - ); - } - - if !keep_running { - clear_fixed(&shell.workspace_stage.ghost_layer); - set_workspace_stage_size(shell, target_canvas_size.0, target_canvas_size.1); - } - - keep_running -} - -fn presented_transition_rect(frame: WindowFrame) -> PresentedTransitionRect { - PresentedTransitionRect { - x: f64::from(frame.x), - y: f64::from(frame.y), - width: f64::from(frame.width), - height: f64::from(frame.height), - } -} - -fn lerp_presented_transition_rect( - start: PresentedTransitionRect, - end: PresentedTransitionRect, - t: f64, -) -> PresentedTransitionRect { - PresentedTransitionRect { - x: start.x + ((end.x - start.x) * t), - y: start.y + ((end.y - start.y) * t), - width: start.width + ((end.width - start.width) * t), - height: start.height + ((end.height - start.height) * t), - } -} - -fn apply_workspace_transition_widget_frame( - layer: &Fixed, - widget: &Widget, - rect: PresentedTransitionRect, - opacity: f64, -) { - widget.set_size_request( - rect.width.round().max(1.0) as i32, - rect.height.round().max(1.0) as i32, - ); - layer.move_(widget, rect.x, rect.y); - widget.set_opacity(opacity.clamp(0.0, 1.0)); -} - -/// Walk the layout host tree to toggle `.workspace-window-active` on the -/// correct window widget, identified by its widget name. -fn sync_active_window_class(workspace_stage: &WorkspaceStageWidgets, active_name: &str) { - let Some(canvas) = workspace_stage.root.child() else { - return; - }; - let mut child = canvas.first_child(); - while let Some(widget) = child { - // Each child of the canvas is an Overlay; the workspace-window box - // is the Overlay's child. - if let Some(inner) = widget.first_child() { - if inner.widget_name().as_str() == active_name { - inner.add_css_class("workspace-window-active"); - } else { - inner.remove_css_class("workspace-window-active"); - } - } - child = widget.next_sibling(); - } -} - -fn build_workspace_canvas_widget( - ui: &Rc, - shell: &ShellWidgets, - workspace: &Workspace, -) -> gtk::Widget { - let canvas = Fixed::new(); - canvas.set_halign(Align::Start); - canvas.set_valign(Align::Start); - canvas.set_hexpand(false); - canvas.set_vexpand(false); - - let render_context = workspace_render_context( - ui.as_ref(), - Some(shell), - workspace, - ui.overview_mode.get(), - workspace_viewport_width(ui.as_ref(), Some(shell)), - workspace_viewport_height(ui.as_ref(), Some(shell)), - ); - let metrics = workspace_canvas_metrics(workspace, render_context); - canvas.set_size_request(metrics.width, metrics.height); - - for placement in workspace_display_window_placements(workspace, render_context) { - let Some(window) = workspace.windows.get(&placement.window_id) else { - continue; - }; - let window_widget = build_workspace_window_widget( - ui, - workspace, - window, - placement.column_id, - placement.frame, - ); - let final_x = f64::from(placement.frame.x + metrics.offset_x); - let final_y = f64::from(placement.frame.y + metrics.offset_y); - canvas.put(&window_widget, final_x, final_y); - } - - canvas.upcast() -} - -fn build_workspace_window_widget( - ui: &Rc, - workspace: &Workspace, - window: &taskers_domain::WorkspaceWindowRecord, - workspace_column_id: WorkspaceColumnId, - display_frame: WindowFrame, -) -> gtk::Widget { - let overlay = Overlay::new(); - overlay.set_size_request(display_frame.width, display_frame.height); - overlay.set_halign(Align::Fill); - overlay.set_valign(Align::Fill); - - let root = GtkBox::new(Orientation::Vertical, 0); - root.add_css_class("workspace-window"); - root.set_widget_name(&format!("ww-{}", window.id)); - let window_attention = workspace_window_attention(workspace, window); - if window_attention != AttentionState::Normal { - root.add_css_class(&format!( - "workspace-window-state-{}", - attention_state_slug(window_attention) - )); - } - if window.id == workspace.active_window { - root.add_css_class("workspace-window-active"); - } - root.set_size_request(display_frame.width, display_frame.height); - root.set_hexpand(true); - root.set_vexpand(true); - - let is_single_pane = window.layout.is_leaf(); - let header_height = if is_single_pane { - 0 - } else { - WORKSPACE_WINDOW_HEADER_HEIGHT - }; - - let window_title = workspace_window_title(workspace, window); - let window_header = GtkBox::new(Orientation::Horizontal, 6); - window_header.add_css_class("workspace-window-toolbar"); - window_header.set_size_request(-1, WORKSPACE_WINDOW_HEADER_HEIGHT); - window_header.set_margin_start(8); - window_header.set_margin_end(8); - window_header.set_margin_top(6); - window_header.set_margin_bottom(4); - window_header.set_visible(!is_single_pane); - - let header_title = Label::new(Some(&window_title)); - header_title.add_css_class("workspace-window-toolbar-title"); - header_title.set_xalign(0.0); - header_title.set_hexpand(true); - header_title.set_ellipsize(gtk::pango::EllipsizeMode::End); - header_title.set_tooltip_text(Some(&window_title)); - window_header.append(&header_title); - - let attention_dot = Label::new(Some("\u{25cf}")); - attention_dot.add_css_class("status-dot"); - attention_dot.add_css_class(&attention_dot_class(window_attention)); - attention_dot.set_tooltip_text(Some(window_attention.label())); - window_header.append(&attention_dot); - - let toolbar_actions = GtkBox::new(Orientation::Horizontal, 4); - toolbar_actions.add_css_class("workspace-window-toolbar-actions"); - window_header.append(&toolbar_actions); - - let workspace_id = workspace.id; - let window_id = window.id; - - if window.id != workspace.active_window { - let focus_button = Button::with_label(TOOLBAR_ACTION_FOCUS_GLYPH); - focus_button.add_css_class("workspace-window-toolbar-action"); - focus_button.set_tooltip_text(Some("Focus this top-level window")); - let focus_header_ui = Rc::clone(ui); - focus_button.connect_clicked(move |_| { - focus_header_ui.dispatch(ControlCommand::FocusWorkspaceWindow { - workspace_id, - workspace_window_id: window_id, - }); - }); - toolbar_actions.append(&focus_button); - } - - let new_button = Button::with_label(TOOLBAR_ACTION_NEW_GLYPH); - new_button.add_css_class("workspace-window-toolbar-action"); - new_button.set_tooltip_text(Some("Create a new top-level window from this one")); - let new_button_parent = new_button.clone(); - let new_header_ui = Rc::clone(ui); - new_button.connect_clicked(move |_| { - show_new_window_popover( - &new_button_parent, - &new_header_ui, - workspace_id, - None, - Some(window_id), - ); - }); - toolbar_actions.append(&new_button); - - let resize_button = Button::with_label(TOOLBAR_ACTION_RESIZE_GLYPH); - resize_button.add_css_class("workspace-window-toolbar-action"); - resize_button.set_tooltip_text(Some("Resize this top-level window")); - let resize_button_parent = resize_button.clone(); - let resize_header_ui = Rc::clone(ui); - resize_button.connect_clicked(move |_| { - show_resize_window_popover( - &resize_button_parent, - &resize_header_ui, - workspace_id, - window_id, - ); - }); - toolbar_actions.append(&resize_button); - root.append(&window_header); - - // Focus click on the window root (no separate header bar) - let focus_ui = Rc::clone(ui); - let focus_click = gtk::GestureClick::new(); - focus_click.connect_pressed(move |_, _, _, _| { - focus_ui.dispatch(ControlCommand::FocusWorkspaceWindow { - workspace_id, - workspace_window_id: window_id, - }); - }); - root.add_controller(focus_click); - - // Right-click context menu on workspace window - let root_for_ctx = root.clone(); - let wctx_click = gtk::GestureClick::new(); - wctx_click.set_button(3); - let wctx_ui = Rc::clone(ui); - let wctx_ws_id = workspace.id; - let wctx_win_id = window.id; - let wctx_is_active = window.id == workspace.active_window; - wctx_click.connect_pressed(move |_, _, _, _| { - let popover = gtk::Popover::new(); - popover.set_parent(&root_for_ctx); - - let content = GtkBox::new(Orientation::Vertical, 2); - content.set_margin_start(4); - content.set_margin_end(4); - content.set_margin_top(4); - content.set_margin_bottom(4); - - let new_right = Button::with_label("\u{25eb} New Window Right"); - new_right.add_css_class("flat"); - new_right.add_css_class("context-item"); - let nr_ui = Rc::clone(&wctx_ui); - let nr_pop = popover.clone(); - new_right.connect_clicked(move |_| { - nr_pop.popdown(); - create_workspace_window_from_window(&nr_ui, wctx_ws_id, wctx_win_id, Direction::Right); - }); - content.append(&new_right); - - let new_left = Button::with_label("\u{2190} New Window Left"); - new_left.add_css_class("flat"); - new_left.add_css_class("context-item"); - let nl_ui = Rc::clone(&wctx_ui); - let nl_pop = popover.clone(); - new_left.connect_clicked(move |_| { - nl_pop.popdown(); - create_workspace_window_from_window(&nl_ui, wctx_ws_id, wctx_win_id, Direction::Left); - }); - content.append(&new_left); - - let new_below = Button::with_label("\u{2193} New Window Below"); - new_below.add_css_class("flat"); - new_below.add_css_class("context-item"); - let nb_ui = Rc::clone(&wctx_ui); - let nb_pop = popover.clone(); - new_below.connect_clicked(move |_| { - nb_pop.popdown(); - create_workspace_window_from_window(&nb_ui, wctx_ws_id, wctx_win_id, Direction::Down); - }); - content.append(&new_below); - - let new_above = Button::with_label("\u{2191} New Window Above"); - new_above.add_css_class("flat"); - new_above.add_css_class("context-item"); - let na_ui = Rc::clone(&wctx_ui); - let na_pop = popover.clone(); - new_above.connect_clicked(move |_| { - na_pop.popdown(); - create_workspace_window_from_window(&na_ui, wctx_ws_id, wctx_win_id, Direction::Up); - }); - content.append(&new_above); - - let sep = Separator::new(Orientation::Horizontal); - sep.add_css_class("context-separator"); - content.append(&sep); - - for (label, direction) in [ - ("Resize Narrower", Direction::Left), - ("Resize Wider", Direction::Right), - ("Resize Shorter", Direction::Up), - ("Resize Taller", Direction::Down), - ] { - let resize_button = Button::with_label(label); - resize_button.add_css_class("flat"); - resize_button.add_css_class("context-item"); - let resize_ui = Rc::clone(&wctx_ui); - let resize_pop = popover.clone(); - resize_button.connect_clicked(move |_| { - resize_pop.popdown(); - resize_workspace_window_from_window(&resize_ui, wctx_ws_id, wctx_win_id, direction); - }); - content.append(&resize_button); - } - - let focus_sep = Separator::new(Orientation::Horizontal); - focus_sep.add_css_class("context-separator"); - content.append(&focus_sep); - - let focus_btn = Button::with_label("Focus Window"); - focus_btn.add_css_class("flat"); - focus_btn.add_css_class("context-item"); - focus_btn.set_sensitive(!wctx_is_active); - let f_ui = Rc::clone(&wctx_ui); - let f_pop = popover.clone(); - focus_btn.connect_clicked(move |_| { - f_pop.popdown(); - f_ui.dispatch(ControlCommand::FocusWorkspaceWindow { - workspace_id: wctx_ws_id, - workspace_window_id: wctx_win_id, - }); - }); - content.append(&focus_btn); - - popover.set_child(Some(&content)); - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - popover.popup(); - }); - root.add_controller(wctx_click); - - let body_frame = WindowFrame { - y: display_frame.y + header_height, - height: (display_frame.height - header_height).max(1), - ..display_frame - }; - - let body = build_split_layout_widget( - ui, - workspace, - window.id, - &window.layout, - body_frame, - Vec::new(), - ); - body.set_hexpand(true); - body.set_vexpand(true); - root.append(&body); - - overlay.set_child(Some(&root)); - if !ui.overview_mode.get() { - attach_workspace_window_resize_handles( - ui, - &overlay, - workspace.id, - workspace_column_id, - window.id, - display_frame, - ); - } - overlay.upcast() -} - -fn workspace_window_title( - workspace: &Workspace, - window: &taskers_domain::WorkspaceWindowRecord, -) -> String { - workspace - .panes - .get(&window.active_pane) - .and_then(|pane| pane.active_surface()) - .map(display_surface_title) - .unwrap_or_else(|| "Top-level window".into()) -} - -fn build_split_layout_widget( - ui: &Rc, - workspace: &Workspace, - workspace_window_id: WorkspaceWindowId, - node: &LayoutNode, - rect: WindowFrame, - path: Vec, -) -> gtk::Widget { - match node { - LayoutNode::Leaf { pane_id } => { - let pane = workspace - .panes - .get(pane_id) - .expect("layout pane should exist in workspace"); - ui.sync_pane_card(workspace.id, workspace.active_pane, pane); - let card = ui.pane_card(workspace.id, pane); - detach_widget(card.root.upcast_ref()); - card.root.upcast() - } - LayoutNode::Split { - axis, - ratio, - first, - second, - } => { - let (first_rect, second_rect) = split_layout_rects(rect, *axis, *ratio); - let paned = Paned::builder() - .orientation(match axis { - taskers_domain::SplitAxis::Horizontal => Orientation::Horizontal, - taskers_domain::SplitAxis::Vertical => Orientation::Vertical, - }) - .wide_handle(false) - .build(); - // Seed the Paned with its final divider position up front so split - // creation does not visibly "settle" on the next main-loop turn. - paned.set_position(split_position_for_extent( - match axis { - taskers_domain::SplitAxis::Horizontal => rect.width, - taskers_domain::SplitAxis::Vertical => rect.height, - }, - *ratio, - )); - let mut first_path = path.clone(); - first_path.push(false); - paned.set_start_child(Some(&build_split_layout_widget( - ui, - workspace, - workspace_window_id, - first, - first_rect, - first_path, - ))); - let mut second_path = path.clone(); - second_path.push(true); - paned.set_end_child(Some(&build_split_layout_widget( - ui, - workspace, - workspace_window_id, - second, - second_rect, - second_path, - ))); - bind_split_ratio_updates(ui, workspace.id, workspace_window_id, &paned, *axis, path); - paned.upcast() - } - } -} - -fn attach_workspace_window_resize_handles( - ui: &Rc, - overlay: &Overlay, - workspace_id: taskers_domain::WorkspaceId, - workspace_column_id: WorkspaceColumnId, - workspace_window_id: WorkspaceWindowId, - display_frame: WindowFrame, -) { - overlay.add_overlay(&build_workspace_window_resize_handle( - ui, - workspace_id, - workspace_column_id, - workspace_window_id, - display_frame, - ResizeHandleEdge::Right, - )); - overlay.add_overlay(&build_workspace_window_resize_handle( - ui, - workspace_id, - workspace_column_id, - workspace_window_id, - display_frame, - ResizeHandleEdge::Bottom, - )); -} - -fn build_workspace_window_resize_handle( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_column_id: WorkspaceColumnId, - workspace_window_id: WorkspaceWindowId, - display_frame: WindowFrame, - edge: ResizeHandleEdge, -) -> Widget { - let handle = GtkBox::new(Orientation::Vertical, 0); - handle.add_css_class("workspace-window-resize-handle"); - match edge { - ResizeHandleEdge::Right => { - handle.add_css_class("workspace-window-resize-handle-right"); - handle.set_halign(Align::End); - handle.set_valign(Align::Fill); - handle.set_vexpand(true); - handle.set_size_request(12, -1); - handle.set_tooltip_text(Some("Drag to resize this column")); - handle.set_cursor_from_name(Some("ew-resize")); - } - ResizeHandleEdge::Bottom => { - handle.add_css_class("workspace-window-resize-handle-bottom"); - handle.set_halign(Align::Fill); - handle.set_valign(Align::End); - handle.set_hexpand(true); - handle.set_size_request(-1, 12); - handle.set_tooltip_text(Some("Drag to resize this stacked window")); - handle.set_cursor_from_name(Some("ns-resize")); - } - } - - let start_size = Rc::new(Cell::new(match edge { - ResizeHandleEdge::Right => display_frame.width, - ResizeHandleEdge::Bottom => display_frame.height, - })); - let current_size = Rc::new(Cell::new(match edge { - ResizeHandleEdge::Right => display_frame.width, - ResizeHandleEdge::Bottom => display_frame.height, - })); - let drag_begin_ui = Rc::clone(ui); - let drag = gtk::GestureDrag::new(); - let start_size_for_begin = Rc::clone(&start_size); - let current_size_for_begin = Rc::clone(¤t_size); - let active_handle_for_begin = handle.clone(); - let active_handle_for_end = handle.clone(); - drag.connect_drag_begin(move |_, _, _| { - let start = drag_begin_ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .map(|workspace| match edge { - ResizeHandleEdge::Right => workspace - .columns - .get(&workspace_column_id) - .map(|column| column.width) - .unwrap_or(display_frame.width), - ResizeHandleEdge::Bottom => workspace - .windows - .get(&workspace_window_id) - .map(|window| window.height) - .unwrap_or(display_frame.height), - }) - .unwrap_or(match edge { - ResizeHandleEdge::Right => display_frame.width, - ResizeHandleEdge::Bottom => display_frame.height, - }); - start_size_for_begin.set(start); - current_size_for_begin.set(start); - active_handle_for_begin.add_css_class("workspace-window-resize-handle-active"); - }); - let current_size_for_update = Rc::clone(¤t_size); - let start_size_for_update = Rc::clone(&start_size); - let drag_update_ui = Rc::clone(ui); - drag.connect_drag_update(move |_, dx, dy| { - let next = - match edge { - ResizeHandleEdge::Right => (start_size_for_update.get() + dx.round() as i32) - .max(MIN_WORKSPACE_WINDOW_WIDTH), - ResizeHandleEdge::Bottom => (start_size_for_update.get() + dy.round() as i32) - .max(MIN_WORKSPACE_WINDOW_HEIGHT), - }; - current_size_for_update.set(next); - let preview = TopLevelResizePreview { - workspace_id, - target: match edge { - ResizeHandleEdge::Right => TopLevelResizePreviewTarget::ColumnWidth { - workspace_column_id, - width: next, - }, - ResizeHandleEdge::Bottom => TopLevelResizePreviewTarget::WindowHeight { - workspace_window_id, - height: next, - }, - }, - }; - drag_update_ui.set_top_level_resize_preview(Some(preview)); - let model = drag_update_ui.app_state.snapshot_model(); - drag_update_ui.apply_top_level_resize_preview(&model); - }); - let start_size_for_end = Rc::clone(&start_size); - let drag_end_ui = Rc::clone(ui); - drag.connect_drag_end(move |_, _, _| { - active_handle_for_end.remove_css_class("workspace-window-resize-handle-active"); - drag_end_ui.set_top_level_resize_preview(None); - if current_size.get() == start_size_for_end.get() { - let model = drag_end_ui.app_state.snapshot_model(); - drag_end_ui.sync_layout_state(&model); - return; - } - let command = match edge { - ResizeHandleEdge::Right => ControlCommand::SetWorkspaceColumnWidth { - workspace_id, - workspace_column_id, - width: current_size.get(), - }, - ResizeHandleEdge::Bottom => ControlCommand::SetWorkspaceWindowHeight { - workspace_id, - workspace_window_id, - height: current_size.get(), - }, - }; - drag_end_ui.dispatch(command); - }); - handle.add_controller(drag); - - handle.upcast() -} - -fn bind_split_ratio_updates( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_window_id: WorkspaceWindowId, - paned: &Paned, - axis: taskers_domain::SplitAxis, - path: Vec, -) { - let path = Arc::new(path); - let pending_ratio = Rc::new(Cell::new(None::)); - let pending_ratio_for_notify = Rc::clone(&pending_ratio); - paned.connect_position_notify(move |paned| { - let extent = paned_extent(paned, axis); - if extent <= 0 { - return; - } - let ratio = (((paned.position() as f64) / f64::from(extent)) * 1000.0) - .round() - .clamp(0.0, 1000.0) as u16; - pending_ratio_for_notify.set(Some(ratio)); - }); - let pending_ratio_for_accept = Rc::clone(&pending_ratio); - let accept_ui = Rc::clone(ui); - let path_for_accept = Arc::clone(&path); - paned.connect_accept_position(move |_| { - let Some(ratio) = pending_ratio_for_accept.take() else { - return false; - }; - accept_ui.dispatch(ControlCommand::SetWindowSplitRatio { - workspace_id, - workspace_window_id, - path: path_for_accept.as_ref().clone(), - ratio, - }); - false - }); - let pending_ratio_for_cancel = Rc::clone(&pending_ratio); - paned.connect_cancel_position(move |_| { - pending_ratio_for_cancel.set(None); - false - }); -} - -fn split_position_for_extent(extent: i32, ratio: u16) -> i32 { - ((extent * i32::from(ratio)) / 1000).max(1) -} - -fn split_layout_rects( - rect: WindowFrame, - axis: taskers_domain::SplitAxis, - ratio: u16, -) -> (WindowFrame, WindowFrame) { - match axis { - taskers_domain::SplitAxis::Horizontal => { - let first_width = split_position_for_extent(rect.width, ratio); - ( - WindowFrame { - width: first_width, - ..rect - }, - WindowFrame { - x: rect.x + first_width, - width: rect.width - first_width, - ..rect - }, - ) - } - taskers_domain::SplitAxis::Vertical => { - let first_height = split_position_for_extent(rect.height, ratio); - ( - WindowFrame { - height: first_height, - ..rect - }, - WindowFrame { - y: rect.y + first_height, - height: rect.height - first_height, - ..rect - }, - ) - } - } -} - -fn initialize_terminal_body( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - card: &PaneCardWidgets, -) -> Widget { - card.displayed_surface_id - .set(pane.active_surface().map(|surface| surface.id)); - - if let Some(widget) = ui.terminal_widget(workspace_id, pane) { - widget.set_focusable(true); - card.terminal_host.append(&widget); - return widget; - } - - if ui.backend_choice == BackendChoice::Ghostty { - let unavailable = GtkBox::new(Orientation::Vertical, 8); - unavailable.set_hexpand(true); - unavailable.set_vexpand(true); - unavailable.add_css_class("terminal-output"); - - let title = Label::new(Some("Ghostty surface unavailable for this pane.")); - title.set_wrap(true); - title.set_xalign(0.0); - unavailable.append(&title); - - let detail = Label::new(Some( - "Restart the app or switch back to the mock backend while the bridge is being debugged.", - )); - detail.add_css_class("pane-meta"); - detail.set_wrap(true); - detail.set_xalign(0.0); - unavailable.append(&detail); - - card.terminal_host.append(&unavailable); - return unavailable.upcast(); - } - - let root = GtkBox::new(Orientation::Vertical, 0); - root.set_hexpand(true); - root.set_vexpand(true); - - let scroller = ScrolledWindow::new(); - scroller.set_vexpand(true); - scroller.set_hexpand(true); - scroller.set_policy(PolicyType::Automatic, PolicyType::Automatic); - - let terminal_output = TextView::new(); - terminal_output.add_css_class("terminal-output"); - terminal_output.set_editable(false); - terminal_output.set_cursor_visible(false); - terminal_output.set_monospace(true); - terminal_output.set_wrap_mode(WrapMode::WordChar); - terminal_output.set_vexpand(true); - let buffer = terminal_output.buffer(); - let initial_output = ui - .app_state - .runtime() - .snapshot( - pane.active_surface() - .map(|surface| surface.id) - .unwrap_or_else(SurfaceId::new), - ) - .map(|snapshot| snapshot.output) - .unwrap_or_default(); - buffer.set_text(&initial_output); - scroller.set_child(Some(&terminal_output)); - if let Some(surface_id) = pane.active_surface().map(|surface| surface.id) { - bind_output_updates(ui, surface_id, terminal_output); - } - root.append(&scroller); - - let entry = Entry::new(); - entry.add_css_class("terminal-entry"); - entry.set_placeholder_text(Some("Type shell input and press Enter")); - let input_ui = Rc::clone(ui); - let surface_id = pane - .active_surface() - .map(|surface| surface.id) - .unwrap_or_else(SurfaceId::new); - entry.connect_activate(move |entry| { - let text = entry.text(); - if text.is_empty() { - return; - } - - input_ui.send_input(surface_id, format!("{text}\n")); - entry.set_text(""); - }); - root.append(&entry); - - card.terminal_host.append(&root); - entry.upcast() -} - -fn sync_terminal_body( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - card: &PaneCardWidgets, -) { - if !terminal_body_needs_refresh( - card.displayed_surface_id.get(), - pane.active_surface().map(|surface| surface.id), - card.terminal_host.first_child().is_some(), - ) { - return; - } - - clear_box(&card.terminal_host); - let _ = initialize_terminal_body(ui, workspace_id, pane, card); -} - -fn terminal_body_needs_refresh( - displayed_surface_id: Option, - next_surface_id: Option, - has_child: bool, -) -> bool { - !has_child || displayed_surface_id != next_surface_id -} - -fn create_workspace_window_from_pane( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane_id: taskers_domain::PaneId, - direction: Direction, -) { - let should_focus_pane = ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .is_some_and(|workspace| { - workspace.active_pane != pane_id - || workspace - .window_for_pane(pane_id) - .is_some_and(|window_id| window_id != workspace.active_window) - }); - - if should_focus_pane { - ui.dispatch(ControlCommand::FocusPane { - workspace_id, - pane_id, - }); - } - - ui.dispatch(ControlCommand::CreateWorkspaceWindow { - workspace_id, - direction, - }); -} - -fn create_workspace_window_from_window( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_window_id: WorkspaceWindowId, - direction: Direction, -) { - let should_focus_window = ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .is_some_and(|workspace| workspace.active_window != workspace_window_id); - - if should_focus_window { - ui.dispatch(ControlCommand::FocusWorkspaceWindow { - workspace_id, - workspace_window_id, - }); - } - - ui.dispatch(ControlCommand::CreateWorkspaceWindow { - workspace_id, - direction, - }); -} - -fn resize_workspace_window_from_window( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_window_id: WorkspaceWindowId, - direction: Direction, -) { - let should_focus_window = ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .is_some_and(|workspace| workspace.active_window != workspace_window_id); - - if should_focus_window { - ui.dispatch(ControlCommand::FocusWorkspaceWindow { - workspace_id, - workspace_window_id, - }); - } - - ui.dispatch(ControlCommand::ResizeActiveWindow { - workspace_id, - direction, - amount: KEYBOARD_RESIZE_STEP, - }); -} - -fn show_new_window_popover( - parent: &Button, - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane_id: Option, - workspace_window_id: Option, -) { - let popover = gtk::Popover::new(); - popover.set_parent(parent); - - let content = GtkBox::new(Orientation::Vertical, 2); - content.set_margin_start(4); - content.set_margin_end(4); - content.set_margin_top(4); - content.set_margin_bottom(4); - - append_new_window_direction_button( - &content, - &popover, - ui, - workspace_id, - pane_id, - workspace_window_id, - Direction::Left, - ); - append_new_window_direction_button( - &content, - &popover, - ui, - workspace_id, - pane_id, - workspace_window_id, - Direction::Right, - ); - append_new_window_direction_button( - &content, - &popover, - ui, - workspace_id, - pane_id, - workspace_window_id, - Direction::Up, - ); - append_new_window_direction_button( - &content, - &popover, - ui, - workspace_id, - pane_id, - workspace_window_id, - Direction::Down, - ); - - popover.set_child(Some(&content)); - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - popover.popup(); -} - -fn append_new_window_direction_button( - content: &GtkBox, - popover: >k::Popover, - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane_id: Option, - workspace_window_id: Option, - direction: Direction, -) { - let button = Button::with_label(new_window_direction_label(direction)); - button.add_css_class("flat"); - button.add_css_class("context-item"); - - let shortcut = ui.shortcut_label(new_window_direction_action(direction)); - if shortcut == "Unbound" { - button.set_tooltip_text(Some(new_window_direction_tooltip(direction))); - } else { - button.set_tooltip_text(Some(&format!( - "{} ({shortcut})", - new_window_direction_tooltip(direction) - ))); - } - - let local_ui = Rc::clone(ui); - let local_popover = popover.clone(); - button.connect_clicked(move |_| { - local_popover.popdown(); - if let Some(pane_id) = pane_id { - create_workspace_window_from_pane(&local_ui, workspace_id, pane_id, direction); - } else if let Some(workspace_window_id) = workspace_window_id { - create_workspace_window_from_window( - &local_ui, - workspace_id, - workspace_window_id, - direction, - ); - } else { - local_ui.dispatch(ControlCommand::CreateWorkspaceWindow { - workspace_id, - direction, - }); - } - }); - content.append(&button); -} - -fn new_window_direction_label(direction: Direction) -> &'static str { - match direction { - Direction::Left => "\u{2190} Left", - Direction::Right => "\u{2192} Right", - Direction::Up => "\u{2191} Above", - Direction::Down => "\u{2193} Below", - } -} - -fn new_window_direction_tooltip(direction: Direction) -> &'static str { - match direction { - Direction::Left => "Create a new window to the left", - Direction::Right => "Create a new window to the right", - Direction::Up => "Create a new window above", - Direction::Down => "Create a new window below", - } -} - -fn new_window_direction_action(direction: Direction) -> ShortcutAction { - match direction { - Direction::Left => ShortcutAction::NewWindowLeft, - Direction::Right => ShortcutAction::NewWindowRight, - Direction::Up => ShortcutAction::NewWindowUp, - Direction::Down => ShortcutAction::NewWindowDown, - } -} - -fn show_resize_window_popover( - parent: &Button, - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - workspace_window_id: WorkspaceWindowId, -) { - let popover = gtk::Popover::new(); - popover.set_parent(parent); - - let content = GtkBox::new(Orientation::Vertical, 2); - content.set_margin_start(4); - content.set_margin_end(4); - content.set_margin_top(4); - content.set_margin_bottom(4); - - for direction in [ - Direction::Left, - Direction::Right, - Direction::Up, - Direction::Down, - ] { - let button = Button::with_label(resize_window_direction_label(direction)); - button.add_css_class("flat"); - button.add_css_class("context-item"); - let shortcut = ui.shortcut_label(resize_window_direction_action(direction)); - if shortcut == "Unbound" { - button.set_tooltip_text(Some(resize_window_direction_tooltip(direction))); - } else { - button.set_tooltip_text(Some(&format!( - "{} ({shortcut})", - resize_window_direction_tooltip(direction) - ))); - } - - let local_ui = Rc::clone(ui); - let local_popover = popover.clone(); - button.connect_clicked(move |_| { - local_popover.popdown(); - resize_workspace_window_from_window( - &local_ui, - workspace_id, - workspace_window_id, - direction, - ); - }); - content.append(&button); - } - - popover.set_child(Some(&content)); - let pop_cleanup = popover.clone(); - popover.connect_closed(move |_| { - pop_cleanup.unparent(); - }); - popover.popup(); -} - -fn resize_window_direction_label(direction: Direction) -> &'static str { - match direction { - Direction::Left => "Narrower", - Direction::Right => "Wider", - Direction::Up => "Shorter", - Direction::Down => "Taller", - } -} - -fn resize_window_direction_tooltip(direction: Direction) -> &'static str { - match direction { - Direction::Left => "Reduce the active window width", - Direction::Right => "Increase the active window width", - Direction::Up => "Reduce the active window height", - Direction::Down => "Increase the active window height", - } -} - -fn resize_window_direction_action(direction: Direction) -> ShortcutAction { - match direction { - Direction::Left => ShortcutAction::ResizeWindowLeft, - Direction::Right => ShortcutAction::ResizeWindowRight, - Direction::Up => ShortcutAction::ResizeWindowUp, - Direction::Down => ShortcutAction::ResizeWindowDown, - } -} - -fn sync_surface_tabs( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - card: &PaneCardWidgets, -) { - let pane_is_active = ui - .app_state - .snapshot_model() - .workspaces - .get(&workspace_id) - .is_some_and(|workspace| workspace.active_pane == pane.id); - let desired_surface_ids = pane.surface_ids().collect::>(); - let model_order = pane.surface_ids().collect::>(); - let animations_enabled = ui.settings.borrow().animations_enabled; - let mut tabs = card.surface_tabs.tabs.borrow_mut(); - for surface in pane.surfaces.values() { - let tab = tabs.entry(surface.id).or_insert_with(|| { - build_surface_tab(ui, &card.surface_tabs, workspace_id, pane.id, surface.id) - }); - if tab.root.parent().is_none() { - card.surface_tabs.root.put(&tab.root, 0.0, 0.0); - } - configure_surface_tab(tab, surface, pane.active_surface); - } - - let stale_surface_ids = tabs - .keys() - .copied() - .filter(|surface_id| !desired_surface_ids.contains(surface_id)) - .collect::>(); - let mut removed_tabs = Vec::new(); - for surface_id in stale_surface_ids { - if let Some(tab) = tabs.remove(&surface_id) { - removed_tabs.push((surface_id, tab)); - } - } - drop(tabs); - - { - let mut state = card.surface_tabs.state.borrow_mut(); - state.model_order = model_order.clone(); - if state - .drag - .as_ref() - .is_some_and(|drag| !desired_surface_ids.contains(&drag.surface_id)) - { - state.drag = None; - } - } - - for (surface_id, tab) in removed_tabs { - remove_surface_tab(&card.surface_tabs, surface_id, tab, animations_enabled); - } - - let display_order = { - let state = card.surface_tabs.state.borrow(); - state - .drag - .as_ref() - .map(|drag| { - let mut order = drag - .preview_order - .iter() - .copied() - .filter(|surface_id| desired_surface_ids.contains(surface_id)) - .collect::>(); - for surface_id in &model_order { - if !order.contains(surface_id) { - order.push(*surface_id); - } - } - order - }) - .unwrap_or_else(|| model_order.clone()) - }; - - let layout = compute_surface_tab_strip_layout(&card.surface_tabs, &display_order); - set_surface_tab_layout(&card.surface_tabs, layout, animations_enabled); - apply_surface_tab_widgets(&card.surface_tabs); - - if !pane_is_active { - clear_box(&card.header_tabs); - card.header_tabs.set_visible(false); - card.header_tabs.set_hexpand(false); - card.title.set_visible(true); - card.surface_tabs.root.set_visible(false); - return; - } - - // Decide between inline tabs (in pane-header) vs. standalone strip vs. hidden (single surface) - let surface_count = pane.surfaces.len(); - match surface_tab_presentation(surface_count, card.header.allocated_width()) { - SurfaceTabPresentation::AddOnly => { - sync_single_surface_add_button(ui, workspace_id, pane, card); - card.header_tabs.set_hexpand(false); - card.header_tabs.set_visible(true); - card.title.set_visible(true); - card.surface_tabs.root.set_visible(false); - } - SurfaceTabPresentation::Inline => { - sync_inline_header_tabs(ui, workspace_id, pane, card); - card.header_tabs.set_hexpand(true); - card.header_tabs.set_visible(true); - card.title.set_visible(false); - card.surface_tabs.root.set_visible(false); - } - SurfaceTabPresentation::Strip => { - clear_box(&card.header_tabs); - card.header_tabs.set_visible(false); - card.header_tabs.set_hexpand(false); - card.title.set_visible(true); - card.surface_tabs.root.set_visible(true); - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SurfaceTabPresentation { - AddOnly, - Inline, - Strip, -} - -fn surface_tab_presentation(surface_count: usize, header_width: i32) -> SurfaceTabPresentation { - if surface_count <= 1 { - return SurfaceTabPresentation::AddOnly; - } - - let effective_header_width = if header_width > 0 { - header_width - } else { - DEFAULT_WORKSPACE_WINDOW_WIDTH - }; - - // Reserve space for status + pane/window action buttons on the right side - // of the header so inline tabs only appear when they comfortably fit. - let action_buttons_width = 200; - let available_for_tabs = (effective_header_width - action_buttons_width).max(0); - let tab_width_estimate = SURFACE_TAB_MIN_WIDTH + SURFACE_TAB_GAP; - let max_inline_tabs = available_for_tabs / tab_width_estimate; - - if surface_count <= 6 && surface_count as i32 <= max_inline_tabs { - SurfaceTabPresentation::Inline - } else { - SurfaceTabPresentation::Strip - } -} - -fn sync_single_surface_add_button( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - card: &PaneCardWidgets, -) { - clear_box(&card.header_tabs); - - let add_btn = Button::with_label("+"); - add_btn.add_css_class("flat"); - add_btn.add_css_class("inline-tab-add"); - add_btn.set_tooltip_text(Some("Open another surface")); - let add_ui = Rc::clone(ui); - let add_pane_id = pane.id; - add_btn.connect_clicked(move |_| { - add_ui.dispatch(ControlCommand::CreateSurface { - workspace_id, - pane_id: add_pane_id, - kind: PaneKind::Terminal, - }); - }); - card.header_tabs.append(&add_btn); -} - -fn sync_inline_header_tabs( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane: &PaneRecord, - card: &PaneCardWidgets, -) { - clear_box(&card.header_tabs); - let active_surface_id = pane.active_surface; - for surface in pane.surfaces.values() { - let tab_title = display_surface_title(surface); - let tab_root = GtkBox::new(Orientation::Horizontal, 4); - tab_root.add_css_class("inline-tab"); - if surface.id == active_surface_id { - tab_root.add_css_class("inline-tab-active"); - } - if surface.attention != AttentionState::Normal { - tab_root.add_css_class(&format!( - "inline-tab-state-{}", - attention_state_slug(surface.attention) - )); - } - - let focus_button = Button::new(); - focus_button.add_css_class("flat"); - focus_button.add_css_class("inline-tab-button"); - let label = Label::new(Some(&tab_title)); - label.add_css_class("inline-tab-label"); - label.set_ellipsize(gtk::pango::EllipsizeMode::End); - label.set_max_width_chars(16); - focus_button.set_child(Some(&label)); - let focus_ui = Rc::clone(ui); - let focus_surface_id = surface.id; - let focus_pane_id = pane.id; - focus_button.connect_clicked(move |_| { - focus_ui.dispatch(ControlCommand::FocusSurface { - workspace_id, - pane_id: focus_pane_id, - surface_id: focus_surface_id, - }); - }); - tab_root.append(&focus_button); - - let close = Button::with_label("\u{00d7}"); - close.add_css_class("flat"); - close.add_css_class("inline-tab-close"); - let close_ui = Rc::clone(ui); - let close_surface_id = surface.id; - let close_pane_id = pane.id; - close.connect_clicked(move |_| { - close_ui.dispatch(ControlCommand::CloseSurface { - workspace_id, - pane_id: close_pane_id, - surface_id: close_surface_id, - }); - }); - tab_root.append(&close); - card.header_tabs.append(&tab_root); - } - // Add "+" button for creating new surface - let add_btn = Button::with_label("+"); - add_btn.add_css_class("flat"); - add_btn.add_css_class("inline-tab-add"); - let add_ui = Rc::clone(ui); - let add_pane_id = pane.id; - add_btn.connect_clicked(move |_| { - add_ui.dispatch(ControlCommand::CreateSurface { - workspace_id, - pane_id: add_pane_id, - kind: PaneKind::Terminal, - }); - }); - card.header_tabs.append(&add_btn); -} - -fn build_surface_tab_strip( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane_id: taskers_domain::PaneId, -) -> SurfaceTabStripWidgets { - let root = Fixed::new(); - root.add_css_class("surface-tabs"); - root.set_hexpand(true); - - let add_button = Button::with_label("+"); - add_button.add_css_class("flat"); - add_button.add_css_class("surface-tab-add"); - let add_ui = Rc::clone(ui); - add_button.connect_clicked(move |_| { - add_ui.dispatch(ControlCommand::CreateSurface { - workspace_id, - pane_id, - kind: PaneKind::Terminal, - }); - }); - root.put(&add_button, 0.0, 0.0); - - SurfaceTabStripWidgets { - root, - add_button, - tabs: Rc::new(RefCell::new(HashMap::new())), - state: Rc::new(RefCell::new(SurfaceTabStripState::default())), - } -} - -fn build_surface_tab( - ui: &Rc, - strip: &SurfaceTabStripWidgets, - workspace_id: taskers_domain::WorkspaceId, - pane_id: taskers_domain::PaneId, - surface_id: SurfaceId, -) -> SurfaceTabWidgets { - let root = GtkBox::new(Orientation::Horizontal, 4); - root.add_css_class("surface-tab"); - - let dot = Label::new(Some("\u{25cf}")); - dot.add_css_class("status-dot"); - root.append(&dot); - - let label = Button::new(); - label.add_css_class("flat"); - label.add_css_class("surface-tab-label"); - label.set_hexpand(true); - let label_content = GtkBox::new(Orientation::Horizontal, 4); - label_content.set_hexpand(true); - let agent_icon = build_agent_icon(None, 12); - agent_icon.add_css_class("surface-tab-agent-icon"); - label_content.append(agent_icon.widget()); - let title = Label::new(None); - title.add_css_class("surface-tab-title"); - title.set_xalign(0.0); - title.set_hexpand(true); - title.set_ellipsize(gtk::pango::EllipsizeMode::End); - label_content.append(&title); - label.set_child(Some(&label_content)); - let focus_ui = Rc::clone(ui); - let focus_state = Rc::clone(&strip.state); - label.connect_clicked(move |_| { - let mut strip_state = focus_state.borrow_mut(); - if strip_state.suppress_click_surface == Some(surface_id) { - strip_state.suppress_click_surface = None; - return; - } - drop(strip_state); - focus_ui.dispatch(ControlCommand::FocusSurface { - workspace_id, - pane_id, - surface_id, - }); - }); - let drag_ui = Rc::clone(ui); - let drag_strip = strip.clone(); - let drag = gtk::GestureDrag::new(); - drag.connect_drag_begin(move |_, _, _| { - begin_surface_tab_drag(&drag_strip, surface_id); - }); - let drag_strip = strip.clone(); - let drag_ui_for_update = Rc::clone(&drag_ui); - drag.connect_drag_update(move |_, dx, _| { - update_surface_tab_drag(&drag_ui_for_update, &drag_strip, surface_id, dx); - }); - let drag_strip = strip.clone(); - drag.connect_drag_end(move |_, _, _| { - end_surface_tab_drag(&drag_ui, &drag_strip, workspace_id, pane_id, surface_id); - }); - label.add_controller(drag); - root.append(&label); - - let close = Button::with_label("\u{00d7}"); - close.add_css_class("flat"); - close.add_css_class("surface-tab-close"); - let close_ui = Rc::clone(ui); - close.connect_clicked(move |_| { - close_ui.dispatch(ControlCommand::CloseSurface { - workspace_id, - pane_id, - surface_id, - }); - }); - root.append(&close); - - SurfaceTabWidgets { - root, - dot, - agent_icon, - title, - } -} - -fn configure_surface_tab( - tab: &SurfaceTabWidgets, - surface: &SurfaceRecord, - active_surface_id: SurfaceId, -) { - for cls in &[ - "surface-tab-has-attention", - "surface-tab-active", - "surface-tab-state-busy", - "surface-tab-state-completed", - "surface-tab-state-waiting", - "surface-tab-state-error", - ] { - tab.root.remove_css_class(cls); - } - for cls in &[ - "status-dot-normal", - "status-dot-busy", - "status-dot-completed", - "status-dot-waiting", - "status-dot-error", - ] { - tab.dot.remove_css_class(cls); - } - - if surface.attention != AttentionState::Normal { - tab.root.add_css_class("surface-tab-has-attention"); - tab.root.add_css_class(&format!( - "surface-tab-state-{}", - attention_state_slug(surface.attention) - )); - } - if surface.id == active_surface_id { - tab.root.add_css_class("surface-tab-active"); - } - - tab.dot - .add_css_class(&attention_dot_class(surface.attention)); - tab.dot.set_tooltip_text(Some(surface.attention.label())); - configure_agent_icon(&tab.agent_icon, surface_agent_kind(surface), 12); - tab.title.set_text(&display_surface_title(surface)); -} - -fn remove_surface_tab( - strip: &SurfaceTabStripWidgets, - surface_id: SurfaceId, - tab: SurfaceTabWidgets, - animations_enabled: bool, -) { - let key = SurfaceTabItemKey::Surface(surface_id); - let mut state = strip.state.borrow_mut(); - if !animations_enabled { - state.presented.remove(&key); - if tab.root.parent().is_some() { - strip.root.remove(&tab.root); - } - return; - } - - if let Some(start_item) = state.presented.remove(&key) { - tab.root.add_css_class("surface-tab-exiting"); - state.exiting.push(SurfaceTabExitAnimation { - root: tab.root, - start_time: glib::monotonic_time(), - duration_us: TERMINAL_MOTION_SPEC.tab.structural.duration_us, - start_item, - end_item: PresentedSurfaceTabItem { - x: start_item.x - TERMINAL_MOTION_SPEC.tab.exit_offset_px, - opacity: 0.0, - width: (start_item.width - TERMINAL_MOTION_SPEC.tab.size_delta_px) - .max(SURFACE_TAB_MIN_WIDTH), - height: start_item.height, - }, - }); - } else if tab.root.parent().is_some() { - strip.root.remove(&tab.root); - } -} - -fn compute_surface_tab_strip_layout( - strip: &SurfaceTabStripWidgets, - order: &[SurfaceId], -) -> SurfaceTabStripLayout { - let tabs = strip.tabs.borrow(); - let mut widths = Vec::new(); - let mut heights = Vec::new(); - for surface_id in order { - let Some(tab) = tabs.get(surface_id) else { - continue; - }; - let (_, natural_width, _, _) = tab.root.measure(Orientation::Horizontal, -1); - let (_, natural_height, _, _) = tab.root.measure(Orientation::Vertical, -1); - widths.push(natural_width.clamp(SURFACE_TAB_MIN_WIDTH, SURFACE_TAB_MAX_WIDTH)); - heights.push(natural_height); - } - drop(tabs); - - let (_, add_width, _, _) = strip.add_button.measure(Orientation::Horizontal, -1); - let (_, add_height, _, _) = strip.add_button.measure(Orientation::Vertical, -1); - let gap_count = if widths.is_empty() { - 0 - } else { - widths.len() as i32 - }; - shrink_surface_tab_widths( - &mut widths, - add_width, - gap_count, - strip.root.allocated_width(), - ); - - let mut x = 0.0; - let mut items = Vec::new(); - let mut max_height = add_height; - for (index, surface_id) in order.iter().copied().enumerate() { - let Some(width) = widths.get(index).copied() else { - continue; - }; - let height = heights.get(index).copied().unwrap_or(add_height); - max_height = max_height.max(height); - items.push(SurfaceTabLayoutItem { - surface_id, - x, - width, - height, - }); - x += f64::from(width + SURFACE_TAB_GAP); - } - - SurfaceTabStripLayout { - items, - add_button: SurfaceTabAuxLayoutItem { - x, - width: add_width, - height: add_height, - }, - height: max_height, - } -} - -fn shrink_surface_tab_widths( - widths: &mut [i32], - add_width: i32, - gap_count: i32, - available_width: i32, -) { - if widths.is_empty() || available_width <= 0 { - return; - } - - let gap_total = gap_count * SURFACE_TAB_GAP; - let mut total_width = widths.iter().sum::() + add_width + gap_total; - if total_width <= available_width { - return; - } - - let min_total = widths.len() as i32 * SURFACE_TAB_MIN_WIDTH + add_width + gap_total; - if available_width <= min_total { - widths.fill(SURFACE_TAB_MIN_WIDTH); - return; - } - - while total_width > available_width { - let mut reduced_any = false; - for width in widths.iter_mut() { - if total_width <= available_width { - break; - } - if *width > SURFACE_TAB_MIN_WIDTH { - *width -= 1; - total_width -= 1; - reduced_any = true; - } - } - if !reduced_any { - break; - } - } -} - -fn set_surface_tab_layout( - strip: &SurfaceTabStripWidgets, - layout: SurfaceTabStripLayout, - animations_enabled: bool, -) { - let mut state = strip.state.borrow_mut(); - let target_items = surface_tab_target_items(&layout, state.drag.as_ref()); - let duration_us = state - .next_animation_duration_us - .take() - .unwrap_or(TERMINAL_MOTION_SPEC.tab.structural.duration_us); - state.layout = Some(layout); - - if !animations_enabled { - for exit in state.exiting.drain(..) { - if exit.root.parent().is_some() { - strip.root.remove(&exit.root); - } - } - state.motion = None; - state.presented = target_items; - return; - } - - if state.presented.is_empty() { - state.motion = None; - state.presented = target_items; - return; - } - - let start_items = target_items - .iter() - .map(|(key, target)| { - let start = state - .presented - .get(key) - .copied() - .unwrap_or_else(|| surface_tab_enter_item(*key, *target)); - (*key, start) - }) - .collect::>(); - - if start_items == target_items { - state.motion = None; - state.presented = target_items; - return; - } - - state.presented = start_items.clone(); - state.motion = Some(SurfaceTabMotionState { - start_time: glib::monotonic_time(), - duration_us, - start_items, - target_items, - }); - drop(state); - start_surface_tab_tick(strip); -} - -fn surface_tab_target_items( - layout: &SurfaceTabStripLayout, - drag: Option<&SurfaceTabDragState>, -) -> HashMap { - let mut items = layout - .items - .iter() - .map(|item| { - ( - SurfaceTabItemKey::Surface(item.surface_id), - PresentedSurfaceTabItem { - x: item.x, - opacity: 1.0, - width: item.width, - height: item.height, - }, - ) - }) - .collect::>(); - items.insert( - SurfaceTabItemKey::AddButton, - PresentedSurfaceTabItem { - x: layout.add_button.x, - opacity: 1.0, - width: layout.add_button.width, - height: layout.add_button.height, - }, - ); - - if let Some(drag) = drag.filter(|drag| drag.threshold_crossed) { - if let Some(item) = items.get_mut(&SurfaceTabItemKey::Surface(drag.surface_id)) { - item.x = drag.start_x + drag.current_dx; - } - } - - items -} - -fn surface_tab_enter_item( - key: SurfaceTabItemKey, - target: PresentedSurfaceTabItem, -) -> PresentedSurfaceTabItem { - match key { - SurfaceTabItemKey::Surface(_) => PresentedSurfaceTabItem { - x: target.x + TERMINAL_MOTION_SPEC.tab.enter_offset_px, - opacity: 0.0, - width: (target.width - TERMINAL_MOTION_SPEC.tab.size_delta_px) - .max(SURFACE_TAB_MIN_WIDTH), - height: target.height, - }, - SurfaceTabItemKey::AddButton => target, - } -} - -fn start_surface_tab_tick(strip: &SurfaceTabStripWidgets) { - let mut state = strip.state.borrow_mut(); - if state.tick_running { - return; - } - state.tick_running = true; - drop(state); - - let strip = strip.clone(); - let root = strip.root.clone(); - root.add_tick_callback(move |_, clock| { - if advance_surface_tab_tick(&strip, clock.frame_time()) { - glib::ControlFlow::Continue - } else { - strip.state.borrow_mut().tick_running = false; - glib::ControlFlow::Break - } - }); -} - -fn advance_surface_tab_tick(strip: &SurfaceTabStripWidgets, now: i64) -> bool { - let (presented, exiting, height, completed_exits, keep_running) = { - let mut state = strip.state.borrow_mut(); - - if let Some(motion) = state.motion.clone() { - let progress = - ((now - motion.start_time) as f64 / motion.duration_us as f64).clamp(0.0, 1.0); - let eased = TERMINAL_MOTION_SPEC.tab.structural.curve.sample(progress); - state.presented = motion - .target_items - .iter() - .map(|(key, target)| { - let start = motion.start_items.get(key).copied().unwrap_or(*target); - (*key, lerp_surface_tab_item(start, *target, eased)) - }) - .collect(); - if progress >= 1.0 { - state.presented = motion.target_items; - state.motion = None; - } - } - - if let Some(drag_surface_id) = state - .drag - .as_ref() - .filter(|drag| drag.threshold_crossed) - .map(|drag| (drag.surface_id, drag.start_x + drag.current_dx)) - { - if let Some(item) = state - .presented - .get_mut(&SurfaceTabItemKey::Surface(drag_surface_id.0)) - { - item.x = drag_surface_id.1; - item.opacity = 1.0; - } - } - - let mut exiting = Vec::new(); - let mut completed_exits = Vec::new(); - state.exiting.retain(|exit| { - let progress = - ((now - exit.start_time) as f64 / exit.duration_us as f64).clamp(0.0, 1.0); - let eased = TERMINAL_MOTION_SPEC.tab.structural.curve.sample(progress); - let item = lerp_surface_tab_item(exit.start_item, exit.end_item, eased); - if progress >= 1.0 { - completed_exits.push(exit.root.clone()); - false - } else { - exiting.push((exit.root.clone(), item)); - true - } - }); - - let height = state - .layout - .as_ref() - .map(|layout| layout.height) - .unwrap_or(0); - let keep_running = state.motion.is_some() || !state.exiting.is_empty(); - ( - state.presented.clone(), - exiting, - height, - completed_exits, - keep_running, - ) - }; - - apply_surface_tab_widget_frames(strip, &presented, &exiting, height); - for exit_root in completed_exits { - if exit_root.parent().is_some() { - strip.root.remove(&exit_root); - } - } - - keep_running -} - -fn apply_surface_tab_widgets(strip: &SurfaceTabStripWidgets) { - let (presented, exiting, height) = { - let state = strip.state.borrow(); - let exiting = state - .exiting - .iter() - .map(|exit| (exit.root.clone(), exit.start_item)) - .collect::>(); - let mut presented = state.presented.clone(); - if let Some(drag) = state.drag.as_ref().filter(|drag| drag.threshold_crossed) { - if let Some(item) = presented.get_mut(&SurfaceTabItemKey::Surface(drag.surface_id)) { - item.x = drag.start_x + drag.current_dx; - item.opacity = 1.0; - } - } - ( - presented, - exiting, - state - .layout - .as_ref() - .map(|layout| layout.height) - .unwrap_or(0), - ) - }; - apply_surface_tab_widget_frames(strip, &presented, &exiting, height); -} - -fn apply_surface_tab_widget_frames( - strip: &SurfaceTabStripWidgets, - presented: &HashMap, - exiting: &[(GtkBox, PresentedSurfaceTabItem)], - height: i32, -) { - let tabs = strip.tabs.borrow(); - for (surface_id, tab) in tabs.iter() { - let Some(item) = presented.get(&SurfaceTabItemKey::Surface(*surface_id)) else { - continue; - }; - if tab.root.parent().is_none() { - strip.root.put(&tab.root, item.x, 0.0); - } - tab.root - .set_size_request(item.width, item.height.max(height)); - strip.root.move_(&tab.root, item.x, 0.0); - tab.root.set_opacity(item.opacity); - } - - if let Some(item) = presented.get(&SurfaceTabItemKey::AddButton) { - if strip.add_button.parent().is_none() { - strip.root.put(&strip.add_button, item.x, 0.0); - } - strip - .add_button - .set_size_request(item.width, item.height.max(height)); - strip.root.move_(&strip.add_button, item.x, 0.0); - strip.add_button.set_opacity(item.opacity); - } - - for (exit_root, item) in exiting { - if exit_root.parent().is_none() { - strip.root.put(exit_root, item.x, 0.0); - } - exit_root.set_size_request(item.width, item.height.max(height)); - strip.root.move_(exit_root, item.x, 0.0); - exit_root.set_opacity(item.opacity); - } - - strip.root.set_size_request(-1, height.max(1)); -} - -fn begin_surface_tab_drag(strip: &SurfaceTabStripWidgets, surface_id: SurfaceId) { - let mut state = strip.state.borrow_mut(); - let Some(presented) = state - .presented - .get(&SurfaceTabItemKey::Surface(surface_id)) - .copied() - else { - return; - }; - let preview_order = state - .layout - .as_ref() - .map(|layout| layout.items.iter().map(|item| item.surface_id).collect()) - .unwrap_or_else(|| state.model_order.clone()); - state.drag = Some(SurfaceTabDragState { - surface_id, - start_x: presented.x, - current_dx: 0.0, - preview_order, - threshold_crossed: false, - }); - drop(state); - - if let Some(tab) = strip.tabs.borrow().get(&surface_id).cloned() { - tab.root.add_css_class("surface-tab-dragging"); - raise_surface_tab_widget(strip, &tab.root, presented); - } -} - -fn update_surface_tab_drag( - ui: &Rc, - strip: &SurfaceTabStripWidgets, - surface_id: SurfaceId, - dx: f64, -) { - let mut next_order = None; - let (should_suppress_click, threshold_crossed, layout, current_center, current_preview) = { - let mut state = strip.state.borrow_mut(); - let current_width = surface_tab_item_width(&state, surface_id); - let state_layout = state.layout.clone(); - let Some(drag) = state.drag.as_mut() else { - return; - }; - if drag.surface_id != surface_id { - return; - } - drag.current_dx = dx; - let mut should_suppress_click = false; - if !drag.threshold_crossed && dx.abs() >= TERMINAL_MOTION_SPEC.tab.drag_threshold_px { - drag.threshold_crossed = true; - should_suppress_click = true; - } - ( - should_suppress_click, - drag.threshold_crossed, - state_layout, - drag.start_x + drag.current_dx + current_width / 2.0, - drag.preview_order.clone(), - ) - }; - - if should_suppress_click { - strip.state.borrow_mut().suppress_click_surface = Some(surface_id); - } - if !threshold_crossed { - apply_surface_tab_widgets(strip); - return; - } - - if let Some(layout) = layout { - let preview = - surface_tab_preview_order(&layout, ¤t_preview, surface_id, current_center); - if preview != current_preview { - if let Some(drag) = strip.state.borrow_mut().drag.as_mut() { - drag.preview_order = preview.clone(); - } - next_order = Some(preview); - } - } - - if let Some(order) = next_order { - let layout = compute_surface_tab_strip_layout(strip, &order); - set_surface_tab_layout(strip, layout, ui.settings.borrow().animations_enabled); - } - apply_surface_tab_widgets(strip); -} - -fn end_surface_tab_drag( - ui: &Rc, - strip: &SurfaceTabStripWidgets, - workspace_id: taskers_domain::WorkspaceId, - pane_id: taskers_domain::PaneId, - surface_id: SurfaceId, -) { - let (threshold_crossed, preview_index, current_index) = { - let mut state = strip.state.borrow_mut(); - let Some(drag) = state.drag.take() else { - return; - }; - state.next_animation_duration_us = drag - .threshold_crossed - .then_some(TERMINAL_MOTION_SPEC.tab.drag_snap.duration_us); - ( - drag.threshold_crossed, - drag.preview_order.iter().position(|id| *id == surface_id), - state.model_order.iter().position(|id| *id == surface_id), - ) - }; - - if let Some(tab) = strip.tabs.borrow().get(&surface_id).cloned() { - tab.root.remove_css_class("surface-tab-dragging"); - } - - if !threshold_crossed { - apply_surface_tab_widgets(strip); - return; - } - - if preview_index != current_index { - ui.dispatch(ControlCommand::MoveSurface { - workspace_id, - pane_id, - surface_id, - to_index: preview_index.unwrap_or_default(), - }); - return; - } - - ui.refresh(true); -} - -fn surface_tab_item_width(state: &SurfaceTabStripState, surface_id: SurfaceId) -> f64 { - state - .presented - .get(&SurfaceTabItemKey::Surface(surface_id)) - .map(|item| f64::from(item.width)) - .or_else(|| { - state.layout.as_ref().and_then(|layout| { - layout - .items - .iter() - .find(|item| item.surface_id == surface_id) - .map(|item| f64::from(item.width)) - }) - }) - .unwrap_or(f64::from(SURFACE_TAB_MIN_WIDTH)) -} - -fn surface_tab_preview_order( - layout: &SurfaceTabStripLayout, - current_order: &[SurfaceId], - surface_id: SurfaceId, - current_center: f64, -) -> Vec { - let mut order = current_order - .iter() - .copied() - .filter(|candidate| *candidate != surface_id) - .collect::>(); - let insert_index = order - .iter() - .position(|candidate| { - layout - .items - .iter() - .find(|item| item.surface_id == *candidate) - .map(|item| current_center < item.x + (f64::from(item.width) / 2.0)) - .unwrap_or(false) - }) - .unwrap_or(order.len()); - order.insert(insert_index, surface_id); - order -} - -fn raise_surface_tab_widget( - strip: &SurfaceTabStripWidgets, - widget: &GtkBox, - presented: PresentedSurfaceTabItem, -) { - if widget.parent().is_some() { - strip.root.remove(widget); - } - strip.root.put(widget, presented.x, 0.0); -} - -fn lerp_surface_tab_item( - start: PresentedSurfaceTabItem, - end: PresentedSurfaceTabItem, - progress: f64, -) -> PresentedSurfaceTabItem { - PresentedSurfaceTabItem { - x: start.x + ((end.x - start.x) * progress), - opacity: start.opacity + ((end.opacity - start.opacity) * progress), - width: lerp_i32(start.width, end.width, progress), - height: lerp_i32(start.height, end.height, progress), - } -} - -fn lerp_i32(start: i32, end: i32, progress: f64) -> i32 { - (f64::from(start) + (f64::from(end - start) * progress)).round() as i32 -} - -fn clear_box(container: &GtkBox) { - while let Some(child) = container.first_child() { - container.remove(&child); - } -} - -fn clear_fixed(container: &Fixed) { - while let Some(child) = container.first_child() { - container.remove(&child); - } -} - -fn activity_notification_key(item: &ActivityItem) -> String { - format!( - "{}:{}:{}:{}:{}", - item.workspace_id, item.pane_id, item.surface_id, item.created_at, item.message - ) -} - -fn activity_kind_label(kind: &SignalKind) -> &'static str { - match kind { - SignalKind::Metadata => "Updated", - SignalKind::Started => "Started", - SignalKind::Progress => "Working", - SignalKind::Completed => "Completed", - SignalKind::WaitingInput => "Waiting", - SignalKind::Error => "Error", - SignalKind::Notification => "Notification", - } -} - -fn widget_contains_window_focus(window: &adw::ApplicationWindow, target: &Widget) -> bool { - let mut current = gtk::prelude::GtkWindowExt::focus(window); - while let Some(widget) = current { - if widget == *target { - return true; - } - current = widget.parent(); - } - false -} - -fn pane_focus_target( - ui: &UiHandle, - workspace: &Workspace, - pane_id: taskers_domain::PaneId, - card: &PaneCardWidgets, -) -> Widget { - let active_surface_widget = workspace - .panes - .get(&pane_id) - .and_then(|pane| pane.active_surface().map(|surface| surface.id)) - .and_then(|surface_id| ui.ghostty_surfaces.borrow().get(&surface_id).cloned()) - .filter(|widget| widget.parent().is_some()); - - active_surface_widget - .or_else(|| card.terminal_host.first_child()) - .or_else(|| { - if card.focus_target.parent().is_some() { - Some(card.focus_target.clone()) - } else { - None - } - }) - .unwrap_or_else(|| card.root.clone().upcast()) -} - -fn active_pane_needs_scroller_focus_recovery( - ui: &UiHandle, - shell: &ShellWidgets, - workspace: &Workspace, -) -> bool { - let Some(card) = ui.pane_cards.borrow().get(&workspace.active_pane).cloned() else { - return false; - }; - let target = pane_focus_target(ui, workspace, workspace.active_pane, &card); - if widget_contains_window_focus(&ui.window, &target) { - return false; - } - - let Some(focused_widget) = gtk::prelude::GtkWindowExt::focus(&ui.window) else { - return false; - }; - - focused_widget.type_().name() == "GtkScrolledWindow" - && widget_is_descendant_of(&focused_widget, shell.layout_scroll.upcast_ref()) -} - -fn widget_is_descendant_of(widget: &Widget, ancestor: &Widget) -> bool { - let mut current = Some(widget.clone()); - while let Some(node) = current { - if node == *ancestor { - return true; - } - current = node.parent(); - } - false -} - -fn display_surface_title(surface: &SurfaceRecord) -> String { - if let Some(title) = surface - .metadata - .title - .as_deref() - .map(str::trim) - .filter(|title| !title.is_empty()) - { - return title.to_string(); - } - - if let Some(agent) = surface_agent_kind(surface) { - return humanize_agent_kind(agent); - } - - match surface.kind { - PaneKind::Terminal => "Terminal".into(), - PaneKind::Browser => "Browser".into(), - } -} - -fn editable_surface_title(surface: &SurfaceRecord) -> String { - surface - .metadata - .title - .as_deref() - .map(str::trim) - .filter(|title| !title.is_empty()) - .unwrap_or_default() - .to_string() -} - -const AGENT_ICON_CLASSES: [&str; 4] = [ - "agent-icon", - "agent-icon-codex", - "agent-icon-claude", - "agent-icon-opencode", -]; - -const CODEX_ICON_PATH: &str = "M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"; - -const CLAUDE_CODE_ICON_PATH: &str = "m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"; - -const OPENCODE_ICON_FRAME_PATH: &str = "M24 8H8V32H24V8ZM32 40H0V0H32V40Z"; -const OPENCODE_ICON_CORE_PATH: &str = "M24 32H8V16H24V32Z"; - -const CODEX_ICON_SPEC: AgentIconSpec = AgentIconSpec { - view_box_width: 256.0, - view_box_height: 260.0, - paths: &[AgentIconPathSpec { - data: CODEX_ICON_PATH, - fill: AgentIconFill::CurrentColor, - }], -}; - -const CLAUDE_CODE_ICON_SPEC: AgentIconSpec = AgentIconSpec { - view_box_width: 256.0, - view_box_height: 257.0, - paths: &[AgentIconPathSpec { - data: CLAUDE_CODE_ICON_PATH, - fill: AgentIconFill::Agent("claude"), - }], -}; - -const OPENCODE_ICON_SPEC: AgentIconSpec = AgentIconSpec { - view_box_width: 32.0, - view_box_height: 40.0, - paths: &[ - AgentIconPathSpec { - data: OPENCODE_ICON_FRAME_PATH, - fill: AgentIconFill::CurrentColor, - }, - AgentIconPathSpec { - data: OPENCODE_ICON_CORE_PATH, - fill: AgentIconFill::Agent("opencode"), - }, - ], -}; - -impl AgentIconWidget { - fn new(agent_kind: Option<&str>, size: i32) -> Self { - let root = DrawingArea::new(); - root.set_halign(Align::Center); - root.set_valign(Align::Center); - - let state = Rc::new(RefCell::new(AgentIconState::default())); - let draw_state = Rc::clone(&state); - root.set_draw_func(move |area, cr, width, height| { - let Some(agent_kind) = draw_state.borrow().kind else { - return; - }; - render_agent_icon(area, cr, width, height, agent_kind); - }); - - let icon = Self { root, state }; - configure_agent_icon(&icon, agent_kind, size); - icon - } - - fn widget(&self) -> &DrawingArea { - &self.root - } - - fn add_css_class(&self, class_name: &str) { - self.root.add_css_class(class_name); - } -} - -fn build_agent_icon(agent_kind: Option<&str>, size: i32) -> AgentIconWidget { - AgentIconWidget::new(agent_kind, size) -} - -fn configure_agent_icon(icon: &AgentIconWidget, agent_kind: Option<&str>, size: i32) { - icon.root.set_content_width(size); - icon.root.set_content_height(size); - icon.root.set_size_request(size, size); - for class_name in AGENT_ICON_CLASSES { - icon.root.remove_css_class(class_name); - } - - let Some(agent_kind) = - agent_kind.and_then(|agent_kind| normalized_agent_kind(Some(agent_kind))) - else { - icon.state.borrow_mut().kind = None; - icon.root.set_tooltip_text(None); - icon.root.set_visible(false); - icon.root.queue_draw(); - return; - }; - - if agent_icon_spec(agent_kind).is_none() { - icon.state.borrow_mut().kind = None; - icon.root.set_tooltip_text(None); - icon.root.set_visible(false); - icon.root.queue_draw(); - return; - } - - icon.state.borrow_mut().kind = Some(agent_kind); - icon.root.add_css_class("agent-icon"); - icon.root.add_css_class(agent_icon_class(agent_kind)); - icon.root - .set_tooltip_text(Some(&humanize_agent_kind(agent_kind))); - icon.root.set_visible(true); - icon.root.queue_draw(); -} - -fn agent_icon_class(agent_kind: &str) -> &'static str { - match agent_kind { - "codex" => "agent-icon-codex", - "claude" => "agent-icon-claude", - "opencode" => "agent-icon-opencode", - _ => "agent-icon", - } -} - -fn agent_icon_spec(agent_kind: &str) -> Option<&'static AgentIconSpec> { - match agent_kind { - "codex" => Some(&CODEX_ICON_SPEC), - "claude" => Some(&CLAUDE_CODE_ICON_SPEC), - "opencode" => Some(&OPENCODE_ICON_SPEC), - _ => None, - } -} - -fn render_agent_icon( - area: &DrawingArea, - cr: >k::cairo::Context, - width: i32, - height: i32, - agent_kind: &'static str, -) { - let Some(spec) = agent_icon_spec(agent_kind) else { - return; - }; - if width <= 0 || height <= 0 { - return; - } - - let scale = f64::min( - f64::from(width) / spec.view_box_width, - f64::from(height) / spec.view_box_height, - ); - if !scale.is_finite() || scale <= 0.0 { - return; - } - - let offset_x = (f64::from(width) - (spec.view_box_width * scale)) / 2.0; - let offset_y = (f64::from(height) - (spec.view_box_height * scale)) / 2.0; - - let _ = cr.save(); - cr.set_antialias(gtk::cairo::Antialias::Best); - cr.translate(offset_x, offset_y); - cr.scale(scale, scale); - - for path in spec.paths { - let Some(commands) = agent_icon_commands(path.data) else { - continue; - }; - cr.new_path(); - append_agent_icon_path(cr, commands.as_ref()); - apply_agent_icon_fill(area, cr, path.fill); - let _ = cr.fill(); - } - - let _ = cr.restore(); -} - -fn agent_icon_commands(path_data: &'static str) -> Option>> { - AGENT_ICON_PATHS.with(|cache| { - if let Some(commands) = cache.borrow().get(path_data).cloned() { - return Some(commands); - } - - let commands = Rc::new( - SimplifyingPathParser::from(path_data) - .map(|segment| segment.ok()) - .collect::>>()?, - ); - cache.borrow_mut().insert(path_data, Rc::clone(&commands)); - Some(commands) - }) -} - -fn append_agent_icon_path(cr: >k::cairo::Context, commands: &[SimplePathSegment]) { - let mut current = (0.0, 0.0); - let mut subpath_start = (0.0, 0.0); - - for command in commands { - match *command { - SimplePathSegment::MoveTo { x, y } => { - cr.move_to(x, y); - current = (x, y); - subpath_start = (x, y); - } - SimplePathSegment::LineTo { x, y } => { - cr.line_to(x, y); - current = (x, y); - } - SimplePathSegment::CurveTo { - x1, - y1, - x2, - y2, - x, - y, - } => { - cr.curve_to(x1, y1, x2, y2, x, y); - current = (x, y); - } - SimplePathSegment::Quadratic { x1, y1, x, y } => { - let cubic_1_x = current.0 + ((2.0 / 3.0) * (x1 - current.0)); - let cubic_1_y = current.1 + ((2.0 / 3.0) * (y1 - current.1)); - let cubic_2_x = x + ((2.0 / 3.0) * (x1 - x)); - let cubic_2_y = y + ((2.0 / 3.0) * (y1 - y)); - cr.curve_to(cubic_1_x, cubic_1_y, cubic_2_x, cubic_2_y, x, y); - current = (x, y); - } - SimplePathSegment::ClosePath => { - cr.close_path(); - current = subpath_start; - } - } - } -} - -fn apply_agent_icon_fill(area: &DrawingArea, cr: >k::cairo::Context, fill: AgentIconFill) { - let color = match fill { - AgentIconFill::CurrentColor => { - let color = area.style_context().color(); - AgentIconColor { - red: f64::from(color.red()), - green: f64::from(color.green()), - blue: f64::from(color.blue()), - alpha: f64::from(color.alpha()), - } - } - AgentIconFill::Agent(kind) => theme::resolve_agent_icon_color(kind).unwrap_or_else(|| { - let color = area.style_context().color(); - AgentIconColor { - red: f64::from(color.red()), - green: f64::from(color.green()), - blue: f64::from(color.blue()), - alpha: f64::from(color.alpha()), - } - }), - }; - - cr.set_source_rgba(color.red, color.green, color.blue, color.alpha); -} - -fn normalized_agent_kind(agent_kind: Option<&str>) -> Option<&'static str> { - let agent_kind = agent_kind?.trim(); - if agent_kind.is_empty() || agent_kind.eq_ignore_ascii_case("shell") { - return None; - } - - if agent_kind.eq_ignore_ascii_case("codex") || agent_kind.eq_ignore_ascii_case("openai") { - Some("codex") - } else if agent_kind.eq_ignore_ascii_case("claude") - || agent_kind.eq_ignore_ascii_case("claude code") - || agent_kind.eq_ignore_ascii_case("claude-code") - || agent_kind.eq_ignore_ascii_case("anthropic") - { - Some("claude") - } else if agent_kind.eq_ignore_ascii_case("opencode") { - Some("opencode") - } else if agent_kind.eq_ignore_ascii_case("aider") { - Some("aider") - } else { - None - } -} - -fn infer_agent_kind(value: &str) -> Option<&'static str> { - let normalized = value.trim().to_ascii_lowercase(); - if normalized.contains("codex") { - Some("codex") - } else if normalized.contains("claude") { - Some("claude") - } else if normalized.contains("opencode") { - Some("opencode") - } else if normalized.contains("aider") { - Some("aider") - } else { - None - } -} - -fn surface_agent_kind(surface: &SurfaceRecord) -> Option<&'static str> { - normalized_agent_kind(surface.metadata.agent_kind.as_deref()) - .or_else(|| surface.metadata.title.as_deref().and_then(infer_agent_kind)) - .or_else(|| { - surface - .command - .as_ref() - .and_then(|command| command.first()) - .and_then(|command| infer_agent_kind(command)) - }) -} - -fn workspace_agent_kind(workspace: &Workspace) -> Option<&'static str> { - workspace - .panes - .get(&workspace.active_pane) - .and_then(PaneRecord::active_surface) - .and_then(surface_agent_kind) - .or_else(|| { - workspace - .panes - .values() - .filter_map(PaneRecord::active_surface) - .find_map(surface_agent_kind) - }) -} - -fn humanize_agent_kind(agent: &str) -> String { - match agent { - "codex" => "Codex".into(), - "claude" => "Claude Code".into(), - "opencode" => "OpenCode".into(), - "aider" => "Aider".into(), - other => { - let mut chars = other.chars(); - match chars.next() { - Some(first) => first.to_uppercase().chain(chars).collect(), - None => "Terminal".into(), - } - } - } -} - -fn configure_pane_card_layout(card: &PaneCardWidgets) { - card.root.remove_css_class("pane-card-scrollable"); - card.root.set_halign(Align::Fill); - card.root.set_valign(Align::Fill); - card.root.set_hexpand(true); - card.root.set_vexpand(true); - card.root.set_size_request(-1, -1); -} - -fn current_workspace_viewport(shell: &ShellWidgets) -> WorkspaceViewport { - WorkspaceViewport { - x: shell.layout_scroll.hadjustment().value().round() as i32, - y: shell.layout_scroll.vadjustment().value().round() as i32, - } -} - -fn workspace_viewport_width(ui: &UiHandle, shell: Option<&ShellWidgets>) -> i32 { - let Some(shell) = shell else { - return DEFAULT_WORKSPACE_WINDOW_WIDTH; - }; - - let page_width = shell.layout_scroll.hadjustment().page_size().round() as i32; - if page_width > WORKSPACE_CANVAS_PADDING * 2 { - return (page_width - (WORKSPACE_CANVAS_PADDING * 2)).max(1); - } - - let allocated_width = shell.layout_scroll.allocated_width(); - if allocated_width > WORKSPACE_CANVAS_PADDING * 2 { - return (allocated_width - (WORKSPACE_CANVAS_PADDING * 2)).max(1); - } - - let window_width = ui.window.allocated_width(); - if window_width > 320 { - return (window_width - 260).max(1); - } - - DEFAULT_WORKSPACE_WINDOW_WIDTH -} - -fn workspace_viewport_height(ui: &UiHandle, shell: Option<&ShellWidgets>) -> i32 { - let Some(shell) = shell else { - return DEFAULT_WORKSPACE_WINDOW_HEIGHT; - }; - - let page_height = shell.layout_scroll.vadjustment().page_size().round() as i32; - if page_height > WORKSPACE_CANVAS_PADDING * 2 { - return (page_height - (WORKSPACE_CANVAS_PADDING * 2)).max(MIN_WORKSPACE_WINDOW_HEIGHT); - } - - let allocated_height = shell.layout_scroll.allocated_height(); - if allocated_height > WORKSPACE_CANVAS_PADDING * 2 { - return (allocated_height - (WORKSPACE_CANVAS_PADDING * 2)) - .max(MIN_WORKSPACE_WINDOW_HEIGHT); - } - - let window_height = ui.window.allocated_height(); - if window_height > 180 { - return (window_height - 140).max(MIN_WORKSPACE_WINDOW_HEIGHT); - } - - DEFAULT_WORKSPACE_WINDOW_HEIGHT -} - -fn scale_window_frame(frame: WindowFrame, scale: f64) -> WindowFrame { - if (scale - 1.0).abs() < f64::EPSILON { - return frame; - } - - WindowFrame { - x: (f64::from(frame.x) * scale).round() as i32, - y: (f64::from(frame.y) * scale).round() as i32, - width: (f64::from(frame.width) * scale).round().max(1.0) as i32, - height: (f64::from(frame.height) * scale).round().max(1.0) as i32, - } -} - -fn workspace_window_placements( - workspace: &Workspace, - top_level_resize_preview: Option, -) -> Vec { - let mut placements = Vec::new(); - let mut x = 0; - - for column in workspace.columns.values() { - let column_width = match top_level_resize_preview { - Some(TopLevelResizePreview { - workspace_id: _, - target: - TopLevelResizePreviewTarget::ColumnWidth { - workspace_column_id, - width, - }, - }) if workspace_column_id == column.id => width.max(MIN_WORKSPACE_WINDOW_WIDTH), - _ => column.width.max(1), - }; - let mut y = 0; - for window_id in &column.window_order { - let Some(window) = workspace.windows.get(window_id) else { - continue; - }; - let window_height = match top_level_resize_preview { - Some(TopLevelResizePreview { - workspace_id: _, - target: - TopLevelResizePreviewTarget::WindowHeight { - workspace_window_id, - height, - }, - }) if workspace_window_id == *window_id => height.max(MIN_WORKSPACE_WINDOW_HEIGHT), - _ => window.height.max(MIN_WORKSPACE_WINDOW_HEIGHT), - }; - placements.push(WorkspaceWindowPlacement { - window_id: *window_id, - column_id: column.id, - frame: WindowFrame { - x, - y, - width: column_width, - height: window_height, - }, - }); - y += window_height + DEFAULT_WORKSPACE_WINDOW_GAP; - } - x += column_width + DEFAULT_WORKSPACE_WINDOW_GAP; - } - - placements -} - -fn workspace_display_window_placements( - workspace: &Workspace, - render_context: WorkspaceRenderContext, -) -> Vec { - workspace_window_placements(workspace, render_context.top_level_resize_preview) - .into_iter() - .map(|mut placement| { - if render_context.overview_mode { - placement.frame = - scale_window_frame(placement.frame, render_context.overview_scale); - } - placement - }) - .collect() -} - -fn canvas_metrics_from_frames(frames: &[WindowFrame]) -> CanvasMetrics { - let min_x = frames.iter().map(|frame| frame.x).min().unwrap_or(0); - let min_y = frames.iter().map(|frame| frame.y).min().unwrap_or(0); - let offset_x = WORKSPACE_CANVAS_PADDING - min_x; - let offset_y = WORKSPACE_CANVAS_PADDING - min_y; - let width = frames - .iter() - .map(|frame| frame.right() + offset_x + WORKSPACE_CANVAS_PADDING) - .max() - .unwrap_or(WORKSPACE_CANVAS_PADDING * 2); - let height = frames - .iter() - .map(|frame| frame.bottom() + offset_y + WORKSPACE_CANVAS_PADDING) - .max() - .unwrap_or(WORKSPACE_CANVAS_PADDING * 2); - - CanvasMetrics { - offset_x, - offset_y, - width, - height, - } -} - -fn workspace_render_context( - ui: &UiHandle, - _shell: Option<&ShellWidgets>, - workspace: &Workspace, - overview_mode: bool, - viewport_width: i32, - viewport_height: i32, -) -> WorkspaceRenderContext { - if !overview_mode { - return WorkspaceRenderContext { - overview_mode: false, - overview_scale: 1.0, - top_level_resize_preview: ui.active_top_level_resize_preview(workspace.id), - }; - } - - let base_frames = workspace_window_placements(workspace, None) - .into_iter() - .map(|placement| placement.frame) - .collect::>(); - let base_metrics = canvas_metrics_from_frames(&base_frames); - let content_width = (base_metrics.width - (WORKSPACE_CANVAS_PADDING * 2)).max(1); - let content_height = (base_metrics.height - (WORKSPACE_CANVAS_PADDING * 2)).max(1); - let available_width = (viewport_width - (WORKSPACE_CANVAS_PADDING * 2)).max(1) as f64; - let available_height = (viewport_height - (WORKSPACE_CANVAS_PADDING * 2)).max(1) as f64; - let overview_scale = (available_width / f64::from(content_width)) - .min(available_height / f64::from(content_height)) - .clamp(0.05, 1.0); - - WorkspaceRenderContext { - overview_mode: true, - overview_scale, - top_level_resize_preview: None, - } -} - -fn workspace_canvas_metrics( - workspace: &Workspace, - render_context: WorkspaceRenderContext, -) -> CanvasMetrics { - let display_frames = workspace_display_window_placements(workspace, render_context) - .into_iter() - .map(|placement| placement.frame) - .collect::>(); - canvas_metrics_from_frames(&display_frames) -} - -fn workspace_window_attention( - workspace: &Workspace, - window: &taskers_domain::WorkspaceWindowRecord, -) -> AttentionState { - window - .layout - .leaves() - .into_iter() - .filter_map(|pane_id| workspace.panes.get(&pane_id)) - .map(PaneRecord::active_attention) - .max_by_key(|attention| attention.rank()) - .unwrap_or(AttentionState::Normal) -} - -fn paned_extent(paned: &Paned, axis: taskers_domain::SplitAxis) -> i32 { - match axis { - taskers_domain::SplitAxis::Horizontal => paned.allocated_width(), - taskers_domain::SplitAxis::Vertical => paned.allocated_height(), - } -} - -#[derive(Clone, Copy)] -enum ResizeHandleEdge { - Right, - Bottom, -} - -fn count_widget_children(widget: &Widget) -> usize { - let mut count = 0; - let mut child = widget.first_child(); - - while let Some(current) = child { - count += 1; - child = current.next_sibling(); - } - - count -} - -fn attention_dot_class(state: AttentionState) -> String { - match state { - AttentionState::Normal => "status-dot-normal".into(), - AttentionState::Busy => "status-dot-busy".into(), - AttentionState::Completed => "status-dot-completed".into(), - AttentionState::WaitingInput => "status-dot-waiting".into(), - AttentionState::Error => "status-dot-error".into(), - } -} - -fn attention_state_slug(state: AttentionState) -> &'static str { - match state { - AttentionState::Normal => "normal", - AttentionState::Busy => "busy", - AttentionState::Completed => "completed", - AttentionState::WaitingInput => "waiting", - AttentionState::Error => "error", - } -} - -fn bind_output_updates(ui: &Rc, surface_id: SurfaceId, text_view: TextView) { - let runtime = ui.app_state.runtime(); - let buffer = text_view.buffer(); - let last_seen = Rc::new(RefCell::new(String::new())); - let last_seen_for_timer = Rc::clone(&last_seen); - - gtk::glib::timeout_add_local(Duration::from_millis(150), move || { - let Some(snapshot) = runtime.snapshot(surface_id) else { - return gtk::glib::ControlFlow::Continue; - }; - - if *last_seen_for_timer.borrow() != snapshot.output { - buffer.set_text(&snapshot.output); - *last_seen_for_timer.borrow_mut() = snapshot.output; - let mut end = buffer.end_iter(); - text_view.scroll_to_iter(&mut end, 0.0, false, 0.0, 1.0); - } - - gtk::glib::ControlFlow::Continue - }); -} - -fn connect_ghostty_widget( - ui: &Rc, - workspace_id: taskers_domain::WorkspaceId, - pane_id: taskers_domain::PaneId, - surface_id: SurfaceId, - widget: &Widget, -) { - widget.set_focusable(true); - let focus_ui = Rc::clone(ui); - let focus_click = gtk::GestureClick::new(); - focus_click.connect_pressed(move |_, _, _, _| { - focus_ui.dispatch(ControlCommand::FocusPane { - workspace_id, - pane_id, - }); - }); - widget.add_controller(focus_click); - - let title_ui = Rc::clone(ui); - widget.connect_notify_local(Some("title"), move |widget, _| { - let title = widget - .property::>("title") - .map(|value| value.to_string()); - let current = title_ui - .app_state - .snapshot_model() - .workspaces - .values() - .find_map(|workspace| workspace.panes.get(&pane_id)) - .and_then(|pane| pane.surfaces.get(&surface_id)) - .and_then(|surface| surface.metadata.title.clone()); - if current == title { - return; - } - let deferred_ui = Rc::clone(&title_ui); - glib::idle_add_local_once(move || { - deferred_ui.dispatch(ControlCommand::UpdateSurfaceMetadata { - surface_id, - patch: PaneMetadataPatch { - title, - cwd: None, - repo_name: None, - git_branch: None, - ports: None, - agent_kind: None, - }, - }); - }); - }); - - let pwd_ui = Rc::clone(ui); - widget.connect_notify_local(Some("pwd"), move |widget, _| { - let cwd = widget - .property::>("pwd") - .map(|value| value.to_string()); - let current = pwd_ui - .app_state - .snapshot_model() - .workspaces - .values() - .find_map(|workspace| workspace.panes.get(&pane_id)) - .and_then(|pane| pane.surfaces.get(&surface_id)) - .and_then(|surface| surface.metadata.cwd.clone()); - if current == cwd { - return; - } - let deferred_ui = Rc::clone(&pwd_ui); - glib::idle_add_local_once(move || { - deferred_ui.dispatch(ControlCommand::UpdateSurfaceMetadata { - surface_id, - patch: PaneMetadataPatch { - title: None, - cwd, - repo_name: None, - git_branch: None, - ports: None, - agent_kind: None, - }, - }); - }); - }); - - let bell_ui = Rc::clone(ui); - widget.connect_notify_local(Some("bell-ringing"), move |widget, _| { - if widget.property::("bell-ringing") { - let model = bell_ui.app_state.snapshot_model(); - let active_pane_has_focus = model.active_workspace().is_some_and(|workspace| { - workspace.id == workspace_id - && workspace.active_pane == pane_id - && bell_ui.window.is_active() - && widget_contains_window_focus(&bell_ui.window, widget) - }); - if active_pane_has_focus { - return; - } - - let deferred_ui = Rc::clone(&bell_ui); - glib::idle_add_local_once(move || { - deferred_ui.dispatch(ControlCommand::EmitSignal { - workspace_id, - pane_id, - surface_id: Some(surface_id), - event: SignalEvent::new( - "ghostty", - SignalKind::Notification, - Some("Terminal requested attention".into()), - ), - }); - }); - } - }); - - let exit_ui = Rc::clone(ui); - widget.connect_notify_local(Some("child-exited"), move |widget, _| { - if widget.property::("child-exited") { - let deferred_ui = Rc::clone(&exit_ui); - glib::idle_add_local_once(move || { - deferred_ui.dispatch(ControlCommand::CloseSurface { - workspace_id, - pane_id, - surface_id, - }); - }); - } - }); -} - -fn detach_widget(widget: &Widget) { - let Some(parent) = widget.parent() else { - return; - }; - - // Reparent through the actual container API. Calling gtk_widget_unparent() - // directly from app code bypasses the parent's bookkeeping and can leave - // reused pane widgets in a visually corrupted state after split/close - // rebuilds. - if let Ok(container) = parent.clone().downcast::() { - container.remove(widget); - return; - } - - if let Ok(paned) = parent.downcast::() { - if paned - .start_child() - .as_ref() - .is_some_and(|child| child == widget) - { - paned.set_start_child(None::<&Widget>); - return; - } - - if paned - .end_child() - .as_ref() - .is_some_and(|child| child == widget) - { - paned.set_end_child(None::<&Widget>); - return; - } + let size = PixelSize::new(window.width().max(1), window.height().max(1)); + if last_size.get() != (size.width, size.height) { + core.set_window_size(size); + last_size.set((size.width, size.height)); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Window, + Some(core.revision()), + format!("window resized width={} height={}", size.width, size.height), + ), + ); } - unreachable!("unsupported parent type for detachable widget"); -} - -fn format_pane_meta(pane: &PaneRecord, snapshot: Option<&PaneRuntimeSnapshot>) -> String { - let metadata = pane.active_metadata(); - let cwd = metadata - .and_then(|meta| meta.cwd.as_deref()) - .unwrap_or("cwd unknown"); - let branch = metadata - .and_then(|meta| meta.git_branch.as_deref()) - .unwrap_or("no branch"); - let agent = metadata - .and_then(|meta| meta.agent_kind.as_deref()) - .unwrap_or("shell"); - let ports = if metadata.is_none_or(|meta| meta.ports.is_empty()) { - "no ports".into() - } else { - metadata - .expect("metadata exists when ports are present") - .ports - .iter() - .map(u16::to_string) - .collect::>() - .join(", ") - }; - let process = snapshot - .and_then(|snapshot| snapshot.process_id) - .map(|process_id| format!("pid {process_id}")) - .unwrap_or_else(|| "starting shell".into()); - - format!("{agent} \u{2022} {cwd} \u{2022} {branch} \u{2022} {ports} \u{2022} {process}") -} - -fn metadata_has_display_context(metadata: &PaneMetadata) -> bool { - metadata - .cwd - .as_deref() - .is_some_and(|cwd| !cwd.trim().is_empty()) - || metadata - .git_branch - .as_deref() - .is_some_and(|branch| !branch.trim().is_empty()) - || metadata - .repo_name - .as_deref() - .is_some_and(|repo_name| !repo_name.trim().is_empty()) - || metadata - .agent_kind - .as_deref() - .is_some_and(|agent_kind| !agent_kind.trim().is_empty()) - || !metadata.ports.is_empty() -} - -fn compact_preview(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - -fn compact_path(path: &str) -> String { - let home = std::env::var("HOME").ok(); - if let Some(home) = home { - if path == home { - return "~".into(); - } - if let Some(suffix) = path.strip_prefix(&(home + "/")) { - return format!("~/{suffix}"); + let revision = core.revision(); + if last_revision.get() != revision { + let snapshot = core.snapshot(); + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(revision), + format!( + "syncing snapshot panes={} active={}", + snapshot.portal.panes.len(), + snapshot.current_workspace.active_pane + ), + ), + ); + if let Err(error) = host.borrow_mut().sync_snapshot(&snapshot) { + log_diagnostic( + diagnostics, + DiagnosticRecord::new( + DiagnosticCategory::Sync, + Some(revision), + format!("snapshot sync failed: {error}"), + ), + ); + eprintln!("taskers host sync failed for revision {revision}: {error}"); } + last_revision.set(revision); } - path.to_string() + host.borrow().tick(); } -fn format_ports(ports: &[u16]) -> String { - match ports { - [] => String::new(), - [port] => format!(":{port}"), - [first, rest @ ..] => format!(":{first} +{}", rest.len()), - } -} - -fn sorted_id_strings(values: I) -> Vec -where - I: IntoIterator, - T: ToString, -{ - let mut ids = values - .into_iter() - .map(|value| value.to_string()) - .collect::>(); - ids.sort(); - ids -} - -fn spawn_control_server(controller: InMemoryController, socket_path: PathBuf) -> String { +fn spawn_control_server(app_state: AppState, socket_path: PathBuf) -> String { if let Some(parent) = socket_path.parent() && let Err(error) = std::fs::create_dir_all(parent) { @@ -7081,8 +770,8 @@ fn spawn_control_server(controller: InMemoryController, socket_path: PathBuf) -> socket_path.display() ); } - let note = format!("Control server starting on {}", socket_path.display()); + let note = format!("Control server starting on {}", socket_path.display()); thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -7091,7 +780,13 @@ fn spawn_control_server(controller: InMemoryController, socket_path: PathBuf) -> runtime.block_on(async move { match bind_socket(&socket_path) { Ok(listener) => { - if let Err(error) = serve(listener, controller, pending::<()>()).await { + let handler = move |command| { + app_state + .dispatch(command) + .map_err(|error| error.to_string()) + }; + if let Err(error) = serve_with_handler(listener, handler, pending::<()>()).await + { eprintln!("control server error: {error}"); } } @@ -7108,736 +803,311 @@ fn spawn_control_server(controller: InMemoryController, socket_path: PathBuf) -> note } -// ── Settings page builders ── - -fn build_settings_theme_page(ui: &Rc) -> ScrolledWindow { - let scroll = ScrolledWindow::new(); - scroll.set_policy(PolicyType::Never, PolicyType::Automatic); - scroll.set_vexpand(true); +fn launch_liveview_server(core: SharedCore) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").context("failed to bind loopback port")?; + listener + .set_nonblocking(true) + .context("failed to set loopback listener nonblocking")?; + let addr = listener + .local_addr() + .context("failed to read loopback addr")?; + let url = format!("http://{addr}/"); - let content = GtkBox::new(Orientation::Vertical, 8); - content.set_margin_start(18); - content.set_margin_end(18); - content.set_margin_top(14); - content.set_margin_bottom(18); - - let current_theme = ui - .settings - .borrow() - .theme - .clone() - .unwrap_or_else(|| "dark".into()); - - // Collect all FlowBoxes so the click handler can clear active styling across families. - let all_flows: Rc>> = Rc::new(RefCell::new(Vec::new())); - - let mut prev_family = ""; - let mut current_flow: Option = None; - - for &name in themes::BUILTIN_NAMES { - let family = themes::theme_family(name); - - if family != prev_family { - // Flush previous FlowBox. - if let Some(flow) = current_flow.take() { - content.append(&flow); - } - - let heading = Label::new(Some(family)); - heading.set_xalign(0.0); - heading.add_css_class("settings-theme-family"); - content.append(&heading); - - let flow = gtk::FlowBox::new(); - flow.set_max_children_per_line(3); - flow.set_min_children_per_line(2); - flow.set_homogeneous(true); - flow.set_row_spacing(8); - flow.set_column_spacing(8); - flow.set_selection_mode(gtk::SelectionMode::None); - all_flows.borrow_mut().push(flow.clone()); - current_flow = Some(flow); - - prev_family = family; - } - - let palette = if name == "dark" { - theme::default_dark() - } else { - themes::builtin_theme(name).unwrap_or_else(theme::default_dark) - }; - let is_active = name == current_theme; - let card = build_theme_card(name, &palette, is_active); - - let card_button = Button::new(); - card_button.set_child(Some(&card)); - card_button.add_css_class("flat"); - - let click_ui = Rc::clone(ui); - let click_name = name.to_string(); - let click_flows = Rc::clone(&all_flows); - card_button.connect_clicked(move |btn| { - let theme_value = if click_name == "dark" { - None - } else { - Some(click_name.clone()) - }; - let mut next_settings = click_ui.settings.borrow().clone(); - next_settings.theme = theme_value; - if let Err(error) = settings_store::save_config(&click_ui.config_path, &next_settings) { - click_ui.toast(&format!("Failed to save theme: {error}")); - return; - } - *click_ui.settings.borrow_mut() = next_settings; - - // Clear active styling across all family FlowBoxes. - for flow in click_flows.borrow().iter() { - let mut idx = 0; - while let Some(child) = flow.child_at_index(idx) { - if let Some(b) = child.child().and_then(|w| w.downcast::