← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


ListlessMac/ListlessMacApp.swift (19288B)

1import AppKit
2import SwiftUI
3
4private enum MenuSelectors {
5    static let showSettingsWindow = Selector(("showSettingsWindow:"))
6    static let closeAll = Selector(("closeAll:"))
7    static let undo = Selector(("undo:"))
8    static let redo = Selector(("redo:"))
9}
10
11@MainActor
12class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {
13    private let persistenceController: PersistenceController
14    private var syncDiagnosticsWindow: NSWindow?
15    private let coordinators = NSMapTable<NSWindow, WindowCoordinator>.weakToStrongObjects()
16    private static let appearanceModeKey = "appearanceMode"
17    private static let colorThemeKey = "colorTheme"
18
19    private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["listName", "colorTheme"])
20
21    private var keyWindowCoordinator: WindowCoordinator? {
22        guard let window = NSApp.keyWindow else { return nil }
23        return coordinators.object(forKey: window)
24    }
25
26    func coordinator(for window: NSWindow) -> WindowCoordinator? {
27        coordinators.object(forKey: window)
28    }
29
30    override init() {
31        let args = ProcessInfo.processInfo.arguments
32        let isUITesting = args.contains("UI_TESTING")
33        persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
34        super.init()
35
36        if isUITesting, args.contains("SCREENSHOT_LIGHT") {
37            UserDefaults.standard.set(1, forKey: Self.appearanceModeKey)
38        } else if isUITesting, args.contains("SCREENSHOT_DARK") {
39            UserDefaults.standard.set(2, forKey: Self.appearanceModeKey)
40        }
41    }
42
43    func applicationDidFinishLaunching(_ notification: Notification) {
44        NSWindow.allowsAutomaticWindowTabbing = false
45        applyAppearanceMode(UserDefaults.standard.integer(forKey: Self.appearanceModeKey))
46        keyValueSyncBridge.start()
47        installMainMenu()
48        if ProcessInfo.processInfo.arguments.contains("UI_TESTING") {
49            promoteAndShowWindowIfNeeded()
50        }
51    }
52
53    func applicationDidBecomeActive(_ notification: Notification) {
54        promoteAndShowWindowIfNeeded()
55    }
56
57    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
58        if NSApp.activationPolicy() == .accessory {
59            return false
60        }
61        if !flag {
62            promoteAndShowWindowIfNeeded()
63        }
64        return true
65    }
66
67    private func promoteAndShowWindowIfNeeded() {
68        NSApp.setActivationPolicy(.regular)
69        if !NSApp.windows.contains(where: { $0.isVisible }) {
70            openNewWindow()
71        }
72    }
73
74    func applicationDidUnhide(_ notification: Notification) {
75        NSApp.keyWindow?.makeFirstResponder(nil)
76    }
77
78    // MARK: - NSMenuItemValidation
79    // AppKit calls this automatically for each item targeting self, both when the
80    // menu opens and when keyboard shortcuts are evaluated.
81
82    func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
83        switch menuItem.action {
84        case #selector(handleNewWindow), #selector(handleShowSyncDiagnostics):
85            return true
86        case #selector(handleAppearanceSystem), #selector(handleAppearanceLight), #selector(handleAppearanceDark):
87            let currentMode = UserDefaults.standard.integer(forKey: Self.appearanceModeKey)
88            let itemMode: Int = switch menuItem.action {
89            case #selector(handleAppearanceLight): 1
90            case #selector(handleAppearanceDark): 2
91            default: 0
92            }
93            menuItem.state = (currentMode == itemMode) ? .on : .off
94            return true
95        case #selector(handleThemeSelection(_:)):
96            let currentTheme = UserDefaults.standard.integer(forKey: Self.colorThemeKey)
97            menuItem.state = (currentTheme == menuItem.tag) ? .on : .off
98            return true
99        default:
100            break
101        }
102        guard let coord = keyWindowCoordinator else { return false }
103        switch menuItem.action {
104        case #selector(selectAll(_:)):         return coord.canSelectAllItems
105        case #selector(cut(_:)):              return coord.canCutSelectedItem
106        case #selector(copy(_:)):             return coord.canCopySelectedItem
107        case #selector(paste(_:)):            return coord.canPasteAfterSelectedItem
108        case #selector(handleDeleteItem):     return coord.canDeleteSelectedItem
109        case #selector(handleMoveUp):         return coord.canMoveSelectedItemUp
110        case #selector(handleMoveDown):       return coord.canMoveSelectedItemDown
111        case #selector(handleMarkCompleted):
112            menuItem.title = coord.markCompletedTitle
113            return coord.canMarkSelectedItemCompleted
114        case #selector(handleClearCompleted): return coord.canClearCompletedItems
115        default: return true
116        }
117    }
118
119    // MARK: - Actions
120
121    @objc private func handleNewItem() {
122        if NSApp.windows.filter({ $0.isVisible }).isEmpty {
123            openNewWindow()
124            Task { @MainActor in
125                keyWindowCoordinator?.newItem?()
126            }
127        } else {
128            keyWindowCoordinator?.newItem?()
129        }
130    }
131
132    @objc func selectAll(_ sender: Any?) {
133        keyWindowCoordinator?.selectAllItems?()
134    }
135
136    @objc func cut(_ sender: Any?) {
137        keyWindowCoordinator?.cutSelectedItem?()
138    }
139
140    @objc func copy(_ sender: Any?) {
141        keyWindowCoordinator?.copySelectedItem?()
142    }
143
144    @objc func paste(_ sender: Any?) {
145        keyWindowCoordinator?.pasteAfterSelectedItem?()
146    }
147
148    @objc private func handleDeleteItem() {
149        keyWindowCoordinator?.deleteSelectedItem?()
150    }
151
152    @objc private func handleNewWindow() {
153        openNewWindow()
154    }
155
156    @objc private func handleMoveUp() {
157        keyWindowCoordinator?.moveSelectedItemUp?()
158    }
159
160    @objc private func handleMoveDown() {
161        keyWindowCoordinator?.moveSelectedItemDown?()
162    }
163
164    @objc private func handleMarkCompleted() {
165        keyWindowCoordinator?.markSelectedItemCompleted?()
166    }
167
168    @objc private func handleClearCompleted() {
169        keyWindowCoordinator?.clearCompletedItems?()
170    }
171
172    @objc func handleShowSyncDiagnostics() {
173        openSyncDiagnosticsWindow()
174    }
175
176    @objc private func handleContactSupport() {
177        NSWorkspace.shared.open(URL(string: "mailto:[email protected]?subject=Listless%20Support")!)
178    }
179
180    @objc private func handleAppearanceSystem() { setAppearanceMode(0) }
181    @objc private func handleAppearanceLight() { setAppearanceMode(1) }
182    @objc private func handleAppearanceDark() { setAppearanceMode(2) }
183
184    @objc private func handleThemeSelection(_ sender: NSMenuItem) {
185        UserDefaults.standard.set(sender.tag, forKey: Self.colorThemeKey)
186    }
187
188    private func setAppearanceMode(_ mode: Int) {
189        UserDefaults.standard.set(mode, forKey: Self.appearanceModeKey)
190        applyAppearanceMode(mode)
191    }
192
193    private func applyAppearanceMode(_ mode: Int) {
194        NSApp.appearance = switch mode {
195        case 1: NSAppearance(named: .aqua)
196        case 2: NSAppearance(named: .darkAqua)
197        default: nil
198        }
199    }
200
201    private func openNewWindow() {
202        let defaultContentSize = NSSize(width: 400, height: 350)
203        let windowCoordinator = WindowCoordinator()
204        let rootView = ItemListView(
205            store: ItemStore(persistenceController: persistenceController),
206            syncMonitor: persistenceController.syncMonitor,
207            windowCoordinator: windowCoordinator
208        )
209        .environment(\.managedObjectContext, persistenceController.viewContext)
210
211        let window = NSWindow(
212            contentRect: NSRect(origin: .zero, size: defaultContentSize),
213            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
214            backing: .buffered,
215            defer: false
216        )
217        window.contentViewController = NSHostingController(rootView: rootView)
218        window.title = "Items"
219        window.setContentSize(defaultContentSize)
220        window.minSize = NSSize(width: 320, height: 240)
221        window.titleVisibility = .hidden
222        window.titlebarAppearsTransparent = false
223        window.isReleasedWhenClosed = false
224        window.isRestorable = false
225        coordinators.setObject(windowCoordinator, forKey: window)
226        let referenceWindow = NSApp.orderedWindows.first { existingWindow in
227            existingWindow.isVisible && existingWindow.title == "Items"
228        }
229        position(window, relativeTo: referenceWindow)
230        NSApp.activate(ignoringOtherApps: true)
231        window.makeKeyAndOrderFront(nil)
232        window.makeFirstResponder(nil)
233    }
234
235    private func position(_ window: NSWindow, relativeTo referenceWindow: NSWindow?) {
236        guard let referenceWindow else {
237            window.center()
238            return
239        }
240
241        let offset: CGFloat = 28
242        var origin = NSPoint(
243            x: referenceWindow.frame.origin.x + offset,
244            y: referenceWindow.frame.origin.y - offset
245        )
246
247        if let visibleFrame = referenceWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame {
248            origin.x = min(max(origin.x, visibleFrame.minX), visibleFrame.maxX - window.frame.width)
249            origin.y = min(max(origin.y, visibleFrame.minY), visibleFrame.maxY - window.frame.height)
250        }
251
252        window.setFrameOrigin(origin)
253    }
254
255    private func openSyncDiagnosticsWindow() {
256        if let window = syncDiagnosticsWindow {
257            window.makeKeyAndOrderFront(nil)
258            NSApp.activate()
259            return
260        }
261
262        let defaultContentSize = NSSize(width: 760, height: 520)
263        let rootView = SyncDiagnosticsView(syncMonitor: persistenceController.syncMonitor)
264        let window = NSWindow(
265            contentRect: NSRect(origin: .zero, size: defaultContentSize),
266            styleMask: [.titled, .closable, .miniaturizable, .resizable],
267            backing: .buffered,
268            defer: false
269        )
270        window.contentViewController = NSHostingController(rootView: rootView)
271        window.title = "iCloud Diagnostics"
272        window.setContentSize(defaultContentSize)
273        window.minSize = NSSize(width: 480, height: 320)
274        window.isReleasedWhenClosed = false
275        window.isRestorable = false
276        window.center()
277        window.makeKeyAndOrderFront(nil)
278        syncDiagnosticsWindow = window
279        NSApp.activate()
280    }
281
282    // MARK: - Main Menu
283
284    private func installMainMenu() {
285        let mainMenu = NSMenu()
286        let appName = ProcessInfo.processInfo.processName
287
288        let appMenu = NSMenu()
289        appMenu.addItem(withTitle: "About \(appName)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
290        appMenu.addItem(NSMenuItem.separator())
291
292        let settingsItem = NSMenuItem(
293            title: "Settings…",
294            action: MenuSelectors.showSettingsWindow,
295            keyEquivalent: ","
296        )
297        settingsItem.target = nil
298        appMenu.addItem(settingsItem)
299        appMenu.addItem(NSMenuItem.separator())
300
301        let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "")
302        let servicesMenu = NSMenu(title: "Services")
303        servicesItem.submenu = servicesMenu
304        appMenu.addItem(servicesItem)
305        NSApp.servicesMenu = servicesMenu
306
307        appMenu.addItem(NSMenuItem.separator())
308        appMenu.addItem(withTitle: "Hide \(appName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
309
310        let hideOthersItem = NSMenuItem(
311            title: "Hide Others",
312            action: #selector(NSApplication.hideOtherApplications(_:)),
313            keyEquivalent: "h"
314        )
315        hideOthersItem.keyEquivalentModifierMask = [.command, .option]
316        appMenu.addItem(hideOthersItem)
317        appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
318        appMenu.addItem(NSMenuItem.separator())
319        appMenu.addItem(withTitle: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
320
321        let appMenuItem = NSMenuItem()
322        appMenuItem.submenu = appMenu
323        mainMenu.addItem(appMenuItem)
324
325        let fileMenu = NSMenu(title: "File")
326        let newItemEntity = NSMenuItem(title: "New Item", action: #selector(handleNewItem), keyEquivalent: "n")
327        newItemEntity.keyEquivalentModifierMask = [.command]
328        newItemEntity.target = self
329        fileMenu.addItem(newItemEntity)
330        fileMenu.addItem(NSMenuItem.separator())
331
332        let newWindowItem = NSMenuItem(title: "New Window", action: #selector(handleNewWindow), keyEquivalent: "n")
333        newWindowItem.keyEquivalentModifierMask = [.command, .shift]
334        newWindowItem.target = self
335        fileMenu.addItem(newWindowItem)
336        fileMenu.addItem(NSMenuItem.separator())
337        fileMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
338
339        let closeAllItem = NSMenuItem(title: "Close All", action: MenuSelectors.closeAll, keyEquivalent: "w")
340        closeAllItem.keyEquivalentModifierMask = [.command, .option]
341        closeAllItem.target = nil
342        fileMenu.addItem(closeAllItem)
343
344        let fileMenuItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
345        fileMenuItem.submenu = fileMenu
346        mainMenu.addItem(fileMenuItem)
347
348        let editMenu = NSMenu(title: "Edit")
349        editMenu.addItem(withTitle: "Undo", action: MenuSelectors.undo, keyEquivalent: "z")
350        let redoItem = NSMenuItem(title: "Redo", action: MenuSelectors.redo, keyEquivalent: "z")
351        redoItem.keyEquivalentModifierMask = [.command, .shift]
352        editMenu.addItem(redoItem)
353        editMenu.addItem(NSMenuItem.separator())
354        editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
355        editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
356        editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
357        editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
358        let deleteItem = NSMenuItem(title: "Delete", action: #selector(handleDeleteItem), keyEquivalent: "\u{08}")
359        deleteItem.keyEquivalentModifierMask = []
360        deleteItem.target = self
361        editMenu.addItem(deleteItem)
362
363        editMenu.addItem(NSMenuItem.separator())
364
365        let moveUpItem = NSMenuItem(title: "Move Up", action: #selector(handleMoveUp), keyEquivalent: "\u{F700}")
366        moveUpItem.keyEquivalentModifierMask = [.command]
367        moveUpItem.target = self
368        editMenu.addItem(moveUpItem)
369
370        let moveDownItem = NSMenuItem(title: "Move Down", action: #selector(handleMoveDown), keyEquivalent: "\u{F701}")
371        moveDownItem.keyEquivalentModifierMask = [.command]
372        moveDownItem.target = self
373        editMenu.addItem(moveDownItem)
374
375        let markCompletedItem = NSMenuItem(title: "Mark as Complete", action: #selector(handleMarkCompleted), keyEquivalent: " ")
376        markCompletedItem.keyEquivalentModifierMask = []
377        markCompletedItem.target = self
378        editMenu.addItem(markCompletedItem)
379
380        editMenu.addItem(NSMenuItem.separator())
381
382        let clearCompletedItem = NSMenuItem(title: "Clear Completed", action: #selector(handleClearCompleted), keyEquivalent: "")
383        clearCompletedItem.target = self
384        editMenu.addItem(clearCompletedItem)
385
386        let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "")
387        editMenuItem.submenu = editMenu
388        mainMenu.addItem(editMenuItem)
389
390        let viewMenu = NSMenu(title: "View")
391
392        let appearanceMenu = NSMenu(title: "Appearance")
393        let systemItem = NSMenuItem(title: "System", action: #selector(handleAppearanceSystem), keyEquivalent: "")
394        systemItem.target = self
395        appearanceMenu.addItem(systemItem)
396        let lightItem = NSMenuItem(title: "Light", action: #selector(handleAppearanceLight), keyEquivalent: "")
397        lightItem.target = self
398        appearanceMenu.addItem(lightItem)
399        let darkItem = NSMenuItem(title: "Dark", action: #selector(handleAppearanceDark), keyEquivalent: "")
400        darkItem.target = self
401        appearanceMenu.addItem(darkItem)
402        let appearanceMenuItem = NSMenuItem(title: "Appearance", action: nil, keyEquivalent: "")
403        appearanceMenuItem.submenu = appearanceMenu
404        viewMenu.addItem(appearanceMenuItem)
405        viewMenu.addItem(NSMenuItem.separator())
406
407        let themeMenu = NSMenu(title: "Theme")
408        for theme in ColorTheme.displayOrder {
409            let item = NSMenuItem(title: theme.displayName, action: #selector(handleThemeSelection(_:)), keyEquivalent: "")
410            item.tag = theme.rawValue
411            item.target = self
412            themeMenu.addItem(item)
413        }
414        let themeMenuItem = NSMenuItem(title: "Theme", action: nil, keyEquivalent: "")
415        themeMenuItem.submenu = themeMenu
416        viewMenu.addItem(themeMenuItem)
417
418        viewMenu.addItem(NSMenuItem.separator())
419
420        let fullScreenItem = NSMenuItem(title: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
421        fullScreenItem.keyEquivalentModifierMask = [.command, .control]
422        viewMenu.addItem(fullScreenItem)
423        let viewMenuItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "")
424        viewMenuItem.submenu = viewMenu
425        mainMenu.addItem(viewMenuItem)
426
427        let windowMenu = NSMenu(title: "Window")
428        windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
429        windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
430        windowMenu.addItem(NSMenuItem.separator())
431        let syncDiagnosticsItem = NSMenuItem(
432            title: "iCloud Diagnostics",
433            action: #selector(handleShowSyncDiagnostics),
434            keyEquivalent: ""
435        )
436        syncDiagnosticsItem.target = self
437        windowMenu.addItem(syncDiagnosticsItem)
438        windowMenu.addItem(NSMenuItem.separator())
439        windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
440        let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "")
441        windowMenuItem.submenu = windowMenu
442        mainMenu.addItem(windowMenuItem)
443        NSApp.windowsMenu = windowMenu
444
445        let helpMenu = NSMenu(title: "Help")
446        helpMenu.addItem(withTitle: "Contact Support", action: #selector(handleContactSupport), keyEquivalent: "")
447        let helpMenuItem = NSMenuItem(title: "Help", action: nil, keyEquivalent: "")
448        helpMenuItem.submenu = helpMenu
449        mainMenu.addItem(helpMenuItem)
450        NSApp.helpMenu = helpMenu
451
452        NSApp.mainMenu = mainMenu
453    }
454}
455
456@main
457enum ListlessMacMain {
458    static func main() {
459        let app = NSApplication.shared
460        let delegate = AppDelegate()
461        app.setActivationPolicy(.accessory)
462        app.delegate = delegate
463        withExtendedLifetime(delegate) {
464            app.run()
465        }
466    }
467}