diff --git a/.clang-format b/.clang-format deleted file mode 100644 index f0b9df629..000000000 --- a/.clang-format +++ /dev/null @@ -1,2 +0,0 @@ -BasedOnStyle: Chromium -SortIncludes: false \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 28fd0c7ef..8b1378917 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,3 +1 @@ -# apply clang format -ce94f9c4403b9c33d5e251fc7f1e15c44725d881 diff --git "a/.github/ISSUE_TEMPLATE/bug-\345\240\261\345\221\212.md" "b/.github/ISSUE_TEMPLATE/bug-\345\240\261\345\221\212.md" new file mode 100644 index 000000000..5063c9d27 --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/bug-\345\240\261\345\221\212.md" @@ -0,0 +1,29 @@ +--- +name: Bug 報告 +about: 我覺得這是個 Bug +title: "[Bug] 我其實沒有遇到Bug" +labels: '' +assignees: '' + +--- + +**簡要描述 Bug:** +~~我其實沒有遇到Bug~~ + +**預期行爲:** + +**實際行爲:** + +**環境** + - 系統版本: (macOS 14.5) + - 鼠鬚管版本: (1.0.0) + - 方案: (如果你用的是自定義或第三方的方案,且該 Bug 可能與方案有關,請提供方案鏈接) + - [ ] 使用了 Lua: (用了甚麼 Lua 腳本?) + - [ ] 與其它 App 有關: (哪個 App?) + +**我試過:** + - [ ] 我換了內置的方案(如`朙月拼音`)後問題仍存在 + - [ ] 我找到了導致問題出現的具體設置: (何設置?) + - [ ] 這是個新 Bug,以前真的沒有 + - [ ] 我對原因有一些猜想: (你的寳貴想法) + - [ ] 在 Issues(包括已關閉的 Issue) 中未找到相關的報告 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..1c111cb91 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Is this a bug? +title: "[Bug] What have I done?" +labels: '' +assignees: '' + +--- + +**Describe the bug:** +~~Actually it's not a bug after all + +**Expected behavior:** + +**Actual behavior:** + +**Environment** + - OS version: (macOS 14.5) + - Squirrel version: (1.0.0) + - Schema: (If you are using a custom schema, and think it might be related to the schema, please provide a link) + - [ ] Using Lua: (what Lua script do you use?) + - [ ] Related to other apps: (which app?) + +**Things you've tried** + - [ ] I tried a built-in schema (like `luna pinyin`), but the bug persists + - [ ] I found the exact setting that produced this bug: (which one?) + - [ ] This bug is new in this version + - [ ] I think the cause might be: (your thoughts) + - [ ] I don't find a similar report in Issues (including closed Issues) diff --git a/.github/workflows/commit-ci.yml b/.github/workflows/commit-ci.yml index c16cc6bc7..339095617 100644 --- a/.github/workflows/commit-ci.yml +++ b/.github/workflows/commit-ci.yml @@ -12,11 +12,11 @@ jobs: with: submodules: true - - name: Install clang-format - run: brew install clang-format + - name: Install SwiftLint + run: brew install swiftlint - name: Lint - run: make clang-format-lint + run: swiftlint - name: Configure build environment run: | @@ -25,6 +25,12 @@ jobs: - name: Build Squirrel run: ./action-build.sh package + - name: Install periphery + run: brew install peripheryapp/periphery/periphery + + - name: Check Unused Code + run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore + - name: Upload Squirrel artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index d2553dea3..f7b1e8b64 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -9,11 +9,11 @@ jobs: with: submodules: true - - name: Install clang-format - run: brew install clang-format + - name: Install SwiftLint + run: brew install swiftlint - name: Lint - run: make clang-format-lint + run: swiftlint - name: Configure build environment run: | @@ -22,6 +22,12 @@ jobs: - name: Build Squirrel run: ./action-build.sh package + - name: Install periphery + run: brew install peripheryapp/periphery/periphery + + - name: Check Unused Code + run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore + - name: Upload Squirrel artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index dc06b355f..86d66e329 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -5,6 +5,8 @@ on: - '*' branches: - master + paths: + - 'sources/**' workflow_dispatch: jobs: @@ -19,15 +21,21 @@ jobs: fetch-depth: 0 submodules: true - - name: Install clang-format - run: brew install clang-format + - name: Install SwiftLint + run: brew install swiftlint - name: Lint - run: make clang-format-lint + run: swiftlint - name: Build Squirrel run: ./action-build.sh archive + - name: Install periphery + run: brew install peripheryapp/periphery/periphery + + - name: Check Unused Code + run: periphery scan --relative-results --skip-build --index-store-path build/Index.noindex/DataStore + - name: Build changelog id: release_log run: | diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 000000000..fe8b06735 --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,6 @@ +project: Squirrel.xcodeproj +schemes: +- Squirrel +targets: +- Squirrel +format: github-actions diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..95041fec6 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,56 @@ +# By default, SwiftLint uses a set of sensible default rules you can adjust: +disabled_rules: # rule identifiers turned on by default to exclude from running + - force_cast + - force_try + - todo +opt_in_rules: # some rules are turned off by default, so you need to opt-in + +# Alternatively, specify all rules explicitly by uncommenting this option: +# only_rules: # delete `disabled_rules` & `opt_in_rules` if using this +# - empty_parameters +# - vertical_whitespace + +analyzer_rules: # rules run by `swiftlint analyze` + - explicit_self + +included: # case-sensitive paths to include during linting. `--path` is ignored if present + - sources +excluded: # case-sensitive paths to ignore during linting. Takes precedence over `included` + +# If true, SwiftLint will not fail if no lintable files are found. +allow_zero_lintable_files: false + +# If true, SwiftLint will treat all warnings as errors. +strict: false + +# rules that have both warning and error levels, can set just the warning level +# implicitly +line_length: 200 +function_body_length: 200 +# they can set both implicitly with an array +type_body_length: + - 300 # warning + - 400 # error +# or they can set both explicitly +file_length: + warning: 800 + error: 1200 +# naming rules can set warnings/errors for min_length and max_length +# additionally they can set excluded names +type_name: + min_length: 4 # only warning + max_length: # warning and error + warning: 40 + error: 50 + excluded: # excluded via string + allowed_symbols: ["_"] # these are allowed in type names +identifier_name: + min_length: # only min_length + warning: 3 + error: 2 + excluded: [i, URL, of, by] # excluded via string array +large_tuple: + warning: 3 + error: 5 +reporter: "github-actions-logging" # reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, sonarqube, markdown, github-actions-logging, summary) + diff --git a/CHANGELOG.md b/CHANGELOG.md index 244eac758..df9261713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,88 @@ + +## 1.0.2 (2024-06-07) + +#### 其它更新內容 +* bug 修復 + * 未設定暗色主題時,配色不生效 + * 橫排時序號偏高 + * 帶 Alt 的快捷鍵不生效 + * App 特定設置 inline 不生效 + * `good_old_caps_lock` 關閉,且 Caps Lock 啓用時,Shift 無法輸入大寫字母 +* Edge 瀏覧器默認行內編輯 (修 #906) +* 日誌置於 $TMPDIR/rime.squirrel 內,以便查找 + +#### Other Updates +* Bug fixes: + * `color_scheme` doesn't apply in dark mode when `color_scheme_dark` is not set + * Label baseline too high in horizontal orientation + * Shortcut with Alt doesn't work + * inline option in app specific setting doesn't work + * when `good_old_caps_lock` turned to false, and Caps Lock is on, Shift cannot product upper case letter +* Edge defaults to inline mode (fix #906) +* Logs dir is now $TMPDIR/rime.squirrel for clarity + +**Full Changelog**: https://github.com/rime/squirrel/compare/1.0.1...1.0.2 + + +## 1.0.1 (2024-06-01) + +#### 其它更新內容 +* bug 修復 + * 不再注冊爲拉丁輸入法,修復 Caps Lock 切換輸入法時不能切換至西文的問題 + * 修復配色中的 candidate_list_layout, text_orientation 不生效問題 + * 修復字體名無法解析時,字號不生效問題 +* 不再支持 `style/horizontal` 和 `style/vertical` + +#### Other Updates +* Bug fixes: + * Remove Latn repertoire so that switching IME by Caps Lock can toggle Squirrel and Latin input + * Fix: candidate_list_layout, text_orientation do not take effect when put in color scheme + * Fix: font point is ignored when font face is invalid +* Drop support for `style/horizontal` and `style/vertical` + +**Full Changelog**: https://github.com/rime/squirrel/compare/1.0.0...1.0.1 + + +## 1.0.0 (2024-05-30) + +#### 主要功能更新 +* 純 Swift 重寫,代碼更易維護,更易讀,貢獻代碼的門檻更低。今天就來看看源代碼,嘗試動手吧! + +#### 其它更新內容 +* UI 設置【**敬請留意**】 + * `style/candidate_format` 格式修改爲 `"[label]. [candidate] [comment]"`,原格式仍能使用,但建議遷移至更靈活、直觀的新格式 + * `style/horizontal` 將徹底移除,雖然本版程序仍支持,但會被新控件的默認值覆蓋 + 請使用 `candidate_list_layout`: `stacked`/`linear` 和 `text_orientation`: `horizontal`/`vertical` + * `style/label_hilited_color` 已移除,請使用 `style/hilited_candidate_label_color` + * `native` 配色小幅修改,減小字號,更像原生輸入法 +* UI + * 在菜單欄新增日志檔案夾,方便快速進入 + * 序號居中顯示,更像原生輸入法 +* 新增 `--help` 命令行命令,以便查詢支持的命令 +* bug 修復 + * 減少使用輸入大寫時造成中英切換的可能性 +* librime:使用 stdbool 後綴 API,以便與 Swift 更好橋接 + +#### Major Update +* Migrated code to pure Swift, which is easier to code, read and learn. Build your own Squirrel today! + +#### Other Updates +* UI settings (**Breaking Changes**) + * `style/candidate_format` now updated to `"[index]. [candidate] [comment]"`, while the old format still works, please consider migrating to this more readable and flexible format at your convenience + * `style/horizontal` will be dropped, it's still supported but will be overwrite by the default values of new options. + Please adopt `candidate_list_layout`: `stacked`/`linear` and `text_orientation`: `horizontal`/`vertical` + * `style/label_hilited_color` is removed, please use `style/hilited_candidate_label_color` instead + * `native` color scheme is updated with smaller font size, to better match macOS builtin IME +* UI + * Added a menu item for logs folder with easy access + * labels will vertically center if label font is smaller than candidate font, to better match macOS builtin IME +* Added `--help` command line argument +* Bug fixes: + * Reduce the chance that ascii mode may unintentionally switch when pressing to enter Cap case +* librime: Use stdbool flavored API, for better Swift interoperation + +**Full Changelog**: https://github.com/rime/squirrel/compare/0.18...1.0.0 + ## 0.18 (2024-05-04) diff --git a/INSTALL.md b/INSTALL.md index 556fda533..3483eb4c9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -173,6 +173,12 @@ To clean up **dependencies**, including librime, librime plugins, plum and spark make clean-deps ``` -If you want to clean both, do both. +To clean up **packages**, run: + +``` sh +make clean-package +``` + +If you want to clean all above, do all. That's it, a verbal journal. Thanks for riming with Squirrel. diff --git a/Makefile b/Makefile index 0680f4f09..1789c5626 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ install: install-release RIME_BIN_DIR = librime/dist/bin RIME_LIB_DIR = librime/dist/lib +DERIVED_DATA_PATH = build RIME_LIBRARY_FILE_NAME = librime.1.dylib RIME_LIBRARY = lib/$(RIME_LIBRARY_FILE_NAME) @@ -21,6 +22,8 @@ OPENCC_DATA = data/opencc/TSCharacters.ocd2 \ data/opencc/TSPhrases.ocd2 \ data/opencc/t2s.json SPARKLE_FRAMEWORK = Frameworks/Sparkle.framework +SPARKLE_SIGN = package/sign_update +PACKAGE = package/Squirrel.pkg DEPS_CHECK = $(RIME_LIBRARY) $(PLUM_DATA) $(OPENCC_DATA) $(SPARKLE_FRAMEWORK) OPENCC_DATA_OUTPUT = librime/share/opencc/*.* @@ -84,12 +87,6 @@ copy-opencc-data: deps: librime data -clang-format-lint: - find . -name '*.m' -o -name '*.h' -maxdepth 1 | xargs clang-format -Werror --dry-run || { echo Please lint your code by '"'"make clang-format-apply"'"'.; false; } - -clang-format-apply: - find . -name '*.m' -o -name '*.h' -maxdepth 1 | xargs clang-format --verbose -i - ifdef ARCHS BUILD_SETTINGS += ARCHS="$(ARCHS)" BUILD_SETTINGS += ONLY_ACTIVE_ARCH=NO @@ -101,13 +98,17 @@ ifdef MACOSX_DEPLOYMENT_TARGET BUILD_SETTINGS += MACOSX_DEPLOYMENT_TARGET="$(MACOSX_DEPLOYMENT_TARGET)" endif +BUILD_SETTINGS += COMPILER_INDEX_STORE_ENABLE=YES + release: $(DEPS_CHECK) + mkdir -p $(DERIVED_DATA_PATH) bash package/add_data_files - xcodebuild -project Squirrel.xcodeproj -configuration Release $(BUILD_SETTINGS) build + xcodebuild -project Squirrel.xcodeproj -configuration Release -scheme Squirrel -derivedDataPath $(DERIVED_DATA_PATH) $(BUILD_SETTINGS) build debug: $(DEPS_CHECK) + mkdir -p $(DERIVED_DATA_PATH) bash package/add_data_files - xcodebuild -project Squirrel.xcodeproj -configuration Debug $(BUILD_SETTINGS) build + xcodebuild -project Squirrel.xcodeproj -configuration Debug -scheme Squirrel -derivedDataPath $(DERIVED_DATA_PATH) $(BUILD_SETTINGS) build .PHONY: sparkle copy-sparkle-framework @@ -120,6 +121,10 @@ sparkle: xcodebuild -project Sparkle/Sparkle.xcodeproj -scheme sign_update -configuration Release -derivedDataPath Sparkle/build $(BUILD_SETTINGS) build $(MAKE) copy-sparkle-framework +$(SPARKLE_SIGN): + xcodebuild -project Sparkle/Sparkle.xcodeproj -scheme sign_update -configuration Release -derivedDataPath Sparkle/build $(BUILD_SETTINGS) build + cp Sparkle/build/Build/Products/Release/sign_update package/ + copy-sparkle-framework: mkdir -p Frameworks cp -RP Sparkle/build/Release/Sparkle.framework Frameworks/ @@ -131,11 +136,11 @@ clean-sparkle: .PHONY: package archive -package: release +$(PACKAGE): ifdef DEV_ID - bash package/sign_app $(DEV_ID) + bash package/sign_app "$(DEV_ID)" "$(DERIVED_DATA_PATH)" endif - bash package/make_package + bash package/make_package "$(DERIVED_DATA_PATH)" ifdef DEV_ID productsign --sign "Developer ID Installer: $(DEV_ID)" package/Squirrel.pkg package/Squirrel-signed.pkg rm package/Squirrel.pkg @@ -144,8 +149,9 @@ ifdef DEV_ID xcrun stapler staple package/Squirrel.pkg endif -archive: package - bash package/make_archive +package: release $(PACKAGE) + +archive: package $(SPARKLE_SIGN) DSTROOT = /Library/Input Methods SQUIRREL_APP_ROOT = $(DSTROOT)/Squirrel.app @@ -157,12 +163,12 @@ permission-check: install-debug: debug permission-check rm -rf "$(SQUIRREL_APP_ROOT)" - cp -R build/Debug/Squirrel.app "$(DSTROOT)" + cp -R $(DERIVED_DATA_PATH)/Build/Products/Debug/Squirrel.app "$(DSTROOT)" DSTROOT="$(DSTROOT)" RIME_NO_PREBUILD=1 bash scripts/postinstall install-release: release permission-check rm -rf "$(SQUIRREL_APP_ROOT)" - cp -R build/Release/Squirrel.app "$(DSTROOT)" + cp -R $(DERIVED_DATA_PATH)/Build/Products/Release/Squirrel.app "$(DSTROOT)" DSTROOT="$(DSTROOT)" bash scripts/postinstall .PHONY: clean clean-deps @@ -176,6 +182,11 @@ clean: rm data/plum/* > /dev/null 2>&1 || true rm data/opencc/* > /dev/null 2>&1 || true +clean-package: + rm -rf package/*appcast.xml > /dev/null 2>&1 || true + rm -rf package/*.pkg > /dev/null 2>&1 || true + rm -rf package/sign_update > /dev/null 2>&1 || true + clean-deps: $(MAKE) -C plum clean $(MAKE) -C librime clean diff --git a/Sparkle b/Sparkle index 5264c01d3..41847a58c 160000 --- a/Sparkle +++ b/Sparkle @@ -1 +1 @@ -Subproject commit 5264c01d37e07f73c579f3f01a90a5605453c577 +Subproject commit 41847a58cdef7506b257591fcca6f9495df591d4 diff --git a/Squirrel.xcodeproj/project.pbxproj b/Squirrel.xcodeproj/project.pbxproj index 15e8a6ac9..2d30fde03 100644 --- a/Squirrel.xcodeproj/project.pbxproj +++ b/Squirrel.xcodeproj/project.pbxproj @@ -593,7 +593,7 @@ CODE_SIGN_ENTITLEMENTS = resources/Squirrel.entitlements; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1.0.0; + CURRENT_PROJECT_VERSION = 1.0.3; DEAD_CODE_STRIPPING = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -649,6 +649,7 @@ CODE_SIGN_ENTITLEMENTS = resources/Squirrel.entitlements; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1.0.0; + CURRENT_PROJECT_VERSION = 1.0.3; DEAD_CODE_STRIPPING = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/action-install.sh b/action-install.sh index 434ce5213..5f314f242 100755 --- a/action-install.sh +++ b/action-install.sh @@ -3,7 +3,8 @@ set -e rime_version=latest -rime_git_hash=6b1b41f +rime_git_hash=2f89098 +sparkle_version=2.6.2 rime_archive="rime-${rime_git_hash}-macOS-universal.tar.bz2" rime_download_url="https://github.com/rime/librime/releases/download/${rime_version}/${rime_archive}" @@ -11,17 +12,24 @@ rime_download_url="https://github.com/rime/librime/releases/download/${rime_vers rime_deps_archive="rime-deps-${rime_git_hash}-macOS-universal.tar.bz2" rime_deps_download_url="https://github.com/rime/librime/releases/download/${rime_version}/${rime_deps_archive}" +sparkle_archive="Sparkle-${sparkle_version}.tar.xz" +sparkle_download_url="https://github.com/sparkle-project/Sparkle/releases/download/${sparkle_version}/${sparkle_archive}" + mkdir -p download && ( cd download [ -z "${no_download}" ] && curl -LO "${rime_download_url}" tar --bzip2 -xf "${rime_archive}" [ -z "${no_download}" ] && curl -LO "${rime_deps_download_url}" tar --bzip2 -xf "${rime_deps_archive}" + [ -z "${no_download}" ] && curl -LO "${sparkle_download_url}" + tar -xJf "${sparkle_archive}" ) mkdir -p librime/share +mkdir -p Frameworks cp -R download/dist librime/ cp -R download/share/opencc librime/share/ +cp -R download/Sparkle.framework Frameworks/ # skip building librime and opencc-data; use downloaded artifacts make copy-rime-binaries copy-opencc-data diff --git a/data/squirrel.yaml b/data/squirrel.yaml index bc85e9745..a97de2932 100644 --- a/data/squirrel.yaml +++ b/data/squirrel.yaml @@ -21,8 +21,7 @@ style: #color_scheme: solarized_light #color_scheme_dark: solarized_dark - # Deprecated since 0.36, Squirrel 0.15 - #horizontal: false + # horizontal is Deprecated since 0.36, Squirrel 0.15, removed since 1.0.1 candidate_list_layout: stacked # stacked | linear text_orientation: horizontal # horizontal | vertical inline_preedit: true @@ -34,6 +33,8 @@ style: mutual_exclusive: false # Whether to use a translucent background. Only visible when background color is transparent translucency: false + # Enable to show small arrows that indicates if paging up/down is possible + show_paging: false corner_radius: 7 hilited_corner_radius: 0 @@ -54,14 +55,14 @@ style: # and %c is replaced by "[label]" candidate_format: '[label]. [candidate] [comment]' - # adjust the base line of vertical text + # adjust the base line of text #base_offset: 0 font_face: 'Avenir' - font_point: 15 + font_point: 16 #label_font_face: 'Avenir' - label_font_point: 12 + #label_font_point: 12 #comment_font_face: 'Avenir' - comment_font_point: 15 + #comment_font_point: 16 preset_color_schemes: native: @@ -386,6 +387,9 @@ app_options: com.google.Chrome: # 規避 https://github.com/rime/squirrel/issues/435 inline: true + com.microsoft.edgemac: + # 規避 https://github.com/rime/squirrel/issues/906 + inline: true ru.keepcoder.Telegram: # 規避 https://github.com/rime/squirrel/issues/475 inline: true diff --git a/package/make_package b/package/make_package index 9c96ff91d..61ce71b69 100755 --- a/package/make_package +++ b/package/make_package @@ -1,5 +1,6 @@ #!/bin/bash +DERIVED_DATA_PATH=$1 BUNDLE_IDENTIFIER='im.rime.inputmethod.Squirrel' INSTALL_LOCATION='/Library/Input Methods' @@ -8,7 +9,7 @@ source common.sh pkgbuild \ --info PackageInfo \ - --root "${PROJECT_ROOT}/build/Release" \ + --root "${PROJECT_ROOT}/${DERIVED_DATA_PATH}/Build/Products/Release" \ --filter '.*\.swiftmodule$' \ --component-plist Squirrel-component.plist \ --identifier "${BUNDLE_IDENTIFIER}" \ diff --git a/package/sign_app b/package/sign_app index c89f5b31a..ee842821d 100755 --- a/package/sign_app +++ b/package/sign_app @@ -1,9 +1,11 @@ #! /bin/bash # enconding: utf-8 -appDir="build/Release/Squirrel.app" +DEV_ID=$1 +DERIVED_DATA_PATH=$2 +appDir="${DERIVED_DATA_PATH}/Build/Products/Release/Squirrel.app" entitlement="resources/Squirrel.entitlements" -codesign --deep --force --options runtime --timestamp --sign "Developer ID Application: $1" --entitlements "$entitlement" --verbose "$appDir"; +codesign --deep --force --options runtime --timestamp --sign "Developer ID Application: ${DEV_ID}" --entitlements "$entitlement" --verbose "$appDir"; spctl -a -vv "$appDir"; diff --git a/resources/Info.plist b/resources/Info.plist index 8a4fa1d88..442ff9188 100644 --- a/resources/Info.plist +++ b/resources/Info.plist @@ -2,6 +2,8 @@ + TISInputSourceID + im.rime.inputmethod.Squirrel CFBundleDevelopmentRegion English CFBundleExecutable diff --git a/sources/BridgingFunctions.swift b/sources/BridgingFunctions.swift index e88023525..a4b15aaa3 100644 --- a/sources/BridgingFunctions.swift +++ b/sources/BridgingFunctions.swift @@ -8,7 +8,8 @@ import Foundation protocol DataSizeable { - var data_size: Int32 { get set } + // swiftlint:disable:next identifier_name + var data_size: Int32 { get set } } extension RimeContext_stdbool: DataSizeable {} @@ -30,16 +31,57 @@ extension DataSizeable { value.data_size = Int32(MemoryLayout.size - offset) return value } - + mutating func setCString(_ swiftString: String, to keypath: WritableKeyPath?>) { swiftString.withCString { cStr in // Duplicate the string to create a persisting C string let mutableCStr = strdup(cStr) // Free the existing string if there is one if let existing = self[keyPath: keypath] { - free(UnsafeMutableRawPointer(mutating: existing)) + free(UnsafeMutableRawPointer(mutating: existing)) } self[keyPath: keypath] = UnsafePointer(mutableCStr) } } } + +infix operator ?= : AssignmentPrecedence +// swiftlint:disable:next operator_whitespace +func ?=(left: inout T, right: T?) { + if let right = right { + left = right + } +} +// swiftlint:disable:next operator_whitespace +func ?=(left: inout T?, right: T?) { + if let right = right { + left = right + } +} + +extension NSRange { + static let empty = NSRange(location: NSNotFound, length: 0) +} + +extension NSPoint { + static func += (lhs: inout Self, rhs: Self) { + lhs.x += rhs.x + lhs.y += rhs.y + } + static func - (lhs: Self, rhs: Self) -> Self { + Self.init(x: lhs.x - rhs.x, y: lhs.y - rhs.y) + } + static func -= (lhs: inout Self, rhs: Self) { + lhs.x -= rhs.x + lhs.y -= rhs.y + } + static func * (lhs: Self, rhs: CGFloat) -> Self { + Self.init(x: lhs.x * rhs, y: lhs.y * rhs) + } + static func / (lhs: Self, rhs: CGFloat) -> Self { + Self.init(x: lhs.x / rhs, y: lhs.y / rhs) + } + var length: CGFloat { + sqrt(pow(self.x, 2) + pow(self.y, 2)) + } +} diff --git a/sources/InputSource.swift b/sources/InputSource.swift index c2ad699e2..a0c80add3 100644 --- a/sources/InputSource.swift +++ b/sources/InputSource.swift @@ -17,7 +17,7 @@ final class SquirrelInstaller { private lazy var inputSources: [String: TISInputSource] = { var inputSources = [String: TISInputSource]() var matchingSources = [InputMode: TISInputSource]() - let sourceList = TISCreateInputSourceList(nil, true).takeUnretainedValue() as! [TISInputSource] + let sourceList = TISCreateInputSourceList(nil, true).takeRetainedValue() as! [TISInputSource] for inputSource in sourceList { let sourceIDRef = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) guard let sourceID = unsafeBitCast(sourceIDRef, to: CFString?.self) as String? else { continue } @@ -26,7 +26,7 @@ final class SquirrelInstaller { } return inputSources }() - + func enabledModes() -> [InputMode] { var enabledModes = Set() for (mode, inputSource) in getInputSource(modes: InputMode.allCases) { @@ -39,7 +39,7 @@ final class SquirrelInstaller { } return Array(enabledModes) } - + func register() { let enabledInputModes = enabledModes() if !enabledInputModes.isEmpty { @@ -50,32 +50,38 @@ final class SquirrelInstaller { TISRegisterInputSource(SquirrelApp.appDir as CFURL) print("Registered input source from \(SquirrelApp.appDir)") } - - func enable(modes: [InputMode] = [.primary]) { + + func enable(modes: [InputMode] = []) { let enabledInputModes = enabledModes() - if !enabledInputModes.isEmpty { + if !enabledInputModes.isEmpty && modes.isEmpty { print("User already enabled Squirrel method(s): \(enabledInputModes.map { $0.rawValue })") // keep user's manually enabled input modes. return } - for (mode, inputSource) in getInputSource(modes: modes) { + let modesToEnable = modes.isEmpty ? [.primary] : modes + for (mode, inputSource) in getInputSource(modes: modesToEnable) { if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), !enabled { let error = TISEnableInputSource(inputSource) - print("Enable \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)"); + print("Enable \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)") } } } - - func select(mode: InputMode = .primary) { + + func select(mode: InputMode? = nil) { let enabledInputModes = enabledModes() - if !enabledInputModes.contains(mode) { - print("Target not enabled yet: \(mode.rawValue)") - return + let modeToSelect = mode ?? .primary + if !enabledInputModes.contains(modeToSelect) { + if mode != nil { + enable(modes: [modeToSelect]) + } else { + print("Default method not enabled yet: \(modeToSelect.rawValue)") + return + } } - for (mode, inputSource) in getInputSource(modes: [mode]) { + for (mode, inputSource) in getInputSource(modes: [modeToSelect]) { if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), - let selectable = getBool(for: inputSource, key: kTISPropertyInputSourceIsSelectCapable), - let selected = getBool(for: inputSource, key: kTISPropertyInputSourceIsSelected), + let selectable = getBool(for: inputSource, key: kTISPropertyInputSourceIsSelectCapable), + let selected = getBool(for: inputSource, key: kTISPropertyInputSourceIsSelected), enabled && selectable && !selected { let error = TISSelectInputSource(inputSource) print("Selection \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)") @@ -84,16 +90,17 @@ final class SquirrelInstaller { } } } - - func disable(modes: [InputMode] = InputMode.allCases) { - for (mode, inputSource) in getInputSource(modes: modes) { + + func disable(modes: [InputMode] = []) { + let modesToDisable = modes.isEmpty ? InputMode.allCases : modes + for (mode, inputSource) in getInputSource(modes: modesToDisable) { if let enabled = getBool(for: inputSource, key: kTISPropertyInputSourceIsEnabled), enabled { - TISDisableInputSource(inputSource) - print("Disabled input source: \(mode.rawValue)") + let error = TISDisableInputSource(inputSource) + print("Disable \(error == noErr ? "succeeds" : "fails") for input source: \(mode.rawValue)") } } } - + private func getInputSource(modes: [InputMode]) -> [InputMode: TISInputSource] { var matchingSources = [InputMode: TISInputSource]() for mode in modes { @@ -103,7 +110,7 @@ final class SquirrelInstaller { } return matchingSources } - + private func getBool(for inputSource: TISInputSource, key: CFString!) -> Bool? { let enabledRef = TISGetInputSourceProperty(inputSource, key) guard let enabled = unsafeBitCast(enabledRef, to: CFBoolean?.self) else { return nil } diff --git a/sources/MacOSKeyCodes.swift b/sources/MacOSKeyCodes.swift index 0613fc648..dd2ec3f7d 100644 --- a/sources/MacOSKeyCodes.swift +++ b/sources/MacOSKeyCodes.swift @@ -1,5 +1,5 @@ // -// MacOSKeyCOdes.swift +// MacOSKeyCodes.swift // Squirrel // // Created by Leo Liu on 5/9/24. @@ -9,7 +9,7 @@ import Carbon import AppKit struct SquirrelKeycode { - + static func osxModifiersToRime(modifiers: NSEvent.ModifierFlags) -> UInt32 { var ret: UInt32 = 0 if modifiers.contains(.capsLock) { @@ -29,19 +29,19 @@ struct SquirrelKeycode { } return ret } - + static func osxKeycodeToRime(keycode: UInt16, keychar: Character?, shift: Bool, caps: Bool) -> UInt32 { if let code = keycodeMappings[Int(keycode)] { return UInt32(code) } - + if let keychar = keychar, keychar.isASCII, let codeValue = keychar.unicodeScalars.first?.value { // NOTE: IBus/Rime use different keycodes for uppercase/lowercase letters. - if keychar.isLowercase && (shift || caps) { + if keychar.isLowercase && (shift != caps) { // lowercase -> Uppercase return keychar.uppercased().unicodeScalars.first!.value } - + switch codeValue { case 0x20...0x7e: return codeValue @@ -57,11 +57,15 @@ struct SquirrelKeycode { break } } - + + if let code = additionalCodeMappings[Int(keycode)] { + return UInt32(code) + } + return UInt32(XK_VoidSymbol) } - - private static let keycodeMappings: Dictionary = [ + + private static let keycodeMappings: [Int: Int32] = [ // modifiers kVK_CapsLock: XK_Caps_Lock, kVK_Command: XK_Super_L, // XK_Meta_L? @@ -73,7 +77,7 @@ struct SquirrelKeycode { kVK_RightOption: XK_Alt_R, kVK_Shift: XK_Shift_L, kVK_RightShift: XK_Shift_R, - + // special kVK_Delete: XK_BackSpace, kVK_Escape: XK_Escape, @@ -82,7 +86,7 @@ struct SquirrelKeycode { kVK_Return: XK_Return, kVK_Space: XK_space, kVK_Tab: XK_Tab, - + // function kVK_F1: XK_F1, kVK_F2: XK_F2, @@ -104,7 +108,7 @@ struct SquirrelKeycode { kVK_F18: XK_F18, kVK_F19: XK_F19, kVK_F20: XK_F20, - + // cursor kVK_UpArrow: XK_Up, kVK_DownArrow: XK_Down, @@ -114,7 +118,7 @@ struct SquirrelKeycode { kVK_PageDown: XK_Page_Down, kVK_Home: XK_Home, kVK_End: XK_End, - + // keypad kVK_ANSI_Keypad0: XK_KP_0, kVK_ANSI_Keypad1: XK_KP_1, @@ -134,13 +138,71 @@ struct SquirrelKeycode { kVK_ANSI_KeypadPlus: XK_KP_Add, kVK_ANSI_KeypadDivide: XK_KP_Divide, kVK_ANSI_KeypadEnter: XK_KP_Enter, - + // other kVK_ISO_Section: XK_section, kVK_JIS_Yen: XK_yen, kVK_JIS_Underscore: XK_underscore, kVK_JIS_KeypadComma: XK_comma, kVK_JIS_Eisu: XK_Eisu_Shift, - kVK_JIS_Kana: XK_Kana_Shift, + kVK_JIS_Kana: XK_Kana_Shift + ] + + private static let additionalCodeMappings: [Int: Int32] = [ + // numbers + kVK_ANSI_0: XK_0, + kVK_ANSI_1: XK_1, + kVK_ANSI_2: XK_2, + kVK_ANSI_3: XK_3, + kVK_ANSI_4: XK_4, + kVK_ANSI_5: XK_5, + kVK_ANSI_6: XK_6, + kVK_ANSI_7: XK_7, + kVK_ANSI_8: XK_8, + kVK_ANSI_9: XK_9, + + // pubct + kVK_ANSI_RightBracket: XK_bracketright, + kVK_ANSI_LeftBracket: XK_bracketleft, + kVK_ANSI_Comma: XK_comma, + kVK_ANSI_Grave: XK_grave, + kVK_ANSI_Period: XK_period, + // kVK_VolumeUp: + // kVK_VolumeDown: + // kVK_Mute: + kVK_ANSI_Semicolon: XK_semicolon, + kVK_ANSI_Quote: XK_apostrophe, + kVK_ANSI_Backslash: XK_backslash, + kVK_ANSI_Minus: XK_minus, + kVK_ANSI_Slash: XK_slash, + kVK_ANSI_Equal: XK_equal, + + // letters + kVK_ANSI_A: XK_a, + kVK_ANSI_B: XK_b, + kVK_ANSI_C: XK_c, + kVK_ANSI_D: XK_d, + kVK_ANSI_E: XK_e, + kVK_ANSI_F: XK_f, + kVK_ANSI_G: XK_g, + kVK_ANSI_H: XK_h, + kVK_ANSI_I: XK_i, + kVK_ANSI_J: XK_j, + kVK_ANSI_K: XK_k, + kVK_ANSI_L: XK_l, + kVK_ANSI_M: XK_m, + kVK_ANSI_N: XK_n, + kVK_ANSI_O: XK_o, + kVK_ANSI_P: XK_p, + kVK_ANSI_Q: XK_q, + kVK_ANSI_R: XK_r, + kVK_ANSI_S: XK_s, + kVK_ANSI_T: XK_t, + kVK_ANSI_U: XK_u, + kVK_ANSI_V: XK_v, + kVK_ANSI_W: XK_w, + kVK_ANSI_X: XK_x, + kVK_ANSI_Y: XK_y, + kVK_ANSI_Z: XK_z ] } diff --git a/sources/Main.swift b/sources/Main.swift index 22dfe6291..8aaa10a16 100644 --- a/sources/Main.swift +++ b/sources/Main.swift @@ -10,18 +10,20 @@ import InputMethodKit @main struct SquirrelApp { - static let userDir = if let pw = getpwuid(getuid()) { - URL(fileURLWithFileSystemRepresentation: pw.pointee.pw_dir, isDirectory: true, relativeTo: nil).appending(components: "Library", "Rime") + static let userDir = if let pwuid = getpwuid(getuid()) { + URL(fileURLWithFileSystemRepresentation: pwuid.pointee.pw_dir, isDirectory: true, relativeTo: nil).appending(components: "Library", "Rime") } else { try! FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("Rime", isDirectory: true) } static let appDir = "/Library/Input Library/Squirrel.app".withCString { dir in URL(fileURLWithFileSystemRepresentation: dir, isDirectory: false, relativeTo: nil) } - + static let logDir = FileManager.default.temporaryDirectory.appending(component: "rime.squirrel", directoryHint: .isDirectory) + + // swiftlint:disable:next cyclomatic_complexity static func main() { let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee - + let handled = autoreleasepool { let installer = SquirrelInstaller() let args = CommandLine.arguments @@ -67,7 +69,7 @@ struct SquirrelApp { return true case "--build": // Notification - SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_update", comment: ""), msgId: "deploy") + SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_update", comment: "")) // Build all schemas in current directory var builderTraits = RimeTraits.rimeStructInit() builderTraits.setCString("rime.squirrel-builder", to: \.app_name) @@ -90,7 +92,7 @@ struct SquirrelApp { if handled { return } - + autoreleasepool { // find the bundle identifier and then initialize the input method server let main = Bundle.main @@ -102,10 +104,10 @@ struct SquirrelApp { let delegate = SquirrelApplicationDelegate() app.delegate = delegate app.setActivationPolicy(.accessory) - + // opencc will be configured with relative dictionary paths FileManager.default.changeCurrentDirectoryPath(main.sharedSupportPath!) - + if NSApp.squirrelAppDelegate.problematicLaunchDetected() { print("Problematic launch detected!") let args = ["Problematic launch detected! Squirrel may be suffering a crash due to improper configuration. Revert previous modifications to see if the problem recurs."] @@ -121,7 +123,7 @@ struct SquirrelApp { NSApp.squirrelAppDelegate.loadSettings() print("Squirrel reporting!") } - + // finally run everything app.run() print("Squirrel is quitting...") @@ -129,7 +131,7 @@ struct SquirrelApp { } return } - + static let helpDoc = """ Supported arguments: Perform actions: diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index f9b1952c9..c60376040 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -13,7 +13,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta static let rimeWikiURL = URL(string: "https://github.com/rime/home/wiki")! static let updateNotificationIdentifier = "SquirrelUpdateNotification" static let notificationIdentifier = "SquirrelNotification" - + let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee var config: SquirrelConfig? var panel: SquirrelPanel? @@ -22,7 +22,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta var supportsGentleScheduledUpdateReminders: Bool { true } - + func standardUserDriverWillHandleShowingUpdate(_ handleShowingUpdate: Bool, forUpdate update: SUAppcastItem, state: SPUUserUpdateState) { NSApp.setActivationPolicy(.regular) if !state.userInitiated { @@ -34,56 +34,56 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta UNUserNotificationCenter.current().add(request) } } - + func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { NSApp.dockTile.badgeLabel = "" UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [Self.updateNotificationIdentifier]) } - + func standardUserDriverWillFinishUpdateSession() { NSApp.setActivationPolicy(.accessory) } - + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.notification.request.identifier == Self.updateNotificationIdentifier && response.actionIdentifier == UNNotificationDefaultActionIdentifier { updateController.updater.checkForUpdates() } - + completionHandler() } - + func applicationWillFinishLaunching(_ notification: Notification) { panel = SquirrelPanel(position: .zero) addObservers() } - + func applicationWillTerminate(_ notification: Notification) { + // swiftlint:disable:next notification_center_detachment NotificationCenter.default.removeObserver(self) DistributedNotificationCenter.default().removeObserver(self) panel?.hide() } - + func deploy() { print("Start maintenance...") self.shutdownRime() self.startRime(fullCheck: true) self.loadSettings() } - + func syncUserData() { print("Sync user data") _ = rimeAPI.sync_user_data() } - + func openLogFolder() { - let logDir = FileManager.default.temporaryDirectory - NSWorkspace.shared.open(logDir) + NSWorkspace.shared.open(SquirrelApp.logDir) } - + func openRimeFolder() { NSWorkspace.shared.open(SquirrelApp.userDir) } - + func checkForUpdates() { if updateController.updater.canCheckForUpdates { print("Checking for updates") @@ -92,14 +92,14 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta print("Cannot check for updates") } } - + func openWiki() { NSWorkspace.shared.open(Self.rimeWikiURL) } - - static func showMessage(msgText: String?, msgId: String?) { + + static func showMessage(msgText: String?) { let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .provisional]) { granted, error in + center.requestAuthorization(options: [.alert, .provisional]) { _, error in if let error = error { print("User notification authorization error: \(error.localizedDescription)") } @@ -121,31 +121,27 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta } } } - + func setupRime() { - let userDataDir = SquirrelApp.userDir - let fileManager = FileManager.default - if !fileManager.fileExists(atPath: userDataDir.path()) { - do { - try fileManager.createDirectory(at: userDataDir, withIntermediateDirectories: true) - } catch { - print("Error creating user data directory: \(userDataDir.path())") - } - } + createDirIfNotExist(path: SquirrelApp.userDir) + createDirIfNotExist(path: SquirrelApp.logDir) + // swiftlint:disable identifier_name let notification_handler: @convention(c) (UnsafeMutableRawPointer?, RimeSessionId, UnsafePointer?, UnsafePointer?) -> Void = notificationHandler let context_object = Unmanaged.passUnretained(self).toOpaque() + // swiftlint:enable identifier_name rimeAPI.set_notification_handler(notification_handler, context_object) - + var squirrelTraits = RimeTraits.rimeStructInit() squirrelTraits.setCString(Bundle.main.sharedSupportPath!, to: \.shared_data_dir) - squirrelTraits.setCString(userDataDir.path(), to: \.user_data_dir) + squirrelTraits.setCString(SquirrelApp.userDir.path(), to: \.user_data_dir) + squirrelTraits.setCString(SquirrelApp.logDir.path(), to: \.log_dir) squirrelTraits.setCString("Squirrel", to: \.distribution_code_name) squirrelTraits.setCString("鼠鬚管", to: \.distribution_name) squirrelTraits.setCString(Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as! String, to: \.distribution_version) squirrelTraits.setCString("rime.squirrel", to: \.app_name) rimeAPI.setup(&squirrelTraits) } - + func startRime(fullCheck: Bool) { print("Initializing la rime...") rimeAPI.initialize(nil) @@ -158,20 +154,20 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta // print("[DEBUG] maintenance fails") } } - + func loadSettings() { config = SquirrelConfig() if !config!.openBaseConfig() { return } - + enableNotifications = config!.getString("show_notifications_when") != "never" if let panel = panel, let config = self.config { panel.load(config: config, forDarkMode: false) panel.load(config: config, forDarkMode: true) } } - + func loadSettings(for schemaID: String) { if schemaID.count == 0 || schemaID.first == "." { return @@ -188,12 +184,12 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta } schema.close() } - + // prevent freezing the system func problematicLaunchDetected() -> Bool { var detected = false let logFile = FileManager.default.temporaryDirectory.appendingPathComponent("squirrel_launch.json", conformingTo: .json) - //print("[DEBUG] archive: \(logFile)") + // print("[DEBUG] archive: \(logFile)") do { let archive = try Data(contentsOf: logFile, options: [.uncached]) let decoder = JSONDecoder() @@ -203,7 +199,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta detected = true } } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { - + } catch { print("Error occurred during processing launch time archive: \(error.localizedDescription)") return detected @@ -218,19 +214,19 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta } return detected } - + // add an awakeFromNib item so that we can set the action method. Note that // any menuItems without an action will be disabled when displayed in the Text // Input Menu. func addObservers() { let center = NSWorkspace.shared.notificationCenter center.addObserver(forName: NSWorkspace.willPowerOffNotification, object: nil, queue: nil, using: workspaceWillPowerOff) - + let notifCenter = DistributedNotificationCenter.default() notifCenter.addObserver(forName: .init("SquirrelReloadNotification"), object: nil, queue: nil, using: rimeNeedsReload) notifCenter.addObserver(forName: .init("SquirrelSyncNotification"), object: nil, queue: nil, using: rimeNeedsSync) } - + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { print("Squirrel is quitting.") rimeAPI.cleanup_all_sessions() @@ -241,17 +237,17 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessionId: RimeSessionId, messageTypeC: UnsafePointer?, messageValueC: UnsafePointer?) { let delegate: SquirrelApplicationDelegate = Unmanaged.fromOpaque(contextObject!).takeUnretainedValue() - + let messageType = messageTypeC.map { String(cString: $0) } let messageValue = messageValueC.map { String(cString: $0) } if messageType == "deploy" { switch messageValue { case "start": - SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_start", comment: ""), msgId: messageType) + SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_start", comment: "")) case "success": - SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_success", comment: ""), msgId: messageType) + SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_success", comment: "")) case "failure": - SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_failure", comment: ""), msgId: messageType) + SquirrelApplicationDelegate.showMessage(msgText: NSLocalizedString("deploy_failure", comment: "")) default: break } @@ -290,26 +286,37 @@ private extension SquirrelApplicationDelegate { panel?.updateStatus(long: msgTextLong ?? "", short: msgTextShort ?? "") } } - + func shutdownRime() { config?.close() rimeAPI.finalize() } - - func workspaceWillPowerOff(notification: Notification) { + + func workspaceWillPowerOff(_: Notification) { print("Finalizing before logging out.") self.shutdownRime() } - - func rimeNeedsReload(notification: Notification) { + + func rimeNeedsReload(_: Notification) { print("Reloading rime on demand.") self.deploy() } - - func rimeNeedsSync(notification: Notification) { + + func rimeNeedsSync(_: Notification) { print("Sync rime on demand.") self.syncUserData() } + + func createDirIfNotExist(path: URL) { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: path.path()) { + do { + try fileManager.createDirectory(at: path, withIntermediateDirectories: true) + } catch { + print("Error creating user data directory: \(path.path())") + } + } + } } extension NSApplication { diff --git a/sources/SquirrelConfig.swift b/sources/SquirrelConfig.swift index 45cb1da0d..3ae74a94c 100644 --- a/sources/SquirrelConfig.swift +++ b/sources/SquirrelConfig.swift @@ -10,28 +10,26 @@ import AppKit final class SquirrelConfig { private let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee private(set) var isOpen = false - var schemaID: String = "" - - private var cache: Dictionary = [:] + + private var cache: [String: Any] = [:] private var config: RimeConfig = .init() private var baseConfig: SquirrelConfig? - + func openBaseConfig() -> Bool { close() isOpen = rimeAPI.config_open("squirrel", &config) return isOpen } - + func open(schemaID: String, baseConfig: SquirrelConfig?) -> Bool { close() isOpen = rimeAPI.schema_open(schemaID, &config) if isOpen { - self.schemaID = schemaID self.baseConfig = baseConfig } return isOpen } - + func close() { if isOpen { _ = rimeAPI.config_close(&config) @@ -39,11 +37,11 @@ final class SquirrelConfig { isOpen = false } } - + deinit { close() } - + func has(section: String) -> Bool { if isOpen { var iterator: RimeConfigIterator = .init() @@ -54,7 +52,7 @@ final class SquirrelConfig { } return false } - + func getBool(_ option: String) -> Bool? { if let cachedValue = cachedValue(of: Bool.self, forKey: option) { return cachedValue @@ -66,19 +64,7 @@ final class SquirrelConfig { } return baseConfig?.getBool(option) } - - func getInt(_ option: String) -> Int? { - if let cachedValue = cachedValue(of: Int.self, forKey: option) { - return cachedValue - } - var value: Int32 = 0 - if isOpen && rimeAPI.config_get_int(&config, option, &value) { - cache[option] = value - return Int(value) - } - return baseConfig?.getInt(option) - } - + func getDouble(_ option: String) -> CGFloat? { if let cachedValue = cachedValue(of: Double.self, forKey: option) { return cachedValue @@ -90,7 +76,7 @@ final class SquirrelConfig { } return baseConfig?.getDouble(option) } - + func getString(_ option: String) -> String? { if let cachedValue = cachedValue(of: String.self, forKey: option) { return cachedValue @@ -101,7 +87,7 @@ final class SquirrelConfig { } return baseConfig?.getString(option) } - + func getColor(_ option: String, inSpace colorSpace: SquirrelTheme.RimeColorSpace) -> NSColor? { if let cachedValue = cachedValue(of: NSColor.self, forKey: option) { return cachedValue @@ -112,10 +98,10 @@ final class SquirrelConfig { } return baseConfig?.getColor(option, inSpace: colorSpace) } - - func getAppOptions(_ appName: String) -> Dictionary { + + func getAppOptions(_ appName: String) -> [String: Bool] { let rootKey = "app_options/\(appName)" - var appOptions = [String : Bool]() + var appOptions = [String: Bool]() var iterator = RimeConfigIterator() _ = rimeAPI.config_begin_map(&iterator, &config, rootKey) while rimeAPI.config_next(&iterator) { @@ -127,53 +113,25 @@ final class SquirrelConfig { rimeAPI.config_end(&iterator) return appOptions } - - // isLinear - func updateCandidateListLayout(prefix: String) -> Bool { - let candidateListLayout = getString("\(prefix)/candidate_list_layout") - switch candidateListLayout { - case "stacked": - return false - case "linear": - return true - default: - // Deprecated. Not to be confused with text_orientation: horizontal - return getBool("\(prefix)/horizontal") ?? false - } - } - - // isVertical - func updateTextOrientation(prefix: String) -> Bool { - let textOrientation = getString("\(prefix)/text_orientation") - switch textOrientation { - case "horizontal": - return false - case "vertical": - return true - default: - // Deprecated. - return getBool("\(prefix)/vertical") ?? false - } - } } private extension SquirrelConfig { func cachedValue(of: T.Type, forKey key: String) -> T? { return cache[key] as? T } - + func color(from colorStr: String, inSpace colorSpace: SquirrelTheme.RimeColorSpace) -> NSColor? { 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) { - let (_, a, b, g, r) = matched.output - return color(alpha: Int(a, radix: 16)!, red: Int(r, radix: 16)!, green: Int(g, radix: 16)!, blue: Int(b, radix: 16)!, colorSpace: colorSpace) + let (_, alpha, blue, green, red) = matched.output + return color(alpha: Int(alpha, radix: 16)!, red: Int(red, radix: 16)!, green: Int(green, radix: 16)!, blue: Int(blue, radix: 16)!, colorSpace: colorSpace) } else if let matched = try? /0x([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})([A-Fa-f0-9]{2})/.wholeMatch(in: colorStr) { - let (_, b, g, r) = matched.output - return color(alpha: 255, red: Int(r, radix: 16)!, green: Int(g, radix: 16)!, blue: Int(b, radix: 16)!, colorSpace: colorSpace) + let (_, blue, green, red) = matched.output + return color(alpha: 255, red: Int(red, radix: 16)!, green: Int(green, radix: 16)!, blue: Int(blue, radix: 16)!, colorSpace: colorSpace) } else { return nil } } - + func color(alpha: Int, red: Int, green: Int, blue: Int, colorSpace: SquirrelTheme.RimeColorSpace) -> NSColor { switch colorSpace { case .displayP3: diff --git a/sources/SquirrelInputController.swift b/sources/SquirrelInputController.swift index d119fe0b6..91a8b1bd4 100644 --- a/sources/SquirrelInputController.swift +++ b/sources/SquirrelInputController.swift @@ -9,11 +9,11 @@ import InputMethodKit final class SquirrelInputController: IMKInputController { private static let keyRollOver = 50 - - private var client: IMKTextInput? + + private weak var client: IMKTextInput? private let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee private var preedit: String = "" - private var selRange: NSRange = NSMakeRange(NSNotFound, 0) + private var selRange: NSRange = .empty private var caretPos: Int = 0 private var lastModifiers: NSEvent.ModifierFlags = .init() private var session: RimeSessionId = 0 @@ -27,30 +27,31 @@ final class SquirrelInputController: IMKInputController { private var chordTimer: Timer? private var chordDuration: TimeInterval = 0 private var currentApp: String = "" - + + // swiftlint:disable:next cyclomatic_complexity override func handle(_ event: NSEvent!, client sender: Any!) -> Bool { let modifiers = event.modifierFlags let changes = lastModifiers.symmetricDifference(modifiers) - + // Return true to indicate the the key input was received and dealt with. // Key processing will not continue in that case. In other words the // system will not deliver a key down event to the application. // Returning false means the original key down will be passed on to the client. var handled = false - + if session == 0 || !rimeAPI.find_session(session) { createSession() if session == 0 { return false } } - - let app = (sender as? IMKTextInput)?.bundleIdentifier() - if let app = app, currentApp != app { + + self.client ?= sender as? IMKTextInput + if let app = client?.bundleIdentifier(), currentApp != app { currentApp = app updateAppOptions() } - + switch event.type { case .flagsChanged: if lastModifiers == modifiers { @@ -62,7 +63,7 @@ final class SquirrelInputController: IMKInputController { // For flags-changed event, keyCode is available since macOS 10.15 // (#715) let rimeKeycode: UInt32 = SquirrelKeycode.osxKeycodeToRime(keycode: event.keyCode, keychar: nil, shift: false, caps: false) - + if changes.contains(.capsLock) { // NOTE: rime assumes XK_Caps_Lock to be sent before modifier changes, // while NSFlagsChanged event has the flag changed already. @@ -70,39 +71,39 @@ final class SquirrelInputController: IMKInputController { rimeModifiers ^= kLockMask.rawValue _ = processKey(rimeKeycode, modifiers: rimeModifiers) } - + // Need to process release before modifier down. Because // sometimes release event is delayed to next modifier keydown. var buffer = [(keycode: UInt32, modifier: UInt32)]() - for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { - if changes.contains(flag) { - if modifiers.contains(flag) { // New modifier - buffer.append((keycode: rimeKeycode, modifier: rimeModifiers)) - } else { // Release - buffer.insert((keycode: rimeKeycode, modifier: rimeModifiers | kReleaseMask.rawValue), at: 0) - } + for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] where changes.contains(flag) { + if modifiers.contains(flag) { // New modifier + buffer.append((keycode: rimeKeycode, modifier: rimeModifiers)) + } else { // Release + buffer.insert((keycode: rimeKeycode, modifier: rimeModifiers | kReleaseMask.rawValue), at: 0) } } for (keycode, modifier) in buffer { _ = processKey(keycode, modifiers: modifier) } - + lastModifiers = modifiers rimeUpdate() - + case .keyDown: // ignore Command+X hotkeys. if modifiers.contains(.command) { break } - + let keyCode = event.keyCode var keyChars = event.charactersIgnoringModifiers - if let code = keyChars?.first, !code.isLetter { + let capitalModifiers = modifiers.isSubset(of: [.shift, .capsLock]) + if let code = keyChars?.first, + (capitalModifiers && !code.isLetter) || (!capitalModifiers && !code.isASCII) { keyChars = event.characters } // print("[DEBUG] KEYDOWN client: \(sender ?? "nil"), modifiers: \(modifiers), keyCode: \(keyCode), keyChars: [\(keyChars ?? "empty")]") - + // translate osx keyevents to rime keyevents if let char = keyChars?.first { let rimeKeycode = SquirrelKeycode.osxKeycodeToRime(keycode: keyCode, keychar: char, @@ -114,14 +115,14 @@ final class SquirrelInputController: IMKInputController { rimeUpdate() } } - + default: break } - + return handled } - + func selectCandidate(_ index: Int) -> Bool { let success = rimeAPI.select_candidate_on_current_page(session, index) if success { @@ -129,7 +130,8 @@ final class SquirrelInputController: IMKInputController { } return success } - + + // swiftlint:disable:next identifier_name func page(up: Bool) -> Bool { var handled = false handled = rimeAPI.change_page(session, up) @@ -138,7 +140,7 @@ final class SquirrelInputController: IMKInputController { } return handled } - + func moveCaret(forward: Bool) -> Bool { let currentCaretPos = rimeAPI.get_caret_pos(session) guard let input = rimeAPI.get_input(session) else { return false } @@ -157,13 +159,14 @@ final class SquirrelInputController: IMKInputController { rimeUpdate() return true } - + override func recognizedEvents(_ sender: Any!) -> Int { // print("[DEBUG] recognizedEvents:") return Int(NSEvent.EventTypeMask.Element(arrayLiteral: .keyDown, .flagsChanged).rawValue) } - + override func activateServer(_ sender: Any!) { + self.client ?= sender as? IMKTextInput // print("[DEBUG] activateServer:") var keyboardLayout = NSApp.squirrelAppDelegate.config?.getString("keyboard_layout") ?? "" if keyboardLayout == "last" || keyboardLayout == "" { @@ -174,29 +177,30 @@ final class SquirrelInputController: IMKInputController { keyboardLayout = "com.apple.keylayout.\(keyboardLayout)" } if keyboardLayout != "" { - (sender as? IMKTextInput)?.overrideKeyboard(withKeyboardNamed: keyboardLayout) + client?.overrideKeyboard(withKeyboardNamed: keyboardLayout) } preedit = "" } - + override init!(server: IMKServer!, delegate: Any!, client: Any!) { self.client = client as? IMKTextInput // print("[DEBUG] initWithServer: \(server ?? .init()) delegate: \(delegate ?? "nil") client:\(client ?? "nil")") super.init(server: server, delegate: delegate, client: client) createSession() } - + override func deactivateServer(_ sender: Any!) { // print("[DEBUG] deactivateServer: \(sender ?? "nil")") hidePalettes() commitComposition(sender) + client = nil } - + override func hidePalettes() { NSApp.squirrelAppDelegate.panel?.hide() super.hidePalettes() } - + /*! @method @abstract Called when a user action was taken that ends an input session. @@ -208,6 +212,7 @@ final class SquirrelInputController: IMKInputController { to clean up if that is necessary. */ override func commitComposition(_ sender: Any!) { + self.client ?= sender as? IMKTextInput // print("[DEBUG] commitComposition: \(sender ?? "nil")") // commit raw input if session != 0 { @@ -217,7 +222,7 @@ final class SquirrelInputController: IMKInputController { } } } - + override func menu() -> NSMenu! { let deploy = NSMenuItem(title: NSLocalizedString("Deploy", comment: "Menu item"), action: #selector(deploy), keyEquivalent: "`") deploy.target = self @@ -232,7 +237,7 @@ final class SquirrelInputController: IMKInputController { wiki.target = self let update = NSMenuItem(title: NSLocalizedString("Check for updates...", comment: "Menu item"), action: #selector(checkForUpdates), keyEquivalent: "") update.target = self - + let menu = NSMenu() menu.addItem(deploy) menu.addItem(sync) @@ -240,48 +245,49 @@ final class SquirrelInputController: IMKInputController { menu.addItem(setting) menu.addItem(wiki) menu.addItem(update) - + return menu } - + @objc func deploy() { NSApp.squirrelAppDelegate.deploy() } - + @objc func syncUserData() { NSApp.squirrelAppDelegate.syncUserData() } - + @objc func openLogFolder() { NSApp.squirrelAppDelegate.openLogFolder() } - + @objc func openRimeFolder() { NSApp.squirrelAppDelegate.openRimeFolder() } - + @objc func checkForUpdates() { NSApp.squirrelAppDelegate.checkForUpdates() } - + @objc func openWiki() { NSApp.squirrelAppDelegate.openWiki() } - + deinit { destroySession() } } private extension SquirrelInputController { - - func onChordTimer(_ timer: Timer) { + + func onChordTimer(_: Timer) { // chord release triggered by timer var processedKeys = false if chordKeyCount > 0 && session != 0 { // simulate key-ups for i in 0..= Self.keyRollOver { // you are cheating. only one human typist (fingers <= 10) is supported. @@ -316,7 +320,7 @@ private extension SquirrelInputController { } chordTimer = Timer.scheduledTimer(withTimeInterval: chordDuration, repeats: false, block: onChordTimer) } - + func clearChord() { chordKeyCount = 0 if let timer = chordTimer { @@ -326,19 +330,19 @@ private extension SquirrelInputController { chordTimer = nil } } - + func createSession() { guard let app = client?.bundleIdentifier() else { return } print("createSession: \(app)") currentApp = app session = rimeAPI.create_session() schemaId = "" - + if session != 0 { updateAppOptions() } } - + func updateAppOptions() { if currentApp == "" { return @@ -350,7 +354,7 @@ private extension SquirrelInputController { } } } - + func destroySession() { // print("[DEBUG] destroySession:") if session != 0 { @@ -359,10 +363,10 @@ private extension SquirrelInputController { } clearChord() } - + func processKey(_ rimeKeycode: UInt32, modifiers rimeModifiers: UInt32) -> Bool { // TODO add special key event preprocessing here - + // with linear candidate list, arrow keys may behave differently. if let panel = NSApp.squirrelAppDelegate.panel { if panel.linear != rimeAPI.get_option(session, "_linear") { @@ -373,12 +377,12 @@ private extension SquirrelInputController { rimeAPI.set_option(session, "_vertical", panel.vertical) } } - + let handled = rimeAPI.process_key(session, Int32(rimeKeycode), Int32(rimeModifiers)) // print("[DEBUG] rime_keycode: \(rimeKeycode), rime_modifiers: \(rimeModifiers), handled = \(handled)") - + // TODO add special key event postprocessing here - + if !handled { let isVimBackInCommandMode = rimeKeycode == XK_Escape || ((rimeModifiers & kControlMask.rawValue != 0) && (rimeKeycode == XK_c || rimeKeycode == XK_C || rimeKeycode == XK_bracketleft)) if isVimBackInCommandMode && rimeAPI.get_option(session, "vim_mode") && @@ -400,50 +404,52 @@ private extension SquirrelInputController { clearChord() } } - + return handled } - + func rimeConsumeCommittedText() { var commitText = RimeCommit.rimeStructInit() if rimeAPI.get_commit(session, &commitText) { if let text = commitText.text { commit(string: String(cString: text)) - _ = rimeAPI.free_commit(&commitText) } + _ = rimeAPI.free_commit(&commitText) } } - + + // swiftlint:disable:next cyclomatic_complexity func rimeUpdate() { // print("[DEBUG] rimeUpdate") rimeConsumeCommittedText() - + var status = RimeStatus_stdbool.rimeStructInit() if rimeAPI.get_status(session, &status) { // enable schema specific ui style + // swiftlint:disable:next identifier_name if let schema_id = status.schema_id, schemaId == "" || schemaId != String(cString: schema_id) { schemaId = String(cString: schema_id) NSApp.squirrelAppDelegate.loadSettings(for: schemaId) // inline preedit if let panel = NSApp.squirrelAppDelegate.panel { - inlinePreedit = panel.inlinePreedit && (!rimeAPI.get_option(session, "no_inline") || rimeAPI.get_option(session, "inline")) - inlineCandidate = panel.inlineCandidate && (!rimeAPI.get_option(session, "no_inline") || rimeAPI.get_option(session, "inline")) + inlinePreedit = (panel.inlinePreedit && !rimeAPI.get_option(session, "no_inline")) || rimeAPI.get_option(session, "inline") + inlineCandidate = panel.inlineCandidate && !rimeAPI.get_option(session, "no_inline") // if not inline, embed soft cursor in preedit string rimeAPI.set_option(session, "soft_cursor", !inlinePreedit) } } _ = rimeAPI.free_status(&status) } - + var ctx = RimeContext_stdbool.rimeStructInit() if rimeAPI.get_context(session, &ctx) { // update preedit text let preedit = ctx.composition.preedit.map({ String(cString: $0) }) ?? "" - + let start = String.Index(preedit.utf8.index(preedit.utf8.startIndex, offsetBy: Int(ctx.composition.sel_start)), within: preedit) ?? preedit.startIndex let end = String.Index(preedit.utf8.index(preedit.utf8.startIndex, offsetBy: Int(ctx.composition.sel_end)), within: preedit) ?? preedit.startIndex let caretPos = String.Index(preedit.utf8.index(preedit.utf8.startIndex, offsetBy: Int(ctx.composition.cursor_pos)), within: preedit) ?? preedit.startIndex - + if inlineCandidate { var candidatePreview = ctx.commit_text_preview.map { String(cString: $0) } ?? "" if inlinePreedit { @@ -471,10 +477,10 @@ private extension SquirrelInputController { // each character in preedit. note this is a full-shape space U+3000; // using half shape characters like "..." will result in an unstable // baseline when composing Chinese characters. - show(preedit: preedit.isEmpty ? "" : " ", selRange: NSMakeRange(0, 0), caretPos: 0) + show(preedit: preedit.isEmpty ? "" : " ", selRange: NSRange(location: 0, length: 0), caretPos: 0) } } - + // update candidates let numCandidates = Int(ctx.menu.num_candidates) var candidates = [String]() @@ -485,54 +491,63 @@ private extension SquirrelInputController { comments.append(candidate.comment.map { String(cString: $0) } ?? "") } var labels = [String]() + // swiftlint:disable identifier_name if let select_keys = ctx.menu.select_keys { - labels = Array(arrayLiteral: String(cString: select_keys)) + labels = String(cString: select_keys).map { String($0) } } else if let select_labels = ctx.select_labels { let pageSize = Int(ctx.menu.page_size) for i in 0.. 0 { - let attrs = mark(forStyle: kTSMHiliteConvertedText, at: NSMakeRange(0, start))! as! [NSAttributedString.Key : Any] - attrString.setAttributes(attrs, range: NSMakeRange(0, start)) + let attrs = mark(forStyle: kTSMHiliteConvertedText, at: NSRange(location: 0, length: start))! as! [NSAttributedString.Key: Any] + attrString.setAttributes(attrs, range: NSRange(location: 0, length: start)) } - let remainingRange = NSMakeRange(start, preedit.utf16.count - start) - let attrs = mark(forStyle: kTSMHiliteSelectedRawText, at: remainingRange)! as! [NSAttributedString.Key : Any] + let remainingRange = NSRange(location: start, length: preedit.utf16.count - start) + let attrs = mark(forStyle: kTSMHiliteSelectedRawText, at: remainingRange)! as! [NSAttributedString.Key: Any] attrString.setAttributes(attrs, range: remainingRange) - client.setMarkedText(attrString, selectionRange: NSMakeRange(caretPos, 0), replacementRange: NSMakeRange(NSNotFound, 0)) + client.setMarkedText(attrString, selectionRange: NSRange(location: caretPos, length: 0), replacementRange: .empty) } - - func showPanel(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted: Int) { + + // swiftlint:disable:next function_parameter_count + func showPanel(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted: Int, page: Int, lastPage: Bool) { // print("[DEBUG] showPanelWithPreedit:...:") guard let client = client else { return } var inputPos = NSRect() @@ -540,7 +555,8 @@ private extension SquirrelInputController { if let panel = NSApp.squirrelAppDelegate.panel { panel.position = inputPos panel.inputController = self - panel.update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: highlighted, update: true) + panel.update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, + highlighted: highlighted, page: page, lastPage: lastPage, update: true) } } } diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 93287b7ef..008ba640b 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -11,16 +11,16 @@ final class SquirrelPanel: NSPanel { private let view: SquirrelView private let back: NSVisualEffectView var inputController: SquirrelInputController? - + var position: NSRect private var screenRect: NSRect = .zero private var maxHeight: CGFloat = 0 - + private var statusMessage: String = "" private var statusTimer: Timer? - + private var preedit: String = "" - private var selRange: NSRange = .init(location: NSNotFound, length: 0) + private var selRange: NSRange = .empty private var caretPos: Int = 0 private var candidates: [String] = .init() private var comments: [String] = .init() @@ -29,7 +29,10 @@ final class SquirrelPanel: NSPanel { private var cursorIndex: Int = 0 private var scrollDirection: CGVector = .zero private var scrollTime: Date = .distantPast - + private var page: Int = 0 + private var lastPage: Bool = true + private var pagingUp: Bool? + init(position: NSRect) { self.position = position self.view = SquirrelView(frame: position) @@ -50,7 +53,7 @@ final class SquirrelPanel: NSPanel { contentView.addSubview(view.textView) self.contentView = contentView } - + var linear: Bool { view.currentTheme.linear } @@ -63,24 +66,36 @@ final class SquirrelPanel: NSPanel { var inlineCandidate: Bool { view.currentTheme.inlineCandidate } - + + // swiftlint:disable:next cyclomatic_complexity override func sendEvent(_ event: NSEvent) { switch event.type { case .leftMouseDown: - let (index, _) = view.click(at: mousePosition()) - if let index = index, index >= 0 && index < candidates.count { + let (index, _, pagingUp) = view.click(at: mousePosition()) + if let pagingUp { + self.pagingUp = pagingUp + } else { + self.pagingUp = nil + } + if let index, index >= 0 && index < candidates.count { self.index = index } case .leftMouseUp: - let (index, preeditIndex) = view.click(at: mousePosition()) - if let preeditIndex = preeditIndex, preeditIndex >= 0 && preeditIndex < preedit.utf16.count { + let (index, preeditIndex, pagingUp) = view.click(at: mousePosition()) + + if let pagingUp, pagingUp == self.pagingUp { + _ = inputController?.page(up: pagingUp) + } else { + self.pagingUp = nil + } + if let preeditIndex, preeditIndex >= 0 && preeditIndex < preedit.utf16.count { if preeditIndex < caretPos { _ = inputController?.moveCaret(forward: true) } else if preeditIndex > caretPos { _ = inputController?.moveCaret(forward: false) } } - if let index = index, index == self.index && index >= 0 && index < candidates.count { + if let index, index == self.index && index >= 0 && index < candidates.count { _ = inputController?.selectCandidate(index) } case .mouseEntered: @@ -88,25 +103,26 @@ final class SquirrelPanel: NSPanel { case .mouseExited: acceptsMouseMovedEvents = false if cursorIndex != index { - update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, update: false) + update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, page: page, lastPage: lastPage, update: false) } + pagingUp = nil case .mouseMoved: - let (index, _) = view.click(at: mousePosition()) + let (index, _, _) = view.click(at: mousePosition()) if let index = index, cursorIndex != index && index >= 0 && index < candidates.count { - update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, update: false) + update(preedit: preedit, selRange: selRange, caretPos: caretPos, candidates: candidates, comments: comments, labels: labels, highlighted: index, page: page, lastPage: lastPage, update: false) } case .scrollWheel: if event.phase == .began { scrollDirection = .zero - // Scrollboard span + // Scrollboard span } else if event.phase == .ended || (event.phase == .init(rawValue: 0) && event.momentumPhase != .init(rawValue: 0)) { if abs(scrollDirection.dx) > abs(scrollDirection.dy) && abs(scrollDirection.dx) > 10 { _ = inputController?.page(up: (scrollDirection.dx < 0) == vertical) } else if abs(scrollDirection.dx) < abs(scrollDirection.dy) && abs(scrollDirection.dy) > 10 { - _ = inputController?.page(up: scrollDirection.dx > 0) + _ = inputController?.page(up: scrollDirection.dy > 0) } scrollDirection = .zero - // Mouse scroll wheel + // Mouse scroll wheel } else if event.phase == .init(rawValue: 0) && event.momentumPhase == .init(rawValue: 0) { if scrollTime.timeIntervalSinceNow < -1 { scrollDirection = .zero @@ -130,16 +146,17 @@ final class SquirrelPanel: NSPanel { } super.sendEvent(event) } - + func hide() { statusTimer?.invalidate() statusTimer = nil orderOut(nil) maxHeight = 0 } - + // Main function to add attributes to text output from librime - func update(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted index: Int, update: Bool) { + // swiftlint:disable:next cyclomatic_complexity function_parameter_count + func update(preedit: String, selRange: NSRange, caretPos: Int, candidates: [String], comments: [String], labels: [String], highlighted index: Int, page: Int, lastPage: Bool, update: Bool) { if update { self.preedit = preedit self.selRange = selRange @@ -148,9 +165,11 @@ final class SquirrelPanel: NSPanel { self.comments = comments self.labels = labels self.index = index + self.page = page + self.lastPage = lastPage } cursorIndex = index - + if !candidates.isEmpty || !preedit.isEmpty { statusMessage = "" statusTimer?.invalidate() @@ -164,46 +183,40 @@ final class SquirrelPanel: NSPanel { } return } - + let theme = view.currentTheme currentScreen() - + let text = NSMutableAttributedString() - var preeditRange = NSMakeRange(NSNotFound, 0) - var highlightedPreeditRange = NSMakeRange(NSNotFound, 0) - + let preeditRange: NSRange + let highlightedPreeditRange: NSRange + // preedit if !preedit.isEmpty { - let line = NSMutableAttributedString() - let startIndex = String.Index(utf16Offset: selRange.location, in: preedit) - let endIndex = String.Index(utf16Offset: selRange.upperBound, in: preedit) - if selRange.location > 0 { - line.append(NSAttributedString(string: String(preedit[.. 0 { - let highlightedPreeditStart = line.length - line.append(NSAttributedString(string: String(preedit[startIndex.. 1 && i < labels.count { labels[i] @@ -217,63 +230,63 @@ final class SquirrelPanel: NSPanel { } else { "" } - + let candidate = candidates[i].precomposedStringWithCanonicalMapping let comment = comments[i].precomposedStringWithCanonicalMapping - + let line = NSMutableAttributedString(string: theme.candidateFormat, attributes: labelAttrs) for range in line.string.ranges(of: /\[candidate\]/) { let convertedRange = convert(range: range, in: line.string) line.addAttributes(attrs, range: convertedRange) if candidate.count <= 5 { - line.addAttribute(.noBreak, value: true, range: NSMakeRange(convertedRange.location+1, convertedRange.length-1)) + line.addAttribute(.noBreak, value: true, range: NSRange(location: convertedRange.location+1, length: convertedRange.length-1)) } } for range in line.string.ranges(of: /\[comment\]/) { line.addAttributes(commentAttrs, range: convert(range: range, in: line.string)) } - line.mutableString.replaceOccurrences(of: "[label]", with: label, range: NSMakeRange(0, line.length)) + line.mutableString.replaceOccurrences(of: "[label]", with: label, range: NSRange(location: 0, length: line.length)) let labeledLine = line.copy() as! NSAttributedString - line.mutableString.replaceOccurrences(of: "[candidate]", with: candidate, range: NSMakeRange(0, line.length)) - line.mutableString.replaceOccurrences(of: "[comment]", with: comment, range: NSMakeRange(0, line.length)) - + line.mutableString.replaceOccurrences(of: "[candidate]", with: candidate, range: NSRange(location: 0, length: line.length)) + line.mutableString.replaceOccurrences(of: "[comment]", with: comment, range: NSRange(location: 0, length: line.length)) + if line.length <= 10 { - line.addAttribute(.noBreak, value: true, range: NSMakeRange(1, line.length-1)) + line.addAttribute(.noBreak, value: true, range: NSRange(location: 1, length: line.length-1)) } - + let lineSeparator = NSAttributedString(string: linear ? " " : "\n", attributes: attrs) if i > 0 { text.append(lineSeparator) } let str = lineSeparator.mutableCopy() as! NSMutableAttributedString if vertical { - str.addAttribute(.verticalGlyphForm, value: 1, range: NSMakeRange(0, str.length)) + str.addAttribute(.verticalGlyphForm, value: 1, range: NSRange(location: 0, length: str.length)) } view.separatorWidth = str.boundingRect(with: .zero).width - + let paragraphStyleCandidate = (i == 0 ? theme.firstParagraphStyle : theme.paragraphStyle).mutableCopy() as! NSMutableParagraphStyle if linear { paragraphStyleCandidate.paragraphSpacingBefore -= theme.linespace paragraphStyleCandidate.lineSpacing = theme.linespace } if !linear, let labelEnd = labeledLine.string.firstMatch(of: /\[(candidate|comment)\]/)?.range.lowerBound { - let labelString = labeledLine.attributedSubstring(from: NSMakeRange(0, labelEnd.utf16Offset(in: labeledLine.string))) + let labelString = labeledLine.attributedSubstring(from: NSRange(location: 0, length: labelEnd.utf16Offset(in: labeledLine.string))) let labelWidth = labelString.boundingRect(with: .zero, options: [.usesLineFragmentOrigin]).width paragraphStyleCandidate.headIndent = labelWidth } - line.addAttribute(.paragraphStyle, value: paragraphStyleCandidate, range: NSMakeRange(0, line.length)) - - candidateRanges.append(NSMakeRange(text.length, line.length)) + line.addAttribute(.paragraphStyle, value: paragraphStyleCandidate, range: NSRange(location: 0, length: line.length)) + + candidateRanges.append(NSRange(location: text.length, length: line.length)) text.append(line) } // text done! view.textView.textContentStorage?.attributedString = text view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) - view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange) + view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange, canPageUp: page > 0, canPageDown: !lastPage) show() } - + func updateStatus(long longMessage: String, short shortMessage: String) { let theme = view.currentTheme switch theme.statusMessageType { @@ -291,7 +304,7 @@ final class SquirrelPanel: NSPanel { } } } - + func load(config: SquirrelConfig, forDarkMode isDark: Bool) { if isDark { view.darkTheme = SquirrelTheme() @@ -309,19 +322,17 @@ private extension SquirrelPanel { point = self.convertPoint(fromScreen: point) return view.convert(point, from: nil) } - + func currentScreen() { if let screen = NSScreen.main { screenRect = screen.frame } - for screen in NSScreen.screens { - if NSPointInRect(position.origin, screen.frame) { - screenRect = screen.frame - break - } + for screen in NSScreen.screens where screen.frame.contains(position.origin) { + screenRect = screen.frame + break } } - + func maxTextWidth() -> CGFloat { let theme = view.currentTheme let font: NSFont = theme.font @@ -334,23 +345,23 @@ private extension SquirrelPanel { } return maxWidth } - + // Get the window size, the windows will be the dirtyRect in // SquirrelView.drawRect + // swiftlint:disable:next cyclomatic_complexity func show() { currentScreen() let theme = view.currentTheme - let requestedAppearance: NSAppearance? = theme.native ? nil : NSAppearance(named: .aqua) - if self.appearance != requestedAppearance { - self.appearance = requestedAppearance + if !view.darkTheme.available { + self.appearance = NSAppearance(named: .aqua) } - + // Break line if the text is too long, based on screen size. let textWidth = maxTextWidth() let maxTextHeight = vertical ? screenRect.width - theme.edgeInset.width * 2 : screenRect.height - theme.edgeInset.height * 2 - view.textContainer.size = NSMakeSize(textWidth, maxTextHeight) - - var panelRect = NSZeroRect + view.textContainer.size = NSSize(width: textWidth, height: maxTextHeight) + + var panelRect = NSRect.zero // in vertical mode, the width and height are interchanged var contentRect = view.contentRect if theme.memorizeSize && (vertical && position.midY / screenRect.height < 0.5) || @@ -359,17 +370,18 @@ private extension SquirrelPanel { maxHeight = contentRect.width } else { contentRect.size.width = maxHeight - view.textContainer.size = NSMakeSize(maxHeight, maxTextHeight) + view.textContainer.size = NSSize(width: maxHeight, height: maxTextHeight) } } if vertical { - panelRect.size = NSMakeSize(min(0.95 * screenRect.width, contentRect.height + theme.edgeInset.height * 2), - min(0.95 * screenRect.height, contentRect.width + theme.edgeInset.width * 2)) + panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.height + theme.edgeInset.height * 2), + height: min(0.95 * screenRect.height, contentRect.width + theme.edgeInset.width * 2) + theme.pagingOffset) + // To avoid jumping up and down while typing, use the lower screen when // typing on upper, and vice versa if position.midY / screenRect.height >= 0.5 { - panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset } else { panelRect.origin.y = position.maxY + SquirrelTheme.offsetHeight } @@ -380,9 +392,10 @@ private extension SquirrelPanel { panelRect.origin.x += preeditRect.height + theme.edgeInset.width } } else { - panelRect.size = NSMakeSize(min(0.95 * screenRect.width, contentRect.width + theme.edgeInset.width * 2), - min(0.95 * screenRect.height, contentRect.height + theme.edgeInset.height * 2)) - panelRect.origin = NSMakePoint(position.minX, position.minY - SquirrelTheme.offsetHeight - panelRect.height) + panelRect.size = NSSize(width: min(0.95 * screenRect.width, contentRect.width + theme.edgeInset.width * 2), + height: min(0.95 * screenRect.height, contentRect.height + theme.edgeInset.height * 2)) + panelRect.size.width += theme.pagingOffset + panelRect.origin = NSPoint(x: position.minX - theme.pagingOffset, y: position.minY - SquirrelTheme.offsetHeight - panelRect.height) } if panelRect.maxX > screenRect.maxX { panelRect.origin.x = screenRect.maxX - panelRect.width @@ -404,24 +417,27 @@ private extension SquirrelPanel { panelRect.origin.y = screenRect.minY } self.setFrame(panelRect, display: true) - + // rotate the view, the core in vertical mode! if vertical { contentView!.boundsRotation = -90 - contentView!.setBoundsOrigin(NSMakePoint(0, panelRect.width)) + contentView!.setBoundsOrigin(NSPoint(x: 0, y: panelRect.width)) } else { contentView!.boundsRotation = 0 contentView!.setBoundsOrigin(.zero) } view.textView.boundsRotation = 0 view.textView.setBoundsOrigin(.zero) - + view.frame = contentView!.bounds view.textView.frame = contentView!.bounds + view.textView.frame.size.width -= theme.pagingOffset + view.textView.frame.origin.x += theme.pagingOffset view.textView.textContainerInset = theme.edgeInset - + if theme.translucency { back.frame = contentView!.bounds + back.frame.size.width += theme.pagingOffset back.appearance = NSApp.effectiveAppearance back.isHidden = false } else { @@ -432,22 +448,23 @@ private extension SquirrelPanel { orderFront(nil) // voila! } - + func show(status message: String) { let theme = view.currentTheme let text = NSMutableAttributedString(string: message, attributes: theme.attrs) - text.addAttribute(.paragraphStyle, value: theme.paragraphStyle, range: NSMakeRange(0, text.length)) + text.addAttribute(.paragraphStyle, value: theme.paragraphStyle, range: NSRange(location: 0, length: text.length)) view.textContentStorage.attributedString = text view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) - view.drawView(candidateRanges: [NSMakeRange(0, text.length)], hilightedIndex: -1, preeditRange: NSMakeRange(NSNotFound, 0), highlightedPreeditRange: NSMakeRange(NSNotFound, 0)) + view.drawView(candidateRanges: [NSRange(location: 0, length: text.length)], hilightedIndex: -1, + preeditRange: .empty, highlightedPreeditRange: .empty, canPageUp: false, canPageDown: false) show() - + statusTimer?.invalidate() statusTimer = Timer.scheduledTimer(withTimeInterval: SquirrelTheme.showStatusDuration, repeats: false) { _ in self.hide() } } - + func convert(range: Range, in string: String) -> NSRange { let startPos = range.lowerBound.utf16Offset(in: string) let endPos = range.upperBound.utf16Offset(in: string) diff --git a/sources/SquirrelTheme.swift b/sources/SquirrelTheme.swift index 76df94719..190a91fae 100644 --- a/sources/SquirrelTheme.swift +++ b/sources/SquirrelTheme.swift @@ -7,24 +7,12 @@ import AppKit -infix operator ?= : AssignmentPrecedence -fileprivate func ?=(left: inout T, right: T?) { - if let right = right { - left = right - } -} -fileprivate func ?=(left: inout T?, right: T?) { - if let right = right { - left = right - } -} - final class SquirrelTheme { static let offsetHeight: CGFloat = 5 static let defaultFontSize: CGFloat = NSFont.systemFontSize static let showStatusDuration: Double = 1.2 static let defaultFont = NSFont.userFont(ofSize: defaultFontSize)! - + enum StatusMessageType: String { case long, short, mix } @@ -38,18 +26,19 @@ final class SquirrelTheme { } } } - - var native = true - var memorizeSize = true + + private(set) var available = true + private(set) var native = true + private(set) var memorizeSize = true private var colorSpace: RimeColorSpace = .sRGB - + var backgroundColor: NSColor = .windowBackgroundColor var highlightedPreeditColor: NSColor? var highlightedBackColor: NSColor? = .selectedTextBackgroundColor var preeditBackgroundColor: NSColor? var candidateBackColor: NSColor? var borderColor: NSColor? - + private var textColor: NSColor = .tertiaryLabelColor private var highlightedTextColor: NSColor = .labelColor private var candidateTextColor: NSColor = .secondaryLabelColor @@ -58,113 +47,131 @@ final class SquirrelTheme { private var highlightedCandidateLabelColor: NSColor? private var commentTextColor: NSColor? = .tertiaryLabelColor private var highlightedCommentTextColor: NSColor? - - var cornerRadius: CGFloat = 0 - var hilitedCornerRadius: CGFloat = 0 - var surroundingExtraExpansion: CGFloat = 0 - var shadowSize: CGFloat = 0 - var borderWidth: CGFloat = 0 - var borderHeight: CGFloat = 0 - var linespace: CGFloat = 0 - var preeditLinespace: CGFloat = 0 - var baseOffset: CGFloat = 0 - var alpha: CGFloat = 1 - - var translucency = false - var mutualExclusive = false - var linear = false - var vertical = false - var inlinePreedit = false - var inlineCandidate = false - - private var fonts = Array() - private var labelFonts = Array() - private var commentFonts = Array() - - private var candidateTemplate = "[label]. [candidate] [comment]" - var statusMessageType: StatusMessageType = .mix - - var font: NSFont { - return combineFonts(fonts) ?? Self.defaultFont - } - var labelFont: NSFont? { - return combineFonts(labelFonts) - } - var commentFont: NSFont? { - return combineFonts(commentFonts) - } - var attrs: [NSAttributedString.Key : Any] { - [.foregroundColor: candidateTextColor, - .font: font, - .baselineOffset: baseOffset] - } - var highlightedAttrs: [NSAttributedString.Key : Any] { - [.foregroundColor: highlightedCandidateTextColor, - .font: font, - .baselineOffset: baseOffset] - } - var labelAttrs: [NSAttributedString.Key : Any] { - return [.foregroundColor: candidateLabelColor ?? blendColor(foregroundColor: self.candidateTextColor, backgroundColor: self.backgroundColor), - .font: labelFont ?? font, - .baselineOffset: baseOffset + (labelFont != nil && !vertical ? (font.pointSize - labelFont!.pointSize) / 2 : 0)] - } - var labelHighlightedAttrs: [NSAttributedString.Key : Any] { - return [.foregroundColor: highlightedCandidateLabelColor ?? blendColor(foregroundColor: highlightedCandidateTextColor, backgroundColor: highlightedBackColor), - .font: labelFont ?? font, - .baselineOffset: baseOffset + (labelFont != nil && !vertical ? (font.pointSize - labelFont!.pointSize) / 2 : 0)] - } - var commentAttrs: [NSAttributedString.Key : Any] { - return [.foregroundColor: commentTextColor ?? candidateTextColor, - .font: commentFont ?? font, - .baselineOffset: baseOffset + (commentFont != nil && !vertical ? (font.pointSize - commentFont!.pointSize) / 2 : 0)] - } - var commentHighlightedAttrs: [NSAttributedString.Key : Any] { - return [.foregroundColor: highlightedCommentTextColor ?? highlightedCandidateTextColor, - .font: commentFont ?? font, - .baselineOffset: baseOffset + (commentFont != nil && !vertical ? (font.pointSize - commentFont!.pointSize) / 2 : 0)] - } - var preeditAttrs: [NSAttributedString.Key : Any] { - [.foregroundColor: textColor, - .font: font, - .baselineOffset: baseOffset] - } - var preeditHighlightedAttrs: [NSAttributedString.Key : Any] { - [.foregroundColor: highlightedTextColor, - .font: font, - .baselineOffset: baseOffset] + + private(set) var cornerRadius: CGFloat = 0 + private(set) var hilitedCornerRadius: CGFloat = 0 + private(set) var surroundingExtraExpansion: CGFloat = 0 + private(set) var shadowSize: CGFloat = 0 + private(set) var borderWidth: CGFloat = 0 + private(set) var borderHeight: CGFloat = 0 + private(set) var linespace: CGFloat = 0 + private(set) var preeditLinespace: CGFloat = 0 + private(set) var baseOffset: CGFloat = 0 + private(set) var alpha: CGFloat = 1 + + private(set) var translucency = false + private(set) var mutualExclusive = false + private(set) var linear = false + private(set) var vertical = false + private(set) var inlinePreedit = false + private(set) var inlineCandidate = false + private(set) var showPaging = false + + private var fonts = [NSFont]() + private var labelFonts = [NSFont]() + private var commentFonts = [NSFont]() + private var fontSize: CGFloat? + private var labelFontSize: CGFloat? + private var commentFontSize: CGFloat? + + private var _candidateFormat = "[label]. [candidate] [comment]" + private(set) var statusMessageType: StatusMessageType = .mix + + private var defaultFont: NSFont { + if let size = fontSize { + Self.defaultFont.withSize(size) + } else { + Self.defaultFont + } } - - var firstParagraphStyle: NSParagraphStyle { + + private(set) lazy var font: NSFont = combineFonts(fonts, size: fontSize) ?? defaultFont + private(set) lazy var labelFont: NSFont = { + if let font = combineFonts(labelFonts, size: labelFontSize ?? fontSize) { + return font + } else if let size = labelFontSize { + return self.font.withSize(size) + } else { + return self.font + } + }() + private(set) lazy var commentFont: NSFont = { + if let font = combineFonts(commentFonts, size: commentFontSize ?? fontSize) { + return font + } else if let size = commentFontSize { + return self.font.withSize(size) + } else { + return self.font + } + }() + private(set) lazy var attrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: candidateTextColor, + .font: font, + .baselineOffset: baseOffset + ] + private(set) lazy var highlightedAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: highlightedCandidateTextColor, + .font: font, + .baselineOffset: baseOffset + ] + private(set) lazy var labelAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: candidateLabelColor ?? blendColor(foregroundColor: self.candidateTextColor, backgroundColor: self.backgroundColor), + .font: labelFont, + .baselineOffset: baseOffset + (!vertical ? (font.pointSize - labelFont.pointSize) / 2.5 : 0) + ] + private(set) lazy var labelHighlightedAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: highlightedCandidateLabelColor ?? blendColor(foregroundColor: highlightedCandidateTextColor, backgroundColor: highlightedBackColor), + .font: labelFont, + .baselineOffset: baseOffset + (!vertical ? (font.pointSize - labelFont.pointSize) / 2.5 : 0) + ] + private(set) lazy var commentAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: commentTextColor ?? candidateTextColor, + .font: commentFont, + .baselineOffset: baseOffset + (!vertical ? (font.pointSize - commentFont.pointSize) / 2.5 : 0) + ] + private(set) lazy var commentHighlightedAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: highlightedCommentTextColor ?? highlightedCandidateTextColor, + .font: commentFont, + .baselineOffset: baseOffset + (!vertical ? (font.pointSize - commentFont.pointSize) / 2.5 : 0) + ] + private(set) lazy var preeditAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, + .font: font, + .baselineOffset: baseOffset + ] + private(set) lazy var preeditHighlightedAttrs: [NSAttributedString.Key: Any] = [ + .foregroundColor: highlightedTextColor, + .font: font, + .baselineOffset: baseOffset + ] + + private(set) lazy var firstParagraphStyle: NSParagraphStyle = { let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle style.paragraphSpacing = linespace / 2 style.paragraphSpacingBefore = preeditLinespace / 2 + hilitedCornerRadius / 2 return style as NSParagraphStyle - } - var paragraphStyle: NSParagraphStyle { + }() + private(set) lazy var paragraphStyle: NSParagraphStyle = { let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle style.paragraphSpacing = linespace / 2 style.paragraphSpacingBefore = linespace / 2 return style as NSParagraphStyle - } - var preeditParagraphStyle: NSParagraphStyle { + }() + private(set) lazy var preeditParagraphStyle: NSParagraphStyle = { let style = NSMutableParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle style.paragraphSpacing = preeditLinespace / 2 + hilitedCornerRadius / 2 style.lineSpacing = linespace return style as NSParagraphStyle + }() + private(set) lazy var edgeInset: NSSize = if self.vertical { + NSSize(width: borderHeight + cornerRadius, height: borderWidth + cornerRadius) + } else { + NSSize(width: borderWidth + cornerRadius, height: borderHeight + cornerRadius) } - var edgeInset: NSSize { - if (self.vertical) { - return NSMakeSize(borderHeight + cornerRadius, borderWidth + cornerRadius) - } else { - return NSMakeSize(borderWidth + cornerRadius, borderHeight + cornerRadius) - } - } - var borderLineWidth: CGFloat { - return min(borderHeight, borderWidth) - } - var candidateFormat: String { + private(set) lazy var borderLineWidth: CGFloat = min(borderHeight, borderWidth) + private(set) var candidateFormat: String { get { - candidateTemplate + _candidateFormat } set { var newTemplate = newValue if newTemplate.contains(/%@/) { @@ -173,22 +180,30 @@ final class SquirrelTheme { if newTemplate.contains(/%c/) { newTemplate.replace(/%c/, with: "[label]") } - candidateTemplate = newTemplate + _candidateFormat = newTemplate + } + } + var pagingOffset: CGFloat { + if showPaging { + (labelFontSize ?? fontSize ?? Self.defaultFontSize) * 1.5 + } else { + 0 } } - + func load(config: SquirrelConfig, dark: Bool) { - linear = config.updateCandidateListLayout(prefix: "style") - vertical = config.updateTextOrientation(prefix: "style") + linear ?= config.getString("style/candidate_list_layout").map { $0 == "linear" } + vertical ?= config.getString("style/text_orientation").map { $0 == "vertical" } inlinePreedit ?= config.getBool("style/inline_preedit") inlineCandidate ?= config.getBool("style/inline_candidate") translucency ?= config.getBool("style/translucency") mutualExclusive ?= config.getBool("style/mutual_exclusive") memorizeSize ?= config.getBool("style/memorize_size") - + showPaging ?= config.getBool("style/show_paging") + statusMessageType ?= .init(rawValue: config.getString("style/status_message_type") ?? "") candidateFormat ?= config.getString("style/candidate_format") - + alpha ?= config.getDouble("style/alpha").map { min(1, max(0, $0)) } cornerRadius ?= config.getDouble("style/corner_radius") hilitedCornerRadius ?= config.getDouble("style/hilited_corner_radius") @@ -199,120 +214,132 @@ final class SquirrelTheme { preeditLinespace ?= config.getDouble("style/spacing") baseOffset ?= config.getDouble("style/base_offset") shadowSize ?= config.getDouble("style/shadow_size").map { max(0, $0) } - + var fontName = config.getString("style/font_face") var fontSize = config.getDouble("style/font_point") var labelFontName = config.getString("style/label_font_face") var labelFontSize = config.getDouble("style/label_font_point") var commentFontName = config.getString("style/comment_font_face") var commentFontSize = config.getDouble("style/comment_font_point") - + let colorSchemeOption = dark ? "style/color_scheme_dark" : "style/color_scheme" - if let colorScheme = config.getString(colorSchemeOption), colorScheme != "native" { - native = false - let prefix = "preset_color_schemes/\(colorScheme)" - colorSpace = .from(name: config.getString("\(prefix)/color_space") ?? "") - backgroundColor ?= config.getColor("\(prefix)/back_color", inSpace: colorSpace) - highlightedPreeditColor = config.getColor("\(prefix)/hilited_back_color", inSpace: colorSpace) - highlightedBackColor = config.getColor("\(prefix)/hilited_candidate_back_color", inSpace: colorSpace) ?? highlightedPreeditColor - preeditBackgroundColor = config.getColor("\(prefix)/preedit_back_color", inSpace: colorSpace) - candidateBackColor = config.getColor("\(prefix)/candidate_back_color", inSpace: colorSpace) - borderColor = config.getColor("\(prefix)/border_color", inSpace: colorSpace) - - textColor ?= config.getColor("\(prefix)/text_color", inSpace: colorSpace) - highlightedTextColor = config.getColor("\(prefix)/hilited_text_color", inSpace: colorSpace) ?? textColor - candidateTextColor = config.getColor("\(prefix)/candidate_text_color", inSpace: colorSpace) ?? textColor - highlightedCandidateTextColor = config.getColor("\(prefix)/hilited_candidate_text_color", inSpace: colorSpace) ?? highlightedTextColor - candidateLabelColor = config.getColor("\(prefix)/label_color", inSpace: colorSpace) - highlightedCandidateLabelColor = config.getColor("\(prefix)/hilited_candidate_label_color", inSpace: colorSpace) - commentTextColor = config.getColor("\(prefix)/comment_text_color", inSpace: colorSpace) - highlightedCommentTextColor = config.getColor("\(prefix)/hilited_comment_text_color", inSpace: colorSpace) - - // the following per-color-scheme configurations, if exist, will - // override configurations with the same name under the global 'style' - // section - inlinePreedit ?= config.getBool("\(prefix)/inline_preedit") - inlineCandidate ?= config.getBool("\(prefix)/inline_candidate") - translucency ?= config.getBool("\(prefix)/translucency") - mutualExclusive ?= config.getBool("\(prefix)/mutual_exclusive") - candidateFormat ?= config.getString("\(prefix)/candidate_format") - fontName ?= config.getString("\(prefix)/font_face") - fontSize ?= config.getDouble("\(prefix)/font_point") - labelFontName ?= config.getString("\(prefix)/label_font_face") - labelFontSize ?= config.getDouble("\(prefix)/label_font_point") - commentFontName ?= config.getString("\(prefix)/comment_font_face") - commentFontSize ?= config.getDouble("\(prefix)/comment_font_point") - - alpha ?= config.getDouble("\(prefix)/alpha").map { max(0, min(1, $0)) } - cornerRadius ?= config.getDouble("\(prefix)/corner_radius") - hilitedCornerRadius ?= config.getDouble("\(prefix)/hilited_corner_radius") - surroundingExtraExpansion ?= config.getDouble("\(prefix)/surrounding_extra_expansion") - borderHeight ?= config.getDouble("\(prefix)/border_height") - borderWidth ?= config.getDouble("\(prefix)/border_width") - linespace ?= config.getDouble("\(prefix)/line_spacing") - preeditLinespace ?= config.getDouble("\(prefix)/spacing") - baseOffset ?= config.getDouble("\(prefix)/base_offset") - shadowSize ?= config.getDouble("\(prefix)/shadow_size").map { max(0, $0) } + if let colorScheme = config.getString(colorSchemeOption) { + if colorScheme != "native" { + native = false + let prefix = "preset_color_schemes/\(colorScheme)" + colorSpace = .from(name: config.getString("\(prefix)/color_space") ?? "") + backgroundColor ?= config.getColor("\(prefix)/back_color", inSpace: colorSpace) + highlightedPreeditColor = config.getColor("\(prefix)/hilited_back_color", inSpace: colorSpace) + highlightedBackColor = config.getColor("\(prefix)/hilited_candidate_back_color", inSpace: colorSpace) ?? highlightedPreeditColor + preeditBackgroundColor = config.getColor("\(prefix)/preedit_back_color", inSpace: colorSpace) + candidateBackColor = config.getColor("\(prefix)/candidate_back_color", inSpace: colorSpace) + borderColor = config.getColor("\(prefix)/border_color", inSpace: colorSpace) + + textColor ?= config.getColor("\(prefix)/text_color", inSpace: colorSpace) + highlightedTextColor = config.getColor("\(prefix)/hilited_text_color", inSpace: colorSpace) ?? textColor + candidateTextColor = config.getColor("\(prefix)/candidate_text_color", inSpace: colorSpace) ?? textColor + highlightedCandidateTextColor = config.getColor("\(prefix)/hilited_candidate_text_color", inSpace: colorSpace) ?? highlightedTextColor + candidateLabelColor = config.getColor("\(prefix)/label_color", inSpace: colorSpace) + highlightedCandidateLabelColor = config.getColor("\(prefix)/hilited_candidate_label_color", inSpace: colorSpace) + commentTextColor = config.getColor("\(prefix)/comment_text_color", inSpace: colorSpace) + highlightedCommentTextColor = config.getColor("\(prefix)/hilited_comment_text_color", inSpace: colorSpace) + + // the following per-color-scheme configurations, if exist, will + // override configurations with the same name under the global 'style' + // section + linear ?= config.getString("\(prefix)/candidate_list_layout").map { $0 == "linear" } + vertical ?= config.getString("\(prefix)/text_orientation").map { $0 == "vertical" } + inlinePreedit ?= config.getBool("\(prefix)/inline_preedit") + inlineCandidate ?= config.getBool("\(prefix)/inline_candidate") + translucency ?= config.getBool("\(prefix)/translucency") + mutualExclusive ?= config.getBool("\(prefix)/mutual_exclusive") + showPaging ?= config.getBool("\(prefix)/show_paging") + candidateFormat ?= config.getString("\(prefix)/candidate_format") + fontName ?= config.getString("\(prefix)/font_face") + fontSize ?= config.getDouble("\(prefix)/font_point") + labelFontName ?= config.getString("\(prefix)/label_font_face") + labelFontSize ?= config.getDouble("\(prefix)/label_font_point") + commentFontName ?= config.getString("\(prefix)/comment_font_face") + commentFontSize ?= config.getDouble("\(prefix)/comment_font_point") + + alpha ?= config.getDouble("\(prefix)/alpha").map { max(0, min(1, $0)) } + cornerRadius ?= config.getDouble("\(prefix)/corner_radius") + hilitedCornerRadius ?= config.getDouble("\(prefix)/hilited_corner_radius") + surroundingExtraExpansion ?= config.getDouble("\(prefix)/surrounding_extra_expansion") + borderHeight ?= config.getDouble("\(prefix)/border_height") + borderWidth ?= config.getDouble("\(prefix)/border_width") + linespace ?= config.getDouble("\(prefix)/line_spacing") + preeditLinespace ?= config.getDouble("\(prefix)/spacing") + baseOffset ?= config.getDouble("\(prefix)/base_offset") + shadowSize ?= config.getDouble("\(prefix)/shadow_size").map { max(0, $0) } + } } else { - native = true - } - if let name = fontName { - fonts = decodeFonts(from: name, size: fontSize) - } - if let name = labelFontName ?? fontName { - labelFonts = decodeFonts(from: name, size: labelFontSize ?? fontSize) - } - if let name = commentFontName ?? fontName { - commentFonts = decodeFonts(from: name, size: commentFontSize ?? fontSize) + available = false } + + fonts = decodeFonts(from: fontName) + self.fontSize = fontSize + labelFonts = decodeFonts(from: labelFontName ?? fontName) + self.labelFontSize = labelFontSize + commentFonts = decodeFonts(from: commentFontName ?? fontName) + self.commentFontSize = commentFontSize } } - + private extension SquirrelTheme { - func combineFonts(_ fonts: Array) -> NSFont? { + func combineFonts(_ fonts: [NSFont], size: CGFloat?) -> NSFont? { if fonts.count == 0 { return nil } - if fonts.count == 1 { return fonts[0] } + if fonts.count == 1 { + if let size = size { + return fonts[0].withSize(size) + } else { + return fonts[0] + } + } let attribute = [NSFontDescriptor.AttributeName.cascadeList: fonts[1...].map { $0.fontDescriptor } ] let fontDescriptor = fonts[0].fontDescriptor.addingAttributes(attribute) - return NSFont.init(descriptor: fontDescriptor, size: fonts[0].pointSize) + return NSFont.init(descriptor: fontDescriptor, size: size ?? fonts[0].pointSize) } - - func decodeFonts(from fontString: String, size: CGFloat?) -> Array { + + func decodeFonts(from fontString: String?) -> [NSFont] { + guard let fontString = fontString else { return [] } var seenFontFamilies = Set() let fontStrings = fontString.split(separator: ",") - var fonts = Array() + var fonts = [NSFont]() for string in fontStrings { - let trimedString = string.trimmingCharacters(in: .whitespaces) - if let fontFamilyName = trimedString.split(separator: "-").first.map({String($0)}) { - if seenFontFamilies.contains(fontFamilyName) { + if let matchedFontName = try? /^\s*(.+)-([^-]+)\s*$/.firstMatch(in: string) { + let family = String(matchedFontName.output.1) + let style = String(matchedFontName.output.2) + if seenFontFamilies.contains(family) { continue } + let fontDescriptor = NSFontDescriptor(fontAttributes: [.family: family, .face: style]) + if let font = NSFont(descriptor: fontDescriptor, size: Self.defaultFontSize) { + fonts.append(font) + seenFontFamilies.insert(family) continue - } else { - seenFontFamilies.insert(fontFamilyName) - } - } else { - if seenFontFamilies.contains(trimedString) { - continue - } else { - seenFontFamilies.insert(trimedString) } } - if let validFont = NSFont(name: String(trimedString), size: size ?? Self.defaultFontSize) { - fonts.append(validFont) + let fontName = string.trimmingCharacters(in: .whitespaces) + if seenFontFamilies.contains(fontName) { continue } + let fontDescriptor = NSFontDescriptor(fontAttributes: [.name: fontName]) + if let font = NSFont(descriptor: fontDescriptor, size: Self.defaultFontSize) { + fonts.append(font) + seenFontFamilies.insert(fontName) + continue } } return fonts } - + func blendColor(foregroundColor: NSColor, backgroundColor: NSColor?) -> NSColor { let foregroundColor = foregroundColor.usingColorSpace(NSColorSpace.deviceRGB)! let backgroundColor = (backgroundColor ?? NSColor.gray).usingColorSpace(NSColorSpace.deviceRGB)! - func blend(_ a: CGFloat, _ b: CGFloat) -> CGFloat { - return (a * 2 + b) / 3 + func blend(foreground: CGFloat, background: CGFloat) -> CGFloat { + return (foreground * 2 + background) / 3 } - return NSColor(deviceRed: blend(foregroundColor.redComponent, backgroundColor.redComponent), - green: blend(foregroundColor.greenComponent, backgroundColor.greenComponent), - blue: blend(foregroundColor.blueComponent, backgroundColor.blueComponent), - alpha: blend(foregroundColor.alphaComponent, backgroundColor.alphaComponent)) + return NSColor(deviceRed: blend(foreground: foregroundColor.redComponent, background: backgroundColor.redComponent), + green: blend(foreground: foregroundColor.greenComponent, background: backgroundColor.greenComponent), + blue: blend(foreground: foregroundColor.blueComponent, background: backgroundColor.blueComponent), + alpha: blend(foreground: foregroundColor.alphaComponent, background: backgroundColor.alphaComponent)) } } diff --git a/sources/SquirrelView.swift b/sources/SquirrelView.swift index 43b6bcb47..e1770decb 100644 --- a/sources/SquirrelView.swift +++ b/sources/SquirrelView.swift @@ -7,21 +7,40 @@ import AppKit +private class SquirrelLayoutDelegate: NSObject, NSTextLayoutManagerDelegate { + func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, shouldBreakLineBefore location: any NSTextLocation, hyphenating: Bool) -> Bool { + let index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: location) + if let attributes = textLayoutManager.textContainer?.textView?.textContentStorage?.attributedString?.attributes(at: index, effectiveRange: nil), + let noBreak = attributes[.noBreak] as? Bool, noBreak { + return false + } + return true + } +} + +extension NSAttributedString.Key { + static let noBreak = NSAttributedString.Key("noBreak") +} + final class SquirrelView: NSView { let textView: NSTextView - + private let squirrelLayoutDelegate: SquirrelLayoutDelegate var candidateRanges: [NSRange] = [] var hilightedIndex = 0 - var preeditRange = NSMakeRange(NSNotFound, 0) - var highlightedPreeditRange = NSMakeRange(NSNotFound, 0) + var preeditRange: NSRange = .empty + var canPageUp: Bool = false + var canPageDown: Bool = false + var highlightedPreeditRange: NSRange = .empty var separatorWidth: CGFloat = 0 var shape = CAShapeLayer() - + private var downPath: CGPath? + private var upPath: CGPath? + var lightTheme = SquirrelTheme() var darkTheme = SquirrelTheme() var currentTheme: SquirrelTheme { - isDark ? darkTheme : lightTheme + if isDark && darkTheme.available { darkTheme } else { lightTheme } } var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! @@ -32,7 +51,7 @@ final class SquirrelView: NSView { var textContainer: NSTextContainer { textLayoutManager.textContainer! } - + override init(frame frameRect: NSRect) { squirrelLayoutDelegate = SquirrelLayoutDelegate() textView = NSTextView(frame: frameRect) @@ -48,62 +67,68 @@ final class SquirrelView: NSView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override var isFlipped: Bool { true } var isDark: Bool { NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua } - + func convert(range: NSRange) -> NSTextRange? { - guard range.location != NSNotFound else { return nil } + guard range != .empty else { return nil } guard let startLocation = textLayoutManager.location(textLayoutManager.documentRange.location, offsetBy: range.location) else { return nil } guard let endLocation = textLayoutManager.location(startLocation, offsetBy: range.length) else { return nil } return NSTextRange(location: startLocation, end: endLocation) } - + // Get the rectangle containing entire contents, expensive to calculate var contentRect: NSRect { var ranges = candidateRanges if preeditRange.length > 0 { ranges.append(preeditRange) } + // swiftlint:disable:next identifier_name var x0 = CGFloat.infinity, x1 = -CGFloat.infinity, y0 = CGFloat.infinity, y1 = -CGFloat.infinity for range in ranges { if let textRange = convert(range: range) { let rect = contentRect(range: textRange) - x0 = min(NSMinX(rect), x0) - x1 = max(NSMaxX(rect), x1) - y0 = min(NSMinY(rect), y0) - y1 = max(NSMaxY(rect), y1) + x0 = min(rect.minX, x0) + x1 = max(rect.maxX, x1) + y0 = min(rect.minY, y0) + y1 = max(rect.maxY, y1) } } - return NSMakeRect(x0, y0, x1-x0, y1-y0) + return NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) } // Get the rectangle containing the range of text, will first convert to glyph range, expensive to calculate func contentRect(range: NSTextRange) -> NSRect { + // swiftlint:disable:next identifier_name var x0 = CGFloat.infinity, x1 = -CGFloat.infinity, y0 = CGFloat.infinity, y1 = -CGFloat.infinity textLayoutManager.enumerateTextSegments(in: range, type: .standard, options: .rangeNotRequired) { _, rect, _, _ in - x0 = min(NSMinX(rect), x0) - x1 = max(NSMaxX(rect), x1) - y0 = min(NSMinY(rect), y0) - y1 = max(NSMaxY(rect), y1) + x0 = min(rect.minX, x0) + x1 = max(rect.maxX, x1) + y0 = min(rect.minY, y0) + y1 = max(rect.maxY, y1) return true } - return NSMakeRect(x0, y0, x1-x0, y1-y0) + return NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) } - + // Will triger - (void)drawRect:(NSRect)dirtyRect - func drawView(candidateRanges: Array, hilightedIndex: Int, preeditRange: NSRange, highlightedPreeditRange: NSRange) { + // swiftlint:disable:next function_parameter_count + func drawView(candidateRanges: [NSRange], hilightedIndex: Int, preeditRange: NSRange, highlightedPreeditRange: NSRange, canPageUp: Bool, canPageDown: Bool) { self.candidateRanges = candidateRanges self.hilightedIndex = hilightedIndex self.preeditRange = preeditRange self.highlightedPreeditRange = highlightedPreeditRange + self.canPageUp = canPageUp + self.canPageDown = canPageDown self.needsDisplay = true } - + // All draws happen here + // swiftlint:disable:next cyclomatic_complexity override func draw(_ dirtyRect: NSRect) { var backgroundPath: CGPath? var preeditPath: CGPath? @@ -111,12 +136,13 @@ final class SquirrelView: NSView { var highlightedPath: CGMutablePath? var highlightedPreeditPath: CGMutablePath? let theme = currentTheme - - let backgroundRect = dirtyRect + var containingRect = dirtyRect - + containingRect.size.width -= theme.pagingOffset + let backgroundRect = containingRect + // Draw preedit Rect - var preeditRect = NSZeroRect + var preeditRect = NSRect.zero if preeditRange.length > 0, let preeditTextRange = convert(range: preeditRange) { preeditRect = contentRect(range: preeditTextRange) preeditRect.size.width = backgroundRect.size.width @@ -131,20 +157,21 @@ final class SquirrelView: NSView { preeditPath = drawSmoothLines(rectVertex(of: preeditRect), straightCorner: Set(), alpha: 0, beta: 0) } } - + containingRect = carveInset(rect: containingRect) // Draw candidate Rects for i in 0.. 0 && theme.highlightedBackColor != nil) { + if candidate.length > 0 && theme.highlightedBackColor != nil { highlightedPath = drawPath(highlightedRange: candidate, backgroundRect: backgroundRect, preeditRect: preeditRect, containingRect: containingRect, extraExpansion: 0)?.mutableCopy() } } else { // Draw other highlighted Rect - if (candidate.length > 0 && theme.candidateBackColor != nil) { - let candidatePath = drawPath(highlightedRange: candidate, backgroundRect: backgroundRect, preeditRect: preeditRect, containingRect: containingRect, extraExpansion: theme.surroundingExtraExpansion) + if candidate.length > 0 && theme.candidateBackColor != nil { + let candidatePath = drawPath(highlightedRange: candidate, backgroundRect: backgroundRect, preeditRect: preeditRect, + containingRect: containingRect, extraExpansion: theme.surroundingExtraExpansion) if candidatePaths == nil { candidatePaths = CGMutablePath() } @@ -154,7 +181,7 @@ final class SquirrelView: NSView { } } } - + // Draw highlighted part of preedit text if (highlightedPreeditRange.length > 0) && (theme.highlightedPreeditColor != nil), let highlightedPreeditTextRange = convert(range: highlightedPreeditRange) { var innerBox = preeditRect @@ -171,15 +198,15 @@ final class SquirrelView: NSView { outerBox.size.width -= max(0, theme.hilitedCornerRadius + theme.borderLineWidth) outerBox.origin.x += max(0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2 outerBox.origin.y += max(0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2 - + let (leadingRect, bodyRect, trailingRect) = multilineRects(forRange: highlightedPreeditTextRange, extraSurounding: 0, bounds: outerBox) var (highlightedPoints, highlightedPoints2, rightCorners, rightCorners2) = linearMultilineFor(body: bodyRect, leading: leadingRect, trailing: trailingRect) - + containingRect = carveInset(rect: preeditRect) highlightedPoints = expand(vertex: highlightedPoints, innerBorder: innerBox, outerBorder: outerBox) rightCorners = removeCorner(highlightedPoints: highlightedPoints, rightCorners: rightCorners, containingRect: containingRect) highlightedPreeditPath = drawSmoothLines(highlightedPoints, straightCorner: rightCorners, alpha: 0.3 * theme.hilitedCornerRadius, beta: 1.4 * theme.hilitedCornerRadius)?.mutableCopy() - if (highlightedPoints2.count > 0) { + if highlightedPoints2.count > 0 { highlightedPoints2 = expand(vertex: highlightedPoints2, innerBorder: innerBox, outerBorder: outerBox) rightCorners2 = removeCorner(highlightedPoints: highlightedPoints2, rightCorners: rightCorners2, containingRect: containingRect) let highlightedPreeditPath2 = drawSmoothLines(highlightedPoints2, straightCorner: rightCorners2, alpha: 0.3 * theme.hilitedCornerRadius, beta: 1.4 * theme.hilitedCornerRadius) @@ -188,11 +215,10 @@ final class SquirrelView: NSView { } } } - + NSBezierPath.defaultLineWidth = 0 backgroundPath = drawSmoothLines(rectVertex(of: backgroundRect), straightCorner: Set(), alpha: 0.3 * theme.cornerRadius, beta: 1.4 * theme.cornerRadius) - shape.path = backgroundPath - + self.layer?.sublayers = nil let backPath = backgroundPath?.mutableCopy() if let path = preeditPath { @@ -211,7 +237,7 @@ final class SquirrelView: NSView { let panelLayerMask = shapeFromPath(path: backgroundPath) panelLayer.mask = panelLayerMask self.layer?.addSublayer(panelLayer) - + // Fill in colors if let color = theme.preeditBackgroundColor, let path = preeditPath { let layer = shapeFromPath(path: path) @@ -247,7 +273,7 @@ final class SquirrelView: NSView { if theme.shadowSize > 0 { let shadowLayer = CAShapeLayer() shadowLayer.shadowColor = NSColor.black.cgColor - shadowLayer.shadowOffset = NSMakeSize(theme.shadowSize/2, (theme.vertical ? -1 : 1) * theme.shadowSize/2) + shadowLayer.shadowOffset = NSSize(width: theme.shadowSize/2, height: (theme.vertical ? -1 : 1) * theme.shadowSize/2) shadowLayer.shadowPath = highlightedPath shadowLayer.shadowRadius = theme.shadowSize shadowLayer.shadowOpacity = 0.2 @@ -261,60 +287,81 @@ final class SquirrelView: NSView { } panelLayer.addSublayer(layer) } + panelLayer.setAffineTransform(CGAffineTransform(translationX: theme.pagingOffset, y: 0)) + let panelPath = CGMutablePath() + panelPath.addPath(backgroundPath!, transform: panelLayer.affineTransform().scaledBy(x: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height)) + + let (pagingLayer, downPath, upPath) = pagingLayer(theme: theme, preeditRect: preeditRect) + if let sublayers = pagingLayer.sublayers, !sublayers.isEmpty { + self.layer?.addSublayer(pagingLayer) + } + let flipTransform = CGAffineTransform(scaleX: 1, y: -1).translatedBy(x: 0, y: -dirtyRect.height) + if let downPath { + panelPath.addPath(downPath, transform: flipTransform) + self.downPath = downPath.copy() + } + if let upPath { + panelPath.addPath(upPath, transform: flipTransform) + self.upPath = upPath.copy() + } + + shape.path = panelPath } - - func click(at clickPoint: NSPoint) -> (Int?, Int?) { + + func click(at clickPoint: NSPoint) -> (Int?, Int?, Bool?) { var index = 0 - var candidateIndex: Int? = nil - var preeditIndex: Int? = nil + var candidateIndex: Int? + var preeditIndex: Int? + if let downPath = self.downPath, downPath.contains(clickPoint) { + return (nil, nil, false) + } + if let upPath = self.upPath, upPath.contains(clickPoint) { + return (nil, nil, true) + } if let path = shape.path, path.contains(clickPoint) { - var point = NSMakePoint(clickPoint.x - textView.textContainerInset.width, - clickPoint.y - textView.textContainerInset.height) + var point = NSPoint(x: clickPoint.x - textView.textContainerInset.width - currentTheme.pagingOffset, + y: clickPoint.y - textView.textContainerInset.height) let fragment = textLayoutManager.textLayoutFragment(for: point) if let fragment = fragment { - point = NSMakePoint(point.x - NSMinX(fragment.layoutFragmentFrame), - point.y - NSMinY(fragment.layoutFragmentFrame)) + point = NSPoint(x: point.x - fragment.layoutFragmentFrame.minX, + y: point.y - fragment.layoutFragmentFrame.minY) index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: fragment.rangeInElement.location) - for lineFragment in fragment.textLineFragments { - if lineFragment.typographicBounds.contains(point) { - point = NSMakePoint(point.x - NSMinX(lineFragment.typographicBounds), - point.y - NSMinY(lineFragment.typographicBounds)) - index += lineFragment.characterIndex(for: point) - if index >= preeditRange.location && index < preeditRange.upperBound { - preeditIndex = index - } else { - for i in 0..= range.location && index < range.upperBound { - candidateIndex = i - break - } + for lineFragment in fragment.textLineFragments where lineFragment.typographicBounds.contains(point) { + point = NSPoint(x: point.x - lineFragment.typographicBounds.minX, + y: point.y - lineFragment.typographicBounds.minY) + index += lineFragment.characterIndex(for: point) + if index >= preeditRange.location && index < preeditRange.upperBound { + preeditIndex = index + } else { + for i in 0..= range.location && index < range.upperBound { + candidateIndex = i + break } } - break } + break } } } - return (candidateIndex, preeditIndex) + return (candidateIndex, preeditIndex, nil) } } private extension SquirrelView { // A tweaked sign function, to winddown corner radius when the size is small - func sign(_ number: CGFloat) -> CGFloat { - if number >= 2 { - return 1 - } else if number <= -2 { - return -1 - }else { + func sign(_ number: NSPoint) -> NSPoint { + if number.length >= 2 { + return number / number.length + } else { return number / 2 } } - + // Bezier cubic curve, which has continuous roundness - func drawSmoothLines(_ vertex: Array, straightCorner: Set, alpha: CGFloat, beta rawBeta: CGFloat) -> CGPath? { - guard vertex.count >= 4 else { + func drawSmoothLines(_ vertex: [NSPoint], straightCorner: Set, alpha: CGFloat, beta rawBeta: CGFloat) -> CGPath? { + guard vertex.count >= 3 else { return nil } let beta = max(0.00001, rawBeta) @@ -325,10 +372,9 @@ private extension SquirrelView { var control1: NSPoint var control2: NSPoint var target = previousPoint - var diff = NSMakePoint(point.x - previousPoint.x, point.y - previousPoint.y) + var diff = point - previousPoint if straightCorner.isEmpty || !straightCorner.contains(vertex.count-1) { - target.x += sign(diff.x/beta)*beta - target.y += sign(diff.y/beta)*beta + target += sign(diff / beta) * beta } path.move(to: target) for i in 0.. Array { + + func rectVertex(of rect: NSRect) -> [NSPoint] { [rect.origin, - NSMakePoint(rect.origin.x, rect.origin.y+rect.size.height), - NSMakePoint(rect.origin.x+rect.size.width, rect.origin.y+rect.size.height), - NSMakePoint(rect.origin.x+rect.size.width, rect.origin.y)] + NSPoint(x: rect.origin.x, y: rect.origin.y+rect.size.height), + NSPoint(x: rect.origin.x+rect.size.width, y: rect.origin.y+rect.size.height), + NSPoint(x: rect.origin.x+rect.size.width, y: rect.origin.y)] } - + func nearEmpty(_ rect: NSRect) -> Bool { return rect.size.height * rect.size.width < 1 } - + // Calculate 3 boxes containing the text in range. leadingRect and trailingRect are incomplete line rectangle // bodyRect is complete lines in the middle func multilineRects(forRange range: NSTextRange, extraSurounding: Double, bounds: NSRect) -> (NSRect, NSRect, NSRect) { @@ -389,10 +431,10 @@ private extension SquirrelView { lineRects.append(newRect) return true } - - var leadingRect = NSZeroRect - var bodyRect = NSZeroRect - var trailingRect = NSZeroRect + + var leadingRect = NSRect.zero + var bodyRect = NSRect.zero + var trailingRect = NSRect.zero if lineRects.count == 1 { bodyRect = lineRects[0] } else if lineRects.count == 2 { @@ -401,20 +443,21 @@ private extension SquirrelView { } else if lineRects.count > 2 { leadingRect = lineRects[0] trailingRect = lineRects[lineRects.count-1] + // swiftlint:disable:next identifier_name var x0 = CGFloat.infinity, x1 = -CGFloat.infinity, y0 = CGFloat.infinity, y1 = -CGFloat.infinity for i in 1..<(lineRects.count-1) { let rect = lineRects[i] - x0 = min(NSMinX(rect), x0) - x1 = max(NSMaxX(rect), x1) - y0 = min(NSMinY(rect), y0) - y1 = max(NSMaxY(rect), y1) + x0 = min(rect.minX, x0) + x1 = max(rect.maxX, x1) + y0 = min(rect.minY, y0) + y1 = max(rect.maxY, y1) } - y0 = min(NSMaxY(leadingRect), y0) - y1 = max(NSMinY(trailingRect), y1) - bodyRect = NSMakeRect(x0, y0, x1-x0, y1-y0) + y0 = min(leadingRect.maxY, y0) + y1 = max(trailingRect.minY, y1) + bodyRect = NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) } - - if (extraSurounding > 0) { + + if extraSurounding > 0 { if nearEmpty(leadingRect) && nearEmpty(trailingRect) { bodyRect = expandHighlightWidth(rect: bodyRect, extraSurrounding: extraSurounding) } else { @@ -426,27 +469,27 @@ private extension SquirrelView { } } } - + if !nearEmpty(leadingRect) && !nearEmpty(trailingRect) { - leadingRect.size.width = NSMaxX(bounds) - leadingRect.origin.x - trailingRect.size.width = NSMaxX(trailingRect) - NSMinX(bounds) - trailingRect.origin.x = NSMinX(bounds) + leadingRect.size.width = bounds.maxX - leadingRect.origin.x + trailingRect.size.width = trailingRect.maxX - bounds.minX + trailingRect.origin.x = bounds.minX if !nearEmpty(bodyRect) { bodyRect.size.width = bounds.size.width bodyRect.origin.x = bounds.origin.x } else { - let diff = NSMinY(trailingRect) - NSMaxY(leadingRect) + let diff = trailingRect.minY - leadingRect.maxY leadingRect.size.height += diff / 2 trailingRect.size.height += diff / 2 trailingRect.origin.y -= diff / 2 } } - + return (leadingRect, bodyRect, trailingRect) } - + // Based on the 3 boxes from multilineRectForRange, calculate the vertex of the polygon containing the text in range - func multilineVertex(leadingRect: NSRect, bodyRect: NSRect, trailingRect: NSRect) -> Array { + func multilineVertex(leadingRect: NSRect, bodyRect: NSRect, trailingRect: NSRect) -> [NSPoint] { if nearEmpty(bodyRect) && !nearEmpty(leadingRect) && nearEmpty(trailingRect) { return rectVertex(of: leadingRect) } else if nearEmpty(bodyRect) && nearEmpty(leadingRect) && !nearEmpty(trailingRect) { @@ -461,7 +504,7 @@ private extension SquirrelView { let trailingVertex = rectVertex(of: trailingRect) let bodyVertex = rectVertex(of: bodyRect) return [trailingVertex[1], trailingVertex[2], trailingVertex[3], bodyVertex[2], bodyVertex[3], bodyVertex[0]] - } else if !nearEmpty(leadingRect) && !nearEmpty(trailingRect) && nearEmpty(bodyRect) && (NSMaxX(leadingRect)>NSMinX(trailingRect)) { + } else if !nearEmpty(leadingRect) && !nearEmpty(trailingRect) && nearEmpty(bodyRect) && (leadingRect.maxX>trailingRect.minX) { let leadingVertex = rectVertex(of: leadingRect) let trailingVertex = rectVertex(of: trailingRect) return [trailingVertex[0], trailingVertex[1], trailingVertex[2], trailingVertex[3], leadingVertex[2], leadingVertex[3], leadingVertex[0], leadingVertex[1]] @@ -471,13 +514,13 @@ private extension SquirrelView { let trailingVertex = rectVertex(of: trailingRect) return [trailingVertex[1], trailingVertex[2], trailingVertex[3], bodyVertex[2], leadingVertex[3], leadingVertex[0], leadingVertex[1], bodyVertex[0]] } else { - return Array() + return [NSPoint]() } } - + // If the point is outside the innerBox, will extend to reach the outerBox - func expand(vertex: Array, innerBorder: NSRect, outerBorder: NSRect) -> Array { - var newVertex = Array() + func expand(vertex: [NSPoint], innerBorder: NSRect, outerBorder: NSRect) -> [NSPoint] { + var newVertex = [NSPoint]() for i in 0.. CGPoint { if diff.y == 0 && diff.x > 0 { - return NSMakePoint(0, 1) + return NSPoint(x: 0, y: 1) } else if diff.y == 0 && diff.x < 0 { - return NSMakePoint(0, -1) + return NSPoint(x: 0, y: -1) } else if diff.x == 0 && diff.y > 0 { - return NSMakePoint(-1, 0) + return NSPoint(x: -1, y: 0) } else if diff.x == 0 && diff.y < 0 { - return NSMakePoint(1, 0) + return NSPoint(x: 1, y: 0) } else { - return NSMakePoint(0, 0) + return NSPoint(x: 0, y: 0) } } - + func shapeFromPath(path: CGPath?) -> CAShapeLayer { let layer = CAShapeLayer() layer.path = path layer.fillRule = .evenOdd return layer } - + // Assumes clockwise iteration - func enlarge(vertex: Array, by: Double) -> Array { + func enlarge(vertex: [NSPoint], by: Double) -> [NSPoint] { if by != 0 { var previousPoint: NSPoint var point: NSPoint @@ -530,10 +573,10 @@ private extension SquirrelView { point = vertex[i] nextPoint = vertex[(i+1) % vertex.count] newPoint = point - displacement = direction(diff: NSMakePoint(point.x - previousPoint.x, point.y - previousPoint.y)) + displacement = direction(diff: point - previousPoint) newPoint.x += by * displacement.x newPoint.y += by * displacement.y - displacement = direction(diff: NSMakePoint(nextPoint.x - point.x, nextPoint.y - point.y)) + displacement = direction(diff: nextPoint - point) newPoint.x += by * displacement.x newPoint.y += by * displacement.y results[i] = newPoint @@ -543,7 +586,7 @@ private extension SquirrelView { return vertex } } - + // Add gap between horizontal candidates func expandHighlightWidth(rect: NSRect, extraSurrounding: CGFloat) -> NSRect { var newRect = rect @@ -553,13 +596,13 @@ private extension SquirrelView { } return newRect } - - func removeCorner(highlightedPoints: Array, rightCorners: Set, containingRect: NSRect) -> Set { + + func removeCorner(highlightedPoints: [CGPoint], rightCorners: Set, containingRect: NSRect) -> Set { if !highlightedPoints.isEmpty && !rightCorners.isEmpty { var result = rightCorners for cornerIndex in rightCorners { let corner = highlightedPoints[cornerIndex] - let dist = min(NSMaxY(containingRect) - corner.y, corner.y - NSMinY(containingRect)) + let dist = min(containingRect.maxY - corner.y, corner.y - containingRect.minY) if dist < 1e-2 { result.remove(cornerIndex) } @@ -569,12 +612,13 @@ private extension SquirrelView { return rightCorners } } - + + // swiftlint:disable:next large_tuple func linearMultilineFor(body: NSRect, leading: NSRect, trailing: NSRect) -> (Array, Array, Set, Set) { - let highlightedPoints, highlightedPoints2: Array + let highlightedPoints, highlightedPoints2: [NSPoint] let rightCorners, rightCorners2: Set // Handles the special case where containing boxes are separated - if (nearEmpty(body) && !nearEmpty(leading) && !nearEmpty(trailing) && NSMaxX(trailing) < NSMinX(leading)) { + if nearEmpty(body) && !nearEmpty(leading) && !nearEmpty(trailing) && trailing.maxX < leading.minX { highlightedPoints = rectVertex(of: leading) highlightedPoints2 = rectVertex(of: trailing) rightCorners = [2, 3] @@ -587,17 +631,17 @@ private extension SquirrelView { } return (highlightedPoints, highlightedPoints2, rightCorners, rightCorners2) } - + func drawPath(highlightedRange: NSRange, backgroundRect: NSRect, preeditRect: NSRect, containingRect: NSRect, extraExpansion: Double) -> CGPath? { let theme = currentTheme let resultingPath: CGMutablePath? - + var currentContainingRect = containingRect currentContainingRect.size.width += extraExpansion * 2 currentContainingRect.size.height += extraExpansion * 2 currentContainingRect.origin.x -= extraExpansion currentContainingRect.origin.y -= extraExpansion - + let halfLinespace = theme.linespace / 2 var innerBox = backgroundRect innerBox.size.width -= (theme.edgeInset.width + 1) * 2 - 2 * extraExpansion @@ -613,26 +657,26 @@ private extension SquirrelView { } innerBox.size.height -= theme.linespace innerBox.origin.y += halfLinespace - + var outerBox = backgroundRect outerBox.size.height -= preeditRect.size.height + max(0, theme.hilitedCornerRadius + theme.borderLineWidth) - 2 * extraExpansion outerBox.size.width -= max(0, theme.hilitedCornerRadius + theme.borderLineWidth) - 2 * extraExpansion outerBox.origin.x += max(0.0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2.0 - extraExpansion outerBox.origin.y += preeditRect.size.height + max(0, theme.hilitedCornerRadius + theme.borderLineWidth) / 2 - extraExpansion - + let effectiveRadius = max(0, theme.hilitedCornerRadius + 2 * extraExpansion / theme.hilitedCornerRadius * max(0, theme.cornerRadius - theme.hilitedCornerRadius)) - + if theme.linear, let highlightedTextRange = convert(range: highlightedRange) { let (leadingRect, bodyRect, trailingRect) = multilineRects(forRange: highlightedTextRange, extraSurounding: separatorWidth, bounds: outerBox) var (highlightedPoints, highlightedPoints2, rightCorners, rightCorners2) = linearMultilineFor(body: bodyRect, leading: leadingRect, trailing: trailingRect) - + // Expand the boxes to reach proper border highlightedPoints = enlarge(vertex: highlightedPoints, by: extraExpansion) highlightedPoints = expand(vertex: highlightedPoints, innerBorder: innerBox, outerBorder: outerBox) rightCorners = removeCorner(highlightedPoints: highlightedPoints, rightCorners: rightCorners, containingRect: currentContainingRect) resultingPath = drawSmoothLines(highlightedPoints, straightCorner: rightCorners, alpha: 0.3*effectiveRadius, beta: 1.4*effectiveRadius)?.mutableCopy() - - if (highlightedPoints2.count > 0) { + + if highlightedPoints2.count > 0 { highlightedPoints2 = enlarge(vertex: highlightedPoints2, by: extraExpansion) highlightedPoints2 = expand(vertex: highlightedPoints2, innerBorder: innerBox, outerBorder: outerBox) rightCorners2 = removeCorner(highlightedPoints: highlightedPoints2, rightCorners: rightCorners2, containingRect: currentContainingRect) @@ -646,11 +690,11 @@ private extension SquirrelView { if !nearEmpty(highlightedRect) { highlightedRect.size.width = backgroundRect.size.width highlightedRect.size.height += theme.linespace - highlightedRect.origin = NSMakePoint(backgroundRect.origin.x, highlightedRect.origin.y + theme.edgeInset.height - halfLinespace) + highlightedRect.origin = NSPoint(x: backgroundRect.origin.x, y: highlightedRect.origin.y + theme.edgeInset.height - halfLinespace) if highlightedRange.upperBound == (textView.string as NSString).length { highlightedRect.size.height += theme.edgeInset.height - halfLinespace } - if highlightedRange.location - ((preeditRange.location == NSNotFound ? 0 : preeditRange.location) + preeditRange.length) <= 1 { + if highlightedRange.location - (preeditRange == .empty ? 0 : preeditRange.upperBound) <= 1 { if preeditRange.length == 0 { highlightedRect.size.height += theme.edgeInset.height - halfLinespace highlightedRect.origin.y -= theme.edgeInset.height - halfLinespace @@ -659,7 +703,7 @@ private extension SquirrelView { highlightedRect.origin.y -= theme.hilitedCornerRadius / 2 } } - + var highlightedPoints = rectVertex(of: highlightedRect) highlightedPoints = enlarge(vertex: highlightedPoints, by: extraExpansion) highlightedPoints = expand(vertex: highlightedPoints, innerBorder: innerBox, outerBorder: outerBox) @@ -672,7 +716,7 @@ private extension SquirrelView { } return resultingPath } - + func carveInset(rect: NSRect) -> NSRect { var newRect = rect newRect.size.height -= (currentTheme.hilitedCornerRadius + currentTheme.borderWidth) * 2 @@ -681,18 +725,45 @@ private extension SquirrelView { newRect.origin.y += currentTheme.hilitedCornerRadius + currentTheme.borderWidth return newRect } -} -fileprivate class SquirrelLayoutDelegate: NSObject, NSTextLayoutManagerDelegate { - func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, shouldBreakLineBefore location: any NSTextLocation, hyphenating: Bool) -> Bool { - let index = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: location) - if let attributes = textLayoutManager.textContainer?.textView?.textContentStorage?.attributedString?.attributes(at: index, effectiveRange: nil), let noBreak = attributes[.noBreak] as? Bool, noBreak { - return false - } - return true + func triangle(center: NSPoint, radius: CGFloat) -> [NSPoint] { + [NSPoint(x: center.x, y: center.y + radius), + NSPoint(x: center.x + 0.5 * sqrt(3) * radius, y: center.y - 0.5 * radius), + NSPoint(x: center.x - 0.5 * sqrt(3) * radius, y: center.y - 0.5 * radius)] } -} -extension NSAttributedString.Key { - static let noBreak = NSAttributedString.Key("noBreak") + func pagingLayer(theme: SquirrelTheme, preeditRect: CGRect) -> (CAShapeLayer, CGPath?, CGPath?) { + let layer = CAShapeLayer() + guard theme.showPaging && (canPageUp || canPageDown) else { return (layer, nil, nil) } + guard let firstCandidate = candidateRanges.first, let range = convert(range: firstCandidate) else { return (layer, nil, nil) } + var height = contentRect(range: range).height + let preeditHeight = max(0, preeditRect.height + theme.preeditLinespace / 2 + theme.hilitedCornerRadius / 2 - theme.edgeInset.height) + theme.edgeInset.height - theme.linespace / 2 + height += theme.linespace + let radius = min(0.5 * theme.pagingOffset, 2 * height / 9) + let effectiveRadius = min(theme.cornerRadius, 0.6 * radius) + guard let trianglePath = drawSmoothLines( + triangle(center: .zero, radius: radius), + straightCorner: [], alpha: 0.3 * effectiveRadius, beta: 1.4 * effectiveRadius + ) else { + return (layer, nil, nil) + } + var downPath: CGPath? + var upPath: CGPath? + if canPageDown { + var downTransform = CGAffineTransform(translationX: 0.5 * theme.pagingOffset, y: 2 * height / 3 + preeditHeight) + let downLayer = shapeFromPath(path: trianglePath.copy(using: &downTransform)) + downLayer.fillColor = theme.backgroundColor.cgColor + downPath = trianglePath.copy(using: &downTransform) + layer.addSublayer(downLayer) + } + if canPageUp { + var upTransform = CGAffineTransform(rotationAngle: .pi).translatedBy(x: -0.5 * theme.pagingOffset, y: -height / 3 - preeditHeight) + let upLayer = shapeFromPath(path: trianglePath.copy(using: &upTransform)) + upLayer.fillColor = theme.backgroundColor.cgColor + upPath = trianglePath.copy(using: &upTransform) + layer.addSublayer(upLayer) + } + return (layer, downPath, upPath) + } } +