├── .git-blame-ignore-revs ├── .github ├── ISSUE_TEMPLATE │ ├── bug-報告.md │ └── bug_report.md └── workflows │ ├── commit-ci.yml │ ├── pull-request-ci.yml │ └── release-ci.yml ├── .gitignore ├── .gitmodules ├── .periphery.yml ├── .swiftlint.yml ├── Assets.xcassets ├── Contents.json └── RimeIcon.appiconset │ ├── Contents.json │ ├── rime-1024.png │ ├── rime-128.png │ ├── rime-16 1.png │ ├── rime-256.png │ ├── rime-32 1.png │ ├── rime-32.png │ ├── rime-512.png │ └── rime-64.png ├── CHANGELOG.md ├── INSTALL.md ├── LICENSE.txt ├── Makefile ├── README.md ├── Squirrel.xcodeproj ├── project.pbxproj └── xcshareddata │ └── xcschemes │ └── Squirrel.xcscheme ├── action-build.sh ├── action-changelog.sh ├── action-install.sh ├── bin └── .placeholder ├── data └── squirrel.yaml ├── lib └── .placeholder ├── package ├── PackageInfo ├── Squirrel-component.plist ├── add_data_files ├── bump_version ├── common.sh ├── make_archive ├── make_package └── sign_app ├── resources ├── Info.plist ├── InfoPlist.xcstrings ├── Localizable.xcstrings ├── Squirrel.entitlements └── rime.pdf ├── scripts └── postinstall └── sources ├── BridgingFunctions.swift ├── InputSource.swift ├── MacOSKeyCodes.swift ├── Main.swift ├── Squirrel-Bridging-Header.h ├── SquirrelApplicationDelegate.swift ├── SquirrelConfig.swift ├── SquirrelInputController.swift ├── SquirrelPanel.swift ├── SquirrelTheme.swift └── SquirrelView.swift /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-報告.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 報告 3 | about: 我覺得這是個 Bug 4 | title: "[Bug] 我其實沒有遇到Bug" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **簡要描述 Bug:** 11 | ~~我其實沒有遇到Bug~~ 12 | 13 | **預期行爲:** 14 | 15 | **實際行爲:** 16 | 17 | **環境** 18 | - 系統版本: (macOS 14.5) 19 | - 鼠鬚管版本: (1.0.0) 20 | - 方案: (如果你用的是自定義或第三方的方案,且該 Bug 可能與方案有關,請提供方案鏈接) 21 | - [ ] 使用了 Lua: (用了甚麼 Lua 腳本?) 22 | - [ ] 與其它 App 有關: (哪個 App?) 23 | 24 | **我試過:** 25 | - [ ] 我換了內置的方案(如`朙月拼音`)後問題仍存在 26 | - [ ] 我找到了導致問題出現的具體設置: (何設置?) 27 | - [ ] 這是個新 Bug,以前真的沒有 28 | - [ ] 我對原因有一些猜想: (你的寳貴想法) 29 | - [ ] 在 Issues(包括已關閉的 Issue) 中未找到相關的報告 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Is this a bug? 4 | title: "[Bug] What have I done?" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug:** 11 | ~~Actually it's not a bug after all 12 | 13 | **Expected behavior:** 14 | 15 | **Actual behavior:** 16 | 17 | **Environment** 18 | - OS version: (macOS 14.5) 19 | - Squirrel version: (1.0.0) 20 | - Schema: (If you are using a custom schema, and think it might be related to the schema, please provide a link) 21 | - [ ] Using Lua: (what Lua script do you use?) 22 | - [ ] Related to other apps: (which app?) 23 | 24 | **Things you've tried** 25 | - [ ] I tried a built-in schema (like `luna pinyin`), but the bug persists 26 | - [ ] I found the exact setting that produced this bug: (which one?) 27 | - [ ] This bug is new in this version 28 | - [ ] I think the cause might be: (your thoughts) 29 | - [ ] I don't find a similar report in Issues (including closed Issues) 30 | -------------------------------------------------------------------------------- /.github/workflows/commit-ci.yml: -------------------------------------------------------------------------------- 1 | name: commit ci 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: macos-14 9 | steps: 10 | - name: Checkout last commit 11 | uses: actions/checkout@v4 12 | with: 13 | submodules: true 14 | 15 | - name: Install SwiftLint 16 | run: brew install swiftlint 17 | 18 | - name: Lint 19 | run: swiftlint 20 | 21 | - name: Configure build environment 22 | run: | 23 | echo git_ref_name="$(git describe --always)" >> $GITHUB_ENV 24 | 25 | - name: Build Squirrel 26 | run: ./action-build.sh package 27 | 28 | - name: Install periphery 29 | run: brew install peripheryapp/periphery/periphery 30 | 31 | - name: Check Unused Code 32 | run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore 33 | 34 | - name: Upload Squirrel artifact 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: Squirrel-${{ env.git_ref_name }}.zip 38 | path: package/*.pkg 39 | # keep 90 days 40 | retention-days: 90 41 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-ci.yml: -------------------------------------------------------------------------------- 1 | name: pull request ci 2 | on: [pull_request] 3 | jobs: 4 | build: 5 | runs-on: macos-14 6 | steps: 7 | - name: Checkout last commit 8 | uses: actions/checkout@v4 9 | with: 10 | submodules: true 11 | 12 | - name: Install SwiftLint 13 | run: brew install swiftlint 14 | 15 | - name: Lint 16 | run: swiftlint 17 | 18 | - name: Configure build environment 19 | run: | 20 | echo git_ref_name="$(git describe --always)" >> $GITHUB_ENV 21 | 22 | - name: Build Squirrel 23 | run: ./action-build.sh package 24 | 25 | - name: Install periphery 26 | run: brew install peripheryapp/periphery/periphery 27 | 28 | - name: Check Unused Code 29 | run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore 30 | 31 | - name: Upload Squirrel artifact 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: Squirrel-${{ env.git_ref_name }}.zip 35 | path: package/*.pkg 36 | # keep 30 days 37 | retention-days: 30 38 | -------------------------------------------------------------------------------- /.github/workflows/release-ci.yml: -------------------------------------------------------------------------------- 1 | name: release ci 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | branches: 7 | - master 8 | paths: 9 | - 'sources/**' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: macos-14 15 | env: 16 | SQUIRREL_BUNDLED_RECIPES: 'lotem/rime-octagram-data lotem/rime-octagram-data@hant' 17 | steps: 18 | - name: Checkout last commit 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | submodules: true 23 | 24 | - name: Install SwiftLint 25 | run: brew install swiftlint 26 | 27 | - name: Lint 28 | run: swiftlint 29 | 30 | - name: Build Squirrel 31 | run: ./action-build.sh archive 32 | 33 | - name: Install periphery 34 | run: brew install peripheryapp/periphery/periphery 35 | 36 | - name: Check Unused Code 37 | run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore 38 | 39 | - name: Build changelog 40 | id: release_log 41 | run: | 42 | echo 'changelog<> $GITHUB_OUTPUT 43 | ./action-changelog.sh >> $GITHUB_OUTPUT 44 | echo 'EOF' >> $GITHUB_OUTPUT 45 | if: startsWith(github.ref, 'refs/tags/') 46 | 47 | - name: Create release 48 | if: startsWith(github.ref, 'refs/tags/') 49 | uses: ncipollo/release-action@v1 50 | with: 51 | artifacts: "package/Squirrel-*.pkg" 52 | body: | 53 | ${{ steps.release_log.outputs.changelog }} 54 | draft: true 55 | token: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | - name: Create nightly release 58 | if: ${{ github.repository == 'rime/squirrel' && github.ref == 'refs/heads/master' }} 59 | uses: 'marvinpinto/action-automatic-releases@latest' 60 | with: 61 | repo_token: ${{ secrets.GITHUB_TOKEN }} 62 | automatic_release_tag: latest 63 | prerelease: true 64 | title: "Nightly build" 65 | files: | 66 | package/Squirrel-*.pkg 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Squirrel.xcodeproj/*.mode1v3 2 | Squirrel.xcodeproj/*.pbxuser 3 | Squirrel.xcodeproj/project.xcworkspace/ 4 | Squirrel.xcodeproj/xcuserdata/ 5 | build/ 6 | build.log 7 | Frameworks/* 8 | bin/* 9 | lib/* 10 | data/opencc/ 11 | data/plum/ 12 | download/ 13 | package/Squirrel*.pkg 14 | package/sign/*.pem 15 | package/test-* 16 | package/sign_update 17 | package/*.xml 18 | *~ 19 | .*.swp 20 | .DS_Store 21 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "librime"] 2 | path = librime 3 | url = https://github.com/rime/librime.git 4 | ignore = dirty 5 | [submodule "plum"] 6 | path = plum 7 | url = https://github.com/rime/plum.git 8 | [submodule "Sparkle"] 9 | path = Sparkle 10 | url = https://github.com/sparkle-project/Sparkle 11 | -------------------------------------------------------------------------------- /.periphery.yml: -------------------------------------------------------------------------------- 1 | project: Squirrel.xcodeproj 2 | schemes: 3 | - Squirrel 4 | targets: 5 | - Squirrel 6 | format: github-actions 7 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # By default, SwiftLint uses a set of sensible default rules you can adjust: 2 | disabled_rules: # rule identifiers turned on by default to exclude from running 3 | - force_cast 4 | - force_try 5 | - todo 6 | opt_in_rules: # some rules are turned off by default, so you need to opt-in 7 | 8 | # Alternatively, specify all rules explicitly by uncommenting this option: 9 | # only_rules: # delete `disabled_rules` & `opt_in_rules` if using this 10 | # - empty_parameters 11 | # - vertical_whitespace 12 | 13 | analyzer_rules: # rules run by `swiftlint analyze` 14 | - explicit_self 15 | 16 | included: # case-sensitive paths to include during linting. `--path` is ignored if present 17 | - sources 18 | excluded: # case-sensitive paths to ignore during linting. Takes precedence over `included` 19 | 20 | # If true, SwiftLint will not fail if no lintable files are found. 21 | allow_zero_lintable_files: false 22 | 23 | # If true, SwiftLint will treat all warnings as errors. 24 | strict: false 25 | 26 | # rules that have both warning and error levels, can set just the warning level 27 | # implicitly 28 | line_length: 200 29 | function_body_length: 200 30 | # they can set both implicitly with an array 31 | type_body_length: 32 | - 300 # warning 33 | - 400 # error 34 | # or they can set both explicitly 35 | file_length: 36 | warning: 800 37 | error: 1200 38 | # naming rules can set warnings/errors for min_length and max_length 39 | # additionally they can set excluded names 40 | type_name: 41 | min_length: 4 # only warning 42 | max_length: # warning and error 43 | warning: 40 44 | error: 50 45 | excluded: # excluded via string 46 | allowed_symbols: ["_"] # these are allowed in type names 47 | identifier_name: 48 | min_length: # only min_length 49 | warning: 3 50 | error: 2 51 | excluded: [i, URL, of, by] # excluded via string array 52 | large_tuple: 53 | warning: 3 54 | error: 5 55 | reporter: "github-actions-logging" # reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, sonarqube, markdown, github-actions-logging, summary) 56 | 57 | -------------------------------------------------------------------------------- /Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "rime-16 1.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "rime-32 1.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "rime-32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "rime-64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "rime-128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "rime-256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "rime-256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "rime-512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "rime-512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "rime-1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-1024.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-128.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-16 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-16 1.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-256.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-32 1.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-32.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-512.png -------------------------------------------------------------------------------- /Assets.xcassets/RimeIcon.appiconset/rime-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/Assets.xcassets/RimeIcon.appiconset/rime-64.png -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # How to Rime with Squirrel 2 | 3 | > Instructions to build Squirrel - the Rime frontend for macOS 4 | 5 | ## Manually build and install Squirrel 6 | 7 | ### Prerequisites 8 | 9 | Install **Xcode 14.0** or above from App Store, to build Squirrel as a Universal 10 | app. 11 | 12 | Install **cmake**. 13 | 14 | Download from https://cmake.org/download/ 15 | 16 | or install from [Homebrew](http://brew.sh/): 17 | 18 | ``` sh 19 | brew install cmake 20 | ``` 21 | 22 | or install from [MacPorts](https://www.macports.org/): 23 | 24 | ``` sh 25 | port install cmake 26 | ``` 27 | 28 | ### Checkout the code 29 | 30 | ``` sh 31 | git clone --recursive https://github.com/rime/squirrel.git 32 | 33 | cd squirrel 34 | ``` 35 | 36 | Optionally, checkout Rime plugins (a list of GitHub repo slugs): 37 | 38 | ``` sh 39 | bash librime/install-plugins.sh rime/librime-sample # ... 40 | ``` 41 | 42 | Popular plugins include [librime-lua](https://github.com/hchunhui/librime-lua), [librime-octagram](https://github.com/lotem/librime-octagram) and [librime-predict](https://github.com/rime/librime-predict) 43 | 44 | ### Shortcut: get the latest librime release 45 | 46 | You have the option to skip the following two sections - building Boost and 47 | librime, by downloading the latest librime binary from GitHub releases. 48 | 49 | ``` sh 50 | bash ./action-install.sh 51 | ``` 52 | 53 | When this is done, you may move on to [Build Squirrel](#build-squirrel). 54 | 55 | ### Install Boost C++ libraries 56 | 57 | Choose one of the following options. 58 | 59 | **Option:** Download and install from source. 60 | 61 | ``` sh 62 | export BUILD_UNIVERSAL=1 63 | 64 | bash librime/install-boost.sh 65 | 66 | export BOOST_ROOT="$(pwd)/librime/deps/boost-1.84.0" 67 | ``` 68 | 69 | Let's set `BUILD_UNIVERSAL` to tell `make` that we are building Boost as 70 | universal macOS binaries. Skip this if building only for the native architecture. 71 | 72 | After Boost source code is downloaded and a few compiled libraries are built, 73 | be sure to set shell variable `BOOST_ROOT` to its top level directory as above. 74 | 75 | You may also set `BOOST_ROOT` to an existing Boost source tree before this step. 76 | 77 | **Option:** Install the current version form Homebrew: 78 | 79 | ``` sh 80 | brew install boost 81 | ``` 82 | 83 | **Note:** with this option, the built Squirrel.app is not portable because it 84 | links to locally installed libraries from Homebrew. 85 | 86 | Learn more about the implications of this at 87 | https://github.com/rime/librime/blob/master/README-mac.md#install-boost-c-libraries 88 | 89 | **Option:** Install from [MacPorts](https://www.macports.org/): 90 | 91 | ``` sh 92 | port install boost -no_static 93 | ``` 94 | 95 | ### Build Squirrel 96 | 97 | * Make sure you have updated all the dependencies. If you cloned squirrel with the command in this guide, you've already done it. But if not, this command will update submodules. 98 | 99 | ``` 100 | git submodule update --init --recursive 101 | ``` 102 | 103 | * There are a few environmental variables that you can define. Here's a list and possible values they may take: 104 | 105 | ``` sh 106 | export BOOST_ROOT="path_to_boost" # required 107 | export DEV_ID="Your Apple ID name" # include this to codesign, optional 108 | export BUILD_UNIVERSAL=1 # set to build universal binary 109 | export PLUM_TAG=":preset” # or ":extra", optional, build with a set of plum formulae 110 | export ARCHS='arm64 x86_64' # optional, if not defined, only active arch is used 111 | export MACOSX_DEPLOYMENT_TARGET='13.0' # optional, lower version than 13.0 is not tested and may not work properly 112 | ``` 113 | 114 | * With all dependencies ready, build `Squirrel.app`: 115 | 116 | ``` sh 117 | make 118 | ``` 119 | 120 | * You can either define the environment variables in your shell/terminal, or append them as arguments to the make command. For example: 121 | 122 | ``` sh 123 | # for Universal macOS App 124 | make ARCHS='arm64 x86_64' BUILD_UNIVERSAL=1 125 | ``` 126 | 127 | ## Install it on your Mac 128 | 129 | ### Make Package 130 | 131 | Just add `package` after `make` 132 | 133 | ``` 134 | make package ARCHS='arm64' 135 | ``` 136 | 137 | Define `DEV_ID` to automatically handle code signing and [notarization](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) (Apple Developer ID needed) 138 | 139 | To make this work, you need a `Developer ID Installer: (your name/org)` and set your name/org as `DEV_ID` env variable. 140 | 141 | To make notarization work, you also need to save your credential under the same name as above. 142 | 143 | ``` 144 | xcrun notarytool store-credentials 'your name/org' 145 | ``` 146 | 147 | You **don't** need to define `DEV_ID` if you don't intend to distribute the package. 148 | 149 | ### Directly Install 150 | 151 | **You might need to precede with sudo, and without a logout, the App might not work properly. Direct install is not very recommended.** 152 | 153 | Once built, you can install and try it live on your Mac computer: 154 | 155 | ``` sh 156 | # Squirrel as a Universal app 157 | make install 158 | ``` 159 | 160 | ## Clean Up Artifacts 161 | 162 | After installation or after a failed attempt, you may want to start over. Before you do so, **make sure you have cleaned up artifacts from previous build.** 163 | 164 | To clean **Squirrel** artifacts, without touching dependencies, run: 165 | 166 | ``` sh 167 | make clean 168 | ``` 169 | 170 | To clean up **dependencies**, including librime, librime plugins, plum and sparkle, run: 171 | 172 | ``` sh 173 | make clean-deps 174 | ``` 175 | 176 | To clean up **packages**, run: 177 | 178 | ``` sh 179 | make clean-package 180 | ``` 181 | 182 | If you want to clean all above, do all. 183 | 184 | That's it, a verbal journal. Thanks for riming with Squirrel. 185 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all install deps release debug 2 | 3 | all: release 4 | install: install-release 5 | 6 | RIME_BIN_DIR = librime/dist/bin 7 | RIME_LIB_DIR = librime/dist/lib 8 | DERIVED_DATA_PATH = build 9 | 10 | RIME_LIBRARY_FILE_NAME = librime.1.dylib 11 | RIME_LIBRARY = lib/$(RIME_LIBRARY_FILE_NAME) 12 | 13 | RIME_DEPS = librime/lib/libmarisa.a \ 14 | librime/lib/libleveldb.a \ 15 | librime/lib/libopencc.a \ 16 | librime/lib/libyaml-cpp.a 17 | PLUM_DATA = bin/rime-install \ 18 | data/plum/default.yaml \ 19 | data/plum/symbols.yaml \ 20 | data/plum/essay.txt 21 | OPENCC_DATA = data/opencc/TSCharacters.ocd2 \ 22 | data/opencc/TSPhrases.ocd2 \ 23 | data/opencc/t2s.json 24 | SPARKLE_FRAMEWORK = Frameworks/Sparkle.framework 25 | SPARKLE_SIGN = package/sign_update 26 | PACKAGE = package/Squirrel.pkg 27 | DEPS_CHECK = $(RIME_LIBRARY) $(PLUM_DATA) $(OPENCC_DATA) $(SPARKLE_FRAMEWORK) 28 | 29 | OPENCC_DATA_OUTPUT = librime/share/opencc/*.* 30 | PLUM_DATA_OUTPUT = plum/output/*.* 31 | PLUM_OPENCC_OUTPUT = plum/output/opencc/*.* 32 | RIME_PACKAGE_INSTALLER = plum/rime-install 33 | 34 | INSTALL_NAME_TOOL = $(shell xcrun -find install_name_tool) 35 | INSTALL_NAME_TOOL_ARGS = -add_rpath @loader_path/../Frameworks 36 | 37 | .PHONY: librime copy-rime-binaries 38 | 39 | $(RIME_LIBRARY): 40 | $(MAKE) librime 41 | 42 | $(RIME_DEPS): 43 | $(MAKE) -C librime deps 44 | 45 | librime: $(RIME_DEPS) 46 | $(MAKE) -C librime release install 47 | $(MAKE) copy-rime-binaries 48 | 49 | copy-rime-binaries: 50 | cp -L $(RIME_LIB_DIR)/$(RIME_LIBRARY_FILE_NAME) lib/ 51 | cp -pR $(RIME_LIB_DIR)/rime-plugins lib/ 52 | cp $(RIME_BIN_DIR)/rime_deployer bin/ 53 | cp $(RIME_BIN_DIR)/rime_dict_manager bin/ 54 | $(INSTALL_NAME_TOOL) $(INSTALL_NAME_TOOL_ARGS) bin/rime_deployer 55 | $(INSTALL_NAME_TOOL) $(INSTALL_NAME_TOOL_ARGS) bin/rime_dict_manager 56 | 57 | .PHONY: data plum-data opencc-data copy-plum-data copy-opencc-data 58 | 59 | data: plum-data opencc-data 60 | 61 | $(PLUM_DATA): 62 | $(MAKE) plum-data 63 | 64 | $(OPENCC_DATA): 65 | $(MAKE) opencc-data 66 | 67 | plum-data: 68 | $(MAKE) -C plum 69 | ifdef PLUM_TAG 70 | rime_dir=plum/output bash plum/rime-install $(PLUM_TAG) 71 | endif 72 | $(MAKE) copy-plum-data 73 | 74 | opencc-data: 75 | $(MAKE) -C librime deps/opencc 76 | $(MAKE) copy-opencc-data 77 | 78 | copy-plum-data: 79 | mkdir -p data/plum 80 | cp $(PLUM_DATA_OUTPUT) data/plum/ 81 | cp $(RIME_PACKAGE_INSTALLER) bin/ 82 | 83 | copy-opencc-data: 84 | mkdir -p data/opencc 85 | cp $(OPENCC_DATA_OUTPUT) data/opencc/ 86 | cp $(PLUM_OPENCC_OUTPUT) data/opencc/ > /dev/null 2>&1 || true 87 | 88 | deps: librime data 89 | 90 | ifdef ARCHS 91 | BUILD_SETTINGS += ARCHS="$(ARCHS)" 92 | BUILD_SETTINGS += ONLY_ACTIVE_ARCH=NO 93 | _=$() $() 94 | export CMAKE_OSX_ARCHITECTURES = $(subst $(_),;,$(ARCHS)) 95 | endif 96 | 97 | ifdef MACOSX_DEPLOYMENT_TARGET 98 | BUILD_SETTINGS += MACOSX_DEPLOYMENT_TARGET="$(MACOSX_DEPLOYMENT_TARGET)" 99 | endif 100 | 101 | BUILD_SETTINGS += COMPILER_INDEX_STORE_ENABLE=YES 102 | 103 | release: $(DEPS_CHECK) 104 | mkdir -p $(DERIVED_DATA_PATH) 105 | bash package/add_data_files 106 | xcodebuild -project Squirrel.xcodeproj -configuration Release -scheme Squirrel -derivedDataPath $(DERIVED_DATA_PATH) $(BUILD_SETTINGS) build 107 | 108 | debug: $(DEPS_CHECK) 109 | mkdir -p $(DERIVED_DATA_PATH) 110 | bash package/add_data_files 111 | xcodebuild -project Squirrel.xcodeproj -configuration Debug -scheme Squirrel -derivedDataPath $(DERIVED_DATA_PATH) $(BUILD_SETTINGS) build 112 | 113 | .PHONY: sparkle copy-sparkle-framework 114 | 115 | $(SPARKLE_FRAMEWORK): 116 | git submodule update --init --recursive Sparkle 117 | $(MAKE) sparkle 118 | 119 | sparkle: 120 | xcodebuild -project Sparkle/Sparkle.xcodeproj -configuration Release $(BUILD_SETTINGS) build 121 | $(MAKE) copy-sparkle-framework 122 | 123 | $(SPARKLE_SIGN): 124 | xcodebuild -project Sparkle/Sparkle.xcodeproj -scheme sign_update -configuration Release -derivedDataPath Sparkle/build $(BUILD_SETTINGS) build 125 | cp Sparkle/build/Build/Products/Release/sign_update package/ 126 | 127 | copy-sparkle-framework: 128 | mkdir -p Frameworks 129 | cp -RP Sparkle/build/Release/Sparkle.framework Frameworks/ 130 | 131 | clean-sparkle: 132 | rm -rf Frameworks/* > /dev/null 2>&1 || true 133 | rm -rf Sparkle/build > /dev/null 2>&1 || true 134 | 135 | .PHONY: package archive 136 | 137 | $(PACKAGE): 138 | ifdef DEV_ID 139 | bash package/sign_app "$(DEV_ID)" "$(DERIVED_DATA_PATH)" 140 | endif 141 | bash package/make_package "$(DERIVED_DATA_PATH)" 142 | ifdef DEV_ID 143 | productsign --sign "Developer ID Installer: $(DEV_ID)" package/Squirrel.pkg package/Squirrel-signed.pkg 144 | rm package/Squirrel.pkg 145 | mv package/Squirrel-signed.pkg package/Squirrel.pkg 146 | xcrun notarytool submit package/Squirrel.pkg --keychain-profile "$(DEV_ID)" --wait 147 | xcrun stapler staple package/Squirrel.pkg 148 | endif 149 | 150 | package: release $(PACKAGE) 151 | 152 | archive: package $(SPARKLE_SIGN) 153 | bash package/make_archive 154 | 155 | DSTROOT = /Library/Input Methods 156 | SQUIRREL_APP_ROOT = $(DSTROOT)/Squirrel.app 157 | 158 | .PHONY: permission-check install-debug install-release 159 | 160 | permission-check: 161 | [ -w "$(DSTROOT)" ] && [ -w "$(SQUIRREL_APP_ROOT)" ] || sudo chown -R ${USER} "$(DSTROOT)" 162 | 163 | install-debug: debug permission-check 164 | rm -rf "$(SQUIRREL_APP_ROOT)" 165 | cp -R $(DERIVED_DATA_PATH)/Build/Products/Debug/Squirrel.app "$(DSTROOT)" 166 | DSTROOT="$(DSTROOT)" RIME_NO_PREBUILD=1 bash scripts/postinstall 167 | 168 | install-release: release permission-check 169 | rm -rf "$(SQUIRREL_APP_ROOT)" 170 | cp -R $(DERIVED_DATA_PATH)/Build/Products/Release/Squirrel.app "$(DSTROOT)" 171 | DSTROOT="$(DSTROOT)" bash scripts/postinstall 172 | 173 | .PHONY: clean clean-deps 174 | 175 | clean: 176 | rm -rf build > /dev/null 2>&1 || true 177 | rm build.log > /dev/null 2>&1 || true 178 | rm bin/* > /dev/null 2>&1 || true 179 | rm lib/* > /dev/null 2>&1 || true 180 | rm lib/rime-plugins/* > /dev/null 2>&1 || true 181 | rm data/plum/* > /dev/null 2>&1 || true 182 | rm data/opencc/* > /dev/null 2>&1 || true 183 | 184 | clean-package: 185 | rm -rf package/*appcast.xml > /dev/null 2>&1 || true 186 | rm -rf package/*.pkg > /dev/null 2>&1 || true 187 | rm -rf package/sign_update > /dev/null 2>&1 || true 188 | 189 | clean-deps: 190 | $(MAKE) -C plum clean 191 | $(MAKE) -C librime clean 192 | rm -rf librime/dist > /dev/null 2>&1 || true 193 | $(MAKE) clean-sparkle 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 鼠鬚管 2 | 爲物雖微情不淺 3 | 新詩醉墨時一揮 4 | 別後寄我無辭遠 5 | 6 |    ——歐陽修 7 | 8 | 今由 [中州韻輸入法引擎/Rime Input Method Engine](https://rime.im) 9 | 及其他開源技術強力驅動 10 | 11 | 【鼠鬚管】輸入法 12 | === 13 | [![Download](https://img.shields.io/github/v/release/rime/squirrel)](https://github.com/rime/squirrel/releases/latest) 14 | [![Build Status](https://github.com/rime/squirrel/actions/workflows/commit-ci.yml/badge.svg)](https://github.com/rime/squirrel/actions/workflows) 15 | [![GitHub Tag](https://img.shields.io/github/tag/rime/squirrel.svg)](https://github.com/rime/squirrel) 16 | 17 | 式恕堂 版權所無 18 | 19 | 授權條款:[GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html) 20 | 21 | 項目主頁:[rime.im](https://rime.im) 22 | 23 | 您可能還需要 Rime 用於其他操作系統的發行版: 24 | 25 | * 【中州韻】(ibus-rime、fcitx-rime)用於 Linux 26 | * 【小狼毫】用於 Windows 27 | 28 | 安裝輸入法 29 | --- 30 | 31 | 本品適用於 macOS 13.0+ 32 | 33 | 初次安裝,如果在部份應用程序中打不出字,請註銷並重新登錄。 34 | 35 | 使用輸入法 36 | --- 37 | 38 | 選取輸入法指示器菜單裏的【ㄓ】字樣圖標,開始用鼠鬚管寫字。 39 | 通過快捷鍵 `` Ctrl+` `` 或 `F4` 呼出方案選單、切換輸入方式。 40 | 41 | 定製輸入法 42 | --- 43 | 44 | 定製方法,請參考線上 [幫助文檔](https://rime.im/docs/)。 45 | 46 | 使用系統輸入法菜單: 47 | 48 | * 選中「在線文檔」可打開以上網址 49 | * 編輯用戶設定後,選擇「重新部署」以令修改生效 50 | 51 | 安裝輸入方案 52 | --- 53 | 54 | 使用 [/plum/](https://github.com/rime/plum) 配置管理器獲取更多輸入方案。 55 | 56 | 致謝 57 | --- 58 | 59 | 輸入方案設計: 60 | 61 | * 【朙月拼音】系列 62 | 63 | 感謝 CC-CEDICT、Android 拼音、新酷音、opencc 等開源項目 64 | 65 | 程序設計: 66 | 67 | * 佛振 68 | * Linghua Zhang 69 | * Chongyu Zhu 70 | * 雪齋 71 | * faberii 72 | * Chun-wei Kuo 73 | * Junlu Cheng 74 | * Jak Wings 75 | * xiehuc 76 | 77 | 美術: 78 | 79 | * 圖標設計 佛振、梁海、雨過之後 80 | * 配色方案 Aben、Chongyu Zhu、skoj、Superoutman、佛振、梁海 81 | 82 | 本品引用了以下開源軟件: 83 | 84 | * Boost C++ Libraries (Boost Software License) 85 | * capnproto (MIT License) 86 | * darts-clone (New BSD License) 87 | * google-glog (New BSD License) 88 | * Google Test (New BSD License) 89 | * LevelDB (New BSD License) 90 | * librime (New BSD License) 91 | * OpenCC / 開放中文轉換 (Apache License 2.0) 92 | * plum / 東風破 (GNU Lesser General Public License 3.0) 93 | * Sparkle (MIT License) 94 | * UTF8-CPP (Boost Software License) 95 | * yaml-cpp (MIT License) 96 | 97 | 感謝王公子捐贈開發用機。 98 | 99 | 問題與反饋 100 | --- 101 | 102 | 發現程序有 BUG,或建議,或感想,請反饋到 [Rime 代碼之家討論區](https://github.com/rime/home/discussions) 103 | 104 | 聯繫方式 105 | --- 106 | 107 | 技術交流,歡迎光臨 [Rime 代碼之家](https://github.com/rime/home), 108 | 或致信 Rime 開發者 。 109 | 110 | 謝謝 111 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /action-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | target="${1:-release}" 6 | 7 | # export BUILD_UNIVERSAL=1 8 | 9 | # preinstall 10 | ./action-install.sh 11 | 12 | # build dependencies 13 | # make deps 14 | 15 | # build Squirrel 16 | make "${target}" 17 | 18 | echo 'Installer package:' 19 | find package -type f -name '*.pkg' -or -name '*.zip' 20 | -------------------------------------------------------------------------------- /action-changelog.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | current=$(git describe --tags --abbrev=0) 4 | previous=$(git describe --always --abbrev=0 --tags ${current}^) 5 | 6 | echo "**Change log since ${previous}:**" 7 | 8 | git log --oneline --decorate ${previous}...${current} --pretty="format:- %h %s" | grep -v Merge 9 | -------------------------------------------------------------------------------- /action-install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | rime_version=latest 6 | rime_git_hash=2f89098 7 | sparkle_version=2.6.2 8 | 9 | rime_archive="rime-${rime_git_hash}-macOS-universal.tar.bz2" 10 | rime_download_url="https://github.com/rime/librime/releases/download/${rime_version}/${rime_archive}" 11 | 12 | rime_deps_archive="rime-deps-${rime_git_hash}-macOS-universal.tar.bz2" 13 | rime_deps_download_url="https://github.com/rime/librime/releases/download/${rime_version}/${rime_deps_archive}" 14 | 15 | sparkle_archive="Sparkle-${sparkle_version}.tar.xz" 16 | sparkle_download_url="https://github.com/sparkle-project/Sparkle/releases/download/${sparkle_version}/${sparkle_archive}" 17 | 18 | mkdir -p download && ( 19 | cd download 20 | [ -z "${no_download}" ] && curl -LO "${rime_download_url}" 21 | tar --bzip2 -xf "${rime_archive}" 22 | [ -z "${no_download}" ] && curl -LO "${rime_deps_download_url}" 23 | tar --bzip2 -xf "${rime_deps_archive}" 24 | [ -z "${no_download}" ] && curl -LO "${sparkle_download_url}" 25 | tar -xJf "${sparkle_archive}" 26 | ) 27 | 28 | mkdir -p librime/share 29 | mkdir -p Frameworks 30 | cp -R download/dist librime/ 31 | cp -R download/share/opencc librime/share/ 32 | cp -R download/Sparkle.framework Frameworks/ 33 | 34 | # skip building librime and opencc-data; use downloaded artifacts 35 | make copy-rime-binaries copy-opencc-data 36 | 37 | echo "SQUIRREL_BUNDLED_RECIPES=${SQUIRREL_BUNDLED_RECIPES}" 38 | 39 | git submodule update --init plum 40 | # install Rime recipes 41 | rime_dir=plum/output bash plum/rime-install ${SQUIRREL_BUNDLED_RECIPES} 42 | make copy-plum-data 43 | -------------------------------------------------------------------------------- /bin/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/bin/.placeholder -------------------------------------------------------------------------------- /data/squirrel.yaml: -------------------------------------------------------------------------------- 1 | # Squirrel settings 2 | # encoding: utf-8 3 | 4 | config_version: '1.0' 5 | 6 | # options: last | default | _custom_ 7 | # last: the last used latin keyboard layout 8 | # default: US (ABC) keyboard layout 9 | # _custom_: keyboard layout of your choice, e.g. 'com.apple.keylayout.USExtended' or simply 'USExtended' 10 | keyboard_layout: last 11 | 12 | # for veteran chord-typist 13 | chord_duration: 0.1 # seconds 14 | 15 | # options: always | never | appropriate 16 | show_notifications_when: appropriate 17 | 18 | style: 19 | color_scheme: native 20 | # Optional: define both light and dark color schemes to match system appearance 21 | #color_scheme: solarized_light 22 | #color_scheme_dark: solarized_dark 23 | 24 | # horizontal is Deprecated since 0.36, Squirrel 0.15, removed since 1.0.1 25 | candidate_list_layout: stacked # stacked | linear 26 | text_orientation: horizontal # horizontal | vertical 27 | inline_preedit: true 28 | # Whether to embed selected candidate as text in input field 29 | inline_candidate: false 30 | # Whether candidate panel stick to screen edge to reduce jumping 31 | memorize_size: true 32 | # Whether transparent colors stack on each other 33 | mutual_exclusive: false 34 | # Whether to use a translucent background. Only visible when background color is transparent 35 | translucency: false 36 | # Enable to show small arrows that indicates if paging up/down is possible 37 | show_paging: false 38 | 39 | corner_radius: 7 40 | hilited_corner_radius: 0 41 | border_height: -2 42 | border_width: -2 43 | # Space between candidates in stacked layout 44 | line_spacing: 5 45 | # Space between preedit and candidates in non-inline mode 46 | spacing: 8 47 | # A number greater than 0 renders shadow around high-lighted candidate 48 | shadow_size: 0 49 | # Controls non-hililighted candidate background size, relative to highlighted 50 | # Nagetive means shrink, positive meas expand 51 | #surrounding_extra_expansion: 0 52 | 53 | # format using %@ and %c is deprecated since 1.0, Squirrel 1.0 54 | # %@ is automatically expanded to "[candidate] [comment]" 55 | # and %c is replaced by "[label]" 56 | candidate_format: '[label]. [candidate] [comment]' 57 | 58 | # adjust the base line of text 59 | #base_offset: 0 60 | font_face: 'Avenir' 61 | font_point: 16 62 | #label_font_face: 'Avenir' 63 | #label_font_point: 12 64 | #comment_font_face: 'Avenir' 65 | #comment_font_point: 16 66 | 67 | preset_color_schemes: 68 | native: 69 | name: 系統配色 70 | 71 | aqua: 72 | name: 碧水/Aqua 73 | author: 佛振 74 | text_color: 0x606060 75 | back_color: 0xeeeceeee 76 | candidate_text_color: 0x000000 77 | hilited_text_color: 0x000000 78 | hilited_candidate_text_color: 0xffffff 79 | hilited_candidate_back_color: 0xeefa3a0a 80 | comment_text_color: 0x5a5a5a 81 | hilited_comment_text_color: 0xfcac9d 82 | 83 | azure: 84 | name: 青天/Azure 85 | author: 佛振 86 | text_color: 0xcfa677 87 | candidate_text_color: 0xffeacc 88 | back_color: 0xee8b4e01 89 | hilited_text_color: 0xffeacc 90 | hilited_candidate_text_color: 0x7ffeff 91 | hilited_candidate_back_color: 0x00000000 92 | comment_text_color: 0xc69664 93 | 94 | luna: 95 | name: 明月/Luna 96 | author: 佛振 97 | text_color: 0xa5a5a5 98 | back_color: 0xdd000000 99 | candidate_text_color: 0xeceeee 100 | hilited_text_color: 0x7fffff 101 | hilited_candidate_text_color: 0x7fffff 102 | hilited_candidate_back_color: 0x40000000 103 | comment_text_color: 0xa5a5a5 104 | hilited_comment_text_color: 0x449c9d 105 | 106 | ink: 107 | name: 墨池/Ink 108 | author: 佛振 109 | text_color: 0x5a5a5a 110 | back_color: 0xeeffffff 111 | candidate_text_color: 0x000000 112 | hilited_text_color: 0x000000 113 | #hilited_back_color: 0xdddddd 114 | hilited_candidate_text_color: 0xffffff 115 | hilited_candidate_back_color: 0xcc000000 116 | comment_text_color: 0x5a5a5a 117 | hilited_comment_text_color: 0x808080 118 | 119 | lost_temple: 120 | name: 孤寺/Lost Temple 121 | author: 佛振 , based on ir_black 122 | text_color: 0xe8f3f6 123 | back_color: 0xee303030 124 | hilited_text_color: 0x82e6ca 125 | hilited_candidate_text_color: 0x000000 126 | hilited_candidate_back_color: 0x82e6ca 127 | comment_text_color: 0xbb82e6ca 128 | hilited_comment_text_color: 0xbb203d34 129 | 130 | dark_temple: 131 | name: 暗堂/Dark Temple 132 | author: 佛振 , based on ir_black 133 | text_color: 0x92f6da 134 | back_color: 0x222222 135 | candidate_text_color: 0xd8e3e6 136 | hilited_text_color: 0xffcf9a 137 | hilited_back_color: 0x222222 138 | hilited_candidate_text_color: 0x92f6da 139 | hilited_candidate_back_color: 0x10000000 # 0x333333 140 | comment_text_color: 0x606cff 141 | 142 | psionics: 143 | name: 幽能/Psionics 144 | author: 雨過之後、佛振 145 | text_color: 0xc2c2c2 146 | back_color: 0x444444 147 | candidate_text_color: 0xeeeeee 148 | hilited_text_color: 0xeeeeee 149 | hilited_back_color: 0x444444 150 | hilited_candidate_label_color: 0xfafafa 151 | hilited_candidate_text_color: 0xfafafa 152 | hilited_candidate_back_color: 0xd4bc00 153 | comment_text_color: 0x808080 154 | hilited_comment_text_color: 0x444444 155 | 156 | purity_of_form: 157 | name: 純粹的形式/Purity of Form 158 | author: 雨過之後、佛振 159 | text_color: 0xc2c2c2 160 | back_color: 0x444444 161 | candidate_text_color: 0xeeeeee 162 | hilited_text_color: 0xeeeeee 163 | hilited_back_color: 0x444444 164 | hilited_candidate_text_color: 0x000000 165 | hilited_candidate_back_color: 0xfafafa 166 | comment_text_color: 0x808080 167 | 168 | purity_of_essence: 169 | name: 純粹的本質/Purity of Essence 170 | author: 佛振 171 | text_color: 0x2c2ccc 172 | back_color: 0xfafafa 173 | candidate_text_color: 0x000000 174 | hilited_text_color: 0x000000 175 | hilited_back_color: 0xfafafa 176 | hilited_candidate_text_color: 0xeeeeee 177 | hilited_candidate_back_color: 0x444444 178 | comment_text_color: 0x808080 179 | 180 | starcraft: 181 | name: 星際我爭霸/StarCraft 182 | author: Contralisk , original artwork by Blizzard Entertainment 183 | text_color: 0xccaa88 184 | candidate_text_color: 0x30bb55 185 | back_color: 0xee000000 186 | border_color: 0x1010a0 187 | hilited_text_color: 0xfecb96 188 | hilited_back_color: 0x000000 189 | hilited_candidate_text_color: 0x70ffaf 190 | hilited_candidate_back_color: 0x00000000 191 | comment_text_color: 0x1010d0 192 | hilited_comment_text_color: 0x1010f0 193 | 194 | google: 195 | name: 谷歌/Google 196 | author: skoj 197 | text_color: 0x666666 #拼音串 198 | candidate_text_color: 0x000000 #非第一候选项 199 | back_color: 0xFFFFFF #背景 200 | border_color: 0xE2E2E2 #边框 201 | hilited_text_color: 0x000000 #拼音串高亮 202 | hilited_back_color: 0xFFFFFF #拼音串高亮背景 203 | hilited_candidate_text_color: 0xFFFFFF #第一候选项 204 | hilited_candidate_back_color: 0xCE7539 #第一候选项背景 205 | comment_text_color: 0x6D6D6D #注解文字 206 | hilited_comment_text_color: 0xEBC6B0 #注解文字高亮 207 | 208 | solarized_rock: 209 | name: 曬經石/Solarized Rock 210 | author: "Aben , based on Ethan Schoonover's Solarized color scheme" 211 | back_color: 0x362b00 212 | border_color: 0x362b00 213 | text_color: 0x8236d3 214 | hilited_text_color: 0x98a12a 215 | candidate_text_color: 0x969483 216 | comment_text_color: 0xc098a12a 217 | hilited_candidate_text_color: 0xffffff 218 | hilited_candidate_back_color: 0x8236d3 219 | hilited_comment_text_color: 0x362b00 220 | 221 | clean_white: 222 | name: 简约白/Clean White 223 | author: Chongyu Zhu , based on 搜狗「简约白」 224 | horizontal: true 225 | candidate_format: '%c %@' 226 | corner_radius: 6 227 | border_height: 6 228 | border_width: 6 229 | font_point: 16 230 | label_font_point: 12 231 | label_color: 0x888888 232 | text_color: 0x808080 233 | hilited_text_color: 0x000000 234 | candidate_text_color: 0x000000 235 | comment_text_color: 0x808080 236 | back_color: 0xeeeeee 237 | hilited_candidate_label_color: 0xa0c98915 238 | hilited_candidate_text_color: 0xc98915 239 | hilited_candidate_back_color: 0xeeeeee 240 | 241 | apathy: 242 | name: 冷漠/Apathy 243 | author: LIANG Hai 244 | horizontal: true # 水平排列 245 | inline_preedit: true #单行显示,false双行显示 246 | candidate_format: "%c\u2005%@\u2005" # 编号 %c 和候选词 %@ 前后的空间 247 | corner_radius: 5 #候选条圆角 248 | border_height: 0 249 | border_width: 0 250 | back_color: 0xFFFFFF #候选条背景色 251 | font_face: "PingFangSC-Regular,HanaMinB" #候选词字体 252 | font_point: 16 #候选字词大小 253 | text_color: 0x424242 #高亮选中词颜色 254 | label_font_face: "STHeitiSC-Light" #候选词编号字体 255 | label_font_point: 12 #候选编号大小 256 | hilited_candidate_text_color: 0xEE6E00 #候选文字颜色 257 | hilited_candidate_back_color: 0xFFF0E4 #候选文字背景色 258 | comment_text_color: 0x999999 #拼音等提示文字颜色 259 | 260 | dust: 261 | name: 浮尘/Dust 262 | author: Superoutman 263 | horizontal: true # 水平排列 264 | inline_preedit: true #单行显示,false双行显示 265 | candidate_format: "%c\u2005%@\u2005" # 用 1/6 em 空格 U+2005 来控制编号 %c 和候选词 %@ 前后的空间。 266 | corner_radius: 2 #候选条圆角 267 | border_height: 3 # 窗口边界高度,大于圆角半径才生效 268 | border_width: 8 # 窗口边界宽度,大于圆角半径才生效 269 | back_color: 0xeeffffff #候选条背景色 270 | border_color: 0xE0B693 # 边框色 271 | font_face: "HYQiHei-55S Book,HanaMinA Regular" #候选词字体 272 | font_point: 14 #候选字词大小 273 | label_font_face: "SimHei" #候选词编号字体 274 | label_font_point: 10 #候选编号大小 275 | label_color: 0xcbcbcb # 预选栏编号颜色 276 | candidate_text_color: 0x555555 # 预选项文字颜色 277 | text_color: 0x424242 # 拼音行文字颜色,24位色值,16进制,BGR顺序 278 | comment_text_color: 0x999999 # 拼音等提示文字颜色 279 | hilited_text_color: 0x9e9e9e # 高亮拼音 (需要开启内嵌编码) 280 | hilited_candidate_text_color: 0x000000 # 第一候选项文字颜色 281 | hilited_candidate_back_color: 0xfff0e4 # 第一候选项背景背景色 282 | hilited_candidate_label_color: 0x555555 # 第一候选项编号颜色 283 | hilited_comment_text_color: 0x9e9e9e # 注解文字高亮 284 | 285 | mojave_dark: 286 | name: 沙漠夜/Mojave Dark 287 | author: xiehuc 288 | horizontal: true # 水平排列 289 | inline_preedit: true # 单行显示,false双行显示 290 | candidate_format: "%c\u2005%@" # 用 1/6 em 空格 U+2005 来控制编号 %c 和候选词 %@ 前后的空间。 291 | corner_radius: 5 # 候选条圆角 292 | hilited_corner_radius: 3 # 高亮圆角 293 | border_height: 6 # 窗口边界高度,大于圆角半径才生效 294 | border_width: 6 # 窗口边界宽度,大于圆角半径才生效 295 | font_face: "PingFangSC" # 候选词字体 296 | font_point: 16 # 候选字词大小 297 | label_font_point: 14 # 候选编号大小 298 | 299 | text_color: 0xdedddd # 拼音行文字颜色,24位色值,16进制,BGR顺序 300 | back_color: 0x252320 # 候选条背景色 301 | label_color: 0x888785 # 预选栏编号颜色 302 | border_color: 0x020202 # 边框色 303 | candidate_text_color: 0xdedddd # 预选项文字颜色 304 | hilited_text_color: 0xdedddd # 高亮拼音 (需要开启内嵌编码) 305 | hilited_back_color: 0x252320 # 高亮拼音 (需要开启内嵌编码) 306 | hilited_candidate_text_color: 0xffffff # 第一候选项文字颜色 307 | hilited_candidate_back_color: 0xcb5d00 # 第一候选项背景背景色 308 | hilited_candidate_label_color: 0xffffff # 第一候选项编号颜色 309 | comment_text_color: 0xdedddd # 拼音等提示文字颜色 310 | #hilited_comment_text_color: 0xdedddd # 注解文字高亮 311 | 312 | solarized_light: 313 | name: 曬經・日/Solarized Light 314 | author: 雪齋 315 | color_space: display_p3 # Only available on macOS 10.12+ 316 | back_color: 0xF0E5F6FB #Lab 97 , 0 , 10 317 | border_color: 0xEDFFFF #Lab 100, 0 , 10 318 | preedit_back_color: 0x403516 #Lab 20 ,-12,-12 319 | #candidate_back_color: 0x403516 #Lab 20 ,-12,-12 320 | candidate_text_color: 0x595E00 #Lab 35 ,-35,-5 321 | label_color: 0xA36407 #Lab 40 ,-10,-45 322 | comment_text_color: 0x005947 #Lab 35 ,-20, 65 323 | text_color: 0xA1A095 #Lab 65 ,-5 ,-2 324 | hilited_back_color: 0x4C4022 #Lab 25 ,-12,-12 325 | hilited_candidate_back_color: 0xD7E8ED #Lab 92 , 0 , 10 326 | hilited_candidate_text_color: 0x3942CB #Lab 50 , 65, 45 327 | hilited_candidate_label_color: 0x2566C6 #Lab 55 , 45, 65 328 | hilited_comment_text_color: 0x8144C2 #Lab 50 , 65,-5 329 | hilited_text_color: 0x2C8BAE #Lab 60 , 10, 65 330 | 331 | solarized_dark: 332 | name: 曬經・月/Solarized Dark 333 | author: 雪齋 334 | back_color: 0xF0352A0A #Lab 15 ,-12,-12 335 | border_color: 0x2A1F00 #Lab 10 ,-12,-12 336 | preedit_back_color: 0xD7E8ED #Lab 92 , 0 , 10 337 | #candidate_back_color: 0xD7E8ED #Lab 92 , 0 , 10 338 | candidate_text_color: 0x7389FF #Lab 75 , 65, 45 339 | label_color: 0x478DF4 #Lab 70 , 45, 65 340 | comment_text_color: 0xC38AFF #Lab 75 , 65,-5 341 | text_color: 0x756E5D #Lab 45 ,-7 ,-7 342 | hilited_back_color: 0xC9DADF #Lab 87 , 0 , 10 343 | hilited_candidate_back_color: 0x403516 #Lab 20 ,-12,-12 344 | hilited_candidate_text_color: 0x989F52 #Lab 60 ,-35,-5 345 | hilited_candidate_label_color: 0xCC8947 #Lab 55 ,-10,-45 346 | hilited_comment_text_color: 0x289989 #Lab 60 ,-20, 65 347 | hilited_text_color: 0xBE706D #Lab 50 , 15,-45 348 | 349 | app_options: 350 | com.apple.Spotlight: 351 | ascii_mode: true 352 | com.alfredapp.Alfred: 353 | ascii_mode: true 354 | com.runningwithcrayons.Alfred-2: 355 | ascii_mode: true 356 | com.blacktree.Quicksilver: 357 | ascii_mode: true 358 | com.apple.Terminal: 359 | ascii_mode: true 360 | no_inline: true 361 | com.googlecode.iterm2: 362 | ascii_mode: true 363 | no_inline: true 364 | org.vim.MacVim: 365 | ascii_mode: true # 初始爲西文模式 366 | no_inline: true # 不使用行內編輯 367 | vim_mode: true # 退出VIM插入模式自動切換輸入法狀態 368 | com.apple.dt.Xcode: 369 | ascii_mode: true 370 | com.barebones.textwrangler: 371 | ascii_mode: true 372 | com.macromates.TextMate.preview: 373 | ascii_mode: true 374 | com.github.atom: 375 | ascii_mode: true 376 | com.microsoft.VSCode: 377 | ascii_mode: true 378 | com.sublimetext.2: 379 | ascii_mode: true 380 | org.gnu.Aquamacs: 381 | ascii_mode: true 382 | org.gnu.Emacs: 383 | ascii_mode: true 384 | no_inline: true 385 | co.zeit.hyper: 386 | ascii_mode: true 387 | com.google.Chrome: 388 | # 規避 https://github.com/rime/squirrel/issues/435 389 | inline: true 390 | com.microsoft.edgemac: 391 | # 規避 https://github.com/rime/squirrel/issues/906 392 | inline: true 393 | ru.keepcoder.Telegram: 394 | # 規避 https://github.com/rime/squirrel/issues/475 395 | inline: true 396 | -------------------------------------------------------------------------------- /lib/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/lib/.placeholder -------------------------------------------------------------------------------- /package/PackageInfo: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /package/Squirrel-component.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RootRelativeBundlePath 7 | Squirrel.app 8 | BundleHasStrictIdentifier 9 | 10 | BundleIsRelocatable 11 | 12 | BundleIsVersionChecked 13 | 14 | BundleOverwriteAction 15 | upgrade 16 | ChildBundles 17 | 18 | 19 | BundleOverwriteAction 20 | upgrade 21 | RootRelativeBundlePath 22 | Squirrel.app/Contents/Frameworks/Sparkle.framework 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /package/add_data_files: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | project_file=Squirrel.xcodeproj/project.pbxproj 6 | test -f "${project_file}" 7 | 8 | anchor=(80 65 terra_pinyin.schema.yaml) 9 | anchor_lib=(9D 9A librime-lua.dylib) 10 | 11 | lastid=80 12 | 13 | obj_entry() { 14 | local id=$1 15 | local ref=$2 16 | local file="$3" 17 | echo "441E63${id}22B7E96F006DCCDD /* ${file} in Copy Shared Support Files */ = {isa = PBXBuildFile; fileRef = 441E63${ref}22B7E90C006DCCDD /* ${file} */; };" 18 | } 19 | 20 | copy_files_entry() { 21 | local id=$1 22 | local ref=$2 23 | local file="$3" 24 | echo "441E63${id}22B7E96F006DCCDD /* ${file} in Copy Shared Support Files */," 25 | } 26 | 27 | file_ref_entry() { 28 | local id=$1 29 | local ref=$2 30 | local file="$3" 31 | echo "441E63${ref}22B7E90C006DCCDD /* ${file} */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = ${file}; path = data/plum/${file}; sourceTree = \"\"; };" 32 | } 33 | 34 | group_entry() { 35 | local id=$1 36 | local ref=$2 37 | local file="$3" 38 | echo "441E63${ref}22B7E90C006DCCDD /* ${file} */," 39 | } 40 | 41 | lib_entry() { 42 | local id=$1 43 | local ref=$2 44 | local lib="$3" 45 | echo "2C6B9F${id}2BCD086700E327DF /* ${lib} in Copy Rime plugins */ = {isa = PBXBuildFile; fileRef = 2C6B9F${ref}2BCD086700E327DF /* ${lib} */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };" 46 | } 47 | 48 | lib_ref_entry() { 49 | local id=$1 50 | local ref=$2 51 | local lib="$3" 52 | echo "2C6B9F${ref}2BCD086700E327DF /* ${lib} */ = {isa = PBXFileReference; lastKnownFileType = \"compiled.mach-o.dylib\"; name = \"${lib}\"; path = \"lib/rime-plugins/${lib}\"; sourceTree = \"\"; };" 53 | } 54 | 55 | frameworks_phase_entry() { 56 | local id=$1 57 | local ref=$2 58 | local lib="$3" 59 | echo "2C6B9F${id}2BCD086700E327DF /* ${lib} in Copy Rime plugins */," 60 | } 61 | 62 | add_line() { 63 | local search="^[[:space:]]*$(sed 's/\//\\\//g; s/\*/\\*/g' <<< "${1}")\$" 64 | sed -i '' "/${search}/a\\ 65 | ${2} 66 | " "${project_file}" 67 | } 68 | 69 | add_file() { 70 | local file="$1" 71 | local new_id=$(( ++lastid )) 72 | local new_refid=$(( ++lastid )) 73 | local anchor_line 74 | local new_line 75 | 76 | anchor_line="$(obj_entry ${anchor[@]})" 77 | new_line="$(obj_entry ${new_id} ${new_refid} "${file}")" 78 | add_line "${anchor_line}" "${new_line}" 79 | 80 | anchor_line="$(copy_files_entry ${anchor[@]})" 81 | new_line="$(copy_files_entry ${new_id} ${new_refid} "${file}")" 82 | add_line "${anchor_line}" "${new_line}" 83 | 84 | anchor_line="$(file_ref_entry ${anchor[@]})" 85 | new_line="$(file_ref_entry ${new_id} ${new_refid} "${file}")" 86 | add_line "${anchor_line}" "${new_line}" 87 | 88 | anchor_line="$(group_entry ${anchor[@]})" 89 | new_line="$(group_entry ${new_id} ${new_refid} "${file}")" 90 | add_line "${anchor_line}" "${new_line}" 91 | } 92 | 93 | add_lib() { 94 | local lib="$1" 95 | local new_lib_id=$(( ++lastid )) 96 | local new_lib_refid=$(( ++lastid )) 97 | local anchor_line 98 | local new_line 99 | 100 | # Assuming you have similar 'anchor' values for library entries 101 | anchor_lib_line="$(lib_entry ${anchor_lib[@]})" 102 | new_lib_line="$(lib_entry ${new_lib_id} ${new_lib_refid} "${lib}")" 103 | add_line "${anchor_lib_line}" "${new_lib_line}" 104 | 105 | anchor_lib_line="$(lib_ref_entry ${anchor_lib[@]})" 106 | new_lib_line="$(lib_ref_entry ${new_lib_id} ${new_lib_refid} "${lib}")" 107 | add_line "${anchor_lib_line}" "${new_lib_line}" 108 | 109 | anchor_lib_line="$(frameworks_phase_entry ${anchor_lib[@]})" 110 | new_lib_line="$(frameworks_phase_entry ${new_lib_id} ${new_lib_refid} "${lib}")" 111 | add_line "${anchor_lib_line}" "${new_lib_line}" 112 | } 113 | 114 | 115 | data_files=( 116 | $(ls data/plum/* | xargs basename) 117 | ) 118 | 119 | lib_files=( 120 | $(ls lib/rime-plugins/* | xargs basename) 121 | ) 122 | 123 | for file in "${data_files[@]}" 124 | do 125 | if grep -Fq " ${file} " "${project_file}" 126 | then 127 | echo "found ${file}" 128 | continue 129 | fi 130 | echo "adding ${file} to ${project_file}" 131 | add_file "${file}" 132 | done 133 | 134 | for lib in "${lib_files[@]}" 135 | do 136 | if grep -Fq " ${lib} " "${project_file}" 137 | then 138 | echo "found ${lib}" 139 | continue 140 | fi 141 | echo "adding ${lib} to ${project_file}" 142 | add_lib "${lib}" 143 | done 144 | -------------------------------------------------------------------------------- /package/bump_version: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd "$(dirname $0)/.." 6 | source package/common.sh 7 | 8 | app_version="$(get_app_version)" 9 | echo "current app version: ${app_version}" 10 | 11 | invalid_args() { 12 | echo "Usage: $(basename $0) |major|minor|patch" 13 | exit 1 14 | } 15 | 16 | version_pattern='([0-9]+)\.([0-9]+)\.([0-9]+)' 17 | 18 | old_version=$app_version 19 | new_version=$1 20 | 21 | if [[ $old_version =~ $version_pattern ]]; then 22 | old_major=${BASH_REMATCH[1]} 23 | old_minor=${BASH_REMATCH[2]} 24 | old_patch=${BASH_REMATCH[3]} 25 | else 26 | invalid_args 27 | fi 28 | 29 | new_major=$old_major 30 | new_minor=$old_minor 31 | new_patch=$old_patch 32 | 33 | if [[ $new_version =~ $version_pattern ]]; then 34 | new_major=${BASH_REMATCH[1]} 35 | new_minor=${BASH_REMATCH[2]} 36 | new_patch=${BASH_REMATCH[3]} 37 | elif [[ $new_version = 'major' ]]; then 38 | ((++new_major)) 39 | new_minor=0 40 | new_patch=0 41 | elif [[ $new_version = 'minor' ]]; then 42 | ((++new_minor)) 43 | new_patch=0 44 | elif [[ $new_version = 'patch' ]]; then 45 | ((++new_patch)) 46 | else 47 | invalid_args 48 | fi 49 | 50 | old_version="${old_major}.${old_minor}.${old_patch}" 51 | new_version="${new_major}.${new_minor}.${new_patch}" 52 | echo "updating ${old_version} => ${new_version}" 53 | 54 | # deprecated 55 | edit_info_plist_file() { 56 | local file="$1" 57 | 58 | if [[ $OSTYPE =~ darwin ]]; then 59 | L_BOUND='[[:<:]]' 60 | R_BOUND='[[:>:]]' 61 | else 62 | L_BOUND='\<' 63 | R_BOUND='\>' 64 | fi 65 | SEP='\([,.]\)' 66 | local version_pattern="${L_BOUND}${old_major}${SEP}${old_minor}${SEP}${old_patch}${R_BOUND}" 67 | local replacement=${new_major}'\1'${new_minor}'\2'${new_patch} 68 | 69 | sed -i '' "s/${version_pattern}/${replacement}/g" "${file}" 70 | } 71 | 72 | update_changelog() { 73 | local version="$1" 74 | clog --from-latest-tag \ 75 | --changelog CHANGELOG.md \ 76 | --repository https://github.com/rime/squirrel \ 77 | --setversion "${version}" 78 | } 79 | 80 | bump_version "${new_version}" 81 | 82 | update_changelog "${new_version}" 83 | ${VISUAL:-${EDITOR:-vim}} CHANGELOG.md 84 | match_line "## ${new_version} " CHANGELOG.md || ( 85 | echo >&2 "CHANGELOG.md has no changes for version ${new_version}." 86 | exit 1 87 | ) 88 | 89 | release_message="chore(release): ${new_version} :tada:" 90 | release_tag="${new_version}" 91 | git commit --all --message "${release_message}" 92 | git tag --annotate "${release_tag}" --message "${release_message}" 93 | -------------------------------------------------------------------------------- /package/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PROJECT_ROOT="$(cd "$(dirname "$BASH_SOURCE")/.."; pwd)" 4 | 5 | bump_version() { 6 | local version="$1" 7 | cd "${PROJECT_ROOT}" 8 | xcrun agvtool new-version "${version}" 9 | } 10 | 11 | get_app_version() { 12 | cd "${PROJECT_ROOT}" 13 | xcrun agvtool what-version | sed -n 'n;s/^[[:space:]]*\([0-9.]*\)$/\1/;p' 14 | } 15 | 16 | # deprecated 17 | get_bundle_version() { 18 | sed -n '/CFBundleVersion/{n;s/.*\(.*\)<\/string>.*/\1/;p;}' "$@" 19 | } 20 | 21 | match_line() { 22 | grep --quiet --fixed-strings "$@" 23 | } 24 | -------------------------------------------------------------------------------- /package/make_archive: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # enconding: utf-8 3 | 4 | set -e 5 | 6 | cd "$(dirname $0)" 7 | source common.sh 8 | 9 | app_version="$(get_app_version)" 10 | target_pkg="Squirrel-${app_version}.pkg" 11 | download_url="https://github.com/rime/squirrel/releases/download/${app_version}/${target_pkg}" 12 | 13 | verify_archive() { 14 | if ! [ -f "${target_pkg}" ]; then 15 | echo >&2 "ERROR: file does not exit: ${target_pkg}" 16 | exit 1 17 | fi 18 | echo 'sha256 checksum:' 19 | echo "${checksum} ${target_pkg}" 20 | shasum -a 256 -c <<<"${checksum} ${target_pkg}" || exit 1 21 | } 22 | 23 | create_archive() { 24 | if [ -e "${target_pkg}" ]; then 25 | echo >&2 "ERROR: target archive already exists: ${target_pkg}" 26 | exit 1 27 | fi 28 | cp Squirrel.pkg "${target_pkg}" 29 | echo 'sha256 checksum:' 30 | shasum -a 256 "${target_pkg}" 31 | } 32 | 33 | if [ -n "${checksum}" ]; then 34 | verify_archive 35 | else 36 | create_archive 37 | fi 38 | 39 | if signature=$(sign_update "${target_pkg}"); then 40 | echo "sign update is successful." 41 | else 42 | echo "sign_update not working, skiping signing update." 43 | exit 0 44 | fi 45 | edSignature=$(echo $signature | awk -F'"' '{print $2}') 46 | length=$(echo $signature | awk -F'"' '{print $4}') 47 | pub_date=$(date -R) 48 | 49 | # for release channel 50 | appcast_file='appcast.xml' 51 | 52 | cat > "${appcast_file}" << EOF 53 | 54 | 55 | 56 | 【鼠鬚管】輸入法更新頻道 57 | https://rime.github.io/release/squirrel/appcast.xml 58 | 鼠鬚管 Appcast 更新頻道 59 | zh 60 | 61 | 鼠鬚管 ${app_version} 62 | https://rime.github.io/release/squirrel/ 63 | 13.0.0 64 | ${pub_date} 65 | 70 | 71 | 72 | 73 | EOF 74 | 75 | ls -l ${appcast_file} 76 | 77 | # for testing channel 78 | appcast_file='testing-appcast.xml' 79 | 80 | cat > "${appcast_file}" << EOF 81 | 82 | 83 | 84 | 【鼠鬚管】輸入法測試頻道 85 | https://rime.github.io/testing/squirrel/appcast.xml 86 | 鼠鬚管測試版 Appcast 更新頻道 87 | zh 88 | 89 | 鼠鬚管 ${app_version} 90 | https://rime.github.io/testing/squirrel/ 91 | 13.0.0 92 | ${pub_date} 93 | 98 | 99 | 100 | 101 | EOF 102 | 103 | ls -l ${appcast_file} 104 | 105 | # for testing appcast push locally. 106 | appcast_file='debug-appcast.xml' 107 | download_url="file://${PROJECT_ROOT}/package/${target_pkg}" 108 | 109 | cat > "${appcast_file}" << EOF 110 | 111 | 112 | 113 | 【鼠鬚管】輸入法本地測試 114 | file://${PROJECT_ROOT}/package/${appcast_file} 115 | 鼠鬚管 Appcast 更新本地測試 116 | zh 117 | 118 | 鼠鬚管 ${app_version} 119 | https://rime.github.io/testing/squirrel/ 120 | 10.9.0 121 | ${pub_date} 122 | 127 | 128 | 129 | 130 | EOF 131 | 132 | ls -l ${appcast_file} 133 | -------------------------------------------------------------------------------- /package/make_package: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DERIVED_DATA_PATH=$1 4 | BUNDLE_IDENTIFIER='im.rime.inputmethod.Squirrel' 5 | INSTALL_LOCATION='/Library/Input Methods' 6 | 7 | cd "$(dirname $0)" 8 | source common.sh 9 | 10 | pkgbuild \ 11 | --info PackageInfo \ 12 | --root "${PROJECT_ROOT}/${DERIVED_DATA_PATH}/Build/Products/Release" \ 13 | --filter '.*\.swiftmodule$' \ 14 | --component-plist Squirrel-component.plist \ 15 | --identifier "${BUNDLE_IDENTIFIER}" \ 16 | --version "$(get_app_version)" \ 17 | --install-location "${INSTALL_LOCATION}" \ 18 | --scripts "${PROJECT_ROOT}/scripts" \ 19 | Squirrel.pkg 20 | -------------------------------------------------------------------------------- /package/sign_app: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # enconding: utf-8 3 | 4 | DEV_ID=$1 5 | DERIVED_DATA_PATH=$2 6 | appDir="${DERIVED_DATA_PATH}/Build/Products/Release/Squirrel.app" 7 | entitlement="resources/Squirrel.entitlements" 8 | 9 | codesign --deep --force --options runtime --timestamp --sign "Developer ID Application: ${DEV_ID}" --entitlements "$entitlement" --verbose "$appDir"; 10 | 11 | spctl -a -vv "$appDir"; 12 | -------------------------------------------------------------------------------- /resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TISInputSourceID 6 | im.rime.inputmethod.Squirrel 7 | CFBundleDevelopmentRegion 8 | English 9 | CFBundleExecutable 10 | Squirrel 11 | CFBundleIconFile 12 | RimeIcon.icns 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | Squirrel 19 | CFBundlePackageType 20 | APPL 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | ComponentInputModeDict 26 | 27 | tsInputModeListKey 28 | 29 | im.rime.inputmethod.Squirrel.Hans 30 | 31 | TISInputSourceID 32 | im.rime.inputmethod.Squirrel.Hans 33 | TISIntendedLanguage 34 | zh-Hans 35 | tsInputModeAlternateMenuIconFileKey 36 | rime.pdf 37 | tsInputModeCharacterRepertoireKey 38 | 39 | Hans 40 | Hant 41 | 42 | tsInputModeDefaultStateKey 43 | 44 | tsInputModeIsVisibleKey 45 | 46 | tsInputModeKeyEquivalentModifiersKey 47 | 4608 48 | tsInputModeMenuIconFileKey 49 | rime.pdf 50 | tsInputModePaletteIconFileKey 51 | rime.pdf 52 | tsInputModePrimaryInScriptKey 53 | 54 | tsInputModeScriptKey 55 | smUnicodeScript 56 | 57 | im.rime.inputmethod.Squirrel.Hant 58 | 59 | TISInputSourceID 60 | im.rime.inputmethod.Squirrel.Hant 61 | TISIntendedLanguage 62 | zh-Hant 63 | tsInputModeAlternateMenuIconFileKey 64 | rime.pdf 65 | tsInputModeCharacterRepertoireKey 66 | 67 | Hant 68 | Hans 69 | 70 | tsInputModeDefaultStateKey 71 | 72 | tsInputModeIsVisibleKey 73 | 74 | tsInputModeKeyEquivalentModifiersKey 75 | 4608 76 | tsInputModeMenuIconFileKey 77 | rime.pdf 78 | tsInputModePaletteIconFileKey 79 | rime.pdf 80 | tsInputModePrimaryInScriptKey 81 | 82 | tsInputModeScriptKey 83 | smUnicodeScript 84 | 85 | 86 | tsVisibleInputModeOrderedArrayKey 87 | 88 | im.rime.inputmethod.Squirrel.Hans 89 | im.rime.inputmethod.Squirrel.Hant 90 | 91 | 92 | InputMethodConnectionName 93 | Squirrel_Connection 94 | InputMethodServerControllerClass 95 | Squirrel.SquirrelInputController 96 | InputMethodServerDelegateClass 97 | Squirrel.SquirrelInputController 98 | LSBackgroundOnly 99 | 100 | LSUIElement 101 | 102 | NSPrincipalClass 103 | NSApplication 104 | SUEnableAutomaticChecks 105 | 106 | SUFeedURL 107 | https://rime.github.io/release/squirrel/appcast.xml 108 | SUPublicEDKey 109 | ukvWq2dKOWn3B9AsdsQIwOptiDdDKdUjAVNgFxSvB2o= 110 | TICapsLockLanguageSwitchCapable 111 | 112 | SUEnableInstallerLauncherService 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /resources/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "CFBundleDisplayName" : { 5 | "extractionState" : "manual", 6 | "localizations" : { 7 | "en" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "Squirrel" 11 | } 12 | }, 13 | "zh-Hans" : { 14 | "stringUnit" : { 15 | "state" : "translated", 16 | "value" : "鼠须管" 17 | } 18 | }, 19 | "zh-Hant" : { 20 | "stringUnit" : { 21 | "state" : "translated", 22 | "value" : "鼠鬚管" 23 | } 24 | } 25 | } 26 | }, 27 | "CFBundleName" : { 28 | "comment" : "Bundle name", 29 | "extractionState" : "manual", 30 | "localizations" : { 31 | "en" : { 32 | "stringUnit" : { 33 | "state" : "translated", 34 | "value" : "Squirrel" 35 | } 36 | }, 37 | "zh-Hans" : { 38 | "stringUnit" : { 39 | "state" : "translated", 40 | "value" : "鼠须管" 41 | } 42 | }, 43 | "zh-Hant" : { 44 | "stringUnit" : { 45 | "state" : "translated", 46 | "value" : "鼠鬚管" 47 | } 48 | } 49 | } 50 | }, 51 | "im.rime.inputmethod.Squirrel" : { 52 | "extractionState" : "manual", 53 | "localizations" : { 54 | "en" : { 55 | "stringUnit" : { 56 | "state" : "translated", 57 | "value" : "Squirrel" 58 | } 59 | }, 60 | "zh-Hans" : { 61 | "stringUnit" : { 62 | "state" : "translated", 63 | "value" : "鼠须管" 64 | } 65 | }, 66 | "zh-Hant" : { 67 | "stringUnit" : { 68 | "state" : "translated", 69 | "value" : "鼠鬚管" 70 | } 71 | } 72 | } 73 | }, 74 | "im.rime.inputmethod.Squirrel.Hans" : { 75 | "extractionState" : "manual", 76 | "localizations" : { 77 | "en" : { 78 | "stringUnit" : { 79 | "state" : "translated", 80 | "value" : "Squirrel - Simplified" 81 | } 82 | }, 83 | "zh-Hans" : { 84 | "stringUnit" : { 85 | "state" : "translated", 86 | "value" : "鼠须管" 87 | } 88 | }, 89 | "zh-Hant" : { 90 | "stringUnit" : { 91 | "state" : "translated", 92 | "value" : "鼠须管" 93 | } 94 | } 95 | } 96 | }, 97 | "im.rime.inputmethod.Squirrel.Hant" : { 98 | "extractionState" : "manual", 99 | "localizations" : { 100 | "en" : { 101 | "stringUnit" : { 102 | "state" : "translated", 103 | "value" : "Squirrel - Traditional" 104 | } 105 | }, 106 | "zh-Hans" : { 107 | "stringUnit" : { 108 | "state" : "translated", 109 | "value" : "鼠鬚管" 110 | } 111 | }, 112 | "zh-Hant" : { 113 | "stringUnit" : { 114 | "state" : "translated", 115 | "value" : "鼠鬚管" 116 | } 117 | } 118 | } 119 | }, 120 | "NSHumanReadableCopyright" : { 121 | "comment" : "Localized versions of Info.plist keys", 122 | "extractionState" : "manual", 123 | "localizations" : { 124 | "en" : { 125 | "stringUnit" : { 126 | "state" : "translated", 127 | "value" : "Copyleft, RIME Developers" 128 | } 129 | }, 130 | "zh-Hans" : { 131 | "stringUnit" : { 132 | "state" : "translated", 133 | "value" : "式恕堂 版权所无" 134 | } 135 | }, 136 | "zh-Hant" : { 137 | "stringUnit" : { 138 | "state" : "translated", 139 | "value" : "式恕堂 版權所無" 140 | } 141 | } 142 | } 143 | } 144 | }, 145 | "version" : "1.0" 146 | } 147 | -------------------------------------------------------------------------------- /resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "A new update is available" : { 5 | "comment" : "Update", 6 | "localizations" : { 7 | "zh-Hans" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "有新版本" 11 | } 12 | }, 13 | "zh-Hant" : { 14 | "stringUnit" : { 15 | "state" : "translated", 16 | "value" : "有新版本" 17 | } 18 | } 19 | } 20 | }, 21 | "Check for updates..." : { 22 | "comment" : "Menu item", 23 | "localizations" : { 24 | "zh-Hans" : { 25 | "stringUnit" : { 26 | "state" : "translated", 27 | "value" : "检查新版本..." 28 | } 29 | }, 30 | "zh-Hant" : { 31 | "stringUnit" : { 32 | "state" : "translated", 33 | "value" : "檢查新版本..." 34 | } 35 | } 36 | } 37 | }, 38 | "Deploy" : { 39 | "comment" : "Menu item", 40 | "localizations" : { 41 | "zh-Hans" : { 42 | "stringUnit" : { 43 | "state" : "translated", 44 | "value" : "重新部署" 45 | } 46 | }, 47 | "zh-Hant" : { 48 | "stringUnit" : { 49 | "state" : "translated", 50 | "value" : "重新部署" 51 | } 52 | } 53 | } 54 | }, 55 | "deploy_failure" : { 56 | "localizations" : { 57 | "en" : { 58 | "stringUnit" : { 59 | "state" : "translated", 60 | "value" : "Error occurred. Please check log files" 61 | } 62 | }, 63 | "zh-Hans" : { 64 | "stringUnit" : { 65 | "state" : "translated", 66 | "value" : "有错误!请查看日志" 67 | } 68 | }, 69 | "zh-Hant" : { 70 | "stringUnit" : { 71 | "state" : "translated", 72 | "value" : "有錯誤!請查看日誌" 73 | } 74 | } 75 | } 76 | }, 77 | "deploy_start" : { 78 | "localizations" : { 79 | "en" : { 80 | "stringUnit" : { 81 | "state" : "translated", 82 | "value" : "Deploying Rime input method engine." 83 | } 84 | }, 85 | "zh-Hans" : { 86 | "stringUnit" : { 87 | "state" : "translated", 88 | "value" : "部署输入法引擎…" 89 | } 90 | }, 91 | "zh-Hant" : { 92 | "stringUnit" : { 93 | "state" : "translated", 94 | "value" : "部署輸入法引擎…" 95 | } 96 | } 97 | } 98 | }, 99 | "deploy_success" : { 100 | "localizations" : { 101 | "en" : { 102 | "stringUnit" : { 103 | "state" : "translated", 104 | "value" : "Squirrel is ready." 105 | } 106 | }, 107 | "zh-Hans" : { 108 | "stringUnit" : { 109 | "state" : "translated", 110 | "value" : "部署完成。" 111 | } 112 | }, 113 | "zh-Hant" : { 114 | "stringUnit" : { 115 | "state" : "translated", 116 | "value" : "部署完成。" 117 | } 118 | } 119 | } 120 | }, 121 | "deploy_update" : { 122 | "localizations" : { 123 | "en" : { 124 | "stringUnit" : { 125 | "state" : "translated", 126 | "value" : "Deploying Rime for updates." 127 | } 128 | }, 129 | "zh-Hans" : { 130 | "stringUnit" : { 131 | "state" : "translated", 132 | "value" : "更新输入法引擎…" 133 | } 134 | }, 135 | "zh-Hant" : { 136 | "stringUnit" : { 137 | "state" : "translated", 138 | "value" : "更新輸入法引擎…" 139 | } 140 | } 141 | } 142 | }, 143 | "Logs..." : { 144 | "comment" : "Menu item", 145 | "localizations" : { 146 | "zh-Hans" : { 147 | "stringUnit" : { 148 | "state" : "translated", 149 | "value" : "日志..." 150 | } 151 | }, 152 | "zh-Hant" : { 153 | "stringUnit" : { 154 | "state" : "translated", 155 | "value" : "日誌..." 156 | } 157 | } 158 | } 159 | }, 160 | "Rime Wiki..." : { 161 | "comment" : "Menu item", 162 | "localizations" : { 163 | "zh-Hans" : { 164 | "stringUnit" : { 165 | "state" : "translated", 166 | "value" : "在线文档..." 167 | } 168 | }, 169 | "zh-Hant" : { 170 | "stringUnit" : { 171 | "state" : "translated", 172 | "value" : "在線文檔..." 173 | } 174 | } 175 | } 176 | }, 177 | "Settings..." : { 178 | "comment" : "Menu item", 179 | "localizations" : { 180 | "zh-Hans" : { 181 | "stringUnit" : { 182 | "state" : "translated", 183 | "value" : "用户设定..." 184 | } 185 | }, 186 | "zh-Hant" : { 187 | "stringUnit" : { 188 | "state" : "translated", 189 | "value" : "用戶設定" 190 | } 191 | } 192 | } 193 | }, 194 | "Squirrel" : { 195 | "comment" : "Menu title", 196 | "localizations" : { 197 | "zh-Hans" : { 198 | "stringUnit" : { 199 | "state" : "translated", 200 | "value" : "鼠须管" 201 | } 202 | }, 203 | "zh-Hant" : { 204 | "stringUnit" : { 205 | "state" : "translated", 206 | "value" : "鼠鬚管" 207 | } 208 | } 209 | } 210 | }, 211 | "Sync user data" : { 212 | "comment" : "Menu item", 213 | "localizations" : { 214 | "zh-Hans" : { 215 | "stringUnit" : { 216 | "state" : "translated", 217 | "value" : "同步用户数据" 218 | } 219 | }, 220 | "zh-Hant" : { 221 | "stringUnit" : { 222 | "state" : "translated", 223 | "value" : "同步用戶資料" 224 | } 225 | } 226 | } 227 | }, 228 | "Version [version] is now available" : { 229 | "comment" : "Update", 230 | "localizations" : { 231 | "zh-Hans" : { 232 | "stringUnit" : { 233 | "state" : "translated", 234 | "value" : "[version] 现已发布" 235 | } 236 | }, 237 | "zh-Hant" : { 238 | "stringUnit" : { 239 | "state" : "translated", 240 | "value" : "[version] 現已發佈" 241 | } 242 | } 243 | } 244 | } 245 | }, 246 | "version" : "1.0" 247 | } -------------------------------------------------------------------------------- /resources/Squirrel.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.disable-library-validation 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/rime.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LEOYoon-Tsaw/squirrel/a937c1253be7e63aaeab634de76cca1a1629a6d7/resources/rime.pdf -------------------------------------------------------------------------------- /scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | login_user=`/usr/bin/stat -f%Su /dev/console` 5 | squirrel_app_root="${DSTROOT}/Squirrel.app" 6 | squirrel_executable="${squirrel_app_root}/Contents/MacOS/Squirrel" 7 | rime_package_installer="${squirrel_app_root}/Contents/MacOS/rime-install" 8 | rime_shared_data_path="${squirrel_app_root}/Contents/SharedSupport" 9 | 10 | /usr/bin/sudo -u "${login_user}" /usr/bin/killall Squirrel > /dev/null || true 11 | 12 | "${squirrel_executable}" --register-input-source 13 | 14 | if [ -z "${RIME_NO_PREBUILD}" ]; then 15 | pushd "${rime_shared_data_path}" > /dev/null 16 | "${squirrel_executable}" --build 17 | popd > /dev/null 18 | fi && ( 19 | /usr/bin/sudo -u "${login_user}" "${squirrel_executable}" --enable-input-source 20 | /usr/bin/sudo -u "${login_user}" "${squirrel_executable}" --select-input-source 21 | ) 22 | -------------------------------------------------------------------------------- /sources/BridgingFunctions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BridgingFunctions.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DataSizeable { 11 | // swiftlint:disable:next identifier_name 12 | var data_size: Int32 { get set } 13 | } 14 | 15 | extension RimeContext_stdbool: DataSizeable {} 16 | extension RimeTraits: DataSizeable {} 17 | extension RimeCommit: DataSizeable {} 18 | extension RimeStatus_stdbool: DataSizeable {} 19 | extension RimeModule: DataSizeable {} 20 | 21 | extension DataSizeable { 22 | static func rimeStructInit() -> Self { 23 | let valuePointer = UnsafeMutablePointer.allocate(capacity: 1) 24 | // Initialize the memory to zero 25 | memset(valuePointer, 0, MemoryLayout.size) 26 | // Convert the pointer to a managed Swift variable 27 | var value = valuePointer.move() 28 | valuePointer.deallocate() 29 | // Initialize data_size property 30 | let offset = MemoryLayout.size(ofValue: \Self.data_size) 31 | value.data_size = Int32(MemoryLayout.size - offset) 32 | return value 33 | } 34 | 35 | mutating func setCString(_ swiftString: String, to keypath: WritableKeyPath?>) { 36 | swiftString.withCString { cStr in 37 | // Duplicate the string to create a persisting C string 38 | let mutableCStr = strdup(cStr) 39 | // Free the existing string if there is one 40 | if let existing = self[keyPath: keypath] { 41 | free(UnsafeMutableRawPointer(mutating: existing)) 42 | } 43 | self[keyPath: keypath] = UnsafePointer(mutableCStr) 44 | } 45 | } 46 | } 47 | 48 | infix operator ?= : AssignmentPrecedence 49 | // swiftlint:disable:next operator_whitespace 50 | func ?=(left: inout T, right: T?) { 51 | if let right = right { 52 | left = right 53 | } 54 | } 55 | // swiftlint:disable:next operator_whitespace 56 | func ?=(left: inout T?, right: T?) { 57 | if let right = right { 58 | left = right 59 | } 60 | } 61 | 62 | extension NSRange { 63 | static let empty = NSRange(location: NSNotFound, length: 0) 64 | } 65 | 66 | extension NSPoint { 67 | static func += (lhs: inout Self, rhs: Self) { 68 | lhs.x += rhs.x 69 | lhs.y += rhs.y 70 | } 71 | static func - (lhs: Self, rhs: Self) -> Self { 72 | Self.init(x: lhs.x - rhs.x, y: lhs.y - rhs.y) 73 | } 74 | static func -= (lhs: inout Self, rhs: Self) { 75 | lhs.x -= rhs.x 76 | lhs.y -= rhs.y 77 | } 78 | static func * (lhs: Self, rhs: CGFloat) -> Self { 79 | Self.init(x: lhs.x * rhs, y: lhs.y * rhs) 80 | } 81 | static func / (lhs: Self, rhs: CGFloat) -> Self { 82 | Self.init(x: lhs.x / rhs, y: lhs.y / rhs) 83 | } 84 | var length: CGFloat { 85 | sqrt(pow(self.x, 2) + pow(self.y, 2)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /sources/InputSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InputSource.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/10/24. 6 | // 7 | 8 | import Foundation 9 | import InputMethodKit 10 | 11 | final class SquirrelInstaller { 12 | enum InputMode: String, CaseIterable { 13 | static let primary = Self.hans 14 | case hans = "im.rime.inputmethod.Squirrel.Hans" 15 | case hant = "im.rime.inputmethod.Squirrel.Hant" 16 | } 17 | private lazy var inputSources: [String: TISInputSource] = { 18 | var inputSources = [String: TISInputSource]() 19 | var matchingSources = [InputMode: TISInputSource]() 20 | let sourceList = TISCreateInputSourceList(nil, true).takeRetainedValue() as! [TISInputSource] 21 | for inputSource in sourceList { 22 | let sourceIDRef = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) 23 | guard let sourceID = unsafeBitCast(sourceIDRef, to: CFString?.self) as String? else { continue } 24 | // print("[DEBUG] Examining input source: \(sourceID)") 25 | inputSources[sourceID] = inputSource 26 | } 27 | return inputSources 28 | }() 29 | 30 | func enabledModes() -> [InputMode] { 31 | var enabledModes = Set() 32 | for (mode, inputSource) in getInputSource(modes: InputMode.allCases) { 33 | if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), enabled { 34 | enabledModes.insert(mode) 35 | } 36 | if enabledModes.count == InputMode.allCases.count { 37 | break 38 | } 39 | } 40 | return Array(enabledModes) 41 | } 42 | 43 | func register() { 44 | let enabledInputModes = enabledModes() 45 | if !enabledInputModes.isEmpty { 46 | print("User already registered Squirrel method(s): \(enabledInputModes.map { $0.rawValue })") 47 | // Already registered. 48 | return 49 | } 50 | TISRegisterInputSource(SquirrelApp.appDir as CFURL) 51 | print("Registered input source from \(SquirrelApp.appDir)") 52 | } 53 | 54 | func enable(modes: [InputMode] = []) { 55 | let enabledInputModes = enabledModes() 56 | if !enabledInputModes.isEmpty && modes.isEmpty { 57 | print("User already enabled Squirrel method(s): \(enabledInputModes.map { $0.rawValue })") 58 | // keep user's manually enabled input modes. 59 | return 60 | } 61 | let modesToEnable = modes.isEmpty ? [.primary] : modes 62 | for (mode, inputSource) in getInputSource(modes: modesToEnable) { 63 | if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), !enabled { 64 | let error = TISEnableInputSource(inputSource) 65 | print("Enable \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)") 66 | } 67 | } 68 | } 69 | 70 | func select(mode: InputMode? = nil) { 71 | let enabledInputModes = enabledModes() 72 | let modeToSelect = mode ?? .primary 73 | if !enabledInputModes.contains(modeToSelect) { 74 | if mode != nil { 75 | enable(modes: [modeToSelect]) 76 | } else { 77 | print("Default method not enabled yet: \(modeToSelect.rawValue)") 78 | return 79 | } 80 | } 81 | for (mode, inputSource) in getInputSource(modes: [modeToSelect]) { 82 | if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), 83 | let selectable = getBool(for: inputSource, key: kTISPropertyInputSourceIsSelectCapable), 84 | let selected = getBool(for: inputSource, key: kTISPropertyInputSourceIsSelected), 85 | enabled && selectable && !selected { 86 | let error = TISSelectInputSource(inputSource) 87 | print("Selection \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)") 88 | } else { 89 | print("Failed to select \(mode.rawValue)") 90 | } 91 | } 92 | } 93 | 94 | func disable(modes: [InputMode] = []) { 95 | let modesToDisable = modes.isEmpty ? InputMode.allCases : modes 96 | for (mode, inputSource) in getInputSource(modes: modesToDisable) { 97 | if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), enabled { 98 | let error = TISDisableInputSource(inputSource) 99 | print("Disable \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)") 100 | } 101 | } 102 | } 103 | 104 | private func getInputSource(modes: [InputMode]) -> [InputMode: TISInputSource] { 105 | var matchingSources = [InputMode: TISInputSource]() 106 | for mode in modes { 107 | if let inputSource = inputSources[mode.rawValue] { 108 | matchingSources[mode] = inputSource 109 | } 110 | } 111 | return matchingSources 112 | } 113 | 114 | private func getBool(for inputSource: TISInputSource, key: CFString!) -> Bool? { 115 | let enabledRef = TISGetInputSourceProperty(inputSource, key) 116 | guard let enabled = unsafeBitCast(enabledRef, to: CFBoolean?.self) else { return nil } 117 | return CFBooleanGetValue(enabled) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /sources/MacOSKeyCodes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MacOSKeyCodes.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/9/24. 6 | // 7 | 8 | import Carbon 9 | import AppKit 10 | 11 | struct SquirrelKeycode { 12 | 13 | static func osxModifiersToRime(modifiers: NSEvent.ModifierFlags) -> UInt32 { 14 | var ret: UInt32 = 0 15 | if modifiers.contains(.capsLock) { 16 | ret |= kLockMask.rawValue 17 | } 18 | if modifiers.contains(.shift) { 19 | ret |= kShiftMask.rawValue 20 | } 21 | if modifiers.contains(.control) { 22 | ret |= kControlMask.rawValue 23 | } 24 | if modifiers.contains(.option) { 25 | ret |= kAltMask.rawValue 26 | } 27 | if modifiers.contains(.command) { 28 | ret |= kSuperMask.rawValue 29 | } 30 | return ret 31 | } 32 | 33 | static func osxKeycodeToRime(keycode: UInt16, keychar: Character?, shift: Bool, caps: Bool) -> UInt32 { 34 | if let code = keycodeMappings[Int(keycode)] { 35 | return UInt32(code) 36 | } 37 | 38 | if let keychar = keychar, keychar.isASCII, let codeValue = keychar.unicodeScalars.first?.value { 39 | // NOTE: IBus/Rime use different keycodes for uppercase/lowercase letters. 40 | if keychar.isLowercase && (shift != caps) { 41 | // lowercase -> Uppercase 42 | return keychar.uppercased().unicodeScalars.first!.value 43 | } 44 | 45 | switch codeValue { 46 | case 0x20...0x7e: 47 | return codeValue 48 | case 0x1b: 49 | return UInt32(XK_bracketleft) 50 | case 0x1c: 51 | return UInt32(XK_backslash) 52 | case 0x1d: 53 | return UInt32(XK_bracketright) 54 | case 0x1f: 55 | return UInt32(XK_minus) 56 | default: 57 | break 58 | } 59 | } 60 | 61 | if let code = additionalCodeMappings[Int(keycode)] { 62 | return UInt32(code) 63 | } 64 | 65 | return UInt32(XK_VoidSymbol) 66 | } 67 | 68 | private static let keycodeMappings: [Int: Int32] = [ 69 | // modifiers 70 | kVK_CapsLock: XK_Caps_Lock, 71 | kVK_Command: XK_Super_L, // XK_Meta_L? 72 | kVK_RightCommand: XK_Super_R, // XK_Meta_R? 73 | kVK_Control: XK_Control_L, 74 | kVK_RightControl: XK_Control_R, 75 | kVK_Function: XK_Hyper_L, 76 | kVK_Option: XK_Alt_L, 77 | kVK_RightOption: XK_Alt_R, 78 | kVK_Shift: XK_Shift_L, 79 | kVK_RightShift: XK_Shift_R, 80 | 81 | // special 82 | kVK_Delete: XK_BackSpace, 83 | kVK_Escape: XK_Escape, 84 | kVK_ForwardDelete: XK_Delete, 85 | kVK_Help: XK_Help, 86 | kVK_Return: XK_Return, 87 | kVK_Space: XK_space, 88 | kVK_Tab: XK_Tab, 89 | 90 | // function 91 | kVK_F1: XK_F1, 92 | kVK_F2: XK_F2, 93 | kVK_F3: XK_F3, 94 | kVK_F4: XK_F4, 95 | kVK_F5: XK_F5, 96 | kVK_F6: XK_F6, 97 | kVK_F7: XK_F7, 98 | kVK_F8: XK_F8, 99 | kVK_F9: XK_F9, 100 | kVK_F10: XK_F10, 101 | kVK_F11: XK_F11, 102 | kVK_F12: XK_F12, 103 | kVK_F13: XK_F13, 104 | kVK_F14: XK_F14, 105 | kVK_F15: XK_F15, 106 | kVK_F16: XK_F16, 107 | kVK_F17: XK_F17, 108 | kVK_F18: XK_F18, 109 | kVK_F19: XK_F19, 110 | kVK_F20: XK_F20, 111 | 112 | // cursor 113 | kVK_UpArrow: XK_Up, 114 | kVK_DownArrow: XK_Down, 115 | kVK_LeftArrow: XK_Left, 116 | kVK_RightArrow: XK_Right, 117 | kVK_PageUp: XK_Page_Up, 118 | kVK_PageDown: XK_Page_Down, 119 | kVK_Home: XK_Home, 120 | kVK_End: XK_End, 121 | 122 | // keypad 123 | kVK_ANSI_Keypad0: XK_KP_0, 124 | kVK_ANSI_Keypad1: XK_KP_1, 125 | kVK_ANSI_Keypad2: XK_KP_2, 126 | kVK_ANSI_Keypad3: XK_KP_3, 127 | kVK_ANSI_Keypad4: XK_KP_4, 128 | kVK_ANSI_Keypad5: XK_KP_5, 129 | kVK_ANSI_Keypad6: XK_KP_6, 130 | kVK_ANSI_Keypad7: XK_KP_7, 131 | kVK_ANSI_Keypad8: XK_KP_8, 132 | kVK_ANSI_Keypad9: XK_KP_9, 133 | kVK_ANSI_KeypadClear: XK_Clear, 134 | kVK_ANSI_KeypadDecimal: XK_KP_Decimal, 135 | kVK_ANSI_KeypadEquals: XK_KP_Equal, 136 | kVK_ANSI_KeypadMinus: XK_KP_Subtract, 137 | kVK_ANSI_KeypadMultiply: XK_KP_Multiply, 138 | kVK_ANSI_KeypadPlus: XK_KP_Add, 139 | kVK_ANSI_KeypadDivide: XK_KP_Divide, 140 | kVK_ANSI_KeypadEnter: XK_KP_Enter, 141 | 142 | // other 143 | kVK_ISO_Section: XK_section, 144 | kVK_JIS_Yen: XK_yen, 145 | kVK_JIS_Underscore: XK_underscore, 146 | kVK_JIS_KeypadComma: XK_comma, 147 | kVK_JIS_Eisu: XK_Eisu_Shift, 148 | kVK_JIS_Kana: XK_Kana_Shift 149 | ] 150 | 151 | private static let additionalCodeMappings: [Int: Int32] = [ 152 | // numbers 153 | kVK_ANSI_0: XK_0, 154 | kVK_ANSI_1: XK_1, 155 | kVK_ANSI_2: XK_2, 156 | kVK_ANSI_3: XK_3, 157 | kVK_ANSI_4: XK_4, 158 | kVK_ANSI_5: XK_5, 159 | kVK_ANSI_6: XK_6, 160 | kVK_ANSI_7: XK_7, 161 | kVK_ANSI_8: XK_8, 162 | kVK_ANSI_9: XK_9, 163 | 164 | // pubct 165 | kVK_ANSI_RightBracket: XK_bracketright, 166 | kVK_ANSI_LeftBracket: XK_bracketleft, 167 | kVK_ANSI_Comma: XK_comma, 168 | kVK_ANSI_Grave: XK_grave, 169 | kVK_ANSI_Period: XK_period, 170 | // kVK_VolumeUp: 171 | // kVK_VolumeDown: 172 | // kVK_Mute: 173 | kVK_ANSI_Semicolon: XK_semicolon, 174 | kVK_ANSI_Quote: XK_apostrophe, 175 | kVK_ANSI_Backslash: XK_backslash, 176 | kVK_ANSI_Minus: XK_minus, 177 | kVK_ANSI_Slash: XK_slash, 178 | kVK_ANSI_Equal: XK_equal, 179 | 180 | // letters 181 | kVK_ANSI_A: XK_a, 182 | kVK_ANSI_B: XK_b, 183 | kVK_ANSI_C: XK_c, 184 | kVK_ANSI_D: XK_d, 185 | kVK_ANSI_E: XK_e, 186 | kVK_ANSI_F: XK_f, 187 | kVK_ANSI_G: XK_g, 188 | kVK_ANSI_H: XK_h, 189 | kVK_ANSI_I: XK_i, 190 | kVK_ANSI_J: XK_j, 191 | kVK_ANSI_K: XK_k, 192 | kVK_ANSI_L: XK_l, 193 | kVK_ANSI_M: XK_m, 194 | kVK_ANSI_N: XK_n, 195 | kVK_ANSI_O: XK_o, 196 | kVK_ANSI_P: XK_p, 197 | kVK_ANSI_Q: XK_q, 198 | kVK_ANSI_R: XK_r, 199 | kVK_ANSI_S: XK_s, 200 | kVK_ANSI_T: XK_t, 201 | kVK_ANSI_U: XK_u, 202 | kVK_ANSI_V: XK_v, 203 | kVK_ANSI_W: XK_w, 204 | kVK_ANSI_X: XK_x, 205 | kVK_ANSI_Y: XK_y, 206 | kVK_ANSI_Z: XK_z 207 | ] 208 | } 209 | -------------------------------------------------------------------------------- /sources/Main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Main.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/10/24. 6 | // 7 | 8 | import Foundation 9 | import InputMethodKit 10 | 11 | @main 12 | struct SquirrelApp { 13 | static let userDir = if let pwuid = getpwuid(getuid()) { 14 | URL(fileURLWithFileSystemRepresentation: pwuid.pointee.pw_dir, isDirectory: true, relativeTo: nil).appending(components: "Library", "Rime") 15 | } else { 16 | try! FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("Rime", isDirectory: true) 17 | } 18 | static let appDir = "/Library/Input Library/Squirrel.app".withCString { dir in 19 | URL(fileURLWithFileSystemRepresentation: dir, isDirectory: false, relativeTo: nil) 20 | } 21 | static let logDir = FileManager.default.temporaryDirectory.appending(component: "rime.squirrel", directoryHint: .isDirectory) 22 | 23 | // swiftlint:disable:next cyclomatic_complexity 24 | static func main() { 25 | let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee 26 | 27 | let handled = autoreleasepool { 28 | let installer = SquirrelInstaller() 29 | let args = CommandLine.arguments 30 | if args.count > 1 { 31 | switch args[1] { 32 | case "--quit": 33 | let bundleId = Bundle.main.bundleIdentifier! 34 | let runningSquirrels = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) 35 | runningSquirrels.forEach { $0.terminate() } 36 | return true 37 | case "--reload": 38 | DistributedNotificationCenter.default().postNotificationName(.init("SquirrelReloadNotification"), object: nil) 39 | return true 40 | case "--register-input-source", "--install": 41 | installer.register() 42 | return true 43 | case "--enable-input-source": 44 | if args.count > 2 { 45 | let modes = args[2...].map { SquirrelInstaller.InputMode(rawValue: $0) }.compactMap { $0 } 46 | if !modes.isEmpty { 47 | installer.enable(modes: modes) 48 | return true 49 | } 50 | } 51 | installer.enable() 52 | return true 53 | case "--disable-input-source": 54 | if args.count > 2 { 55 | let modes = args[2...].map { SquirrelInstaller.InputMode(rawValue: $0) }.compactMap { $0 } 56 | if !modes.isEmpty { 57 | installer.disable(modes: modes) 58 | return true 59 | } 60 | } 61 | installer.disable() 62 | return true 63 | case "--select-input-source": 64 | if args.count > 2, let mode = SquirrelInstaller.InputMode(rawValue: args[2]) { 65 | installer.select(mode: mode) 66 | } else { 67 | installer.select() 68 | } 69 | return true 70 | case "--build": 71 | // Notification 72 | SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_update", comment: "")) 73 | // Build all schemas in current directory 74 | var builderTraits = RimeTraits.rimeStructInit() 75 | builderTraits.setCString("rime.squirrel-builder", to: \.app_name) 76 | rimeAPI.setup(&builderTraits) 77 | rimeAPI.deployer_initialize(nil) 78 | _ = rimeAPI.deploy() 79 | return true 80 | case "--sync": 81 | DistributedNotificationCenter.default().postNotificationName(.init("SquirrelSyncNotification"), object: nil) 82 | return true 83 | case "--help": 84 | print(helpDoc) 85 | return true 86 | default: 87 | break 88 | } 89 | } 90 | return false 91 | } 92 | if handled { 93 | return 94 | } 95 | 96 | autoreleasepool { 97 | // find the bundle identifier and then initialize the input method server 98 | let main = Bundle.main 99 | let connectionName = main.object(forInfoDictionaryKey: "InputMethodConnectionName") as! String 100 | _ = IMKServer(name: connectionName, bundleIdentifier: main.bundleIdentifier!) 101 | // load the bundle explicitly because in this case the input method is a 102 | // background only application 103 | let app = NSApplication.shared 104 | let delegate = SquirrelApplicationDelegate() 105 | app.delegate = delegate 106 | app.setActivationPolicy(.accessory) 107 | 108 | // opencc will be configured with relative dictionary paths 109 | FileManager.default.changeCurrentDirectoryPath(main.sharedSupportPath!) 110 | 111 | if NSApp.squirrelAppDelegate.problematicLaunchDetected() { 112 | print("Problematic launch detected!") 113 | let args = ["Problematic launch detected! Squirrel may be suffering a crash due to improper configuration. Revert previous modifications to see if the problem recurs."] 114 | let task = Process() 115 | task.executableURL = "/usr/bin/say".withCString { dir in 116 | URL(fileURLWithFileSystemRepresentation: dir, isDirectory: false, relativeTo: nil) 117 | } 118 | task.arguments = args 119 | try? task.run() 120 | } else { 121 | NSApp.squirrelAppDelegate.setupRime() 122 | NSApp.squirrelAppDelegate.startRime(fullCheck: true) 123 | NSApp.squirrelAppDelegate.loadSettings() 124 | print("Squirrel reporting!") 125 | } 126 | 127 | // finally run everything 128 | app.run() 129 | print("Squirrel is quitting...") 130 | rimeAPI.finalize() 131 | } 132 | return 133 | } 134 | 135 | static let helpDoc = """ 136 | Supported arguments: 137 | Perform actions: 138 | --quit quit all Squirrel process 139 | --reload deploy 140 | --sync sync user data 141 | --build build all schemas in current directory 142 | Install Squirrel: 143 | --install, --register-input-source register input source 144 | --enable-input-source [source id...] input source list optional 145 | --disable-input-source [source id...] input source list optional 146 | --select-input-source [source id] input source optional 147 | """ 148 | } 149 | -------------------------------------------------------------------------------- /sources/Squirrel-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to 3 | // expose to Swift. 4 | // 5 | 6 | #import 7 | #import 8 | -------------------------------------------------------------------------------- /sources/SquirrelApplicationDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquirrelApplicationDelegate.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/6/24. 6 | // 7 | 8 | import UserNotifications 9 | import Sparkle 10 | import AppKit 11 | 12 | final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate { 13 | static let rimeWikiURL = URL(string: "https://github.com/rime/home/wiki")! 14 | static let updateNotificationIdentifier = "SquirrelUpdateNotification" 15 | static let notificationIdentifier = "SquirrelNotification" 16 | 17 | let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee 18 | var config: SquirrelConfig? 19 | var panel: SquirrelPanel? 20 | var enableNotifications = false 21 | let updateController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) 22 | var supportsGentleScheduledUpdateReminders: Bool { 23 | true 24 | } 25 | 26 | func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) { 27 | NSApp.setActivationPolicy(.regular) 28 | if !state.userInitiated { 29 | NSApp.dockTile.badgeLabel = "1" 30 | let content = UNMutableNotificationContent() 31 | content.title = NSLocalizedString("A new update is available", comment: "Update") 32 | content.body = NSLocalizedString("Version [version] is now available", comment: "Update").replacingOccurrences(of: "[version]", with: update.displayVersionString) 33 | let request = UNNotificationRequest(identifier: Self.updateNotificationIdentifier, content: content, trigger: nil) 34 | UNUserNotificationCenter.current().add(request) 35 | } 36 | } 37 | 38 | func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { 39 | NSApp.dockTile.badgeLabel = "" 40 | UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [Self.updateNotificationIdentifier]) 41 | } 42 | 43 | func standardUserDriverWillFinishUpdateSession() { 44 | NSApp.setActivationPolicy(.accessory) 45 | } 46 | 47 | func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { 48 | if response.notification.request.identifier == Self.updateNotificationIdentifier && response.actionIdentifier == UNNotificationDefaultActionIdentifier { 49 | updateController.updater.checkForUpdates() 50 | } 51 | 52 | completionHandler() 53 | } 54 | 55 | func applicationWillFinishLaunching(_ notification: Notification) { 56 | panel = SquirrelPanel(position: .zero) 57 | addObservers() 58 | } 59 | 60 | func applicationWillTerminate(_ notification: Notification) { 61 | // swiftlint:disable:next notification_center_detachment 62 | NotificationCenter.default.removeObserver(self) 63 | DistributedNotificationCenter.default().removeObserver(self) 64 | panel?.hide() 65 | } 66 | 67 | func deploy() { 68 | print("Start maintenance...") 69 | self.shutdownRime() 70 | self.startRime(fullCheck: true) 71 | self.loadSettings() 72 | } 73 | 74 | func syncUserData() { 75 | print("Sync user data") 76 | _ = rimeAPI.sync_user_data() 77 | } 78 | 79 | func openLogFolder() { 80 | NSWorkspace.shared.open(SquirrelApp.logDir) 81 | } 82 | 83 | func openRimeFolder() { 84 | NSWorkspace.shared.open(SquirrelApp.userDir) 85 | } 86 | 87 | func checkForUpdates() { 88 | if updateController.updater.canCheckForUpdates { 89 | print("Checking for updates") 90 | updateController.updater.checkForUpdates() 91 | } else { 92 | print("Cannot check for updates") 93 | } 94 | } 95 | 96 | func openWiki() { 97 | NSWorkspace.shared.open(Self.rimeWikiURL) 98 | } 99 | 100 | static func showMessage(msgText: String?) { 101 | let center = UNUserNotificationCenter.current() 102 | center.requestAuthorization(options: [.alert, .provisional]) { _, error in 103 | if let error = error { 104 | print("User notification authorization error: \(error.localizedDescription)") 105 | } 106 | } 107 | center.getNotificationSettings { settings in 108 | if (settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional) && settings.alertSetting == .enabled { 109 | let content = UNMutableNotificationContent() 110 | content.title = NSLocalizedString("Squirrel", comment: "") 111 | if let msgText = msgText { 112 | content.subtitle = msgText 113 | } 114 | content.interruptionLevel = .active 115 | let request = UNNotificationRequest(identifier: Self.notificationIdentifier, content: content, trigger: nil) 116 | center.add(request) { error in 117 | if let error = error { 118 | print("User notification request error: \(error.localizedDescription)") 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | func setupRime() { 126 | createDirIfNotExist(path: SquirrelApp.userDir) 127 | createDirIfNotExist(path: SquirrelApp.logDir) 128 | // swiftlint:disable identifier_name 129 | let notification_handler: @convention(c) (UnsafeMutableRawPointer?, RimeSessionId, UnsafePointer?, UnsafePointer?) -> Void = notificationHandler 130 | let context_object = Unmanaged.passUnretained(self).toOpaque() 131 | // swiftlint:enable identifier_name 132 | rimeAPI.set_notification_handler(notification_handler, context_object) 133 | 134 | var squirrelTraits = RimeTraits.rimeStructInit() 135 | squirrelTraits.setCString(Bundle.main.sharedSupportPath!, to: \.shared_data_dir) 136 | squirrelTraits.setCString(SquirrelApp.userDir.path(), to: \.user_data_dir) 137 | squirrelTraits.setCString(SquirrelApp.logDir.path(), to: \.log_dir) 138 | squirrelTraits.setCString("Squirrel", to: \.distribution_code_name) 139 | squirrelTraits.setCString("鼠鬚管", to: \.distribution_name) 140 | squirrelTraits.setCString(Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String, to: \.distribution_version) 141 | squirrelTraits.setCString("rime.squirrel", to: \.app_name) 142 | rimeAPI.setup(&squirrelTraits) 143 | } 144 | 145 | func startRime(fullCheck: Bool) { 146 | print("Initializing la rime...") 147 | rimeAPI.initialize(nil) 148 | // check for configuration updates 149 | if rimeAPI.start_maintenance(fullCheck) { 150 | // update squirrel config 151 | // print("[DEBUG] maintenance suceeds") 152 | _ = rimeAPI.deploy_config_file("squirrel.yaml", "config_version") 153 | } else { 154 | // print("[DEBUG] maintenance fails") 155 | } 156 | } 157 | 158 | func loadSettings() { 159 | config = SquirrelConfig() 160 | if !config!.openBaseConfig() { 161 | return 162 | } 163 | 164 | enableNotifications = config!.getString("show_notifications_when") != "never" 165 | if let panel = panel, let config = self.config { 166 | panel.load(config: config, forDarkMode: false) 167 | panel.load(config: config, forDarkMode: true) 168 | } 169 | } 170 | 171 | func loadSettings(for schemaID: String) { 172 | if schemaID.count == 0 || schemaID.first == "." { 173 | return 174 | } 175 | let schema = SquirrelConfig() 176 | if let panel = panel, let config = self.config { 177 | if schema.open(schemaID: schemaID, baseConfig: config) && schema.has(section: "style") { 178 | panel.load(config: schema, forDarkMode: false) 179 | panel.load(config: schema, forDarkMode: true) 180 | } else { 181 | panel.load(config: config, forDarkMode: false) 182 | panel.load(config: config, forDarkMode: true) 183 | } 184 | } 185 | schema.close() 186 | } 187 | 188 | // prevent freezing the system 189 | func problematicLaunchDetected() -> Bool { 190 | var detected = false 191 | let logFile = FileManager.default.temporaryDirectory.appendingPathComponent("squirrel_launch.json", conformingTo: .json) 192 | // print("[DEBUG] archive: \(logFile)") 193 | do { 194 | let archive = try Data(contentsOf: logFile, options: [.uncached]) 195 | let decoder = JSONDecoder() 196 | decoder.dateDecodingStrategy = .millisecondsSince1970 197 | let previousLaunch = try decoder.decode(Date.self, from: archive) 198 | if previousLaunch.timeIntervalSinceNow >= -2 { 199 | detected = true 200 | } 201 | } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { 202 | 203 | } catch { 204 | print("Error occurred during processing launch time archive: \(error.localizedDescription)") 205 | return detected 206 | } 207 | do { 208 | let encoder = JSONEncoder() 209 | encoder.dateEncodingStrategy = .millisecondsSince1970 210 | let record = try encoder.encode(Date.now) 211 | try record.write(to: logFile) 212 | } catch { 213 | print("Error occurred during saving launch time to archive: \(error.localizedDescription)") 214 | } 215 | return detected 216 | } 217 | 218 | // add an awakeFromNib item so that we can set the action method. Note that 219 | // any menuItems without an action will be disabled when displayed in the Text 220 | // Input Menu. 221 | func addObservers() { 222 | let center = NSWorkspace.shared.notificationCenter 223 | center.addObserver(forName: NSWorkspace.willPowerOffNotification, object: nil, queue: nil, using: workspaceWillPowerOff) 224 | 225 | let notifCenter = DistributedNotificationCenter.default() 226 | notifCenter.addObserver(forName: .init("SquirrelReloadNotification"), object: nil, queue: nil, using: rimeNeedsReload) 227 | notifCenter.addObserver(forName: .init("SquirrelSyncNotification"), object: nil, queue: nil, using: rimeNeedsSync) 228 | } 229 | 230 | func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { 231 | print("Squirrel is quitting.") 232 | rimeAPI.cleanup_all_sessions() 233 | return .terminateNow 234 | } 235 | 236 | } 237 | 238 | private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessionId: RimeSessionId, messageTypeC: UnsafePointer?, messageValueC: UnsafePointer?) { 239 | let delegate: SquirrelApplicationDelegate = Unmanaged.fromOpaque(contextObject!).takeUnretainedValue() 240 | 241 | let messageType = messageTypeC.map { String(cString: $0) } 242 | let messageValue = messageValueC.map { String(cString: $0) } 243 | if messageType == "deploy" { 244 | switch messageValue { 245 | case "start": 246 | SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_start", comment: "")) 247 | case "success": 248 | SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_success", comment: "")) 249 | case "failure": 250 | SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_failure", comment: "")) 251 | default: 252 | break 253 | } 254 | return 255 | } 256 | // off 257 | if !delegate.enableNotifications { 258 | return 259 | } 260 | 261 | if messageType == "schema", let messageValue = messageValue, let schemaName = try? /^[^\/]*\/(.*)$/.firstMatch(in: messageValue)?.output.1 { 262 | delegate.showStatusMessage(msgTextLong: String(schemaName), msgTextShort: String(schemaName)) 263 | return 264 | } else if messageType == "option" { 265 | let state = messageValue?.first != "!" 266 | let optionName = if state { 267 | messageValue 268 | } else { 269 | String(messageValue![messageValue!.index(after: messageValue!.startIndex)...]) 270 | } 271 | if let optionName = optionName { 272 | optionName.withCString { name in 273 | let stateLabelLong = delegate.rimeAPI.get_state_label_abbreviated(sessionId, name, state, false) 274 | let stateLabelShort = delegate.rimeAPI.get_state_label_abbreviated(sessionId, name, state, true) 275 | let longLabel = stateLabelLong.str.map { String(cString: $0) } 276 | let shortLabel = stateLabelShort.str.map { String(cString: $0) } 277 | delegate.showStatusMessage(msgTextLong: longLabel, msgTextShort: shortLabel) 278 | } 279 | } 280 | } 281 | } 282 | 283 | private extension SquirrelApplicationDelegate { 284 | func showStatusMessage(msgTextLong: String?, msgTextShort: String?) { 285 | if !(msgTextLong ?? "").isEmpty || !(msgTextShort ?? "").isEmpty { 286 | panel?.updateStatus(long: msgTextLong ?? "", short: msgTextShort ?? "") 287 | } 288 | } 289 | 290 | func shutdownRime() { 291 | config?.close() 292 | rimeAPI.finalize() 293 | } 294 | 295 | func workspaceWillPowerOff(_: Notification) { 296 | print("Finalizing before logging out.") 297 | self.shutdownRime() 298 | } 299 | 300 | func rimeNeedsReload(_: Notification) { 301 | print("Reloading rime on demand.") 302 | self.deploy() 303 | } 304 | 305 | func rimeNeedsSync(_: Notification) { 306 | print("Sync rime on demand.") 307 | self.syncUserData() 308 | } 309 | 310 | func createDirIfNotExist(path: URL) { 311 | let fileManager = FileManager.default 312 | if !fileManager.fileExists(atPath: path.path()) { 313 | do { 314 | try fileManager.createDirectory(at: path, withIntermediateDirectories: true) 315 | } catch { 316 | print("Error creating user data directory: \(path.path())") 317 | } 318 | } 319 | } 320 | } 321 | 322 | extension NSApplication { 323 | var squirrelAppDelegate: SquirrelApplicationDelegate { 324 | self.delegate as! SquirrelApplicationDelegate 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /sources/SquirrelConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquirrelConfig.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/9/24. 6 | // 7 | 8 | import AppKit 9 | 10 | final class SquirrelConfig { 11 | private let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee 12 | private(set) var isOpen = false 13 | 14 | private var cache: [String: Any] = [:] 15 | private var config: RimeConfig = .init() 16 | private var baseConfig: SquirrelConfig? 17 | 18 | func openBaseConfig() -> Bool { 19 | close() 20 | isOpen = rimeAPI.config_open("squirrel", &config) 21 | return isOpen 22 | } 23 | 24 | func open(schemaID: String, baseConfig: SquirrelConfig?) -> Bool { 25 | close() 26 | isOpen = rimeAPI.schema_open(schemaID, &config) 27 | if isOpen { 28 | self.baseConfig = baseConfig 29 | } 30 | return isOpen 31 | } 32 | 33 | func close() { 34 | if isOpen { 35 | _ = rimeAPI.config_close(&config) 36 | baseConfig = nil 37 | isOpen = false 38 | } 39 | } 40 | 41 | deinit { 42 | close() 43 | } 44 | 45 | func has(section: String) -> Bool { 46 | if isOpen { 47 | var iterator: RimeConfigIterator = .init() 48 | if rimeAPI.config_begin_map(&iterator, &config, section) { 49 | rimeAPI.config_end(&iterator) 50 | return true 51 | } 52 | } 53 | return false 54 | } 55 | 56 | func getBool(_ option: String) -> Bool? { 57 | if let cachedValue = cachedValue(of: Bool.self, forKey: option) { 58 | return cachedValue 59 | } 60 | var value = false 61 | if isOpen && rimeAPI.config_get_bool(&config, option, &value) { 62 | cache[option] = value 63 | return value 64 | } 65 | return baseConfig?.getBool(option) 66 | } 67 | 68 | func getDouble(_ option: String) -> CGFloat? { 69 | if let cachedValue = cachedValue(of: Double.self, forKey: option) { 70 | return cachedValue 71 | } 72 | var value: Double = 0 73 | if isOpen && rimeAPI.config_get_double(&config, option, &value) { 74 | cache[option] = value 75 | return value 76 | } 77 | return baseConfig?.getDouble(option) 78 | } 79 | 80 | func getString(_ option: String) -> String? { 81 | if let cachedValue = cachedValue(of: String.self, forKey: option) { 82 | return cachedValue 83 | } 84 | if isOpen, let value = rimeAPI.config_get_cstring(&config, option) { 85 | cache[option] = String(cString: value) 86 | return String(cString: value) 87 | } 88 | return baseConfig?.getString(option) 89 | } 90 | 91 | func getColor(_ option: String, inSpace colorSpace: SquirrelTheme.RimeColorSpace) -> NSColor? { 92 | if let cachedValue = cachedValue(of: NSColor.self, forKey: option) { 93 | return cachedValue 94 | } 95 | if let colorStr = getString(option), let color = color(from: colorStr, inSpace: colorSpace) { 96 | cache[option] = color 97 | return color 98 | } 99 | return baseConfig?.getColor(option, inSpace: colorSpace) 100 | } 101 | 102 | func getAppOptions(_ appName: String) -> [String: Bool] { 103 | let rootKey = "app_options/\(appName)" 104 | var appOptions = [String: Bool]() 105 | var iterator = RimeConfigIterator() 106 | _ = rimeAPI.config_begin_map(&iterator, &config, rootKey) 107 | while rimeAPI.config_next(&iterator) { 108 | // print("[DEBUG] option[\(iterator.index)]: \(String(cString: iterator.key)), path: (\(String(cString: iterator.path))") 109 | if let key = iterator.key, let path = iterator.path, let value = getBool(String(cString: path)) { 110 | appOptions[String(cString: key)] = value 111 | } 112 | } 113 | rimeAPI.config_end(&iterator) 114 | return appOptions 115 | } 116 | } 117 | 118 | private extension SquirrelConfig { 119 | func cachedValue(of: T.Type, forKey key: String) -> T? { 120 | return cache[key] as? T 121 | } 122 | 123 | func color(from colorStr: String, inSpace colorSpace: SquirrelTheme.RimeColorSpace) -> NSColor? { 124 | if let matched = try? /0x([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})/.wholeMatch(in: colorStr) { 125 | let (_, alpha, blue, green, red) = matched.output 126 | return color(alpha: Int(alpha, radix: 16)!, red: Int(red, radix: 16)!, green: Int(green, radix: 16)!, blue: Int(blue, radix: 16)!, colorSpace: colorSpace) 127 | } else if let matched = try? /0x([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})/.wholeMatch(in: colorStr) { 128 | let (_, blue, green, red) = matched.output 129 | return color(alpha: 255, red: Int(red, radix: 16)!, green: Int(green, radix: 16)!, blue: Int(blue, radix: 16)!, colorSpace: colorSpace) 130 | } else { 131 | return nil 132 | } 133 | } 134 | 135 | func color(alpha: Int, red: Int, green: Int, blue: Int, colorSpace: SquirrelTheme.RimeColorSpace) -> NSColor { 136 | switch colorSpace { 137 | case .displayP3: 138 | return NSColor(displayP3Red: CGFloat(red) / 255, 139 | green: CGFloat(green) / 255, 140 | blue: CGFloat(blue) / 255, 141 | alpha: CGFloat(alpha) / 255) 142 | case .sRGB: 143 | return NSColor(srgbRed: CGFloat(red) / 255, 144 | green: CGFloat(green) / 255, 145 | blue: CGFloat(blue) / 255, 146 | alpha: CGFloat(alpha) / 255) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /sources/SquirrelInputController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquirrelInputController.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/7/24. 6 | // 7 | 8 | import InputMethodKit 9 | 10 | final class SquirrelInputController: IMKInputController { 11 | private static let keyRollOver = 50 12 | 13 | private weak var client: IMKTextInput? 14 | private let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee 15 | private var preedit: String = "" 16 | private var selRange: NSRange = .empty 17 | private var caretPos: Int = 0 18 | private var lastModifiers: NSEvent.ModifierFlags = .init() 19 | private var session: RimeSessionId = 0 20 | private var schemaId: String = "" 21 | private var inlinePreedit = false 22 | private var inlineCandidate = false 23 | // for chord-typing 24 | private var chordKeyCodes: [UInt32] = .init(repeating: 0, count: SquirrelInputController.keyRollOver) 25 | private var chordModifiers: [UInt32] = .init(repeating: 0, count: SquirrelInputController.keyRollOver) 26 | private var chordKeyCount: Int = 0 27 | private var chordTimer: Timer? 28 | private var chordDuration: TimeInterval = 0 29 | private var currentApp: String = "" 30 | 31 | // swiftlint:disable:next cyclomatic_complexity 32 | override func handle(_ event: NSEvent!, client sender: Any!) -> Bool { 33 | let modifiers = event.modifierFlags 34 | let changes = lastModifiers.symmetricDifference(modifiers) 35 | 36 | // Return true to indicate the the key input was received and dealt with. 37 | // Key processing will not continue in that case. In other words the 38 | // system will not deliver a key down event to the application. 39 | // Returning false means the original key down will be passed on to the client. 40 | var handled = false 41 | 42 | if session == 0 || !rimeAPI.find_session(session) { 43 | createSession() 44 | if session == 0 { 45 | return false 46 | } 47 | } 48 | 49 | self.client ?= sender as? IMKTextInput 50 | if let app = client?.bundleIdentifier(), currentApp != app { 51 | currentApp = app 52 | updateAppOptions() 53 | } 54 | 55 | switch event.type { 56 | case .flagsChanged: 57 | if lastModifiers == modifiers { 58 | handled = true 59 | break 60 | } 61 | // print("[DEBUG] FLAGSCHANGED client: \(sender ?? "nil"), modifiers: \(modifiers)") 62 | var rimeModifiers: UInt32 = SquirrelKeycode.osxModifiersToRime(modifiers: modifiers) 63 | // For flags-changed event, keyCode is available since macOS 10.15 64 | // (#715) 65 | let rimeKeycode: UInt32 = SquirrelKeycode.osxKeycodeToRime(keycode: event.keyCode, keychar: nil, shift: false, caps: false) 66 | 67 | if changes.contains(.capsLock) { 68 | // NOTE: rime assumes XK_Caps_Lock to be sent before modifier changes, 69 | // while NSFlagsChanged event has the flag changed already. 70 | // so it is necessary to revert kLockMask. 71 | rimeModifiers ^= kLockMask.rawValue 72 | _ = processKey(rimeKeycode, modifiers: rimeModifiers) 73 | } 74 | 75 | // Need to process release before modifier down. Because 76 | // sometimes release event is delayed to next modifier keydown. 77 | var buffer = [(keycode: UInt32, modifier: UInt32)]() 78 | for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] where changes.contains(flag) { 79 | if modifiers.contains(flag) { // New modifier 80 | buffer.append((keycode: rimeKeycode, modifier: rimeModifiers)) 81 | } else { // Release 82 | buffer.insert((keycode: rimeKeycode, modifier: rimeModifiers | kReleaseMask.rawValue), at: 0) 83 | } 84 | } 85 | for (keycode, modifier) in buffer { 86 | _ = processKey(keycode, modifiers: modifier) 87 | } 88 | 89 | lastModifiers = modifiers 90 | rimeUpdate() 91 | 92 | case .keyDown: 93 | // ignore Command+X hotkeys. 94 | if modifiers.contains(.command) { 95 | break 96 | } 97 | 98 | let keyCode = event.keyCode 99 | var keyChars = event.charactersIgnoringModifiers 100 | let capitalModifiers = modifiers.isSubset(of: [.shift, .capsLock]) 101 | if let code = keyChars?.first, 102 | (capitalModifiers && !code.isLetter) || (!capitalModifiers && !code.isASCII) { 103 | keyChars = event.characters 104 | } 105 | // print("[DEBUG] KEYDOWN client: \(sender ?? "nil"), modifiers: \(modifiers), keyCode: \(keyCode), keyChars: [\(keyChars ?? "empty")]") 106 | 107 | // translate osx keyevents to rime keyevents 108 | if let char = keyChars?.first { 109 | let rimeKeycode = SquirrelKeycode.osxKeycodeToRime(keycode: keyCode, keychar: char, 110 | shift: modifiers.contains(.shift), 111 | caps: modifiers.contains(.capsLock)) 112 | if rimeKeycode != 0 { 113 | let rimeModifiers = SquirrelKeycode.osxModifiersToRime(modifiers: modifiers) 114 | handled = processKey(rimeKeycode, modifiers: rimeModifiers) 115 | rimeUpdate() 116 | } 117 | } 118 | 119 | default: 120 | break 121 | } 122 | 123 | return handled 124 | } 125 | 126 | func selectCandidate(_ index: Int) -> Bool { 127 | let success = rimeAPI.select_candidate_on_current_page(session, index) 128 | if success { 129 | rimeUpdate() 130 | } 131 | return success 132 | } 133 | 134 | // swiftlint:disable:next identifier_name 135 | func page(up: Bool) -> Bool { 136 | var handled = false 137 | handled = rimeAPI.change_page(session, up) 138 | if handled { 139 | rimeUpdate() 140 | } 141 | return handled 142 | } 143 | 144 | func moveCaret(forward: Bool) -> Bool { 145 | let currentCaretPos = rimeAPI.get_caret_pos(session) 146 | guard let input = rimeAPI.get_input(session) else { return false } 147 | if forward { 148 | if currentCaretPos <= 0 { 149 | return false 150 | } 151 | rimeAPI.set_caret_pos(session, currentCaretPos - 1) 152 | } else { 153 | let inputStr = String(cString: input) 154 | if currentCaretPos >= inputStr.utf8.count { 155 | return false 156 | } 157 | rimeAPI.set_caret_pos(session, currentCaretPos + 1) 158 | } 159 | rimeUpdate() 160 | return true 161 | } 162 | 163 | override func recognizedEvents(_ sender: Any!) -> Int { 164 | // print("[DEBUG] recognizedEvents:") 165 | return Int(NSEvent.EventTypeMask.Element(arrayLiteral: .keyDown, .flagsChanged).rawValue) 166 | } 167 | 168 | override func activateServer(_ sender: Any!) { 169 | self.client ?= sender as? IMKTextInput 170 | // print("[DEBUG] activateServer:") 171 | var keyboardLayout = NSApp.squirrelAppDelegate.config?.getString("keyboard_layout") ?? "" 172 | if keyboardLayout == "last" || keyboardLayout == "" { 173 | keyboardLayout = "" 174 | } else if keyboardLayout == "default" { 175 | keyboardLayout = "com.apple.keylayout.ABC" 176 | } else if !keyboardLayout.hasPrefix("com.apple.keylayout.") { 177 | keyboardLayout = "com.apple.keylayout.\(keyboardLayout)" 178 | } 179 | if keyboardLayout != "" { 180 | client?.overrideKeyboard(withKeyboardNamed: keyboardLayout) 181 | } 182 | preedit = "" 183 | } 184 | 185 | override init!(server: IMKServer!, delegate: Any!, client: Any!) { 186 | self.client = client as? IMKTextInput 187 | // print("[DEBUG] initWithServer: \(server ?? .init()) delegate: \(delegate ?? "nil") client:\(client ?? "nil")") 188 | super.init(server: server, delegate: delegate, client: client) 189 | createSession() 190 | } 191 | 192 | override func deactivateServer(_ sender: Any!) { 193 | // print("[DEBUG] deactivateServer: \(sender ?? "nil")") 194 | hidePalettes() 195 | commitComposition(sender) 196 | client = nil 197 | } 198 | 199 | override func hidePalettes() { 200 | NSApp.squirrelAppDelegate.panel?.hide() 201 | super.hidePalettes() 202 | } 203 | 204 | /*! 205 | @method 206 | @abstract Called when a user action was taken that ends an input session. 207 | Typically triggered by the user selecting a new input method 208 | or keyboard layout. 209 | @discussion When this method is called your controller should send the 210 | current input buffer to the client via a call to 211 | insertText:replacementRange:. Additionally, this is the time 212 | to clean up if that is necessary. 213 | */ 214 | override func commitComposition(_ sender: Any!) { 215 | self.client ?= sender as? IMKTextInput 216 | // print("[DEBUG] commitComposition: \(sender ?? "nil")") 217 | // commit raw input 218 | if session != 0 { 219 | if let input = rimeAPI.get_input(session) { 220 | commit(string: String(cString: input)) 221 | rimeAPI.clear_composition(session) 222 | } 223 | } 224 | } 225 | 226 | override func menu() -> NSMenu! { 227 | let deploy = NSMenuItem(title: NSLocalizedString("Deploy", comment: "Menu item"), action: #selector(deploy), keyEquivalent: "`") 228 | deploy.target = self 229 | deploy.keyEquivalentModifierMask = [.control, .option] 230 | let sync = NSMenuItem(title: NSLocalizedString("Sync user data", comment: "Menu item"), action: #selector(syncUserData), keyEquivalent: "") 231 | sync.target = self 232 | let logDir = NSMenuItem(title: NSLocalizedString("Logs...", comment: "Menu item"), action: #selector(openLogFolder), keyEquivalent: "") 233 | logDir.target = self 234 | let setting = NSMenuItem(title: NSLocalizedString("Settings...", comment: "Menu item"), action: #selector(openRimeFolder), keyEquivalent: "") 235 | setting.target = self 236 | let wiki = NSMenuItem(title: NSLocalizedString("Rime Wiki...", comment: "Menu item"), action: #selector(openWiki), keyEquivalent: "") 237 | wiki.target = self 238 | let update = NSMenuItem(title: NSLocalizedString("Check for updates...", comment: "Menu item"), action: #selector(checkForUpdates), keyEquivalent: "") 239 | update.target = self 240 | 241 | let menu = NSMenu() 242 | menu.addItem(deploy) 243 | menu.addItem(sync) 244 | menu.addItem(logDir) 245 | menu.addItem(setting) 246 | menu.addItem(wiki) 247 | menu.addItem(update) 248 | 249 | return menu 250 | } 251 | 252 | @objc func deploy() { 253 | NSApp.squirrelAppDelegate.deploy() 254 | } 255 | 256 | @objc func syncUserData() { 257 | NSApp.squirrelAppDelegate.syncUserData() 258 | } 259 | 260 | @objc func openLogFolder() { 261 | NSApp.squirrelAppDelegate.openLogFolder() 262 | } 263 | 264 | @objc func openRimeFolder() { 265 | NSApp.squirrelAppDelegate.openRimeFolder() 266 | } 267 | 268 | @objc func checkForUpdates() { 269 | NSApp.squirrelAppDelegate.checkForUpdates() 270 | } 271 | 272 | @objc func openWiki() { 273 | NSApp.squirrelAppDelegate.openWiki() 274 | } 275 | 276 | deinit { 277 | destroySession() 278 | } 279 | } 280 | 281 | private extension SquirrelInputController { 282 | 283 | func onChordTimer(_: Timer) { 284 | // chord release triggered by timer 285 | var processedKeys = false 286 | if chordKeyCount > 0 && session != 0 { 287 | // simulate key-ups 288 | for i in 0..= Self.keyRollOver { 307 | // you are cheating. only one human typist (fingers <= 10) is supported. 308 | return 309 | } 310 | chordKeyCodes[chordKeyCount] = keycode 311 | chordModifiers[chordKeyCount] = modifiers 312 | chordKeyCount += 1 313 | // reset timer 314 | if let timer = chordTimer, timer.isValid { 315 | timer.invalidate() 316 | } 317 | chordDuration = 0.1 318 | if let duration = NSApp.squirrelAppDelegate.config?.getDouble("chord_duration"), duration > 0 { 319 | chordDuration = duration 320 | } 321 | chordTimer = Timer.scheduledTimer(withTimeInterval: chordDuration, repeats: false, block: onChordTimer) 322 | } 323 | 324 | func clearChord() { 325 | chordKeyCount = 0 326 | if let timer = chordTimer { 327 | if timer.isValid { 328 | timer.invalidate() 329 | } 330 | chordTimer = nil 331 | } 332 | } 333 | 334 | func createSession() { 335 | guard let app = client?.bundleIdentifier() else { return } 336 | print("createSession: \(app)") 337 | currentApp = app 338 | session = rimeAPI.create_session() 339 | schemaId = "" 340 | 341 | if session != 0 { 342 | updateAppOptions() 343 | } 344 | } 345 | 346 | func updateAppOptions() { 347 | if currentApp == "" { 348 | return 349 | } 350 | if let appOptions = NSApp.squirrelAppDelegate.config?.getAppOptions(currentApp) { 351 | for (key, value) in appOptions { 352 | print("set app option: \(key) = \(value)") 353 | rimeAPI.set_option(session, key, value) 354 | } 355 | } 356 | } 357 | 358 | func destroySession() { 359 | // print("[DEBUG] destroySession:") 360 | if session != 0 { 361 | _ = rimeAPI.destroy_session(session) 362 | session = 0 363 | } 364 | clearChord() 365 | } 366 | 367 | func processKey(_ rimeKeycode: UInt32, modifiers rimeModifiers: UInt32) -> Bool { 368 | // TODO add special key event preprocessing here 369 | 370 | // with linear candidate list, arrow keys may behave differently. 371 | if let panel = NSApp.squirrelAppDelegate.panel { 372 | if panel.linear != rimeAPI.get_option(session, "_linear") { 373 | rimeAPI.set_option(session, "_linear", panel.linear) 374 | } 375 | // with vertical text, arrow keys may behave differently. 376 | if panel.vertical != rimeAPI.get_option(session, "_vertical") { 377 | rimeAPI.set_option(session, "_vertical", panel.vertical) 378 | } 379 | } 380 | 381 | let handled = rimeAPI.process_key(session, Int32(rimeKeycode), Int32(rimeModifiers)) 382 | // print("[DEBUG] rime_keycode: \(rimeKeycode), rime_modifiers: \(rimeModifiers), handled = \(handled)") 383 | 384 | // TODO add special key event postprocessing here 385 | 386 | if !handled { 387 | let isVimBackInCommandMode = rimeKeycode == XK_Escape || ((rimeModifiers & kControlMask.rawValue != 0) && (rimeKeycode == XK_c || rimeKeycode == XK_C || rimeKeycode == XK_bracketleft)) 388 | if isVimBackInCommandMode && rimeAPI.get_option(session, "vim_mode") && 389 | !rimeAPI.get_option(session, "ascii_mode") { 390 | rimeAPI.set_option(session, "ascii_mode", true) 391 | // print("[DEBUG] turned Chinese mode off in vim-like editor's command mode") 392 | } 393 | } else { 394 | let isChordingKey = switch Int32(rimeKeycode) { 395 | case XK_space...XK_asciitilde, XK_Control_L, XK_Control_R, XK_Alt_L, XK_Alt_R, XK_Shift_L, XK_Shift_R: 396 | true 397 | default: 398 | false 399 | } 400 | if isChordingKey && rimeAPI.get_option(session, "_chord_typing") { 401 | updateChord(keycode: rimeKeycode, modifiers: rimeModifiers) 402 | } else if (rimeModifiers & kReleaseMask.rawValue) == 0 { 403 | // non-chording key pressed 404 | clearChord() 405 | } 406 | } 407 | 408 | return handled 409 | } 410 | 411 | func rimeConsumeCommittedText() { 412 | var commitText = RimeCommit.rimeStructInit() 413 | if rimeAPI.get_commit(session, &commitText) { 414 | if let text = commitText.text { 415 | commit(string: String(cString: text)) 416 | } 417 | _ = rimeAPI.free_commit(&commitText) 418 | } 419 | } 420 | 421 | // swiftlint:disable:next cyclomatic_complexity 422 | func rimeUpdate() { 423 | // print("[DEBUG] rimeUpdate") 424 | rimeConsumeCommittedText() 425 | 426 | var status = RimeStatus_stdbool.rimeStructInit() 427 | if rimeAPI.get_status(session, &status) { 428 | // enable schema specific ui style 429 | // swiftlint:disable:next identifier_name 430 | if let schema_id = status.schema_id, schemaId == "" || schemaId != String(cString: schema_id) { 431 | schemaId = String(cString: schema_id) 432 | NSApp.squirrelAppDelegate.loadSettings(for: schemaId) 433 | // inline preedit 434 | if let panel = NSApp.squirrelAppDelegate.panel { 435 | inlinePreedit = (panel.inlinePreedit && !rimeAPI.get_option(session, "no_inline")) || rimeAPI.get_option(session, "inline") 436 | inlineCandidate = panel.inlineCandidate && !rimeAPI.get_option(session, "no_inline") 437 | // if not inline, embed soft cursor in preedit string 438 | rimeAPI.set_option(session, "soft_cursor", !inlinePreedit) 439 | } 440 | } 441 | _ = rimeAPI.free_status(&status) 442 | } 443 | 444 | var ctx = RimeContext_stdbool.rimeStructInit() 445 | if rimeAPI.get_context(session, &ctx) { 446 | // update preedit text 447 | let preedit = ctx.composition.preedit.map({ String(cString: $0) }) ?? "" 448 | 449 | let start = String.Index(preedit.utf8.index(preedit.utf8.startIndex, offsetBy: Int(ctx.composition.sel_start)), within: preedit) ?? preedit.startIndex 450 | let end = String.Index(preedit.utf8.index(preedit.utf8.startIndex, offsetBy: Int(ctx.composition.sel_end)), within: preedit) ?? preedit.startIndex 451 | let caretPos = String.Index(preedit.utf8.index(preedit.utf8.startIndex, offsetBy: Int(ctx.composition.cursor_pos)), within: preedit) ?? preedit.startIndex 452 | 453 | if inlineCandidate { 454 | var candidatePreview = ctx.commit_text_preview.map { String(cString: $0) } ?? "" 455 | if inlinePreedit { 456 | if caretPos >= end && caretPos < preedit.endIndex { 457 | candidatePreview += preedit[caretPos...] 458 | } 459 | show(preedit: candidatePreview, 460 | selRange: NSRange(location: start.utf16Offset(in: candidatePreview), length: candidatePreview.utf16.distance(from: start, to: candidatePreview.endIndex)), 461 | caretPos: candidatePreview.utf16.count - max(0, preedit.utf16.distance(from: caretPos, to: preedit.endIndex))) 462 | } else { 463 | if end < caretPos && start < caretPos { 464 | candidatePreview = String(candidatePreview[.. 0 { 540 | let attrs = mark(forStyle: kTSMHiliteConvertedText, at: NSRange(location: 0, length: start))! as! [NSAttributedString.Key: Any] 541 | attrString.setAttributes(attrs, range: NSRange(location: 0, length: start)) 542 | } 543 | let remainingRange = NSRange(location: start, length: preedit.utf16.count - start) 544 | let attrs = mark(forStyle: kTSMHiliteSelectedRawText, at: remainingRange)! as! [NSAttributedString.Key: Any] 545 | attrString.setAttributes(attrs, range: remainingRange) 546 | client.setMarkedText(attrString, selectionRange: NSRange(location: caretPos, length: 0), replacementRange: .empty) 547 | } 548 | 549 | // swiftlint:disable:next function_parameter_count 550 | func showPanel(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted: Int, page: Int, lastPage: Bool) { 551 | // print("[DEBUG] showPanelWithPreedit:...:") 552 | guard let client = client else { return } 553 | var inputPos = NSRect() 554 | client.attributes(forCharacterIndex: 0, lineHeightRectangle: &inputPos) 555 | if let panel = NSApp.squirrelAppDelegate.panel { 556 | panel.position = inputPos 557 | panel.inputController = self 558 | panel.update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, 559 | highlighted: highlighted, page: page, lastPage: lastPage, update: true) 560 | } 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /sources/SquirrelPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquirrelPanel.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/10/24. 6 | // 7 | 8 | import AppKit 9 | 10 | final class SquirrelPanel: NSPanel { 11 | private let view: SquirrelView 12 | private let back: NSVisualEffectView 13 | var inputController: SquirrelInputController? 14 | 15 | var position: NSRect 16 | private var screenRect: NSRect = .zero 17 | private var maxHeight: CGFloat = 0 18 | 19 | private var statusMessage: String = "" 20 | private var statusTimer: Timer? 21 | 22 | private var preedit: String = "" 23 | private var selRange: NSRange = .empty 24 | private var caretPos: Int = 0 25 | private var candidates: [String] = .init() 26 | private var comments: [String] = .init() 27 | private var labels: [String] = .init() 28 | private var index: Int = 0 29 | private var cursorIndex: Int = 0 30 | private var scrollDirection: CGVector = .zero 31 | private var scrollTime: Date = .distantPast 32 | private var page: Int = 0 33 | private var lastPage: Bool = true 34 | private var pagingUp: Bool? 35 | 36 | init(position: NSRect) { 37 | self.position = position 38 | self.view = SquirrelView(frame: position) 39 | self.back = NSVisualEffectView() 40 | super.init(contentRect: position, styleMask: .nonactivatingPanel, backing: .buffered, defer: true) 41 | self.level = .init(Int(CGShieldingWindowLevel())) 42 | self.hasShadow = true 43 | self.isOpaque = false 44 | self.backgroundColor = .clear 45 | back.blendingMode = .behindWindow 46 | back.material = .hudWindow 47 | back.state = .active 48 | back.wantsLayer = true 49 | back.layer?.mask = view.shape 50 | let contentView = NSView() 51 | contentView.addSubview(back) 52 | contentView.addSubview(view) 53 | contentView.addSubview(view.textView) 54 | self.contentView = contentView 55 | } 56 | 57 | var linear: Bool { 58 | view.currentTheme.linear 59 | } 60 | var vertical: Bool { 61 | view.currentTheme.vertical 62 | } 63 | var inlinePreedit: Bool { 64 | view.currentTheme.inlinePreedit 65 | } 66 | var inlineCandidate: Bool { 67 | view.currentTheme.inlineCandidate 68 | } 69 | 70 | // swiftlint:disable:next cyclomatic_complexity 71 | override func sendEvent(_ event: NSEvent) { 72 | switch event.type { 73 | case .leftMouseDown: 74 | let (index, _, pagingUp) = view.click(at: mousePosition()) 75 | if let pagingUp { 76 | self.pagingUp = pagingUp 77 | } else { 78 | self.pagingUp = nil 79 | } 80 | if let index, index >= 0 && index < candidates.count { 81 | self.index = index 82 | } 83 | case .leftMouseUp: 84 | let (index, preeditIndex, pagingUp) = view.click(at: mousePosition()) 85 | 86 | if let pagingUp, pagingUp == self.pagingUp { 87 | _ = inputController?.page(up: pagingUp) 88 | } else { 89 | self.pagingUp = nil 90 | } 91 | if let preeditIndex, preeditIndex >= 0 && preeditIndex < preedit.utf16.count { 92 | if preeditIndex < caretPos { 93 | _ = inputController?.moveCaret(forward: true) 94 | } else if preeditIndex > caretPos { 95 | _ = inputController?.moveCaret(forward: false) 96 | } 97 | } 98 | if let index, index == self.index && index >= 0 && index < candidates.count { 99 | _ = inputController?.selectCandidate(index) 100 | } 101 | case .mouseEntered: 102 | acceptsMouseMovedEvents = true 103 | case .mouseExited: 104 | acceptsMouseMovedEvents = false 105 | if cursorIndex != index { 106 | update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, page: page, lastPage: lastPage, update: false) 107 | } 108 | pagingUp = nil 109 | case .mouseMoved: 110 | let (index, _, _) = view.click(at: mousePosition()) 111 | if let index = index, cursorIndex != index && index >= 0 && index < candidates.count { 112 | update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, page: page, lastPage: lastPage, update: false) 113 | } 114 | case .scrollWheel: 115 | if event.phase == .began { 116 | scrollDirection = .zero 117 | // Scrollboard span 118 | } else if event.phase == .ended || (event.phase == .init(rawValue: 0) && event.momentumPhase != .init(rawValue: 0)) { 119 | if abs(scrollDirection.dx) > abs(scrollDirection.dy) && abs(scrollDirection.dx) > 10 { 120 | _ = inputController?.page(up: (scrollDirection.dx < 0) == vertical) 121 | } else if abs(scrollDirection.dx) < abs(scrollDirection.dy) && abs(scrollDirection.dy) > 10 { 122 | _ = inputController?.page(up: scrollDirection.dy > 0) 123 | } 124 | scrollDirection = .zero 125 | // Mouse scroll wheel 126 | } else if event.phase == .init(rawValue: 0) && event.momentumPhase == .init(rawValue: 0) { 127 | if scrollTime.timeIntervalSinceNow < -1 { 128 | scrollDirection = .zero 129 | } 130 | scrollTime = .now 131 | if (scrollDirection.dy >= 0 && event.scrollingDeltaY > 0) || (scrollDirection.dy <= 0 && event.scrollingDeltaY < 0) { 132 | scrollDirection.dy += event.scrollingDeltaY 133 | } else { 134 | scrollDirection = .zero 135 | } 136 | if abs(scrollDirection.dy) > 10 { 137 | _ = inputController?.page(up: scrollDirection.dy > 0) 138 | scrollDirection = .zero 139 | } 140 | } else { 141 | scrollDirection.dx += event.scrollingDeltaX 142 | scrollDirection.dy += event.scrollingDeltaY 143 | } 144 | default: 145 | break 146 | } 147 | super.sendEvent(event) 148 | } 149 | 150 | func hide() { 151 | statusTimer?.invalidate() 152 | statusTimer = nil 153 | orderOut(nil) 154 | maxHeight = 0 155 | } 156 | 157 | // Main function to add attributes to text output from librime 158 | // swiftlint:disable:next cyclomatic_complexity function_parameter_count 159 | func update(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted index: Int, page: Int, lastPage: Bool, update: Bool) { 160 | if update { 161 | self.preedit = preedit 162 | self.selRange = selRange 163 | self.caretPos = caretPos 164 | self.candidates = candidates 165 | self.comments = comments 166 | self.labels = labels 167 | self.index = index 168 | self.page = page 169 | self.lastPage = lastPage 170 | } 171 | cursorIndex = index 172 | 173 | if !candidates.isEmpty || !preedit.isEmpty { 174 | statusMessage = "" 175 | statusTimer?.invalidate() 176 | statusTimer = nil 177 | } else { 178 | if !statusMessage.isEmpty { 179 | show(status: statusMessage) 180 | statusMessage = "" 181 | } else if statusTimer == nil { 182 | hide() 183 | } 184 | return 185 | } 186 | 187 | let theme = view.currentTheme 188 | currentScreen() 189 | 190 | let text = NSMutableAttributedString() 191 | let preeditRange: NSRange 192 | let highlightedPreeditRange: NSRange 193 | 194 | // preedit 195 | if !preedit.isEmpty { 196 | preeditRange = NSRange(location: 0, length: preedit.utf16.count) 197 | highlightedPreeditRange = selRange 198 | 199 | let line = NSMutableAttributedString(string: preedit) 200 | line.addAttributes(theme.preeditAttrs, range: preeditRange) 201 | line.addAttributes(theme.preeditHighlightedAttrs, range: selRange) 202 | text.append(line) 203 | 204 | text.addAttribute(.paragraphStyle, value: theme.preeditParagraphStyle, range: NSRange(location: 0, length: text.length)) 205 | if !candidates.isEmpty { 206 | text.append(NSAttributedString(string: "\n", attributes: theme.preeditAttrs)) 207 | } 208 | } else { 209 | preeditRange = .empty 210 | highlightedPreeditRange = .empty 211 | } 212 | 213 | // candidates 214 | var candidateRanges = [NSRange]() 215 | for i in 0.. 1 && i < labels.count { 222 | labels[i] 223 | } else if labels.count == 1 && i < labels.first!.count { 224 | // custom: A. B. C... 225 | String(labels.first![labels.first!.index(labels.first!.startIndex, offsetBy: i)]) 226 | } else { 227 | // default: 1. 2. 3... 228 | "\(i+1)" 229 | } 230 | } else { 231 | "" 232 | } 233 | 234 | let candidate = candidates[i].precomposedStringWithCanonicalMapping 235 | let comment = comments[i].precomposedStringWithCanonicalMapping 236 | 237 | let line = NSMutableAttributedString(string: theme.candidateFormat, attributes: labelAttrs) 238 | for range in line.string.ranges(of: /\[candidate\]/) { 239 | let convertedRange = convert(range: range, in: line.string) 240 | line.addAttributes(attrs, range: convertedRange) 241 | if candidate.count <= 5 { 242 | line.addAttribute(.noBreak, value: true, range: NSRange(location: convertedRange.location+1, length: convertedRange.length-1)) 243 | } 244 | } 245 | for range in line.string.ranges(of: /\[comment\]/) { 246 | line.addAttributes(commentAttrs, range: convert(range: range, in: line.string)) 247 | } 248 | line.mutableString.replaceOccurrences(of: "[label]", with: label, range: NSRange(location: 0, length: line.length)) 249 | let labeledLine = line.copy() as! NSAttributedString 250 | line.mutableString.replaceOccurrences(of: "[candidate]", with: candidate, range: NSRange(location: 0, length: line.length)) 251 | line.mutableString.replaceOccurrences(of: "[comment]", with: comment, range: NSRange(location: 0, length: line.length)) 252 | 253 | if line.length <= 10 { 254 | line.addAttribute(.noBreak, value: true, range: NSRange(location: 1, length: line.length-1)) 255 | } 256 | 257 | let lineSeparator = NSAttributedString(string: linear ? " " : "\n", attributes: attrs) 258 | if i > 0 { 259 | text.append(lineSeparator) 260 | } 261 | let str = lineSeparator.mutableCopy() as! NSMutableAttributedString 262 | if vertical { 263 | str.addAttribute(.verticalGlyphForm, value: 1, range: NSRange(location: 0, length: str.length)) 264 | } 265 | view.separatorWidth = str.boundingRect(with: .zero).width 266 | 267 | let paragraphStyleCandidate = (i == 0 ? theme.firstParagraphStyle : theme.paragraphStyle).mutableCopy() as! NSMutableParagraphStyle 268 | if linear { 269 | paragraphStyleCandidate.paragraphSpacingBefore -= theme.linespace 270 | paragraphStyleCandidate.lineSpacing = theme.linespace 271 | } 272 | if !linear, let labelEnd = labeledLine.string.firstMatch(of: /\[(candidate|comment)\]/)?.range.lowerBound { 273 | let labelString = labeledLine.attributedSubstring(from: NSRange(location: 0, length: labelEnd.utf16Offset(in: labeledLine.string))) 274 | let labelWidth = labelString.boundingRect(with: .zero, options: [.usesLineFragmentOrigin]).width 275 | paragraphStyleCandidate.headIndent = labelWidth 276 | } 277 | line.addAttribute(.paragraphStyle, value: paragraphStyleCandidate, range: NSRange(location: 0, length: line.length)) 278 | 279 | candidateRanges.append(NSRange(location: text.length, length: line.length)) 280 | text.append(line) 281 | } 282 | 283 | // text done! 284 | view.textView.textContentStorage?.attributedString = text 285 | view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) 286 | view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange, canPageUp: page > 0, canPageDown: !lastPage) 287 | show() 288 | } 289 | 290 | func updateStatus(long longMessage: String, short shortMessage: String) { 291 | let theme = view.currentTheme 292 | switch theme.statusMessageType { 293 | case .mix: 294 | statusMessage = shortMessage.isEmpty ? longMessage : shortMessage 295 | case .long: 296 | statusMessage = longMessage 297 | case .short: 298 | if !shortMessage.isEmpty { 299 | statusMessage = shortMessage 300 | } else if let initial = longMessage.first { 301 | statusMessage = String(initial) 302 | } else { 303 | statusMessage = "" 304 | } 305 | } 306 | } 307 | 308 | func load(config: SquirrelConfig, forDarkMode isDark: Bool) { 309 | if isDark { 310 | view.darkTheme = SquirrelTheme() 311 | view.darkTheme.load(config: config, dark: true) 312 | } else { 313 | view.lightTheme = SquirrelTheme() 314 | view.lightTheme.load(config: config, dark: isDark) 315 | } 316 | } 317 | } 318 | 319 | private extension SquirrelPanel { 320 | func mousePosition() -> NSPoint { 321 | var point = NSEvent.mouseLocation 322 | point = self.convertPoint(fromScreen: point) 323 | return view.convert(point, from: nil) 324 | } 325 | 326 | func currentScreen() { 327 | if let screen = NSScreen.main { 328 | screenRect = screen.frame 329 | } 330 | for screen in NSScreen.screens where screen.frame.contains(position.origin) { 331 | screenRect = screen.frame 332 | break 333 | } 334 | } 335 | 336 | func maxTextWidth() -> CGFloat { 337 | let theme = view.currentTheme 338 | let font: NSFont = theme.font 339 | let fontScale = font.pointSize / 12 340 | let textWidthRatio = min(1, 1 / (vertical ? 4 : 3) + fontScale / 12) 341 | let maxWidth = if vertical { 342 | screenRect.height * textWidthRatio - theme.edgeInset.height * 2 343 | } else { 344 | screenRect.width * textWidthRatio - theme.edgeInset.width * 2 345 | } 346 | return maxWidth 347 | } 348 | 349 | // Get the window size, the windows will be the dirtyRect in 350 | // SquirrelView.drawRect 351 | // swiftlint:disable:next cyclomatic_complexity 352 | func show() { 353 | currentScreen() 354 | let theme = view.currentTheme 355 | if !view.darkTheme.available { 356 | self.appearance = NSAppearance(named: .aqua) 357 | } 358 | 359 | // Break line if the text is too long, based on screen size. 360 | let textWidth = maxTextWidth() 361 | let maxTextHeight = vertical ? screenRect.width - theme.edgeInset.width * 2 : screenRect.height - theme.edgeInset.height * 2 362 | view.textContainer.size = NSSize(width: textWidth, height: maxTextHeight) 363 | 364 | var panelRect = NSRect.zero 365 | // in vertical mode, the width and height are interchanged 366 | var contentRect = view.contentRect 367 | if theme.memorizeSize && (vertical && position.midY / screenRect.height < 0.5) || 368 | (vertical && position.minX + max(contentRect.width, maxHeight) + theme.edgeInset.width * 2 > screenRect.maxX) { 369 | if contentRect.width >= maxHeight { 370 | maxHeight = contentRect.width 371 | } else { 372 | contentRect.size.width = maxHeight 373 | view.textContainer.size = NSSize(width: maxHeight, height: maxTextHeight) 374 | } 375 | } 376 | 377 | if vertical { 378 | panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.height + theme.edgeInset.height * 2), 379 | height: min(0.95 * screenRect.height, contentRect.width + theme.edgeInset.width * 2) + theme.pagingOffset) 380 | 381 | // To avoid jumping up and down while typing, use the lower screen when 382 | // typing on upper, and vice versa 383 | if position.midY / screenRect.height >= 0.5 { 384 | panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset 385 | } else { 386 | panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight 387 | } 388 | // Make the first candidate fixed at the left of cursor 389 | panelRect.origin.x = position.minX - panelRect.width - SquirrelTheme.offsetHeight 390 | if view.preeditRange.length > 0, let preeditTextRange = view.convert(range: view.preeditRange) { 391 | let preeditRect = view.contentRect(range: preeditTextRange) 392 | panelRect.origin.x += preeditRect.height + theme.edgeInset.width 393 | } 394 | } else { 395 | panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.width + theme.edgeInset.width * 2), 396 | height: min(0.95 * screenRect.height, contentRect.height + theme.edgeInset.height * 2)) 397 | panelRect.size.width += theme.pagingOffset 398 | panelRect.origin = NSPoint(x: position.minX - theme.pagingOffset, y: position.minY - SquirrelTheme.offsetHeight - panelRect.height) 399 | } 400 | if panelRect.maxX > screenRect.maxX { 401 | panelRect.origin.x = screenRect.maxX - panelRect.width 402 | } 403 | if panelRect.minX < screenRect.minX { 404 | panelRect.origin.x = screenRect.minX 405 | } 406 | if panelRect.minY < screenRect.minY { 407 | if vertical { 408 | panelRect.origin.y = screenRect.minY 409 | } else { 410 | panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight 411 | } 412 | } 413 | if panelRect.maxY > screenRect.maxY { 414 | panelRect.origin.y = screenRect.maxY - panelRect.height 415 | } 416 | if panelRect.minY < screenRect.minY { 417 | panelRect.origin.y = screenRect.minY 418 | } 419 | self.setFrame(panelRect, display: true) 420 | 421 | // rotate the view, the core in vertical mode! 422 | if vertical { 423 | contentView!.boundsRotation = -90 424 | contentView!.setBoundsOrigin(NSPoint(x: 0, y: panelRect.width)) 425 | } else { 426 | contentView!.boundsRotation = 0 427 | contentView!.setBoundsOrigin(.zero) 428 | } 429 | view.textView.boundsRotation = 0 430 | view.textView.setBoundsOrigin(.zero) 431 | 432 | view.frame = contentView!.bounds 433 | view.textView.frame = contentView!.bounds 434 | view.textView.frame.size.width -= theme.pagingOffset 435 | view.textView.frame.origin.x += theme.pagingOffset 436 | view.textView.textContainerInset = theme.edgeInset 437 | 438 | if theme.translucency { 439 | back.frame = contentView!.bounds 440 | back.frame.size.width += theme.pagingOffset 441 | back.appearance = NSApp.effectiveAppearance 442 | back.isHidden = false 443 | } else { 444 | back.isHidden = true 445 | } 446 | alphaValue = theme.alpha 447 | invalidateShadow() 448 | orderFront(nil) 449 | // voila! 450 | } 451 | 452 | func show(status message: String) { 453 | let theme = view.currentTheme 454 | let text = NSMutableAttributedString(string: message, attributes: theme.attrs) 455 | text.addAttribute(.paragraphStyle, value: theme.paragraphStyle, range: NSRange(location: 0, length: text.length)) 456 | view.textContentStorage.attributedString = text 457 | view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) 458 | view.drawView(candidateRanges: [NSRange(location: 0, length: text.length)], hilightedIndex: -1, 459 | preeditRange: .empty, highlightedPreeditRange: .empty, canPageUp: false, canPageDown: false) 460 | show() 461 | 462 | statusTimer?.invalidate() 463 | statusTimer = Timer.scheduledTimer(withTimeInterval: SquirrelTheme.showStatusDuration, repeats: false) { _ in 464 | self.hide() 465 | } 466 | } 467 | 468 | func convert(range: Range, in string: String) -> NSRange { 469 | let startPos = range.lowerBound.utf16Offset(in: string) 470 | let endPos = range.upperBound.utf16Offset(in: string) 471 | return NSRange(location: startPos, length: endPos - startPos) 472 | } 473 | } 474 | -------------------------------------------------------------------------------- /sources/SquirrelTheme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquirrelTheme.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/9/24. 6 | // 7 | 8 | import AppKit 9 | 10 | final class SquirrelTheme { 11 | static let offsetHeight: CGFloat = 5 12 | static let defaultFontSize: CGFloat = NSFont.systemFontSize 13 | static let showStatusDuration: Double = 1.2 14 | static let defaultFont = NSFont.userFont(ofSize: defaultFontSize)! 15 | 16 | enum StatusMessageType: String { 17 | case long, short, mix 18 | } 19 | enum RimeColorSpace { 20 | case displayP3, sRGB 21 | static func from(name: String) -> Self { 22 | if name == "display_p3" { 23 | return .displayP3 24 | } else { 25 | return .sRGB 26 | } 27 | } 28 | } 29 | 30 | private(set) var available = true 31 | private(set) var native = true 32 | private(set) var memorizeSize = true 33 | private var colorSpace: RimeColorSpace = .sRGB 34 | 35 | var backgroundColor: NSColor = .windowBackgroundColor 36 | var highlightedPreeditColor: NSColor? 37 | var highlightedBackColor: NSColor? = .selectedTextBackgroundColor 38 | var preeditBackgroundColor: NSColor? 39 | var candidateBackColor: NSColor? 40 | var borderColor: NSColor? 41 | 42 | private var textColor: NSColor = .tertiaryLabelColor 43 | private var highlightedTextColor: NSColor = .labelColor 44 | private var candidateTextColor: NSColor = .secondaryLabelColor 45 | private var highlightedCandidateTextColor: NSColor = .labelColor 46 | private var candidateLabelColor: NSColor? 47 | private var highlightedCandidateLabelColor: NSColor? 48 | private var commentTextColor: NSColor? = .tertiaryLabelColor 49 | private var highlightedCommentTextColor: NSColor? 50 | 51 | private(set) var cornerRadius: CGFloat = 0 52 | private(set) var hilitedCornerRadius: CGFloat = 0 53 | private(set) var surroundingExtraExpansion: CGFloat = 0 54 | private(set) var shadowSize: CGFloat = 0 55 | private(set) var borderWidth: CGFloat = 0 56 | private(set) var borderHeight: CGFloat = 0 57 | private(set) var linespace: CGFloat = 0 58 | private(set) var preeditLinespace: CGFloat = 0 59 | private(set) var baseOffset: CGFloat = 0 60 | private(set) var alpha: CGFloat = 1 61 | 62 | private(set) var translucency = false 63 | private(set) var mutualExclusive = false 64 | private(set) var linear = false 65 | private(set) var vertical = false 66 | private(set) var inlinePreedit = false 67 | private(set) var inlineCandidate = false 68 | private(set) var showPaging = false 69 | 70 | private var fonts = [NSFont]() 71 | private var labelFonts = [NSFont]() 72 | private var commentFonts = [NSFont]() 73 | private var fontSize: CGFloat? 74 | private var labelFontSize: CGFloat? 75 | private var commentFontSize: CGFloat? 76 | 77 | private var _candidateFormat = "[label]. [candidate] [comment]" 78 | private(set) var statusMessageType: StatusMessageType = .mix 79 | 80 | private var defaultFont: NSFont { 81 | if let size = fontSize { 82 | Self.defaultFont.withSize(size) 83 | } else { 84 | Self.defaultFont 85 | } 86 | } 87 | 88 | private(set) lazy var font: NSFont = combineFonts(fonts, size: fontSize) ?? defaultFont 89 | private(set) lazy var labelFont: NSFont = { 90 | if let font = combineFonts(labelFonts, size: labelFontSize ?? fontSize) { 91 | return font 92 | } else if let size = labelFontSize { 93 | return self.font.withSize(size) 94 | } else { 95 | return self.font 96 | } 97 | }() 98 | private(set) lazy var commentFont: NSFont = { 99 | if let font = combineFonts(commentFonts, size: commentFontSize ?? fontSize) { 100 | return font 101 | } else if let size = commentFontSize { 102 | return self.font.withSize(size) 103 | } else { 104 | return self.font 105 | } 106 | }() 107 | private(set) lazy var attrs: [NSAttributedString.Key: Any] = [ 108 | .foregroundColor: candidateTextColor, 109 | .font: font, 110 | .baselineOffset: baseOffset 111 | ] 112 | private(set) lazy var highlightedAttrs: [NSAttributedString.Key: Any] = [ 113 | .foregroundColor: highlightedCandidateTextColor, 114 | .font: font, 115 | .baselineOffset: baseOffset 116 | ] 117 | private(set) lazy var labelAttrs: [NSAttributedString.Key: Any] = [ 118 | .foregroundColor: candidateLabelColor ?? blendColor(foregroundColor: self.candidateTextColor, backgroundColor: self.backgroundColor), 119 | .font: labelFont, 120 | .baselineOffset: baseOffset + (!vertical ? (font.pointSize - labelFont.pointSize) / 2.5 : 0) 121 | ] 122 | private(set) lazy var labelHighlightedAttrs: [NSAttributedString.Key: Any] = [ 123 | .foregroundColor: highlightedCandidateLabelColor ?? blendColor(foregroundColor: highlightedCandidateTextColor, backgroundColor: highlightedBackColor), 124 | .font: labelFont, 125 | .baselineOffset: baseOffset + (!vertical ? (font.pointSize - labelFont.pointSize) / 2.5 : 0) 126 | ] 127 | private(set) lazy var commentAttrs: [NSAttributedString.Key: Any] = [ 128 | .foregroundColor: commentTextColor ?? candidateTextColor, 129 | .font: commentFont, 130 | .baselineOffset: baseOffset + (!vertical ? (font.pointSize - commentFont.pointSize) / 2.5 : 0) 131 | ] 132 | private(set) lazy var commentHighlightedAttrs: [NSAttributedString.Key: Any] = [ 133 | .foregroundColor: highlightedCommentTextColor ?? highlightedCandidateTextColor, 134 | .font: commentFont, 135 | .baselineOffset: baseOffset + (!vertical ? (font.pointSize - commentFont.pointSize) / 2.5 : 0) 136 | ] 137 | private(set) lazy var preeditAttrs: [NSAttributedString.Key: Any] = [ 138 | .foregroundColor: textColor, 139 | .font: font, 140 | .baselineOffset: baseOffset 141 | ] 142 | private(set) lazy var preeditHighlightedAttrs: [NSAttributedString.Key: Any] = [ 143 | .foregroundColor: highlightedTextColor, 144 | .font: font, 145 | .baselineOffset: baseOffset 146 | ] 147 | 148 | private(set) lazy var firstParagraphStyle: NSParagraphStyle = { 149 | let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle 150 | style.paragraphSpacing = linespace / 2 151 | style.paragraphSpacingBefore = preeditLinespace / 2 + hilitedCornerRadius / 2 152 | return style as NSParagraphStyle 153 | }() 154 | private(set) lazy var paragraphStyle: NSParagraphStyle = { 155 | let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle 156 | style.paragraphSpacing = linespace / 2 157 | style.paragraphSpacingBefore = linespace / 2 158 | return style as NSParagraphStyle 159 | }() 160 | private(set) lazy var preeditParagraphStyle: NSParagraphStyle = { 161 | let style = NSMutableParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle 162 | style.paragraphSpacing = preeditLinespace / 2 + hilitedCornerRadius / 2 163 | style.lineSpacing = linespace 164 | return style as NSParagraphStyle 165 | }() 166 | private(set) lazy var edgeInset: NSSize = if self.vertical { 167 | NSSize(width: borderHeight + cornerRadius, height: borderWidth + cornerRadius) 168 | } else { 169 | NSSize(width: borderWidth + cornerRadius, height: borderHeight + cornerRadius) 170 | } 171 | private(set) lazy var borderLineWidth: CGFloat = min(borderHeight, borderWidth) 172 | private(set) var candidateFormat: String { 173 | get { 174 | _candidateFormat 175 | } set { 176 | var newTemplate = newValue 177 | if newTemplate.contains(/%@/) { 178 | newTemplate.replace(/%@/, with: "[candidate] [comment]") 179 | } 180 | if newTemplate.contains(/%c/) { 181 | newTemplate.replace(/%c/, with: "[label]") 182 | } 183 | _candidateFormat = newTemplate 184 | } 185 | } 186 | var pagingOffset: CGFloat { 187 | if showPaging { 188 | (labelFontSize ?? fontSize ?? Self.defaultFontSize) * 1.5 189 | } else { 190 | 0 191 | } 192 | } 193 | 194 | func load(config: SquirrelConfig, dark: Bool) { 195 | linear ?= config.getString("style/candidate_list_layout").map { $0 == "linear" } 196 | vertical ?= config.getString("style/text_orientation").map { $0 == "vertical" } 197 | inlinePreedit ?= config.getBool("style/inline_preedit") 198 | inlineCandidate ?= config.getBool("style/inline_candidate") 199 | translucency ?= config.getBool("style/translucency") 200 | mutualExclusive ?= config.getBool("style/mutual_exclusive") 201 | memorizeSize ?= config.getBool("style/memorize_size") 202 | showPaging ?= config.getBool("style/show_paging") 203 | 204 | statusMessageType ?= .init(rawValue: config.getString("style/status_message_type") ?? "") 205 | candidateFormat ?= config.getString("style/candidate_format") 206 | 207 | alpha ?= config.getDouble("style/alpha").map { min(1, max(0, $0)) } 208 | cornerRadius ?= config.getDouble("style/corner_radius") 209 | hilitedCornerRadius ?= config.getDouble("style/hilited_corner_radius") 210 | surroundingExtraExpansion ?= config.getDouble("style/surrounding_extra_expansion") 211 | borderHeight ?= config.getDouble("style/border_height") 212 | borderWidth ?= config.getDouble("style/border_width") 213 | linespace ?= config.getDouble("style/line_spacing") 214 | preeditLinespace ?= config.getDouble("style/spacing") 215 | baseOffset ?= config.getDouble("style/base_offset") 216 | shadowSize ?= config.getDouble("style/shadow_size").map { max(0, $0) } 217 | 218 | var fontName = config.getString("style/font_face") 219 | var fontSize = config.getDouble("style/font_point") 220 | var labelFontName = config.getString("style/label_font_face") 221 | var labelFontSize = config.getDouble("style/label_font_point") 222 | var commentFontName = config.getString("style/comment_font_face") 223 | var commentFontSize = config.getDouble("style/comment_font_point") 224 | 225 | let colorSchemeOption = dark ? "style/color_scheme_dark" : "style/color_scheme" 226 | if let colorScheme = config.getString(colorSchemeOption) { 227 | if colorScheme != "native" { 228 | native = false 229 | let prefix = "preset_color_schemes/\(colorScheme)" 230 | colorSpace = .from(name: config.getString("\(prefix)/color_space") ?? "") 231 | backgroundColor ?= config.getColor("\(prefix)/back_color", inSpace: colorSpace) 232 | highlightedPreeditColor = config.getColor("\(prefix)/hilited_back_color", inSpace: colorSpace) 233 | highlightedBackColor = config.getColor("\(prefix)/hilited_candidate_back_color", inSpace: colorSpace) ?? highlightedPreeditColor 234 | preeditBackgroundColor = config.getColor("\(prefix)/preedit_back_color", inSpace: colorSpace) 235 | candidateBackColor = config.getColor("\(prefix)/candidate_back_color", inSpace: colorSpace) 236 | borderColor = config.getColor("\(prefix)/border_color", inSpace: colorSpace) 237 | 238 | textColor ?= config.getColor("\(prefix)/text_color", inSpace: colorSpace) 239 | highlightedTextColor = config.getColor("\(prefix)/hilited_text_color", inSpace: colorSpace) ?? textColor 240 | candidateTextColor = config.getColor("\(prefix)/candidate_text_color", inSpace: colorSpace) ?? textColor 241 | highlightedCandidateTextColor = config.getColor("\(prefix)/hilited_candidate_text_color", inSpace: colorSpace) ?? highlightedTextColor 242 | candidateLabelColor = config.getColor("\(prefix)/label_color", inSpace: colorSpace) 243 | highlightedCandidateLabelColor = config.getColor("\(prefix)/hilited_candidate_label_color", inSpace: colorSpace) 244 | commentTextColor = config.getColor("\(prefix)/comment_text_color", inSpace: colorSpace) 245 | highlightedCommentTextColor = config.getColor("\(prefix)/hilited_comment_text_color", inSpace: colorSpace) 246 | 247 | // the following per-color-scheme configurations, if exist, will 248 | // override configurations with the same name under the global 'style' 249 | // section 250 | linear ?= config.getString("\(prefix)/candidate_list_layout").map { $0 == "linear" } 251 | vertical ?= config.getString("\(prefix)/text_orientation").map { $0 == "vertical" } 252 | inlinePreedit ?= config.getBool("\(prefix)/inline_preedit") 253 | inlineCandidate ?= config.getBool("\(prefix)/inline_candidate") 254 | translucency ?= config.getBool("\(prefix)/translucency") 255 | mutualExclusive ?= config.getBool("\(prefix)/mutual_exclusive") 256 | showPaging ?= config.getBool("\(prefix)/show_paging") 257 | candidateFormat ?= config.getString("\(prefix)/candidate_format") 258 | fontName ?= config.getString("\(prefix)/font_face") 259 | fontSize ?= config.getDouble("\(prefix)/font_point") 260 | labelFontName ?= config.getString("\(prefix)/label_font_face") 261 | labelFontSize ?= config.getDouble("\(prefix)/label_font_point") 262 | commentFontName ?= config.getString("\(prefix)/comment_font_face") 263 | commentFontSize ?= config.getDouble("\(prefix)/comment_font_point") 264 | 265 | alpha ?= config.getDouble("\(prefix)/alpha").map { max(0, min(1, $0)) } 266 | cornerRadius ?= config.getDouble("\(prefix)/corner_radius") 267 | hilitedCornerRadius ?= config.getDouble("\(prefix)/hilited_corner_radius") 268 | surroundingExtraExpansion ?= config.getDouble("\(prefix)/surrounding_extra_expansion") 269 | borderHeight ?= config.getDouble("\(prefix)/border_height") 270 | borderWidth ?= config.getDouble("\(prefix)/border_width") 271 | linespace ?= config.getDouble("\(prefix)/line_spacing") 272 | preeditLinespace ?= config.getDouble("\(prefix)/spacing") 273 | baseOffset ?= config.getDouble("\(prefix)/base_offset") 274 | shadowSize ?= config.getDouble("\(prefix)/shadow_size").map { max(0, $0) } 275 | } 276 | } else { 277 | available = false 278 | } 279 | 280 | fonts = decodeFonts(from: fontName) 281 | self.fontSize = fontSize 282 | labelFonts = decodeFonts(from: labelFontName ?? fontName) 283 | self.labelFontSize = labelFontSize 284 | commentFonts = decodeFonts(from: commentFontName ?? fontName) 285 | self.commentFontSize = commentFontSize 286 | } 287 | } 288 | 289 | private extension SquirrelTheme { 290 | func combineFonts(_ fonts: [NSFont], size: CGFloat?) -> NSFont? { 291 | if fonts.count == 0 { return nil } 292 | if fonts.count == 1 { 293 | if let size = size { 294 | return fonts[0].withSize(size) 295 | } else { 296 | return fonts[0] 297 | } 298 | } 299 | let attribute = [NSFontDescriptor.AttributeName.cascadeList: fonts[1...].map { $0.fontDescriptor } ] 300 | let fontDescriptor = fonts[0].fontDescriptor.addingAttributes(attribute) 301 | return NSFont.init(descriptor: fontDescriptor, size: size ?? fonts[0].pointSize) 302 | } 303 | 304 | func decodeFonts(from fontString: String?) -> [NSFont] { 305 | guard let fontString = fontString else { return [] } 306 | var seenFontFamilies = Set() 307 | let fontStrings = fontString.split(separator: ",") 308 | var fonts = [NSFont]() 309 | for string in fontStrings { 310 | if let matchedFontName = try? /^\s*(.+)-([^-]+)\s*$/.firstMatch(in: string) { 311 | let family = String(matchedFontName.output.1) 312 | let style = String(matchedFontName.output.2) 313 | if seenFontFamilies.contains(family) { continue } 314 | let fontDescriptor = NSFontDescriptor(fontAttributes: [.family: family, .face: style]) 315 | if let font = NSFont(descriptor: fontDescriptor, size: Self.defaultFontSize) { 316 | fonts.append(font) 317 | seenFontFamilies.insert(family) 318 | continue 319 | } 320 | } 321 | let fontName = string.trimmingCharacters(in: .whitespaces) 322 | if seenFontFamilies.contains(fontName) { continue } 323 | let fontDescriptor = NSFontDescriptor(fontAttributes: [.name: fontName]) 324 | if let font = NSFont(descriptor: fontDescriptor, size: Self.defaultFontSize) { 325 | fonts.append(font) 326 | seenFontFamilies.insert(fontName) 327 | continue 328 | } 329 | } 330 | return fonts 331 | } 332 | 333 | func blendColor(foregroundColor: NSColor, backgroundColor: NSColor?) -> NSColor { 334 | let foregroundColor = foregroundColor.usingColorSpace(NSColorSpace.deviceRGB)! 335 | let backgroundColor = (backgroundColor ?? NSColor.gray).usingColorSpace(NSColorSpace.deviceRGB)! 336 | func blend(foreground: CGFloat, background: CGFloat) -> CGFloat { 337 | return (foreground * 2 + background) / 3 338 | } 339 | return NSColor(deviceRed: blend(foreground: foregroundColor.redComponent, background: backgroundColor.redComponent), 340 | green: blend(foreground: foregroundColor.greenComponent, background: backgroundColor.greenComponent), 341 | blue: blend(foreground: foregroundColor.blueComponent, background: backgroundColor.blueComponent), 342 | alpha: blend(foreground: foregroundColor.alphaComponent, background: backgroundColor.alphaComponent)) 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /sources/SquirrelView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquirrelView.swift 3 | // Squirrel 4 | // 5 | // Created by Leo Liu on 5/9/24. 6 | // 7 | 8 | import AppKit 9 | 10 | private class SquirrelLayoutDelegate: NSObject, NSTextLayoutManagerDelegate { 11 | func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, shouldBreakLineBefore location: any NSTextLocation, hyphenating: Bool) -> Bool { 12 | let index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: location) 13 | if let attributes = textLayoutManager.textContainer?.textView?.textContentStorage?.attributedString?.attributes(at: index, effectiveRange: nil), 14 | let noBreak = attributes[.noBreak] as? Bool, noBreak { 15 | return false 16 | } 17 | return true 18 | } 19 | } 20 | 21 | extension NSAttributedString.Key { 22 | static let noBreak = NSAttributedString.Key("noBreak") 23 | } 24 | 25 | final class SquirrelView: NSView { 26 | let textView: NSTextView 27 | 28 | private let squirrelLayoutDelegate: SquirrelLayoutDelegate 29 | var candidateRanges: [NSRange] = [] 30 | var hilightedIndex = 0 31 | var preeditRange: NSRange = .empty 32 | var canPageUp: Bool = false 33 | var canPageDown: Bool = false 34 | var highlightedPreeditRange: NSRange = .empty 35 | var separatorWidth: CGFloat = 0 36 | var shape = CAShapeLayer() 37 | private var downPath: CGPath? 38 | private var upPath: CGPath? 39 | 40 | var lightTheme = SquirrelTheme() 41 | var darkTheme = SquirrelTheme() 42 | var currentTheme: SquirrelTheme { 43 | if isDark && darkTheme.available { darkTheme } else { lightTheme } 44 | } 45 | var textLayoutManager: NSTextLayoutManager { 46 | textView.textLayoutManager! 47 | } 48 | var textContentStorage: NSTextContentStorage { 49 | textView.textContentStorage! 50 | } 51 | var textContainer: NSTextContainer { 52 | textLayoutManager.textContainer! 53 | } 54 | 55 | override init(frame frameRect: NSRect) { 56 | squirrelLayoutDelegate = SquirrelLayoutDelegate() 57 | textView = NSTextView(frame: frameRect) 58 | textView.drawsBackground = false 59 | textView.isEditable = false 60 | textView.isSelectable = false 61 | textView.textLayoutManager?.delegate = squirrelLayoutDelegate 62 | super.init(frame: frameRect) 63 | textContainer.lineFragmentPadding = 0 64 | self.wantsLayer = true 65 | self.layer?.masksToBounds = true 66 | } 67 | required init?(coder: NSCoder) { 68 | fatalError("init(coder:) has not been implemented") 69 | } 70 | 71 | override var isFlipped: Bool { 72 | true 73 | } 74 | var isDark: Bool { 75 | NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua 76 | } 77 | 78 | func convert(range: NSRange) -> NSTextRange? { 79 | guard range != .empty else { return nil } 80 | guard let startLocation = textLayoutManager.location(textLayoutManager.documentRange.location, offsetBy: range.location) else { return nil } 81 | guard let endLocation = textLayoutManager.location(startLocation, offsetBy: range.length) else { return nil } 82 | return NSTextRange(location: startLocation, end: endLocation) 83 | } 84 | 85 | // Get the rectangle containing entire contents, expensive to calculate 86 | var contentRect: NSRect { 87 | var ranges = candidateRanges 88 | if preeditRange.length > 0 { 89 | ranges.append(preeditRange) 90 | } 91 | // swiftlint:disable:next identifier_name 92 | var x0 = CGFloat.infinity, x1 = -CGFloat.infinity, y0 = CGFloat.infinity, y1 = -CGFloat.infinity 93 | for range in ranges { 94 | if let textRange = convert(range: range) { 95 | let rect = contentRect(range: textRange) 96 | x0 = min(rect.minX, x0) 97 | x1 = max(rect.maxX, x1) 98 | y0 = min(rect.minY, y0) 99 | y1 = max(rect.maxY, y1) 100 | } 101 | } 102 | return NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) 103 | } 104 | // Get the rectangle containing the range of text, will first convert to glyph range, expensive to calculate 105 | func contentRect(range: NSTextRange) -> NSRect { 106 | // swiftlint:disable:next identifier_name 107 | var x0 = CGFloat.infinity, x1 = -CGFloat.infinity, y0 = CGFloat.infinity, y1 = -CGFloat.infinity 108 | textLayoutManager.enumerateTextSegments(in: range, type: .standard, options: .rangeNotRequired) { _, rect, _, _ in 109 | x0 = min(rect.minX, x0) 110 | x1 = max(rect.maxX, x1) 111 | y0 = min(rect.minY, y0) 112 | y1 = max(rect.maxY, y1) 113 | return true 114 | } 115 | return NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) 116 | } 117 | 118 | // Will triger - (void)drawRect:(NSRect)dirtyRect 119 | // swiftlint:disable:next function_parameter_count 120 | func drawView(candidateRanges: [NSRange], hilightedIndex: Int, preeditRange: NSRange, highlightedPreeditRange: NSRange, canPageUp: Bool, canPageDown: Bool) { 121 | self.candidateRanges = candidateRanges 122 | self.hilightedIndex = hilightedIndex 123 | self.preeditRange = preeditRange 124 | self.highlightedPreeditRange = highlightedPreeditRange 125 | self.canPageUp = canPageUp 126 | self.canPageDown = canPageDown 127 | self.needsDisplay = true 128 | } 129 | 130 | // All draws happen here 131 | // swiftlint:disable:next cyclomatic_complexity 132 | override func draw(_ dirtyRect: NSRect) { 133 | var backgroundPath: CGPath? 134 | var preeditPath: CGPath? 135 | var candidatePaths: CGMutablePath? 136 | var highlightedPath: CGMutablePath? 137 | var highlightedPreeditPath: CGMutablePath? 138 | let theme = currentTheme 139 | 140 | var containingRect = dirtyRect 141 | containingRect.size.width -= theme.pagingOffset 142 | let backgroundRect = containingRect 143 | 144 | // Draw preedit Rect 145 | var preeditRect = NSRect.zero 146 | if preeditRange.length > 0, let preeditTextRange = convert(range: preeditRange) { 147 | preeditRect = contentRect(range: preeditTextRange) 148 | preeditRect.size.width = backgroundRect.size.width 149 | preeditRect.size.height += theme.edgeInset.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 150 | preeditRect.origin = backgroundRect.origin 151 | if candidateRanges.count == 0 { 152 | preeditRect.size.height += theme.edgeInset.height - theme.preeditLinespace / 2 - theme.hilitedCornerRadius / 2 153 | } 154 | containingRect.size.height -= preeditRect.size.height 155 | containingRect.origin.y += preeditRect.size.height 156 | if theme.preeditBackgroundColor != nil { 157 | preeditPath = drawSmoothLines(rectVertex(of: preeditRect), straightCorner: Set(), alpha: 0, beta: 0) 158 | } 159 | } 160 | 161 | containingRect = carveInset(rect: containingRect) 162 | // Draw candidate Rects 163 | for i in 0.. 0 && theme.highlightedBackColor != nil { 168 | highlightedPath = drawPath(highlightedRange: candidate, backgroundRect: backgroundRect, preeditRect: preeditRect, containingRect: containingRect, extraExpansion: 0)?.mutableCopy() 169 | } 170 | } else { 171 | // Draw other highlighted Rect 172 | if candidate.length > 0 && theme.candidateBackColor != nil { 173 | let candidatePath = drawPath(highlightedRange: candidate, backgroundRect: backgroundRect, preeditRect: preeditRect, 174 | containingRect: containingRect, extraExpansion: theme.surroundingExtraExpansion) 175 | if candidatePaths == nil { 176 | candidatePaths = CGMutablePath() 177 | } 178 | if let candidatePath = candidatePath { 179 | candidatePaths?.addPath(candidatePath) 180 | } 181 | } 182 | } 183 | } 184 | 185 | // Draw highlighted part of preedit text 186 | if (highlightedPreeditRange.length > 0) && (theme.highlightedPreeditColor != nil), let highlightedPreeditTextRange = convert(range: highlightedPreeditRange) { 187 | var innerBox = preeditRect 188 | innerBox.size.width -= (theme.edgeInset.width + 1) * 2 189 | innerBox.origin.x += theme.edgeInset.width + 1 190 | innerBox.origin.y += theme.edgeInset.height + 1 191 | if candidateRanges.count == 0 { 192 | innerBox.size.height -= (theme.edgeInset.height + 1) * 2 193 | } else { 194 | innerBox.size.height -= theme.edgeInset.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 + 2 195 | } 196 | var outerBox = preeditRect 197 | outerBox.size.height -= max(0, theme.hilitedCornerRadius + theme.borderLineWidth) 198 | outerBox.size.width -= max(0, theme.hilitedCornerRadius + theme.borderLineWidth) 199 | outerBox.origin.x += max(0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2 200 | outerBox.origin.y += max(0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2 201 | 202 | let (leadingRect, bodyRect, trailingRect) = multilineRects(forRange: highlightedPreeditTextRange, extraSurounding: 0, bounds: outerBox) 203 | var (highlightedPoints, highlightedPoints2, rightCorners, rightCorners2) = linearMultilineFor(body: bodyRect, leading: leadingRect, trailing: trailingRect) 204 | 205 | containingRect = carveInset(rect: preeditRect) 206 | highlightedPoints = expand(vertex: highlightedPoints, innerBorder: innerBox, outerBorder: outerBox) 207 | rightCorners = removeCorner(highlightedPoints: highlightedPoints, rightCorners: rightCorners, containingRect: containingRect) 208 | highlightedPreeditPath = drawSmoothLines(highlightedPoints, straightCorner: rightCorners, alpha: 0.3 * theme.hilitedCornerRadius, beta: 1.4 * theme.hilitedCornerRadius)?.mutableCopy() 209 | if highlightedPoints2.count > 0 { 210 | highlightedPoints2 = expand(vertex: highlightedPoints2, innerBorder: innerBox, outerBorder: outerBox) 211 | rightCorners2 = removeCorner(highlightedPoints: highlightedPoints2, rightCorners: rightCorners2, containingRect: containingRect) 212 | let highlightedPreeditPath2 = drawSmoothLines(highlightedPoints2, straightCorner: rightCorners2, alpha: 0.3 * theme.hilitedCornerRadius, beta: 1.4 * theme.hilitedCornerRadius) 213 | if let highlightedPreeditPath2 = highlightedPreeditPath2 { 214 | highlightedPreeditPath?.addPath(highlightedPreeditPath2) 215 | } 216 | } 217 | } 218 | 219 | NSBezierPath.defaultLineWidth = 0 220 | backgroundPath = drawSmoothLines(rectVertex(of: backgroundRect), straightCorner: Set(), alpha: 0.3 * theme.cornerRadius, beta: 1.4 * theme.cornerRadius) 221 | 222 | self.layer?.sublayers = nil 223 | let backPath = backgroundPath?.mutableCopy() 224 | if let path = preeditPath { 225 | backPath?.addPath(path) 226 | } 227 | if theme.mutualExclusive { 228 | if let path = highlightedPath { 229 | backPath?.addPath(path) 230 | } 231 | if let path = candidatePaths { 232 | backPath?.addPath(path) 233 | } 234 | } 235 | let panelLayer = shapeFromPath(path: backPath) 236 | panelLayer.fillColor = theme.backgroundColor.cgColor 237 | let panelLayerMask = shapeFromPath(path: backgroundPath) 238 | panelLayer.mask = panelLayerMask 239 | self.layer?.addSublayer(panelLayer) 240 | 241 | // Fill in colors 242 | if let color = theme.preeditBackgroundColor, let path = preeditPath { 243 | let layer = shapeFromPath(path: path) 244 | layer.fillColor = color.cgColor 245 | let maskPath = backgroundPath?.mutableCopy() 246 | if theme.mutualExclusive, let hilitedPath = highlightedPreeditPath { 247 | maskPath?.addPath(hilitedPath) 248 | } 249 | let mask = shapeFromPath(path: maskPath) 250 | layer.mask = mask 251 | panelLayer.addSublayer(layer) 252 | } 253 | if theme.borderLineWidth > 0, let color = theme.borderColor { 254 | let borderLayer = shapeFromPath(path: backgroundPath) 255 | borderLayer.lineWidth = theme.borderLineWidth * 2 256 | borderLayer.strokeColor = color.cgColor 257 | borderLayer.fillColor = nil 258 | panelLayer.addSublayer(borderLayer) 259 | } 260 | if let color = theme.highlightedPreeditColor, let path = highlightedPreeditPath { 261 | let layer = shapeFromPath(path: path) 262 | layer.fillColor = color.cgColor 263 | panelLayer.addSublayer(layer) 264 | } 265 | if let color = theme.candidateBackColor, let path = candidatePaths { 266 | let layer = shapeFromPath(path: path) 267 | layer.fillColor = color.cgColor 268 | panelLayer.addSublayer(layer) 269 | } 270 | if let color = theme.highlightedBackColor, let path = highlightedPath { 271 | let layer = shapeFromPath(path: path) 272 | layer.fillColor = color.cgColor 273 | if theme.shadowSize > 0 { 274 | let shadowLayer = CAShapeLayer() 275 | shadowLayer.shadowColor = NSColor.black.cgColor 276 | shadowLayer.shadowOffset = NSSize(width: theme.shadowSize/2, height: (theme.vertical ? -1 : 1) * theme.shadowSize/2) 277 | shadowLayer.shadowPath = highlightedPath 278 | shadowLayer.shadowRadius = theme.shadowSize 279 | shadowLayer.shadowOpacity = 0.2 280 | let outerPath = backgroundPath?.mutableCopy() 281 | outerPath?.addPath(path) 282 | let shadowLayerMask = shapeFromPath(path: outerPath) 283 | shadowLayer.mask = shadowLayerMask 284 | layer.strokeColor = NSColor.black.withAlphaComponent(0.15).cgColor 285 | layer.lineWidth = 0.5 286 | layer.addSublayer(shadowLayer) 287 | } 288 | panelLayer.addSublayer(layer) 289 | } 290 | panelLayer.setAffineTransform(CGAffineTransform(translationX: theme.pagingOffset, y: 0)) 291 | let panelPath = CGMutablePath() 292 | panelPath.addPath(backgroundPath!, transform: panelLayer.affineTransform().scaledBy(x: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height)) 293 | 294 | let (pagingLayer, downPath, upPath) = pagingLayer(theme: theme, preeditRect: preeditRect) 295 | if let sublayers = pagingLayer.sublayers, !sublayers.isEmpty { 296 | self.layer?.addSublayer(pagingLayer) 297 | } 298 | let flipTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height) 299 | if let downPath { 300 | panelPath.addPath(downPath, transform: flipTransform) 301 | self.downPath = downPath.copy() 302 | } 303 | if let upPath { 304 | panelPath.addPath(upPath, transform: flipTransform) 305 | self.upPath = upPath.copy() 306 | } 307 | 308 | shape.path = panelPath 309 | } 310 | 311 | func click(at clickPoint: NSPoint) -> (Int?, Int?, Bool?) { 312 | var index = 0 313 | var candidateIndex: Int? 314 | var preeditIndex: Int? 315 | if let downPath = self.downPath, downPath.contains(clickPoint) { 316 | return (nil, nil, false) 317 | } 318 | if let upPath = self.upPath, upPath.contains(clickPoint) { 319 | return (nil, nil, true) 320 | } 321 | if let path = shape.path, path.contains(clickPoint) { 322 | var point = NSPoint(x: clickPoint.x - textView.textContainerInset.width - currentTheme.pagingOffset, 323 | y: clickPoint.y - textView.textContainerInset.height) 324 | let fragment = textLayoutManager.textLayoutFragment(for: point) 325 | if let fragment = fragment { 326 | point = NSPoint(x: point.x - fragment.layoutFragmentFrame.minX, 327 | y: point.y - fragment.layoutFragmentFrame.minY) 328 | index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: fragment.rangeInElement.location) 329 | for lineFragment in fragment.textLineFragments where lineFragment.typographicBounds.contains(point) { 330 | point = NSPoint(x: point.x - lineFragment.typographicBounds.minX, 331 | y: point.y - lineFragment.typographicBounds.minY) 332 | index += lineFragment.characterIndex(for: point) 333 | if index >= preeditRange.location && index < preeditRange.upperBound { 334 | preeditIndex = index 335 | } else { 336 | for i in 0..= range.location && index < range.upperBound { 339 | candidateIndex = i 340 | break 341 | } 342 | } 343 | } 344 | break 345 | } 346 | } 347 | } 348 | return (candidateIndex, preeditIndex, nil) 349 | } 350 | } 351 | 352 | private extension SquirrelView { 353 | // A tweaked sign function, to winddown corner radius when the size is small 354 | func sign(_ number: NSPoint) -> NSPoint { 355 | if number.length >= 2 { 356 | return number / number.length 357 | } else { 358 | return number / 2 359 | } 360 | } 361 | 362 | // Bezier cubic curve, which has continuous roundness 363 | func drawSmoothLines(_ vertex: [NSPoint], straightCorner: Set, alpha: CGFloat, beta rawBeta: CGFloat) -> CGPath? { 364 | guard vertex.count >= 3 else { 365 | return nil 366 | } 367 | let beta = max(0.00001, rawBeta) 368 | let path = CGMutablePath() 369 | var previousPoint = vertex[vertex.count-1] 370 | var point = vertex[0] 371 | var nextPoint: NSPoint 372 | var control1: NSPoint 373 | var control2: NSPoint 374 | var target = previousPoint 375 | var diff = point - previousPoint 376 | if straightCorner.isEmpty || !straightCorner.contains(vertex.count-1) { 377 | target += sign(diff / beta) * beta 378 | } 379 | path.move(to: target) 380 | for i in 0.. [NSPoint] { 410 | [rect.origin, 411 | NSPoint(x: rect.origin.x, y: rect.origin.y+rect.size.height), 412 | NSPoint(x: rect.origin.x+rect.size.width, y: rect.origin.y+rect.size.height), 413 | NSPoint(x: rect.origin.x+rect.size.width, y: rect.origin.y)] 414 | } 415 | 416 | func nearEmpty(_ rect: NSRect) -> Bool { 417 | return rect.size.height * rect.size.width < 1 418 | } 419 | 420 | // Calculate 3 boxes containing the text in range. leadingRect and trailingRect are incomplete line rectangle 421 | // bodyRect is complete lines in the middle 422 | func multilineRects(forRange range: NSTextRange, extraSurounding: Double, bounds: NSRect) -> (NSRect, NSRect, NSRect) { 423 | let edgeInset = currentTheme.edgeInset 424 | var lineRects = [NSRect]() 425 | textLayoutManager.enumerateTextSegments(in: range, type: .standard, options: [.rangeNotRequired]) { _, rect, _, _ in 426 | var newRect = rect 427 | newRect.origin.x += edgeInset.width 428 | newRect.origin.y += edgeInset.height 429 | newRect.size.height += currentTheme.linespace 430 | newRect.origin.y -= currentTheme.linespace / 2 431 | lineRects.append(newRect) 432 | return true 433 | } 434 | 435 | var leadingRect = NSRect.zero 436 | var bodyRect = NSRect.zero 437 | var trailingRect = NSRect.zero 438 | if lineRects.count == 1 { 439 | bodyRect = lineRects[0] 440 | } else if lineRects.count == 2 { 441 | leadingRect = lineRects[0] 442 | trailingRect = lineRects[1] 443 | } else if lineRects.count > 2 { 444 | leadingRect = lineRects[0] 445 | trailingRect = lineRects[lineRects.count-1] 446 | // swiftlint:disable:next identifier_name 447 | var x0 = CGFloat.infinity, x1 = -CGFloat.infinity, y0 = CGFloat.infinity, y1 = -CGFloat.infinity 448 | for i in 1..<(lineRects.count-1) { 449 | let rect = lineRects[i] 450 | x0 = min(rect.minX, x0) 451 | x1 = max(rect.maxX, x1) 452 | y0 = min(rect.minY, y0) 453 | y1 = max(rect.maxY, y1) 454 | } 455 | y0 = min(leadingRect.maxY, y0) 456 | y1 = max(trailingRect.minY, y1) 457 | bodyRect = NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) 458 | } 459 | 460 | if extraSurounding > 0 { 461 | if nearEmpty(leadingRect) && nearEmpty(trailingRect) { 462 | bodyRect = expandHighlightWidth(rect: bodyRect, extraSurrounding: extraSurounding) 463 | } else { 464 | if !(nearEmpty(leadingRect)) { 465 | leadingRect = expandHighlightWidth(rect: leadingRect, extraSurrounding: extraSurounding) 466 | } 467 | if !(nearEmpty(trailingRect)) { 468 | trailingRect = expandHighlightWidth(rect: trailingRect, extraSurrounding: extraSurounding) 469 | } 470 | } 471 | } 472 | 473 | if !nearEmpty(leadingRect) && !nearEmpty(trailingRect) { 474 | leadingRect.size.width = bounds.maxX - leadingRect.origin.x 475 | trailingRect.size.width = trailingRect.maxX - bounds.minX 476 | trailingRect.origin.x = bounds.minX 477 | if !nearEmpty(bodyRect) { 478 | bodyRect.size.width = bounds.size.width 479 | bodyRect.origin.x = bounds.origin.x 480 | } else { 481 | let diff = trailingRect.minY - leadingRect.maxY 482 | leadingRect.size.height += diff / 2 483 | trailingRect.size.height += diff / 2 484 | trailingRect.origin.y -= diff / 2 485 | } 486 | } 487 | 488 | return (leadingRect, bodyRect, trailingRect) 489 | } 490 | 491 | // Based on the 3 boxes from multilineRectForRange, calculate the vertex of the polygon containing the text in range 492 | func multilineVertex(leadingRect: NSRect, bodyRect: NSRect, trailingRect: NSRect) -> [NSPoint] { 493 | if nearEmpty(bodyRect) && !nearEmpty(leadingRect) && nearEmpty(trailingRect) { 494 | return rectVertex(of: leadingRect) 495 | } else if nearEmpty(bodyRect) && nearEmpty(leadingRect) && !nearEmpty(trailingRect) { 496 | return rectVertex(of: trailingRect) 497 | } else if nearEmpty(leadingRect) && nearEmpty(trailingRect) && !nearEmpty(bodyRect) { 498 | return rectVertex(of: bodyRect) 499 | } else if nearEmpty(trailingRect) && !nearEmpty(bodyRect) { 500 | let leadingVertex = rectVertex(of: leadingRect) 501 | let bodyVertex = rectVertex(of: bodyRect) 502 | return [bodyVertex[0], bodyVertex[1], bodyVertex[2], leadingVertex[3], leadingVertex[0], leadingVertex[1]] 503 | } else if nearEmpty(leadingRect) && !nearEmpty(bodyRect) { 504 | let trailingVertex = rectVertex(of: trailingRect) 505 | let bodyVertex = rectVertex(of: bodyRect) 506 | return [trailingVertex[1], trailingVertex[2], trailingVertex[3], bodyVertex[2], bodyVertex[3], bodyVertex[0]] 507 | } else if !nearEmpty(leadingRect) && !nearEmpty(trailingRect) && nearEmpty(bodyRect) && (leadingRect.maxX>trailingRect.minX) { 508 | let leadingVertex = rectVertex(of: leadingRect) 509 | let trailingVertex = rectVertex(of: trailingRect) 510 | return [trailingVertex[0], trailingVertex[1], trailingVertex[2], trailingVertex[3], leadingVertex[2], leadingVertex[3], leadingVertex[0], leadingVertex[1]] 511 | } else if !nearEmpty(leadingRect) && !nearEmpty(trailingRect) && !nearEmpty(bodyRect) { 512 | let leadingVertex = rectVertex(of: leadingRect) 513 | let bodyVertex = rectVertex(of: bodyRect) 514 | let trailingVertex = rectVertex(of: trailingRect) 515 | return [trailingVertex[1], trailingVertex[2], trailingVertex[3], bodyVertex[2], leadingVertex[3], leadingVertex[0], leadingVertex[1], bodyVertex[0]] 516 | } else { 517 | return [NSPoint]() 518 | } 519 | } 520 | 521 | // If the point is outside the innerBox, will extend to reach the outerBox 522 | func expand(vertex: [NSPoint], innerBorder: NSRect, outerBorder: NSRect) -> [NSPoint] { 523 | var newVertex = [NSPoint]() 524 | for i in 0.. innerBorder.origin.x+innerBorder.size.width { 529 | point.x = outerBorder.origin.x+outerBorder.size.width 530 | } 531 | if point.y < innerBorder.origin.y { 532 | point.y = outerBorder.origin.y 533 | } else if point.y > innerBorder.origin.y+innerBorder.size.height { 534 | point.y = outerBorder.origin.y+outerBorder.size.height 535 | } 536 | newVertex.append(point) 537 | } 538 | return newVertex 539 | } 540 | 541 | func direction(diff: CGPoint) -> CGPoint { 542 | if diff.y == 0 && diff.x > 0 { 543 | return NSPoint(x: 0, y: 1) 544 | } else if diff.y == 0 && diff.x < 0 { 545 | return NSPoint(x: 0, y: -1) 546 | } else if diff.x == 0 && diff.y > 0 { 547 | return NSPoint(x: -1, y: 0) 548 | } else if diff.x == 0 && diff.y < 0 { 549 | return NSPoint(x: 1, y: 0) 550 | } else { 551 | return NSPoint(x: 0, y: 0) 552 | } 553 | } 554 | 555 | func shapeFromPath(path: CGPath?) -> CAShapeLayer { 556 | let layer = CAShapeLayer() 557 | layer.path = path 558 | layer.fillRule = .evenOdd 559 | return layer 560 | } 561 | 562 | // Assumes clockwise iteration 563 | func enlarge(vertex: [NSPoint], by: Double) -> [NSPoint] { 564 | if by != 0 { 565 | var previousPoint: NSPoint 566 | var point: NSPoint 567 | var nextPoint: NSPoint 568 | var results = vertex 569 | var newPoint: NSPoint 570 | var displacement: NSPoint 571 | for i in 0.. NSRect { 592 | var newRect = rect 593 | if !nearEmpty(newRect) { 594 | newRect.size.width += extraSurrounding 595 | newRect.origin.x -= extraSurrounding / 2 596 | } 597 | return newRect 598 | } 599 | 600 | func removeCorner(highlightedPoints: [CGPoint], rightCorners: Set, containingRect: NSRect) -> Set { 601 | if !highlightedPoints.isEmpty && !rightCorners.isEmpty { 602 | var result = rightCorners 603 | for cornerIndex in rightCorners { 604 | let corner = highlightedPoints[cornerIndex] 605 | let dist = min(containingRect.maxY - corner.y, corner.y - containingRect.minY) 606 | if dist < 1e-2 { 607 | result.remove(cornerIndex) 608 | } 609 | } 610 | return result 611 | } else { 612 | return rightCorners 613 | } 614 | } 615 | 616 | // swiftlint:disable:next large_tuple 617 | func linearMultilineFor(body: NSRect, leading: NSRect, trailing: NSRect) -> (Array, Array, Set, Set) { 618 | let highlightedPoints, highlightedPoints2: [NSPoint] 619 | let rightCorners, rightCorners2: Set 620 | // Handles the special case where containing boxes are separated 621 | if nearEmpty(body) && !nearEmpty(leading) && !nearEmpty(trailing) && trailing.maxX < leading.minX { 622 | highlightedPoints = rectVertex(of: leading) 623 | highlightedPoints2 = rectVertex(of: trailing) 624 | rightCorners = [2, 3] 625 | rightCorners2 = [0, 1] 626 | } else { 627 | highlightedPoints = multilineVertex(leadingRect: leading, bodyRect: body, trailingRect: trailing) 628 | highlightedPoints2 = [] 629 | rightCorners = [] 630 | rightCorners2 = [] 631 | } 632 | return (highlightedPoints, highlightedPoints2, rightCorners, rightCorners2) 633 | } 634 | 635 | func drawPath(highlightedRange: NSRange, backgroundRect: NSRect, preeditRect: NSRect, containingRect: NSRect, extraExpansion: Double) -> CGPath? { 636 | let theme = currentTheme 637 | let resultingPath: CGMutablePath? 638 | 639 | var currentContainingRect = containingRect 640 | currentContainingRect.size.width += extraExpansion * 2 641 | currentContainingRect.size.height += extraExpansion * 2 642 | currentContainingRect.origin.x -= extraExpansion 643 | currentContainingRect.origin.y -= extraExpansion 644 | 645 | let halfLinespace = theme.linespace / 2 646 | var innerBox = backgroundRect 647 | innerBox.size.width -= (theme.edgeInset.width + 1) * 2 - 2 * extraExpansion 648 | innerBox.origin.x += theme.edgeInset.width + 1 - extraExpansion 649 | innerBox.size.height += 2 * extraExpansion 650 | innerBox.origin.y -= extraExpansion 651 | if preeditRange.length == 0 { 652 | innerBox.origin.y += theme.edgeInset.height + 1 653 | innerBox.size.height -= (theme.edgeInset.height + 1) * 2 654 | } else { 655 | innerBox.origin.y += preeditRect.size.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 + 1 656 | innerBox.size.height -= theme.edgeInset.height + preeditRect.size.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 + 2 657 | } 658 | innerBox.size.height -= theme.linespace 659 | innerBox.origin.y += halfLinespace 660 | 661 | var outerBox = backgroundRect 662 | outerBox.size.height -= preeditRect.size.height + max(0, theme.hilitedCornerRadius + theme.borderLineWidth) - 2 * extraExpansion 663 | outerBox.size.width -= max(0, theme.hilitedCornerRadius + theme.borderLineWidth) - 2 * extraExpansion 664 | outerBox.origin.x += max(0.0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2.0 - extraExpansion 665 | outerBox.origin.y += preeditRect.size.height + max(0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2 - extraExpansion 666 | 667 | let effectiveRadius = max(0, theme.hilitedCornerRadius + 2 * extraExpansion / theme.hilitedCornerRadius * max(0, theme.cornerRadius - theme.hilitedCornerRadius)) 668 | 669 | if theme.linear, let highlightedTextRange = convert(range: highlightedRange) { 670 | let (leadingRect, bodyRect, trailingRect) = multilineRects(forRange: highlightedTextRange, extraSurounding: separatorWidth, bounds: outerBox) 671 | var (highlightedPoints, highlightedPoints2, rightCorners, rightCorners2) = linearMultilineFor(body: bodyRect, leading: leadingRect, trailing: trailingRect) 672 | 673 | // Expand the boxes to reach proper border 674 | highlightedPoints = enlarge(vertex: highlightedPoints, by: extraExpansion) 675 | highlightedPoints = expand(vertex: highlightedPoints, innerBorder: innerBox, outerBorder: outerBox) 676 | rightCorners = removeCorner(highlightedPoints: highlightedPoints, rightCorners: rightCorners, containingRect: currentContainingRect) 677 | resultingPath = drawSmoothLines(highlightedPoints, straightCorner: rightCorners, alpha: 0.3*effectiveRadius, beta: 1.4*effectiveRadius)?.mutableCopy() 678 | 679 | if highlightedPoints2.count > 0 { 680 | highlightedPoints2 = enlarge(vertex: highlightedPoints2, by: extraExpansion) 681 | highlightedPoints2 = expand(vertex: highlightedPoints2, innerBorder: innerBox, outerBorder: outerBox) 682 | rightCorners2 = removeCorner(highlightedPoints: highlightedPoints2, rightCorners: rightCorners2, containingRect: currentContainingRect) 683 | let highlightedPath2 = drawSmoothLines(highlightedPoints2, straightCorner: rightCorners2, alpha: 0.3*effectiveRadius, beta: 1.4*effectiveRadius) 684 | if let highlightedPath2 = highlightedPath2 { 685 | resultingPath?.addPath(highlightedPath2) 686 | } 687 | } 688 | } else if let highlightedTextRange = convert(range: highlightedRange) { 689 | var highlightedRect = self.contentRect(range: highlightedTextRange) 690 | if !nearEmpty(highlightedRect) { 691 | highlightedRect.size.width = backgroundRect.size.width 692 | highlightedRect.size.height += theme.linespace 693 | highlightedRect.origin = NSPoint(x: backgroundRect.origin.x, y: highlightedRect.origin.y + theme.edgeInset.height - halfLinespace) 694 | if highlightedRange.upperBound == (textView.string as NSString).length { 695 | highlightedRect.size.height += theme.edgeInset.height - halfLinespace 696 | } 697 | if highlightedRange.location - (preeditRange == .empty ? 0 : preeditRange.upperBound) <= 1 { 698 | if preeditRange.length == 0 { 699 | highlightedRect.size.height += theme.edgeInset.height - halfLinespace 700 | highlightedRect.origin.y -= theme.edgeInset.height - halfLinespace 701 | } else { 702 | highlightedRect.size.height += theme.hilitedCornerRadius / 2 703 | highlightedRect.origin.y -= theme.hilitedCornerRadius / 2 704 | } 705 | } 706 | 707 | var highlightedPoints = rectVertex(of: highlightedRect) 708 | highlightedPoints = enlarge(vertex: highlightedPoints, by: extraExpansion) 709 | highlightedPoints = expand(vertex: highlightedPoints, innerBorder: innerBox, outerBorder: outerBox) 710 | resultingPath = drawSmoothLines(highlightedPoints, straightCorner: Set(), alpha: effectiveRadius*0.3, beta: effectiveRadius*1.4)?.mutableCopy() 711 | } else { 712 | resultingPath = nil 713 | } 714 | } else { 715 | resultingPath = nil 716 | } 717 | return resultingPath 718 | } 719 | 720 | func carveInset(rect: NSRect) -> NSRect { 721 | var newRect = rect 722 | newRect.size.height -= (currentTheme.hilitedCornerRadius + currentTheme.borderWidth) * 2 723 | newRect.size.width -= (currentTheme.hilitedCornerRadius + currentTheme.borderWidth) * 2 724 | newRect.origin.x += currentTheme.hilitedCornerRadius + currentTheme.borderWidth 725 | newRect.origin.y += currentTheme.hilitedCornerRadius + currentTheme.borderWidth 726 | return newRect 727 | } 728 | 729 | func triangle(center: NSPoint, radius: CGFloat) -> [NSPoint] { 730 | [NSPoint(x: center.x, y: center.y + radius), 731 | NSPoint(x: center.x + 0.5 * sqrt(3) * radius, y: center.y - 0.5 * radius), 732 | NSPoint(x: center.x - 0.5 * sqrt(3) * radius, y: center.y - 0.5 * radius)] 733 | } 734 | 735 | func pagingLayer(theme: SquirrelTheme, preeditRect: CGRect) -> (CAShapeLayer, CGPath?, CGPath?) { 736 | let layer = CAShapeLayer() 737 | guard theme.showPaging && (canPageUp || canPageDown) else { return (layer, nil, nil) } 738 | guard let firstCandidate = candidateRanges.first, let range = convert(range: firstCandidate) else { return (layer, nil, nil) } 739 | var height = contentRect(range: range).height 740 | let preeditHeight = max(0, preeditRect.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 - theme.edgeInset.height) + theme.edgeInset.height - theme.linespace / 2 741 | height += theme.linespace 742 | let radius = min(0.5 * theme.pagingOffset, 2 * height / 9) 743 | let effectiveRadius = min(theme.cornerRadius, 0.6 * radius) 744 | guard let trianglePath = drawSmoothLines( 745 | triangle(center: .zero, radius: radius), 746 | straightCorner: [], alpha: 0.3 * effectiveRadius, beta: 1.4 * effectiveRadius 747 | ) else { 748 | return (layer, nil, nil) 749 | } 750 | var downPath: CGPath? 751 | var upPath: CGPath? 752 | if canPageDown { 753 | var downTransform = CGAffineTransform(translationX: 0.5 * theme.pagingOffset, y: 2 * height / 3 + preeditHeight) 754 | let downLayer = shapeFromPath(path: trianglePath.copy(using: &downTransform)) 755 | downLayer.fillColor = theme.backgroundColor.cgColor 756 | downPath = trianglePath.copy(using: &downTransform) 757 | layer.addSublayer(downLayer) 758 | } 759 | if canPageUp { 760 | var upTransform = CGAffineTransform(rotationAngle: .pi).translatedBy(x: -0.5 * theme.pagingOffset, y: -height / 3 - preeditHeight) 761 | let upLayer = shapeFromPath(path: trianglePath.copy(using: &upTransform)) 762 | upLayer.fillColor = theme.backgroundColor.cgColor 763 | upPath = trianglePath.copy(using: &upTransform) 764 | layer.addSublayer(upLayer) 765 | } 766 | return (layer, downPath, upPath) 767 | } 768 | } 769 | --------------------------------------------------------------------------------