From 7315da18f2b46e3b909d4f4fbeae0a080ccf9fcd Mon Sep 17 00:00:00 2001 From: WYJRichhhhh Date: Wed, 17 Jun 2026 10:23:37 +0800 Subject: [PATCH 1/6] feat: add ReservedProperty protocol for preserving reserved comments Introduce ReservedProperty to track and preserve reserved comments across Rime updates, and avoid clearing them on caret/selection-only updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- Squirrel.xcodeproj/project.pbxproj | 4 + sources/Main.swift | 10 +- sources/ReservedProperty.swift | 113 ++++++++++++++++++++++ sources/SquirrelApplicationDelegate.swift | 20 ++++ sources/SquirrelInputController.swift | 47 ++++++++- sources/SquirrelPanel.swift | 23 ++++- sources/SquirrelTheme.swift | 9 ++ 7 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 sources/ReservedProperty.swift diff --git a/Squirrel.xcodeproj/project.pbxproj b/Squirrel.xcodeproj/project.pbxproj index 148fe3f37..813deaeb0 100644 --- a/Squirrel.xcodeproj/project.pbxproj +++ b/Squirrel.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ 7B5488C91D2DACDF0056A1BE /* symbols.yaml in Copy Shared Support Files */ = {isa = PBXBuildFile; fileRef = 7B54883B1D2DAAD10056A1BE /* symbols.yaml */; }; B3216E5C2BF438F800E292D2 /* rime.pdf in Resources */ = {isa = PBXBuildFile; fileRef = B3216E5B2BF438F800E292D2 /* rime.pdf */; }; B35D2FE82BF00839009D156B /* BridgingFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35D2FE72BF00839009D156B /* BridgingFunctions.swift */; }; + B3A001022F260100009D156B /* ReservedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A001012F260100009D156B /* ReservedProperty.swift */; }; B38E9B912BE9AE1E0036ABEF /* SquirrelApplicationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38E9B902BE9AE1E0036ABEF /* SquirrelApplicationDelegate.swift */; }; B38E9B952BEAFEFD0036ABEF /* SquirrelInputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38E9B942BEAFEFD0036ABEF /* SquirrelInputController.swift */; }; B39771232BECEA150093A49B /* MacOSKeyCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39771222BECEA150093A49B /* MacOSKeyCodes.swift */; }; @@ -305,6 +306,7 @@ B3216E5B2BF438F800E292D2 /* rime.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; name = rime.pdf; path = resources/rime.pdf; sourceTree = ""; }; B32B80772BE7FAA200FCF3BC /* Squirrel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Squirrel.entitlements; path = resources/Squirrel.entitlements; sourceTree = ""; }; B35D2FE72BF00839009D156B /* BridgingFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BridgingFunctions.swift; path = sources/BridgingFunctions.swift; sourceTree = ""; }; + B3A001012F260100009D156B /* ReservedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ReservedProperty.swift; path = sources/ReservedProperty.swift; sourceTree = ""; }; B38E9B8F2BE9AE1E0036ABEF /* Squirrel-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Squirrel-Bridging-Header.h"; path = "sources/Squirrel-Bridging-Header.h"; sourceTree = ""; }; B38E9B902BE9AE1E0036ABEF /* SquirrelApplicationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SquirrelApplicationDelegate.swift; path = sources/SquirrelApplicationDelegate.swift; sourceTree = ""; }; B38E9B942BEAFEFD0036ABEF /* SquirrelInputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SquirrelInputController.swift; path = sources/SquirrelInputController.swift; sourceTree = ""; }; @@ -351,6 +353,7 @@ B39771282BEDAF4A0093A49B /* SquirrelView.swift */, B39771242BED899F0093A49B /* SquirrelConfig.swift */, B35D2FE72BF00839009D156B /* BridgingFunctions.swift */, + B3A001012F260100009D156B /* ReservedProperty.swift */, B38E9B8F2BE9AE1E0036ABEF /* Squirrel-Bridging-Header.h */, ); name = Sources; @@ -623,6 +626,7 @@ B38E9B912BE9AE1E0036ABEF /* SquirrelApplicationDelegate.swift in Sources */, B39771272BED9B250093A49B /* SquirrelTheme.swift in Sources */, B35D2FE82BF00839009D156B /* BridgingFunctions.swift in Sources */, + B3A001022F260100009D156B /* ReservedProperty.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sources/Main.swift b/sources/Main.swift index 01faeeb43..847bec8a4 100644 --- a/sources/Main.swift +++ b/sources/Main.swift @@ -18,7 +18,15 @@ struct SquirrelApp { 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) + // Use ~/Library/Logs/Squirrel/ instead of TMPDIR so that the log files are + // visible from a normal user shell (the IMK sandbox redirects TMPDIR to a + // location that is not reachable outside the sandbox, which makes debugging + // very hard — see https://github.com/rime/squirrel/issues for context). + static let logDir = if let pwuid = getpwuid(getuid()) { + URL(fileURLWithFileSystemRepresentation: pwuid.pointee.pw_dir, isDirectory: true, relativeTo: nil).appending(components: "Library", "Logs", "Squirrel") + } else { + try! FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("Logs/Squirrel", isDirectory: true) + } // swiftlint:disable:next cyclomatic_complexity static func main() { diff --git a/sources/ReservedProperty.swift b/sources/ReservedProperty.swift new file mode 100644 index 000000000..621f58e24 --- /dev/null +++ b/sources/ReservedProperty.swift @@ -0,0 +1,113 @@ +// +// ReservedProperty.swift +// Squirrel +// +// Cross-frontend protocol for plugin -> frontend coordination over +// librime's notification_handler. See rime/squirrel#1124. +// +// ┌──────────────────────────── flow ────────────────────────────┐ +// │ Plugin ctx->set_property("_", "") │ +// │ librime notification_handler(type:"property", │ +// │ value:"_=") │ +// │ Squirrel ApplicationDelegate parses prefix → dispatches to │ +// │ the active InputController via handleReservedProperty│ +// └──────────────────────────────────────────────────────────────┘ +// +// The leading-underscore namespace marks the key as part of this +// reserved protocol. Plugin-private keys SHOULD use a "/key" +// namespace instead so they will never collide with reserved keys. +// +// Value encoding: URL-style query string (RFC 3986 application/x-www- +// form-urlencoded). Picked over JSON / YAML because: +// - Builtin parser is available on every target frontend +// (Swift URLComponents / Win HTTP / Lua / C++ Boost) +// - weasel previously used JSON for IPC and dropped it on +// performance grounds (rime/squirrel#1124, fxliang 2026-05-27) +// - Forward-compatible: unknown fields are preserved and ignored +// +// Backward-compatible shorthand: +// A bare value without "=" is treated as { "indices": "" }, +// so the historical "_comment_highlight=0,2" form still works. + +import Foundation + +/// Reserved property keys recognised by Squirrel. Plugins targeting any +/// Rime frontend should only use keys listed here; unrecognised "_*" +/// keys are silently ignored so the table can grow without breaking +/// older Squirrel builds. +enum ReservedPropertyKey: String { + /// State - candidates at these indices should render their comment + /// with `accent_text_color` from the active color scheme. + /// Fields: `indices` (comma-separated non-negative integers) + case commentHighlight = "_comment_highlight" + + /// State - candidates at these indices should render their comment + /// with `warning_text_color` from the active color scheme. + /// Fields: `indices` (comma-separated non-negative integers) + case commentWarning = "_comment_warning" + + /// Action - the candidate panel should be refreshed because an async + /// task (network / inference / ...) has produced new candidates. + /// Optional fields: `source` (plugin codename), `kind` (full|partial) + case refreshUI = "_refresh_ui" + + /// `true` when the key represents a one-shot action that should be + /// applied and forgotten. `false` when it represents a piece of + /// composition-scoped state that sticks until the next overwrite. + var isAction: Bool { + switch self { + case .refreshUI: + return true + case .commentHighlight, .commentWarning: + return false + } + } +} + +/// Parsed representation of a reserved-property value. +/// +/// Use `fields[name]` for a single scalar (e.g. `source`, `kind`) and +/// `indices()` for the conventional comma-separated non-negative integer +/// list that several keys carry. +struct ReservedPropertyValue { + let fields: [String: String] + + static let empty = ReservedPropertyValue(fields: [:]) + + /// Parses raw value strings written by plugins. + /// + /// Accepts two shapes: + /// 1. URL-style query string: `indices=0,2&source=ai_predict` + /// 2. Bare comma list: `0,2` (normalised to `indices=0,2`) + /// + /// Both shapes round-trip through the same `fields[name]` API so + /// callers never need to know which one the plugin used. + static func parse(_ raw: String) -> ReservedPropertyValue { + guard !raw.isEmpty else { return .empty } + if !raw.contains("=") { + return ReservedPropertyValue(fields: ["indices": raw]) + } + // URLComponents needs a scheme-less URL with a leading "?". + guard let queryItems = URLComponents(string: "?\(raw)")?.queryItems else { + return .empty + } + let pairs = queryItems.map { ($0.name, $0.value ?? "") } + let dict = Dictionary(pairs, uniquingKeysWith: { _, new in new }) + return ReservedPropertyValue(fields: dict) + } + + /// Extracts a non-negative integer index list from the conventional + /// `indices` field. Whitespace and malformed entries are skipped so + /// stray spaces in hand-written plugin code don't break rendering. + func indices() -> Set { + guard let raw = fields["indices"] else { return [] } + var out = Set() + for part in raw.split(separator: ",") { + let trimmed = part.trimmingCharacters(in: .whitespaces) + if let n = Int(trimmed), n >= 0 { + out.insert(n) + } + } + return out + } +} diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index 27f5f02ae..bcecf47a6 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -18,6 +18,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee var config: SquirrelConfig? var panel: SquirrelPanel? + weak var activeInputController: SquirrelInputController? var enableNotifications = false var showStatusIcon: Bool = true var statusItem: NSStatusItem? @@ -139,6 +140,11 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta func setupRime() { createDirIfNotExist(path: SquirrelApp.userDir) createDirIfNotExist(path: SquirrelApp.logDir) + // librime 不会把 log_dir 透传给插件 dylib(每个插件 dylib 各自静态 + // 链接了一份 glog,与主进程实例互不可见,参见 rime/librime#983)。 + // 我们在这里把日志目录通过环境变量暴露出来,让插件初始化它那一份 + // glog 实例时可以输出到与主进程相同的目录,方便用户集中查看。 + setenv("RIME_LOG_DIR", SquirrelApp.logDir.path(), 1) // swiftlint:disable identifier_name let notification_handler: @convention(c) (UnsafeMutableRawPointer?, RimeSessionId, UnsafePointer?, UnsafePointer?) -> Void = notificationHandler let context_object = Unmanaged.passUnretained(self).toOpaque() @@ -274,6 +280,20 @@ private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessio let messageType = messageTypeC.map { String(cString: $0) } let messageValue = messageValueC.map { String(cString: $0) } + // Reserved property keys: cross-frontend protocol per rime/squirrel#1124. + // librime forwards every ctx->set_property() as ("property", "="). + // We honour keys with the leading "_" namespace, treating them as a + // contract between plugins and frontends. Unrecognized "_*" keys are + // silently ignored, so adding a new reserved key is backward-compatible. + if messageType == "property", let messageValue = messageValue, + let eq = messageValue.firstIndex(of: "="), messageValue.first == "_" { + let key = String(messageValue[.. = [] + private(set) var warningCommentIndices: Set = [] + + /// Dispatched on the main queue from notificationHandler when librime + /// forwards a reserved property key (leading underscore). The wire + /// format and reserved-key table are documented in ReservedProperty.swift + /// (rime/squirrel#1124). Unknown keys are silently ignored so the table + /// can grow over time without breaking older Squirrel builds. + func handleReservedProperty(key rawKey: String, value rawValue: String, for sessionId: RimeSessionId) { + guard session == sessionId, session != 0, rimeAPI.find_session(session) else { return } + guard let key = ReservedPropertyKey(rawValue: rawKey) else { return } + let parsed = ReservedPropertyValue.parse(rawValue) + switch key { + case .commentHighlight: + accentCommentIndices = parsed.indices() + case .commentWarning: + warningCommentIndices = parsed.indices() + case .refreshUI: + // Preserve the indices just set by _comment_highlight/_comment_warning; + // this is the render pass that paints them. + rimeUpdate(clearReservedComments: false) + } + } + deinit { destroySession() } @@ -466,8 +500,19 @@ private extension SquirrelInputController { } // swiftlint:disable:next cyclomatic_complexity - func rimeUpdate() { + // `clearReservedComments` defaults to true so every state-changing update + // (keystroke, paging, caret move, chord release, ascii toggle) drops the + // reserved-comment indices set by the *previous* Compose(). They are only + // preserved for the `_refresh_ui`-driven render (see handleReservedProperty), + // which is the pass that actually paints the indices the plugin just set. + // Without this, stale indices from an earlier keystroke colour the wrong + // candidates in the new list, or linger after the plugin stops highlighting. + func rimeUpdate(clearReservedComments: Bool = true) { // print("[DEBUG] rimeUpdate") + if clearReservedComments { + accentCommentIndices = [] + warningCommentIndices = [] + } rimeConsumeCommittedText() var status = RimeStatus_stdbool.rimeStructInit() diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 7742b2ebb..61216bcce 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -240,7 +240,28 @@ final class SquirrelPanel: NSPanel { } } for range in line.string.ranges(of: /\[comment\]/) { - line.addAttributes(commentAttrs, range: convert(range: range, in: line.string)) + let convertedRange = convert(range: range, in: line.string) + // Apply semantic accent/warning colors only for non-highlighted rows; + // when the row is highlighted, the highlighted comment color wins so + // selection state stays unambiguous. Indices come from reserved + // property keys (_comment_highlight / _comment_warning) maintained + // on the input controller; see rime/squirrel#1124. + let semanticColor: NSColor? = if i == index { + nil + } else if inputController?.accentCommentIndices.contains(i) == true { + theme.accentCommentTextColor + } else if inputController?.warningCommentIndices.contains(i) == true { + theme.warningCommentTextColor + } else { + nil + } + if let semanticColor = semanticColor { + var override = commentAttrs + override[.foregroundColor] = semanticColor + line.addAttributes(override, range: convertedRange) + } else { + line.addAttributes(commentAttrs, range: convertedRange) + } } line.mutableString.replaceOccurrences(of: "[label]", with: label, range: NSRange(location: 0, length: line.length)) let labeledLine = line.copy() as! NSAttributedString diff --git a/sources/SquirrelTheme.swift b/sources/SquirrelTheme.swift index feacfccb4..210ea0ca6 100644 --- a/sources/SquirrelTheme.swift +++ b/sources/SquirrelTheme.swift @@ -47,6 +47,13 @@ final class SquirrelTheme { private var highlightedCandidateLabelColor: NSColor? = .secondaryLabelColor private var commentTextColor: NSColor? = .secondaryLabelColor private var highlightedCommentTextColor: NSColor? = .secondaryLabelColor + // Semantic comment colors (proposal in rime/squirrel#1124). + // Plugins / translators don't pick literal RGB values; instead they tag + // candidates by semantic role and the active color scheme owns the actual + // values. Both default to nil → fall back to commentTextColor at render + // time, so existing themes need no change. + private(set) var accentCommentTextColor: NSColor? + private(set) var warningCommentTextColor: NSColor? private(set) var cornerRadius: CGFloat = 0 private(set) var hilitedCornerRadius: CGFloat = 0 @@ -243,6 +250,8 @@ final class SquirrelTheme { 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) + 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' From b983b60dd5f0a385b8819334917127169e0e7552 Mon Sep 17 00:00:00 2001 From: WYJRichhhhh Date: Wed, 17 Jun 2026 12:09:09 +0800 Subject: [PATCH 2/6] refactor: address review on ReservedProperty protocol - Document leading-underscore keys as librime transient properties whose lifetime is bound to the active input schema (per @lotem, context.cc). - Rename the bare-value shorthand field from `indices` to the neutral `value` so non-index keys can reuse the same scalar shorthand. Co-Authored-By: Claude Opus 4.8 (1M context) --- sources/ReservedProperty.swift | 36 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/sources/ReservedProperty.swift b/sources/ReservedProperty.swift index 621f58e24..07d18dd0d 100644 --- a/sources/ReservedProperty.swift +++ b/sources/ReservedProperty.swift @@ -13,9 +13,12 @@ // │ the active InputController via handleReservedProperty│ // └──────────────────────────────────────────────────────────────┘ // -// The leading-underscore namespace marks the key as part of this -// reserved protocol. Plugin-private keys SHOULD use a "/key" -// namespace instead so they will never collide with reserved keys. +// Leading-underscore keys are librime "transient properties": their +// lifetime is bound to the active input schema in the context and they +// are cleared when the schema changes (see librime context.cc). This +// protocol reserves a subset of that namespace for cross-frontend use. +// Plugin-private keys SHOULD use a "/key" namespace instead so +// they will never collide with reserved keys. // // Value encoding: URL-style query string (RFC 3986 application/x-www- // form-urlencoded). Picked over JSON / YAML because: @@ -26,7 +29,7 @@ // - Forward-compatible: unknown fields are preserved and ignored // // Backward-compatible shorthand: -// A bare value without "=" is treated as { "indices": "" }, +// A bare value without "=" is treated as { "value": "" }, // so the historical "_comment_highlight=0,2" form still works. import Foundation @@ -38,12 +41,12 @@ import Foundation enum ReservedPropertyKey: String { /// State - candidates at these indices should render their comment /// with `accent_text_color` from the active color scheme. - /// Fields: `indices` (comma-separated non-negative integers) + /// Fields: `value` (comma-separated non-negative integers) case commentHighlight = "_comment_highlight" /// State - candidates at these indices should render their comment /// with `warning_text_color` from the active color scheme. - /// Fields: `indices` (comma-separated non-negative integers) + /// Fields: `value` (comma-separated non-negative integers) case commentWarning = "_comment_warning" /// Action - the candidate panel should be refreshed because an async @@ -68,24 +71,29 @@ enum ReservedPropertyKey: String { /// /// Use `fields[name]` for a single scalar (e.g. `source`, `kind`) and /// `indices()` for the conventional comma-separated non-negative integer -/// list that several keys carry. +/// list carried in the neutral `value` field. struct ReservedPropertyValue { let fields: [String: String] + /// Field name a bare (no "=") value is stored under, and the field + /// `indices()` reads from. Neutral so keys that aren't index lists can + /// reuse the same shorthand for their own scalar payload. + static let defaultField = "value" + static let empty = ReservedPropertyValue(fields: [:]) /// Parses raw value strings written by plugins. /// /// Accepts two shapes: - /// 1. URL-style query string: `indices=0,2&source=ai_predict` - /// 2. Bare comma list: `0,2` (normalised to `indices=0,2`) + /// 1. URL-style query string: `value=0,2&source=ai_predict` + /// 2. Bare comma list: `0,2` (normalised to `value=0,2`) /// /// Both shapes round-trip through the same `fields[name]` API so /// callers never need to know which one the plugin used. static func parse(_ raw: String) -> ReservedPropertyValue { guard !raw.isEmpty else { return .empty } if !raw.contains("=") { - return ReservedPropertyValue(fields: ["indices": raw]) + return ReservedPropertyValue(fields: [defaultField: raw]) } // URLComponents needs a scheme-less URL with a leading "?". guard let queryItems = URLComponents(string: "?\(raw)")?.queryItems else { @@ -96,11 +104,11 @@ struct ReservedPropertyValue { return ReservedPropertyValue(fields: dict) } - /// Extracts a non-negative integer index list from the conventional - /// `indices` field. Whitespace and malformed entries are skipped so - /// stray spaces in hand-written plugin code don't break rendering. + /// Extracts a non-negative integer index list from the neutral `value` + /// field. Whitespace and malformed entries are skipped so stray spaces + /// in hand-written plugin code don't break rendering. func indices() -> Set { - guard let raw = fields["indices"] else { return [] } + guard let raw = fields[Self.defaultField] else { return [] } var out = Set() for part in raw.split(separator: ",") { let trimmed = part.trimmingCharacters(in: .whitespaces) From 822f1977b1daccbc27ff4bdeacadddd63e51ea04 Mon Sep 17 00:00:00 2001 From: WYJRichhhhh Date: Wed, 17 Jun 2026 13:18:58 +0800 Subject: [PATCH 3/6] style: fix swiftlint identifier_name warnings Rename short local bindings flagged by swiftlint and reposition a cyclomatic_complexity disable comment so it sits directly above the function declaration: - ReservedProperty.swift: n -> index - SquirrelApplicationDelegate.swift: eq -> eqIndex - SquirrelInputController.swift: move disable directive to the decl No behavioural change. Co-Authored-By: Claude Opus 4.8 (1M context) --- sources/ReservedProperty.swift | 4 ++-- sources/SquirrelApplicationDelegate.swift | 7 ++++--- sources/SquirrelInputController.swift | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/sources/ReservedProperty.swift b/sources/ReservedProperty.swift index 07d18dd0d..cdb3d021c 100644 --- a/sources/ReservedProperty.swift +++ b/sources/ReservedProperty.swift @@ -112,8 +112,8 @@ struct ReservedPropertyValue { var out = Set() for part in raw.split(separator: ",") { let trimmed = part.trimmingCharacters(in: .whitespaces) - if let n = Int(trimmed), n >= 0 { - out.insert(n) + if let index = Int(trimmed), index >= 0 { + out.insert(index) } } return out diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index bcecf47a6..de62840a3 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -275,6 +275,7 @@ extension RimeStringSlice { } } +// swiftlint:disable:next cyclomatic_complexity private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessionId: RimeSessionId, messageTypeC: UnsafePointer?, messageValueC: UnsafePointer?) { let delegate: SquirrelApplicationDelegate = Unmanaged.fromOpaque(contextObject!).takeUnretainedValue() @@ -286,9 +287,9 @@ private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessio // contract between plugins and frontends. Unrecognized "_*" keys are // silently ignored, so adding a new reserved key is backward-compatible. if messageType == "property", let messageValue = messageValue, - let eq = messageValue.firstIndex(of: "="), messageValue.first == "_" { - let key = String(messageValue[.. Date: Sun, 21 Jun 2026 17:29:28 -0400 Subject: [PATCH 4/6] Fix the PR by suggestions --- sources/Main.swift | 10 +- sources/ReservedProperty.swift | 111 ++++++---------------- sources/SquirrelApplicationDelegate.swift | 35 +++---- sources/SquirrelInputController.swift | 48 +++------- sources/SquirrelPanel.swift | 27 ++---- sources/SquirrelTheme.swift | 4 - 6 files changed, 64 insertions(+), 171 deletions(-) diff --git a/sources/Main.swift b/sources/Main.swift index 847bec8a4..01faeeb43 100644 --- a/sources/Main.swift +++ b/sources/Main.swift @@ -18,15 +18,7 @@ struct SquirrelApp { static let appDir = "/Library/Input Library/Squirrel.app".withCString { dir in URL(fileURLWithFileSystemRepresentation: dir, isDirectory: false, relativeTo: nil) } - // Use ~/Library/Logs/Squirrel/ instead of TMPDIR so that the log files are - // visible from a normal user shell (the IMK sandbox redirects TMPDIR to a - // location that is not reachable outside the sandbox, which makes debugging - // very hard — see https://github.com/rime/squirrel/issues for context). - static let logDir = if let pwuid = getpwuid(getuid()) { - URL(fileURLWithFileSystemRepresentation: pwuid.pointee.pw_dir, isDirectory: true, relativeTo: nil).appending(components: "Library", "Logs", "Squirrel") - } else { - try! FileManager.default.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appendingPathComponent("Logs/Squirrel", isDirectory: true) - } + static let logDir = FileManager.default.temporaryDirectory.appending(component: "rime.squirrel", directoryHint: .isDirectory) // swiftlint:disable:next cyclomatic_complexity static func main() { diff --git a/sources/ReservedProperty.swift b/sources/ReservedProperty.swift index cdb3d021c..dd36915f5 100644 --- a/sources/ReservedProperty.swift +++ b/sources/ReservedProperty.swift @@ -2,120 +2,69 @@ // ReservedProperty.swift // Squirrel // -// Cross-frontend protocol for plugin -> frontend coordination over -// librime's notification_handler. See rime/squirrel#1124. +// Reserved librime properties for plugin -> frontend coordination. +// See rime/squirrel#1124. // -// ┌──────────────────────────── flow ────────────────────────────┐ -// │ Plugin ctx->set_property("_", "") │ -// │ librime notification_handler(type:"property", │ -// │ value:"_=") │ -// │ Squirrel ApplicationDelegate parses prefix → dispatches to │ -// │ the active InputController via handleReservedProperty│ -// └──────────────────────────────────────────────────────────────┘ -// -// Leading-underscore keys are librime "transient properties": their -// lifetime is bound to the active input schema in the context and they -// are cleared when the schema changes (see librime context.cc). This -// protocol reserves a subset of that namespace for cross-frontend use. -// Plugin-private keys SHOULD use a "/key" namespace instead so -// they will never collide with reserved keys. -// -// Value encoding: URL-style query string (RFC 3986 application/x-www- -// form-urlencoded). Picked over JSON / YAML because: -// - Builtin parser is available on every target frontend -// (Swift URLComponents / Win HTTP / Lua / C++ Boost) -// - weasel previously used JSON for IPC and dropped it on -// performance grounds (rime/squirrel#1124, fxliang 2026-05-27) -// - Forward-compatible: unknown fields are preserved and ignored -// -// Backward-compatible shorthand: -// A bare value without "=" is treated as { "value": "" }, -// so the historical "_comment_highlight=0,2" form still works. +// 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. Plugins targeting any -/// Rime frontend should only use keys listed here; unrecognised "_*" -/// keys are silently ignored so the table can grow without breaking -/// older Squirrel builds. +/// Reserved property keys recognised by Squirrel. enum ReservedPropertyKey: String { - /// State - candidates at these indices should render their comment - /// with `accent_text_color` from the active color scheme. - /// Fields: `value` (comma-separated non-negative integers) + /// Candidate comment indices using `accent_text_color`. case commentHighlight = "_comment_highlight" - /// State - candidates at these indices should render their comment - /// with `warning_text_color` from the active color scheme. - /// Fields: `value` (comma-separated non-negative integers) + /// Candidate comment indices using `warning_text_color`. case commentWarning = "_comment_warning" - /// Action - the candidate panel should be refreshed because an async - /// task (network / inference / ...) has produced new candidates. - /// Optional fields: `source` (plugin codename), `kind` (full|partial) + /// Requests a candidate panel refresh. case refreshUI = "_refresh_ui" - - /// `true` when the key represents a one-shot action that should be - /// applied and forgotten. `false` when it represents a piece of - /// composition-scoped state that sticks until the next overwrite. - var isAction: Bool { - switch self { - case .refreshUI: - return true - case .commentHighlight, .commentWarning: - return false - } - } } -/// Parsed representation of a reserved-property value. -/// -/// Use `fields[name]` for a single scalar (e.g. `source`, `kind`) and -/// `indices()` for the conventional comma-separated non-negative integer -/// list carried in the neutral `value` field. +/// Parsed reserved-property fields. struct ReservedPropertyValue { let fields: [String: String] - /// Field name a bare (no "=") value is stored under, and the field - /// `indices()` reads from. Neutral so keys that aren't index lists can - /// reuse the same shorthand for their own scalar payload. + /// Field used for bare values and index lists. static let defaultField = "value" static let empty = ReservedPropertyValue(fields: [:]) - /// Parses raw value strings written by plugins. - /// - /// Accepts two shapes: - /// 1. URL-style query string: `value=0,2&source=ai_predict` - /// 2. Bare comma list: `0,2` (normalised to `value=0,2`) - /// - /// Both shapes round-trip through the same `fields[name]` API so - /// callers never need to know which one the plugin used. - static func parse(_ raw: String) -> ReservedPropertyValue { - guard !raw.isEmpty else { return .empty } + /// 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("=") { return ReservedPropertyValue(fields: [defaultField: raw]) } // URLComponents needs a scheme-less URL with a leading "?". - guard let queryItems = URLComponents(string: "?\(raw)")?.queryItems else { - return .empty + if let queryItems = URLComponents(string: "?\(raw)")?.queryItems { + let pairs = queryItems.map { ($0.name, $0.value ?? "") } + let dict = Dictionary(pairs, uniquingKeysWith: { _, new in new }) + return ReservedPropertyValue(fields: dict) } - let pairs = queryItems.map { ($0.name, $0.value ?? "") } - let dict = Dictionary(pairs, uniquingKeysWith: { _, new in new }) - return ReservedPropertyValue(fields: dict) + throw .unknownInput(raw) } - /// Extracts a non-negative integer index list from the neutral `value` - /// field. Whitespace and malformed entries are skipped so stray spaces - /// in hand-written plugin code don't break rendering. - func indices() -> Set { - guard let raw = fields[Self.defaultField] else { return [] } + /// 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() for part in raw.split(separator: ",") { let trimmed = part.trimmingCharacters(in: .whitespaces) if let index = Int(trimmed), index >= 0 { out.insert(index) + } else { + throw .invalidIndex(trimmed) } } return out } } + +enum ReservedPropertyError: Error { + case emptyInput + case unknownInput(String) + case missingDefaultFields + case invalidIndex(String) +} diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index de62840a3..4234446f5 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -18,7 +18,6 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta let rimeAPI: RimeApi_stdbool = rime_get_api_stdbool().pointee var config: SquirrelConfig? var panel: SquirrelPanel? - weak var activeInputController: SquirrelInputController? var enableNotifications = false var showStatusIcon: Bool = true var statusItem: NSStatusItem? @@ -140,10 +139,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta func setupRime() { createDirIfNotExist(path: SquirrelApp.userDir) createDirIfNotExist(path: SquirrelApp.logDir) - // librime 不会把 log_dir 透传给插件 dylib(每个插件 dylib 各自静态 - // 链接了一份 glog,与主进程实例互不可见,参见 rime/librime#983)。 - // 我们在这里把日志目录通过环境变量暴露出来,让插件初始化它那一份 - // glog 实例时可以输出到与主进程相同的目录,方便用户集中查看。 + // expose log file location to librime for librime plugins to write into setenv("RIME_LOG_DIR", SquirrelApp.logDir.path(), 1) // swiftlint:disable identifier_name let notification_handler: @convention(c) (UnsafeMutableRawPointer?, RimeSessionId, UnsafePointer?, UnsafePointer?) -> Void = notificationHandler @@ -278,23 +274,10 @@ extension RimeStringSlice { // swiftlint:disable:next cyclomatic_complexity 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) } - // Reserved property keys: cross-frontend protocol per rime/squirrel#1124. - // librime forwards every ctx->set_property() as ("property", "="). - // We honour keys with the leading "_" namespace, treating them as a - // contract between plugins and frontends. Unrecognized "_*" keys are - // silently ignored, so adding a new reserved key is backward-compatible. - if messageType == "property", let messageValue = messageValue, - let eqIndex = messageValue.firstIndex(of: "="), messageValue.first == "_" { - let key = String(messageValue[.. = [] - private(set) var warningCommentIndices: Set = [] - - /// Dispatched on the main queue from notificationHandler when librime - /// forwards a reserved property key (leading underscore). The wire - /// format and reserved-key table are documented in ReservedProperty.swift - /// (rime/squirrel#1124). Unknown keys are silently ignored so the table - /// can grow over time without breaking older Squirrel builds. - func handleReservedProperty(key rawKey: String, value rawValue: String, for sessionId: RimeSessionId) { + // 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) { guard session == sessionId, session != 0, rimeAPI.find_session(session) else { return } - guard let key = ReservedPropertyKey(rawValue: rawKey) else { return } - let parsed = ReservedPropertyValue.parse(rawValue) + guard let key = ReservedPropertyKey(rawValue: rawKey) else { throw .unknownInput(rawKey) } + let parsed = try ReservedPropertyValue.parse(rawValue) switch key { case .commentHighlight: - accentCommentIndices = parsed.indices() + specialCommentIndices[.commentHighlight] = try parsed.indices() case .commentWarning: - warningCommentIndices = parsed.indices() + specialCommentIndices[.commentWarning] = try parsed.indices() case .refreshUI: - // Preserve the indices just set by _comment_highlight/_comment_warning; - // this is the render pass that paints them. rimeUpdate(clearReservedComments: false) } } @@ -499,19 +482,12 @@ private extension SquirrelInputController { } } - // `clearReservedComments` defaults to true so every state-changing update - // (keystroke, paging, caret move, chord release, ascii toggle) drops the - // reserved-comment indices set by the *previous* Compose(). They are only - // preserved for the `_refresh_ui`-driven render (see handleReservedProperty), - // which is the pass that actually paints the indices the plugin just set. - // Without this, stale indices from an earlier keystroke colour the wrong - // candidates in the new list, or linger after the plugin stops highlighting. - // swiftlint:disable:next cyclomatic_complexity + // `clearReservedComments` defaults to true to preserve previous behavior + // false is used when `refresh_ui` message is sent from librime func rimeUpdate(clearReservedComments: Bool = true) { // print("[DEBUG] rimeUpdate") if clearReservedComments { - accentCommentIndices = [] - warningCommentIndices = [] + specialCommentIndices = [:] } rimeConsumeCommittedText() @@ -689,7 +665,7 @@ private extension SquirrelInputController { private func reportASCIIMode(_: Notification) { // Only active input controller should respond - guard let client = client else { return } + guard client != nil else { return } guard session != 0 && rimeAPI.find_session(session) else { return } let isASCIIMode = rimeAPI.get_option(session, "ascii_mode") diff --git a/sources/SquirrelPanel.swift b/sources/SquirrelPanel.swift index 61216bcce..87251c057 100644 --- a/sources/SquirrelPanel.swift +++ b/sources/SquirrelPanel.swift @@ -241,24 +241,15 @@ final class SquirrelPanel: NSPanel { } for range in line.string.ranges(of: /\[comment\]/) { let convertedRange = convert(range: range, in: line.string) - // Apply semantic accent/warning colors only for non-highlighted rows; - // when the row is highlighted, the highlighted comment color wins so - // selection state stays unambiguous. Indices come from reserved - // property keys (_comment_highlight / _comment_warning) maintained - // on the input controller; see rime/squirrel#1124. - let semanticColor: NSColor? = if i == index { - nil - } else if inputController?.accentCommentIndices.contains(i) == true { - theme.accentCommentTextColor - } else if inputController?.warningCommentIndices.contains(i) == true { - theme.warningCommentTextColor - } else { - nil - } - if let semanticColor = semanticColor { - var override = commentAttrs - override[.foregroundColor] = semanticColor - line.addAttributes(override, range: convertedRange) + // Apply semantic accent/warning colors only for non-highlighted rows + if let inputController, !inputController.specialCommentIndices.isEmpty && i != index { + var newCommentAttrs = commentAttrs + if let accent = inputController.specialCommentIndices[.commentHighlight], accent.contains(i) { + newCommentAttrs[.foregroundColor] = theme.accentCommentTextColor + } else if let warning = inputController.specialCommentIndices[.commentWarning], warning.contains(i) { + newCommentAttrs[.foregroundColor] = theme.warningCommentTextColor + } + line.addAttributes(newCommentAttrs, range: convertedRange) } else { line.addAttributes(commentAttrs, range: convertedRange) } diff --git a/sources/SquirrelTheme.swift b/sources/SquirrelTheme.swift index 210ea0ca6..d14d370ee 100644 --- a/sources/SquirrelTheme.swift +++ b/sources/SquirrelTheme.swift @@ -48,10 +48,6 @@ final class SquirrelTheme { private var commentTextColor: NSColor? = .secondaryLabelColor private var highlightedCommentTextColor: NSColor? = .secondaryLabelColor // Semantic comment colors (proposal in rime/squirrel#1124). - // Plugins / translators don't pick literal RGB values; instead they tag - // candidates by semantic role and the active color scheme owns the actual - // values. Both default to nil → fall back to commentTextColor at render - // time, so existing themes need no change. private(set) var accentCommentTextColor: NSColor? private(set) var warningCommentTextColor: NSColor? From f2e37b56b6888d51902325682661759aabae9522 Mon Sep 17 00:00:00 2001 From: LEO Yoon Tsaw Date: Sun, 21 Jun 2026 17:30:31 -0400 Subject: [PATCH 5/6] Lint --- sources/SquirrelApplicationDelegate.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index 4234446f5..f1a0b095c 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -274,10 +274,10 @@ extension RimeStringSlice { // swiftlint:disable:next cyclomatic_complexity 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": From cd03a81cd02acba062be14061b9e93a5e2476566 Mon Sep 17 00:00:00 2001 From: LEO Yoon Tsaw Date: Sun, 21 Jun 2026 18:23:15 -0400 Subject: [PATCH 6/6] Print out errors in handleReservedProperty --- sources/SquirrelApplicationDelegate.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index f1a0b095c..376f2fcc1 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -324,7 +324,11 @@ private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessio let key = String(messageValue[..