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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Native macOS PDF reader built with SwiftUI + PDFKit as a Swift Package
files are reported and dropped on open.
- **Reading modes** — layout menu, book mode, RTL, night background,
presentation (full-screen).
- **Tabs + session restore** — open multiple PDFs as tabs in the tab bar
(⌘T / + button); click to switch, × to close. Quitting and relaunching
reopens the same tabs with each file back on the page you left it.
- **Settings** — ⌘, opens Settings; toggle "Remember where I left off
in each file" (on by default) to control last-position restore.
- **Updates** — Help → Check for Updates… compares the build's version
Expand Down
101 changes: 101 additions & 0 deletions Sources/PDFReader/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ public struct ContentView: View {
sidebarDivider
}
VStack(spacing: 0) {
// Tab bar — shows open PDF tabs like Adobe Reader
if !state.tabURLs.isEmpty {
TabBar(state: state)
.frame(height: 32)
}
if state.document == nil {
welcomeView
} else {
Expand Down Expand Up @@ -179,6 +184,8 @@ public struct ContentView: View {
.onAppear {
AboutWindow.open = { openWindow(id: "about") }
SettingsWindow.open = { openWindow(id: "settings") }
// Bring back the tabs and pages that were open at last quit.
state.restoreSession()
// 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 Down Expand Up @@ -770,3 +777,97 @@ extension View {
}
}
}


// MARK: - Tab Bar

/// One tab: filename, click to switch, × to close.
struct TabButton: View {
let title: String
let isActive: Bool
let onSelect: () -> Void
let onClose: () -> Void

var body: some View {
HStack(spacing: 6) {
Image(systemName: "doc.text")
.font(.system(size: 10))
.foregroundStyle(isActive ? Color.accentColor : .secondary)
Text(title)
.font(.system(size: 11, weight: isActive ? .semibold : .regular))
.lineLimit(1)
.frame(maxWidth: 160, alignment: .leading)
.truncationMode(.middle)
Button {
onClose()
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 14, height: 14)
.background(Circle().fill(Color.secondary.opacity(0.15)))
}
.buttonStyle(.plain)
.help("Close tab")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isActive ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(isActive ? Color.accentColor.opacity(0.5) : .clear, lineWidth: 1)
)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.help(title)
}
}

/// Adobe-style tab bar, left-aligned, with a New Tab (+) button.
struct TabBar: View {
@ObservedObject var state: PDFReaderState

var body: some View {
HStack(spacing: 4) {
ForEach(Array(state.tabURLs.enumerated()), id: \.element) { index, url in
TabButton(
title: url.lastPathComponent,
isActive: state.activeTabIndex == index,
onSelect: { state.switchToTab(index) },
onClose: { state.closeTab(at: index) }
)
}
Button {
openNewTab()
} label: {
Image(systemName: "plus")
.font(.system(size: 11, weight: .semibold))
.frame(width: 22, height: 22)
}
.buttonStyle(.plain)
.background(Circle().fill(Color.primary.opacity(0.04)))
.help("Open PDF in new tab (⌘T)")
.keyboardShortcut("t", modifiers: .command)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(state.appTheme.chromeBackground)
}

private func openNewTab() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.allowedContentTypes = [.pdf]
panel.begin { response in
if response == .OK, let url = panel.url {
state.open(url: url)
}
}
}
}
107 changes: 101 additions & 6 deletions Sources/PDFReaderKit/Models/PDFReaderState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,17 @@ public final class PDFReaderState: ObservableObject {
// MARK: - Tab management

/// URLs of all open PDF tabs.
@Published public var tabURLs: [URL] = []
@Published public var tabURLs: [URL] = [] {
didSet { saveSession() }
}

/// Index of the currently active tab.
@Published public var activeTabIndex: Int = 0 {
didSet {
if tabURLs.isEmpty { activeTabIndex = 0 }
else if activeTabIndex >= tabURLs.count { activeTabIndex = tabURLs.count - 1 }
else if activeTabIndex < 0 { activeTabIndex = 0 }
saveSession()
}
}

Expand Down Expand Up @@ -223,6 +226,11 @@ public final class PDFReaderState: ObservableObject {
private static let thumbnailScaleKey = "com.pdfreader.thumbnailScale.v1"
private static let remembersPositionKey = "com.pdfreader.remembersLastPosition.v1"
private static let lastPositionsKey = "com.pdfreader.lastPositions.v1"
private static let openTabsKey = "com.pdfreader.openTabs.v1"
private static let activeTabKey = "com.pdfreader.activeTab.v1"
/// While restoring the session, tab mutations must not write back
/// (mirrors the `loadingPrefs` guard for highlight/theme prefs).
private var restoringSession = false
/// While loading, assignments must not save back mid-load (the choice
/// restores before the custom value, which would clobber it).
private var loadingPrefs = false
Expand Down Expand Up @@ -314,12 +322,51 @@ public final class PDFReaderState: ObservableObject {

@discardableResult
public func open(url: URL) -> Bool {
// Add URL to tabs if not already present
if !tabURLs.contains(url) {
// Re-opening an already-open file activates its existing tab.
if let idx = tabURLs.firstIndex(of: url) {
activeTabIndex = idx
} else {
tabURLs.append(url)
activeTabIndex = tabURLs.count - 1
}
activeTabIndex = tabURLs.count - 1
return loadDocument(at: url)
}

/// Switches the active tab and loads its document.
@discardableResult
public func switchToTab(_ index: Int) -> Bool {
guard tabURLs.indices.contains(index) else { return false }
activeTabIndex = index
return loadDocument(at: tabURLs[index])
}

/// Closes the tab at `index`, activating a neighbor, or clears the
/// view when the last tab closes.
public func closeTab(at index: Int) {
guard tabURLs.indices.contains(index) else { return }
tabURLs.remove(at: index)
if tabURLs.isEmpty {
activeTabIndex = 0
document = nil
fileURL = nil
pageCount = 0
outlineNodes = []
annotationRows = []
searchHits = []
searchQuery = ""
pdfView?.highlightedSelections = nil
pdfView?.currentSelection = nil
statusMessage = "Open a PDF to get started."
return
}
let next = min(index, tabURLs.count - 1)
activeTabIndex = next
loadDocument(at: tabURLs[next])
}

/// Loads `url` into the viewer. The URL must already be tracked in
/// `tabURLs` by the caller (`open`/`switchToTab`).
private func loadDocument(at url: URL) -> Bool {
guard let doc = PDFDocument(url: url) else {
statusMessage = "Could not open \(url.lastPathComponent)."
return false
Expand All @@ -330,6 +377,8 @@ public final class PDFReaderState: ObservableObject {
pageCount = doc.pageCount
searchHits = []
searchQuery = ""
pdfView?.highlightedSelections = nil
pdfView?.currentSelection = nil
rebuildOutline()
refreshAnnotations()
undoStack.removeAll()
Expand Down Expand Up @@ -365,11 +414,57 @@ public final class PDFReaderState: ObservableObject {

private func restoreLastPositionIndex() -> Int? {
guard remembersLastPosition, let url = fileURL else { return nil }
let all = UserDefaults.standard.dictionary(forKey: Self.lastPositionsKey) as? [String: Int]
guard let idx = all?[url.path] else { return nil }
let all = UserDefaults.standard.dictionary(forKey: Self.lastPositionsKey) as? [String: Int] ?? [:]
guard let idx = all[url.path] else { return nil }
return max(0, min(idx, max(0, pageCount - 1)))
}

// MARK: - Session restore

/// Persists the open tabs and active tab so quitting and relaunching
/// brings back the same documents (per-file last page is saved by
/// `saveLastPosition()` on every page change).
private func saveSession() {
guard !restoringSession else { return }
UserDefaults.standard.set(tabURLs.map(\.path), forKey: Self.openTabsKey)
UserDefaults.standard.set(activeTabIndex, forKey: Self.activeTabKey)
}

/// Reopens the tabs from the previous session. Files that no longer
/// exist are skipped; if nothing loads, the app starts clean. Called
/// from `ContentView.onAppear` once the PDF view exists (also keeps
/// library tests, which build a bare state, unaffected).
public func restoreSession() {
guard document == nil, tabURLs.isEmpty else { return }
let saved = UserDefaults.standard.stringArray(forKey: Self.openTabsKey) ?? []
let urls = saved
.map { URL(fileURLWithPath: $0) }
.filter { FileManager.default.fileExists(atPath: $0.path) }
guard !urls.isEmpty else { return }

restoringSession = true
tabURLs = urls
let savedIndex = UserDefaults.standard.integer(forKey: Self.activeTabKey)
activeTabIndex = min(max(0, savedIndex), urls.count - 1)
restoringSession = false

// Prefer the active tab; if it fails to parse, try the others.
var loaded = false
for idx in [activeTabIndex] + tabURLs.indices.filter({ $0 != activeTabIndex }) {
activeTabIndex = idx
if loadDocument(at: tabURLs[idx]) {
loaded = true
break
}
}
if !loaded {
tabURLs = []
activeTabIndex = 0
statusMessage = "Open a PDF to get started."
}
saveSession()
}

// MARK: - Navigation

public func goToPage(index: Int, viaView: Bool = true) {
Expand Down
Loading