mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
316 lines
11 KiB
Swift
316 lines
11 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
enum PaletteMode: String, CaseIterable, Identifiable {
|
|
case launcher
|
|
case clipboard
|
|
case calculatorHistory
|
|
case emoji
|
|
|
|
var id: String { rawValue }
|
|
var title: String {
|
|
switch self {
|
|
case .launcher: return "Apps"
|
|
case .clipboard: return "Clipboard"
|
|
case .calculatorHistory: return "Calculator History"
|
|
case .emoji: return "Emoji & Symbols"
|
|
}
|
|
}
|
|
var systemImage: String {
|
|
switch self {
|
|
case .launcher: return "magnifyingglass"
|
|
case .clipboard: return "doc.on.doc"
|
|
case .calculatorHistory: return "plus.forwardslash.minus"
|
|
case .emoji: return "face.smiling"
|
|
}
|
|
}
|
|
var placeholder: String {
|
|
switch self {
|
|
case .launcher: return "Search for apps and commands…"
|
|
case .clipboard: return "Type to filter entries…"
|
|
case .calculatorHistory: return "Do math, convert units, or search your past calculations…"
|
|
case .emoji: return "Search emoji and symbols…"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// View-model shared between the panel's SwiftUI tree and the coordinator.
|
|
@MainActor
|
|
final class PaletteViewModel: ObservableObject {
|
|
@Published var mode: PaletteMode = .launcher
|
|
@Published var query: String = ""
|
|
@Published var selection: Int = 0
|
|
/// Changes every time the palette is shown so the search field can re-focus.
|
|
@Published var focusToken = UUID()
|
|
/// Changes only when `prepare` resets the palette, so the lists snap their scroll to the top even when query/mode were already at their defaults (`focusToken` can't serve: it bumps on every reopen, which must preserve a within-timeout scroll).
|
|
@Published var resetToken = UUID()
|
|
|
|
func prepare(mode: PaletteMode) {
|
|
self.mode = mode
|
|
query = ""
|
|
selection = 0
|
|
focusToken = UUID()
|
|
resetToken = UUID()
|
|
}
|
|
}
|
|
|
|
/// Single owner of every long-lived manager. Wired up once from the app delegate.
|
|
@MainActor
|
|
final class AppCore: ObservableObject {
|
|
static let shared = AppCore()
|
|
|
|
let appIndex = AppIndex()
|
|
let clipboardStore = ClipboardStore()
|
|
let clipboardManager: ClipboardManager
|
|
let hotKeys = HotKeyManager()
|
|
let hyperKeyTap = HyperKeyTap()
|
|
let settings = AppSettings()
|
|
let favorites = FavoritesStore()
|
|
let visibility = VisibilityStore()
|
|
let calcHistory = CalculatorHistoryStore()
|
|
let emojiIndex = EmojiIndex()
|
|
let frequentEmoji = FrequentEmojiStore()
|
|
let runningApps = RunningAppsMonitor()
|
|
let palette = PaletteViewModel()
|
|
|
|
private lazy var windowController = PaletteWindowController(core: self)
|
|
private let auxWindows = AuxWindowController()
|
|
|
|
private init() {
|
|
clipboardManager = ClipboardManager(store: clipboardStore, settings: settings)
|
|
}
|
|
|
|
func start() {
|
|
NSApp.setActivationPolicy(.accessory)
|
|
// Force dark: the Liquid Glass material is tuned for a deep dark surface and renders washed-out in Light mode.
|
|
NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
|
|
clipboardStore.maxAge = settings.clipboardRetention.maxAge
|
|
// Defer the initial SQLite read + stale-image prune off the synchronous launch path so the menu bar is interactive immediately; `items` is @Published, so the palette fills in when it lands.
|
|
Task { clipboardStore.load() }
|
|
clipboardManager.start()
|
|
|
|
Task { await appIndex.refresh() }
|
|
Task { await emojiIndex.load() }
|
|
|
|
hotKeys.onTogglePalette = { [weak self] in self?.togglePalette() }
|
|
hotKeys.onToggleClipboard = { [weak self] in self?.toggleClipboard() }
|
|
hotKeys.onToggleEmoji = { [weak self] in self?.toggleEmoji() }
|
|
hotKeys.start()
|
|
// Deliberately keeps running while `hotKeys.recordingAction` pauses Carbon: the recorder relies on the tap's rewritten flags to capture Hyper shortcuts.
|
|
hyperKeyTap.start(settings: settings)
|
|
|
|
// First launch has no palette hotkey bound and shows nothing but the menu-bar icon; guide the user once. Marker is written at show-time so it stays one-time even if they Cmd-Q mid-flow.
|
|
if !OnboardingState.hasOnboarded {
|
|
OnboardingState.markShown()
|
|
showOnboarding()
|
|
}
|
|
}
|
|
|
|
// MARK: - Palette control
|
|
|
|
func togglePalette() {
|
|
if windowController.isVisible, palette.mode == .launcher {
|
|
hidePalette()
|
|
} else {
|
|
showPalette(mode: .launcher, restoreAnyMode: true)
|
|
}
|
|
}
|
|
|
|
func toggleClipboard() {
|
|
if windowController.isVisible, palette.mode == .clipboard {
|
|
hidePalette()
|
|
} else {
|
|
showPalette(mode: .clipboard)
|
|
}
|
|
}
|
|
|
|
func toggleEmoji() {
|
|
if windowController.isVisible, palette.mode == .emoji {
|
|
hidePalette()
|
|
} else {
|
|
showPalette(mode: .emoji)
|
|
}
|
|
}
|
|
|
|
/// Shows the palette, honoring Pop to Root Search: a reopen within the timeout restores the pre-close state — any mode for the generic summon (`restoreAnyMode`), else only when the preserved mode already matches the requested one.
|
|
func showPalette(mode: PaletteMode, restoreAnyMode: Bool = false) {
|
|
let preserved = windowController.consumePreservedState()
|
|
if !(preserved && (restoreAnyMode || palette.mode == mode)) {
|
|
palette.prepare(mode: mode)
|
|
}
|
|
windowController.show()
|
|
}
|
|
|
|
func hidePalette(restoreFocus: Bool = true) {
|
|
windowController.hide(restoreFocus: restoreFocus)
|
|
}
|
|
|
|
/// Dock-icon / reopen: focus an open aux window (About/Settings/Onboarding), else summon the launcher. Decoupled from the individual show paths so activation always works.
|
|
func handleReopen() {
|
|
if auxWindows.focusExisting() { return }
|
|
showPalette(mode: .launcher, restoreAnyMode: true)
|
|
}
|
|
|
|
/// Settings runs in its own window (the SwiftUI `Settings` scene is unreliable for accessory apps). A fresh window mounts directly on `tab` (no first-frame flicker); an already-open one is switched in place.
|
|
func showSettings(tab: SettingsTab = .general) {
|
|
let isNew = auxWindows.show(
|
|
id: "settings", title: "Settings", size: CGSize(width: 720, height: 650),
|
|
seamlessTitleBar: true
|
|
) {
|
|
SettingsRootView(initialTab: tab)
|
|
.environmentObject(self.appIndex)
|
|
.environmentObject(self.visibility)
|
|
}
|
|
if !isNew {
|
|
NotificationCenter.default.post(name: .tinycastSelectSettingsTab, object: tab)
|
|
}
|
|
}
|
|
|
|
func showBackupSettings() {
|
|
showSettings(tab: .backup)
|
|
}
|
|
|
|
func showAbout() {
|
|
showSettings(tab: .about)
|
|
}
|
|
|
|
/// The first-run wizard: palette shortcut, Accessibility, Raycast import. Also re-runnable from Settings.
|
|
func showOnboarding() {
|
|
auxWindows.show(
|
|
id: "onboarding", title: "Welcome to Tinycast",
|
|
size: OnboardingView.windowSize, seamlessTitleBar: true
|
|
) {
|
|
OnboardingView()
|
|
}
|
|
}
|
|
|
|
/// Final onboarding step: close the wizard and drop straight into the launcher.
|
|
func finishOnboarding() {
|
|
auxWindows.close(id: "onboarding")
|
|
showPalette(mode: .launcher)
|
|
}
|
|
|
|
// MARK: - Actions invoked from the palette UI
|
|
|
|
func launch(_ app: AppEntry) {
|
|
// Commands dispatch before the palette hides: mode-switching commands keep it open.
|
|
if app.kind == .command {
|
|
runCommand(app)
|
|
return
|
|
}
|
|
hidePalette(restoreFocus: false)
|
|
switch app.kind {
|
|
case .application:
|
|
AppLauncher.launch(app.url)
|
|
case .systemSettings:
|
|
guard let bundleID = app.bundleID else { return }
|
|
AppLauncher.openSettingsPane(bundleID: bundleID)
|
|
case .command:
|
|
break // handled above
|
|
}
|
|
}
|
|
|
|
private func runCommand(_ entry: AppEntry) {
|
|
switch CommandRegistry.command(for: entry) {
|
|
case .calculatorHistory:
|
|
showPalette(mode: .calculatorHistory)
|
|
case .clipboardHistory:
|
|
showPalette(mode: .clipboard)
|
|
case .searchEmoji:
|
|
showPalette(mode: .emoji)
|
|
case .exportSettings:
|
|
hidePalette(restoreFocus: false)
|
|
BackupActions.exportSettings()
|
|
case .importSettings:
|
|
hidePalette(restoreFocus: false)
|
|
BackupActions.importSettings()
|
|
case .importFromRaycast:
|
|
hidePalette(restoreFocus: false)
|
|
showBackupSettings()
|
|
case .settings:
|
|
hidePalette(restoreFocus: false)
|
|
showSettings()
|
|
case .about:
|
|
hidePalette(restoreFocus: false)
|
|
showAbout()
|
|
case .quit:
|
|
NSApp.terminate(nil)
|
|
case nil:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Enter on the inline calculator card: copy the answer, remember the calculation, dismiss.
|
|
func copyCalculatorResult(_ result: CalcResult) {
|
|
guard case .value(let display, let copyText) = result.payload else { return }
|
|
calcHistory.record(expression: result.expression, result: display)
|
|
hidePalette(restoreFocus: false)
|
|
Paster.copyPlainText(copyText)
|
|
}
|
|
|
|
/// Enter on a Calculator History row: re-copy the stored answer (no re-record).
|
|
func copyHistoryEntry(_ entry: CalcHistoryEntry) {
|
|
hidePalette(restoreFocus: false)
|
|
Paster.copyPlainText(entry.result.replacingOccurrences(of: ",", with: ""))
|
|
}
|
|
|
|
func copyHistoryExpression(_ entry: CalcHistoryEntry) {
|
|
hidePalette(restoreFocus: false)
|
|
Paster.copyPlainText(entry.expression)
|
|
}
|
|
|
|
func showInFinder(_ app: AppEntry) {
|
|
hidePalette(restoreFocus: false)
|
|
AppLauncher.showInFinder(app.url)
|
|
}
|
|
|
|
func paste(_ item: ClipboardItem) {
|
|
let previous = windowController.previousApp
|
|
hidePalette(restoreFocus: false)
|
|
// A successful write promotes the item to index 0; follow it so any preserved (pop-to-root) or open clipboard state highlights the row that moved.
|
|
if Paster.paste(item, store: clipboardStore, previousApp: previous) {
|
|
palette.selection = 0
|
|
}
|
|
}
|
|
|
|
func pasteKeepingWindowOpen(_ item: ClipboardItem) {
|
|
if windowController.pasteKeepingWindowOpen(item, store: clipboardStore) {
|
|
palette.selection = 0
|
|
}
|
|
}
|
|
|
|
func copyToClipboard(_ item: ClipboardItem) {
|
|
hidePalette(restoreFocus: false)
|
|
if Paster.copy(item, store: clipboardStore) {
|
|
palette.selection = 0
|
|
}
|
|
}
|
|
|
|
func revealClipboardImage(_ item: ClipboardItem) {
|
|
guard let url = clipboardStore.imageURL(for: item) else { return }
|
|
hidePalette(restoreFocus: false)
|
|
AppLauncher.showInFinder(url)
|
|
}
|
|
|
|
// MARK: - Emoji actions (frequency is tallied on the base glyph; the configured tone is applied at copy time)
|
|
|
|
func pasteEmoji(_ entry: EmojiEntry) {
|
|
frequentEmoji.record(entry.glyph)
|
|
let previous = windowController.previousApp
|
|
hidePalette(restoreFocus: false)
|
|
Paster.pasteString(entry.display(tone: settings.emojiSkinTone), previousApp: previous)
|
|
}
|
|
|
|
func copyEmoji(_ entry: EmojiEntry) {
|
|
frequentEmoji.record(entry.glyph)
|
|
hidePalette(restoreFocus: false)
|
|
Paster.copyString(entry.display(tone: settings.emojiSkinTone))
|
|
}
|
|
|
|
func pasteEmojiKeepingWindowOpen(_ entry: EmojiEntry) {
|
|
frequentEmoji.record(entry.glyph)
|
|
windowController.pasteStringKeepingWindowOpen(entry.display(tone: settings.emojiSkinTone))
|
|
}
|
|
}
|