Clean up repository structure and release docs

This commit is contained in:
Akshay Kolli
2026-06-30 00:18:59 -07:00
parent 226c29b565
commit 992f1444e6
66 changed files with 330 additions and 1193 deletions

View File

@@ -0,0 +1,138 @@
import AppKit
import Foundation
import IHatePDFsCore
import SwiftUI
enum AppSettings {
static let highlightColorStorageKey = "IHatePDFs.highlightColorRGBA.v1"
static let commentColorStorageKey = "IHatePDFs.commentColorRGBA.v1"
static let defaultHighlightColorStorageValue = storageString(for: AcademicAnnotationPalette.highlight)
static let defaultCommentColorStorageValue = storageString(for: AcademicAnnotationPalette.comment)
private static let minimumHighlightAlpha: CGFloat = 0.38
private static let minimumCommentAlpha: CGFloat = 0.12
static var highlightSwatches: [(name: String, color: NSColor)] {
[
("Yellow", AcademicAnnotationPalette.highlight),
("Green", NSColor(calibratedRed: 0.27, green: 0.78, blue: 0.28, alpha: 0.58)),
("Aqua", NSColor(calibratedRed: 0.0, green: 0.70, blue: 0.78, alpha: 0.58)),
("Pink", NSColor(calibratedRed: 1.0, green: 0.32, blue: 0.62, alpha: 0.58)),
("Orange", NSColor(calibratedRed: 1.0, green: 0.48, blue: 0.08, alpha: 0.58)),
("Purple", NSColor(calibratedRed: 0.62, green: 0.36, blue: 0.94, alpha: 0.58)),
("Cyan", NSColor(calibratedRed: 0.0, green: 0.78, blue: 0.92, alpha: 0.56)),
("Graphite", NSColor(calibratedWhite: 0.28, alpha: 0.50))
]
}
static var highlightColor: NSColor {
get {
highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey))
}
set {
UserDefaults.standard.set(storageString(forHighlightColor: newValue), forKey: highlightColorStorageKey)
}
}
static var commentColor: NSColor {
get {
commentColor(from: UserDefaults.standard.string(forKey: commentColorStorageKey))
}
set {
UserDefaults.standard.set(storageString(forCommentColor: newValue), forKey: commentColorStorageKey)
}
}
static func highlightColor(from storageValue: String?) -> NSColor {
AnnotationColorPreference.color(
from: storageValue,
fallback: AcademicAnnotationPalette.highlight,
minimumAlpha: minimumHighlightAlpha
)
}
static func commentColor(from storageValue: String?) -> NSColor {
AnnotationColorPreference.color(
from: storageValue,
fallback: AcademicAnnotationPalette.comment,
minimumAlpha: minimumCommentAlpha
)
}
static func storageString(for color: NSColor) -> String {
AnnotationColorPreference.storageString(for: color)
}
static func storageString(for color: Color) -> String {
storageString(for: NSColor(color))
}
static func storageString(forHighlightColor color: NSColor) -> String {
storageString(for: highlightColor(from: storageString(for: color)))
}
static func storageString(forHighlightColor color: Color) -> String {
storageString(forHighlightColor: NSColor(color))
}
static func displayColor(forHighlightColor color: NSColor) -> NSColor {
highlightColor(from: storageString(for: color)).withAlphaComponent(1)
}
static func storageString(forCommentColor color: NSColor) -> String {
storageString(for: commentColor(from: storageString(for: color)))
}
static func storageString(forCommentColor color: Color) -> String {
storageString(forCommentColor: NSColor(color))
}
}
struct SettingsView: View {
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
@AppStorage(AppSettings.commentColorStorageKey)
private var storedCommentColor = AppSettings.defaultCommentColorStorageValue
var body: some View {
Form {
Section("Annotations") {
ColorPicker(
"Highlight color",
selection: highlightColor,
supportsOpacity: true
)
ColorPicker(
"Comment color",
selection: commentColor,
supportsOpacity: true
)
Button {
storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
storedCommentColor = AppSettings.defaultCommentColorStorageValue
} label: {
Label("Reset Annotation Colors", systemImage: "arrow.counterclockwise")
}
}
}
.padding(20)
.frame(width: 360)
}
private var highlightColor: Binding<Color> {
Binding {
Color(nsColor: AppSettings.highlightColor(from: storedHighlightColor))
} set: { newValue in
storedHighlightColor = AppSettings.storageString(forHighlightColor: newValue)
}
}
private var commentColor: Binding<Color> {
Binding {
Color(nsColor: AppSettings.commentColor(from: storedCommentColor))
} set: { newValue in
storedCommentColor = AppSettings.storageString(forCommentColor: newValue)
}
}
}

2442
sources/app/AppState.swift Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
import IHatePDFsCore
import SwiftUI
@MainActor
final class CommentPopoverModel: ObservableObject {
let context: AnnotationEditorContext
@Published var text: String
@Published var author: String
private weak var appState: AppState?
private var didFinish = false
init(context: AnnotationEditorContext, appState: AppState) {
self.context = context
self.appState = appState
self.text = context.initialText
self.author = context.initialAuthor
}
func commit() {
guard !didFinish else { return }
didFinish = true
appState?.saveEditor(context, text: text, author: author)
}
func delete() {
guard !didFinish else { return }
didFinish = true
appState?.deleteAnnotations(in: context)
}
func updateDraft() {
guard !didFinish else { return }
appState?.updateEditorDraft(context, text: text, author: author)
}
func reply() {
guard !didFinish else { return }
didFinish = true
appState?.replyFromEditor(context, text: text, author: author)
}
}
struct CommentEditorView: View {
@ObservedObject var model: CommentPopoverModel
@Environment(\.colorScheme) private var colorScheme
private let editorHorizontalInset: CGFloat = 9
private let editorVerticalInset: CGFloat = 7
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
commentField
footer
}
.padding(12)
.frame(width: 340)
.background(.regularMaterial)
.onChange(of: model.text) { _ in
model.updateDraft()
}
.onChange(of: model.author) { _ in
model.updateDraft()
}
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: symbolName)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.frame(width: 16)
Text(title)
.font(.headline)
.lineLimit(1)
}
}
private var commentField: some View {
ZStack(alignment: .topLeading) {
CommitTextView(
text: $model.text,
font: NSFont.preferredFont(forTextStyle: .body),
onCommit: {
model.commit()
}
)
.padding(.horizontal, editorHorizontalInset)
.padding(.vertical, editorVerticalInset)
if model.text.isEmpty {
Text(placeholderText)
.font(.body)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.padding(.leading, editorHorizontalInset + 7)
.padding(.top, editorVerticalInset)
.allowsHitTesting(false)
}
}
.frame(minHeight: 118)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
}
private var footer: some View {
HStack(spacing: 8) {
TextField("Author", text: $model.author)
.textFieldStyle(.plain)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.onSubmit {
model.commit()
}
.padding(.horizontal, 7)
.frame(height: 28)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
.frame(width: 190)
.layoutPriority(1)
Spacer()
if model.context.allowsReply,
!model.context.isNewAnnotation,
model.context.primaryAnnotation != nil {
Button {
model.reply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
.labelStyle(.iconOnly)
.frame(width: 34)
.help("Reply")
.accessibilityLabel("Reply")
}
if model.context.allowsDelete {
if model.context.isNewAnnotation {
Button {
model.delete()
} label: {
Label("Cancel", systemImage: "xmark")
}
.labelStyle(.iconOnly)
.keyboardShortcut(.cancelAction)
.frame(width: 34)
.help("Cancel")
} else {
Button(role: .destructive) {
model.delete()
} label: {
Label("Delete Annotation", systemImage: "trash")
}
.labelStyle(.iconOnly)
.frame(width: 34)
.help("Delete Annotation")
}
}
}
}
private var title: String {
model.context.title.replacingOccurrences(of: " Comment", with: "")
}
private var placeholderText: String {
model.context.allowsReply ? "Add comment" : "Edit text"
}
private var symbolName: String {
guard let annotation = model.context.primaryAnnotation else {
return "text.bubble"
}
let kind = AcademicAnnotationKind(annotation: annotation)
return kind.symbolName
}
}

View File

@@ -0,0 +1,106 @@
import AppKit
import IHatePDFsCore
import SwiftUI
struct CommitTextView: NSViewRepresentable {
@Binding var text: String
var font: NSFont
var focusOnAppear = true
var onCommit: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
let textView = CommitTextNSTextView()
textView.delegate = context.coordinator
textView.string = text
textView.font = font
textView.textColor = .labelColor
textView.drawsBackground = false
textView.isRichText = false
textView.importsGraphics = false
textView.allowsUndo = true
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.onCommit = onCommit
scrollView.documentView = textView
context.coordinator.textView = textView
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? CommitTextNSTextView else { return }
textView.onCommit = onCommit
textView.font = font
if textView.string != text {
textView.string = text
}
guard focusOnAppear,
!context.coordinator.didFocus,
textView.window != nil
else {
return
}
context.coordinator.didFocus = true
DispatchQueue.main.async { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
@Binding var text: String
weak var textView: NSTextView?
var didFocus = false
init(text: Binding<String>) {
_text = text
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
text = textView.string
}
}
}
private final class CommitTextNSTextView: NSTextView {
var onCommit: (() -> Void)?
override func keyDown(with event: NSEvent) {
guard !hasMarkedText() else {
super.keyDown(with: event)
return
}
if ReturnKeyCommitPolicy.shouldCommit(
keyCode: UInt16(event.keyCode),
shift: event.modifierFlags.contains(.shift),
option: event.modifierFlags.contains(.option),
command: event.modifierFlags.contains(.command),
control: event.modifierFlags.contains(.control),
isEditableMultilineText: isEditable && !isFieldEditor
) {
onCommit?()
return
}
super.keyDown(with: event)
}
}

View File

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

View File

@@ -0,0 +1,53 @@
import AppKit
import SwiftUI
enum InterfacePalette {
static func primaryText(for scheme: ColorScheme) -> Color {
Color(nsColor: .labelColor).opacity(scheme == .dark ? 0.88 : 0.86)
}
static func secondaryText(for scheme: ColorScheme) -> Color {
Color(nsColor: .secondaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.88)
}
static func quietText(for scheme: ColorScheme) -> Color {
Color(nsColor: .tertiaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.9)
}
static func actionText(for scheme: ColorScheme) -> Color {
Color(nsColor: .controlAccentColor).opacity(scheme == .dark ? 0.78 : 0.72)
}
static func subtleFill(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.045 : 0.026))
}
static func fieldFill(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.032))
}
static func hairline(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.12 : 0.095))
}
static func connector(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.14 : 0.11))
}
static func markerFill(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.035))
}
static func markerStroke(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.16 : 0.13))
}
static func selectedRowFill(for scheme: ColorScheme) -> Color {
Color(nsColor: .unemphasizedSelectedContentBackgroundColor)
.opacity(scheme == .dark ? 0.38 : 0.48)
}
private static func overlayBase(for scheme: ColorScheme) -> NSColor {
scheme == .dark ? .white : .black
}
}

861
sources/app/MainView.swift Normal file
View File

@@ -0,0 +1,861 @@
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
}
}

View File

@@ -0,0 +1,768 @@
import AppKit
import IHatePDFsCore
import PDFKit
import SwiftUI
final class AcademicPDFView: PDFView {
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
var onCancelPlacement: (() -> Void)?
var onSelectionComment: (() -> Void)?
var onHighlighterSelection: (() -> Void)?
var onToggleHighlighterKey: (() -> Void)?
var onUnderlineSelectionKey: (() -> Void)?
var onCommentSelectionKey: (() -> Void)?
var onPreviousPageKey: (() -> Void)?
var onNextPageKey: (() -> Void)?
var placementTool: AnnotationPlacementTool? {
didSet {
guard oldValue != placementTool else { return }
window?.invalidateCursorRects(for: self)
}
}
var isHighlighterModeActive = false {
didSet {
guard oldValue != isHighlighterModeActive else { return }
window?.invalidateCursorRects(for: self)
if mouseIsInside {
applyToolCursorIfNeeded()
}
}
}
private var handledAnnotationMouseDown = false
override var acceptsFirstResponder: Bool { true }
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
backgroundColor = NSColor.underPageBackgroundColor
needsDisplay = true
}
override func mouseDown(with event: NSEvent) {
handledAnnotationMouseDown = false
let point = convert(event.locationInWindow, from: nil)
if let page = page(for: point, nearest: false) ?? page(for: point, nearest: true) {
closeNativePopups(on: page)
let pagePoint = convert(point, to: page)
if placementTool != nil {
onPlacementClick?(page, pagePoint)
return
}
if let annotation = editableAnnotation(on: page, at: pagePoint) {
handledAnnotationMouseDown = true
closeNativePopups(on: page)
onAnnotationClick?(annotation, page)
return
}
}
super.mouseDown(with: event)
window?.makeFirstResponder(self)
DispatchQueue.main.async { [weak self] in
guard let self,
let page = self.page(for: point, nearest: false)
else {
return
}
self.closeNativePopups(on: page)
}
}
override func mouseUp(with event: NSEvent) {
if handledAnnotationMouseDown {
handledAnnotationMouseDown = false
return
}
let point = convert(event.locationInWindow, from: nil)
let page = page(for: point, nearest: false) ?? page(for: point, nearest: true)
let pagePoint = page.map { convert(point, to: $0) }
super.mouseUp(with: event)
if isHighlighterModeActive, hasCommentableSelection {
DispatchQueue.main.async { [weak self] in
guard self?.hasCommentableSelection == true else { return }
self?.onHighlighterSelection?()
}
return
}
guard let page else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let clickedAnnotation = pagePoint.flatMap {
self.editableAnnotation(on: page, at: $0)
}
let target = clickedAnnotation ?? self.openNativePopupOwner(on: page)
self.closeNativePopups(on: page)
if let target {
self.onAnnotationClick?(target, page)
}
}
}
override func rightMouseDown(with event: NSEvent) {
guard hasCommentableSelection else {
super.rightMouseDown(with: event)
return
}
let menu = commentMenu(from: super.menu(for: event))
NSMenu.popUpContextMenu(menu, with: event, for: self)
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53, placementTool != nil {
onCancelPlacement?()
return
}
if !event.isARepeat,
event.modifierFlags.intersection([.command, .control, .option]).isEmpty,
let key = event.charactersIgnoringModifiers?.lowercased() {
switch key {
case "h":
onToggleHighlighterKey?()
return
case "u":
onUnderlineSelectionKey?()
return
case "c":
onCommentSelectionKey?()
return
default:
break
}
}
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 123, 126:
onPreviousPageKey?()
case 124, 125:
onNextPageKey?()
default:
super.keyDown(with: event)
}
}
override func resetCursorRects() {
super.resetCursorRects()
if isHighlighterModeActive {
addCursorRect(bounds, cursor: Self.highlighterCursor)
} else if placementTool != nil {
addCursorRect(bounds, cursor: .crosshair)
}
}
override func cursorUpdate(with event: NSEvent) {
if applyToolCursorIfNeeded() {
return
}
super.cursorUpdate(with: event)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
applyToolCursorIfNeeded()
}
override func mouseDragged(with event: NSEvent) {
super.mouseDragged(with: event)
applyToolCursorIfNeeded()
}
override func menu(for event: NSEvent) -> NSMenu? {
commentMenu(from: super.menu(for: event))
}
@discardableResult
private func applyToolCursorIfNeeded() -> Bool {
if isHighlighterModeActive {
Self.highlighterCursor.set()
return true
}
if placementTool != nil {
NSCursor.crosshair.set()
return true
}
return false
}
private var mouseIsInside: Bool {
guard let window else { return false }
return bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
private static let highlighterCursor: NSCursor = {
let size = NSSize(width: 36, height: 36)
let image = highResolutionCursorImage(size: size) {
NSGraphicsContext.current?.imageInterpolation = .high
NSGraphicsContext.current?.shouldAntialias = true
let outline = NSColor.black.withAlphaComponent(0.58)
let bodyTint = NSColor(red: 1.0, green: 0.86, blue: 0.28, alpha: 0.98)
let bodyShade = NSColor(red: 1.0, green: 0.58, blue: 0.12, alpha: 0.98)
let nibColor = NSColor(red: 0.16, green: 0.07, blue: 0.02, alpha: 0.96)
let highlightTrail = NSBezierPath(roundedRect: NSRect(x: 4.5, y: 3.6, width: 17.5, height: 5.2), xRadius: 2.6, yRadius: 2.6)
NSColor(red: 1.0, green: 0.93, blue: 0.28, alpha: 0.34).setFill()
highlightTrail.fill()
let shadow = NSShadow()
shadow.shadowOffset = NSSize(width: 0.7, height: -1.1)
shadow.shadowBlurRadius = 2.4
shadow.shadowColor = NSColor.black.withAlphaComponent(0.24)
let body = NSBezierPath()
body.move(to: NSPoint(x: 10.4, y: 9.2))
body.line(to: NSPoint(x: 21.9, y: 23.5))
body.curve(
to: NSPoint(x: 25.3, y: 23.8),
controlPoint1: NSPoint(x: 22.8, y: 24.4),
controlPoint2: NSPoint(x: 24.2, y: 24.5)
)
body.line(to: NSPoint(x: 28.8, y: 20.8))
body.curve(
to: NSPoint(x: 28.1, y: 17.5),
controlPoint1: NSPoint(x: 29.7, y: 20.0),
controlPoint2: NSPoint(x: 29.3, y: 18.4)
)
body.line(to: NSPoint(x: 15.2, y: 4.1))
body.curve(
to: NSPoint(x: 11.7, y: 4.3),
controlPoint1: NSPoint(x: 13.9, y: 3.1),
controlPoint2: NSPoint(x: 12.4, y: 3.0)
)
body.line(to: NSPoint(x: 8.7, y: 6.8))
body.curve(
to: NSPoint(x: 10.4, y: 9.2),
controlPoint1: NSPoint(x: 8.9, y: 7.7),
controlPoint2: NSPoint(x: 9.5, y: 8.6)
)
body.close()
body.lineJoinStyle = .round
NSGraphicsContext.saveGraphicsState()
shadow.set()
NSGradient(colors: [bodyTint, bodyShade])?.draw(in: body, angle: 38)
NSGraphicsContext.restoreGraphicsState()
outline.setStroke()
body.lineWidth = 1.05
body.stroke()
let grip = NSBezierPath()
grip.move(to: NSPoint(x: 17.2, y: 10.4))
grip.line(to: NSPoint(x: 25.0, y: 18.6))
grip.lineWidth = 1.0
NSColor(red: 0.58, green: 0.27, blue: 0.02, alpha: 0.22).setStroke()
grip.stroke()
let shine = NSBezierPath()
shine.move(to: NSPoint(x: 15.2, y: 9.5))
shine.curve(
to: NSPoint(x: 25.0, y: 20.7),
controlPoint1: NSPoint(x: 18.8, y: 12.6),
controlPoint2: NSPoint(x: 22.1, y: 17.8)
)
shine.lineWidth = 1.05
NSColor.white.withAlphaComponent(0.46).setStroke()
shine.stroke()
let nib = NSBezierPath()
nib.move(to: NSPoint(x: 5.3, y: 6.0))
nib.line(to: NSPoint(x: 10.6, y: 9.2))
nib.curve(
to: NSPoint(x: 14.4, y: 4.6),
controlPoint1: NSPoint(x: 11.8, y: 9.4),
controlPoint2: NSPoint(x: 13.6, y: 4.9)
)
nib.line(to: NSPoint(x: 8.6, y: 3.1))
nib.close()
nibColor.setFill()
nib.fill()
outline.setStroke()
nib.lineWidth = 0.9
nib.stroke()
}
return NSCursor(image: image, hotSpot: NSPoint(x: 8, y: 29))
}()
private static func highResolutionCursorImage(
size: NSSize,
scale: CGFloat = 2,
draw: () -> Void
) -> NSImage {
let image = NSImage(size: size)
guard let representation = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(size.width * scale),
pixelsHigh: Int(size.height * scale),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
),
let context = NSGraphicsContext(bitmapImageRep: representation)
else {
return image
}
representation.size = size
let previousContext = NSGraphicsContext.current
NSGraphicsContext.current = context
NSGraphicsContext.saveGraphicsState()
context.cgContext.scaleBy(x: scale, y: scale)
draw()
NSGraphicsContext.restoreGraphicsState()
NSGraphicsContext.current = previousContext
image.addRepresentation(representation)
return image
}
private var hasCommentableSelection: Bool {
guard let selection = currentSelection,
!selection.pages.isEmpty,
selection.string?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
else {
return false
}
return true
}
private func commentMenu(from baseMenu: NSMenu?) -> NSMenu {
let menu = baseMenu ?? NSMenu()
guard hasCommentableSelection else { return menu }
guard !menu.items.contains(where: { $0.action == #selector(commentOnSelectionFromMenu(_:)) }) else {
return menu
}
let item = NSMenuItem(
title: "Comment",
action: #selector(commentOnSelectionFromMenu(_:)),
keyEquivalent: ""
)
item.target = self
menu.insertItem(item, at: 0)
if menu.items.count > 1 {
menu.insertItem(.separator(), at: 1)
}
return menu
}
@objc private func commentOnSelectionFromMenu(_ sender: Any?) {
onSelectionComment?()
}
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
if let direct = page.annotation(at: point),
let editable = editableParent(for: direct, on: page),
isInteractionPoint(point, on: direct, editable: editable) {
return editable
}
for annotation in page.annotations.reversed() {
guard let editable = editableParent(for: annotation, on: page) else { continue }
if isInteractionPoint(point, on: annotation, editable: editable) {
return editable
}
}
return nil
}
private func isInteractionPoint(
_ point: CGPoint,
on annotation: PDFAnnotation,
editable: PDFAnnotation
) -> Bool {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
return annotation.bounds.insetBy(dx: -10, dy: -10).contains(point)
}
if isTextMarkup(editable) {
return AnnotationHitTesting.containsTextMarkupPoint(point, in: editable)
}
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) {
return true
}
if let popup = editable.popup,
popup.bounds.insetBy(dx: -10, dy: -10).contains(point) {
return true
}
return false
}
private func editableParent(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
if let owner = popupOwner(for: annotation, on: page) {
return isEditableAcademicAnnotation(owner) ? owner : nil
}
let parent = AnnotationFactory.parentAnnotation(for: annotation)
return isEditableAcademicAnnotation(parent) ? parent : nil
}
private func popupOwner(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
guard AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { return nil }
if let parent = annotation.value(forAnnotationKey: .parent) as? PDFAnnotation {
return parent
}
return page.annotations.first { candidate in
candidate.popup === annotation
}
}
private func openNativePopupOwner(on page: PDFPage) -> PDFAnnotation? {
for annotation in page.annotations.reversed() {
if annotation.popup?.isOpen == true,
isEditableAcademicAnnotation(annotation) {
return annotation
}
guard AnnotationKeys.annotation(annotation, hasSubtype: .popup),
annotation.isOpen,
let owner = popupOwner(for: annotation, on: page),
isEditableAcademicAnnotation(owner)
else {
continue
}
return owner
}
return nil
}
private func isTextMarkup(_ annotation: PDFAnnotation) -> Bool {
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
}
private func closeNativePopups(on page: PDFPage) {
for annotation in page.annotations {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
annotation.isOpen = false
}
annotation.popup?.isOpen = false
}
annotationsChanged(on: page)
}
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
let isSelectionComment = annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String
== AnnotationKeys.appKindComment
let hasCommentText = !AnnotationKeys.commentText(for: annotation)
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
return isSelectionComment || hasCommentText
}
return AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|| AnnotationKeys.annotation(annotation, hasSubtype: .text)
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
}
}
struct PDFKitRepresentedView: NSViewRepresentable {
@EnvironmentObject private var appState: AppState
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> AcademicPDFView {
let view = AcademicPDFView()
view.onAnnotationClick = { annotation, page in
Task { @MainActor in
appState.openAnnotationFromPDF(annotation, page: page)
}
}
view.onPlacementClick = { page, point in
Task { @MainActor in
appState.placePendingAnnotation(on: page, near: point)
}
}
view.onCancelPlacement = {
Task { @MainActor in
appState.cancelPlacementTool()
}
}
view.onSelectionComment = {
Task { @MainActor in
appState.addComment()
}
}
view.onHighlighterSelection = {
Task { @MainActor in
appState.addHighlightFromHighlighterMode()
}
}
view.onToggleHighlighterKey = {
Task { @MainActor in
appState.toggleHighlighterMode()
}
}
view.onUnderlineSelectionKey = {
Task { @MainActor in
appState.addUnderline()
}
}
view.onCommentSelectionKey = {
Task { @MainActor in
appState.addComment()
}
}
view.onPreviousPageKey = {
Task { @MainActor in
appState.goToPreviousPage()
}
}
view.onNextPageKey = {
Task { @MainActor in
appState.goToNextPage()
}
}
appState.attachPDFView(view)
return view
}
func updateNSView(_ view: AcademicPDFView, context: Context) {
if view.document !== appState.document {
view.document = appState.document
}
view.placementTool = appState.placementTool
view.isHighlighterModeActive = appState.isHighlighterModeActive
view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults
context.coordinator.sync(
editor: appState.activeEditor,
in: view,
appState: appState
)
}
@MainActor
final class Coordinator: NSObject, NSPopoverDelegate {
private enum PopoverKind {
case comment
}
private var popover: NSPopover?
private var model: CommentPopoverModel?
private var editorID: UUID?
private var popoverKind: PopoverKind?
private var isClosing = false
private var commitsCommentOnClose = true
private weak var appState: AppState?
func sync(
editor context: AnnotationEditorContext?,
in view: AcademicPDFView,
appState: AppState
) {
self.appState = appState
if let context {
if popoverKind == .comment,
editorID == context.id,
popover?.isShown == true {
return
}
dismissCurrent(commit: true)
show(context, in: view, appState: appState)
return
}
if !isClosing {
dismissCurrent(commit: false)
}
}
private func show(
_ context: AnnotationEditorContext,
in view: AcademicPDFView,
appState: AppState
) {
guard view.window != nil else { return }
let model = CommentPopoverModel(context: context, appState: appState)
let controller = NSHostingController(rootView: CommentEditorView(model: model))
let popover = NSPopover()
popover.behavior = .semitransient
popover.animates = true
popover.contentSize = NSSize(width: 340, height: 258)
popover.contentViewController = controller
popover.delegate = self
self.model = model
self.popover = popover
self.editorID = context.id
self.popoverKind = .comment
self.isClosing = false
self.commitsCommentOnClose = true
let anchor = anchorRect(for: context, in: view)
popover.show(
relativeTo: anchor,
of: view,
preferredEdge: preferredEdge(for: anchor, in: view)
)
focusCommentEditor(in: controller.view)
}
private func focusCommentEditor(in view: NSView) {
Self.focusFirstTextView(in: view)
DispatchQueue.main.async { [weak view] in
guard let view else { return }
Self.focusFirstTextView(in: view)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak view] in
guard let view else { return }
Self.focusFirstTextView(in: view)
}
}
private static func focusFirstTextView(in view: NSView) {
view.layoutSubtreeIfNeeded()
guard let textView = firstTextView(in: view) else { return }
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: textView.string.utf16.count, length: 0))
textView.insertionPointColor = .labelColor
textView.needsDisplay = true
}
private static func firstTextView(in view: NSView) -> NSTextView? {
if let textView = view as? NSTextView {
return textView
}
for subview in view.subviews {
if let textView = firstTextView(in: subview) {
return textView
}
}
return nil
}
private func dismissCurrent(commit: Bool) {
guard let popover else {
cleanup()
return
}
if commit {
model?.commit()
}
commitsCommentOnClose = commit
if popover.isShown {
popover.performClose(nil)
} else {
cleanup()
}
}
func popoverWillClose(_ notification: Notification) {
isClosing = true
if popoverKind == .comment, commitsCommentOnClose {
model?.commit()
}
}
func popoverDidClose(_ notification: Notification) {
let closedEditorID = editorID
let closedPopoverKind = popoverKind
let currentAppState = appState
cleanup()
if closedPopoverKind == .comment,
currentAppState?.activeEditor?.id == closedEditorID {
currentAppState?.activeEditor = nil
}
}
private func cleanup() {
popover?.delegate = nil
popover = nil
model = nil
editorID = nil
popoverKind = nil
isClosing = false
commitsCommentOnClose = true
}
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {
guard let annotation = context.primaryAnnotation,
let page = context.primaryPage ?? annotation.page
else {
return centeredAnchor(in: view)
}
let rect = view.convert(annotation.bounds, from: page).insetBy(dx: -4, dy: -4)
guard rect.width.isFinite,
rect.height.isFinite,
rect.width > 0,
rect.height > 0
else {
return centeredAnchor(in: view)
}
return rect.intersection(view.bounds).isNull ? centeredAnchor(in: view) : rect
}
private func centeredAnchor(in view: AcademicPDFView) -> NSRect {
NSRect(x: view.bounds.midX - 1, y: view.bounds.midY - 1, width: 2, height: 2)
}
private func preferredEdge(for anchor: NSRect, in view: AcademicPDFView) -> NSRectEdge {
anchor.midX > view.bounds.midX ? .minX : .maxX
}
}
}
struct PDFThumbnailRepresentedView: NSViewRepresentable {
@EnvironmentObject private var appState: AppState
func makeNSView(context: Context) -> PDFThumbnailView {
let view = PDFThumbnailView()
view.thumbnailSize = CGSize(width: 88, height: 116)
view.backgroundColor = .clear
view.maximumNumberOfColumns = 1
view.labelFont = NSFont.systemFont(ofSize: 11)
view.allowsDragging = false
view.pdfView = appState.pdfView
return view
}
func updateNSView(_ view: PDFThumbnailView, context: Context) {
view.pdfView = appState.pdfView
}
}

View File

@@ -0,0 +1,180 @@
import CoreGraphics
struct ReaderAdaptiveLayout: Equatable {
enum SizeClass: String, CaseIterable {
case compact
case regular
case wide
init(width: CGFloat) {
if width < 960 {
self = .compact
} else if width < 1280 {
self = .regular
} else {
self = .wide
}
}
}
struct SidebarWidths: Equatable {
var left: CGFloat
var right: CGFloat
}
static let minimumWindowWidth: CGFloat = 820
static let minimumWindowHeight: CGFloat = 620
static let resizeHandleWidth: CGFloat = 16
let sizeClass: SizeClass
init(width: CGFloat) {
sizeClass = SizeClass(width: width)
}
init(sizeClass: SizeClass) {
self.sizeClass = sizeClass
}
var usesCompactToolbar: Bool {
sizeClass == .compact
}
var allowsDualSidebars: Bool {
sizeClass != .compact
}
var leftSidebarMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 208
case .regular:
return 196
case .wide:
return 220
}
}
var leftSidebarIdealWidth: CGFloat {
switch sizeClass {
case .compact:
return 236
case .regular:
return 215
case .wide:
return 248
}
}
var leftSidebarMaxWidth: CGFloat {
switch sizeClass {
case .compact:
return 300
case .regular:
return 280
case .wide:
return 340
}
}
var rightSidebarMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 280
case .regular:
return 280
case .wide:
return 300
}
}
var rightSidebarIdealWidth: CGFloat {
switch sizeClass {
case .compact:
return 292
case .regular:
return 300
case .wide:
return 340
}
}
var rightSidebarMaxWidth: CGFloat {
switch sizeClass {
case .compact:
return 340
case .regular:
return 360
case .wide:
return 420
}
}
var documentMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 320
case .regular:
return 420
case .wide:
return 560
}
}
func clampedLeftWidth(_ width: CGFloat) -> CGFloat {
clamped(width, lower: leftSidebarMinWidth, upper: leftSidebarMaxWidth)
}
func clampedRightWidth(_ width: CGFloat) -> CGFloat {
clamped(width, lower: rightSidebarMinWidth, upper: rightSidebarMaxWidth)
}
func resolvedSidebarWidths(
availableWidth: CGFloat,
requestedLeft: CGFloat,
requestedRight: CGFloat,
showLeft: Bool,
showRight: Bool
) -> SidebarWidths {
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
let rightHandle = showRight ? Self.resizeHandleWidth : 0
let maxSidebarTotal = max(0, availableWidth - documentMinWidth - leftHandle - rightHandle)
var left = showLeft ? clampedLeftWidth(requestedLeft) : 0
var right = showRight ? clampedRightWidth(requestedRight) : 0
guard left + right > maxSidebarTotal else {
return SidebarWidths(left: left, right: right)
}
var overflow = left + right - maxSidebarTotal
if showRight {
let reduction = min(overflow, max(0, right - rightSidebarMinWidth))
right -= reduction
overflow -= reduction
}
if showLeft, overflow > 0 {
let reduction = min(overflow, max(0, left - leftSidebarMinWidth))
left -= reduction
}
return SidebarWidths(left: left, right: right)
}
func visibleContentWidth(
availableWidth: CGFloat,
leftWidth: CGFloat,
rightWidth: CGFloat,
showLeft: Bool,
showRight: Bool
) -> CGFloat {
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
let rightHandle = showRight ? Self.resizeHandleWidth : 0
return availableWidth - leftWidth - rightWidth - leftHandle - rightHandle
}
private func clamped(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}
}

View File

@@ -0,0 +1,30 @@
enum ReviewState {
static let allStatuses = "All Statuses"
static let reviewed = "Reviewed"
static let notReviewed = "Not reviewed"
static func isReviewed(_ status: String) -> Bool {
status.localizedCaseInsensitiveCompare("Marked") == .orderedSame
|| status.localizedCaseInsensitiveCompare(reviewed) == .orderedSame
}
static func label(for status: String) -> String {
if isReviewed(status) { return reviewed }
return status.localizedCaseInsensitiveCompare("Unmarked") == .orderedSame
? notReviewed
: status
}
static func matches(_ status: String, filter: String) -> Bool {
switch filter {
case allStatuses:
return true
case reviewed:
return isReviewed(status)
case notReviewed:
return !isReviewed(status)
default:
return status == filter || label(for: status) == filter
}
}
}

File diff suppressed because it is too large Load Diff