diff --git a/README.md b/README.md index 92c1805..34d7f74 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ Native macOS PDF reader built with SwiftUI + PDFKit as a Swift Package - **Basic viewing** — open PDFs, continuous / single / two-up layouts, zoom in/out + auto-fit, page navigation, thumbnail strip. +- **Reading progress** — the status bar shows the current page plus the + percentage of the document read, updated as you scroll. +- **Customizable page highlight** — the current page in the Pages sidebar + is highlighted in blue by default; pick any color from the swatch in the + thumbnail toolbar (persisted). - **Search + outline** — full-text search with match list + highlight, table-of-contents navigation, per-file bookmarks (persisted). - **Annotations** — highlight, underline, strikethrough, sticky notes with @@ -19,9 +24,17 @@ Native macOS PDF reader built with SwiftUI + PDFKit as a Swift Package Highlights apply automatically once a text selection settles, and the color advances to the next rotation color after every highlight. **Undo / redo** for annotation changes (⌘Z / ⇧⌘Z). - Annotation list inspector with jump-to + delete. + Annotation list inspector with jump-to, **recolor existing highlights**, + and delete. +- **Library** — a full-page library (⌘L) groups opened books into + **Currently reading**, **Want to read** and **Finished**. Every book shows + a first-page cover and title plus how much you've read; switch between + list and grid views. Move books between shelves with the row menu, + context menu, or drag and drop. Shelves, layout, and progress persist. - **Saving** — **Save** (⌘S) writes annotations back into the opened PDF; **Save Copy** exports an annotated duplicate. +- **Printing** — **File → Print…** (⌘P) prints through the system print + panel, so you can pick page ranges, scale, and include annotations. - **Recents** — the Open menu keeps an Open Recent list (with Clear Menu); the welcome screen shows your recent files with relative dates. Missing files are reported and dropped on open. @@ -106,6 +119,12 @@ scripts/make-app.sh [debug|release] open .build/app/PDFReader.app ``` +> **Important:** `swift build` only updates `.build/debug/PDFReader`; it +> does **not** refresh an existing `.build/app/PDFReader.app`. After code +> changes, rerun `scripts/make-app.sh` and relaunch the app — otherwise +> you'll keep running the old build (and won't see new menu items, +> toolbar buttons, etc.). + ## Verification ```sh @@ -128,10 +147,12 @@ Sources/PDFReader/ PDFReaderState.swift # document state, search, outline, annotations HighlightColor.swift # switchable markup palette + tools BookmarkStore.swift # persisted per-file bookmarks + LibraryStore.swift # persisted reading shelves + progress Views/ ContentView.swift # split view, toolbar, status bar PDFKitView.swift # PDFView + PDFThumbnailView bridges SidebarView.swift # pages / contents / search / bookmarks / notes + LibraryView.swift # library page (shelves + progress) AboutView.swift # App menu → About SettingsView.swift # App menu → Settings… (⌘,) Services/UpdateChecker.swift # Help → Check for Updates… diff --git a/Sources/PDFReader/PDFReaderApp.swift b/Sources/PDFReader/PDFReaderApp.swift index 7f202c6..2e3a708 100644 --- a/Sources/PDFReader/PDFReaderApp.swift +++ b/Sources/PDFReader/PDFReaderApp.swift @@ -10,6 +10,12 @@ enum SettingsWindow { static var open: (() -> Void)? } +/// Print handler captured from the view so the File menu command (which +/// can't read the environment) can print the active document. +enum PrintAction { + static var run: (() -> Void)? +} + @main struct PDFReaderApp: App { var body: some Scene { @@ -27,6 +33,10 @@ struct PDFReaderApp: App { Button("Settings…") { SettingsWindow.open?() } .keyboardShortcut(",", modifiers: .command) } + CommandGroup(replacing: .printItem) { + Button("Print…") { PrintAction.run?() } + .keyboardShortcut("p", modifiers: .command) + } } Window("About PDFReader", id: "about") { diff --git a/Sources/PDFReader/Views/ContentView.swift b/Sources/PDFReader/Views/ContentView.swift index 98d102d..ac12d85 100644 --- a/Sources/PDFReader/Views/ContentView.swift +++ b/Sources/PDFReader/Views/ContentView.swift @@ -7,9 +7,11 @@ public struct ContentView: View { @StateObject private var state = PDFReaderState() @StateObject private var bookmarks = BookmarkStore() @StateObject private var recents = RecentFilesStore() + @StateObject private var library = LibraryStore() @State private var sidebarTab: SidebarTab = .outline @Environment(\.openWindow) private var openWindow @State private var showingSidebar = true + @State private var showingLibrary = false @State private var sidebarWidth: CGFloat = 280 @State private var hoveringDivider = false @State private var dividerDragAnchor: CGFloat? @@ -20,25 +22,48 @@ public struct ContentView: View { public var body: some View { HStack(spacing: 0) { - if showingSidebar { + if showingSidebar && !showingLibrary { SidebarView(state: state, bookmarks: bookmarks, tab: $sidebarTab) .frame(width: sidebarWidth) sidebarDivider } VStack(spacing: 0) { // Tab bar — shows open PDF tabs like Adobe Reader - if !state.tabURLs.isEmpty { + if !showingLibrary, !state.tabURLs.isEmpty { TabBar(state: state) .frame(height: 32) } - if state.document == nil { + if showingLibrary { + LibraryView( + state: state, + library: library, + canReturnToReading: state.document != nil, + onOpen: { url in open(url: url) }, + onOpenDocument: openDocument, + onReturnToReading: { showingLibrary = false } + ) + } else if state.document == nil { welcomeView } else { - PDFKitView(state: state) - .colorInvertIfActive(state.usesDarkPages) - .background(state.usesDarkPages ? Color.black : state.appTheme.windowBackground) + GeometryReader { geo in + PDFKitView(state: state) + .colorInvertIfActive(state.usesDarkPages) + .background(state.usesDarkPages ? Color.black : state.appTheme.windowBackground) + .popover( + item: Binding( + get: { state.annotationColorTarget }, + set: { state.annotationColorTarget = $0 } + ), + attachmentAnchor: .point(annotationColorAnchor(in: geo.size)), + arrowEdge: .bottom + ) { target in + AnnotationColorPopover(state: state, target: target) + } + } + } + if !showingLibrary { + statusBar } - statusBar } .frame(maxWidth: .infinity, maxHeight: .infinity) .toolbar { @@ -72,6 +97,15 @@ public struct ContentView: View { Label("Sidebar", systemImage: "sidebar.leading") } .help("Toggle sidebar") + .disabled(showingLibrary) + + Button { + showingLibrary.toggle() + } label: { + Label("Library", systemImage: "books.vertical") + } + .help("Library (⌘L)") + .keyboardShortcut("l", modifiers: .command) } ToolbarItemGroup(placement: .primaryAction) { @@ -150,6 +184,14 @@ public struct ContentView: View { } .help("Presentation / full screen") + Button { + state.printDocument() + } label: { + Label("Print", systemImage: "printer") + } + .help("Print pages (⌘P)") + .disabled(state.document == nil) + Button { saveInPlace() } label: { @@ -178,14 +220,25 @@ public struct ContentView: View { .onChange(of: state.highlightColor) { _, _ in state.applyToCurrentSelectionIfPresent() } + .onChange(of: state.fileURL) { _, _ in + syncLibrary() + } + .onChange(of: state.currentPageIndex) { _, _ in + syncLibrary() + } + .onChange(of: state.pageCount) { _, _ in + syncLibrary() + } .preferredColorScheme(state.appTheme.preferredColorScheme) .tint(state.appTheme.accent) .frame(minWidth: 900, minHeight: 600) .onAppear { AboutWindow.open = { openWindow(id: "about") } SettingsWindow.open = { openWindow(id: "settings") } + PrintAction.run = { state.printDocument() } // Bring back the tabs and pages that were open at last quit. state.restoreSession() + syncLibrary() // Handy for `swift run` smoke tests: `PDFREADER_SAMPLE=/path/to.pdf swift run` if let sample = ProcessInfo.processInfo.environment["PDFREADER_SAMPLE"] { let url = URL(fileURLWithPath: sample) @@ -199,6 +252,18 @@ public struct ContentView: View { // MARK: - Subviews + /// Converts the clicked annotation's PDFView-space anchor (bottom-left + /// origin) into a unit point for the popover attachment (top-left origin). + private func annotationColorAnchor(in size: CGSize) -> UnitPoint { + guard let target = state.annotationColorTarget, + size.width > 0, size.height > 0 + else { return .center } + return UnitPoint( + x: min(max(target.anchor.x / size.width, 0), 1), + y: min(max(1 - target.anchor.y / size.height, 0), 1) + ) + } + /// Explicit draggable divider between sidebar and detail: a neutral /// gray strip (11pt hit zone) with resize cursor, drag-to-resize /// (200–500pt), and double-click to reset. Cursor comes from the @@ -297,6 +362,10 @@ public struct ContentView: View { Text("Page \(state.currentPageIndex + 1) of \(state.pageCount)") .font(.caption) .foregroundStyle(.secondary) + Text("\(state.readingPercentage)% read") + .font(.caption) + .foregroundStyle(.secondary) + .help("Percentage of the document read") Text("\(Int(state.scaleFactor * 100))%") .font(.caption) .foregroundStyle(.secondary) @@ -610,11 +679,20 @@ public struct ContentView: View { private func open(url: URL) -> Bool { let ok = state.open(url: url) if ok { + showingLibrary = false recents.record(url) + syncLibrary() } return ok } + /// Mirrors the active document's reading position into the Library so + /// shelf rows show an up-to-date percentage. + private func syncLibrary() { + guard state.document != nil, let url = state.fileURL else { return } + library.record(url: url, progress: state.readingProgress) + } + private func openRecent(_ item: RecentFile) { guard FileManager.default.fileExists(atPath: item.url.path) else { state.statusMessage = "\(item.name) is no longer available." @@ -1078,3 +1156,52 @@ private struct TabFrameKey: PreferenceKey { value.merge(nextValue()) { _, new in new } } } + +/// Click-to-recolor popover shown when a markup annotation is clicked in +/// the viewer. Presets mirror the Notes inspector; custom colors route +/// through the same `setAnnotationColor` undo path. +private struct AnnotationColorPopover: View { + @ObservedObject var state: PDFReaderState + let target: AnnotationColorTarget + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Highlight color") + .font(.headline) + LazyVGrid( + columns: Array(repeating: GridItem(.fixed(24), spacing: 8), count: 5), + spacing: 8 + ) { + ForEach(HighlightColor.presets) { color in + Circle() + .fill(color.swiftUIColor) + .frame(width: 24, height: 24) + .overlay( + Circle().strokeBorder( + state.annotationColorMatches(target.annotation, color) + ? Color.accentColor + : Color.secondary.opacity(0.35), + lineWidth: state.annotationColorMatches(target.annotation, color) ? 2 : 0.5 + ) + ) + .contentShape(Circle()) + .onTapGesture { + state.setAnnotationColor(color, for: target.annotation) + } + .help(color.displayName) + } + } + Divider() + ColorPicker( + "Custom…", + selection: Binding( + get: { Color(nsColor: target.annotation.color) }, + set: { state.setAnnotationColor(NSColor($0), for: target.annotation) } + ), + supportsOpacity: false + ) + } + .padding() + .frame(minWidth: 210) + } +} diff --git a/Sources/PDFReader/Views/LibraryView.swift b/Sources/PDFReader/Views/LibraryView.swift new file mode 100644 index 0000000..fc7f941 --- /dev/null +++ b/Sources/PDFReader/Views/LibraryView.swift @@ -0,0 +1,413 @@ +import AppKit +import Foundation +import PDFKit +import PDFReaderKit +import SwiftUI + +/// How the Library shelves are laid out. +enum LibraryLayout: String, CaseIterable, Identifiable { + case list + case grid + + var id: String { rawValue } +} + +/// Lazily renders first-page cover thumbnails and keeps them in memory so +/// switching between list and grid (or leaving and re-entering the +/// library) doesn't reload every PDF. +@MainActor +final class LibraryCoverCache: ObservableObject { + static let shared = LibraryCoverCache() + + @Published private(set) var images: [String: NSImage] = [:] + private var loading: Set = [] + + func image(for url: URL) -> NSImage? { + images[url.path] + } + + func load(url: URL) { + let key = url.path + guard images[key] == nil, !loading.contains(key) else { return } + loading.insert(key) + Task { + let image = await Task.detached(priority: .utility) { + Self.renderCover(url: url) + }.value + loading.remove(key) + if let image { + images[key] = image + } + } + } + + nonisolated private static func renderCover(url: URL) -> NSImage? { + guard let doc = PDFDocument(url: url), let page = doc.page(at: 0) else { return nil } + let bounds = page.bounds(for: .mediaBox) + guard bounds.width > 0, bounds.height > 0 else { return nil } + let target = CGSize(width: 240, height: 320) + let scale = min(target.width / bounds.width, target.height / bounds.height) + let size = CGSize(width: max(1, bounds.width * scale), height: max(1, bounds.height * scale)) + return page.thumbnail(of: size, for: .mediaBox) + } +} + +/// Full-page library showing every tracked PDF grouped into shelves. +/// Each book shows its cover and title, reading progress, and can be moved +/// between shelves via its menu, the context menu, or drag and drop. +public struct LibraryView: View { + @ObservedObject var state: PDFReaderState + @ObservedObject var library: LibraryStore + let canReturnToReading: Bool + let onOpen: (URL) -> Void + let onOpenDocument: () -> Void + let onReturnToReading: () -> Void + + @ObservedObject private var coverCache = LibraryCoverCache.shared + @AppStorage("com.pdfreader.libraryLayout.v1") private var layout: LibraryLayout = .list + + public init( + state: PDFReaderState, + library: LibraryStore, + canReturnToReading: Bool, + onOpen: @escaping (URL) -> Void, + onOpenDocument: @escaping () -> Void, + onReturnToReading: @escaping () -> Void + ) { + self.state = state + self.library = library + self.canReturnToReading = canReturnToReading + self.onOpen = onOpen + self.onOpenDocument = onOpenDocument + self.onReturnToReading = onReturnToReading + } + + public var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + ForEach(LibrarySection.allCases) { section in + sectionView(section) + } + } + .padding(24) + .frame(maxWidth: 980, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .background(state.appTheme.windowBackground) + } + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "books.vertical.fill") + .font(.title2) + .foregroundStyle(state.appTheme.accent) + VStack(alignment: .leading, spacing: 1) { + Text("Library") + .font(.title2.bold()) + Text("\(library.books.count) book\(library.books.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Picker("Layout", selection: $layout) { + Image(systemName: "list.bullet") + .tag(LibraryLayout.list) + Image(systemName: "square.grid.2x2") + .tag(LibraryLayout.grid) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 84) + .help("Switch between list and grid") + if canReturnToReading { + Button("Back to Reading") { + onReturnToReading() + } + } + Button("Open PDF…") { + onOpenDocument() + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(state.appTheme.chromeBackground) + } + + private func sectionView(_ section: LibrarySection) -> some View { + let books = library.books(in: section) + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: section.symbol) + .foregroundStyle(.secondary) + Text(section.title) + .font(.headline) + Text("\(books.count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.12), in: Capsule()) + Spacer() + } + if books.isEmpty { + Text("No books here yet. Drag a book onto this section or use its menu to move it.") + .font(.callout) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } else if layout == .grid { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 140, maximum: 190), spacing: 16)], + alignment: .leading, + spacing: 16 + ) { + ForEach(books) { book in + LibraryBookCard( + book: book, + accent: state.appTheme.accent, + chrome: state.appTheme.chromeBackground, + coverCache: coverCache, + onOpen: { onOpen(book.url) }, + onMove: { library.move(book.url, to: $0) }, + onRemove: { library.remove(book.url) } + ) + } + } + } else { + VStack(spacing: 0) { + ForEach(Array(books.enumerated()), id: \.element.id) { index, book in + LibraryBookRow( + book: book, + accent: state.appTheme.accent, + coverCache: coverCache, + onOpen: { onOpen(book.url) }, + onMove: { library.move(book.url, to: $0) }, + onRemove: { library.remove(book.url) } + ) + if index < books.count - 1 { + Divider().padding(.leading, 60) + } + } + } + .background( + state.appTheme.chromeBackground.opacity(0.6), + in: RoundedRectangle(cornerRadius: 10) + ) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color.secondary.opacity(0.15)) + ) + .dropDestination(for: String.self) { paths, _ in + var moved = false + for path in paths { + let url = URL(fileURLWithPath: path) + if library.books.contains(where: { $0.url.path == path && $0.section != section }) { + library.move(url, to: section) + moved = true + } + } + return moved + } + } +} + +private struct LibraryBookRow: View { + let book: LibraryBook + let accent: Color + @ObservedObject var coverCache: LibraryCoverCache + let onOpen: () -> Void + let onMove: (LibrarySection) -> Void + let onRemove: () -> Void + + private var fileExists: Bool { + FileManager.default.fileExists(atPath: book.url.path) + } + + var body: some View { + HStack(spacing: 10) { + Button(action: onOpen) { + HStack(spacing: 12) { + BookCover(url: book.url, cache: coverCache, width: 36, height: 48) + VStack(alignment: .leading, spacing: 4) { + Text(book.name) + .font(.body) + .lineLimit(1) + HStack(spacing: 8) { + ProgressView(value: book.clampedProgress) + .frame(width: 120) + .tint(accent) + Text("\(book.percentage)% read") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + if !fileExists { + Label("Missing", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Menu { + LibraryBookMenuItems(book: book, onMove: onMove, onRemove: onRemove) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .fixedSize() + .help("Move or remove") + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .draggable(book.url.path) + .contextMenu { + LibraryBookMenuItems(book: book, onMove: onMove, onRemove: onRemove) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(book.name), \(book.percentage) percent read, \(book.section.title)") + } +} + +private struct LibraryBookCard: View { + let book: LibraryBook + let accent: Color + let chrome: Color + @ObservedObject var coverCache: LibraryCoverCache + let onOpen: () -> Void + let onMove: (LibrarySection) -> Void + let onRemove: () -> Void + + private var fileExists: Bool { + FileManager.default.fileExists(atPath: book.url.path) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button(action: onOpen) { + VStack(alignment: .leading, spacing: 8) { + BookCover(url: book.url, cache: coverCache, height: 170) + Text(book.name) + .font(.subheadline) + .lineLimit(2) + .frame(maxWidth: .infinity, minHeight: 34, alignment: .topLeading) + HStack(spacing: 6) { + ProgressView(value: book.clampedProgress) + .tint(accent) + Text("\(book.percentage)%") + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + if !fileExists { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + } + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(10) + .background( + chrome.opacity(0.6), + in: RoundedRectangle(cornerRadius: 10) + ) + .overlay(alignment: .topTrailing) { + Menu { + LibraryBookMenuItems(book: book, onMove: onMove, onRemove: onRemove) + } label: { + Image(systemName: "ellipsis.circle.fill") + .font(.body) + .foregroundStyle(.white, .black.opacity(0.45)) + } + .menuStyle(.borderlessButton) + .fixedSize() + .padding(6) + .help("Move or remove") + } + .draggable(book.url.path) + .contextMenu { + LibraryBookMenuItems(book: book, onMove: onMove, onRemove: onRemove) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(book.name), \(book.percentage) percent read, \(book.section.title)") + } +} + +private struct BookCover: View { + let url: URL + @ObservedObject var cache: LibraryCoverCache + var width: CGFloat? + var height: CGFloat + + init(url: URL, cache: LibraryCoverCache, width: CGFloat? = nil, height: CGFloat) { + self.url = url + self.cache = cache + self.width = width + self.height = height + } + + var body: some View { + Group { + if let image = cache.image(for: url) { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + } else { + ZStack { + Rectangle() + .fill(Color.secondary.opacity(0.1)) + Image(systemName: "doc.richtext") + .font(.title3) + .foregroundStyle(.secondary) + } + } + } + .frame(width: width, height: height) + .background(Color(nsColor: .textBackgroundColor).opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.secondary.opacity(0.2)) + ) + .task(id: url.path) { + cache.load(url: url) + } + } +} + +private struct LibraryBookMenuItems: View { + let book: LibraryBook + let onMove: (LibrarySection) -> Void + let onRemove: () -> Void + + var body: some View { + Menu("Move to") { + ForEach(LibrarySection.allCases.filter { $0 != book.section }) { section in + Button(section.title) { + onMove(section) + } + } + } + Divider() + Button("Remove from Library", role: .destructive) { + onRemove() + } + } +} diff --git a/Sources/PDFReader/Views/PDFKitView.swift b/Sources/PDFReader/Views/PDFKitView.swift index b293b06..abcf294 100644 --- a/Sources/PDFReader/Views/PDFKitView.swift +++ b/Sources/PDFReader/Views/PDFKitView.swift @@ -14,7 +14,7 @@ public struct PDFKitView: NSViewRepresentable { } public func makeNSView(context: Context) -> PDFView { - let pdfView = PDFView() + let pdfView = ClickablePDFView() pdfView.delegate = context.coordinator pdfView.autoScales = state.autoScales pdfView.displayMode = state.layout.pdfDisplayMode @@ -46,9 +46,20 @@ public struct PDFKitView: NSViewRepresentable { name: .PDFViewSelectionChanged, object: pdfView ) + NotificationCenter.default.addObserver( + context.coordinator, + selector: #selector(Coordinator.annotationHit(_:)), + name: .PDFViewAnnotationHit, + object: pdfView + ) + context.coordinator.installClickHandling(for: pdfView) return pdfView } + public static func dismantleNSView(_ pdfView: PDFView, coordinator: Coordinator) { + coordinator.tearDown() + } + public func updateNSView(_ pdfView: PDFView, context: Context) { if pdfView.document !== state.document { pdfView.document = state.document @@ -65,6 +76,7 @@ public struct PDFKitView: NSViewRepresentable { // view back mid-scroll and kept the toolbar/status page numbers // from updating. state.consumePendingNavigation(in: pdfView) + context.coordinator.ensureClickOverlay(in: pdfView) } public func makeCoordinator() -> Coordinator { @@ -126,65 +138,295 @@ public struct PDFKitView: NSViewRepresentable { } } } + + @objc func annotationHit(_ note: Notification) { + guard let view = note.object as? PDFView, + let annotation = note.userInfo?["PDFAnnotationHit"] as? PDFAnnotation + else { return } + if Thread.isMainThread { + MainActor.assumeIsolated { + self.state?.handleAnnotationClick(annotation, in: view) + } + } else { + Task { @MainActor in + self.state?.handleAnnotationClick(annotation, in: view) + } + } + } + + /// PDFKit's annotation-hit notification is unreliable for text + /// markup on recent macOS versions. A transparent overlay captures + /// clicks only when they land on an editable annotation, so PDFView's + /// own selection/link handling keeps working everywhere else. The + /// local mouse-up monitor is a second path in case the overlay is + /// displaced by PDFView's internal scroll view. + func installClickHandling(for view: PDFView) { + let overlay = AnnotationClickOverlay() + overlay.pdfView = view + overlay.annotationAtPoint = { [weak self] point, view in + MainActor.assumeIsolated { + self?.annotation(at: point, in: view) + } + } + overlay.onClick = { [weak self] annotation, view in + MainActor.assumeIsolated { + self?.state?.handleAnnotationClick(annotation, in: view) + } + } + overlay.frame = view.bounds + overlay.autoresizingMask = [.width, .height] + view.addSubview(overlay, positioned: .above, relativeTo: nil) + clickOverlay = overlay + (view as? ClickablePDFView)?.clickOverlay = overlay + + clickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self, weak view] event in + guard let self, let view, event.window === view.window else { return event } + let point = view.convert(event.locationInWindow, from: nil) + guard view.bounds.contains(point) else { return event } + MainActor.assumeIsolated { + guard let annotation = self.annotation(at: point, in: view) else { return } + self.state?.handleAnnotationClick(annotation, in: view) + } + return event + } + } + + func ensureClickOverlay(in view: PDFView) { + guard let clickOverlay else { + installClickHandling(for: view) + return + } + if clickOverlay.superview !== view || view.subviews.last !== clickOverlay { + view.addSubview(clickOverlay, positioned: .above, relativeTo: nil) + } + if clickOverlay.frame != view.bounds { + clickOverlay.frame = view.bounds + } + } + + func tearDown() { + if let clickMonitor { + NSEvent.removeMonitor(clickMonitor) + } + clickMonitor = nil + clickOverlay?.removeFromSuperview() + clickOverlay = nil + } + + private func annotation(at point: NSPoint, in view: PDFView) -> PDFAnnotation? { + guard let page = view.page(for: point, nearest: false) else { return nil } + let pagePoint = view.convert(point, to: page) + let candidates = page.annotations.reversed().filter { $0.bounds.contains(pagePoint) } + return candidates.first { PDFReaderState.isEditableAnnotation($0) } + } + + private var clickMonitor: Any? + private var clickOverlay: AnnotationClickOverlay? } } -/// Thumbnail strip used in the sidebar. Separate representable because -/// `PDFThumbnailView` must share the same `PDFView` instance. +/// PDFView subclass that routes clicks on editable annotations to the +/// transparent overlay even when PDFKit's internal scroll view sits above it. +@MainActor +private final class ClickablePDFView: PDFView { + weak var clickOverlay: AnnotationClickOverlay? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let clickOverlay else { return super.hitTest(point) } + let local = convert(point, from: superview) + if clickOverlay.annotationAtPoint?(local, self) != nil { + return clickOverlay + } + return super.hitTest(point) + } +} + +/// Transparent hit-testing overlay above `PDFView`. Returns itself only when +/// the point lands on an editable annotation, leaving every other click to +/// flow through to PDFView. +@MainActor +private final class AnnotationClickOverlay: NSView { + weak var pdfView: PDFView? + var annotationAtPoint: ((NSPoint, PDFView) -> PDFAnnotation?)? + var onClick: ((PDFAnnotation, PDFView) -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + guard let pdfView, + annotationAtPoint?(point, pdfView) != nil + else { return nil } + return self + } + + override func mouseUp(with event: NSEvent) { + guard let pdfView else { return } + let point = convert(event.locationInWindow, from: nil) + guard let annotation = annotationAtPoint?(point, pdfView) else { return } + onClick?(annotation, pdfView) + } +} + +/// Thumbnail strip used in the sidebar. Renders page thumbnails in a lazy +/// stack so the current page can be highlighted with a user-chosen color +/// (PDFThumbnailView owns its selection color and offers no API for it). /// Thumbnail size follows `state.thumbnailScale` (0.5...5.0, 1.0 = 72x96). -public struct PDFThumbnailStrip: NSViewRepresentable { +public struct PDFThumbnailStrip: View { @ObservedObject public var state: PDFReaderState public init(state: PDFReaderState) { self.state = state } - public func makeNSView(context: Context) -> PDFThumbnailView { - let thumbs = PDFThumbnailView() - thumbs.thumbnailSize = scaledThumbnailSize(for: state.thumbnailScale) - thumbs.backgroundColor = .clear - return thumbs + public var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 10) { + ForEach(0 ..< state.pageCount, id: \.self) { index in + ThumbnailCell(state: state, pageIndex: index) + .id(index) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .onAppear { scrollToCurrentPage(proxy) } + .onChange(of: state.currentPageIndex) { _, _ in + scrollToCurrentPage(proxy) + } + .onChange(of: state.fileURL) { _, _ in + scrollToCurrentPage(proxy) + } + } } - public func updateNSView(_ thumbs: PDFThumbnailView, context: Context) { - if thumbs.pdfView !== state.pdfView { - thumbs.pdfView = state.pdfView - } - let want = scaledThumbnailSize(for: state.thumbnailScale) - if thumbs.thumbnailSize != want { - // Debounce: resizing thumbnails re-renders every page's thumbnail - // synchronously on the main thread. Rapid successive changes - // (slider drags, repeated +/- taps) would queue overlapping - // reloads and freeze the app with no recovery on large docs, so - // only the settled size is applied. - context.coordinator.scheduleThumbnailResize(thumbs, to: want) + /// Keeps the current page visible, matching PDFThumbnailView's + /// follow-the-document behavior. + private func scrollToCurrentPage(_ proxy: ScrollViewProxy) { + guard state.pageCount > 0 else { return } + let index = min(max(0, state.currentPageIndex), state.pageCount - 1) + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(index, anchor: .center) + } } } +} + +private struct ThumbnailCell: View { + @ObservedObject var state: PDFReaderState + let pageIndex: Int + + /// Size thumbnails are rendered at. Lags `thumbnailSize` slightly so a + /// slider drag doesn't re-render every visible page at every step (the + /// same reason the old PDFThumbnailView bridge debounced resizes). + @State private var renderSize: NSSize = .zero + @State private var resizeTask: Task? + + private var isCurrent: Bool { state.currentPageIndex == pageIndex } + + private var thumbnailSize: NSSize { + let scale = min(5.0, max(0.5, state.thumbnailScale)) + return NSSize(width: 72 * scale, height: 96 * scale) + } - public func makeCoordinator() -> ThumbnailCoordinator { - ThumbnailCoordinator() + private var imageSize: NSSize { + renderSize == .zero ? thumbnailSize : renderSize } - /// Coalesces rapid `thumbnailSize` changes so only the latest settled - /// value hits `PDFThumbnailView` (which re-renders synchronously). - public final class ThumbnailCoordinator: NSObject { - private var pending: DispatchWorkItem? + private var highlightColor: Color { state.pageHighlightSwiftUIColor } - func scheduleThumbnailResize(_ view: PDFThumbnailView, to size: NSSize) { - pending?.cancel() - let work = DispatchWorkItem { [weak view] in - guard let view else { return } - if view.thumbnailSize != size { - view.thumbnailSize = size - } + var body: some View { + VStack(spacing: 4) { + thumbnail + .frame(width: thumbnailSize.width, height: thumbnailSize.height) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isCurrent ? highlightColor.opacity(0.18) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .strokeBorder( + isCurrent ? highlightColor : Color.secondary.opacity(0.25), + lineWidth: isCurrent ? 2.5 : 0.5 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 4)) + Text("\(pageIndex + 1)") + .font(.caption2) + .fontWeight(isCurrent ? .semibold : .regular) + .foregroundStyle(isCurrent ? highlightColor : Color.secondary) + } + .contentShape(Rectangle()) + .onTapGesture { + state.goToPage(index: pageIndex) + } + .help("Page \(pageIndex + 1)") + .accessibilityLabel("Page \(pageIndex + 1)") + .accessibilityAddTraits(isCurrent ? .isSelected : []) + .onAppear { + renderSize = thumbnailSize + } + .onChange(of: thumbnailSize) { _, newSize in + resizeTask?.cancel() + resizeTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 150_000_000) + guard !Task.isCancelled else { return } + renderSize = newSize } - pending = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work) + } + .onDisappear { + resizeTask?.cancel() } } - private func scaledThumbnailSize(for scale: CGFloat) -> NSSize { - let clamped = min(5.0, max(0.5, scale)) - return NSSize(width: 72 * clamped, height: 96 * clamped) + @ViewBuilder + private var thumbnail: some View { + if let image = thumbnailImage { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + } else { + RoundedRectangle(cornerRadius: 3) + .fill(Color(nsColor: .textBackgroundColor)) + .overlay( + Image(systemName: "doc") + .foregroundStyle(.tertiary) + ) + } + } + + private var thumbnailImage: NSImage? { + guard let doc = state.document else { return nil } + return ThumbnailImageCache.image( + document: doc, + pageIndex: pageIndex, + fileURL: state.fileURL, + size: imageSize, + revision: state.thumbnailRevision + ) + } +} + +/// Small in-memory cache so scrolling and page changes don't re-render +/// every visible thumbnail on each SwiftUI update pass. +@MainActor +private enum ThumbnailImageCache { + private static let cache = NSCache() + + static func image( + document: PDFDocument, + pageIndex: Int, + fileURL: URL?, + size: NSSize, + revision: Int + ) -> NSImage? { + let key = "\(fileURL?.path ?? "untitled")#\(pageIndex)#\(Int(size.width))x\(Int(size.height))#\(revision)" as NSString + if let cached = cache.object(forKey: key) { + return cached + } + guard let page = document.page(at: pageIndex) else { return nil } + let image = page.thumbnail(of: size, for: .mediaBox) + cache.setObject(image, forKey: key) + return image } } diff --git a/Sources/PDFReader/Views/SidebarView.swift b/Sources/PDFReader/Views/SidebarView.swift index 242aedf..f9aac30 100644 --- a/Sources/PDFReader/Views/SidebarView.swift +++ b/Sources/PDFReader/Views/SidebarView.swift @@ -1,3 +1,4 @@ +import AppKit import PDFReaderKit import SwiftUI @@ -142,11 +143,26 @@ public struct SidebarView: View { .foregroundStyle(.secondary) .frame(width: 40, alignment: .trailing) .help("Thumbnail zoom — double-click slider to reset to 100%") + + ColorPicker( + "Current page highlight", + selection: pageHighlightBinding, + supportsOpacity: false + ) + .labelsHidden() + .help("Current page highlight color") } .padding(.horizontal, 8) .padding(.vertical, 6) } + private var pageHighlightBinding: Binding { + Binding( + get: { state.pageHighlightSwiftUIColor }, + set: { state.pageHighlightColor = NSColor($0) } + ) + } + private var thumbnailsTab: some View { VStack(spacing: 0) { thumbnailZoomBar @@ -319,6 +335,15 @@ public struct SidebarView: View { } .buttonStyle(.plain) Spacer() + ColorPicker( + "Annotation color", + selection: annotationCustomColorBinding(for: row), + supportsOpacity: false + ) + .labelsHidden() + .fixedSize() + .help("Change annotation color") + .accessibilityLabel("Change annotation color") Button { state.removeAnnotation(row) } label: { @@ -334,6 +359,13 @@ public struct SidebarView: View { } } + private func annotationCustomColorBinding(for row: PDFAnnotationRow) -> Binding { + Binding( + get: { Color(row.annotation.color) }, + set: { state.setAnnotationColor(NSColor($0), for: row) } + ) + } + private func emptyHint(_ text: String) -> some View { VStack { Spacer() diff --git a/Sources/PDFReaderKit/Models/LibraryStore.swift b/Sources/PDFReaderKit/Models/LibraryStore.swift new file mode 100644 index 0000000..a75345e --- /dev/null +++ b/Sources/PDFReaderKit/Models/LibraryStore.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Shelf a book belongs to on the Library page. +public enum LibrarySection: String, Codable, CaseIterable, Identifiable { + case currentlyReading + case wantToRead + case finished + + public var id: String { rawValue } + + public var title: String { + switch self { + case .currentlyReading: return "Currently reading" + case .wantToRead: return "Want to read" + case .finished: return "Finished" + } + } + + public var symbol: String { + switch self { + case .currentlyReading: return "book" + case .wantToRead: return "bookmark" + case .finished: return "checkmark.circle" + } + } +} + +/// A PDF tracked by the Library. Progress is the fraction read (0...1), +/// mirroring the viewer's reading-progress percentage. +public struct LibraryBook: Codable, Identifiable, Hashable { + public var url: URL + public var section: LibrarySection + public var progress: Double + public var lastRead: Date + + public var id: String { url.path } + public var name: String { url.lastPathComponent } + public var percentage: Int { Int((clampedProgress * 100).rounded()) } + + public var clampedProgress: Double { min(max(progress, 0), 1) } + + public init( + url: URL, + section: LibrarySection = .currentlyReading, + progress: Double = 0, + lastRead: Date = Date() + ) { + self.url = url + self.section = section + self.progress = min(max(progress, 0), 1) + self.lastRead = lastRead + } +} + +/// Persisted list of books grouped into reading shelves. Books enter the +/// library when opened (into Currently reading) and keep their section +/// until the user moves them. +public final class LibraryStore: ObservableObject { + @Published public private(set) var books: [LibraryBook] = [] + + private let storageKey = "com.pdfreader.library.v1" + + public init() { + load() + } + + /// Books on a shelf, most recently read first. + public func books(in section: LibrarySection) -> [LibraryBook] { + books + .filter { $0.section == section } + .sorted { $0.lastRead > $1.lastRead } + } + + /// Records an open/read: new books join Currently reading, existing + /// books keep their shelf and just refresh progress + recency. + public func record(url: URL, progress: Double) { + let clamped = min(max(progress, 0), 1) + if let index = books.firstIndex(where: { $0.url.path == url.path }) { + books[index].progress = clamped + books[index].lastRead = Date() + } else { + books.append(LibraryBook(url: url, section: .currentlyReading, progress: clamped)) + } + save() + } + + public func move(_ url: URL, to section: LibrarySection) { + guard let index = books.firstIndex(where: { $0.url.path == url.path }), + books[index].section != section + else { return } + books[index].section = section + save() + } + + public func remove(_ url: URL) { + let before = books.count + books.removeAll { $0.url.path == url.path } + if books.count != before { save() } + } + + private func load() { + guard let data = UserDefaults.standard.data(forKey: storageKey) else { return } + if let decoded = try? JSONDecoder().decode([LibraryBook].self, from: data) { + books = decoded.map { + LibraryBook( + url: $0.url, + section: $0.section, + progress: $0.progress, + lastRead: $0.lastRead + ) + } + } + } + + private func save() { + if let data = try? JSONEncoder().encode(books) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } +} diff --git a/Sources/PDFReaderKit/Models/PDFReaderState.swift b/Sources/PDFReaderKit/Models/PDFReaderState.swift index acfabb6..8ccc9a0 100644 --- a/Sources/PDFReaderKit/Models/PDFReaderState.swift +++ b/Sources/PDFReaderKit/Models/PDFReaderState.swift @@ -36,6 +36,15 @@ public struct PDFAnnotationRow: Identifiable { public var contents: String { annotation.contents ?? "" } } +/// Annotation currently being recolored by clicking it in the viewer. +/// `anchor` is in `PDFView` coordinates (bottom-left origin). +public struct AnnotationColorTarget: Identifiable { + public let id = UUID() + public let annotation: PDFAnnotation + public let pageIndex: Int + public let anchor: CGPoint +} + /// Reading layout options exposed in the toolbar. public enum ReadingLayout: String, CaseIterable, Identifiable { case singleContinuous @@ -107,6 +116,12 @@ public final class PDFReaderState: ObservableObject { @Published public var outlineNodes: [PDFOutlineNode] = [] @Published public var annotationRows: [PDFAnnotationRow] = [] + /// Non-nil while the click-to-recolor popover is showing. + @Published public var annotationColorTarget: AnnotationColorTarget? + + /// Bumped whenever page content changes (annotations added/removed) + /// so thumbnail caches can invalidate. + @Published public var thumbnailRevision: Int = 0 @Published public var annotationMode: AnnotationMode = .select @Published public var highlightColor: HighlightColor = .yellow { @@ -127,6 +142,24 @@ public final class PDFReaderState: ObservableObject { } } + /// Color used to highlight the current page in the Pages sidebar. + /// Defaults to blue and is persisted across relaunches. + @Published public var pageHighlightColor: NSColor = .systemBlue { + didSet { + if pageHighlightColor.colorSpace != .sRGB, + let converted = pageHighlightColor.usingColorSpace(.sRGB) + { + pageHighlightColor = converted + } + savePageHighlightColor() + } + } + + /// SwiftUI bridge for `pageHighlightColor`. + public var pageHighlightSwiftUIColor: Color { + Color(pageHighlightColor) + } + /// User-selected shortlist cycled by "Next color", in order. @Published public var favoriteColors: [HighlightColor] = [.yellow, .green, .pink] { didSet { saveHighlightPrefs() } @@ -141,6 +174,22 @@ public final class PDFReaderState: ObservableObject { highlightColor == .custom ? Color(customHighlightColor) : highlightColor.swiftUIColor } + // MARK: - Reading progress + + /// Fraction of the document read, 0...1. The first page reads as 0% + /// and the last page as 100%, interpolating linearly in between. + public var readingProgress: Double { + guard pageCount > 0 else { return 0 } + guard pageCount > 1 else { return 1 } + let page = min(max(currentPageIndex, 0), pageCount - 1) + return Double(page) / Double(pageCount - 1) + } + + /// Reading progress as a whole percentage (0...100). + public var readingPercentage: Int { + Int((readingProgress * 100).rounded()) + } + @Published public var statusMessage: String = "Open a PDF to get started." // MARK: - Appearance theme @@ -272,6 +321,8 @@ public final class PDFReaderState: ObservableObject { case added(pairs: [(page: PDFPage, annotation: PDFAnnotation)]) /// An annotation we removed (undo re-adds it, redo removes it). case removed(page: PDFPage, annotation: PDFAnnotation) + /// An annotation whose color we changed (undo restores, redo reapplies). + case recolored(annotation: PDFAnnotation, oldColor: NSColor, newColor: NSColor) } private var undoStack: [AnnotationChange] = [] @@ -310,6 +361,7 @@ public final class PDFReaderState: ObservableObject { private static let highlightColorKey = "com.pdfreader.highlightColor.v1" private static let customColorKey = "com.pdfreader.highlightCustomColor.v1" + private static let pageHighlightColorKey = "com.pdfreader.pageHighlightColor.v1" private static let favoritesKey = "com.pdfreader.highlightFavorites.v1" private static let themeKey = "com.pdfreader.appTheme.v1" private static let thumbnailScaleKey = "com.pdfreader.thumbnailScale.v1" @@ -341,6 +393,16 @@ public final class PDFReaderState: ObservableObject { } } + private func savePageHighlightColor() { + guard !loadingPrefs else { return } + if let srgb = pageHighlightColor.usingColorSpace(.sRGB) { + UserDefaults.standard.set( + "\(srgb.redComponent) \(srgb.greenComponent) \(srgb.blueComponent)", + forKey: Self.pageHighlightColorKey + ) + } + } + private func loadHighlightPrefs() { loadingPrefs = true defer { loadingPrefs = false } @@ -360,6 +422,17 @@ public final class PDFReaderState: ObservableObject { ) } } + if let str = UserDefaults.standard.string(forKey: Self.pageHighlightColorKey) { + let parts = str.split(separator: " ").compactMap { Double(String($0)) } + if parts.count == 3 { + pageHighlightColor = NSColor( + srgbRed: CGFloat(parts[0]), + green: CGFloat(parts[1]), + blue: CGFloat(parts[2]), + alpha: 1.0 + ) + } + } if let raws = UserDefaults.standard.stringArray(forKey: Self.favoritesKey), !raws.isEmpty { let list = raws.compactMap { HighlightColor(rawValue: $0) } if !list.isEmpty { @@ -786,6 +859,7 @@ public final class PDFReaderState: ObservableObject { // MARK: - Annotations public func refreshAnnotations() { + thumbnailRevision &+= 1 guard let doc = document else { annotationRows = [] return @@ -937,6 +1011,105 @@ public final class PDFReaderState: ObservableObject { refreshAnnotations() } + /// Recolors an existing annotation. Used by the Notes inspector so a + /// highlight can be edited after it was applied. + public func setAnnotationColor(_ color: NSColor, for row: PDFAnnotationRow) { + setAnnotationColor(color, for: row.annotation) + } + + /// Convenience for palette colors; `.custom` resolves through + /// `customHighlightColor`. + public func setAnnotationColor(_ color: HighlightColor, for row: PDFAnnotationRow) { + setAnnotationColor(color, for: row.annotation) + } + + /// Recolors an annotation directly (used by click-to-recolor). + public func setAnnotationColor(_ color: NSColor, for annotation: PDFAnnotation) { + let oldColor = annotation.color + guard !Self.colorsMatch(oldColor, color) else { return } + annotation.color = color + pdfView?.needsDisplay = true + undoStack.append( + .recolored(annotation: annotation, oldColor: oldColor, newColor: color) + ) + redoStack.removeAll() + refreshAnnotations() + statusMessage = "Changed annotation color." + } + + /// Convenience for palette colors; `.custom` resolves through + /// `customHighlightColor`. + public func setAnnotationColor(_ color: HighlightColor, for annotation: PDFAnnotation) { + setAnnotationColor(color == .custom ? customHighlightColor : color.nsColor, for: annotation) + } + + public func annotationColorMatches(_ annotation: PDFAnnotation, _ color: HighlightColor) -> Bool { + Self.colorsMatch( + annotation.color, + color == .custom ? customHighlightColor : color.nsColor + ) + } + + /// Annotation types that can be recolored by clicking them in the viewer. + /// PDFKit reports subtypes as `/Highlight` but `PDFAnnotation.type` as + /// `Highlight`, so normalize the leading slash before matching. + private static let editableAnnotationTypes: Set = [ + PDFAnnotationSubtype.highlight.rawValue, + PDFAnnotationSubtype.underline.rawValue, + PDFAnnotationSubtype.strikeOut.rawValue, + PDFAnnotationSubtype.text.rawValue, + PDFAnnotationSubtype.freeText.rawValue, + ].map { $0.hasPrefix("/") ? String($0.dropFirst()) : $0 } + .reduce(into: Set()) { $0.insert($1) } + + public static func isEditableAnnotation(_ annotation: PDFAnnotation) -> Bool { + let type = annotation.type ?? "" + let normalized = type.hasPrefix("/") ? String(type.dropFirst()) : type + return editableAnnotationTypes.contains(normalized) + } + + /// Handles a click on an annotation in the viewer by opening the color + /// editor anchored at that annotation. + public func handleAnnotationClick(_ annotation: PDFAnnotation, in view: PDFView) { + guard let doc = document, + Self.isEditableAnnotation(annotation) + else { return } + if let current = annotationColorTarget, current.annotation === annotation { + return + } + let page: PDFPage? + if let annotationPage = annotation.page { + page = annotationPage + } else { + page = (0 ..< doc.pageCount) + .compactMap { doc.page(at: $0) } + .first { $0.annotations.contains { $0 === annotation } } + } + guard let page else { return } + let pageIndex = doc.index(for: page) + guard pageIndex >= 0, pageIndex < doc.pageCount else { return } + let boundsInView = view.convert(annotation.bounds, from: page) + annotationColorTarget = AnnotationColorTarget( + annotation: annotation, + pageIndex: pageIndex, + anchor: CGPoint(x: boundsInView.midX, y: boundsInView.midY) + ) + } + + public func dismissAnnotationColorEditor() { + annotationColorTarget = nil + } + + private static func colorsMatch(_ lhs: NSColor, _ rhs: NSColor) -> Bool { + guard let a = lhs.usingColorSpace(.sRGB), + let b = rhs.usingColorSpace(.sRGB) + else { return lhs == rhs } + return abs(a.redComponent - b.redComponent) < 0.001 + && abs(a.greenComponent - b.greenComponent) < 0.001 + && abs(a.blueComponent - b.blueComponent) < 0.001 + && abs(a.alphaComponent - b.alphaComponent) < 0.001 + } + public func undo() { guard let change = undoStack.popLast() else { return } applyInverse(change) @@ -961,6 +1134,9 @@ public final class PDFReaderState: ObservableObject { } case .removed(let page, let annotation): page.addAnnotation(annotation) + case .recolored(let annotation, let oldColor, _): + annotation.color = oldColor + pdfView?.needsDisplay = true } } @@ -972,6 +1148,9 @@ public final class PDFReaderState: ObservableObject { } case .removed(let page, let annotation): page.removeAnnotation(annotation) + case .recolored(let annotation, _, let newColor): + annotation.color = newColor + pdfView?.needsDisplay = true } } @@ -982,6 +1161,43 @@ public final class PDFReaderState: ObservableObject { } } + // MARK: - Printing + + /// Builds a print operation for the current document, or `nil` when no + /// document is open. Exposed separately so callers can validate a job + /// without showing the print panel. + public func makePrintOperation(printInfo: NSPrintInfo = .shared) -> NSPrintOperation? { + guard let doc = document else { return nil } + let operation = doc.printOperation( + for: printInfo, + scalingMode: .pageScaleDownToFit, + autoRotate: true + ) + operation?.showsPrintPanel = true + operation?.showsProgressPanel = true + return operation + } + + /// Opens the system print panel for the current document. The panel + /// handles page ranges; pages are scaled down to fit the paper and + /// auto-rotated, and annotations are included. + public func printDocument() { + guard let doc = document else { + statusMessage = "Nothing to print." + return + } + guard let operation = makePrintOperation() else { + statusMessage = "Could not prepare the print job." + return + } + let ok = operation.run() + if ok { + statusMessage = "Printed \(doc.pageCount) page\(doc.pageCount == 1 ? "" : "s") from \(fileName)." + } else { + statusMessage = "Print canceled." + } + } + // MARK: - Saving /// Writes annotations back into the opened file. diff --git a/Sources/PDFReaderVerify/main.swift b/Sources/PDFReaderVerify/main.swift index d3a3b69..0dd348f 100644 --- a/Sources/PDFReaderVerify/main.swift +++ b/Sources/PDFReaderVerify/main.swift @@ -16,6 +16,16 @@ func check(_ condition: Bool, _ label: String, failures: inout Int) { } } +func colorsClose(_ lhs: NSColor, _ rhs: NSColor) -> Bool { + guard let a = lhs.usingColorSpace(.sRGB), + let b = rhs.usingColorSpace(.sRGB) + else { return lhs == rhs } + return abs(a.redComponent - b.redComponent) < 0.001 + && abs(a.greenComponent - b.greenComponent) < 0.001 + && abs(a.blueComponent - b.blueComponent) < 0.001 + && abs(a.alphaComponent - b.alphaComponent) < 0.001 +} + @MainActor func makeSamplePDF(pages: Int) throws -> URL { // Draw real text into a CG PDF context so PDFKit's findString works. @@ -159,6 +169,33 @@ func run() async -> Int { "reading layouts map to distinct PDFKit display modes", failures: &failures ) + // Reading progress: first page is 0%, last page is 100%. + do { + let progress = PDFReaderState() + check(progress.readingPercentage == 0, "reading percentage empty is 0", failures: &failures) + progress.pageCount = 4 + progress.currentPageIndex = 0 + check(progress.readingPercentage == 0, "reading percentage starts at 0", failures: &failures) + progress.currentPageIndex = 1 + check(progress.readingPercentage == 33, "reading percentage interpolates", failures: &failures) + progress.currentPageIndex = 3 + check(progress.readingPercentage == 100, "reading percentage reaches 100", failures: &failures) + } + // Page highlight: blue by default, persisted after a change. + UserDefaults.standard.removeObject(forKey: "com.pdfreader.pageHighlightColor.v1") + do { + let hl = PDFReaderState() + let c = hl.pageHighlightColor.usingColorSpace(.sRGB) + check( + (c?.blueComponent ?? 0) > 0.8 && (c?.blueComponent ?? 0) > (c?.redComponent ?? 1), + "page highlight defaults to blue", + failures: &failures + ) + hl.pageHighlightColor = NSColor(srgbRed: 0.9, green: 0.3, blue: 0.2, alpha: 1.0) + let reloaded = PDFReaderState().pageHighlightColor.usingColorSpace(.sRGB) + check(abs((reloaded?.redComponent ?? 0) - 0.9) < 0.001, "page highlight color persists", failures: &failures) + } + UserDefaults.standard.removeObject(forKey: "com.pdfreader.pageHighlightColor.v1") UserDefaults.standard.removeObject(forKey: "com.pdfreader.bookmarks.v1") let store = BookmarkStore() @@ -169,6 +206,42 @@ func run() async -> Int { check(!store.isBookmarked(fileName: "a.pdf", pageIndex: 2), "bookmark toggle removes", failures: &failures) UserDefaults.standard.removeObject(forKey: "com.pdfreader.bookmarks.v1") + // Library shelves: opened books default to Currently Reading, rows + // expose progress, and moving a book persists its section. + UserDefaults.standard.removeObject(forKey: "com.pdfreader.library.v1") + do { + let library = LibraryStore() + let bookA = URL(fileURLWithPath: "/tmp/verify-library-a.pdf") + let bookB = URL(fileURLWithPath: "/tmp/verify-library-b.pdf") + library.record(url: bookA, progress: 0.25) + library.record(url: bookB, progress: 1.0) + check(library.books(in: .currentlyReading).count == 2, "library adds opened books", failures: &failures) + check( + library.books(in: .currentlyReading).first { $0.url == bookA }?.percentage == 25, + "library shows reading percentage", + failures: &failures + ) + library.move(bookA, to: .wantToRead) + library.move(bookB, to: .finished) + check( + library.books(in: .wantToRead).contains { $0.url == bookA }, + "library moves book to want to read", + failures: &failures + ) + check( + library.books(in: .finished).contains { $0.url == bookB }, + "library moves book to finished", + failures: &failures + ) + let reloaded = LibraryStore() + check( + reloaded.books(in: .wantToRead).first?.percentage == 25, + "library persists section and progress", + failures: &failures + ) + } + UserDefaults.standard.removeObject(forKey: "com.pdfreader.library.v1") + let bad = PDFReaderState() check(!bad.open(url: URL(fileURLWithPath: "/nonexistent/missing.pdf")), "invalid URL fails gracefully", failures: &failures) @@ -316,11 +389,66 @@ func run() async -> Int { print("FAIL: no hit available for undo checks") failures += 1 } + // Recoloring an existing annotation is undoable and persists on save. + if let row = state.annotationRows.first { + let before = row.annotation.color + state.setAnnotationColor(HighlightColor.blue, for: row) + check( + colorsClose(row.annotation.color, HighlightColor.blue.nsColor), + "recolor changes annotation color", + failures: &failures + ) + state.undo() + check(colorsClose(row.annotation.color, before), "undo restores annotation color", failures: &failures) + state.redo() + check( + colorsClose(row.annotation.color, HighlightColor.blue.nsColor), + "redo reapplies annotation color", + failures: &failures + ) + } else { + print("FAIL: no annotation row to recolor") + failures += 1 + } + // Click-to-recolor: hit-testing an editable annotation opens the + // editor anchored at it; clicking the same annotation again is + // deduped so the popover does not thrash. + panel.frame = NSRect(x: 0, y: 0, width: 612, height: 792) + panel.autoScales = true + panel.layoutSubtreeIfNeeded() + if let row = state.annotationRows.first(where: { PDFReaderState.isEditableAnnotation($0.annotation) }) { + state.dismissAnnotationColorEditor() + state.handleAnnotationClick(row.annotation, in: panel) + if let target = state.annotationColorTarget { + check(target.annotation === row.annotation, "click sets annotation color target", failures: &failures) + check(target.pageIndex == row.pageIndex, "click target page index", failures: &failures) + check(target.anchor.x.isFinite && target.anchor.y.isFinite, "click anchor is finite", failures: &failures) + } else { + print("FAIL: click did not set annotation color target") + failures += 1 + } + let targetID = state.annotationColorTarget?.id + state.handleAnnotationClick(row.annotation, in: panel) + check(state.annotationColorTarget?.id == targetID, "same annotation click is deduped", failures: &failures) + state.dismissAnnotationColorEditor() + check(state.annotationColorTarget == nil, "dismiss clears annotation color target", failures: &failures) + } else { + print("FAIL: no editable annotation for click target") + failures += 1 + } + // Printing prepares a job when a document is open and nothing otherwise. + check(state.makePrintOperation() != nil, "print operation is available", failures: &failures) + check(PDFReaderState().makePrintOperation() == nil, "print without a document yields no operation", failures: &failures) // Save writes annotations back into the opened file. check(state.save(), "save writes back to file", failures: &failures) let reopenedFile = PDFReaderState() check(reopenedFile.open(url: url), "reopens saved file", failures: &failures) check(!reopenedFile.annotationRows.isEmpty, "saved annotations persist on disk", failures: &failures) + check( + reopenedFile.annotationRows.contains { colorsClose($0.annotation.color, HighlightColor.blue.nsColor) }, + "saved annotation color persists", + failures: &failures + ) check(!PDFReaderState().save(), "save with no document fails gracefully", failures: &failures) state.clearSearch() check(state.searchHits.isEmpty, "clear search empties hits", failures: &failures) diff --git a/Tests/PDFReaderKitTests/BookmarkAndPaletteTests.swift b/Tests/PDFReaderKitTests/BookmarkAndPaletteTests.swift index 7343452..f07c904 100644 --- a/Tests/PDFReaderKitTests/BookmarkAndPaletteTests.swift +++ b/Tests/PDFReaderKitTests/BookmarkAndPaletteTests.swift @@ -1,4 +1,5 @@ import AppKit +import PDFKit import XCTest @testable import PDFReaderKit @@ -77,6 +78,92 @@ final class BookmarkAndPaletteTests: XCTestCase { XCTAssertEqual(Set(modes).count, ReadingLayout.allCases.count) } + func testLibrarySectionsProgressAndPersistence() { + UserDefaults.standard.removeObject(forKey: "com.pdfreader.library.v1") + let store = LibraryStore() + XCTAssertTrue(store.books.isEmpty) + + let bookA = URL(fileURLWithPath: "/tmp/library-a.pdf") + let bookB = URL(fileURLWithPath: "/tmp/library-b.pdf") + store.record(url: bookA, progress: 0.25) + store.record(url: bookB, progress: 1.0) + + XCTAssertEqual(store.books(in: .currentlyReading).count, 2) + XCTAssertEqual( + store.books(in: .currentlyReading).first { $0.url == bookA }?.percentage, + 25 + ) + + store.move(bookA, to: .wantToRead) + store.move(bookB, to: .finished) + XCTAssertTrue(store.books(in: .wantToRead).contains { $0.url == bookA }) + XCTAssertTrue(store.books(in: .finished).contains { $0.url == bookB }) + XCTAssertTrue(store.books(in: .currentlyReading).isEmpty) + + // A fresh instance reads the persisted shelves + progress. + let reloaded = LibraryStore() + XCTAssertEqual(reloaded.books(in: .wantToRead).first?.percentage, 25) + XCTAssertEqual(reloaded.books(in: .finished).first?.percentage, 100) + + // Reading an existing book updates progress without moving shelves. + reloaded.record(url: bookA, progress: 0.5) + XCTAssertEqual(reloaded.books(in: .wantToRead).first?.percentage, 50) + + UserDefaults.standard.removeObject(forKey: "com.pdfreader.library.v1") + } + + @MainActor + func testAnnotationColorCanBeEditedWithUndo() { + let image = NSImage(size: NSSize(width: 100, height: 100), flipped: false) { rect in + NSColor.white.setFill() + rect.fill() + return true + } + guard let page = PDFPage(image: image) else { + XCTFail("could not create a PDF page") + return + } + let annotation = PDFAnnotation( + bounds: NSRect(x: 10, y: 10, width: 40, height: 20), + forType: .highlight, + withProperties: nil + ) + annotation.color = NSColor.yellow + page.addAnnotation(annotation) + + let doc = PDFDocument() + doc.insert(page, at: 0) + let state = PDFReaderState() + state.document = doc + state.pageCount = 1 + state.refreshAnnotations() + + guard let row = state.annotationRows.first else { + XCTFail("annotation row missing") + return + } + state.setAnnotationColor(NSColor.red, for: row) + XCTAssertTrue(colorsClose(row.annotation.color, NSColor.red)) + XCTAssertTrue(state.canUndo) + + state.undo() + XCTAssertTrue(colorsClose(row.annotation.color, NSColor.yellow)) + XCTAssertTrue(state.canRedo) + + state.redo() + XCTAssertTrue(colorsClose(row.annotation.color, NSColor.red)) + } + + private func colorsClose(_ lhs: NSColor, _ rhs: NSColor) -> Bool { + guard let a = lhs.usingColorSpace(.sRGB), + let b = rhs.usingColorSpace(.sRGB) + else { return lhs == rhs } + return abs(a.redComponent - b.redComponent) < 0.001 + && abs(a.greenComponent - b.greenComponent) < 0.001 + && abs(a.blueComponent - b.blueComponent) < 0.001 + && abs(a.alphaComponent - b.alphaComponent) < 0.001 + } + func testRecentFilesRecordDedupeCapAndPersist() { UserDefaults.standard.removeObject(forKey: "com.pdfreader.recentFiles.v1") let recents = RecentFilesStore() diff --git a/Tests/PDFReaderKitTests/ThemeAndSidebarTests.swift b/Tests/PDFReaderKitTests/ThemeAndSidebarTests.swift index b9b8751..a3bad6e 100644 --- a/Tests/PDFReaderKitTests/ThemeAndSidebarTests.swift +++ b/Tests/PDFReaderKitTests/ThemeAndSidebarTests.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import XCTest @@ -61,4 +62,38 @@ final class ThemeAndSidebarTests: XCTestCase { XCTAssertEqual(state.appTheme, .dark) XCTAssertTrue(state.usesDarkPages) } + + @MainActor + func testReadingProgressTracksCurrentPage() { + let state = PDFReaderState() + XCTAssertEqual(state.readingProgress, 0) + XCTAssertEqual(state.readingPercentage, 0) + + state.pageCount = 10 + state.currentPageIndex = 0 + XCTAssertEqual(state.readingPercentage, 0) + state.currentPageIndex = 4 + XCTAssertEqual(state.readingProgress, 4.0 / 9.0, accuracy: 0.0001) + XCTAssertEqual(state.readingPercentage, 44) + state.currentPageIndex = 9 + XCTAssertEqual(state.readingPercentage, 100) + } + + @MainActor + func testPageHighlightDefaultsToBlueAndPersists() { + UserDefaults.standard.removeObject(forKey: "com.pdfreader.pageHighlightColor.v1") + let state = PDFReaderState() + let defaultComponents = state.pageHighlightColor.usingColorSpace(.sRGB) + XCTAssertNotNil(defaultComponents) + XCTAssertGreaterThan(defaultComponents?.blueComponent ?? 0, 0.8) + XCTAssertGreaterThan(defaultComponents?.blueComponent ?? 0, defaultComponents?.redComponent ?? 1) + + state.pageHighlightColor = NSColor(srgbRed: 0.9, green: 0.3, blue: 0.2, alpha: 1.0) + let reloaded = PDFReaderState().pageHighlightColor.usingColorSpace(.sRGB) + XCTAssertEqual(reloaded?.redComponent ?? 0, 0.9, accuracy: 0.001) + XCTAssertEqual(reloaded?.greenComponent ?? 0, 0.3, accuracy: 0.001) + XCTAssertEqual(reloaded?.blueComponent ?? 0, 0.2, accuracy: 0.001) + + UserDefaults.standard.removeObject(forKey: "com.pdfreader.pageHighlightColor.v1") + } }