diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 0000000..27c6dc9 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,17 @@ +name: Renovate/Dependabot auto-merge +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: github.actor == 'renovate[bot]' || github.actor == 'dependabot[bot]' + steps: + - name: Enable auto-merge for Renovate/Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e70e18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftLint + run: brew install swiftlint + - name: Run SwiftLint + run: swiftlint --strict + + format: + name: Format + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftFormat + run: brew install swiftformat + - name: Check formatting + run: swiftformat --lint CursorIME + + build: + name: Build and Analyse + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Install SwiftLint + run: brew install swiftlint + - name: Build and Analyse + run: | + set -o pipefail + xcodebuild build analyze \ + -project CursorIME.xcodeproj \ + -scheme CursorIME \ + -destination 'generic/platform=macOS' \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + | tee build.log + - name: SwiftLint Analyse + run: swiftlint analyze --strict --compiler-log-path build.log diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..e0ea36f --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.0 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..e2d14f6 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,9 @@ +disabled_rules: + - line_length + - nesting +# Require trailing commas to match SwiftFormat's trailingCommas rule. +trailing_comma: + mandatory_comma: true +analyzer_rules: + - unused_declaration + - unused_import diff --git a/CursorIME.xcodeproj/project.pbxproj b/CursorIME.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0c53279 --- /dev/null +++ b/CursorIME.xcodeproj/project.pbxproj @@ -0,0 +1,291 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + CE0000000000000000000006 /* CursorIME.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CursorIME.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + CE0000000000000000000004 /* CursorIME */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CursorIME; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + CE0000000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + CE0000000000000000000002 = { + isa = PBXGroup; + children = ( + CE0000000000000000000004 /* CursorIME */, + CE0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + CE0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + CE0000000000000000000006 /* CursorIME.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + CE0000000000000000000005 /* CursorIME */ = { + isa = PBXNativeTarget; + buildConfigurationList = CE000000000000000000000C /* Build configuration list for PBXNativeTarget "CursorIME" */; + buildPhases = ( + CE0000000000000000000007 /* Sources */, + CE0000000000000000000008 /* Frameworks */, + CE0000000000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + CE0000000000000000000004 /* CursorIME */, + ); + name = CursorIME; + packageProductDependencies = ( + ); + productName = CursorIME; + productReference = CE0000000000000000000006 /* CursorIME.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + CE0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + CE0000000000000000000005 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = CE000000000000000000000B /* Build configuration list for PBXProject "CursorIME" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = CE0000000000000000000002; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = CE0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + CE0000000000000000000005 /* CursorIME */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + CE0000000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + CE0000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + CE000000000000000000000D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + CE000000000000000000000E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + CE000000000000000000000F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = CursorIME/CursorIME.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 97A8B2WE2P; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = jp.winebarrel.CursorIME; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + CE0000000000000000000010 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = CursorIME/CursorIME.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = 97A8B2WE2P; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = jp.winebarrel.CursorIME; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CE000000000000000000000B /* Build configuration list for PBXProject "CursorIME" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CE000000000000000000000D /* Debug */, + CE000000000000000000000E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CE000000000000000000000C /* Build configuration list for PBXNativeTarget "CursorIME" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CE000000000000000000000F /* Debug */, + CE0000000000000000000010 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = CE0000000000000000000001 /* Project object */; +} diff --git a/CursorIME/AppDelegate.swift b/CursorIME/AppDelegate.swift new file mode 100644 index 0000000..a159697 --- /dev/null +++ b/CursorIME/AppDelegate.swift @@ -0,0 +1,198 @@ +import AppKit +import Carbon +import ServiceManagement + +enum Defaults { + static let showMenuBarIcon = "showMenuBarIcon" +} + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + private let badgeSize = NSSize(width: 34, height: 34) + /// Offset from the cursor hotspot: the badge sits to the lower-right of the pointer. + private let offset = NSPoint(x: 16, y: -40) + + private var panel: NSPanel! + private var badge: BadgeView! + private var statusItem: NSStatusItem! + private var launchAtLoginItem: NSMenuItem! + private var lastState: IMEState? + private var lastMouse = NSPoint(x: -1, y: -1) + private var frame = 0 + private var timer: Timer? + private var imeObserver: NSObjectProtocol? + + func applicationDidFinishLaunching(_: Notification) { + setupBadge() + setupStatusItem() + startIMEObserver() + startTimer() + updateState() + } + + /// Relaunching the app from Finder while it is already running restores a + /// hidden menu bar icon. + func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { + setStatusItemVisible(true) + return true + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + let icon = NSImage(named: "MenuBarIcon") + icon?.isTemplate = true + icon?.accessibilityDescription = "CursorIME" + statusItem.button?.image = icon + + let menu = NSMenu() + menu.delegate = self + menu.addItem( + withTitle: "About CursorIME", + action: #selector(showAbout), + keyEquivalent: "" + ) + menu.addItem(.separator()) + launchAtLoginItem = NSMenuItem( + title: "Launch at Login", + action: #selector(toggleLaunchAtLogin), + keyEquivalent: "" + ) + menu.addItem(launchAtLoginItem) + menu.addItem( + withTitle: "Hide Menu Bar Icon", + action: #selector(hideStatusItem), + keyEquivalent: "" + ) + menu.addItem(.separator()) + menu.addItem( + withTitle: "Quit CursorIME", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + statusItem.menu = menu + updateLaunchAtLoginItem() + + // Honor the stored preference on launch (visible by default). + let visible = UserDefaults.standard.object(forKey: Defaults.showMenuBarIcon) as? Bool ?? true + statusItem.isVisible = visible + } + + @objc private func showAbout() { + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel(nil) + } + + @objc private func toggleLaunchAtLogin() { + do { + if SMAppService.mainApp.status == .enabled { + try SMAppService.mainApp.unregister() + } else { + try SMAppService.mainApp.register() + } + } catch { + NSLog("CursorIME: failed to toggle launch at login: \(error)") + } + updateLaunchAtLoginItem() + } + + private func updateLaunchAtLoginItem() { + launchAtLoginItem.state = SMAppService.mainApp.status == .enabled ? .on : .off + } + + @objc private func hideStatusItem() { + setStatusItemVisible(false) + } + + private func setStatusItemVisible(_ visible: Bool) { + statusItem.isVisible = visible + UserDefaults.standard.set(visible, forKey: Defaults.showMenuBarIcon) + } + + private func setupBadge() { + badge = BadgeView(frame: NSRect(origin: .zero, size: badgeSize)) + + panel = NSPanel( + contentRect: NSRect(origin: .zero, size: badgeSize), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .statusBar + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.contentView = badge + } + + /// Instant IME updates from input-source-change notifications. The observer + /// fires on the main queue, so we can safely assume main-actor isolation. + private func startIMEObserver() { + let name = Notification.Name(kTISNotifySelectedKeyboardInputSourceChanged as String) + imeObserver = DistributedNotificationCenter.default().addObserver( + forName: name, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.updateState() + } + } + } + + /// A single 60fps timer caps how often we move the window, regardless of how + /// fast the mouse reports events. It runs on the main run loop. + private func startTimer() { + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.tick() + } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + private func tick() { + // Safety net for IMEs that do not post the change notification on every + // kana/eisu toggle: re-check roughly twice a second. + frame += 1 + if frame % 30 == 0 { + updateState() + } + + // Nothing to move while the badge is hidden (i.e. not Japanese input), + // so English typing with the mouse moving costs nothing here. + guard lastState == .japanese else { return } + + let location = NSEvent.mouseLocation + if location != lastMouse { + lastMouse = location + panel.setFrameOrigin(NSPoint(x: location.x + offset.x, y: location.y + offset.y)) + } + } + + /// Show the badge only while Japanese input is active; hide it otherwise. + private func updateState() { + let state = currentIMEState() + + guard state != lastState else { return } + lastState = state + + if state == .japanese { + lastMouse = NSPoint(x: -1, y: -1) // force a reposition on the next tick + let location = NSEvent.mouseLocation + panel.setFrameOrigin(NSPoint(x: location.x + offset.x, y: location.y + offset.y)) + panel.orderFrontRegardless() + } else { + panel.orderOut(nil) + } + } +} + +extension AppDelegate: NSMenuDelegate { + /// Refresh the checkbox in case the login item was changed elsewhere. + func menuNeedsUpdate(_: NSMenu) { + updateLaunchAtLoginItem() + } +} diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/Contents.json b/CursorIME/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..64dc11e --- /dev/null +++ b/CursorIME/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..c24c8da Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..74bf049 Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..75a0fc3 Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..5de109b Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..74bf049 Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..93f5ff2 Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..5de109b Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..92109b7 Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..93f5ff2 Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..bf4e49b Binary files /dev/null and b/CursorIME/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/CursorIME/Assets.xcassets/Contents.json b/CursorIME/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CursorIME/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CursorIME/Assets.xcassets/MenuBarIcon.imageset/Contents.json b/CursorIME/Assets.xcassets/MenuBarIcon.imageset/Contents.json new file mode 100644 index 0000000..60f66f1 --- /dev/null +++ b/CursorIME/Assets.xcassets/MenuBarIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "menubar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "menubar@2x.png", + "idiom" : "universal", + "scale" : "2x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/CursorIME/Assets.xcassets/MenuBarIcon.imageset/menubar.png b/CursorIME/Assets.xcassets/MenuBarIcon.imageset/menubar.png new file mode 100644 index 0000000..f42d05f Binary files /dev/null and b/CursorIME/Assets.xcassets/MenuBarIcon.imageset/menubar.png differ diff --git a/CursorIME/Assets.xcassets/MenuBarIcon.imageset/menubar@2x.png b/CursorIME/Assets.xcassets/MenuBarIcon.imageset/menubar@2x.png new file mode 100644 index 0000000..385ed09 Binary files /dev/null and b/CursorIME/Assets.xcassets/MenuBarIcon.imageset/menubar@2x.png differ diff --git a/CursorIME/BadgeView.swift b/CursorIME/BadgeView.swift new file mode 100644 index 0000000..994b80d --- /dev/null +++ b/CursorIME/BadgeView.swift @@ -0,0 +1,29 @@ +import AppKit + +/// A small fixed "あ" badge, shown only while Japanese input is active. +final class BadgeView: NSView { + override func draw(_: NSRect) { + let radius = bounds.height / 4 + let path = NSBezierPath(roundedRect: bounds, xRadius: radius, yRadius: radius) + NSColor.systemBlue.setFill() + path.fill() + + let style = NSMutableParagraphStyle() + style.alignment = .center + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: bounds.height * 0.55, weight: .bold), + .foregroundColor: NSColor.white, + .paragraphStyle: style, + ] + + let string = "あ" as NSString + let size = string.size(withAttributes: attrs) + let rect = NSRect( + x: 0, + y: (bounds.height - size.height) / 2, + width: bounds.width, + height: size.height + ) + string.draw(in: rect, withAttributes: attrs) + } +} diff --git a/CursorIME/CursorIME.entitlements b/CursorIME/CursorIME.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/CursorIME/CursorIME.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/CursorIME/CursorIMEApp.swift b/CursorIME/CursorIMEApp.swift new file mode 100644 index 0000000..c8296e3 --- /dev/null +++ b/CursorIME/CursorIMEApp.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@main +struct CursorIMEApp: App { + // NOTE: The overlay badge and the menu bar item are both driven from + // AppDelegate; the adaptor keeps it alive. No window is needed. + // swiftlint:disable unused_declaration + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + // swiftlint:enable unused_declaration + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/CursorIME/InputSource.swift b/CursorIME/InputSource.swift new file mode 100644 index 0000000..f92d6ee --- /dev/null +++ b/CursorIME/InputSource.swift @@ -0,0 +1,31 @@ +import Carbon + +/// Whether the keyboard is producing something other than standard half-width +/// alphanumeric. +/// +/// A single IME (ATOK, Kotoeri, ...) keeps one input source while switching +/// between direct Roman input and kana or other modes, so we read the input +/// *mode* rather than the source id. Only the shared Roman mode types plain +/// ASCII; every other mode does not. +enum IMEState { + case japanese + case roman +} + +func currentIMEState() -> IMEState { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { + return .roman + } + + func property(_ key: CFString) -> String? { + guard let ptr = TISGetInputSourceProperty(source, key) else { return nil } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String + } + + // Plain keyboard layouts (US, ...) expose no input mode and type standard + // alphanumeric. An input method exposes a mode; only its direct Roman mode + // is standard, so anything else (kana, katakana, full-width, other scripts) + // shows the badge. + guard let mode = property(kTISPropertyInputModeID) else { return .roman } + return mode == "com.apple.inputmethod.Roman" ? .roman : .japanese +} diff --git a/README.md b/README.md index c036410..b1219fe 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# CursorIME \ No newline at end of file +# CursorIME [![CI](https://github.com/winebarrel/CursorIME/actions/workflows/ci.yml/badge.svg)](https://github.com/winebarrel/CursorIME/actions/workflows/ci.yml) [![AI Generated](https://img.shields.io/badge/AI%20Generated-Claude-orange?logo=anthropic)](https://claude.ai/claude-code) + +CursorIME is a macOS utility that shows an "あ" badge next to the mouse +cursor while Japanese input is active. When you switch back to Roman input +the badge disappears, so a glance at the cursor tells you which mode you are +in. + +It reads the current input mode through the Text Input Sources API, so it +works with Japanese IMEs such as Kotoeri, ATOK, and Google Japanese Input +without extra setup. + +## Usage + +Launch the app. It runs as a menu bar item with no Dock icon. The badge +follows the cursor only while Japanese input is on. + +The menu bar icon has these items: + +- **Launch at Login** toggles starting CursorIME when you log in. +- **Hide Menu Bar Icon** removes the icon. Click the app in Finder or + Launchpad again to bring it back. +- **About** shows the standard about panel. +- **Quit** exits. + +## Hiding the built-in input indicator + +macOS shows its own input mode indicator near the text caret when you switch +input sources. To avoid a double indicator you can turn it off: + +```sh +defaults write kCFPreferencesAnyApplication TSMLanguageIndicatorEnabled -bool false +``` + +Log out and back in for it to take effect. To restore the default: + +```sh +defaults delete kCFPreferencesAnyApplication TSMLanguageIndicatorEnabled +``` + +CursorIME does not change this setting for you. The key lives in the global +preferences domain, which a sandboxed app cannot write, and the change only +applies after a re-login, so it is left as a one-time manual step. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..84b4b2d --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":disableDependencyDashboard" + ], + "vulnerabilityAlerts": { + "enabled": true + } +}