Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Squirrel.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -305,6 +306,7 @@
B3216E5B2BF438F800E292D2 /* rime.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; name = rime.pdf; path = resources/rime.pdf; sourceTree = "<group>"; };
B32B80772BE7FAA200FCF3BC /* Squirrel.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Squirrel.entitlements; path = resources/Squirrel.entitlements; sourceTree = "<group>"; };
B35D2FE72BF00839009D156B /* BridgingFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BridgingFunctions.swift; path = sources/BridgingFunctions.swift; sourceTree = "<group>"; };
B3A001012F260100009D156B /* ReservedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ReservedProperty.swift; path = sources/ReservedProperty.swift; sourceTree = "<group>"; };
B38E9B8F2BE9AE1E0036ABEF /* Squirrel-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Squirrel-Bridging-Header.h"; path = "sources/Squirrel-Bridging-Header.h"; sourceTree = "<group>"; };
B38E9B902BE9AE1E0036ABEF /* SquirrelApplicationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SquirrelApplicationDelegate.swift; path = sources/SquirrelApplicationDelegate.swift; sourceTree = "<group>"; };
B38E9B942BEAFEFD0036ABEF /* SquirrelInputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SquirrelInputController.swift; path = sources/SquirrelInputController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -351,6 +353,7 @@
B39771282BEDAF4A0093A49B /* SquirrelView.swift */,
B39771242BED899F0093A49B /* SquirrelConfig.swift */,
B35D2FE72BF00839009D156B /* BridgingFunctions.swift */,
B3A001012F260100009D156B /* ReservedProperty.swift */,
B38E9B8F2BE9AE1E0036ABEF /* Squirrel-Bridging-Header.h */,
);
name = Sources;
Expand Down Expand Up @@ -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;
};
Expand Down
10 changes: 9 additions & 1 deletion sources/Main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
113 changes: 113 additions & 0 deletions sources/ReservedProperty.swift
Original file line number Diff line number Diff line change
@@ -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("_<key>", "<value>") │
// │ librime notification_handler(type:"property", │
// │ value:"_<key>=<value>") │
// │ Squirrel ApplicationDelegate parses prefix → dispatches to │
// │ the active InputController via handleReservedProperty│
// └──────────────────────────────────────────────────────────────┘
//
// The leading-underscore namespace marks the key as part of this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keys with leading-underscore are "transient options/properties":
https://github.com/rime/librime/blob/d71168e9e8c8392ed219dca011dbc76b80727d6c/src/rime/context.cc#L313

Their lifetime are bound to the active input schema in the context.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — corrected. The header now states that leading-underscore keys are librime transient properties whose lifetime is bound to the active input schema in the context (and cleared on schema change), and frames this protocol as reserving a subset of that namespace rather than inventing it. Fixed in b983b60.

// reserved protocol. Plugin-private keys SHOULD use a "<plugin>/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": "<value>" },
// 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`)
Comment thread
wyjrichhh marked this conversation as resolved.
Outdated
///
/// 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<Int> {
guard let raw = fields["indices"] else { return [] }
var out = Set<Int>()
for part in raw.split(separator: ",") {
let trimmed = part.trimmingCharacters(in: .whitespaces)
if let n = Int(trimmed), n >= 0 {

Check failure on line 107 in sources/ReservedProperty.swift

View workflow job for this annotation

GitHub Actions / build

Variable name 'n' should be between 3 and 40 characters long (identifier_name)
out.insert(n)
}
}
return out
}
}
20 changes: 20 additions & 0 deletions sources/SquirrelApplicationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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?
Expand Down Expand Up @@ -139,6 +140,11 @@
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<CChar>?, UnsafePointer<CChar>?) -> Void = notificationHandler
let context_object = Unmanaged.passUnretained(self).toOpaque()
Expand Down Expand Up @@ -269,11 +275,25 @@
}
}

private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessionId: RimeSessionId, messageTypeC: UnsafePointer<CChar>?, messageValueC: UnsafePointer<CChar>?) {

Check warning on line 278 in sources/SquirrelApplicationDelegate.swift

View workflow job for this annotation

GitHub Actions / build

Function should have complexity 10 or less; currently complexity is 14 (cyclomatic_complexity)
let delegate: SquirrelApplicationDelegate = Unmanaged<SquirrelApplicationDelegate>.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", "<key>=<value>").
// 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 == "_" {

Check warning on line 289 in sources/SquirrelApplicationDelegate.swift

View workflow job for this annotation

GitHub Actions / build

Variable name 'eq' should be between 3 and 40 characters long (identifier_name)
let key = String(messageValue[..<eq])
let value = String(messageValue[messageValue.index(after: eq)...])
DispatchQueue.main.async {
delegate.activeInputController?.handleReservedProperty(key: key, value: value, for: sessionId)
}
return
}
if messageType == "deploy" {
switch messageValue {
case "start":
Expand Down
47 changes: 46 additions & 1 deletion sources/SquirrelInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@

override func activateServer(_ sender: Any!) {
self.client ?= sender as? IMKTextInput
NSApp.squirrelAppDelegate.activeInputController = self
// print("[DEBUG] activateServer:")
var keyboardLayout = NSApp.squirrelAppDelegate.config?.getString("keyboard_layout") ?? ""
if keyboardLayout == "last" || keyboardLayout == "" {
Expand Down Expand Up @@ -230,6 +231,9 @@

override func deactivateServer(_ sender: Any!) {
// print("[DEBUG] deactivateServer: \(sender ?? "nil")")
if NSApp.squirrelAppDelegate.activeInputController === self {
NSApp.squirrelAppDelegate.activeInputController = nil
}
hidePalettes()
commitComposition(sender)
client = nil
Expand Down Expand Up @@ -312,6 +316,36 @@
NSApp.squirrelAppDelegate.openWiki()
}

// State-type reserved property cache. Indices into the most recently
// rendered candidate list, populated by plugins via _comment_highlight
// / _comment_warning. SquirrelPanel reads them at render time to apply
// theme.accentCommentTextColor / warningCommentTextColor. Sticky within
// one composition; plugins overwrite each Compose() with the new list,
// an empty value clears.
private(set) var accentCommentIndices: Set<Int> = []
private(set) var warningCommentIndices: Set<Int> = []

/// 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()
}
Expand Down Expand Up @@ -466,8 +500,19 @@
}

// swiftlint:disable:next cyclomatic_complexity
func rimeUpdate() {
// `clearReservedComments` defaults to true so every state-changing update

Check warning on line 503 in sources/SquirrelInputController.swift

View workflow job for this annotation

GitHub Actions / build

SwiftLint rule 'cyclomatic_complexity' did not trigger a violation in the disabled region; remove the disable command (superfluous_disable_command)
// (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) {

Check warning on line 510 in sources/SquirrelInputController.swift

View workflow job for this annotation

GitHub Actions / build

Function should have complexity 10 or less; currently complexity is 13 (cyclomatic_complexity)
// print("[DEBUG] rimeUpdate")
if clearReservedComments {
accentCommentIndices = []
warningCommentIndices = []
}
rimeConsumeCommittedText()

var status = RimeStatus_stdbool.rimeStructInit()
Expand Down
23 changes: 22 additions & 1 deletion sources/SquirrelPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions sources/SquirrelTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
Loading