diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 36b38ccc1..9228a26d5 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -18,45 +18,108 @@ # Maintainers can vouch for new contributors by commenting "!vouch" on a # discussion by the author. Maintainers can denounce users by commenting # "!denounce" or "!denounce [username]" on a discussion. +00-kat +abudvytis +aindriu80 +alanmoyano +alexfeijoo44 +andrejdaskalov +atomk +balazs-szucs bennettp123 +benodiwal bernsno +beryesa bitigchi bkircher +bo2themax +brentschroeter +charliie-dev chernetskyi +craziestowl +d-dudas daiimus +damyanbogoev +danulqua doprz elias8 ephemera eriksremess filip7 +flou +francescarpi +gagbo +ghokun +gmile +gordonbondon +gpanders +guilhermetk hakonhagland +halosatrio hqnna +jacobsandlund jake-stewart jcollie +johnslavik +josephmart +jparise juniqlim kawarimidoll +kenvandine khipp +kirwiisp +kjvdven +kloneets +kristina8888 kristofersoler +laxystem +liby +lonsagisawa mahnokropotkinvich +marijagjorgjieva marrocco-simone +matkotiric miguelelgallo mikailmm +misairuzame mitchellh +miupa +mtak +natesmyth +neo773 +nicosuave nwehg +oshdubh pan93412 pangoraw peilingjiang peterdavehello phush0 +piedrahitac pluiedev pouwerkerk priyans-hu prsweet qwerasd205 +reo101 +rgehan +rmengelbrecht rmunn +rockorager +rpfaeffle secrus +silveirapf slsrepo +sunshine-syz +tdslot +ticclick +tnagatomi +trag1c +tristan957 tweedbeetle +uhojin uzaaft vlsi yamshta +zenyr +zeshi09 diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 49bba4e6b..33a074159 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -19,8 +19,7 @@ jobs: if: github.event.pull_request.merged == true with: action: bind-pr # `bind-pr` is the default action - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue @@ -28,5 +27,4 @@ jobs: if: github.event.issue.state == 'closed' with: action: bind-issue - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index f12ba2211..1897fa4a1 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index a24e5a389..d9f73197d 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 2227ae09c..3eb4296f7 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -165,7 +165,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 68ec9cacd..b16f5b209 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e07ffe859..b9058b395 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,15 +11,84 @@ concurrency: cancel-in-progress: true jobs: + # Determines whether other jobs should be skipped. Modify this if there + # are other fast skip conditions, and add it as an output. Then modify + # other tests `needs/if` to check them. Document the outputs. + skip: + if: github.repository == 'ghostty-org/ghostty' + runs-on: namespace-profile-ghostty-xsm + outputs: + # 'true' when all changed files are non-code (e.g. only VOUCHED.td), + # signaling that all other jobs can be skipped entirely. + skip: ${{ steps.determine.outputs.skip }} + # Path-based filters to gate specific linter/formatter jobs. + actions_pins: ${{ steps.filter_any.outputs.actions_pins }} + blueprints: ${{ steps.filter_any.outputs.blueprints }} + macos: ${{ steps.filter_any.outputs.macos }} + nix: ${{ steps.filter_any.outputs.nix }} + shell: ${{ steps.filter_any.outputs.shell }} + zig: ${{ steps.filter_any.outputs.zig }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter_every + with: + token: ${{ secrets.GITHUB_TOKEN }} + predicate-quantifier: "every" + filters: | + code: + - '**' + - '!.github/VOUCHED.td' + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter_any + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: | + macos: + - '.swiftlint.yml' + - 'macos/**' + actions_pins: + - '.github/workflows/**' + - '.github/pinact.yml' + shell: + - '**/*.sh' + - '**/*.bash' + nix: + - 'nix/**' + - '*.nix' + - 'flake.nix' + - 'flake.lock' + - 'default.nix' + - 'shell.nix' + zig: + - '**/*.zig' + - 'build.zig*' + blueprints: + - 'src/apprt/gtk/**/*.blp' + - 'nix/build-support/check-blueprints.sh' + + - id: determine + name: Determine skip + run: | + if [ "${{ steps.filter_every.outputs.code }}" = "false" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + required: name: "Required Checks: Test" + if: always() runs-on: namespace-profile-ghostty-xsm needs: + - skip - build-bench - build-dist - build-examples - build-flatpak - build-libghostty-vt + - build-libghostty-vt-android - build-libghostty-vt-macos - build-linux - build-linux-libghostty @@ -36,6 +105,7 @@ jobs: - test-macos - pinact - prettier + - swiftlint - alejandra - typos - shellcheck @@ -78,7 +148,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -121,7 +191,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -154,7 +224,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -188,7 +258,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -232,7 +302,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -281,6 +351,54 @@ jobs: nix develop -c zig build lib-vt \ -Dtarget=${{ matrix.target }} + # lib-vt requires the Android NDK for Android builds + build-libghostty-vt-android: + strategy: + matrix: + target: + [aarch64-linux-android, x86_64-linux-android, arm-linux-androideabi] + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + ANDROID_NDK_VERSION: r29 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Setup Android NDK + uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1.6.0 + id: setup-ndk + with: + ndk-version: r29 + add-to-path: false + link-to-sdk: false + local-cache: true + + - name: Build + run: | + nix develop -c zig build lib-vt \ + -Dtarget=${{ matrix.target }} + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + build-linux: strategy: fail-fast: false @@ -296,7 +414,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -325,7 +443,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -358,7 +476,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -404,7 +522,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -616,7 +734,8 @@ jobs: run: Get-Content -Path ".\build.log" test: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip runs-on: namespace-profile-ghostty-md outputs: zig_version: ${{ steps.zig.outputs.version }} @@ -633,7 +752,7 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -675,7 +794,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -723,7 +842,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -758,7 +877,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -811,7 +930,7 @@ jobs: fail-fast: false matrix: i18n: ["true", "false"] - name: Build -Di18n=${{ matrix.simd }} + name: Build -Di18n=${{ matrix.i18n }} runs-on: namespace-profile-ghostty-sm needs: test env: @@ -822,7 +941,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -842,7 +961,8 @@ jobs: nix develop -c zig build -Di18n=${{ matrix.i18n }} zig-fmt: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.zig == 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -851,7 +971,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -870,7 +990,8 @@ jobs: pinact: name: "GitHub Actions Pins" - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.actions_pins == 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 permissions: @@ -881,7 +1002,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -901,7 +1022,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} prettier: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -910,7 +1032,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -927,8 +1049,31 @@ jobs: - name: prettier check run: nix develop -c prettier --check . + swiftlint: + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.macos == 'true' + runs-on: namespace-profile-ghostty-macos-tahoe + needs: skip + timeout-minutes: 60 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 + - uses: DeterminateSystems/nix-installer-action@main + with: + determinate: true + - uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + skipPush: true + useDaemon: false # sometimes fails on short jobs + + - name: swiftlint check + run: nix develop -c swiftlint lint --strict + alejandra: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.nix == 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -937,7 +1082,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -955,7 +1100,8 @@ jobs: run: nix develop -c alejandra --check . typos: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -964,7 +1110,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -982,7 +1128,8 @@ jobs: run: nix develop -c typos shellcheck: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.shell == 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -991,7 +1138,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -1014,7 +1161,8 @@ jobs: $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) translations: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -1023,7 +1171,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -1041,7 +1189,8 @@ jobs: run: nix develop -c .github/scripts/check-translations.sh blueprint-compiler: - if: github.repository == 'ghostty-org/ghostty' + if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' && needs.skip.outputs.blueprints == 'true' + needs: skip runs-on: namespace-profile-ghostty-xsm timeout-minutes: 60 env: @@ -1050,7 +1199,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -1085,7 +1234,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -1147,7 +1296,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 026b1e9df..ac92c0608 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index e0933aaf1..60c56fe8f 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-issue@6803dde571265e13489c3f118200f60b6ab59e4d # v1.3.1 + - uses: mitchellh/vouch/action/check-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index eb5a7e6fb..aaf9176b3 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@6803dde571265e13489c3f118200f60b6ab59e4d # v1.3.1 + - uses: mitchellh/vouch/action/check-pr@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index 50e2a23f3..93e7a1343 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@6803dde571265e13489c3f118200f60b6ab59e4d # v1.3.1 + - uses: mitchellh/vouch/action/manage-by-discussion@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index f00270a0d..acea8f4fd 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@6803dde571265e13489c3f118200f60b6ab59e4d # v1.3.1 + - uses: mitchellh/vouch/action/manage-by-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml new file mode 100644 index 000000000..fe1977a66 --- /dev/null +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -0,0 +1,32 @@ +on: + schedule: + - cron: "0 0 * * 1" # Every Monday at midnight UTC + workflow_dispatch: + +name: "Vouch - Sync CODEOWNERS" + +concurrency: + group: vouch-manage + cancel-in-progress: false + +jobs: + sync: + runs-on: namespace-profile-ghostty-xsm + steps: + - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + id: app-token + with: + app-id: ${{ secrets.VOUCH_APP_ID }} + private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: mitchellh/vouch/action/sync-codeowners@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + with: + repo: ${{ github.repository }} + pull-request: "true" + merge-immediately: "true" + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.gitignore b/.gitignore index e521f8851..40a04dbae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ zig-cache/ .zig-cache/ zig-out/ +/build.zig.zon.bak /result* /.nixos-test-history example/*.wasm diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..7f1b56883 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,2 @@ +included: macos +child_config: macos/.swiftlint.yml diff --git a/AGENTS.md b/AGENTS.md index 04d3570a7..21645e4d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ A file for [guiding coding agents](https://agents.md/). - **Test (Zig):** `zig build test` - **Test filter (Zig)**: `zig build test -Dtest-filter=` - **Formatting (Zig)**: `zig fmt .` +- **Formatting (Swift)**: `swiftlint lint --fix` - **Formatting (other)**: `prettier -w .` ## Directory Structure diff --git a/CODEOWNERS b/CODEOWNERS index 7e471d1b8..23c95583c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -137,6 +137,7 @@ /dist/macos/ @ghostty-org/macos /pkg/apple-sdk/ @ghostty-org/macos /pkg/macos/ @ghostty-org/macos +/.swiftlint.yml @ghostty-org/macos # Renderer /src/renderer.zig @ghostty-org/renderer diff --git a/HACKING.md b/HACKING.md index 921ed71ff..7ba584881 100644 --- a/HACKING.md +++ b/HACKING.md @@ -186,6 +186,31 @@ shellcheck \ $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) ``` +### SwiftLint + +Swift code is linted using [SwiftLint](https://github.com/realm/SwiftLint). A +SwiftLint CI check will fail builds with improper formatting. Therefore, if you +are modifying Swift code, you may want to install it locally and run this from +the repo root before you commit: + +``` +swiftlint lint --fix +``` + +Make sure your SwiftLint version matches the version in [devShell.nix](https://github.com/ghostty-org/ghostty/blob/main/nix/devShell.nix). + +Nix users can use the following command to format with SwiftLint: + +``` +nix develop -c swiftlint lint --fix +``` + +To check for violations without auto-fixing: + +``` +nix develop -c swiftlint lint --strict +``` + ### Updating the Zig Cache Fixed-Output Derivation Hash The Nix package depends on a [fixed-output diff --git a/build.zig.zon b/build.zig.zon index 497cef406..e7a8747f7 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -21,7 +21,7 @@ }, .z2d = .{ // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + .url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", .hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", .lazy = true, }, @@ -39,8 +39,8 @@ }, .uucode = .{ // jacobsandlund/uucode - .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", - .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + .url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", + .hash = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9", }, .zig_wayland = .{ // codeberg ifreund/zig-wayland @@ -115,9 +115,10 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, + .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - .hash = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + .hash = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", .lazy = true, }, }, diff --git a/build.zig.zon.bak b/build.zig.zon.bak deleted file mode 100644 index 191ae7fa9..000000000 --- a/build.zig.zon.bak +++ /dev/null @@ -1,124 +0,0 @@ -.{ - .name = .ghostty, - .version = "1.3.0-dev", - .paths = .{""}, - .fingerprint = 0x64407a2a0b4147e5, - .minimum_zig_version = "0.15.2", - .dependencies = .{ - // Zig libs - - .libxev = .{ - // mitchellh/libxev - .url = "https://deps.files.ghostty.org/libxev-34fa50878aec6e5fa8f532867001ab3c36fae23e.tar.gz", - .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs", - .lazy = true, - }, - .vaxis = .{ - // rockorager/libvaxis - .url = "https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz", - .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", - .lazy = true, - }, - .z2d = .{ - // vancluever/z2d - .url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz", - .hash = "z2d-0.9.0-j5P_Hu-WFgA_JEfRpiFss6gdvcvS47cgOc0Via2eKD_T", - .lazy = true, - }, - .zig_objc = .{ - // mitchellh/zig-objc - .url = "https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz", - .hash = "zig_objc-0.0.0-Ir_Sp5gTAQCvxxR7oVIrPXxXwsfKgVP7_wqoOQrZjFeK", - .lazy = true, - }, - .zig_js = .{ - // mitchellh/zig-js - .url = "https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz", - .hash = "zig_js-0.0.0-rjCAV-6GAADxFug7rDmPH-uM_XcnJ5NmuAMJCAscMjhi", - .lazy = true, - }, - .uucode = .{ - // jacobsandlund/uucode - .url = "https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", - .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", - }, - .zig_wayland = .{ - // codeberg ifreund/zig-wayland - .url = "https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e8ea.tar.gz", - .hash = "wayland-0.5.0-dev-lQa1khrMAQDJDwYFKpdH3HizherB7sHo5dKMECfvxQHe", - .lazy = true, - }, - .zf = .{ - // natecraddock/zf - .url = "https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz", - .hash = "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh", - .lazy = true, - }, - .gobject = .{ - // https://github.com/ghostty-org/zig-gobject based on zig_gobject - // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.7.0-2025-11-08-23-1/ghostty-gobject-0.7.0-2025-11-08-23-1.tar.zst", - .hash = "gobject-0.3.0-Skun7ANLnwDvEfIpVmohcppXgOvg_I6YOJFmPIsKfXk-", - .lazy = true, - }, - - // C libs - .cimgui = .{ .path = "./pkg/cimgui", .lazy = true }, - .fontconfig = .{ .path = "./pkg/fontconfig", .lazy = true }, - .freetype = .{ .path = "./pkg/freetype", .lazy = true }, - .gtk4_layer_shell = .{ .path = "./pkg/gtk4-layer-shell", .lazy = true }, - .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, - .highway = .{ .path = "./pkg/highway", .lazy = true }, - .libintl = .{ .path = "./pkg/libintl", .lazy = true }, - .libpng = .{ .path = "./pkg/libpng", .lazy = true }, - .macos = .{ .path = "./pkg/macos", .lazy = true }, - .oniguruma = .{ .path = "./pkg/oniguruma", .lazy = true }, - .opengl = .{ .path = "./pkg/opengl", .lazy = true }, - .sentry = .{ .path = "./pkg/sentry", .lazy = true }, - .simdutf = .{ .path = "./pkg/simdutf", .lazy = true }, - .utfcpp = .{ .path = "./pkg/utfcpp", .lazy = true }, - .wuffs = .{ .path = "./pkg/wuffs", .lazy = true }, - .zlib = .{ .path = "./pkg/zlib", .lazy = true }, - - // Shader translation - .glslang = .{ .path = "./pkg/glslang", .lazy = true }, - .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true }, - - // Wayland - .wayland = .{ - .url = "https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz", - .hash = "N-V-__8AAKrHGAAs2shYq8UkE6bGcR1QJtLTyOE_lcosMn6t", - .lazy = true, - }, - .wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", - .hash = "N-V-__8AAKw-DAAaV8bOAAGqA0-oD7o-HNIlPFYKRXSPT03S", - .lazy = true, - }, - .plasma_wayland_protocols = .{ - .url = "https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e893e0132fc87bb763969a585dc16ecca33e88334c566.tar.gz", - .hash = "N-V-__8AAKYZBAB-CFHBKs3u4JkeiT4BMvyHu3Y5aaWF3Bbs", - .lazy = true, - }, - - // Fonts - .jetbrains_mono = .{ - .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", - .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", - .lazy = true, - }, - .nerd_fonts_symbols_only = .{ - .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", - .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", - .lazy = true, - }, - - // Other - .apple_sdk = .{ .path = "./pkg/apple-sdk" }, - .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz", - .hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-", - .lazy = true, - }, - }, -} diff --git a/build.zig.zon.json b/build.zig.zon.json index 5b557a493..4a88e2017 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z": { + "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - "hash": "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + "hash": "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", @@ -119,10 +119,10 @@ "url": "git+https://github.com/jacobsandlund/uucode#5f05f8f83a75caea201f12cc8ea32a2d82ea9732", "hash": "sha256-sHPh+TQSdUGus/QTbj7KSJJkTuNTrK4VNmQDjS30Lf8=" }, - "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E": { + "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9": { "name": "uucode", - "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", - "hash": "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4=" + "url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", + "hash": "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U=" }, "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS": { "name": "vaxis", @@ -146,7 +146,7 @@ }, "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ": { "name": "z2d", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", "hash": "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c=" }, "zf-0.10.3-OIRy8RuJAACKA3Lohoumrt85nRbHwbpMcUaLES8vxDnh": { diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 1cdecbb85..53e1b6c02 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z"; + name = "N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz"; - hash = "sha256-xN+3iQaN3uIJ/BzkgFxLojgHqeuz1htNcVjcjWR7Qjg="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz"; + hash = "sha256-FCALuGoMgUq2lgnVALKAs5a20uuDXt8Gdt5KeJwKqP0="; }; } { @@ -275,11 +275,11 @@ in }; } { - name = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E"; + name = "uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9"; path = fetchZigArtifact { name = "uucode"; - url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz"; - hash = "sha256-SzpYGhgG4B6Luf8eT35sKLobCxjmwEuo1Twk0jeu9g4="; + url = "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz"; + hash = "sha256-0KvuD0+L1urjwFF3fhbnxC2JZKqqAVWRxOVlcD9GX5U="; }; } { @@ -318,7 +318,7 @@ in name = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ"; path = fetchZigArtifact { name = "z2d"; - url = "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz"; + url = "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz"; hash = "sha256-afIdou/V7gk3/lXE0J5Ir8T7L5GgHvFnyMJ1rgRnl/c="; }; } diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 468208621..4ac9e6592 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz @@ -21,11 +21,12 @@ https://deps.files.ghostty.org/plasma_wayland_protocols-12207e0851c12acdeee0991e https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz https://deps.files.ghostty.org/utfcpp-1220d4d18426ca72fc2b7e56ce47273149815501d0d2395c2a98c726b31ba931e641.tar.gz -https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz +https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz https://deps.files.ghostty.org/vaxis-7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz https://deps.files.ghostty.org/zf-3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz https://deps.files.ghostty.org/zig_js-04db83c617da1956ac5adc1cb9ba1e434c1cb6fd.tar.gz https://deps.files.ghostty.org/zig_objc-f356ed02833f0f1b8e84d50bed9e807bf7cdc0ae.tar.gz @@ -33,4 +34,3 @@ https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz -https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index d5c96064d..e58ecd448 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260202-151632-49169e9.tgz", - "dest": "vendor/p/N-V-__8AAKlTAwClvdUfsAoiLgM6sb2NPZNnS6wzCiIs252Z", - "sha256": "c4dfb789068ddee209fc1ce4805c4ba23807a9ebb3d61b4d7158dc8d647b4238" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260216-151611-fc73ce3.tgz", + "dest": "vendor/p/N-V-__8AABVbAwBwDRyZONfx553tvMW8_A2OKUoLzPUSRiLF", + "sha256": "14200bb86a0c814ab69609d500b280b396b6d2eb835edf0676de4a789c0aa8fd" }, { "type": "archive", @@ -145,9 +145,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", - "dest": "vendor/p/uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", - "sha256": "4b3a581a1806e01e8bb9ff1e4f7e6c28ba1b0b18e6c04ba8d53c24d237aef60e" + "url": "https://deps.files.ghostty.org/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9.tar.gz", + "dest": "vendor/p/uucode-0.2.0-ZZjBPqZVVABQepOqZHR7vV_NcaN-wats0IB6o-Exj6m9", + "sha256": "d0abee0f4f8bd6eae3c051777e16e7c42d8964aaaa015591c4e565703f465f95" }, { "type": "archive", @@ -175,7 +175,7 @@ }, { "type": "archive", - "url": "https://github.com/vancluever/z2d/archive/refs/tags/v0.10.0.tar.gz", + "url": "https://deps.files.ghostty.org/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ.tar.gz", "dest": "vendor/p/z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ", "sha256": "69f21da2efd5ee0937fe55c4d09e48afc4fb2f91a01ef167c8c275ae046797f7" }, diff --git a/include/ghostty.h b/include/ghostty.h index b32cc9856..ae41429de 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -509,6 +509,15 @@ typedef struct { ghostty_quick_terminal_size_s secondary; } ghostty_config_quick_terminal_size_s; +// config.Fullscreen +typedef enum { + GHOSTTY_CONFIG_FULLSCREEN_FALSE, + GHOSTTY_CONFIG_FULLSCREEN_TRUE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, +} ghostty_config_fullscreen_e; + // apprt.Target.Key typedef enum { GHOSTTY_TARGET_APP, diff --git a/macos/.swiftlint.yml b/macos/.swiftlint.yml new file mode 100644 index 000000000..d2b371cc1 --- /dev/null +++ b/macos/.swiftlint.yml @@ -0,0 +1,36 @@ +# SwiftLint +# +check_for_updates: false + +excluded: + - build + +disabled_rules: + - cyclomatic_complexity + - file_length + - function_body_length + - line_length + - nesting + - no_fallthrough_only + - todo + - trailing_comma + - trailing_newline + - type_body_length + +identifier_name: + min_length: 1 + allowed_symbols: ["_"] + excluded: + - Core.* + +type_name: + min_length: 2 + allowed_symbols: ["_"] + excluded: + - iOS_.* + +function_parameter_count: + warning: 6 + +large_tuple: + warning: 3 diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ab6dde118..49d8132e8 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -355,6 +355,7 @@ isa = PBXNativeTarget; buildConfigurationList = A5B30540299BEAAB0047F10C /* Build configuration list for PBXNativeTarget "Ghostty" */; buildPhases = ( + FC501E0B2F46B410007AE49D /* Run SwiftLint */, A5B3052D299BEAAA0047F10C /* Sources */, A5B3052E299BEAAA0047F10C /* Frameworks */, A5B3052F299BEAAA0047F10C /* Resources */, @@ -490,6 +491,29 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + FC501E0B2F46B410007AE49D /* Run SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run SwiftLint"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "[[ -z \"$GITHUB_ACTIONS\" ]] || exit 0;\n\nSWIFTLINT=\"\"\nif command -v swiftlint >/dev/null 2>&1; then\n SWIFTLINT=\"$(command -v swiftlint)\"\nelif [[ -f \"/opt/homebrew/bin/swiftlint\" ]]; then\n SWIFTLINT=\"/opt/homebrew/bin/swiftlint\"\nfi\n\nif [[ -n \"$SWIFTLINT\" ]]; then\n \"$SWIFTLINT\" lint --quiet\nfi\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 810ACC9B2E9D3301004F8F92 /* Sources */ = { isa = PBXSourcesBuildPhase; diff --git a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift index 4d798a1a5..fc9a49067 100644 --- a/macos/Sources/App/macOS/AppDelegate+Ghostty.swift +++ b/macos/Sources/App/macOS/AppDelegate+Ghostty.swift @@ -10,14 +10,12 @@ extension AppDelegate: Ghostty.Delegate { guard let controller = window.windowController as? BaseTerminalController else { continue } - - for surface in controller.surfaceTree { - if surface.id == id { - return surface - } + + for surface in controller.surfaceTree where surface.id == id { + return surface } } - + return nil } } diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2ca7d4813..582af1746 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -9,8 +9,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, - GhosttyAppDelegate -{ + GhosttyAppDelegate { // The application logger. We should probably move this at some point to a dedicated // class/struct but for now it lives here! 🤷‍♂️ static let logger = Logger( @@ -110,7 +109,7 @@ class AppDelegate: NSObject, switch quickTerminalControllerState { case .initialized(let controller): return controller - + case .pendingRestore(let state): let controller = QuickTerminalController( ghostty, @@ -120,7 +119,7 @@ class AppDelegate: NSObject, ) quickTerminalControllerState = .initialized(controller) return controller - + case .uninitialized: let controller = QuickTerminalController( ghostty, @@ -144,16 +143,16 @@ class AppDelegate: NSObject, } /// Tracks the windows that we hid for toggleVisibility. - private(set) var hiddenState: ToggleVisibilityState? = nil + private(set) var hiddenState: ToggleVisibilityState? /// The observer for the app appearance. - private var appearanceObserver: NSKeyValueObservation? = nil + private var appearanceObserver: NSKeyValueObservation? /// Signals private var signals: [DispatchSourceSignal] = [] /// The custom app icon image that is currently in use. - @Published private(set) var appIcon: NSImage? = nil + @Published private(set) var appIcon: NSImage? override init() { #if DEBUG @@ -166,14 +165,14 @@ class AppDelegate: NSObject, ghostty.delegate = self } - //MARK: - NSApplicationDelegate + // MARK: - NSApplicationDelegate func applicationWillFinishLaunching(_ notification: Notification) { UserDefaults.standard.register(defaults: [ // Disable the automatic full screen menu item because we handle // it manually. "NSFullScreenMenuItemEverywhere": false, - + // On macOS 26 RC1, the autofill heuristic controller causes unusable levels // of slowdowns and CPU usage in the terminal window under certain [unknown] // conditions. We don't know exactly why/how. This disables the full heuristic @@ -197,7 +196,7 @@ class AppDelegate: NSObject, applicationLaunchTime = ProcessInfo.processInfo.systemUptime // Check if secure input was enabled when we last quit. - if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { + if UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled { toggleSecureInput(self) } @@ -280,7 +279,7 @@ class AppDelegate: NSObject, guard let appearance = change.newValue else { return } guard let app = self.ghostty.app else { return } let scheme: ghostty_color_scheme_e - if (appearance.isDark) { + if appearance.isDark { scheme = GHOSTTY_COLOR_SCHEME_DARK } else { scheme = GHOSTTY_COLOR_SCHEME_LIGHT @@ -299,12 +298,12 @@ class AppDelegate: NSObject, case .app: // Don't have to do anything. break - + case .zig_run, .cli: // Part of launch services (clicking an app, using `open`, etc.) activates // the application and brings it to the front. When using the CLI we don't // get this behavior, so we have to do it manually. - + // This never gets called until we click the dock icon. This forces it // activate immediately. applicationDidBecomeActive(.init(name: NSApplication.didBecomeActiveNotification)) @@ -332,7 +331,7 @@ class AppDelegate: NSObject, self.setDockBadge(nil) // First launch stuff - if (!applicationHasBecomeActive) { + if !applicationHasBecomeActive { applicationHasBecomeActive = true // Let's launch our first window. We only do this if we have no other windows. It @@ -353,8 +352,8 @@ class AppDelegate: NSObject, func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let windows = NSApplication.shared.windows - if (windows.isEmpty) { return .terminateNow } - + if windows.isEmpty { return .terminateNow } + // If we've already accepted to install an update, then we don't need to // confirm quit. The user is already expecting the update to happen. if updateController.isInstalling { @@ -380,14 +379,8 @@ class AppDelegate: NSObject, guard let keyword = AEKeyword("why?") else { break why } if let why = event.attributeDescriptor(forKeyword: keyword) { - switch (why.typeCodeValue) { - case kAEShutDown: - fallthrough - - case kAERestart: - fallthrough - - case kAEReallyLogOut: + switch why.typeCodeValue { + case kAEShutDown, kAERestart, kAEReallyLogOut: return .terminateNow default: @@ -397,7 +390,7 @@ class AppDelegate: NSObject, } // If our app says we don't need to confirm, we can exit now. - if (!ghostty.needsConfirmQuit) { return .terminateNow } + if !ghostty.needsConfirmQuit { return .terminateNow } // We have some visible window. Show an app-wide modal to confirm quitting. let alert = NSAlert() @@ -406,7 +399,7 @@ class AppDelegate: NSObject, alert.addButton(withTitle: "Close Ghostty") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - switch (alert.runModal()) { + switch alert.runModal() { case .alertFirstButtonReturn: return .terminateNow @@ -449,18 +442,18 @@ class AppDelegate: NSObject, // Ghostty will validate as well but we can avoid creating an entirely new // surface by doing our own validation here. We can also show a useful error // this way. - + var isDirectory = ObjCBool(true) guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false } - + // Set to true if confirmation is required before starting up the // new terminal. var requiresConfirm: Bool = false - + // Initialize the surface config which will be used to create the tab or window for the opened file. var config = Ghostty.SurfaceConfiguration() - - if (isDirectory.boolValue) { + + if isDirectory.boolValue { // When opening a directory, check the configuration to decide // whether to open in a new tab or new window. config.workingDirectory = filename @@ -471,24 +464,24 @@ class AppDelegate: NSObject, // because there is a sandbox escape possible if a sandboxed application // somehow is tricked into `open`-ing a non-sandboxed application. requiresConfirm = true - + // When opening a file, we want to execute the file. To do this, we // don't override the command directly, because it won't load the // profile/rc files for the shell, which is super important on macOS // due to things like Homebrew. Instead, we set the command to // `; exit` which is what Terminal and iTerm2 do. config.initialInput = "\(Ghostty.Shell.quote(filename)); exit\n" - + // For commands executed directly, we want to ensure we wait after exit // because in most cases scripts don't block on exit and we don't want // the window to just flash closed once complete. config.waitAfterCommand = true - + // Set the parent directory to our working directory so that relative // paths in scripts work. config.workingDirectory = (filename as NSString).deletingLastPathComponent } - + if requiresConfirm { // Confirmation required. We use an app-wide NSAlert for now. In the future we // may want to show this as a sheet on the focused window (especially if we're @@ -498,15 +491,15 @@ class AppDelegate: NSObject, alert.addButton(withTitle: "Allow") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning - switch (alert.runModal()) { + switch alert.runModal() { case .alertFirstButtonReturn: break - + default: return false } } - + switch ghostty.config.macosDockDropBehavior { case .new_tab: _ = TerminalController.newTab( @@ -516,7 +509,7 @@ class AppDelegate: NSObject, ) case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } - + return true } @@ -746,7 +739,7 @@ class AppDelegate: NSObject, guard let ghostty = self.ghostty.app else { return event } // Build our event input and call ghostty - if (ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS))) { + if ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) { // The key was used so we want to stop it from going to our Mac app Ghostty.logger.debug("local key event handled event=\(event)") return nil @@ -761,7 +754,7 @@ class AppDelegate: NSObject, @objc private func quickTerminalDidChangeVisibility(_ notification: Notification) { guard let quickController = notification.object as? QuickTerminalController else { return } - self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } + self.menuQuickTerminal?.state = if quickController.visible { .on } else { .off } } @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -777,11 +770,11 @@ class AppDelegate: NSObject, } @objc private func ghosttyBellDidRing(_ notification: Notification) { - if (ghostty.config.bellFeatures.contains(.system)) { + if ghostty.config.bellFeatures.contains(.system) { NSSound.beep() } - if (ghostty.config.bellFeatures.contains(.attention)) { + if ghostty.config.bellFeatures.contains(.attention) { // Bounce the dock icon if we're not focused. NSApp.requestUserAttention(.informationalRequest) @@ -861,7 +854,7 @@ class AppDelegate: NSObject, // Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows // configuration. This is the only way to carefully control whether macOS invokes the // state restoration system. - switch (config.windowSaveState) { + switch config.windowSaveState { case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") case "default": fallthrough @@ -880,14 +873,14 @@ class AppDelegate: NSObject, autoUpdate == .check || autoUpdate == .download updateController.updater.automaticallyDownloadsUpdates = autoUpdate == .download - /** + /* To test `auto-update` easily, uncomment the line below and delete `SUEnableAutomaticChecks` in Ghostty-Info.plist. Note: When `auto-update = download`, you may need to `Clean Build Folder` if a background install has already begun. */ - //updateController.updater.checkForUpdatesInBackground() + // updateController.updater.checkForUpdatesInBackground() } // Config could change keybindings, so update everything that depends on that @@ -900,7 +893,7 @@ class AppDelegate: NSObject, DispatchQueue.main.async { self.syncAppearance(config: config) } // Decide whether to hide/unhide app from dock and app switcher - switch (config.macosHidden) { + switch config.macosHidden { case .never: NSApp.setActivationPolicy(.regular) @@ -911,16 +904,16 @@ class AppDelegate: NSObject, // If we have configuration errors, we need to show them. let c = ConfigurationErrorsController.sharedInstance c.errors = config.errors - if (c.errors.count > 0) { - if (c.window == nil || !c.window!.isVisible) { + if c.errors.count > 0 { + if c.window == nil || !c.window!.isVisible { c.showWindow(self) } } // We need to handle our global event tap depending on if there are global // events that we care about in Ghostty. - if (ghostty_app_has_global_keybinds(ghostty.app!)) { - if (timeSinceLaunch > 5) { + if ghostty_app_has_global_keybinds(ghostty.app!) { + if timeSinceLaunch > 5 { // If the process has been running for awhile we enable right away // because no windows are likely to pop up. GlobalEventTap.shared.enable() @@ -948,11 +941,11 @@ class AppDelegate: NSObject, // Using AppIconActor to ensure this work // happens synchronously in the background @AppIconActor - private func updateAppIcon(from config: Ghostty.Config) async { + private func updateAppIcon(from config: Ghostty.Config) async { var appIcon: NSImage? var appIconName: String? = config.macosIcon.rawValue - switch (config.macosIcon) { + switch config.macosIcon { case let icon where icon.assetName != nil: appIcon = NSImage(named: icon.assetName!)! @@ -1022,7 +1015,7 @@ class AppDelegate: NSObject, UserDefaults.standard.set(currentBuild, forKey: "CustomGhosttyIconBuild") } - //MARK: - Restorable State + // MARK: - Restorable State /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -1031,18 +1024,18 @@ class AppDelegate: NSObject, func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) { Self.logger.debug("application will save window state") - + guard ghostty.config.windowSaveState != "never" else { return } - + // Encode our quick terminal state if we have it. switch quickTerminalControllerState { case .initialized(let controller) where controller.restorable: let data = QuickTerminalRestorableState(from: controller) data.encode(with: coder) - + case .pendingRestore(let state): state.encode(with: coder) - + default: break } @@ -1050,7 +1043,7 @@ class AppDelegate: NSObject, func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) { Self.logger.debug("application will restore window state") - + // Decode our quick terminal state. if ghostty.config.windowSaveState != "never", let state = QuickTerminalRestorableState(coder: coder) { @@ -1058,7 +1051,7 @@ class AppDelegate: NSObject, } } - //MARK: - UNUserNotificationCenterDelegate + // MARK: - UNUserNotificationCenterDelegate func userNotificationCenter( _ center: UNUserNotificationCenter, @@ -1079,21 +1072,19 @@ class AppDelegate: NSObject, withCompletionHandler(options) } - //MARK: - GhosttyAppDelegate + // MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in TerminalController.all { - for view in c.surfaceTree { - if view.id == uuid { - return view - } + for view in c.surfaceTree where view.id == uuid { + return view } } return nil } - //MARK: - Dock Menu + // MARK: - Dock Menu private func reloadDockMenu() { let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") @@ -1104,11 +1095,11 @@ class AppDelegate: NSObject, dockMenu.addItem(newTab) } - //MARK: - Global State + // MARK: - Global State func setSecureInput(_ mode: Ghostty.SetSecureInput) { let input = SecureInput.shared - switch (mode) { + switch mode { case .on: input.global = true @@ -1118,11 +1109,11 @@ class AppDelegate: NSObject, case .toggle: input.global.toggle() } - self.menuSecureInput?.state = if (input.global) { .on } else { .off } + self.menuSecureInput?.state = if input.global { .on } else { .off } UserDefaults.standard.set(input.global, forKey: "SecureInput") } - //MARK: - IB Actions + // MARK: - IB Actions @IBAction func openConfig(_ sender: Any?) { Ghostty.App.openConfig() @@ -1134,7 +1125,7 @@ class AppDelegate: NSObject, @IBAction func checkForUpdates(_ sender: Any?) { updateController.checkForUpdates() - //UpdateSimulator.happyPath.simulate(with: updateViewModel) + // UpdateSimulator.happyPath.simulate(with: updateViewModel) } @IBAction func newWindow(_ sender: Any?) { @@ -1288,7 +1279,7 @@ extension AppDelegate { @IBAction func useAsDefault(_ sender: NSMenuItem) { let ud = UserDefaults.standard let key = TerminalWindow.defaultLevelKey - if (menuFloatOnTop?.state == .on) { + if menuFloatOnTop?.state == .on { ud.set(NSWindow.Level.floating, forKey: key) } else { ud.removeObject(forKey: key) @@ -1360,6 +1351,6 @@ private enum QuickTerminalState { } @globalActor -fileprivate actor AppIconActor: GlobalActor { +private actor AppIconActor: GlobalActor { static let shared = AppIconActor() } diff --git a/macos/Sources/App/macOS/main.swift b/macos/Sources/App/macOS/main.swift index ad32f4e70..ade9bf3f0 100644 --- a/macos/Sources/App/macOS/main.swift +++ b/macos/Sources/App/macOS/main.swift @@ -7,7 +7,7 @@ import GhosttyKit // rest of the app. if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS { Ghostty.logger.critical("ghostty_init failed") - + // We also write to stderr if this is executed from the CLI or zig run switch Ghostty.launchSource { case .cli, .zig_run: @@ -18,7 +18,7 @@ if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCE "Actions start with the `+` character.\n\n" + "View all available actions by running `ghostty +help`.\n") exit(1) - + case .app: // For the app we exit immediately. We should handle this case more // gracefully in the future. @@ -28,6 +28,6 @@ if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCE // This will run the CLI action and exit if one was specified. A CLI // action is a command starting with a `+`, such as `ghostty +boo`. -ghostty_cli_try_action(); +ghostty_cli_try_action() _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index efd7a515a..2f494f12c 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -24,7 +24,7 @@ class AboutController: NSWindowController, NSWindowDelegate { window?.close() } - //MARK: - First Responder + // MARK: - First Responder @IBAction func close(_ sender: Any) { self.window?.performClose(sender) diff --git a/macos/Sources/Features/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index 967eb16b0..d9a12e4dc 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -21,8 +21,7 @@ struct AboutView: View { init(material: NSVisualEffectView.Material, blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - isEmphasized: Bool = false) - { + isEmphasized: Bool = false) { self.material = material self.blendingMode = blendingMode self.isEmphasized = isEmphasized diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift index 0155cf855..c3cca2514 100644 --- a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -22,7 +22,7 @@ struct CloseTerminalIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surfaceView = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift index 2f07d7861..de6063564 100644 --- a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift +++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift @@ -29,7 +29,7 @@ struct CommandPaletteIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift index 3c7745e7c..f05b5d9b9 100644 --- a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift @@ -25,11 +25,6 @@ struct CommandEntity: AppEntity { struct ID: Hashable { let terminalId: TerminalEntity.ID let actionKey: String - - init(terminalId: TerminalEntity.ID, actionKey: String) { - self.terminalId = terminalId - self.actionKey = actionKey - } } static var typeDisplayRepresentation: TypeDisplayRepresentation { @@ -79,7 +74,7 @@ extension CommandEntity.ID: EntityIdentifierConvertible { static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? { .init(rawValue: entityIdentifierString) } - + var entityIdentifierString: String { rawValue } diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index e805466a2..a2c4abea0 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -52,7 +52,7 @@ struct TerminalEntity: AppEntity { if let nsImage = ImageRenderer(content: view.screenshot()).nsImage { self.screenshot = nsImage } - + // Determine the kind based on the window controller type if view.window?.windowController is QuickTerminalController { self.kind = .quick @@ -66,9 +66,9 @@ extension TerminalEntity { enum Kind: String, AppEnum { case normal case quick - + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind") - + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ .normal: .init(title: "Normal"), .quick: .init(title: "Quick") @@ -112,7 +112,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } - + // Get all our surfaces return controllers.flatMap { $0.surfaceTree.root?.leaves() ?? [] diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift index 563e3719b..99d6e39ba 100644 --- a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -31,7 +31,7 @@ struct GetTerminalDetailsIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + switch detail { case .title: return .result(value: terminal.title) case .workingDirectory: return .result(value: terminal.workingDirectory) diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index d169b3a8c..b77945ccc 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -34,7 +34,7 @@ struct InputTextIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -86,7 +86,7 @@ struct KeyEventIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -95,7 +95,7 @@ struct KeyEventIntent: AppIntent { let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in result.union(mod.ghosttyMod) } - + let keyEvent = Ghostty.Input.KeyEvent( key: key, action: action, @@ -150,7 +150,7 @@ struct MouseButtonIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -159,7 +159,7 @@ struct MouseButtonIntent: AppIntent { let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in result.union(mod.ghosttyMod) } - + let mouseEvent = Ghostty.Input.MouseButtonEvent( action: action, button: button, @@ -184,7 +184,7 @@ struct MousePosIntent: AppIntent { var x: Double @Parameter( - title: "Y Position", + title: "Y Position", description: "The vertical position of the mouse cursor in pixels.", default: 0 ) @@ -213,7 +213,7 @@ struct MousePosIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -222,7 +222,7 @@ struct MousePosIntent: AppIntent { let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in result.union(mod.ghosttyMod) } - + let mousePosEvent = Ghostty.Input.MousePosEvent( x: x, y: y, @@ -283,7 +283,7 @@ struct MouseScrollIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -306,16 +306,16 @@ enum KeyEventMods: String, AppEnum, CaseIterable { case control case option case command - + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key") - - static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [ + + static var caseDisplayRepresentations: [KeyEventMods: DisplayRepresentation] = [ .shift: "Shift", .control: "Control", .option: "Option", .command: "Command" ] - + var ghosttyMod: Ghostty.Input.Mods { switch self { case .shift: .shift diff --git a/macos/Sources/Features/App Intents/IntentPermission.swift b/macos/Sources/Features/App Intents/IntentPermission.swift index 210d2cb2e..26a21e70b 100644 --- a/macos/Sources/Features/App Intents/IntentPermission.swift +++ b/macos/Sources/Features/App Intents/IntentPermission.swift @@ -28,7 +28,7 @@ func requestIntentPermission() async -> Bool { await withCheckedContinuation { continuation in Task { @MainActor in if let delegate = NSApp.delegate as? AppDelegate { - switch (delegate.ghostty.config.macosShortcuts) { + switch delegate.ghostty.config.macosShortcuts { case .allow: continuation.resume(returning: true) return @@ -43,7 +43,6 @@ func requestIntentPermission() async -> Bool { } } - PermissionRequest.show( "com.mitchellh.ghostty.shortcutsPermission", message: "Allow Shortcuts to interact with Ghostty?", diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift index a8cea8561..e4f41ebbd 100644 --- a/macos/Sources/Features/App Intents/KeybindIntent.swift +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -26,7 +26,7 @@ struct KeybindIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 6de9e1e7e..858d5ceb0 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -152,7 +152,7 @@ enum NewTerminalLocation: String { case splitRight = "split:right" case splitUp = "split:up" case splitDown = "split:down" - + var splitDirection: SplitTree.NewDirection? { switch self { case .splitLeft: return .left diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift index 2048a3b88..df0fe17a5 100644 --- a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift @@ -15,7 +15,7 @@ struct QuickTerminalIntent: AppIntent { guard await requestIntentPermission() else { throw GhosttyIntentError.permissionDenied } - + guard let delegate = NSApp.delegate as? AppDelegate else { throw GhosttyIntentError.appUnavailable } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 2040dcfae..37b20afb0 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -13,7 +13,7 @@ class ClipboardConfirmationController: NSWindowController { let contents: String let request: Ghostty.ClipboardRequest let state: UnsafeMutableRawPointer? - weak private var delegate: ClipboardConfirmationViewDelegate? = nil + weak private var delegate: ClipboardConfirmationViewDelegate? init(surface: ghostty_surface_t, contents: String, request: Ghostty.ClipboardRequest, state: UnsafeMutableRawPointer?, delegate: ClipboardConfirmationViewDelegate) { self.surface = surface @@ -28,12 +28,12 @@ class ClipboardConfirmationController: NSWindowController { fatalError("init(coder:) is not supported for this view") } - //MARK: - NSWindowController + // MARK: - NSWindowController override func windowDidLoad() { guard let window = window else { return } - switch (request) { + switch request { case .paste: window.title = "Warning: Potentially Unsafe Paste" case .osc_52_read, .osc_52_write: diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 6423e3cf6..17ab4aa24 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -7,7 +7,7 @@ protocol ClipboardConfirmationViewDelegate: AnyObject { /// The SwiftUI view for showing a clipboard confirmation dialog. struct ClipboardConfirmationView: View { - enum Action : String { + enum Action: String { case cancel case confirm @@ -32,7 +32,7 @@ struct ClipboardConfirmationView: View { let request: Ghostty.ClipboardRequest /// Optional delegate to get results. If this is nil, then this view will never close on its own. - weak var delegate: ClipboardConfirmationViewDelegate? = nil + weak var delegate: ClipboardConfirmationViewDelegate? /// Used to track if we should rehide on disappear @State private var cursorHiddenCount: UInt = 0 @@ -45,16 +45,16 @@ struct ClipboardConfirmationView: View { .font(.system(size: 42)) .padding() .frame(alignment: .center) - + Text(request.text()) .frame(maxWidth: .infinity, alignment: .leading) .padding() } - + TextEditor(text: .constant(contents)) .focusable(false) .font(.system(.body, design: .monospaced)) - + HStack { Spacer() Button(Action.text(.cancel, request)) { onCancel() } @@ -74,7 +74,7 @@ struct ClipboardConfirmationView: View { // If we didn't unhide anything, we just send an unhide to be safe. // I don't think the count can go negative on NSCursor so this handles // scenarios cursor is hidden outside of our own NSCursor usage. - if (cursorHiddenCount == 0) { + if cursorHiddenCount == 0 { _ = Cursor.unhide() } } diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift index 58de8f771..e58699cff 100644 --- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift +++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIcon.swift @@ -19,7 +19,7 @@ struct ColorizedGhosttyIcon { guard let crt = NSImage(named: "CustomIconCRT") else { return nil } guard let gloss = NSImage(named: "CustomIconGloss") else { return nil } - let baseName = switch (frame) { + let baseName = switch frame { case .aluminum: "CustomIconBaseAluminum" case .beige: "CustomIconBaseBeige" case .chrome: "CustomIconBaseChrome" diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 235881dde..10c56f8dd 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -23,7 +23,7 @@ struct CommandOption: Identifiable, Hashable { let sortKey: AnySortKey? /// The action to perform when this option is selected. let action: () -> Void - + init( title: String, subtitle: String? = nil, @@ -78,7 +78,7 @@ struct CommandPaletteView: View { ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) || colorMatchScore(for: $0.leadingColor, query: query) > 0 } - + // Sort by color match score (higher scores first), then maintain original order return filtered.sorted { a, b in let scoreA = colorMatchScore(for: a.leadingColor, query: query) @@ -106,7 +106,7 @@ struct CommandPaletteView: View { VStack(alignment: .leading, spacing: 0) { CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in - switch (event) { + switch event { case .exit: isPresented = false @@ -128,7 +128,7 @@ struct CommandPaletteView: View { ? 0 : current + 1 - case .move(_): + case .move: // Unknown, ignore break } @@ -200,20 +200,20 @@ struct CommandPaletteView: View { isTextFieldFocused = isPresented } } - + /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. /// Returns 0 if no color name in the query matches, or if the color is nil. private func colorMatchScore(for color: Color?, query: String) -> Double { guard let color = color else { return 0 } - + let queryLower = query.lowercased() let nsColor = NSColor(color) - + var bestScore: Double = 0 for name in NSColor.colorNames { guard queryLower.contains(name), let systemColor = NSColor(named: name) else { continue } - + let distance = nsColor.distance(to: systemColor) // Max distance in weighted RGB space is ~3.0, so normalize and invert // Use a threshold to determine "close enough" matches @@ -223,15 +223,15 @@ struct CommandPaletteView: View { bestScore = max(bestScore, score) } } - + return bestScore } } /// The text field for building the query for the command palette. -fileprivate struct CommandPaletteQuery: View { +private struct CommandPaletteQuery: View { @Binding var query: String - var onEvent: ((KeyboardEvent) -> Void)? = nil + var onEvent: ((KeyboardEvent) -> Void)? @FocusState private var isTextFieldFocused: Bool init(query: Binding, isTextFieldFocused: FocusState, onEvent: ((KeyboardEvent) -> Void)? = nil) { @@ -284,7 +284,7 @@ fileprivate struct CommandPaletteQuery: View { } } -fileprivate struct CommandTable: View { +private struct CommandTable: View { var options: [CommandOption] @Binding var selectedIndex: UInt? @Binding var hoveredOptionID: UUID? @@ -332,7 +332,7 @@ fileprivate struct CommandTable: View { } /// A single row in the command palette. -fileprivate struct CommandRow: View { +private struct CommandRow: View { let option: CommandOption var isSelected: Bool @Binding var hoveredID: UUID? @@ -346,26 +346,26 @@ fileprivate struct CommandRow: View { .fill(color) .frame(width: 8, height: 8) } - + if let icon = option.leadingIcon { Image(systemName: icon) .foregroundStyle(option.emphasis ? Color.accentColor : .secondary) .font(.system(size: 14, weight: .medium)) } - + VStack(alignment: .leading, spacing: 2) { Text(option.title) .fontWeight(option.emphasis ? .medium : .regular) - + if let subtitle = option.subtitle { Text(subtitle) .font(.caption) .foregroundStyle(.secondary) } } - + Spacer() - + if let badge = option.badge, !badge.isEmpty { Text(badge) .font(.caption2.weight(.medium)) @@ -376,7 +376,7 @@ fileprivate struct CommandRow: View { ) .foregroundStyle(Color.accentColor) } - + if let symbols = option.symbols { ShortcutSymbolsView(symbols: symbols) .foregroundStyle(.secondary) @@ -406,7 +406,7 @@ fileprivate struct CommandRow: View { } /// A row of Text representing a shortcut. -fileprivate struct ShortcutSymbolsView: View { +private struct ShortcutSymbolsView: View { let symbols: [String] var body: some View { diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 9bdf4b4ff..70d1273a2 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -11,7 +11,7 @@ struct TerminalCommandPaletteView: View { /// The configuration so we can lookup keyboard shortcuts. @ObservedObject var ghosttyConfig: Ghostty.Config - + /// The update view model for showing update commands. var updateViewModel: UpdateViewModel? @@ -54,13 +54,13 @@ struct TerminalCommandPaletteView: View { } } } - + /// All commands available in the command palette, combining update and terminal options. private var commandOptions: [CommandOption] { var options: [CommandOption] = [] // Updates always appear first options.append(contentsOf: updateOptions) - + // Sort the rest. We replace ":" with a character that sorts before space // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker // for stable ordering when titles are equal. @@ -83,11 +83,11 @@ struct TerminalCommandPaletteView: View { /// Commands for installing or canceling available updates. private var updateOptions: [CommandOption] { var options: [CommandOption] = [] - + guard let updateViewModel, updateViewModel.state.isInstallable else { return options } - + // We override the update available one only because we want to properly // convey it'll go all the way through. let title: String @@ -96,7 +96,7 @@ struct TerminalCommandPaletteView: View { } else { title = updateViewModel.text } - + options.append(CommandOption( title: title, description: updateViewModel.description, @@ -106,14 +106,14 @@ struct TerminalCommandPaletteView: View { ) { (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() }) - + options.append(CommandOption( title: "Cancel or Skip Update", description: "Dismiss the current update process" ) { updateViewModel.state.cancel() }) - + return options } @@ -171,7 +171,7 @@ struct TerminalCommandPaletteView: View { } /// This is done to ensure that the given view is in the responder chain. -fileprivate struct ResponderChainInjector: NSViewRepresentable { +private struct ResponderChainInjector: NSViewRepresentable { let responder: NSResponder func makeNSView(context: Context) -> NSView { diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index ae77535be..9d4023c2e 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -16,11 +16,11 @@ class GlobalEventTap { // The event tap used for global event listening. This is non-nil if it is // created. - private var eventTap: CFMachPort? = nil + private var eventTap: CFMachPort? // This is the timer used to retry enabling the global event tap if we // don't have permissions. - private var enableTimer: Timer? = nil + private var enableTimer: Timer? // Private init so it can't be constructed outside of our singleton private init() {} @@ -33,7 +33,7 @@ class GlobalEventTap { // If enabling fails due to permissions, this will start a timer to retry since // accessibility permissions take affect immediately. func enable() { - if (eventTap != nil) { + if eventTap != nil { // Already enabled return } @@ -44,7 +44,7 @@ class GlobalEventTap { } // Try to enable the event tap immediately. If this succeeds then we're done! - if (tryEnable()) { + if tryEnable() { return } @@ -117,7 +117,7 @@ class GlobalEventTap { } } -fileprivate func cgEventFlagsChangedHandler( +private func cgEventFlagsChangedHandler( proxy: CGEventTapProxy, type: CGEventType, cgEvent: CGEvent, @@ -142,7 +142,7 @@ fileprivate func cgEventFlagsChangedHandler( // Build our event input and call ghostty let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) - if (ghostty_app_key(ghostty, key_ev)) { + if ghostty_app_key(ghostty, key_ev) { GlobalEventTap.logger.info("global key event handled event=\(event)") return nil } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 07c0c4c19..de1ea903d 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -16,20 +16,20 @@ class QuickTerminalController: BaseTerminalController { /// The previously running application when the terminal is shown. This is NEVER Ghostty. /// If this is set then when the quick terminal is animated out then we will restore this /// application to the front. - private var previousApp: NSRunningApplication? = nil + private var previousApp: NSRunningApplication? // The active space when the quick terminal was last shown. - private var previousActiveSpace: CGSSpace? = nil + private var previousActiveSpace: CGSSpace? /// Cache for per-screen window state. let screenStateCache: QuickTerminalScreenStateCache /// Non-nil if we have hidden dock state. - private var hiddenDock: HiddenDock? = nil + private var hiddenDock: HiddenDock? /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig - + /// Tracks if we're currently handling a manual resize to prevent recursion private var isHandlingResize: Bool = false @@ -135,14 +135,14 @@ class QuickTerminalController: BaseTerminalController { if let qtWindow = window as? QuickTerminalWindow { qtWindow.initialFrame = window.frame } - + // Setup our content window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self ) - + // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { qtWindow.initialFrame = nil @@ -234,7 +234,7 @@ class QuickTerminalController: BaseTerminalController { // Prevent recursive loops isHandlingResize = true defer { isHandlingResize = false } - + switch position { case .top, .bottom, .center: // For centered positions (top, bottom, center), we need to recenter the window @@ -316,7 +316,7 @@ class QuickTerminalController: BaseTerminalController { // MARK: Methods func toggle() { - if (visible) { + if visible { animateOut() } else { animateIn() @@ -340,8 +340,7 @@ class QuickTerminalController: BaseTerminalController { // we want to store it so we can restore state later. if !NSApp.isActive { if let previousApp = NSWorkspace.shared.frontmostApplication, - previousApp.bundleIdentifier != Bundle.main.bundleIdentifier - { + previousApp.bundleIdentifier != Bundle.main.bundleIdentifier { self.previousApp = previousApp } } @@ -370,7 +369,7 @@ class QuickTerminalController: BaseTerminalController { } else { var config = Ghostty.SurfaceConfiguration() config.environmentVariables["GHOSTTY_QUICK_TERMINAL"] = "1" - + let view = Ghostty.SurfaceView(ghostty_app, baseConfig: config) surfaceTree = SplitTree(view: view) focusedSurface = view @@ -417,7 +416,7 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } - + // Grab our last closed frame to use from the cache. let closedFrame = screenStateCache.frame(for: screen) @@ -441,7 +440,7 @@ class QuickTerminalController: BaseTerminalController { // If our dock position would conflict with our target location then // we autohide the dock. if position.conflictsWithDock(on: screen) { - if (hiddenDock == nil) { + if hiddenDock == nil { hiddenDock = .init() } @@ -675,10 +674,10 @@ class QuickTerminalController: BaseTerminalController { // We ignore the configured fullscreen style and always use non-native // because the way the quick terminal works doesn't support native. let mode: FullscreenMode - if (NSApp.isFrontmost) { + if NSApp.isFrontmost { // If we're frontmost and we have a notch then we keep padding // so all lines of the terminal are visible. - if (window?.screen?.hasNotch ?? false) { + if window?.screen?.hasNotch ?? false { mode = .nonNativePaddedNotch } else { mode = .nonNative diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index d7660f77a..8742a7836 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -1,6 +1,6 @@ import Cocoa -enum QuickTerminalPosition : String { +enum QuickTerminalPosition: String { case top case bottom case left @@ -64,7 +64,7 @@ enum QuickTerminalPosition : String { /// The initial point origin for this position. func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { - switch (self) { + switch self { case .top: return .init( x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), @@ -86,13 +86,13 @@ enum QuickTerminalPosition : String { y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2)) case .center: - return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: screen.visibleFrame.height - window.frame.width) + return .init(x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: screen.visibleFrame.height - window.frame.width) } } /// The final point origin for this position. func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { - switch (self) { + switch self { case .top: return .init( x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), @@ -128,7 +128,7 @@ enum QuickTerminalPosition : String { // Depending on the orientation of the dock, we conflict if our quick terminal // would potentially "hit" the dock. In the future we should probably consider // the frame of the quick terminal. - return switch (orientation) { + return switch orientation { case .top: self == .top || self == .left || self == .right case .bottom: self == .bottom || self == .left || self == .right case .left: self == .top || self == .bottom @@ -144,25 +144,25 @@ enum QuickTerminalPosition : String { x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: window.frame.origin.y // Keep the same Y position ) - + case .bottom: return CGPoint( x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: window.frame.origin.y // Keep the same Y position ) - + case .center: return CGPoint( x: round(screen.visibleFrame.origin.x + (screen.visibleFrame.width - window.frame.width) / 2), y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) ) - + case .left, .right: // For left/right positions, only adjust horizontal centering if needed return window.frame.origin } } - + /// Calculate the vertically centered origin for side-positioned windows func verticallyCenteredOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch self { @@ -171,13 +171,13 @@ enum QuickTerminalPosition : String { x: window.frame.origin.x, // Keep the same X position y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) ) - + case .right: return CGPoint( x: window.frame.origin.x, // Keep the same X position y: round(screen.visibleFrame.origin.y + (screen.visibleFrame.height - window.frame.height) / 2) ) - + case .top, .bottom, .center: // These positions don't need vertical recentering during resize return window.frame.origin diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift index cd07a6f12..70af0a505 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift @@ -6,23 +6,23 @@ enum QuickTerminalScreen { case menuBar init?(fromGhosttyConfig string: String) { - switch (string) { + switch string { case "main": self = .main case "mouse": self = .mouse - + case "macos-menu-bar": self = .menuBar - + default: return nil } } var screen: NSScreen? { - switch (self) { + switch self { case .main: return NSScreen.main diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift index a1c17abb9..301865561 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreenStateCache.swift @@ -8,15 +8,15 @@ import Cocoa /// to survive NSScreen garbage collection and automatically prunes stale entries. class QuickTerminalScreenStateCache { typealias Entries = [UUID: DisplayEntry] - + /// The maximum number of saved screen states we retain. This is to avoid some kind of /// pathological memory growth in case we get our screen state serializing wrong. I don't /// know anyone with more than 10 screens, so let's just arbitrarily go with that. private static let maxSavedScreens = 10 - + /// Time-to-live for screen entries that are no longer present (14 days). private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60 - + /// Keyed by display UUID to survive NSScreen garbage collection. private(set) var stateByDisplay: Entries = [:] @@ -28,11 +28,11 @@ class QuickTerminalScreenStateCache { name: NSApplication.didChangeScreenParametersNotification, object: nil) } - + deinit { NotificationCenter.default.removeObserver(self) } - + /// Save the window frame for a screen. func save(frame: NSRect, for screen: NSScreen) { guard let key = screen.displayUUID else { return } @@ -45,27 +45,27 @@ class QuickTerminalScreenStateCache { stateByDisplay[key] = entry pruneCapacity() } - + /// Retrieve the last closed frame for a screen, if valid. func frame(for screen: NSScreen) -> NSRect? { guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil } - + // Drop on dimension/scale change that makes the entry invalid if !entry.isValid(for: screen) { stateByDisplay.removeValue(forKey: key) return nil } - + entry.lastSeen = Date() stateByDisplay[key] = entry return entry.frame } - + @objc private func onScreensChanged(_ note: Notification) { let screens = NSScreen.screens let now = Date() let currentIDs = Set(screens.compactMap { $0.displayUUID }) - + for screen in screens { guard let key = screen.displayUUID else { continue } if var entry = stateByDisplay[key] { @@ -80,15 +80,15 @@ class QuickTerminalScreenStateCache { } } } - + // TTL prune for non-present screens stateByDisplay = stateByDisplay.filter { key, entry in currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL } - + pruneCapacity() } - + private func pruneCapacity() { guard stateByDisplay.count > Self.maxSavedScreens else { return } let toRemove = stateByDisplay @@ -98,13 +98,13 @@ class QuickTerminalScreenStateCache { stateByDisplay.removeValue(forKey: key) } } - + struct DisplayEntry: Codable { var frame: NSRect var screenSize: CGSize var scale: CGFloat var lastSeen: Date - + /// Returns true if this entry is still valid for the given screen. /// Valid if the scale matches and the cached size is not larger than the current screen size. /// This allows entries to persist when screens grow, but invalidates them when screens shrink. diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift index 08bbcb8d9..2cd11e42e 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSize.swift @@ -48,7 +48,6 @@ struct QuickTerminalSize { } } - /// This is an almost direct port of th Zig function QuickTerminalSize.calculate func calculate(position: QuickTerminalPosition, screenDimensions: CGSize) -> CGSize { let dims = CGSize(width: screenDimensions.width, height: screenDimensions.height) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift index 0561aaa18..176cbf160 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalSpaceBehavior.swift @@ -6,15 +6,15 @@ enum QuickTerminalSpaceBehavior { case move init?(fromGhosttyConfig string: String) { - switch (string) { - case "move": - self = .move + switch string { + case "move": + self = .move - case "remain": - self = .remain + case "remain": + self = .remain - default: - return nil + default: + return nil } } @@ -24,13 +24,13 @@ enum QuickTerminalSpaceBehavior { .fullScreenAuxiliary ] - switch (self) { - case .move: - // We want this to move the window to the active space. - return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) - case .remain: - // We want this to remain the window in the current space. - return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) + switch self { + case .move: + // We want this to move the window to the active space. + return NSWindow.CollectionBehavior([.canJoinAllSpaces] + commonBehavior) + case .remain: + // We want this to remain the window in the current space. + return NSWindow.CollectionBehavior([.moveToActiveSpace] + commonBehavior) } } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index 1a4170dbc..507ec1baf 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -5,18 +5,18 @@ class QuickTerminalWindow: NSPanel { // still become key/main and receive events. override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } - + override func awakeFromNib() { super.awakeFromNib() // Note: almost all of this stuff can be done in the nib/xib directly // but I prefer to do it programmatically because the properties we // care about are less hidden. - + // Add a custom identifier so third party apps can use the Accessibility // API to apply special rules to the quick terminal. self.identifier = .init(rawValue: "com.mitchellh.ghostty.quickTerminal") - + // Set the correct AXSubrole of kAXFloatingWindowSubrole (allows // AeroSpace to treat the Quick Terminal as a floating window) self.setAccessibilitySubrole(.floatingWindow) @@ -32,8 +32,8 @@ class QuickTerminalWindow: NSPanel { /// This is set to the frame prior to setting `contentView`. This is purely a hack to workaround /// bugs in older macOS versions (Ventura): https://github.com/ghostty-org/ghostty/pull/8026 - var initialFrame: NSRect? = nil - + var initialFrame: NSRect? + override func setFrame(_ frameRect: NSRect, display flag: Bool) { // Upon first adding this Window to its host view, older SwiftUI // seems to have a "hiccup" and corrupts the frameRect, diff --git a/macos/Sources/Features/Secure Input/SecureInput.swift b/macos/Sources/Features/Secure Input/SecureInput.swift index f999ce5ca..261a38e5c 100644 --- a/macos/Sources/Features/Secure Input/SecureInput.swift +++ b/macos/Sources/Features/Secure Input/SecureInput.swift @@ -12,7 +12,7 @@ import OSLog // it. You have to yield secure input on application deactivation (because // it'll affect other apps) and reacquire on reactivation, and every enable // needs to be balanced with a disable. -class SecureInput : ObservableObject { +class SecureInput: ObservableObject { static let shared = SecureInput() private static let logger = Logger( @@ -90,12 +90,12 @@ class SecureInput : ObservableObject { guard enabled != desired else { return } let err: OSStatus - if (enabled) { + if enabled { err = DisableSecureEventInput() } else { err = EnableSecureEventInput() } - if (err == noErr) { + if err == noErr { enabled = desired Self.logger.debug("secure input state=\(self.enabled)") return @@ -111,7 +111,7 @@ class SecureInput : ObservableObject { // desire to be enabled. guard !enabled && desired else { return } let err = EnableSecureEventInput() - if (err == noErr) { + if err == noErr { enabled = true Self.logger.debug("secure input enabled on activation") return @@ -124,7 +124,7 @@ class SecureInput : ObservableObject { // We only want to disable if we're enabled. guard enabled else { return } let err = DisableSecureEventInput() - if (err == noErr) { + if err == noErr { enabled = false Self.logger.debug("secure input disabled on deactivation") return diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift index 96f309de5..ebf5b5138 100644 --- a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -2,8 +2,8 @@ import SwiftUI struct SecureInputOverlay: View { // Animations - @State private var shadowAngle: Angle = .degrees(0) - @State private var shadowWidth: CGFloat = 6 + @State private var gradientAngle: Angle = .degrees(0) + @State private var gradientOpacity: CGFloat = 0.5 // Popover explainer text @State private var isPopover = false @@ -20,18 +20,32 @@ struct SecureInputOverlay: View { .foregroundColor(.primary) .padding(5) .background( - RoundedRectangle(cornerRadius: 12) + Rectangle() .fill(.background) - .innerShadow( - using: RoundedRectangle(cornerRadius: 12), - stroke: AngularGradient( - gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]), - center: .center, - angle: shadowAngle - ), - width: shadowWidth + .overlay( + Rectangle() + .fill( + AngularGradient( + gradient: Gradient( + colors: [.cyan, .blue, .yellow, .blue, .cyan] + ), + center: .center, + angle: gradientAngle + ) + ) + .blur(radius: 4, opaque: true) + .mask( + RadialGradient( + colors: [.clear, .black], + center: .center, + startRadius: 0, + endRadius: 25 + ) + ) + .opacity(gradientOpacity) ) ) + .mask(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.gray, lineWidth: 1) @@ -44,9 +58,9 @@ struct SecureInputOverlay: View { .padding(.trailing, 10) .popover(isPresented: $isPopover, arrowEdge: .bottom) { Text(""" - Secure Input is active. Secure Input is a macOS security feature that - prevents applications from reading keyboard events. This is enabled - automatically whenever Ghostty detects a password prompt in the terminal, + Secure Input is active. Secure Input is a macOS security feature that + prevents applications from reading keyboard events. This is enabled + automatically whenever Ghostty detects a password prompt in the terminal, or at all times if `Ghostty > Secure Keyboard Entry` is active. """) .padding(.all) @@ -57,11 +71,11 @@ struct SecureInputOverlay: View { } .onAppear { withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) { - shadowAngle = .degrees(360) + gradientAngle = .degrees(360) } withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) { - shadowWidth = 12 + gradientOpacity = 1 } } } diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index f165769a7..9bf46fcf9 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -50,7 +50,7 @@ class ServiceProvider: NSObject { var config = Ghostty.SurfaceConfiguration() config.workingDirectory = url.path(percentEncoded: false) - switch (target) { + switch target { case .window: _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift index b2550b94e..06fcebda3 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -12,13 +12,13 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi /// The data model for this view. Update this directly and the associated view will be updated, too. @Published var errors: [String] = [] { didSet { - if (errors.count == 0) { + if errors.count == 0 { self.window?.performClose(nil) } } } - //MARK: - NSWindowController + // MARK: - NSWindowController override func windowWillLoad() { shouldCascadeWindows = false diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 2fb83e64c..86932b1bb 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -222,7 +222,7 @@ extension SplitTree { case .split: // If the best candidate is a split node, use its the leaf/rightmost // depending on our spatial direction. - return switch (spatialDirection) { + return switch spatialDirection { case .up, .left: bestNode.node.leftmostLeaf() case .down, .right: bestNode.node.rightmostLeaf() } @@ -343,7 +343,7 @@ extension SplitTree { // MARK: SplitTree Codable -fileprivate enum CodingKeys: String, CodingKey { +private enum CodingKeys: String, CodingKey { case version case root case zoomed @@ -422,7 +422,7 @@ extension SplitTree.Node { /// Returns the node in the tree that contains the given view. func node(view: ViewType) -> Node? { - switch (self) { + switch self { case .leaf(view): return self @@ -728,7 +728,6 @@ extension SplitTree.Node { } } - /// Calculate the bounds of all views in this subtree based on split ratios func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { switch self { diff --git a/macos/Sources/Features/Splits/SplitView.Divider.swift b/macos/Sources/Features/Splits/SplitView.Divider.swift index a01175dce..59a10ef60 100644 --- a/macos/Sources/Features/Splits/SplitView.Divider.swift +++ b/macos/Sources/Features/Splits/SplitView.Divider.swift @@ -10,7 +10,7 @@ extension SplitView { @Binding var split: CGFloat private var visibleWidth: CGFloat? { - switch (direction) { + switch direction { case .horizontal: return visibleSize case .vertical: @@ -19,7 +19,7 @@ extension SplitView { } private var visibleHeight: CGFloat? { - switch (direction) { + switch direction { case .horizontal: return nil case .vertical: @@ -28,7 +28,7 @@ extension SplitView { } private var invisibleWidth: CGFloat? { - switch (direction) { + switch direction { case .horizontal: return visibleSize + invisibleSize case .vertical: @@ -37,7 +37,7 @@ extension SplitView { } private var invisibleHeight: CGFloat? { - switch (direction) { + switch direction { case .horizontal: return nil case .vertical: @@ -46,7 +46,7 @@ extension SplitView { } private var pointerStyle: BackportPointerStyle { - return switch (direction) { + return switch direction { case .horizontal: .resizeLeftRight case .vertical: .resizeUpDown } @@ -69,8 +69,8 @@ extension SplitView { return } - if (isHovered) { - switch (direction) { + if isHovered { + switch direction { case .horizontal: NSCursor.resizeLeftRight.push() case .vertical: diff --git a/macos/Sources/Features/Splits/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift index 42de97590..a19fdca6a 100644 --- a/macos/Sources/Features/Splits/SplitView.swift +++ b/macos/Sources/Features/Splits/SplitView.swift @@ -90,7 +90,7 @@ struct SplitView: View { private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { return DragGesture() .onChanged { gesture in - switch (direction) { + switch direction { case .horizontal: let new = min(max(minSize, gesture.location.x), size.width - minSize) split = new / size.width @@ -106,14 +106,14 @@ struct SplitView: View { private func leftRect(for size: CGSize) -> CGRect { // Initially the rect is the full size var result = CGRect(x: 0, y: 0, width: size.width, height: size.height) - switch (direction) { + switch direction { case .horizontal: - result.size.width = result.size.width * split + result.size.width *= split result.size.width -= splitterVisibleSize / 2 result.size.width -= result.size.width.truncatingRemainder(dividingBy: self.resizeIncrements.width) case .vertical: - result.size.height = result.size.height * split + result.size.height *= split result.size.height -= splitterVisibleSize / 2 result.size.height -= result.size.height.truncatingRemainder(dividingBy: self.resizeIncrements.height) } @@ -125,7 +125,7 @@ struct SplitView: View { private func rightRect(for size: CGSize, leftRect: CGRect) -> CGRect { // Initially the rect is the full size var result = CGRect(x: 0, y: 0, width: size.width, height: size.height) - switch (direction) { + switch direction { case .horizontal: // For horizontal layouts we offset the starting X by the left rect // and make the width fit the remaining space. @@ -144,7 +144,7 @@ struct SplitView: View { /// Calculates the point at which the splitter should be rendered. private func splitterPoint(for size: CGSize, leftRect: CGRect) -> CGPoint { - switch (direction) { + switch direction { case .horizontal: return CGPoint(x: leftRect.size.width, y: size.height / 2) @@ -152,9 +152,9 @@ struct SplitView: View { return CGPoint(x: size.width / 2, y: leftRect.size.height) } } - + // MARK: Accessibility - + private var splitViewLabel: String { switch direction { case .horizontal: @@ -163,7 +163,7 @@ struct SplitView: View { return "Vertical split view" } } - + private var leftPaneLabel: String { switch direction { case .horizontal: @@ -172,7 +172,7 @@ struct SplitView: View { return "Top pane" } } - + private var rightPaneLabel: String { switch direction { case .horizontal: diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift index 2a42dc599..5fa12edeb 100644 --- a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift +++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift @@ -7,19 +7,19 @@ import SwiftUI enum TerminalSplitOperation { case resize(Resize) case drop(Drop) - + struct Resize { let node: SplitTree.Node let ratio: Double } - + struct Drop { /// The surface being dragged. let payload: Ghostty.SurfaceView - + /// The surface it was dragged onto let destination: Ghostty.SurfaceView - + /// The zone it was dropped to determine how to split the destination. let zone: TerminalSplitDropZone } @@ -44,7 +44,7 @@ struct TerminalSplitTreeView: View { } } -fileprivate struct TerminalSplitSubtreeView: View { +private struct TerminalSplitSubtreeView: View { @EnvironmentObject var ghostty: Ghostty.App let node: SplitTree.Node @@ -52,12 +52,12 @@ fileprivate struct TerminalSplitSubtreeView: View { let action: (TerminalSplitOperation) -> Void var body: some View { - switch (node) { + switch node { case .leaf(let leafView): TerminalSplitLeaf(surfaceView: leafView, isSplit: !isRoot, action: action) case .split(let split): - let splitViewDirection: SplitViewDirection = switch (split.direction) { + let splitViewDirection: SplitViewDirection = switch split.direction { case .horizontal: .horizontal case .vertical: .vertical } @@ -86,14 +86,14 @@ fileprivate struct TerminalSplitSubtreeView: View { } } -fileprivate struct TerminalSplitLeaf: View { +private struct TerminalSplitLeaf: View { let surfaceView: Ghostty.SurfaceView let isSplit: Bool let action: (TerminalSplitOperation) -> Void - + @State private var dropState: DropState = .idle @State private var isSelfDragging: Bool = false - + var body: some View { GeometryReader { geometry in Ghostty.InspectableSurface( @@ -129,26 +129,26 @@ fileprivate struct TerminalSplitLeaf: View { .accessibilityLabel("Terminal pane") } } - + private enum DropState: Equatable { case idle case dropping(TerminalSplitDropZone) } - + private struct SplitDropDelegate: DropDelegate { @Binding var dropState: DropState let viewSize: CGSize let destinationSurface: Ghostty.SurfaceView let action: (TerminalSplitOperation) -> Void - + func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [.ghosttySurfaceId]) } - + func dropEntered(info: DropInfo) { dropState = .dropping(.calculate(at: info.location, in: viewSize)) } - + func dropUpdated(info: DropInfo) -> DropProposal? { // For some reason dropUpdated is sent after performDrop is called // and we don't want to reset our drop zone to show it so we have @@ -157,11 +157,11 @@ fileprivate struct TerminalSplitLeaf: View { dropState = .dropping(.calculate(at: info.location, in: viewSize)) return DropProposal(operation: .move) } - + func dropExited(info: DropInfo) { dropState = .idle } - + func performDrop(info: DropInfo) -> Bool { let zone = TerminalSplitDropZone.calculate(at: info.location, in: viewSize) dropState = .idle @@ -169,7 +169,7 @@ fileprivate struct TerminalSplitLeaf: View { // Load the dropped surface asynchronously using Transferable let providers = info.itemProviders(for: [.ghosttySurfaceId]) guard let provider = providers.first else { return false } - + // Capture action before the async closure _ = provider.loadTransferable(type: Ghostty.SurfaceView.self) { [weak destinationSurface] result in switch result { @@ -180,12 +180,12 @@ fileprivate struct TerminalSplitLeaf: View { guard sourceSurface !== destinationSurface else { return } action(.drop(.init(payload: sourceSurface, destination: destinationSurface, zone: zone))) } - + case .failure: break } } - + return true } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b739e9ed1..1cff80c52 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -31,13 +31,12 @@ class BaseTerminalController: NSWindowController, TerminalViewDelegate, TerminalViewModel, ClipboardConfirmationViewDelegate, - FullscreenDelegate -{ + FullscreenDelegate { /// The app instance that this terminal view will represent. let ghostty: Ghostty.App /// The currently focused surface. - var focusedSurface: Ghostty.SurfaceView? = nil { + var focusedSurface: Ghostty.SurfaceView? { didSet { syncFocusToSurfaceTree() } } @@ -48,7 +47,7 @@ class BaseTerminalController: NSWindowController, /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false - + /// Set if the terminal view should show the update overlay. @Published var updateOverlayIsVisible: Bool = false @@ -58,19 +57,19 @@ class BaseTerminalController: NSWindowController, } /// Non-nil when an alert is active so we don't overlap multiple. - private var alert: NSAlert? = nil + private var alert: NSAlert? /// The clipboard confirmation window, if shown. - private var clipboardConfirmation: ClipboardConfirmationController? = nil + private var clipboardConfirmation: ClipboardConfirmationController? /// Fullscreen state management. private(set) var fullscreenStyle: FullscreenStyle? /// Event monitor (see individual events for why) - private var eventMonitor: Any? = nil + private var eventMonitor: Any? /// The previous frame information from the window - private var savedFrame: SavedFrame? = nil + private var savedFrame: SavedFrame? /// Cache previously applied appearance to avoid unnecessary updates private var appliedColorScheme: ghostty_color_scheme_e? @@ -86,7 +85,7 @@ class BaseTerminalController: NSWindowController, /// An override title for the tab/window set by the user via prompt_tab_title. /// When set, this takes precedence over the computed title from the terminal. - var titleOverride: String? = nil { + var titleOverride: String? { didSet { applyTitleToWindow() } } @@ -281,7 +280,7 @@ class BaseTerminalController: NSWindowController, /// Subclasses should call super first. func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { // If our surface tree becomes empty then we have no focused surface. - if (to.isEmpty) { + if to.isEmpty { focusedSurface = nil } } @@ -424,7 +423,7 @@ class BaseTerminalController: NSWindowController, /// Goes to previous split unless we're the leftmost leaf, then goes to next. private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { guard let root = surfaceTree.root else { return nil } - + // If we're the leftmost, then we move to the next surface after closing. // Otherwise, we move to the previous. if root.leftmostLeaf() == node.leftmostLeaf() { @@ -433,7 +432,7 @@ class BaseTerminalController: NSWindowController, return surfaceTree.focusTarget(for: .previous, from: node) } } - + /// Remove a node from the surface tree and move focus appropriately. /// /// This also updates the undo manager to support restoring this node. @@ -471,13 +470,13 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: newView, from: oldView) } } - + // Setup our undo guard let undoManager else { return } if let undoAction { undoManager.setActionName(undoAction) } - + undoManager.registerUndo( withTarget: self, expiresAfter: undoExpiration @@ -488,7 +487,7 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: oldView, from: target.focusedSurface) } } - + undoManager.registerUndo( withTarget: target, expiresAfter: target.undoExpiration @@ -531,14 +530,14 @@ class BaseTerminalController: NSWindowController, // then we let it stay that way. x: if newFrame.origin.x < visibleFrame.origin.x { if let savedFrame, savedFrame.window.origin.x < savedFrame.screen.origin.x { - break x; + break x } newFrame.origin.x = visibleFrame.origin.x } y: if newFrame.origin.y < visibleFrame.origin.y { if let savedFrame, savedFrame.window.origin.y < savedFrame.screen.origin.y { - break y; + break y } newFrame.origin.y = visibleFrame.origin.y @@ -596,7 +595,7 @@ class BaseTerminalController: NSWindowController, guard let directionAny = notification.userInfo?["direction"] else { return } guard let direction = directionAny as? ghostty_action_split_direction_e else { return } let splitDirection: SplitTree.NewDirection - switch (direction) { + switch direction { case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down @@ -609,14 +608,14 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - + // Check if target surface is in current controller's tree guard surfaceTree.contains(target) else { return } - + // Equalize the splits surfaceTree = surfaceTree.equalized() } - + @objc private func ghosttyDidFocusSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } @@ -628,7 +627,7 @@ class BaseTerminalController: NSWindowController, // Find the node for the target surface guard let targetNode = surfaceTree.root?.node(view: target) else { return } - + // Find the next surface to focus guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else { return @@ -649,7 +648,7 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: nextSurface, from: target) } } - + @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } @@ -677,19 +676,19 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: target) } } - + @objc private func ghosttyDidResizeSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return } - + // Extract direction and amount from notification guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } - + guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } guard let amount = amountAny as? UInt16 else { return } - + // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction let spatialDirection: SplitTree.Spatial.Direction switch direction { @@ -698,10 +697,10 @@ class BaseTerminalController: NSWindowController, case .left: spatialDirection = .left case .right: spatialDirection = .right } - + // Use viewBounds for the spatial calculation bounds let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds()) - + // Perform the resize using the new SplitTree resize method do { surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds) @@ -716,7 +715,7 @@ class BaseTerminalController: NSWindowController, // Bring the window to front and focus the surface. window?.makeKeyAndOrderFront(nil) - + // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). Ghostty.moveFocus(to: target) @@ -729,11 +728,11 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttySurfaceDragEndedNoTarget(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return } - + // If our tree isn't split, then we never create a new window, because // it is already a single split. guard surfaceTree.isSplit else { return } - + // If we are removing our focused surface then we move it. We need to // keep track of our old one so undo sends focus back to the right place. let oldFocusedSurface = focusedSurface @@ -746,14 +745,14 @@ class BaseTerminalController: NSWindowController, // Create a new tree with the dragged surface and open a new window let newTree = SplitTree(view: target) - + // Treat our undo below as a full group. undoManager?.beginUndoGrouping() undoManager?.setActionName("Move Split") defer { undoManager?.endUndoGrouping() } - + replaceSurfaceTree(removedTree, moveFocusFrom: oldFocusedSurface) _ = TerminalController.newWindow( ghostty, @@ -783,7 +782,7 @@ class BaseTerminalController: NSWindowController, if NSApp.mainWindow == window { surfaces = surfaces.filter { $0 != focusedSurface } } - + for surface in surfaces { surface.flagsChanged(with: event) } @@ -817,10 +816,10 @@ class BaseTerminalController: NSWindowController, titleDidChange(to: "👻") } } - + private func computeTitle(title: String, bell: Bool) -> String { var result = title - if (bell && ghostty.config.bellFeatures.contains(.title)) { + if bell && ghostty.config.bellFeatures.contains(.title) { result = "🔔 \(result)" } @@ -834,17 +833,17 @@ class BaseTerminalController: NSWindowController, private func applyTitleToWindow() { guard let window else { return } - + if let titleOverride { window.title = computeTitle( title: titleOverride, bell: focusedSurface?.bell ?? false) return } - + window.title = lastComputedTitle } - + func pwdDidChange(to: URL?) { guard let window else { return } @@ -856,7 +855,6 @@ class BaseTerminalController: NSWindowController, } } - func cellSizeDidChange(to: NSSize) { guard derivedConfig.windowStepResize else { return } // Stage manager can sometimes present windows in such a way that the @@ -896,7 +894,7 @@ class BaseTerminalController: NSWindowController, case .left: .left case .right: .right } - + // Check if source is in our tree if let sourceNode = surfaceTree.root?.node(view: source) { // Source is in our tree - same window move @@ -908,7 +906,7 @@ class BaseTerminalController: NSWindowController, Ghostty.logger.warning("failed to insert surface during drop: \(error)") return } - + replaceSurfaceTree( newTree, moveFocusTo: source, @@ -916,7 +914,7 @@ class BaseTerminalController: NSWindowController, undoAction: "Move Split") return } - + // Source is not in our tree - search other windows var sourceController: BaseTerminalController? var sourceNode: SplitTree.Node? @@ -929,12 +927,12 @@ class BaseTerminalController: NSWindowController, break } } - + guard let sourceController, let sourceNode else { Ghostty.logger.warning("source surface not found in any window during drop") return } - + // Remove from source controller's tree and add it to our tree. // We do this first because if there is an error then we can // abort. @@ -945,17 +943,17 @@ class BaseTerminalController: NSWindowController, Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)") return } - + // Treat our undo below as a full group. undoManager?.beginUndoGrouping() undoManager?.setActionName("Move Split") defer { undoManager?.endUndoGrouping() } - + // Remove the node from the source. sourceController.removeSurfaceNode(sourceNode) - + // Add in the surface to our tree replaceSurfaceTree( newTree, @@ -966,7 +964,7 @@ class BaseTerminalController: NSWindowController, func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { guard let surface = surfaceView.surface else { return } let len = action.utf8CString.count - if (len == 0) { return } + if len == 0 { return } _ = action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(len - 1)) } @@ -980,17 +978,17 @@ class BaseTerminalController: NSWindowController, func toggleBackgroundOpacity() { // Do nothing if config is already fully opaque guard ghostty.config.backgroundOpacity < 1 else { return } - + // Do nothing if in fullscreen (transparency doesn't apply in fullscreen) guard let window, !window.styleMask.contains(.fullScreen) else { return } // Toggle between transparent and opaque isBackgroundOpaque.toggle() - + // Update our appearance syncAppearance() } - + /// Override this to resync any appearance related properties. This will be called automatically /// when certain window properties change that affect appearance. The list below should be updated /// as we add new things: @@ -1052,7 +1050,7 @@ class BaseTerminalController: NSWindowController, func fullscreenDidChange() { guard let fullscreenStyle else { return } - + // When we enter fullscreen, we want to show the update overlay so that it // is easily visible. For native fullscreen this is visible by showing the // menubar but we don't want to rely on that. @@ -1061,7 +1059,7 @@ class BaseTerminalController: NSWindowController, } else { updateOverlayIsVisible = defaultUpdateOverlayVisibility() } - + // Always resync our appearance syncAppearance() } @@ -1109,7 +1107,7 @@ class BaseTerminalController: NSWindowController, window?.endSheet(ccWindow) } - switch (request) { + switch request { case let .osc_52_write(pasteboard): guard case .confirm = action else { break } let pb = pasteboard ?? NSPasteboard.general @@ -1117,7 +1115,7 @@ class BaseTerminalController: NSWindowController, pb.setString(cc.contents, forType: .string) case .osc_52_read, .paste: let str: String - switch (action) { + switch action { case .cancel: str = "" @@ -1146,26 +1144,26 @@ class BaseTerminalController: NSWindowController, fullscreenStyle = NativeFullscreen(window) fullscreenStyle?.delegate = self } - + // Set our update overlay state updateOverlayIsVisible = defaultUpdateOverlayVisibility() } - + func defaultUpdateOverlayVisibility() -> Bool { guard let window else { return true } - + // No titlebar we always show the update overlay because it can't support // updates in the titlebar guard window.styleMask.contains(.titled) else { return true } - + // If it's a non terminal window we can't trust it has an update accessory, // so we always want to show the overlay. guard let window = window as? TerminalWindow else { return true } - + // Show the overlay if the window isn't. return !window.supportsUpdateAccessory } @@ -1295,7 +1293,6 @@ class BaseTerminalController: NSWindowController, ghostty.splitToggleZoom(surface: surface) } - @IBAction func splitMoveFocusPrevious(_ sender: Any) { splitMoveFocus(direction: .previous) } @@ -1368,7 +1365,7 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } - + @IBAction func find(_ sender: Any) { focusedSurface?.find(sender) } @@ -1384,11 +1381,11 @@ class BaseTerminalController: NSWindowController, @IBAction func findNext(_ sender: Any) { focusedSurface?.findNext(sender) } - + @IBAction func findPrevious(_ sender: Any) { focusedSurface?.findNext(sender) } - + @IBAction func findHide(_ sender: Any) { focusedSurface?.findHide(sender) } @@ -1430,7 +1427,7 @@ extension BaseTerminalController: NSMenuItemValidation { return true } } - + // MARK: - Surface Color Scheme /// Update the surface tree's color scheme only when it actually changes. diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c7f9fe086..6573ac7fc 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -8,16 +8,16 @@ import GhosttyKit class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller { override var windowNibName: NSNib.Name? { let defaultValue = "Terminal" - + guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config - + // If we have no window decorations, there's no reason to do anything but // the default titlebar (because there will be no titlebar). if !config.windowDecorations { return defaultValue } - + let nib = switch config.macosTitlebarStyle { case "native": "Terminal" case "hidden": "TerminalHiddenTitlebar" @@ -34,33 +34,32 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr #endif default: defaultValue } - + return nib } - + /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail /// early if we don't care. private var tabListenForFrame: Bool = false - + /// This is the hash value of the last tabGroup.windows array. We use this to detect order /// changes in the list. private var tabWindowsHash: Int = 0 - + /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true - + /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig - - + /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] - + /// This will be set to the initial frame of the window from the xib on load. - private var initialFrame: NSRect? = nil - + private var initialFrame: NSRect? + init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: SplitTree? = nil, @@ -72,12 +71,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // as the script. We may want to revisit this behavior when we have scrollback // restoration. self.restorable = (base?.command ?? "") == "" - + // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) - + super.init(ghostty, baseConfig: base, surfaceTree: tree) - + // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( @@ -134,37 +133,37 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr object: nil ) } - + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } - + deinit { // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) } - + // MARK: Base Controller Overrides - + override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - + // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() - + // Update our zoom state if let window = window as? TerminalWindow { window.surfaceIsZoomed = to.zoomed != nil } - + // If our surface tree is now nil then we close our window. - if (to.isEmpty) { + if to.isEmpty { self.window?.close() } } - + override func replaceSurfaceTree( _ newTree: SplitTree, moveFocusTo newView: Ghostty.SurfaceView? = nil, @@ -177,7 +176,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeTabImmediately() return } - + super.replaceSurfaceTree( newTree, moveFocusTo: newView, @@ -210,7 +209,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // to find the preferred window to attach new tabs, perform actions, etc. We // always prefer the main window but if there isn't any (because we're triggered // by something like an App Intent) then we prefer the most previous main. - static private(set) weak var lastMain: TerminalController? = nil + static private(set) weak var lastMain: TerminalController? /// The "new window" action. static func newWindow( @@ -224,27 +223,25 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // otherwise the focused terminal, otherwise an arbitrary one. let parent: NSWindow? = explicitParent ?? preferredParent?.window - if let parent { - if parent.styleMask.contains(.fullScreen) { - // If our previous window was fullscreen then we want our new window to - // be fullscreen. This behavior actually doesn't match the native tabbing - // behavior of macOS apps where new windows create tabs when in native - // fullscreen but this is how we've always done it. This matches iTerm2 - // behavior. + if let parent, parent.styleMask.contains(.fullScreen) { + // If our previous window was fullscreen then we want our new window to + // be fullscreen. This behavior actually doesn't match the native tabbing + // behavior of macOS apps where new windows create tabs when in native + // fullscreen but this is how we've always done it. This matches iTerm2 + // behavior. + c.toggleFullscreen(mode: .native) + } else if let fullscreenMode = ghostty.config.windowFullscreen { + switch fullscreenMode { + case .native: + // Native has to be done immediately so that our stylemask contains + // fullscreen for the logic later in this method. c.toggleFullscreen(mode: .native) - } else if ghostty.config.windowFullscreen { - switch (ghostty.config.windowFullscreenMode) { - case .native: - // Native has to be done immediately so that our stylemask contains - // fullscreen for the logic later in this method. - c.toggleFullscreen(mode: .native) - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: - // If we're non-native then we have to do it on a later loop - // so that the content view is setup. - DispatchQueue.main.async { - c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode) - } + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + // If we're non-native then we have to do it on a later loop + // so that the content view is setup. + DispatchQueue.main.async { + c.toggleFullscreen(mode: fullscreenMode) } } } @@ -255,7 +252,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr DispatchQueue.main.async { // Only cascade if we aren't fullscreen. if let window = c.window { - if (!window.styleMask.contains(.fullScreen)) { + if !window.styleMask.contains(.fullScreen) { Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) } } @@ -392,7 +389,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If the parent is miniaturized, then macOS exhibits really strange behaviors // so we have to bring it back out. - if (parent.isMiniaturized) { parent.deminiaturize(self) } + if parent.isMiniaturized { parent.deminiaturize(self) } // If our parent tab group already has this window, macOS added it and // we need to remove it so we can set the correct order in the next line. @@ -407,7 +404,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // If we don't allow tabs then we create a new window instead. - if (window.tabbingMode != .disallowed) { + if window.tabbingMode != .disallowed { // Add the window to the tab group and show it. switch ghostty.config.windowNewTabPosition { case "end": @@ -483,8 +480,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return controller } - - //MARK: - Methods + + // MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { // Get our managed configuration object out @@ -493,7 +490,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr ] as? Ghostty.Config else { return } // If this is an app-level config update then we update some things. - if (notification.object == nil) { + if notification.object == nil { // Update our derived config self.derivedConfig = DerivedConfig(config) @@ -564,7 +561,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tabWindowsHash = v self.relabelTabs() } - + override func syncAppearance() { // When our focus changes, we update our window appearance based on the // currently focused surface. @@ -909,7 +906,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning alert.beginSheetModal(for: confirmWindow, completionHandler: { response in - if (response == .alertFirstButtonReturn) { + if response == .alertFirstButtonReturn { // This is important so that we avoid losing focus when Stage // Manager is used (#8336) alert.window.orderOut(nil) @@ -938,9 +935,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let tabColor: TerminalTabColor } - convenience init(_ ghostty: Ghostty.App, - with undoState: UndoState - ) { + convenience init(_ ghostty: Ghostty.App, with undoState: UndoState) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) // Show the window and restore its frame @@ -965,7 +960,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Make it the key window window.makeKeyAndOrderFront(nil) } - + // Restore focus to the previously focused surface if let focusedUUID = undoState.focusedSurface, let focusTarget = surfaceTree.first(where: { $0.id == focusedUUID }) { @@ -996,7 +991,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr tabColor: (window as? TerminalWindow)?.tabColor ?? .none) } - //MARK: - NSWindowController + // MARK: - NSWindowController override func windowWillLoad() { // We do NOT want to cascade because we handle this manually from the manager. @@ -1015,7 +1010,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Setting all three of these is required for restoration to work. window.isRestorable = restorable - if (restorable) { + if restorable { window.restorationClass = TerminalWindowRestoration.self window.identifier = .init(String(describing: TerminalWindowRestoration.self)) } @@ -1037,7 +1032,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we have a default size, we want to apply it. if let defaultSize { - switch (defaultSize) { + switch defaultSize { case .frame: // Frames can be applied immediately defaultSize.apply(to: window) @@ -1073,7 +1068,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // We don't run this logic in fullscreen because in fullscreen this will end up // removing the window and putting it into its own dedicated fullscreen, which is not // the expected or desired behavior of anyone I've found. - if (!window.styleMask.contains(.fullScreen)) { + if !window.styleMask.contains(.fullScreen) { // If we have more than 1 window in our tab group we know we're a new window. // Since Ghostty manages tabbing manually this will never be more than one // at this point in the AppKit lifecycle (we add to the group after this). @@ -1103,7 +1098,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr override func windowShouldClose(_ sender: NSWindow) -> Bool { tabGroupCloseCoordinator.windowShouldClose(sender) { [weak self] scope in guard let self else { return } - switch (scope) { + switch scope { case .tab: closeTab(nil) case .window: guard self.window?.isFirstWindowInTabGroup ?? false else { return } @@ -1133,7 +1128,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // https://github.com/ghostty-org/ghostty/issues/2565 let oldFrame = focusedWindow.frame - Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: .zero) if focusedWindow.frame != oldFrame { focusedWindow.setFrame(oldFrame, display: true) @@ -1317,7 +1312,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr ghostty.toggleTerminalInspector(surface: surface) } - //MARK: - TerminalViewDelegate + // MARK: - TerminalViewDelegate override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) @@ -1349,7 +1344,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - //MARK: - Notifications + // MARK: - Notifications @objc private func onMoveTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } @@ -1432,23 +1427,23 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let finalIndex: Int // An index that is invalid is used to signal some special values. - if (tabIndex <= 0) { + if tabIndex <= 0 { guard let selectedWindow = tabGroup.selectedWindow else { return } guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return } - if (tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue) { - if (selectedIndex == 0) { + if tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue { + if selectedIndex == 0 { finalIndex = tabbedWindows.count - 1 } else { finalIndex = selectedIndex - 1 } - } else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) { - if (selectedIndex == tabbedWindows.count - 1) { + } else if tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue { + if selectedIndex == tabbedWindows.count - 1 { finalIndex = 0 } else { finalIndex = selectedIndex + 1 } - } else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) { + } else if tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue { finalIndex = tabbedWindows.count - 1 } else { return @@ -1549,25 +1544,25 @@ extension TerminalController { case #selector(closeTabsOnTheRight): guard let window, let tabGroup = window.tabGroup else { return false } guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } - return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } - + return tabGroup.windows.indices.contains { $0 > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } - + // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } - + // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } - + // If our window is already the default size or we don't have a // default size, then disable. return defaultSize?.isChanged(for: window) ?? false - + default: return super.validateMenuItem(item) } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index fd0f4eab5..aab51f6bd 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -98,7 +98,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // no matter what. Note its safe to use "ghostty.config" directly here // because window restoration is only ever invoked on app start so we // don't have to deal with config reloads. - if (appDelegate.ghostty.config.windowSaveState == "never") { + if appDelegate.ghostty.config.windowSaveState == "never" { completionHandler(nil, nil) return } @@ -131,13 +131,11 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { var foundView: Ghostty.SurfaceView? - for view in c.surfaceTree { - if view.id.uuidString == focusedStr { - foundView = view - break - } + for view in c.surfaceTree where view.id.uuidString == focusedStr { + foundView = view + break } - + if let view = foundView { c.focusedSurface = view restoreFocus(to: view, inWindow: window) @@ -161,9 +159,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // For the first attempt, we schedule it immediately. Subsequent events wait a bit // so we don't just spin the CPU at 100%. Give up after some period of time. let after: DispatchTime - if (attempts == 0) { + if attempts == 0 { after = .now() - } else if (attempts > 40) { + } else if attempts > 40 { // 2 seconds, give up return } else { @@ -185,11 +183,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // If the window is main, then we also make sure it comes forward. This // prevents a bug found in #1177 where sometimes on restore the windows // would be behind other applications. - if (viewWindow.isMainWindow) { + if viewWindow.isMainWindow { viewWindow.orderFront(nil) } } } } - diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 08d89324c..2879822b3 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -122,7 +122,7 @@ struct TabColorMenuView: View { VStack(alignment: .leading, spacing: 3) { Text("Tab Color") .padding(.bottom, 2) - + ForEach(Self.paletteRows, id: \.self) { row in HStack(spacing: 2) { ForEach(row, id: \.self) { color in @@ -142,7 +142,7 @@ struct TabColorMenuView: View { .padding(.top, 4) .padding(.bottom, 4) } - + static let paletteRows: [[TerminalTabColor]] = [ [.none, .blue, .purple, .pink, .red], [.orange, .yellow, .green, .teal, .graphite], diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index e117e0647..1aab8f497 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -17,7 +17,7 @@ protocol TerminalViewDelegate: AnyObject { /// Perform an action. At the time of writing this is only triggered by the command palette. func performAction(_ action: String, on: Ghostty.SurfaceView) - + /// A split tree operation func performSplitAction(_ action: TerminalSplitOperation) } @@ -32,7 +32,7 @@ protocol TerminalViewModel: ObservableObject { /// The command palette state. var commandPaletteIsShowing: Bool { get set } - + /// The update overlay should be visible. var updateOverlayIsVisible: Bool { get } } @@ -45,8 +45,8 @@ struct TerminalView: View { @ObservedObject var viewModel: ViewModel // An optional delegate to receive information about terminal changes. - weak var delegate: (any TerminalViewDelegate)? = nil - + weak var delegate: (any TerminalViewDelegate)? + // The most recently focused surface, equal to focusedSurface when // it is non-nil. @State private var lastFocusedSurface: Weak = .init() @@ -76,7 +76,7 @@ struct TerminalView: View { VStack(spacing: 0) { // If we're running in debug mode we show a warning so that users // know that performance will be degraded. - if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) { + if Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE { DebugBuildWarningView() } @@ -116,7 +116,7 @@ struct TerminalView: View { self.delegate?.performAction(action, on: surfaceView) } } - + // Show update information above all else. if viewModel.updateOverlayIsVisible { UpdateOverlay() @@ -127,12 +127,12 @@ struct TerminalView: View { } } -fileprivate struct UpdateOverlay: View { +private struct UpdateOverlay: View { var body: some View { if let appDelegate = NSApp.delegate as? AppDelegate { VStack { Spacer() - + HStack { Spacer() UpdatePill(model: appDelegate.updateViewModel) diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift index dd8b258f3..766ec5857 100644 --- a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift @@ -3,7 +3,7 @@ import AppKit class HiddenTitlebarTerminalWindow: TerminalWindow { // No titlebar, we don't support accessories. override var supportsUpdateAccessory: Bool { false } - + override func awakeFromNib() { super.awakeFromNib() @@ -34,7 +34,7 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { .closable, .miniaturizable, ] - + /// Apply the hidden titlebar style. private func reapplyHiddenStyle() { // If our window is fullscreen then we don't reapply the hidden style because @@ -43,7 +43,7 @@ class HiddenTitlebarTerminalWindow: TerminalWindow { if terminalController?.fullscreenStyle?.isFullscreen ?? false { return } - + // Apply our style mask while preserving the .fullScreen option if styleMask.contains(.fullScreen) { styleMask = Self.hiddenStyleMask.union([.fullScreen]) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 501ac0e67..cde8d2747 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -33,9 +33,9 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() - + /// Sets up our tab context menu - private var tabMenuObserver: NSObjectProtocol? = nil + private var tabMenuObserver: NSObjectProtocol? /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -112,7 +112,7 @@ class TerminalWindow: NSWindow { } // If window decorations are disabled, remove our title - if (!config.windowDecorations) { styleMask.remove(.titled) } + if !config.windowDecorations { styleMask.remove(.titled) } // Set our window positioning to coordinates if config value exists, otherwise // fallback to original centering behavior @@ -295,7 +295,7 @@ class TerminalWindow: NSWindow { // MARK: Tab Key Equivalents - var keyEquivalent: String? = nil { + var keyEquivalent: String? { didSet { // When our key equivalent is set, we must update the tab label. guard let keyEquivalent else { @@ -347,7 +347,7 @@ class TerminalWindow: NSWindow { button.toolTip = "Reset Zoom" button.contentTintColor = isMainWindow ? .controlAccentColor : .secondaryLabelColor button.state = .on - button.image = NSImage(named:"ResetZoom") + button.image = NSImage(named: "ResetZoom") button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) button.translatesAutoresizingMaskIntoConstraints = false button.widthAnchor.constraint(equalToConstant: 20).isActive = true @@ -449,8 +449,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) - { + (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) { isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -459,7 +458,7 @@ class TerminalWindow: NSWindow { backgroundColor = .white.withAlphaComponent(0.001) // We don't need to set blur when using glass - if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { + if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -510,7 +509,7 @@ class TerminalWindow: NSWindow { private func setInitialWindowPosition(x: Int16?, y: Int16?) { // If we don't have an X/Y then we try to use the previously saved window pos. guard x != nil, y != nil else { - if (!LastWindowPosition.shared.restore(self)) { + if !LastWindowPosition.shared.restore(self) { center() } @@ -544,7 +543,7 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - + // MARK: Config struct DerivedConfig { diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift index 918191522..184614831 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift @@ -8,7 +8,7 @@ import SwiftUI class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate { /// The view model for SwiftUI views private var viewModel = ViewModel() - + /// Titlebar tabs can't support the update accessory because of the way we layout /// the native tabs back into the menu bar. override var supportsUpdateAccessory: Bool { false } @@ -58,13 +58,13 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // Check if we have a tab bar and set it up if we have to. See the comment // on this function to learn why we need to check this here. setupTabBar() - + viewModel.isMainWindow = true } override func resignMain() { super.resignMain() - + viewModel.isMainWindow = false } @@ -84,18 +84,18 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool super.sendEvent(event) return } - + guard let tabBarView else { super.sendEvent(event) return } - + let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { super.sendEvent(event) return } - + tabBarView.rightMouseDown(with: event) } @@ -107,7 +107,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // After dragging a tab into a new window, `hasTabBar` needs to be // updated to properly review window title viewModel.hasTabBar = false - + super.addTitlebarAccessoryViewController(childViewController) return } @@ -116,7 +116,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // system will also try to add tab bar to this window, so we want to reset observer, // to put tab bar where we want again tabBarObserver = nil - + // Some setup needs to happen BEFORE it is added, such as layout. If // we don't do this before the call below, we'll trigger an AppKit // assertion. @@ -189,7 +189,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool guard let clipView = tabBarView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return } guard let accessoryView = clipView.subviews[safe: 0] else { return } guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return } - + // Make sure tabBar's height won't be stretched guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return } tabBarView.frame.size.height = newTabButton.frame.width @@ -199,7 +199,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // The padding for the tab bar. If we're showing window buttons then // we need to offset the window buttons. - let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) { + let leftPadding: CGFloat = switch self.derivedConfig.macosWindowButtons { case .hidden: 0 case .visible: 70 } @@ -282,7 +282,7 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool // This is the documented way to avoid the glass view on an item. // We don't want glass on our title. item.isBordered = false - + return item default: return NSToolbarItem(itemIdentifier: itemIdentifier) @@ -327,7 +327,7 @@ extension TitlebarTabsTahoeTerminalWindow { Color.clear.frame(width: 1, height: 1) } } - + @ViewBuilder var titleText: some View { Text(title) diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift index 39db13c6d..fe83fc5fd 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift @@ -20,13 +20,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // false if all three traffic lights are missing/hidden, otherwise true private var hasWindowButtons: Bool { - get { - // if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true - let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true - let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true - let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true - return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden) - } + // if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true + let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true + let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true + let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true + return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden) } // MARK: NSWindow @@ -159,7 +157,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity) } - if (isOpaque || themeChanged) { + if isOpaque || themeChanged { // If there is transparency, calling this will make the titlebar opaque // so we only call this if we are opaque. updateTabBar() @@ -172,7 +170,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { backgroundColor.luminance < 0.05 } - private var newTabButtonImageLayer: VibrantLayer? = nil + private var newTabButtonImageLayer: VibrantLayer? func updateTabBar() { newTabButtonImageLayer = nil @@ -251,7 +249,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { button.toolTip = "Reset Zoom" button.contentTintColor = .controlAccentColor button.state = .on - button.image = NSImage(named:"ResetZoom") + button.image = NSImage(named: "ResetZoom") button.frame = NSRect(x: 0, y: 0, width: 20, height: 20) button.translatesAutoresizingMaskIntoConstraints = false button.widthAnchor.constraint(equalToConstant: 20).isActive = true @@ -286,9 +284,9 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { // MARK: - Titlebar Tabs - private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil + private var windowButtonsBackdrop: WindowButtonsBackdropView? - private var windowDragHandle: WindowDragView? = nil + private var windowDragHandle: WindowDragView? // Used by the window controller to enable/disable titlebar tabs. var titlebarTabs = false { @@ -340,7 +338,6 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } } - // HACK: hide the "collapsed items" marker from the toolbar if it's present. // idk why it appears in macOS 15.0+ but it does... so... make it go away. (sigh) private func hideToolbarOverflowButton() { @@ -359,7 +356,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) { let isTabBar = self.titlebarTabs && isTabBar(childViewController) - if (isTabBar) { + if isTabBar { // Ensure it has the right layoutAttribute to force it next to our titlebar childViewController.layoutAttribute = .right @@ -374,7 +371,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { super.addTitlebarAccessoryViewController(childViewController) - if (isTabBar) { + if isTabBar { pushTabsToTitlebar(childViewController) } } @@ -382,7 +379,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { override func removeTitlebarAccessoryViewController(at index: Int) { let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier super.removeTitlebarAccessoryViewController(at: index) - if (isTabBar) { + if isTabBar { resetCustomTabBarViews() } } @@ -403,7 +400,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) { // We need a toolbar as a target for our titlebar tabs. - if (toolbar == nil) { + if toolbar == nil { generateToolbar() } @@ -506,10 +503,10 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow { } // Passes mouseDown events from this view to window.performDrag so that you can drag the window by it. -fileprivate class WindowDragView: NSView { +private class WindowDragView: NSView { override public func mouseDown(with event: NSEvent) { // Drag the window for single left clicks, double clicks should bypass the drag handle. - if (event.type == .leftMouseDown && event.clickCount == 1) { + if event.type == .leftMouseDown && event.clickCount == 1 { window?.performDrag(with: event) NSCursor.closedHand.set() } else { @@ -535,7 +532,7 @@ fileprivate class WindowDragView: NSView { } // A view that matches the color of selected and unselected tabs in the adjacent tab bar. -fileprivate class WindowButtonsBackdropView: NSView { +private class WindowButtonsBackdropView: NSView { // This must be weak because the window has this view. Otherwise // a retain cycle occurs. private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow? @@ -588,7 +585,7 @@ fileprivate class WindowButtonsBackdropView: NSView { // Custom NSToolbar subclass that displays a centered window title, // in order to accommodate the titlebar tabs feature. -fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate { +private class TerminalToolbar: NSToolbar, NSToolbarDelegate { private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty") var titleText: String { @@ -674,7 +671,7 @@ fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate { } /// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window. -fileprivate class CenteredDynamicLabel: NSTextField { +private class CenteredDynamicLabel: NSTextField { override func viewDidMoveToSuperview() { // Configure the text field isEditable = false diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index a72436d7f..a547d5286 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -151,7 +151,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { tabGroupWindowsObservation = tabGroup.observe( \.windows, options: [.new] - ) { [weak self] _, change in + ) { [weak self] _, _ in // NOTE: At one point, I guarded this on only if we went from 0 to N // or N to 0 under the assumption that the tab bar would only get // replaced on those cases. This turned out to be false (Tahoe). @@ -175,7 +175,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { tabBarVisibleObservation = tabGroup?.observe( \.isTabBarVisible, options: [.new] - ) { [weak self] _, change in + ) { [weak self] _, _ in guard let self else { return } guard let lastSurfaceConfig else { return } self.syncAppearance(lastSurfaceConfig) diff --git a/macos/Sources/Features/Update/UpdateBadge.swift b/macos/Sources/Features/Update/UpdateBadge.swift index 054fdf971..ce98bd277 100644 --- a/macos/Sources/Features/Update/UpdateBadge.swift +++ b/macos/Sources/Features/Update/UpdateBadge.swift @@ -9,15 +9,15 @@ import SwiftUI struct UpdateBadge: View { /// The update view model that provides the current state and progress @ObservedObject var model: UpdateViewModel - + /// Current rotation angle for animated icon states @State private var rotationAngle: Double = 0 - + var body: some View { badgeContent .accessibilityLabel(model.text) } - + @ViewBuilder private var badgeContent: some View { switch model.state { @@ -28,10 +28,10 @@ struct UpdateBadge: View { } else { Image(systemName: "arrow.down.circle") } - + case .extracting(let extracting): ProgressRingView(progress: min(1, max(0, extracting.progress))) - + case .checking: if let iconName = model.iconName { Image(systemName: iconName) @@ -47,7 +47,7 @@ struct UpdateBadge: View { } else { EmptyView() } - + default: if let iconName = model.iconName { Image(systemName: iconName) @@ -61,18 +61,18 @@ struct UpdateBadge: View { /// A circular progress indicator with a stroke-based ring design. /// /// Displays a partially filled circle that represents progress from 0.0 to 1.0. -fileprivate struct ProgressRingView: View { +private struct ProgressRingView: View { /// The current progress value, ranging from 0.0 (empty) to 1.0 (complete) let progress: Double - + /// The width of the progress ring stroke let lineWidth: CGFloat = 2 - + var body: some View { ZStack { Circle() .stroke(Color.primary.opacity(0.2), lineWidth: lineWidth) - + Circle() .trim(from: 0, to: progress) .stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift index 939eed420..1ca218c8b 100644 --- a/macos/Sources/Features/Update/UpdateController.swift +++ b/macos/Sources/Features/Update/UpdateController.swift @@ -11,16 +11,16 @@ class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private var installCancellable: AnyCancellable? - + var viewModel: UpdateViewModel { userDriver.viewModel } - + /// True if we're installing an update. var isInstalling: Bool { installCancellable != nil } - + /// Initialize a new update controller. init() { let hostBundle = Bundle.main @@ -34,11 +34,11 @@ class UpdateController { delegate: userDriver ) } - + deinit { installCancellable?.cancel() } - + /// Start the updater. /// /// This must be called before the updater can check for updates. If starting fails, @@ -59,35 +59,35 @@ class UpdateController { )) } } - + /// Force install the current update. As long as we're in some "update available" state this will /// trigger all the steps necessary to complete the update. func installUpdate() { // Must be in an installable state guard viewModel.state.isInstallable else { return } - + // If we're already force installing then do nothing. guard installCancellable == nil else { return } - + // Setup a combine listener to listen for state changes and to always // confirm them. If we go to a non-installable state, cancel the listener. // The sink runs immediately with the current state, so we don't need to // manually confirm the first state. installCancellable = viewModel.$state.sink { [weak self] state in guard let self else { return } - + // If we move to a non-installable state (error, idle, etc.) then we // stop force installing. guard state.isInstallable else { self.installCancellable = nil return } - + // Continue the `yes` chain! state.confirm() } } - + /// Check for updates. /// /// This is typically connected to a menu item action. @@ -97,11 +97,11 @@ class UpdateController { updater.checkForUpdates() return } - + // If we're not idle then we need to cancel any prior state. installCancellable?.cancel() viewModel.state.cancel() - + // The above will take time to settle, so we delay the check for some time. // The 100ms is arbitrary and I'd rather not, but we have to wait more than // one loop tick it seems. @@ -109,7 +109,7 @@ class UpdateController { self?.updater.checkForUpdates() } } - + /// Validate the check for updates menu item. /// /// - Parameter item: The menu item to validate diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index 619540851..72d54bd22 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -6,11 +6,11 @@ extension UpdateDriver: SPUUpdaterDelegate { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - + // Sparkle supports a native concept of "channels" but it requires that // you share a single appcast file. We don't want to do that so we // do this instead. - switch (appDelegate.ghostty.config.autoUpdateChannel) { + switch appDelegate.ghostty.config.autoUpdateChannel { case .tip: return "https://tip.files.ghostty.org/appcast.xml" case .stable: return "https://release.files.ghostty.org/appcast.xml" } diff --git a/macos/Sources/Features/Update/UpdateDriver.swift b/macos/Sources/Features/Update/UpdateDriver.swift index 3beb4c9be..b5f580f1b 100644 --- a/macos/Sources/Features/Update/UpdateDriver.swift +++ b/macos/Sources/Features/Update/UpdateDriver.swift @@ -5,23 +5,23 @@ import Sparkle class UpdateDriver: NSObject, SPUUserDriver { let viewModel: UpdateViewModel let standard: SPUStandardUserDriver - + init(viewModel: UpdateViewModel, hostBundle: Bundle) { self.viewModel = viewModel self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil) super.init() - + NotificationCenter.default.addObserver( self, selector: #selector(handleTerminalWindowWillClose), name: TerminalWindow.terminalWillCloseNotification, object: nil) } - + deinit { NotificationCenter.default.removeObserver(self) } - + @objc private func handleTerminalWindowWillClose() { // If we lost the ability to show unobtrusive states, cancel whatever // update state we're in. This will allow the manual `check for updates` @@ -36,7 +36,7 @@ class UpdateDriver: NSObject, SPUUserDriver { viewModel.state = .idle } } - + func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) { viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in @@ -47,7 +47,7 @@ class UpdateDriver: NSObject, SPUUserDriver { standard.show(request, reply: reply) } } - + func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { viewModel.state = .checking(.init(cancel: cancellation)) @@ -55,7 +55,7 @@ class UpdateDriver: NSObject, SPUUserDriver { standard.showUserInitiatedUpdateCheck(cancellation: cancellation) } } - + func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { @@ -64,25 +64,25 @@ class UpdateDriver: NSObject, SPUUserDriver { standard.showUpdateFound(with: appcastItem, state: state, reply: reply) } } - + func showUpdateReleaseNotes(with downloadData: SPUDownloadData) { // We don't do anything with the release notes here because Ghostty // doesn't use the release notes feature of Sparkle currently. } - + func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) { // We don't do anything with release notes. See `showUpdateReleaseNotes` } - + func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) { viewModel.state = .notFound(.init(acknowledgement: acknowledgement)) - + if !hasUnobtrusiveTarget { standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement) } } - + func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) { viewModel.state = .error(.init( @@ -98,71 +98,71 @@ class UpdateDriver: NSObject, SPUUserDriver { dismiss: { [weak viewModel] in viewModel?.state = .idle })) - + if !hasUnobtrusiveTarget { standard.showUpdaterError(error, acknowledgement: acknowledgement) } else { acknowledgement() } } - + func showDownloadInitiated(cancellation: @escaping () -> Void) { viewModel.state = .downloading(.init( cancel: cancellation, expectedLength: nil, progress: 0)) - + if !hasUnobtrusiveTarget { standard.showDownloadInitiated(cancellation: cancellation) } } - + func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) { guard case let .downloading(downloading) = viewModel.state else { return } - + viewModel.state = .downloading(.init( cancel: downloading.cancel, expectedLength: expectedContentLength, progress: 0)) - + if !hasUnobtrusiveTarget { standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength) } } - + func showDownloadDidReceiveData(ofLength length: UInt64) { guard case let .downloading(downloading) = viewModel.state else { return } - + viewModel.state = .downloading(.init( cancel: downloading.cancel, expectedLength: downloading.expectedLength, progress: downloading.progress + length)) - + if !hasUnobtrusiveTarget { standard.showDownloadDidReceiveData(ofLength: length) } } - + func showDownloadDidStartExtractingUpdate() { viewModel.state = .extracting(.init(progress: 0)) - + if !hasUnobtrusiveTarget { standard.showDownloadDidStartExtractingUpdate() } } - + func showExtractionReceivedProgress(_ progress: Double) { viewModel.state = .extracting(.init(progress: progress)) - + if !hasUnobtrusiveTarget { standard.showExtractionReceivedProgress(progress) } } - + func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) { if !hasUnobtrusiveTarget { standard.showReady(toInstallAndRelaunch: reply) @@ -170,7 +170,7 @@ class UpdateDriver: NSObject, SPUUserDriver { reply(.install) } } - + func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) { viewModel.state = .installing(.init( retryTerminatingApplication: retryTerminatingApplication, @@ -178,30 +178,30 @@ class UpdateDriver: NSObject, SPUUserDriver { viewModel?.state = .idle } )) - + if !hasUnobtrusiveTarget { standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication) } } - + func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement) viewModel.state = .idle } - + func showUpdateInFocus() { if !hasUnobtrusiveTarget { standard.showUpdateInFocus() } } - + func dismissUpdateInstallation() { viewModel.state = .idle standard.dismissUpdateInstallation() } - + // MARK: No-Window Fallback - + /// True if there is a target that can render our unobtrusive update checker. var hasUnobtrusiveTarget: Bool { NSApp.windows.contains { window in diff --git a/macos/Sources/Features/Update/UpdatePill.swift b/macos/Sources/Features/Update/UpdatePill.swift index 29d1669e1..b14cde1ac 100644 --- a/macos/Sources/Features/Update/UpdatePill.swift +++ b/macos/Sources/Features/Update/UpdatePill.swift @@ -4,16 +4,16 @@ import SwiftUI struct UpdatePill: View { /// The update view model that provides the current state and information @ObservedObject var model: UpdateViewModel - + /// Whether the update popover is currently visible @State private var showPopover = false - + /// Task for auto-dismissing the "No Updates" state @State private var resetTask: Task? - + /// The font used for the pill text private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium) - + var body: some View { if !model.state.isIdle { pillButton @@ -36,7 +36,7 @@ struct UpdatePill: View { } } } - + /// The pill-shaped button view that displays the update badge and text @ViewBuilder private var pillButton: some View { @@ -47,11 +47,11 @@ struct UpdatePill: View { } else { showPopover.toggle() } - }) { + }, label: { HStack(spacing: 6) { UpdateBadge(model: model) .frame(width: 14, height: 14) - + Text(model.text) .font(Font(textFont)) .lineLimit(1) @@ -66,12 +66,12 @@ struct UpdatePill: View { ) .foregroundColor(model.foregroundColor) .contentShape(Capsule()) - } + }) .buttonStyle(.plain) .help(model.text) .accessibilityLabel(model.text) } - + /// Calculated width for the text to prevent resizing during progress updates private var textWidth: CGFloat? { let attributes: [NSAttributedString.Key: Any] = [.font: textFont] diff --git a/macos/Sources/Features/Update/UpdatePopoverView.swift b/macos/Sources/Features/Update/UpdatePopoverView.swift index 87d76f801..aa4e822f3 100644 --- a/macos/Sources/Features/Update/UpdatePopoverView.swift +++ b/macos/Sources/Features/Update/UpdatePopoverView.swift @@ -8,10 +8,10 @@ import Sparkle struct UpdatePopoverView: View { /// The update view model that provides the current state and information @ObservedObject var model: UpdateViewModel - + /// Environment value for dismissing the popover @Environment(\.dismiss) private var dismiss - + var body: some View { VStack(alignment: .leading, spacing: 0) { switch model.state { @@ -19,31 +19,31 @@ struct UpdatePopoverView: View { // Shouldn't happen in a well-formed view stack. Higher levels // should not call the popover for idles. EmptyView() - + case .permissionRequest(let request): PermissionRequestView(request: request, dismiss: dismiss) - + case .checking(let checking): CheckingView(checking: checking, dismiss: dismiss) - + case .updateAvailable(let update): UpdateAvailableView(update: update, dismiss: dismiss) - + case .downloading(let download): DownloadingView(download: download, dismiss: dismiss) - + case .extracting(let extracting): ExtractingView(extracting: extracting) - + case .installing(let installing): // This is only required when `installing.isAutoUpdate == true`, // but we keep it anyway, just in case something unexpected // happens during installing InstallingView(installing: installing, dismiss: dismiss) - + case .notFound(let notFound): NotFoundView(notFound: notFound, dismiss: dismiss) - + case .error(let error): UpdateErrorView(error: error, dismiss: dismiss) } @@ -52,22 +52,22 @@ struct UpdatePopoverView: View { } } -fileprivate struct PermissionRequestView: View { +private struct PermissionRequestView: View { let request: UpdateState.PermissionRequest let dismiss: DismissAction - + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Enable automatic updates?") .font(.system(size: 13, weight: .semibold)) - + Text("Ghostty can automatically check for updates in the background.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } - + HStack(spacing: 8) { Button("Not Now") { request.reply(SUUpdatePermissionResponse( @@ -76,9 +76,9 @@ fileprivate struct PermissionRequestView: View { dismiss() } .keyboardShortcut(.cancelAction) - + Spacer() - + Button("Allow") { request.reply(SUUpdatePermissionResponse( automaticUpdateChecks: true, @@ -93,10 +93,10 @@ fileprivate struct PermissionRequestView: View { } } -fileprivate struct CheckingView: View { +private struct CheckingView: View { let checking: UpdateState.Checking let dismiss: DismissAction - + var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 10) { @@ -105,7 +105,7 @@ fileprivate struct CheckingView: View { Text("Checking for updates…") .font(.system(size: 13)) } - + HStack { Spacer() Button("Cancel") { @@ -120,19 +120,19 @@ fileprivate struct CheckingView: View { } } -fileprivate struct UpdateAvailableView: View { +private struct UpdateAvailableView: View { let update: UpdateState.UpdateAvailable let dismiss: DismissAction - + private let labelWidth: CGFloat = 60 - + var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text("Update Available") .font(.system(size: 13, weight: .semibold)) - + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text("Version:") @@ -141,7 +141,7 @@ fileprivate struct UpdateAvailableView: View { Text(update.appcastItem.displayVersionString) } .font(.system(size: 11)) - + if update.appcastItem.contentLength > 0 { HStack(spacing: 6) { Text("Size:") @@ -151,7 +151,7 @@ fileprivate struct UpdateAvailableView: View { } .font(.system(size: 11)) } - + if let date = update.appcastItem.date { HStack(spacing: 6) { Text("Released:") @@ -164,23 +164,23 @@ fileprivate struct UpdateAvailableView: View { } .textSelection(.enabled) } - + HStack(spacing: 8) { Button("Skip") { update.reply(.skip) dismiss() } .controlSize(.small) - + Button("Later") { update.reply(.dismiss) dismiss() } .controlSize(.small) .keyboardShortcut(.cancelAction) - + Spacer() - + Button("Install and Relaunch") { update.reply(.install) dismiss() @@ -191,10 +191,10 @@ fileprivate struct UpdateAvailableView: View { } } .padding(16) - + if let notes = update.releaseNotes { Divider() - + Link(destination: notes.url) { HStack { Image(systemName: "doc.text") @@ -217,16 +217,16 @@ fileprivate struct UpdateAvailableView: View { } } -fileprivate struct DownloadingView: View { +private struct DownloadingView: View { let download: UpdateState.Downloading let dismiss: DismissAction - + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Downloading Update") .font(.system(size: 13, weight: .semibold)) - + if let expectedLength = download.expectedLength, expectedLength > 0 { let progress = min(1, max(0, Double(download.progress) / Double(expectedLength))) VStack(alignment: .leading, spacing: 6) { @@ -240,7 +240,7 @@ fileprivate struct DownloadingView: View { .controlSize(.small) } } - + HStack { Spacer() Button("Cancel") { @@ -255,14 +255,14 @@ fileprivate struct DownloadingView: View { } } -fileprivate struct ExtractingView: View { +private struct ExtractingView: View { let extracting: UpdateState.Extracting - + var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Preparing Update") .font(.system(size: 13, weight: .semibold)) - + VStack(alignment: .leading, spacing: 6) { ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0) Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100)) @@ -274,22 +274,22 @@ fileprivate struct ExtractingView: View { } } -fileprivate struct InstallingView: View { +private struct InstallingView: View { let installing: UpdateState.Installing let dismiss: DismissAction - + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Restart Required") .font(.system(size: 13, weight: .semibold)) - + Text("The update is ready. Please restart the application to complete the installation.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } - + HStack { Button("Restart Later") { installing.dismiss() @@ -297,9 +297,9 @@ fileprivate struct InstallingView: View { } .keyboardShortcut(.cancelAction) .controlSize(.small) - + Spacer() - + Button("Restart Now") { installing.retryTerminatingApplication() dismiss() @@ -313,22 +313,22 @@ fileprivate struct InstallingView: View { } } -fileprivate struct NotFoundView: View { +private struct NotFoundView: View { let notFound: UpdateState.NotFound let dismiss: DismissAction - + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("No Updates Found") .font(.system(size: 13, weight: .semibold)) - + Text("You're already running the latest version.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } - + HStack { Spacer() Button("OK") { @@ -343,10 +343,10 @@ fileprivate struct NotFoundView: View { } } -fileprivate struct UpdateErrorView: View { +private struct UpdateErrorView: View { let error: UpdateState.Error let dismiss: DismissAction - + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { @@ -357,13 +357,13 @@ fileprivate struct UpdateErrorView: View { Text("Update Failed") .font(.system(size: 13, weight: .semibold)) } - + Text(error.error.localizedDescription) .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } - + HStack(spacing: 8) { Button("OK") { error.dismiss() @@ -371,9 +371,9 @@ fileprivate struct UpdateErrorView: View { } .keyboardShortcut(.cancelAction) .controlSize(.small) - + Spacer() - + Button("Retry") { error.retry() dismiss() diff --git a/macos/Sources/Features/Update/UpdateSimulator.swift b/macos/Sources/Features/Update/UpdateSimulator.swift index bf168d9fc..c893993e0 100644 --- a/macos/Sources/Features/Update/UpdateSimulator.swift +++ b/macos/Sources/Features/Update/UpdateSimulator.swift @@ -9,31 +9,31 @@ import Sparkle enum UpdateSimulator { /// Complete successful update flow: checking → available → download → extract → ready → install → idle case happyPath - + /// No updates available: checking (2s) → "No Updates Available" (3s) → idle case notFound - + /// Error during check: checking (2s) → error with retry callback case error - + /// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install case slowDownload - + /// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted case permissionRequest - + /// User cancels during download: checking → available → download (5 steps) → cancels → idle case cancelDuringDownload - + /// User cancels while checking: checking (1s) → cancels → idle case cancelDuringChecking - + /// Shows the installing state with restart button: installing (stays until dismissed) case installing - + /// Simulates auto-update flow: goes directly to installing state without showing intermediate UI case autoUpdate - + func simulate(with viewModel: UpdateViewModel) { switch self { case .happyPath: @@ -56,12 +56,12 @@ enum UpdateSimulator { simulateAutoUpdate(viewModel) } } - + private func simulateHappyPath(_ viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: { viewModel.state = .idle })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { viewModel.state = .updateAvailable(.init( appcastItem: SUAppcastItem.empty(), @@ -75,28 +75,28 @@ enum UpdateSimulator { )) } } - + private func simulateNotFound(_ viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: { viewModel.state = .idle })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { viewModel.state = .notFound(.init(acknowledgement: { // Acknowledgement called when dismissed })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { viewModel.state = .idle } } } - + private func simulateError(_ viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: { viewModel.state = .idle })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { viewModel.state = .error(.init( error: NSError(domain: "UpdateError", code: 1, userInfo: [ @@ -111,12 +111,12 @@ enum UpdateSimulator { )) } } - + private func simulateSlowDownload(_ viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: { viewModel.state = .idle })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { viewModel.state = .updateAvailable(.init( appcastItem: SUAppcastItem.empty(), @@ -130,7 +130,7 @@ enum UpdateSimulator { )) } } - + private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) { let download = UpdateState.Downloading( cancel: { @@ -140,7 +140,7 @@ enum UpdateSimulator { progress: 0 ) viewModel.state = .downloading(download) - + for i in 1...20 { DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) { let updatedDownload = UpdateState.Downloading( @@ -149,7 +149,7 @@ enum UpdateSimulator { progress: UInt64(i * 100) ) viewModel.state = .downloading(updatedDownload) - + if i == 20 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { simulateExtract(viewModel) @@ -158,7 +158,7 @@ enum UpdateSimulator { } } } - + private func simulatePermissionRequest(_ viewModel: UpdateViewModel) { let request = SPUUpdatePermissionRequest(systemProfile: []) viewModel.state = .permissionRequest(.init( @@ -172,12 +172,12 @@ enum UpdateSimulator { } )) } - + private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: { viewModel.state = .idle })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { viewModel.state = .updateAvailable(.init( appcastItem: SUAppcastItem.empty(), @@ -191,7 +191,7 @@ enum UpdateSimulator { )) } } - + private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) { let download = UpdateState.Downloading( cancel: { @@ -201,7 +201,7 @@ enum UpdateSimulator { progress: 0 ) viewModel.state = .downloading(download) - + for i in 1...5 { DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { let updatedDownload = UpdateState.Downloading( @@ -210,7 +210,7 @@ enum UpdateSimulator { progress: UInt64(i * 100) ) viewModel.state = .downloading(updatedDownload) - + if i == 5 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { viewModel.state = .idle @@ -219,17 +219,17 @@ enum UpdateSimulator { } } } - + private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) { viewModel.state = .checking(.init(cancel: { viewModel.state = .idle })) - + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { viewModel.state = .idle } } - + private func simulateDownload(_ viewModel: UpdateViewModel) { let download = UpdateState.Downloading( cancel: { @@ -239,7 +239,7 @@ enum UpdateSimulator { progress: 0 ) viewModel.state = .downloading(download) - + for i in 1...10 { DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) { let updatedDownload = UpdateState.Downloading( @@ -248,7 +248,7 @@ enum UpdateSimulator { progress: UInt64(i * 100) ) viewModel.state = .downloading(updatedDownload) - + if i == 10 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { simulateExtract(viewModel) @@ -257,14 +257,14 @@ enum UpdateSimulator { } } } - + private func simulateExtract(_ viewModel: UpdateViewModel) { viewModel.state = .extracting(.init(progress: 0.0)) - + for j in 1...5 { DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) { viewModel.state = .extracting(.init(progress: Double(j) / 5.0)) - + if j == 5 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { simulateInstalling(viewModel) @@ -273,7 +273,7 @@ enum UpdateSimulator { } } } - + private func simulateInstalling(_ viewModel: UpdateViewModel) { viewModel.state = .installing(.init( retryTerminatingApplication: { @@ -285,7 +285,7 @@ enum UpdateSimulator { } )) } - + private func simulateAutoUpdate(_ viewModel: UpdateViewModel) { viewModel.state = .installing(.init( isAutoUpdate: true, diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift index 1f9304616..8e66f4a16 100644 --- a/macos/Sources/Features/Update/UpdateViewModel.swift +++ b/macos/Sources/Features/Update/UpdateViewModel.swift @@ -4,7 +4,7 @@ import Sparkle class UpdateViewModel: ObservableObject { @Published var state: UpdateState = .idle - + /// The text to display for the current update state. /// Returns an empty string for idle state, progress percentages for downloading/extracting, /// or descriptive text for other states. @@ -38,7 +38,7 @@ class UpdateViewModel: ObservableObject { return err.error.localizedDescription } } - + /// The maximum width text for states that show progress. /// Used to prevent the pill from resizing as percentages change. var maxWidthText: String { @@ -51,7 +51,7 @@ class UpdateViewModel: ObservableObject { return text } } - + /// The SF Symbol icon name for the current update state. var iconName: String? { switch state { @@ -75,7 +75,7 @@ class UpdateViewModel: ObservableObject { return "exclamationmark.triangle.fill" } } - + /// A longer description for the current update state. /// Used in contexts like the command palette where more detail is helpful. var description: String { @@ -100,7 +100,7 @@ class UpdateViewModel: ObservableObject { return "An error occurred during the update process" } } - + /// A badge to display for the current update state. /// Returns version numbers, progress percentages, or nil. var badge: String? { @@ -120,7 +120,7 @@ class UpdateViewModel: ObservableObject { return nil } } - + /// The color to apply to the icon for the current update state. var iconColor: Color { switch state { @@ -140,7 +140,7 @@ class UpdateViewModel: ObservableObject { return .orange } } - + /// The background color for the update pill. var backgroundColor: Color { switch state { @@ -156,7 +156,7 @@ class UpdateViewModel: ObservableObject { return Color(nsColor: .controlBackgroundColor) } } - + /// The foreground (text) color for the update pill. var foregroundColor: Color { switch state { @@ -184,27 +184,27 @@ enum UpdateState: Equatable { case downloading(Downloading) case extracting(Extracting) case installing(Installing) - + var isIdle: Bool { if case .idle = self { return true } return false } - + /// This is true if we're in a state that can be force installed. var isInstallable: Bool { - switch (self) { + switch self { case .checking, .updateAvailable, .downloading, .extracting, .installing: return true - + default: return false } } - + func cancel() { switch self { case .checking(let checking): @@ -221,7 +221,7 @@ enum UpdateState: Equatable { break } } - + /// Confirms or accepts the current update state. /// - For available updates: begins installation /// - For ready-to-install: proceeds with installation @@ -233,7 +233,7 @@ enum UpdateState: Equatable { break } } - + static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { switch (lhs, rhs) { case (.idle, .idle): @@ -258,38 +258,38 @@ enum UpdateState: Equatable { return false } } - + struct NotFound { let acknowledgement: () -> Void } - + struct PermissionRequest { let request: SPUUpdatePermissionRequest let reply: @Sendable (SUUpdatePermissionResponse) -> Void } - + struct Checking { let cancel: () -> Void } - + struct UpdateAvailable { let appcastItem: SUAppcastItem let reply: @Sendable (SPUUserUpdateChoice) -> Void - + var releaseNotes: ReleaseNotes? { let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit) } } - + enum ReleaseNotes { case commit(URL) case compareTip(URL) case tagged(URL) - + init?(displayVersionString: String, currentCommit: String?) { let version = displayVersionString - + // Check for semantic version (x.y.z) if let semver = Self.extractSemanticVersion(from: version) { let slug = semver.replacingOccurrences(of: ".", with: "-") @@ -298,12 +298,12 @@ enum UpdateState: Equatable { return } } - + // Fall back to git hash detection guard let newHash = Self.extractGitHash(from: version) else { return nil } - + if let currentHash = currentCommit, !currentHash.isEmpty, let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") { self = .compareTip(url) @@ -313,7 +313,7 @@ enum UpdateState: Equatable { return nil } } - + private static func extractSemanticVersion(from version: String) -> String? { let pattern = #"^\d+\.\d+\.\d+$"# if version.range(of: pattern, options: .regularExpression) != nil { @@ -321,7 +321,7 @@ enum UpdateState: Equatable { } return nil } - + private static func extractGitHash(from version: String) -> String? { let pattern = #"[0-9a-f]{7,40}"# if let range = version.range(of: pattern, options: .regularExpression) { @@ -329,7 +329,7 @@ enum UpdateState: Equatable { } return nil } - + var url: URL { switch self { case .commit(let url): return url @@ -337,32 +337,32 @@ enum UpdateState: Equatable { case .tagged(let url): return url } } - + var label: String { - switch (self) { + switch self { case .commit: return "View GitHub Commit" case .compareTip: return "Changes Since This Tip Release" case .tagged: return "View Release Notes" } } } - + struct Error { let error: any Swift.Error let retry: () -> Void let dismiss: () -> Void } - + struct Downloading { let cancel: () -> Void let expectedLength: UInt64? let progress: UInt64 } - + struct Extracting { let progress: Double } - + struct Installing { /// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)`` var isAutoUpdate = false diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 91f1491dd..f3842fc56 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -18,7 +18,7 @@ extension Ghostty.Action { } init(c: ghostty_action_color_change_s) { - switch (c.kind) { + switch c.kind { case GHOSTTY_ACTION_COLOR_KIND_FOREGROUND: self.kind = .foreground case GHOSTTY_ACTION_COLOR_KIND_BACKGROUND: @@ -40,13 +40,13 @@ extension Ghostty.Action { self.amount = c.amount } } - + struct OpenURL { enum Kind { case unknown case text case html - + init(_ c: ghostty_action_open_url_kind_e) { switch c { case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: @@ -58,13 +58,13 @@ extension Ghostty.Action { } } } - + let kind: Kind let url: String - + init(c: ghostty_action_open_url_s) { self.kind = Kind(c.kind) - + if let urlCString = c.url { let data = Data(bytes: urlCString, count: Int(c.len)) self.url = String(data: data, encoding: .utf8) ?? "" @@ -81,7 +81,7 @@ extension Ghostty.Action { case error case indeterminate case pause - + init(_ c: ghostty_action_progress_report_state_e) { switch c { case GHOSTTY_PROGRESS_STATE_REMOVE: @@ -99,26 +99,26 @@ extension Ghostty.Action { } } } - + let state: State let progress: UInt8? } - + struct Scrollbar { let total: UInt64 let offset: UInt64 let len: UInt64 - + init(c: ghostty_action_scrollbar_s) { total = c.total - offset = c.offset + offset = c.offset len = c.len } } struct StartSearch { let needle: String? - + init(c: ghostty_action_start_search_s) { if let needleCString = c.needle { self.needle = String(cString: needleCString) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index e3441257f..89b2f18f1 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -33,7 +33,7 @@ extension Ghostty { private var configPath: String? /// The ghostty app instance. We only have one of these for the entire app, although I guess /// in theory you can have multiple... I don't know why you would... - @Published var app: ghostty_app_t? = nil { + @Published var app: ghostty_app_t? { didSet { guard let old = oldValue else { return } ghostty_app_free(old) @@ -140,7 +140,7 @@ extension Ghostty { guard let app = self.app else { return } // Soft updates just call with our existing config - if (soft) { + if soft { ghostty_app_update_config(app, config.config!) return } @@ -158,7 +158,7 @@ extension Ghostty { func reloadConfig(surface: ghostty_surface_t, soft: Bool = false) { // Soft updates just call with our existing config - if (soft) { + if soft { ghostty_surface_update_config(surface, config.config!) return } @@ -183,14 +183,14 @@ extension Ghostty { func newTab(surface: ghostty_surface_t) { let action = "new_tab" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } func newWindow(surface: ghostty_surface_t) { let action = "new_window" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } @@ -213,14 +213,14 @@ extension Ghostty { func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } func toggleFullscreen(surface: ghostty_surface_t) { let action = "toggle_fullscreen" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } @@ -241,21 +241,21 @@ extension Ghostty { case .reset: action = "reset_font_size" } - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } func resetTerminal(surface: ghostty_surface_t) { let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { logger.warning("action failed action=\(action)") } } @@ -312,7 +312,6 @@ extension Ghostty { ghostty_app_set_focus(app, false) } - // MARK: Ghostty Callbacks (macOS) static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) { @@ -379,25 +378,25 @@ extension Ghostty { let surface = self.surfaceUserdata(from: userdata) guard let pasteboard = NSPasteboard.ghostty(location) else { return } guard let content = content, len > 0 else { return } - + // Convert the C array to Swift array let contentArray = (0.. Bool { // Make sure it a target we understand so all our action handlers can assert - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP, GHOSTTY_TARGET_SURFACE: break @@ -473,7 +472,7 @@ extension Ghostty { } // Action dispatch - switch (action.tag) { + switch action.tag { case GHOSTTY_ACTION_QUIT: quit(app) @@ -605,7 +604,7 @@ extension Ghostty { case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) - + case GHOSTTY_ACTION_OPEN_URL: return openURL(action.action.open_url) @@ -681,12 +680,12 @@ extension Ghostty { appDelegate.checkForUpdates(nil) } } - + private static func openURL( _ v: ghostty_action_open_url_s ) -> Bool { let action = Ghostty.Action.OpenURL(c: v) - + // If the URL doesn't have a valid scheme we assume its a file path. The URL // initializer will gladly take invalid URLs (e.g. plain file paths) and turn // them into schema-less URLs, but these won't open properly in text editors. @@ -695,9 +694,12 @@ extension Ghostty { if let candidate = URL(string: action.url), candidate.scheme != nil { url = candidate } else { - url = URL(filePath: action.url) + // Expand ~ to the user's home directory so that file paths + // like ~/Documents/file.txt resolve correctly. + let expandedPath = NSString(string: action.url).standardizingPath + url = URL(filePath: expandedPath) } - + switch action.kind { case .text: // Open with the default editor for `*.ghostty` file or just system text editor @@ -706,15 +708,15 @@ extension Ghostty { NSWorkspace.shared.open([url], withApplicationAt: textEditor, configuration: NSWorkspace.OpenConfiguration()) return true } - + case .html: // The extension will be HTML and we do the right thing automatically. break - + case .unknown: break } - + // Open with the default application for the URL NSWorkspace.shared.open(url) return true @@ -722,7 +724,7 @@ extension Ghostty { private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { let undoManager: UndoManager? - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: undoManager = (NSApp.delegate as? AppDelegate)?.undoManager @@ -743,7 +745,7 @@ extension Ghostty { private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { let undoManager: UndoManager? - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: undoManager = (NSApp.delegate as? AppDelegate)?.undoManager @@ -763,7 +765,7 @@ extension Ghostty { } private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: NotificationCenter.default.post( name: Notification.ghosttyNewWindow, @@ -782,14 +784,13 @@ extension Ghostty { ] ) - default: assertionFailure() } } private static func newTab(_ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: NotificationCenter.default.post( name: Notification.ghosttyNewTab, @@ -819,7 +820,6 @@ extension Ghostty { ] ) - default: assertionFailure() } @@ -829,7 +829,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, direction: ghostty_action_split_direction_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: // New split does nothing with an app target Ghostty.logger.warning("new split does nothing with an app target") @@ -848,7 +848,6 @@ extension Ghostty { ] ) - default: assertionFailure() } @@ -858,7 +857,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s ) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: return false @@ -879,7 +878,7 @@ extension Ghostty { } private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("close tabs does nothing with an app target") return @@ -888,7 +887,7 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - switch (mode) { + switch mode { case GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS: NotificationCenter.default.post( name: .ghosttyCloseTab, @@ -914,14 +913,13 @@ extension Ghostty { assertionFailure() } - default: assertionFailure() } } private static func closeWindow(_ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("close window does nothing with an app target") return @@ -949,7 +947,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, mode raw: ghostty_action_fullscreen_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle fullscreen does nothing with an app target") return @@ -969,7 +967,6 @@ extension Ghostty { ] ) - default: assertionFailure() } @@ -978,7 +975,7 @@ extension Ghostty { private static func toggleCommandPalette( _ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle command palette does nothing with an app target") return @@ -991,7 +988,6 @@ extension Ghostty { object: surfaceView ) - default: assertionFailure() } @@ -1001,7 +997,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s ) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle maximize does nothing with an app target") return @@ -1014,7 +1010,6 @@ extension Ghostty { object: surfaceView ) - default: assertionFailure() } @@ -1031,7 +1026,7 @@ extension Ghostty { private static func ringBell( _ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: // Technically we could still request app attention here but there // are no known cases where the bell is rang with an app target so @@ -1056,7 +1051,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_readonly_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("set readonly does nothing with an app target") return @@ -1081,7 +1076,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, move: ghostty_action_move_tab_s) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("move tab does nothing with an app target") return false @@ -1112,7 +1107,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, tab: ghostty_action_goto_tab_e) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("goto tab does nothing with an app target") return false @@ -1144,7 +1139,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, direction: ghostty_action_goto_split_e) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("goto split does nothing with an app target") return false @@ -1250,7 +1245,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, resize: ghostty_action_resize_split_s) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("resize split does nothing with an app target") return false @@ -1283,7 +1278,7 @@ extension Ghostty { private static func equalizeSplits( _ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("equalize splits does nothing with an app target") return @@ -1296,7 +1291,6 @@ extension Ghostty { object: surfaceView ) - default: assertionFailure() } @@ -1305,7 +1299,7 @@ extension Ghostty { private static func toggleSplitZoom( _ app: ghostty_app_t, target: ghostty_target_s) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle split zoom does nothing with an app target") return false @@ -1324,7 +1318,6 @@ extension Ghostty { ) return true - default: assertionFailure() return false @@ -1335,7 +1328,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_inspector_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle inspector does nothing with an app target") return @@ -1349,7 +1342,6 @@ extension Ghostty { userInfo: ["mode": mode] ) - default: assertionFailure() } @@ -1359,7 +1351,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, n: ghostty_action_desktop_notification_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle split zoom does nothing with an app target") return @@ -1377,12 +1369,11 @@ extension Ghostty { } } - center.getNotificationSettings() { settings in + center.getNotificationSettings { settings in guard settings.authorizationStatus == .authorized else { return } surfaceView.showUserNotification(title: title, body: body) } - default: assertionFailure() } @@ -1395,7 +1386,7 @@ extension Ghostty { ) { guard let mode = SetFloatWIndow.from(mode_raw) else { return } - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle float window does nothing with an app target") return @@ -1405,7 +1396,7 @@ extension Ghostty { guard let surfaceView = self.surfaceView(from: surface) else { return } guard let window = surfaceView.window as? TerminalWindow else { return } - switch (mode) { + switch mode { case .on: window.level = .floating @@ -1429,7 +1420,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s ) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("toggle background opacity does nothing with an app target") return @@ -1453,7 +1444,7 @@ extension Ghostty { ) { guard let mode = SetSecureInput.from(mode_raw) else { return } - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } appDelegate.setSecureInput(mode) @@ -1464,7 +1455,7 @@ extension Ghostty { guard let appState = self.appState(fromView: surfaceView) else { return } guard appState.config.autoSecureInput else { return } - switch (mode) { + switch mode { case .on: surfaceView.passwordInput = true @@ -1492,7 +1483,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_set_title_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("set title does nothing with an app target") return @@ -1511,7 +1502,7 @@ extension Ghostty { private static func copyTitleToClipboard( _ app: ghostty_app_t, target: ghostty_target_s) -> Bool { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return false } guard let surfaceView = self.surfaceView(from: surface) else { return false } @@ -1534,7 +1525,7 @@ extension Ghostty { let promptTitle = Action.PromptTitle(v) switch promptTitle { case .surface: - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("set title prompt does nothing with an app target") return false @@ -1551,7 +1542,7 @@ extension Ghostty { } case .tab: - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: guard let window = NSApp.mainWindow ?? NSApp.keyWindow, let controller = window.windowController as? BaseTerminalController @@ -1579,7 +1570,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_pwd_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("pwd change does nothing with an app target") return @@ -1599,7 +1590,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, shape: ghostty_action_mouse_shape_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("set mouse shapes nothing with an app target") return @@ -1609,7 +1600,6 @@ extension Ghostty { guard let surfaceView = self.surfaceView(from: surface) else { return } surfaceView.setCursorShape(shape) - default: assertionFailure() } @@ -1619,7 +1609,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_mouse_visibility_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("set mouse shapes nothing with an app target") return @@ -1627,7 +1617,7 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - switch (v) { + switch v { case GHOSTTY_MOUSE_VISIBLE: surfaceView.setCursorVisibility(true) @@ -1638,7 +1628,6 @@ extension Ghostty { return } - default: assertionFailure() } @@ -1648,7 +1637,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_mouse_over_link_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("mouse over link does nothing with an app target") return @@ -1664,7 +1653,6 @@ extension Ghostty { let buffer = Data(bytes: v.url!, count: v.len) surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) - default: assertionFailure() } @@ -1674,7 +1662,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_initial_size_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("initial size does nothing with an app target") return @@ -1682,8 +1670,7 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - surfaceView.initialSize = NSMakeSize(Double(v.width), Double(v.height)) - + surfaceView.initialSize = NSSize(width: Double(v.width), height: Double(v.height)) default: assertionFailure() @@ -1693,7 +1680,7 @@ extension Ghostty { private static func resetWindowSize( _ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("reset window size does nothing with an app target") return @@ -1706,7 +1693,6 @@ extension Ghostty { object: surfaceView ) - default: assertionFailure() } @@ -1716,7 +1702,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_cell_size_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("mouse over link does nothing with an app target") return @@ -1738,7 +1724,7 @@ extension Ghostty { private static func renderInspector( _ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("mouse over link does nothing with an app target") return @@ -1760,7 +1746,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_renderer_health_e) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("mouse over link does nothing with an app target") return @@ -1785,7 +1771,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_key_sequence_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("key sequence does nothing with an app target") return @@ -1817,7 +1803,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_key_table_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("key table does nothing with an app target") return @@ -1842,7 +1828,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_progress_report_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("progress report does nothing with an app target") return @@ -1850,7 +1836,7 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - + let progressReport = Ghostty.Action.ProgressReport(c: v) DispatchQueue.main.async { if progressReport.state == .remove { @@ -1869,7 +1855,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_scrollbar_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("scrollbar does nothing with an app target") return @@ -1877,7 +1863,7 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - + let scrollbar = Ghostty.Action.Scrollbar(c: v) NotificationCenter.default.post( name: .ghosttyDidUpdateScrollbar, @@ -1896,7 +1882,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_start_search_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("start_search does nothing with an app target") return @@ -1914,7 +1900,7 @@ extension Ghostty { } else { surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) } - + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) } @@ -1926,7 +1912,7 @@ extension Ghostty { private static func endSearch( _ app: ghostty_app_t, target: ghostty_target_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("end_search does nothing with an app target") return @@ -1948,7 +1934,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_search_total_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("search_total does nothing with an app target") return @@ -1971,7 +1957,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, v: ghostty_action_search_selected_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("search_selected does nothing with an app target") return @@ -1993,14 +1979,13 @@ extension Ghostty { private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, - v: ghostty_action_reload_config_s) - { + v: ghostty_action_reload_config_s) { logger.info("config reload notification") guard let app_ud = ghostty_app_userdata(app) else { return } let ghostty = Unmanaged.fromOpaque(app_ud).takeUnretainedValue() - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: ghostty.reloadConfig(soft: v.soft) return @@ -2026,7 +2011,7 @@ extension Ghostty { // something so apprt's do not have to do this. let config = Config(clone: v.config) - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: // Notify the world that the app config changed NotificationCenter.default.post( @@ -2066,7 +2051,7 @@ extension Ghostty { _ app: ghostty_app_t, target: ghostty_target_s, change: ghostty_action_color_change_s) { - switch (target.tag) { + switch target.tag { case GHOSTTY_TARGET_APP: Ghostty.logger.warning("color change does nothing with an app target") return @@ -2087,7 +2072,6 @@ extension Ghostty { } } - // MARK: User Notifications /// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user @@ -2097,7 +2081,7 @@ extension Ghostty { let uuid = UUID(uuidString: uuidString), let surface = delegate?.findSurface(forUUID: uuid) else { return } - switch (response.actionIdentifier) { + switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier, Ghostty.userNotificationActionShow: // The user clicked on a notification surface.handleUserNotification(notification: response.notification, focus: true) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index c64646e25..d65bac27f 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -7,7 +7,7 @@ extension Ghostty { // The underlying C pointer to the Ghostty config structure. This // should never be accessed directly. Any operations on this should // be called from the functions on this or another class. - private(set) var config: ghostty_config_t? = nil { + private(set) var config: ghostty_config_t? { didSet { // Free the old value whenever we change guard let old = oldValue else { return } @@ -22,7 +22,7 @@ extension Ghostty { var errors: [String] { guard let cfg = self.config else { return [] } - var diags: [String] = []; + var diags: [String] = [] let diagsCount = ghostty_config_diagnostics_count(cfg) for i in 0.. 0 { logger.warning("config error: \(diagsCount) configuration errors on reload") - var diags: [String] = []; + var diags: [String] = [] for i in 0..? = nil + var v: UnsafePointer? let key = "title" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } @@ -169,7 +169,7 @@ extension Ghostty { var windowSaveState: String { guard let config = self.config else { return "" } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "window-save-state" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } @@ -192,7 +192,7 @@ extension Ghostty { var windowNewTabPosition: String { guard let config = self.config else { return "" } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "window-new-tab-position" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } @@ -202,7 +202,7 @@ extension Ghostty { var windowDecorations: Bool { let defaultValue = true guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "window-decoration" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -212,7 +212,7 @@ extension Ghostty { var windowTheme: String? { guard let config = self.config else { return nil } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "window-theme" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } @@ -227,19 +227,51 @@ extension Ghostty { return v } - var windowFullscreen: Bool { - guard let config = self.config else { return true } - var v = false + /// Returns the fullscreen mode if fullscreen is enabled, or nil if disabled. + /// This parses the `fullscreen` enum config which supports both + /// native and non-native fullscreen modes. + #if canImport(AppKit) + var windowFullscreen: FullscreenMode? { + guard let config = self.config else { return nil } + var v: UnsafePointer? let key = "fullscreen" - _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } + guard let ptr = v else { return nil } + let str = String(cString: ptr) + return switch str { + case "false": + nil + case "true": + .native + case "non-native": + .nonNative + case "non-native-visible-menu": + .nonNativeVisibleMenu + case "non-native-padded-notch": + .nonNativePaddedNotch + default: + nil + } } + #else + var windowFullscreen: Bool { + guard let config = self.config else { return false } + var v: UnsafePointer? + let key = "fullscreen" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return false } + guard let ptr = v else { return false } + let str = String(cString: ptr) + return str != "false" + } + #endif + /// Returns the fullscreen mode for toggle actions (keybindings). + /// This is controlled by `macos-non-native-fullscreen` config. #if canImport(AppKit) var windowFullscreenMode: FullscreenMode { let defaultValue: FullscreenMode = .native guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-non-native-fullscreen" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -261,7 +293,7 @@ extension Ghostty { var windowTitleFontFamily: String? { guard let config = self.config else { return nil } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "window-title-font-family" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } @@ -271,7 +303,7 @@ extension Ghostty { var macosWindowButtons: MacOSWindowButtons { let defaultValue = MacOSWindowButtons.visible guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-window-buttons" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -282,7 +314,7 @@ extension Ghostty { var macosTitlebarStyle: String { let defaultValue = "transparent" guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-titlebar-style" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -292,7 +324,7 @@ extension Ghostty { var macosTitlebarProxyIcon: MacOSTitlebarProxyIcon { let defaultValue = MacOSTitlebarProxyIcon.visible guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-titlebar-proxy-icon" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -303,7 +335,7 @@ extension Ghostty { var macosDockDropBehavior: MacDockDropBehavior { let defaultValue = MacDockDropBehavior.new_tab guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-dock-drop-behavior" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -313,7 +345,7 @@ extension Ghostty { var macosWindowShadow: Bool { guard let config = self.config else { return false } - var v = false; + var v = false let key = "macos-window-shadow" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v @@ -322,7 +354,7 @@ extension Ghostty { var macosIcon: MacOSIcon { let defaultValue = MacOSIcon.official guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-icon" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -334,7 +366,7 @@ extension Ghostty { #if os(macOS) let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-custom-icon" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -348,7 +380,7 @@ extension Ghostty { var macosIconFrame: MacOSIconFrame { let defaultValue = MacOSIconFrame.aluminum guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-icon-frame" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -376,7 +408,7 @@ extension Ghostty { var macosHidden: MacHidden { guard let config = self.config else { return .never } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-hidden" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never } guard let ptr = v else { return .never } @@ -384,18 +416,18 @@ extension Ghostty { return MacHidden(rawValue: str) ?? .never } - var focusFollowsMouse : Bool { + var focusFollowsMouse: Bool { guard let config = self.config else { return false } - var v = false; + var v = false let key = "focus-follows-mouse" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } var backgroundColor: Color { - var color: ghostty_config_color_s = .init(); + var color: ghostty_config_color_s = .init() let bg_key = "background" - if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8)))) { + if !ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))) { #if os(macOS) return Color(NSColor.windowBackgroundColor) #elseif os(iOS) @@ -417,7 +449,7 @@ extension Ghostty { var v: Double = 1 let key = "background-opacity" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v; + return v } var backgroundBlur: BackgroundBlur { @@ -439,11 +471,11 @@ extension Ghostty { var unfocusedSplitFill: Color { guard let config = self.config else { return .white } - var color: ghostty_config_color_s = .init(); + var color: ghostty_config_color_s = .init() let key = "unfocused-split-fill" - if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { + if !ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8))) { let bg_key = "background" - _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))); + _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))) } return .init( @@ -460,9 +492,9 @@ extension Ghostty { guard let config = self.config else { return Color(newColor) } - var color: ghostty_config_color_s = .init(); + var color: ghostty_config_color_s = .init() let key = "split-divider-color" - if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { + if !ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8))) { return Color(newColor) } @@ -476,7 +508,7 @@ extension Ghostty { #if canImport(AppKit) var quickTerminalPosition: QuickTerminalPosition { guard let config = self.config else { return .top } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "quick-terminal-position" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .top } guard let ptr = v else { return .top } @@ -486,7 +518,7 @@ extension Ghostty { var quickTerminalScreen: QuickTerminalScreen { guard let config = self.config else { return .main } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "quick-terminal-screen" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .main } guard let ptr = v else { return .main } @@ -512,7 +544,7 @@ extension Ghostty { var quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior { guard let config = self.config else { return .move } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "quick-terminal-space-behavior" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .move } guard let ptr = v else { return .move } @@ -531,7 +563,7 @@ extension Ghostty { var resizeOverlay: ResizeOverlay { guard let config = self.config else { return .after_first } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "resize-overlay" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .after_first } guard let ptr = v else { return .after_first } @@ -542,7 +574,7 @@ extension Ghostty { var resizeOverlayPosition: ResizeOverlayPosition { let defaultValue = ResizeOverlayPosition.center guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "resize-overlay-position" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -555,7 +587,7 @@ extension Ghostty { var v: UInt = 0 let key = "resize-overlay-duration" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v; + return v } var undoTimeout: Duration { @@ -568,7 +600,7 @@ extension Ghostty { var autoUpdate: AutoUpdate? { guard let config = self.config else { return nil } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "auto-update" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } @@ -579,7 +611,7 @@ extension Ghostty { var autoUpdateChannel: AutoUpdateChannel { let defaultValue = AutoUpdateChannel.stable guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "auto-update-channel" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -589,7 +621,7 @@ extension Ghostty { var autoSecureInput: Bool { guard let config = self.config else { return true } - var v = false; + var v = false let key = "macos-auto-secure-input" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v @@ -597,7 +629,7 @@ extension Ghostty { var secureInputIndication: Bool { guard let config = self.config else { return true } - var v = false; + var v = false let key = "macos-secure-input-indication" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v @@ -605,7 +637,7 @@ extension Ghostty { var maximize: Bool { guard let config = self.config else { return true } - var v = false; + var v = false let key = "maximize" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v @@ -614,7 +646,7 @@ extension Ghostty { var macosShortcuts: MacShortcuts { let defaultValue = MacShortcuts.ask guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "macos-shortcuts" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -625,7 +657,7 @@ extension Ghostty { var scrollbar: Scrollbar { let defaultValue = Scrollbar.system guard let config = self.config else { return defaultValue } - var v: UnsafePointer? = nil + var v: UnsafePointer? let key = "scrollbar" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } @@ -648,7 +680,7 @@ extension Ghostty { // MARK: Configuration Enums extension Ghostty.Config { - enum AutoUpdate : String { + enum AutoUpdate: String { case off case check case download @@ -731,13 +763,13 @@ extension Ghostty.Config { static let navigation = SplitPreserveZoom(rawValue: 1 << 0) } - + enum MacDockDropBehavior: String { case new_tab = "new-tab" case new_window = "new-window" } - enum MacHidden : String { + enum MacHidden: String { case never case always } @@ -753,13 +785,13 @@ extension Ghostty.Config { case never } - enum ResizeOverlay : String { + enum ResizeOverlay: String { case always case never case after_first = "after-first" } - enum ResizeOverlayPosition : String { + enum ResizeOverlayPosition: String { case center case top_left = "top-left" case top_center = "top-center" @@ -769,30 +801,30 @@ extension Ghostty.Config { case bottom_right = "bottom-right" func top() -> Bool { - switch (self) { - case .top_left, .top_center, .top_right: return true; - default: return false; + switch self { + case .top_left, .top_center, .top_right: return true + default: return false } } func bottom() -> Bool { - switch (self) { - case .bottom_left, .bottom_center, .bottom_right: return true; - default: return false; + switch self { + case .bottom_left, .bottom_center, .bottom_right: return true + default: return false } } func left() -> Bool { - switch (self) { - case .top_left, .bottom_left: return true; - default: return false; + switch self { + case .top_left, .bottom_left: return true + default: return false } } func right() -> Bool { - switch (self) { - case .top_right, .bottom_right: return true; - default: return false; + switch self { + case .top_right, .bottom_right: return true + default: return false } } } diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 7b2905abb..27f4d05dd 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -18,7 +18,7 @@ extension Ghostty { /// be used for things like NSMenu that only support keyboard shortcuts anyways. static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let key: KeyEquivalent - switch (trigger.tag) { + switch trigger.tag { case GHOSTTY_TRIGGER_PHYSICAL: // Only functional keys can be converted to a KeyboardShortcut. Other physical // mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent. @@ -49,11 +49,11 @@ extension Ghostty { /// Returns the event modifier flags set for the Ghostty mods enum. static func eventModifierFlags(mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { - var flags = NSEvent.ModifierFlags(rawValue: 0); - if (mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0) { flags.insert(.shift) } - if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0) { flags.insert(.control) } - if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0) { flags.insert(.option) } - if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.insert(.command) } + var flags = NSEvent.ModifierFlags(rawValue: 0) + if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) } + if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) } + if mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) } + if mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) } return flags } @@ -61,19 +61,19 @@ extension Ghostty { static func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue - if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } - if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } - if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } - if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } - if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } + if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } + if flags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } + if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } + if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } + if flags.contains(.capsLock) { mods |= GHOSTTY_MODS_CAPS.rawValue } // Handle sided input. We can't tell that both are pressed in the // Ghostty structure but that's okay -- we don't use that information. let rawFlags = flags.rawValue - if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } - if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } + if rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0 { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } + if rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0 { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } + if rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0 { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } + if rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0 { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } return ghostty_input_mods_e(mods) } @@ -81,7 +81,7 @@ extension Ghostty { /// A map from the Ghostty key enum to the keyEquivalent string for shortcuts. Note that /// not all ghostty key enum values are represented here because not all of them can be /// mapped to a KeyEquivalent. - static let keyToEquivalent: [ghostty_input_key_e : KeyEquivalent] = [ + static let keyToEquivalent: [ghostty_input_key_e: KeyEquivalent] = [ // Function keys GHOSTTY_KEY_ARROW_UP: .upArrow, GHOSTTY_KEY_ARROW_DOWN: .downArrow, @@ -243,7 +243,7 @@ extension Ghostty.Input { extension Ghostty.Input.Action: AppEnum { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key Action") - static var caseDisplayRepresentations: [Ghostty.Input.Action : DisplayRepresentation] = [ + static var caseDisplayRepresentations: [Ghostty.Input.Action: DisplayRepresentation] = [ .release: "Release", .press: "Press", .repeat: "Repeat" @@ -355,7 +355,7 @@ extension Ghostty.Input { extension Ghostty.Input.MouseState: AppEnum { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse State") - static var caseDisplayRepresentations: [Ghostty.Input.MouseState : DisplayRepresentation] = [ + static var caseDisplayRepresentations: [Ghostty.Input.MouseState: DisplayRepresentation] = [ .release: "Release", .press: "Press" ] @@ -420,7 +420,7 @@ extension Ghostty.Input { extension Ghostty.Input.MouseButton: AppEnum { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse Button") - static var caseDisplayRepresentations: [Ghostty.Input.MouseButton : DisplayRepresentation] = [ + static var caseDisplayRepresentations: [Ghostty.Input.MouseButton: DisplayRepresentation] = [ .unknown: "Unknown", .left: "Left", .right: "Right", @@ -504,7 +504,7 @@ extension Ghostty.Input { extension Ghostty.Input.Momentum: AppEnum { static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum") - static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [ + static var caseDisplayRepresentations: [Ghostty.Input.Momentum: DisplayRepresentation] = [ .none: "None", .began: "Began", .stationary: "Stationary", @@ -1223,7 +1223,7 @@ extension Ghostty.Input.Key: AppEnum { ] } - static var caseDisplayRepresentations: [Ghostty.Input.Key : DisplayRepresentation] = [ + static var caseDisplayRepresentations: [Ghostty.Input.Key: DisplayRepresentation] = [ // Letters (A-Z) .a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J", .k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T", diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 7cb32ed71..b072db15e 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -40,7 +40,7 @@ extension Ghostty { @MainActor func sendText(_ text: String) { let len = text.utf8CString.count - if (len == 0) { return } + if len == 0 { return } text.withCString { ptr in // len includes the null terminator so we do len - 1 @@ -149,7 +149,7 @@ extension Ghostty { @MainActor func perform(action: String) -> Bool { let len = action.utf8CString.count - if (len == 0) { return false } + if len == 0 { return false } return action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(len - 1)) } diff --git a/macos/Sources/Ghostty/NSEvent+Extension.swift b/macos/Sources/Ghostty/NSEvent+Extension.swift index b67c1932e..55888944e 100644 --- a/macos/Sources/Ghostty/NSEvent+Extension.swift +++ b/macos/Sources/Ghostty/NSEvent+Extension.swift @@ -39,8 +39,7 @@ extension NSEvent { key_ev.unshifted_codepoint = 0 if type == .keyDown || type == .keyUp { if let chars = characters(byApplyingModifiers: []), - let codepoint = chars.unicodeScalars.first - { + let codepoint = chars.unicodeScalars.first { key_ev.unshifted_codepoint = codepoint.value } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 15cb3a51e..1e92eb8a1 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -100,7 +100,7 @@ extension Ghostty { case toggle static func from(_ c: ghostty_action_float_window_e) -> Self? { - switch (c) { + switch c { case GHOSTTY_FLOAT_WINDOW_ON: return .on @@ -122,7 +122,7 @@ extension Ghostty { case toggle static func from(_ c: ghostty_action_secure_input_e) -> Self? { - switch (c) { + switch c { case GHOSTTY_SECURE_INPUT_ON: return .on @@ -144,7 +144,7 @@ extension Ghostty { /// Initialize from a Ghostty API enum. static func from(direction: ghostty_action_goto_split_e) -> Self? { - switch (direction) { + switch direction { case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .previous @@ -169,7 +169,7 @@ extension Ghostty { } func toNative() -> ghostty_action_goto_split_e { - switch (self) { + switch self { case .previous: return GHOSTTY_GOTO_SPLIT_PREVIOUS @@ -196,30 +196,30 @@ extension Ghostty { case up, down, left, right static func from(direction: ghostty_action_resize_split_direction_e) -> Self? { - switch (direction) { + switch direction { case GHOSTTY_RESIZE_SPLIT_UP: - return .up; + return .up case GHOSTTY_RESIZE_SPLIT_DOWN: - return .down; + return .down case GHOSTTY_RESIZE_SPLIT_LEFT: - return .left; + return .left case GHOSTTY_RESIZE_SPLIT_RIGHT: - return .right; + return .right default: return nil } } func toNative() -> ghostty_action_resize_split_direction_e { - switch (self) { + switch self { case .up: - return GHOSTTY_RESIZE_SPLIT_UP; + return GHOSTTY_RESIZE_SPLIT_UP case .down: - return GHOSTTY_RESIZE_SPLIT_DOWN; + return GHOSTTY_RESIZE_SPLIT_DOWN case .left: - return GHOSTTY_RESIZE_SPLIT_LEFT; + return GHOSTTY_RESIZE_SPLIT_LEFT case .right: - return GHOSTTY_RESIZE_SPLIT_RIGHT; + return GHOSTTY_RESIZE_SPLIT_RIGHT } } } @@ -268,7 +268,7 @@ extension Ghostty { /// The text to show in the clipboard confirmation prompt for a given request type func text() -> String { - switch (self) { + switch self { case .paste: return """ Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. @@ -287,7 +287,7 @@ extension Ghostty { } static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? { - switch (request) { + switch request { case GHOSTTY_CLIPBOARD_REQUEST_PASTE: return .paste case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ: @@ -299,17 +299,17 @@ extension Ghostty { } } } - + struct ClipboardContent { let mime: String let data: String - + static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? { guard let mimePtr = content.mime, let dataPtr = content.data else { return nil } - + return ClipboardContent( mime: String(cString: mimePtr), data: String(cString: dataPtr) @@ -498,4 +498,4 @@ extension Ghostty.Notification { } // Make the input enum hashable. -extension ghostty_input_key_e : @retroactive Hashable {} +extension ghostty_input_key_e: @retroactive Hashable {} diff --git a/macos/Sources/Ghostty/Surface View/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift index 03be794e9..e7320c782 100644 --- a/macos/Sources/Ghostty/Surface View/InspectorView.swift +++ b/macos/Sources/Ghostty/Surface View/InspectorView.swift @@ -23,7 +23,7 @@ extension Ghostty { let pubInspector = center.publisher(for: Notification.didControlInspector, object: surfaceView) ZStack { - if (!surfaceView.inspectorVisible) { + if !surfaceView.inspectorVisible { SurfaceWrapper(surfaceView: surfaceView, isSplit: isSplit) } else { SplitView(.vertical, $split, dividerColor: ghostty.config.splitDividerColor, left: { @@ -42,7 +42,7 @@ extension Ghostty { .onChange(of: surfaceView.inspectorVisible) { inspectorVisible in // When we show the inspector, we want to focus on the inspector. // When we hide the inspector, we want to move focus back to the surface. - if (inspectorVisible) { + if inspectorVisible { // We need to delay this until SwiftUI shows the inspector. DispatchQueue.main.async { _ = surfaceView.resignFirstResponder() @@ -59,7 +59,7 @@ extension Ghostty { guard let modeAny = notification.userInfo?["mode"] else { return } guard let mode = modeAny as? ghostty_action_inspector_e else { return } - switch (mode) { + switch mode { case GHOSTTY_INSPECTOR_TOGGLE: surfaceView.inspectorVisible = !surfaceView.inspectorVisible @@ -94,7 +94,7 @@ extension Ghostty { class InspectorView: MTKView, NSTextInputClient { let commandQueue: MTLCommandQueue - var surfaceView: SurfaceView? = nil { + var surfaceView: SurfaceView? { didSet { surfaceViewDidChange() } } @@ -180,7 +180,7 @@ extension Ghostty { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() - if (result) { + if result { if let inspector = self.inspector { inspector.setFocus(true) } @@ -190,7 +190,7 @@ extension Ghostty { override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() - if (result) { + if result { if let inspector = self.inspector { inspector.setFocus(false) } @@ -275,7 +275,7 @@ extension Ghostty { // Determine our momentum value var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE - switch (event.momentumPhase) { + switch event.momentumPhase { case .began: momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN case .stationary: @@ -309,8 +309,8 @@ extension Ghostty { } override func flagsChanged(with event: NSEvent) { - let mod: UInt32; - switch (event.keyCode) { + let mod: UInt32 + switch event.keyCode { case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue @@ -325,7 +325,7 @@ extension Ghostty { // If the key that pressed this is active, its a press, else release var action = GHOSTTY_ACTION_RELEASE - if (mods.rawValue & mod != 0) { action = GHOSTTY_ACTION_PRESS } + if mods.rawValue & mod != 0 { action = GHOSTTY_ACTION_PRESS } keyAction(action, event: event) } @@ -382,7 +382,7 @@ extension Ghostty { } func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) + return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) } func insertText(_ string: Any, replacementRange: NSRange) { @@ -392,7 +392,7 @@ extension Ghostty { // We want the string view of the any value var chars = "" - switch (string) { + switch string { case let v as NSAttributedString: chars = v.string case let v as String: @@ -402,7 +402,7 @@ extension Ghostty { } let len = chars.utf8CString.count - if (len == 0) { return } + if len == 0 { return } inspector.text(chars) } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift index 37a69852e..dd2f3ef5e 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceDragSource.swift @@ -5,13 +5,13 @@ extension Ghostty { /// A preference key that propagates the ID of the SurfaceView currently being dragged, /// or nil if no surface is being dragged. struct DraggingSurfaceKey: PreferenceKey { - static var defaultValue: SurfaceView.ID? = nil - + static var defaultValue: SurfaceView.ID? + static func reduce(value: inout SurfaceView.ID?, nextValue: () -> SurfaceView.ID?) { value = nextValue() ?? value } } - + /// A SwiftUI view that provides drag source functionality for terminal surfaces. /// /// This view wraps an AppKit-based drag source to enable drag-and-drop reordering @@ -24,13 +24,13 @@ extension Ghostty { struct SurfaceDragSource: View { /// The surface view that will be dragged. let surfaceView: SurfaceView - + /// Binding that reflects whether a drag session is currently active. @Binding var isDragging: Bool - + /// Binding that reflects whether the mouse is hovering over this view. @Binding var isHovering: Bool - + var body: some View { SurfaceDragSourceViewRepresentable( surfaceView: surfaceView, @@ -46,7 +46,7 @@ extension Ghostty { let surfaceView: SurfaceView @Binding var isDragging: Bool @Binding var isHovering: Bool - + func makeNSView(context: Context) -> SurfaceDragSourceView { let view = SurfaceDragSourceView() view.surfaceView = surfaceView @@ -60,7 +60,7 @@ extension Ghostty { } return view } - + func updateNSView(_ nsView: SurfaceDragSourceView, context: Context) { nsView.surfaceView = surfaceView nsView.onDragStateChanged = { dragging in @@ -73,7 +73,7 @@ extension Ghostty { } } } - + /// The underlying NSView that handles drag operations. /// /// This view manages mouse tracking and drag initiation for surface reordering. @@ -82,26 +82,26 @@ extension Ghostty { fileprivate class SurfaceDragSourceView: NSView, NSDraggingSource { /// Scale factor applied to the surface snapshot for the drag preview image. private static let previewScale: CGFloat = 0.2 - + /// The surface view that will be dragged. Its UUID is encoded into the /// pasteboard for drop targets to identify which surface is being moved. var surfaceView: SurfaceView? - + /// Callback invoked when the drag state changes. Called with `true` when /// a drag session begins, and `false` when it ends (completed or cancelled). var onDragStateChanged: ((Bool) -> Void)? - + /// Callback invoked when the mouse enters or exits this view's bounds. /// Used to update the hover state for visual feedback in the parent view. var onHoverChanged: ((Bool) -> Void)? - + /// Whether we are currently in a mouse tracking loop (between mouseDown /// and either mouseUp or drag initiation). Used to determine cursor state. private var isTracking: Bool = false - + /// Local event monitor to detect escape key presses during drag. private var escapeMonitor: Any? - + /// Whether the current drag was cancelled by pressing escape. private var dragCancelledByEscape: Bool = false @@ -137,26 +137,26 @@ extension Ghostty { userInfo: nil )) } - + override func resetCursorRects() { addCursorRect(bounds, cursor: isTracking ? .closedHand : .openHand) } - + override func mouseEntered(with event: NSEvent) { onHoverChanged?(true) } - + override func mouseExited(with event: NSEvent) { onHoverChanged?(false) } - + override func mouseDragged(with event: NSEvent) { guard !isTracking, let surfaceView = surfaceView else { return } - + // Create our dragging item from our transferable guard let pasteboardItem = surfaceView.pasteboardItem() else { return } let item = NSDraggingItem(pasteboardWriter: pasteboardItem) - + // Create a scaled preview image from the surface snapshot if let snapshot = surfaceView.asImage { let imageSize = NSSize( @@ -172,7 +172,7 @@ extension Ghostty { fraction: 1.0 ) scaledImage.unlockFocus() - + // Position the drag image so the mouse is at the center of the image. // I personally like the top middle or top left corner best but // this matches macOS native tab dragging behavior (at least, as of @@ -187,30 +187,30 @@ extension Ghostty { contents: scaledImage ) } - + onDragStateChanged?(true) let session = beginDraggingSession(with: [item], event: event, source: self) - + // We need to disable this so that endedAt happens immediately for our // drags outside of any targets. session.animatesToStartingPositionsOnCancelOrFail = false } - + // MARK: NSDraggingSource - + func draggingSession( _ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext ) -> NSDragOperation { return context == .withinApplication ? .move : [] } - + func draggingSession( _ session: NSDraggingSession, willBeginAt screenPoint: NSPoint ) { isTracking = true - + // Reset our escape tracking dragCancelledByEscape = false escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in @@ -220,14 +220,14 @@ extension Ghostty { return event } } - + func draggingSession( _ session: NSDraggingSession, movedTo screenPoint: NSPoint ) { NSCursor.closedHand.set() } - + func draggingSession( _ session: NSDraggingSession, endedAt screenPoint: NSPoint, @@ -262,7 +262,7 @@ extension Notification.Name { /// released outside a valid drop target) and was not cancelled by the user /// pressing escape. The notification's object is the SurfaceView that was dragged. static let ghosttySurfaceDragEndedNoTarget = Notification.Name("ghosttySurfaceDragEndedNoTarget") - + /// Key for the screen point where the drag ended in the userInfo dictionary. static let ghosttySurfaceDragEndedNoTargetPointKey = "endedAtPoint" } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index f3ee80874..ff751df10 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,17 +1,17 @@ import AppKit import SwiftUI -extension Ghostty { +extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { private let handleHeight: CGFloat = 10 - + let surfaceView: SurfaceView - + @State private var isHovering: Bool = false @State private var isDragging: Bool = false - + var body: some View { VStack(spacing: 0) { Rectangle() @@ -32,7 +32,7 @@ extension Ghostty { isHovering: $isHovering ) } - + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceProgressBar.swift b/macos/Sources/Ghostty/Surface View/SurfaceProgressBar.swift index 82d26e681..0478bf2bf 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceProgressBar.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceProgressBar.swift @@ -5,7 +5,7 @@ import SwiftUI /// control. struct SurfaceProgressBar: View { let report: Ghostty.Action.ProgressReport - + private var color: Color { switch report.state { case .error: return .red @@ -13,17 +13,17 @@ struct SurfaceProgressBar: View { default: return .accentColor } } - + private var progress: UInt8? { // If we have an explicit progress use that. if let v = report.progress { return v } - + // Otherwise, if we're in the pause state, we act as if we're at 100%. if report.state == .pause { return 100 } - + return nil } - + private var accessibilityLabel: String { switch report.state { case .error: return "Terminal progress - Error" @@ -32,7 +32,7 @@ struct SurfaceProgressBar: View { default: return "Terminal progress" } } - + private var accessibilityValue: String { if let progress { return "\(progress) percent complete" @@ -45,7 +45,7 @@ struct SurfaceProgressBar: View { } } } - + var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { @@ -78,15 +78,15 @@ struct SurfaceProgressBar: View { private struct BouncingProgressBar: View { let color: Color @State private var position: CGFloat = 0 - + private let barWidthRatio: CGFloat = 0.25 - + var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { Rectangle() .fill(color.opacity(0.3)) - + Rectangle() .fill(color) .frame( @@ -110,4 +110,3 @@ private struct BouncingProgressBar: View { } } - diff --git a/macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift b/macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift index b55f2e231..aab99c088 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceScrollView.swift @@ -19,12 +19,12 @@ class SurfaceScrollView: NSView { private var observers: [NSObjectProtocol] = [] private var cancellables: Set = [] private var isLiveScrolling = false - + /// The last row position sent via scroll_to_row action. Used to avoid /// sending redundant actions when the user drags the scrollbar but stays /// on the same row. private var lastSentRow: Int? - + init(contentSize: CGSize, surfaceView: Ghostty.SurfaceView) { self.surfaceView = surfaceView // The scroll view is our outermost view that controls all our scrollbar @@ -44,26 +44,26 @@ class SurfaceScrollView: NSView { // (we currently only use overlay scrollers, but might as well // configure the views correctly in case we change our mind) scrollView.contentView.clipsToBounds = false - + // The document view is what the scrollview is actually going // to be directly scrolling. We set it up to a "blank" NSView // with the desired content size. documentView = NSView(frame: NSRect(origin: .zero, size: contentSize)) scrollView.documentView = documentView - + // The document view contains our actual surface as a child. // We synchronize the scrolling of the document with this surface // so that our primary Ghostty renderer only needs to render the viewport. documentView.addSubview(surfaceView) - + super.init(frame: .zero) - + // Our scroll view is our only view addSubview(scrollView) - + // Apply initial scrollbar settings synchronizeAppearance() - + // We listen for scroll events through bounds notifications on our NSClipView. // This is based on: https://christiantietze.de/posts/2018/07/synchronize-nsscrollview/ scrollView.contentView.postsBoundsChangedNotifications = true @@ -74,7 +74,7 @@ class SurfaceScrollView: NSView { ) { [weak self] notification in self?.handleScrollChange(notification) }) - + // Listen for scrollbar updates from Ghostty observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateScrollbar, @@ -83,7 +83,7 @@ class SurfaceScrollView: NSView { ) { [weak self] notification in self?.handleScrollbarUpdate(notification) }) - + // Listen for live scroll events observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.willStartLiveScrollNotification, @@ -92,7 +92,7 @@ class SurfaceScrollView: NSView { ) { [weak self] _ in self?.isLiveScrolling = true }) - + observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didEndLiveScrollNotification, object: scrollView, @@ -100,7 +100,7 @@ class SurfaceScrollView: NSView { ) { [weak self] _ in self?.isLiveScrolling = false }) - + observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didLiveScrollNotification, object: scrollView, @@ -108,7 +108,7 @@ class SurfaceScrollView: NSView { ) { [weak self] _ in self?.handleLiveScroll() }) - + observers.append(NotificationCenter.default.addObserver( forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, @@ -150,11 +150,11 @@ class SurfaceScrollView: NSView { } .store(in: &cancellables) } - + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - + deinit { observers.forEach { NotificationCenter.default.removeObserver($0) } } @@ -163,10 +163,10 @@ class SurfaceScrollView: NSView { // insets. This is necessary for the content view to match the // surface view if we have the "hidden" titlebar style. override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero } - + override func layout() { super.layout() - + // Fill entire bounds with scroll view scrollView.frame = bounds surfaceView.frame.size = scrollView.bounds.size @@ -174,13 +174,13 @@ class SurfaceScrollView: NSView { // We only set the width of the documentView here, as the height depends // on the scrollbar state and is updated in synchronizeScrollView documentView.frame.size.width = scrollView.bounds.width - + // When our scrollview changes make sure our scroller and surface views are synchronized synchronizeScrollView() synchronizeSurfaceView() synchronizeCoreSurface() } - + // MARK: Scrolling private func synchronizeAppearance() { @@ -220,7 +220,7 @@ class SurfaceScrollView: NSView { private func synchronizeScrollView() { // Update the document height to give our scroller the correct proportions documentView.frame.size.height = documentHeight() - + // Only update our actual scroll position if we're not actively scrolling. if !isLiveScrolling { // Convert row units to pixels using cell height, ignore zero height. @@ -236,13 +236,13 @@ class SurfaceScrollView: NSView { lastSentRow = Int(scrollbar.offset) } } - + // Always update our scrolled view with the latest dimensions scrollView.reflectScrolledClipView(scrollView.contentView) } - + // MARK: Notifications - + /// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized. private func handleScrollChange(_ notification: Notification) { synchronizeSurfaceView() @@ -259,7 +259,7 @@ class SurfaceScrollView: NSView { synchronizeAppearance() synchronizeCoreSurface() } - + /// Handles live scroll events (user actively dragging the scrollbar). /// /// Converts the current scroll position to a row number and sends a `scroll_to_row` action @@ -270,21 +270,21 @@ class SurfaceScrollView: NSView { // happen with a tiny terminal. let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } - + // AppKit views are +Y going up, so we calculate from the bottom let visibleRect = scrollView.contentView.documentVisibleRect let documentHeight = documentView.frame.height let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height let row = Int(scrollOffset / cellHeight) - + // Only send action if the row changed to avoid action spam guard row != lastSentRow else { return } lastSentRow = row - + // Use the keybinding action to scroll. _ = surfaceView.surfaceModel?.perform(action: "scroll_to_row:\(row)") } - + /// Handles scrollbar state updates from the terminal core. /// /// Updates the document view size to reflect total scrollback and adjusts scroll position diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift index 509713309..106875813 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView+Transferable.swift @@ -17,11 +17,11 @@ extension Ghostty.SurfaceView: Transferable { let uuid = data.withUnsafeBytes { $0.load(as: UUID.self) } - + guard let imported = await Self.find(uuid: uuid) else { throw TransferError.invalidData } - + return imported } } @@ -29,7 +29,7 @@ extension Ghostty.SurfaceView: Transferable { enum TransferError: Error { case invalidData } - + @MainActor static func find(uuid: UUID) -> Self? { #if canImport(AppKit) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index c5c2ee97c..fb5a1a864 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -49,7 +49,7 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false - + #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared @@ -84,7 +84,7 @@ extension Ghostty { .onReceive(pubResign) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } - if (surfaceWindow == window) { + if surfaceWindow == window { windowFocus = false } } @@ -103,7 +103,7 @@ extension Ghostty { } } .ghosttySurfaceView(surfaceView) - + // Progress report if let progressReport = surfaceView.progressReport, progressReport.state != .remove { VStack(spacing: 0) { @@ -114,7 +114,7 @@ extension Ghostty { .allowsHitTesting(false) .transition(.opacity) } - + #if canImport(AppKit) // Readonly indicator badge if surfaceView.readonly { @@ -122,7 +122,7 @@ extension Ghostty { surfaceView.toggleReadonly(nil) } } - + // Show key state indicator for active key tables and/or pending key sequences KeyStateIndicator( keyTables: surfaceView.keyTables, @@ -177,10 +177,10 @@ extension Ghostty { #if canImport(AppKit) // If we have secure input enabled and we're the focused surface and window // then we want to show the secure input overlay. - if (ghostty.config.secureInputIndication && + if ghostty.config.secureInputIndication && secureInput.enabled && surfaceFocus && - windowFocus) { + windowFocus { SecureInputOverlay() } #endif @@ -200,7 +200,7 @@ extension Ghostty { } // Show bell border if enabled - if (ghostty.config.bellFeatures.contains(.border)) { + if ghostty.config.bellFeatures.contains(.border) { BellBorderOverlay(bell: surfaceView.bell) } @@ -208,10 +208,10 @@ extension Ghostty { HighlightOverlay(highlighted: surfaceView.highlighted) // If our surface is not healthy, then we render an error view over it. - if (!surfaceView.healthy) { + if !surfaceView.healthy { Rectangle().fill(ghostty.config.backgroundColor) SurfaceRendererUnhealthyView() - } else if (surfaceView.error != nil) { + } else if surfaceView.error != nil { Rectangle().fill(ghostty.config.backgroundColor) SurfaceErrorView() } @@ -220,9 +220,9 @@ extension Ghostty { // rectangle above our view to make it look unfocused. We use "surfaceFocus" // because we want to keep our focused surface dark even if we don't have window // focus. - if (isSplit && !surfaceFocus) { - let overlayOpacity = ghostty.config.unfocusedSplitOpacity; - if (overlayOpacity > 0) { + if isSplit && !surfaceFocus { + let overlayOpacity = ghostty.config.unfocusedSplitOpacity + if overlayOpacity > 0 { Rectangle() .fill(ghostty.config.unfocusedSplitFill) .allowsHitTesting(false) @@ -286,8 +286,6 @@ extension Ghostty { } } - - // This is the resize overlay that shows on top of a surface to show the current // size during a resize operation. struct SurfaceResizeOverlay: View { @@ -300,7 +298,7 @@ extension Ghostty { // This is the last size that we processed. This is how we handle our // timer state. - @State var lastSize: CGSize? = nil + @State var lastSize: CGSize? // Ready is set to true after a short delay. This avoids some of the // challenges of initial view sizing from SwiftUI. @@ -312,42 +310,42 @@ extension Ghostty { // This computed boolean is set to true when the overlay should be hidden. private var hidden: Bool { // If we aren't ready yet then we wait... - if (!ready) { return true; } + if !ready { return true; } // Hidden if we already processed this size. - if (lastSize == geoSize) { return true; } + if lastSize == geoSize { return true; } // If we were focused recently we hide it as well. This avoids showing // the resize overlay when SwiftUI is lazily resizing. if let instant = focusInstant { let d = instant.duration(to: ContinuousClock.now) - if (d < .milliseconds(500)) { + if d < .milliseconds(500) { // Avoid this size completely. We can't set values during // view updates so we have to defer this to another tick. DispatchQueue.main.async { lastSize = geoSize } - return true; + return true } } // Hidden depending on overlay config - switch (overlay) { - case .never: return true; - case .always: return false; - case .after_first: return lastSize == nil; + switch overlay { + case .never: return true + case .always: return false + case .after_first: return lastSize == nil } } var body: some View { VStack { - if (!position.top()) { + if !position.top() { Spacer() } HStack { - if (!position.left()) { + if !position.left() { Spacer() } @@ -361,12 +359,12 @@ extension Ghostty { .lineLimit(1) .truncationMode(.tail) - if (!position.right()) { + if !position.right() { Spacer() } } - if (!position.bottom()) { + if !position.bottom() { Spacer() } } @@ -386,7 +384,7 @@ extension Ghostty { // We only sleep if we're ready. If we're not ready then we want to set // our last size right away to avoid a flash. - if (ready) { + if ready { try? await Task.sleep(nanoseconds: UInt64(duration) * 1_000_000) } @@ -404,9 +402,9 @@ extension Ghostty { @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @FocusState private var isSearchFieldFocused: Bool - + private let padding: CGFloat = 8 - + var body: some View { GeometryReader { geo in HStack(spacing: 4) { @@ -456,20 +454,20 @@ extension Ghostty { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) - }) { + }, label: { Image(systemName: "chevron.up") - } + }) .buttonStyle(SearchButtonStyle()) - + Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:previous" ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) - }) { + }, label: { Image(systemName: "chevron.down") - } + }) .buttonStyle(SearchButtonStyle()) - + Button(action: onClose) { Image(systemName: "xmark") } @@ -529,7 +527,7 @@ extension Ghostty { enum Corner { case topLeft, topRight, bottomLeft, bottomRight - + var alignment: Alignment { switch self { case .topLeft: return .topLeading @@ -539,11 +537,11 @@ extension Ghostty { } } } - + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { let halfWidth = barSize.width / 2 + padding let halfHeight = barSize.height / 2 + padding - + switch corner { case .topLeft: return CGPoint(x: halfWidth, y: halfHeight) @@ -555,21 +553,21 @@ extension Ghostty { return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) } } - + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { let midX = containerSize.width / 2 let midY = containerSize.height / 2 - + if point.x < midX { return point.y < midY ? .topLeft : .bottomLeft } else { return point.y < midY ? .topRight : .bottomRight } } - + struct SearchButtonStyle: ButtonStyle { @State private var isHovered = false - + func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(isHovered || configuration.isPressed ? .primary : .secondary) @@ -584,7 +582,7 @@ extension Ghostty { } .backport.pointerStyle(.link) } - + private func backgroundColor(isPressed: Bool) -> Color { if isPressed { return Color.primary.opacity(0.2) @@ -640,20 +638,20 @@ extension Ghostty { /// libghostty, usually from the Ghostty configuration. struct SurfaceConfiguration { /// Explicit font size to use in points - var fontSize: Float32? = nil + var fontSize: Float32? /// Explicit working directory to set - var workingDirectory: String? = nil + var workingDirectory: String? /// Explicit command to set - var command: String? = nil - + var command: String? + /// Environment variables to set for the terminal var environmentVariables: [String: String] = [:] /// Extra input to send as stdin - var initialInput: String? = nil - + var initialInput: String? + /// Wait after the command var waitAfterCommand: Bool = false @@ -711,7 +709,7 @@ extension Ghostty { // Zero is our default value that means to inherit the font size. config.font_size = fontSize ?? 0 - + // Set wait after command config.wait_after_command = waitAfterCommand @@ -736,7 +734,7 @@ extension Ghostty { return try keys.withCStrings { keyCStrings in return try values.withCStrings { valueCStrings in // Create array of ghostty_env_var_s - var envVars = Array() + var envVars = [ghostty_env_var_s]() envVars.reserveCapacity(environmentVariables.count) for i in 0.. Double { let phase = animationPhase let offset = Double(index) / 3.0 @@ -981,7 +979,7 @@ extension Ghostty { /// Visual overlay that shows a border around the edges when the bell rings with border feature enabled. struct BellBorderOverlay: View { let bell: Bool - + var body: some View { Rectangle() .strokeBorder( @@ -998,7 +996,7 @@ extension Ghostty { /// Uses a soft, soothing highlight with a pulsing border effect. struct HighlightOverlay: View { let highlighted: Bool - + @State private var borderPulse: Bool = false var body: some View { @@ -1051,21 +1049,21 @@ extension Ghostty { } // MARK: Readonly Badge - + /// A badge overlay that indicates a surface is in readonly mode. /// Positioned in the top-right corner and styled to be noticeable but unobtrusive. struct ReadonlyBadge: View { let onDisable: () -> Void - + @State private var showingPopover = false - + private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8) - + var body: some View { VStack { HStack { Spacer() - + HStack(spacing: 5) { Image(systemName: "eye.fill") .font(.system(size: 12)) @@ -1085,13 +1083,13 @@ extension Ghostty { } } .padding(8) - + Spacer() } .accessibilityElement(children: .ignore) .accessibilityLabel("Read-only terminal") } - + private var badgeBackground: some View { RoundedRectangle(cornerRadius: 6) .fill(.regularMaterial) @@ -1101,11 +1099,11 @@ extension Ghostty { ) } } - + struct ReadonlyPopoverView: View { let onDisable: () -> Void @Binding var isPresented: Bool - + var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { @@ -1116,16 +1114,16 @@ extension Ghostty { Text("Read-Only Mode") .font(.system(size: 13, weight: .semibold)) } - + Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.") .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } - + HStack { Spacer() - + Button("Disable") { onDisable() isPresented = false @@ -1252,8 +1250,8 @@ extension FocusedValues { extension Ghostty.SurfaceView { class SearchState: ObservableObject { @Published var needle: String = "" - @Published var selected: UInt? = nil - @Published var total: UInt? = nil + @Published var selected: UInt? + @Published var total: UInt? init(from startSearch: Ghostty.Action.StartSearch) { self.needle = startSearch.needle ?? "" diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index c856b0163..6b3bfbfb4 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -27,7 +27,7 @@ extension Ghostty { // The current pwd of the surface as defined by the pty. This can be // changed with escape codes. - @Published var pwd: String? = nil + @Published var pwd: String? // The cell size of this surface. This is set by the core when the // surface is first created and any time the cell size changes (i.e. @@ -40,13 +40,13 @@ extension Ghostty { @Published var healthy: Bool = true // Any error while initializing the surface. - @Published var error: Error? = nil + @Published var error: Error? // The hovered URL string - @Published var hoverUrl: String? = nil + @Published var hoverUrl: String? // The progress report (if any) - @Published var progressReport: Action.ProgressReport? = nil { + @Published var progressReport: Action.ProgressReport? { didSet { // Cancel any existing timer progressReportTimer?.invalidate() @@ -69,7 +69,7 @@ extension Ghostty { @Published var keyTables: [String] = [] // The current search state. When non-nil, the search overlay should be shown. - @Published var searchState: SearchState? = nil { + @Published var searchState: SearchState? { didSet { if let searchState { // I'm not a Combine expert so if there is a better way to do this I'm @@ -107,11 +107,11 @@ extension Ghostty { // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. - @Published var focusInstant: ContinuousClock.Instant? = nil + @Published var focusInstant: ContinuousClock.Instant? // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. - @Published var surfaceSize: ghostty_surface_size_s? = nil + @Published var surfaceSize: ghostty_surface_size_s? // Whether the pointer should be visible or not @Published private(set) var pointerStyle: CursorStyle = .horizontalText @@ -121,7 +121,7 @@ extension Ghostty { /// The background color within the color palette of the surface. This is only set if it is /// dynamically updated. Otherwise, the background color is the default background color. - @Published private(set) var backgroundColor: Color? = nil + @Published private(set) var backgroundColor: Color? /// True when the bell is active. This is set inactive on focus or event. @Published private(set) var bell: Bool = false @@ -134,7 +134,7 @@ extension Ghostty { // An initial size to request for a window. This will only affect // then the view is moved to a new window. - var initialSize: NSSize? = nil + var initialSize: NSSize? // A content size received through sizeDidChange that may in some cases // be different from the frame size. @@ -151,7 +151,7 @@ extension Ghostty { // We need to update our state within the SecureInput manager. let input = SecureInput.shared let id = ObjectIdentifier(self) - if (passwordInput) { + if passwordInput { input.setScoped(id, focused: focused) } else { input.removeScoped(id) @@ -183,7 +183,7 @@ extension Ghostty { // True if the inspector should be visible @Published var inspectorVisible: Bool = false { didSet { - if (oldValue && !inspectorVisible) { + if oldValue && !inspectorVisible { guard let surface = self.surface else { return } ghostty_inspector_free(surface) } @@ -210,10 +210,10 @@ extension Ghostty { private var markedText: NSMutableAttributedString private(set) var focused: Bool = true private var prevPressureStage: Int = 0 - private var appearanceObserver: NSKeyValueObservation? = nil + private var appearanceObserver: NSKeyValueObservation? // This is set to non-null during keyDown to accumulate insertText contents - private var keyTextAccumulator: [String]? = nil + private var keyTextAccumulator: [String]? // A small delay that is introduced before a title change to avoid flickers private var titleChangeTimer: Timer? @@ -234,7 +234,7 @@ extension Ghostty { private(set) var cachedVisibleContents: CachedValue /// Event monitor (see individual events for why) - private var eventMonitor: Any? = nil + private var eventMonitor: Any? // We need to support being a first responder so that we can get input events override var acceptsFirstResponder: Bool { return true } @@ -259,7 +259,7 @@ extension Ghostty { // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer // can do SOMETHING. - super.init(frame: NSMakeRect(0, 0, 800, 600)) + super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) // Our cache of screen data cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in @@ -431,11 +431,11 @@ extension Ghostty { ghostty_surface_set_focus(surface, focused) // Update our secure input state if we are a password input - if (passwordInput) { + if passwordInput { SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused) } - if (focused) { + if focused { // On macOS 13+ we can store our continuous clock... focusInstant = ContinuousClock.now @@ -480,7 +480,7 @@ extension Ghostty { } func setCursorShape(_ shape: ghostty_action_mouse_shape_e) { - switch (shape) { + switch shape { case GHOSTTY_MOUSE_SHAPE_DEFAULT: pointerStyle = .default @@ -656,7 +656,7 @@ extension Ghostty { private func localEventKeyUp(_ event: NSEvent) -> NSEvent? { // We only care about events with "command" because all others will // trigger the normal responder chain. - if (!event.modifierFlags.contains(.command)) { return event } + if !event.modifierFlags.contains(.command) { return event } // Command keyUp events are never sent to the normal responder chain // so we send them here. @@ -722,7 +722,7 @@ extension Ghostty { SwiftUI.Notification.Name.GhosttyColorChangeKey ] as? Ghostty.Action.ColorChange else { return } - switch (change.kind) { + switch change.kind { case .background: DispatchQueue.main.async { [weak self] in self?.backgroundColor = change.color @@ -767,7 +767,7 @@ extension Ghostty { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() - if (result) { focusDidChange(true) } + if result { focusDidChange(true) } return result } @@ -776,7 +776,7 @@ extension Ghostty { // We sometimes call this manually (see SplitView) as a way to force us to // yield our focus state. - if (result) { focusDidChange(false) } + if result { focusDidChange(false) } return result } @@ -873,17 +873,16 @@ extension Ghostty { ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, button.cMouseButton, mods) } - override func rightMouseDown(with event: NSEvent) { guard let surface = self.surface else { return super.rightMouseDown(with: event) } let mods = Ghostty.ghosttyMods(event.modifierFlags) - if (ghostty_surface_mouse_button( + if ghostty_surface_mouse_button( surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods - )) { + ) { // Consumed return } @@ -896,12 +895,12 @@ extension Ghostty { guard let surface = self.surface else { return super.rightMouseUp(with: event) } let mods = Ghostty.ghosttyMods(event.modifierFlags) - if (ghostty_surface_mouse_button( + if ghostty_surface_mouse_button( surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods - )) { + ) { // Handled return } @@ -963,10 +962,9 @@ extension Ghostty { if let window, let controller = window.windowController as? BaseTerminalController, !controller.commandPaletteIsShowing, - (window.isKeyWindow && + window.isKeyWindow && !self.focused && - controller.focusFollowsMouse) - { + controller.focusFollowsMouse { Ghostty.moveFocus(to: self) } } @@ -992,8 +990,8 @@ extension Ghostty { if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. - x *= 2; - y *= 2; + x *= 2 + y *= 2 // TODO(mitchellh): do we have to scale the x/y here by window scale factor? } @@ -1048,7 +1046,7 @@ extension Ghostty { // for exact states and set them. var translationMods = event.modifierFlags for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { - if (translationModsGhostty.contains(flag)) { + if translationModsGhostty.contains(flag) { translationMods.insert(flag) } else { translationMods.remove(flag) @@ -1061,7 +1059,7 @@ extension Ghostty { // this keeps things like Korean input working. There must be some object // equality happening in AppKit somewhere because this is required. let translationEvent: NSEvent - if (translationMods == event.modifierFlags) { + if translationMods == event.modifierFlags { translationEvent = event } else { translationEvent = NSEvent.keyEvent( @@ -1093,7 +1091,7 @@ extension Ghostty { // We need to know the keyboard layout before below because some keyboard // input events will change our keyboard layout and we don't want those // going to the terminal. - let keyboardIdBefore: String? = if (!markedTextBefore) { + let keyboardIdBefore: String? = if !markedTextBefore { KeyboardLayout.id } else { nil @@ -1108,7 +1106,7 @@ extension Ghostty { // If our keyboard changed from this we just assume an input method // grabbed it and do nothing. - if (!markedTextBefore && keyboardIdBefore != KeyboardLayout.id) { + if !markedTextBefore && keyboardIdBefore != KeyboardLayout.id { return } @@ -1185,17 +1183,17 @@ extension Ghostty { // We only care about key down events. It might not even be possible // to receive any other event type here. guard event.type == .keyDown else { return false } - + // Only process events if we're focused. Some key events like C-/ macOS // appears to send to the first view in the hierarchy rather than the // the first responder (I don't know why). This prevents us from handling it. // Besides C-/, its important we don't process key equivalents if unfocused // because there are other event listeners for that (i.e. AppDelegate's // local event handler). - if (!focused) { + if !focused { return false } - + // Get information about if this is a binding. let bindingFlags = surfaceModel.flatMap { surface in var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) @@ -1204,7 +1202,7 @@ extension Ghostty { return surface.keyIsBinding(ghosttyEvent) } } - + // If this is a binding then we want to perform it. if let bindingFlags { // Attempt to trigger a menu item for this key binding. We only do this if: @@ -1221,17 +1219,17 @@ extension Ghostty { return true } } - + self.keyDown(with: event) return true } let equivalent: String - switch (event.charactersIgnoringModifiers) { + switch event.charactersIgnoringModifiers { case "\r": // Pass C- through verbatim // (prevent the default context menu equivalent) - if (!event.modifierFlags.contains(.control)) { + if !event.modifierFlags.contains(.control) { return false } @@ -1240,8 +1238,8 @@ extension Ghostty { case "/": // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep // sound and we don't like the beep sound. - if (!event.modifierFlags.contains(.control) || - !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { + if !event.modifierFlags.contains(.control) || + !event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) { return false } @@ -1265,8 +1263,8 @@ extension Ghostty { // Ignore all other non-command events. This lets the event continue // through the AppKit event systems. - if (!event.modifierFlags.contains(.command) && - !event.modifierFlags.contains(.control)) { + if !event.modifierFlags.contains(.command) && + !event.modifierFlags.contains(.control) { // Reset since we got a non-command event. lastPerformKeyEvent = nil return false @@ -1304,8 +1302,8 @@ extension Ghostty { } override func flagsChanged(with event: NSEvent) { - let mod: UInt32; - switch (event.keyCode) { + let mod: UInt32 + switch event.keyCode { case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue @@ -1323,26 +1321,26 @@ extension Ghostty { // If the key that pressed this is active, its a press, else release. var action = GHOSTTY_ACTION_RELEASE - if (mods.rawValue & mod != 0) { + if mods.rawValue & mod != 0 { // If the key is pressed, its slightly more complicated, because we // want to check if the pressed modifier is the correct side. If the // correct side is pressed then its a press event otherwise its a release // event with the opposite modifier still held. let sidePressed: Bool - switch (event.keyCode) { + switch event.keyCode { case 0x3C: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0; + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0 case 0x3E: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0; + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0 case 0x3D: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0; + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0 case 0x36: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0; + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0 default: sidePressed = true } - if (sidePressed) { + if sidePressed { action = GHOSTTY_ACTION_PRESS } } @@ -1389,7 +1387,7 @@ extension Ghostty { // since we always have a primary font. The only scenario this doesn't // work is if someone is using a non-CoreText build which would be // unofficial. - var attributes: [ NSAttributedString.Key : Any ] = [:]; + var attributes: [ NSAttributedString.Key: Any ] = [:] if let fontRaw = ghostty_surface_quicklook_font(surface) { // Memory management here is wonky: ghostty_surface_quicklook_font // will create a copy of a CTFont, Swift will auto-retain the @@ -1400,9 +1398,9 @@ extension Ghostty { } // Ghostty coordinate system is top-left, convert to bottom-left for AppKit - let pt = NSMakePoint(text.tl_px_x, frame.size.height - text.tl_px_y) + let pt = NSPoint(x: text.tl_px_x, y: frame.size.height - text.tl_px_y) let str = NSAttributedString.init(string: String(cString: text.text), attributes: attributes) - self.showDefinition(for: str, at: pt); + self.showDefinition(for: str, at: pt) } override func menu(for event: NSEvent) -> NSMenu? { @@ -1483,7 +1481,7 @@ extension Ghostty { @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1491,16 +1489,15 @@ extension Ghostty { @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } - @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1508,7 +1505,7 @@ extension Ghostty { @IBAction func pasteSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1516,7 +1513,7 @@ extension Ghostty { @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1524,7 +1521,7 @@ extension Ghostty { @IBAction func find(_ sender: Any?) { guard let surface = self.surface else { return } let action = "start_search" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1532,7 +1529,7 @@ extension Ghostty { @IBAction func selectionForFind(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1540,7 +1537,7 @@ extension Ghostty { @IBAction func scrollToSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "scroll_to_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1548,7 +1545,7 @@ extension Ghostty { @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1556,7 +1553,7 @@ extension Ghostty { @IBAction func findPrevious(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:previous" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1564,7 +1561,7 @@ extension Ghostty { @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } let action = "end_search" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1572,7 +1569,7 @@ extension Ghostty { @IBAction func toggleReadonly(_ sender: Any?) { guard let surface = self.surface else { return } let action = "toggle_readonly" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1608,7 +1605,7 @@ extension Ghostty { @objc func resetTerminal(_ sender: Any) { guard let surface = self.surface else { return } let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1616,7 +1613,7 @@ extension Ghostty { @objc func toggleTerminalInspector(_ sender: Any) { guard let surface = self.surface else { return } let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { + if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1657,7 +1654,7 @@ extension Ghostty { // If we're focused then we schedule to remove the notification // after a few seconds. If we gain focus we automatically remove it // in focusDidChange. - if (self.focused) { + if self.focused { Task { @MainActor [weak self] in try await Task.sleep(for: .seconds(3)) self?.notificationIdentifiers.remove(uuid) @@ -1831,7 +1828,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { // since we always have a primary font. The only scenario this doesn't // work is if someone is using a non-CoreText build which would be // unofficial. - var attributes: [ NSAttributedString.Key : Any ] = [:]; + var attributes: [ NSAttributedString.Key: Any ] = [:] if let fontRaw = ghostty_surface_quicklook_font(surface) { // Memory management here is wonky: ghostty_surface_quicklook_font // will create a copy of a CTFont, Swift will auto-retain the @@ -1850,7 +1847,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { guard let surface = self.surface else { - return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) + return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) } // Ghostty will tell us where it thinks an IME keyboard should render. @@ -1869,8 +1866,8 @@ extension Ghostty.SurfaceView: NSTextInputClient { if ghostty_surface_read_selection(surface, &text) { // The -2/+2 here is subjective. QuickLook seems to offset the rectangle // a bit and I think these small adjustments make it look more natural. - x = text.tl_px_x - 2; - y = text.tl_px_y + 2; + x = text.tl_px_x - 2 + y = text.tl_px_y + 2 // Free our text ghostty_surface_free_text(surface, &text) @@ -1892,11 +1889,11 @@ extension Ghostty.SurfaceView: NSTextInputClient { // when there's is no characters selected, // width should be 0 so that dictation indicator // can start in the right place - let viewRect = NSMakeRect( - x, - frame.size.height - y, - width, - max(height, cellSize.height)) + let viewRect = NSRect( + x: x, + y: frame.size.height - y, + width: width, + height: max(height, cellSize.height)) // Convert the point to the window coordinates let winRect = self.convert(viewRect, to: nil) @@ -1913,7 +1910,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { // We want the string view of the any value var chars = "" - switch (string) { + switch string { case let v as NSAttributedString: chars = v.string case let v as String: @@ -1944,8 +1941,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { // we send it back through the event system so it can be encoded. if let lastPerformKeyEvent, let current = NSApp.currentEvent, - lastPerformKeyEvent == current.timestamp - { + lastPerformKeyEvent == current.timestamp { NSApp.sendEvent(current) return } @@ -2052,7 +2048,7 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor { guard let str = pboard.getOpinionatedStringContents() else { return false } let len = str.utf8CString.count - if (len == 0) { return true } + if len == 0 { return true } str.withCString { ptr in // len includes the null terminator so we do len - 1 ghostty_surface_text(surface, ptr, UInt(len - 1)) @@ -2134,7 +2130,7 @@ extension Ghostty.SurfaceView { DispatchQueue.main.async { self.insertText( content, - replacementRange: NSMakeRange(0, 0) + replacementRange: NSRange(location: 0, length: 0) ) } return true diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift index f9baf56c9..9a4cf4d9b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_UIKit.swift @@ -15,7 +15,7 @@ extension Ghostty { @Published var title: String = "👻" // The current pwd of the surface. - @Published var pwd: String? = nil + @Published var pwd: String? // The cell size of this surface. This is set by the core when the // surface is first created and any time the cell size changes (i.e. @@ -28,30 +28,30 @@ extension Ghostty { @Published var healthy: Bool = true // Any error while initializing the surface. - @Published var error: Error? = nil + @Published var error: Error? // The hovered URL - @Published var hoverUrl: String? = nil - + @Published var hoverUrl: String? + // The progress report (if any) - @Published var progressReport: Action.ProgressReport? = nil + @Published var progressReport: Action.ProgressReport? // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. - @Published var focusInstant: ContinuousClock.Instant? = nil + @Published var focusInstant: ContinuousClock.Instant? /// True when the bell is active. This is set inactive on focus or event. @Published var bell: Bool = false - + // The current search state. When non-nil, the search overlay should be shown. - @Published var searchState: SearchState? = nil + @Published var searchState: SearchState? // The currently active key tables. Empty if no tables are active. @Published var keyTables: [String] = [] /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false - + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). @Published private(set) var highlighted: Bool = false @@ -81,7 +81,7 @@ extension Ghostty { // TODO return } - self.surface = surface; + self.surface = surface } required init?(coder: NSCoder) { @@ -98,7 +98,7 @@ extension Ghostty { ghostty_surface_set_focus(surface, focused) // On macOS 13+ we can store our continuous clock... - if (focused) { + if focused { focusInstant = ContinuousClock.now } } @@ -122,9 +122,7 @@ extension Ghostty { // MARK: UIView override class var layerClass: AnyClass { - get { - return CAMetalLayer.self - } + return CAMetalLayer.self } override func didMoveToWindow() { diff --git a/macos/Sources/Helpers/AnySortKey.swift b/macos/Sources/Helpers/AnySortKey.swift index 6813ccf45..ffafb6b90 100644 --- a/macos/Sources/Helpers/AnySortKey.swift +++ b/macos/Sources/Helpers/AnySortKey.swift @@ -4,7 +4,7 @@ import Foundation struct AnySortKey: Comparable { private let value: Any private let comparator: (Any, Any) -> ComparisonResult - + init(_ value: T) { self.value = value self.comparator = { lhs, rhs in @@ -14,11 +14,11 @@ struct AnySortKey: Comparable { return .orderedSame } } - + static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool { lhs.comparator(lhs.value, rhs.value) == .orderedAscending } - + static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool { lhs.comparator(lhs.value, rhs.value) == .orderedSame } diff --git a/macos/Sources/Helpers/AppInfo.swift b/macos/Sources/Helpers/AppInfo.swift index 281bad18b..940d247d5 100644 --- a/macos/Sources/Helpers/AppInfo.swift +++ b/macos/Sources/Helpers/AppInfo.swift @@ -2,9 +2,5 @@ import Foundation /// True if we appear to be running in Xcode. func isRunningInXcode() -> Bool { - if let _ = ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] { - return true - } - - return false + ProcessInfo.processInfo.environment["__XCODE_BUILT_PRODUCTS_DIR_PATHS"] != nil } diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 8c43652e4..28da6cce6 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -48,7 +48,7 @@ extension Backport where Content: View { return content #endif } - + /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { #if canImport(AppKit) diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift index 5fde0e870..3b1abd44a 100644 --- a/macos/Sources/Helpers/ExpiringUndoManager.swift +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -98,10 +98,10 @@ class ExpiringUndoManager: UndoManager { private class ExpiringTarget { /// The actual target object for the undo operation, held weakly to avoid retain cycles. private(set) weak var target: AnyObject? - + /// Timer that triggers expiration after the specified duration. private var timer: Timer? - + /// The undo manager from which to remove actions when this target expires. private weak var undoManager: UndoManager? @@ -141,7 +141,7 @@ extension ExpiringTarget: Hashable, Equatable { static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool { return lhs === rhs } - + func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift index 4e8e39918..92beb0505 100644 --- a/macos/Sources/Helpers/Extensions/Array+Extension.swift +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -2,7 +2,7 @@ extension Array { subscript(safe index: Int) -> Element? { return indices.contains(index) ? self[index] : nil } - + /// Returns the index before i, with wraparound. Assumes i is a valid index. func indexWrapping(before i: Int) -> Int { if i == 0 { @@ -35,7 +35,7 @@ extension Array where Element == String { if index == count { return try body(accumulated) } - + return try self[index].withCString { cStr in var newAccumulated = accumulated newAccumulated.append(cStr) diff --git a/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift index 8d379bd99..cc8d49cf8 100644 --- a/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift +++ b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift @@ -5,11 +5,13 @@ import SwiftUI extension EventModifiers { init(nsFlags: NSEvent.ModifierFlags) { var result: SwiftUI.EventModifiers = [] + // swiftlint:disable opening_brace if nsFlags.contains(.shift) { result.insert(.shift) } if nsFlags.contains(.control) { result.insert(.control) } if nsFlags.contains(.option) { result.insert(.option) } if nsFlags.contains(.command) { result.insert(.command) } if nsFlags.contains(.capsLock) { result.insert(.capsLock) } + // swiftlint:enable opening_brace self = result } } @@ -17,11 +19,13 @@ extension EventModifiers { extension NSEvent.ModifierFlags { init(swiftUIFlags: SwiftUI.EventModifiers) { var result: NSEvent.ModifierFlags = [] + // swiftlint:disable opening_brace if swiftUIFlags.contains(.shift) { result.insert(.shift) } if swiftUIFlags.contains(.control) { result.insert(.control) } if swiftUIFlags.contains(.option) { result.insert(.option) } if swiftUIFlags.contains(.command) { result.insert(.command) } if swiftUIFlags.contains(.capsLock) { result.insert(.capsLock) } + // swiftlint:enable opening_brace self = result } } diff --git a/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift index 28edb1a35..c45f37a62 100644 --- a/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift @@ -9,7 +9,7 @@ extension NSAppearance { /// Initialize a desired NSAppearance for the Ghostty configuration. convenience init?(ghosttyConfig config: Ghostty.Config) { guard let theme = config.windowTheme else { return nil } - switch (theme) { + switch theme { case "dark": self.init(named: .darkAqua) diff --git a/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift index 0bc79fb6a..2d3bc2cba 100644 --- a/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift @@ -18,7 +18,7 @@ extension NSApplication { func releasePresentationOption(_ option: NSApplication.PresentationOptions.Element) { guard let value = Self.presentationOptionCounts[option] else { return } guard value > 0 else { return } - if (value == 1) { + if value == 1 { presentationOptions.remove(option) Self.presentationOptionCounts.removeValue(forKey: option) } else { diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index a036f02b4..a54735fde 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -13,14 +13,14 @@ extension NSPasteboard.PasteboardType { default: break } - + // Try to get UTType from MIME type guard let utType = UTType(mimeType: mimeType) else { // Fallback: use the MIME type directly as identifier self.init(mimeType) return } - + // Use the UTType's identifier self.init(utType.identifier) } @@ -50,7 +50,7 @@ extension NSPasteboard { /// The pasteboard for the Ghostty enum type. static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { - switch (clipboard) { + switch clipboard { case GHOSTTY_CLIPBOARD_STANDARD: return Self.general diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index a8eb7b876..ca338f102 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -5,7 +5,7 @@ extension NSScreen { var displayID: UInt32? { deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 } - + /// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection. var displayUUID: UUID? { guard let displayID = displayID else { return nil } @@ -19,7 +19,7 @@ extension NSScreen { var hasDock: Bool { // If the dock autohides then we don't have a dock ever. if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { - if (dockAutohide) { return false } + if dockAutohide { return false } } // There is no public API to directly ask about dock visibility, so we have to figure it out @@ -29,7 +29,7 @@ extension NSScreen { // which triggers showing the dock. // If our visible width is less than the frame we assume its the dock. - if (visibleFrame.width < frame.width) { + if visibleFrame.width < frame.width { return true } @@ -48,7 +48,7 @@ extension NSScreen { // know any other situation this is true. return safeAreaInsets.top > 0 } - + /// Converts top-left offset coordinates to bottom-left origin coordinates for window positioning. /// - Parameters: /// - x: X offset from top-left corner @@ -57,11 +57,11 @@ extension NSScreen { /// - Returns: CGPoint suitable for setFrameOrigin that positions the window as requested func origin(fromTopLeftOffsetX x: CGFloat, offsetY y: CGFloat, windowSize: CGSize) -> CGPoint { let vf = visibleFrame - + // Convert top-left coordinates to bottom-left origin let originX = vf.minX + x let originY = vf.maxY - y - windowSize.height - + return CGPoint(x: originX, y: originY) } } diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index fb209e4ac..2546caa38 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -52,10 +52,8 @@ extension NSView { return true } - for subview in subviews { - if subview.contains(view) { - return true - } + for subview in subviews where subview.contains(view) { + return true } return false @@ -67,10 +65,8 @@ extension NSView { return true } - for subview in subviews { - if subview.contains(className: name) { - return true - } + for subview in subviews where subview.contains(className: name) { + return true } return false @@ -131,12 +127,12 @@ extension NSView { /// This includes private views like title bar views. func firstViewFromRoot(withClassName name: String) -> NSView? { let root = rootView - + // Check if the root view itself matches if String(describing: type(of: root)) == name { return root } - + // Otherwise search descendants return root.firstDescendant(withClassName: name) } @@ -155,67 +151,67 @@ extension NSView { print("View Hierarchy from Root:") print(root.viewHierarchyDescription()) } - + /// Returns a string representation of the view hierarchy in a tree-like format. func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String { var result = "" - + // Add the tree branch characters result += indent if !indent.isEmpty { result += isLast ? "└── " : "├── " } - + // Add the class name and optional identifier let className = String(describing: type(of: self)) result += className - + // Add identifier if present if let identifier = self.identifier { result += " (id: \(identifier.rawValue))" } - + // Add frame info result += " [frame: \(frame)]" - + // Add visual properties var properties: [String] = [] - + // Hidden status if isHidden { properties.append("hidden") } - + // Opaque status properties.append(isOpaque ? "opaque" : "transparent") - + // Layer backing if wantsLayer { properties.append("layer-backed") if let bgColor = layer?.backgroundColor { let color = NSColor(cgColor: bgColor) if let rgb = color?.usingColorSpace(.deviceRGB) { - properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)", - rgb.redComponent * 255, - rgb.greenComponent * 255, - rgb.blueComponent * 255, + properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)", + rgb.redComponent * 255, + rgb.greenComponent * 255, + rgb.blueComponent * 255, rgb.alphaComponent)) } else { properties.append("bg:\(bgColor)") } } } - + result += " [\(properties.joined(separator: ", "))]" result += "\n" - + // Process subviews for (index, subview) in subviews.enumerated() { let isLastSubview = index == subviews.count - 1 let newIndent = indent + (isLast ? " " : "│ ") result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview) } - + return result } } diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 5d1831f26..0fa330f1b 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -52,31 +52,31 @@ extension NSWindow { guard themeFrameView.responds(to: Selector(("titlebarView"))) else { return nil } return themeFrameView.value(forKey: "titlebarView") as? NSView } - + /// Returns the [private] NSTabBar view, if it exists. var tabBarView: NSView? { titlebarView?.firstDescendant(withClassName: "NSTabBar") } - + /// Returns the index of the tab button at the given screen point, if any. func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { guard let tabBarView else { return nil } let locationInWindow = convertPoint(fromScreen: screenPoint) let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { return nil } - + // Find all tab buttons and sort by x position to get visual order. // The view hierarchy order doesn't match the visual tab order. let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton") .sorted { $0.frame.origin.x < $1.frame.origin.x } - + for (index, tabItemView) in tabItemViews.enumerated() { let locationInTab = tabItemView.convert(locationInWindow, from: nil) if tabItemView.bounds.contains(locationInTab) { return index } } - + return nil } } diff --git a/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift b/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift index e87e0676c..c4f7ca5c1 100644 --- a/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift @@ -24,7 +24,7 @@ extension NSWorkspace { nil )?.takeRetainedValue() as? URL } - + /// Returns the URL of the default application for opening files with the specified file extension. /// - Parameter ext: The file extension to find the default application for. /// - Returns: The URL of the default application, or nil if no default application is found. diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 03f715fd8..e28877ca8 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -27,5 +27,4 @@ extension String { } #endif - } diff --git a/macos/Sources/Helpers/Extensions/Transferable+Extension.swift b/macos/Sources/Helpers/Extensions/Transferable+Extension.swift index 3bcc9057f..a45cdc7a4 100644 --- a/macos/Sources/Helpers/Extensions/Transferable+Extension.swift +++ b/macos/Sources/Helpers/Extensions/Transferable+Extension.swift @@ -40,16 +40,16 @@ private final class TransferableDataProvider: NSObject, NSPasteboardItemDataProv // to block until the async load completes. This is safe because AppKit // calls this method on a background thread during drag operations. let semaphore = DispatchSemaphore(value: 0) - + var result: Data? itemProvider.loadDataRepresentation(forTypeIdentifier: type.rawValue) { data, _ in result = data semaphore.signal() } - + // Wait for the data to load semaphore.wait() - + // Set it. I honestly don't know what happens here if this fails. if let data = result { item.setData(data, forType: type) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 8ab476267..6773b6f0c 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -204,12 +204,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // We must hide the dock FIRST then hide the menu: // If you specify autoHideMenuBar, it must be accompanied by either hideDock or autoHideDock. // https://developer.apple.com/documentation/appkit/nsapplication/presentationoptions-swift.struct - if (savedState.dock) { + if savedState.dock { hideDock() } // Hide the menu if requested - if (properties.hideMenu && savedState.menu) { + if properties.hideMenu && savedState.menu { hideMenu() } @@ -261,7 +261,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if savedState.dock { unhideDock() } - if (properties.hideMenu && savedState.menu) { + if properties.hideMenu && savedState.menu { unhideMenu() } @@ -328,8 +328,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // calculate this ourselves. var frame = screen.frame - if (!NSApp.presentationOptions.contains(.autoHideMenuBar) && - !NSApp.presentationOptions.contains(.hideMenuBar)) { + if !NSApp.presentationOptions.contains(.autoHideMenuBar) && + !NSApp.presentationOptions.contains(.hideMenuBar) { // We need to subtract the menu height since we're still showing it. frame.size.height -= NSApp.mainMenu?.menuBarHeight ?? 0 @@ -339,7 +339,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // put an #available check, but it was in a bug fix release so I think // if a bug is reported to Ghostty we can just advise the user to // update. - } else if (properties.paddedNotch) { + } else if properties.paddedNotch { // We are hiding the menu, we may need to avoid the notch. frame.size.height -= screen.safeAreaInsets.top } @@ -413,7 +413,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.toolbarStyle = window.toolbarStyle self.dock = window.screen?.hasDock ?? false - self.titlebarAccessoryViewControllers = if (window.hasTitleBar) { + self.titlebarAccessoryViewControllers = if window.hasTitleBar { // Accessing titlebarAccessoryViewControllers without a titlebar triggers a crash. window.titlebarAccessoryViewControllers } else { diff --git a/macos/Sources/Helpers/MetalView.swift b/macos/Sources/Helpers/MetalView.swift index 6579f8863..e8c27b52b 100644 --- a/macos/Sources/Helpers/MetalView.swift +++ b/macos/Sources/Helpers/MetalView.swift @@ -10,7 +10,7 @@ struct MetalView: View { } } -fileprivate struct MetalViewRepresentable: NSViewRepresentable { +private struct MetalViewRepresentable: NSViewRepresentable { @Binding var metalView: V func makeNSView(context: Context) -> some NSView { diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift index 9c16c7163..29d1ab6d3 100644 --- a/macos/Sources/Helpers/PermissionRequest.swift +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -40,7 +40,7 @@ class PermissionRequest { completion(storedResult) return } - + let alert = NSAlert() alert.messageText = message alert.informativeText = informative @@ -59,7 +59,7 @@ class PermissionRequest { target: nil, action: nil) checkbox!.state = .off - + // Set checkbox as accessory view alert.accessoryView = checkbox } @@ -74,7 +74,7 @@ class PermissionRequest { handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion) } } - + /// Handles the alert response and processes caching logic /// - Parameters: /// - response: The alert response from the user @@ -90,7 +90,7 @@ class PermissionRequest { allowDuration: AllowDuration, rememberDuration: Duration?, completion: @escaping (Bool) -> Void) { - + let result: Bool switch response { case .alertFirstButtonReturn: // Allow @@ -100,7 +100,7 @@ class PermissionRequest { default: result = false } - + // Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set if rememberDecision, let rememberDuration = rememberDuration { storeResult(result, for: key, duration: rememberDuration) @@ -118,10 +118,10 @@ class PermissionRequest { storeResult(result, for: key, duration: duration) } } - + completion(result) } - + /// Retrieves a cached permission decision if it hasn't expired /// - Parameter key: The UserDefaults key to check /// - Returns: The cached decision, or nil if no valid cached decision exists @@ -132,16 +132,16 @@ class PermissionRequest { ofClass: StoredPermission.self, from: data) else { return nil } - + if Date() > storedPermission.expiry { // Decision has expired, remove stored value userDefaults.removeObject(forKey: key) return nil } - + return storedPermission.result } - + /// Stores a permission decision in UserDefaults with an expiration date /// - Parameters: /// - result: The permission decision to store @@ -180,7 +180,7 @@ class PermissionRequest { return "Remember my decision for \(days) day\(days == 1 ? "" : "s")" } } - + /// Internal class for storing permission decisions with expiration dates in UserDefaults /// Conforms to NSSecureCoding for safe archiving/unarchiving @objc(StoredPermission) diff --git a/macos/Tests/NSPasteboardTests.swift b/macos/Tests/NSPasteboardTests.swift index d956ce733..9db17ca33 100644 --- a/macos/Tests/NSPasteboardTests.swift +++ b/macos/Tests/NSPasteboardTests.swift @@ -16,14 +16,14 @@ struct NSPasteboardTypeExtensionTests { #expect(pasteboardType != nil) #expect(pasteboardType == .string) } - + /// Test text/html MIME type converts to .html @Test func testTextHtmlMimeType() async throws { let pasteboardType = NSPasteboard.PasteboardType(mimeType: "text/html") #expect(pasteboardType != nil) #expect(pasteboardType == .html) } - + /// Test image/png MIME type @Test func testImagePngMimeType() async throws { let pasteboardType = NSPasteboard.PasteboardType(mimeType: "image/png") diff --git a/macos/Tests/NSScreenTests.swift b/macos/Tests/NSScreenTests.swift index f7431bf05..6e67bb7e4 100644 --- a/macos/Tests/NSScreenTests.swift +++ b/macos/Tests/NSScreenTests.swift @@ -15,65 +15,65 @@ struct NSScreenExtensionTests { // Mock screen with 1000x800 visible frame starting at (0, 100) let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800) let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) - + // Mock window size let windowSize = CGSize(width: 400, height: 300) - + // Test top-left positioning: x=15, y=15 let origin = mockScreen.origin( fromTopLeftOffsetX: 15, offsetY: 15, windowSize: windowSize) - + // Expected: x = 0 + 15 = 15, y = (100 + 800) - 15 - 300 = 585 #expect(origin.x == 15) #expect(origin.y == 585) } - + /// Test zero coordinates (exact top-left corner) @Test func testZeroCoordinates() async throws { let mockScreenFrame = NSRect(x: 0, y: 100, width: 1000, height: 800) let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) let windowSize = CGSize(width: 400, height: 300) - + let origin = mockScreen.origin( fromTopLeftOffsetX: 0, offsetY: 0, windowSize: windowSize) - + // Expected: x = 0, y = (100 + 800) - 0 - 300 = 600 #expect(origin.x == 0) #expect(origin.y == 600) } - + /// Test with offset screen (not starting at origin) @Test func testOffsetScreen() async throws { // Secondary monitor at position (1440, 0) with 1920x1080 resolution let mockScreenFrame = NSRect(x: 1440, y: 0, width: 1920, height: 1080) let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) let windowSize = CGSize(width: 600, height: 400) - + let origin = mockScreen.origin( fromTopLeftOffsetX: 100, offsetY: 50, windowSize: windowSize) - + // Expected: x = 1440 + 100 = 1540, y = (0 + 1080) - 50 - 400 = 630 #expect(origin.x == 1540) #expect(origin.y == 630) } - + /// Test large coordinates @Test func testLargeCoordinates() async throws { let mockScreenFrame = NSRect(x: 0, y: 0, width: 1920, height: 1080) let mockScreen = MockNSScreen(visibleFrame: mockScreenFrame) let windowSize = CGSize(width: 400, height: 300) - + let origin = mockScreen.origin( fromTopLeftOffsetX: 500, offsetY: 200, windowSize: windowSize) - + // Expected: x = 0 + 500 = 500, y = (0 + 1080) - 200 - 300 = 580 #expect(origin.x == 500) #expect(origin.y == 580) @@ -83,16 +83,16 @@ struct NSScreenExtensionTests { /// Mock NSScreen class for testing coordinate conversion private class MockNSScreen: NSScreen { private let mockVisibleFrame: NSRect - + init(visibleFrame: NSRect) { self.mockVisibleFrame = visibleFrame super.init() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override var visibleFrame: NSRect { return mockVisibleFrame } diff --git a/macos/Tests/Update/ReleaseNotesTests.swift b/macos/Tests/Update/ReleaseNotesTests.swift index b029fa6bc..6c7d43ed5 100644 --- a/macos/Tests/Update/ReleaseNotesTests.swift +++ b/macos/Tests/Update/ReleaseNotesTests.swift @@ -9,7 +9,7 @@ struct ReleaseNotesTests { displayVersionString: "1.2.3", currentCommit: nil ) - + #expect(notes != nil) if case .tagged(let url) = notes { #expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3") @@ -18,14 +18,14 @@ struct ReleaseNotesTests { Issue.record("Expected tagged case") } } - + /// Test tip release comparison with current commit @Test func testTipReleaseComparison() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "tip-abc1234", currentCommit: "def5678" ) - + #expect(notes != nil) if case .compareTip(let url) = notes { #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") @@ -34,14 +34,14 @@ struct ReleaseNotesTests { Issue.record("Expected compareTip case") } } - + /// Test tip release without current commit @Test func testTipReleaseWithoutCurrentCommit() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "tip-abc1234", currentCommit: nil ) - + #expect(notes != nil) if case .commit(let url) = notes { #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") @@ -50,14 +50,14 @@ struct ReleaseNotesTests { Issue.record("Expected commit case") } } - + /// Test tip release with empty current commit @Test func testTipReleaseWithEmptyCurrentCommit() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "tip-abc1234", currentCommit: "" ) - + #expect(notes != nil) if case .commit(let url) = notes { #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234") @@ -65,14 +65,14 @@ struct ReleaseNotesTests { Issue.record("Expected commit case") } } - + /// Test version with full 40-character hash @Test func testFullGitHash() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678", currentCommit: nil ) - + #expect(notes != nil) if case .commit(let url) = notes { #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678") @@ -80,46 +80,46 @@ struct ReleaseNotesTests { Issue.record("Expected commit case") } } - + /// Test version with no recognizable pattern @Test func testInvalidVersion() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "unknown-version", currentCommit: nil ) - + #expect(notes == nil) } - + /// Test semantic version with prerelease suffix should not match @Test func testSemanticVersionWithSuffix() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "1.2.3-beta", currentCommit: nil ) - + // Should not match semantic version pattern, falls back to hash detection #expect(notes == nil) } - + /// Test semantic version with 4 components should not match @Test func testSemanticVersionFourComponents() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "1.2.3.4", currentCommit: nil ) - + // Should not match pattern #expect(notes == nil) } - + /// Test version string with git hash embedded @Test func testVersionWithEmbeddedHash() async throws { let notes = UpdateState.ReleaseNotes( displayVersionString: "v2024.01.15-abc1234", currentCommit: "def5678" ) - + #expect(notes != nil) if case .compareTip(let url) = notes { #expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234") diff --git a/macos/Tests/Update/UpdateStateTests.swift b/macos/Tests/Update/UpdateStateTests.swift index 354d371c5..6aefa22a2 100644 --- a/macos/Tests/Update/UpdateStateTests.swift +++ b/macos/Tests/Update/UpdateStateTests.swift @@ -5,25 +5,25 @@ import Sparkle struct UpdateStateTests { // MARK: - Equatable Tests - + @Test func testIdleEquality() { let state1: UpdateState = .idle let state2: UpdateState = .idle #expect(state1 == state2) } - + @Test func testCheckingEquality() { let state1: UpdateState = .checking(.init(cancel: {})) let state2: UpdateState = .checking(.init(cancel: {})) #expect(state1 == state2) } - + @Test func testNotFoundEquality() { let state1: UpdateState = .notFound(.init(acknowledgement: {})) let state2: UpdateState = .notFound(.init(acknowledgement: {})) #expect(state1 == state2) } - + @Test func testInstallingEquality() { let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) @@ -31,7 +31,7 @@ struct UpdateStateTests { let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) #expect(state3 != state2) } - + @Test func testPermissionRequestEquality() { let request1 = SPUUpdatePermissionRequest(systemProfile: []) let request2 = SPUUpdatePermissionRequest(systemProfile: []) @@ -39,43 +39,43 @@ struct UpdateStateTests { let state2: UpdateState = .permissionRequest(.init(request: request2, reply: { _ in })) #expect(state1 == state2) } - + @Test func testDownloadingEqualityWithSameProgress() { let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) #expect(state1 == state2) } - + @Test func testDownloadingInequalityWithDifferentProgress() { let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 600)) #expect(state1 != state2) } - + @Test func testDownloadingInequalityWithDifferentExpectedLength() { let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: 2000, progress: 500)) #expect(state1 != state2) } - + @Test func testDownloadingEqualityWithNilExpectedLength() { let state1: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) let state2: UpdateState = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) #expect(state1 == state2) } - + @Test func testExtractingEqualityWithSameProgress() { let state1: UpdateState = .extracting(.init(progress: 0.5)) let state2: UpdateState = .extracting(.init(progress: 0.5)) #expect(state1 == state2) } - + @Test func testExtractingInequalityWithDifferentProgress() { let state1: UpdateState = .extracting(.init(progress: 0.5)) let state2: UpdateState = .extracting(.init(progress: 0.6)) #expect(state1 != state2) } - + @Test func testErrorEqualityWithSameDescription() { let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error message"]) let error2 = NSError(domain: "Test", code: 2, userInfo: [NSLocalizedDescriptionKey: "Error message"]) @@ -83,7 +83,7 @@ struct UpdateStateTests { let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) #expect(state1 == state2) } - + @Test func testErrorInequalityWithDifferentDescription() { let error1 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 1"]) let error2 = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Error 2"]) @@ -91,20 +91,20 @@ struct UpdateStateTests { let state2: UpdateState = .error(.init(error: error2, retry: {}, dismiss: {})) #expect(state1 != state2) } - + @Test func testDifferentStatesAreNotEqual() { let state1: UpdateState = .idle let state2: UpdateState = .checking(.init(cancel: {})) #expect(state1 != state2) } - + // MARK: - isIdle Tests - + @Test func testIsIdleTrue() { let state: UpdateState = .idle #expect(state.isIdle == true) } - + @Test func testIsIdleFalse() { let state: UpdateState = .checking(.init(cancel: {})) #expect(state.isIdle == false) diff --git a/macos/Tests/Update/UpdateViewModelTests.swift b/macos/Tests/Update/UpdateViewModelTests.swift index 529c2bc52..9b747f9ec 100644 --- a/macos/Tests/Update/UpdateViewModelTests.swift +++ b/macos/Tests/Update/UpdateViewModelTests.swift @@ -6,50 +6,50 @@ import Sparkle struct UpdateViewModelTests { // MARK: - Text Formatting Tests - + @Test func testIdleText() { let viewModel = UpdateViewModel() viewModel.state = .idle #expect(viewModel.text == "") } - + @Test func testPermissionRequestText() { let viewModel = UpdateViewModel() let request = SPUUpdatePermissionRequest(systemProfile: []) viewModel.state = .permissionRequest(.init(request: request, reply: { _ in })) #expect(viewModel.text == "Enable Automatic Updates?") } - + @Test func testCheckingText() { let viewModel = UpdateViewModel() viewModel.state = .checking(.init(cancel: {})) #expect(viewModel.text == "Checking for Updates…") } - + @Test func testDownloadingTextWithKnownLength() { let viewModel = UpdateViewModel() viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 500)) #expect(viewModel.text == "Downloading: 50%") } - + @Test func testDownloadingTextWithUnknownLength() { let viewModel = UpdateViewModel() viewModel.state = .downloading(.init(cancel: {}, expectedLength: nil, progress: 500)) #expect(viewModel.text == "Downloading…") } - + @Test func testDownloadingTextWithZeroExpectedLength() { let viewModel = UpdateViewModel() viewModel.state = .downloading(.init(cancel: {}, expectedLength: 0, progress: 500)) #expect(viewModel.text == "Downloading…") } - + @Test func testExtractingText() { let viewModel = UpdateViewModel() viewModel.state = .extracting(.init(progress: 0.75)) #expect(viewModel.text == "Preparing: 75%") } - + @Test func testInstallingText() { let viewModel = UpdateViewModel() viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {})) @@ -57,34 +57,34 @@ struct UpdateViewModelTests { viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) #expect(viewModel.text == "Restart to Complete Update") } - + @Test func testNotFoundText() { let viewModel = UpdateViewModel() viewModel.state = .notFound(.init(acknowledgement: {})) #expect(viewModel.text == "No Updates Available") } - + @Test func testErrorText() { let viewModel = UpdateViewModel() let error = NSError(domain: "Test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) viewModel.state = .error(.init(error: error, retry: {}, dismiss: {})) #expect(viewModel.text == "Network error") } - + // MARK: - Max Width Text Tests - + @Test func testMaxWidthTextForDownloading() { let viewModel = UpdateViewModel() viewModel.state = .downloading(.init(cancel: {}, expectedLength: 1000, progress: 50)) #expect(viewModel.maxWidthText == "Downloading: 100%") } - + @Test func testMaxWidthTextForExtracting() { let viewModel = UpdateViewModel() viewModel.state = .extracting(.init(progress: 0.5)) #expect(viewModel.maxWidthText == "Preparing: 100%") } - + @Test func testMaxWidthTextForNonProgressState() { let viewModel = UpdateViewModel() viewModel.state = .checking(.init(cancel: {})) diff --git a/nix/devShell.nix b/nix/devShell.nix index 90059a730..c78c9081b 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -66,6 +66,7 @@ poop, typos, shellcheck, + swiftlint, uv, wayland, wayland-scanner, @@ -198,6 +199,9 @@ in # for benchmarking poop + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + swiftlint ]; # This should be set onto the rpath of the ghostty binary if you want diff --git a/pkg/android-ndk/build.zig b/pkg/android-ndk/build.zig new file mode 100644 index 000000000..5b005665b --- /dev/null +++ b/pkg/android-ndk/build.zig @@ -0,0 +1,207 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub fn build(_: *std.Build) !void {} + +// Configure the step to point to the Android NDK for libc and include +// paths. This requires the Android NDK installed in the system and +// setting the appropriate environment variables or installing the NDK +// in the default location. +// +// The environment variables can be set as follows: +// - `ANDROID_NDK_HOME`: Directly points to the NDK path, including the version. +// - `ANDROID_HOME` or `ANDROID_SDK_ROOT`: Points to the Android SDK path; +// latest available NDK will be automatically selected. +// +// NB: This is a workaround until zig natively supports bionic +// cross-compilation (ziglang/zig#23906). +pub fn addPaths(b: *std.Build, step: *std.Build.Step.Compile) !void { + const Cache = struct { + const Key = struct { + arch: std.Target.Cpu.Arch, + abi: std.Target.Abi, + api_level: u32, + }; + + var map: std.AutoHashMapUnmanaged(Key, ?struct { + libc: std.Build.LazyPath, + cpp_include: std.Build.LazyPath, + lib: std.Build.LazyPath, + }) = .empty; + }; + + const target = step.rootModuleTarget(); + const gop = try Cache.map.getOrPut(b.allocator, .{ + .arch = target.cpu.arch, + .abi = target.abi, + .api_level = target.os.version_range.linux.android, + }); + + if (!gop.found_existing) { + const ndk_path = findNDKPath(b) orelse return error.AndroidNDKNotFound; + + const ndk_triple = ndkTriple(target) orelse { + gop.value_ptr.* = null; + return error.AndroidNDKUnsupportedTarget; + }; + + const host = hostTag() orelse { + gop.value_ptr.* = null; + return error.AndroidNDKUnsupportedHost; + }; + + const sysroot = b.pathJoin(&.{ + ndk_path, + "toolchains", + "llvm", + "prebuilt", + host, + "sysroot", + }); + const include_dir = b.pathJoin(&.{ + sysroot, + "usr", + "include", + }); + const sys_include_dir = b.pathJoin(&.{ + sysroot, + "usr", + "include", + ndk_triple, + }); + const c_runtime_dir = b.pathJoin(&.{ + sysroot, + "usr", + "lib", + ndk_triple, + b.fmt("{d}", .{target.os.version_range.linux.android}), + }); + const lib = b.pathJoin(&.{ + sysroot, + "usr", + "lib", + ndk_triple, + }); + const cpp_include = b.pathJoin(&.{ + sysroot, + "usr", + "include", + "c++", + "v1", + }); + + const libc_txt = b.fmt( + \\include_dir={s} + \\sys_include_dir={s} + \\crt_dir={s} + \\msvc_lib_dir= + \\kernel32_lib_dir= + \\gcc_dir= + , .{ include_dir, sys_include_dir, c_runtime_dir }); + + const wf = b.addWriteFiles(); + const libc_path = wf.add("libc.txt", libc_txt); + + gop.value_ptr.* = .{ + .libc = libc_path, + .cpp_include = .{ .cwd_relative = cpp_include }, + .lib = .{ .cwd_relative = lib }, + }; + } + + const value = gop.value_ptr.* orelse return error.AndroidNDKNotFound; + + step.setLibCFile(value.libc); + step.root_module.addSystemIncludePath(value.cpp_include); + step.root_module.addLibraryPath(value.lib); +} + +fn findNDKPath(b: *std.Build) ?[]const u8 { + // Check if user has set the environment variable for the NDK path. + if (std.process.getEnvVarOwned(b.allocator, "ANDROID_NDK_HOME") catch null) |value| { + if (value.len == 0) return null; + var dir = std.fs.openDirAbsolute(value, .{}) catch return null; + defer dir.close(); + return value; + } + + // Check the common environment variables for the Android SDK path and look for the NDK inside it. + inline for (.{ "ANDROID_HOME", "ANDROID_SDK_ROOT" }) |env| { + if (std.process.getEnvVarOwned(b.allocator, env) catch null) |sdk| { + if (sdk.len > 0) { + if (findLatestNDK(b, sdk)) |ndk| return ndk; + } + } + } + + // As a fallback, we assume the most common/default SDK path based on the OS. + const home = std.process.getEnvVarOwned( + b.allocator, + if (builtin.os.tag == .windows) "LOCALAPPDATA" else "HOME", + ) catch return null; + + const default_sdk_path = b.pathJoin( + &.{ + home, + switch (builtin.os.tag) { + .linux => "Android/sdk", + .macos => "Library/Android/Sdk", + .windows => "Android/Sdk", + else => return null, + }, + }, + ); + + return findLatestNDK(b, default_sdk_path); +} + +fn findLatestNDK(b: *std.Build, sdk_path: []const u8) ?[]const u8 { + const ndk_dir = b.pathJoin(&.{ sdk_path, "ndk" }); + var dir = std.fs.openDirAbsolute(ndk_dir, .{ .iterate = true }) catch return null; + defer dir.close(); + + var latest_: ?struct { + name: []const u8, + version: std.SemanticVersion, + } = null; + var iterator = dir.iterate(); + + while (iterator.next() catch null) |file| { + if (file.kind != .directory) continue; + const version = std.SemanticVersion.parse(file.name) catch continue; + if (latest_) |latest| { + if (version.order(latest.version) != .gt) continue; + } + latest_ = .{ + .name = file.name, + .version = version, + }; + } + + const latest = latest_ orelse return null; + + return b.pathJoin(&.{ sdk_path, "ndk", latest.name }); +} + +fn hostTag() ?[]const u8 { + return switch (builtin.os.tag) { + .linux => "linux-x86_64", + // All darwin hosts use the same prebuilt binaries + // (https://developer.android.com/ndk/guides/other_build_systems). + .macos => "darwin-x86_64", + .windows => "windows-x86_64", + else => null, + }; +} + +// We must map the target architecture to the corresponding NDK triple following the NDK +// documentation: https://android.googlesource.com/platform/ndk/+/master/docs/BuildSystemMaintainers.md#architectures +fn ndkTriple(target: std.Target) ?[]const u8 { + return switch (target.cpu.arch) { + .arm => "arm-linux-androideabi", + .aarch64 => "aarch64-linux-android", + .x86 => "i686-linux-android", + .x86_64 => "x86_64-linux-android", + else => null, + }; +} diff --git a/pkg/android-ndk/build.zig.zon b/pkg/android-ndk/build.zig.zon new file mode 100644 index 000000000..eb0de6820 --- /dev/null +++ b/pkg/android-ndk/build.zig.zon @@ -0,0 +1,10 @@ +.{ + .name = .android_ndk, + .version = "0.0.2", + .fingerprint = 0xee68d62c5a97b68b, + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + }, +} diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig index 3715baf4a..b6e188b13 100644 --- a/pkg/highway/build.zig +++ b/pkg/highway/build.zig @@ -31,6 +31,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ diff --git a/pkg/highway/build.zig.zon b/pkg/highway/build.zig.zon index 0777fcb7a..4870d1db5 100644 --- a/pkg/highway/build.zig.zon +++ b/pkg/highway/build.zig.zon @@ -12,5 +12,6 @@ }, .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig index 3123cab21..8dcd141c1 100644 --- a/pkg/simdutf/build.zig +++ b/pkg/simdutf/build.zig @@ -20,6 +20,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); // Zig 0.13 bug: https://github.com/ziglang/zig/issues/20414 diff --git a/pkg/simdutf/build.zig.zon b/pkg/simdutf/build.zig.zon index cd81c841e..afbef5418 100644 --- a/pkg/simdutf/build.zig.zon +++ b/pkg/simdutf/build.zig.zon @@ -5,5 +5,6 @@ .paths = .{""}, .dependencies = .{ .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig index e06813b83..08efb4ac8 100644 --- a/pkg/utfcpp/build.zig +++ b/pkg/utfcpp/build.zig @@ -19,6 +19,11 @@ pub fn build(b: *std.Build) !void { try apple_sdk.addPaths(b, lib); } + if (target.result.abi.isAndroid()) { + const android_ndk = @import("android_ndk"); + try android_ndk.addPaths(b, lib); + } + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); diff --git a/pkg/utfcpp/build.zig.zon b/pkg/utfcpp/build.zig.zon index eff395a60..1077e9655 100644 --- a/pkg/utfcpp/build.zig.zon +++ b/pkg/utfcpp/build.zig.zon @@ -12,5 +12,6 @@ }, .apple_sdk = .{ .path = "../apple-sdk" }, + .android_ndk = .{ .path = "../android-ndk" }, }, } diff --git a/po/bg_BG.UTF-8.po b/po/bg_BG.UTF-8.po index 3f7d55913..ac4e8c24c 100644 --- a/po/bg_BG.UTF-8.po +++ b/po/bg_BG.UTF-8.po @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Отвори в Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -179,7 +179,7 @@ msgstr "Раздел" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Смени името на таба…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -336,7 +336,7 @@ msgstr "Промяна на заглавието на терминала" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Смени името на таба" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/es_AR.UTF-8.po b/po/es_AR.UTF-8.po index 930d8ada5..3182ebf48 100644 --- a/po/es_AR.UTF-8.po +++ b/po/es_AR.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-09 17:50-0300\n" +"PO-Revision-Date: 2026-02-19 13:34-0300\n" "Last-Translator: Alan Moyano \n" "Language-Team: Argentinian \n" "Language: es_AR\n" @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Abrir en Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -179,7 +179,7 @@ msgstr "Pestaña" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Cambiar título de la pestaña…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -332,11 +332,11 @@ msgstr "Comando fallido" #: src/apprt/gtk/class/title_dialog.zig:225 msgid "Change Terminal Title" -msgstr "Cambiar el título de la terminal" +msgstr "Cambiar título de la terminal" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Cambiar título de la pestaña" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/it_IT.UTF-8.po b/po/it_IT.UTF-8.po index 87fc0c756..ea2031382 100644 --- a/po/it_IT.UTF-8.po +++ b/po/it_IT.UTF-8.po @@ -1,4 +1,4 @@ -# Italian translations for com.mitchellh.ghostty package +# Italian translations for com.mitchellh.ghostty package. # Traduzioni italiane per il pacchetto com.mitchellh.ghostty. # Copyright (C) 2025 "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Apri in Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -93,23 +93,23 @@ msgstr "Ghostty: Ispettore del terminale" #: src/apprt/gtk/ui/1.2/search-overlay.blp:29 msgid "Find…" -msgstr "" +msgstr "Cerca…" #: src/apprt/gtk/ui/1.2/search-overlay.blp:64 msgid "Previous Match" -msgstr "" +msgstr "Corrispondenza precedente" #: src/apprt/gtk/ui/1.2/search-overlay.blp:74 msgid "Next Match" -msgstr "" +msgstr "Corrispondenza successiva" #: src/apprt/gtk/ui/1.2/surface.blp:6 msgid "Oh, no." -msgstr "" +msgstr "Oh no!" #: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Unable to acquire an OpenGL context for rendering." -msgstr "" +msgstr "Impossibile ottenere un contesto OpenGL per il rendering." #: src/apprt/gtk/ui/1.2/surface.blp:97 msgid "" @@ -117,10 +117,13 @@ msgid "" "through the content, but no input events will be sent to the running " "application." msgstr "" +"Questo terminale è in modalità di sola lettura. Puoi ancora vedere, " +"selezionare e scorrere il contenuto, ma non verrà inviato alcun evento " +"di input all'applicazione in esecuzione." #: src/apprt/gtk/ui/1.2/surface.blp:107 msgid "Read-only" -msgstr "" +msgstr "Sola lettura" #: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 msgid "Copy" @@ -132,7 +135,7 @@ msgstr "Incolla" #: src/apprt/gtk/ui/1.2/surface.blp:270 msgid "Notify on Next Command Finish" -msgstr "" +msgstr "Notifica al termine del prossimo comando" #: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" @@ -177,7 +180,7 @@ msgstr "Scheda" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Cambia titolo scheda…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -311,15 +314,15 @@ msgstr "" #: src/apprt/gtk/class/surface.zig:1108 msgid "Command Finished" -msgstr "" +msgstr "Comando terminato" #: src/apprt/gtk/class/surface.zig:1109 msgid "Command Succeeded" -msgstr "" +msgstr "Comando riuscito" #: src/apprt/gtk/class/surface.zig:1110 msgid "Command Failed" -msgstr "" +msgstr "Comando fallito" #: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" @@ -335,7 +338,7 @@ msgstr "Cambia il titolo del terminale" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Cambia il titolo della scheda" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/lt_LT.UTF-8.po b/po/lt_LT.UTF-8.po index ed0ec86e6..ba4995ddc 100644 --- a/po/lt_LT.UTF-8.po +++ b/po/lt_LT.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-10 08:14+0100\n" +"PO-Revision-Date: 2026-02-20 12:13+0100\n" "Last-Translator: Tadas Lotuzas \n" "Language-Team: Language LT\n" "Language: LT\n" @@ -18,7 +18,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Atidaryti su Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -177,7 +177,7 @@ msgstr "Kortelė" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Keisti kortelės pavadinimą…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -334,7 +334,7 @@ msgstr "Keisti terminalo pavadinimą" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Keisti kortelės pavadinimą" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index e4e314141..d64229eed 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2025-09-03 01:50+0300\n" +"PO-Revision-Date: 2025-02-18 10:20+0100\n" "Last-Translator: Ivan Bastrakov \n" "Language-Team: Russian \n" "Language: ru\n" @@ -21,7 +21,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Открыть в Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -68,8 +68,8 @@ msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" -"Конфигурация содержит ошибки. Проверьте их ниже, а затем либо перезагрузите " -"конфигурацию, либо проигнорируйте ошибки." +"В конфигурации обнаружены перечисленные ниже ошибки. При необходимости " +"исправьте их, а затем перезагрузите конфигурацию." #: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10 msgid "Ignore" @@ -78,7 +78,7 @@ msgstr "Игнорировать" #: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11 #: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:300 msgid "Reload Configuration" -msgstr "Обновить конфигурацию" +msgstr "Перезагрузить конфигурацию" #: src/apprt/gtk/ui/1.2/debug-warning.blp:7 #: src/apprt/gtk/ui/1.3/debug-warning.blp:6 @@ -94,23 +94,23 @@ msgstr "Ghostty: инспектор терминала" #: src/apprt/gtk/ui/1.2/search-overlay.blp:29 msgid "Find…" -msgstr "" +msgstr "Найти…" #: src/apprt/gtk/ui/1.2/search-overlay.blp:64 msgid "Previous Match" -msgstr "" +msgstr "Предыдущий результат" #: src/apprt/gtk/ui/1.2/search-overlay.blp:74 msgid "Next Match" -msgstr "" +msgstr "Следующий результат" #: src/apprt/gtk/ui/1.2/surface.blp:6 msgid "Oh, no." -msgstr "" +msgstr "Ой!" #: src/apprt/gtk/ui/1.2/surface.blp:7 msgid "Unable to acquire an OpenGL context for rendering." -msgstr "" +msgstr "Не удалось получить доступ к контексту OpenGL для отрисовки." #: src/apprt/gtk/ui/1.2/surface.blp:97 msgid "" @@ -118,10 +118,13 @@ msgid "" "through the content, but no input events will be sent to the running " "application." msgstr "" +"Терминал работает в режиме только для чтения: его содержимое можно " +"прокручивать и выделять, но запущенное приложение не будет получать события " +"ввода." #: src/apprt/gtk/ui/1.2/surface.blp:107 msgid "Read-only" -msgstr "" +msgstr "Режим только для чтения" #: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200 msgid "Copy" @@ -133,7 +136,7 @@ msgstr "Вставить" #: src/apprt/gtk/ui/1.2/surface.blp:270 msgid "Notify on Next Command Finish" -msgstr "" +msgstr "Сообщить о завершении следующей команды" #: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273 msgid "Clear" @@ -149,7 +152,7 @@ msgstr "Сплит" #: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245 msgid "Change Title…" -msgstr "Изменить заголовок…" +msgstr "Переименовать…" #: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177 #: src/apprt/gtk/ui/1.5/window.blp:250 @@ -178,7 +181,7 @@ msgstr "Вкладка" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Переименовать вкладку…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -211,7 +214,7 @@ msgstr "Открыть конфигурационный файл" #: src/apprt/gtk/ui/1.5/title-dialog.blp:5 msgid "Leave blank to restore the default title." -msgstr "Оставьте пустым, чтобы восстановить исходный заголовок." +msgstr "Оставьте поле пустым, чтобы вернуть название по умолчанию." #: src/apprt/gtk/ui/1.5/title-dialog.blp:9 msgid "OK" @@ -262,8 +265,8 @@ msgid "" "An application is attempting to read from the clipboard. The current " "clipboard contents are shown below." msgstr "" -"Приложение пытается прочитать данные из буфера обмена. Эти данные отображены " -"ниже." +"Приложение пытается прочитать данные из буфера обмена. Его содержимое " +"показано ниже." #: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205 msgid "Warning: Potentially Unsafe Paste" @@ -274,12 +277,12 @@ msgid "" "Pasting this text into the terminal may be dangerous as it looks like some " "commands may be executed." msgstr "" -"Вставка этого текста в терминал может быть опасной. Это выглядит как " -"команды, которые могут быть исполнены." +"Этот текст может быть опасен: его вставка в терминал приведёт к выполнению " +"команд." #: src/apprt/gtk/class/close_confirmation_dialog.zig:184 msgid "Quit Ghostty?" -msgstr "Закрыть Ghostty?" +msgstr "Выйти из Ghostty?" #: src/apprt/gtk/class/close_confirmation_dialog.zig:185 msgid "Close Tab?" @@ -311,15 +314,15 @@ msgstr "Процесс, работающий в этой сплит-област #: src/apprt/gtk/class/surface.zig:1108 msgid "Command Finished" -msgstr "" +msgstr "Команда завершилась" #: src/apprt/gtk/class/surface.zig:1109 msgid "Command Succeeded" -msgstr "" +msgstr "Команда выполнена успешно" #: src/apprt/gtk/class/surface.zig:1110 msgid "Command Failed" -msgstr "" +msgstr "Команда завершилась с ошибкой" #: src/apprt/gtk/class/surface_child_exited.zig:109 msgid "Command succeeded" @@ -331,15 +334,15 @@ msgstr "Команда завершилась с ошибкой" #: src/apprt/gtk/class/title_dialog.zig:225 msgid "Change Terminal Title" -msgstr "Изменить заголовок терминала" +msgstr "Переименовать терминал" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Переименовать вкладку" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" -msgstr "Конфигурация была обновлена" +msgstr "Конфигурация перезагружена" #: src/apprt/gtk/class/window.zig:1566 msgid "Copied to clipboard" diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 92b79ee21..8e7e241fc 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "在 Ghostty 中打开" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -175,7 +175,7 @@ msgstr "标签页" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "更改标签页标题…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -326,7 +326,7 @@ msgstr "更改终端标题" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "更改标签页标题" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/src/Surface.zig b/src/Surface.zig index 588d52968..b9dbefa1b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -312,6 +312,7 @@ const DerivedConfig = struct { mouse_reporting: bool, mouse_scroll_multiplier: configpkg.MouseScrollMultiplier, mouse_shift_capture: configpkg.MouseShiftCapture, + fullscreen: configpkg.Fullscreen, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: ?input.OptionAsAlt, selection_clear_on_copy: bool, @@ -389,6 +390,7 @@ const DerivedConfig = struct { .mouse_reporting = config.@"mouse-reporting", .mouse_scroll_multiplier = config.@"mouse-scroll-multiplier", .mouse_shift_capture = config.@"mouse-shift-capture", + .fullscreen = config.fullscreen, .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_copy = config.@"selection-clear-on-copy", @@ -1174,7 +1176,7 @@ fn selectionScrollTick(self: *Surface) !void { } // Scroll the viewport as required - try t.scrollViewport(.{ .delta = delta }); + t.scrollViewport(.{ .delta = delta }); // Next, trigger our drag behavior const pin = t.screens.active.pages.pin(.{ @@ -2779,7 +2781,7 @@ pub fn keyCallback( try self.setSelection(null); } - if (self.config.scroll_to_bottom.keystroke) try self.io.terminal.scrollViewport(.bottom); + if (self.config.scroll_to_bottom.keystroke) self.io.terminal.scrollViewport(.bottom); try self.queueRender(); } @@ -3532,7 +3534,7 @@ pub fn scrollCallback( // Modify our viewport, this requires a lock since it affects // rendering. We have to switch signs here because our delta // is negative down but our viewport is positive down. - try self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 }); + self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 }); } } @@ -5063,7 +5065,7 @@ pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordin /// /// Precondition: the render_state mutex must be held. fn scrollToBottom(self: *Surface) !void { - try self.io.terminal.scrollViewport(.{ .bottom = {} }); + self.io.terminal.scrollViewport(.{ .bottom = {} }); try self.queueRender(); } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 1d9ef633c..06634856e 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -109,7 +109,7 @@ pub const Action = union(Key) { /// Toggle the quick terminal in or out. toggle_quick_terminal, - /// Toggle the command palette. This currently only works on macOS. + /// Toggle the command palette. toggle_command_palette, /// Toggle the visibility of all Ghostty terminal windows. diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index f96bccd64..dc33abd21 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -308,7 +308,7 @@ pub const Window = extern struct { if (priv.config) |config_obj| { const config = config_obj.get(); if (config.maximize) self.as(gtk.Window).maximize(); - if (config.fullscreen) self.as(gtk.Window).fullscreen(); + if (config.fullscreen != .false) self.as(gtk.Window).fullscreen(); // If we have an explicit title set, we set that immediately // so that any applications inspecting the window states see @@ -383,6 +383,10 @@ pub const Window = extern struct { .config = priv.config, }); if (parent_) |p| { + // For a new window's first tab, inherit the parent's initial size hints. + if (context == .window) { + surfaceInit(p.rt_surface.gobj(), self); + } tab.setParentWithContext(p, context); } diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 2f3d4a124..6d44c62b6 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -62,6 +62,13 @@ pub fn initShared( .{ .include_extensions = &.{".h"} }, ); + if (lib.rootModuleTarget().abi.isAndroid()) { + // Support 16kb page sizes, required for Android 15+. + lib.link_z_max_page_size = 16384; // 16kb + + try @import("android_ndk").addPaths(b, lib); + } + if (lib.rootModuleTarget().os.tag.isDarwin()) { // Self-hosted x86_64 doesn't work for darwin. It may not work // for other platforms too but definitely darwin. diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index 70f4ed810..2bb0d4508 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -20,12 +20,17 @@ fn computeWidth( _ = backing; _ = tracking; - // This condition is to get the previous behavior of uucode's `wcwidth`, - // returning the width of a code point in a grapheme cluster but with the - // exception to treat emoji modifiers as width 2 so they can be displayed - // in isolation. PRs to follow will take advantage of the new uucode - // `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. - if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier) { + // This condition is needed as Ghostty currently has a singular concept for + // the `width` of a code point, while `uucode` splits the concept into + // `wcwidth_standalone` and `wcwidth_zero_in_grapheme`. The two cases where + // we want to use the `wcwidth_standalone` despite the code point occupying + // zero width in a grapheme (`wcwidth_zero_in_grapheme`) are emoji + // modifiers and prepend code points. For emoji modifiers we want to + // support displaying them in isolation as color patches, and if prepend + // characters were to be width 0 they would disappear from the output with + // Ghostty's current width 0 handling. Future work will take advantage of + // the new uucode `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. + if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier and data.grapheme_break_no_control != .prepend) { data.width = 0; } else { data.width = @min(2, data.wcwidth_standalone); @@ -37,6 +42,7 @@ const width = config.Extension{ "wcwidth_standalone", "wcwidth_zero_in_grapheme", "is_emoji_modifier", + "grapheme_break_no_control", }, .compute = &computeWidth, .fields = &.{ diff --git a/src/config.zig b/src/config.zig index 4abd319a6..0bf61a47f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -31,6 +31,7 @@ pub const Keybinds = Config.Keybinds; pub const MouseShiftCapture = Config.MouseShiftCapture; pub const MouseScrollMultiplier = Config.MouseScrollMultiplier; pub const NonNativeFullscreen = Config.NonNativeFullscreen; +pub const Fullscreen = Config.Fullscreen; pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; diff --git a/src/config/CApi.zig b/src/config/CApi.zig index 4ea9ea63f..ca15ce4e8 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -144,3 +144,101 @@ export fn ghostty_config_open_path() c.String { const Diagnostic = extern struct { message: [*:0]const u8 = "", }; + +test "ghostty_config_get: bool" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.maximize = true; + + var out = false; + const key = "maximize"; + try testing.expect(ghostty_config_get(&cfg, &out, key, key.len)); + try testing.expect(out); +} + +test "ghostty_config_get: enum" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.@"window-theme" = .dark; + + var out: [*:0]const u8 = undefined; + const key = "window-theme"; + try testing.expect(ghostty_config_get(&cfg, @ptrCast(&out), key, key.len)); + const str = std.mem.sliceTo(out, 0); + try testing.expectEqualStrings("dark", str); +} + +test "ghostty_config_get: optional null returns false" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.@"unfocused-split-fill" = null; + + var out: Config.Color.C = undefined; + const key = "unfocused-split-fill"; + try testing.expect(!ghostty_config_get(&cfg, @ptrCast(&out), key, key.len)); +} + +test "ghostty_config_get: unknown key returns false" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + + var out = false; + const key = "not-a-real-key"; + try testing.expect(!ghostty_config_get(&cfg, &out, key, key.len)); +} + +test "ghostty_config_get: optional string null returns true" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.title = null; + + var out: ?[*:0]const u8 = undefined; + const key = "title"; + try testing.expect(ghostty_config_get(&cfg, @ptrCast(&out), key, key.len)); + try testing.expect(out == null); +} + +test "ghostty_config_get: float" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.@"background-opacity" = 0.42; + + var out: f64 = 0; + const key = "background-opacity"; + try testing.expect(ghostty_config_get(&cfg, &out, key, key.len)); + try testing.expectApproxEqAbs(@as(f64, 0.42), out, 0.000001); +} + +test "ghostty_config_get: struct cval conversion" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + cfg.background = .{ .r = 12, .g = 34, .b = 56 }; + + var out: Config.Color.C = undefined; + const key = "background"; + try testing.expect(ghostty_config_get(&cfg, @ptrCast(&out), key, key.len)); + try testing.expectEqual(@as(u8, 12), out.r); + try testing.expectEqual(@as(u8, 34), out.g); + try testing.expectEqual(@as(u8, 56), out.b); +} diff --git a/src/config/Config.zig b/src/config/Config.zig index bb86b6bd5..9b0e6cc0f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -39,6 +39,7 @@ pub const Path = @import("path.zig").Path; pub const RepeatablePath = @import("path.zig").RepeatablePath; const ClipboardCodepointMap = @import("ClipboardCodepointMap.zig"); const KeyRemapSet = @import("../input/key_mods.zig").RemapSet; +const string = @import("string.zig"); // We do this instead of importing all of terminal/main.zig to // limit the dependency graph. This is important because some things @@ -808,6 +809,24 @@ palette: Palette = .{}, /// Available since: 1.3.0 @"palette-generate": bool = true, +/// Invert the palette colors generated when `palette-generate` is enabled, +/// so that the colors go in reverse order. This allows palette-based +/// applications to work well in both light and dark mode since the +/// palettes are always relatively good colors. +/// +/// This defaults to off because some legacy terminal applications +/// hardcode the assumption that palette indices 16–231 are ordered from +/// darkest to lightest, so enabling this would make them unreadable. +/// This is not a generally good assumption and we encourage modern +/// terminal applications to use the indices in a more semantic way. +/// +/// This has no effect if `palette-generate` is disabled. +/// +/// For more information see `palette-generate`. +/// +/// Available since: 1.3.0 +@"palette-harmonious": bool = false, + /// The color of the cursor. If this is not set, a default will be chosen. /// /// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`) @@ -906,7 +925,7 @@ palette: Palette = .{}, /// anything but modifiers or keybinds that are processed by Ghostty). /// /// - `output` If set, scroll the surface to the bottom if there is new data -/// to display. (Currently unimplemented.) +/// to display (e.g., when new lines are printed to the terminal). /// /// The default is `keystroke, no-output`. @"scroll-to-bottom": ScrollToBottom = .default, @@ -1428,10 +1447,27 @@ maximize: bool = false, /// does not apply to tabs, splits, etc. However, this setting will apply to all /// new windows, not just the first one. /// -/// On macOS, this setting does not work if window-decoration is set to -/// "none", because native fullscreen on macOS requires window decorations -/// to be set. -fullscreen: bool = false, +/// Allowable values are: +/// +/// * `false` - Don't start in fullscreen (default) +/// * `true` - Start in native fullscreen +/// * `non-native` - (macOS only) Start in non-native fullscreen, hiding the +/// menu bar. This is faster than native fullscreen since it doesn't use +/// animations. On non-macOS platforms, this behaves the same as `true`. +/// * `non-native-visible-menu` - (macOS only) Start in non-native fullscreen, +/// keeping the menu bar visible. On non-macOS platforms, behaves like `true`. +/// * `non-native-padded-notch` - (macOS only) Start in non-native fullscreen, +/// hiding the menu bar but padding for the notch on applicable devices. +/// On non-macOS platforms, behaves like `true`. +/// +/// Important: tabs DO NOT WORK with non-native fullscreen modes. Non-native +/// fullscreen removes the titlebar and macOS native tabs require the titlebar. +/// If you use tabs, use `true` (native) instead. +/// +/// On macOS, `true` (native fullscreen) does not work if `window-decoration` +/// is set to `false`, because native fullscreen on macOS requires window +/// decorations. +fullscreen: Fullscreen = .false, /// The title Ghostty will use for the window. This will force the title of the /// window to be this title at all times and Ghostty will ignore any set title @@ -1822,6 +1858,12 @@ class: ?[:0]const u8 = null, /// If an invalid key is pressed, the sequence ends but the table remains /// active. /// +/// * Chain actions work within tables, the `chain` keyword applies to +/// the most recently defined binding in the table. e.g. if you set +/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`. +/// Important: chain itself doesn't get prefixed with the table name, +/// since it applies to the most recent binding in any table. +/// /// * Prefixes like `global:` work within tables: /// `foo/global:ctrl+a=new_window`. /// @@ -5136,6 +5178,17 @@ pub const NonNativeFullscreen = enum(c_int) { @"padded-notch", }; +/// Valid values for fullscreen config option +/// c_int because it needs to be extern compatible +/// If this is changed, you must also update ghostty.h +pub const Fullscreen = enum(c_int) { + false, + true, + @"non-native", + @"non-native-visible-menu", + @"non-native-padded-notch", +}; + pub const WindowPaddingColor = enum { background, extend, @@ -5919,22 +5972,15 @@ pub const SelectionWordChars = struct { pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { const value = input orelse return error.ValueRequired; - // Parse UTF-8 string into codepoints + // Parse string with Zig escape sequence support into codepoints var list: std.ArrayList(u21) = .empty; defer list.deinit(alloc); // Always include null as first boundary try list.append(alloc, 0); - // Parse the UTF-8 string - const utf8_view = std.unicode.Utf8View.init(value) catch { - // Invalid UTF-8, just use null boundary - self.codepoints = try list.toOwnedSlice(alloc); - return; - }; - - var utf8_it = utf8_view.iterator(); - while (utf8_it.nextCodepoint()) |codepoint| { + var it = string.codepointIterator(value); + while (it.next() catch return error.InvalidValue) |codepoint| { try list.append(alloc, codepoint); } @@ -5987,6 +6033,56 @@ pub const SelectionWordChars = struct { try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); } + + test "parseCLI escape sequences" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \t escape should be parsed as tab + var chars: Self = .{}; + try chars.parseCLI(alloc, " \\t;,"); + + try testing.expectEqual(@as(usize, 5), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, ' '), chars.codepoints[1]); + try testing.expectEqual(@as(u21, '\t'), chars.codepoints[2]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[3]); + try testing.expectEqual(@as(u21, ','), chars.codepoints[4]); + } + + test "parseCLI backslash escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \\ should be parsed as a single backslash + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\\\;"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '\\'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } + + test "parseCLI unicode escape" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + // \u{2502} should be parsed as │ + var chars: Self = .{}; + try chars.parseCLI(alloc, "\\u{2502};"); + + try testing.expectEqual(@as(usize, 3), chars.codepoints.len); + try testing.expectEqual(@as(u21, 0), chars.codepoints[0]); + try testing.expectEqual(@as(u21, '│'), chars.codepoints[1]); + try testing.expectEqual(@as(u21, ';'), chars.codepoints[2]); + } }; /// FontVariation is a repeatable configuration value that sets a single @@ -6123,6 +6219,15 @@ pub const Keybinds = struct { /// which allows all table names to be available without reservation. tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty, + /// The most recent binding target for `chain=` additions. + /// + /// This is intentionally tracked at the Keybinds level so that chains can + /// apply across table boundaries according to parse order. + chain_target: union(enum) { + root, + table: []const u8, + } = .root, + pub fn init(self: *Keybinds, alloc: Allocator) !void { // We don't clear the memory because it's in the arena and unlikely // to be free-able anyways (since arenas can only clear the last @@ -6130,6 +6235,7 @@ pub const Keybinds = struct { // will be freed when the config is freed. self.set = .{}; self.tables = .empty; + self.chain_target = .root; // keybinds for opening and reloading config try self.set.put( @@ -6912,6 +7018,7 @@ pub const Keybinds = struct { log.info("config has 'keybind = clear', all keybinds cleared", .{}); self.set = .{}; self.tables = .empty; + self.chain_target = .root; return; } @@ -6949,16 +7056,39 @@ pub const Keybinds = struct { if (binding.len == 0) { log.debug("config has 'keybind = {s}/', table cleared", .{table_name}); gop.value_ptr.* = .{}; + self.chain_target = .root; return; } + // Chains are only allowed at the root level. Their target is + // tracked globally by parse order in `self.chain_target`. + if (std.mem.startsWith(u8, binding, "chain=")) { + return error.InvalidFormat; + } + // Parse and add the binding to the table try gop.value_ptr.parseAndPut(alloc, binding); + self.chain_target = .{ .table = gop.key_ptr.* }; + return; + } + + if (std.mem.startsWith(u8, value, "chain=")) { + switch (self.chain_target) { + .root => try self.set.parseAndPut(alloc, value), + .table => |table_name| { + const table = self.tables.getPtr(table_name) orelse { + self.chain_target = .root; + return error.InvalidFormat; + }; + try table.parseAndPut(alloc, value); + }, + } return; } // Parse into default set try self.set.parseAndPut(alloc, value); + self.chain_target = .root; } /// Deep copy of the struct. Required by Config. @@ -7400,6 +7530,63 @@ pub const Keybinds = struct { try testing.expect(keybinds.tables.contains("mytable")); } + test "parseCLI chain without prior parsed binding is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "chain=new_tab"), + ); + } + + test "parseCLI table chain syntax is invalid" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try testing.expectError( + error.InvalidFormat, + keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"), + ); + } + + test "parseCLI chain applies to most recent table binding" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var keybinds: Keybinds = .{}; + + try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo"); + try keybinds.parseCLI(alloc, "foo/a=text:hello"); + try keybinds.parseCLI(alloc, "chain=deactivate_key_table"); + + const root_entry = keybinds.set.get(.{ + .mods = .{ .ctrl = true }, + .key = .{ .unicode = 'n' }, + }).?.value_ptr.*; + try testing.expect(root_entry == .leaf); + try testing.expect(root_entry.leaf.action == .activate_key_table); + + const foo_entry = keybinds.tables.get("foo").?.get(.{ + .key = .{ .unicode = 'a' }, + }).?.value_ptr.*; + try testing.expect(foo_entry == .leaf_chained); + try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len); + try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text); + try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table); + } + test "clone with tables" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); diff --git a/src/config/string.zig b/src/config/string.zig index 71826f005..450799373 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -36,6 +36,40 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 { return out[0..dst_i]; } +/// Creates an iterator that requires no allocation to extract codepoints +/// from the string literal, parsing escape sequences as it goes. +pub fn codepointIterator(bytes: []const u8) CodepointIterator { + return .{ .bytes = bytes, .i = 0 }; +} + +pub const CodepointIterator = struct { + bytes: []const u8, + i: usize, + + pub fn next(self: *CodepointIterator) error{InvalidString}!?u21 { + if (self.i >= self.bytes.len) return null; + switch (self.bytes[self.i]) { + // An escape sequence + '\\' => return switch (std.zig.string_literal.parseEscapeSequence( + self.bytes, + &self.i, + )) { + .failure => error.InvalidString, + .success => |cp| cp, + }, + + // Not an escape, parse as UTF-8 + else => |start| { + const cp_len = std.unicode.utf8ByteSequenceLength(start) catch + return error.InvalidString; + defer self.i += cp_len; + return std.unicode.utf8Decode(self.bytes[self.i..][0..cp_len]) catch + return error.InvalidString; + }, + } + } +}; + test "parse: empty" { const testing = std.testing; @@ -65,3 +99,48 @@ test "parse: escapes" { try testing.expectEqualStrings("hello\u{1F601}world", result); } } + +test "codepointIterator: empty" { + var it = codepointIterator(""); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: ascii no escapes" { + var it = codepointIterator("abc"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'c'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: multibyte utf8" { + // │ is U+2502 (3 bytes in UTF-8) + var it = codepointIterator("a│b"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: escape sequences" { + var it = codepointIterator("a\\tb\\n\\\\"); + try std.testing.expectEqual(@as(u21, 'a'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\t'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'b'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\n'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, '\\'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: unicode escape" { + var it = codepointIterator("\\u{2502}x"); + try std.testing.expectEqual(@as(u21, '│'), (try it.next()).?); + try std.testing.expectEqual(@as(u21, 'x'), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} + +test "codepointIterator: emoji unicode escape" { + var it = codepointIterator("\\u{1F601}"); + try std.testing.expectEqual(@as(u21, 0x1F601), (try it.next()).?); + try std.testing.expectEqual(null, try it.next()); +} diff --git a/src/config/url.zig b/src/config/url.zig index da0892a91..e7cf8603c 100644 --- a/src/config/url.zig +++ b/src/config/url.zig @@ -65,11 +65,11 @@ const non_dotted_path_lookahead = ; const dotted_path_space_segments = - \\(?:(? .{ .top = {} }, .bottom => .{ .active = {} }, diff --git a/src/terminal/color.zig b/src/terminal/color.zig index 483d65e28..3b806f8b8 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -90,14 +90,31 @@ pub fn generate256Color( skip: PaletteMask, bg: RGB, fg: RGB, + harmonious: bool, ) Palette { // Convert the background, foreground, and 8 base theme colors into // CIELAB space so that all interpolation is perceptually uniform. - const bg_lab: LAB = .fromRgb(bg); - const fg_lab: LAB = .fromRgb(fg); const base8_lab: [8]LAB = base8: { - var base8: [8]LAB = undefined; - for (0..8) |i| base8[i] = .fromRgb(base[i]); + var base8: [8]LAB = .{ + .fromRgb(bg), + LAB.fromRgb(base[1]), + LAB.fromRgb(base[2]), + LAB.fromRgb(base[3]), + LAB.fromRgb(base[4]), + LAB.fromRgb(base[5]), + LAB.fromRgb(base[6]), + .fromRgb(fg), + }; + + // For light themes (where the foreground is darker than the + // background), the cube's dark-to-light orientation is inverted + // relative to the base color mapping. When `harmonious` is false, + // swap bg and fg so the cube still runs from black (16) to + // white (231). + const is_light_theme = base8[7].l < base8[0].l; + const invert = is_light_theme and !harmonious; + if (invert) std.mem.swap(LAB, &base8[0], &base8[7]); + break :base8 base8; }; @@ -115,10 +132,10 @@ pub fn generate256Color( for (0..6) |ri| { // R-axis corners: blend base colors along the red dimension. const tr = @as(f32, @floatFromInt(ri)) / 5.0; - const c0: LAB = .lerp(tr, bg_lab, base8_lab[1]); + const c0: LAB = .lerp(tr, base8_lab[0], base8_lab[1]); const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]); const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]); - const c3: LAB = .lerp(tr, base8_lab[6], fg_lab); + const c3: LAB = .lerp(tr, base8_lab[6], base8_lab[7]); for (0..6) |gi| { // G-axis edges: blend the R-interpolated corners along green. const tg = @as(f32, @floatFromInt(gi)) / 5.0; @@ -147,7 +164,7 @@ pub fn generate256Color( for (0..24) |i| { const t = @as(f32, @floatFromInt(i + 1)) / 25.0; if (!skip.isSet(idx)) { - const c: LAB = .lerp(t, bg_lab, fg_lab); + const c: LAB = .lerp(t, base8_lab[0], base8_lab[7]); result[idx] = c.toRgb(); } idx += 1; @@ -926,7 +943,7 @@ test "generate256Color: base16 preserved" { const bg = RGB{ .r = 0, .g = 0, .b = 0 }; const fg = RGB{ .r = 255, .g = 255, .b = 255 }; - const palette = generate256Color(default, .initEmpty(), bg, fg); + const palette = generate256Color(default, .initEmpty(), bg, fg, false); // The first 16 colors (base16) must remain unchanged. for (0..16) |i| { @@ -939,7 +956,7 @@ test "generate256Color: cube corners match base colors" { const bg = RGB{ .r = 0, .g = 0, .b = 0 }; const fg = RGB{ .r = 255, .g = 255, .b = 255 }; - const palette = generate256Color(default, .initEmpty(), bg, fg); + const palette = generate256Color(default, .initEmpty(), bg, fg, false); // Index 16 is cube (0,0,0) which should equal bg. try testing.expectEqual(bg, palette[16]); @@ -948,12 +965,43 @@ test "generate256Color: cube corners match base colors" { try testing.expectEqual(fg, palette[231]); } +test "generate256Color: cube corners black/white with harmonious=false" { + const testing = std.testing; + + const black = RGB{ .r = 0, .g = 0, .b = 0 }; + const white = RGB{ .r = 255, .g = 255, .b = 255 }; + + // Dark theme: bg=black, fg=white. + const dark = generate256Color(default, .initEmpty(), black, white, false); + try testing.expectEqual(black, dark[16]); + try testing.expectEqual(white, dark[231]); + + // Light theme: bg=white, fg=black. The bg/red swap ensures + // the cube still runs from black (16) to white (231). + const light = generate256Color(default, .initEmpty(), white, black, false); + try testing.expectEqual(black, light[16]); + try testing.expectEqual(white, light[231]); +} + +test "generate256Color: light theme cube corners with harmonious=true" { + const testing = std.testing; + + const white = RGB{ .r = 255, .g = 255, .b = 255 }; + const black = RGB{ .r = 0, .g = 0, .b = 0 }; + + // harmonious=true skips the bg/fg swap, so the cube preserves the + // original orientation: (0,0,0)=bg=white, (5,5,5)=fg=black. + const palette = generate256Color(default, .initEmpty(), white, black, true); + try testing.expectEqual(white, palette[16]); + try testing.expectEqual(black, palette[231]); +} + test "generate256Color: grayscale ramp monotonic luminance" { const testing = std.testing; const bg = RGB{ .r = 0, .g = 0, .b = 0 }; const fg = RGB{ .r = 255, .g = 255, .b = 255 }; - const palette = generate256Color(default, .initEmpty(), bg, fg); + const palette = generate256Color(default, .initEmpty(), bg, fg, false); // The grayscale ramp (232–255) should have monotonically increasing // luminance from near-black to near-white. @@ -977,7 +1025,7 @@ test "generate256Color: skip mask preserves original colors" { skip.set(100); skip.set(240); - const palette = generate256Color(default, skip, bg, fg); + const palette = generate256Color(default, skip, bg, fg, false); try testing.expectEqual(default[20], palette[20]); try testing.expectEqual(default[100], palette[100]); try testing.expectEqual(default[240], palette[240]); @@ -986,6 +1034,73 @@ test "generate256Color: skip mask preserves original colors" { try testing.expect(!palette[21].eql(default[21])); } +test "generate256Color: dark theme harmonious has no effect" { + const testing = std.testing; + + // For a dark theme (fg lighter than bg), harmonious should not change + // the output because the inversion is only relevant for light themes. + const bg = RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = RGB{ .r = 255, .g = 255, .b = 255 }; + const normal = generate256Color(default, .initEmpty(), bg, fg, false); + const harmonious = generate256Color(default, .initEmpty(), bg, fg, true); + + for (16..256) |i| { + try testing.expectEqual(normal[i], harmonious[i]); + } +} + +test "generate256Color: light theme harmonious skips inversion" { + const testing = std.testing; + + // For a light theme (fg darker than bg), harmonious=true skips the + // bg/red swap, producing different cube colors than harmonious=false. + const bg = RGB{ .r = 255, .g = 255, .b = 255 }; + const fg = RGB{ .r = 0, .g = 0, .b = 0 }; + const inverted = generate256Color(default, .initEmpty(), bg, fg, false); + const harmonious = generate256Color(default, .initEmpty(), bg, fg, true); + + // Cube origin (0,0,0) at index 16: without harmonious, bg and red are + // swapped so it becomes the red base; with harmonious it stays as bg. + try testing.expectEqual(bg, harmonious[16]); + try testing.expect(!inverted[16].eql(bg)); + + // At least some cube colors should differ between the two modes. + var differ: usize = 0; + for (16..232) |i| { + if (!inverted[i].eql(harmonious[i])) differ += 1; + } + try testing.expect(differ > 0); +} + +test "generate256Color: light theme harmonious grayscale ramp" { + const testing = std.testing; + + const bg = RGB{ .r = 255, .g = 255, .b = 255 }; + const fg = RGB{ .r = 0, .g = 0, .b = 0 }; + + // harmonious=false swaps bg/fg, so the ramp runs black→white (increasing). + { + const palette = generate256Color(default, .initEmpty(), bg, fg, false); + var prev_lum: f64 = 0.0; + for (232..256) |i| { + const lum = palette[i].luminance(); + try testing.expect(lum >= prev_lum); + prev_lum = lum; + } + } + + // harmonious=true keeps original order, so the ramp runs white→black (decreasing). + { + const palette = generate256Color(default, .initEmpty(), bg, fg, true); + var prev_lum: f64 = 1.0; + for (232..256) |i| { + const lum = palette[i].luminance(); + try testing.expect(lum <= prev_lum); + prev_lum = lum; + } + } +} + test "LAB.toRgb" { const testing = std.testing; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 9d75fe4b7..2332866ac 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -1092,7 +1092,7 @@ test "cursor state out of viewport" { try testing.expectEqual(1, state.cursor.viewport.?.y); // Scroll the viewport - try t.scrollViewport(.top); + t.scrollViewport(.top); try state.update(alloc, &t); // Set a style on the cursor diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 76deebcec..f5e6c8601 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -358,7 +358,7 @@ test "history search, no active area" { try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); try s.nextSlice("Buzz\r\nFizz"); - try t.scrollViewport(.top); + t.scrollViewport(.top); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dee58dc22..dcd0d8cf7 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -184,12 +184,7 @@ pub const DerivedConfig = struct { break :generate; } - break :palette terminalpkg.color.generate256Color( - config.palette.value, - config.palette.mask, - config.background.toTerminalRGB(), - config.foreground.toTerminalRGB(), - ); + break :palette terminalpkg.color.generate256Color(config.palette.value, config.palette.mask, config.background.toTerminalRGB(), config.foreground.toTerminalRGB(), config.@"palette-harmonious"); } break :palette config.palette.value; @@ -641,10 +636,13 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { } /// Scroll the viewport -pub fn scrollViewport(self: *Termio, scroll: terminalpkg.Terminal.ScrollViewport) !void { +pub fn scrollViewport( + self: *Termio, + scroll: terminalpkg.Terminal.ScrollViewport, +) void { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - try self.terminal.scrollViewport(scroll); + self.terminal.scrollViewport(scroll); } /// Jump the viewport to the prompt. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 6aa5e1c26..ce4c1f4af 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -321,7 +321,7 @@ fn drainMailbox( .resize => |v| self.handleResize(cb, v), .size_report => |v| try io.sizeReport(data, v), .clear_screen => |v| try io.clearScreen(data, v.history), - .scroll_viewport => |v| try io.scrollViewport(v), + .scroll_viewport => |v| io.scrollViewport(v), .selection_scroll => |v| { if (v) { self.startScrollTimer(cb); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index bc3edd185..8c1b5b8ab 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -232,7 +232,7 @@ pub const StreamHandler = struct { .erase_display_below => self.terminal.eraseDisplay(.below, value), .erase_display_above => self.terminal.eraseDisplay(.above, value), .erase_display_complete => { - try self.terminal.scrollViewport(.{ .bottom = {} }); + self.terminal.scrollViewport(.{ .bottom = {} }); self.terminal.eraseDisplay(.complete, value); }, .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value),