| | |
| | | // MARK: - Application Entry Point |
| | | |
| | | @main |
| | | struct ApptagApp: App { |
| | | struct TagLauncherApp: App { |
| | | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate |
| | | |
| | | var body: some Scene { |
| | |
| | | setupLaunchAtLogin() |
| | | } |
| | | |
| | | /// Dock icon click → show overlay (same as menubar "Show Apptag") |
| | | /// Dock icon click → show overlay (same as menubar "Show TagLauncher") |
| | | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { |
| | | showOverlay() |
| | | return false // Suppress default "unhide all windows" behavior |
| | |
| | | if let button = statusItem.button { |
| | | button.image = NSImage( |
| | | systemSymbolName: "tag.fill", |
| | | accessibilityDescription: "Apptag" |
| | | accessibilityDescription: "TagLauncher" |
| | | ) |
| | | button.toolTip = "Apptag — Tag-based app launcher" |
| | | button.toolTip = "TagLauncher — Tag-based app launcher" |
| | | button.action = #selector(toggleOverlay) |
| | | button.target = self |
| | | } |
| | |
| | | NSMouseInRect(mousePoint, $0.frame, false) |
| | | }) ?? NSScreen.main ?? NSScreen.screens.first else { return } |
| | | |
| | | if overlayWindow == nil { |
| | | let panel = OverlayPanel( |
| | | contentRect: screen.frame, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | | panel.isFloatingPanel = true |
| | | panel.hidesOnDeactivate = false |
| | | overlayWindow = panel |
| | | overlayWindow?.collectionBehavior = [ |
| | | .canJoinAllSpaces, |
| | | .fullScreenAuxiliary, |
| | | .stationary, |
| | | .transient, |
| | | .ignoresCycle |
| | | ] |
| | | overlayWindow?.isOpaque = false |
| | | overlayWindow?.backgroundColor = .clear |
| | | overlayWindow?.hasShadow = false |
| | | overlayWindow?.titlebarAppearsTransparent = true |
| | | overlayWindow?.titleVisibility = .hidden |
| | | overlayWindow?.isReleasedWhenClosed = false |
| | | |
| | | overlayWindow?.contentView = DismissibleHostingView( |
| | | rootView: ContentView(hideOverlay: { [weak self] in |
| | | self?.hideOverlay() |
| | | }), |
| | | onBackdropTap: { [weak self] in |
| | | self?.hideOverlay() |
| | | } |
| | | ) |
| | | } |
| | | overlayWindow?.orderOut(nil) |
| | | overlayWindow = makeOverlayWindow(on: screen) |
| | | overlayWindow?.setFrame(screen.frame, display: true) |
| | | overlayWindow?.level = .screenSaver |
| | | overlayWindow?.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) |
| | | |
| | | // Local key monitor: catch Escape while overlay is up |
| | | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in |
| | |
| | | |
| | | overlayWindow?.makeKeyAndOrderFront(nil) |
| | | overlayWindow?.orderFrontRegardless() |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | } |
| | | |
| | | private func hideOverlay() { |
| | | guard !isInEditMode else { return } |
| | | private func makeOverlayWindow(on screen: NSScreen) -> NSWindow { |
| | | let panel = OverlayPanel( |
| | | contentRect: screen.frame, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | | panel.isFloatingPanel = true |
| | | panel.hidesOnDeactivate = false |
| | | panel.collectionBehavior = [ |
| | | .moveToActiveSpace, |
| | | .fullScreenAuxiliary, |
| | | .stationary, |
| | | .transient, |
| | | .ignoresCycle |
| | | ] |
| | | panel.isOpaque = false |
| | | panel.backgroundColor = .clear |
| | | panel.hasShadow = false |
| | | panel.titlebarAppearsTransparent = true |
| | | panel.titleVisibility = .hidden |
| | | panel.isReleasedWhenClosed = false |
| | | panel.contentView = DismissibleHostingView( |
| | | rootView: ContentView(hideOverlay: { [weak self] in |
| | | self?.hideOverlay(force: true) |
| | | }), |
| | | onBackdropTap: { [weak self] in |
| | | self?.hideOverlay() |
| | | } |
| | | ) |
| | | return panel |
| | | } |
| | | |
| | | private func hideOverlay(force: Bool = false) { |
| | | guard force || !isInEditMode else { return } |
| | | overlayWindow?.orderOut(nil) |
| | | if force { |
| | | overlayWindow = nil |
| | | } |
| | | } |
| | | |
| | | // MARK: - Global Hotkey (Shift+Option+Space) |
| | |
| | | hotkeyRef = ref |
| | | |
| | | if status != noErr { |
| | | print("[Apptag] Hotkey registration failed: \(status). Falling back to menu bar only.") |
| | | print("[TagLauncher] Hotkey registration failed: \(status). Falling back to menu bar only.") |
| | | return |
| | | } |
| | | |
| | |
| | | |
| | | // Settings/Preferences window → float it above overlay for real-time preview |
| | | if NSApp.windows.contains(keyWindow) { |
| | | if self.overlayWindow?.isVisible == true { |
| | | keyWindow.level = .popUpMenu |
| | | } |
| | | if self.settingsWindow == nil { |
| | | keyWindow.minSize = NSSize(width: 660, height: 380) |
| | | keyWindow.maxSize = NSSize(width: 660, height: CGFloat.greatestFiniteMagnitude) |
| | | } |
| | | self.settingsWindow = keyWindow |
| | | self.prepareSettingsWindow(keyWindow) |
| | | return |
| | | } |
| | | |
| | | self.hideOverlay() |
| | | } |
| | | } |
| | | |
| | | /// Settings must always float above the overlay so changes can be previewed live. |
| | | private func prepareSettingsWindow(_ window: NSWindow) { |
| | | if overlayWindow?.isVisible == true { |
| | | let overlayLevel = overlayWindow?.level.rawValue ?? NSWindow.Level.screenSaver.rawValue |
| | | window.level = NSWindow.Level(rawValue: overlayLevel + 1) |
| | | window.orderFrontRegardless() |
| | | } |
| | | if settingsWindow == nil { |
| | | window.minSize = NSSize(width: 660, height: 380) |
| | | window.maxSize = NSSize(width: 660, height: CGFloat.greatestFiniteMagnitude) |
| | | } |
| | | settingsWindow = window |
| | | } |
| | | |
| | | /// Clean up settingsWindow reference when the Settings window closes. |
| | |
| | | /// Track whether the overlay is in edit mode to suppress auto-dismiss. |
| | | private func observeEditMode() { |
| | | NotificationCenter.default.addObserver( |
| | | forName: .apptagEditModeChanged, |
| | | forName: .tagLauncherEditModeChanged, |
| | | object: nil, |
| | | queue: .main |
| | | ) { [weak self] notification in |
| | |
| | | |
| | | @objc private func openPreferences() { |
| | | // Don't hide overlay — keep it visible for real-time setting preview. |
| | | // observeOtherWindows handles raising the Settings window above the overlay. |
| | | NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) |
| | | DispatchQueue.main.async { [weak self] in |
| | | guard let self else { return } |
| | | for window in NSApp.windows where window != self.overlayWindow { |
| | | self.prepareSettingsWindow(window) |
| | | } |
| | | } |
| | | } |
| | | |
| | | @objc private func switchLanguage(_ sender: NSMenuItem) { |
| | |
| | | private func exportTags() { |
| | | let panel = NSSavePanel() |
| | | panel.title = tr("settings.export") |
| | | panel.nameFieldStringValue = "Apptag-tags.json" |
| | | panel.nameFieldStringValue = "TagLauncher-tags.json" |
| | | panel.allowedContentTypes = [.json] |
| | | panel.begin { response in |
| | | guard response == .OK, let url = panel.url else { return } |
| | | do { |
| | | try TagDatabase.exportTo(url) |
| | | } catch { |
| | | fputs("[Apptag] Export failed: \(error)\n", stderr) |
| | | fputs("[TagLauncher] Export failed: \(error)\n", stderr) |
| | | } |
| | | } |
| | | } |
| | |
| | | _ = try TagDatabase.importFrom(url) |
| | | scanApps() |
| | | } catch { |
| | | fputs("[Apptag] Import failed: \(error)\n", stderr) |
| | | fputs("[TagLauncher] Import failed: \(error)\n", stderr) |
| | | // Show alert on failure |
| | | let alert = NSAlert() |
| | | alert.messageText = tr("settings.importFailed") |
| | |
| | | Picker("", selection: $displayMode) { |
| | | Text(tr("settings.flat")).tag("flat") |
| | | Text(tr("settings.container")).tag("container") |
| | | Text(tr("settings.coloredContainer")).tag("coloredContainer") |
| | | Text(tr("settings.gridContainer")).tag("gridContainer") |
| | | Text(tr("settings.coloredGridContainer")).tag("coloredGridContainer") |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: 280, alignment: .leading) |
| | | .frame(width: 520, alignment: .leading) |
| | | Text(tr("settings.flatDesc")) |
| | | .font(.caption).foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | |
| | | .frame(width: 128, height: 128) |
| | | } |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text("Apptag") |
| | | Text("TagLauncher") |
| | | .font(.title2) |
| | | .fontWeight(.semibold) |
| | | Text(tr("app.description")) |