Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
121 changes: 121 additions & 0 deletions sources/ReservedProperty.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// 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│
// └──────────────────────────────────────────────────────────────┘
//
// 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 "<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 { "value": "<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: `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: `value` (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 {

Check warning on line 60 in sources/ReservedProperty.swift

View workflow job for this annotation

GitHub Actions / build

unused

Property 'isAction' is unused
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.
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: `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: [defaultField: 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 neutral `value`
/// 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[Self.defaultField] else { return [] }
var out = Set<Int>()
for part in raw.split(separator: ",") {
let trimmed = part.trimmingCharacters(in: .whitespaces)
if let index = Int(trimmed), index >= 0 {
out.insert(index)
}
}
return out
}
}
21 changes: 21 additions & 0 deletions sources/SquirrelApplicationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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<CChar>?, UnsafePointer<CChar>?) -> Void = notificationHandler
let context_object = Unmanaged.passUnretained(self).toOpaque()
Expand Down Expand Up @@ -269,11 +275,26 @@ extension RimeStringSlice {
}
}

// swiftlint:disable:next cyclomatic_complexity
private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessionId: RimeSessionId, messageTypeC: UnsafePointer<CChar>?, messageValueC: UnsafePointer<CChar>?) {
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 eqIndex = messageValue.firstIndex(of: "="), messageValue.first == "_" {
let key = String(messageValue[..<eqIndex])
let value = String(messageValue[messageValue.index(after: eqIndex)...])
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 @@ final class SquirrelInputController: IMKInputController {

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 @@ final class SquirrelInputController: IMKInputController {

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 @@ final class SquirrelInputController: IMKInputController {
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 @@ -465,9 +499,20 @@ 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
func rimeUpdate() {
func rimeUpdate(clearReservedComments: Bool = true) {
// 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