diff --git a/data/squirrel.yaml b/data/squirrel.yaml index 40227deb2..b684b47ca 100644 --- a/data/squirrel.yaml +++ b/data/squirrel.yaml @@ -15,6 +15,15 @@ chord_duration: 0.1 # seconds # options: always | never | appropriate show_notifications_when: appropriate +# Menu-bar status icon. +# show — whether to show the icon at all; set to false for a clean menu bar. +# The icon's text is the schema's short state label for ascii_mode (via +# get_state_label_abbreviated). Schemas declaring `abbrev: [中, A]` get +# compact glyphs; otherwise the schema's `states:` value is used as-is. +# Falls back to "中" / "A" when no `states:` is defined. +status_icon: + show: true + style: color_scheme: native # Optional: define both light and dark color schemes to match system appearance diff --git a/sources/InputSource.swift b/sources/InputSource.swift index a0c80add3..8fd8daf41 100644 --- a/sources/InputSource.swift +++ b/sources/InputSource.swift @@ -91,6 +91,12 @@ final class SquirrelInstaller { } } + static func currentInputSourceID() -> String? { + let source = TISCopyCurrentKeyboardInputSource().takeRetainedValue() + let idRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) + return unsafeBitCast(idRef, to: CFString?.self) as String? + } + func disable(modes: [InputMode] = []) { let modesToDisable = modes.isEmpty ? InputMode.allCases : modes for (mode, inputSource) in getInputSource(modes: modesToDisable) { diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index c60376040..10d58d05f 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -8,6 +8,7 @@ import UserNotifications import Sparkle import AppKit +import InputMethodKit final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate { static let rimeWikiURL = URL(string: "https://github.com/rime/home/wiki")! @@ -18,6 +19,8 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta var config: SquirrelConfig? var panel: SquirrelPanel? var enableNotifications = false + var showStatusIcon: Bool = true + var statusItem: NSStatusItem? let updateController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) var supportsGentleScheduledUpdateReminders: Bool { true @@ -54,6 +57,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta func applicationWillFinishLaunching(_ notification: Notification) { panel = SquirrelPanel(position: .zero) + refreshStatusItem() addObservers() } @@ -62,6 +66,16 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta NotificationCenter.default.removeObserver(self) DistributedNotificationCenter.default().removeObserver(self) panel?.hide() + if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + + func updateStatusIcon(asciiMode: Bool, schemaLabel: String?) { + DispatchQueue.main.async { [weak self] in + self?.applyStatusIcon(asciiMode: asciiMode, schemaLabel: schemaLabel) + } } func deploy() { @@ -162,6 +176,8 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta } enableNotifications = config!.getString("show_notifications_when") != "never" + showStatusIcon = config!.getBool("status_icon/show") ?? true + refreshStatusItem() if let panel = panel, let config = self.config { panel.load(config: config, forDarkMode: false) panel.load(config: config, forDarkMode: true) @@ -225,6 +241,9 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta let notifCenter = DistributedNotificationCenter.default() notifCenter.addObserver(forName: .init("SquirrelReloadNotification"), object: nil, queue: nil, using: rimeNeedsReload) notifCenter.addObserver(forName: .init("SquirrelSyncNotification"), object: nil, queue: nil, using: rimeNeedsSync) + notifCenter.addObserver(forName: .init(kTISNotifySelectedKeyboardInputSourceChanged as String), object: nil, queue: .main) { [weak self] _ in + self?.updateStatusItemVisibility() + } } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -253,30 +272,42 @@ private func notificationHandler(contextObject: UnsafeMutableRawPointer?, sessio } return } - // off - if !delegate.enableNotifications { - return - } - if messageType == "schema", let messageValue = messageValue, let schemaName = try? /^[^\/]*\/(.*)$/.firstMatch(in: messageValue)?.output.1 { - delegate.showStatusMessage(msgTextLong: String(schemaName), msgTextShort: String(schemaName)) - return - } else if messageType == "option" { + if messageType == "option" { let state = messageValue?.first != "!" - let optionName = if state { - messageValue + let optionName: String? + if state { + optionName = messageValue + } else if let value = messageValue { + optionName = String(value[value.index(after: value.startIndex)...]) } else { - String(messageValue![messageValue!.index(after: messageValue!.startIndex)...]) + optionName = nil } if let optionName = optionName { optionName.withCString { name in - let stateLabelLong = delegate.rimeAPI.get_state_label_abbreviated(sessionId, name, state, false) + let stateLabelLong = delegate.rimeAPI.get_state_label_abbreviated(sessionId, name, state, false) let stateLabelShort = delegate.rimeAPI.get_state_label_abbreviated(sessionId, name, state, true) - let longLabel = stateLabelLong.str.map { String(cString: $0) } + let longLabel = stateLabelLong.str .map { String(cString: $0) } let shortLabel = stateLabelShort.str.map { String(cString: $0) } - delegate.showStatusMessage(msgTextLong: longLabel, msgTextShort: shortLabel) + if optionName == "ascii_mode" { + delegate.updateStatusIcon(asciiMode: state, schemaLabel: shortLabel) + } + if delegate.enableNotifications { + delegate.showStatusMessage(msgTextLong: longLabel, msgTextShort: shortLabel) + } } } + return + } + + // off + if !delegate.enableNotifications { + return + } + + if messageType == "schema", let messageValue = messageValue, let schemaName = try? /^[^\/]*\/(.*)$/.firstMatch(in: messageValue)?.output.1 { + delegate.showStatusMessage(msgTextLong: String(schemaName), msgTextShort: String(schemaName)) + return } } @@ -287,6 +318,43 @@ private extension SquirrelApplicationDelegate { } } + func refreshStatusItem() { + if showStatusIcon { + if statusItem == nil { + setupStatusItem() + } + } else if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } + + func setupStatusItem() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = item.button { + button.font = NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + button.toolTip = NSLocalizedString("Squirrel", comment: "") + } + statusItem = item + applyStatusIcon(asciiMode: false, schemaLabel: nil) + updateStatusItemVisibility() + } + + func updateStatusItemVisibility() { + guard let statusItem = statusItem else { return } + let id = SquirrelInstaller.currentInputSourceID() ?? "" + statusItem.isVisible = id.hasPrefix("im.rime.inputmethod.Squirrel") + } + + func applyStatusIcon(asciiMode: Bool, schemaLabel: String?) { + guard let button = statusItem?.button else { return } + if let schemaLabel = schemaLabel, !schemaLabel.isEmpty { + button.title = schemaLabel + } else { + button.title = asciiMode ? "A" : "中" + } + } + func shutdownRime() { config?.close() rimeAPI.finalize()