Files
ihatepdfs/sources/app/IHatePDFsApp.swift

519 lines
15 KiB
Swift
Raw Permalink Normal View History

import AppKit
import IHatePDFsCore
import SwiftUI
@main
struct IHatePDFsApp: App {
2026-06-24 17:51:26 -07:00
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
2026-06-18 16:44:19 -07:00
AppWindowRoot()
}
.windowStyle(.titleBar)
.commands {
2026-06-18 16:44:19 -07:00
AppCommands()
}
2026-06-24 17:51:26 -07:00
Settings {
SettingsView()
}
}
}
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
2026-06-24 17:51:26 -07:00
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
AppStateRegistry.shared.confirmApplicationShouldTerminate()
? .terminateNow
: .terminateCancel
}
}
@MainActor
private final class AppStateRegistry {
static let shared = AppStateRegistry()
private var appStates: [WeakAppState] = []
private(set) var isTerminationApproved = false
func register(_ appState: AppState) {
prune()
guard !appStates.contains(where: { $0.value === appState }) else {
return
}
appStates.append(WeakAppState(appState))
}
func unregister(_ appState: AppState) {
appStates.removeAll { $0.value == nil || $0.value === appState }
}
func confirmApplicationShouldTerminate() -> Bool {
prune()
for appState in appStates.compactMap(\.value) {
guard appState.confirmApplicationQuit() else {
cancelTerminationApproval()
return false
}
}
isTerminationApproved = true
return true
}
func cancelTerminationApproval() {
isTerminationApproved = false
}
func closeOtherEmptyWindows(keeping activeAppState: AppState) {
prune()
for appState in appStates.compactMap(\.value) where appState !== activeAppState {
guard appState.document == nil,
!appState.hasUnsavedWork,
let window = appState.hostingWindow
else {
continue
}
window.close()
}
}
2026-06-30 10:36:12 -07:00
func appStateForActiveWindow() -> AppState? {
prune()
let candidateWindows = [NSApp.keyWindow, NSApp.mainWindow].compactMap { $0 }
for window in candidateWindows {
if let appState = appStates.compactMap(\.value).first(where: { $0.hostingWindow === window }) {
return appState
}
}
return nil
}
2026-06-24 17:51:26 -07:00
private func prune() {
appStates.removeAll { $0.value == nil }
}
}
private final class WeakAppState {
weak var value: AppState?
init(_ value: AppState) {
self.value = value
2026-06-18 16:44:19 -07:00
}
}
private struct AppWindowRoot: View {
@StateObject private var appState = AppState()
var body: some View {
MainView()
.environmentObject(appState)
.focusedObject(appState)
2026-06-24 17:51:26 -07:00
.background(WindowCloseGuard(appState: appState))
2026-06-18 16:44:19 -07:00
.onOpenURL { url in
appState.loadDocument(from: url)
if appState.documentURL == url {
AppStateRegistry.shared.closeOtherEmptyWindows(keeping: appState)
}
2026-06-18 16:44:19 -07:00
}
2026-06-24 17:51:26 -07:00
.onAppear {
AppStateRegistry.shared.register(appState)
}
.onDisappear {
AppStateRegistry.shared.unregister(appState)
}
}
}
private struct WindowCloseGuard: NSViewRepresentable {
@ObservedObject var appState: AppState
func makeCoordinator() -> Coordinator {
Coordinator(appState: appState)
}
func makeNSView(context: Context) -> WindowCloseGuardView {
let view = WindowCloseGuardView()
view.onWindowChange = { [weak coordinator = context.coordinator] window in
coordinator?.attach(to: window)
}
return view
}
func updateNSView(_ view: WindowCloseGuardView, context: Context) {
context.coordinator.appState = appState
context.coordinator.updateDocumentState()
view.onWindowChange = { [weak coordinator = context.coordinator] window in
coordinator?.attach(to: window)
}
view.reportWindow()
}
@MainActor
final class Coordinator: NSObject, NSWindowDelegate {
weak var appState: AppState?
private weak var window: NSWindow?
private weak var previousDelegate: NSWindowDelegate?
init(appState: AppState) {
self.appState = appState
}
func attach(to window: NSWindow?) {
guard self.window !== window else { return }
if let oldWindow = self.window, oldWindow.delegate === self {
oldWindow.delegate = previousDelegate
}
self.window = window
previousDelegate = window?.delegate
appState?.hostingWindow = window
2026-06-24 17:51:26 -07:00
if window?.delegate !== self {
window?.delegate = self
}
updateDocumentState()
}
func updateDocumentState() {
guard let window else { return }
let representedURL = appState?.documentURL
if window.representedURL != representedURL {
window.representedURL = representedURL
}
let isDocumentEdited = appState?.hasUnsavedWork == true
if window.isDocumentEdited != isDocumentEdited {
window.isDocumentEdited = isDocumentEdited
}
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
if previousDelegate?.windowShouldClose?(sender) == false {
AppStateRegistry.shared.cancelTerminationApproval()
return false
}
if AppStateRegistry.shared.isTerminationApproved {
return true
}
return appState?.confirmDocumentWindowClose() ?? true
}
func windowWillClose(_ notification: Notification) {
previousDelegate?.windowWillClose?(notification)
if window?.delegate === self {
window?.delegate = previousDelegate
}
appState?.hostingWindow = nil
2026-06-24 17:51:26 -07:00
window = nil
previousDelegate = nil
}
deinit {
MainActor.assumeIsolated {
if window?.delegate === self {
window?.delegate = previousDelegate
}
}
}
}
}
private final class WindowCloseGuardView: NSView {
var onWindowChange: ((NSWindow?) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
reportWindow()
}
func reportWindow() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onWindowChange?(window)
}
2026-06-18 16:44:19 -07:00
}
}
private struct AppCommands: Commands {
2026-06-30 10:36:12 -07:00
@FocusedObject private var focusedAppState: AppState?
private var appState: AppState? {
focusedAppState ?? AppStateRegistry.shared.appStateForActiveWindow()
}
2026-06-18 16:44:19 -07:00
private var hasDocument: Bool {
appState?.document != nil
}
2026-06-24 17:51:26 -07:00
private var hasTextSelection: Bool {
appState?.hasTextSelection == true
}
private var isHighlighterModeActive: Bool {
appState?.isHighlighterModeActive == true
}
2026-06-24 17:51:26 -07:00
private var canSaveDocument: Bool {
appState?.canSaveDocument == true
}
2026-06-30 10:36:12 -07:00
private var canDeleteSelectedAnnotation: Bool {
appState?.canDeleteSelectedAnnotation == true
}
private var recentDocumentURLs: [URL] {
appState?.recentDocumentURLs ?? []
}
2026-06-24 17:51:26 -07:00
private var saveHelpText: String {
appState?.saveHelpText ?? "Open a PDF before saving."
}
2026-06-18 16:44:19 -07:00
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("Open...") {
appState?.openDocument()
}
.keyboardShortcut("o")
.disabled(appState == nil)
Menu("Open Recent") {
if recentDocumentURLs.isEmpty {
Button("No Recent PDFs") {}
.disabled(true)
} else {
ForEach(recentDocumentURLs, id: \.self) { url in
Button(url.lastPathComponent) {
appState?.openRecentDocument(url)
}
.help(url.path)
}
Divider()
Button("Clear Menu") {
appState?.clearRecentDocuments()
}
}
}
.disabled(appState == nil)
2026-06-18 16:44:19 -07:00
Button("Save") {
appState?.saveDocument()
}
.keyboardShortcut("s")
2026-06-24 17:51:26 -07:00
.disabled(!canSaveDocument)
.help(saveHelpText)
2026-06-18 16:44:19 -07:00
Button("Save As...") {
appState?.saveDocumentAs()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(!hasDocument)
Button("Share...") {
appState?.shareDocument()
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(!hasDocument)
Divider()
2026-06-24 17:51:26 -07:00
Button("Settings...") {
openSettingsWindow()
}
.keyboardShortcut(",")
2026-06-24 17:51:26 -07:00
Divider()
Button(hasDocument ? "Close PDF" : "Close Window") {
if hasDocument {
appState?.closeDocument()
} else {
NSApp.keyWindow?.performClose(nil)
}
2026-06-18 16:44:19 -07:00
}
.keyboardShortcut("w")
.disabled(appState == nil)
2026-06-18 16:44:19 -07:00
}
CommandGroup(after: .textEditing) {
Button("Find in PDF") {
appState?.showSearch()
}
.keyboardShortcut("f")
.disabled(!hasDocument)
Button("Find Next") {
appState?.nextSearchResult()
}
.keyboardShortcut("g")
.disabled(appState?.searchResults.isEmpty != false)
Button("Find Previous") {
appState?.previousSearchResult()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
.disabled(appState?.searchResults.isEmpty != false)
}
CommandGroup(after: .toolbar) {
2026-06-18 16:44:19 -07:00
Button("Toggle Page Sidebar") {
appState?.togglePageSidebar()
2026-06-18 16:44:19 -07:00
}
.keyboardShortcut("0", modifiers: [.command, .option])
.disabled(!hasDocument)
Button("Toggle Annotation List") {
appState?.toggleAnnotationSidebar()
}
.keyboardShortcut("2", modifiers: [.command, .option])
.disabled(!hasDocument)
Button("Toggle Right Sidebar") {
appState?.toggleRightSidebarVisibility()
2026-06-18 16:44:19 -07:00
}
.keyboardShortcut("1", modifiers: [.command, .option])
.disabled(!hasDocument)
Divider()
Button("Zoom In") {
appState?.zoomIn()
}
.keyboardShortcut("+")
.disabled(!hasDocument)
Button("Zoom Out") {
appState?.zoomOut()
}
.keyboardShortcut("-")
.disabled(!hasDocument)
Button("Fit to Width") {
appState?.fitWidth()
}
.keyboardShortcut("9", modifiers: [.command])
.disabled(!hasDocument)
Button("Fit to Page") {
appState?.fitPage()
}
.keyboardShortcut("8", modifiers: [.command])
.disabled(!hasDocument)
Button("Two Pages Continuous") {
appState?.twoPageContinuous()
}
.keyboardShortcut("7", modifiers: [.command])
.disabled(!hasDocument)
}
CommandMenu("Annotate") {
2026-06-30 10:36:12 -07:00
Button("Undo Annotation Change") {
appState?.undoAnnotationChange()
}
.disabled(appState?.canUndoAnnotationChange != true)
Button("Redo Annotation Change") {
appState?.redoAnnotationChange()
}
.disabled(appState?.canRedoAnnotationChange != true)
Divider()
Button("Cancel Annotation Mode") {
appState?.cancelActiveMode()
}
.keyboardShortcut(.cancelAction)
.disabled(appState?.canCancelActiveMode != true)
Divider()
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
appState?.toggleHighlighterMode()
2026-06-18 16:44:19 -07:00
}
.keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(!hasDocument)
2026-06-18 16:44:19 -07:00
Button("Underline Selection") {
appState?.addUnderline()
}
.keyboardShortcut("u", modifiers: [.command, .shift])
2026-06-24 17:51:26 -07:00
.disabled(!hasDocument || !hasTextSelection)
2026-06-18 16:44:19 -07:00
Button("Comment on Selection") {
appState?.addComment()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
2026-06-24 17:51:26 -07:00
.disabled(!hasDocument || !hasTextSelection)
2026-06-18 16:44:19 -07:00
Button("Add Free Text") {
appState?.addFreeText()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
.disabled(!hasDocument)
2026-06-30 10:36:12 -07:00
Divider()
Button("Delete Selected Annotation") {
appState?.deleteSelectedAnnotation()
}
.disabled(!canDeleteSelectedAnnotation)
2026-06-18 16:44:19 -07:00
}
CommandMenu("Bookmark") {
Button(appState?.bookmarkActionTitle ?? "Add Bookmark") {
appState?.toggleBookmarkForCurrentPage()
}
.keyboardShortcut("b", modifiers: [.command])
.disabled(!hasDocument)
Button("Go to Bookmark") {
appState?.goToSavedBookmark()
}
.keyboardShortcut("b", modifiers: [.command, .option])
.disabled(appState?.savedBookmark == nil)
}
2026-06-18 16:44:19 -07:00
CommandGroup(after: .windowArrangement) {
Button("Minimize") {
appState?.minimizeWindow()
}
.keyboardShortcut("m", modifiers: [.command])
.disabled(appState == nil)
Button("Toggle Full Screen") {
appState?.toggleFullScreen()
}
2026-06-18 16:44:19 -07:00
.keyboardShortcut("f", modifiers: [.command, .control])
.disabled(appState == nil)
}
}
2026-06-24 17:51:26 -07:00
private func openSettingsWindow() {
if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
}
}