diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 000000000..55f6cf396 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,368 @@ +--- +name: squirrel-input-method-architecture +description: Understand and modify the Squirrel macOS input method frontend. Use this when working on input handling, librime sessions, candidate UI, configuration, lifecycle, installer commands, or backend/frontend coordination in this repository. +--- + +# Squirrel Input Method Architecture Skill + +Use this skill when making changes to Squirrel, a macOS InputMethodKit frontend for librime. Squirrel is an input method, so correctness depends on event ordering, session lifetime, marked text behavior, candidate window geometry, and clean handoff between the macOS text client and librime. + +## Repository Map + +The Xcode project is organized around one app target, `Squirrel.app`, plus bundled resources and librime plugins. + +- `Squirrel/Sources/Main.swift`: process entry point, command-line maintenance commands, IMK server creation, app setup, and global librime startup. +- `Squirrel/Sources/SquirrelApplicationDelegate.swift`: app-wide state. Owns the candidate panel, global `SquirrelConfig`, status item, Sparkle update integration, distributed notifications, and librime setup/finalization. +- `Squirrel/Sources/SquirrelInputController.swift`: the main InputMethodKit controller. Owns one active librime session per controller instance, receives key events, translates macOS events to Rime key events, commits text, updates marked text, and drives the candidate panel. +- `Squirrel/Sources/MacOSKeyCodes.swift`: maps AppKit/Carbon key codes and modifier flags to librime/X11 key symbols and masks. +- `Squirrel/Sources/SquirrelConfig.swift`: thin typed wrapper over `RimeConfig`, with base config/schema fallback and cached option reads. +- `Squirrel/Sources/SquirrelTheme.swift`: converts Rime/Squirrel style configuration into fonts, colors, layout flags, candidate formatting, and drawing attributes. +- `Squirrel/Sources/SquirrelPanel.swift`: nonactivating candidate/status panel. Builds attributed candidate text, positions the panel near the text cursor, handles paging/candidate mouse events, and delegates selection actions back to the input controller. +- `Squirrel/Sources/SquirrelView.swift`: custom AppKit drawing surface for candidate/preedit backgrounds, highlights, paging affordances, vertical text, and hit testing. +- `Squirrel/Sources/ReservedProperty.swift`: reserved librime plugin property protocol for frontend UI hints such as comment highlighting and UI refresh. +- `Squirrel/Sources/BridgingFunctions.swift`: Swift helpers for C bridge structs, persistent C strings, optional assignment, and geometry utilities. +- `Squirrel/Sources/InputSource.swift`: Text Input Source registration, enable/disable/select helpers, and current input source lookup. +- `Squirrel/Resources/Info.plist`: InputMethodKit registration metadata, input modes (`Hans`, `Hant`), IMK controller class names, connection name, Sparkle metadata, and input-source properties. +- `Squirrel/Resources/Squirrel.entitlements`: disables App Sandbox, enables network client access, and disables library validation for bundled dylibs/frameworks. +- `Squirrel/SharedSupport`: bundled Rime data, default schemas, OpenCC data, and `squirrel.yaml`. +- `Squirrel/librime-*.dylib`, `Squirrel/Frameworks/Linked Frameworks/librime.1.dylib`: backend libraries and plugins used by the frontend. + +## Process Startup + +`SquirrelApp.main()` is the only entry point. + +1. It first checks command-line arguments and exits early for maintenance commands: + - `--quit`, `--reload`, `--sync` + - `--install` / `--register-input-source` + - `--enable-input-source`, `--disable-input-source`, `--select-input-source` + - `--build` + - `--ascii`, `--nascii`, `--getascii` +2. If no maintenance command is handled, it creates an `IMKServer` using `InputMethodConnectionName` from `Info.plist`. +3. It creates `NSApplication.shared`, assigns `SquirrelApplicationDelegate`, sets accessory activation policy, and changes the current directory to `Bundle.main.sharedSupportPath`. This is important because OpenCC/librime configuration may use relative dictionary paths. +4. It runs a quick problematic-launch detector to avoid repeated crash/freeze loops from bad configuration. +5. Normal startup calls: + - `setupRime()` + - `startRime(fullCheck: false)` + - `loadSettings()` + - `app.run()` +6. On app-run return, it calls `rimeAPI.finalize()`. + +## Global Librime Initialization + +`SquirrelApplicationDelegate` owns global librime setup. + +- `setupRime()` creates the user data directory (`~/Library/Rime`) and temporary log directory, sets `RIME_LOG_DIR`, installs librime's notification handler, fills `RimeTraits`, and calls `rimeAPI.setup(&traits)`. +- Important trait paths and identity fields: + - `shared_data_dir`: app bundle shared support path. + - `user_data_dir`: `~/Library/Rime`. + - `log_dir`: temporary `rime.squirrel` directory. + - distribution code/name/version and `app_name = rime.squirrel`. +- `startRime(fullCheck:)` calls `rimeAPI.initialize(nil)`, then `start_maintenance(fullCheck)`. On successful maintenance it deploys `squirrel.yaml` with the `config_version` marker. +- `loadSettings()` opens base `squirrel` config, refreshes notification/status-icon settings, and loads light/dark panel themes. +- `loadSettings(for schemaID:)` opens the active schema config and, when it has a `style` section, overlays schema-specific panel style. Otherwise it falls back to base config. +- `shutdownRime()` closes config and calls `rimeAPI.finalize()`. +- `applicationShouldTerminate(_:)` calls `cleanup_all_sessions()` before termination. + +Do not initialize/finalize librime from individual input controllers. Controllers own sessions; the application delegate owns the backend lifetime. + +## Input Controller Lifecycle + +`SquirrelInputController` subclasses `IMKInputController` and is the core input-method object. + +- `init(server:delegate:client:)` stores the initial `IMKTextInput` client, calls `createSession()`, and registers local notification observers for ASCII-mode set/report requests. +- `createSession()` chooses a client bundle identifier, creates a librime session with `rimeAPI.create_session()`, clears `schemaId`, and applies app-specific options. +- `destroySession()` calls `rimeAPI.destroy_session(session)` and clears chord typing state. +- `deinit` destroys the session. +- `activateServer(_:)` refreshes the current client, optionally overrides the keyboard layout from `keyboard_layout`, clears local preedit cache, and updates the menu-bar status label from `ascii_mode` if a session already exists. +- `deactivateServer(_:)` hides palettes, commits the current composition to the client, and releases the client reference. +- `commitComposition(_:)` commits raw pending librime input via `client.insertText`, then clears the librime composition. + +The controller keeps `client` weak. Always guard client access. An input method may be activated, deactivated, or retargeted by macOS at awkward times. + +## Input Update Loop + +The critical loop is `handle(_:client:) -> Bool` in `SquirrelInputController`. + +1. Ensure there is a valid librime session. If `session == 0` or `find_session(session)` fails, call `createSession()`. +2. Update the weak `IMKTextInput` client from `sender` when possible. +3. Detect client app bundle ID changes and apply `app_options/` from `squirrel.yaml`. +4. For `.flagsChanged`: + - Compute changed modifier flags by comparing with `lastModifiers`. + - Convert modifiers with `SquirrelKeycode.osxModifiersToRime`. + - Validate or infer modifier keycode. This protects against remote desktop tools sending bogus keycode 0 for modifier events. + - Handle caps lock specially because librime expects `XK_Caps_Lock` before lock-mask state changes. + - Process modifier releases before presses to handle delayed release events. + - Update `lastModifiers` and call `rimeUpdate()`. +5. For `.keyDown`: + - Ignore Command-modified shortcuts so the client application receives them. + - Choose `charactersIgnoringModifiers` or `characters` depending on modifiers and ASCII/non-ASCII behavior. + - Convert keycode/character/modifiers to librime keycode and masks. + - Call `processKey(...)`. + - Call `rimeUpdate()` when a valid rime keycode was processed. +6. Return `true` only when the event was handled and should not continue to the client application. + +`recognizedEvents(_:)` returns key-down and flags-changed masks only. + +## Key Processing Details + +`processKey(_:, modifiers:)` is the narrow frontend/backend key boundary. + +- Before calling librime, it synchronizes `_linear` and `_vertical` options from the current panel theme. Arrow-key behavior can depend on candidate layout and text orientation. +- It calls `rimeAPI.process_key(session, keycode, modifiers)`. +- If librime does not handle a Vim-like command-mode escape (`Esc`, `Ctrl-C`, `Ctrl-[`) and `vim_mode` is set, it forces `ascii_mode` on unless already in ASCII mode. +- If librime handles a key while `_chord_typing` is active, printable keys and modifiers are recorded and later released by a timer. Non-chording keys clear the chord buffer. + +`MacOSKeyCodes.swift` is intentionally centralized. Add key translations there rather than scattering keycode conditionals through the controller. + +## Rime Update and Dataflow + +`rimeUpdate(clearReservedComments:)` consumes all frontend-visible librime state after key processing, paging, selection, caret movement, or plugin UI refresh. + +Main sequence: + +1. Clear reserved comment UI hints unless the caller explicitly preserves them. +2. `rimeConsumeCommittedText()` calls `get_commit`, inserts committed text into the client, frees the commit struct, resets local preedit, and hides the panel. +3. `get_status` detects schema changes: + - reloads schema-specific settings through the app delegate; + - calculates `inlinePreedit` and `inlineCandidate` using panel config plus librime options (`no_inline`, `inline`); + - sets librime `soft_cursor` to the inverse of inline preedit. +4. `get_context` reads composition and menu state: + - preedit string; + - selected segment byte offsets converted to Swift indices; + - cursor position; + - candidate texts, comments, labels, page number, last-page flag, highlighted index. +5. It updates marked text through `show(preedit:selRange:caretPos:)`. +6. It updates the candidate panel through `showPanel(...)` unless no context is available, in which case it hides palettes. +7. It frees the librime context. + +The text path is: + +`NSEvent` -> `SquirrelInputController.handle` -> `processKey` -> `rimeAPI.process_key` -> `rimeUpdate` -> `get_commit`/`get_status`/`get_context` -> `client.insertText` and/or `client.setMarkedText` plus `SquirrelPanel.update`. + +## Marked Text and Commit Rules + +- Committed text must go through `client.insertText(_, replacementRange: .empty)`. +- Active composition should go through `client.setMarkedText(_, selectionRange:, replacementRange: .empty)`. +- `show(preedit:selRange:caretPos:)` caches the last marked preedit, caret, and selected range to avoid redundant marked-text calls. +- When non-inline preedit is configured, the controller may set a full-width space (`U+3000`) as marked text so clients such as iTerm2 do not echo every raw preedit character. +- `commitComposition(_:)` commits raw pending librime input during deactivation. This matters when macOS switches input sources or the focused text client changes. + +Input methods must be conservative about when they consume events. Incorrect `true` returns drop app shortcuts or text; incorrect `false` returns can duplicate input. + +## Candidate Panel Flow + +The app delegate creates one shared `SquirrelPanel` during `applicationWillFinishLaunching`. The active input controller assigns itself to `panel.inputController` before updating the panel. + +`showPanel(...)` gets cursor geometry from `client.attributes(forCharacterIndex:lineHeightRectangle:)`, stores it in `panel.position`, and calls `panel.update(...)`. + +`SquirrelPanel.update(...)`: + +- stores the latest preedit/candidate state; +- builds a single attributed string containing preedit and candidate rows; +- applies theme attributes, candidate labels, comments, semantic comment colors, no-break hints, and paragraph styles; +- updates the `NSTextView` storage and layout orientation; +- forces TextKit layout before measuring geometry; +- calls `SquirrelView.drawView(...)` for background/highlight paths; +- calls `show()` to position and display the panel. + +`SquirrelPanel.show()`: + +- chooses screen based on cursor position; +- sets effective appearance; +- measures text with TextKit 2; +- constrains oversized panels to most of the screen and scales via content-view bounds; +- positions normal panels near the cursor, with special handling for vertical text; +- applies content-view rotation for vertical mode; +- configures translucency background (`NSGlassEffectView` on macOS 26+, `NSVisualEffectView` otherwise); +- orders the nonactivating panel front. + +Mouse and scroll events on the panel are forwarded back to the input controller: + +- click candidate -> `selectCandidate(_:)` -> `rimeUpdate()`; +- click/scroll paging controls -> `page(up:)` -> `rimeUpdate()`; +- click preedit position -> `moveCaret(forward:)` -> `rimeUpdate()`. + +## Custom Drawing + +`SquirrelView` owns the drawing and hit-testing model. + +- It uses an `NSTextView` with TextKit 2 layout to measure actual rendered text segments. +- `contentRect` and `contentRect(range:)` enumerate text layout segments to compute bounds. +- `draw(_:)` builds Core Graphics paths for panel background, preedit background, candidate backgrounds, highlighted candidate, highlighted preedit range, border, shadow, and paging controls. +- `shape` is also used as the panel background mask and hit-test region. +- `click(at:)` maps mouse points back into TextKit offsets and candidate/preedit ranges. + +When changing panel layout, preserve the order: set attributed text, set layout orientation, force layout, measure, draw paths, then show/reposition. + +## Configuration Model + +`SquirrelConfig` is a typed facade over `RimeConfig`. + +- `openBaseConfig()` opens `squirrel` config. +- `open(schemaID:baseConfig:)` opens schema config and falls back to base config for missing values. +- `getBool`, `getDouble`, `getString`, and `getColor` cache successful reads. +- `getAppOptions(_:)` reads boolean options under `app_options/`. + +`SquirrelTheme.load(config:dark:)` reads global `style/*`, then optional preset color scheme settings. Per-color-scheme values can override style values for layout, color, fonts, alpha, spacing, and candidate formatting. + +Important theme flags: + +- `candidate_list_layout`: linear vs stacked candidate list. +- `text_orientation`: horizontal vs vertical. +- `inline_preedit`, `inline_candidate`: marked text vs panel display strategy. +- `translucency`, `mutual_exclusive`, `memorize_size`, `show_paging`. +- `candidate_format`: template using `[label]`, `[candidate]`, `[comment]`; legacy `%c` and `%@` are normalized. + +## Notifications and External Commands + +The app uses distributed notifications for process-to-running-instance commands. + +- `SquirrelReloadNotification` -> deploy: shutdown Rime, reinitialize, reload settings. +- `SquirrelSyncNotification` -> `sync_user_data()`. +- `SquirrelToggleASCIIModeNotification` -> posts local `SquirrelSetASCIIModeNotification` with `Bool`. +- `SquirrelGetASCIIModeNotification` -> posts local report request; active controller responds with `SquirrelASCIIModeResponse` (`ascii` or `nascii`). +- `kTISNotifySelectedKeyboardInputSourceChanged` -> updates status item visibility and finalizes stranded compositions. + +The finalization fallback is important: some macOS/input-source switch paths may not call `deactivateServer`. When the selected input source no longer starts with `im.rime.inputmethod.Squirrel`, the app delegate calls `deactivateServer` on the panel's current input controller to avoid orphaned composition/panel state. + +## Librime Notification Handler + +`notificationHandler(...)` is installed by `setupRime()` and receives backend notifications. + +- `deploy/start`, `deploy/success`, `deploy/failure`: show user notifications. +- `option`: parses enabled/disabled option names, gets abbreviated and long state labels from librime, updates status icon for `ascii_mode`, and optionally shows a status message on the panel. +- `property` where the value starts with `_` and contains `=`: treats it as a reserved frontend property and calls `handleReservedProperty(...)` on the current panel input controller on the main actor. +- `schema`: when notifications are enabled, extracts and shows schema name. + +Reserved properties currently include: + +- `_comment_highlight`: comma-separated candidate indices to draw with `accent_text_color`. +- `_comment_warning`: comma-separated candidate indices to draw with `warning_text_color`. +- `_refresh_ui`: requests `rimeUpdate(clearReservedComments: false)`. + +Reserved-property values are query-string compatible; bare comma lists are parsed under the `value` field. + +## Installer and Input Source Registration + +`SquirrelInstaller` wraps Text Input Source Services. + +- Input modes are `im.rime.inputmethod.Squirrel.Hans` and `im.rime.inputmethod.Squirrel.Hant`; `Hans` is the primary default. +- `register()` calls `TISRegisterInputSource` for `/Library/Input Library/Squirrel.app` when no Squirrel modes are already enabled. +- `enable`, `disable`, and `select` operate on TIS input sources. +- `currentInputSourceID()` reads `TISCopyCurrentKeyboardInputSource()` and is used to control status item visibility and stranded-composition cleanup. + +`Info.plist` must stay consistent with `InputSource.swift`: input mode identifiers, `InputMethodConnectionName`, and controller class names are part of macOS input method registration. + +## Backend Bridge Conventions + +The Swift/C boundary uses generated librime types plus helpers in `BridgingFunctions.swift`. + +- Initialize librime structs with `.rimeStructInit()` so memory is zeroed and `data_size` is set correctly. +- Free librime-owned structs after successful reads: commits with `free_commit`, statuses with `free_status`, contexts with `free_context`. +- `setCString(_:to:)` duplicates Swift strings for C fields. Be mindful that duplicated C strings are manually allocated. +- `RimeStringSlice.asString` must respect `.length`; do not replace it with `String(cString:)` for abbreviated labels. + +## Coding and Comment Style + +Follow the existing Swift/AppKit style unless there is a strong local reason to do otherwise. + +- Types use PascalCase: `SquirrelInputController`, `ReservedPropertyValue`, `SquirrelTheme`. +- Methods, properties, local variables, and enum cases use camelCase. +- Keep local acronym style consistent with nearby code: `rimeAPI`, `schemaId`, `currentApp`, `asciiMode`. +- Boolean names should read naturally with `is`, `has`, `can`, `should`, or a clear state noun when the existing API already uses one. +- Generated C bridge fields may keep snake_case names such as `data_size`; use narrow SwiftLint suppressions rather than renaming generated API concepts. +- Prefer `let` for values that do not change, `private` for implementation details, and `private(set)` when other types need read-only state. +- Keep IMK lifecycle and event handling in `SquirrelInputController`; keep global Rime/app lifetime in `SquirrelApplicationDelegate`. +- Keep config access in `SquirrelConfig` and configurable visual state in `SquirrelTheme`. +- Keep key translation in `MacOSKeyCodes`; do not scatter raw Carbon/Rime key mappings through input handling code. +- Keep candidate panel state and positioning in `SquirrelPanel`; keep drawing, geometry, and hit testing in `SquirrelView`. + +Reuse existing helpers before adding new abstractions. + +- Use `.rimeStructInit()` for librime structs that need zeroed memory and `data_size`. +- Use `setCString(_:to:)` when assigning Swift strings into Rime trait/config structs. +- Use the `?=` operator for optional config overrides in theme/config-loading code. +- Use `NSRange.empty` for the project's sentinel empty range. +- Use `RimeStringSlice.asString` for Rime slices because it respects the slice length. +- Use `SquirrelKeycode` for macOS-to-Rime key conversion. +- Extend `ReservedPropertyValue` for reserved-property parsing instead of adding one-off string parsing. +- Add a shared helper only when multiple call sites need the same non-trivial behavior. Avoid wrapping a single straightforward expression. + +Comment style is intentionally sparse. + +- Keep the simple file headers already used by the project. +- Keep SwiftLint directive comments exactly where they are needed. +- Use English for retained comments. +- Comments should explain why, ordering constraints, ownership, or platform/backend quirks. Do not restate what the next line does. +- Remove commented-out debug prints and temporary tracing instead of preserving them in source. +- Keep comments near IMK/librime event ordering, TextKit measurement constraints, vertical-mode coordinate transforms, C memory ownership, and plugin/frontend contracts. +- Prefer one compact explanatory comment over long branch-by-branch examples unless the example prevents a likely regression. + +## Input Method Invariants + +Keep these invariants in mind for any change: + +- Global librime lifetime belongs to the app delegate; session lifetime belongs to input controllers. +- Every key event path that changes librime state should call `rimeUpdate()` exactly when frontend state needs to be consumed. +- Do not consume Command shortcuts in normal text input; let client applications handle them. +- Deactivation must hide the panel and commit or clear active composition so no marked text or panel is stranded. +- Always guard against nil or stale `IMKTextInput` clients. +- Convert librime byte offsets into Swift string indices before building `NSRange` values. +- Keep `get_context`, `get_status`, and `get_commit` free calls paired with successful reads. +- Preserve app-specific options on session creation and when the focused client bundle changes. +- Candidate panel geometry depends on TextKit layout results. Avoid measuring before layout is forced. +- Vertical text affects key behavior, layout orientation, content rotation, panel positioning, and scroll paging direction. +- `inlinePreedit` and `inlineCandidate` are determined jointly by theme config and librime options. +- Shared panel state should always point to the active input controller before candidate updates or mouse actions. + +## Common Change Areas + +For key handling changes: + +1. Start in `SquirrelInputController.handle` and `processKey`. +2. Put reusable key mappings in `MacOSKeyCodes.swift`. +3. Preserve modifier ordering and caps-lock behavior. +4. Verify event consumption semantics. + +For candidate UI changes: + +1. Start in `SquirrelPanel.update` for text/attributes/data shaping. +2. Use `SquirrelTheme` for configurable style values. +3. Use `SquirrelView` for geometry, drawing, and hit testing. +4. Test horizontal, linear, vertical, paging, inline preedit, and no-candidate states. + +For config changes: + +1. Add reads in `SquirrelTheme` or `SquirrelConfig` only where the value belongs. +2. Keep base config and schema-specific fallback behavior intact. +3. Consider dark/light theme loading separately. + +For lifecycle or command changes: + +1. Start in `Main.swift` for command-line behavior. +2. Start in `SquirrelApplicationDelegate` for app-global observers, Rime setup, status item behavior, and termination. +3. Keep distributed notification names stable unless all callers are updated. + +For librime plugin/frontend coordination: + +1. Add reserved keys to `ReservedPropertyKey`. +2. Parse values in `ReservedPropertyValue` or in `handleReservedProperty`. +3. Apply UI effects in `SquirrelInputController` or `SquirrelPanel`, depending on whether the state belongs to the session or rendering. +4. Preserve `_refresh_ui` behavior for plugin-driven redraws. + +## Validation Checklist + +When possible, validate with Xcode build diagnostics or a full Xcode build. For behavior changes, manually exercise: + +- input activation/deactivation in multiple apps; +- typing, committing, cancelling, and switching input sources mid-composition; +- ASCII mode toggle and status reporting; +- schema switching and schema-specific style reload; +- candidate selection by number key and mouse; +- paging by key, mouse, and scroll; +- inline and non-inline preedit; +- vertical and linear candidate layouts; +- deployment/reload and sync commands; +- app quit/log out cleanup. + +Input-method bugs often appear as duplicated text, dropped shortcuts, orphaned candidate panels, stale marked text, or session-specific state leaking between client apps. Test around those failure modes first. diff --git a/sources/BridgingFunctions.swift b/sources/BridgingFunctions.swift index a4b15aaa3..f41c08136 100644 --- a/sources/BridgingFunctions.swift +++ b/sources/BridgingFunctions.swift @@ -21,12 +21,9 @@ extension RimeModule: DataSizeable {} extension DataSizeable { static func rimeStructInit() -> Self { let valuePointer = UnsafeMutablePointer.allocate(capacity: 1) - // Initialize the memory to zero memset(valuePointer, 0, MemoryLayout.size) - // Convert the pointer to a managed Swift variable var value = valuePointer.move() valuePointer.deallocate() - // Initialize data_size property let offset = MemoryLayout.size(ofValue: \Self.data_size) value.data_size = Int32(MemoryLayout.size - offset) return value @@ -34,9 +31,8 @@ extension DataSizeable { mutating func setCString(_ swiftString: String, to keypath: WritableKeyPath?>) { swiftString.withCString { cStr in - // Duplicate the string to create a persisting C string + // Rime traits keep C string pointers after this closure returns. let mutableCStr = strdup(cStr) - // Free the existing string if there is one if let existing = self[keyPath: keypath] { free(UnsafeMutableRawPointer(mutating: existing)) } diff --git a/sources/InputSource.swift b/sources/InputSource.swift index 8fd8daf41..89c1214de 100644 --- a/sources/InputSource.swift +++ b/sources/InputSource.swift @@ -21,7 +21,6 @@ final class SquirrelInstaller { for inputSource in sourceList { let sourceIDRef = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) guard let sourceID = unsafeBitCast(sourceIDRef, to: CFString?.self) as String? else { continue } - // print("[DEBUG] Examining input source: \(sourceID)") inputSources[sourceID] = inputSource } return inputSources @@ -44,7 +43,6 @@ final class SquirrelInstaller { let enabledInputModes = enabledModes() if !enabledInputModes.isEmpty { print("User already registered Squirrel method(s): \(enabledInputModes.map { $0.rawValue })") - // Already registered. return } TISRegisterInputSource(SquirrelApp.appDir as CFURL) @@ -55,7 +53,7 @@ final class SquirrelInstaller { let enabledInputModes = enabledModes() if !enabledInputModes.isEmpty && modes.isEmpty { print("User already enabled Squirrel method(s): \(enabledInputModes.map { $0.rawValue })") - // keep user's manually enabled input modes. + // Preserve manually enabled input modes. return } let modesToEnable = modes.isEmpty ? [.primary] : modes diff --git a/sources/MacOSKeyCodes.swift b/sources/MacOSKeyCodes.swift index c9cc8cc04..b851338a5 100644 --- a/sources/MacOSKeyCodes.swift +++ b/sources/MacOSKeyCodes.swift @@ -36,7 +36,7 @@ struct SquirrelKeycode { } if let keychar = keychar, keychar.isASCII, let codeValue = keychar.unicodeScalars.first?.value { - // NOTE: IBus/Rime use different keycodes for uppercase/lowercase letters. + // IBus/Rime use different keycodes for uppercase and lowercase letters. if keychar.isLowercase && (shift != caps) { // lowercase -> Uppercase return keychar.uppercased().unicodeScalars.first!.value diff --git a/sources/Main.swift b/sources/Main.swift index 01faeeb43..f998248d9 100644 --- a/sources/Main.swift +++ b/sources/Main.swift @@ -68,9 +68,7 @@ struct SquirrelApp { } return true case "--build": - // Notification 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) rimeAPI.setup(&builderTraits) @@ -125,18 +123,15 @@ struct SquirrelApp { } autoreleasepool { - // find the bundle identifier and then initialize the input method server let main = Bundle.main let connectionName = main.object(forInfoDictionaryKey: "InputMethodConnectionName") as! String _ = IMKServer(name: connectionName, bundleIdentifier: main.bundleIdentifier!) - // load the bundle explicitly because in this case the input method is a - // background only application let app = NSApplication.shared let delegate = SquirrelApplicationDelegate() app.delegate = delegate app.setActivationPolicy(.accessory) - // opencc will be configured with relative dictionary paths + // OpenCC uses relative dictionary paths from SharedSupport. FileManager.default.changeCurrentDirectoryPath(main.sharedSupportPath!) if NSApp.squirrelAppDelegate.problematicLaunchDetected() { @@ -155,7 +150,6 @@ struct SquirrelApp { print("Squirrel reporting!") } - // finally run everything app.run() print("Squirrel is quitting...") rimeAPI.finalize() diff --git a/sources/ReservedProperty.swift b/sources/ReservedProperty.swift index dd36915f5..91aa0104a 100644 --- a/sources/ReservedProperty.swift +++ b/sources/ReservedProperty.swift @@ -2,36 +2,25 @@ // ReservedProperty.swift // Squirrel // -// Reserved librime properties for plugin -> frontend coordination. -// See rime/squirrel#1124. -// -// Values use URL-style query strings. Bare values are stored under -// "value" for compatibility with historical comma-list payloads. import Foundation -/// Reserved property keys recognised by Squirrel. +// Reserved librime properties for plugin-to-frontend coordination. Values use +// URL-style query strings; bare values are stored under "value" for compatibility +// with historical comma-list payloads. See rime/squirrel#1124. enum ReservedPropertyKey: String { - /// Candidate comment indices using `accent_text_color`. case commentHighlight = "_comment_highlight" - - /// Candidate comment indices using `warning_text_color`. case commentWarning = "_comment_warning" - - /// Requests a candidate panel refresh. case refreshUI = "_refresh_ui" } -/// Parsed reserved-property fields. struct ReservedPropertyValue { let fields: [String: String] - /// Field used for bare values and index lists. static let defaultField = "value" static let empty = ReservedPropertyValue(fields: [:]) - /// Parses query strings or bare values written by plugins. static func parse(_ raw: String) throws(ReservedPropertyError) -> ReservedPropertyValue { guard !raw.isEmpty else { throw .emptyInput } if !raw.contains("=") { @@ -46,7 +35,6 @@ struct ReservedPropertyValue { throw .unknownInput(raw) } - /// Extracts non-negative indices from the `value` field. func indices() throws(ReservedPropertyError) -> Set { guard let raw = fields[Self.defaultField] else { throw .missingDefaultFields } var out = Set() diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index 376f2fcc1..124ef7b2e 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -139,7 +139,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta func setupRime() { createDirIfNotExist(path: SquirrelApp.userDir) createDirIfNotExist(path: SquirrelApp.logDir) - // expose log file location to librime for librime plugins to write into + // Expose the log directory to librime plugins. setenv("RIME_LOG_DIR", SquirrelApp.logDir.path(), 1) // swiftlint:disable identifier_name let notification_handler: @convention(c) (UnsafeMutableRawPointer?, RimeSessionId, UnsafePointer?, UnsafePointer?) -> Void = notificationHandler @@ -161,13 +161,8 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta func startRime(fullCheck: Bool) { print("Initializing la rime...") rimeAPI.initialize(nil) - // check for configuration updates if rimeAPI.start_maintenance(fullCheck) { - // update squirrel config - // print("[DEBUG] maintenance suceeds") _ = rimeAPI.deploy_config_file("squirrel.yaml", "config_version") - } else { - // print("[DEBUG] maintenance fails") } } @@ -203,11 +198,10 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta schema.close() } - // prevent freezing the system + // Detect repeated launches that may indicate a bad configuration loop. func problematicLaunchDetected() -> Bool { var detected = false let logFile = FileManager.default.temporaryDirectory.appendingPathComponent("squirrel_launch.json", conformingTo: .json) - // print("[DEBUG] archive: \(logFile)") do { let archive = try Data(contentsOf: logFile, options: [.uncached]) let decoder = JSONDecoder() @@ -233,9 +227,6 @@ 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) diff --git a/sources/SquirrelConfig.swift b/sources/SquirrelConfig.swift index 3ae74a94c..71b9ae0f9 100644 --- a/sources/SquirrelConfig.swift +++ b/sources/SquirrelConfig.swift @@ -105,7 +105,6 @@ final class SquirrelConfig { var iterator = RimeConfigIterator() _ = rimeAPI.config_begin_map(&iterator, &config, rootKey) while rimeAPI.config_next(&iterator) { - // print("[DEBUG] option[\(iterator.index)]: \(String(cString: iterator.key)), path: (\(String(cString: iterator.path))") if let key = iterator.key, let path = iterator.path, let value = getBool(String(cString: path)) { appOptions[String(cString: key)] = value } diff --git a/sources/SquirrelInputController.swift b/sources/SquirrelInputController.swift index 9e7f856ad..c2adf74eb 100644 --- a/sources/SquirrelInputController.swift +++ b/sources/SquirrelInputController.swift @@ -21,7 +21,6 @@ final class SquirrelInputController: IMKInputController { private var schemaId: String = "" private var inlinePreedit = false private var inlineCandidate = false - // for chord-typing private var chordKeyCodes: [UInt32] = .init(repeating: 0, count: SquirrelInputController.keyRollOver) private var chordModifiers: [UInt32] = .init(repeating: 0, count: SquirrelInputController.keyRollOver) private var chordKeyCount: Int = 0 @@ -35,10 +34,7 @@ final class SquirrelInputController: IMKInputController { 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. + // Return true to consume the key event; return false to pass it to the client app. var handled = false if session == 0 || !rimeAPI.find_session(session) { @@ -60,13 +56,8 @@ final class SquirrelInputController: IMKInputController { handled = true break } - // print("[DEBUG] FLAGSCHANGED client: \(sender ?? "nil"), modifiers: \(modifiers)") var rimeModifiers: UInt32 = SquirrelKeycode.osxModifiersToRime(modifiers: modifiers) - // For flags-changed event, keyCode is available since macOS 10.15 (#715) - // Some remote desktop software (e.g. Parsec) sends flagsChanged events with - // keyCode defaulting to 0 (kVK_ANSI_A) instead of the actual modifier keycode, - // causing a ghost 'a' keypress. Validate and infer the correct keycode from - // the changed modifier flags when necessary. (#825) + // Some remote desktop tools send flagsChanged with keyCode 0; infer the real modifier key when needed. var keyCode = event.keyCode if !SquirrelKeycode.modifierKeycodes.contains(keyCode) { guard let inferred = SquirrelKeycode.inferModifierKeycode(from: changes) else { @@ -80,20 +71,17 @@ final class SquirrelInputController: IMKInputController { let rimeKeycode: UInt32 = SquirrelKeycode.osxKeycodeToRime(keycode: 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. - // so it is necessary to revert kLockMask. + // Rime expects XK_Caps_Lock before the lock mask changes; NSFlagsChanged has already applied it. rimeModifiers ^= kLockMask.rawValue _ = processKey(rimeKeycode, modifiers: rimeModifiers) } - // Need to process release before modifier down. Because - // sometimes release event is delayed to next modifier keydown. + // Process releases first because some modifier releases arrive with the next keydown. var buffer = [(keycode: UInt32, modifier: UInt32)]() for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] where changes.contains(flag) { - if modifiers.contains(flag) { // New modifier + if modifiers.contains(flag) { buffer.append((keycode: rimeKeycode, modifier: rimeModifiers)) - } else { // Release + } else { buffer.insert((keycode: rimeKeycode, modifier: rimeModifiers | kReleaseMask.rawValue), at: 0) } } @@ -105,7 +93,7 @@ final class SquirrelInputController: IMKInputController { rimeUpdate() case .keyDown: - // ignore Command+X hotkeys. + // Let client apps handle Command shortcuts. if modifiers.contains(.command) { break } @@ -117,9 +105,6 @@ final class SquirrelInputController: IMKInputController { (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, shift: modifiers.contains(.shift), @@ -176,13 +161,11 @@ final class SquirrelInputController: IMKInputController { } 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 == "" { keyboardLayout = "" @@ -195,7 +178,6 @@ final class SquirrelInputController: IMKInputController { client?.overrideKeyboard(withKeyboardNamed: keyboardLayout) } preedit = "" - // Update menu bar icon if session != 0 { let state = rimeAPI.get_option(session, "ascii_mode") let label = rimeAPI.get_state_label_abbreviated(session, "ascii_mode", state, true).asString @@ -205,11 +187,9 @@ final class SquirrelInputController: IMKInputController { 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() - // Listen for ASCII mode toggle notifications NotificationCenter.default.addObserver( forName: .init("SquirrelSetASCIIModeNotification"), object: nil, @@ -218,7 +198,6 @@ final class SquirrelInputController: IMKInputController { self?.handleASCIIModeToggle(notification) } - // Listen for ASCII mode status requests NotificationCenter.default.addObserver( forName: .init("SquirrelReportASCIIModeNotification"), object: nil, @@ -229,7 +208,6 @@ final class SquirrelInputController: IMKInputController { } override func deactivateServer(_ sender: Any!) { - // print("[DEBUG] deactivateServer: \(sender ?? "nil")") hidePalettes() commitComposition(sender) client = nil @@ -240,20 +218,8 @@ final class SquirrelInputController: IMKInputController { super.hidePalettes() } - /*! - @method - @abstract Called when a user action was taken that ends an input session. - Typically triggered by the user selecting a new input method - or keyboard layout. - @discussion When this method is called your controller should send the - current input buffer to the client via a call to - insertText:replacementRange:. Additionally, this is the time - 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 { if let input = rimeAPI.get_input(session) { commit(string: String(cString: input)) @@ -312,7 +278,6 @@ final class SquirrelInputController: IMKInputController { NSApp.squirrelAppDelegate.openWiki() } - // Added for special properties reserved for librime plugins private(set) var specialCommentIndices: [ReservedPropertyKey: Set] = [:] func handleReservedProperty(key rawKey: String, value rawValue: String, for sessionId: RimeSessionId) throws(ReservedPropertyError) { @@ -337,10 +302,9 @@ final class SquirrelInputController: IMKInputController { private extension SquirrelInputController { func onChordTimer(_: Timer) { - // chord release triggered by timer var processedKeys = false if chordKeyCount > 0 && session != 0 { - // simulate key-ups + // Chord typing releases are synthesized after the configured timeout. for i in 0..= Self.keyRollOver { - // you are cheating. only one human typist (fingers <= 10) is supported. return } chordKeyCodes[chordKeyCount] = keycode chordModifiers[chordKeyCount] = modifiers chordKeyCount += 1 - // reset timer if let timer = chordTimer, timer.isValid { timer.invalidate() } @@ -420,7 +381,6 @@ private extension SquirrelInputController { } func destroySession() { - // print("[DEBUG] destroySession:") if session != 0 { _ = rimeAPI.destroy_session(session) session = 0 @@ -429,30 +389,22 @@ private extension SquirrelInputController { } 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") { rimeAPI.set_option(session, "_linear", panel.linear) } - // with vertical text, arrow keys may behave differently. if panel.vertical != rimeAPI.get_option(session, "_vertical") { 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") && !rimeAPI.get_option(session, "ascii_mode") { rimeAPI.set_option(session, "ascii_mode", true) - // print("[DEBUG] turned Chinese mode off in vim-like editor's command mode") } } else { let isChordingKey = switch Int32(rimeKeycode) { @@ -464,7 +416,6 @@ private extension SquirrelInputController { if isChordingKey && rimeAPI.get_option(session, "_chord_typing") { updateChord(keycode: rimeKeycode, modifiers: rimeModifiers) } else if (rimeModifiers & kReleaseMask.rawValue) == 0 { - // non-chording key pressed clearChord() } } @@ -482,10 +433,8 @@ private extension SquirrelInputController { } } - // `clearReservedComments` defaults to true to preserve previous behavior - // false is used when `refresh_ui` message is sent from librime + // Preserve reserved comment marks when librime requests a UI-only refresh. func rimeUpdate(clearReservedComments: Bool = true) { - // print("[DEBUG] rimeUpdate") if clearReservedComments { specialCommentIndices = [:] } @@ -493,16 +442,13 @@ private extension SquirrelInputController { 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") - // if not inline, embed soft cursor in preedit string rimeAPI.set_option(session, "soft_cursor", !inlinePreedit) } } @@ -511,7 +457,6 @@ private extension SquirrelInputController { 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 @@ -556,7 +501,6 @@ private extension SquirrelInputController { // preedit can contain additional prompt text before start: // ^(prompt)[selection]$ let start = min(start, candidatePreview.endIndex) - // caret can be either before or after the selected range. let caretPos = caretPos <= start ? caretPos : endOfCandidatePreview show(preedit: candidatePreview, selRange: NSRange(location: start.utf16Offset(in: candidatePreview), @@ -566,15 +510,12 @@ private extension SquirrelInputController { if inlinePreedit { show(preedit: preedit, selRange: NSRange(location: start.utf16Offset(in: preedit), length: preedit.utf16.distance(from: start, to: end)), caretPos: caretPos.utf16Offset(in: preedit)) } else { - // TRICKY: display a non-empty string to prevent iTerm2 from echoing - // 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. + // Use a full-width space placeholder to prevent iTerm2 from echoing raw preedit; + // half-width placeholders make the Chinese composition baseline unstable. show(preedit: preedit.isEmpty ? "" : " ", selRange: NSRange(location: 0, length: 0), caretPos: 0) } } - // update candidates let numCandidates = Int(ctx.menu.num_candidates) var candidates = [String]() var comments = [String]() @@ -609,7 +550,6 @@ private extension SquirrelInputController { func commit(string: String) { guard let client = client else { return } - // print("[DEBUG] commitString: \(string)") client.insertText(string, replacementRange: .empty) preedit = "" hidePalettes() @@ -617,7 +557,6 @@ private extension SquirrelInputController { func show(preedit: String, selRange: NSRange, caretPos: Int) { guard let client = client else { return } - // print("[DEBUG] showPreeditString: '\(preedit)'") if self.preedit == preedit && self.caretPos == caretPos && self.selRange == selRange { return } @@ -626,7 +565,6 @@ private extension SquirrelInputController { self.caretPos = caretPos self.selRange = selRange - // print("[DEBUG] selRange.location = \(selRange.location), selRange.length = \(selRange.length); caretPos = \(caretPos)") let start = selRange.location let attrString = NSMutableAttributedString(string: preedit) if start > 0 { @@ -641,7 +579,6 @@ private extension SquirrelInputController { // 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() client.attributes(forCharacterIndex: 0, lineHeightRectangle: &inputPos) @@ -658,20 +595,16 @@ private extension SquirrelInputController { guard session != 0 && rimeAPI.find_session(session) else { return } rimeAPI.set_option(session, "ascii_mode", enableASCII) - - // Force update the UI to reflect the mode change rimeUpdate() } private func reportASCIIMode(_: Notification) { - // Only active input controller should respond guard client != nil else { return } guard session != 0 && rimeAPI.find_session(session) else { return } let isASCIIMode = rimeAPI.get_option(session, "ascii_mode") let status = isASCIIMode ? "ascii" : "nascii" - // Directly respond with the status DistributedNotificationCenter.default().postNotificationName( .init("SquirrelASCIIModeResponse"), object: status diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 87251c057..a9e2dae29 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -111,7 +111,6 @@ final class SquirrelPanel: NSPanel { case .scrollWheel: if event.phase == .began { scrollDirection = .zero - // 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) @@ -119,7 +118,6 @@ final class SquirrelPanel: NSPanel { _ = inputController?.page(up: scrollDirection.dy > 0) } scrollDirection = .zero - // Mouse scroll wheel } else if event.phase == .init(rawValue: 0) && event.momentumPhase == .init(rawValue: 0) { if scrollTime.timeIntervalSinceNow < -1 { scrollDirection = .zero @@ -151,7 +149,6 @@ final class SquirrelPanel: NSPanel { maxHeight = 0 } - // Main function to add attributes to text output from librime // 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 { @@ -188,7 +185,6 @@ final class SquirrelPanel: NSPanel { let preeditRange: NSRange let highlightedPreeditRange: NSRange - // preedit if !preedit.isEmpty { preeditRange = NSRange(location: 0, length: preedit.utf16.count) highlightedPreeditRange = selRange @@ -207,7 +203,6 @@ final class SquirrelPanel: NSPanel { highlightedPreeditRange = .empty } - // candidates var candidateRanges = [NSRange]() for i in 0.. 1 && i < labels.count { labels[i] } else if labels.count == 1 && i < labels.first!.count { - // custom: A. B. C... String(labels.first![labels.first!.index(labels.first!.startIndex, offsetBy: i)]) } else { - // default: 1. 2. 3... "\(i+1)" } } else { @@ -289,17 +282,16 @@ final class SquirrelPanel: NSPanel { text.append(line) } - // text done! view.textView.textContentStorage?.attributedString = text view.textView.setLayoutOrientation(vertical ? .vertical : .horizontal) - // 強制 TextKit 2 立即同步佈局,確保後續計算窗口和高亮背景時,拿到的是折行後的真實尺寸 + // Force TextKit 2 layout before measuring wrapped text and highlight bounds. let textWidth = maxTextWidth() let maxTextHeight = vertical ? screenRect.width - theme.edgeInset.width * 2 : screenRect.height - theme.edgeInset.height * 2 view.textContainer.size = NSSize(width: textWidth, height: maxTextHeight) view.textLayoutManager.ensureLayout(for: view.textLayoutManager.documentRange) - // 重置 NSTextView 內部視圖滾動位置,防止因爲折行超高導致自動滾動到文末(第一行溢出) + // Keep very tall wrapped text from auto-scrolling past the first line. view.textView.scrollToBeginningOfDocument(nil) view.drawView(candidateRanges: candidateRanges, hilightedIndex: index, preeditRange: preeditRange, highlightedPreeditRange: highlightedPreeditRange, canPageUp: page > 0, canPageDown: !lastPage) @@ -379,20 +371,19 @@ private extension SquirrelPanel { view.textView.textContainerInset = theme.edgeInset var textWidth = maxTextWidth() - // 高度設置爲無窮大,放開限制,讓超大文本完全測量出真實自然高度 + // Measure natural text height before constraining the panel. view.textContainer.size = NSSize(width: textWidth, height: .greatestFiniteMagnitude) - // 嚴禁 NSTextView 自動把 textContainer 縮小到當前的視圖寬度,防止死循環與文字消失 + // Do not let NSTextView shrink the container to the current view width; it can loop or hide text. view.textContainer.widthTracksTextView = false view.textContainer.heightTracksTextView = false - // 強制完成排版,並歸零 bounds view.textLayoutManager.ensureLayout(for: view.textLayoutManager.documentRange) view.textView.bounds.origin = .zero var contentRect = view.contentRect - // 計算出「不加限制時」需要的自然面板巨型尺寸 + // Compute the largest size possible for a giant panel var naturalPanelSize = NSSize.zero if vertical { naturalPanelSize.width = contentRect.height + theme.edgeInset.height * 2 @@ -402,29 +393,25 @@ private extension SquirrelPanel { naturalPanelSize.height = contentRect.height + theme.edgeInset.height * 2 } - // 屏幕最大可用範圍(留白 5%) let maxAllowedWidth = screenRect.width * 0.95 let maxAllowedHeight = screenRect.height * 0.95 - // 判斷是否需要觸發「全屏模式」 let requiresFullScreen = naturalPanelSize.width > maxAllowedWidth || naturalPanelSize.height > maxAllowedHeight if requiresFullScreen { - // --- 動態長寬比優化 --- - // 解決全屏縮放時,等比縮小導致「行長物理縮減、窗口變窄」的空間浪費問題 + // Expand line length before fullscreen scaling to avoid wasting screen space on narrow text. let area = contentRect.width * contentRect.height let screenRatio = maxAllowedWidth / maxAllowedHeight let optimalTextWidth: CGFloat if vertical { - // 直排:自然寬度=高,自然高度=寬。算出完美契合螢幕比例的虛擬行長 + // Width = screen height in vertical mode optimalTextWidth = sqrt(area / screenRatio) } else { - // 橫排:算出完美契合螢幕比例的虛擬行長 optimalTextWidth = sqrt(area * screenRatio) } - // 如果最佳行長大於原本設定的限制,就放開限制進行第二次完美排版 + // If there's extra room for text, tighten the layout if optimalTextWidth > textWidth { textWidth = optimalTextWidth view.textContainer.size = NSSize(width: textWidth, height: .greatestFiniteMagnitude) @@ -445,31 +432,25 @@ private extension SquirrelPanel { var panelRect = NSRect.zero if requiresFullScreen { - // --- 全屏縮放模式 --- let scaleX = maxAllowedWidth / naturalPanelSize.width let scaleY = maxAllowedHeight / naturalPanelSize.height - let scale = min(scaleX, scaleY) // 保持等比縮小 + let scale = min(scaleX, scaleY) - // 窗口實際物理大小被縮小 panelRect.size = NSSize(width: naturalPanelSize.width * scale, height: naturalPanelSize.height * scale) - // 屏幕正中央對齊 panelRect.origin = NSPoint( x: screenRect.minX + (screenRect.width - panelRect.width) / 2, y: screenRect.minY + (screenRect.height - panelRect.height) / 2 ) - maxHeight = 0 // 重置記憶尺寸緩存 + maxHeight = 0 } else { - // --- 常規跟隨光標模式 --- - // Apply memorizeSize if theme.memorizeSize && (vertical && position.midY / screenRect.height < 0.5) || (vertical && position.minX + max(contentRect.width, maxHeight) + theme.edgeInset.width * 2 > screenRect.maxX) { if contentRect.width >= maxHeight { maxHeight = contentRect.width } else { contentRect.size.width = maxHeight - // 需要根據記憶寬度更新自然尺寸 if vertical { naturalPanelSize.height = contentRect.width + theme.edgeInset.width * 2 + theme.pagingOffset } else { @@ -481,7 +462,7 @@ private extension SquirrelPanel { panelRect.size = naturalPanelSize if vertical { - // To avoid jumping up and down while typing + // Anchor vertical panels on one side of the cursor to avoid jumping while typing. if position.midY / screenRect.height >= 0.5 { panelRect.origin.y = position.minY - SquirrelTheme.offsetHeight - panelRect.height + theme.pagingOffset } else { @@ -496,7 +477,6 @@ private extension SquirrelPanel { 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 } if panelRect.minX < screenRect.minX { panelRect.origin.x = screenRect.minX } if panelRect.minY < screenRect.minY { @@ -508,13 +488,10 @@ private extension SquirrelPanel { self.setFrame(panelRect, display: true) - // contentView 的 frame 決定了它在窗口上的物理大小; - // contentView 的 bounds 決定了它內部的投影座標系。 - // 將 bounds 設爲 naturalPanelSize(自然尺寸),視圖內部畫的一切東西就會自動被縮小到窗口(frame)裏 + // Keep the window frame at the scaled physical size while drawing in natural coordinates through bounds. contentView!.frame = NSRect(origin: .zero, size: panelRect.size) contentView!.bounds = NSRect(origin: .zero, size: naturalPanelSize) - // rotate the view, the core in vertical mode! if vertical { contentView!.boundsRotation = -90 contentView!.setBoundsOrigin(NSPoint(x: 0, y: naturalPanelSize.width)) @@ -526,8 +503,7 @@ private extension SquirrelPanel { view.textView.boundsRotation = 0 view.textView.setBoundsOrigin(.zero) - // 下層組件必須讀取 contentView 旋轉後的真實 bounds - // (在 vertical 模式下,Cocoa 會自動將 bounds origin 偏移並交換長寬,確保內容在可見範圍內) + // Subviews must read the post-rotation bounds; Cocoa adjusts the origin and swaps dimensions in vertical mode. let subviewFrame = contentView!.bounds view.frame = subviewFrame diff --git a/sources/SquirrelTheme.swift b/sources/SquirrelTheme.swift index d14d370ee..5e6122a7f 100644 --- a/sources/SquirrelTheme.swift +++ b/sources/SquirrelTheme.swift @@ -249,9 +249,7 @@ final class SquirrelTheme { accentCommentTextColor = config.getColor("\(prefix)/accent_text_color", inSpace: colorSpace) warningCommentTextColor = config.getColor("\(prefix)/warning_text_color", inSpace: colorSpace) - // the following per-color-scheme configurations, if exist, will - // override configurations with the same name under the global 'style' - // section + // Per-color-scheme settings override global style settings. 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") diff --git a/sources/SquirrelView.swift b/sources/SquirrelView.swift index def4ab2b2..0a98ad64f 100644 --- a/sources/SquirrelView.swift +++ b/sources/SquirrelView.swift @@ -82,7 +82,6 @@ final class SquirrelView: NSView { 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 { @@ -102,7 +101,7 @@ final class SquirrelView: NSView { if x1 == -CGFloat.infinity { return .zero } return NSRect(x: min(0, x0), y: min(0, y0), width: x1 - min(0, x0), height: y1 - min(0, 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 @@ -116,7 +115,6 @@ final class SquirrelView: NSView { return NSRect(x: x0, y: y0, width: x1-x0, height: y1-y0) } - // Will triger - (void)drawRect:(NSRect)dirtyRect // swiftlint:disable:next function_parameter_count func drawView(candidateRanges: [NSRange], hilightedIndex: Int, preeditRange: NSRange, highlightedPreeditRange: NSRange, canPageUp: Bool, canPageDown: Bool) { self.candidateRanges = candidateRanges @@ -128,7 +126,6 @@ final class SquirrelView: NSView { self.needsDisplay = true } - // All draws happen here // swiftlint:disable:next cyclomatic_complexity override func draw(_ dirtyRect: NSRect) { var backgroundPath: CGPath? @@ -142,7 +139,6 @@ final class SquirrelView: NSView { containingRect.size.width -= theme.pagingOffset let backgroundRect = containingRect - // Draw preedit Rect var preeditRect = NSRect.zero if preeditRange.length > 0, let preeditTextRange = convert(range: preeditRange) { preeditRect = contentRect(range: preeditTextRange) @@ -160,16 +156,13 @@ final class SquirrelView: NSView { } containingRect = carveInset(rect: containingRect) - // Draw candidate Rects for i in 0.. 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) @@ -183,7 +176,6 @@ 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 innerBox.size.width -= (theme.edgeInset.width + 1) * 2 @@ -239,7 +231,6 @@ final class SquirrelView: NSView { panelLayer.mask = panelLayerMask self.layer?.addSublayer(panelLayer) - // Fill in colors if let color = theme.preeditBackgroundColor, let path = preeditPath { let layer = shapeFromPath(path: path) layer.fillColor = color.cgColor @@ -351,7 +342,7 @@ final class SquirrelView: NSView { } private extension SquirrelView { - // A tweaked sign function, to winddown corner radius when the size is small + // Soften corner radius when adjacent points are very close. func sign(_ number: NSPoint) -> NSPoint { if number.length >= 2 { return number / number.length @@ -360,7 +351,7 @@ private extension SquirrelView { } } - // Bezier cubic curve, which has continuous roundness + // Use cubic Bezier corners for continuous rounded paths. func drawSmoothLines(_ vertex: [NSPoint], straightCorner: Set, alpha: CGFloat, beta rawBeta: CGFloat) -> CGPath? { guard vertex.count >= 3 else { return nil @@ -418,8 +409,7 @@ private extension SquirrelView { 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 + // Split a multiline range into leading, complete-body, and trailing boxes. func multilineRects(forRange range: NSTextRange, extraSurounding: Double, bounds: NSRect) -> (NSRect, NSRect, NSRect) { let edgeInset = currentTheme.edgeInset var lineRects = [NSRect]() @@ -489,7 +479,7 @@ private extension SquirrelView { return (leadingRect, bodyRect, trailingRect) } - // Based on the 3 boxes from multilineRectForRange, calculate the vertex of the polygon containing the text in range + // Build the highlight polygon from the multiline text boxes. func multilineVertex(leadingRect: NSRect, bodyRect: NSRect, trailingRect: NSRect) -> [NSPoint] { if nearEmpty(bodyRect) && !nearEmpty(leadingRect) && nearEmpty(trailingRect) { return rectVertex(of: leadingRect) @@ -519,7 +509,7 @@ private extension SquirrelView { } } - // If the point is outside the innerBox, will extend to reach the outerBox + // Clamp points outside the inner border to the outer border. func expand(vertex: [NSPoint], innerBorder: NSRect, outerBorder: NSRect) -> [NSPoint] { var newVertex = [NSPoint]() for i in 0.. NSRect { var newRect = rect if !nearEmpty(newRect) { @@ -618,7 +607,6 @@ private extension SquirrelView { func linearMultilineFor(body: NSRect, leading: NSRect, trailing: NSRect) -> (Array, Array, Set, Set) { let highlightedPoints, highlightedPoints2: [NSPoint] let rightCorners, rightCorners2: Set - // Handles the special case where containing boxes are separated if nearEmpty(body) && !nearEmpty(leading) && !nearEmpty(trailing) && trailing.maxX < leading.minX { highlightedPoints = rectVertex(of: leading) highlightedPoints2 = rectVertex(of: trailing) @@ -671,7 +659,6 @@ private extension SquirrelView { 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)