diff --git a/.gitignore b/.gitignore index 1f2816c..1580104 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ build/ dist/ +.build/ +.swiftpm/ +Package.resolved .DS_Store *.o *.swp diff --git a/AppIcon.icns b/AppIcon.icns deleted file mode 100644 index 6dca585..0000000 Binary files a/AppIcon.icns and /dev/null differ diff --git a/Makefile b/Makefile index 473eb1c..1af8681 100644 --- a/Makefile +++ b/Makefile @@ -1,24 +1,13 @@ -APP_NAME := TermIMS -BUILD_DIR := build -BUNDLE := $(BUILD_DIR)/$(APP_NAME).app -BIN := $(BUNDLE)/Contents/MacOS/$(APP_NAME) +APP_NAME := TermIMS +BUILD_DIR := build +BUNDLE := $(BUILD_DIR)/$(APP_NAME).app INSTALL_DIR := /Applications/$(APP_NAME).app -SRC := TermIMS.swift -PLIST := Info.plist -DIST_DIR := dist -FRAMEWORKS := -framework Cocoa -framework Carbon +DIST_DIR := dist -.PHONY: build install clean run dist +.PHONY: build install run dist clean -build: $(BIN) - -$(BIN): $(SRC) $(PLIST) AppIcon.icns - @mkdir -p $(BUNDLE)/Contents/MacOS $(BUNDLE)/Contents/Resources - @cp $(PLIST) $(BUNDLE)/Contents/ - @cp AppIcon.icns $(BUNDLE)/Contents/Resources/ - swiftc -O -o $@ $(SRC) $(FRAMEWORKS) - @codesign --force --sign - $(BUNDLE) - @echo "Built → $(BUNDLE)" +build: + @bash Scripts/package-app.sh install: build @pkill -x $(APP_NAME) 2>/dev/null && sleep 0.3 || true @@ -40,4 +29,4 @@ dist: build @echo "Packaged → $(DIST_DIR)/$(APP_NAME).dmg" clean: - rm -rf $(BUILD_DIR) $(DIST_DIR) + rm -rf $(BUILD_DIR) $(DIST_DIR) .build .swiftpm diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..fe87d96 --- /dev/null +++ b/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "TermIMS", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "TermIMS", targets: ["TermIMS"]) + ], + targets: [ + .executableTarget( + name: "TermIMS", + path: "Sources/TermIMS" + ) + ] +) diff --git a/README.md b/README.md index ccc2bef..f9ff8bc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

TermIMS

-

+

A lightweight macOS menu bar app that automatically switches input methods based on the active application — and for terminal apps, based on the running process or tab title.

@@ -55,25 +55,20 @@ Download `TermIMS.dmg` from the [Releases](https://github.com/cuiko/TermIMS/rele ### Build from source -The project is a single Swift file compiled directly with `swiftc`. No Xcode project needed. +Swift Package Manager layout, no Xcode project. `make` wraps `swift build -c release` and bundles the binary into `TermIMS.app` via `Scripts/package-app.sh`. ```sh git clone https://github.com/cuiko/TermIMS.git cd TermIMS -# Build locally -make build - -# Build and install to /Applications -make install - -# Build, install, and launch -make run - -# Package as DMG for distribution -make dist +make build # build into build/TermIMS.app +make install # build + copy to /Applications +make run # build + install + launch +make dist # package dist/TermIMS.dmg ``` +Requires Swift 5.9+ (Xcode 15) and macOS 13.0+. + ## Usage 1. Launch TermIMS (or run `make run`). diff --git a/icon.png b/Resources/AppIcon.png similarity index 100% rename from icon.png rename to Resources/AppIcon.png diff --git a/Scripts/package-app.sh b/Scripts/package-app.sh new file mode 100755 index 0000000..2c69bd5 --- /dev/null +++ b/Scripts/package-app.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Build the SPM target, then assemble a TermIMS.app bundle in build/. +# Mirrors notchnotes' Scripts/package-app.sh layout: SPM builds the binary, +# this script wraps it with Info.plist + an .icns generated from +# Resources/AppIcon.png, and ad-hoc signs the result. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/build" +APP_DIR="$BUILD_DIR/TermIMS.app" +CONTENTS_DIR="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" +SOURCE_ICON="$ROOT_DIR/Resources/AppIcon.png" +SIGN_IDENTITY="${SIGN_IDENTITY:--}" + +cd "$ROOT_DIR" +swift build -c release + +rm -rf "$APP_DIR" +mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" +cp "$ROOT_DIR/.build/release/TermIMS" "$MACOS_DIR/TermIMS" +cp "$ROOT_DIR/Info.plist" "$CONTENTS_DIR/Info.plist" + +if [[ -f "$SOURCE_ICON" ]]; then + TMP_DIR="$(mktemp -d)" + ICONSET_DIR="$TMP_DIR/AppIcon.iconset" + mkdir -p "$ICONSET_DIR" + sips -z 16 16 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null + sips -z 32 32 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null + sips -z 32 32 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null + sips -z 64 64 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null + sips -z 128 128 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null + sips -z 256 256 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null + sips -z 256 256 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null + sips -z 512 512 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null + sips -z 512 512 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null + sips -z 1024 1024 "$SOURCE_ICON" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null + iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/AppIcon.icns" + rm -rf "$TMP_DIR" +fi + +codesign --force --sign "$SIGN_IDENTITY" "$APP_DIR" >/dev/null +echo "Built → $APP_DIR" diff --git a/Sources/TermIMS/AppDelegate.swift b/Sources/TermIMS/AppDelegate.swift new file mode 100644 index 0000000..e0712b7 --- /dev/null +++ b/Sources/TermIMS/AppDelegate.swift @@ -0,0 +1,217 @@ +import Cocoa +import ApplicationServices + +class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem? + private let monitor = FocusMonitor() + private let indicator = IndicatorPanel() + private var settingsWC: SettingsWindowController? + private var permissionWC: PermissionWindowController? + private var enabledItem: NSMenuItem! + private var loginItem: NSMenuItem! + private var permissionPollTimer: Timer? + private var wasTrusted = false + + private var launchAgentPath: String { + NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist" + } + + func applicationDidFinishLaunching(_ n: Notification) { + Log.debug("=== TermIMS started ===") + installEditMenu() + if AXIsProcessTrusted() { + wasTrusted = true + startApp() + } else { + showPermissionWindow() + } + startPermissionPolling() + } + + /// LSUIElement apps don't get a main menu by default, which means + /// Cmd+C/V/X/A in any text field are dispatched into the void. Wire up + /// a minimal Edit menu so the standard shortcuts reach the field editor. + private func installEditMenu() { + let main = NSMenu() + let editItem = NSMenuItem() + main.addItem(editItem) + + let edit = NSMenu(title: "Edit") + edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + let redo = NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "z") + redo.keyEquivalentModifierMask = [.command, .shift] + edit.addItem(redo) + edit.addItem(.separator()) + edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + edit.addItem(withTitle: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: "") + edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + + editItem.submenu = edit + NSApp.mainMenu = main + } + + private func startPermissionPolling() { + permissionPollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + guard let self else { return } + let trusted = AXIsProcessTrusted() + if self.wasTrusted && !trusted { + self.wasTrusted = false + self.monitor.stop() + self.rebuildMenu() + } else if !self.wasTrusted && trusted { + self.wasTrusted = true + self.startApp() + } + } + } + + private func showPermissionWindow() { + permissionWC = PermissionWindowController() + permissionWC?.onGranted = { [weak self] in + self?.permissionWC = nil + self?.startApp() + } + permissionWC?.showWindow(nil) + permissionWC?.startPolling() + NSApp.activate(ignoringOtherApps: true) + } + + private var appStarted = false + private func startApp() { + guard !appStarted else { rebuildMenu(); monitor.reload(); return } + appStarted = true + applyMenuBarVisibility() + NotificationCenter.default.addObserver(self, selector: #selector(rebuildMenu), + name: .rulesDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(imDidSwitch), + name: .imDidSwitch, object: nil) + monitor.start() + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + if !AXIsProcessTrusted() { + showPermissionWindow() + } else { + showSettings() + } + return true + } + + @objc private func imDidSwitch() { + guard RuleStore.shared.indicatorEnabled, + let name = currentInputSourceName() else { return } + indicator.show(text: name, position: RuleStore.shared.indicatorPosition) + } + + private func applyMenuBarVisibility() { + if RuleStore.shared.hideMenuBarIcon { + if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + statusItem = nil + } + } else { + if statusItem == nil { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let btn = statusItem?.button { + btn.image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "TermIMS") + } + } + rebuildMenu() + } + } + + @objc private func rebuildMenu() { + if RuleStore.shared.hideMenuBarIcon { applyMenuBarVisibility(); return } + guard let statusItem else { applyMenuBarVisibility(); return } + + let menu = NSMenu() + let store = RuleStore.shared + let trusted = AXIsProcessTrusted() + + if !trusted { + let warn = NSMenuItem(title: "Accessibility Permission Required", action: #selector(showPermissionPrompt), keyEquivalent: "") + warn.target = self + warn.image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: nil) + menu.addItem(warn) + menu.addItem(.separator()) + } + + enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "") + enabledItem.target = self; enabledItem.state = monitor.enabled ? .on : .off + menu.addItem(enabledItem) + menu.addItem(.separator()) + + let defName = store.defaultSourceName ?? "None" + let defItem = NSMenuItem(title: "Default: \(defName)", action: nil, keyEquivalent: "") + defItem.isEnabled = false; menu.addItem(defItem) + + let activeAppRules = store.rules.filter(\.enabled) + if !activeAppRules.isEmpty { + menu.addItem(.separator()) + let hdr = NSMenuItem(title: "App Rules", action: nil, keyEquivalent: "") + hdr.isEnabled = false; menu.addItem(hdr) + for rule in activeAppRules { + let item = NSMenuItem(title: " \(rule.appName) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "") + item.isEnabled = false; menu.addItem(item) + } + } + + let activeTermRules = store.terminalRules.filter(\.enabled) + if !activeTermRules.isEmpty { + menu.addItem(.separator()) + let hdr = NSMenuItem(title: "Terminal Rules", action: nil, keyEquivalent: "") + hdr.isEnabled = false; menu.addItem(hdr) + for rule in activeTermRules { + let typeStr = rule.matchType == .title ? "title" : "proc" + let item = NSMenuItem(title: " \(typeStr):\(rule.pattern) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "") + item.isEnabled = false; menu.addItem(item) + } + } + + menu.addItem(.separator()) + let settings = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",") + settings.target = self; menu.addItem(settings) + + menu.addItem(.separator()) + loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLogin), keyEquivalent: "") + loginItem.target = self + loginItem.state = FileManager.default.fileExists(atPath: launchAgentPath) ? .on : .off + menu.addItem(loginItem) + + menu.addItem(.separator()) + let quit = NSMenuItem(title: "Quit TermIMS", action: #selector(quitApp), keyEquivalent: "q") + quit.target = self; menu.addItem(quit) + + statusItem.menu = menu + } + + @objc private func toggleEnabled() { + monitor.enabled.toggle(); enabledItem.state = monitor.enabled ? .on : .off + } + @objc private func showPermissionPrompt() { + showPermissionWindow() + } + @objc private func showSettings() { + if !AXIsProcessTrusted() { showPermissionWindow(); return } + if settingsWC == nil { settingsWC = SettingsWindowController() } + settingsWC?.showWindow(nil); NSApp.activate(ignoringOtherApps: true) + } + @objc private func toggleLogin() { + let fm = FileManager.default + if fm.fileExists(atPath: launchAgentPath) { + try? fm.removeItem(atPath: launchAgentPath) + } else { + try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", withIntermediateDirectories: true) + let plist: NSDictionary = [ + "Label": "top.cuiko.termims", + "ProgramArguments": [Bundle.main.executablePath ?? ""], + "RunAtLoad": true, + ] + plist.write(toFile: launchAgentPath, atomically: true) + } + loginItem.state = fm.fileExists(atPath: launchAgentPath) ? .on : .off + } + @objc private func quitApp() { monitor.stop(); NSApp.terminate(nil) } +} diff --git a/Sources/TermIMS/FocusMonitor.swift b/Sources/TermIMS/FocusMonitor.swift new file mode 100644 index 0000000..2e2ec9a --- /dev/null +++ b/Sources/TermIMS/FocusMonitor.swift @@ -0,0 +1,477 @@ +import Cocoa +import ApplicationServices + +class AXContext { + let bundleID: String + weak var monitor: FocusMonitor? + init(_ bid: String, _ m: FocusMonitor) { bundleID = bid; monitor = m } +} + +class FocusMonitor { + var enabled = true + private var observers: [String: AXObserver] = [:] + private var elements: [String: AXUIElement] = [:] + private var contexts: [String: AXContext] = [:] + private var termDebounce: Timer? + + func start() { + let nc = NSWorkspace.shared.notificationCenter + nc.addObserver(self, selector: #selector(activated), + name: NSWorkspace.didActivateApplicationNotification, object: nil) + nc.addObserver(self, selector: #selector(launched), + name: NSWorkspace.didLaunchApplicationNotification, object: nil) + nc.addObserver(self, selector: #selector(terminated), + name: NSWorkspace.didTerminateApplicationNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(reload), + name: .rulesDidChange, object: nil) + reload() + } + + func stop() { + NSWorkspace.shared.notificationCenter.removeObserver(self) + NotificationCenter.default.removeObserver(self) + termDebounce?.invalidate() + for bid in Array(observers.keys) { detach(bid) } + } + + @objc func reload() { + let store = RuleStore.shared + var needed = Set(store.rules.filter(\.enabled).map(\.appBundleID)) + if store.terminalRules.contains(where: \.enabled) { + needed.formUnion(terminalBundleIDs) + } + for bid in observers.keys where !needed.contains(bid) { detach(bid) } + for bid in needed where observers[bid] == nil { attach(bid) } + } + + private func isTerminalWithRules(_ bid: String) -> Bool { + terminalBundleIDs.contains(bid) && RuleStore.shared.terminalRules.contains(where: \.enabled) + } + + private func resolveInputSource(for bid: String) -> String? { + let store = RuleStore.shared + let result: (String?, String) + if isTerminalWithRules(bid) { + if let matched = matchTerminalRule(bid: bid, store: store) { + result = (matched, "terminal-rule") + } else if let termDefault = store.terminalDefaultSourceID { + result = (termDefault, "terminal-default") + } else if let rule = store.rules.first(where: { $0.enabled && $0.appBundleID == bid }) { + result = (rule.inputSourceID, "app-rule") + } else { + result = (store.defaultSourceID, "global-default") + } + } else if let rule = store.rules.first(where: { $0.enabled && $0.appBundleID == bid }) { + result = (rule.inputSourceID, "app-rule") + } else { + result = (store.defaultSourceID, "global-default") + } + Log.debug("MATCH RESULT bid=\(bid) source=\(result.0 ?? "nil") via=\(result.1)") + return result.0 + } + + private func matchTerminalRule(bid: String, store: RuleStore) -> String? { + Log.debug("MATCH START bid=\(bid)") + let rules = store.terminalRules.filter(\.enabled) + guard !rules.isEmpty else { return nil } + + let titleRules = rules.filter { $0.matchType == .title } + let processRules = rules.filter { $0.matchType == .process } + + if !titleRules.isEmpty, let title = getFocusedWindowTitle(bid: bid) { + for rule in titleRules where !rule.pattern.isEmpty { + if title.matches(pattern: rule.pattern) { + Log.debug("TITLE RULE HIT: pattern=\(rule.pattern) title=\(title)") + return rule.inputSourceID + } + } + } + + if !processRules.isEmpty { + let candidates = getTerminalCandidateProcesses(bid: bid) + let titleForLog = getFocusedWindowTitle(bid: bid) ?? "" + Log.debug("PROCESS MATCH: title=\(titleForLog) candidates=\(candidates) rules=\(processRules.map { "\($0.pattern)" })") + + // Per-candidate rule resolution. Multiple candidates mean we + // couldn't narrow the focused tab to one tty (e.g. several + // ghostty tabs in the same cwd, generic title). Apply a rule + // only when every candidate agrees — otherwise an idle tab + // could get mis-switched just because a *different* tab in + // the same cwd happens to be running `claude`. + let hits: [TerminalRule?] = candidates.map { procs in + processRules.first(where: { rule in + !rule.pattern.isEmpty && + procs.contains(where: { $0.matches(pattern: rule.pattern) }) + }) + } + if let firstHit = hits.first ?? nil, + hits.allSatisfy({ $0?.inputSourceID == firstHit.inputSourceID }) { + Log.debug("PROCESS RULE HIT: pattern=\(firstHit.pattern) across \(hits.count) candidate(s)") + return firstHit.inputSourceID + } + let hitCount = hits.compactMap { $0 }.count + Log.debug("PROCESS RULE MISS: \(candidates.count) candidate(s), \(hitCount) hit, ambiguous → fall through") + } + + return nil + } + + // MARK: AX Helpers + + private func getFocusedWindow(bid: String) -> AXUIElement? { + guard let el = elements[bid] else { return nil } + var win: AnyObject? + guard AXUIElementCopyAttributeValue(el, kAXFocusedWindowAttribute as CFString, &win) == .success, + let raw = win, + CFGetTypeID(raw) == AXUIElementGetTypeID() else { return nil } + return (raw as! AXUIElement) + } + + private func getFocusedWindowTitle(bid: String) -> String? { + guard let app = elements[bid] else { return nil } + return TerminalAdapters.adapter(for: bid).focusedTitle(appElement: app) + } + + private struct TabInfo { let cwd: String; let tty: dev_t? } + + private func getFocusedTabInfo(bid: String) -> TabInfo? { + guard let win = getFocusedWindow(bid: bid) else { return nil } + var val: AnyObject? + guard AXUIElementCopyAttributeValue(win, kAXDocumentAttribute as CFString, &val) == .success, + let urlStr = val as? String, + let url = URL(string: urlStr) else { return nil } + Log.debug("AXDocument raw=\(urlStr)") + let path = url.path + guard !path.isEmpty else { return nil } + let tty = URLComponents(string: urlStr)?.queryItems? + .first(where: { $0.name == "tty" })?.value + .flatMap { Self.ttyDev(forName: $0) } + return TabInfo(cwd: path, tty: tty) + } + + /// Look up `/dev/`'s rdev so we can compare against `kinfo_proc.e_tdev`. + /// Exposed (internal) because terminal adapters resolve their own tty names + /// (AppleScript, CLI output) and need to convert to dev_t for filtering. + static func ttyDev(forName name: String) -> dev_t? { + var st = stat() + guard lstat("/dev/" + name, &st) == 0 else { return nil } + return st.st_rdev + } + + // MARK: Process Helpers + + private func processCWD(_ pid: Int32) -> String? { + var pathInfo = proc_vnodepathinfo() + let size = Int32(MemoryLayout.size) + let ret = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &pathInfo, size) + guard ret == size else { return nil } + return withUnsafePointer(to: pathInfo.pvi_cdir.vip_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { + String(cString: $0) + } + } + } + + private func procName(from kp: kinfo_proc) -> String { + withUnsafePointer(to: kp.kp_proc.p_comm) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN) + 1) { + String(cString: $0) + } + } + } + + private func getTerminalCandidateProcesses(bid: String) -> [[String]] { + // When the user runs multiple instances of the same terminal app + // (e.g. several kitty windows, each a separate process), prefer the + // currently active one — that's the instance whose focus event + // brought us here. Falls back to first match for safety. + let apps = NSWorkspace.shared.runningApplications + guard let app = apps.first(where: { $0.bundleIdentifier == bid && $0.isActive }) + ?? apps.first(where: { $0.bundleIdentifier == bid }) else { return [] } + let termPid = app.processIdentifier + + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL] + var size: Int = 0 + guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 0 else { return [] } + let count = size / MemoryLayout.stride + var procs = [kinfo_proc](repeating: kinfo_proc(), count: count) + guard sysctl(&mib, 3, &procs, &size, nil, 0) == 0 else { return [] } + let actual = size / MemoryLayout.stride + + struct PE { let pid: Int32; let ppid: Int32; let tdev: dev_t; let isFg: Bool; let comm: String } + var entries: [PE] = [] + entries.reserveCapacity(actual) + for i in 0..() + var queue: [Int32] = [termPid] + while let p = queue.popLast() { + for e in entries where e.ppid == p { + descendants.insert(e.pid) + queue.append(e.pid) + } + } + + // 1. Native tty (Apple Terminal AppleScript, kitty/wezterm CLI). Works + // without AXDocument — required for terminals that don't expose cwd. + if let tty = TerminalAdapters.adapter(for: bid).focusedTty(appPid: termPid) { + // All processes on this tty, not just the foreground process group. + // Foreground flips when cc/claude spawns tool subprocesses (bash, + // grep, ...) — but `claude` is still alive on the tty, so a full + // listing keeps the claude rule matching during "thinking" pauses. + let procs = entries.filter { descendants.contains($0.pid) && $0.tdev == tty }.map(\.comm) + Log.debug("TTY DIRECT: tty=\(tty) source=native procs=\(procs)") + return procs.isEmpty ? [] : [procs] + } + + // 2. OSC 7 query tty (shell hook) — needs AXDocument. + let tabInfo = getFocusedTabInfo(bid: bid) + if let tty = tabInfo?.tty { + let procs = entries.filter { descendants.contains($0.pid) && $0.tdev == tty }.map(\.comm) + Log.debug("TTY DIRECT: tty=\(tty) source=osc7 procs=\(procs)") + return procs.isEmpty ? [] : [procs] + } + + // 3. No cwd from AXDocument either (Alacritty and similar minimal + // terminals). Return foreground processes of every tty under the + // app's process tree. If multiple ttys exist (e.g. several + // Alacritty windows sharing one process), try to narrow to the + // focused one by matching the AXTitle against fg process names — + // Alacritty's title tracks the focused window's running command. + guard let tabCWD = tabInfo?.cwd else { + var byTty: [dev_t: [String]] = [:] + var shellPidPerTty: [dev_t: Int32] = [:] + let shellNamesFallback: Set = ["zsh", "bash", "fish", "login"] + for e in entries where descendants.contains(e.pid) && e.tdev != 0 { + if e.isFg { + byTty[e.tdev, default: []].append(e.comm) + } + if shellNamesFallback.contains(e.comm) { + shellPidPerTty[e.tdev] = e.pid + } + } + let rawTitle = getFocusedWindowTitle(bid: bid) ?? "" + if byTty.count > 1 { + // 1. Title literally names a fg process (e.g. "cc-connect", + // "✳ Claude Code"). Catches Warp's cc/cc-connect tabs. + // Shell process names are excluded so a title like + // "Running zsh in foo" doesn't match every candidate. + for (_, procs) in byTty { + let nonShellOnly = procs.filter { !shellNamesFallback.contains($0) } + if rawTitle.mentionsAny(of: nonShellOnly) { + Log.debug("DESCENDANT FALLBACK: title=\(rawTitle) proc-match → \(procs)") + return [procs] + } + } + + // 2. Title is a bare path (Warp tags idle shell tabs with + // their cwd). Match against each candidate shell's cwd + // to pick the focused one. When multiple ttys share the + // cwd, prefer the one whose fg is only a shell — a + // bare-cwd title means the user is at a prompt, not + // running a foreground command. Gate on `~`/`/` prefix + // so titles like "bwg-us:~" (ssh tab) don't false-match. + if rawTitle.hasPrefix("~") || rawTitle.hasPrefix("/") { + let expanded = (rawTitle as NSString).expandingTildeInPath + var matches: [(dev_t, [String])] = [] + for (tdev, procs) in byTty { + guard let pid = shellPidPerTty[tdev], + let cwd = processCWD(pid) else { continue } + if cwd == expanded { matches.append((tdev, procs)) } + } + if let shellOnly = matches.first(where: { (_, procs) in + procs.allSatisfy { shellNamesFallback.contains($0) } + }) { + Log.debug("DESCENDANT FALLBACK: title=\(rawTitle) cwd+shell-only → \(shellOnly.1)") + return [shellOnly.1] + } + if let first = matches.first { + Log.debug("DESCENDANT FALLBACK: title=\(rawTitle) cwd-match \(expanded) → \(first.1)") + return [first.1] + } + } + } + let result = Array(byTty.values) + Log.debug("DESCENDANT FALLBACK: \(result.count) ttys title=\(rawTitle) (no narrowing)") + return result + } + + let shellNames: Set = ["zsh", "bash", "fish", "login"] + let shells = entries.filter { descendants.contains($0.pid) && $0.tdev != 0 && shellNames.contains($0.comm) } + + var candidateTdevs: [dev_t] = [] + var matched = Set() + for shell in shells { + guard !matched.contains(shell.tdev) else { continue } + if processCWD(shell.pid) == tabCWD { + matched.insert(shell.tdev) + candidateTdevs.append(shell.tdev) + } + } + + guard !candidateTdevs.isEmpty else { return [] } + + // Per-tty foreground process list — used by the heuristics below + // (e.g. "is this tty currently idle at a shell prompt?"). + let fgByTty: [dev_t: [String]] = { + var m: [dev_t: [String]] = [:] + for e in entries where descendants.contains(e.pid) && e.isFg && e.tdev != 0 { + m[e.tdev, default: []].append(e.comm) + } + return m + }() + + // Full process list per tty — used for what we hand to the rule + // matcher. Including non-fg processes keeps `claude` discoverable + // while cc spawns transient tool subprocesses that briefly own + // the foreground process group. + func ttyProcs(_ td: dev_t) -> [String] { + entries.filter { descendants.contains($0.pid) && $0.tdev == td }.map(\.comm) + } + + if candidateTdevs.count == 1 { + return [ttyProcs(candidateTdevs[0])] + } + + let title = getFocusedWindowTitle(bid: bid) ?? "" + // Shell prompt signal: title equals a bare shell name, or shows the + // focused tab's own cwd in any of the recognised forms. + let looksLikeShell = shellNames.contains(title) || title.showsCwd(tabCWD) + + if looksLikeShell { + let tdev = candidateTdevs.first(where: { td in + (fgByTty[td] ?? []).allSatisfy { shellNames.contains($0) } + }) ?? candidateTdevs[0] + return [ttyProcs(tdev)] + } + + let nonShellTdevs = candidateTdevs.filter { td in + (fgByTty[td] ?? []).contains(where: { !shellNames.contains($0) }) + } + guard nonShellTdevs.count > 1 else { + return [ttyProcs(nonShellTdevs.first ?? candidateTdevs[0])] + } + + // Multi non-shell candidates: try to single out the focused one by + // checking which candidate's fg process name appears in the window + // title. Works for terminals that auto-set the title to the running + // command (Ghostty, kitty, etc.) when several tabs share a cwd. + // Shell processes are excluded so a title like "Running zsh in foo" + // doesn't accidentally match every candidate (every tty has zsh). + for td in nonShellTdevs { + let procs = ttyProcs(td) + let nonShellOnly = procs.filter { !shellNames.contains($0) } + if title.mentionsAny(of: nonShellOnly) { + Log.debug("CWD MULTI title=\(title) → \(procs)") + return [procs] + } + } + return nonShellTdevs.map { ttyProcs($0) } + } + + // MARK: Event Handlers + + @objc private func activated(_ n: Notification) { + guard enabled, let bid = bundleID(from: n) else { return } + if isTerminalWithRules(bid) { + debouncedResolve(bid) + } else { + if let id = resolveInputSource(for: bid) { + selectInputSource(id) + } + } + } + + private func handleWindowFocus(_ bid: String) { + guard enabled else { return } + if isTerminalWithRules(bid) { + debouncedResolve(bid) + } else { + if let id = resolveInputSource(for: bid) { + selectInputSource(id) + } + } + } + + private func debouncedResolve(_ bid: String) { + termDebounce?.invalidate() + termDebounce = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in + guard let self, self.enabled else { return } + if let id = self.resolveInputSource(for: bid) { + selectInputSource(id) + } + } + } + + @objc private func launched(_ n: Notification) { + guard let bid = bundleID(from: n) else { return } + let store = RuleStore.shared + let needsObserver = store.rules.contains(where: { $0.enabled && $0.appBundleID == bid }) + || (terminalBundleIDs.contains(bid) && store.terminalRules.contains(where: \.enabled)) + if needsObserver { attach(bid) } + } + + @objc private func terminated(_ n: Notification) { + guard let bid = bundleID(from: n) else { return } + detach(bid) + } + + private func bundleID(from n: Notification) -> String? { + (n.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier + } + + func attach(_ bid: String) { + guard let app = NSWorkspace.shared.runningApplications + .first(where: { $0.bundleIdentifier == bid }) else { return } + detach(bid) + + let pid = app.processIdentifier + let el = AXUIElementCreateApplication(pid) + elements[bid] = el + + let ctx = AXContext(bid, self) + contexts[bid] = ctx + let ptr = UnsafeMutableRawPointer(Unmanaged.passUnretained(ctx).toOpaque()) + + let cb: AXObserverCallback = { _, _, _, refcon in + guard let refcon else { return } + let c = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + guard let m = c.monitor, m.enabled else { return } + m.handleWindowFocus(c.bundleID) + } + + var obs: AXObserver? + guard AXObserverCreate(pid, cb, &obs) == .success, let observer = obs else { return } + observers[bid] = observer + + var notifs = [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification] as [CFString] + if terminalBundleIDs.contains(bid) { + notifs.append(kAXFocusedUIElementChangedNotification as CFString) + if TerminalAdapters.adapter(for: bid).needsTitleChangeNotification { + notifs.append(kAXTitleChangedNotification as CFString) + } + } + for name in notifs { AXObserverAddNotification(observer, el, name, ptr) } + CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .commonModes) + } + + private func detach(_ bid: String) { + guard let obs = observers[bid], let el = elements[bid] else { return } + let notifs = [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification, + kAXFocusedUIElementChangedNotification, kAXTitleChangedNotification] as [CFString] + for n in notifs { AXObserverRemoveNotification(obs, el, n) } + CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .commonModes) + observers.removeValue(forKey: bid) + elements.removeValue(forKey: bid) + contexts.removeValue(forKey: bid) + } +} diff --git a/Sources/TermIMS/IndicatorPanel.swift b/Sources/TermIMS/IndicatorPanel.swift new file mode 100644 index 0000000..56a323e --- /dev/null +++ b/Sources/TermIMS/IndicatorPanel.swift @@ -0,0 +1,76 @@ +import Cocoa + +class IndicatorPanel: NSPanel { + private let label = NSTextField(labelWithString: "") + private var hideTimer: Timer? + + init() { + super.init(contentRect: NSRect(x: 0, y: 0, width: 180, height: 44), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + level = .floating + isOpaque = false + backgroundColor = .clear + ignoresMouseEvents = true + hasShadow = true + collectionBehavior = [.canJoinAllSpaces, .transient] + + let bg = NSView(frame: contentView!.bounds) + bg.autoresizingMask = [.width, .height] + bg.wantsLayer = true + bg.layer?.backgroundColor = NSColor(white: 0.15, alpha: 0.9).cgColor + bg.layer?.cornerRadius = 10 + bg.layer?.masksToBounds = true + contentView?.addSubview(bg) + + label.alignment = .center + label.font = .systemFont(ofSize: 16, weight: .medium) + label.textColor = .white + label.translatesAutoresizingMaskIntoConstraints = false + bg.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: bg.centerXAnchor), + label.centerYAnchor.constraint(equalTo: bg.centerYAnchor), + label.leadingAnchor.constraint(greaterThanOrEqualTo: bg.leadingAnchor, constant: 12), + label.trailingAnchor.constraint(lessThanOrEqualTo: bg.trailingAnchor, constant: -12), + ]) + } + + func show(text: String, position: IndicatorPosition) { + label.stringValue = text + label.sizeToFit() + let w = max(label.frame.width + 40, 100) + let h: CGFloat = 44 + setContentSize(NSSize(width: w, height: h)) + + guard let s = NSScreen.main else { return } + let mx: CGFloat = 60 + let my: CGFloat = 60 + let pt: NSPoint + switch position { + case .screenCenter: + pt = NSPoint(x: s.frame.midX - w / 2, y: s.frame.midY - h / 2) + case .centerBottom: + pt = NSPoint(x: s.frame.midX - w / 2, y: s.frame.minY + my) + case .topLeft: + pt = NSPoint(x: s.frame.minX + mx, y: s.frame.maxY - h - my) + case .topRight: + pt = NSPoint(x: s.frame.maxX - w - mx, y: s.frame.maxY - h - my) + case .bottomLeft: + pt = NSPoint(x: s.frame.minX + mx, y: s.frame.minY + my) + case .bottomRight: + pt = NSPoint(x: s.frame.maxX - w - mx, y: s.frame.minY + my) + } + setFrameOrigin(pt) + alphaValue = 1 + orderFrontRegardless() + + hideTimer?.invalidate() + hideTimer = Timer.scheduledTimer(withTimeInterval: 1.2, repeats: false) { [weak self] _ in + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.3 + self?.animator().alphaValue = 0 + }, completionHandler: { self?.orderOut(nil) }) + } + } +} diff --git a/Sources/TermIMS/InputSource.swift b/Sources/TermIMS/InputSource.swift new file mode 100644 index 0000000..b49c056 --- /dev/null +++ b/Sources/TermIMS/InputSource.swift @@ -0,0 +1,46 @@ +import Cocoa +import Carbon + +struct IMSource { + let id: String + let name: String +} + +func listInputSources() -> [IMSource] { + guard let all = TISCreateInputSourceList(nil, false)?.takeRetainedValue() as? [TISInputSource] else { return [] } + return all.compactMap { tis -> IMSource? in + guard let typePtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceType), + let capPtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceIsSelectCapable), + let enPtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceIsEnabled), + let idPtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceID), + let namePtr = TISGetInputSourceProperty(tis, kTISPropertyLocalizedName) else { return nil } + let type = Unmanaged.fromOpaque(typePtr).takeUnretainedValue() as String + guard type == kTISTypeKeyboardLayout as String || + type == kTISTypeKeyboardInputMode as String else { return nil } + guard CFBooleanGetValue(Unmanaged.fromOpaque(capPtr).takeUnretainedValue()), + CFBooleanGetValue(Unmanaged.fromOpaque(enPtr).takeUnretainedValue()) else { return nil } + return IMSource( + id: Unmanaged.fromOpaque(idPtr).takeUnretainedValue() as String, + name: Unmanaged.fromOpaque(namePtr).takeUnretainedValue() as String + ) + } +} + +func currentInputSourceName() -> String? { + guard let cur = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let ptr = TISGetInputSourceProperty(cur, kTISPropertyLocalizedName) else { return nil } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String +} + +func selectInputSource(_ id: String) { + if let cur = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let ptr = TISGetInputSourceProperty(cur, kTISPropertyInputSourceID) { + let curID = Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String + if curID == id { return } + } + let props = [kTISPropertyInputSourceID: id] as CFDictionary + guard let list = TISCreateInputSourceList(props, false)?.takeRetainedValue() as? [TISInputSource], + let source = list.first else { return } + TISSelectInputSource(source) + NotificationCenter.default.post(name: .imDidSwitch, object: nil) +} diff --git a/Sources/TermIMS/Log.swift b/Sources/TermIMS/Log.swift new file mode 100644 index 0000000..a60fb65 --- /dev/null +++ b/Sources/TermIMS/Log.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Single-line append-only log gated by `RuleStore.shared.debugLogEnabled`. +/// All log emission goes through `Log.debug(_:)` — call sites stay short +/// and unaware of file paths, timestamps, or the toggle. +enum Log { + static let path: String = { + let dir = NSHomeDirectory() + "/Library/Logs/TermIMS" + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + return dir + "/termims.log" + }() + + private static let formatter = ISO8601DateFormatter() + private static var handle: FileHandle? = openHandle() + + private static func openHandle() -> FileHandle? { + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + let fh = FileHandle(forWritingAtPath: path) + fh?.seekToEndOfFile() + return fh + } + + static func debug(_ msg: @autoclosure () -> String) { + guard RuleStore.shared.debugLogEnabled else { return } + if handle == nil { handle = openHandle() } + guard let fh = handle else { return } + let line = "[\(formatter.string(from: Date()))] \(msg())\n" + guard let data = line.data(using: .utf8) else { return } + fh.write(data) + } + + static func clear() { + try? handle?.close() + handle = nil + try? FileManager.default.removeItem(atPath: path) + } +} diff --git a/Sources/TermIMS/Models.swift b/Sources/TermIMS/Models.swift new file mode 100644 index 0000000..d0edcc2 --- /dev/null +++ b/Sources/TermIMS/Models.swift @@ -0,0 +1,64 @@ +import Foundation + +struct Rule: Codable { + var enabled: Bool = true + var appBundleID: String + var appName: String + var inputSourceID: String + var inputSourceName: String +} + +enum TerminalMatchType: String, Codable, CaseIterable { + case process = "Process Name" + case title = "Tab Title" +} + +struct TerminalRule: Codable { + var enabled: Bool = true + var matchType: TerminalMatchType = .title + var pattern: String = "" + var inputSourceID: String + var inputSourceName: String + /// Free-form annotation shown in the rule table. Purely cosmetic — not + /// used by the matcher. `decodeIfPresent` lets older stored rules (saved + /// before this field existed) round-trip cleanly with an empty note. + var note: String = "" + + init(enabled: Bool = true, + matchType: TerminalMatchType = .title, + pattern: String = "", + inputSourceID: String, + inputSourceName: String, + note: String = "") { + self.enabled = enabled + self.matchType = matchType + self.pattern = pattern + self.inputSourceID = inputSourceID + self.inputSourceName = inputSourceName + self.note = note + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + matchType = try c.decodeIfPresent(TerminalMatchType.self, forKey: .matchType) ?? .title + pattern = try c.decodeIfPresent(String.self, forKey: .pattern) ?? "" + inputSourceID = try c.decode(String.self, forKey: .inputSourceID) + inputSourceName = try c.decode(String.self, forKey: .inputSourceName) + note = try c.decodeIfPresent(String.self, forKey: .note) ?? "" + } +} + +enum IndicatorPosition: String, Codable, CaseIterable { + case screenCenter = "Screen Center" + case centerBottom = "Center Bottom" + case topLeft = "Top Left" + case topRight = "Top Right" + case bottomLeft = "Bottom Left" + case bottomRight = "Bottom Right" +} + +extension Notification.Name { + static let rulesDidChange = Notification.Name("RulesDidChange") + static let imDidSwitch = Notification.Name("IMDidSwitch") +} diff --git a/Sources/TermIMS/PermissionWindowController.swift b/Sources/TermIMS/PermissionWindowController.swift new file mode 100644 index 0000000..015b950 --- /dev/null +++ b/Sources/TermIMS/PermissionWindowController.swift @@ -0,0 +1,87 @@ +import Cocoa +import ApplicationServices + +class PermissionWindowController: NSWindowController { + private var pollTimer: Timer? + var onGranted: (() -> Void)? + + convenience init() { + let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 200), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + w.title = "TermIMS" + w.center() + w.isReleasedWhenClosed = false + self.init(window: w) + buildUI() + } + + private func buildUI() { + guard let cv = window?.contentView else { return } + + let icon = NSImageView() + icon.translatesAutoresizingMaskIntoConstraints = false + icon.image = NSImage(systemSymbolName: "lock.shield", accessibilityDescription: nil) + icon.symbolConfiguration = .init(pointSize: 36, weight: .light) + icon.contentTintColor = .secondaryLabelColor + + let title = NSTextField(labelWithString: "Accessibility Permission Required") + title.translatesAutoresizingMaskIntoConstraints = false + title.font = .systemFont(ofSize: 16, weight: .semibold) + + let desc = NSTextField(wrappingLabelWithString: + "TermIMS needs accessibility access to detect app switches and change input methods. Please enable it in System Settings > Privacy & Security > Accessibility.") + desc.translatesAutoresizingMaskIntoConstraints = false + desc.font = .systemFont(ofSize: 12) + desc.textColor = .secondaryLabelColor + + let openBtn = NSButton(title: "Open System Settings", target: self, action: #selector(openSettings)) + openBtn.translatesAutoresizingMaskIntoConstraints = false + openBtn.bezelStyle = .rounded + openBtn.keyEquivalent = "\r" + + let quitBtn = NSButton(title: "Quit", target: self, action: #selector(quitApp)) + quitBtn.translatesAutoresizingMaskIntoConstraints = false + quitBtn.bezelStyle = .rounded + + for sub in [icon, title, desc, openBtn, quitBtn] { cv.addSubview(sub) } + NSLayoutConstraint.activate([ + icon.topAnchor.constraint(equalTo: cv.topAnchor, constant: 24), + icon.centerXAnchor.constraint(equalTo: cv.centerXAnchor), + + title.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 12), + title.centerXAnchor.constraint(equalTo: cv.centerXAnchor), + + desc.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), + desc.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: 32), + desc.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -32), + + openBtn.topAnchor.constraint(equalTo: desc.bottomAnchor, constant: 16), + openBtn.trailingAnchor.constraint(equalTo: cv.centerXAnchor, constant: -6), + openBtn.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -16), + + quitBtn.topAnchor.constraint(equalTo: desc.bottomAnchor, constant: 16), + quitBtn.leadingAnchor.constraint(equalTo: cv.centerXAnchor, constant: 6), + quitBtn.widthAnchor.constraint(equalTo: openBtn.widthAnchor), + ]) + } + + func startPolling() { + pollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + if AXIsProcessTrusted() { + self?.pollTimer?.invalidate() + self?.pollTimer = nil + self?.window?.close() + self?.onGranted?() + } + } + } + + @objc private func openSettings() { + AXIsProcessTrustedWithOptions( + [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary) + } + + @objc private func quitApp() { + NSApp.terminate(nil) + } +} diff --git a/Sources/TermIMS/RuleStore.swift b/Sources/TermIMS/RuleStore.swift new file mode 100644 index 0000000..a8b4341 --- /dev/null +++ b/Sources/TermIMS/RuleStore.swift @@ -0,0 +1,65 @@ +import Foundation + +class RuleStore { + static let shared = RuleStore() + private let ud = UserDefaults.standard + + private func notify() { + NotificationCenter.default.post(name: .rulesDidChange, object: nil) + } + + var rules: [Rule] { + get { decode("TermIMSRules") ?? [] } + set { encode(newValue, "TermIMSRules"); notify() } + } + + var defaultSourceID: String? { + get { ud.string(forKey: "DefaultSourceID") } + set { ud.set(newValue, forKey: "DefaultSourceID"); notify() } + } + var defaultSourceName: String? { + get { ud.string(forKey: "DefaultSourceName") } + set { ud.set(newValue, forKey: "DefaultSourceName") } + } + + var indicatorEnabled: Bool { + get { ud.object(forKey: "IndicatorEnabled") as? Bool ?? true } + set { ud.set(newValue, forKey: "IndicatorEnabled") } + } + var indicatorPosition: IndicatorPosition { + get { IndicatorPosition(rawValue: ud.string(forKey: "IndicatorPosition") ?? "") ?? .centerBottom } + set { ud.set(newValue.rawValue, forKey: "IndicatorPosition") } + } + + var hideMenuBarIcon: Bool { + get { ud.bool(forKey: "HideMenuBarIcon") } + set { ud.set(newValue, forKey: "HideMenuBarIcon"); notify() } + } + + var debugLogEnabled: Bool { + get { ud.bool(forKey: "DebugLogEnabled") } + set { ud.set(newValue, forKey: "DebugLogEnabled") } + } + + var terminalRules: [TerminalRule] { + get { decode("TermIMSTerminalRules") ?? [] } + set { encode(newValue, "TermIMSTerminalRules"); notify() } + } + + var terminalDefaultSourceID: String? { + get { ud.string(forKey: "TerminalDefaultSourceID") } + set { ud.set(newValue, forKey: "TerminalDefaultSourceID"); notify() } + } + var terminalDefaultSourceName: String? { + get { ud.string(forKey: "TerminalDefaultSourceName") } + set { ud.set(newValue, forKey: "TerminalDefaultSourceName") } + } + + private func decode(_ key: String) -> T? { + guard let data = ud.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + private func encode(_ val: T, _ key: String) { + ud.set(try? JSONEncoder().encode(val), forKey: key) + } +} diff --git a/Sources/TermIMS/SettingsWindowController.swift b/Sources/TermIMS/SettingsWindowController.swift new file mode 100644 index 0000000..38f0ddb --- /dev/null +++ b/Sources/TermIMS/SettingsWindowController.swift @@ -0,0 +1,689 @@ +import Cocoa +import UniformTypeIdentifiers + +/// NSTableView that only allows row drag when the mouse-down originated in +/// a designated drag-handle column. Without this filter NSTableView happily +/// starts a drag from anywhere on the row, so a user adjusting a popup risks +/// accidentally reordering the table. +final class RuleTableView: NSTableView { + /// Column identifier that acts as the row-drag grabber. Mouse-downs + /// outside this column are still delivered normally (clicks, popup + /// activation, text editing) but won't initiate drag. + var dragHandleColumnID: String? + fileprivate(set) var dragInitiatedFromHandle = false + + override func mouseDown(with event: NSEvent) { + let pt = convert(event.locationInWindow, from: nil) + let col = column(at: pt) + if col >= 0, col < tableColumns.count, + tableColumns[col].identifier.rawValue == dragHandleColumnID { + dragInitiatedFromHandle = true + } else { + dragInitiatedFromHandle = false + } + super.mouseDown(with: event) + } +} + +/// Small "grip" icon shown at the start of each rule row. Purely visual: the +/// row's drag is initiated by NSTableView when the user mouse-drags anywhere +/// inside a row (see `tableView(_:pasteboardWriterForRow:)`). The handle's +/// jobs are (a) signal draggability with an open-hand cursor on hover, and +/// (b) give users an obvious target to grab when they don't want to risk +/// clicking a checkbox / popup by accident. +final class DragHandleView: NSView { + private var trackingArea: NSTrackingArea? + override var intrinsicContentSize: NSSize { NSSize(width: 16, height: 16) } + + override init(frame: NSRect) { + super.init(frame: frame) + let img = NSImageView() + img.image = NSImage(systemSymbolName: "line.3.horizontal", + accessibilityDescription: "Drag to reorder") + img.contentTintColor = .tertiaryLabelColor + img.translatesAutoresizingMaskIntoConstraints = false + addSubview(img) + NSLayoutConstraint.activate([ + img.centerXAnchor.constraint(equalTo: centerXAnchor), + img.centerYAnchor.constraint(equalTo: centerYAnchor), + img.widthAnchor.constraint(equalToConstant: 14), + img.heightAnchor.constraint(equalToConstant: 14), + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + // NSTableView aggressively manages cursor rects, so addCursorRect doesn't + // stick. A tracking area + explicit push/pop in mouseEntered/Exited makes + // the hand cursor reliably appear over the handle. + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let existing = trackingArea { removeTrackingArea(existing) } + let area = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + NSCursor.openHand.push() + } + + override func mouseExited(with event: NSEvent) { + NSCursor.pop() + } +} + +class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate { + private var appTableView: NSTableView! + private var termTableView: NSTableView! + private let inputSources = listInputSources() + + convenience init() { + let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 480), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, defer: false) + w.title = "TermIMS Settings" + w.center() + w.minSize = NSSize(width: 620, height: 380) + w.isReleasedWhenClosed = false + self.init(window: w) + buildUI() + } + + // MARK: Tab Builder + + private func buildUI() { + guard let cv = window?.contentView else { return } + let tabView = NSTabView() + tabView.translatesAutoresizingMaskIntoConstraints = false + cv.addSubview(tabView) + NSLayoutConstraint.activate([ + tabView.topAnchor.constraint(equalTo: cv.topAnchor, constant: 8), + tabView.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: 8), + tabView.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -8), + tabView.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -8), + ]) + + let t1 = NSTabViewItem(identifier: "general"); t1.label = "General" + t1.view = buildGeneralTab() + let t2 = NSTabViewItem(identifier: "apps"); t2.label = "App Rules" + t2.view = buildAppRulesTab() + let t3 = NSTabViewItem(identifier: "terminal"); t3.label = "Terminal Rules" + t3.view = buildTerminalRulesTab() + + tabView.addTabViewItem(t1) + tabView.addTabViewItem(t2) + tabView.addTabViewItem(t3) + + appTableView.dataSource = self; appTableView.delegate = self + termTableView.dataSource = self; termTableView.delegate = self + appTableView.registerForDraggedTypes([Self.appRowDragType]) + termTableView.registerForDraggedTypes([Self.termRowDragType]) + appTableView.draggingDestinationFeedbackStyle = .gap + termTableView.draggingDestinationFeedbackStyle = .gap + } + + static let appRowDragType = NSPasteboard.PasteboardType("top.cuiko.termims.app-rule-row") + static let termRowDragType = NSPasteboard.PasteboardType("top.cuiko.termims.term-rule-row") + + // MARK: General Tab + + private func buildGeneralTab() -> NSView { + let v = NSView() + let store = RuleStore.shared + + let defLabel = label("Default Input Method:") + let defPopup = imPopup(selected: store.defaultSourceID, includeNone: true) + defPopup.tag = 900 + defPopup.target = self; defPopup.action = #selector(defaultIMChanged(_:)) + + let sep1 = separator() + let indLabel = label("Indicator") + indLabel.font = .systemFont(ofSize: 13, weight: .semibold) + + let indCheck = NSButton(checkboxWithTitle: "Show input method indicator on switch", + target: self, action: #selector(indicatorToggled(_:))) + indCheck.translatesAutoresizingMaskIntoConstraints = false + indCheck.state = store.indicatorEnabled ? .on : .off + + let posLabel = label("Position:") + let posPopup = NSPopUpButton(frame: .zero, pullsDown: false) + posPopup.translatesAutoresizingMaskIntoConstraints = false + for p in IndicatorPosition.allCases { posPopup.addItem(withTitle: p.rawValue) } + if let idx = IndicatorPosition.allCases.firstIndex(of: store.indicatorPosition) { + posPopup.selectItem(at: idx) + } + posPopup.target = self; posPopup.action = #selector(indicatorPosChanged(_:)) + + let sep2 = separator() + let appLabel = label("Application") + appLabel.font = .systemFont(ofSize: 13, weight: .semibold) + + let hideCheck = NSButton(checkboxWithTitle: "Hide menu bar icon (reopen app to show Settings)", + target: self, action: #selector(hideIconToggled(_:))) + hideCheck.translatesAutoresizingMaskIntoConstraints = false + hideCheck.state = store.hideMenuBarIcon ? .on : .off + + let sep3 = separator() + let debugLabel = label("Debug") + debugLabel.font = .systemFont(ofSize: 13, weight: .semibold) + + let debugCheck = NSButton(checkboxWithTitle: "Enable debug logging", + target: self, action: #selector(debugLogToggled(_:))) + debugCheck.translatesAutoresizingMaskIntoConstraints = false + debugCheck.state = store.debugLogEnabled ? .on : .off + + let clearBtn = NSButton(title: "Clear Log", target: self, action: #selector(clearDebugLog(_:))) + clearBtn.translatesAutoresizingMaskIntoConstraints = false + clearBtn.bezelStyle = .rounded + clearBtn.controlSize = .regular + + for sub in [defLabel, defPopup, sep1, indLabel, indCheck, posLabel, posPopup, + sep2, appLabel, hideCheck, + sep3, debugLabel, debugCheck, clearBtn] { v.addSubview(sub) } + NSLayoutConstraint.activate([ + defLabel.topAnchor.constraint(equalTo: v.topAnchor, constant: 20), + defLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + defPopup.centerYAnchor.constraint(equalTo: defLabel.centerYAnchor), + defPopup.leadingAnchor.constraint(equalTo: defLabel.trailingAnchor, constant: 8), + defPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), + + sep1.topAnchor.constraint(equalTo: defLabel.bottomAnchor, constant: 20), + sep1.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), + sep1.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), + + indLabel.topAnchor.constraint(equalTo: sep1.bottomAnchor, constant: 16), + indLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + indCheck.topAnchor.constraint(equalTo: indLabel.bottomAnchor, constant: 10), + indCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + posLabel.topAnchor.constraint(equalTo: indCheck.bottomAnchor, constant: 12), + posLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + posPopup.centerYAnchor.constraint(equalTo: posLabel.centerYAnchor), + posPopup.leadingAnchor.constraint(equalTo: posLabel.trailingAnchor, constant: 8), + posPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), + + sep2.topAnchor.constraint(equalTo: posLabel.bottomAnchor, constant: 20), + sep2.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), + sep2.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), + + appLabel.topAnchor.constraint(equalTo: sep2.bottomAnchor, constant: 16), + appLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + hideCheck.topAnchor.constraint(equalTo: appLabel.bottomAnchor, constant: 10), + hideCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + + sep3.topAnchor.constraint(equalTo: hideCheck.bottomAnchor, constant: 20), + sep3.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), + sep3.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), + + debugLabel.topAnchor.constraint(equalTo: sep3.bottomAnchor, constant: 16), + debugLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + debugCheck.topAnchor.constraint(equalTo: debugLabel.bottomAnchor, constant: 10), + debugCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), + clearBtn.centerYAnchor.constraint(equalTo: debugCheck.centerYAnchor), + clearBtn.leadingAnchor.constraint(equalTo: debugCheck.trailingAnchor, constant: 12), + ]) + return v + } + + @objc private func defaultIMChanged(_ sender: NSPopUpButton) { + let store = RuleStore.shared + if sender.indexOfSelectedItem == 0 { + store.defaultSourceName = nil; store.defaultSourceID = nil + } else { + let src = inputSources[sender.indexOfSelectedItem - 1] + store.defaultSourceName = src.name; store.defaultSourceID = src.id + } + } + @objc private func indicatorToggled(_ sender: NSButton) { + RuleStore.shared.indicatorEnabled = sender.state == .on + } + @objc private func indicatorPosChanged(_ sender: NSPopUpButton) { + RuleStore.shared.indicatorPosition = IndicatorPosition.allCases[sender.indexOfSelectedItem] + } + @objc private func hideIconToggled(_ sender: NSButton) { + RuleStore.shared.hideMenuBarIcon = sender.state == .on + } + @objc private func debugLogToggled(_ sender: NSButton) { + RuleStore.shared.debugLogEnabled = sender.state == .on + } + @objc private func clearDebugLog(_ sender: NSButton) { + Log.clear() + } + + // MARK: App Rules Tab + + private func buildAppRulesTab() -> NSView { + let v = NSView() + let sv = scrolledTable(&appTableView) + let addBtn = smallButton("+", action: #selector(addAppRule)) + let rmBtn = smallButton("\u{2212}", action: #selector(removeAppRule)) + for sub in [sv, addBtn, rmBtn] { v.addSubview(sub) } + NSLayoutConstraint.activate([ + sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 12), + sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), + sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8), + sv.bottomAnchor.constraint(equalTo: addBtn.topAnchor, constant: -6), + addBtn.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), + addBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), + rmBtn.leadingAnchor.constraint(equalTo: addBtn.trailingAnchor), + rmBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), + ]) + return v + } + + @objc private func addAppRule() { + let panel = NSOpenPanel() + panel.canChooseFiles = true; panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.allowedContentTypes = [.applicationBundle] + panel.beginSheetModal(for: window!) { resp in + guard resp == .OK, let url = panel.url, let b = Bundle(url: url), + let bid = b.bundleIdentifier else { return } + var rules = RuleStore.shared.rules + if rules.contains(where: { $0.appBundleID == bid }) { return } + let name = FileManager.default.displayName(atPath: url.path).replacingOccurrences(of: ".app", with: "") + let im = self.inputSources.first { $0.id.contains("ABC") } ?? self.inputSources.first + ?? IMSource(id: "com.apple.keylayout.ABC", name: "ABC") + rules.append(Rule(appBundleID: bid, appName: name, inputSourceID: im.id, inputSourceName: im.name)) + RuleStore.shared.rules = rules + self.appTableView.reloadData() + } + } + @objc private func removeAppRule() { + let r = appTableView.selectedRow; guard r >= 0 else { return } + var rules = RuleStore.shared.rules; rules.remove(at: r) + RuleStore.shared.rules = rules; appTableView.reloadData() + } + + // MARK: Terminal Rules Tab + + private func buildTerminalRulesTab() -> NSView { + let v = NSView() + let store = RuleStore.shared + + let defLabel = label("Default Input Method (in terminal):") + let defPopup = imPopup(selected: store.terminalDefaultSourceID, includeNone: true) + defPopup.tag = 901 + defPopup.target = self; defPopup.action = #selector(termDefaultIMChanged(_:)) + + let hintLabel = label("When a terminal tab matches a rule below, switch to that rule's input method instead:") + hintLabel.font = .systemFont(ofSize: 11) + hintLabel.textColor = .secondaryLabelColor + + let sv = scrolledTermTable(&termTableView) + let addBtn = smallButton("+", action: #selector(addTermRule)) + let rmBtn = smallButton("\u{2212}", action: #selector(removeTermRule)) + + for sub in [defLabel, defPopup, hintLabel, sv, addBtn, rmBtn] { v.addSubview(sub) } + NSLayoutConstraint.activate([ + defLabel.topAnchor.constraint(equalTo: v.topAnchor, constant: 14), + defLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), + defPopup.centerYAnchor.constraint(equalTo: defLabel.centerYAnchor), + defPopup.leadingAnchor.constraint(equalTo: defLabel.trailingAnchor, constant: 8), + defPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), + + hintLabel.topAnchor.constraint(equalTo: defLabel.bottomAnchor, constant: 10), + hintLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), + hintLabel.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), + + sv.topAnchor.constraint(equalTo: hintLabel.bottomAnchor, constant: 8), + sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), + sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8), + sv.bottomAnchor.constraint(equalTo: addBtn.topAnchor, constant: -6), + addBtn.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), + addBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), + rmBtn.leadingAnchor.constraint(equalTo: addBtn.trailingAnchor), + rmBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), + ]) + return v + } + + @objc private func termDefaultIMChanged(_ sender: NSPopUpButton) { + let store = RuleStore.shared + if sender.indexOfSelectedItem == 0 { + store.terminalDefaultSourceName = nil; store.terminalDefaultSourceID = nil + } else { + let src = inputSources[sender.indexOfSelectedItem - 1] + store.terminalDefaultSourceName = src.name; store.terminalDefaultSourceID = src.id + } + } + + @objc private func addTermRule() { + let im = inputSources.first { $0.id.contains("ABC") } ?? inputSources.first + ?? IMSource(id: "com.apple.keylayout.ABC", name: "ABC") + var rules = RuleStore.shared.terminalRules + rules.append(TerminalRule(inputSourceID: im.id, inputSourceName: im.name)) + RuleStore.shared.terminalRules = rules + termTableView.reloadData() + } + + @objc private func removeTermRule() { + let r = termTableView.selectedRow; guard r >= 0 else { return } + var rules = RuleStore.shared.terminalRules; rules.remove(at: r) + RuleStore.shared.terminalRules = rules; termTableView.reloadData() + } + + private func scrolledTermTable(_ tv: inout NSTableView!) -> NSScrollView { + let sv = NSScrollView(); sv.translatesAutoresizingMaskIntoConstraints = false + sv.hasVerticalScroller = true; sv.borderType = .bezelBorder + let rtv = RuleTableView() + rtv.dragHandleColumnID = "tdrag" + tv = rtv + tv.usesAlternatingRowBackgroundColors = true; tv.rowHeight = 24 + let colDrag = NSTableColumn(identifier: .init("tdrag")); colDrag.title = ""; colDrag.width = 24; colDrag.minWidth = 24; colDrag.maxWidth = 24 + let colOn = NSTableColumn(identifier: .init("ton")); colOn.title = ""; colOn.width = 30; colOn.minWidth = 30; colOn.maxWidth = 30 + let colType = NSTableColumn(identifier: .init("ttype")); colType.title = "Match"; colType.width = 100; colType.minWidth = 80 + let colPat = NSTableColumn(identifier: .init("tpat")); colPat.title = "Pattern"; colPat.width = 140; colPat.minWidth = 80 + let colIM = NSTableColumn(identifier: .init("tim")); colIM.title = "Input Method"; colIM.width = 160; colIM.minWidth = 120 + let colNote = NSTableColumn(identifier: .init("tnote")); colNote.title = "Note"; colNote.width = 140; colNote.minWidth = 80 + tv.addTableColumn(colDrag); tv.addTableColumn(colOn); tv.addTableColumn(colType); tv.addTableColumn(colPat); tv.addTableColumn(colIM); tv.addTableColumn(colNote) + sv.documentView = tv; return sv + } + + // MARK: Table Drag Reordering + + func tableView(_ tv: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { + // Only allow drag when the mouse-down was on the drag handle column — + // see RuleTableView.mouseDown. Prevents accidental row reorder when a + // user drags a popup or text field to select / scroll. + if let rtv = tv as? RuleTableView, !rtv.dragInitiatedFromHandle { return nil } + let item = NSPasteboardItem() + let type = (tv === termTableView) ? Self.termRowDragType : Self.appRowDragType + item.setString("\(row)", forType: type) + return item + } + + func tableView(_ tv: NSTableView, + validateDrop info: NSDraggingInfo, + proposedRow row: Int, + proposedDropOperation op: NSTableView.DropOperation) -> NSDragOperation { + guard op == .above else { return [] } + return .move + } + + func tableView(_ tv: NSTableView, + acceptDrop info: NSDraggingInfo, + row: Int, + dropOperation op: NSTableView.DropOperation) -> Bool { + let type = (tv === termTableView) ? Self.termRowDragType : Self.appRowDragType + guard let items = info.draggingPasteboard.pasteboardItems, + let str = items.first?.string(forType: type), + let src = Int(str), src != row, src != row - 1 else { return false } + + if tv === termTableView { + var rules = RuleStore.shared.terminalRules + let dst = src < row ? row - 1 : row + let moved = rules.remove(at: src) + rules.insert(moved, at: dst) + RuleStore.shared.terminalRules = rules + } else { + var rules = RuleStore.shared.rules + let dst = src < row ? row - 1 : row + let moved = rules.remove(at: src) + rules.insert(moved, at: dst) + RuleStore.shared.rules = rules + } + tv.reloadData() + return true + } + + // MARK: Table DataSource / Delegate + + func numberOfRows(in tv: NSTableView) -> Int { + if tv === termTableView { return RuleStore.shared.terminalRules.count } + return RuleStore.shared.rules.count + } + + func tableView(_ tv: NSTableView, viewFor col: NSTableColumn?, row: Int) -> NSView? { + if tv === termTableView { return termTableCellView(col, row: row) } + let rules = RuleStore.shared.rules + guard row < rules.count else { return nil } + let rule = rules[row] + switch col?.identifier.rawValue { + case "adrag": + return centeredNarrowCellView(tv, id: "adrag\(row)", control: DragHandleView()) + case "on": + let btn = recycledCheckbox(tv, id: "aon") + btn.state = rule.enabled ? .on : .off; btn.tag = row + btn.target = self; btn.action = #selector(appToggle(_:)) + return centeredNarrowCellView(tv, id: "aon\(row)", control: btn) + case "app": + let cell = recycledAppCell(tv) + cell.textField?.stringValue = rule.appName + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: rule.appBundleID) { + cell.imageView?.image = NSWorkspace.shared.icon(forFile: url.path) + } else { + cell.imageView?.image = NSImage(systemSymbolName: "app", accessibilityDescription: nil) + } + return cell + case "im": + let popup = recycledIMPopup(tv, id: "aim") + configureIMPopup(popup, selected: rule.inputSourceID, row: row) + popup.target = self; popup.action = #selector(appIMChanged(_:)) + return popup + default: return nil + } + } + + /// Stretches the control to fill the full cell width — used for wide + /// controls (popup buttons, text fields). The table's default 3pt + /// horizontal intercell spacing keeps a small gap between columns + /// (macOS standard), while controls themselves occupy 100% of their + /// own cell. + private func centeredCellView(_ tv: NSTableView, id: String, control: NSView) -> NSView { + let cell = NSTableCellView() + cell.identifier = NSUserInterfaceItemIdentifier(id + "cell") + control.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(control) + NSLayoutConstraint.activate([ + control.leadingAnchor.constraint(equalTo: cell.leadingAnchor), + control.trailingAnchor.constraint(equalTo: cell.trailingAnchor), + control.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } + + /// Centers a narrow control (checkbox, drag handle) at its intrinsic size + /// inside the cell. Stretching a checkbox via leading/trailing pins the + /// visible glyph to the left of the cell — using centerX keeps it + /// visually centered under the column header. + private func centeredNarrowCellView(_ tv: NSTableView, id: String, control: NSView) -> NSView { + let cell = NSTableCellView() + cell.identifier = NSUserInterfaceItemIdentifier(id + "cell") + control.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(control) + NSLayoutConstraint.activate([ + control.centerXAnchor.constraint(equalTo: cell.centerXAnchor), + control.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } + + private func termTableCellView(_ col: NSTableColumn?, row: Int) -> NSView? { + let rules = RuleStore.shared.terminalRules + guard row < rules.count else { return nil } + let rule = rules[row] + switch col?.identifier.rawValue { + case "tdrag": + return centeredNarrowCellView(termTableView, id: "tdrag\(row)", control: DragHandleView()) + case "ton": + let btn = recycledCheckbox(termTableView, id: "ton") + btn.state = rule.enabled ? .on : .off; btn.tag = row + btn.target = self; btn.action = #selector(termToggle(_:)) + return centeredNarrowCellView(termTableView, id: "ton\(row)", control: btn) + case "ttype": + let popup = recycledMatchTypePopup(termTableView) + popup.removeAllItems() + for t in TerminalMatchType.allCases { popup.addItem(withTitle: t.rawValue) } + if let idx = TerminalMatchType.allCases.firstIndex(of: rule.matchType) { popup.selectItem(at: idx) } + popup.tag = row + popup.target = self; popup.action = #selector(termMatchTypeChanged(_:)) + return centeredCellView(termTableView, id: "ttype\(row)", control: popup) + case "tpat": + let tf = recycledPatternField(termTableView) + tf.stringValue = rule.pattern; tf.tag = row + tf.target = self; tf.action = #selector(termPatternChanged(_:)) + return centeredCellView(termTableView, id: "tpat\(row)", control: tf) + case "tim": + let popup = recycledIMPopup(termTableView, id: "tim") + configureIMPopup(popup, selected: rule.inputSourceID, row: row) + popup.target = self; popup.action = #selector(termIMChanged(_:)) + return centeredCellView(termTableView, id: "tim\(row)", control: popup) + case "tnote": + let tf = recycledNoteField(termTableView) + tf.stringValue = rule.note; tf.tag = row + tf.target = self; tf.action = #selector(termNoteChanged(_:)) + return centeredCellView(termTableView, id: "tnote\(row)", control: tf) + default: return nil + } + } + + // MARK: Table Actions + + @objc private func appToggle(_ s: NSButton) { + var r = RuleStore.shared.rules; guard s.tag < r.count else { return } + r[s.tag].enabled = s.state == .on; RuleStore.shared.rules = r + } + @objc private func appIMChanged(_ s: NSPopUpButton) { + var r = RuleStore.shared.rules + guard s.tag < r.count, let item = s.selectedItem, let sid = item.representedObject as? String else { return } + r[s.tag].inputSourceID = sid; r[s.tag].inputSourceName = item.title; RuleStore.shared.rules = r + } + + @objc private func termToggle(_ s: NSButton) { + var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } + r[s.tag].enabled = s.state == .on; RuleStore.shared.terminalRules = r + } + @objc private func termMatchTypeChanged(_ s: NSPopUpButton) { + var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } + r[s.tag].matchType = TerminalMatchType.allCases[s.indexOfSelectedItem] + RuleStore.shared.terminalRules = r + } + @objc private func termPatternChanged(_ s: NSTextField) { + var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } + r[s.tag].pattern = s.stringValue; RuleStore.shared.terminalRules = r + } + @objc private func termIMChanged(_ s: NSPopUpButton) { + var r = RuleStore.shared.terminalRules + guard s.tag < r.count, let item = s.selectedItem, let sid = item.representedObject as? String else { return } + r[s.tag].inputSourceID = sid; r[s.tag].inputSourceName = item.title; RuleStore.shared.terminalRules = r + } + @objc private func termNoteChanged(_ s: NSTextField) { + var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } + r[s.tag].note = s.stringValue; RuleStore.shared.terminalRules = r + } + + // MARK: View Factories + + private func label(_ text: String) -> NSTextField { + let l = NSTextField(labelWithString: text) + l.translatesAutoresizingMaskIntoConstraints = false; l.font = .systemFont(ofSize: 13) + return l + } + private func separator() -> NSBox { + let b = NSBox(); b.translatesAutoresizingMaskIntoConstraints = false; b.boxType = .separator; return b + } + private func smallButton(_ title: String, action: Selector) -> NSButton { + let b = NSButton(title: title, target: self, action: action) + b.translatesAutoresizingMaskIntoConstraints = false; b.bezelStyle = .smallSquare + b.widthAnchor.constraint(equalToConstant: 28).isActive = true + b.heightAnchor.constraint(equalToConstant: 24).isActive = true + return b + } + + private func imPopup(selected sid: String?, includeNone: Bool) -> NSPopUpButton { + let p = NSPopUpButton(frame: .zero, pullsDown: false) + p.translatesAutoresizingMaskIntoConstraints = false + if includeNone { p.addItem(withTitle: "None"); p.lastItem?.representedObject = nil } + for src in inputSources { p.addItem(withTitle: src.name); p.lastItem?.representedObject = src.id } + if let sid, let idx = inputSources.firstIndex(where: { $0.id == sid }) { + p.selectItem(at: idx + (includeNone ? 1 : 0)) + } else if includeNone { p.selectItem(at: 0) } + return p + } + + private func scrolledTable(_ tv: inout NSTableView!) -> NSScrollView { + let sv = NSScrollView(); sv.translatesAutoresizingMaskIntoConstraints = false + sv.hasVerticalScroller = true; sv.borderType = .bezelBorder + let rtv = RuleTableView() + rtv.dragHandleColumnID = "adrag" + tv = rtv + tv.usesAlternatingRowBackgroundColors = true; tv.rowHeight = 24 + let colDrag = NSTableColumn(identifier: .init("adrag")); colDrag.title = ""; colDrag.width = 24; colDrag.minWidth = 24; colDrag.maxWidth = 24 + let colOn = NSTableColumn(identifier: .init("on")); colOn.title = ""; colOn.width = 30; colOn.minWidth = 30; colOn.maxWidth = 30 + let colApp = NSTableColumn(identifier: .init("app")); colApp.title = "Application"; colApp.width = 200; colApp.minWidth = 120 + let colIM = NSTableColumn(identifier: .init("im")); colIM.title = "Input Method"; colIM.width = 200; colIM.minWidth = 120 + tv.addTableColumn(colDrag); tv.addTableColumn(colOn); tv.addTableColumn(colApp); tv.addTableColumn(colIM) + sv.documentView = tv; return sv + } + + private func recycledCheckbox(_ tv: NSTableView, id: String) -> NSButton { + let uid = NSUserInterfaceItemIdentifier(id) + if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSButton { return v } + let b = NSButton(checkboxWithTitle: "", target: nil, action: nil); b.identifier = uid; return b + } + + private func recycledAppCell(_ tv: NSTableView) -> NSTableCellView { + let uid = NSUserInterfaceItemIdentifier("appcell") + if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSTableCellView { return v } + let v = NSTableCellView(); v.identifier = uid + let iv = NSImageView(); iv.translatesAutoresizingMaskIntoConstraints = false + let tf = NSTextField(labelWithString: ""); tf.translatesAutoresizingMaskIntoConstraints = false; tf.lineBreakMode = .byTruncatingTail + v.addSubview(iv); v.addSubview(tf); v.imageView = iv; v.textField = tf + NSLayoutConstraint.activate([ + iv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 2), + iv.centerYAnchor.constraint(equalTo: v.centerYAnchor), + iv.widthAnchor.constraint(equalToConstant: 20), iv.heightAnchor.constraint(equalToConstant: 20), + tf.leadingAnchor.constraint(equalTo: iv.trailingAnchor, constant: 6), + tf.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -2), + tf.centerYAnchor.constraint(equalTo: v.centerYAnchor), + ]) + return v + } + + private func recycledIMPopup(_ tv: NSTableView, id: String) -> NSPopUpButton { + let uid = NSUserInterfaceItemIdentifier(id) + if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSPopUpButton { return v } + let p = NSPopUpButton(frame: .zero, pullsDown: false) + p.identifier = uid; p.controlSize = .small; p.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + return p + } + + private func recycledMatchTypePopup(_ tv: NSTableView) -> NSPopUpButton { + let uid = NSUserInterfaceItemIdentifier("tmtype") + if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSPopUpButton { return v } + let p = NSPopUpButton(frame: .zero, pullsDown: false) + p.identifier = uid; p.controlSize = .small; p.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + return p + } + + private func recycledPatternField(_ tv: NSTableView) -> NSTextField { + let uid = NSUserInterfaceItemIdentifier("tpat") + if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSTextField { return v } + let tf = NSTextField() + tf.identifier = uid; tf.controlSize = .small; tf.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + tf.placeholderString = "e.g. opencode, nvim" + tf.lineBreakMode = .byTruncatingTail; tf.cell?.sendsActionOnEndEditing = true + return tf + } + + private func recycledNoteField(_ tv: NSTableView) -> NSTextField { + let uid = NSUserInterfaceItemIdentifier("tnote") + if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSTextField { return v } + let tf = NSTextField() + tf.identifier = uid; tf.controlSize = .small; tf.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + tf.placeholderString = "" + tf.lineBreakMode = .byTruncatingTail; tf.cell?.sendsActionOnEndEditing = true + return tf + } + + private func configureIMPopup(_ popup: NSPopUpButton, selected sid: String, row: Int) { + popup.removeAllItems() + for src in inputSources { popup.addItem(withTitle: src.name); popup.lastItem?.representedObject = src.id } + if let idx = inputSources.firstIndex(where: { $0.id == sid }) { popup.selectItem(at: idx) } + popup.tag = row + } +} diff --git a/Sources/TermIMS/TerminalAdapters.swift b/Sources/TermIMS/TerminalAdapters.swift new file mode 100644 index 0000000..5fbfdb6 --- /dev/null +++ b/Sources/TermIMS/TerminalAdapters.swift @@ -0,0 +1,356 @@ +import Cocoa +import ApplicationServices + +// MARK: - Protocol +// +// Strategy pattern: each terminal app encapsulates its own native ways to +// disambiguate the focused tab. Three protocol hooks (all defaulted, all +// optional to override): +// +// - `focusedTty(appPid:)` — primary disambiguation channel. Returning a +// non-nil tty lets the matcher skip the cwd-based candidate heuristic. +// - `focusedTitle(appElement:)` — title source for the rule matcher's +// heuristics. Override when a terminal's window title doesn't track +// focus reliably. +// - `needsTitleChangeNotification` — whether to subscribe to +// kAXTitleChangedNotification for intra-window pane focus events. +// +// Adapters that don't override anything fall through to the generic +// BFS + cwd-matching path. + +protocol TerminalAdapter { + var bundleID: String { get } + + /// Whether the matcher should also subscribe to `kAXTitleChangedNotification` + /// for this terminal. Useful for terminals that render multiple panes + /// inside a single AX window (e.g. kitty splits) and thus signal pane + /// focus changes only via title updates. Most terminals get this wrong + /// when foreground programs animate their title (cc's braille spinner), + /// so default is false. + var needsTitleChangeNotification: Bool { get } + + /// Resolve the focused tab's tty (e.g. via AppleScript, CLI, IPC). + /// `appPid` is the PID of the terminal app instance whose focus event + /// triggered this lookup — adapters use it to disambiguate when the user + /// runs multiple instances of the same terminal (e.g. several kitty + /// windows, each with its own listen socket). + /// Default implementation: nil → caller uses the generic CWD heuristic. + func focusedTty(appPid: pid_t) -> dev_t? + + /// Title string used by the rule matcher's heuristics. Default returns + /// the focused window's kAXTitleAttribute. Override for terminals whose + /// window title doesn't track focus reliably — Warp keeps the window + /// title at whichever tab most recently set OSC 2, so we read the + /// focused text area's value (which IS per-tab) instead. + func focusedTitle(appElement: AXUIElement) -> String? +} + +extension TerminalAdapter { + var needsTitleChangeNotification: Bool { false } + func focusedTty(appPid: pid_t) -> dev_t? { nil } + func focusedTitle(appElement: AXUIElement) -> String? { + defaultFocusedWindowTitle(appElement: appElement) + } +} + +/// Helper exposed to adapter implementations: read kAXTitleAttribute on the +/// focused window. Used as the default `focusedTitle` and as a fallback by +/// adapters that combine it with other signals. +func defaultFocusedWindowTitle(appElement: AXUIElement) -> String? { + var winObj: AnyObject? + guard AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &winObj) == .success, + let raw = winObj, + CFGetTypeID(raw) == AXUIElementGetTypeID() else { return nil } + let win = raw as! AXUIElement + var titleObj: AnyObject? + guard AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleObj) == .success else { return nil } + return titleObj as? String +} + +// MARK: - Adapters + +struct DefaultTerminalAdapter: TerminalAdapter { + let bundleID: String +} + +/// Apple Terminal exposes the focused tab's tty via AppleScript. +/// First call may prompt for Automation permission (System Settings → Privacy +/// & Security → Automation → TermIMS → Terminal). If denied, returns nil and +/// matching falls back to the generic heuristic. +struct AppleTerminalAdapter: TerminalAdapter { + let bundleID = "com.apple.Terminal" + + // Apple Terminal is single-instance (one process per user). The + // AppleScript `front window` always points to the frontmost window, + // so we don't need to filter by appPid. + func focusedTty(appPid: pid_t) -> dev_t? { + let script = """ + tell application "Terminal" + try + return tty of selected tab of front window + on error errMsg + return "ERR:" & errMsg + end try + end tell + """ + guard let s = NSAppleScript(source: script) else { + Log.debug("APPLESCRIPT init failed") + return nil + } + var err: NSDictionary? + let descriptor = s.executeAndReturnError(&err) + if let err = err { + Log.debug("APPLESCRIPT exec error: \(err)") + return nil + } + guard let path = descriptor.stringValue else { + Log.debug("APPLESCRIPT no string result") + return nil + } + if path.hasPrefix("ERR:") { + Log.debug("APPLESCRIPT terminal error: \(path)") + return nil + } + guard !path.isEmpty else { return nil } + let name = (path as NSString).lastPathComponent + let dev = FocusMonitor.ttyDev(forName: name) + Log.debug("APPLE-TERMINAL tty=\(path) dev=\(dev.map(String.init(describing:)) ?? "nil")") + return dev + } +} + +/// iTerm2 exposes the focused pane's tty via AppleScript. Object model is +/// window → tab → session(s); the "current session of current window" is +/// the focused pane (handles split panes correctly because iTerm2 tracks +/// pane focus inside each tab). +struct ITerm2Adapter: TerminalAdapter { + let bundleID = "com.googlecode.iterm2" + + func focusedTty(appPid: pid_t) -> dev_t? { + let script = """ + tell application "iTerm" + try + return tty of current session of current window + on error errMsg + return "ERR:" & errMsg + end try + end tell + """ + guard let s = NSAppleScript(source: script) else { + Log.debug("ITERM2 init failed") + return nil + } + var err: NSDictionary? + let descriptor = s.executeAndReturnError(&err) + if let err = err { + Log.debug("ITERM2 exec error: \(err)") + return nil + } + guard let path = descriptor.stringValue else { + Log.debug("ITERM2 no string result") + return nil + } + if path.hasPrefix("ERR:") { + Log.debug("ITERM2 script error: \(path)") + return nil + } + guard !path.isEmpty else { return nil } + let name = (path as NSString).lastPathComponent + let dev = FocusMonitor.ttyDev(forName: name) + Log.debug("ITERM2 tty=\(path) dev=\(dev.map(String.init(describing:)) ?? "nil")") + return dev + } +} + +/// Wezterm: `wezterm cli list-clients` gives the focused_pane_id; `wezterm cli list` +/// maps pane_id → tty_name. Two short-lived subprocess calls per focus event. +struct WezTermAdapter: TerminalAdapter { + let bundleID = "com.github.wez.wezterm" + + func focusedTty(appPid: pid_t) -> dev_t? { + guard let bin = resolveSiblingBinary(bundleID: bundleID, name: "wezterm") else { + Log.debug("WEZTERM binary not found") + return nil + } + guard let clientsData = runCommand(bin, args: ["cli", "list-clients", "--format", "json"]), + let clients = try? JSONSerialization.jsonObject(with: clientsData) as? [[String: Any]] else { + Log.debug("WEZTERM list-clients failed") + return nil + } + // Multiple wezterm-gui instances all connect to the same mux daemon + // and appear as separate clients here. Match by appPid to find the + // client driven by the focused window; fall back to the first one + // if no client carries a matching pid (older wezterm versions). + let client = clients.first { ($0["pid"] as? Int) == Int(appPid) } ?? clients.first + guard let focusedPaneID = client?["focused_pane_id"] as? Int else { + Log.debug("WEZTERM no focused_pane_id for pid=\(appPid)") + return nil + } + guard let panesData = runCommand(bin, args: ["cli", "list", "--format", "json"]), + let panes = try? JSONSerialization.jsonObject(with: panesData) as? [[String: Any]], + let pane = panes.first(where: { ($0["pane_id"] as? Int) == focusedPaneID }), + let ttyName = pane["tty_name"] as? String else { + Log.debug("WEZTERM no pane match for id=\(focusedPaneID)") + return nil + } + let leaf = (ttyName as NSString).lastPathComponent + let dev = FocusMonitor.ttyDev(forName: leaf) + Log.debug("WEZTERM tty=\(ttyName) dev=\(dev.map(String.init(describing:)) ?? "nil")") + return dev + } +} + +/// Kitty: `kitten @ ls` returns nested JSON (OS-windows → tabs → windows). +/// Recurse into the focused chain to find the focused inner window's pid, +/// then look up its tdev. Requires both `allow_remote_control yes` and an +/// explicit `listen_on unix:/path/...` in kitty.conf so external processes +/// know which socket to talk to. +struct KittyAdapter: TerminalAdapter { + let bundleID = "net.kovidgoyal.kitty" + // kitty renders panes inside a single AX window; intra-window split + // focus changes only fire title-change notifications, not focus-element. + var needsTitleChangeNotification: Bool { true } + + func focusedTty(appPid: pid_t) -> dev_t? { + guard let bin = resolveSiblingBinary(bundleID: bundleID, name: "kitten") else { + Log.debug("KITTY kitten binary not found") + return nil + } + var args = ["@", "ls"] + if let socket = Self.resolveSocket(kittyPid: appPid) { + args = ["@", "--to", socket, "ls"] + } + guard let data = runCommand(bin, args: args), + let osWindows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + Log.debug("KITTY @ ls failed (check allow_remote_control + listen_on in kitty.conf)") + return nil + } + guard let pid = Self.focusedWindowPid(in: osWindows) else { + Log.debug("KITTY no focused window in JSON") + return nil + } + guard let dev = ttyDevForPid(pid_t(pid)) else { + Log.debug("KITTY pid=\(pid) has no tdev") + return nil + } + Log.debug("KITTY pid=\(pid) dev=\(dev)") + return dev + } + + /// Walk OS-window → tab → window picking the entry with `is_focused = true` + /// at each level. Falls back to first available if any level lacks the flag. + private static func focusedWindowPid(in osWindows: [[String: Any]]) -> Int? { + let osWin = osWindows.first(where: { ($0["is_focused"] as? Bool) == true }) ?? osWindows.first + guard let tabs = osWin?["tabs"] as? [[String: Any]] else { return nil } + let tab = tabs.first(where: { ($0["is_focused"] as? Bool) == true }) ?? tabs.first + guard let windows = tab?["windows"] as? [[String: Any]] else { return nil } + let win = windows.first(where: { ($0["is_focused"] as? Bool) == true }) ?? windows.first + return win?["pid"] as? Int + } + + /// Parse the first `listen_on ` line from ~/.config/kitty/kitty.conf, + /// then resolve the actual socket path: kitty appends `-{pid}` to the + /// configured path (so `listen_on unix:/tmp/kitty` opens `/tmp/kitty-84629`). + /// `kittyPid` is the specific kitty instance whose focus event triggered + /// us — necessary when the user runs multiple kitty windows (each gets + /// its own socket file). + private static func resolveSocket(kittyPid: pid_t) -> String? { + let confPath = NSHomeDirectory() + "/.config/kitty/kitty.conf" + guard let content = try? String(contentsOfFile: confPath, encoding: .utf8) else { return nil } + var declared: String? + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("listen_on") else { continue } + let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + if parts.count == 2 { + declared = parts[1].trimmingCharacters(in: .whitespaces) + break + } + } + guard let socket = declared, socket.hasPrefix("unix:") else { return declared } + let rawPath = String(socket.dropFirst("unix:".count)) + if FileManager.default.fileExists(atPath: rawPath) { return socket } + let withPid = "\(rawPath)-\(kittyPid)" + if FileManager.default.fileExists(atPath: withPid) { + return "unix:\(withPid)" + } + return socket // best effort — kitten will report a clearer error + } +} + +// MARK: - Registry + +enum TerminalAdapters { + static let all: [TerminalAdapter] = [ + DefaultTerminalAdapter(bundleID: "com.mitchellh.ghostty"), + AppleTerminalAdapter(), + ITerm2Adapter(), + KittyAdapter(), + WezTermAdapter(), + DefaultTerminalAdapter(bundleID: "dev.warp.Warp-Stable"), + ] + private static let byBundleID: [String: TerminalAdapter] = Dictionary( + uniqueKeysWithValues: all.map { ($0.bundleID, $0) } + ) + static func adapter(for bid: String) -> TerminalAdapter { + byBundleID[bid] ?? DefaultTerminalAdapter(bundleID: bid) + } +} + +let terminalBundleIDs: Set = Set(TerminalAdapters.all.map(\.bundleID)) + +// MARK: - Subprocess helpers + +/// Locates a sibling binary inside a running app's bundle, e.g. resolving +/// `kitten` to `/Applications/kitty.app/Contents/MacOS/kitten`. Needed because +/// GUI-launched processes inherit a minimal PATH that often misses Homebrew / +/// `/usr/local/bin`, so we can't rely on bare command names. +func resolveSiblingBinary(bundleID: String, name: String) -> String? { + guard let app = NSWorkspace.shared.runningApplications + .first(where: { $0.bundleIdentifier == bundleID }), + let url = app.bundleURL else { return nil } + let candidate = url.appendingPathComponent("Contents/MacOS/\(name)").path + return FileManager.default.fileExists(atPath: candidate) ? candidate : nil +} + +/// Run a short-lived subprocess and return its stdout as Data, or nil on +/// failure / non-zero exit. Bounded by a timeout — focus handlers run on +/// the main queue so we cannot block long. On failure, stderr is appended +/// to the debug log to make diagnosis straightforward. +func runCommand(_ executable: String, args: [String], timeout: TimeInterval = 2.0) -> Data? { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executable) + proc.arguments = args + let outPipe = Pipe(); let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + do { try proc.run() } catch { + Log.debug("CMD launch failed: \(executable) \(args) err=\(error)") + return nil + } + let deadline = Date().addingTimeInterval(timeout) + while proc.isRunning && Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + if proc.isRunning { + proc.terminate() + Log.debug("CMD timeout: \(executable) \(args)") + return nil + } + guard proc.terminationStatus == 0 else { + let errOut = (try? errPipe.fileHandleForReading.readToEnd()).flatMap { String(data: $0, encoding: .utf8) } ?? "" + Log.debug("CMD exit \(proc.terminationStatus): \(executable) \(args) stderr=\(errOut.trimmingCharacters(in: .whitespacesAndNewlines))") + return nil + } + return try? outPipe.fileHandleForReading.readToEnd() +} + +/// Resolves a pid to its controlling terminal's dev_t via sysctl. +func ttyDevForPid(_ pid: pid_t) -> dev_t? { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + var info = kinfo_proc() + var size = MemoryLayout.size + guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else { return nil } + let tdev = info.kp_eproc.e_tdev + return tdev == 0 ? nil : tdev +} diff --git a/Sources/TermIMS/TitleHeuristics.swift b/Sources/TermIMS/TitleHeuristics.swift new file mode 100644 index 0000000..976af89 --- /dev/null +++ b/Sources/TermIMS/TitleHeuristics.swift @@ -0,0 +1,63 @@ +import Foundation + +// Shared predicates used by terminals that fall through to the generic +// matching path (no native tty channel). Both Ghostty (cwd-based path) and +// Warp (descendant fallback) use these to ask the same two questions about +// the focused window's title; the orchestration around them stays per-path +// because the upstream signals differ (Ghostty has AXDocument cwd, Warp +// doesn't). + +extension String { + /// Match self against a user-supplied rule pattern. + /// `/body/` or `/body/i` is treated as an ICU regex (case-insensitive + /// with `i`); anything else is a case-insensitive substring match. + /// Patterns missing the closing slash, with empty body, or with + /// unknown flags fall back to literal substring so users can still + /// match strings like "/usr/bin/foo" without escaping. + func matches(pattern: String) -> Bool { + guard !pattern.isEmpty else { return false } + if pattern.count >= 2, pattern.first == "/", + let lastSlash = pattern.lastIndex(of: "/"), + lastSlash != pattern.startIndex { + let flagsStr = pattern[pattern.index(after: lastSlash)...] + let body = pattern[pattern.index(after: pattern.startIndex).. Bool { + let lower = self.lowercased() + return processNames.contains { !$0.isEmpty && lower.contains($0.lowercased()) } + } + + /// Does this title look like it is displaying `cwd` (the focused tab's + /// own working directory)? Matches: the cwd's basename exactly, the + /// absolute path as substring, the tilde-collapsed form as substring, + /// and Ghostty's "…/tail/of/path" truncation. A title that mentions some + /// other path will not match — useful to distinguish a shell prompt + /// showing the tab's cwd from a foreground command title that happens + /// to contain unrelated paths. + func showsCwd(_ cwd: String) -> Bool { + guard !self.isEmpty else { return false } + let base = (cwd as NSString).lastPathComponent + if self == base { return true } + if self.contains(cwd) { return true } + let home = NSHomeDirectory() + if cwd.hasPrefix(home) { + let tilded = "~" + cwd.dropFirst(home.count) + if self.contains(tilded) { return true } + } + if self.hasPrefix("…"), cwd.hasSuffix(String(self.dropFirst())) { return true } + return false + } +} diff --git a/Sources/TermIMS/main.swift b/Sources/TermIMS/main.swift new file mode 100644 index 0000000..d23904f --- /dev/null +++ b/Sources/TermIMS/main.swift @@ -0,0 +1,7 @@ +import Cocoa + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/TermIMS.swift b/TermIMS.swift deleted file mode 100644 index f64ed18..0000000 --- a/TermIMS.swift +++ /dev/null @@ -1,2185 +0,0 @@ -import Cocoa -import Carbon -import ApplicationServices -import UniformTypeIdentifiers - -// MARK: - Input Source - -struct IMSource { - let id: String - let name: String -} - -func listInputSources() -> [IMSource] { - guard let all = TISCreateInputSourceList(nil, false)?.takeRetainedValue() as? [TISInputSource] else { return [] } - return all.compactMap { tis -> IMSource? in - guard let typePtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceType), - let capPtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceIsSelectCapable), - let enPtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceIsEnabled), - let idPtr = TISGetInputSourceProperty(tis, kTISPropertyInputSourceID), - let namePtr = TISGetInputSourceProperty(tis, kTISPropertyLocalizedName) else { return nil } - let type = Unmanaged.fromOpaque(typePtr).takeUnretainedValue() as String - guard type == kTISTypeKeyboardLayout as String || - type == kTISTypeKeyboardInputMode as String else { return nil } - guard CFBooleanGetValue(Unmanaged.fromOpaque(capPtr).takeUnretainedValue()), - CFBooleanGetValue(Unmanaged.fromOpaque(enPtr).takeUnretainedValue()) else { return nil } - return IMSource( - id: Unmanaged.fromOpaque(idPtr).takeUnretainedValue() as String, - name: Unmanaged.fromOpaque(namePtr).takeUnretainedValue() as String - ) - } -} - -func currentInputSourceName() -> String? { - guard let cur = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), - let ptr = TISGetInputSourceProperty(cur, kTISPropertyLocalizedName) else { return nil } - return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String -} - -func selectInputSource(_ id: String) { - if let cur = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), - let ptr = TISGetInputSourceProperty(cur, kTISPropertyInputSourceID) { - let curID = Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String - if curID == id { return } - } - let props = [kTISPropertyInputSourceID: id] as CFDictionary - guard let list = TISCreateInputSourceList(props, false)?.takeRetainedValue() as? [TISInputSource], - let source = list.first else { return } - TISSelectInputSource(source) - NotificationCenter.default.post(name: .imDidSwitch, object: nil) -} - -// MARK: - Data Models - -struct Rule: Codable { - var enabled: Bool = true - var appBundleID: String - var appName: String - var inputSourceID: String - var inputSourceName: String -} - -enum TerminalMatchType: String, Codable, CaseIterable { - case process = "Process Name" - case title = "Tab Title" -} - -struct TerminalRule: Codable { - var enabled: Bool = true - var matchType: TerminalMatchType = .title - var pattern: String = "" - var inputSourceID: String - var inputSourceName: String - /// Free-form annotation shown in the rule table. Purely cosmetic — not - /// used by the matcher. `decodeIfPresent` lets older stored rules (saved - /// before this field existed) round-trip cleanly with an empty note. - var note: String = "" - - init(enabled: Bool = true, - matchType: TerminalMatchType = .title, - pattern: String = "", - inputSourceID: String, - inputSourceName: String, - note: String = "") { - self.enabled = enabled - self.matchType = matchType - self.pattern = pattern - self.inputSourceID = inputSourceID - self.inputSourceName = inputSourceName - self.note = note - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true - matchType = try c.decodeIfPresent(TerminalMatchType.self, forKey: .matchType) ?? .title - pattern = try c.decodeIfPresent(String.self, forKey: .pattern) ?? "" - inputSourceID = try c.decode(String.self, forKey: .inputSourceID) - inputSourceName = try c.decode(String.self, forKey: .inputSourceName) - note = try c.decodeIfPresent(String.self, forKey: .note) ?? "" - } -} - -enum IndicatorPosition: String, Codable, CaseIterable { - case screenCenter = "Screen Center" - case centerBottom = "Center Bottom" - case topLeft = "Top Left" - case topRight = "Top Right" - case bottomLeft = "Bottom Left" - case bottomRight = "Bottom Right" -} - -// MARK: - Terminal Adapters -// -// Strategy pattern: each terminal app encapsulates its own native ways to -// disambiguate the focused tab. Three protocol hooks (all defaulted, all -// optional to override): -// -// - `focusedTty(appPid:)` — primary disambiguation channel. Returning a -// non-nil tty lets the matcher skip the cwd-based candidate heuristic. -// - `focusedTitle(appElement:)` — title source for the rule matcher's -// heuristics. Override when a terminal's window title doesn't track -// focus reliably. -// - `needsTitleChangeNotification` — whether to subscribe to -// kAXTitleChangedNotification for intra-window pane focus events. -// -// Adapters that don't override anything fall through to the generic -// BFS + cwd-matching path. - -protocol TerminalAdapter { - var bundleID: String { get } - - /// Whether the matcher should also subscribe to `kAXTitleChangedNotification` - /// for this terminal. Useful for terminals that render multiple panes - /// inside a single AX window (e.g. kitty splits) and thus signal pane - /// focus changes only via title updates. Most terminals get this wrong - /// when foreground programs animate their title (cc's braille spinner), - /// so default is false. - var needsTitleChangeNotification: Bool { get } - - /// Resolve the focused tab's tty (e.g. via AppleScript, CLI, IPC). - /// `appPid` is the PID of the terminal app instance whose focus event - /// triggered this lookup — adapters use it to disambiguate when the user - /// runs multiple instances of the same terminal (e.g. several kitty - /// windows, each with its own listen socket). - /// Default implementation: nil → caller uses the generic CWD heuristic. - func focusedTty(appPid: pid_t) -> dev_t? - - /// Title string used by the rule matcher's heuristics. Default returns - /// the focused window's kAXTitleAttribute. Override for terminals whose - /// window title doesn't track focus reliably — Warp keeps the window - /// title at whichever tab most recently set OSC 2, so we read the - /// focused text area's value (which IS per-tab) instead. - func focusedTitle(appElement: AXUIElement) -> String? -} - -extension TerminalAdapter { - var needsTitleChangeNotification: Bool { false } - func focusedTty(appPid: pid_t) -> dev_t? { nil } - func focusedTitle(appElement: AXUIElement) -> String? { - defaultFocusedWindowTitle(appElement: appElement) - } -} - -/// Helper exposed to adapter implementations: read kAXTitleAttribute on the -/// focused window. Used as the default `focusedTitle` and as a fallback by -/// adapters that combine it with other signals. -fileprivate func defaultFocusedWindowTitle(appElement: AXUIElement) -> String? { - var winObj: AnyObject? - guard AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &winObj) == .success, - let raw = winObj, - CFGetTypeID(raw) == AXUIElementGetTypeID() else { return nil } - let win = raw as! AXUIElement - var titleObj: AnyObject? - guard AXUIElementCopyAttributeValue(win, kAXTitleAttribute as CFString, &titleObj) == .success else { return nil } - return titleObj as? String -} - -struct DefaultTerminalAdapter: TerminalAdapter { - let bundleID: String -} - -/// Apple Terminal exposes the focused tab's tty via AppleScript. -/// First call may prompt for Automation permission (System Settings → Privacy -/// & Security → Automation → TermIMS → Terminal). If denied, returns nil and -/// matching falls back to the generic heuristic. -struct AppleTerminalAdapter: TerminalAdapter { - let bundleID = "com.apple.Terminal" - - // Apple Terminal is single-instance (one process per user). The - // AppleScript `front window` always points to the frontmost window, - // so we don't need to filter by appPid. - func focusedTty(appPid: pid_t) -> dev_t? { - let script = """ - tell application "Terminal" - try - return tty of selected tab of front window - on error errMsg - return "ERR:" & errMsg - end try - end tell - """ - guard let s = NSAppleScript(source: script) else { - Log.debug("APPLESCRIPT init failed") - return nil - } - var err: NSDictionary? - let descriptor = s.executeAndReturnError(&err) - if let err = err { - Log.debug("APPLESCRIPT exec error: \(err)") - return nil - } - guard let path = descriptor.stringValue else { - Log.debug("APPLESCRIPT no string result") - return nil - } - if path.hasPrefix("ERR:") { - Log.debug("APPLESCRIPT terminal error: \(path)") - return nil - } - guard !path.isEmpty else { return nil } - let name = (path as NSString).lastPathComponent - let dev = FocusMonitor.ttyDev(forName: name) - Log.debug("APPLE-TERMINAL tty=\(path) dev=\(dev.map(String.init(describing:)) ?? "nil")") - return dev - } -} - -/// iTerm2 exposes the focused pane's tty via AppleScript. Object model is -/// window → tab → session(s); the "current session of current window" is -/// the focused pane (handles split panes correctly because iTerm2 tracks -/// pane focus inside each tab). -struct ITerm2Adapter: TerminalAdapter { - let bundleID = "com.googlecode.iterm2" - - func focusedTty(appPid: pid_t) -> dev_t? { - let script = """ - tell application "iTerm" - try - return tty of current session of current window - on error errMsg - return "ERR:" & errMsg - end try - end tell - """ - guard let s = NSAppleScript(source: script) else { - Log.debug("ITERM2 init failed") - return nil - } - var err: NSDictionary? - let descriptor = s.executeAndReturnError(&err) - if let err = err { - Log.debug("ITERM2 exec error: \(err)") - return nil - } - guard let path = descriptor.stringValue else { - Log.debug("ITERM2 no string result") - return nil - } - if path.hasPrefix("ERR:") { - Log.debug("ITERM2 script error: \(path)") - return nil - } - guard !path.isEmpty else { return nil } - let name = (path as NSString).lastPathComponent - let dev = FocusMonitor.ttyDev(forName: name) - Log.debug("ITERM2 tty=\(path) dev=\(dev.map(String.init(describing:)) ?? "nil")") - return dev - } -} - -/// Locates a sibling binary inside a running app's bundle, e.g. resolving -/// `kitten` to `/Applications/kitty.app/Contents/MacOS/kitten`. Needed because -/// GUI-launched processes inherit a minimal PATH that often misses Homebrew / -/// `/usr/local/bin`, so we can't rely on bare command names. -fileprivate func resolveSiblingBinary(bundleID: String, name: String) -> String? { - guard let app = NSWorkspace.shared.runningApplications - .first(where: { $0.bundleIdentifier == bundleID }), - let url = app.bundleURL else { return nil } - let candidate = url.appendingPathComponent("Contents/MacOS/\(name)").path - return FileManager.default.fileExists(atPath: candidate) ? candidate : nil -} - -/// Run a short-lived subprocess and return its stdout as Data, or nil on -/// failure / non-zero exit. Bounded by a timeout — focus handlers run on -/// the main queue so we cannot block long. On failure, stderr is appended -/// to the debug log to make diagnosis straightforward. -fileprivate func runCommand(_ executable: String, args: [String], timeout: TimeInterval = 2.0) -> Data? { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: executable) - proc.arguments = args - let outPipe = Pipe(); let errPipe = Pipe() - proc.standardOutput = outPipe - proc.standardError = errPipe - do { try proc.run() } catch { - Log.debug("CMD launch failed: \(executable) \(args) err=\(error)") - return nil - } - let deadline = Date().addingTimeInterval(timeout) - while proc.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.01) - } - if proc.isRunning { - proc.terminate() - Log.debug("CMD timeout: \(executable) \(args)") - return nil - } - guard proc.terminationStatus == 0 else { - let errOut = (try? errPipe.fileHandleForReading.readToEnd()).flatMap { String(data: $0, encoding: .utf8) } ?? "" - Log.debug("CMD exit \(proc.terminationStatus): \(executable) \(args) stderr=\(errOut.trimmingCharacters(in: .whitespacesAndNewlines))") - return nil - } - return try? outPipe.fileHandleForReading.readToEnd() -} - -/// Wezterm: `wezterm cli list-clients` gives the focused_pane_id; `wezterm cli list` -/// maps pane_id → tty_name. Two short-lived subprocess calls per focus event. -struct WezTermAdapter: TerminalAdapter { - let bundleID = "com.github.wez.wezterm" - - func focusedTty(appPid: pid_t) -> dev_t? { - guard let bin = resolveSiblingBinary(bundleID: bundleID, name: "wezterm") else { - Log.debug("WEZTERM binary not found") - return nil - } - guard let clientsData = runCommand(bin, args: ["cli", "list-clients", "--format", "json"]), - let clients = try? JSONSerialization.jsonObject(with: clientsData) as? [[String: Any]] else { - Log.debug("WEZTERM list-clients failed") - return nil - } - // Multiple wezterm-gui instances all connect to the same mux daemon - // and appear as separate clients here. Match by appPid to find the - // client driven by the focused window; fall back to the first one - // if no client carries a matching pid (older wezterm versions). - let client = clients.first { ($0["pid"] as? Int) == Int(appPid) } ?? clients.first - guard let focusedPaneID = client?["focused_pane_id"] as? Int else { - Log.debug("WEZTERM no focused_pane_id for pid=\(appPid)") - return nil - } - guard let panesData = runCommand(bin, args: ["cli", "list", "--format", "json"]), - let panes = try? JSONSerialization.jsonObject(with: panesData) as? [[String: Any]], - let pane = panes.first(where: { ($0["pane_id"] as? Int) == focusedPaneID }), - let ttyName = pane["tty_name"] as? String else { - Log.debug("WEZTERM no pane match for id=\(focusedPaneID)") - return nil - } - let leaf = (ttyName as NSString).lastPathComponent - let dev = FocusMonitor.ttyDev(forName: leaf) - Log.debug("WEZTERM tty=\(ttyName) dev=\(dev.map(String.init(describing:)) ?? "nil")") - return dev - } -} - -/// Kitty: `kitten @ ls` returns nested JSON (OS-windows → tabs → windows). -/// Recurse into the focused chain to find the focused inner window's pid, -/// then look up its tdev. Requires both `allow_remote_control yes` and an -/// explicit `listen_on unix:/path/...` in kitty.conf so external processes -/// know which socket to talk to. -struct KittyAdapter: TerminalAdapter { - let bundleID = "net.kovidgoyal.kitty" - // kitty renders panes inside a single AX window; intra-window split - // focus changes only fire title-change notifications, not focus-element. - var needsTitleChangeNotification: Bool { true } - - func focusedTty(appPid: pid_t) -> dev_t? { - guard let bin = resolveSiblingBinary(bundleID: bundleID, name: "kitten") else { - Log.debug("KITTY kitten binary not found") - return nil - } - var args = ["@", "ls"] - if let socket = Self.resolveSocket(kittyPid: appPid) { - args = ["@", "--to", socket, "ls"] - } - guard let data = runCommand(bin, args: args), - let osWindows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { - Log.debug("KITTY @ ls failed (check allow_remote_control + listen_on in kitty.conf)") - return nil - } - guard let pid = Self.focusedWindowPid(in: osWindows) else { - Log.debug("KITTY no focused window in JSON") - return nil - } - guard let dev = ttyDevForPid(pid_t(pid)) else { - Log.debug("KITTY pid=\(pid) has no tdev") - return nil - } - Log.debug("KITTY pid=\(pid) dev=\(dev)") - return dev - } - - /// Walk OS-window → tab → window picking the entry with `is_focused = true` - /// at each level. Falls back to first available if any level lacks the flag. - private static func focusedWindowPid(in osWindows: [[String: Any]]) -> Int? { - let osWin = osWindows.first(where: { ($0["is_focused"] as? Bool) == true }) ?? osWindows.first - guard let tabs = osWin?["tabs"] as? [[String: Any]] else { return nil } - let tab = tabs.first(where: { ($0["is_focused"] as? Bool) == true }) ?? tabs.first - guard let windows = tab?["windows"] as? [[String: Any]] else { return nil } - let win = windows.first(where: { ($0["is_focused"] as? Bool) == true }) ?? windows.first - return win?["pid"] as? Int - } - - /// Parse the first `listen_on ` line from ~/.config/kitty/kitty.conf, - /// then resolve the actual socket path: kitty appends `-{pid}` to the - /// configured path (so `listen_on unix:/tmp/kitty` opens `/tmp/kitty-84629`). - /// `kittyPid` is the specific kitty instance whose focus event triggered - /// us — necessary when the user runs multiple kitty windows (each gets - /// its own socket file). - private static func resolveSocket(kittyPid: pid_t) -> String? { - let confPath = NSHomeDirectory() + "/.config/kitty/kitty.conf" - guard let content = try? String(contentsOfFile: confPath, encoding: .utf8) else { return nil } - var declared: String? - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.hasPrefix("listen_on") else { continue } - let parts = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) - if parts.count == 2 { - declared = parts[1].trimmingCharacters(in: .whitespaces) - break - } - } - guard let socket = declared, socket.hasPrefix("unix:") else { return declared } - let rawPath = String(socket.dropFirst("unix:".count)) - if FileManager.default.fileExists(atPath: rawPath) { return socket } - let withPid = "\(rawPath)-\(kittyPid)" - if FileManager.default.fileExists(atPath: withPid) { - return "unix:\(withPid)" - } - return socket // best effort — kitten will report a clearer error - } -} - -/// Resolves a pid to its controlling terminal's dev_t via sysctl. -fileprivate func ttyDevForPid(_ pid: pid_t) -> dev_t? { - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] - var info = kinfo_proc() - var size = MemoryLayout.size - guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else { return nil } - let tdev = info.kp_eproc.e_tdev - return tdev == 0 ? nil : tdev -} - -enum TerminalAdapters { - static let all: [TerminalAdapter] = [ - DefaultTerminalAdapter(bundleID: "com.mitchellh.ghostty"), - AppleTerminalAdapter(), - ITerm2Adapter(), - KittyAdapter(), - WezTermAdapter(), - DefaultTerminalAdapter(bundleID: "dev.warp.Warp-Stable"), - ] - private static let byBundleID: [String: TerminalAdapter] = Dictionary( - uniqueKeysWithValues: all.map { ($0.bundleID, $0) } - ) - static func adapter(for bid: String) -> TerminalAdapter { - byBundleID[bid] ?? DefaultTerminalAdapter(bundleID: bid) - } -} - -let terminalBundleIDs: Set = Set(TerminalAdapters.all.map(\.bundleID)) - -extension Notification.Name { - static let rulesDidChange = Notification.Name("RulesDidChange") - static let imDidSwitch = Notification.Name("IMDidSwitch") -} - -// MARK: - Title Heuristics -// -// Shared predicates used by terminals that fall through to the generic -// matching path (no native tty channel). Both Ghostty (cwd-based path) and -// Warp (descendant fallback) use these to ask the same two questions about -// the focused window's title; the orchestration around them stays per-path -// because the upstream signals differ (Ghostty has AXDocument cwd, Warp -// doesn't). - -fileprivate extension String { - /// Match self against a user-supplied rule pattern. - /// `/body/` or `/body/i` is treated as an ICU regex (case-insensitive - /// with `i`); anything else is a case-insensitive substring match. - /// Patterns missing the closing slash, with empty body, or with - /// unknown flags fall back to literal substring so users can still - /// match strings like "/usr/bin/foo" without escaping. - func matches(pattern: String) -> Bool { - guard !pattern.isEmpty else { return false } - if pattern.count >= 2, pattern.first == "/", - let lastSlash = pattern.lastIndex(of: "/"), - lastSlash != pattern.startIndex { - let flagsStr = pattern[pattern.index(after: lastSlash)...] - let body = pattern[pattern.index(after: pattern.startIndex).. Bool { - let lower = self.lowercased() - return processNames.contains { !$0.isEmpty && lower.contains($0.lowercased()) } - } - - /// Does this title look like it is displaying `cwd` (the focused tab's - /// own working directory)? Matches: the cwd's basename exactly, the - /// absolute path as substring, the tilde-collapsed form as substring, - /// and Ghostty's "…/tail/of/path" truncation. A title that mentions some - /// other path will not match — useful to distinguish a shell prompt - /// showing the tab's cwd from a foreground command title that happens - /// to contain unrelated paths. - func showsCwd(_ cwd: String) -> Bool { - guard !self.isEmpty else { return false } - let base = (cwd as NSString).lastPathComponent - if self == base { return true } - if self.contains(cwd) { return true } - let home = NSHomeDirectory() - if cwd.hasPrefix(home) { - let tilded = "~" + cwd.dropFirst(home.count) - if self.contains(tilded) { return true } - } - if self.hasPrefix("…"), cwd.hasSuffix(String(self.dropFirst())) { return true } - return false - } -} - -// MARK: - Log -// -// Single-line append-only log gated by RuleStore.debugLogEnabled. -// All log emission goes through `Log.debug(_:)` — call sites stay short -// and unaware of file paths, timestamps, or the toggle. -enum Log { - static let path: String = { - let dir = NSHomeDirectory() + "/Library/Logs/TermIMS" - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - return dir + "/termims.log" - }() - - private static let formatter = ISO8601DateFormatter() - private static var handle: FileHandle? = openHandle() - - private static func openHandle() -> FileHandle? { - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - let fh = FileHandle(forWritingAtPath: path) - fh?.seekToEndOfFile() - return fh - } - - static func debug(_ msg: @autoclosure () -> String) { - guard RuleStore.shared.debugLogEnabled else { return } - if handle == nil { handle = openHandle() } - guard let fh = handle else { return } - let line = "[\(formatter.string(from: Date()))] \(msg())\n" - guard let data = line.data(using: .utf8) else { return } - fh.write(data) - } - - static func clear() { - try? handle?.close() - handle = nil - try? FileManager.default.removeItem(atPath: path) - } -} - -// MARK: - Rule Store - -class RuleStore { - static let shared = RuleStore() - private let ud = UserDefaults.standard - - private func notify() { - NotificationCenter.default.post(name: .rulesDidChange, object: nil) - } - - var rules: [Rule] { - get { decode("TermIMSRules") ?? [] } - set { encode(newValue, "TermIMSRules"); notify() } - } - - var defaultSourceID: String? { - get { ud.string(forKey: "DefaultSourceID") } - set { ud.set(newValue, forKey: "DefaultSourceID"); notify() } - } - var defaultSourceName: String? { - get { ud.string(forKey: "DefaultSourceName") } - set { ud.set(newValue, forKey: "DefaultSourceName") } - } - - var indicatorEnabled: Bool { - get { ud.object(forKey: "IndicatorEnabled") as? Bool ?? true } - set { ud.set(newValue, forKey: "IndicatorEnabled") } - } - var indicatorPosition: IndicatorPosition { - get { IndicatorPosition(rawValue: ud.string(forKey: "IndicatorPosition") ?? "") ?? .centerBottom } - set { ud.set(newValue.rawValue, forKey: "IndicatorPosition") } - } - - var hideMenuBarIcon: Bool { - get { ud.bool(forKey: "HideMenuBarIcon") } - set { ud.set(newValue, forKey: "HideMenuBarIcon"); notify() } - } - - var debugLogEnabled: Bool { - get { ud.bool(forKey: "DebugLogEnabled") } - set { ud.set(newValue, forKey: "DebugLogEnabled") } - } - - var terminalRules: [TerminalRule] { - get { decode("TermIMSTerminalRules") ?? [] } - set { encode(newValue, "TermIMSTerminalRules"); notify() } - } - - var terminalDefaultSourceID: String? { - get { ud.string(forKey: "TerminalDefaultSourceID") } - set { ud.set(newValue, forKey: "TerminalDefaultSourceID"); notify() } - } - var terminalDefaultSourceName: String? { - get { ud.string(forKey: "TerminalDefaultSourceName") } - set { ud.set(newValue, forKey: "TerminalDefaultSourceName") } - } - - private func decode(_ key: String) -> T? { - guard let data = ud.data(forKey: key) else { return nil } - return try? JSONDecoder().decode(T.self, from: data) - } - private func encode(_ val: T, _ key: String) { - ud.set(try? JSONEncoder().encode(val), forKey: key) - } -} - -// MARK: - Indicator - -class IndicatorPanel: NSPanel { - private let label = NSTextField(labelWithString: "") - private var hideTimer: Timer? - - init() { - super.init(contentRect: NSRect(x: 0, y: 0, width: 180, height: 44), - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, defer: false) - level = .floating - isOpaque = false - backgroundColor = .clear - ignoresMouseEvents = true - hasShadow = true - collectionBehavior = [.canJoinAllSpaces, .transient] - - let bg = NSView(frame: contentView!.bounds) - bg.autoresizingMask = [.width, .height] - bg.wantsLayer = true - bg.layer?.backgroundColor = NSColor(white: 0.15, alpha: 0.9).cgColor - bg.layer?.cornerRadius = 10 - bg.layer?.masksToBounds = true - contentView?.addSubview(bg) - - label.alignment = .center - label.font = .systemFont(ofSize: 16, weight: .medium) - label.textColor = .white - label.translatesAutoresizingMaskIntoConstraints = false - bg.addSubview(label) - NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: bg.centerXAnchor), - label.centerYAnchor.constraint(equalTo: bg.centerYAnchor), - label.leadingAnchor.constraint(greaterThanOrEqualTo: bg.leadingAnchor, constant: 12), - label.trailingAnchor.constraint(lessThanOrEqualTo: bg.trailingAnchor, constant: -12), - ]) - } - - func show(text: String, position: IndicatorPosition) { - label.stringValue = text - label.sizeToFit() - let w = max(label.frame.width + 40, 100) - let h: CGFloat = 44 - setContentSize(NSSize(width: w, height: h)) - - guard let s = NSScreen.main else { return } - let mx: CGFloat = 60 - let my: CGFloat = 60 - let pt: NSPoint - switch position { - case .screenCenter: - pt = NSPoint(x: s.frame.midX - w / 2, y: s.frame.midY - h / 2) - case .centerBottom: - pt = NSPoint(x: s.frame.midX - w / 2, y: s.frame.minY + my) - case .topLeft: - pt = NSPoint(x: s.frame.minX + mx, y: s.frame.maxY - h - my) - case .topRight: - pt = NSPoint(x: s.frame.maxX - w - mx, y: s.frame.maxY - h - my) - case .bottomLeft: - pt = NSPoint(x: s.frame.minX + mx, y: s.frame.minY + my) - case .bottomRight: - pt = NSPoint(x: s.frame.maxX - w - mx, y: s.frame.minY + my) - } - setFrameOrigin(pt) - alphaValue = 1 - orderFrontRegardless() - - hideTimer?.invalidate() - hideTimer = Timer.scheduledTimer(withTimeInterval: 1.2, repeats: false) { [weak self] _ in - NSAnimationContext.runAnimationGroup({ ctx in - ctx.duration = 0.3 - self?.animator().alphaValue = 0 - }, completionHandler: { self?.orderOut(nil) }) - } - } -} - -// MARK: - Focus Monitor - -class AXContext { - let bundleID: String - weak var monitor: FocusMonitor? - init(_ bid: String, _ m: FocusMonitor) { bundleID = bid; monitor = m } -} - -class FocusMonitor { - var enabled = true - private var observers: [String: AXObserver] = [:] - private var elements: [String: AXUIElement] = [:] - private var contexts: [String: AXContext] = [:] - private var termDebounce: Timer? - - func start() { - let nc = NSWorkspace.shared.notificationCenter - nc.addObserver(self, selector: #selector(activated), - name: NSWorkspace.didActivateApplicationNotification, object: nil) - nc.addObserver(self, selector: #selector(launched), - name: NSWorkspace.didLaunchApplicationNotification, object: nil) - nc.addObserver(self, selector: #selector(terminated), - name: NSWorkspace.didTerminateApplicationNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(reload), - name: .rulesDidChange, object: nil) - reload() - } - - func stop() { - NSWorkspace.shared.notificationCenter.removeObserver(self) - NotificationCenter.default.removeObserver(self) - termDebounce?.invalidate() - for bid in Array(observers.keys) { detach(bid) } - } - - @objc func reload() { - let store = RuleStore.shared - var needed = Set(store.rules.filter(\.enabled).map(\.appBundleID)) - if store.terminalRules.contains(where: \.enabled) { - needed.formUnion(terminalBundleIDs) - } - for bid in observers.keys where !needed.contains(bid) { detach(bid) } - for bid in needed where observers[bid] == nil { attach(bid) } - } - - private func isTerminalWithRules(_ bid: String) -> Bool { - terminalBundleIDs.contains(bid) && RuleStore.shared.terminalRules.contains(where: \.enabled) - } - - private func resolveInputSource(for bid: String) -> String? { - let store = RuleStore.shared - let result: (String?, String) - if isTerminalWithRules(bid) { - if let matched = matchTerminalRule(bid: bid, store: store) { - result = (matched, "terminal-rule") - } else if let termDefault = store.terminalDefaultSourceID { - result = (termDefault, "terminal-default") - } else if let rule = store.rules.first(where: { $0.enabled && $0.appBundleID == bid }) { - result = (rule.inputSourceID, "app-rule") - } else { - result = (store.defaultSourceID, "global-default") - } - } else if let rule = store.rules.first(where: { $0.enabled && $0.appBundleID == bid }) { - result = (rule.inputSourceID, "app-rule") - } else { - result = (store.defaultSourceID, "global-default") - } - Log.debug("MATCH RESULT bid=\(bid) source=\(result.0 ?? "nil") via=\(result.1)") - return result.0 - } - - private func matchTerminalRule(bid: String, store: RuleStore) -> String? { - Log.debug("MATCH START bid=\(bid)") - let rules = store.terminalRules.filter(\.enabled) - guard !rules.isEmpty else { return nil } - - let titleRules = rules.filter { $0.matchType == .title } - let processRules = rules.filter { $0.matchType == .process } - - if !titleRules.isEmpty, let title = getFocusedWindowTitle(bid: bid) { - for rule in titleRules where !rule.pattern.isEmpty { - if title.matches(pattern: rule.pattern) { - Log.debug("TITLE RULE HIT: pattern=\(rule.pattern) title=\(title)") - return rule.inputSourceID - } - } - } - - if !processRules.isEmpty { - let candidates = getTerminalCandidateProcesses(bid: bid) - let titleForLog = getFocusedWindowTitle(bid: bid) ?? "" - Log.debug("PROCESS MATCH: title=\(titleForLog) candidates=\(candidates) rules=\(processRules.map { "\($0.pattern)" })") - - // Per-candidate rule resolution. Multiple candidates mean we - // couldn't narrow the focused tab to one tty (e.g. several - // ghostty tabs in the same cwd, generic title). Apply a rule - // only when every candidate agrees — otherwise an idle tab - // could get mis-switched just because a *different* tab in - // the same cwd happens to be running `claude`. - let hits: [TerminalRule?] = candidates.map { procs in - processRules.first(where: { rule in - !rule.pattern.isEmpty && - procs.contains(where: { $0.matches(pattern: rule.pattern) }) - }) - } - if let firstHit = hits.first ?? nil, - hits.allSatisfy({ $0?.inputSourceID == firstHit.inputSourceID }) { - Log.debug("PROCESS RULE HIT: pattern=\(firstHit.pattern) across \(hits.count) candidate(s)") - return firstHit.inputSourceID - } - let hitCount = hits.compactMap { $0 }.count - Log.debug("PROCESS RULE MISS: \(candidates.count) candidate(s), \(hitCount) hit, ambiguous → fall through") - } - - return nil - } - - // MARK: AX Helpers - - private func getFocusedWindow(bid: String) -> AXUIElement? { - guard let el = elements[bid] else { return nil } - var win: AnyObject? - guard AXUIElementCopyAttributeValue(el, kAXFocusedWindowAttribute as CFString, &win) == .success, - let raw = win, - CFGetTypeID(raw) == AXUIElementGetTypeID() else { return nil } - return (raw as! AXUIElement) - } - - private func getFocusedWindowTitle(bid: String) -> String? { - guard let app = elements[bid] else { return nil } - return TerminalAdapters.adapter(for: bid).focusedTitle(appElement: app) - } - - private struct TabInfo { let cwd: String; let tty: dev_t? } - - private func getFocusedTabInfo(bid: String) -> TabInfo? { - guard let win = getFocusedWindow(bid: bid) else { return nil } - var val: AnyObject? - guard AXUIElementCopyAttributeValue(win, kAXDocumentAttribute as CFString, &val) == .success, - let urlStr = val as? String, - let url = URL(string: urlStr) else { return nil } - Log.debug("AXDocument raw=\(urlStr)") - let path = url.path - guard !path.isEmpty else { return nil } - let tty = URLComponents(string: urlStr)?.queryItems? - .first(where: { $0.name == "tty" })?.value - .flatMap { Self.ttyDev(forName: $0) } - return TabInfo(cwd: path, tty: tty) - } - - fileprivate static func ttyDev(forName name: String) -> dev_t? { - var st = stat() - guard lstat("/dev/" + name, &st) == 0 else { return nil } - return st.st_rdev - } - - // MARK: Process Helpers - - private func processCWD(_ pid: Int32) -> String? { - var pathInfo = proc_vnodepathinfo() - let size = Int32(MemoryLayout.size) - let ret = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &pathInfo, size) - guard ret == size else { return nil } - return withUnsafePointer(to: pathInfo.pvi_cdir.vip_path) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { - String(cString: $0) - } - } - } - - private func procName(from kp: kinfo_proc) -> String { - withUnsafePointer(to: kp.kp_proc.p_comm) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN) + 1) { - String(cString: $0) - } - } - } - - private func getTerminalCandidateProcesses(bid: String) -> [[String]] { - // When the user runs multiple instances of the same terminal app - // (e.g. several kitty windows, each a separate process), prefer the - // currently active one — that's the instance whose focus event - // brought us here. Falls back to first match for safety. - let apps = NSWorkspace.shared.runningApplications - guard let app = apps.first(where: { $0.bundleIdentifier == bid && $0.isActive }) - ?? apps.first(where: { $0.bundleIdentifier == bid }) else { return [] } - let termPid = app.processIdentifier - - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL] - var size: Int = 0 - guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 0 else { return [] } - let count = size / MemoryLayout.stride - var procs = [kinfo_proc](repeating: kinfo_proc(), count: count) - guard sysctl(&mib, 3, &procs, &size, nil, 0) == 0 else { return [] } - let actual = size / MemoryLayout.stride - - struct PE { let pid: Int32; let ppid: Int32; let tdev: dev_t; let isFg: Bool; let comm: String } - var entries: [PE] = [] - entries.reserveCapacity(actual) - for i in 0..() - var queue: [Int32] = [termPid] - while let p = queue.popLast() { - for e in entries where e.ppid == p { - descendants.insert(e.pid) - queue.append(e.pid) - } - } - - // 1. Native tty (Apple Terminal AppleScript, kitty/wezterm CLI). Works - // without AXDocument — required for terminals that don't expose cwd. - if let tty = TerminalAdapters.adapter(for: bid).focusedTty(appPid: termPid) { - // All processes on this tty, not just the foreground process group. - // Foreground flips when cc/claude spawns tool subprocesses (bash, - // grep, ...) — but `claude` is still alive on the tty, so a full - // listing keeps the claude rule matching during "thinking" pauses. - let procs = entries.filter { descendants.contains($0.pid) && $0.tdev == tty }.map(\.comm) - Log.debug("TTY DIRECT: tty=\(tty) source=native procs=\(procs)") - return procs.isEmpty ? [] : [procs] - } - - // 2. OSC 7 query tty (shell hook) — needs AXDocument. - let tabInfo = getFocusedTabInfo(bid: bid) - if let tty = tabInfo?.tty { - let procs = entries.filter { descendants.contains($0.pid) && $0.tdev == tty }.map(\.comm) - Log.debug("TTY DIRECT: tty=\(tty) source=osc7 procs=\(procs)") - return procs.isEmpty ? [] : [procs] - } - - // 3. No cwd from AXDocument either (Alacritty and similar minimal - // terminals). Return foreground processes of every tty under the - // app's process tree. If multiple ttys exist (e.g. several - // Alacritty windows sharing one process), try to narrow to the - // focused one by matching the AXTitle against fg process names — - // Alacritty's title tracks the focused window's running command. - guard let tabCWD = tabInfo?.cwd else { - var byTty: [dev_t: [String]] = [:] - var shellPidPerTty: [dev_t: Int32] = [:] - let shellNamesFallback: Set = ["zsh", "bash", "fish", "login"] - for e in entries where descendants.contains(e.pid) && e.tdev != 0 { - if e.isFg { - byTty[e.tdev, default: []].append(e.comm) - } - if shellNamesFallback.contains(e.comm) { - shellPidPerTty[e.tdev] = e.pid - } - } - let rawTitle = getFocusedWindowTitle(bid: bid) ?? "" - if byTty.count > 1 { - // 1. Title literally names a fg process (e.g. "cc-connect", - // "✳ Claude Code"). Catches Warp's cc/cc-connect tabs. - // Shell process names are excluded so a title like - // "Running zsh in foo" doesn't match every candidate. - for (_, procs) in byTty { - let nonShellOnly = procs.filter { !shellNamesFallback.contains($0) } - if rawTitle.mentionsAny(of: nonShellOnly) { - Log.debug("DESCENDANT FALLBACK: title=\(rawTitle) proc-match → \(procs)") - return [procs] - } - } - - // 2. Title is a bare path (Warp tags idle shell tabs with - // their cwd). Match against each candidate shell's cwd - // to pick the focused one. When multiple ttys share the - // cwd, prefer the one whose fg is only a shell — a - // bare-cwd title means the user is at a prompt, not - // running a foreground command. Gate on `~`/`/` prefix - // so titles like "bwg-us:~" (ssh tab) don't false-match. - if rawTitle.hasPrefix("~") || rawTitle.hasPrefix("/") { - let expanded = (rawTitle as NSString).expandingTildeInPath - var matches: [(dev_t, [String])] = [] - for (tdev, procs) in byTty { - guard let pid = shellPidPerTty[tdev], - let cwd = processCWD(pid) else { continue } - if cwd == expanded { matches.append((tdev, procs)) } - } - if let shellOnly = matches.first(where: { (_, procs) in - procs.allSatisfy { shellNamesFallback.contains($0) } - }) { - Log.debug("DESCENDANT FALLBACK: title=\(rawTitle) cwd+shell-only → \(shellOnly.1)") - return [shellOnly.1] - } - if let first = matches.first { - Log.debug("DESCENDANT FALLBACK: title=\(rawTitle) cwd-match \(expanded) → \(first.1)") - return [first.1] - } - } - } - let result = Array(byTty.values) - Log.debug("DESCENDANT FALLBACK: \(result.count) ttys title=\(rawTitle) (no narrowing)") - return result - } - - let shellNames: Set = ["zsh", "bash", "fish", "login"] - let shells = entries.filter { descendants.contains($0.pid) && $0.tdev != 0 && shellNames.contains($0.comm) } - - var candidateTdevs: [dev_t] = [] - var matched = Set() - for shell in shells { - guard !matched.contains(shell.tdev) else { continue } - if processCWD(shell.pid) == tabCWD { - matched.insert(shell.tdev) - candidateTdevs.append(shell.tdev) - } - } - - guard !candidateTdevs.isEmpty else { return [] } - - // Per-tty foreground process list — used by the heuristics below - // (e.g. "is this tty currently idle at a shell prompt?"). - let fgByTty: [dev_t: [String]] = { - var m: [dev_t: [String]] = [:] - for e in entries where descendants.contains(e.pid) && e.isFg && e.tdev != 0 { - m[e.tdev, default: []].append(e.comm) - } - return m - }() - - // Full process list per tty — used for what we hand to the rule - // matcher. Including non-fg processes keeps `claude` discoverable - // while cc spawns transient tool subprocesses that briefly own - // the foreground process group. - func ttyProcs(_ td: dev_t) -> [String] { - entries.filter { descendants.contains($0.pid) && $0.tdev == td }.map(\.comm) - } - - if candidateTdevs.count == 1 { - return [ttyProcs(candidateTdevs[0])] - } - - let title = getFocusedWindowTitle(bid: bid) ?? "" - // Shell prompt signal: title equals a bare shell name, or shows the - // focused tab's own cwd in any of the recognised forms. - let looksLikeShell = shellNames.contains(title) || title.showsCwd(tabCWD) - - if looksLikeShell { - let tdev = candidateTdevs.first(where: { td in - (fgByTty[td] ?? []).allSatisfy { shellNames.contains($0) } - }) ?? candidateTdevs[0] - return [ttyProcs(tdev)] - } - - let nonShellTdevs = candidateTdevs.filter { td in - (fgByTty[td] ?? []).contains(where: { !shellNames.contains($0) }) - } - guard nonShellTdevs.count > 1 else { - return [ttyProcs(nonShellTdevs.first ?? candidateTdevs[0])] - } - - // Multi non-shell candidates: try to single out the focused one by - // checking which candidate's fg process name appears in the window - // title. Works for terminals that auto-set the title to the running - // command (Ghostty, kitty, etc.) when several tabs share a cwd. - // Shell processes are excluded so a title like "Running zsh in foo" - // doesn't accidentally match every candidate (every tty has zsh). - for td in nonShellTdevs { - let procs = ttyProcs(td) - let nonShellOnly = procs.filter { !shellNames.contains($0) } - if title.mentionsAny(of: nonShellOnly) { - Log.debug("CWD MULTI title=\(title) → \(procs)") - return [procs] - } - } - return nonShellTdevs.map { ttyProcs($0) } - } - - // MARK: Event Handlers - - @objc private func activated(_ n: Notification) { - guard enabled, let bid = bundleID(from: n) else { return } - if isTerminalWithRules(bid) { - debouncedResolve(bid) - } else { - if let id = resolveInputSource(for: bid) { - selectInputSource(id) - } - } - } - - private func handleWindowFocus(_ bid: String) { - guard enabled else { return } - if isTerminalWithRules(bid) { - debouncedResolve(bid) - } else { - if let id = resolveInputSource(for: bid) { - selectInputSource(id) - } - } - } - - private func debouncedResolve(_ bid: String) { - termDebounce?.invalidate() - termDebounce = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak self] _ in - guard let self, self.enabled else { return } - if let id = self.resolveInputSource(for: bid) { - selectInputSource(id) - } - } - } - - @objc private func launched(_ n: Notification) { - guard let bid = bundleID(from: n) else { return } - let store = RuleStore.shared - let needsObserver = store.rules.contains(where: { $0.enabled && $0.appBundleID == bid }) - || (terminalBundleIDs.contains(bid) && store.terminalRules.contains(where: \.enabled)) - if needsObserver { attach(bid) } - } - - @objc private func terminated(_ n: Notification) { - guard let bid = bundleID(from: n) else { return } - detach(bid) - } - - private func bundleID(from n: Notification) -> String? { - (n.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier - } - - func attach(_ bid: String) { - guard let app = NSWorkspace.shared.runningApplications - .first(where: { $0.bundleIdentifier == bid }) else { return } - detach(bid) - - let pid = app.processIdentifier - let el = AXUIElementCreateApplication(pid) - elements[bid] = el - - let ctx = AXContext(bid, self) - contexts[bid] = ctx - let ptr = UnsafeMutableRawPointer(Unmanaged.passUnretained(ctx).toOpaque()) - - let cb: AXObserverCallback = { _, _, _, refcon in - guard let refcon else { return } - let c = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - guard let m = c.monitor, m.enabled else { return } - m.handleWindowFocus(c.bundleID) - } - - var obs: AXObserver? - guard AXObserverCreate(pid, cb, &obs) == .success, let observer = obs else { return } - observers[bid] = observer - - var notifs = [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification] as [CFString] - if terminalBundleIDs.contains(bid) { - notifs.append(kAXFocusedUIElementChangedNotification as CFString) - if TerminalAdapters.adapter(for: bid).needsTitleChangeNotification { - notifs.append(kAXTitleChangedNotification as CFString) - } - } - for name in notifs { AXObserverAddNotification(observer, el, name, ptr) } - CFRunLoopAddSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(observer), .commonModes) - } - - private func detach(_ bid: String) { - guard let obs = observers[bid], let el = elements[bid] else { return } - let notifs = [kAXFocusedWindowChangedNotification, kAXMainWindowChangedNotification, - kAXFocusedUIElementChangedNotification, kAXTitleChangedNotification] as [CFString] - for n in notifs { AXObserverRemoveNotification(obs, el, n) } - CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .commonModes) - observers.removeValue(forKey: bid) - elements.removeValue(forKey: bid) - contexts.removeValue(forKey: bid) - } -} - -// MARK: - Settings Window - -/// NSTableView that only allows row drag when the mouse-down originated in -/// a designated drag-handle column. Without this filter NSTableView happily -/// starts a drag from anywhere on the row, so a user adjusting a popup risks -/// accidentally reordering the table. -final class RuleTableView: NSTableView { - /// Column identifier that acts as the row-drag grabber. Mouse-downs - /// outside this column are still delivered normally (clicks, popup - /// activation, text editing) but won't initiate drag. - var dragHandleColumnID: String? - fileprivate(set) var dragInitiatedFromHandle = false - - override func mouseDown(with event: NSEvent) { - let pt = convert(event.locationInWindow, from: nil) - let col = column(at: pt) - if col >= 0, col < tableColumns.count, - tableColumns[col].identifier.rawValue == dragHandleColumnID { - dragInitiatedFromHandle = true - } else { - dragInitiatedFromHandle = false - } - super.mouseDown(with: event) - } -} - -/// Small "grip" icon shown at the start of each rule row. Purely visual: the -/// row's drag is initiated by NSTableView when the user mouse-drags anywhere -/// inside a row (see `tableView(_:pasteboardWriterForRow:)`). The handle's -/// jobs are (a) signal draggability with an open-hand cursor on hover, and -/// (b) give users an obvious target to grab when they don't want to risk -/// clicking a checkbox / popup by accident. -final class DragHandleView: NSView { - private var trackingArea: NSTrackingArea? - override var intrinsicContentSize: NSSize { NSSize(width: 16, height: 16) } - - override init(frame: NSRect) { - super.init(frame: frame) - let img = NSImageView() - img.image = NSImage(systemSymbolName: "line.3.horizontal", - accessibilityDescription: "Drag to reorder") - img.contentTintColor = .tertiaryLabelColor - img.translatesAutoresizingMaskIntoConstraints = false - addSubview(img) - NSLayoutConstraint.activate([ - img.centerXAnchor.constraint(equalTo: centerXAnchor), - img.centerYAnchor.constraint(equalTo: centerYAnchor), - img.widthAnchor.constraint(equalToConstant: 14), - img.heightAnchor.constraint(equalToConstant: 14), - ]) - } - - required init?(coder: NSCoder) { fatalError() } - - // NSTableView aggressively manages cursor rects, so addCursorRect doesn't - // stick. A tracking area + explicit push/pop in mouseEntered/Exited makes - // the hand cursor reliably appear over the handle. - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let existing = trackingArea { removeTrackingArea(existing) } - let area = NSTrackingArea(rect: bounds, - options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], - owner: self, userInfo: nil) - addTrackingArea(area) - trackingArea = area - } - - override func mouseEntered(with event: NSEvent) { - NSCursor.openHand.push() - } - - override func mouseExited(with event: NSEvent) { - NSCursor.pop() - } -} - -class SettingsWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate { - private var appTableView: NSTableView! - private var termTableView: NSTableView! - private let inputSources = listInputSources() - - convenience init() { - let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 480), - styleMask: [.titled, .closable, .resizable, .miniaturizable], - backing: .buffered, defer: false) - w.title = "TermIMS Settings" - w.center() - w.minSize = NSSize(width: 620, height: 380) - w.isReleasedWhenClosed = false - self.init(window: w) - buildUI() - } - - // MARK: Tab Builder - - private func buildUI() { - guard let cv = window?.contentView else { return } - let tabView = NSTabView() - tabView.translatesAutoresizingMaskIntoConstraints = false - cv.addSubview(tabView) - NSLayoutConstraint.activate([ - tabView.topAnchor.constraint(equalTo: cv.topAnchor, constant: 8), - tabView.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: 8), - tabView.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -8), - tabView.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -8), - ]) - - let t1 = NSTabViewItem(identifier: "general"); t1.label = "General" - t1.view = buildGeneralTab() - let t2 = NSTabViewItem(identifier: "apps"); t2.label = "App Rules" - t2.view = buildAppRulesTab() - let t3 = NSTabViewItem(identifier: "terminal"); t3.label = "Terminal Rules" - t3.view = buildTerminalRulesTab() - - tabView.addTabViewItem(t1) - tabView.addTabViewItem(t2) - tabView.addTabViewItem(t3) - - appTableView.dataSource = self; appTableView.delegate = self - termTableView.dataSource = self; termTableView.delegate = self - appTableView.registerForDraggedTypes([Self.appRowDragType]) - termTableView.registerForDraggedTypes([Self.termRowDragType]) - appTableView.draggingDestinationFeedbackStyle = .gap - termTableView.draggingDestinationFeedbackStyle = .gap - } - - static let appRowDragType = NSPasteboard.PasteboardType("top.cuiko.termims.app-rule-row") - static let termRowDragType = NSPasteboard.PasteboardType("top.cuiko.termims.term-rule-row") - - // MARK: General Tab - - private func buildGeneralTab() -> NSView { - let v = NSView() - let store = RuleStore.shared - - let defLabel = label("Default Input Method:") - let defPopup = imPopup(selected: store.defaultSourceID, includeNone: true) - defPopup.tag = 900 - defPopup.target = self; defPopup.action = #selector(defaultIMChanged(_:)) - - let sep1 = separator() - let indLabel = label("Indicator") - indLabel.font = .systemFont(ofSize: 13, weight: .semibold) - - let indCheck = NSButton(checkboxWithTitle: "Show input method indicator on switch", - target: self, action: #selector(indicatorToggled(_:))) - indCheck.translatesAutoresizingMaskIntoConstraints = false - indCheck.state = store.indicatorEnabled ? .on : .off - - let posLabel = label("Position:") - let posPopup = NSPopUpButton(frame: .zero, pullsDown: false) - posPopup.translatesAutoresizingMaskIntoConstraints = false - for p in IndicatorPosition.allCases { posPopup.addItem(withTitle: p.rawValue) } - if let idx = IndicatorPosition.allCases.firstIndex(of: store.indicatorPosition) { - posPopup.selectItem(at: idx) - } - posPopup.target = self; posPopup.action = #selector(indicatorPosChanged(_:)) - - let sep2 = separator() - let appLabel = label("Application") - appLabel.font = .systemFont(ofSize: 13, weight: .semibold) - - let hideCheck = NSButton(checkboxWithTitle: "Hide menu bar icon (reopen app to show Settings)", - target: self, action: #selector(hideIconToggled(_:))) - hideCheck.translatesAutoresizingMaskIntoConstraints = false - hideCheck.state = store.hideMenuBarIcon ? .on : .off - - let sep3 = separator() - let debugLabel = label("Debug") - debugLabel.font = .systemFont(ofSize: 13, weight: .semibold) - - let debugCheck = NSButton(checkboxWithTitle: "Enable debug logging", - target: self, action: #selector(debugLogToggled(_:))) - debugCheck.translatesAutoresizingMaskIntoConstraints = false - debugCheck.state = store.debugLogEnabled ? .on : .off - - let clearBtn = NSButton(title: "Clear Log", target: self, action: #selector(clearDebugLog(_:))) - clearBtn.translatesAutoresizingMaskIntoConstraints = false - clearBtn.bezelStyle = .rounded - clearBtn.controlSize = .regular - - for sub in [defLabel, defPopup, sep1, indLabel, indCheck, posLabel, posPopup, - sep2, appLabel, hideCheck, - sep3, debugLabel, debugCheck, clearBtn] { v.addSubview(sub) } - NSLayoutConstraint.activate([ - defLabel.topAnchor.constraint(equalTo: v.topAnchor, constant: 20), - defLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - defPopup.centerYAnchor.constraint(equalTo: defLabel.centerYAnchor), - defPopup.leadingAnchor.constraint(equalTo: defLabel.trailingAnchor, constant: 8), - defPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), - - sep1.topAnchor.constraint(equalTo: defLabel.bottomAnchor, constant: 20), - sep1.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), - sep1.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), - - indLabel.topAnchor.constraint(equalTo: sep1.bottomAnchor, constant: 16), - indLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - indCheck.topAnchor.constraint(equalTo: indLabel.bottomAnchor, constant: 10), - indCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - posLabel.topAnchor.constraint(equalTo: indCheck.bottomAnchor, constant: 12), - posLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - posPopup.centerYAnchor.constraint(equalTo: posLabel.centerYAnchor), - posPopup.leadingAnchor.constraint(equalTo: posLabel.trailingAnchor, constant: 8), - posPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), - - sep2.topAnchor.constraint(equalTo: posLabel.bottomAnchor, constant: 20), - sep2.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), - sep2.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), - - appLabel.topAnchor.constraint(equalTo: sep2.bottomAnchor, constant: 16), - appLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - hideCheck.topAnchor.constraint(equalTo: appLabel.bottomAnchor, constant: 10), - hideCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - - sep3.topAnchor.constraint(equalTo: hideCheck.bottomAnchor, constant: 20), - sep3.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), - sep3.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), - - debugLabel.topAnchor.constraint(equalTo: sep3.bottomAnchor, constant: 16), - debugLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - debugCheck.topAnchor.constraint(equalTo: debugLabel.bottomAnchor, constant: 10), - debugCheck.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 16), - clearBtn.centerYAnchor.constraint(equalTo: debugCheck.centerYAnchor), - clearBtn.leadingAnchor.constraint(equalTo: debugCheck.trailingAnchor, constant: 12), - ]) - return v - } - - @objc private func defaultIMChanged(_ sender: NSPopUpButton) { - let store = RuleStore.shared - if sender.indexOfSelectedItem == 0 { - store.defaultSourceName = nil; store.defaultSourceID = nil - } else { - let src = inputSources[sender.indexOfSelectedItem - 1] - store.defaultSourceName = src.name; store.defaultSourceID = src.id - } - } - @objc private func indicatorToggled(_ sender: NSButton) { - RuleStore.shared.indicatorEnabled = sender.state == .on - } - @objc private func indicatorPosChanged(_ sender: NSPopUpButton) { - RuleStore.shared.indicatorPosition = IndicatorPosition.allCases[sender.indexOfSelectedItem] - } - @objc private func hideIconToggled(_ sender: NSButton) { - RuleStore.shared.hideMenuBarIcon = sender.state == .on - } - @objc private func debugLogToggled(_ sender: NSButton) { - RuleStore.shared.debugLogEnabled = sender.state == .on - } - @objc private func clearDebugLog(_ sender: NSButton) { - Log.clear() - } - - // MARK: App Rules Tab - - private func buildAppRulesTab() -> NSView { - let v = NSView() - let sv = scrolledTable(&appTableView) - let addBtn = smallButton("+", action: #selector(addAppRule)) - let rmBtn = smallButton("\u{2212}", action: #selector(removeAppRule)) - for sub in [sv, addBtn, rmBtn] { v.addSubview(sub) } - NSLayoutConstraint.activate([ - sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 12), - sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), - sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8), - sv.bottomAnchor.constraint(equalTo: addBtn.topAnchor, constant: -6), - addBtn.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), - addBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), - rmBtn.leadingAnchor.constraint(equalTo: addBtn.trailingAnchor), - rmBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), - ]) - return v - } - - @objc private func addAppRule() { - let panel = NSOpenPanel() - panel.canChooseFiles = true; panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.directoryURL = URL(fileURLWithPath: "/Applications") - panel.allowedContentTypes = [.applicationBundle] - panel.beginSheetModal(for: window!) { resp in - guard resp == .OK, let url = panel.url, let b = Bundle(url: url), - let bid = b.bundleIdentifier else { return } - var rules = RuleStore.shared.rules - if rules.contains(where: { $0.appBundleID == bid }) { return } - let name = FileManager.default.displayName(atPath: url.path).replacingOccurrences(of: ".app", with: "") - let im = self.inputSources.first { $0.id.contains("ABC") } ?? self.inputSources.first - ?? IMSource(id: "com.apple.keylayout.ABC", name: "ABC") - rules.append(Rule(appBundleID: bid, appName: name, inputSourceID: im.id, inputSourceName: im.name)) - RuleStore.shared.rules = rules - self.appTableView.reloadData() - } - } - @objc private func removeAppRule() { - let r = appTableView.selectedRow; guard r >= 0 else { return } - var rules = RuleStore.shared.rules; rules.remove(at: r) - RuleStore.shared.rules = rules; appTableView.reloadData() - } - - // MARK: Terminal Rules Tab - - private func buildTerminalRulesTab() -> NSView { - let v = NSView() - let store = RuleStore.shared - - let defLabel = label("Default Input Method (in terminal):") - let defPopup = imPopup(selected: store.terminalDefaultSourceID, includeNone: true) - defPopup.tag = 901 - defPopup.target = self; defPopup.action = #selector(termDefaultIMChanged(_:)) - - let hintLabel = label("When a terminal tab matches a rule below, switch to that rule's input method instead:") - hintLabel.font = .systemFont(ofSize: 11) - hintLabel.textColor = .secondaryLabelColor - - let sv = scrolledTermTable(&termTableView) - let addBtn = smallButton("+", action: #selector(addTermRule)) - let rmBtn = smallButton("\u{2212}", action: #selector(removeTermRule)) - - for sub in [defLabel, defPopup, hintLabel, sv, addBtn, rmBtn] { v.addSubview(sub) } - NSLayoutConstraint.activate([ - defLabel.topAnchor.constraint(equalTo: v.topAnchor, constant: 14), - defLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), - defPopup.centerYAnchor.constraint(equalTo: defLabel.centerYAnchor), - defPopup.leadingAnchor.constraint(equalTo: defLabel.trailingAnchor, constant: 8), - defPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), - - hintLabel.topAnchor.constraint(equalTo: defLabel.bottomAnchor, constant: 10), - hintLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 12), - hintLabel.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -12), - - sv.topAnchor.constraint(equalTo: hintLabel.bottomAnchor, constant: 8), - sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), - sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8), - sv.bottomAnchor.constraint(equalTo: addBtn.topAnchor, constant: -6), - addBtn.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8), - addBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), - rmBtn.leadingAnchor.constraint(equalTo: addBtn.trailingAnchor), - rmBtn.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8), - ]) - return v - } - - @objc private func termDefaultIMChanged(_ sender: NSPopUpButton) { - let store = RuleStore.shared - if sender.indexOfSelectedItem == 0 { - store.terminalDefaultSourceName = nil; store.terminalDefaultSourceID = nil - } else { - let src = inputSources[sender.indexOfSelectedItem - 1] - store.terminalDefaultSourceName = src.name; store.terminalDefaultSourceID = src.id - } - } - - @objc private func addTermRule() { - let im = inputSources.first { $0.id.contains("ABC") } ?? inputSources.first - ?? IMSource(id: "com.apple.keylayout.ABC", name: "ABC") - var rules = RuleStore.shared.terminalRules - rules.append(TerminalRule(inputSourceID: im.id, inputSourceName: im.name)) - RuleStore.shared.terminalRules = rules - termTableView.reloadData() - } - - @objc private func removeTermRule() { - let r = termTableView.selectedRow; guard r >= 0 else { return } - var rules = RuleStore.shared.terminalRules; rules.remove(at: r) - RuleStore.shared.terminalRules = rules; termTableView.reloadData() - } - - private func scrolledTermTable(_ tv: inout NSTableView!) -> NSScrollView { - let sv = NSScrollView(); sv.translatesAutoresizingMaskIntoConstraints = false - sv.hasVerticalScroller = true; sv.borderType = .bezelBorder - let rtv = RuleTableView() - rtv.dragHandleColumnID = "tdrag" - tv = rtv - tv.usesAlternatingRowBackgroundColors = true; tv.rowHeight = 24 - let colDrag = NSTableColumn(identifier: .init("tdrag")); colDrag.title = ""; colDrag.width = 24; colDrag.minWidth = 24; colDrag.maxWidth = 24 - let colOn = NSTableColumn(identifier: .init("ton")); colOn.title = ""; colOn.width = 30; colOn.minWidth = 30; colOn.maxWidth = 30 - let colType = NSTableColumn(identifier: .init("ttype")); colType.title = "Match"; colType.width = 100; colType.minWidth = 80 - let colPat = NSTableColumn(identifier: .init("tpat")); colPat.title = "Pattern"; colPat.width = 140; colPat.minWidth = 80 - let colIM = NSTableColumn(identifier: .init("tim")); colIM.title = "Input Method"; colIM.width = 160; colIM.minWidth = 120 - let colNote = NSTableColumn(identifier: .init("tnote")); colNote.title = "Note"; colNote.width = 140; colNote.minWidth = 80 - tv.addTableColumn(colDrag); tv.addTableColumn(colOn); tv.addTableColumn(colType); tv.addTableColumn(colPat); tv.addTableColumn(colIM); tv.addTableColumn(colNote) - sv.documentView = tv; return sv - } - - // MARK: Table Drag Reordering - - func tableView(_ tv: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { - // Only allow drag when the mouse-down was on the drag handle column — - // see RuleTableView.mouseDown. Prevents accidental row reorder when a - // user drags a popup or text field to select / scroll. - if let rtv = tv as? RuleTableView, !rtv.dragInitiatedFromHandle { return nil } - let item = NSPasteboardItem() - let type = (tv === termTableView) ? Self.termRowDragType : Self.appRowDragType - item.setString("\(row)", forType: type) - return item - } - - func tableView(_ tv: NSTableView, - validateDrop info: NSDraggingInfo, - proposedRow row: Int, - proposedDropOperation op: NSTableView.DropOperation) -> NSDragOperation { - guard op == .above else { return [] } - return .move - } - - func tableView(_ tv: NSTableView, - acceptDrop info: NSDraggingInfo, - row: Int, - dropOperation op: NSTableView.DropOperation) -> Bool { - let type = (tv === termTableView) ? Self.termRowDragType : Self.appRowDragType - guard let items = info.draggingPasteboard.pasteboardItems, - let str = items.first?.string(forType: type), - let src = Int(str), src != row, src != row - 1 else { return false } - - if tv === termTableView { - var rules = RuleStore.shared.terminalRules - let dst = src < row ? row - 1 : row - let moved = rules.remove(at: src) - rules.insert(moved, at: dst) - RuleStore.shared.terminalRules = rules - } else { - var rules = RuleStore.shared.rules - let dst = src < row ? row - 1 : row - let moved = rules.remove(at: src) - rules.insert(moved, at: dst) - RuleStore.shared.rules = rules - } - tv.reloadData() - return true - } - - // MARK: Table DataSource / Delegate - - func numberOfRows(in tv: NSTableView) -> Int { - if tv === termTableView { return RuleStore.shared.terminalRules.count } - return RuleStore.shared.rules.count - } - - func tableView(_ tv: NSTableView, viewFor col: NSTableColumn?, row: Int) -> NSView? { - if tv === termTableView { return termTableCellView(col, row: row) } - let rules = RuleStore.shared.rules - guard row < rules.count else { return nil } - let rule = rules[row] - switch col?.identifier.rawValue { - case "adrag": - return centeredNarrowCellView(tv, id: "adrag\(row)", control: DragHandleView()) - case "on": - let btn = recycledCheckbox(tv, id: "aon") - btn.state = rule.enabled ? .on : .off; btn.tag = row - btn.target = self; btn.action = #selector(appToggle(_:)) - return centeredNarrowCellView(tv, id: "aon\(row)", control: btn) - case "app": - let cell = recycledAppCell(tv) - cell.textField?.stringValue = rule.appName - if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: rule.appBundleID) { - cell.imageView?.image = NSWorkspace.shared.icon(forFile: url.path) - } else { - cell.imageView?.image = NSImage(systemSymbolName: "app", accessibilityDescription: nil) - } - return cell - case "im": - let popup = recycledIMPopup(tv, id: "aim") - configureIMPopup(popup, selected: rule.inputSourceID, row: row) - popup.target = self; popup.action = #selector(appIMChanged(_:)) - return popup - default: return nil - } - } - - /// Stretches the control to fill the full cell width — used for wide - /// controls (popup buttons, text fields). The table's default 3pt - /// horizontal intercell spacing keeps a small gap between columns - /// (macOS standard), while controls themselves occupy 100% of their - /// own cell. - private func centeredCellView(_ tv: NSTableView, id: String, control: NSView) -> NSView { - let cell = NSTableCellView() - cell.identifier = NSUserInterfaceItemIdentifier(id + "cell") - control.translatesAutoresizingMaskIntoConstraints = false - cell.addSubview(control) - NSLayoutConstraint.activate([ - control.leadingAnchor.constraint(equalTo: cell.leadingAnchor), - control.trailingAnchor.constraint(equalTo: cell.trailingAnchor), - control.centerYAnchor.constraint(equalTo: cell.centerYAnchor), - ]) - return cell - } - - /// Centers a narrow control (checkbox, drag handle) at its intrinsic size - /// inside the cell. Stretching a checkbox via leading/trailing pins the - /// visible glyph to the left of the cell — using centerX keeps it - /// visually centered under the column header. - private func centeredNarrowCellView(_ tv: NSTableView, id: String, control: NSView) -> NSView { - let cell = NSTableCellView() - cell.identifier = NSUserInterfaceItemIdentifier(id + "cell") - control.translatesAutoresizingMaskIntoConstraints = false - cell.addSubview(control) - NSLayoutConstraint.activate([ - control.centerXAnchor.constraint(equalTo: cell.centerXAnchor), - control.centerYAnchor.constraint(equalTo: cell.centerYAnchor), - ]) - return cell - } - - private func termTableCellView(_ col: NSTableColumn?, row: Int) -> NSView? { - let rules = RuleStore.shared.terminalRules - guard row < rules.count else { return nil } - let rule = rules[row] - switch col?.identifier.rawValue { - case "tdrag": - return centeredNarrowCellView(termTableView, id: "tdrag\(row)", control: DragHandleView()) - case "ton": - let btn = recycledCheckbox(termTableView, id: "ton") - btn.state = rule.enabled ? .on : .off; btn.tag = row - btn.target = self; btn.action = #selector(termToggle(_:)) - return centeredNarrowCellView(termTableView, id: "ton\(row)", control: btn) - case "ttype": - let popup = recycledMatchTypePopup(termTableView) - popup.removeAllItems() - for t in TerminalMatchType.allCases { popup.addItem(withTitle: t.rawValue) } - if let idx = TerminalMatchType.allCases.firstIndex(of: rule.matchType) { popup.selectItem(at: idx) } - popup.tag = row - popup.target = self; popup.action = #selector(termMatchTypeChanged(_:)) - return centeredCellView(termTableView, id: "ttype\(row)", control: popup) - case "tpat": - let tf = recycledPatternField(termTableView) - tf.stringValue = rule.pattern; tf.tag = row - tf.target = self; tf.action = #selector(termPatternChanged(_:)) - return centeredCellView(termTableView, id: "tpat\(row)", control: tf) - case "tim": - let popup = recycledIMPopup(termTableView, id: "tim") - configureIMPopup(popup, selected: rule.inputSourceID, row: row) - popup.target = self; popup.action = #selector(termIMChanged(_:)) - return centeredCellView(termTableView, id: "tim\(row)", control: popup) - case "tnote": - let tf = recycledNoteField(termTableView) - tf.stringValue = rule.note; tf.tag = row - tf.target = self; tf.action = #selector(termNoteChanged(_:)) - return centeredCellView(termTableView, id: "tnote\(row)", control: tf) - default: return nil - } - } - - // MARK: Table Actions - - @objc private func appToggle(_ s: NSButton) { - var r = RuleStore.shared.rules; guard s.tag < r.count else { return } - r[s.tag].enabled = s.state == .on; RuleStore.shared.rules = r - } - @objc private func appIMChanged(_ s: NSPopUpButton) { - var r = RuleStore.shared.rules - guard s.tag < r.count, let item = s.selectedItem, let sid = item.representedObject as? String else { return } - r[s.tag].inputSourceID = sid; r[s.tag].inputSourceName = item.title; RuleStore.shared.rules = r - } - - @objc private func termToggle(_ s: NSButton) { - var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } - r[s.tag].enabled = s.state == .on; RuleStore.shared.terminalRules = r - } - @objc private func termMatchTypeChanged(_ s: NSPopUpButton) { - var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } - r[s.tag].matchType = TerminalMatchType.allCases[s.indexOfSelectedItem] - RuleStore.shared.terminalRules = r - } - @objc private func termPatternChanged(_ s: NSTextField) { - var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } - r[s.tag].pattern = s.stringValue; RuleStore.shared.terminalRules = r - } - @objc private func termIMChanged(_ s: NSPopUpButton) { - var r = RuleStore.shared.terminalRules - guard s.tag < r.count, let item = s.selectedItem, let sid = item.representedObject as? String else { return } - r[s.tag].inputSourceID = sid; r[s.tag].inputSourceName = item.title; RuleStore.shared.terminalRules = r - } - @objc private func termNoteChanged(_ s: NSTextField) { - var r = RuleStore.shared.terminalRules; guard s.tag < r.count else { return } - r[s.tag].note = s.stringValue; RuleStore.shared.terminalRules = r - } - - // MARK: View Factories - - private func label(_ text: String) -> NSTextField { - let l = NSTextField(labelWithString: text) - l.translatesAutoresizingMaskIntoConstraints = false; l.font = .systemFont(ofSize: 13) - return l - } - private func separator() -> NSBox { - let b = NSBox(); b.translatesAutoresizingMaskIntoConstraints = false; b.boxType = .separator; return b - } - private func smallButton(_ title: String, action: Selector) -> NSButton { - let b = NSButton(title: title, target: self, action: action) - b.translatesAutoresizingMaskIntoConstraints = false; b.bezelStyle = .smallSquare - b.widthAnchor.constraint(equalToConstant: 28).isActive = true - b.heightAnchor.constraint(equalToConstant: 24).isActive = true - return b - } - - private func imPopup(selected sid: String?, includeNone: Bool) -> NSPopUpButton { - let p = NSPopUpButton(frame: .zero, pullsDown: false) - p.translatesAutoresizingMaskIntoConstraints = false - if includeNone { p.addItem(withTitle: "None"); p.lastItem?.representedObject = nil } - for src in inputSources { p.addItem(withTitle: src.name); p.lastItem?.representedObject = src.id } - if let sid, let idx = inputSources.firstIndex(where: { $0.id == sid }) { - p.selectItem(at: idx + (includeNone ? 1 : 0)) - } else if includeNone { p.selectItem(at: 0) } - return p - } - - private func scrolledTable(_ tv: inout NSTableView!) -> NSScrollView { - let sv = NSScrollView(); sv.translatesAutoresizingMaskIntoConstraints = false - sv.hasVerticalScroller = true; sv.borderType = .bezelBorder - let rtv = RuleTableView() - rtv.dragHandleColumnID = "adrag" - tv = rtv - tv.usesAlternatingRowBackgroundColors = true; tv.rowHeight = 24 - let colDrag = NSTableColumn(identifier: .init("adrag")); colDrag.title = ""; colDrag.width = 24; colDrag.minWidth = 24; colDrag.maxWidth = 24 - let colOn = NSTableColumn(identifier: .init("on")); colOn.title = ""; colOn.width = 30; colOn.minWidth = 30; colOn.maxWidth = 30 - let colApp = NSTableColumn(identifier: .init("app")); colApp.title = "Application"; colApp.width = 200; colApp.minWidth = 120 - let colIM = NSTableColumn(identifier: .init("im")); colIM.title = "Input Method"; colIM.width = 200; colIM.minWidth = 120 - tv.addTableColumn(colDrag); tv.addTableColumn(colOn); tv.addTableColumn(colApp); tv.addTableColumn(colIM) - sv.documentView = tv; return sv - } - - private func recycledCheckbox(_ tv: NSTableView, id: String) -> NSButton { - let uid = NSUserInterfaceItemIdentifier(id) - if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSButton { return v } - let b = NSButton(checkboxWithTitle: "", target: nil, action: nil); b.identifier = uid; return b - } - - private func recycledAppCell(_ tv: NSTableView) -> NSTableCellView { - let uid = NSUserInterfaceItemIdentifier("appcell") - if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSTableCellView { return v } - let v = NSTableCellView(); v.identifier = uid - let iv = NSImageView(); iv.translatesAutoresizingMaskIntoConstraints = false - let tf = NSTextField(labelWithString: ""); tf.translatesAutoresizingMaskIntoConstraints = false; tf.lineBreakMode = .byTruncatingTail - v.addSubview(iv); v.addSubview(tf); v.imageView = iv; v.textField = tf - NSLayoutConstraint.activate([ - iv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 2), - iv.centerYAnchor.constraint(equalTo: v.centerYAnchor), - iv.widthAnchor.constraint(equalToConstant: 20), iv.heightAnchor.constraint(equalToConstant: 20), - tf.leadingAnchor.constraint(equalTo: iv.trailingAnchor, constant: 6), - tf.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -2), - tf.centerYAnchor.constraint(equalTo: v.centerYAnchor), - ]) - return v - } - - private func recycledIMPopup(_ tv: NSTableView, id: String) -> NSPopUpButton { - let uid = NSUserInterfaceItemIdentifier(id) - if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSPopUpButton { return v } - let p = NSPopUpButton(frame: .zero, pullsDown: false) - p.identifier = uid; p.controlSize = .small; p.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - return p - } - - private func recycledMatchTypePopup(_ tv: NSTableView) -> NSPopUpButton { - let uid = NSUserInterfaceItemIdentifier("tmtype") - if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSPopUpButton { return v } - let p = NSPopUpButton(frame: .zero, pullsDown: false) - p.identifier = uid; p.controlSize = .small; p.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - return p - } - - private func recycledPatternField(_ tv: NSTableView) -> NSTextField { - let uid = NSUserInterfaceItemIdentifier("tpat") - if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSTextField { return v } - let tf = NSTextField() - tf.identifier = uid; tf.controlSize = .small; tf.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - tf.placeholderString = "e.g. opencode, nvim" - tf.lineBreakMode = .byTruncatingTail; tf.cell?.sendsActionOnEndEditing = true - return tf - } - - private func recycledNoteField(_ tv: NSTableView) -> NSTextField { - let uid = NSUserInterfaceItemIdentifier("tnote") - if let v = tv.makeView(withIdentifier: uid, owner: nil) as? NSTextField { return v } - let tf = NSTextField() - tf.identifier = uid; tf.controlSize = .small; tf.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - tf.placeholderString = "" - tf.lineBreakMode = .byTruncatingTail; tf.cell?.sendsActionOnEndEditing = true - return tf - } - - private func configureIMPopup(_ popup: NSPopUpButton, selected sid: String, row: Int) { - popup.removeAllItems() - for src in inputSources { popup.addItem(withTitle: src.name); popup.lastItem?.representedObject = src.id } - if let idx = inputSources.firstIndex(where: { $0.id == sid }) { popup.selectItem(at: idx) } - popup.tag = row - } -} - -// MARK: - Permission Window - -class PermissionWindowController: NSWindowController { - private var pollTimer: Timer? - var onGranted: (() -> Void)? - - convenience init() { - let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 200), - styleMask: [.titled, .closable], backing: .buffered, defer: false) - w.title = "TermIMS" - w.center() - w.isReleasedWhenClosed = false - self.init(window: w) - buildUI() - } - - private func buildUI() { - guard let cv = window?.contentView else { return } - - let icon = NSImageView() - icon.translatesAutoresizingMaskIntoConstraints = false - icon.image = NSImage(systemSymbolName: "lock.shield", accessibilityDescription: nil) - icon.symbolConfiguration = .init(pointSize: 36, weight: .light) - icon.contentTintColor = .secondaryLabelColor - - let title = NSTextField(labelWithString: "Accessibility Permission Required") - title.translatesAutoresizingMaskIntoConstraints = false - title.font = .systemFont(ofSize: 16, weight: .semibold) - - let desc = NSTextField(wrappingLabelWithString: - "TermIMS needs accessibility access to detect app switches and change input methods. Please enable it in System Settings > Privacy & Security > Accessibility.") - desc.translatesAutoresizingMaskIntoConstraints = false - desc.font = .systemFont(ofSize: 12) - desc.textColor = .secondaryLabelColor - - let openBtn = NSButton(title: "Open System Settings", target: self, action: #selector(openSettings)) - openBtn.translatesAutoresizingMaskIntoConstraints = false - openBtn.bezelStyle = .rounded - openBtn.keyEquivalent = "\r" - - let quitBtn = NSButton(title: "Quit", target: self, action: #selector(quitApp)) - quitBtn.translatesAutoresizingMaskIntoConstraints = false - quitBtn.bezelStyle = .rounded - - for sub in [icon, title, desc, openBtn, quitBtn] { cv.addSubview(sub) } - NSLayoutConstraint.activate([ - icon.topAnchor.constraint(equalTo: cv.topAnchor, constant: 24), - icon.centerXAnchor.constraint(equalTo: cv.centerXAnchor), - - title.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 12), - title.centerXAnchor.constraint(equalTo: cv.centerXAnchor), - - desc.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8), - desc.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: 32), - desc.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -32), - - openBtn.topAnchor.constraint(equalTo: desc.bottomAnchor, constant: 16), - openBtn.trailingAnchor.constraint(equalTo: cv.centerXAnchor, constant: -6), - openBtn.bottomAnchor.constraint(equalTo: cv.bottomAnchor, constant: -16), - - quitBtn.topAnchor.constraint(equalTo: desc.bottomAnchor, constant: 16), - quitBtn.leadingAnchor.constraint(equalTo: cv.centerXAnchor, constant: 6), - quitBtn.widthAnchor.constraint(equalTo: openBtn.widthAnchor), - ]) - } - - func startPolling() { - pollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - if AXIsProcessTrusted() { - self?.pollTimer?.invalidate() - self?.pollTimer = nil - self?.window?.close() - self?.onGranted?() - } - } - } - - @objc private func openSettings() { - AXIsProcessTrustedWithOptions( - [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary) - } - - @objc private func quitApp() { - NSApp.terminate(nil) - } -} - -// MARK: - App Delegate - -class AppDelegate: NSObject, NSApplicationDelegate { - private var statusItem: NSStatusItem? - private let monitor = FocusMonitor() - private let indicator = IndicatorPanel() - private var settingsWC: SettingsWindowController? - private var permissionWC: PermissionWindowController? - private var enabledItem: NSMenuItem! - private var loginItem: NSMenuItem! - private var permissionPollTimer: Timer? - private var wasTrusted = false - - private var launchAgentPath: String { - NSHomeDirectory() + "/Library/LaunchAgents/top.cuiko.termims.plist" - } - - func applicationDidFinishLaunching(_ n: Notification) { - Log.debug("=== TermIMS started ===") - installEditMenu() - if AXIsProcessTrusted() { - wasTrusted = true - startApp() - } else { - showPermissionWindow() - } - startPermissionPolling() - } - - /// LSUIElement apps don't get a main menu by default, which means - /// Cmd+C/V/X/A in any text field are dispatched into the void. Wire up - /// a minimal Edit menu so the standard shortcuts reach the field editor. - private func installEditMenu() { - let main = NSMenu() - let editItem = NSMenuItem() - main.addItem(editItem) - - let edit = NSMenu(title: "Edit") - edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") - let redo = NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "z") - redo.keyEquivalentModifierMask = [.command, .shift] - edit.addItem(redo) - edit.addItem(.separator()) - edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") - edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") - edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") - edit.addItem(withTitle: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: "") - edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") - - editItem.submenu = edit - NSApp.mainMenu = main - } - - private func startPermissionPolling() { - permissionPollTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in - guard let self else { return } - let trusted = AXIsProcessTrusted() - if self.wasTrusted && !trusted { - self.wasTrusted = false - self.monitor.stop() - self.rebuildMenu() - } else if !self.wasTrusted && trusted { - self.wasTrusted = true - self.startApp() - } - } - } - - private func showPermissionWindow() { - permissionWC = PermissionWindowController() - permissionWC?.onGranted = { [weak self] in - self?.permissionWC = nil - self?.startApp() - } - permissionWC?.showWindow(nil) - permissionWC?.startPolling() - NSApp.activate(ignoringOtherApps: true) - } - - private var appStarted = false - private func startApp() { - guard !appStarted else { rebuildMenu(); monitor.reload(); return } - appStarted = true - applyMenuBarVisibility() - NotificationCenter.default.addObserver(self, selector: #selector(rebuildMenu), - name: .rulesDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(imDidSwitch), - name: .imDidSwitch, object: nil) - monitor.start() - } - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { - if !AXIsProcessTrusted() { - showPermissionWindow() - } else { - showSettings() - } - return true - } - - @objc private func imDidSwitch() { - guard RuleStore.shared.indicatorEnabled, - let name = currentInputSourceName() else { return } - indicator.show(text: name, position: RuleStore.shared.indicatorPosition) - } - - private func applyMenuBarVisibility() { - if RuleStore.shared.hideMenuBarIcon { - if let item = statusItem { - NSStatusBar.system.removeStatusItem(item) - statusItem = nil - } - } else { - if statusItem == nil { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - if let btn = statusItem?.button { - btn.image = NSImage(systemSymbolName: "keyboard", accessibilityDescription: "TermIMS") - } - } - rebuildMenu() - } - } - - @objc private func rebuildMenu() { - if RuleStore.shared.hideMenuBarIcon { applyMenuBarVisibility(); return } - guard let statusItem else { applyMenuBarVisibility(); return } - - let menu = NSMenu() - let store = RuleStore.shared - let trusted = AXIsProcessTrusted() - - if !trusted { - let warn = NSMenuItem(title: "Accessibility Permission Required", action: #selector(showPermissionPrompt), keyEquivalent: "") - warn.target = self - warn.image = NSImage(systemSymbolName: "exclamationmark.triangle", accessibilityDescription: nil) - menu.addItem(warn) - menu.addItem(.separator()) - } - - enabledItem = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "") - enabledItem.target = self; enabledItem.state = monitor.enabled ? .on : .off - menu.addItem(enabledItem) - menu.addItem(.separator()) - - let defName = store.defaultSourceName ?? "None" - let defItem = NSMenuItem(title: "Default: \(defName)", action: nil, keyEquivalent: "") - defItem.isEnabled = false; menu.addItem(defItem) - - let activeAppRules = store.rules.filter(\.enabled) - if !activeAppRules.isEmpty { - menu.addItem(.separator()) - let hdr = NSMenuItem(title: "App Rules", action: nil, keyEquivalent: "") - hdr.isEnabled = false; menu.addItem(hdr) - for rule in activeAppRules { - let item = NSMenuItem(title: " \(rule.appName) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "") - item.isEnabled = false; menu.addItem(item) - } - } - - let activeTermRules = store.terminalRules.filter(\.enabled) - if !activeTermRules.isEmpty { - menu.addItem(.separator()) - let hdr = NSMenuItem(title: "Terminal Rules", action: nil, keyEquivalent: "") - hdr.isEnabled = false; menu.addItem(hdr) - for rule in activeTermRules { - let typeStr = rule.matchType == .title ? "title" : "proc" - let item = NSMenuItem(title: " \(typeStr):\(rule.pattern) \u{2192} \(rule.inputSourceName)", action: nil, keyEquivalent: "") - item.isEnabled = false; menu.addItem(item) - } - } - - menu.addItem(.separator()) - let settings = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",") - settings.target = self; menu.addItem(settings) - - menu.addItem(.separator()) - loginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLogin), keyEquivalent: "") - loginItem.target = self - loginItem.state = FileManager.default.fileExists(atPath: launchAgentPath) ? .on : .off - menu.addItem(loginItem) - - menu.addItem(.separator()) - let quit = NSMenuItem(title: "Quit TermIMS", action: #selector(quitApp), keyEquivalent: "q") - quit.target = self; menu.addItem(quit) - - statusItem.menu = menu - } - - @objc private func toggleEnabled() { - monitor.enabled.toggle(); enabledItem.state = monitor.enabled ? .on : .off - } - @objc private func showPermissionPrompt() { - showPermissionWindow() - } - @objc private func showSettings() { - if !AXIsProcessTrusted() { showPermissionWindow(); return } - if settingsWC == nil { settingsWC = SettingsWindowController() } - settingsWC?.showWindow(nil); NSApp.activate(ignoringOtherApps: true) - } - @objc private func toggleLogin() { - let fm = FileManager.default - if fm.fileExists(atPath: launchAgentPath) { - try? fm.removeItem(atPath: launchAgentPath) - } else { - try? fm.createDirectory(atPath: NSHomeDirectory() + "/Library/LaunchAgents", withIntermediateDirectories: true) - let plist: NSDictionary = [ - "Label": "top.cuiko.termims", - "ProgramArguments": [Bundle.main.executablePath ?? ""], - "RunAtLoad": true, - ] - plist.write(toFile: launchAgentPath, atomically: true) - } - loginItem.state = fm.fileExists(atPath: launchAgentPath) ? .on : .off - } - @objc private func quitApp() { monitor.stop(); NSApp.terminate(nil) } -} - -// MARK: - Main - -let app = NSApplication.shared -app.setActivationPolicy(.accessory) -let delegate = AppDelegate() -app.delegate = delegate -app.run()