import AppKit import SwiftUI import UniformTypeIdentifiers struct MainView: View { @EnvironmentObject private var appState: AppState @State private var leftSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).leftSidebarIdealWidth @State private var rightSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).rightSidebarIdealWidth @State private var leftSidebarDragStartWidth: CGFloat? @State private var rightSidebarDragStartWidth: CGFloat? var body: some View { GeometryReader { proxy in content(availableWidth: proxy.size.width) .onAppear { appState.updateWindowWidth(proxy.size.width) } .onChange(of: proxy.size.width) { width in appState.updateWindowWidth(width) } } .navigationTitle(appState.displayTitle) .frame( minWidth: ReaderAdaptiveLayout.minimumWindowWidth, minHeight: ReaderAdaptiveLayout.minimumWindowHeight ) .toolbar { ReaderToolbar() } } private func content(availableWidth: CGFloat) -> some View { let layout = ReaderAdaptiveLayout(width: availableWidth) let showsRightSidebar = appState.showCommentsSidebar let showsLeftSidebar = appState.showLeftSidebar && (layout.allowsDualSidebars || !showsRightSidebar) let sidebarWidths = layout.resolvedSidebarWidths( availableWidth: availableWidth, requestedLeft: leftSidebarWidth, requestedRight: rightSidebarWidth, showLeft: showsLeftSidebar, showRight: showsRightSidebar ) return VStack(spacing: 0) { if appState.document == nil { EmptyDocumentView() } else { VStack(spacing: 0) { HStack(spacing: 0) { if showsLeftSidebar { LeftSidebarView() .frame(width: sidebarWidths.left) .transition( .asymmetric( insertion: .move(edge: .leading).combined(with: .opacity), removal: .opacity ) ) SidebarResizeHandle() .gesture(leftSidebarResizeGesture(for: layout)) } PDFReaderView() .frame(minWidth: layout.documentMinWidth, maxWidth: .infinity) if showsRightSidebar { SidebarResizeHandle() .gesture(rightSidebarResizeGesture(for: layout)) RightSidebarView() .frame(width: sidebarWidths.right) .transition( .asymmetric( insertion: .move(edge: .trailing).combined(with: .opacity), removal: .opacity ) ) } } .animation(.easeInOut(duration: 0.18), value: appState.showLeftSidebar) .animation(.easeInOut(duration: 0.18), value: appState.showCommentsSidebar) .animation(.easeInOut(duration: 0.18), value: appState.readerSizeClass) } } StatusBarView() } } private func leftSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in if leftSidebarDragStartWidth == nil { leftSidebarDragStartWidth = leftSidebarWidth } let proposedWidth = (leftSidebarDragStartWidth ?? leftSidebarWidth) + value.translation.width leftSidebarWidth = layout.clampedLeftWidth(proposedWidth) } .onEnded { _ in leftSidebarDragStartWidth = nil } } private func rightSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture { DragGesture(minimumDistance: 0) .onChanged { value in if rightSidebarDragStartWidth == nil { rightSidebarDragStartWidth = rightSidebarWidth } let proposedWidth = (rightSidebarDragStartWidth ?? rightSidebarWidth) - value.translation.width rightSidebarWidth = layout.clampedRightWidth(proposedWidth) } .onEnded { _ in rightSidebarDragStartWidth = nil } } } private struct SidebarResizeHandle: View { @Environment(\.colorScheme) private var colorScheme @State private var isHovering = false var body: some View { Rectangle() .fill(Color.clear) .frame(width: ReaderAdaptiveLayout.resizeHandleWidth) .frame(maxHeight: .infinity) .overlay(alignment: .center) { Capsule() .fill(InterfacePalette.hairline(for: colorScheme).opacity(isHovering ? 0.95 : 0.58)) .frame(width: isHovering ? 2 : 1, height: isHovering ? 42 : 30) .animation(.easeInOut(duration: 0.12), value: isHovering) } .background { Rectangle() .fill(Color.accentColor.opacity(isHovering ? 0.05 : 0)) } .contentShape(Rectangle()) .onHover { hovering in isHovering = hovering if hovering { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } } .onDisappear { if isHovering { NSCursor.pop() } } .help("Resize Sidebar") } } private struct PDFReaderView: View { @EnvironmentObject private var appState: AppState @State private var isDropTargeted = false var body: some View { ZStack(alignment: .bottom) { PDFKitRepresentedView() .background(Color(nsColor: .windowBackgroundColor)) if appState.canShowSelectionActions { SelectionActionBar() .padding(.bottom, 14) .transition(.opacity.combined(with: .move(edge: .bottom))) } if isDropTargeted { DropTargetOverlay() .transition(.opacity.combined(with: .scale(scale: 0.985))) } } .onDrop( of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted ) { providers in appState.openDroppedDocument(from: providers) } .animation(.easeInOut(duration: 0.14), value: appState.canShowSelectionActions) .animation(.easeInOut(duration: 0.16), value: isDropTargeted) } } private struct SelectionActionBar: View { @EnvironmentObject private var appState: AppState @Environment(\.colorScheme) private var colorScheme @AppStorage(AppSettings.highlightColorStorageKey) private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue private var activeHighlightDisplayColor: Color { Color(nsColor: AppSettings.displayColor(forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor))) } var body: some View { HStack(spacing: 2) { actionButton( "Highlight (H)", systemImage: "highlighter", foregroundStyle: activeHighlightDisplayColor ) { appState.addHighlight() } actionButton( "Underline (U)", systemImage: "underline", foregroundStyle: InterfacePalette.primaryText(for: colorScheme) ) { appState.addUnderline() } actionButton( "Comment (C)", systemImage: "text.bubble", foregroundStyle: InterfacePalette.primaryText(for: colorScheme) ) { appState.addComment() } } .controlSize(.small) .padding(5) .background(.regularMaterial, in: Capsule()) .overlay { Capsule() .stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1) } .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.28 : 0.12), radius: 12, y: 5) } private func actionButton( _ title: String, systemImage: String, foregroundStyle: Color, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemImage) .font(.system(size: 14, weight: .semibold)) .symbolRenderingMode(.hierarchical) .foregroundStyle(foregroundStyle) .frame(width: 30, height: 28) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(title) .accessibilityLabel(title) } } private struct EmptyDocumentView: View { @EnvironmentObject private var appState: AppState @Environment(\.colorScheme) private var colorScheme @State private var isDropTargeted = false var body: some View { ZStack { VStack(spacing: 24) { VStack(spacing: 12) { Image(systemName: "doc.text.magnifyingglass") .font(.system(size: 46, weight: .regular)) .symbolRenderingMode(.hierarchical) .foregroundStyle(InterfacePalette.quietText(for: colorScheme)) .frame(width: 62, height: 62) Text("Open a PDF") .font(.title2.weight(.semibold)) Button { appState.openDocument() } label: { Label("Open PDF", systemImage: "doc") } .keyboardShortcut("o") .keyboardShortcut(.defaultAction) .controlSize(.large) } RecentPDFsView() .frame(maxWidth: 420) } .padding(.horizontal, 28) .padding(.vertical, 36) .frame(maxWidth: .infinity, maxHeight: .infinity) if isDropTargeted { DropTargetOverlay() .transition(.opacity.combined(with: .scale(scale: 0.985))) } } .background(Color(nsColor: .windowBackgroundColor)) .onDrop( of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted ) { providers in appState.openDroppedDocument(from: providers) } .animation(.easeInOut(duration: 0.16), value: isDropTargeted) .onAppear { appState.refreshRecentDocuments() } } } private struct RecentPDFsView: View { @EnvironmentObject private var appState: AppState @Environment(\.colorScheme) private var colorScheme private var recentPDFs: [RecentDocumentItem] { Array(appState.recentDocuments.prefix(5)) } var body: some View { if !recentPDFs.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack { Text("Recent") .font(.callout.weight(.semibold)) Spacer() Button { appState.clearRecentDocuments() } label: { Label("Clear Recent PDFs", systemImage: "xmark.circle") } .labelStyle(.iconOnly) .buttonStyle(.plain) .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) .help("Clear Recent PDFs") } ForEach(recentPDFs, id: \.id) { url in Button { appState.openRecentDocument(url.url) } label: { RecentPDFRow(item: url) } .buttonStyle(.plain) .help(url.url.path) } } .padding(.top, 2) } } } private struct RecentPDFRow: View { @Environment(\.colorScheme) private var colorScheme let item: RecentDocumentItem private var detailText: String { let pieces = [item.pageText, item.openedAt.map { Self.relativeDateFormatter.localizedString(for: $0, relativeTo: Date()) }] .compactMap { $0 } if pieces.isEmpty { return item.folderName } return pieces.joined(separator: " - ") } var body: some View { HStack(spacing: 10) { Image(systemName: "doc.text") .font(.system(size: 16, weight: .regular)) .symbolRenderingMode(.hierarchical) .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.callout) .foregroundStyle(InterfacePalette.primaryText(for: colorScheme)) .lineLimit(1) .truncationMode(.middle) Text(detailText) .font(.caption) .foregroundStyle(InterfacePalette.secondaryText(for: colorScheme)) .lineLimit(1) .truncationMode(.middle) } Spacer(minLength: 8) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(InterfacePalette.quietText(for: colorScheme)) } .padding(.horizontal, 10) .frame(height: 46) .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .background { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(InterfacePalette.subtleFill(for: colorScheme)) } .overlay { RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1) } } private static let relativeDateFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated formatter.dateTimeStyle = .named return formatter }() } private struct DropTargetOverlay: View { @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(spacing: 10) { Image(systemName: "tray.and.arrow.down.fill") .font(.system(size: 36, weight: .regular)) .symbolRenderingMode(.hierarchical) Text("Drop to Open") .font(.title3.weight(.semibold)) } .foregroundStyle(Color.accentColor) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor).opacity(colorScheme == .dark ? 0.72 : 0.82)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke( Color.accentColor.opacity(colorScheme == .dark ? 0.68 : 0.58), style: StrokeStyle(lineWidth: 2, dash: [8, 6]) ) .padding(18) } } } private struct StatusBarView: View { @EnvironmentObject private var appState: AppState private var annotationStatusText: String { let count = appState.annotations.count return count == 1 ? "1 annotation" : "\(count) annotations" } var body: some View { if appState.isCompactWindow { compactStatus } else { regularStatus } } private var regularStatus: some View { HStack(spacing: 12) { Text(appState.statusMessage) .lineLimit(1) .truncationMode(.middle) Spacer() if appState.document != nil { if appState.hasUnsentSidebarReplyDraft { Text("Reply draft") } Text(annotationStatusText) Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))") } } .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 12) .frame(height: 26) .background(.bar) } private var compactStatus: some View { HStack(spacing: 8) { Text(appState.statusMessage) .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 8) if appState.document != nil { if appState.hasUnsentSidebarReplyDraft { Image(systemName: "text.bubble") .help("Reply draft") .accessibilityLabel("Reply draft") } Text("\(appState.currentPageIndex + 1)/\(max(appState.pageCount, 1))") .font(.caption.monospacedDigit()) .lineLimit(1) .fixedSize() } } .font(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 10) .frame(height: 24) .background(.bar) } } private struct ReaderToolbar: ToolbarContent { @EnvironmentObject private var appState: AppState @AppStorage(AppSettings.highlightColorStorageKey) private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue @State private var showsHighlightPalette = false @FocusState private var searchFocused: Bool private var activeHighlightColor: NSColor { AppSettings.highlightColor(from: storedHighlightColor) } private var activeHighlightDisplayColor: Color { Color(nsColor: AppSettings.displayColor(forHighlightColor: activeHighlightColor)) } private var pageNumberWidth: CGFloat { CGFloat(max(2, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 8 : 8.4) + (appState.isCompactWindow ? 16 : 20) } private var totalPageNumberWidth: CGFloat { CGFloat(max(1, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 7.8 : 8.2) + (appState.isCompactWindow ? 18 : 21) } private var pageSeparatorSpacing: CGFloat { appState.isCompactWindow ? 28 : 46 } private var pageControlWidth: CGFloat { pageNumberWidth + totalPageNumberWidth + (appState.isCompactWindow ? 46 : 56) } private var compactToolbarControlsEnabled: Bool { appState.isCompactWindow } private var annotationToolsMenu: some View { Menu { Button { appState.toggleHighlighterMode() } label: { Label( appState.isHighlighterModeActive ? "Turn Highlighter Off (H)" : "Turn Highlighter On (H)", systemImage: "highlighter" ) } .disabled(appState.document == nil) Button { appState.addUnderline() } label: { Label("Underline Selection (U)", systemImage: "underline") } .disabled(appState.document == nil || !appState.hasTextSelection) Button { appState.addComment() } label: { Label("Comment (C)", systemImage: "text.bubble") } .disabled(appState.document == nil || !appState.hasTextSelection) } label: { Image(systemName: "pencil.tip.crop.circle") } .help("Annotation Tools") .disabled(appState.document == nil) } private var highlightColorButton: some View { Button { showsHighlightPalette.toggle() } label: { Image(systemName: "circle.fill") .foregroundStyle(activeHighlightDisplayColor) } .popover(isPresented: $showsHighlightPalette, arrowEdge: .bottom) { HighlightPalettePopover( storedHighlightColor: $storedHighlightColor, onSelect: { color in showsHighlightPalette = false appState.selectHighlightColor(color, applyToSelection: appState.hasTextSelection) } ) } .help("Highlight Color") .accessibilityLabel("Highlight Color Palette") .disabled(appState.document == nil) } var body: some ToolbarContent { if appState.document == nil { ToolbarItemGroup(placement: .primaryAction) { Button { appState.openDocument() } label: { Label("Open", systemImage: "doc") } .help("Open PDF") } } else { ToolbarItemGroup(placement: .navigation) { Button { appState.togglePageSidebar() } label: { Image(systemName: "square.grid.2x2") } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help("Toggle Page Thumbnails") Button { appState.toggleBookmarkForCurrentPage() } label: { Image(systemName: appState.currentPageBookmark == nil ? "bookmark" : "bookmark.fill") } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help(appState.bookmarkActionHelpText) } ToolbarItem(placement: .principal) { HStack(spacing: 5) { Button { appState.goToPreviousPage() } label: { Image(systemName: "chevron.up") } .disabled(!appState.canGoToPreviousPage) .help("Previous Page") TextField("Page", text: $appState.pageText) .textFieldStyle(.plain) .multilineTextAlignment(.center) .font(.system(size: 13, weight: .semibold, design: .rounded)) .frame(width: pageNumberWidth, height: compactToolbarControlsEnabled ? 21 : 22) .onSubmit { appState.goToPageFromField() } .disabled(appState.document == nil) HStack(spacing: pageSeparatorSpacing) { Text("/") Text("\(max(appState.pageCount, 1))") } .font(.system(size: 13, weight: .regular, design: .rounded).monospacedDigit()) .foregroundStyle(.secondary) .frame(width: totalPageNumberWidth + pageSeparatorSpacing, alignment: .leading) Button { appState.goToNextPage() } label: { Image(systemName: "chevron.down") } .disabled(!appState.canGoToNextPage) .help("Next Page") } .controlSize(.small) .frame(width: pageControlWidth) } ToolbarItemGroup(placement: .primaryAction) { if appState.showToolbarSearch { HStack(spacing: 7) { ZStack(alignment: .trailing) { TextField("Search", text: $appState.searchText) .textFieldStyle(.plain) .font(.system(size: 13)) .padding(.leading, 10) .padding(.trailing, appState.canClearSearchQuery ? 28 : 10) .frame(height: compactToolbarControlsEnabled ? 26 : 28) .background { RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) } .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke( searchFocused ? Color.accentColor : Color(nsColor: .separatorColor), lineWidth: searchFocused ? 2 : 1 ) } .focused($searchFocused) .onChange(of: appState.toolbarSearchFocusRequest) { _ in searchFocused = true } .onSubmit { appState.runSearch() } .disabled(appState.document == nil) if appState.canClearSearchQuery { Button { appState.clearSearchQuery() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12, weight: .semibold)) .symbolRenderingMode(.hierarchical) } .buttonStyle(.plain) .foregroundStyle(.secondary) .padding(.trailing, 9) .disabled(appState.document == nil) .help("Clear Search") .accessibilityLabel("Clear Search") } } .frame(width: compactToolbarControlsEnabled ? 138 : 154, height: compactToolbarControlsEnabled ? 26 : 28) if let searchSummaryText = appState.searchSummaryText { Text(searchSummaryText) .font(.system(size: 12, weight: .medium, design: .rounded).monospacedDigit()) .foregroundStyle(.secondary) .lineLimit(1) .frame(width: searchSummaryText == "No match" ? 58 : 34, alignment: .leading) .layoutPriority(1) .accessibilityLabel(searchSummaryText) } } Button { appState.previousSearchResult() } label: { Image(systemName: "chevron.left") } .labelStyle(.iconOnly) .disabled(appState.searchResults.isEmpty) .help("Previous Search Match") Button { appState.nextSearchResult() } label: { Image(systemName: "chevron.right") } .labelStyle(.iconOnly) .disabled(appState.searchResults.isEmpty) .help("Next Search Match") Button { appState.hideSearch() } label: { Image(systemName: "xmark") } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help("Close Search") } else { Button { appState.showSearch() } label: { Image(systemName: "magnifyingglass") } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help("Search") } } ToolbarItem(placement: .primaryAction) { annotationToolsMenu } ToolbarItem(placement: .primaryAction) { highlightColorButton } ToolbarItemGroup(placement: .primaryAction) { Button { appState.fitWidth() } label: { Image(systemName: "arrow.left.and.right") } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help("Fit to Width") Button { appState.toggleRightSidebarVisibility() } label: { Image(systemName: "sidebar.right") .foregroundStyle(appState.showCommentsSidebar ? Color.accentColor : Color.primary) } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help(appState.showCommentsSidebar ? "Hide Right Sidebar" : "Show Right Sidebar") .accessibilityLabel("Toggle Right Sidebar") Button { appState.shareDocument() } label: { Image(systemName: "square.and.arrow.up") } .labelStyle(.iconOnly) .disabled(appState.document == nil) .help("Share PDF") } } } } private struct HighlightPalettePopover: View { @Binding var storedHighlightColor: String let onSelect: (NSColor) -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "highlighter") .foregroundStyle(Color(nsColor: AppSettings.displayColor( forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor) ))) Text("Highlight") .font(.headline) Spacer(minLength: 0) } HStack(spacing: 8) { ForEach(Array(AppSettings.highlightSwatches.enumerated()), id: \.offset) { _, swatch in Button { storedHighlightColor = AppSettings.storageString(forHighlightColor: swatch.color) onSelect(swatch.color) } label: { ZStack { Circle() .fill(Color(nsColor: AppSettings.displayColor(forHighlightColor: swatch.color))) .frame(width: 24, height: 24) .overlay { Circle() .stroke( isSelected(swatch.color) ? Color.accentColor : Color(nsColor: .separatorColor), lineWidth: isSelected(swatch.color) ? 2 : 0.8 ) } if isSelected(swatch.color) { Image(systemName: "checkmark") .font(.system(size: 9, weight: .bold)) .foregroundStyle(Color(nsColor: .labelColor)) } } } .buttonStyle(.plain) .help(swatch.name) .accessibilityLabel("Highlight \(swatch.name)") } } } .padding(14) .frame(width: 284) } private func isSelected(_ color: NSColor) -> Bool { AppSettings.storageString(forHighlightColor: color) == storedHighlightColor } }