Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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…
Expand Down
10 changes: 10 additions & 0 deletions Sources/PDFReader/PDFReaderApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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") {
Expand Down
141 changes: 134 additions & 7 deletions Sources/PDFReader/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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)
}
}
Loading
Loading